Merge branch 'main' of github.com:monochrome-music/monochrome
This commit is contained in:
commit
90dc00ba5a
98 changed files with 15162 additions and 18680 deletions
|
|
@ -4,7 +4,7 @@
|
|||
"context": "..",
|
||||
"dockerfile": "./Dockerfile"
|
||||
},
|
||||
"postCreateCommand": "git config --local core.editor \"code --wait\" && git config --local commit.gpgsign false && npm install && bun install",
|
||||
"postCreateCommand": "git config --local core.editor \"code --wait\" && git config --local commit.gpgsign false && bun install",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "anthropic.claude-code"]
|
||||
|
|
|
|||
62
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
Normal file
62
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
name: 'Bug Report'
|
||||
description: Create a bug report.
|
||||
title: '[BUG] <title>'
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 'Description'
|
||||
description: Please enter an explicit description of your issue
|
||||
placeholder: Short and explicit description of your bug...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 'Reproduction steps'
|
||||
description: Please enter steps to reproduce your issue.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
render: bash
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: Write what you expected to happen.
|
||||
placeholder: |
|
||||
The app should do this when I do that...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: 'Screenshots'
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
placeholder: Paste screenshot here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 'Logs (Desktop only)'
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
placeholder: Accessible via F12, select only Errors and Warnings, then right-click the Console and select "save to file"
|
||||
render: bash
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 'Browser'
|
||||
description: What browser are you seeing the problem on ?
|
||||
placeholder: Firefox 149.0
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 'Device'
|
||||
description: What device are you seeing the problem on ?
|
||||
placeholder: PC Running Windows 11
|
||||
validations:
|
||||
required: true
|
||||
95
.github/workflows/editors-picks.yml
vendored
Normal file
95
.github/workflows/editors-picks.yml
vendored
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
name: Update Editors Picks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'editors-picks-input.txt'
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check for backoff condition
|
||||
id: backoff
|
||||
run: |
|
||||
CHANGED=$(git diff-tree --no-commit-id -r --name-only HEAD)
|
||||
echo "Files changed in this commit:"
|
||||
echo "$CHANGED"
|
||||
if echo "$CHANGED" | grep -qE '^public/editors-picks\.json$|^public/editors-picks-old/'; then
|
||||
echo "Detected changes to generated files in this commit — backing off to avoid overwriting manual edits."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Python
|
||||
if: steps.backoff.outputs.skip == 'false'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Archive current editors picks
|
||||
if: steps.backoff.outputs.skip == 'false'
|
||||
run: |
|
||||
python3 - << 'EOF'
|
||||
import json, re
|
||||
from datetime import date
|
||||
|
||||
today = date.today()
|
||||
# Filename uses non-padded month/day to match existing convention
|
||||
filename = f"{today.year}-{today.month}-{today.day}.json"
|
||||
# Date field uses ISO format (zero-padded)
|
||||
iso_date = today.strftime("%Y-%m-%d")
|
||||
archive_path = f"public/editors-picks-old/{filename}"
|
||||
|
||||
# Read optional label from input file
|
||||
label = iso_date
|
||||
with open("editors-picks-input.txt") as f:
|
||||
for line in f:
|
||||
m = re.match(r"^#\s*label:\s*(.+)", line.strip())
|
||||
if m:
|
||||
label = m.group(1).strip()
|
||||
break
|
||||
|
||||
# Copy current picks to archive
|
||||
with open("public/editors-picks.json") as f:
|
||||
current = json.load(f)
|
||||
with open(archive_path, "w") as f:
|
||||
json.dump(current, f, indent=4)
|
||||
|
||||
# Prepend to index so newest archived version appears first
|
||||
with open("public/editors-picks-old/index.json") as f:
|
||||
index = json.load(f)
|
||||
index.insert(0, {
|
||||
"file": filename,
|
||||
"label": label,
|
||||
"date": iso_date,
|
||||
})
|
||||
with open("public/editors-picks-old/index.json", "w") as f:
|
||||
json.dump(index, f, indent=4)
|
||||
|
||||
print(f"Archived to {archive_path} with label '{label}'")
|
||||
EOF
|
||||
|
||||
- name: Generate new editors picks
|
||||
if: steps.backoff.outputs.skip == 'false'
|
||||
run: python3 gen-editors-picks.py
|
||||
|
||||
- name: Commit and push
|
||||
if: steps.backoff.outputs.skip == 'false'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add public/editors-picks.json public/editors-picks-old/
|
||||
git diff --staged --quiet && echo "No changes to commit." && exit 0
|
||||
git commit -m "chore: update editors picks"
|
||||
git push
|
||||
1
.github/workflows/lint.yml
vendored
1
.github/workflows/lint.yml
vendored
|
|
@ -19,6 +19,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
ref: ${{ github.head_ref || github.ref }}
|
||||
|
||||
- name: Setup Bun
|
||||
|
|
|
|||
41
.github/workflows/tests.yml
vendored
Normal file
41
.github/workflows/tests.yml
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
name: Run Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['*']
|
||||
pull_request:
|
||||
branches: ['*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
./bun_modules
|
||||
./node_modules
|
||||
./bun.lock
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install playwright dependencies
|
||||
run: bun run install:playwright
|
||||
|
||||
- name: Run vitest
|
||||
run: bun run test:headless
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -3,15 +3,17 @@ dist
|
|||
.DS_Store
|
||||
*.local
|
||||
.vite
|
||||
.vscode
|
||||
.claude
|
||||
# Docker
|
||||
.env
|
||||
# Neutralino
|
||||
.tmp/
|
||||
.vitest-attachments/
|
||||
**/__screenshots__/*
|
||||
bin/
|
||||
*.log
|
||||
.storage/
|
||||
auth_storage/
|
||||
www
|
||||
neutralino.js
|
||||
neutralino.js
|
||||
package-lock.json
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
||||
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Edge",
|
||||
"request": "launch",
|
||||
"type": "msedge",
|
||||
"url": "http://localhost:5173",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
.vscode/settings.json
vendored
Normal file
23
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"eslint.lintTask.enable": true,
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||
"eslint.enable": true,
|
||||
"js/ts.validate.enabled": true,
|
||||
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ Portainer can deploy directly from your GitHub fork with auto-updates on push.
|
|||
|
||||
> **Tip:** `COMPOSE_PROFILES` is a built-in Docker Compose variable. Setting it to `pocketbase` is equivalent to passing `--profile pocketbase` on the command line.
|
||||
|
||||
> **Warning:** The `dev` profile is for **local development only**. It uses volume mounts to enable hot-reload, which requires the source code to be present on the host machine. Do **not** include `dev` in `COMPOSE_PROFILES` on Portainer deployments from GitHub — it will fail because there's no local source code to mount.
|
||||
> **Warning:** The `dev` profile is for **local development only**. It uses volume mounts to enable hot-reload, which requires the source code to be present on the host machine. Do **not** include `dev` in `COMPOSE_PROFILES` on Portainer deployments from GitHub - it will fail because there's no local source code to mount.
|
||||
|
||||
### Fork Workflow
|
||||
|
||||
|
|
|
|||
23
editors-picks-input.txt
Normal file
23
editors-picks-input.txt
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Editors Picks
|
||||
# One item per line. Format: type:id
|
||||
# Supported types: album, artist, track, playlist, userplaylist, podcast
|
||||
# Bare numbers (no prefix) are treated as albums for backwards compatibility.
|
||||
# Optional: set a label for this archived set with "# label: ..."
|
||||
# label: Spring 2026
|
||||
|
||||
album:324660713
|
||||
album:15427733
|
||||
album:464178301
|
||||
album:75115890
|
||||
album:410197513
|
||||
album:418729278
|
||||
album:504004321
|
||||
album:510893864
|
||||
album:325723583
|
||||
album:336178142
|
||||
album:106369871
|
||||
album:423471869
|
||||
album:250986538
|
||||
album:509761344
|
||||
album:15621057
|
||||
album:103897783
|
||||
|
|
@ -1,15 +1,23 @@
|
|||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
|
||||
export default [
|
||||
export default defineConfig(
|
||||
{
|
||||
ignores: ['**/dist/**', '**/node_modules/**', '**/legacy/**', '**/bin/**', '**/www/**', '**/public/lib/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
prettierConfig,
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
parser: tsParser, // 👈 REQUIRED
|
||||
parserOptions: {
|
||||
project: './tsconfig-eslint.json', // 👈 REQUIRED
|
||||
},
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
|
|
@ -18,8 +26,38 @@ export default [
|
|||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'no-console': ['warn', { allow: ['log', 'warn', 'error'] }],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
args: 'all',
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrors: 'all',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
'no-console': ['warn', { allow: ['log', 'warn', 'error', 'debug', 'trace', 'time', 'timeEnd'] }],
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
'no-async-promise-executor': 'warn',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-namespace': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'off',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
|||
34
functions/about/index.js
Normal file
34
functions/about/index.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export async function onRequest(context) {
|
||||
const { request } = context;
|
||||
const pageUrl = request.url;
|
||||
|
||||
const metaHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Monochrome Music | About</title>
|
||||
<meta name="description" content="A minimalist music streaming application">
|
||||
<meta name="theme-color" content="#000000">
|
||||
|
||||
<meta property="og:site_name" content="Monochrome">
|
||||
<meta property="og:title" content="Monochrome Music | About">
|
||||
<meta property="og:description" content="A minimalist music streaming application">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="${pageUrl}">
|
||||
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Monochrome Music | About">
|
||||
<meta name="twitter:description" content="A minimalist music streaming application">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Monochrome Music | About</h1>
|
||||
<p>A minimalist music streaming application</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return new Response(metaHtml, {
|
||||
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||
});
|
||||
}
|
||||
34
functions/donate/index.js
Normal file
34
functions/donate/index.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export async function onRequest(context) {
|
||||
const { request } = context;
|
||||
const pageUrl = request.url;
|
||||
|
||||
const metaHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Monochrome Music | Donate</title>
|
||||
<meta name="description" content="A minimalist music streaming application">
|
||||
<meta name="theme-color" content="#000000">
|
||||
|
||||
<meta property="og:site_name" content="Monochrome">
|
||||
<meta property="og:title" content="Monochrome Music | Donate">
|
||||
<meta property="og:description" content="A minimalist music streaming application">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="${pageUrl}">
|
||||
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Monochrome Music | Donate">
|
||||
<meta name="twitter:description" content="A minimalist music streaming application">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Monochrome Music | Donate</h1>
|
||||
<p>A minimalist music streaming application</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return new Response(metaHtml, {
|
||||
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||
});
|
||||
}
|
||||
34
functions/library/index.js
Normal file
34
functions/library/index.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export async function onRequest(context) {
|
||||
const { request } = context;
|
||||
const pageUrl = request.url;
|
||||
|
||||
const metaHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Monochrome Music | Library</title>
|
||||
<meta name="description" content="A minimalist music streaming application">
|
||||
<meta name="theme-color" content="#000000">
|
||||
|
||||
<meta property="og:site_name" content="Monochrome">
|
||||
<meta property="og:title" content="Monochrome Music | Library">
|
||||
<meta property="og:description" content="A minimalist music streaming application">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="${pageUrl}">
|
||||
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Monochrome Music | Library">
|
||||
<meta name="twitter:description" content="A minimalist music streaming application">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Monochrome Music | Library</h1>
|
||||
<p>A minimalist music streaming application</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return new Response(metaHtml, {
|
||||
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||
});
|
||||
}
|
||||
34
functions/parties/index.js
Normal file
34
functions/parties/index.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export async function onRequest(context) {
|
||||
const { request } = context;
|
||||
const pageUrl = request.url;
|
||||
|
||||
const metaHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Monochrome Music | Listening Parties</title>
|
||||
<meta name="description" content="Listen to music with your friends">
|
||||
<meta name="theme-color" content="#000000">
|
||||
|
||||
<meta property="og:site_name" content="Monochrome">
|
||||
<meta property="og:title" content="Monochrome Music | Listening Parties">
|
||||
<meta property="og:description" content="Listen to music with your friends">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="${pageUrl}">
|
||||
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Monochrome Music | Listening Parties">
|
||||
<meta name="twitter:description" content="Listen to music with your friends">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Monochrome Music | Listening Parties</h1>
|
||||
<p>Listen to music with your friends</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return new Response(metaHtml, {
|
||||
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||
});
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ export async function onRequest(context) {
|
|||
const title = feed.title;
|
||||
const author = feed.author || feed.ownerName || '';
|
||||
const episodeCount = feed.episodeCount || 0;
|
||||
const rawDescription = feed.description || '';
|
||||
const _rawDescription = feed.description || '';
|
||||
const description = author
|
||||
? `Podcast by ${author} • ${episodeCount} Episodes\nListen on Monochrome`
|
||||
: `Podcast • ${episodeCount} Episodes\nListen on Monochrome`;
|
||||
|
|
|
|||
34
functions/recent/index.js
Normal file
34
functions/recent/index.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export async function onRequest(context) {
|
||||
const { request } = context;
|
||||
const pageUrl = request.url;
|
||||
|
||||
const metaHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Monochrome Music | Recent</title>
|
||||
<meta name="description" content="A minimalist music streaming application">
|
||||
<meta name="theme-color" content="#000000">
|
||||
|
||||
<meta property="og:site_name" content="Monochrome">
|
||||
<meta property="og:title" content="Monochrome Music | Recent">
|
||||
<meta property="og:description" content="A minimalist music streaming application">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="${pageUrl}">
|
||||
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Monochrome Music | Recent">
|
||||
<meta name="twitter:description" content="A minimalist music streaming application">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Monochrome Music | Recent</h1>
|
||||
<p>A minimalist music streaming application</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return new Response(metaHtml, {
|
||||
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||
});
|
||||
}
|
||||
34
functions/settings/index.js
Normal file
34
functions/settings/index.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export async function onRequest(context) {
|
||||
const { request } = context;
|
||||
const pageUrl = request.url;
|
||||
|
||||
const metaHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Monochrome Music | Settings</title>
|
||||
<meta name="description" content="A minimalist music streaming application">
|
||||
<meta name="theme-color" content="#000000">
|
||||
|
||||
<meta property="og:site_name" content="Monochrome">
|
||||
<meta property="og:title" content="Monochrome Music | Settings">
|
||||
<meta property="og:description" content="A minimalist music streaming application">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="${pageUrl}">
|
||||
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Monochrome Music | Settings">
|
||||
<meta name="twitter:description" content="A minimalist music streaming application">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Monochrome Music | Settings</h1>
|
||||
<p>A minimalist music streaming application</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return new Response(metaHtml, {
|
||||
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||
});
|
||||
}
|
||||
101
functions/unreleased/[sheetId].js
Normal file
101
functions/unreleased/[sheetId].js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// functions/unreleased/[sheetId].js
|
||||
|
||||
const ARTISTS_NDJSON_URL = 'https://assets.artistgrid.cx/artists.ndjson';
|
||||
const ASSETS_BASE_URL = 'https://assets.artistgrid.cx';
|
||||
|
||||
function getSheetId(url) {
|
||||
if (!url) return null;
|
||||
const match = url.match(/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function normalizeArtistName(name) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
async function loadArtistsData() {
|
||||
try {
|
||||
const response = await fetch(ARTISTS_NDJSON_URL);
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
const text = await response.text();
|
||||
return text
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((item) => item !== null);
|
||||
} catch (e) {
|
||||
console.error('Failed to load Artists List:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function onRequest(context) {
|
||||
const { request, params, env } = context;
|
||||
const userAgent = request.headers.get('User-Agent') || '';
|
||||
const isBot =
|
||||
/discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot|telegrambot|linkedinbot|mastodon|signal|snapchat|redditbot|skypeuripreview|viberbot|linebot|embedly|quora|outbrain|tumblr|duckduckbot|yandexbot|rogerbot|showyoubot|kakaotalk|naverbot|seznambot|mediapartners|adsbot|petalbot|applebot|ia_archiver/i.test(
|
||||
userAgent
|
||||
);
|
||||
const sheetId = params.sheetId;
|
||||
|
||||
if (isBot && sheetId) {
|
||||
try {
|
||||
const artists = await loadArtistsData();
|
||||
const artist = artists.find((a) => getSheetId(a.url) === sheetId);
|
||||
|
||||
if (artist && artist.name) {
|
||||
const normalizedName = normalizeArtistName(artist.name);
|
||||
const imageUrl = `${ASSETS_BASE_URL}/${normalizedName}.webp`;
|
||||
const pageUrl = new URL(request.url).href;
|
||||
const title = `${artist.name} | Unreleased`;
|
||||
const description = `Stream unreleased music by ${artist.name} on Monochrome`;
|
||||
|
||||
const metaHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${title}</title>
|
||||
<meta name="description" content="${description}">
|
||||
<meta name="theme-color" content="#000000">
|
||||
|
||||
<meta property="og:site_name" content="Monochrome">
|
||||
<meta property="og:title" content="${title}">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:url" content="${pageUrl}">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${title}">
|
||||
<meta name="twitter:description" content="${description}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>${artist.name}</h1>
|
||||
<p>${description}</p>
|
||||
<img src="${imageUrl}" alt="${artist.name}">
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return new Response(metaHtml, {
|
||||
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error generating meta tags for unreleased artist ${sheetId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
url.pathname = '/';
|
||||
return env.ASSETS.fetch(new Request(url, request));
|
||||
}
|
||||
135
functions/unreleased/[sheetId]/[projectName].js
Normal file
135
functions/unreleased/[sheetId]/[projectName].js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// functions/unreleased/[sheetId]/[projectName].js
|
||||
|
||||
const ARTISTS_NDJSON_URL = 'https://assets.artistgrid.cx/artists.ndjson';
|
||||
const _ASSETS_BASE_URL = 'https://assets.artistgrid.cx';
|
||||
const TRACKER_API_ENDPOINTS = [
|
||||
'https://trackerapi-1.artistgrid.cx/get/',
|
||||
'https://trackerapi-2.artistgrid.cx/get/',
|
||||
'https://trackerapi-3.artistgrid.cx/get/',
|
||||
];
|
||||
|
||||
function getSheetId(url) {
|
||||
if (!url) return null;
|
||||
const match = url.match(/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function _normalizeArtistName(name) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function transformImageUrl(url) {
|
||||
if (!url) return url;
|
||||
return url.replace('https://s3.sad.ovh/trackerapi/', 'https://r2.artistgrid.cx/');
|
||||
}
|
||||
|
||||
async function loadArtistsData() {
|
||||
try {
|
||||
const response = await fetch(ARTISTS_NDJSON_URL);
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
const text = await response.text();
|
||||
return text
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((item) => item !== null);
|
||||
} catch (e) {
|
||||
console.error('Failed to load Artists List:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTrackerData(sheetId) {
|
||||
for (const baseUrl of TRACKER_API_ENDPOINTS) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${sheetId}`);
|
||||
if (!response.ok) continue;
|
||||
const data = await response.json();
|
||||
if (data.eras) {
|
||||
for (const eraName in data.eras) {
|
||||
const era = data.eras[eraName];
|
||||
if (era.image) {
|
||||
era.image = transformImageUrl(era.image);
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.warn(`Failed to fetch from ${baseUrl}, trying next...`, e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function onRequest(context) {
|
||||
const { request, params, env } = context;
|
||||
const userAgent = request.headers.get('User-Agent') || '';
|
||||
const isBot =
|
||||
/discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot|telegrambot|linkedinbot|linkedinbot|mastodon|signal|snapchat|redditbot|skypeuripreview|viberbot|linebot|embedly|quora|outbrain|tumblr|duckduckbot|yandexbot|rogerbot|showyoubot|kakaotalk|naverbot|seznambot|mediapartners|adsbot|petalbot|applebot|ia_archiver/i.test(
|
||||
userAgent
|
||||
);
|
||||
const sheetId = params.sheetId;
|
||||
const projectName = params.projectName ? decodeURIComponent(params.projectName) : null;
|
||||
|
||||
if (isBot && sheetId && projectName) {
|
||||
try {
|
||||
const artists = await loadArtistsData();
|
||||
const artist = artists.find((a) => getSheetId(a.url) === sheetId);
|
||||
const trackerData = await fetchTrackerData(sheetId);
|
||||
|
||||
if (artist && artist.name && trackerData && trackerData.eras) {
|
||||
const era = trackerData.eras[projectName];
|
||||
const imageUrl = era && era.image ? era.image : 'https://monochrome.tf/assets/appicon.png';
|
||||
const pageUrl = new URL(request.url).href;
|
||||
const title = `${projectName} - ${artist.name}`;
|
||||
const description = `Stream ${projectName} by ${artist.name} on Monochrome`;
|
||||
|
||||
const metaHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${title}</title>
|
||||
<meta name="description" content="${description}">
|
||||
<meta name="theme-color" content="#000000">
|
||||
|
||||
<meta property="og:site_name" content="Monochrome">
|
||||
<meta property="og:title" content="${title}">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta property="og:type" content="music.album">
|
||||
<meta property="og:url" content="${pageUrl}">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${title}">
|
||||
<meta name="twitter:description" content="${description}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>${title}</h1>
|
||||
<p>${description}</p>
|
||||
<img src="${imageUrl}" alt="${projectName} cover">
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return new Response(metaHtml, {
|
||||
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error generating meta tags for unreleased project ${sheetId}/${projectName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
url.pathname = '/';
|
||||
return env.ASSETS.fetch(new Request(url, request));
|
||||
}
|
||||
34
functions/unreleased/index.js
Normal file
34
functions/unreleased/index.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export async function onRequest(context) {
|
||||
const { request } = context;
|
||||
const pageUrl = request.url;
|
||||
|
||||
const metaHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Monochrome Music | Unreleased</title>
|
||||
<meta name="description" content="Stream unreleased music on Monochrome. Provided by Artistgrid.">
|
||||
<meta name="theme-color" content="#000000">
|
||||
|
||||
<meta property="og:site_name" content="Monochrome">
|
||||
<meta property="og:title" content="Monochrome Music | Unreleased">
|
||||
<meta property="og:description" content="Stream unreleased music on Monochrome. Provided by Artistgrid.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="${pageUrl}">
|
||||
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Monochrome Music | Unreleased">
|
||||
<meta name="twitter:description" content="Stream unreleased music on Monochrome. Provided by Artistgrid.">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Monochrome Music | Unreleased</h1>
|
||||
<p>Stream unreleased music on Monochrome. Provided by Artistgrid.</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return new Response(metaHtml, {
|
||||
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||
});
|
||||
}
|
||||
|
|
@ -3,6 +3,54 @@
|
|||
const POCKETBASE_URL = 'https://data.samidy.xyz';
|
||||
const PUBLIC_COLLECTION = 'public_playlists';
|
||||
|
||||
function safeParseTracks(tracksData) {
|
||||
if (!tracksData) return [];
|
||||
if (Array.isArray(tracksData)) return tracksData;
|
||||
if (typeof tracksData === 'string') {
|
||||
try {
|
||||
return JSON.parse(tracksData);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseDuration(durationStr) {
|
||||
if (!durationStr || durationStr === 'N/A' || typeof durationStr !== 'string') return 0;
|
||||
const parts = durationStr.split(':');
|
||||
if (parts.length === 2) {
|
||||
return parseInt(parts[0]) * 60 + parseInt(parts[1]);
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function calculatePlaylistDuration(tracks) {
|
||||
let totalSeconds = 0;
|
||||
for (const track of tracks) {
|
||||
const duration = track.duration || track.durationSeconds || 0;
|
||||
if (typeof duration === 'number') {
|
||||
totalSeconds += duration;
|
||||
} else if (typeof duration === 'string') {
|
||||
totalSeconds += parseDuration(duration);
|
||||
}
|
||||
}
|
||||
return totalSeconds;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds || seconds <= 0) return '0 min';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) {
|
||||
return `${hours} hr ${minutes} min`;
|
||||
}
|
||||
return `${minutes} min`;
|
||||
}
|
||||
|
||||
export async function onRequest(context) {
|
||||
const { request, params, env } = context;
|
||||
const userAgent = request.headers.get('User-Agent') || '';
|
||||
|
|
@ -37,14 +85,10 @@ export async function onRequest(context) {
|
|||
(extraData && (extraData.title || extraData.name)) ||
|
||||
'Untitled Playlist';
|
||||
|
||||
let tracks = [];
|
||||
try {
|
||||
tracks = record.tracks ? JSON.parse(record.tracks) : [];
|
||||
} catch {
|
||||
tracks = [];
|
||||
}
|
||||
|
||||
let tracks = safeParseTracks(record.tracks);
|
||||
const trackCount = tracks.length;
|
||||
const totalDuration = calculatePlaylistDuration(tracks);
|
||||
const durationStr = formatDuration(totalDuration);
|
||||
|
||||
let rawCover = record.image || record.cover || record.playlist_cover || '';
|
||||
if (!rawCover && extraData && typeof extraData === 'object') {
|
||||
|
|
@ -70,7 +114,7 @@ export async function onRequest(context) {
|
|||
imageUrl = 'https://monochrome.tf/assets/appicon.png';
|
||||
}
|
||||
|
||||
const description = `Playlist • ${trackCount} Tracks\nListen on Monochrome`;
|
||||
const description = `Playlist • ${trackCount} Tracks • ${durationStr}\nListen on Monochrome`;
|
||||
const pageUrl = new URL(request.url).href;
|
||||
|
||||
const metaHtml = `
|
||||
|
|
|
|||
225
gen-editors-picks.py
Normal file
225
gen-editors-picks.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
INPUT_FILE = "editors-picks-input.txt"
|
||||
COUNTRY = "US"
|
||||
|
||||
# Tidal internal token — replace when expired
|
||||
TIDAL_TOKEN = "eyJraWQiOiJ2OU1GbFhqWSIsImFsZyI6IkVTMjU2In0.eyJ0eXBlIjoibzJfYWNjZXNzIiwic2NvcGUiOiIiLCJnVmVyIjowLCJzVmVyIjowLCJjaWQiOjEzNTU3LCJhdCI6IklOVEVSTkFMIiwiZXhwIjoxNzc1MjQ2NzQ2LCJpc3MiOiJodHRwczovL2F1dGgudGlkYWwuY29tL3YxIn0.ksUE4yhQ39IG7oHWk8DyJ91dwIoDVWGzvTAnpeDJ5p-_Gp0F_yO858xDO11AINBaahQCq0jlbqWqIaTqCTOjqg"
|
||||
|
||||
TIDAL_HEADERS = {
|
||||
"accept": "*/*",
|
||||
"authorization": f"Bearer {TIDAL_TOKEN}",
|
||||
}
|
||||
|
||||
# PodcastIndex credentials
|
||||
PODCAST_API_KEY = "YU5HMSDYBQQVYDF6QN4P"
|
||||
PODCAST_API_SECRET = "8hCvpjSL7T$S7^5ftnf5MhqQwYUYVjM^fmUL3Ld$"
|
||||
PODCASTINDEX_BASE = "https://api.podcastindex.org/api/1.0"
|
||||
|
||||
|
||||
# ── Tidal helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def tidal_get(path, params=None):
|
||||
if params is None:
|
||||
params = {}
|
||||
params.setdefault("countryCode", COUNTRY)
|
||||
url = f"https://api.tidal.com/v1/{path}?{urllib.parse.urlencode(params)}"
|
||||
req = urllib.request.Request(url, headers=TIDAL_HEADERS)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
print(f"Error fetching {url}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_album(album_id):
|
||||
return tidal_get(f"albums/{album_id}")
|
||||
|
||||
|
||||
def fetch_artist(artist_id):
|
||||
return tidal_get(f"artists/{artist_id}")
|
||||
|
||||
|
||||
def fetch_track(track_id):
|
||||
return tidal_get(f"tracks/{track_id}")
|
||||
|
||||
|
||||
def fetch_playlist(uuid):
|
||||
return tidal_get(f"playlists/{uuid}")
|
||||
|
||||
|
||||
# ── PodcastIndex helper ───────────────────────────────────────────────────────
|
||||
|
||||
def podcast_get(endpoint):
|
||||
api_time = str(int(time.time()))
|
||||
raw = PODCAST_API_KEY + PODCAST_API_SECRET + api_time
|
||||
auth_hash = hashlib.sha1(raw.encode()).hexdigest()
|
||||
headers = {
|
||||
"User-Agent": "MonochromeMusic/1.0",
|
||||
"X-Auth-Key": PODCAST_API_KEY,
|
||||
"X-Auth-Date": api_time,
|
||||
"Authorization": auth_hash,
|
||||
}
|
||||
url = f"{PODCASTINDEX_BASE}{endpoint}"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
print(f"Error fetching {url}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_podcast(feed_id):
|
||||
return podcast_get(f"/podcasts/byfeedid?id={feed_id}&pretty")
|
||||
|
||||
|
||||
# ── Transformers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def transform_album(d):
|
||||
return {
|
||||
"type": "album",
|
||||
"id": d.get("id"),
|
||||
"title": d.get("title"),
|
||||
"artist": {
|
||||
"id": d.get("artist", {}).get("id"),
|
||||
"name": d.get("artist", {}).get("name"),
|
||||
},
|
||||
"releaseDate": d.get("releaseDate"),
|
||||
"cover": d.get("cover"),
|
||||
"explicit": d.get("explicit"),
|
||||
"audioQuality": d.get("audioQuality"),
|
||||
"mediaMetadata": d.get("mediaMetadata"),
|
||||
}
|
||||
|
||||
|
||||
def transform_artist(d):
|
||||
return {
|
||||
"type": "artist",
|
||||
"id": d.get("id"),
|
||||
"name": d.get("name"),
|
||||
"picture": d.get("picture"),
|
||||
}
|
||||
|
||||
|
||||
def transform_track(d):
|
||||
album = d.get("album") or {}
|
||||
return {
|
||||
"type": "track",
|
||||
"id": d.get("id"),
|
||||
"title": d.get("title"),
|
||||
"artist": {
|
||||
"id": d.get("artist", {}).get("id"),
|
||||
"name": d.get("artist", {}).get("name"),
|
||||
},
|
||||
"album": {
|
||||
"id": album.get("id"),
|
||||
"title": album.get("title"),
|
||||
"cover": album.get("cover"),
|
||||
},
|
||||
"duration": d.get("duration"),
|
||||
"explicit": d.get("explicit"),
|
||||
"audioQuality": d.get("audioQuality"),
|
||||
"mediaMetadata": d.get("mediaMetadata"),
|
||||
}
|
||||
|
||||
|
||||
def transform_playlist(d):
|
||||
# Tidal editorial playlist → rendered as album card with playlist href
|
||||
cover = d.get("squareImage") or d.get("image") or d.get("cover")
|
||||
return {
|
||||
"type": "playlist",
|
||||
"id": d.get("uuid"),
|
||||
"title": d.get("title"),
|
||||
"cover": cover,
|
||||
"numberOfTracks": d.get("numberOfTracks", 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_userplaylist(d):
|
||||
# User playlist → rendered with createUserPlaylistCardHTML
|
||||
cover = d.get("squareImage") or d.get("image") or d.get("cover")
|
||||
creator = d.get("creator") or {}
|
||||
return {
|
||||
"type": "user-playlist",
|
||||
"id": d.get("uuid"),
|
||||
"name": d.get("title"),
|
||||
"cover": cover,
|
||||
"numberOfTracks": d.get("numberOfTracks", 0),
|
||||
"username": creator.get("name"),
|
||||
}
|
||||
|
||||
|
||||
def transform_podcast(d):
|
||||
feed = d.get("feed") or {}
|
||||
return {
|
||||
"type": "podcast",
|
||||
"id": str(feed.get("id", "")),
|
||||
"title": feed.get("title"),
|
||||
"author": feed.get("author") or feed.get("ownerName"),
|
||||
"image": feed.get("image") or feed.get("artwork"),
|
||||
"episodeCount": feed.get("episodeCount", 0),
|
||||
}
|
||||
|
||||
|
||||
# ── Input parser ──────────────────────────────────────────────────────────────
|
||||
|
||||
def read_items(path):
|
||||
"""
|
||||
Parses editors-picks-input.txt.
|
||||
Each non-comment line is either:
|
||||
- a bare number → album:<number> (backwards-compatible)
|
||||
- type:value → e.g. artist:123, track:456, playlist:uuid, podcast:789
|
||||
Supported types: album, artist, track, playlist, userplaylist, podcast
|
||||
"""
|
||||
items = []
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if ":" in line:
|
||||
item_type, _, value = line.partition(":")
|
||||
items.append((item_type.strip().lower(), value.strip()))
|
||||
else:
|
||||
# bare number → album
|
||||
items.append(("album", line))
|
||||
return items
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
FETCHERS = {
|
||||
"album": (fetch_album, transform_album),
|
||||
"artist": (fetch_artist, transform_artist),
|
||||
"track": (fetch_track, transform_track),
|
||||
"playlist": (fetch_playlist, transform_playlist),
|
||||
"userplaylist":(fetch_playlist, transform_userplaylist),
|
||||
"podcast": (fetch_podcast, transform_podcast),
|
||||
}
|
||||
|
||||
items = read_items(INPUT_FILE)
|
||||
picks = []
|
||||
|
||||
for item_type, item_id in items:
|
||||
if item_type not in FETCHERS:
|
||||
print(f"Unknown type '{item_type}' for id {item_id!r} — skipping", file=sys.stderr)
|
||||
continue
|
||||
fetch_fn, transform_fn = FETCHERS[item_type]
|
||||
data = fetch_fn(item_id)
|
||||
if data:
|
||||
picks.append(transform_fn(data))
|
||||
|
||||
with open("public/editors-picks.json", "w") as f:
|
||||
json.dump(picks, f, indent=4)
|
||||
|
||||
print(f"Written {len(picks)} items to public/editors-picks.json")
|
||||
1168
index.html
1168
index.html
File diff suppressed because it is too large
Load diff
200
js/HiFi.test.ts
Normal file
200
js/HiFi.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { expect, test } from 'vitest';
|
||||
import { HiFiClient, TidalResponse } from './HiFi';
|
||||
import type { Album, PlaybackInfo, Track } from './container-classes';
|
||||
|
||||
const ARTIST_ID = 3523908; // deadmau5
|
||||
const ALBUM_ID = 433360012; // deadmau5 - 4x4=12
|
||||
const _ALBUM_ATMOS = 463900719; // Taylor Swift - The Life of a Showgirl
|
||||
const TRACK_ATMOS = 463900720; // Taylor Swift - The Fate of Ophelia
|
||||
const _TRACK_NO_LOSSLESS = 31097959; // deadmau5 - while(1<2)
|
||||
const TRACK_VIDEO = 466464180; // Taylow Swift - The Fate of Ophelia
|
||||
const TRACK_LOSSLESS = 31097949; // deadmau5 - Avaritia
|
||||
const PLAYLIST_ID = '36ea71a8-445e-41a4-82ab-6628c581535d'; // Pop Hits
|
||||
|
||||
const instance = new HiFiClient();
|
||||
await instance.fetchToken();
|
||||
|
||||
function checkVersion({ version }: { version?: string }) {
|
||||
expect(version).toBeTypeOf('string');
|
||||
expect(version).not.equals('');
|
||||
expect(version).equals(HiFiClient.API_VERSION);
|
||||
}
|
||||
|
||||
async function _getJson(res: Response | Promise<Response>) {
|
||||
res = await res;
|
||||
expect(res).toBeInstanceOf(Response);
|
||||
expect(res.ok).toBeTruthy();
|
||||
return (await res.json()) as object;
|
||||
}
|
||||
|
||||
async function checkRoute(
|
||||
route: string,
|
||||
routeResult: () => Promise<Response>,
|
||||
checks: (data: object) => Promise<void>,
|
||||
mainKey: string | null = 'data'
|
||||
) {
|
||||
const routeData = await instance.query(route);
|
||||
const routeRes = (await routeResult()) as unknown;
|
||||
expect(routeData).toBeInstanceOf(TidalResponse);
|
||||
expect(routeData).toEqual(routeRes);
|
||||
|
||||
const json = (await routeData.json()) as object;
|
||||
checkVersion(json);
|
||||
|
||||
if (mainKey != null) {
|
||||
expect(json).toHaveProperty(mainKey);
|
||||
expect(json[mainKey]).not.toBeUndefined();
|
||||
}
|
||||
|
||||
await checks(json);
|
||||
}
|
||||
|
||||
test('Get token', async () => {
|
||||
const instance = new HiFiClient();
|
||||
|
||||
const token = await instance.fetchToken();
|
||||
expect(token).toBeTypeOf('string');
|
||||
expect(token).not.toBeUndefined();
|
||||
expect(token).not.length(0);
|
||||
expect(token).equals(instance.token);
|
||||
|
||||
const token2 = await instance.fetchToken(true);
|
||||
expect(token2).toBeTypeOf('string');
|
||||
expect(token2).not.toBeUndefined();
|
||||
expect(token2).not.length(0);
|
||||
expect(token2).equals(instance.token);
|
||||
expect(token2).not.equals(token);
|
||||
|
||||
expect(instance.appTokenExpiry).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
test('Fetch atmos track info', async () => {
|
||||
await checkRoute(
|
||||
`/info/?id=${TRACK_ATMOS}`,
|
||||
() => instance.getInfo(TRACK_ATMOS),
|
||||
async (info: { data: Track }) => {
|
||||
expect(info.data.audioModes).toContain('DOLBY_ATMOS');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('Fetch track', async () => {
|
||||
await checkRoute(
|
||||
`/track/?id=${TRACK_LOSSLESS}`,
|
||||
() => instance.getTrack(TRACK_LOSSLESS),
|
||||
async (track: { data: PlaybackInfo }) => {
|
||||
expect(track?.data?.trackId).toBe(TRACK_LOSSLESS);
|
||||
expect(track.data.assetPresentation).toBeTypeOf('string');
|
||||
expect(track.data.audioQuality).toBeTypeOf('string');
|
||||
expect(track.data.manifestMimeType).toBeTypeOf('string');
|
||||
expect(track.data.manifestHash).toBeTypeOf('string');
|
||||
expect(track.data.manifest).toBeTypeOf('string');
|
||||
expect(track.data.albumReplayGain).toBeTypeOf('number');
|
||||
expect(track.data.albumPeakAmplitude).toBeTypeOf('number');
|
||||
expect(track.data.trackReplayGain).toBeTypeOf('number');
|
||||
expect(track.data.trackPeakAmplitude).toBeTypeOf('number');
|
||||
expect(track.data.bitDepth).toBeTypeOf('number');
|
||||
expect(track.data.sampleRate).toBeTypeOf('number');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test.skipIf(!instance.refreshToken)('Fetch recommendations', async () => {
|
||||
await checkRoute(
|
||||
`/recommendations/?id=${ARTIST_ID}`,
|
||||
() => instance.getRecommendations(ARTIST_ID),
|
||||
async (_data) => {}
|
||||
);
|
||||
});
|
||||
|
||||
test('Fetch similar artists', async () => {
|
||||
await checkRoute(
|
||||
`/artist/similar/?id=${ARTIST_ID}`,
|
||||
() => instance.getSimilarArtists(ARTIST_ID),
|
||||
async (_data) => {},
|
||||
'artists'
|
||||
);
|
||||
});
|
||||
|
||||
test('Fetch similar albums', async () => {
|
||||
await checkRoute(
|
||||
`/album/similar/?id=${ALBUM_ID}`,
|
||||
() => instance.getSimilarAlbums(ALBUM_ID),
|
||||
async (_data) => {},
|
||||
'albums'
|
||||
);
|
||||
});
|
||||
|
||||
test('Fetch artist info', async () => {
|
||||
await checkRoute(
|
||||
`/artist/?id=${ARTIST_ID}`,
|
||||
() => instance.getArtist(ARTIST_ID),
|
||||
async (info: { cover: string }) => {
|
||||
expect(info).toHaveProperty('cover');
|
||||
expect(info.cover).not.toBeUndefined();
|
||||
},
|
||||
'artist'
|
||||
);
|
||||
});
|
||||
|
||||
test('Search', async () => {
|
||||
const query = 'deadmau5';
|
||||
await checkRoute(
|
||||
`/search/?q=${encodeURIComponent(query)}`,
|
||||
() =>
|
||||
instance.search({
|
||||
q: query,
|
||||
}),
|
||||
async (_res) => {}
|
||||
);
|
||||
});
|
||||
|
||||
test('Fetch album info', async () => {
|
||||
await checkRoute(
|
||||
`/album/?id=${ALBUM_ID}`,
|
||||
() => instance.getAlbum(ALBUM_ID),
|
||||
async (info: { data: Album }) => {
|
||||
expect(info.data).toHaveProperty('cover');
|
||||
expect(info.data.cover).not.toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('Fetch playlist info', async () => {
|
||||
await checkRoute(
|
||||
`/playlist/?id=${PLAYLIST_ID}`,
|
||||
() => instance.getPlaylist(PLAYLIST_ID),
|
||||
async (info: { playlist: { image: string } }) => {
|
||||
expect(info.playlist).toHaveProperty('image');
|
||||
expect(info.playlist.image).not.toBeUndefined();
|
||||
},
|
||||
'playlist'
|
||||
);
|
||||
});
|
||||
|
||||
test.skipIf(!instance.refreshToken)('Fetch lyrics ', async () => {
|
||||
await checkRoute(
|
||||
`/lyrics/?id=${TRACK_ATMOS}`,
|
||||
() => instance.getLyrics(TRACK_ATMOS),
|
||||
async (_info) => {},
|
||||
'lyrics'
|
||||
);
|
||||
});
|
||||
|
||||
test('Fetch video ', async () => {
|
||||
await checkRoute(
|
||||
`/video/?id=${TRACK_VIDEO}`,
|
||||
() => instance.getVideo(TRACK_VIDEO),
|
||||
async (_info) => {},
|
||||
'video'
|
||||
);
|
||||
});
|
||||
|
||||
test('Fetch track manifests ', async () => {
|
||||
await checkRoute(
|
||||
`/trackManifests/?id=${TRACK_LOSSLESS}`,
|
||||
() => instance.getTrackManifest(TRACK_LOSSLESS),
|
||||
async (_info) => {},
|
||||
'data'
|
||||
);
|
||||
});
|
||||
136
js/HiFi.ts
136
js/HiFi.ts
|
|
@ -1,8 +1,11 @@
|
|||
import { EventEmitter } from 'events';
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
const API_VERSION = '2.7';
|
||||
const BROWSER_CLIENT_ID = 'txNoH4kkV41MfH25';
|
||||
const BROWSER_CLIENT_SECRET = 'dQjy0MinCEvxi1O4UmxvxWnDjt4cgHBPw8ll6nYBk98=';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
type Params = Record<string, string | number | undefined | null>;
|
||||
|
||||
|
|
@ -37,6 +40,10 @@ export enum HiFiClientEvents {
|
|||
}
|
||||
|
||||
class HiFiClient {
|
||||
static readonly API_VERSION = '2.7';
|
||||
static readonly BROWSER_CLIENT_ID = 'txNoH4kkV41MfH25';
|
||||
static readonly BROWSER_CLIENT_SECRET = 'dQjy0MinCEvxi1O4UmxvxWnDjt4cgHBPw8ll6nYBk98=';
|
||||
|
||||
static #instance: HiFiClient | null = null;
|
||||
static get instance() {
|
||||
if (!HiFiClient.#instance) {
|
||||
|
|
@ -57,6 +64,7 @@ class HiFiClient {
|
|||
readonly #albumTracksMax = 20;
|
||||
readonly #albumTracksQueue: Array<() => void> = [];
|
||||
readonly #countryCode: string;
|
||||
readonly #locale: string;
|
||||
readonly #clientId: string;
|
||||
readonly #clientSecret: string;
|
||||
readonly #emitter = new EventEmitter();
|
||||
|
|
@ -149,7 +157,7 @@ class HiFiClient {
|
|||
|
||||
setToken({ token, tokenExpiry, refreshToken }: HiFiClient.TokenOptions & HiFiClient.RefreshTokenOptions) {
|
||||
this.token = token;
|
||||
this.appTokenExpiry = this.appTokenExpiry;
|
||||
this.appTokenExpiry = tokenExpiry;
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
|
|
@ -158,8 +166,8 @@ class HiFiClient {
|
|||
}
|
||||
|
||||
async #fetchAppToken({
|
||||
clientId = BROWSER_CLIENT_ID,
|
||||
clientSecret = BROWSER_CLIENT_SECRET,
|
||||
clientId = HiFiClient.BROWSER_CLIENT_ID,
|
||||
clientSecret = HiFiClient.BROWSER_CLIENT_SECRET,
|
||||
refreshToken,
|
||||
scope = 'r_usr+w_usr+w_sub',
|
||||
signal = new AbortController().signal,
|
||||
|
|
@ -169,7 +177,7 @@ class HiFiClient {
|
|||
scope?: string;
|
||||
signal?: AbortSignal;
|
||||
force?: boolean;
|
||||
}) {
|
||||
}): Promise<string | null> {
|
||||
if (!force && this.token && (this.appTokenExpiry < 0 || Date.now() < this.appTokenExpiry)) return this.token;
|
||||
|
||||
return await (this.#tokenPromise ??= (async () => {
|
||||
|
|
@ -216,16 +224,37 @@ class HiFiClient {
|
|||
}
|
||||
|
||||
static #getOptions({
|
||||
locale = 'en_US',
|
||||
countryCode = 'US',
|
||||
baseUrl = null,
|
||||
clientId = BROWSER_CLIENT_ID,
|
||||
clientSecret = BROWSER_CLIENT_SECRET,
|
||||
clientId = HiFiClient.BROWSER_CLIENT_ID,
|
||||
clientSecret = HiFiClient.BROWSER_CLIENT_SECRET,
|
||||
token,
|
||||
tokenExpiry,
|
||||
refreshToken: tokenRefresh,
|
||||
refreshToken,
|
||||
storage = [],
|
||||
}: HiFiClient.ConstructorOptions = {}) {
|
||||
return { countryCode, baseUrl, clientId, clientSecret, token, tokenExpiry, tokenRefresh, storage };
|
||||
}: HiFiClient.ConstructorOptions = {}): WithRequiredKeys<HiFiClient.ConstructorOptions> {
|
||||
return {
|
||||
locale,
|
||||
countryCode,
|
||||
baseUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
token,
|
||||
tokenExpiry,
|
||||
refreshToken,
|
||||
storage,
|
||||
};
|
||||
}
|
||||
|
||||
async fetchToken(force: boolean = false, signal: AbortSignal | undefined = undefined) {
|
||||
return await this.#fetchAppToken({
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
signal,
|
||||
refreshToken: this.refreshToken || undefined,
|
||||
force: !!force,
|
||||
});
|
||||
}
|
||||
|
||||
async #fetchAuthenticated(
|
||||
|
|
@ -239,7 +268,7 @@ class HiFiClient {
|
|||
while (true) {
|
||||
const unauthorized = res?.status === 401;
|
||||
const previousResponse = res;
|
||||
const token = await await this.#fetchAppToken({
|
||||
const token = await this.#fetchAppToken({
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
signal,
|
||||
|
|
@ -279,15 +308,16 @@ class HiFiClient {
|
|||
}
|
||||
|
||||
constructor(options: HiFiClient.ConstructorOptions = {}) {
|
||||
const { countryCode, baseUrl, clientId, clientSecret, token, tokenExpiry, tokenRefresh, storage } =
|
||||
const { locale, countryCode, baseUrl, clientId, clientSecret, token, tokenExpiry, refreshToken, storage } =
|
||||
HiFiClient.#getOptions(options);
|
||||
this.#locale = locale;
|
||||
this.#countryCode = countryCode;
|
||||
this.#baseUrl = baseUrl;
|
||||
this.#clientId = clientId;
|
||||
this.#clientSecret = clientSecret;
|
||||
this.token = token;
|
||||
this.appTokenExpiry = tokenExpiry;
|
||||
this.refreshToken = tokenRefresh;
|
||||
this.refreshToken = refreshToken;
|
||||
|
||||
for (const store of !Array.isArray(storage) ? [storage] : storage) {
|
||||
this.#useStorage(store);
|
||||
|
|
@ -334,7 +364,7 @@ class HiFiClient {
|
|||
async getInfo(id: number, signal?: AbortSignal) {
|
||||
const url = `https://api.tidal.com/v1/tracks/${id}/`;
|
||||
const data = await this.#fetchJson(url, { countryCode: this.#countryCode }, signal);
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, data });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
|
||||
}
|
||||
|
||||
async getTrack(id: number, quality = 'HI_RES_LOSSLESS', immersiveAudio: boolean = false, signal?: AbortSignal) {
|
||||
|
|
@ -347,7 +377,7 @@ class HiFiClient {
|
|||
immersiveAudio: String(immersiveAudio),
|
||||
};
|
||||
const data = await this.#fetchJson(url, params, signal);
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, data });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
|
||||
}
|
||||
|
||||
async getTrackManifest(
|
||||
|
|
@ -382,7 +412,7 @@ class HiFiClient {
|
|||
drmData.certificateUrl = url;
|
||||
}
|
||||
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, data: res });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: res });
|
||||
}
|
||||
|
||||
async getWidevine() {
|
||||
|
|
@ -392,7 +422,7 @@ class HiFiClient {
|
|||
async getRecommendations(id: number, signal?: AbortSignal) {
|
||||
const url = `https://api.tidal.com/v1/tracks/${id}/recommendations`;
|
||||
const data = await this.#fetchJson(url, { limit: '20', countryCode: this.#countryCode }, signal);
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, data });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
|
||||
}
|
||||
|
||||
async getSimilarArtists(id: number, cursor?: string | number | null, signal?: AbortSignal) {
|
||||
|
|
@ -436,7 +466,10 @@ class HiFiClient {
|
|||
};
|
||||
};
|
||||
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, artists: (payload?.data || []).map(resolveArtist) });
|
||||
return HiFiClient.#jsonResponse({
|
||||
version: HiFiClient.API_VERSION,
|
||||
artists: (payload?.data || []).map(resolveArtist),
|
||||
});
|
||||
}
|
||||
|
||||
async getSimilarAlbums(id: number, cursor?: string | number | null, signal?: AbortSignal) {
|
||||
|
|
@ -497,7 +530,10 @@ class HiFiClient {
|
|||
};
|
||||
};
|
||||
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, albums: (payload?.data || []).map(resolveAlbum) });
|
||||
return HiFiClient.#jsonResponse({
|
||||
version: HiFiClient.API_VERSION,
|
||||
albums: (payload?.data || []).map(resolveAlbum),
|
||||
});
|
||||
}
|
||||
|
||||
async getArtist(
|
||||
|
|
@ -530,7 +566,7 @@ class HiFiClient {
|
|||
};
|
||||
}
|
||||
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, artist: artist_data, cover });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, artist: artist_data, cover });
|
||||
}
|
||||
|
||||
// f provided -> gather albums and optionally tracks
|
||||
|
|
@ -584,10 +620,11 @@ class HiFiClient {
|
|||
}
|
||||
}
|
||||
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, albums: page_data, tracks: top_tracks });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks: top_tracks });
|
||||
}
|
||||
|
||||
if (!album_ids.length) return HiFiClient.#jsonResponse({ version: API_VERSION, albums: page_data, tracks: [] });
|
||||
if (!album_ids.length)
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks: [] });
|
||||
|
||||
const fetchAlbumTracks = async (album_id: number) => {
|
||||
return await this.#withAlbumTrackSlot(async () => {
|
||||
|
|
@ -613,7 +650,18 @@ class HiFiClient {
|
|||
if (Array.isArray(t)) tracks.push(...t);
|
||||
}
|
||||
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, albums: page_data, tracks });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks });
|
||||
}
|
||||
|
||||
async getArtistBiography(artistId: number, signal?: AbortSignal) {
|
||||
const url = `https://api.tidal.com/v1/artists/${artistId}/bio`;
|
||||
const params = {
|
||||
locale: this.#locale,
|
||||
countryCode: this.#countryCode,
|
||||
};
|
||||
const data = await this.#fetchJson(url, params, signal);
|
||||
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: data });
|
||||
}
|
||||
|
||||
#buildCoverEntry(cover_slug: string, name?: string | null, track_id?: number | null) {
|
||||
|
|
@ -640,7 +688,7 @@ class HiFiClient {
|
|||
const cover_slug = album.cover;
|
||||
if (!cover_slug) throw new ResponseError(404, 'Cover not found');
|
||||
const entry = this.#buildCoverEntry(cover_slug, album.title || track_data.title, album.id || id);
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, covers: [entry] });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, covers: [entry] });
|
||||
}
|
||||
|
||||
const search_data = await this.#fetchJson(
|
||||
|
|
@ -658,7 +706,7 @@ class HiFiClient {
|
|||
covers.push(this.#buildCoverEntry(cover_slug, track.title, track.id));
|
||||
}
|
||||
if (!covers.length) throw new ResponseError(404, 'Cover not found');
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, covers });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, covers });
|
||||
}
|
||||
|
||||
async search(
|
||||
|
|
@ -690,7 +738,7 @@ class HiFiClient {
|
|||
},
|
||||
signal
|
||||
);
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, data: res });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: res });
|
||||
} catch (err: any) {
|
||||
if (err.status && ![400, 404].includes(err.status)) throw err;
|
||||
// fallback to text search
|
||||
|
|
@ -705,7 +753,7 @@ class HiFiClient {
|
|||
},
|
||||
signal
|
||||
);
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, data: fallback });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: fallback });
|
||||
}
|
||||
|
||||
const mapping: Array<[string | undefined, string, Params]> = [
|
||||
|
|
@ -746,7 +794,7 @@ class HiFiClient {
|
|||
for (const [val, url, params] of mapping) {
|
||||
if (val) {
|
||||
const data = await this.#fetchJson(url, params, signal);
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, data });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -783,7 +831,7 @@ class HiFiClient {
|
|||
if (Array.isArray(pageItems)) allItems.push(...pageItems);
|
||||
}
|
||||
albumData.items = allItems;
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, data: albumData });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: albumData });
|
||||
}
|
||||
|
||||
async getMix(id: string, signal?: AbortSignal) {
|
||||
|
|
@ -803,7 +851,7 @@ class HiFiClient {
|
|||
}
|
||||
}
|
||||
return HiFiClient.#jsonResponse({
|
||||
version: API_VERSION,
|
||||
version: HiFiClient.API_VERSION,
|
||||
mix: header,
|
||||
items: items.map((it: any) => (it.item ? it.item : it)),
|
||||
});
|
||||
|
|
@ -817,7 +865,7 @@ class HiFiClient {
|
|||
this.#fetchJson(itemsUrl, { countryCode: this.#countryCode, limit, offset }, signal),
|
||||
]);
|
||||
const items = (itemsData && itemsData.items) || itemsData;
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, playlist: playlistData, items });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, playlist: playlistData, items });
|
||||
}
|
||||
|
||||
// simplified artist/cover/lyrics/video/topvideos/similar methods (same pattern)
|
||||
|
|
@ -833,7 +881,7 @@ class HiFiClient {
|
|||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, lyrics: data });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, lyrics: data });
|
||||
}
|
||||
|
||||
async getVideo(id: number, quality = 'HIGH', mode = 'STREAM', presentation = 'FULL', signal?: AbortSignal) {
|
||||
|
|
@ -843,7 +891,7 @@ class HiFiClient {
|
|||
{ videoquality: quality, playbackmode: mode, assetpresentation: presentation },
|
||||
signal
|
||||
);
|
||||
return HiFiClient.#jsonResponse({ version: API_VERSION, video: data });
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, video: data });
|
||||
}
|
||||
|
||||
async getTopVideos(
|
||||
|
|
@ -867,7 +915,7 @@ class HiFiClient {
|
|||
}
|
||||
}
|
||||
return HiFiClient.#jsonResponse({
|
||||
version: API_VERSION,
|
||||
version: HiFiClient.API_VERSION,
|
||||
videos: videos.slice(offset, offset + limit),
|
||||
total: videos.length,
|
||||
});
|
||||
|
|
@ -886,7 +934,10 @@ class HiFiClient {
|
|||
switch (pathname) {
|
||||
case '/':
|
||||
return new TidalResponse(
|
||||
HiFiClient.#jsonResponse({ version: API_VERSION, Repo: 'https://github.com/binimum/hifi-api' })
|
||||
HiFiClient.#jsonResponse({
|
||||
version: HiFiClient.API_VERSION,
|
||||
Repo: 'https://github.com/binimum/hifi-api',
|
||||
})
|
||||
);
|
||||
case '/info':
|
||||
return new TidalResponse(await this.getInfo(Number(qp.id)));
|
||||
|
|
@ -902,6 +953,8 @@ class HiFiClient {
|
|||
return new TidalResponse(
|
||||
await this.getSimilarAlbums(Number(qp.id), qp.cursor ?? undefined, signal)
|
||||
);
|
||||
case '/artist/bio':
|
||||
return new TidalResponse(await this.getArtistBiography(Number(qp.id), signal));
|
||||
case '/artist':
|
||||
return new TidalResponse(
|
||||
await this.getArtist(
|
||||
|
|
@ -1007,8 +1060,13 @@ namespace HiFiClient {
|
|||
clientSecret?: string;
|
||||
}
|
||||
|
||||
export interface ConstructorOptions extends ClientOptions, TokenOptions, RefreshTokenOptions {
|
||||
export interface LocaleOptions {
|
||||
locale?: string;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
export interface ConstructorOptions
|
||||
extends LocaleOptions, RefreshTokenOptions, ClientOptions, TokenOptions, RefreshTokenOptions {
|
||||
baseUrl?: string;
|
||||
storage?: Pick<Storage, 'setItem' | 'removeItem'>[] | Pick<Storage, 'setItem' | 'removeItem'>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import { db } from './db';
|
|||
*
|
||||
* @template C The accumulated shape of the settings object.
|
||||
*/
|
||||
class ModernSettings<C extends object = {}> {
|
||||
class ModernSettings<C extends object = object> {
|
||||
/** Internal map of pending async operations keyed by unique symbols. */
|
||||
#pending: Record<symbol, Promise<any>> = {};
|
||||
#pending: Record<symbol, Promise<void>> = {};
|
||||
|
||||
/** Whether new properties are prevented from being added. */
|
||||
#finalized: boolean = false;
|
||||
|
|
@ -51,7 +51,7 @@ class ModernSettings<C extends object = {}> {
|
|||
* @param callback Function producing the promise to track.
|
||||
* @returns The created promise.
|
||||
*/
|
||||
#addPending<C extends Promise<any>>(callback: () => C): C {
|
||||
#addPending<C extends Promise<void>>(callback: () => C): C {
|
||||
const sym = Symbol();
|
||||
|
||||
return (this.#pending[sym] = callback().finally(() => {
|
||||
|
|
@ -145,14 +145,14 @@ class ModernSettings<C extends object = {}> {
|
|||
const legacyValue = localStorage.getItem(legacy?.key ?? backingKey ?? key);
|
||||
|
||||
if (legacyValue !== null) {
|
||||
db.saveSetting(backingKey ?? key, legacy.transformer!(legacyValue));
|
||||
await db.saveSetting(backingKey ?? key, legacy.transformer(legacyValue));
|
||||
localStorage.removeItem(legacy?.key ?? backingKey ?? key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
value = (await db.getSetting(backingKey ?? key)) ?? defaultValue;
|
||||
value = ((await db.getSetting(backingKey ?? key)) as T) ?? defaultValue;
|
||||
} catch {
|
||||
value = defaultValue;
|
||||
}
|
||||
|
|
@ -162,7 +162,7 @@ class ModernSettings<C extends object = {}> {
|
|||
get: () => (getter ? getter(value, typed as ModernSettings<C> & C & Record<K, T>) : value),
|
||||
set: (newValue: T) => {
|
||||
value = setter ? setter(newValue, typed as ModernSettings<C> & C & Record<K, T>) : newValue;
|
||||
this.#addPending(() => db.saveSetting(backingKey ?? key, value));
|
||||
void this.#addPending(() => db.saveSetting(backingKey ?? key, value));
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
|
|
@ -261,6 +261,7 @@ export const modernSettings = new ModernSettings()
|
|||
transformer: String,
|
||||
},
|
||||
})
|
||||
.addProperty('writeArtistsSeparately', false)
|
||||
.finalize() as ModernSettings & {
|
||||
/** The last used directory handle for bulk downloads */
|
||||
bulkDownloadFolder: FileSystemDirectoryHandle | null;
|
||||
|
|
@ -286,4 +287,7 @@ export const modernSettings = new ModernSettings()
|
|||
|
||||
/** Filename template for downloads */
|
||||
filenameTemplate: string;
|
||||
|
||||
/** Whether to write multiple artists to downloaded files */
|
||||
writeArtistsSeparately: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export class AuthManager {
|
|||
constructor() {
|
||||
this.user = null;
|
||||
this.authListeners = [];
|
||||
this.init();
|
||||
this.init().catch(console.error);
|
||||
}
|
||||
|
||||
async init() {
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ const syncManager = {
|
|||
}
|
||||
},
|
||||
|
||||
safeParseInternal(str, fieldName, fallback) {
|
||||
safeParseInternal(str, _fieldName, fallback) {
|
||||
if (!str) return fallback;
|
||||
if (typeof str !== 'string') return str;
|
||||
try {
|
||||
|
|
@ -136,7 +136,7 @@ const syncManager = {
|
|||
} catch {
|
||||
try {
|
||||
// Recovery attempt: replace illegal internal quotes in name/title fields
|
||||
const recovered = str.replace(/(:\s*")(.+?)("(?=\s*[,}\n\r]))/g, (match, p1, p2, p3) => {
|
||||
const recovered = str.replace(/(:\s*")(.+?)("(?=\s*[,}\n\r]))/g, (_match, p1, p2, p3) => {
|
||||
const escapedContent = p2.replace(/(?<!\\)"/g, '\\"');
|
||||
return p1 + escapedContent + p3;
|
||||
});
|
||||
|
|
@ -156,6 +156,8 @@ const syncManager = {
|
|||
(jsFriendly.trim().startsWith('[') || jsFriendly.trim().startsWith('{')) &&
|
||||
!jsFriendly.match(/function|=>|window|document|alert|eval/)
|
||||
) {
|
||||
// TODO: maybe this could be parsed as json5?
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
return new Function('return ' + jsFriendly)();
|
||||
}
|
||||
}
|
||||
|
|
@ -565,11 +567,6 @@ const syncManager = {
|
|||
|
||||
if (cloudData) {
|
||||
let database = db;
|
||||
if (typeof database === 'function') {
|
||||
database = await database();
|
||||
} else {
|
||||
database = await database;
|
||||
}
|
||||
|
||||
const localData = {
|
||||
tracks: (await database.getAll('favorites_tracks')) || [],
|
||||
|
|
|
|||
25
js/api.js
25
js/api.js
|
|
@ -5,10 +5,7 @@ import {
|
|||
delay,
|
||||
isTrackUnavailable,
|
||||
getExtensionFromBlob,
|
||||
getTrackTitle,
|
||||
getFullArtistString,
|
||||
getTrackDiscNumber,
|
||||
getMimeType,
|
||||
} from './utils.js';
|
||||
import { preferDolbyAtmosSettings, trackDateSettings } from './storage.js';
|
||||
import { APICache } from './cache.js';
|
||||
|
|
@ -36,7 +33,6 @@ import {
|
|||
|
||||
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
||||
export { resolveDownloadTotalBytes };
|
||||
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
|
||||
|
||||
export class LosslessAPI {
|
||||
constructor(settings) {
|
||||
|
|
@ -48,8 +44,8 @@ export class LosslessAPI {
|
|||
this.streamCache = new Map();
|
||||
|
||||
setInterval(
|
||||
() => {
|
||||
this.cache.clearExpired();
|
||||
async () => {
|
||||
await this.cache.clearExpired();
|
||||
this.pruneStreamCache();
|
||||
},
|
||||
1000 * 60 * 5
|
||||
|
|
@ -492,7 +488,7 @@ export class LosslessAPI {
|
|||
await this.cache.set('search_all', query, results);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Fallback to individual searches if the backend proxy doesn't support ?q= or throws
|
||||
const [tracks, videos, artists, albums, playlists] = await Promise.all([
|
||||
this.searchTracks(query, options).catch(() => ({ items: [] })),
|
||||
|
|
@ -1240,15 +1236,10 @@ export class LosslessAPI {
|
|||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const url = `https://api.tidal.com/v1/artists/${artistId}/bio?locale=en_US&countryCode=GB`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-Tidal-Token': TIDAL_V2_TOKEN,
|
||||
},
|
||||
});
|
||||
const response = await HiFiClient.instance.query(`/artist/bio/?id=${artistId}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const { data } = await response.json();
|
||||
if (data && data.text) {
|
||||
const bio = {
|
||||
text: data.text,
|
||||
|
|
@ -1500,7 +1491,9 @@ export class LosslessAPI {
|
|||
a.canPlayType('audio/mp4; codecs="ec-3"') || a.canPlayType('audio/mp4; codecs="eac3"')
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// Atmos codec probe - intentionally swallowed; canPlayAtmos stays false
|
||||
}
|
||||
|
||||
const paramsArray = [];
|
||||
|
||||
|
|
@ -1561,7 +1554,7 @@ export class LosslessAPI {
|
|||
} else {
|
||||
throw new Error('No URI in trackManifests response');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Fallback to /track endpoint
|
||||
}
|
||||
|
||||
|
|
|
|||
456
js/api.test.ts
Normal file
456
js/api.test.ts
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
import { expect, test, suite, vi } from 'vitest';
|
||||
import { apiSettings, preferDolbyAtmosSettings, losslessContainerSettings } from './storage.js';
|
||||
import { MusicAPI } from './music-api.js';
|
||||
import { LyricsManager } from './lyrics.js';
|
||||
import { HiFiClient } from './HiFi.js';
|
||||
import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js';
|
||||
import { Mp4File } from '!/@dantheman827/taglib-ts/src/mp4/mp4File.js';
|
||||
import { MpegFile } from '!/@dantheman827/taglib-ts/src/mpeg/mpegFile.js';
|
||||
import { FlacFile } from '!/@dantheman827/taglib-ts/src/flac/flacFile.js';
|
||||
import { Mp4Atom, Mp4Atoms } from '!/@dantheman827/taglib-ts/src/mp4/mp4Atoms.js';
|
||||
import { ByteVector, StringType } from '!/@dantheman827/taglib-ts/src/byteVector.js';
|
||||
import { Mp4Codec } from '!/@dantheman827/taglib-ts/src/mp4/mp4Properties.js';
|
||||
import { OggFile } from '!/@dantheman827/taglib-ts/src/ogg/oggFile.js';
|
||||
import { ffmpeg } from './ffmpeg.js';
|
||||
import type { Track } from './container-classes.js';
|
||||
|
||||
vi.mock(import('./storage.js'), async (importOriginal) => {
|
||||
const mod = await importOriginal();
|
||||
|
||||
return {
|
||||
...mod,
|
||||
preferDolbyAtmosSettings: {
|
||||
...mod.preferDolbyAtmosSettings,
|
||||
isEnabled: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
},
|
||||
losslessContainerSettings: {
|
||||
...mod.losslessContainerSettings,
|
||||
getContainer: vi.fn(),
|
||||
setContainer: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(import('./ffmpeg.js'), async (importOriginal) => {
|
||||
const mod = await importOriginal();
|
||||
|
||||
return {
|
||||
...mod,
|
||||
ffmpeg: vi.fn(mod.ffmpeg),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(import('./doTimed.js'), async (importOriginal) => {
|
||||
const mod = await importOriginal();
|
||||
|
||||
return {
|
||||
...mod,
|
||||
doTimed: function <T>(_label: string, fn: () => T): T {
|
||||
return fn();
|
||||
},
|
||||
doTimedAsync<T, R = T extends Promise<T> ? Promise<T> : T>(
|
||||
_message: string,
|
||||
callback: () => R,
|
||||
throwError: boolean = false
|
||||
): R {
|
||||
return new Promise<R>((resolve, reject) => {
|
||||
Promise.resolve()
|
||||
.then(callback)
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
if (throwError) {
|
||||
reject(err as Error);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
}) as R;
|
||||
},
|
||||
} satisfies typeof import('./doTimed.js');
|
||||
});
|
||||
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
enum Detection {
|
||||
DolbyAtmos,
|
||||
FlacHD,
|
||||
FlacLossless,
|
||||
AlacHD,
|
||||
AlacLossless,
|
||||
Mp4Flac,
|
||||
AacLow,
|
||||
AacReallyLow,
|
||||
AacHigh,
|
||||
AAC_256,
|
||||
MP3_320,
|
||||
MP3_256,
|
||||
MP3_128,
|
||||
OGG_320,
|
||||
OGG_256,
|
||||
OGG_128,
|
||||
}
|
||||
|
||||
suite('Track Downloads', async () => {
|
||||
const SILENCE_TRACK = 46022548;
|
||||
const TRACK_ATMOS = 463900720; // Taylor Swift - The Fate of Ophelia
|
||||
const TRACK_NO_LOSSLESS = 31097959; // deadmau5 - while(1<2)
|
||||
|
||||
await MusicAPI.initialize(apiSettings);
|
||||
await LyricsManager.initialize(apiSettings);
|
||||
await HiFiClient.initialize();
|
||||
|
||||
const api = MusicAPI.instance.tidalAPI;
|
||||
|
||||
async function downloadTrack(trackId: number, quality: string) {
|
||||
const track = (await (await HiFiClient.instance.getInfo(trackId)).json()) as { data: Track };
|
||||
return await api.downloadTrack(trackId.toString(), quality, undefined, {
|
||||
track: track.data,
|
||||
triggerDownload: false,
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
display_quality: 'Dolby Atmos',
|
||||
quality: 'HI_RES_LOSSLESS',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: true,
|
||||
trackId: TRACK_ATMOS,
|
||||
detection: Detection.DolbyAtmos,
|
||||
ffmpegCalls: 0,
|
||||
},
|
||||
{
|
||||
display_quality: 'HD Lossless (FLAC)',
|
||||
quality: 'HI_RES_LOSSLESS',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.FlacHD,
|
||||
ffmpegCalls: 1,
|
||||
},
|
||||
{
|
||||
display_quality: 'Lossless (FLAC)',
|
||||
quality: 'LOSSLESS',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.FlacLossless,
|
||||
ffmpegCalls: 0,
|
||||
},
|
||||
{
|
||||
display_quality: 'HD Lossless (ALAC)',
|
||||
quality: 'HI_RES_LOSSLESS',
|
||||
container: 'alac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.AlacHD,
|
||||
ffmpegCalls: 1,
|
||||
},
|
||||
{
|
||||
display_quality: 'Lossless (ALAC)',
|
||||
quality: 'LOSSLESS',
|
||||
container: 'alac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.AlacLossless,
|
||||
ffmpegCalls: 1,
|
||||
},
|
||||
{
|
||||
display_quality: 'HD Lossless (Unchanged)',
|
||||
quality: 'HI_RES_LOSSLESS',
|
||||
container: 'nochange',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.Mp4Flac,
|
||||
ffmpegCalls: 0,
|
||||
},
|
||||
{
|
||||
display_quality: 'Lossless (Unchanged)',
|
||||
quality: 'LOSSLESS',
|
||||
container: 'nochange',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.FlacLossless,
|
||||
ffmpegCalls: 0,
|
||||
},
|
||||
{
|
||||
display_quality: 'Lossless, but not really',
|
||||
quality: 'HI_RES_LOSSLESS',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: TRACK_NO_LOSSLESS,
|
||||
detection: Detection.AacReallyLow,
|
||||
ffmpegCalls: 0,
|
||||
},
|
||||
{
|
||||
display_quality: 'High',
|
||||
quality: 'HIGH',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.AacHigh,
|
||||
ffmpegCalls: 0,
|
||||
},
|
||||
{
|
||||
display_quality: 'Low',
|
||||
quality: 'LOW',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.AacLow,
|
||||
ffmpegCalls: 0,
|
||||
},
|
||||
|
||||
{
|
||||
display_quality: 'AAC 256',
|
||||
quality: 'FFMPEG_AAC_256',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: TRACK_ATMOS,
|
||||
detection: Detection.AAC_256,
|
||||
ffmpegCalls: 1,
|
||||
},
|
||||
|
||||
{
|
||||
display_quality: 'MP3 320',
|
||||
quality: 'FFMPEG_MP3_320',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.MP3_320,
|
||||
ffmpegCalls: 1,
|
||||
},
|
||||
{
|
||||
display_quality: 'MP3 256',
|
||||
quality: 'FFMPEG_MP3_256',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.MP3_256,
|
||||
ffmpegCalls: 1,
|
||||
},
|
||||
{
|
||||
display_quality: 'MP3 128',
|
||||
quality: 'FFMPEG_MP3_128',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.MP3_128,
|
||||
ffmpegCalls: 1,
|
||||
},
|
||||
|
||||
{
|
||||
display_quality: 'OGG 320',
|
||||
quality: 'FFMPEG_OGG_320',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.OGG_320,
|
||||
ffmpegCalls: 1,
|
||||
},
|
||||
{
|
||||
display_quality: 'OGG 256',
|
||||
quality: 'FFMPEG_OGG_256',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.OGG_256,
|
||||
ffmpegCalls: 1,
|
||||
},
|
||||
{
|
||||
display_quality: 'OGG 128',
|
||||
quality: 'FFMPEG_OGG_128',
|
||||
container: 'flac',
|
||||
preferDolbyAtmos: false,
|
||||
trackId: SILENCE_TRACK,
|
||||
detection: Detection.OGG_128,
|
||||
ffmpegCalls: 1,
|
||||
},
|
||||
])('$display_quality', async ({ quality, container, preferDolbyAtmos, trackId, detection, ffmpegCalls }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
vi.mocked(preferDolbyAtmosSettings.isEnabled).mockReturnValue(preferDolbyAtmos);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
vi.mocked(losslessContainerSettings.getContainer).mockReturnValue(container);
|
||||
|
||||
const blob = await downloadTrack(trackId, quality);
|
||||
expect(ffmpeg).toHaveBeenCalledTimes(ffmpegCalls);
|
||||
const file = await FileRef.fromBlob(blob);
|
||||
const stream = file.file().stream();
|
||||
|
||||
expect(file.isValid).toBe(true);
|
||||
|
||||
let stsd: Mp4Atom | null = null;
|
||||
let stsdData: ByteVector | null = null;
|
||||
|
||||
const streamPosition = await stream.tell();
|
||||
|
||||
if (file.file() instanceof Mp4File) {
|
||||
const atoms = await Mp4Atoms.create(stream);
|
||||
const moov = atoms.find('moov');
|
||||
expect(moov).not.toBeNull();
|
||||
|
||||
let trak: Mp4Atom | null = null;
|
||||
let data: ByteVector;
|
||||
|
||||
const trakList = moov.findAll('trak');
|
||||
for (const track of trakList) {
|
||||
const hdlr = track.find('mdia', 'hdlr');
|
||||
if (!hdlr) continue;
|
||||
trak = track;
|
||||
await stream.seek(hdlr.offset);
|
||||
data = await stream.readBlock(hdlr.length);
|
||||
if (data.containsAt(ByteVector.fromString('soun', StringType.Latin1), 16)) {
|
||||
break;
|
||||
}
|
||||
trak = null;
|
||||
}
|
||||
expect(trak).toBeInstanceOf(Mp4Atom);
|
||||
stsd = trak.find('mdia', 'minf', 'stbl', 'stsd');
|
||||
expect(stsd).toBeInstanceOf(Mp4Atom);
|
||||
await stream.seek(stsd.offset);
|
||||
stsdData = await stream.readBlock(stsd.length);
|
||||
}
|
||||
|
||||
await stream.seek(streamPosition);
|
||||
|
||||
switch (detection) {
|
||||
case Detection.DolbyAtmos: {
|
||||
expect(file.file()).toBeInstanceOf(Mp4File);
|
||||
const codec = stsdData.toString().substring(20, 24);
|
||||
expect(codec).toBe('ec-3');
|
||||
break;
|
||||
}
|
||||
case Detection.FlacHD: {
|
||||
expect(file.file()).toBeInstanceOf(FlacFile);
|
||||
const flac = file.file() as FlacFile;
|
||||
expect(flac.audioProperties().bitsPerSample).toBe(24);
|
||||
expect(flac.audioProperties().sampleRate).toBe(176400);
|
||||
break;
|
||||
}
|
||||
case Detection.FlacLossless: {
|
||||
expect(file.file()).toBeInstanceOf(FlacFile);
|
||||
const flac = file.file() as FlacFile;
|
||||
expect(flac.audioProperties().bitsPerSample).toBe(16);
|
||||
expect(flac.audioProperties().sampleRate).toBe(44100);
|
||||
break;
|
||||
}
|
||||
case Detection.Mp4Flac: {
|
||||
expect(file.file()).toBeInstanceOf(Mp4File);
|
||||
const codec = stsdData.toString().substring(20, 24);
|
||||
expect(codec).toBe('fLaC');
|
||||
break;
|
||||
}
|
||||
case Detection.AlacHD: {
|
||||
expect(file.file()).toBeInstanceOf(Mp4File);
|
||||
const mp4 = file.file() as Mp4File;
|
||||
expect(mp4.audioProperties().codec).toBe(Mp4Codec.ALAC);
|
||||
expect(mp4.audioProperties().bitsPerSample).toBe(24);
|
||||
expect(mp4.audioProperties().sampleRate).toBe(176400);
|
||||
break;
|
||||
}
|
||||
case Detection.AlacLossless: {
|
||||
expect(file.file()).toBeInstanceOf(Mp4File);
|
||||
const mp4 = file.file() as Mp4File;
|
||||
expect(mp4.audioProperties().codec).toBe(Mp4Codec.ALAC);
|
||||
expect(mp4.audioProperties().bitsPerSample).toBe(16);
|
||||
expect(mp4.audioProperties().sampleRate).toBe(44100);
|
||||
break;
|
||||
}
|
||||
case Detection.AacLow: {
|
||||
expect(file.file()).toBeInstanceOf(Mp4File);
|
||||
const mp4 = file.file() as Mp4File;
|
||||
expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC);
|
||||
expect(mp4.audioProperties().bitsPerSample).toBe(16);
|
||||
expect(mp4.audioProperties().sampleRate).toBe(44100);
|
||||
expect(mp4.audioProperties().bitrate).toBe(97);
|
||||
break;
|
||||
}
|
||||
case Detection.AacReallyLow: {
|
||||
expect(file.file()).toBeInstanceOf(Mp4File);
|
||||
const mp4 = file.file() as Mp4File;
|
||||
expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC);
|
||||
expect(mp4.audioProperties().bitsPerSample).toBe(16);
|
||||
expect(mp4.audioProperties().sampleRate).toBe(22050);
|
||||
expect(mp4.audioProperties().bitrate).toBe(97);
|
||||
break;
|
||||
}
|
||||
case Detection.AacHigh: {
|
||||
expect(file.file()).toBeInstanceOf(Mp4File);
|
||||
const mp4 = file.file() as Mp4File;
|
||||
expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC);
|
||||
expect(mp4.audioProperties().bitsPerSample).toBe(16);
|
||||
expect(mp4.audioProperties().sampleRate).toBe(44100);
|
||||
expect(mp4.audioProperties().bitrate).toBe(322);
|
||||
break;
|
||||
}
|
||||
|
||||
case Detection.AAC_256: {
|
||||
expect(file.file()).toBeInstanceOf(Mp4File);
|
||||
const mp4 = file.file() as Mp4File;
|
||||
expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC);
|
||||
expect(mp4.audioProperties().bitsPerSample).toBe(16);
|
||||
expect(mp4.audioProperties().sampleRate).toBe(44100);
|
||||
expect(mp4.audioProperties().bitrate).toBe(263);
|
||||
break;
|
||||
}
|
||||
|
||||
case Detection.MP3_320: {
|
||||
expect(file.file()).toBeInstanceOf(MpegFile);
|
||||
const mp3 = file.file() as MpegFile;
|
||||
expect(mp3.audioProperties().sampleRate).toBe(44100);
|
||||
expect(mp3.audioProperties().bitrate).toBe(322);
|
||||
break;
|
||||
}
|
||||
|
||||
case Detection.MP3_256: {
|
||||
expect(file.file()).toBeInstanceOf(MpegFile);
|
||||
const mp3 = file.file() as MpegFile;
|
||||
expect(mp3.audioProperties().sampleRate).toBe(44100);
|
||||
expect(mp3.audioProperties().bitrate).toBe(258);
|
||||
break;
|
||||
}
|
||||
|
||||
case Detection.MP3_128: {
|
||||
expect(file.file()).toBeInstanceOf(MpegFile);
|
||||
const mp3 = file.file() as MpegFile;
|
||||
expect(mp3.audioProperties().sampleRate).toBe(44100);
|
||||
expect(mp3.audioProperties().bitrate).toBe(129);
|
||||
break;
|
||||
}
|
||||
|
||||
case Detection.OGG_320: {
|
||||
expect(file.file()).toBeInstanceOf(OggFile);
|
||||
const ogg = file.file() as OggFile;
|
||||
expect(ogg.audioProperties().sampleRate).toBe(44100);
|
||||
expect(ogg.audioProperties().bitrate).toBe(314);
|
||||
break;
|
||||
}
|
||||
|
||||
case Detection.OGG_256: {
|
||||
expect(file.file()).toBeInstanceOf(OggFile);
|
||||
const ogg = file.file() as OggFile;
|
||||
expect(ogg.audioProperties().sampleRate).toBe(44100);
|
||||
expect(ogg.audioProperties().bitrate).toBe(253);
|
||||
break;
|
||||
}
|
||||
|
||||
case Detection.OGG_128: {
|
||||
expect(file.file()).toBeInstanceOf(OggFile);
|
||||
const ogg = file.file() as OggFile;
|
||||
expect(ogg.audioProperties().sampleRate).toBe(44100);
|
||||
expect(ogg.audioProperties().bitrate).toBe(130);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error('Unknown detection type');
|
||||
}
|
||||
});
|
||||
});
|
||||
181
js/app.js
181
js/app.js
|
|
@ -33,7 +33,6 @@ import { authManager } from './accounts/auth.js';
|
|||
import { registerSW } from 'virtual:pwa-register';
|
||||
import { openEditProfile } from './profile.js';
|
||||
import { ThemeStore } from './themeStore.js';
|
||||
import { partyManager } from './listening-party.js';
|
||||
import './commandPalette.js';
|
||||
import { initTracker } from './tracker.js';
|
||||
import {
|
||||
|
|
@ -131,12 +130,12 @@ async function fetchcontributors() {
|
|||
const response = await fetch('https://api.samidy.com/api/contributors');
|
||||
const data1 = await response.json();
|
||||
|
||||
const data = data1.filter(user => user.type !== 'Bot');
|
||||
const data = data1.filter((user) => user.type !== 'Bot');
|
||||
|
||||
const con = document.querySelector(".about-contributors");
|
||||
const con = document.querySelector('.about-contributors');
|
||||
|
||||
data.forEach(user => {
|
||||
const userDIV = document.createElement("div");
|
||||
data.forEach((user) => {
|
||||
const userDIV = document.createElement('div');
|
||||
userDIV.innerHTML = `
|
||||
<a href="${user.html_url}" target="_blank">
|
||||
<img src="${user.avatar_url}" alt="${user.login}" width="50" style="border-radius: 50%;">
|
||||
|
|
@ -148,7 +147,6 @@ async function fetchcontributors() {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
async function loadMetadataModule() {
|
||||
if (!metadataModule) {
|
||||
metadataModule = await import('./metadata.js');
|
||||
|
|
@ -278,7 +276,7 @@ function initializeKeyboardShortcuts(player, _audioPlayer) {
|
|||
},
|
||||
lyrics: () => {
|
||||
trackKeyboardShortcut('L');
|
||||
document.querySelector('.now-playing-bar .cover')?.click();
|
||||
document.getElementById('toggle-lyrics-btn')?.click();
|
||||
},
|
||||
search: () => {
|
||||
trackKeyboardShortcut('/');
|
||||
|
|
@ -428,6 +426,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
// Populate commit info
|
||||
{
|
||||
const repo = 'https://github.com/monochrome-music/monochrome';
|
||||
// eslint-disable-next-line no-undef
|
||||
const hash = typeof __COMMIT_HASH__ !== 'undefined' ? __COMMIT_HASH__ : 'dev';
|
||||
const commitLink =
|
||||
hash !== 'dev' && hash !== 'unknown'
|
||||
|
|
@ -491,11 +490,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality);
|
||||
|
||||
// Initialize tracker
|
||||
initTracker();
|
||||
initTracker().catch(console.error);
|
||||
|
||||
// Initialize Contributor List
|
||||
fetchcontributors();
|
||||
|
||||
const castBtn = document.getElementById('cast-btn');
|
||||
initializeCasting(audioPlayer, castBtn);
|
||||
|
||||
|
|
@ -575,8 +572,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
* having to manually re-scan.
|
||||
*
|
||||
* When called with a `blob` and `filename` (single-track download case) it
|
||||
* performs a cheap partial update — reading metadata only from that one file
|
||||
* and inserting it into the existing cache — so the full folder does not need
|
||||
* performs a cheap partial update - reading metadata only from that one file
|
||||
* and inserting it into the existing cache - so the full folder does not need
|
||||
* to be re-walked. When called with no arguments (bulk download case, or when
|
||||
* `localFilesCache` has never been populated) it falls back to a full rescan.
|
||||
*/
|
||||
|
|
@ -610,7 +607,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
// checks for a saved handle and (in browser mode) requests read permission,
|
||||
// so this is a silent no-op when no folder is configured or permission is not
|
||||
// yet granted.
|
||||
scanLocalMediaFolder();
|
||||
scanLocalMediaFolder().catch(console.error);
|
||||
|
||||
const scrobbler = new MultiScrobbler();
|
||||
window.monochromeScrobbler = scrobbler;
|
||||
|
|
@ -1020,9 +1017,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
}
|
||||
});
|
||||
|
||||
document.getElementById('download-current-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('download-current-btn')?.addEventListener('click', async () => {
|
||||
if (Player.instance.currentTrack) {
|
||||
handleTrackAction(
|
||||
await handleTrackAction(
|
||||
'download',
|
||||
Player.instance.currentTrack,
|
||||
Player.instance,
|
||||
|
|
@ -1445,14 +1442,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
if (editingId) {
|
||||
// Edit
|
||||
const cover = document.getElementById('playlist-cover-input').value.trim();
|
||||
db.getPlaylist(editingId).then(async (playlist) => {
|
||||
await db.getPlaylist(editingId).then(async (playlist) => {
|
||||
if (playlist) {
|
||||
playlist.name = name;
|
||||
playlist.cover = cover;
|
||||
playlist.description = description;
|
||||
await handlePublicStatus(playlist);
|
||||
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
||||
syncManager.syncUserPlaylist(playlist, 'update');
|
||||
await syncManager.syncUserPlaylist(playlist, 'update');
|
||||
UIRenderer.instance.renderLibraryPage();
|
||||
// Also update current page if we are on it
|
||||
if (window.location.pathname === `/userplaylist/${editingId}`) {
|
||||
|
|
@ -1973,7 +1970,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
console.log(`Added ${tracks.length} tracks (including pending)`);
|
||||
}
|
||||
|
||||
db.createPlaylist(name, tracks, cover, description).then(async (playlist) => {
|
||||
await db.createPlaylist(name, tracks, cover, description).then(async (playlist) => {
|
||||
await handlePublicStatus(playlist);
|
||||
// Update DB again with isPublic flag
|
||||
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
||||
|
|
@ -1996,7 +1993,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
if (e.target.closest('.edit-playlist-btn')) {
|
||||
const card = e.target.closest('.user-playlist');
|
||||
const playlistId = card.dataset.userPlaylistId;
|
||||
db.getPlaylist(playlistId).then(async (playlist) => {
|
||||
await db.getPlaylist(playlistId).then(async (playlist) => {
|
||||
if (playlist) {
|
||||
const modal = document.getElementById('playlist-modal');
|
||||
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
|
||||
|
|
@ -2016,7 +2013,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
|
||||
shareBtn.onclick = () => {
|
||||
const url = getShareUrl(`/userplaylist/${playlist.id}`);
|
||||
navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!'));
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => alert('Link copied to clipboard!'))
|
||||
.catch(console.error);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2055,73 +2055,77 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const card = e.target.closest('.user-playlist');
|
||||
const playlistId = card.dataset.userPlaylistId;
|
||||
if (confirm('Are you sure you want to delete this playlist?')) {
|
||||
db.deletePlaylist(playlistId).then(() => {
|
||||
syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
|
||||
UIRenderer.instance.renderLibraryPage();
|
||||
});
|
||||
await db.deletePlaylist(playlistId);
|
||||
await syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
|
||||
UIRenderer.instance.renderLibraryPage();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.target.closest('#edit-playlist-btn')) {
|
||||
const playlistId = window.location.pathname.split('/')[2];
|
||||
db.getPlaylist(playlistId).then((playlist) => {
|
||||
if (playlist) {
|
||||
const modal = document.getElementById('playlist-modal');
|
||||
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
|
||||
document.getElementById('playlist-name-input').value = playlist.name;
|
||||
document.getElementById('playlist-cover-input').value = playlist.cover || '';
|
||||
document.getElementById('playlist-description-input').value = playlist.description || '';
|
||||
await db
|
||||
.getPlaylist(playlistId)
|
||||
.then((playlist) => {
|
||||
if (playlist) {
|
||||
const modal = document.getElementById('playlist-modal');
|
||||
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
|
||||
document.getElementById('playlist-name-input').value = playlist.name;
|
||||
document.getElementById('playlist-cover-input').value = playlist.cover || '';
|
||||
document.getElementById('playlist-description-input').value = playlist.description || '';
|
||||
|
||||
const publicToggle = document.getElementById('playlist-public-toggle');
|
||||
const shareBtn = document.getElementById('playlist-share-btn');
|
||||
const publicToggle = document.getElementById('playlist-public-toggle');
|
||||
const shareBtn = document.getElementById('playlist-share-btn');
|
||||
|
||||
if (publicToggle) publicToggle.checked = !!playlist.isPublic;
|
||||
if (shareBtn) {
|
||||
shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
|
||||
shareBtn.onclick = () => {
|
||||
const url = getShareUrl(`/userplaylist/${playlist.id}`);
|
||||
navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!'));
|
||||
};
|
||||
if (publicToggle) publicToggle.checked = !!playlist.isPublic;
|
||||
if (shareBtn) {
|
||||
shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
|
||||
shareBtn.onclick = async () => {
|
||||
const url = getShareUrl(`/userplaylist/${playlist.id}`);
|
||||
await navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => alert('Link copied to clipboard!'))
|
||||
.catch(console.error);
|
||||
};
|
||||
}
|
||||
|
||||
// Set cover upload state - show URL input if there's an existing cover
|
||||
const coverUploadBtn = document.getElementById('playlist-cover-upload-btn');
|
||||
const coverUrlInput = document.getElementById('playlist-cover-input');
|
||||
const coverToggleUrlBtn = document.getElementById('playlist-cover-toggle-url-btn');
|
||||
if (playlist.cover) {
|
||||
if (coverUploadBtn) coverUploadBtn.style.display = 'none';
|
||||
if (coverUrlInput) coverUrlInput.style.display = 'block';
|
||||
if (coverToggleUrlBtn) {
|
||||
coverToggleUrlBtn.textContent = 'Upload';
|
||||
coverToggleUrlBtn.title = 'Switch to file upload';
|
||||
}
|
||||
} else {
|
||||
if (coverUploadBtn) {
|
||||
coverUploadBtn.style.flex = '1';
|
||||
coverUploadBtn.style.display = 'flex';
|
||||
}
|
||||
if (coverUrlInput) coverUrlInput.style.display = 'none';
|
||||
if (coverToggleUrlBtn) {
|
||||
coverToggleUrlBtn.textContent = 'or URL';
|
||||
coverToggleUrlBtn.title = 'Switch to URL input';
|
||||
}
|
||||
}
|
||||
|
||||
modal.dataset.editingId = playlistId;
|
||||
document.getElementById('import-section').style.display = 'none';
|
||||
modal.classList.add('active');
|
||||
document.getElementById('playlist-name-input').focus();
|
||||
}
|
||||
|
||||
// Set cover upload state - show URL input if there's an existing cover
|
||||
const coverUploadBtn = document.getElementById('playlist-cover-upload-btn');
|
||||
const coverUrlInput = document.getElementById('playlist-cover-input');
|
||||
const coverToggleUrlBtn = document.getElementById('playlist-cover-toggle-url-btn');
|
||||
if (playlist.cover) {
|
||||
if (coverUploadBtn) coverUploadBtn.style.display = 'none';
|
||||
if (coverUrlInput) coverUrlInput.style.display = 'block';
|
||||
if (coverToggleUrlBtn) {
|
||||
coverToggleUrlBtn.textContent = 'Upload';
|
||||
coverToggleUrlBtn.title = 'Switch to file upload';
|
||||
}
|
||||
} else {
|
||||
if (coverUploadBtn) {
|
||||
coverUploadBtn.style.flex = '1';
|
||||
coverUploadBtn.style.display = 'flex';
|
||||
}
|
||||
if (coverUrlInput) coverUrlInput.style.display = 'none';
|
||||
if (coverToggleUrlBtn) {
|
||||
coverToggleUrlBtn.textContent = 'or URL';
|
||||
coverToggleUrlBtn.title = 'Switch to URL input';
|
||||
}
|
||||
}
|
||||
|
||||
modal.dataset.editingId = playlistId;
|
||||
document.getElementById('import-section').style.display = 'none';
|
||||
modal.classList.add('active');
|
||||
document.getElementById('playlist-name-input').focus();
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
if (e.target.closest('#delete-playlist-btn')) {
|
||||
const playlistId = window.location.pathname.split('/')[2];
|
||||
if (confirm('Are you sure you want to delete this playlist?')) {
|
||||
db.deletePlaylist(playlistId).then(() => {
|
||||
syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
|
||||
navigate('/library');
|
||||
});
|
||||
await db.deletePlaylist(playlistId);
|
||||
await syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
|
||||
navigate('/library');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2130,7 +2134,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const btn = e.target.closest('.remove-from-playlist-btn');
|
||||
const playlistId = window.location.pathname.split('/')[2];
|
||||
|
||||
db.getPlaylist(playlistId).then(async (playlist) => {
|
||||
await db.getPlaylist(playlistId).then(async (playlist) => {
|
||||
let trackId = null;
|
||||
let trackType = null;
|
||||
|
||||
|
|
@ -2149,7 +2153,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
if (trackId) {
|
||||
const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId, trackType);
|
||||
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
const scrollTop = document.querySelector('.main-content').scrollTop;
|
||||
await UIRenderer.instance.renderPlaylistPage(playlistId, 'user');
|
||||
document.querySelector('.main-content').scrollTop = scrollTop;
|
||||
|
|
@ -2670,7 +2674,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
// PWA Update Logic
|
||||
if (window.__AUTH_GATE__) {
|
||||
disablePwaForAuthGate();
|
||||
await disablePwaForAuthGate().catch(console.error);
|
||||
} else {
|
||||
const updateSW = registerSW({
|
||||
onNeedRefresh() {
|
||||
|
|
@ -2788,10 +2792,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
);
|
||||
});
|
||||
} else {
|
||||
headerAccountBtn.addEventListener('click', (e) => {
|
||||
headerAccountBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
headerAccountDropdown.classList.toggle('active');
|
||||
updateAccountDropdown();
|
||||
await updateAccountDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2864,8 +2868,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
<button class="btn-primary" id="header-create-profile">Create Profile</button>
|
||||
<button class="btn-secondary danger" id="header-sign-out">Sign Out</button>
|
||||
`;
|
||||
document.getElementById('header-create-profile').onclick = () => {
|
||||
openEditProfile();
|
||||
document.getElementById('header-create-profile').onclick = async () => {
|
||||
openEditProfile().catch(console.error);
|
||||
headerAccountDropdown.classList.remove('active');
|
||||
};
|
||||
}
|
||||
|
|
@ -2953,7 +2957,7 @@ function showMissingTracksNotification(missingTracks, playlistName) {
|
|||
const newCopyBtn = copyBtn.cloneNode(true);
|
||||
copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn);
|
||||
|
||||
newCopyBtn.addEventListener('click', () => {
|
||||
newCopyBtn.addEventListener('click', async () => {
|
||||
const header = `Missing songs from ${playlistName} import:\n\n`;
|
||||
const textToCopy =
|
||||
header +
|
||||
|
|
@ -2965,11 +2969,14 @@ function showMissingTracksNotification(missingTracks, playlistName) {
|
|||
})
|
||||
.join('\n');
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
const originalText = newCopyBtn.textContent;
|
||||
newCopyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => (newCopyBtn.textContent = originalText), 2000);
|
||||
});
|
||||
await navigator.clipboard
|
||||
.writeText(textToCopy)
|
||||
.then(async () => {
|
||||
const originalText = newCopyBtn.textContent;
|
||||
newCopyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => (newCopyBtn.textContent = originalText), 2000);
|
||||
})
|
||||
.catch(console.error);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -239,9 +239,10 @@ class AudioContextManager {
|
|||
// Create biquad filters for each frequency band
|
||||
this.filters = this.frequencies.map((freq, index) => {
|
||||
const filter = this.audioContext.createBiquadFilter();
|
||||
filter.type = 'peaking';
|
||||
filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
||||
filter.frequency.value = freq;
|
||||
filter.Q.value = this._calculateQ(index);
|
||||
filter.Q.value =
|
||||
this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
||||
filter.gain.value = this.currentGains[index] || 0;
|
||||
return filter;
|
||||
});
|
||||
|
|
@ -312,10 +313,10 @@ class AudioContextManager {
|
|||
try {
|
||||
this.audioContext = new AudioContext(highResOptions);
|
||||
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
try {
|
||||
this.audioContext = new AudioContext({ latencyHint: 'playback' });
|
||||
} catch (e2) {
|
||||
} catch {
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
}
|
||||
|
|
@ -358,7 +359,9 @@ class AudioContextManager {
|
|||
if (this.source) {
|
||||
try {
|
||||
this.source.disconnect();
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// node may already be disconnected
|
||||
}
|
||||
}
|
||||
|
||||
this.audio = audioElement;
|
||||
|
|
@ -386,7 +389,9 @@ class AudioContextManager {
|
|||
// Disconnect everything first
|
||||
try {
|
||||
this.source.disconnect();
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// node may already be disconnected
|
||||
}
|
||||
this.outputNode.disconnect();
|
||||
if (this.volumeNode) {
|
||||
this.volumeNode.disconnect();
|
||||
|
|
@ -405,16 +410,23 @@ class AudioContextManager {
|
|||
|
||||
// Apply mono audio if enabled
|
||||
if (this.isMonoAudioEnabled && this.monoMergerNode) {
|
||||
// Create a gain node to mix channels before the merger
|
||||
const monoGain = this.audioContext.createGain();
|
||||
monoGain.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
|
||||
// Reuse persistent gain node to avoid leaking AudioNodes
|
||||
if (!this.monoGainNode) {
|
||||
this.monoGainNode = this.audioContext.createGain();
|
||||
this.monoGainNode.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
|
||||
}
|
||||
try {
|
||||
this.monoGainNode.disconnect();
|
||||
} catch {
|
||||
/* not connected */
|
||||
}
|
||||
|
||||
// Connect source to mono gain
|
||||
this.source.connect(monoGain);
|
||||
this.source.connect(this.monoGainNode);
|
||||
|
||||
// Connect mono gain to both inputs of the merger
|
||||
monoGain.connect(this.monoMergerNode, 0, 0);
|
||||
monoGain.connect(this.monoMergerNode, 0, 1);
|
||||
this.monoGainNode.connect(this.monoMergerNode, 0, 0);
|
||||
this.monoGainNode.connect(this.monoMergerNode, 0, 1);
|
||||
|
||||
lastNode = this.monoMergerNode;
|
||||
console.log('[AudioContext] Mono audio enabled');
|
||||
|
|
@ -573,6 +585,57 @@ class AudioContextManager {
|
|||
return equalizerSettings.getRange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate biquad filter magnitude response in dB at a given frequency
|
||||
*/
|
||||
_biquadResponseDb(f, band, sr) {
|
||||
if (!band.enabled || !band.type) return 0;
|
||||
const w = (2 * Math.PI * band.freq) / sr;
|
||||
const p = (2 * Math.PI * f) / sr;
|
||||
const s = Math.sin(w) / (2 * band.q);
|
||||
const A = Math.pow(10, band.gain / 40);
|
||||
const c = Math.cos(w);
|
||||
let b0, b1, b2, a0, a1, a2;
|
||||
const t = band.type[0];
|
||||
if (t === 'p') {
|
||||
b0 = 1 + s * A;
|
||||
b1 = -2 * c;
|
||||
b2 = 1 - s * A;
|
||||
a0 = 1 + s / A;
|
||||
a1 = -2 * c;
|
||||
a2 = 1 - s / A;
|
||||
} else if (t === 'l') {
|
||||
const sq = 2 * Math.sqrt(A) * s;
|
||||
b0 = A * (A + 1 - (A - 1) * c + sq);
|
||||
b1 = 2 * A * (A - 1 - (A + 1) * c);
|
||||
b2 = A * (A + 1 - (A - 1) * c - sq);
|
||||
a0 = A + 1 + (A - 1) * c + sq;
|
||||
a1 = -2 * (A - 1 + (A + 1) * c);
|
||||
a2 = A + 1 + (A - 1) * c - sq;
|
||||
} else if (t === 'h') {
|
||||
const sq = 2 * Math.sqrt(A) * s;
|
||||
b0 = A * (A + 1 + (A - 1) * c + sq);
|
||||
b1 = -2 * A * (A - 1 + (A + 1) * c);
|
||||
b2 = A * (A + 1 + (A - 1) * c - sq);
|
||||
a0 = A + 1 - (A - 1) * c + sq;
|
||||
a1 = 2 * (A - 1 - (A + 1) * c);
|
||||
a2 = A + 1 - (A - 1) * c - sq;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
const _a0 = 1 / a0;
|
||||
const b0n = b0 * _a0,
|
||||
b1n = b1 * _a0,
|
||||
b2n = b2 * _a0;
|
||||
const a1n = a1 * _a0,
|
||||
a2n = a2 * _a0;
|
||||
const cp = Math.cos(p),
|
||||
c2p = Math.cos(2 * p);
|
||||
const n = b0n * b0n + b1n * b1n + b2n * b2n + 2 * (b0n * b1n + b1n * b2n) * cp + 2 * b0n * b2n * c2p;
|
||||
const d = 1 + a1n * a1n + a2n * a2n + 2 * (a1n + a1n * a2n) * cp + 2 * a2n * c2p;
|
||||
return 10 * Math.log10(n / d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp gain to valid range
|
||||
*/
|
||||
|
|
@ -665,8 +728,11 @@ class AudioContextManager {
|
|||
this.isEQEnabled = equalizerSettings.isEnabled();
|
||||
this.bandCount = equalizerSettings.getBandCount();
|
||||
this.freqRange = equalizerSettings.getFreqRange();
|
||||
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
||||
const customFreqs = equalizerSettings.getCustomFrequencies(this.bandCount);
|
||||
this.frequencies = customFreqs || generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
||||
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
||||
this.currentTypes = equalizerSettings.getBandTypes(this.bandCount);
|
||||
this.currentQs = equalizerSettings.getBandQs(this.bandCount);
|
||||
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
|
||||
this.preamp = equalizerSettings.getPreamp();
|
||||
}
|
||||
|
|
@ -697,7 +763,89 @@ class AudioContextManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Called when the app enters the background (screen lock, app switch).
|
||||
* Apply AutoEQ-generated bands to the equalizer
|
||||
* Unlike regular presets, AutoEQ bands have specific frequencies, gains, and Q values
|
||||
* @param {Array<{id: number, type: string, freq: number, gain: number, q: number, enabled: boolean}>} bands
|
||||
* @returns {string} Exported text representation of the applied EQ
|
||||
*/
|
||||
applyAutoEQBands(bands, skipPreamp = false) {
|
||||
if (!bands || bands.length === 0) return '';
|
||||
|
||||
const enabledBands = bands.filter((b) => b.enabled);
|
||||
const count = Math.max(equalizerSettings.MIN_BANDS, Math.min(equalizerSettings.MAX_BANDS, enabledBands.length));
|
||||
|
||||
// Calculate preamp: negative of cumulative peak gain across all bands to prevent clipping
|
||||
let cumulativePeak = 0;
|
||||
if (!skipPreamp) {
|
||||
const sr = this.audioContext?.sampleRate ?? 48000;
|
||||
// Sweep log-spaced frequencies (24 points/octave from 20-20kHz) to catch narrow peaks
|
||||
for (let f = 20; f <= 20000; f *= Math.pow(2, 1 / 24)) {
|
||||
let sum = 0;
|
||||
for (const b of enabledBands) {
|
||||
sum += this._biquadResponseDb(f, b, sr);
|
||||
}
|
||||
if (sum > cumulativePeak) cumulativePeak = sum;
|
||||
}
|
||||
}
|
||||
const preamp = skipPreamp
|
||||
? equalizerSettings.getPreamp()
|
||||
: cumulativePeak > 0
|
||||
? -Math.round(cumulativePeak * 10) / 10
|
||||
: 0;
|
||||
|
||||
// Sort bands by frequency so index order is deterministic
|
||||
const sortedBands = [...enabledBands].sort((a, b) => a.freq - b.freq);
|
||||
|
||||
// Build normalized band descriptor arrays
|
||||
const newFrequencies = sortedBands
|
||||
.slice(0, count)
|
||||
.map((b) => Math.round(Math.min(b.freq, (this.audioContext?.sampleRate ?? 48000) / 2 - 1)));
|
||||
const newTypes = sortedBands.slice(0, count).map((b) => b.type || 'peaking');
|
||||
const newQs = sortedBands.slice(0, count).map((b) => b.q);
|
||||
const newGains = sortedBands.slice(0, count).map((b) => this._clampGain(b.gain));
|
||||
|
||||
// Update band count via class setter to trigger equalizer-band-count-changed event
|
||||
if (count !== this.bandCount) {
|
||||
this.setBandCount(count);
|
||||
}
|
||||
|
||||
// Override frequencies, types, and Qs with band-specific values
|
||||
this.frequencies = newFrequencies;
|
||||
this.currentTypes = newTypes;
|
||||
this.currentQs = newQs;
|
||||
this.currentGains = newGains;
|
||||
|
||||
// Rebuild EQ so _createEQ picks up the new types/Qs
|
||||
if (this.isInitialized && this.audioContext) {
|
||||
this._destroyEQ();
|
||||
this._createEQ();
|
||||
this._connectGraph();
|
||||
}
|
||||
|
||||
// Apply preamp (skip if caller manages preamp externally)
|
||||
if (!skipPreamp) {
|
||||
this.setPreamp(preamp);
|
||||
}
|
||||
|
||||
// Persist normalized band descriptors to settings store
|
||||
equalizerSettings.setCustomFrequencies(this.frequencies);
|
||||
equalizerSettings.setGains(this.currentGains);
|
||||
equalizerSettings.setBandTypes(this.currentTypes);
|
||||
equalizerSettings.setBandQs(this.currentQs);
|
||||
|
||||
// Generate export text using the actual applied preamp value
|
||||
const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`];
|
||||
sortedBands.forEach((band, index) => {
|
||||
if (index >= count) return;
|
||||
const filterType = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK';
|
||||
lines.push(
|
||||
`Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
|
||||
);
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export equalizer settings to text format
|
||||
* @returns {string} Exported settings in text format
|
||||
|
|
@ -709,8 +857,13 @@ class AudioContextManager {
|
|||
|
||||
this.frequencies.forEach((freq, index) => {
|
||||
const gain = this.currentGains[index] || 0;
|
||||
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
||||
const filterType = type === 'lowshelf' ? 'LS' : type === 'highshelf' ? 'HS' : 'PK';
|
||||
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
||||
const filterNum = index + 1;
|
||||
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`);
|
||||
lines.push(
|
||||
`Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`
|
||||
);
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
|
|
@ -760,24 +913,42 @@ class AudioContextManager {
|
|||
this.setPreamp(preamp);
|
||||
|
||||
// If different number of bands, adjust
|
||||
if (filters.length !== this.bandCount) {
|
||||
const newCount = Math.max(
|
||||
equalizerSettings.MIN_BANDS,
|
||||
Math.min(equalizerSettings.MAX_BANDS, filters.length)
|
||||
);
|
||||
const newCount = Math.max(
|
||||
equalizerSettings.MIN_BANDS,
|
||||
Math.min(equalizerSettings.MAX_BANDS, filters.length)
|
||||
);
|
||||
if (newCount !== this.bandCount) {
|
||||
this.setBandCount(newCount);
|
||||
}
|
||||
|
||||
// Extract gains from filters
|
||||
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
|
||||
this.setAllGains(gains);
|
||||
// Apply per-band frequencies, types, Qs, and gains from import
|
||||
const sliced = filters.slice(0, this.bandCount);
|
||||
const typeMap = {
|
||||
PK: 'peaking',
|
||||
LS: 'lowshelf',
|
||||
LSC: 'lowshelf',
|
||||
LSF: 'lowshelf',
|
||||
HS: 'highshelf',
|
||||
HSC: 'highshelf',
|
||||
HSF: 'highshelf',
|
||||
};
|
||||
this.frequencies = sliced.map((f) => f.freq);
|
||||
this.currentTypes = sliced.map((f) => typeMap[f.type] || 'peaking');
|
||||
this.currentQs = sliced.map((f) => f.q);
|
||||
this.currentGains = sliced.map((f) => this._clampGain(f.gain));
|
||||
|
||||
// Store filter frequencies if different
|
||||
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
|
||||
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
|
||||
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
|
||||
// Rebuild EQ chain to apply new frequencies, types, and Qs
|
||||
if (this.isInitialized && this.audioContext) {
|
||||
this._destroyEQ();
|
||||
this._createEQ();
|
||||
this._connectGraph();
|
||||
}
|
||||
|
||||
// Persist all band settings
|
||||
equalizerSettings.setGains(this.currentGains);
|
||||
equalizerSettings.setBandTypes(this.currentTypes);
|
||||
equalizerSettings.setBandQs(this.currentQs);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[AudioContext] Failed to import EQ settings:', e);
|
||||
|
|
|
|||
4394
js/autoeq-data.js
Normal file
4394
js/autoeq-data.js
Normal file
File diff suppressed because it is too large
Load diff
270
js/autoeq-engine.js
Normal file
270
js/autoeq-engine.js
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// js/autoeq-engine.js
|
||||
// AutoEQ Algorithm - Ported from Seap Engine AutoEqEngine.ts
|
||||
// Iterative peak-flattening parametric EQ optimization
|
||||
|
||||
// Constants
|
||||
const MAX_BOOST = 30.0;
|
||||
const MAX_CUT = 30.0;
|
||||
const MIN_Q = 0.6;
|
||||
const DEFAULT_SR = 48000;
|
||||
const PI = Math.PI;
|
||||
const DB_BASE = 10;
|
||||
const DB_DIVISOR = 40;
|
||||
|
||||
/**
|
||||
* Calculate biquad filter magnitude response at a given frequency
|
||||
* @param {number} f - Frequency to evaluate (Hz)
|
||||
* @param {object} band - EQ band {type, freq, gain, q, enabled}
|
||||
* @param {number} sr - Sample rate
|
||||
* @returns {number} Magnitude in dB
|
||||
*/
|
||||
function calculateBiquadResponse(f, band, sr = DEFAULT_SR) {
|
||||
if (!band.enabled) return 0;
|
||||
if (!band.type || band.type.length === 0) return 0;
|
||||
const w = (2 * PI * band.freq) / sr;
|
||||
const p = (2 * PI * f) / sr;
|
||||
const t = band.type[0];
|
||||
// WebAudio ignores Q for shelf filters; use 1/√2 (slope = 1) to match
|
||||
const effectiveQ = t === 'l' || t === 'h' ? Math.SQRT1_2 : band.q;
|
||||
const s = Math.sin(w) / (2 * effectiveQ);
|
||||
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
|
||||
const c = Math.cos(w);
|
||||
let b0 = 0,
|
||||
b1 = 0,
|
||||
b2 = 0,
|
||||
a0 = 0,
|
||||
a1 = 0,
|
||||
a2 = 0;
|
||||
|
||||
if (t === 'p') {
|
||||
b0 = 1 + s * A;
|
||||
b1 = -2 * c;
|
||||
b2 = 1 - s * A;
|
||||
a0 = 1 + s / A;
|
||||
a1 = -2 * c;
|
||||
a2 = 1 - s / A;
|
||||
} else if (t === 'l') {
|
||||
const sq = 2 * Math.sqrt(A) * s;
|
||||
b0 = A * (A + 1 - (A - 1) * c + sq);
|
||||
b1 = 2 * A * (A - 1 - (A + 1) * c);
|
||||
b2 = A * (A + 1 - (A - 1) * c - sq);
|
||||
a0 = A + 1 + (A - 1) * c + sq;
|
||||
a1 = -2 * (A - 1 + (A + 1) * c);
|
||||
a2 = A + 1 + (A - 1) * c - sq;
|
||||
} else if (t === 'h') {
|
||||
const sq = 2 * Math.sqrt(A) * s;
|
||||
b0 = A * (A + 1 + (A - 1) * c + sq);
|
||||
b1 = -2 * A * (A - 1 + (A + 1) * c);
|
||||
b2 = A * (A + 1 + (A - 1) * c - sq);
|
||||
a0 = A + 1 - (A - 1) * c + sq;
|
||||
a1 = 2 * (A - 1 - (A + 1) * c);
|
||||
a2 = A + 1 - (A - 1) * c - sq;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const _a0 = 1 / a0;
|
||||
const b0n = b0 * _a0,
|
||||
b1n = b1 * _a0,
|
||||
b2n = b2 * _a0;
|
||||
const a1n = a1 * _a0,
|
||||
a2n = a2 * _a0;
|
||||
const cp = Math.cos(p),
|
||||
c2p = Math.cos(2 * p);
|
||||
const n = b0n * b0n + b1n * b1n + b2n * b2n + 2 * (b0n * b1n + b1n * b2n) * cp + 2 * b0n * b2n * c2p;
|
||||
const d = 1 + a1n * a1n + a2n * a2n + 2 * (a1n + a1n * a2n) * cp + 2 * a2n * c2p;
|
||||
return 10 * Math.log10(n / d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear interpolation on frequency response data
|
||||
* @param {number} freq - Frequency to interpolate at
|
||||
* @param {Array<{freq: number, gain: number}>} data - Frequency response data
|
||||
* @returns {number} Interpolated gain value
|
||||
*/
|
||||
function interpolate(freq, data) {
|
||||
if (data.length === 0) return 0;
|
||||
if (freq <= data[0].freq) return data[0].gain;
|
||||
if (freq >= data[data.length - 1].freq) return data[data.length - 1].gain;
|
||||
for (let i = 0; i < data.length - 1; i++) {
|
||||
if (freq >= data[i].freq && freq <= data[i + 1].freq) {
|
||||
return (
|
||||
data[i].gain +
|
||||
((freq - data[i].freq) / (data[i + 1].freq - data[i].freq)) * (data[i + 1].gain - data[i].gain)
|
||||
);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate normalization offset based on midrange average (250-2500 Hz)
|
||||
* With one argument: returns the midrange average of that curve (for graph centering).
|
||||
* With two arguments: evaluates both curves on the measurement frequency grid
|
||||
* to avoid sampling-density bias, returning (avgTarget - avgMeasurement).
|
||||
* @param {Array<{freq: number, gain: number}>} measurement - Measurement/data curve
|
||||
* @param {Array<{freq: number, gain: number}>} [target] - Optional target curve
|
||||
* @returns {number} Midrange average, or alignment offset when target is provided
|
||||
*/
|
||||
function getNormalizationOffset(measurement, target) {
|
||||
if (!target) {
|
||||
let sum = 0,
|
||||
count = 0;
|
||||
for (const p of measurement) {
|
||||
if (p.freq >= 250 && p.freq <= 2500) {
|
||||
sum += p.gain;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count > 0 ? sum / count : interpolate(1000, measurement);
|
||||
}
|
||||
let sumTarget = 0,
|
||||
sumMeasurement = 0,
|
||||
count = 0;
|
||||
for (const p of measurement) {
|
||||
if (p.freq >= 250 && p.freq <= 2500) {
|
||||
sumTarget += interpolate(p.freq, target);
|
||||
sumMeasurement += p.gain;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count > 0) return sumTarget / count - sumMeasurement / count;
|
||||
return interpolate(1000, target) - interpolate(1000, measurement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the AutoEQ algorithm to generate parametric EQ bands
|
||||
* Iterative peak-flattening: finds largest error, places a corrective filter, repeats
|
||||
*
|
||||
* @param {Array<{freq: number, gain: number}>} measurement - Headphone frequency response
|
||||
* @param {Array<{freq: number, gain: number}>} target - Target frequency response curve
|
||||
* @param {number} bandCount - Number of EQ bands to generate
|
||||
* @param {number} maxFreq - Maximum frequency limit (Hz)
|
||||
* @param {number} minFreq - Minimum frequency limit (Hz)
|
||||
* @param {number} maxQ - Maximum Q factor
|
||||
* @returns {Array<{id: number, type: string, freq: number, gain: number, q: number, enabled: boolean}>}
|
||||
*/
|
||||
function runAutoEqAlgorithm(
|
||||
measurement,
|
||||
target,
|
||||
bandCount,
|
||||
maxFreq = 16000,
|
||||
minFreq = 20,
|
||||
maxQ = 5.0,
|
||||
sampleRate = DEFAULT_SR
|
||||
) {
|
||||
if (minFreq > maxFreq) return [];
|
||||
const off = getNormalizationOffset(measurement, target);
|
||||
let err = measurement.map((p) => ({ freq: p.freq, gain: p.gain + off - interpolate(p.freq, target) }));
|
||||
|
||||
const hasInRangePoints = err.some((p) => p.freq >= minFreq && p.freq <= maxFreq);
|
||||
if (!hasInRangePoints) return [];
|
||||
|
||||
const out = [];
|
||||
|
||||
for (let i = 0; i < bandCount; i++) {
|
||||
let maxDev = 0,
|
||||
maxWeightedDev = 0,
|
||||
peakFreq = 1000,
|
||||
peakIdx = 0;
|
||||
|
||||
// Scan for maximum weighted error
|
||||
for (let j = 0; j < err.length; j++) {
|
||||
const p = err[j];
|
||||
if (p.freq < minFreq || p.freq > maxFreq) continue;
|
||||
|
||||
// 3-point smoothing
|
||||
let v = p.gain;
|
||||
if (j > 0 && j < err.length - 1) {
|
||||
v = (err[j - 1].gain + v + err[j + 1].gain) / 3;
|
||||
}
|
||||
|
||||
// Frequency-dependent weighting
|
||||
let w = 1.0;
|
||||
if (p.freq < 300) w = 1.5;
|
||||
else if (p.freq < 4000) w = 1.0;
|
||||
else if (p.freq < 8000) w = 0.5;
|
||||
else w = 0.25;
|
||||
|
||||
if (Math.abs(v * w) > Math.abs(maxWeightedDev)) {
|
||||
maxWeightedDev = Math.abs(v * w);
|
||||
maxDev = v;
|
||||
peakFreq = p.freq;
|
||||
peakIdx = j;
|
||||
}
|
||||
}
|
||||
|
||||
let gain = -maxDev;
|
||||
|
||||
// Safety clamps - reduce max boost at higher frequencies
|
||||
let safeBoost = MAX_BOOST;
|
||||
if (peakFreq > 3000) safeBoost = 6.0;
|
||||
if (peakFreq > 6000) safeBoost = 3.0;
|
||||
if (gain > safeBoost) gain = safeBoost;
|
||||
if (gain < -MAX_CUT) gain = -MAX_CUT;
|
||||
if (Math.abs(gain) < 0.2) break;
|
||||
|
||||
// Q factor calculation from error bandwidth (half-gain points)
|
||||
let upperFreq = peakFreq,
|
||||
lowerFreq = peakFreq;
|
||||
let foundLower = false,
|
||||
foundUpper = false;
|
||||
const thresholdError = maxDev / 2;
|
||||
for (let k = peakIdx; k >= 0; k--) {
|
||||
if (Math.abs(err[k].gain) < Math.abs(thresholdError)) {
|
||||
lowerFreq = err[k].freq;
|
||||
foundLower = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let k = peakIdx; k < err.length; k++) {
|
||||
if (Math.abs(err[k].gain) < Math.abs(thresholdError)) {
|
||||
upperFreq = err[k].freq;
|
||||
foundUpper = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If half-gain boundary not found on one side, mirror the other side
|
||||
// to avoid degenerate bandwidth = 0 producing extremely narrow filters
|
||||
if (!foundLower && foundUpper) {
|
||||
lowerFreq = (peakFreq * peakFreq) / upperFreq;
|
||||
} else if (!foundUpper && foundLower) {
|
||||
upperFreq = (peakFreq * peakFreq) / lowerFreq;
|
||||
} else if (!foundLower && !foundUpper) {
|
||||
// Neither boundary found - use 1 octave default
|
||||
lowerFreq = peakFreq / Math.SQRT2;
|
||||
upperFreq = peakFreq * Math.SQRT2;
|
||||
}
|
||||
|
||||
let bandwidth = Math.log2(upperFreq / Math.max(1, lowerFreq));
|
||||
if (bandwidth < 0.1) bandwidth = 0.1;
|
||||
let q = Math.sqrt(Math.pow(2, bandwidth)) / (Math.pow(2, bandwidth) - 1);
|
||||
q = Math.max(MIN_Q, Math.min(maxQ, q));
|
||||
if (peakFreq > 5000 && q > 3.0) q = 3.0;
|
||||
if (gain > 0 && q > 2.0) q = 2.0;
|
||||
|
||||
const newBand = { id: i, type: 'peaking', freq: peakFreq, gain, q, enabled: true };
|
||||
|
||||
// Check cumulative gain at the peak frequency across all existing bands + this one
|
||||
let cumulativeGain = gain;
|
||||
for (const existing of out) {
|
||||
cumulativeGain += calculateBiquadResponse(peakFreq, existing, sampleRate);
|
||||
}
|
||||
// If cumulative boost exceeds safe limits, reduce this band's gain
|
||||
const cumulativeLimit = MAX_BOOST;
|
||||
if (cumulativeGain > cumulativeLimit) {
|
||||
newBand.gain = gain - (cumulativeGain - cumulativeLimit);
|
||||
if (newBand.gain < 0.2) continue;
|
||||
}
|
||||
|
||||
out.push(newBand);
|
||||
|
||||
// Update error curve by applying the new band's response
|
||||
err = err.map((p) => ({ ...p, gain: p.gain + calculateBiquadResponse(p.freq, newBand, sampleRate) }));
|
||||
}
|
||||
|
||||
return out.sort((a, b) => a.freq - b.freq).map((b, i) => ({ ...b, id: i }));
|
||||
}
|
||||
|
||||
export { calculateBiquadResponse, interpolate, getNormalizationOffset, runAutoEqAlgorithm };
|
||||
291
js/autoeq-importer.js
Normal file
291
js/autoeq-importer.js
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
// js/autoeq-importer.js
|
||||
// Headphone Database Browser - Fetches from AutoEq GitHub repository
|
||||
// Provides access to 4000+ headphone measurement profiles
|
||||
|
||||
import { parseRawData } from './autoeq-data.js';
|
||||
import { db } from './db.js';
|
||||
|
||||
const CACHE_KEY = 'autoeq_index_v4';
|
||||
const OLD_LS_CACHE_KEY = 'monochrome_autoeq_index_v4';
|
||||
const CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// 5 most popular headphones - pre-loaded as defaults and shown in the headphone select
|
||||
// All measured on Rtings B&K 5128 rig for consistency
|
||||
const POPULAR_HEADPHONES = [
|
||||
{
|
||||
name: 'Sony WH-1000XM5 (Rtings)',
|
||||
type: 'over-ear',
|
||||
path: 'Rtings/Bruel & Kjaer 5128 over-ear/Sony WH-1000XM5',
|
||||
fileName: 'Sony WH-1000XM5.csv',
|
||||
},
|
||||
{
|
||||
name: 'Apple AirPods Pro2 (Rtings)',
|
||||
type: 'in-ear',
|
||||
path: 'Rtings/Bruel & Kjaer 5128 in-ear/Apple AirPods Pro2',
|
||||
fileName: 'Apple AirPods Pro2.csv',
|
||||
},
|
||||
{
|
||||
name: 'Sony WF-1000XM5 (Rtings)',
|
||||
type: 'in-ear',
|
||||
path: 'Rtings/Bruel & Kjaer 5128 in-ear/Sony WF-1000XM5',
|
||||
fileName: 'Sony WF-1000XM5.csv',
|
||||
},
|
||||
{
|
||||
name: 'Samsung Galaxy Buds3 Pro (Rtings)',
|
||||
type: 'in-ear',
|
||||
path: 'Rtings/Bruel & Kjaer 5128 in-ear/Samsung Galaxy Buds3 Pro',
|
||||
fileName: 'Samsung Galaxy Buds3 Pro.csv',
|
||||
},
|
||||
{
|
||||
name: 'Sennheiser HD 600 (Rtings)',
|
||||
type: 'over-ear',
|
||||
path: 'Rtings/Bruel & Kjaer 5128 over-ear/Sennheiser HD 600',
|
||||
fileName: 'Sennheiser HD 600.csv',
|
||||
},
|
||||
];
|
||||
|
||||
// Static fallback list in case GitHub API fails - popular picks + additional well-known models
|
||||
const FALLBACK_INDEX = [
|
||||
...POPULAR_HEADPHONES,
|
||||
{
|
||||
name: 'Sennheiser HD 600 (Filk)',
|
||||
type: 'over-ear',
|
||||
path: 'Filk/over-ear/Sennheiser HD 600',
|
||||
fileName: 'Sennheiser HD 600.csv',
|
||||
},
|
||||
{
|
||||
name: 'Sennheiser HD 600 (Innerfidelity)',
|
||||
type: 'over-ear',
|
||||
path: 'Innerfidelity/over-ear/Sennheiser HD 600',
|
||||
fileName: 'Sennheiser HD 600.csv',
|
||||
},
|
||||
{
|
||||
name: 'Samsung Galaxy Buds2 Pro (Rtings)',
|
||||
type: 'in-ear',
|
||||
path: 'Rtings/Bruel & Kjaer 5128 in-ear/Samsung Galaxy Buds2 Pro',
|
||||
fileName: 'Samsung Galaxy Buds2 Pro.csv',
|
||||
},
|
||||
{
|
||||
name: 'Sony WF-1000XM5 (Kazi)',
|
||||
type: 'in-ear',
|
||||
path: 'Kazi/in-ear/Sony WF-1000XM5',
|
||||
fileName: 'Sony WF-1000XM5.csv',
|
||||
},
|
||||
{
|
||||
name: 'Samsung Galaxy Buds3 Pro (DHRME)',
|
||||
type: 'in-ear',
|
||||
path: 'DHRME/in-ear/Samsung Galaxy Buds3 Pro',
|
||||
fileName: 'Samsung Galaxy Buds3 Pro.csv',
|
||||
},
|
||||
{
|
||||
name: 'Apple AirPods Pro (Super Review)',
|
||||
type: 'in-ear',
|
||||
path: 'Super Review/in-ear/Apple AirPods Pro',
|
||||
fileName: 'Apple AirPods Pro.csv',
|
||||
},
|
||||
{
|
||||
name: 'Sennheiser HD 600 (2020) (Kuulokenurkka)',
|
||||
type: 'over-ear',
|
||||
path: 'Kuulokenurkka/over-ear/Sennheiser HD 600 (2020)',
|
||||
fileName: 'Sennheiser HD 600 (2020).csv',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Fetch the full AutoEq headphone index from GitHub
|
||||
* Uses GitHub API to get the repository tree, then parses it for measurement files
|
||||
* Caches results in localStorage for 24 hours
|
||||
* @returns {Promise<Array<{name: string, type: string, path: string, fileName: string}>>}
|
||||
*/
|
||||
async function fetchAutoEqIndex() {
|
||||
// Migrate: remove old localStorage cache to free quota
|
||||
try {
|
||||
localStorage.removeItem(OLD_LS_CACHE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// 1. Try loading from IndexedDB cache
|
||||
try {
|
||||
const cached = await db.getSetting(CACHE_KEY);
|
||||
if (cached && cached.timestamp && cached.data) {
|
||||
if (Date.now() - cached.timestamp < CACHE_EXPIRY) {
|
||||
console.log('[AutoEQ] Loaded index from cache');
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[AutoEQ] Failed to read cache:', e);
|
||||
}
|
||||
|
||||
// 2. Fetch from GitHub API
|
||||
try {
|
||||
console.log('[AutoEQ] Fetching index from GitHub...');
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch('https://api.github.com/repos/jaakkopasanen/AutoEq/git/trees/master?recursive=1', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const cached = await db.getSetting(CACHE_KEY);
|
||||
if (cached?.data) {
|
||||
console.warn('[AutoEQ] GitHub API limit reached. Using stale cache.');
|
||||
return cached.data;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
console.warn('[AutoEQ] GitHub API error. Using fallback.');
|
||||
return FALLBACK_INDEX;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const entries = [];
|
||||
|
||||
for (const item of data.tree) {
|
||||
if (!item.path.startsWith('results/')) continue;
|
||||
if (!item.path.endsWith('.csv') && !item.path.endsWith('.txt')) continue;
|
||||
|
||||
const parts = item.path.split('/');
|
||||
if (parts.length < 4) continue;
|
||||
|
||||
const fileName = parts.pop();
|
||||
const fileNameLower = fileName.toLowerCase();
|
||||
|
||||
// Skip non-measurement files (EQ presets, not raw frequency response)
|
||||
if (
|
||||
fileNameLower.includes('parametriceq') ||
|
||||
fileNameLower.includes('fixedbandeq') ||
|
||||
fileNameLower.includes('graphiceq') ||
|
||||
fileNameLower.includes('convolution') ||
|
||||
fileNameLower.includes('fixed band eq') ||
|
||||
fileNameLower.includes('parametric eq') ||
|
||||
fileNameLower.includes('graphic eq')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const headphoneName = parts[parts.length - 1];
|
||||
const folderPath = parts.slice(1).join('/');
|
||||
const source = parts[1];
|
||||
|
||||
let type = 'over-ear';
|
||||
const lowerPath = item.path.toLowerCase();
|
||||
if (lowerPath.includes('in-ear') || lowerPath.includes('iem')) {
|
||||
type = 'in-ear';
|
||||
} else if (lowerPath.includes('earbud')) {
|
||||
type = 'in-ear';
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name: `${headphoneName} (${source})`,
|
||||
type,
|
||||
path: folderPath,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.length === 0) return FALLBACK_INDEX;
|
||||
|
||||
const sortedEntries = entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// 3. Save to IndexedDB cache
|
||||
try {
|
||||
await db.saveSetting(CACHE_KEY, {
|
||||
timestamp: Date.now(),
|
||||
data: sortedEntries,
|
||||
});
|
||||
console.log(`[AutoEQ] Cached ${sortedEntries.length} entries`);
|
||||
} catch (e) {
|
||||
console.warn('[AutoEQ] Failed to save cache:', e);
|
||||
}
|
||||
|
||||
return sortedEntries;
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
console.warn('[AutoEQ] GitHub API request timed out. Falling back to cache or fallback index.');
|
||||
} else {
|
||||
console.error('[AutoEQ] Failed to fetch index:', err);
|
||||
}
|
||||
try {
|
||||
const cached = await db.getSetting(CACHE_KEY);
|
||||
if (cached?.data) return cached.data;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return FALLBACK_INDEX;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the frequency response measurement data for a specific headphone
|
||||
* Tries raw GitHub first, falls back to jsDelivr CDN
|
||||
* @param {object} entry - AutoEq entry {name, type, path, fileName}
|
||||
* @returns {Promise<Array<{freq: number, gain: number}>>}
|
||||
*/
|
||||
async function fetchHeadphoneData(entry) {
|
||||
const encodedPath = entry.path.split('/').map(encodeURIComponent).join('/');
|
||||
const encodedFileName = encodeURIComponent(entry.fileName);
|
||||
|
||||
const urls = [
|
||||
`https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results/${encodedPath}/${encodedFileName}`,
|
||||
`https://cdn.jsdelivr.net/gh/jaakkopasanen/AutoEq@master/results/${encodedPath}/${encodedFileName}`,
|
||||
];
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
if (!response.ok) continue;
|
||||
|
||||
const text = await response.text();
|
||||
// Validate it's not an HTML error page
|
||||
if (text.trim().startsWith('<!') || text.trim().startsWith('<html')) continue;
|
||||
|
||||
const points = parseRawData(text);
|
||||
if (points.length > 0) return points;
|
||||
} catch (e) {
|
||||
console.warn(`[AutoEQ] Fetch failed for ${url}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch data for ${entry.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search/filter headphone entries by query and optional type filter
|
||||
* @param {string} query - Search query
|
||||
* @param {Array} entries - Full list of entries
|
||||
* @param {string} typeFilter - Optional type filter ('all', 'over-ear', 'in-ear')
|
||||
* @param {number} limit - Maximum results to return
|
||||
* @returns {Array}
|
||||
*/
|
||||
function searchHeadphones(query, entries, typeFilter = 'all', limit = 100) {
|
||||
let filtered = entries;
|
||||
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter((e) => e.type === typeFilter);
|
||||
}
|
||||
|
||||
if (query && query.trim()) {
|
||||
const lower = query.toLowerCase().trim();
|
||||
filtered = filtered.filter((e) => e.name.toLowerCase().includes(lower));
|
||||
}
|
||||
|
||||
return filtered.slice(0, limit);
|
||||
}
|
||||
|
||||
export { fetchAutoEqIndex, fetchHeadphoneData, searchHeadphones, POPULAR_HEADPHONES };
|
||||
|
|
@ -69,9 +69,7 @@ export class ZipStreamWriter implements IBulkDownloadWriter {
|
|||
constructor(private readonly suggestedFilename: string) {}
|
||||
|
||||
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
||||
// showSaveFilePicker is part of the File System Access API (not yet in all TS DOM libs)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fileHandle = await (window as any).showSaveFilePicker({
|
||||
const fileHandle = await window.showSaveFilePicker({
|
||||
suggestedName: this.suggestedFilename,
|
||||
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
||||
});
|
||||
|
|
@ -134,8 +132,7 @@ export class FolderPickerWriter implements IBulkDownloadWriter {
|
|||
// Try to re-use a saved handle first
|
||||
if (savedHandle) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const permission = await (savedHandle as any).requestPermission({ mode: 'readwrite' });
|
||||
const permission = await savedHandle.requestPermission({ mode: 'readwrite' });
|
||||
if (permission === 'granted') {
|
||||
return new FolderPickerWriter(savedHandle);
|
||||
}
|
||||
|
|
@ -145,9 +142,8 @@ export class FolderPickerWriter implements IBulkDownloadWriter {
|
|||
}
|
||||
|
||||
// showDirectoryPicker is part of the File System Access API (not yet in all TS DOM libs)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
try {
|
||||
const dirHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker({
|
||||
const dirHandle: FileSystemDirectoryHandle = await window.showDirectoryPicker({
|
||||
mode: 'readwrite',
|
||||
});
|
||||
return new FolderPickerWriter(dirHandle);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export class APICache {
|
|||
this.dbName = 'monochrome-cache';
|
||||
this.dbVersion = 1;
|
||||
this.db = null;
|
||||
this.initDB();
|
||||
this.initDB().catch(console.error);
|
||||
}
|
||||
|
||||
async initDB() {
|
||||
|
|
|
|||
|
|
@ -338,9 +338,9 @@ class CommandPalette {
|
|||
icon: 'trash',
|
||||
label: 'Clear Queue',
|
||||
keywords: ['wipe', 'clear', 'empty', 'queue'],
|
||||
action: () => {
|
||||
action: async () => {
|
||||
Player.instance.wipeQueue();
|
||||
this.notify('Queue cleared');
|
||||
await this.notify('Queue cleared');
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -674,7 +674,7 @@ class CommandPalette {
|
|||
keywords: ['edit', 'profile', 'username', 'avatar', 'display name'],
|
||||
action: async () => {
|
||||
const { openEditProfile } = await import('./profile.js');
|
||||
openEditProfile();
|
||||
await openEditProfile();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -780,7 +780,7 @@ class CommandPalette {
|
|||
this.updateSelection();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.executeSelected();
|
||||
this.executeSelected().catch(console.error);
|
||||
} else if (e.key === 'Escape') {
|
||||
if (this.settingsMode) {
|
||||
this.settingsMode = false;
|
||||
|
|
@ -1036,9 +1036,9 @@ class CommandPalette {
|
|||
|
||||
el.innerHTML = `${iconHtml}<div class="cmdk-item-content"><span class="cmdk-item-label">${escapeHtml(item.label)}</span>${descHtml}</div>${shortcutHtml}`;
|
||||
|
||||
el.addEventListener('click', () => {
|
||||
el.addEventListener('click', async () => {
|
||||
this.selectedIndex = index;
|
||||
this.executeSelected();
|
||||
await this.executeSelected();
|
||||
});
|
||||
|
||||
el.addEventListener('mouseenter', () => {
|
||||
|
|
@ -1171,14 +1171,14 @@ class CommandPalette {
|
|||
if (opt.dataset.theme === theme) opt.classList.add('active');
|
||||
else opt.classList.remove('active');
|
||||
});
|
||||
this.notify(`Theme set to ${theme}`);
|
||||
await this.notify(`Theme set to ${theme}`);
|
||||
}
|
||||
|
||||
async toggleVisualizer() {
|
||||
const { visualizerSettings } = await import('./storage.js');
|
||||
const current = visualizerSettings.isEnabled();
|
||||
visualizerSettings.setEnabled(!current);
|
||||
this.notify(`Visualizer ${!current ? 'enabled' : 'disabled'}`);
|
||||
await this.notify(`Visualizer ${!current ? 'enabled' : 'disabled'}`);
|
||||
|
||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||
if (overlay && getComputedStyle(overlay).display !== 'none') {
|
||||
|
|
@ -1192,7 +1192,7 @@ class CommandPalette {
|
|||
if (UIRenderer.instance.visualizer) {
|
||||
UIRenderer.instance.visualizer.setPreset(preset);
|
||||
}
|
||||
this.notify(`Visualizer preset: ${preset}`);
|
||||
await this.notify(`Visualizer preset: ${preset}`);
|
||||
}
|
||||
|
||||
async setQuality(quality) {
|
||||
|
|
@ -1225,13 +1225,13 @@ class CommandPalette {
|
|||
const downloadSelect = document.getElementById('download-quality-setting');
|
||||
if (downloadSelect) downloadSelect.value = dlQuality;
|
||||
|
||||
this.notify(`Quality set to ${qualityNames[quality] || quality}`);
|
||||
await this.notify(`Quality set to ${qualityNames[quality] || quality}`);
|
||||
}
|
||||
|
||||
setSleepTimer(minutes) {
|
||||
async setSleepTimer(minutes) {
|
||||
if (Player.instance) {
|
||||
Player.instance.setSleepTimer(minutes);
|
||||
this.notify(`Sleep timer: ${minutes} minutes`);
|
||||
await this.notify(`Sleep timer: ${minutes} minutes`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1242,7 +1242,7 @@ class CommandPalette {
|
|||
|
||||
const queue = player.getCurrentQueue();
|
||||
if (queue.length === 0) {
|
||||
this.notify('Queue is empty');
|
||||
await this.notify('Queue is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1250,7 +1250,7 @@ class CommandPalette {
|
|||
const scrobbler = window.monochromeScrobbler;
|
||||
|
||||
let likedCount = 0;
|
||||
this.notify('Liking all tracks in queue...');
|
||||
await this.notify('Liking all tracks in queue...');
|
||||
for (const track of queue) {
|
||||
const isLiked = await db.isFavorite('track', track.id);
|
||||
if (!isLiked) {
|
||||
|
|
@ -1258,7 +1258,7 @@ class CommandPalette {
|
|||
likedCount++;
|
||||
}
|
||||
}
|
||||
this.notify(`Liked ${likedCount} new track(s)`);
|
||||
await this.notify(`Liked ${likedCount} new track(s)`);
|
||||
}
|
||||
|
||||
async downloadQueue() {
|
||||
|
|
@ -1268,40 +1268,39 @@ class CommandPalette {
|
|||
|
||||
const queue = player.getCurrentQueue();
|
||||
if (queue.length === 0) {
|
||||
this.notify('Queue is empty');
|
||||
await this.notify('Queue is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const { downloadTracks } = await import('./downloads.js');
|
||||
const { downloadQualitySettings } = await import('./storage.js');
|
||||
downloadTracks(queue, ui.api, downloadQualitySettings.getQuality(), ui.lyricsManager);
|
||||
await downloadTracks(queue, ui.api, downloadQualitySettings.getQuality(), ui.lyricsManager);
|
||||
}
|
||||
|
||||
async createPlaylist() {
|
||||
const name = `New Playlist ${new Date().toLocaleDateString()}`;
|
||||
await db.createPlaylist(name);
|
||||
navigate('/library');
|
||||
this.notify('Playlist created');
|
||||
await this.notify('Playlist created');
|
||||
}
|
||||
|
||||
async createFolder() {
|
||||
const name = `New Folder ${new Date().toLocaleDateString()}`;
|
||||
await db.createFolder(name);
|
||||
navigate('/library');
|
||||
this.notify('Folder created');
|
||||
await this.notify('Folder created');
|
||||
}
|
||||
|
||||
async clearCache() {
|
||||
const api = UIRenderer.instance.api;
|
||||
if (api) {
|
||||
await api.clearCache();
|
||||
this.notify('Cache cleared');
|
||||
await this.notify('Cache cleared');
|
||||
}
|
||||
}
|
||||
|
||||
async notify(message) {
|
||||
const { showNotification } = await import('./downloads.js');
|
||||
showNotification(message);
|
||||
await import('./downloads.js').then((m) => m.showNotification(message)).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export class MediaMetadata extends BaseContainer {
|
|||
}
|
||||
|
||||
export class Artist extends BaseContainer {
|
||||
handle: any;
|
||||
handle: unknown;
|
||||
id: number;
|
||||
name: string;
|
||||
picture: string;
|
||||
|
|
@ -99,6 +99,7 @@ export class Artist extends BaseContainer {
|
|||
|
||||
export class EnrichedTrack extends Track {
|
||||
declare album: TrackAlbum | EnrichedAlbum;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-redundant-type-constituents
|
||||
declare replayGain: any | ReplayGain;
|
||||
|
||||
constructor(data: object) {
|
||||
|
|
|
|||
|
|
@ -204,13 +204,13 @@ export class DashDownloader {
|
|||
const resolveTemplate = (template: string, number: number, time: number): string => {
|
||||
return template
|
||||
.replace(/\$RepresentationID\$/g, repId ?? '')
|
||||
.replace(/\$Number(?:%0([0-9]+)d)?\$/g, (_, width) => {
|
||||
.replace(/\$Number(?:%0([0-9]+)d)?\$/g, (_, width: string) => {
|
||||
if (width) {
|
||||
return number.toString().padStart(parseInt(width), '0');
|
||||
}
|
||||
return number.toString();
|
||||
})
|
||||
.replace(/\$Time(?:%0([0-9]+)d)?\$/g, (_, width) => {
|
||||
.replace(/\$Time(?:%0([0-9]+)d)?\$/g, (_, width: string) => {
|
||||
if (width) {
|
||||
return time.toString().padStart(parseInt(width), '0');
|
||||
}
|
||||
|
|
|
|||
3
js/db.js
3
js/db.js
|
|
@ -24,7 +24,7 @@ export class MusicDatabase {
|
|||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
|
||||
// v10 introduced track_ratings (bad PR) — remove it
|
||||
// v10 introduced track_ratings (bad PR) - remove it
|
||||
if (db.objectStoreNames.contains('track_ratings')) {
|
||||
db.deleteObjectStore('track_ratings');
|
||||
}
|
||||
|
|
@ -783,7 +783,6 @@ export class MusicDatabase {
|
|||
}
|
||||
|
||||
// Return lightweight copy without tracks
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { tracks, ...minified } = playlist;
|
||||
return minified;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,24 +24,30 @@ export function doTimedAsync<T, R = T extends Promise<T> ? Promise<T> : T>(
|
|||
throwError: boolean = false
|
||||
): R {
|
||||
if (import.meta.env.DEV) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const hiddenId = InvisibleCodec.encode(v7());
|
||||
console.time(message + hiddenId);
|
||||
try {
|
||||
const output = await callback();
|
||||
resolve(output);
|
||||
} catch (err) {
|
||||
console.error(`Error in timed operation "${message}":`, err);
|
||||
if (throwError) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(undefined as R);
|
||||
return new Promise((resolve, reject) => {
|
||||
(async () => {
|
||||
const hiddenId = InvisibleCodec.encode(v7());
|
||||
console.time(message + hiddenId);
|
||||
try {
|
||||
const output = await callback();
|
||||
resolve(output);
|
||||
} catch (err) {
|
||||
console.error(`Error in timed operation "${message}":`, err);
|
||||
if (throwError) {
|
||||
if (err instanceof Error) {
|
||||
reject(err);
|
||||
} else {
|
||||
reject(new Error(String(err)));
|
||||
}
|
||||
} else {
|
||||
resolve(undefined as R);
|
||||
}
|
||||
} finally {
|
||||
console.timeEnd(message + hiddenId);
|
||||
}
|
||||
} finally {
|
||||
console.timeEnd(message + hiddenId);
|
||||
}
|
||||
})().catch(reject);
|
||||
}) as R;
|
||||
} else {
|
||||
return callback() as R;
|
||||
return callback();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ import { ZipStreamWriter, ZipBlobWriter, FolderPickerWriter, SequentialFileWrite
|
|||
import { FfmpegProgress } from './ffmpeg.types.js';
|
||||
import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './progressEvents.js';
|
||||
import { db } from './db.js';
|
||||
import { modernSettings } from './ModernSettings.js';
|
||||
import { BulkDownloadMethod, modernSettings } from './ModernSettings.js';
|
||||
import { SVG_CLOSE } from './icons.ts';
|
||||
import { MusicAPI } from './music-api.js';
|
||||
import { LyricsManager } from './lyrics.js';
|
||||
|
||||
const downloadTasks = new Map();
|
||||
|
|
@ -166,7 +167,7 @@ export function showNotification(message) {
|
|||
}, 1500);
|
||||
}
|
||||
|
||||
export function addDownloadTask(trackId, track, filename, api, abortController) {
|
||||
export function addDownloadTask(trackId, track, _filename, api, abortController) {
|
||||
const container = createDownloadNotification();
|
||||
|
||||
const taskEl = document.createElement('div');
|
||||
|
|
@ -507,7 +508,7 @@ async function createSingleTrackFolderWriter() {
|
|||
const method = modernSettings.bulkDownloadMethod;
|
||||
const hasFolderPicker = 'showDirectoryPicker' in window;
|
||||
|
||||
if (method === 'local') {
|
||||
if (method === BulkDownloadMethod.LocalMedia) {
|
||||
const localHandle = await db.getSetting('local_folder_handle');
|
||||
if (hasFolderPicker && localHandle && typeof localHandle.requestPermission === 'function') {
|
||||
try {
|
||||
|
|
@ -520,7 +521,7 @@ async function createSingleTrackFolderWriter() {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (method === 'folder' && hasFolderPicker) {
|
||||
if (method === BulkDownloadMethod.Folder && hasFolderPicker) {
|
||||
const rememberFolder = modernSettings.rememberBulkDownloadFolder;
|
||||
const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null;
|
||||
// Try to reuse the saved handle silently first.
|
||||
|
|
@ -563,7 +564,7 @@ async function createBulkWriter(folderName) {
|
|||
const hasFolderPicker = 'showDirectoryPicker' in window;
|
||||
|
||||
// ── Local Media Folder method ────────────────────────────────────────────
|
||||
if (method === 'local') {
|
||||
if (method === BulkDownloadMethod.LocalMedia) {
|
||||
const localHandle = await db.getSetting('local_folder_handle');
|
||||
if (hasFolderPicker) {
|
||||
// Browser mode: try to reuse the stored handle with write permission
|
||||
|
|
@ -593,7 +594,7 @@ async function createBulkWriter(folderName) {
|
|||
}
|
||||
|
||||
// ── Folder Picker method ─────────────────────────────────────────────────
|
||||
if (method === 'folder' && hasFolderPicker) {
|
||||
if (method === BulkDownloadMethod.Folder && hasFolderPicker) {
|
||||
const rememberFolder = modernSettings.rememberBulkDownloadFolder;
|
||||
const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null;
|
||||
try {
|
||||
|
|
@ -613,7 +614,7 @@ async function createBulkWriter(folderName) {
|
|||
}
|
||||
}
|
||||
|
||||
if (method === 'individual') {
|
||||
if (method === BulkDownloadMethod.Individual) {
|
||||
return SequentialFileWriter;
|
||||
}
|
||||
// method === 'zip' (or folder picker unavailable as fallback)
|
||||
|
|
@ -658,7 +659,7 @@ async function startBulkDownload({
|
|||
completeBulkDownload(notification, true);
|
||||
|
||||
// If the download went to the local media folder, refresh the local library.
|
||||
if (modernSettings.bulkDownloadMethod === 'local') {
|
||||
if (modernSettings.bulkDownloadMethod === BulkDownloadMethod.LocalMedia) {
|
||||
window.refreshLocalMediaFolder?.();
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -671,7 +672,7 @@ async function startBulkDownload({
|
|||
}
|
||||
}
|
||||
|
||||
export async function downloadTracks(tracks, api, quality, lyricsManager = null) {
|
||||
export async function downloadTracks(tracks, api, quality, _lyricsManager = null) {
|
||||
const folderName = `Queue - ${new Date().toISOString().slice(0, 10)}`;
|
||||
await startBulkDownload({
|
||||
tracks,
|
||||
|
|
@ -686,7 +687,7 @@ export async function downloadTracks(tracks, api, quality, lyricsManager = null)
|
|||
});
|
||||
}
|
||||
|
||||
export async function downloadAlbum(album, tracks, api, quality, lyricsManager = null) {
|
||||
export async function downloadAlbum(album, tracks, api, quality, _lyricsManager = null) {
|
||||
const releaseDateStr =
|
||||
album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
|
||||
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||
|
|
@ -711,7 +712,7 @@ export async function downloadAlbum(album, tracks, api, quality, lyricsManager =
|
|||
});
|
||||
}
|
||||
|
||||
export async function downloadPlaylist(playlist, tracks, api, quality, lyricsManager = null) {
|
||||
export async function downloadPlaylist(playlist, tracks, api, quality, _lyricsManager = null) {
|
||||
const folderName = formatPathTemplate(modernSettings.folderTemplate, {
|
||||
albumTitle: playlist.title,
|
||||
albumArtist: 'Playlist',
|
||||
|
|
@ -1012,19 +1013,49 @@ function completeBulkDownload(notifEl, success = true, message = null) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function downloadTrackWithMetadata(track, quality, api, lyricsManager = null, abortController = null) {
|
||||
/**
|
||||
* Downloads a track with metadata and optionally lyrics.
|
||||
* @async
|
||||
* @param {Object} track - The track object to download
|
||||
* @param {string} quality - The desired audio quality for download
|
||||
* @param {MusicAPI | LosslessAPI} [api=MusicAPI.instance] - The API instance to use for downloading
|
||||
* @param {Object} [lyricsManager=null] - Optional manager for fetching and processing lyrics
|
||||
* @param {AbortController} [abortController=null] - Optional abort controller for cancelling the download
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If the download fails (except for AbortError)
|
||||
* @description
|
||||
* This function:
|
||||
* - Validates that a track is provided
|
||||
* - Prevents duplicate downloads of the same track
|
||||
* - Enriches track metadata via the API
|
||||
* - Downloads the audio blob with progress tracking
|
||||
* - Organizes the file into subfolders based on the folder template
|
||||
* - Optionally downloads and saves lyrics in LRC format
|
||||
* - Updates the local media folder cache if using LocalMedia download method
|
||||
* - Handles errors gracefully and updates download task status
|
||||
*/
|
||||
export async function downloadTrackWithMetadata(
|
||||
track,
|
||||
quality,
|
||||
api = MusicAPI.instance,
|
||||
lyricsManager = null,
|
||||
abortController = null
|
||||
) {
|
||||
if (!track) {
|
||||
alert('No track is currently playing');
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {LosslessAPI} */
|
||||
const tidalAPI = api.tidalAPI || api;
|
||||
|
||||
const downloadKey = `track-${track.id}`;
|
||||
if (ongoingDownloads.has(downloadKey)) {
|
||||
showNotification('This track is already being downloaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const { enrichedTrack } = await api.tidalAPI.enrichTrack(track, { downloadQuality: quality });
|
||||
const { enrichedTrack } = await tidalAPI.enrichTrack(track, { downloadQuality: quality });
|
||||
const filename = buildTrackFilename(enrichedTrack, quality);
|
||||
|
||||
const controller = abortController || new AbortController();
|
||||
|
|
@ -1089,7 +1120,7 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
|||
// If the target is the local media folder, do a cheap partial update:
|
||||
// pass the downloaded blob and base filename so only this one track's metadata
|
||||
// is read and inserted into localFilesCache instead of re-walking the whole folder.
|
||||
if (modernSettings.bulkDownloadMethod === 'local') {
|
||||
if (modernSettings.bulkDownloadMethod === BulkDownloadMethod.LocalMedia) {
|
||||
window.refreshLocalMediaFolder?.(blob, finalFilename);
|
||||
}
|
||||
|
||||
|
|
@ -1105,7 +1136,7 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
|||
}
|
||||
}
|
||||
|
||||
export async function downloadLikedTracks(tracks, api, quality, lyricsManager = null) {
|
||||
export async function downloadLikedTracks(tracks, api, quality, _lyricsManager = null) {
|
||||
const folderName = `Liked Tracks - ${new Date().toISOString().slice(0, 10)}`;
|
||||
await startBulkDownload({
|
||||
tracks,
|
||||
|
|
|
|||
|
|
@ -621,8 +621,9 @@ export class Equalizer {
|
|||
|
||||
this.frequencies.forEach((freq, index) => {
|
||||
const gain = this.currentGains[index] || 0;
|
||||
const q = this.filters[index] ? this.filters[index].Q.value : this._calculateQ(index);
|
||||
const filterNum = index + 1;
|
||||
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`);
|
||||
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`);
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
|
|
@ -680,15 +681,52 @@ export class Equalizer {
|
|||
this.setBandCount(newCount);
|
||||
}
|
||||
|
||||
// Extract gains from filters
|
||||
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
|
||||
// Apply imported filter frequencies directly instead of regenerating
|
||||
const sliced = filters.slice(0, this.bandCount);
|
||||
const newFreqs = sliced.map((f) => f.freq);
|
||||
this.frequencies = newFreqs;
|
||||
this.frequencyLabels = generateFrequencyLabels(newFreqs);
|
||||
|
||||
// Update filter frequencies on the actual biquad nodes
|
||||
if (this.filters.length === newFreqs.length) {
|
||||
newFreqs.forEach((freq, i) => {
|
||||
if (this.filters[i]) {
|
||||
this.filters[i].frequency.value = freq;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract and apply gains, types, and Qs
|
||||
const gains = sliced.map((f) => f.gain);
|
||||
this.setAllGains(gains);
|
||||
|
||||
// Store filter frequencies if different
|
||||
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
|
||||
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
|
||||
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
|
||||
// Apply filter types (PK/LS/HS -> peaking/lowshelf/highshelf)
|
||||
const typeMap = { PK: 'peaking', LS: 'lowshelf', HS: 'highshelf', LSC: 'lowshelf', HSC: 'highshelf' };
|
||||
const types = sliced.map((f) => typeMap[f.type] || 'peaking');
|
||||
this.currentTypes = types;
|
||||
if (this.filters.length === types.length) {
|
||||
types.forEach((type, i) => {
|
||||
if (this.filters[i]) this.filters[i].type = type;
|
||||
});
|
||||
}
|
||||
equalizerSettings.setBandTypes(types);
|
||||
|
||||
// Apply Q values
|
||||
const qs = sliced.map((f) => f.q);
|
||||
this.currentQs = qs;
|
||||
if (this.filters.length === qs.length) {
|
||||
qs.forEach((q, i) => {
|
||||
if (this.filters[i]) this.filters[i].Q.value = q;
|
||||
});
|
||||
}
|
||||
equalizerSettings.setBandQs(qs);
|
||||
|
||||
// Persist custom frequencies and update freqRange
|
||||
equalizerSettings.setCustomFrequencies(newFreqs);
|
||||
const minFreq = Math.min(...newFreqs);
|
||||
const maxFreq = Math.max(...newFreqs);
|
||||
this.freqRange = { min: minFreq, max: maxFreq };
|
||||
equalizerSettings.setFreqRange(minFreq, maxFreq);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
|
|
|||
382
js/events.js
382
js/events.js
|
|
@ -56,6 +56,9 @@ import {
|
|||
} from './analytics.js';
|
||||
import { SVG_BIN, SVG_MUTE, SVG_PAUSE, SVG_PLAY, SVG_VOLUME, SVG_CHECKBOX, SVG_CHECKBOX_CHECKED } from './icons.js';
|
||||
import { partyManager } from './listening-party.js';
|
||||
import { MusicAPI } from './music-api.js';
|
||||
import { LyricsManager } from './lyrics.js';
|
||||
import { Player } from './player.js';
|
||||
|
||||
let currentTrackIdForWaveform = null;
|
||||
|
||||
|
|
@ -73,26 +76,26 @@ const LONG_PRESS_DURATION = 500;
|
|||
function handleTrackTouchStart(e) {
|
||||
if (!('ontouchstart' in window)) return;
|
||||
const trackItem = e.target.closest('.track-item');
|
||||
if (!trackItem || trackItem.classList.contains('unavailable') || trackItem.classList.contains('blocked')) return;
|
||||
if (!trackItem || trackItem.classList.contains('unavailable')) return;
|
||||
|
||||
isLongPress = false;
|
||||
longPressTrackItem = trackItem;
|
||||
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = setTimeout(async () => {
|
||||
isLongPress = true;
|
||||
toggleTrackSelection(trackItem, true, false);
|
||||
hapticLongPress();
|
||||
await hapticLongPress();
|
||||
}, LONG_PRESS_DURATION);
|
||||
}
|
||||
|
||||
function handleTrackTouchMove(e) {
|
||||
function handleTrackTouchMove(_e) {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrackTouchEnd(e) {
|
||||
function handleTrackTouchEnd(_e) {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
|
|
@ -204,7 +207,7 @@ function toggleTrackSelection(trackItem, ctrlHeld, shiftHeld) {
|
|||
document.body.classList.toggle('multi-select-mode', trackSelection.isSelecting);
|
||||
}
|
||||
|
||||
function showMultiSelectPlaylistModal(tracks) {
|
||||
async function showMultiSelectPlaylistModal(tracks) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.style.cssText =
|
||||
|
|
@ -237,7 +240,7 @@ function showMultiSelectPlaylistModal(tracks) {
|
|||
document.body.appendChild(modal);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
db.getPlaylists(true).then((playlists) => {
|
||||
await db.getPlaylists(true).then((playlists) => {
|
||||
const listEl = modal.querySelector('.playlist-list');
|
||||
if (playlists.length === 0) {
|
||||
listEl.innerHTML = '<div style="padding: 12px; color: var(--muted-foreground);">No playlists yet</div>';
|
||||
|
|
@ -260,17 +263,17 @@ function showMultiSelectPlaylistModal(tracks) {
|
|||
for (const track of tracks) {
|
||||
await db.addTrackToPlaylist(playlistId, track);
|
||||
}
|
||||
syncManager.syncUserPlaylist(await db.getPlaylist(playlistId), 'update');
|
||||
await syncManager.syncUserPlaylist(await db.getPlaylist(playlistId), 'update');
|
||||
showNotification(`Added ${tracks.length} tracks to playlist`);
|
||||
closeModal();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
modal.querySelector('.create-new-playlist').addEventListener('click', () => {
|
||||
modal.querySelector('.create-new-playlist').addEventListener('click', async () => {
|
||||
const name = prompt('Playlist name:');
|
||||
if (name) {
|
||||
db.createPlaylist(name, tracks).then((playlist) => {
|
||||
await db.createPlaylist(name, tracks).then((_playlist) => {
|
||||
showNotification(`Created playlist "${name}" with ${tracks.length} tracks`);
|
||||
closeModal();
|
||||
});
|
||||
|
|
@ -278,127 +281,132 @@ function showMultiSelectPlaylistModal(tracks) {
|
|||
});
|
||||
}
|
||||
|
||||
export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||
const playPauseBtn = document.querySelector('.now-playing-bar .play-pause-btn');
|
||||
const nextBtn = document.getElementById('next-btn');
|
||||
const prevBtn = document.getElementById('prev-btn');
|
||||
const shuffleBtn = document.getElementById('shuffle-btn');
|
||||
const repeatBtn = document.getElementById('repeat-btn');
|
||||
const homeStartRadioBtn = document.getElementById('home-start-infinite-radio-btn');
|
||||
const sleepTimerBtnDesktop = document.getElementById('sleep-timer-btn-desktop');
|
||||
const playPauseBtn = document.querySelector('.now-playing-bar .play-pause-btn');
|
||||
const nextBtn = document.getElementById('next-btn');
|
||||
const prevBtn = document.getElementById('prev-btn');
|
||||
const shuffleBtn = document.getElementById('shuffle-btn');
|
||||
const repeatBtn = document.getElementById('repeat-btn');
|
||||
const homeStartRadioBtn = document.getElementById('home-start-infinite-radio-btn');
|
||||
const sleepTimerBtnDesktop = document.getElementById('sleep-timer-btn-desktop');
|
||||
|
||||
const volumeBar = document.getElementById('volume-bar');
|
||||
const volumeFill = document.getElementById('volume-fill');
|
||||
const volumeBtn = document.getElementById('volume-btn');
|
||||
const _volumeBar = document.getElementById('volume-bar');
|
||||
const volumeFill = document.getElementById('volume-fill');
|
||||
const volumeBtn = document.getElementById('volume-btn');
|
||||
|
||||
const updateVolumeUI = () => {
|
||||
const activeEl = player.activeElement;
|
||||
const { muted } = activeEl;
|
||||
const volume = player.userVolume;
|
||||
volumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE(20) : SVG_VOLUME(20);
|
||||
const effectiveVolume = muted ? 0 : volume * 100;
|
||||
volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`);
|
||||
volumeFill.style.width = `${effectiveVolume}%`;
|
||||
};
|
||||
const updateVolumeUI = () => {
|
||||
const activeEl = Player.instance.activeElement;
|
||||
const { muted } = activeEl;
|
||||
const volume = Player.instance.userVolume;
|
||||
volumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE(20) : SVG_VOLUME(20);
|
||||
const effectiveVolume = muted ? 0 : volume * 100;
|
||||
volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`);
|
||||
volumeFill.style.width = `${effectiveVolume}%`;
|
||||
};
|
||||
|
||||
function clearSelection() {
|
||||
trackSelection.selectedIds.clear();
|
||||
trackSelection.lastClickedId = null;
|
||||
trackSelection.isSelecting = false;
|
||||
document.body.classList.remove('multi-select-mode');
|
||||
document.querySelectorAll('.track-item.selected').forEach((el) => {
|
||||
el.classList.remove('selected');
|
||||
});
|
||||
document.querySelectorAll('.track-checkbox').forEach((checkbox) => {
|
||||
checkbox.innerHTML = SVG_CHECKBOX(18);
|
||||
checkbox.classList.remove('checked');
|
||||
});
|
||||
updateSelectionBar();
|
||||
}
|
||||
function clearSelection() {
|
||||
trackSelection.selectedIds.clear();
|
||||
trackSelection.lastClickedId = null;
|
||||
trackSelection.isSelecting = false;
|
||||
document.body.classList.remove('multi-select-mode');
|
||||
document.querySelectorAll('.track-item.selected').forEach((el) => {
|
||||
el.classList.remove('selected');
|
||||
});
|
||||
document.querySelectorAll('.track-checkbox').forEach((checkbox) => {
|
||||
checkbox.innerHTML = SVG_CHECKBOX(18);
|
||||
checkbox.classList.remove('checked');
|
||||
});
|
||||
updateSelectionBar();
|
||||
}
|
||||
|
||||
function updateSelectionBar() {
|
||||
let bar = document.getElementById('selection-bar');
|
||||
if (!bar) {
|
||||
bar = document.createElement('div');
|
||||
bar.id = 'selection-bar';
|
||||
bar.className = 'selection-bar';
|
||||
bar.innerHTML = `
|
||||
<span class="selection-count">0 selected</span>
|
||||
<div class="selection-actions">
|
||||
<button data-action="play-selected">Play</button>
|
||||
<button data-action="add-to-queue-selected">Add to queue</button>
|
||||
<button data-action="add-to-playlist-selected">Add to playlist</button>
|
||||
<button data-action="download-selected">Download</button>
|
||||
<button data-action="like-selected">Like</button>
|
||||
</div>
|
||||
<button data-action="clear-selection" style="margin-left: 8px;">Clear</button>
|
||||
function updateSelectionBar() {
|
||||
let bar = document.getElementById('selection-bar');
|
||||
if (!bar) {
|
||||
bar = document.createElement('div');
|
||||
bar.id = 'selection-bar';
|
||||
bar.className = 'selection-bar';
|
||||
bar.innerHTML = `
|
||||
<span class="selection-count">0 selected</span>
|
||||
<div class="selection-actions">
|
||||
<button data-action="play-selected">Play</button>
|
||||
<button data-action="add-to-queue-selected">Add to queue</button>
|
||||
<button data-action="add-to-playlist-selected">Add to playlist</button>
|
||||
<button data-action="download-selected">Download</button>
|
||||
<button data-action="like-selected">Like</button>
|
||||
</div>
|
||||
<button data-action="clear-selection" style="margin-left: 8px;">Clear</button>
|
||||
`;
|
||||
document.body.appendChild(bar);
|
||||
document.body.appendChild(bar);
|
||||
|
||||
bar.querySelectorAll('button').forEach((btn) => {
|
||||
btn.addEventListener('click', () => handleSelectionAction(btn.dataset.action));
|
||||
});
|
||||
}
|
||||
|
||||
const count = trackSelection.selectedIds.size;
|
||||
bar.querySelector('.selection-count').textContent = `${count} selected`;
|
||||
bar.classList.toggle('visible', count > 0);
|
||||
}
|
||||
|
||||
function handleSelectionAction(action) {
|
||||
const selectedIds = getSelectedTracks();
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
const mainContent = document.getElementById('main-content');
|
||||
const selectedTracks = [];
|
||||
mainContent.querySelectorAll('.track-item').forEach((item) => {
|
||||
if (trackSelection.selectedIds.has(item.dataset.trackId)) {
|
||||
const track = trackDataStore.get(item);
|
||||
if (track) selectedTracks.push(track);
|
||||
}
|
||||
bar.querySelectorAll('button').forEach((btn) => {
|
||||
btn.addEventListener('click', () => handleSelectionAction(btn.dataset.action));
|
||||
});
|
||||
|
||||
switch (action) {
|
||||
case 'play-selected':
|
||||
if (selectedTracks.length > 0) {
|
||||
player.setQueue(selectedTracks, 0);
|
||||
document.getElementById('shuffle-btn').classList.remove('active');
|
||||
player.playTrackFromQueue();
|
||||
}
|
||||
break;
|
||||
case 'add-to-queue-selected':
|
||||
if (selectedTracks.length > 0) {
|
||||
player.addToQueue(selectedTracks);
|
||||
if (window.renderQueueFunction) window.renderQueueFunction();
|
||||
showNotification(`Added ${selectedTracks.length} tracks to queue`);
|
||||
}
|
||||
break;
|
||||
case 'add-to-playlist-selected':
|
||||
if (selectedTracks.length > 0) {
|
||||
showMultiSelectPlaylistModal(selectedTracks);
|
||||
}
|
||||
break;
|
||||
case 'download-selected':
|
||||
if (selectedTracks.length > 0) {
|
||||
selectedTracks.forEach((track) => {
|
||||
downloadTrackWithMetadata(track, downloadQualitySettings.getQuality(), api, lyricsManager);
|
||||
});
|
||||
showNotification(`Downloading ${selectedTracks.length} tracks`);
|
||||
}
|
||||
break;
|
||||
case 'like-selected':
|
||||
selectedTracks.forEach(async (track) => {
|
||||
const added = await db.toggleFavorite('track', track);
|
||||
syncManager.syncLibraryItem('track', track, added);
|
||||
});
|
||||
showNotification(`Liked ${selectedTracks.length} tracks`);
|
||||
break;
|
||||
case 'clear-selection':
|
||||
clearSelection();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const count = trackSelection.selectedIds.size;
|
||||
bar.querySelector('.selection-count').textContent = `${count} selected`;
|
||||
bar.classList.toggle('visible', count > 0);
|
||||
}
|
||||
|
||||
async function handleSelectionAction(action) {
|
||||
const selectedIds = getSelectedTracks();
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
const mainContent = document.getElementById('main-content');
|
||||
const selectedTracks = [];
|
||||
mainContent.querySelectorAll('.track-item').forEach((item) => {
|
||||
if (trackSelection.selectedIds.has(item.dataset.trackId)) {
|
||||
const track = trackDataStore.get(item);
|
||||
if (track) selectedTracks.push(track);
|
||||
}
|
||||
});
|
||||
|
||||
switch (action) {
|
||||
case 'play-selected':
|
||||
if (selectedTracks.length > 0) {
|
||||
Player.instance.setQueue(selectedTracks, 0);
|
||||
document.getElementById('shuffle-btn').classList.remove('active');
|
||||
Player.instance.playTrackFromQueue();
|
||||
}
|
||||
break;
|
||||
case 'add-to-queue-selected':
|
||||
if (selectedTracks.length > 0) {
|
||||
Player.instance.addToQueue(selectedTracks);
|
||||
if (window.renderQueueFunction) await window.renderQueueFunction();
|
||||
showNotification(`Added ${selectedTracks.length} tracks to queue`);
|
||||
}
|
||||
break;
|
||||
case 'add-to-playlist-selected':
|
||||
if (selectedTracks.length > 0) {
|
||||
await showMultiSelectPlaylistModal(selectedTracks);
|
||||
}
|
||||
break;
|
||||
case 'download-selected':
|
||||
if (selectedTracks.length > 0) {
|
||||
showNotification(`Downloading ${selectedTracks.length} tracks`);
|
||||
for (const track of selectedTracks) {
|
||||
await downloadTrackWithMetadata(
|
||||
track,
|
||||
downloadQualitySettings.getQuality(),
|
||||
MusicAPI.instance.tidalAPI,
|
||||
LyricsManager.instance
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'like-selected':
|
||||
for (const track of selectedTracks) {
|
||||
const added = await db.toggleFavorite('track', track);
|
||||
await syncManager.syncLibraryItem('track', track, added);
|
||||
}
|
||||
showNotification(`Liked ${selectedTracks.length} tracks`);
|
||||
break;
|
||||
case 'clear-selection':
|
||||
clearSelection();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||
if (homeStartRadioBtn) {
|
||||
homeStartRadioBtn.addEventListener('click', async () => {
|
||||
await player.enableRadio();
|
||||
|
|
@ -417,14 +425,14 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
}
|
||||
});
|
||||
|
||||
element.addEventListener('play', () => {
|
||||
element.addEventListener('play', async () => {
|
||||
if (player.activeElement !== element) return;
|
||||
|
||||
// Initialize audio context manager for EQ (only once)
|
||||
if (!audioContextManager.isReady()) {
|
||||
audioContextManager.init(element);
|
||||
}
|
||||
audioContextManager.resume();
|
||||
await audioContextManager.resume();
|
||||
|
||||
if (player.currentTrack) {
|
||||
// Track play event
|
||||
|
|
@ -435,7 +443,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
scrobbler.updateNowPlaying(player.currentTrack);
|
||||
}
|
||||
|
||||
updateWaveform();
|
||||
await updateWaveform();
|
||||
}
|
||||
|
||||
playPauseBtn.innerHTML = SVG_PAUSE(20);
|
||||
|
|
@ -479,7 +487,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) {
|
||||
historyLoggedTrackId = player.currentTrack.id;
|
||||
const historyEntry = await db.addToHistory(player.currentTrack);
|
||||
syncManager.syncHistoryItem(historyEntry);
|
||||
await syncManager.syncHistoryItem(historyEntry);
|
||||
|
||||
if (window.location.hash === '#recent') {
|
||||
ui.renderRecentPage();
|
||||
|
|
@ -554,31 +562,31 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
setupMediaListeners(player.video);
|
||||
}
|
||||
|
||||
playPauseBtn.addEventListener('click', () => {
|
||||
hapticMedium();
|
||||
playPauseBtn.addEventListener('click', async () => {
|
||||
await hapticMedium();
|
||||
player.handlePlayPause();
|
||||
});
|
||||
nextBtn.addEventListener('click', () => {
|
||||
hapticMedium();
|
||||
nextBtn.addEventListener('click', async () => {
|
||||
await hapticMedium();
|
||||
trackSkipTrack(player.currentTrack, 'next');
|
||||
player.playNext();
|
||||
});
|
||||
prevBtn.addEventListener('click', () => {
|
||||
hapticMedium();
|
||||
prevBtn.addEventListener('click', async () => {
|
||||
await hapticMedium();
|
||||
trackSkipTrack(player.currentTrack, 'previous');
|
||||
player.playPrev();
|
||||
});
|
||||
|
||||
shuffleBtn.addEventListener('click', () => {
|
||||
hapticLight();
|
||||
shuffleBtn.addEventListener('click', async () => {
|
||||
await hapticLight();
|
||||
player.toggleShuffle();
|
||||
trackToggleShuffle(player.shuffleActive);
|
||||
shuffleBtn.classList.toggle('active', player.shuffleActive);
|
||||
if (window.renderQueueFunction) window.renderQueueFunction();
|
||||
if (window.renderQueueFunction) await window.renderQueueFunction();
|
||||
});
|
||||
|
||||
repeatBtn.addEventListener('click', () => {
|
||||
hapticLight();
|
||||
repeatBtn.addEventListener('click', async () => {
|
||||
await hapticLight();
|
||||
const mode = player.toggleRepeat();
|
||||
trackToggleRepeat(mode === REPEAT_MODE.OFF ? 'off' : mode === REPEAT_MODE.ALL ? 'all' : 'one');
|
||||
repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF);
|
||||
|
|
@ -655,7 +663,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
progressBar.style.maskImage = '';
|
||||
|
||||
try {
|
||||
const streamUrl = await player.api.getStreamUrl(player.currentTrack.id, 'LOW');
|
||||
const { url: streamUrl } = await player.api.getStreamUrl(player.currentTrack.id, 'LOW');
|
||||
const waveformData = await waveformGenerator.getWaveform(streamUrl, player.currentTrack.id);
|
||||
|
||||
if (waveformData && currentTrackIdForWaveform === player.currentTrack.id) {
|
||||
|
|
@ -709,7 +717,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
}
|
||||
};
|
||||
|
||||
window.addEventListener('waveform-toggle', (e) => {
|
||||
window.addEventListener('waveform-toggle', async (e) => {
|
||||
if (!e.detail.enabled) {
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const playerControls = document.querySelector('.player-controls');
|
||||
|
|
@ -722,7 +730,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
playerControls.classList.remove('waveform-loaded');
|
||||
}
|
||||
}
|
||||
updateWaveform();
|
||||
await updateWaveform();
|
||||
});
|
||||
|
||||
if (volumeBtn) {
|
||||
|
|
@ -1102,7 +1110,7 @@ export async function showAddToPlaylistModal(track) {
|
|||
e.stopPropagation();
|
||||
await db.removeTrackFromPlaylist(playlistId, track.id);
|
||||
const updatedPlaylist = await db.getPlaylist(playlistId);
|
||||
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
showNotification(`Removed from playlist: ${option.querySelector('span').textContent}`);
|
||||
await renderModal();
|
||||
} else {
|
||||
|
|
@ -1110,7 +1118,7 @@ export async function showAddToPlaylistModal(track) {
|
|||
|
||||
await db.addTrackToPlaylist(playlistId, track);
|
||||
const updatedPlaylist = await db.getPlaylist(playlistId);
|
||||
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
showNotification(`Added to playlist: ${option.querySelector('span').textContent}`);
|
||||
closeModal();
|
||||
}
|
||||
|
|
@ -1272,14 +1280,14 @@ export async function handleTrackAction(
|
|||
|
||||
if (action === 'add-to-queue') {
|
||||
player.addToQueue(tracks);
|
||||
if (window.renderQueueFunction) window.renderQueueFunction();
|
||||
if (window.renderQueueFunction) await window.renderQueueFunction();
|
||||
showNotification(`Added ${tracks.length} tracks to queue`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'play-next') {
|
||||
player.addNextToQueue(tracks);
|
||||
if (window.renderQueueFunction) window.renderQueueFunction();
|
||||
if (window.renderQueueFunction) await window.renderQueueFunction();
|
||||
showNotification(`Playing next: ${tracks.length} tracks`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -1336,7 +1344,8 @@ export async function handleTrackAction(
|
|||
// Individual Track Actions
|
||||
// Check if track/artist is blocked
|
||||
const { contentBlockingSettings } = await import('./storage.js');
|
||||
if (type === 'track' && contentBlockingSettings.shouldHideTrack(item)) {
|
||||
const BLOCKED_PLAY_ACTIONS = new Set(['play-card', 'add-to-queue', 'play-next', 'start-mix']);
|
||||
if (type === 'track' && BLOCKED_PLAY_ACTIONS.has(action) && contentBlockingSettings.shouldHideTrack(item)) {
|
||||
showNotification('This track is blocked');
|
||||
return;
|
||||
}
|
||||
|
|
@ -1344,12 +1353,12 @@ export async function handleTrackAction(
|
|||
if (action === 'add-to-queue') {
|
||||
trackAddToQueue(item, 'end');
|
||||
player.addToQueue(item);
|
||||
if (window.renderQueueFunction) window.renderQueueFunction();
|
||||
if (window.renderQueueFunction) await window.renderQueueFunction();
|
||||
showNotification(`Added to queue: ${item.title}`);
|
||||
} else if (action === 'play-next') {
|
||||
trackPlayNext(item);
|
||||
player.addNextToQueue(item);
|
||||
if (window.renderQueueFunction) window.renderQueueFunction();
|
||||
if (window.renderQueueFunction) await window.renderQueueFunction();
|
||||
showNotification(`Playing next: ${item.title}`);
|
||||
} else if (action === 'play-card') {
|
||||
player.setQueue([item], 0);
|
||||
|
|
@ -1367,7 +1376,7 @@ export async function handleTrackAction(
|
|||
await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager);
|
||||
} else if (action === 'toggle-like') {
|
||||
const added = await db.toggleFavorite(type, item);
|
||||
syncManager.syncLibraryItem(type, item, added);
|
||||
await syncManager.syncLibraryItem(type, item, added);
|
||||
|
||||
// Track like/unlike
|
||||
if (added) {
|
||||
|
|
@ -1623,7 +1632,7 @@ export async function handleTrackAction(
|
|||
e.stopPropagation();
|
||||
await db.removeTrackFromPlaylist(playlistId, item.id);
|
||||
const updatedPlaylist = await db.getPlaylist(playlistId);
|
||||
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
showNotification(`Removed from playlist: ${option.querySelector('span').textContent}`);
|
||||
await renderModal();
|
||||
} else {
|
||||
|
|
@ -1631,7 +1640,7 @@ export async function handleTrackAction(
|
|||
|
||||
await db.addTrackToPlaylist(playlistId, item);
|
||||
const updatedPlaylist = await db.getPlaylist(playlistId);
|
||||
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
showNotification(`Added to playlist: ${option.querySelector('span').textContent}`);
|
||||
closeModal();
|
||||
}
|
||||
|
|
@ -1669,9 +1678,12 @@ export async function handleTrackAction(
|
|||
const url = getShareUrl(storedHref ? storedHref : `/${typeForUrl}/${item.id || item.uuid}`);
|
||||
|
||||
trackCopyLink(type, item.id || item.uuid);
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
showNotification('Link copied to clipboard!');
|
||||
});
|
||||
await navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
showNotification('Link copied to clipboard!');
|
||||
})
|
||||
.catch(console.error);
|
||||
} else if (action === 'open-in-new-tab') {
|
||||
// Use stored href from card if available, otherwise construct URL
|
||||
const contextMenu = document.getElementById('context-menu');
|
||||
|
|
@ -2165,7 +2177,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
}
|
||||
|
||||
const trackItem = e.target.closest('.track-item');
|
||||
if (trackItem && (trackItem.classList.contains('unavailable') || trackItem.classList.contains('blocked'))) {
|
||||
if (trackItem && trackItem.classList.contains('unavailable')) {
|
||||
return;
|
||||
}
|
||||
if (isLongPress && longPressTrackItem === trackItem) {
|
||||
|
|
@ -2173,6 +2185,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
}
|
||||
if (
|
||||
trackItem &&
|
||||
!trackItem.classList.contains('blocked') &&
|
||||
!trackItem.dataset.queueIndex &&
|
||||
!e.target.closest('.remove-from-playlist-btn') &&
|
||||
!e.target.closest('.artist-link') &&
|
||||
|
|
@ -2256,17 +2269,13 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
|
||||
const card = e.target.closest('.card');
|
||||
if (card) {
|
||||
// Don't navigate if card is blocked (unless clicking menu button)
|
||||
if (card.classList.contains('blocked') && !e.target.closest('.card-menu-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target.closest('.edit-playlist-btn') || e.target.closest('.delete-playlist-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const libraryTracksContainer = card.closest('#library-tracks-container');
|
||||
if (libraryTracksContainer && card.dataset.trackId) {
|
||||
if (card.classList.contains('blocked')) return;
|
||||
if (
|
||||
e.target.closest('.like-btn') ||
|
||||
e.target.closest('.card-play-btn') ||
|
||||
|
|
@ -2382,7 +2391,39 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
document.querySelector('.now-playing-bar')?.addEventListener('contextmenu', async (e) => {
|
||||
if (!player.currentTrack) return;
|
||||
const track = player.currentTrack;
|
||||
if (track.isLocal) return;
|
||||
|
||||
const target = e.target.closest('.cover, .title, .album, .artist');
|
||||
if (!target) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (contextMenu._originalHTML) {
|
||||
contextMenu.innerHTML = contextMenu._originalHTML;
|
||||
contextMenu._originalHTML = null;
|
||||
}
|
||||
|
||||
contextTrack = track;
|
||||
contextMenu._contextTrack = track;
|
||||
contextMenu._contextType = track.type || 'track';
|
||||
contextMenu._selectedTracks = [];
|
||||
|
||||
const unavailableActions = ['play-next', 'add-to-queue', 'download', 'track-mix'];
|
||||
contextMenu.querySelectorAll('[data-action]').forEach((btn) => {
|
||||
if (unavailableActions.includes(btn.dataset.action)) {
|
||||
btn.style.display = track.isUnavailable ? 'none' : 'block';
|
||||
}
|
||||
});
|
||||
|
||||
await updateContextMenuLikeState(contextMenu, track);
|
||||
positionMenu(contextMenu, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
document.addEventListener('click', async (e) => {
|
||||
if (contextMenu.style.display === 'block') {
|
||||
if (contextMenu._originalHTML) {
|
||||
contextMenu.innerHTML = contextMenu._originalHTML;
|
||||
|
|
@ -2402,7 +2443,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
document.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'Escape' && trackSelection.isSelecting) {
|
||||
clearSelection();
|
||||
}
|
||||
|
|
@ -2458,34 +2499,39 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
trackPlayNext(t);
|
||||
player.addNextToQueue(t);
|
||||
});
|
||||
if (window.renderQueueFunction) window.renderQueueFunction();
|
||||
if (window.renderQueueFunction) await window.renderQueueFunction();
|
||||
showNotification(`Playing next: ${selectedTracks.length} tracks`);
|
||||
clearSelection();
|
||||
break;
|
||||
case 'add-to-queue':
|
||||
player.addToQueue(selectedTracks);
|
||||
if (window.renderQueueFunction) window.renderQueueFunction();
|
||||
if (window.renderQueueFunction) await window.renderQueueFunction();
|
||||
showNotification(`Added ${selectedTracks.length} tracks to queue`);
|
||||
clearSelection();
|
||||
break;
|
||||
case 'toggle-like':
|
||||
selectedTracks.forEach(async (t) => {
|
||||
const added = await db.toggleFavorite('track', t);
|
||||
syncManager.syncLibraryItem('track', t, added);
|
||||
await syncManager.syncLibraryItem('track', t, added);
|
||||
});
|
||||
showNotification(`Liked ${selectedTracks.length} tracks`);
|
||||
clearSelection();
|
||||
break;
|
||||
case 'add-to-playlist':
|
||||
showMultiSelectPlaylistModal(selectedTracks);
|
||||
await showMultiSelectPlaylistModal(selectedTracks);
|
||||
clearSelection();
|
||||
break;
|
||||
case 'download':
|
||||
selectedTracks.forEach((t) => {
|
||||
downloadTrackWithMetadata(t, downloadQualitySettings.getQuality(), api, lyricsManager);
|
||||
});
|
||||
showNotification(`Downloading ${selectedTracks.length} tracks`);
|
||||
clearSelection();
|
||||
for (const track of selectedTracks) {
|
||||
await downloadTrackWithMetadata(
|
||||
track,
|
||||
downloadQualitySettings.getQuality(),
|
||||
api,
|
||||
lyricsManager
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
clearSelection();
|
||||
|
|
|
|||
14
js/ffmpeg.js
14
js/ffmpeg.js
|
|
@ -49,7 +49,8 @@ async function ffmpegWorker(
|
|||
onProgress = null,
|
||||
signal = null,
|
||||
extraFiles = [],
|
||||
logConsole = true
|
||||
logConsole = true,
|
||||
rawArgs = false
|
||||
) {
|
||||
const audioData = audioBlob ? await audioBlob.arrayBuffer() : null;
|
||||
const assets = loadFfmpeg();
|
||||
|
|
@ -113,7 +114,7 @@ async function ffmpegWorker(
|
|||
reject(new FfmpegError('Worker failed: ' + error.message));
|
||||
};
|
||||
|
||||
(async () => {
|
||||
void (async () => {
|
||||
const transferables = [];
|
||||
if (audioData) transferables.push(audioData);
|
||||
for (const f of extraFiles) {
|
||||
|
|
@ -128,7 +129,7 @@ async function ffmpegWorker(
|
|||
{
|
||||
audioData,
|
||||
extraFiles,
|
||||
args,
|
||||
...(rawArgs ? { rawArgs: args } : { args }),
|
||||
output: {
|
||||
name: outputName,
|
||||
mime: outputMime,
|
||||
|
|
@ -153,6 +154,7 @@ async function ffmpegWorker(
|
|||
* @param {AbortSignal|null} [opts.signal=null] - Optional abort signal to cancel encoding
|
||||
* @param {Array} [opts.extraFiles=[]] - Additional files to provide to FFmpeg
|
||||
* @param {Boolean} [opts.logConsole=true] - Whether to log FFmpeg output to the console
|
||||
* @param {string[]} [opts.rawArgs=[]] - Whether to pass args as raw command line (without default input/output)
|
||||
* @returns {Promise<Blob>} Encoded audio blob
|
||||
* @throws {FfmpegError} If Web Workers are not available
|
||||
* @throws {Error} If FFmpeg encoding fails
|
||||
|
|
@ -167,6 +169,7 @@ export async function ffmpeg(
|
|||
signal = null,
|
||||
extraFiles = [],
|
||||
logConsole = true,
|
||||
rawArgs = null,
|
||||
} = {}
|
||||
) {
|
||||
try {
|
||||
|
|
@ -174,13 +177,14 @@ export async function ffmpeg(
|
|||
if (typeof Worker !== 'undefined') {
|
||||
return await ffmpegWorker(
|
||||
audioBlob,
|
||||
args,
|
||||
rawArgs || args,
|
||||
outputName,
|
||||
outputMime,
|
||||
onProgress,
|
||||
signal,
|
||||
extraFiles,
|
||||
logConsole
|
||||
logConsole,
|
||||
!!rawArgs
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
19
js/ffmpeg.test.ts
Normal file
19
js/ffmpeg.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { expect, test } from 'vitest';
|
||||
import { ffmpeg } from './ffmpeg';
|
||||
|
||||
test('Run `ffmpeg --help`', async () => {
|
||||
const lines: string[] = [];
|
||||
await ffmpeg(null, {
|
||||
rawArgs: ['--help'],
|
||||
logConsole: false,
|
||||
outputName: null,
|
||||
onProgress: (progress) => {
|
||||
if (progress.stage == 'stdout') {
|
||||
lines.push(progress.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
expect(lines).length.greaterThan(0);
|
||||
expect(lines[0]).matches(/ffmpeg version/i);
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { FFmpeg } from '!/@ffmpeg/ffmpeg/dist/esm/classes.js';
|
||||
|
||||
let ffmpeg = null;
|
||||
let loadingPromise = null;
|
||||
|
|
@ -99,6 +99,7 @@ self.onmessage = async (e) => {
|
|||
const {
|
||||
audioData,
|
||||
extraFiles = [],
|
||||
rawArgs,
|
||||
args = [],
|
||||
output = {
|
||||
name: 'output',
|
||||
|
|
@ -123,7 +124,7 @@ self.onmessage = async (e) => {
|
|||
await ffmpeg.writeFile(file.name, new Uint8Array(file.data));
|
||||
}
|
||||
|
||||
const ffmpegArgs = ['-i', 'input', ...args, ...(output.name ? [output.name] : [])];
|
||||
const ffmpegArgs = rawArgs || ['-i', 'input', ...args, ...(output.name ? [output.name] : [])];
|
||||
self.postMessage({ type: 'command', command: ffmpegArgs });
|
||||
|
||||
const exitCode = await ffmpeg.exec(ffmpegArgs);
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ if (import.meta.env.DEV) {
|
|||
export const containerFormats: Record<string, ContainerFormat> = {
|
||||
flac: {
|
||||
displayName: 'FLAC',
|
||||
ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'],
|
||||
ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'copy'],
|
||||
outputFilename: 'output.flac',
|
||||
outputMime: 'audio/flac',
|
||||
extension: 'flac',
|
||||
|
|
@ -183,6 +183,11 @@ export function getContainerFormat(internalName: string): ContainerFormat | unde
|
|||
return containerFormats[internalName];
|
||||
}
|
||||
|
||||
export interface ExtraFile {
|
||||
name: string;
|
||||
data: ArrayBuffer | Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcodes an audio blob using the specified custom format via ffmpeg.
|
||||
* Throws if ffmpeg fails during transcoding.
|
||||
|
|
@ -192,7 +197,7 @@ export async function transcodeWithCustomFormat(
|
|||
format: CustomFormat,
|
||||
onProgress: ((progress: ProgressEvent) => void) | null = null,
|
||||
signal: AbortSignal | null = null,
|
||||
extraFiles: any[] = []
|
||||
extraFiles: ExtraFile[] = []
|
||||
): Promise<Blob> {
|
||||
return ffmpeg(audioBlob, {
|
||||
args: format.ffmpegArgs,
|
||||
|
|
@ -213,7 +218,7 @@ export async function transcodeWithContainerFormat(
|
|||
format: ContainerFormat,
|
||||
onProgress: ((progress: ProgressEvent) => void) | null = null,
|
||||
signal: AbortSignal | null = null,
|
||||
extraFiles: any[] = []
|
||||
extraFiles: ExtraFile[] = []
|
||||
): Promise<Blob> {
|
||||
return ffmpeg(audioBlob, {
|
||||
args: format.ffmpegArgs,
|
||||
|
|
|
|||
8
js/global.d.ts
vendored
8
js/global.d.ts
vendored
|
|
@ -27,3 +27,11 @@ declare module 'https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm' {
|
|||
/** Creates a ZIP stream from an async iterable of file entries. */
|
||||
export function downloadZip(files: AsyncIterable<object>): Response;
|
||||
}
|
||||
|
||||
type WithRequiredKeys<T> = {
|
||||
[K in keyof T]-?: T[K] | undefined;
|
||||
};
|
||||
|
||||
declare global {
|
||||
const __COMMIT_HASH__: string | undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ let _Haptics = null;
|
|||
let _ImpactStyle = null;
|
||||
let _NotificationStyle = null;
|
||||
|
||||
// Single stored promise — subsequent calls reuse the same one
|
||||
// Single stored promise - subsequent calls reuse the same one
|
||||
const _ready = import('@capacitor/haptics')
|
||||
.then((mod) => {
|
||||
_Haptics = mod.Haptics;
|
||||
|
|
@ -13,14 +13,14 @@ const _ready = import('@capacitor/haptics')
|
|||
_NotificationStyle = mod.NotificationStyle;
|
||||
})
|
||||
.catch(() => {
|
||||
// Not in Capacitor or haptics not available — fall back to navigator.vibrate
|
||||
// Not in Capacitor or haptics not available - fall back to navigator.vibrate
|
||||
});
|
||||
|
||||
function vibrateFallback(ms) {
|
||||
if (navigator.vibrate) navigator.vibrate(ms);
|
||||
}
|
||||
|
||||
/** Light tap — for toggles, menu opens */
|
||||
/** Light tap - for toggles, menu opens */
|
||||
export async function hapticLight() {
|
||||
await _ready;
|
||||
try {
|
||||
|
|
@ -32,7 +32,7 @@ export async function hapticLight() {
|
|||
vibrateFallback(30);
|
||||
}
|
||||
|
||||
/** Medium impact — for play/pause, skip */
|
||||
/** Medium impact - for play/pause, skip */
|
||||
export async function hapticMedium() {
|
||||
await _ready;
|
||||
try {
|
||||
|
|
@ -44,7 +44,7 @@ export async function hapticMedium() {
|
|||
vibrateFallback(50);
|
||||
}
|
||||
|
||||
/** Success notification — for like/unlike, add to queue */
|
||||
/** Success notification - for like/unlike, add to queue */
|
||||
export async function hapticSuccess() {
|
||||
await _ready;
|
||||
try {
|
||||
|
|
@ -56,7 +56,7 @@ export async function hapticSuccess() {
|
|||
vibrateFallback(40);
|
||||
}
|
||||
|
||||
/** Long press — replaces navigator.vibrate(50) for track selection */
|
||||
/** Long press - replaces navigator.vibrate(50) for track selection */
|
||||
export async function hapticLongPress() {
|
||||
await _ready;
|
||||
try {
|
||||
|
|
|
|||
16
js/indexedIterator.ts
Normal file
16
js/indexedIterator.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* A generic iterator that yields the index, total count, and item for any finite iterable.
|
||||
*
|
||||
* @template T - The type of items in the iterable.
|
||||
* @param iterable - The iterable to process.
|
||||
* @returns A generator that yields an object with index, total, and item.
|
||||
*/
|
||||
export default function* indexedIterator<T>(
|
||||
iterable: Iterable<T>
|
||||
): Generator<{ index: number; total: number; item: T }> {
|
||||
const array = Array.from(iterable); // Convert the iterable to an array
|
||||
const total = array.length; // Get the total count of items
|
||||
for (let index = 0; index < total; index++) {
|
||||
yield { index, total, item: array[index] }; // Yield index, total, and item
|
||||
}
|
||||
}
|
||||
|
|
@ -284,8 +284,8 @@ export class LastFMScrobbler {
|
|||
scheduleScrobble(delay) {
|
||||
this.clearScrobbleTimer();
|
||||
|
||||
this.scrobbleTimer = setTimeout(() => {
|
||||
this.scrobbleCurrentTrack();
|
||||
this.scrobbleTimer = setTimeout(async () => {
|
||||
await this.scrobbleCurrentTrack();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
|
|
@ -350,9 +350,9 @@ export class LastFMScrobbler {
|
|||
}
|
||||
}
|
||||
|
||||
onTrackChange(track) {
|
||||
async onTrackChange(track) {
|
||||
if (!this.isAuthenticated()) return;
|
||||
this.updateNowPlaying(track);
|
||||
await this.updateNowPlaying(track);
|
||||
}
|
||||
|
||||
onPlaybackStop() {
|
||||
|
|
|
|||
|
|
@ -216,8 +216,8 @@ export class LibreFmScrobbler {
|
|||
scheduleScrobble(delay) {
|
||||
this.clearScrobbleTimer();
|
||||
|
||||
this.scrobbleTimer = setTimeout(() => {
|
||||
this.scrobbleCurrentTrack();
|
||||
this.scrobbleTimer = setTimeout(async () => {
|
||||
await this.scrobbleCurrentTrack();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
|
|
@ -282,9 +282,9 @@ export class LibreFmScrobbler {
|
|||
}
|
||||
}
|
||||
|
||||
onTrackChange(track) {
|
||||
async onTrackChange(track) {
|
||||
if (!this.isAuthenticated()) return;
|
||||
this.updateNowPlaying(track);
|
||||
await this.updateNowPlaying(track);
|
||||
}
|
||||
|
||||
onPlaybackStop() {
|
||||
|
|
|
|||
|
|
@ -209,8 +209,8 @@ export class ListenBrainzScrobbler {
|
|||
|
||||
scheduleScrobble(delay) {
|
||||
this.clearScrobbleTimer();
|
||||
this.scrobbleTimer = setTimeout(() => {
|
||||
this.scrobbleCurrentTrack();
|
||||
this.scrobbleTimer = setTimeout(async () => {
|
||||
await this.scrobbleCurrentTrack();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
|
|
@ -235,8 +235,8 @@ export class ListenBrainzScrobbler {
|
|||
}
|
||||
}
|
||||
|
||||
onTrackChange(track) {
|
||||
this.updateNowPlaying(track);
|
||||
async onTrackChange(track) {
|
||||
await this.updateNowPlaying(track);
|
||||
}
|
||||
|
||||
onPlaybackStop() {
|
||||
|
|
|
|||
|
|
@ -19,20 +19,24 @@ class Modal {
|
|||
<h3 style="margin-bottom: 1rem; font-size: 1.5rem;">${title}</h3>
|
||||
<div class="modal-body" style="margin-bottom: 2rem; color: var(--muted-foreground); line-height: 1.5;">${content}</div>
|
||||
<div class="modal-actions" style="display: flex; flex-direction: column; gap: 0.75rem;">
|
||||
${actions.map((a, i) => `
|
||||
${actions
|
||||
.map(
|
||||
(a, i) => `
|
||||
<button class="btn-${a.type || 'secondary'} modal-action-btn" data-index="${i}" style="width: 100%; padding: 0.8rem; font-weight: 600;">${a.label}</button>
|
||||
`).join('')}
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
|
||||
const cleanup = (val) => {
|
||||
modal.remove();
|
||||
resolve(val);
|
||||
};
|
||||
|
||||
modal.querySelectorAll('.modal-action-btn').forEach(btn => {
|
||||
modal.querySelectorAll('.modal-action-btn').forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const action = actions[btn.dataset.index];
|
||||
if (action.callback) {
|
||||
|
|
@ -52,7 +56,7 @@ class Modal {
|
|||
return this.show({
|
||||
title,
|
||||
content: message,
|
||||
actions: [{ label: 'OK', type: 'primary' }]
|
||||
actions: [{ label: 'OK', type: 'primary' }],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -62,8 +66,8 @@ class Modal {
|
|||
content: message,
|
||||
actions: [
|
||||
{ label: confirmLabel, type: type },
|
||||
{ label: 'Cancel', type: 'secondary', callback: () => false }
|
||||
]
|
||||
{ label: 'Cancel', type: 'secondary', callback: () => false },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -82,7 +86,7 @@ export class ListeningPartyManager {
|
|||
this.isJoining = false;
|
||||
this.isInternalSync = false;
|
||||
this.originalSafePlay = null;
|
||||
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +96,7 @@ export class ListeningPartyManager {
|
|||
document.getElementById('copy-party-link-btn')?.addEventListener('click', () => this.copyInviteLink());
|
||||
document.getElementById('party-chat-send-btn')?.addEventListener('click', () => this.sendChatMessage());
|
||||
document.getElementById('party-chat-input')?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.sendChatMessage();
|
||||
if (e.key === 'Enter') this.sendChatMessage().catch(console.error);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -100,13 +104,13 @@ export class ListeningPartyManager {
|
|||
const nameInput = document.getElementById('party-name-input');
|
||||
const user = authManager.user;
|
||||
if (!user) {
|
||||
Modal.alert('Login Required', 'You must be logged in to host a listening party.');
|
||||
await Modal.alert('Login Required', 'You must be logged in to host a listening party.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pbUser = await syncManager._getUserRecord(user.$id);
|
||||
if (!pbUser) {
|
||||
Modal.alert('Sync Error', 'Failed to sync user data. Please try again.');
|
||||
await Modal.alert('Sync Error', 'Failed to sync user data. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -119,25 +123,27 @@ export class ListeningPartyManager {
|
|||
is_playing: player.currentTrack ? !player.activeElement.paused : false,
|
||||
playback_time: player.activeElement.currentTime || 0,
|
||||
playback_timestamp: Date.now(),
|
||||
queue: player.queue?.map(t => syncManager._minifyItem('track', t)) || []
|
||||
queue: player.queue?.map((t) => syncManager._minifyItem('track', t)) || [],
|
||||
};
|
||||
if (currentTrack) partyData.current_track = currentTrack;
|
||||
|
||||
try {
|
||||
const party = await pb.collection('parties').create(partyData, { f_id: user.$id });
|
||||
navigate(`/party/${party.id}`);
|
||||
} catch (e) { console.error('Create error:', e); }
|
||||
} catch (e) {
|
||||
console.error('Create error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async joinParty(partyId) {
|
||||
if (this.currentParty?.id === partyId || this.isJoining) return;
|
||||
this.isJoining = true;
|
||||
|
||||
|
||||
try {
|
||||
const user = authManager.user;
|
||||
const f_id = user ? user.$id : 'guest';
|
||||
const party = await pb.collection('parties').getOne(partyId, { expand: 'host', f_id });
|
||||
|
||||
|
||||
const confirmed = await this.showJoinModal(user);
|
||||
if (!confirmed) {
|
||||
this.isJoining = false;
|
||||
|
|
@ -148,33 +154,39 @@ export class ListeningPartyManager {
|
|||
this.currentParty = party;
|
||||
const pbUser = user ? await syncManager._getUserRecord(user.$id) : null;
|
||||
this.isHost = pbUser && pbUser.id === party.host;
|
||||
|
||||
const profile = confirmed.profile || await this.getMemberProfile(pbUser);
|
||||
const memberData = { party: partyId, name: profile.name, avatar_url: profile.avatar_url, is_host: !!this.isHost, last_seen: Date.now() };
|
||||
|
||||
const profile = confirmed.profile || (await this.getMemberProfile(pbUser));
|
||||
const memberData = {
|
||||
party: partyId,
|
||||
name: profile.name,
|
||||
avatar_url: profile.avatar_url,
|
||||
is_host: !!this.isHost,
|
||||
last_seen: Date.now(),
|
||||
};
|
||||
if (pbUser?.id) memberData.user = pbUser.id;
|
||||
|
||||
const member = await pb.collection('party_members').create(memberData, { f_id });
|
||||
this.memberId = member.id;
|
||||
|
||||
|
||||
this.setupSubscriptions(partyId);
|
||||
this.startHeartbeat();
|
||||
this.renderPartyUI();
|
||||
this.loadInitialData(partyId);
|
||||
await this.loadInitialData(partyId);
|
||||
|
||||
if (!this.isHost) {
|
||||
this.lockControls();
|
||||
this.setupGuestSyncInterception();
|
||||
if (party.current_track) {
|
||||
await audioContextManager.resume();
|
||||
this.syncWithHost(party);
|
||||
await this.syncWithHost(party);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Join error:', error);
|
||||
Modal.alert('Error', 'Failed to join the party. It may have ended.');
|
||||
navigate('/parties');
|
||||
} finally {
|
||||
this.isJoining = false;
|
||||
} catch (error) {
|
||||
console.error('Join error:', error);
|
||||
await Modal.alert('Error', 'Failed to join the party. It may have ended.');
|
||||
navigate('/parties');
|
||||
} finally {
|
||||
this.isJoining = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -187,7 +199,7 @@ export class ListeningPartyManager {
|
|||
);
|
||||
return confirmed ? { profile: null } : false;
|
||||
} else {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cached = localStorage.getItem('party_guest_profile');
|
||||
const defaultName = cached ? JSON.parse(cached).name : '';
|
||||
|
||||
|
|
@ -198,19 +210,24 @@ export class ListeningPartyManager {
|
|||
<input type="text" id="guest-name-input" class="template-input" value="${defaultName}" placeholder="Your nickname" style="width: 100%; text-align: center;">
|
||||
`,
|
||||
actions: [
|
||||
{
|
||||
label: 'Join Party',
|
||||
{
|
||||
label: 'Join Party',
|
||||
type: 'primary',
|
||||
callback: (modal) => {
|
||||
const name = modal.querySelector('#guest-name-input').value.trim() || 'Guest';
|
||||
const profile = { name, avatar_url: `https://api.dicebear.com/9.x/identicon/svg?seed=${name}` };
|
||||
const profile = {
|
||||
name,
|
||||
avatar_url: `https://api.dicebear.com/9.x/identicon/svg?seed=${name}`,
|
||||
};
|
||||
localStorage.setItem('party_guest_profile', JSON.stringify(profile));
|
||||
return { profile };
|
||||
}
|
||||
},
|
||||
},
|
||||
{ label: 'Cancel', type: 'secondary', callback: () => false }
|
||||
]
|
||||
}).then(resolve);
|
||||
{ label: 'Cancel', type: 'secondary', callback: () => false },
|
||||
],
|
||||
})
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -227,57 +244,100 @@ export class ListeningPartyManager {
|
|||
async getMemberProfile(pbUser = null) {
|
||||
const user = authManager.user;
|
||||
if (user) {
|
||||
const name = pbUser?.display_name || pbUser?.username || user.displayName || user.email?.split('@')[0] || 'Member';
|
||||
const avatar = pbUser?.avatar_url || user.photoURL || `https://api.dicebear.com/9.x/identicon/svg?seed=${name}`;
|
||||
const name =
|
||||
pbUser?.display_name || pbUser?.username || user.displayName || user.email?.split('@')[0] || 'Member';
|
||||
const avatar =
|
||||
pbUser?.avatar_url || user.photoURL || `https://api.dicebear.com/9.x/identicon/svg?seed=${name}`;
|
||||
return { name, avatar_url: avatar };
|
||||
}
|
||||
const cached = localStorage.getItem('party_guest_profile');
|
||||
return cached ? JSON.parse(cached) : { name: 'Guest', avatar_url: 'https://api.dicebear.com/9.x/identicon/svg?seed=Guest' };
|
||||
return cached
|
||||
? JSON.parse(cached)
|
||||
: { name: 'Guest', avatar_url: 'https://api.dicebear.com/9.x/identicon/svg?seed=Guest' };
|
||||
}
|
||||
|
||||
setupSubscriptions(partyId) {
|
||||
this.unsubscribeFunctions.forEach(unsub => unsub());
|
||||
this.unsubscribeFunctions.forEach((unsub) => unsub());
|
||||
this.unsubscribeFunctions = [];
|
||||
const f_id = authManager.user ? authManager.user.$id : 'guest';
|
||||
|
||||
pb.collection('parties').subscribe(partyId, (e) => {
|
||||
if (e.action === 'update') {
|
||||
this.currentParty = e.record;
|
||||
if (!this.isHost) this.syncWithHost(e.record);
|
||||
this.updatePartyHeader();
|
||||
} else if (e.action === 'delete') {
|
||||
Modal.alert('Party Ended', 'The host has ended the listening party.');
|
||||
this.leaveParty(false);
|
||||
}
|
||||
}, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub));
|
||||
pb.collection('parties')
|
||||
.subscribe(
|
||||
partyId,
|
||||
async (e) => {
|
||||
if (e.action === 'update') {
|
||||
this.currentParty = e.record;
|
||||
if (!this.isHost) await this.syncWithHost(e.record);
|
||||
this.updatePartyHeader();
|
||||
} else if (e.action === 'delete') {
|
||||
await Modal.alert('Party Ended', 'The host has ended the listening party.');
|
||||
await this.leaveParty(false);
|
||||
}
|
||||
},
|
||||
{ f_id }
|
||||
)
|
||||
.then((unsub) => this.unsubscribeFunctions.push(unsub))
|
||||
.catch(console.error);
|
||||
|
||||
pb.collection('party_members').subscribe('*', (e) => {
|
||||
if (e.record.party === partyId) this.loadMembers();
|
||||
}, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub));
|
||||
pb.collection('party_members')
|
||||
.subscribe(
|
||||
'*',
|
||||
async (e) => {
|
||||
if (e.record.party === partyId) await this.loadMembers();
|
||||
},
|
||||
{ f_id }
|
||||
)
|
||||
.then((unsub) => this.unsubscribeFunctions.push(unsub))
|
||||
.catch(console.error);
|
||||
|
||||
pb.collection('party_messages').subscribe('*', (e) => {
|
||||
if (e.record.party === partyId && e.action === 'create') this.addChatMessage(e.record);
|
||||
}, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub));
|
||||
pb.collection('party_messages')
|
||||
.subscribe(
|
||||
'*',
|
||||
(e) => {
|
||||
if (e.record.party === partyId && e.action === 'create') this.addChatMessage(e.record);
|
||||
},
|
||||
{ f_id }
|
||||
)
|
||||
.then((unsub) => this.unsubscribeFunctions.push(unsub))
|
||||
.catch(console.error);
|
||||
|
||||
pb.collection('party_requests').subscribe('*', (e) => {
|
||||
if (e.record.party === partyId) this.loadRequests();
|
||||
}, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub));
|
||||
pb.collection('party_requests')
|
||||
.subscribe(
|
||||
'*',
|
||||
async (e) => {
|
||||
if (e.record.party === partyId) await this.loadRequests();
|
||||
},
|
||||
{ f_id }
|
||||
)
|
||||
.then((unsub) => this.unsubscribeFunctions.push(unsub))
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
async loadInitialData(partyId) { this.loadMembers(); this.loadMessages(); this.loadRequests(); }
|
||||
async loadInitialData(_partyId) {
|
||||
await this.loadMembers();
|
||||
await this.loadMessages();
|
||||
await this.loadRequests();
|
||||
}
|
||||
|
||||
async loadMembers() {
|
||||
const f_id = authManager.user ? authManager.user.$id : 'guest';
|
||||
this.members = await pb.collection('party_members').getFullList({ filter: `party = "${this.currentParty.id}"`, sort: '-is_host,name', f_id });
|
||||
this.members = await pb
|
||||
.collection('party_members')
|
||||
.getFullList({ filter: `party = "${this.currentParty.id}"`, sort: '-is_host,name', f_id });
|
||||
this.renderMembers();
|
||||
}
|
||||
|
||||
async loadMessages() {
|
||||
const f_id = authManager.user ? authManager.user.$id : 'guest';
|
||||
const res = await pb.collection('party_messages').getList(1, 50, { filter: `party = "${this.currentParty.id}"`, sort: '-created', f_id });
|
||||
const res = await pb
|
||||
.collection('party_messages')
|
||||
.getList(1, 50, { filter: `party = "${this.currentParty.id}"`, sort: '-created', f_id });
|
||||
this.messages = res.items.reverse();
|
||||
const container = document.getElementById('party-chat-messages');
|
||||
if (container) { container.innerHTML = ''; this.messages.forEach(m => this.addChatMessage(m)); }
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
this.messages.forEach((m) => this.addChatMessage(m));
|
||||
}
|
||||
}
|
||||
|
||||
async loadRequests() {
|
||||
|
|
@ -286,26 +346,36 @@ export class ListeningPartyManager {
|
|||
this.requests = await pb.collection('party_requests').getFullList({
|
||||
filter: `party = "${this.currentParty.id}"`,
|
||||
sort: 'created',
|
||||
f_id: f_id
|
||||
f_id: f_id,
|
||||
});
|
||||
this.renderRequests();
|
||||
} catch (e) { console.error('Failed to load requests:', e); }
|
||||
} catch (e) {
|
||||
console.error('Failed to load requests:', e);
|
||||
}
|
||||
}
|
||||
|
||||
renderPartyUI() {
|
||||
this.updatePartyHeader(); this.renderMembers(); this.renderRequests(); this.showPartyIndicator();
|
||||
if (this.isHost) { this.unlockControls(); this.setupHostPlayerSync(); }
|
||||
else { this.lockControls(); this.setupGuestPlayerInterferenceCheck(); }
|
||||
this.updatePartyHeader();
|
||||
this.renderMembers();
|
||||
this.renderRequests();
|
||||
this.showPartyIndicator();
|
||||
if (this.isHost) {
|
||||
this.unlockControls();
|
||||
this.setupHostPlayerSync();
|
||||
} else {
|
||||
this.lockControls();
|
||||
this.setupGuestPlayerInterferenceCheck();
|
||||
}
|
||||
}
|
||||
|
||||
updatePartyHeader() {
|
||||
const titleEl = document.getElementById('party-title');
|
||||
const countEl = document.getElementById('party-member-count');
|
||||
const metaEl = document.getElementById('party-meta');
|
||||
|
||||
|
||||
if (titleEl) titleEl.textContent = this.currentParty.name;
|
||||
if (countEl) countEl.textContent = this.members.length;
|
||||
|
||||
|
||||
if (metaEl) {
|
||||
const host = this.currentParty.expand?.host;
|
||||
const hostName = host?.display_name || host?.username || 'Unknown';
|
||||
|
|
@ -325,11 +395,15 @@ export class ListeningPartyManager {
|
|||
<div class="track-title" style="font-size: 1.8rem; font-weight: 700; margin-bottom: 0.5rem">${track.title}</div>
|
||||
<div class="track-artist" style="font-size: 1.2rem; color: var(--muted-foreground)">${getTrackArtists(track)}</div>
|
||||
</div>
|
||||
${!this.currentParty.is_playing ? `
|
||||
${
|
||||
!this.currentParty.is_playing
|
||||
? `
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; color: var(--primary); font-weight: 600; text-transform: uppercase; letter-spacing: 1px; font-size: 0.9rem">
|
||||
${SVG_PAUSE(24)} Paused
|
||||
</div>
|
||||
` : ''}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
|
|
@ -341,7 +415,12 @@ export class ListeningPartyManager {
|
|||
renderMembers() {
|
||||
const list = document.getElementById('party-members-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = this.members.map(m => `<div class="member-item" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: var(--background-secondary); border-radius: var(--radius); border: 1px solid var(--border)"><img src="${m.avatar_url}" style="width: 40px; height: 40px; border-radius: 50%; background: var(--background-modifier-accent)"><div style="flex: 1; overflow: hidden"><div style="font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">${m.name}</div>${m.is_host ? '<div style="color: var(--primary); font-size: 0.7rem; font-weight: bold; text-transform: uppercase;">Host</div>' : '<div style="color: var(--muted-foreground); font-size: 0.7rem">Listening</div>'}</div></div>`).join('');
|
||||
list.innerHTML = this.members
|
||||
.map(
|
||||
(m) =>
|
||||
`<div class="member-item" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: var(--background-secondary); border-radius: var(--radius); border: 1px solid var(--border)"><img src="${m.avatar_url}" style="width: 40px; height: 40px; border-radius: 50%; background: var(--background-modifier-accent)"><div style="flex: 1; overflow: hidden"><div style="font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">${m.name}</div>${m.is_host ? '<div style="color: var(--primary); font-size: 0.7rem; font-weight: bold; text-transform: uppercase;">Host</div>' : '<div style="color: var(--muted-foreground); font-size: 0.7rem">Listening</div>'}</div></div>`
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
renderRequests() {
|
||||
|
|
@ -351,13 +430,14 @@ export class ListeningPartyManager {
|
|||
list.innerHTML = `<div style="padding: 2rem; text-align: center; color: var(--muted-foreground); font-size: 0.9rem">No requests yet. Right-click a song to request!</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = this.requests.map(r => {
|
||||
try {
|
||||
const api = Player.instance.api;
|
||||
const artists = getTrackArtists(r.track);
|
||||
const coverUrl = api.getCoverUrl(r.track.artwork || r.track.cover || r.track.album?.cover);
|
||||
return `<div class="track-item" style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; border-bottom: 1px solid var(--border)">
|
||||
|
||||
list.innerHTML = this.requests
|
||||
.map((r) => {
|
||||
try {
|
||||
const api = Player.instance.api;
|
||||
const artists = getTrackArtists(r.track);
|
||||
const coverUrl = api.getCoverUrl(r.track.artwork || r.track.cover || r.track.album?.cover);
|
||||
return `<div class="track-item" style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; border-bottom: 1px solid var(--border)">
|
||||
<img src="${coverUrl}" style="width: 48px; height: 48px; border-radius: 4px; object-fit: cover; flex-shrink: 0;">
|
||||
<div class="track-info" style="flex: 1; min-width: 0;">
|
||||
<div class="track-title" style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${r.track.title || 'Unknown Title'}</div>
|
||||
|
|
@ -365,20 +445,25 @@ export class ListeningPartyManager {
|
|||
</div>
|
||||
${this.isHost ? `<button class="btn-primary btn-sm add-request-btn" data-req-id="${r.id}" style="padding: 0.4rem 1rem; font-size: 0.8rem; flex-shrink: 0; white-space: nowrap;">Add to Queue</button>` : ''}
|
||||
</div>`;
|
||||
} catch (e) { return ''; }
|
||||
}).join('');
|
||||
} catch (_e) {
|
||||
return '';
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
|
||||
if (this.isHost) {
|
||||
const f_id = authManager.user ? authManager.user.$id : 'guest';
|
||||
list.querySelectorAll('.add-request-btn').forEach(btn => btn.addEventListener('click', async (e) => {
|
||||
const reqId = e.currentTarget.dataset.reqId;
|
||||
const req = this.requests.find(r => r.id === reqId);
|
||||
if (req) {
|
||||
Player.instance.addToQueue(req.track);
|
||||
showNotification(`Added "${req.track.title}" to queue`);
|
||||
await pb.collection('party_requests').delete(req.id, { f_id });
|
||||
}
|
||||
}));
|
||||
list.querySelectorAll('.add-request-btn').forEach((btn) =>
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const reqId = e.currentTarget.dataset.reqId;
|
||||
const req = this.requests.find((r) => r.id === reqId);
|
||||
if (req) {
|
||||
Player.instance.addToQueue(req.track);
|
||||
showNotification(`Added "${req.track.title}" to queue`);
|
||||
await pb.collection('party_requests').delete(req.id, { f_id });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -387,15 +472,17 @@ export class ListeningPartyManager {
|
|||
if (!container) return;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg';
|
||||
|
||||
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
let content = escapeHtml(msg.content);
|
||||
|
||||
|
||||
content = content.replace(urlRegex, (url) => {
|
||||
if (url.match(/\.(jpeg|jpg|gif|png|webp|svg)(\?.*)?$/i)) {
|
||||
return `<a href="${url}" target="_blank" class="chat-link">${url}</a><img src="${url}" style="max-width: 100%; border-radius: 8px; margin-top: 8px; display: block; cursor: pointer" onclick="window.open('${url}')">`;
|
||||
}
|
||||
const ytMatch = url.match(/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/i);
|
||||
const ytMatch = url.match(
|
||||
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/i
|
||||
);
|
||||
if (ytMatch) {
|
||||
return `<a href="${url}" target="_blank" class="chat-link">${url}</a><iframe style="width: 100%; aspect-ratio: 16/9; border-radius: 8px; margin-top: 8px; border: none" src="https://www.youtube.com/embed/${ytMatch[1]}" allowfullscreen></iframe>`;
|
||||
}
|
||||
|
|
@ -407,7 +494,7 @@ export class ListeningPartyManager {
|
|||
}
|
||||
return `<a href="${url}" target="_blank" class="chat-link" style="color: var(--primary); text-decoration: underline;">${url}</a>`;
|
||||
});
|
||||
|
||||
|
||||
div.innerHTML = `
|
||||
<div style="font-weight: 600; font-size: 0.75rem; color: var(--primary); margin-bottom: 2px">${escapeHtml(msg.sender_name)}</div>
|
||||
<div style="background: var(--background-modifier-accent); padding: 0.6rem 0.8rem; border-radius: 0.75rem; display: inline-block; max-width: 100%; word-break: break-word; font-size: 0.9rem; line-height: 1.4">
|
||||
|
|
@ -421,10 +508,15 @@ export class ListeningPartyManager {
|
|||
async sendChatMessage() {
|
||||
const input = document.getElementById('party-chat-input');
|
||||
if (!input || !input.value.trim()) return;
|
||||
const content = input.value.trim(); input.value = '';
|
||||
const content = input.value.trim();
|
||||
input.value = '';
|
||||
const profile = await this.getMemberProfile();
|
||||
const f_id = authManager.user ? authManager.user.$id : 'guest';
|
||||
try { await pb.collection('party_messages').create({ party: this.currentParty.id, sender_name: profile.name, content }, { f_id }); } catch (e) {}
|
||||
try {
|
||||
await pb
|
||||
.collection('party_messages')
|
||||
.create({ party: this.currentParty.id, sender_name: profile.name, content }, { f_id });
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
async requestSong(track) {
|
||||
|
|
@ -433,13 +525,18 @@ export class ListeningPartyManager {
|
|||
const f_id = authManager.user ? authManager.user.$id : 'guest';
|
||||
try {
|
||||
const minifiedTrack = syncManager._minifyItem('track', track);
|
||||
await pb.collection('party_requests').create({
|
||||
party: this.currentParty.id,
|
||||
track: minifiedTrack,
|
||||
requested_by: profile.name
|
||||
}, { f_id });
|
||||
await pb.collection('party_requests').create(
|
||||
{
|
||||
party: this.currentParty.id,
|
||||
track: minifiedTrack,
|
||||
requested_by: profile.name,
|
||||
},
|
||||
{ f_id }
|
||||
);
|
||||
showNotification(`Requested "${track.title}"`);
|
||||
} catch (e) { console.error('Request error:', e); }
|
||||
} catch (e) {
|
||||
console.error('Request error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async syncWithHost(party) {
|
||||
|
|
@ -448,23 +545,28 @@ export class ListeningPartyManager {
|
|||
try {
|
||||
const player = Player.instance;
|
||||
const el = player.activeElement;
|
||||
if (!party.current_track) { if (player.currentTrack) el.pause(); return; }
|
||||
|
||||
if (!party.current_track) {
|
||||
if (player.currentTrack) el.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentId = String(player.currentTrack?.id || '');
|
||||
const targetId = String(party.current_track.id || '');
|
||||
|
||||
if (currentId !== targetId) {
|
||||
const cleanedTrack = { ...party.current_track };
|
||||
delete cleanedTrack.audioUrl; delete cleanedTrack.streamUrl; delete cleanedTrack.remoteUrl;
|
||||
delete cleanedTrack.audioUrl;
|
||||
delete cleanedTrack.streamUrl;
|
||||
delete cleanedTrack.remoteUrl;
|
||||
player.setQueue([cleanedTrack], 0);
|
||||
await player.playTrackFromQueue(party.playback_time);
|
||||
if (!party.is_playing) el.pause();
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
if (party.is_playing) {
|
||||
if (el.paused) {
|
||||
const success = await player.safePlay(el);
|
||||
const _success = await player.safePlay(el);
|
||||
}
|
||||
const latency = (Date.now() - party.playback_timestamp) / 1000;
|
||||
const targetTime = party.is_playing ? party.playback_time + latency : party.playback_time;
|
||||
|
|
@ -473,17 +575,57 @@ export class ListeningPartyManager {
|
|||
if (!el.paused) el.pause();
|
||||
if (Math.abs(el.currentTime - party.playback_time) > 0.5) el.currentTime = party.playback_time;
|
||||
}
|
||||
} catch (e) { console.error('Sync error:', e); } finally { this.isInternalSync = false; }
|
||||
} catch (e) {
|
||||
console.error('Sync error:', e);
|
||||
} finally {
|
||||
this.isInternalSync = false;
|
||||
}
|
||||
}
|
||||
|
||||
lockControls() {
|
||||
const selectors = ['.play-pause-btn', '#next-btn', '#prev-btn', '#shuffle-btn', '#repeat-btn', '#progress-bar', '#fs-play-pause-btn', '#fs-next-btn', '#fs-prev-btn', '#fs-shuffle-btn', '#fs-repeat-btn', '#fs-progress-bar'];
|
||||
selectors.forEach(s => document.querySelectorAll(s).forEach(el => { el.style.opacity = '0.5'; el.style.pointerEvents = 'none'; }));
|
||||
const selectors = [
|
||||
'.play-pause-btn',
|
||||
'#next-btn',
|
||||
'#prev-btn',
|
||||
'#shuffle-btn',
|
||||
'#repeat-btn',
|
||||
'#progress-bar',
|
||||
'#fs-play-pause-btn',
|
||||
'#fs-next-btn',
|
||||
'#fs-prev-btn',
|
||||
'#fs-shuffle-btn',
|
||||
'#fs-repeat-btn',
|
||||
'#fs-progress-bar',
|
||||
];
|
||||
selectors.forEach((s) =>
|
||||
document.querySelectorAll(s).forEach((el) => {
|
||||
el.style.opacity = '0.5';
|
||||
el.style.pointerEvents = 'none';
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unlockControls() {
|
||||
const selectors = ['.play-pause-btn', '#next-btn', '#prev-btn', '#shuffle-btn', '#repeat-btn', '#progress-bar', '#fs-play-pause-btn', '#fs-next-btn', '#fs-prev-btn', '#fs-shuffle-btn', '#fs-repeat-btn', '#fs-progress-bar'];
|
||||
selectors.forEach(s => document.querySelectorAll(s).forEach(el => { el.style.opacity = '1'; el.style.pointerEvents = 'auto'; }));
|
||||
const selectors = [
|
||||
'.play-pause-btn',
|
||||
'#next-btn',
|
||||
'#prev-btn',
|
||||
'#shuffle-btn',
|
||||
'#repeat-btn',
|
||||
'#progress-bar',
|
||||
'#fs-play-pause-btn',
|
||||
'#fs-next-btn',
|
||||
'#fs-prev-btn',
|
||||
'#fs-shuffle-btn',
|
||||
'#fs-repeat-btn',
|
||||
'#fs-progress-bar',
|
||||
];
|
||||
selectors.forEach((s) =>
|
||||
document.querySelectorAll(s).forEach((el) => {
|
||||
el.style.opacity = '1';
|
||||
el.style.pointerEvents = 'auto';
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setupHostPlayerSync() {
|
||||
|
|
@ -493,16 +635,20 @@ export class ListeningPartyManager {
|
|||
const el = player.activeElement;
|
||||
const sharedTrack = player.currentTrack ? syncManager._minifyItem('track', player.currentTrack) : null;
|
||||
try {
|
||||
await pb.collection('parties').update(this.currentParty.id, {
|
||||
current_track: sharedTrack,
|
||||
is_playing: !el.paused,
|
||||
playback_time: el.currentTime,
|
||||
playback_timestamp: Date.now(),
|
||||
queue: player.queue?.map(t => syncManager._minifyItem('track', t)) || []
|
||||
}, { f_id: authManager.user?.$id });
|
||||
} catch (e) {}
|
||||
await pb.collection('parties').update(
|
||||
this.currentParty.id,
|
||||
{
|
||||
current_track: sharedTrack,
|
||||
is_playing: !el.paused,
|
||||
playback_time: el.currentTime,
|
||||
playback_timestamp: Date.now(),
|
||||
queue: player.queue?.map((t) => syncManager._minifyItem('track', t)) || [],
|
||||
},
|
||||
{ f_id: authManager.user?.$id }
|
||||
);
|
||||
} catch (_e) {}
|
||||
};
|
||||
['play', 'pause', 'seeked'].forEach(ev => {
|
||||
['play', 'pause', 'seeked'].forEach((ev) => {
|
||||
player.audio.addEventListener(ev, updateParty);
|
||||
if (player.video) player.video.addEventListener(ev, updateParty);
|
||||
});
|
||||
|
|
@ -520,9 +666,14 @@ export class ListeningPartyManager {
|
|||
const originalPlayTrackFromQueue = player.playTrackFromQueue.bind(player);
|
||||
player.playTrackFromQueue = async (...args) => {
|
||||
if (this.currentParty && !this.isHost && !this.isInternalSync) {
|
||||
const leave = await Modal.confirm('Leave Party?', 'Playing a song will cause you to leave the listening party. Are you sure?', 'Leave and Play', 'danger');
|
||||
const leave = await Modal.confirm(
|
||||
'Leave Party?',
|
||||
'Playing a song will cause you to leave the listening party. Are you sure?',
|
||||
'Leave and Play',
|
||||
'danger'
|
||||
);
|
||||
if (!leave) return;
|
||||
this.leaveParty();
|
||||
await this.leaveParty();
|
||||
}
|
||||
return await originalPlayTrackFromQueue(...args);
|
||||
};
|
||||
|
|
@ -531,43 +682,64 @@ export class ListeningPartyManager {
|
|||
startHeartbeat() {
|
||||
this.heartbeatInterval = setInterval(async () => {
|
||||
if (!this.memberId) return;
|
||||
try { await pb.collection('party_members').update(this.memberId, { last_seen: Date.now() }, { f_id: authManager.user?.$id || 'guest' }); } catch (e) {}
|
||||
try {
|
||||
await pb
|
||||
.collection('party_members')
|
||||
.update(this.memberId, { last_seen: Date.now() }, { f_id: authManager.user?.$id || 'guest' });
|
||||
} catch (_e) {}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
async leaveParty(shouldCleanup = true) {
|
||||
const f_id = authManager.user?.$id || 'guest';
|
||||
if (this.isHost && shouldCleanup) {
|
||||
const end = await Modal.confirm('End Party?', 'Leaving will end the party for everyone. Are you sure?', 'End Party', 'danger');
|
||||
const end = await Modal.confirm(
|
||||
'End Party?',
|
||||
'Leaving will end the party for everyone. Are you sure?',
|
||||
'End Party',
|
||||
'danger'
|
||||
);
|
||||
if (!end) return;
|
||||
try {
|
||||
const cleanup = async (coll) => {
|
||||
const items = await pb.collection(coll).getFullList({ filter: `party = "${this.currentParty.id}"`, f_id });
|
||||
const items = await pb
|
||||
.collection(coll)
|
||||
.getFullList({ filter: `party = "${this.currentParty.id}"`, f_id });
|
||||
for (const i of items) await pb.collection(coll).delete(i.id, { f_id });
|
||||
};
|
||||
await cleanup('party_members'); await cleanup('party_messages'); await cleanup('party_requests');
|
||||
await cleanup('party_members');
|
||||
await cleanup('party_messages');
|
||||
await cleanup('party_requests');
|
||||
await pb.collection('parties').delete(this.currentParty.id, { f_id });
|
||||
} catch (e) {}
|
||||
} catch (_e) {}
|
||||
} else if (this.memberId) {
|
||||
try { await pb.collection('party_members').delete(this.memberId, { f_id }); } catch (e) {}
|
||||
try {
|
||||
await pb.collection('party_members').delete(this.memberId, { f_id });
|
||||
} catch (_e) {}
|
||||
}
|
||||
this.restorePlayerMethods();
|
||||
this.unlockControls();
|
||||
this.unsubscribeFunctions.forEach(unsub => unsub());
|
||||
this.unsubscribeFunctions.forEach((unsub) => unsub());
|
||||
this.unsubscribeFunctions = [];
|
||||
clearInterval(this.syncInterval); clearInterval(this.heartbeatInterval);
|
||||
this.currentParty = null; this.isHost = false; this.memberId = null;
|
||||
clearInterval(this.syncInterval);
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.currentParty = null;
|
||||
this.isHost = false;
|
||||
this.memberId = null;
|
||||
document.getElementById('party-indicator')?.remove();
|
||||
navigate('/parties');
|
||||
}
|
||||
|
||||
restorePlayerMethods() {
|
||||
const player = Player.instance;
|
||||
if (this.originalSafePlay) { player.safePlay = this.originalSafePlay; this.originalSafePlay = null; }
|
||||
if (this.originalSafePlay) {
|
||||
player.safePlay = this.originalSafePlay;
|
||||
this.originalSafePlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
copyInviteLink() {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/party/${this.currentParty.id}`);
|
||||
navigator.clipboard.writeText(`${window.location.origin}/party/${this.currentParty.id}`).catch(console.error);
|
||||
showNotification('Invite link copied!');
|
||||
}
|
||||
|
||||
|
|
@ -580,7 +752,7 @@ export class ListeningPartyManager {
|
|||
document.body.appendChild(indicator);
|
||||
indicator.onclick = () => navigate(`/party/${this.currentParty.id}`);
|
||||
}
|
||||
|
||||
|
||||
indicator.innerHTML = `
|
||||
<div class="party-indicator-content">
|
||||
<span class="party-indicator-label">Listening Party</span>
|
||||
|
|
|
|||
17
js/lyrics.js
17
js/lyrics.js
|
|
@ -10,7 +10,7 @@ import {
|
|||
SVG_GLOBE,
|
||||
} from './icons.js';
|
||||
import { sidePanelManager } from './side-panel.js';
|
||||
import('@uimaxbai/am-lyrics/am-lyrics.js');
|
||||
import('@uimaxbai/am-lyrics/am-lyrics.js').catch(console.error);
|
||||
|
||||
// Check if text contains Japanese, Chinese, or Korean characters
|
||||
function containsAsianText(text) {
|
||||
|
|
@ -246,6 +246,7 @@ export class LyricsManager {
|
|||
// Monkey-patch XMLHttpRequest to redirect dictionary requests to CDN
|
||||
// Kuromoji uses XHR, not fetch, for loading dictionary files
|
||||
if (!window._originalXHROpen) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
window._originalXHROpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||||
const urlStr = url.toString();
|
||||
|
|
@ -264,7 +265,7 @@ export class LyricsManager {
|
|||
if (!window._originalFetch) {
|
||||
window._originalFetch = window.fetch;
|
||||
window.fetch = async (url, options) => {
|
||||
const urlStr = url.toString();
|
||||
const urlStr = url instanceof URL ? url.toString() : url.url;
|
||||
if (urlStr.includes('/dict/') && urlStr.includes('.dat.gz')) {
|
||||
const filename = urlStr.split('/').pop();
|
||||
const cdnUrl = `https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/${filename}`;
|
||||
|
|
@ -527,7 +528,7 @@ export class LyricsManager {
|
|||
}
|
||||
|
||||
// Setup MutationObserver to convert lyrics in am-lyrics component
|
||||
setupLyricsObserver(amLyricsElement) {
|
||||
async setupLyricsObserver(amLyricsElement) {
|
||||
this.stopLyricsObserver();
|
||||
|
||||
if (!amLyricsElement) return;
|
||||
|
|
@ -575,7 +576,7 @@ export class LyricsManager {
|
|||
await this.convertLyricsContent(amLyricsElement);
|
||||
}
|
||||
if (this.isGeniusMode && this.currentGeniusData) {
|
||||
this.applyGeniusAnnotations(amLyricsElement, this.currentGeniusData.referents);
|
||||
await this.applyGeniusAnnotations(amLyricsElement, this.currentGeniusData.referents);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
|
@ -591,10 +592,10 @@ export class LyricsManager {
|
|||
|
||||
// Initial conversion if Romaji mode is enabled - single attempt, no periodic polling
|
||||
if (this.isRomajiMode) {
|
||||
this.convertLyricsContent(amLyricsElement);
|
||||
await this.convertLyricsContent(amLyricsElement);
|
||||
}
|
||||
if (this.isGeniusMode && this.currentGeniusData) {
|
||||
this.applyGeniusAnnotations(amLyricsElement, this.currentGeniusData.referents);
|
||||
await this.applyGeniusAnnotations(amLyricsElement, this.currentGeniusData.referents);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -692,7 +693,7 @@ export class LyricsManager {
|
|||
if (amLyricsElement) {
|
||||
if (this.isRomajiMode) {
|
||||
// Turning ON: Setup observer and convert immediately
|
||||
this.setupLyricsObserver(amLyricsElement);
|
||||
await this.setupLyricsObserver(amLyricsElement);
|
||||
await this.convertLyricsContent(amLyricsElement);
|
||||
} else {
|
||||
// Turning OFF: Stop observer
|
||||
|
|
@ -1238,7 +1239,7 @@ export function clearFullscreenLyricsSync(container) {
|
|||
}
|
||||
}
|
||||
|
||||
export function clearLyricsPanelSync(audioPlayer, panel) {
|
||||
export function clearLyricsPanelSync(_audioPlayer, panel) {
|
||||
if (panel && panel.lyricsCleanup) {
|
||||
panel.lyricsCleanup();
|
||||
panel.lyricsCleanup = null;
|
||||
|
|
|
|||
|
|
@ -135,8 +135,8 @@ export class MalojaScrobbler {
|
|||
|
||||
scheduleScrobble(delay) {
|
||||
this.clearScrobbleTimer();
|
||||
this.scrobbleTimer = setTimeout(() => {
|
||||
this.scrobbleCurrentTrack();
|
||||
this.scrobbleTimer = setTimeout(async () => {
|
||||
await this.scrobbleCurrentTrack();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
|
|
@ -161,8 +161,8 @@ export class MalojaScrobbler {
|
|||
}
|
||||
}
|
||||
|
||||
onTrackChange(track) {
|
||||
this.updateNowPlaying(track);
|
||||
async onTrackChange(track) {
|
||||
await this.updateNowPlaying(track);
|
||||
}
|
||||
|
||||
onPlaybackStop() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType, getTrackCoverId } from './utils.js';
|
||||
import {
|
||||
getCoverBlob,
|
||||
getTrackTitle,
|
||||
getFullArtistString,
|
||||
getMimeType,
|
||||
getTrackCoverId,
|
||||
getFullArtistArray,
|
||||
} from './utils.js';
|
||||
import { addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
|
||||
import { LyricsManager } from './lyrics.js';
|
||||
import { Mp4Stik } from './taglib.types.ts';
|
||||
import { modernSettings } from './ModernSettings.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('./container-classes.ts').Track} Track
|
||||
|
|
@ -29,19 +37,21 @@ export function prefetchMetadataObjects(track, api, coverBlob = null) {
|
|||
* @param {string} quality - Audio quality
|
||||
* @returns {Promise<Blob>} - Audio blob with embedded metadata
|
||||
*/
|
||||
export async function addMetadataToAudio(audioBlob, track, api, _quality, prefetchPromises) {
|
||||
export async function addMetadataToAudio(audioBlob, track, _api, _quality, prefetchPromises) {
|
||||
const { coverFetch, lyricsFetch } = prefetchPromises;
|
||||
|
||||
/**
|
||||
* @type {TagLibMetadata}
|
||||
*/
|
||||
const data = {};
|
||||
const data = {
|
||||
writeArtistsSeparately: modernSettings.writeArtistsSeparately,
|
||||
};
|
||||
|
||||
try {
|
||||
data.title = getTrackTitle(track);
|
||||
data.artist = getFullArtistString(track);
|
||||
data.artist = getFullArtistArray(track);
|
||||
data.albumTitle = track.album?.title;
|
||||
data.albumArtist = track.album?.artist?.name || track.artist?.name;
|
||||
data.albumArtist = track.album?.artist?.name || getFullArtistString(track) || '';
|
||||
data.trackNumber = track.trackNumber;
|
||||
data.discNumber = track.volumeNumber ?? track.discNumber;
|
||||
data.totalTracks = track.album?.numberOfTracksOnDisc ?? track.album?.numberOfTracks;
|
||||
|
|
|
|||
|
|
@ -546,9 +546,9 @@ export function createStringAtom(type, value, truncateType = true) {
|
|||
|
||||
export function createUserAtom(namespace, name, value) {
|
||||
const encoder = new TextEncoder();
|
||||
const dashBytes = encoder.encode('----'); // User-defined atom type
|
||||
const _dashBytes = encoder.encode('----'); // User-defined atom type
|
||||
const namespaceBytes = encoder.encode(namespace);
|
||||
const meanBytes = encoder.encode('mean'); // Standard 'mean' atom for namespace
|
||||
const _meanBytes = encoder.encode('mean'); // Standard 'mean' atom for namespace
|
||||
const nameBytes = encoder.encode(name);
|
||||
const valueBytes = encoder.encode('\x00\x00\x00\x01\x00\x00\x00\x00' + value);
|
||||
|
||||
|
|
|
|||
|
|
@ -32,18 +32,26 @@ export class MultiScrobbler {
|
|||
);
|
||||
}
|
||||
|
||||
updateNowPlaying(track) {
|
||||
this.lastfm.updateNowPlaying(track);
|
||||
this.listenbrainz.updateNowPlaying(track);
|
||||
this.maloja.updateNowPlaying(track);
|
||||
this.librefm.updateNowPlaying(track);
|
||||
async updateNowPlaying(track) {
|
||||
await Promise.allSettled(
|
||||
[
|
||||
this.lastfm.updateNowPlaying(track),
|
||||
this.listenbrainz.updateNowPlaying(track),
|
||||
this.maloja.updateNowPlaying(track),
|
||||
this.librefm.updateNowPlaying(track),
|
||||
].map((p) => p.catch(console.error))
|
||||
);
|
||||
}
|
||||
|
||||
onTrackChange(track) {
|
||||
this.lastfm.onTrackChange(track);
|
||||
this.listenbrainz.onTrackChange(track);
|
||||
this.maloja.onTrackChange(track);
|
||||
this.librefm.onTrackChange(track);
|
||||
async onTrackChange(track) {
|
||||
await Promise.allSettled(
|
||||
[
|
||||
this.lastfm.onTrackChange(track),
|
||||
this.listenbrainz.onTrackChange(track),
|
||||
this.maloja.onTrackChange(track),
|
||||
this.librefm.onTrackChange(track),
|
||||
].map((p) => p.catch(console.error))
|
||||
);
|
||||
}
|
||||
|
||||
onPlaybackStop() {
|
||||
|
|
@ -55,9 +63,11 @@ export class MultiScrobbler {
|
|||
|
||||
// Love/Like tracks on all services that support it
|
||||
async loveTrack(track) {
|
||||
await this.lastfm.loveTrack(track);
|
||||
await this.librefm.loveTrack(track);
|
||||
await this.listenbrainz.loveTrack(track);
|
||||
await Promise.allSettled(
|
||||
[this.lastfm.loveTrack(track), this.librefm.loveTrack(track), this.listenbrainz.loveTrack(track)].map((p) =>
|
||||
p.catch(console.error)
|
||||
)
|
||||
);
|
||||
// Maloja feedback could be added here when supported
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,45 @@ import { LosslessAPI } from './api.js';
|
|||
import { PodcastsAPI } from './podcasts-api.js';
|
||||
import { musicProviderSettings } from './storage.js';
|
||||
|
||||
/**
|
||||
* MusicAPI - Singleton class that provides a unified interface for accessing music streaming services.
|
||||
*
|
||||
* Supports multiple providers (primarily Tidal) and includes functionality for searching,
|
||||
* retrieving metadata, streaming, and managing playlists, artists, albums, tracks, and podcasts.
|
||||
*
|
||||
* @class MusicAPI
|
||||
* @classdesc Manages API interactions with music providers and provides caching mechanisms
|
||||
* for cover artwork and video metadata.
|
||||
*
|
||||
* @example
|
||||
* // Initialize the MusicAPI
|
||||
* await MusicAPI.initialize(settings);
|
||||
*
|
||||
* // Get the singleton instance
|
||||
* const api = MusicAPI.instance;
|
||||
*
|
||||
* // Search for tracks
|
||||
* const results = await api.search('query');
|
||||
*
|
||||
* // Get a specific track
|
||||
* const track = await api.getTrack('track-id');
|
||||
*
|
||||
* // Get stream URL
|
||||
* const streamUrl = await api.getStreamUrl('track-id', 'HIGH');
|
||||
*
|
||||
* @property {LosslessAPI} tidalAPI - The Tidal API instance
|
||||
* @property {PodcastsAPI} podcastsAPI - The Podcasts API instance
|
||||
* @property {Object} _settings - Configuration settings
|
||||
* @property {Map} videoArtworkCache - Cache for video artwork data
|
||||
*
|
||||
* @throws {Error} Throws if instance is accessed before initialization
|
||||
* @throws {Error} Throws if initialize is called more than once
|
||||
*/
|
||||
export class MusicAPI {
|
||||
static #instance = null;
|
||||
/**
|
||||
* @type {MusicAPI}
|
||||
*/
|
||||
static get instance() {
|
||||
if (!MusicAPI.#instance) {
|
||||
throw new Error('MusicAPI not initialized. Call MusicAPI.initialize(settings) first.');
|
||||
|
|
@ -35,7 +72,7 @@ export class MusicAPI {
|
|||
}
|
||||
|
||||
// Get the appropriate API based on provider
|
||||
getAPI(provider = null) {
|
||||
getAPI() {
|
||||
return this.tidalAPI;
|
||||
}
|
||||
|
||||
|
|
@ -101,31 +138,31 @@ export class MusicAPI {
|
|||
}
|
||||
|
||||
// Get methods
|
||||
async getTrack(id, quality, provider = null) {
|
||||
async getTrack(id, quality) {
|
||||
const api = this.getAPI();
|
||||
const cleanId = this.stripProviderPrefix(id);
|
||||
return api.getTrack(cleanId, quality);
|
||||
}
|
||||
|
||||
async getTrackMetadata(id, provider = null) {
|
||||
async getTrackMetadata(id) {
|
||||
const api = this.getAPI();
|
||||
const cleanId = this.stripProviderPrefix(id);
|
||||
return api.getTrackMetadata(cleanId);
|
||||
}
|
||||
|
||||
async getAlbum(id, provider = null) {
|
||||
async getAlbum(id) {
|
||||
const api = this.getAPI();
|
||||
const cleanId = this.stripProviderPrefix(id);
|
||||
return api.getAlbum(cleanId);
|
||||
}
|
||||
|
||||
async getArtist(id, provider = null) {
|
||||
async getArtist(id) {
|
||||
const api = this.getAPI();
|
||||
const cleanId = this.stripProviderPrefix(id);
|
||||
return api.getArtist(cleanId);
|
||||
}
|
||||
|
||||
async getArtistBiography(id, provider = null) {
|
||||
async getArtistBiography(id) {
|
||||
const api = this.getAPI();
|
||||
const cleanId = this.stripProviderPrefix(id);
|
||||
if (typeof api.getArtistBiography === 'function') {
|
||||
|
|
@ -134,13 +171,13 @@ export class MusicAPI {
|
|||
return null;
|
||||
}
|
||||
|
||||
async getVideo(id, provider = null) {
|
||||
async getVideo(id) {
|
||||
const api = this.getAPI();
|
||||
const cleanId = this.stripProviderPrefix(id);
|
||||
return api.getVideo(cleanId);
|
||||
}
|
||||
|
||||
async getVideoStreamUrl(id, provider = null) {
|
||||
async getVideoStreamUrl(id) {
|
||||
const api = this.getAPI();
|
||||
const cleanId = this.stripProviderPrefix(id);
|
||||
if (typeof api.getVideoStreamUrl === 'function') {
|
||||
|
|
@ -157,7 +194,7 @@ export class MusicAPI {
|
|||
return this.tidalAPI.getPlaylist(id);
|
||||
}
|
||||
|
||||
async getMix(id, _provider = null) {
|
||||
async getMix(id) {
|
||||
// Mixes are always Tidal for now
|
||||
return this.tidalAPI.getMix(id);
|
||||
}
|
||||
|
|
@ -172,7 +209,7 @@ export class MusicAPI {
|
|||
}
|
||||
|
||||
// Stream methods
|
||||
async getStreamUrl(id, quality, provider = null) {
|
||||
async getStreamUrl(id, quality) {
|
||||
const api = this.getAPI();
|
||||
const cleanId = this.stripProviderPrefix(id);
|
||||
return api.getStreamUrl(cleanId, quality);
|
||||
|
|
|
|||
280
js/player.js
280
js/player.js
|
|
@ -16,7 +16,6 @@ import {
|
|||
exponentialVolumeSettings,
|
||||
audioEffectsSettings,
|
||||
radioSettings,
|
||||
playbackSettings,
|
||||
} from './storage.js';
|
||||
import { audioContextManager } from './audio-context.js';
|
||||
import { isIos, isSafari } from './platform-detection.js';
|
||||
|
|
@ -49,7 +48,6 @@ export class Player {
|
|||
this.repeatMode = REPEAT_MODE.OFF;
|
||||
this.preloadCache = new Map();
|
||||
this.preloadAbortController = null;
|
||||
this._lastPreloadTime = null;
|
||||
this.currentTrack = null;
|
||||
this.currentRgValues = null;
|
||||
this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7');
|
||||
|
|
@ -108,6 +106,7 @@ export class Player {
|
|||
bufferingGoal: 30,
|
||||
rebufferingGoal: 2,
|
||||
bufferBehind: 30,
|
||||
jumpLargeGaps: true,
|
||||
},
|
||||
abr: {
|
||||
enabled: true,
|
||||
|
|
@ -134,7 +133,7 @@ export class Player {
|
|||
}
|
||||
|
||||
this.loadQueueState();
|
||||
this.setupMediaSession();
|
||||
await this.setupMediaSession();
|
||||
|
||||
this.radioEnabled = radioSettings.isEnabled();
|
||||
this.radioSeeds = [];
|
||||
|
|
@ -143,18 +142,19 @@ export class Player {
|
|||
|
||||
this.playbackSequence = 0;
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.saveQueueState();
|
||||
window.addEventListener('beforeunload', async () => {
|
||||
await this.saveQueueState();
|
||||
});
|
||||
|
||||
// Handle visibility change for iOS - AudioContext gets suspended when screen locks
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
document.addEventListener('visibilitychange', async () => {
|
||||
const el = this.activeElement;
|
||||
if (document.visibilityState === 'visible' && !el.paused) {
|
||||
// Ensure audio context is resumed when user returns to the app
|
||||
if (!audioContextManager.isReady()) {
|
||||
audioContextManager.init(el);
|
||||
}
|
||||
audioContextManager.resume();
|
||||
await audioContextManager.resume();
|
||||
}
|
||||
if (document.visibilityState === 'visible' && this.autoplayBlocked) {
|
||||
this.autoplayBlocked = false;
|
||||
|
|
@ -162,17 +162,6 @@ export class Player {
|
|||
}
|
||||
});
|
||||
|
||||
// Time-based preload trigger for Safari background playback
|
||||
this._timeUpdateHandler = this._handleTimeUpdateForPreload.bind(this);
|
||||
this.audio.addEventListener('timeupdate', this._timeUpdateHandler);
|
||||
if (this.video) {
|
||||
this.video.addEventListener('timeupdate', this._timeUpdateHandler);
|
||||
}
|
||||
|
||||
window.addEventListener('preload-time-change', () => {
|
||||
this._lastPreloadTime = null;
|
||||
});
|
||||
|
||||
this._setupVideoSync();
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +177,9 @@ export class Player {
|
|||
if (this.video.readyState >= 2 && (this.audio.readyState > 0 || this.audio.src)) {
|
||||
this.audio.currentTime = this.video.currentTime;
|
||||
}
|
||||
} catch (err) {}
|
||||
} catch {
|
||||
// Video-to-audio time sync may fail if readyState is stale
|
||||
}
|
||||
}
|
||||
|
||||
const syncedEvent = new Event(eventName, { bubbles: e.bubbles, cancelable: e.cancelable });
|
||||
|
|
@ -379,7 +370,7 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
saveQueueState() {
|
||||
async saveQueueState() {
|
||||
queueManager.saveQueue({
|
||||
queue: this.queue,
|
||||
shuffledQueue: this.shuffledQueue,
|
||||
|
|
@ -390,14 +381,14 @@ export class Player {
|
|||
});
|
||||
|
||||
if (window.renderQueueFunction) {
|
||||
window.renderQueueFunction();
|
||||
await window.renderQueueFunction();
|
||||
}
|
||||
}
|
||||
|
||||
setupMediaSession() {
|
||||
async setupMediaSession() {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
||||
const setHandlers = () => {
|
||||
const setHandlers = async () => {
|
||||
navigator.mediaSession.setActionHandler('play', async () => {
|
||||
const el = this.activeElement;
|
||||
// Initialize and resume audio context first (required for iOS lock screen)
|
||||
|
|
@ -413,7 +404,7 @@ export class Player {
|
|||
} catch (e) {
|
||||
console.error('MediaSession play failed:', e);
|
||||
// If play fails, try to handle it like a regular play/pause
|
||||
this.handlePlayPause();
|
||||
await this.handlePlayPause();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -438,7 +429,7 @@ export class Player {
|
|||
this.applyReplayGain();
|
||||
}
|
||||
await audioContextManager.resume();
|
||||
this.playNext();
|
||||
await this.playNext();
|
||||
});
|
||||
|
||||
if (!this.isIOS) {
|
||||
|
|
@ -474,7 +465,7 @@ export class Player {
|
|||
this.video.addEventListener('playing', () => setHandlers(), { once: true });
|
||||
}
|
||||
} else {
|
||||
setHandlers();
|
||||
await setHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -527,21 +518,6 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
_handleTimeUpdateForPreload() {
|
||||
const el = this.activeElement;
|
||||
if (!el || !el.duration || el.paused) return;
|
||||
|
||||
const preloadTime = playbackSettings.getPreloadTime();
|
||||
const timeRemaining = el.duration - el.currentTime;
|
||||
if (timeRemaining <= preloadTime && timeRemaining > 0) {
|
||||
const now = Date.now();
|
||||
if (!this._lastPreloadTime || now - this._lastPreloadTime > 5000) {
|
||||
this._lastPreloadTime = now;
|
||||
this.preloadNextTracks();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setupHlsVideo(video, result, fallbackImg) {
|
||||
const url = result.videoUrl || result.hlsUrl || result;
|
||||
const Hls = (await import('hls.js')).default;
|
||||
|
|
@ -566,7 +542,7 @@ export class Player {
|
|||
video.play().catch(() => {});
|
||||
await this.setupVideoQualitySelector();
|
||||
});
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
this.hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
console.warn('HLS fatal error:', data.type);
|
||||
if (fallbackImg) video.replaceWith(fallbackImg);
|
||||
|
|
@ -602,7 +578,7 @@ export class Player {
|
|||
const levels = this.hls.levels;
|
||||
const qualityLabels = [
|
||||
'Auto',
|
||||
...levels.map((level, i) => {
|
||||
...levels.map((level) => {
|
||||
const height = level.height || 0;
|
||||
const bandwidth = level.bitrate || 0;
|
||||
if (height >= 1080) return '1080p';
|
||||
|
|
@ -669,7 +645,7 @@ export class Player {
|
|||
artist: video.artist || (video.artists && video.artists[0]) || 'Unknown Artist',
|
||||
album: video.album || { title: 'Video', cover: video.image || video.cover },
|
||||
};
|
||||
this.setQueue([videoTrack], 0);
|
||||
await this.setQueue([videoTrack], 0);
|
||||
await this.playTrackFromQueue();
|
||||
}
|
||||
|
||||
|
|
@ -687,7 +663,7 @@ export class Player {
|
|||
const track = currentQueue[this.currentQueueIndex];
|
||||
if (track.isUnavailable) {
|
||||
console.warn(`Attempted to play unavailable track: ${track.title}. Skipping...`);
|
||||
this.playNext();
|
||||
await this.playNext();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -695,7 +671,7 @@ export class Player {
|
|||
const { contentBlockingSettings } = await import('./storage.js');
|
||||
if (contentBlockingSettings.shouldHideTrack(track)) {
|
||||
console.warn(`Attempted to play blocked track: ${track.title}. Skipping...`);
|
||||
this.playNext();
|
||||
await this.playNext();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -718,15 +694,15 @@ export class Player {
|
|||
this.currentQueueIndex >= currentQueue.length - 1
|
||||
) {
|
||||
console.log('[playTrackFromQueue] Fetching more tracks!');
|
||||
this.fetchMoreArtistPopularTracks().then((newTracks) => {
|
||||
await this.fetchMoreArtistPopularTracks().then(async (newTracks) => {
|
||||
console.log('[playTrackFromQueue] Got tracks:', newTracks?.length);
|
||||
if (newTracks && newTracks.length > 0) {
|
||||
this.addToQueue(newTracks);
|
||||
await this.addToQueue(newTracks);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.saveQueueState();
|
||||
await this.saveQueueState();
|
||||
|
||||
this.currentTrack = track;
|
||||
|
||||
|
|
@ -842,7 +818,7 @@ export class Player {
|
|||
if (!streamUrl) {
|
||||
console.warn(`Podcast episode ${trackTitle} audio URL is missing. Skipping.`);
|
||||
track.isUnavailable = true;
|
||||
this.playNext();
|
||||
await this.playNext();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -875,7 +851,7 @@ export class Player {
|
|||
if (!streamUrl) {
|
||||
console.warn(`Track ${trackTitle} audio URL is missing. Skipping.`);
|
||||
track.isUnavailable = true;
|
||||
this.playNext();
|
||||
await this.playNext();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1042,7 +1018,7 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
this.preloadNextTracks();
|
||||
void this.preloadNextTracks().catch(console.error);
|
||||
} catch (error) {
|
||||
if (this.playbackSequence !== currentSequence) return;
|
||||
if (error && (error.name === 'NotAllowedError' || error.name === 'AbortError')) {
|
||||
|
|
@ -1058,13 +1034,15 @@ export class Player {
|
|||
try {
|
||||
await this.playTrackFromQueue(startTime, recursiveCount, true);
|
||||
return;
|
||||
} catch (retryError) {
|
||||
} catch {
|
||||
// LOSSLESS fallback also failed - fall through to error handling below
|
||||
} finally {
|
||||
this.quality = originalQuality;
|
||||
this.isFallbackRetry = false;
|
||||
this.isFallbackInProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Could not play track: ${trackTitle}`, error);
|
||||
|
|
@ -1075,33 +1053,33 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
playAtIndex(index) {
|
||||
async playAtIndex(index) {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
if (index >= 0 && index < currentQueue.length) {
|
||||
this.currentQueueIndex = index;
|
||||
this.playTrackFromQueue(0, 0);
|
||||
await this.playTrackFromQueue(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
playNext(recursiveCount = 0) {
|
||||
async playNext(recursiveCount = 0) {
|
||||
const currentQueue = this.getCurrentQueue();
|
||||
const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1;
|
||||
|
||||
if (recursiveCount > currentQueue.length) {
|
||||
if (this.radioEnabled && isLastTrack) {
|
||||
this.fetchRadioRecommendations().then(() => {
|
||||
this.fetchRadioRecommendations().then(async () => {
|
||||
const updatedQueue = this.getCurrentQueue();
|
||||
if (this.currentQueueIndex < updatedQueue.length - 1) {
|
||||
this.playNext(0);
|
||||
await this.playNext(0);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) {
|
||||
this.fetchMoreArtistPopularTracks().then((newTracks) => {
|
||||
await this.fetchMoreArtistPopularTracks().then(async (newTracks) => {
|
||||
if (newTracks && newTracks.length > 0) {
|
||||
this.addToQueue(newTracks);
|
||||
this.playNext(0);
|
||||
await this.addToQueue(newTracks);
|
||||
await this.playNext(0);
|
||||
} else {
|
||||
this.activeElement.pause();
|
||||
}
|
||||
|
|
@ -1112,52 +1090,54 @@ export class Player {
|
|||
return;
|
||||
}
|
||||
|
||||
import('./storage.js').then(({ contentBlockingSettings }) => {
|
||||
if (
|
||||
this.repeatMode === REPEAT_MODE.ONE &&
|
||||
!currentQueue[this.currentQueueIndex]?.isUnavailable &&
|
||||
!contentBlockingSettings.shouldHideTrack(currentQueue[this.currentQueueIndex])
|
||||
) {
|
||||
this.playTrackFromQueue(0, recursiveCount);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLastTrack) {
|
||||
this.currentQueueIndex++;
|
||||
const track = currentQueue[this.currentQueueIndex];
|
||||
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
|
||||
return this.playNext(recursiveCount + 1);
|
||||
import('./storage.js')
|
||||
.then(async ({ contentBlockingSettings }) => {
|
||||
if (
|
||||
this.repeatMode === REPEAT_MODE.ONE &&
|
||||
!currentQueue[this.currentQueueIndex]?.isUnavailable &&
|
||||
!contentBlockingSettings.shouldHideTrack(currentQueue[this.currentQueueIndex])
|
||||
) {
|
||||
await this.playTrackFromQueue(0, recursiveCount);
|
||||
return;
|
||||
}
|
||||
} else if (this.radioEnabled) {
|
||||
this.fetchRadioRecommendations().then(() => {
|
||||
const updatedQueue = this.getCurrentQueue();
|
||||
if (this.currentQueueIndex < updatedQueue.length - 1) {
|
||||
this.playNext(0);
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) {
|
||||
this.fetchMoreArtistPopularTracks().then((newTracks) => {
|
||||
if (newTracks && newTracks.length > 0) {
|
||||
this.addToQueue(newTracks);
|
||||
}
|
||||
// Now play the next track (which is now at currentQueueIndex + 1 if tracks were added)
|
||||
|
||||
if (!isLastTrack) {
|
||||
this.currentQueueIndex++;
|
||||
this.playTrackFromQueue(0, recursiveCount);
|
||||
});
|
||||
return;
|
||||
} else if (this.repeatMode === REPEAT_MODE.ALL) {
|
||||
this.currentQueueIndex = 0;
|
||||
const track = currentQueue[this.currentQueueIndex];
|
||||
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
|
||||
return this.playNext(recursiveCount + 1);
|
||||
const track = currentQueue[this.currentQueueIndex];
|
||||
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
|
||||
return this.playNext(recursiveCount + 1);
|
||||
}
|
||||
} else if (this.radioEnabled) {
|
||||
this.fetchRadioRecommendations().then(async () => {
|
||||
const updatedQueue = this.getCurrentQueue();
|
||||
if (this.currentQueueIndex < updatedQueue.length - 1) {
|
||||
await this.playNext(0);
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) {
|
||||
await this.fetchMoreArtistPopularTracks().then(async (newTracks) => {
|
||||
if (newTracks && newTracks.length > 0) {
|
||||
await this.addToQueue(newTracks);
|
||||
}
|
||||
// Now play the next track (which is now at currentQueueIndex + 1 if tracks were added)
|
||||
this.currentQueueIndex++;
|
||||
await this.playTrackFromQueue(0, recursiveCount);
|
||||
});
|
||||
return;
|
||||
} else if (this.repeatMode === REPEAT_MODE.ALL) {
|
||||
this.currentQueueIndex = 0;
|
||||
const track = currentQueue[this.currentQueueIndex];
|
||||
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
|
||||
return this.playNext(recursiveCount + 1);
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playTrackFromQueue(0, recursiveCount);
|
||||
});
|
||||
await this.playTrackFromQueue(0, recursiveCount);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
async enableRadio(seeds = []) {
|
||||
|
|
@ -1165,20 +1145,20 @@ export class Player {
|
|||
radioSettings.setEnabled(true);
|
||||
|
||||
if (seeds.length === 0) {
|
||||
this.wipeQueue();
|
||||
await this.wipeQueue();
|
||||
const pickedSeeds = await this.pickRadioSeeds();
|
||||
if (pickedSeeds.length > 0) {
|
||||
this.radioSeeds = pickedSeeds;
|
||||
const initialQueue = [...pickedSeeds].sort(() => 0.5 - Math.random()).slice(0, 5);
|
||||
this.setQueue(initialQueue, 0, true);
|
||||
this.playAtIndex(0);
|
||||
await this.setQueue(initialQueue, 0, true);
|
||||
await this.playAtIndex(0);
|
||||
}
|
||||
} else {
|
||||
this.radioSeeds = Array.isArray(seeds) ? seeds : [seeds];
|
||||
this.wipeQueue();
|
||||
await this.wipeQueue();
|
||||
const initialQueue = Array.isArray(seeds) ? seeds.slice(0, 5) : [seeds];
|
||||
this.setQueue(initialQueue, 0, true);
|
||||
this.playAtIndex(0);
|
||||
await this.setQueue(initialQueue, 0, true);
|
||||
await this.playAtIndex(0);
|
||||
}
|
||||
|
||||
const currentQueue = this.getCurrentQueue();
|
||||
|
|
@ -1241,7 +1221,7 @@ export class Player {
|
|||
|
||||
if (newTracks.length > 0) {
|
||||
const tracksToAdd = newTracks.sort(() => 0.5 - Math.random()).slice(0, 5);
|
||||
this.addToQueue(tracksToAdd);
|
||||
await this.addToQueue(tracksToAdd);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1328,13 +1308,15 @@ export class Player {
|
|||
return;
|
||||
}
|
||||
|
||||
import('./storage.js').then(({ contentBlockingSettings }) => {
|
||||
const track = currentQueue[this.currentQueueIndex];
|
||||
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
|
||||
return this.playPrev(recursiveCount + 1);
|
||||
}
|
||||
this.playTrackFromQueue(0, recursiveCount);
|
||||
});
|
||||
import('./storage.js')
|
||||
.then(async ({ contentBlockingSettings }) => {
|
||||
const track = currentQueue[this.currentQueueIndex];
|
||||
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
|
||||
return this.playPrev(recursiveCount + 1);
|
||||
}
|
||||
await this.playTrackFromQueue(0, recursiveCount);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1342,28 +1324,28 @@ export class Player {
|
|||
return this.currentTrack?.type === 'video' ? this.video : this.audio;
|
||||
}
|
||||
|
||||
handlePlayPause() {
|
||||
async handlePlayPause() {
|
||||
const el = this.activeElement;
|
||||
const hasSource = el.src || el.currentSrc || el.srcObject || this.shakaInitialized;
|
||||
|
||||
if (!hasSource || el.error) {
|
||||
if (this.currentTrack) {
|
||||
this.playTrackFromQueue(0, 0);
|
||||
await this.playTrackFromQueue(0, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (el.paused) {
|
||||
this.safePlay(el).catch((e) => {
|
||||
this.safePlay(el).catch(async (e) => {
|
||||
if (e.name === 'NotAllowedError' || e.name === 'AbortError') return;
|
||||
console.error('Play failed, reloading track:', e);
|
||||
if (this.currentTrack) {
|
||||
this.playTrackFromQueue(0, 0);
|
||||
await this.playTrackFromQueue(0, 0);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
el.pause();
|
||||
this.saveQueueState();
|
||||
await this.saveQueueState();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1382,7 +1364,7 @@ export class Player {
|
|||
this.updateMediaSessionPositionState();
|
||||
}
|
||||
|
||||
toggleShuffle() {
|
||||
async toggleShuffle() {
|
||||
this.shuffleActive = !this.shuffleActive;
|
||||
|
||||
if (this.shuffleActive) {
|
||||
|
|
@ -1413,17 +1395,17 @@ export class Player {
|
|||
}
|
||||
|
||||
this.preloadCache.clear();
|
||||
this.preloadNextTracks();
|
||||
this.saveQueueState();
|
||||
void this.preloadNextTracks().catch(console.error);
|
||||
await this.saveQueueState();
|
||||
}
|
||||
|
||||
toggleRepeat() {
|
||||
async toggleRepeat() {
|
||||
this.repeatMode = (this.repeatMode + 1) % 3;
|
||||
this.saveQueueState();
|
||||
await this.saveQueueState();
|
||||
return this.repeatMode;
|
||||
}
|
||||
|
||||
setQueue(tracks, startIndex = 0, isRadio = false) {
|
||||
async setQueue(tracks, startIndex = 0, isRadio = false) {
|
||||
if (!isRadio) {
|
||||
this.disableRadio();
|
||||
}
|
||||
|
|
@ -1431,7 +1413,7 @@ export class Player {
|
|||
this.currentQueueIndex = startIndex;
|
||||
this.shuffleActive = false;
|
||||
this.preloadCache.clear();
|
||||
this.saveQueueState();
|
||||
await this.saveQueueState();
|
||||
}
|
||||
|
||||
setArtistPopularTracksContext(artistId, initialTracks, offset = 15, hasMore = true) {
|
||||
|
|
@ -1498,7 +1480,7 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
addToQueue(trackOrTracks) {
|
||||
async addToQueue(trackOrTracks) {
|
||||
const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks];
|
||||
this.queue.push(...tracks);
|
||||
|
||||
|
|
@ -1509,12 +1491,12 @@ export class Player {
|
|||
|
||||
if (!this.currentTrack || this.currentQueueIndex === -1) {
|
||||
this.currentQueueIndex = this.getCurrentQueue().length - tracks.length;
|
||||
this.playTrackFromQueue(0, 0);
|
||||
await this.playTrackFromQueue(0, 0);
|
||||
}
|
||||
this.saveQueueState();
|
||||
await this.saveQueueState();
|
||||
}
|
||||
|
||||
addNextToQueue(trackOrTracks) {
|
||||
async addNextToQueue(trackOrTracks) {
|
||||
const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks];
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
const insertIndex = this.currentQueueIndex + 1;
|
||||
|
|
@ -1528,11 +1510,11 @@ export class Player {
|
|||
this.originalQueueBeforeShuffle.push(...tracks); // Sync original queue
|
||||
}
|
||||
|
||||
this.saveQueueState();
|
||||
this.preloadNextTracks(); // Update preload since next track changed
|
||||
await this.saveQueueState();
|
||||
void this.preloadNextTracks().catch(console.error); // Update preload since next track changed
|
||||
}
|
||||
|
||||
removeFromQueue(index) {
|
||||
async removeFromQueue(index) {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
|
||||
// If removing current track
|
||||
|
|
@ -1556,11 +1538,11 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
this.saveQueueState();
|
||||
this.preloadNextTracks();
|
||||
await this.saveQueueState();
|
||||
void this.preloadNextTracks().catch(console.error);
|
||||
}
|
||||
|
||||
clearQueue() {
|
||||
async clearQueue() {
|
||||
if (this.currentTrack) {
|
||||
this.queue = [this.currentTrack];
|
||||
|
||||
|
|
@ -1580,10 +1562,10 @@ export class Player {
|
|||
}
|
||||
|
||||
this.preloadCache.clear();
|
||||
this.saveQueueState();
|
||||
await this.saveQueueState();
|
||||
}
|
||||
|
||||
wipeQueue() {
|
||||
async wipeQueue() {
|
||||
const el = this.activeElement;
|
||||
el.pause();
|
||||
el.src = '';
|
||||
|
|
@ -1592,16 +1574,16 @@ export class Player {
|
|||
this.shuffledQueue = [];
|
||||
this.originalQueueBeforeShuffle = [];
|
||||
this.currentQueueIndex = -1;
|
||||
this.saveQueueState();
|
||||
await this.saveQueueState();
|
||||
if (UIRenderer.instance) {
|
||||
UIRenderer.instance.setCurrentTrack(null);
|
||||
}
|
||||
if (window.renderQueueFunction) {
|
||||
window.renderQueueFunction();
|
||||
await window.renderQueueFunction();
|
||||
}
|
||||
}
|
||||
|
||||
moveInQueue(fromIndex, toIndex) {
|
||||
async moveInQueue(fromIndex, toIndex) {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
|
||||
if (fromIndex < 0 || fromIndex >= currentQueue.length) return;
|
||||
|
|
@ -1617,7 +1599,7 @@ export class Player {
|
|||
} else if (fromIndex > this.currentQueueIndex && toIndex <= this.currentQueueIndex) {
|
||||
this.currentQueueIndex++;
|
||||
}
|
||||
this.saveQueueState();
|
||||
await this.saveQueueState();
|
||||
}
|
||||
|
||||
getCurrentQueue() {
|
||||
|
|
@ -1772,7 +1754,9 @@ export class Player {
|
|||
a.canPlayType('audio/mp4; codecs="ec-3"') || a.canPlayType('audio/mp4; codecs="eac3"')
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// Atmos codec detection may fail on some browsers
|
||||
}
|
||||
|
||||
let isAtmosPlaying = isTrackAtmos && deviceSupportsAtmos;
|
||||
const q = this.quality || localStorage.getItem('adaptive-playback-quality') || 'auto';
|
||||
|
|
@ -1840,7 +1824,7 @@ export class Player {
|
|||
// Re-enable ABR so it can dynamically downgrade within that new codec family if needed
|
||||
this.shakaPlayer.configure({ abr: { enabled: true } });
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// fail silently on abr checks
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,11 +41,11 @@ function getTrackArtists(track) {
|
|||
|
||||
/**
|
||||
* Generates CSV playlist export
|
||||
* @param {Object} playlist - Playlist metadata
|
||||
* @param {Object} _playlist - Playlist metadata
|
||||
* @param {Array} tracks - Array of track objects
|
||||
* @returns {string} CSV content
|
||||
*/
|
||||
export function generateCSV(playlist, tracks) {
|
||||
export function generateCSV(_playlist, tracks) {
|
||||
const headers = ['Track Name', 'Artist Name(s)', 'Album', 'Duration'];
|
||||
let content = headers.map((h) => `"${h}"`).join(',') + '\n';
|
||||
|
||||
|
|
|
|||
258
js/profile.js
258
js/profile.js
|
|
@ -248,30 +248,31 @@ export async function loadProfile(username) {
|
|||
}
|
||||
|
||||
if (profile.lastfm_username && profile.privacy?.lastfm !== 'private') {
|
||||
fetchLastFmRecentTracks(profile.lastfm_username).then(async (tracks) => {
|
||||
if (tracks.length > 0) {
|
||||
recentSection.style.display = 'block';
|
||||
recentContainer.innerHTML = tracks
|
||||
.map((track, index) => {
|
||||
const isNowPlaying = track['@attr']?.nowplaying === 'true';
|
||||
let image = getLastFmImage(track.image);
|
||||
const hasImage = !!image;
|
||||
if (!image) image = '/assets/appicon.png';
|
||||
fetchLastFmRecentTracks(profile.lastfm_username)
|
||||
.then(async (tracks) => {
|
||||
if (tracks.length > 0) {
|
||||
recentSection.style.display = 'block';
|
||||
recentContainer.innerHTML = tracks
|
||||
.map((track, index) => {
|
||||
const isNowPlaying = track['@attr']?.nowplaying === 'true';
|
||||
let image = getLastFmImage(track.image);
|
||||
const hasImage = !!image;
|
||||
if (!image) image = '/assets/appicon.png';
|
||||
|
||||
track._imgId = `scrobble-img-${index}`;
|
||||
track._needsCover = !hasImage;
|
||||
track._imgId = `scrobble-img-${index}`;
|
||||
track._needsCover = !hasImage;
|
||||
|
||||
let dateDisplay = '';
|
||||
if (isNowPlaying) dateDisplay = 'Scrobbling now';
|
||||
else if (track.date) {
|
||||
const date = new Date(track.date.uts * 1000);
|
||||
dateDisplay =
|
||||
date.toLocaleDateString() +
|
||||
' ' +
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
let dateDisplay = '';
|
||||
if (isNowPlaying) dateDisplay = 'Scrobbling now';
|
||||
else if (track.date) {
|
||||
const date = new Date(track.date.uts * 1000);
|
||||
dateDisplay =
|
||||
date.toLocaleDateString() +
|
||||
' ' +
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
return `
|
||||
return `
|
||||
<div class="track-item lastfm-track" data-title="${escapeHtml(track.name)}" data-artist="${escapeHtml(track.artist?.['#text'] || track.artist?.name || '')}" style="grid-template-columns: 40px 1fr auto; cursor: pointer;">
|
||||
<img id="${track._imgId}" src="${image}" class="track-item-cover" style="width: 40px; height: 40px; border-radius: 4px;" loading="lazy" onerror="this.src='/assets/appicon.png'">
|
||||
<div class="track-item-info">
|
||||
|
|
@ -283,39 +284,45 @@ export async function loadProfile(username) {
|
|||
<div class="track-item-duration" style="font-size: 0.8rem; min-width: auto;">${dateDisplay}</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
})
|
||||
.join('');
|
||||
|
||||
recentContainer.querySelectorAll('.track-item').forEach((item) => {
|
||||
item.addEventListener('click', () => handleTrackClick(item.dataset.title, item.dataset.artist));
|
||||
item.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
recentContainer.querySelectorAll('.track-item').forEach((item) => {
|
||||
item.addEventListener('click', () => handleTrackClick(item.dataset.title, item.dataset.artist));
|
||||
item.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
for (const track of tracks) {
|
||||
if (track._needsCover) {
|
||||
fetchFallbackCover(track.name, track.artist?.['#text'] || track.artist?.name, track._imgId);
|
||||
for (const track of tracks) {
|
||||
if (track._needsCover) {
|
||||
await fetchFallbackCover(
|
||||
track.name,
|
||||
track.artist?.['#text'] || track.artist?.name,
|
||||
track._imgId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
fetchLastFmTopArtists(profile.lastfm_username).then(async (artists) => {
|
||||
if (artists.length > 0 && topArtistsSection && topArtistsContainer) {
|
||||
topArtistsSection.style.display = 'block';
|
||||
topArtistsContainer.innerHTML = artists
|
||||
.map((artist, index) => {
|
||||
let image = getLastFmImage(artist.image);
|
||||
const hasImage = !!image;
|
||||
if (!image) image = '/assets/appicon.png';
|
||||
fetchLastFmTopArtists(profile.lastfm_username)
|
||||
.then(async (artists) => {
|
||||
if (artists.length > 0 && topArtistsSection && topArtistsContainer) {
|
||||
topArtistsSection.style.display = 'block';
|
||||
topArtistsContainer.innerHTML = artists
|
||||
.map((artist, index) => {
|
||||
let image = getLastFmImage(artist.image);
|
||||
const hasImage = !!image;
|
||||
if (!image) image = '/assets/appicon.png';
|
||||
|
||||
const imgId = `top-artist-img-${index}`;
|
||||
artist._imgId = imgId;
|
||||
artist._needsCover = !hasImage;
|
||||
const imgId = `top-artist-img-${index}`;
|
||||
artist._imgId = imgId;
|
||||
artist._needsCover = !hasImage;
|
||||
|
||||
return `
|
||||
return `
|
||||
<div class="card artist lastfm-card" data-name="${escapeHtml(artist.name)}" style="cursor: pointer;">
|
||||
<div class="card-image-wrapper">
|
||||
<img id="${imgId}" src="${image}" class="card-image" loading="lazy" onerror="this.src='/assets/appicon.png'">
|
||||
|
|
@ -326,45 +333,47 @@ export async function loadProfile(username) {
|
|||
</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
})
|
||||
.join('');
|
||||
|
||||
topArtistsContainer.querySelectorAll('.card').forEach((card) => {
|
||||
card.addEventListener('click', () => handleArtistClick(card.dataset.name));
|
||||
card.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
topArtistsContainer.querySelectorAll('.card').forEach((card) => {
|
||||
card.addEventListener('click', () => handleArtistClick(card.dataset.name));
|
||||
card.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
for (const artist of artists) {
|
||||
if (artist._needsCover) {
|
||||
fetchFallbackArtistImage(artist.name, artist._imgId);
|
||||
for (const artist of artists) {
|
||||
if (artist._needsCover) {
|
||||
await fetchFallbackArtistImage(artist.name, artist._imgId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
fetchLastFmTopAlbums(profile.lastfm_username).then(async (albums) => {
|
||||
if (albums.length > 0 && topAlbumsSection && topAlbumsContainer) {
|
||||
topAlbumsSection.style.display = 'block';
|
||||
topAlbumsContainer.innerHTML = albums
|
||||
.map((album, index) => {
|
||||
let image = getLastFmImage(album.image);
|
||||
const hasImage = !!image;
|
||||
if (!image) image = '/assets/appicon.png';
|
||||
fetchLastFmTopAlbums(profile.lastfm_username)
|
||||
.then(async (albums) => {
|
||||
if (albums.length > 0 && topAlbumsSection && topAlbumsContainer) {
|
||||
topAlbumsSection.style.display = 'block';
|
||||
topAlbumsContainer.innerHTML = albums
|
||||
.map((album, index) => {
|
||||
let image = getLastFmImage(album.image);
|
||||
const hasImage = !!image;
|
||||
if (!image) image = '/assets/appicon.png';
|
||||
|
||||
const imgId = `top-album-img-${index}`;
|
||||
album._imgId = imgId;
|
||||
album._needsCover = !hasImage;
|
||||
const imgId = `top-album-img-${index}`;
|
||||
album._imgId = imgId;
|
||||
album._needsCover = !hasImage;
|
||||
|
||||
const artistName =
|
||||
album.artist?.name ||
|
||||
album.artist?.['#text'] ||
|
||||
(typeof album.artist === 'string' ? album.artist : 'Unknown Artist');
|
||||
album._artistName = artistName;
|
||||
const artistName =
|
||||
album.artist?.name ||
|
||||
album.artist?.['#text'] ||
|
||||
(typeof album.artist === 'string' ? album.artist : 'Unknown Artist');
|
||||
album._artistName = artistName;
|
||||
|
||||
return `
|
||||
return `
|
||||
<div class="card lastfm-card" data-name="${escapeHtml(album.name)}" data-artist="${escapeHtml(artistName)}" style="cursor: pointer;">
|
||||
<div class="card-image-wrapper">
|
||||
<img id="${imgId}" src="${image}" class="card-image" loading="lazy" onerror="this.src='/assets/appicon.png'">
|
||||
|
|
@ -375,45 +384,47 @@ export async function loadProfile(username) {
|
|||
</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
})
|
||||
.join('');
|
||||
|
||||
topAlbumsContainer.querySelectorAll('.card').forEach((card) => {
|
||||
card.addEventListener('click', () => handleAlbumClick(card.dataset.name, card.dataset.artist));
|
||||
card.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
topAlbumsContainer.querySelectorAll('.card').forEach((card) => {
|
||||
card.addEventListener('click', () => handleAlbumClick(card.dataset.name, card.dataset.artist));
|
||||
card.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
for (const album of albums) {
|
||||
if (album._needsCover) {
|
||||
fetchFallbackAlbumCover(album.name, album._artistName, album._imgId);
|
||||
for (const album of albums) {
|
||||
if (album._needsCover) {
|
||||
await fetchFallbackAlbumCover(album.name, album._artistName, album._imgId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
fetchLastFmTopTracks(profile.lastfm_username).then(async (tracks) => {
|
||||
if (tracks.length > 0 && topTracksSection && topTracksContainer) {
|
||||
topTracksSection.style.display = 'block';
|
||||
topTracksContainer.innerHTML = tracks
|
||||
.map((track, index) => {
|
||||
let image = getLastFmImage(track.image);
|
||||
const hasImage = !!image;
|
||||
if (!image) image = '/assets/appicon.png';
|
||||
fetchLastFmTopTracks(profile.lastfm_username)
|
||||
.then(async (tracks) => {
|
||||
if (tracks.length > 0 && topTracksSection && topTracksContainer) {
|
||||
topTracksSection.style.display = 'block';
|
||||
topTracksContainer.innerHTML = tracks
|
||||
.map((track, index) => {
|
||||
let image = getLastFmImage(track.image);
|
||||
const hasImage = !!image;
|
||||
if (!image) image = '/assets/appicon.png';
|
||||
|
||||
const imgId = `top-track-img-${index}`;
|
||||
track._imgId = imgId;
|
||||
track._needsCover = !hasImage;
|
||||
const imgId = `top-track-img-${index}`;
|
||||
track._imgId = imgId;
|
||||
track._needsCover = !hasImage;
|
||||
|
||||
const artistName =
|
||||
track.artist?.name ||
|
||||
track.artist?.['#text'] ||
|
||||
(typeof track.artist === 'string' ? track.artist : 'Unknown Artist');
|
||||
track._artistName = artistName;
|
||||
const artistName =
|
||||
track.artist?.name ||
|
||||
track.artist?.['#text'] ||
|
||||
(typeof track.artist === 'string' ? track.artist : 'Unknown Artist');
|
||||
track._artistName = artistName;
|
||||
|
||||
return `
|
||||
return `
|
||||
<div class="track-item lastfm-track" data-title="${escapeHtml(track.name)}" data-artist="${escapeHtml(artistName)}" style="grid-template-columns: 40px 1fr auto; cursor: pointer;">
|
||||
<img id="${imgId}" src="${image}" class="track-item-cover" style="width: 40px; height: 40px; border-radius: 4px;" loading="lazy" onerror="this.src='/assets/appicon.png'">
|
||||
<div class="track-item-info">
|
||||
|
|
@ -425,24 +436,25 @@ export async function loadProfile(username) {
|
|||
<div class="track-item-duration" style="font-size: 0.8rem; min-width: auto;">${parseInt(track.playcount).toLocaleString()} plays</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
})
|
||||
.join('');
|
||||
|
||||
topTracksContainer.querySelectorAll('.track-item').forEach((item) => {
|
||||
item.addEventListener('click', () => handleTrackClick(item.dataset.title, item.dataset.artist));
|
||||
item.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
topTracksContainer.querySelectorAll('.track-item').forEach((item) => {
|
||||
item.addEventListener('click', () => handleTrackClick(item.dataset.title, item.dataset.artist));
|
||||
item.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
for (const track of tracks) {
|
||||
if (track._needsCover) {
|
||||
fetchFallbackCover(track.name, track._artistName, track._imgId);
|
||||
for (const track of tracks) {
|
||||
if (track._needsCover) {
|
||||
await fetchFallbackCover(track.name, track._artistName, track._imgId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
const currentUser = await syncManager.getUserData();
|
||||
|
|
@ -483,8 +495,8 @@ export async function loadProfile(username) {
|
|||
}
|
||||
}
|
||||
|
||||
export function openEditProfile() {
|
||||
syncManager.getUserData().then((data) => {
|
||||
export async function openEditProfile() {
|
||||
await syncManager.getUserData().then((data) => {
|
||||
if (!data || !data.profile) return;
|
||||
const p = data.profile;
|
||||
|
||||
|
|
@ -566,7 +578,7 @@ async function saveProfile() {
|
|||
try {
|
||||
await syncManager.updateProfile(data);
|
||||
editProfileModal.classList.remove('active');
|
||||
loadProfile(newUsername);
|
||||
await loadProfile(newUsername);
|
||||
|
||||
if (window.location.pathname.includes('/user/@')) {
|
||||
window.history.replaceState(null, '', `/user/@${newUsername}`);
|
||||
|
|
@ -589,7 +601,7 @@ viewMyProfileBtn.addEventListener('click', async () => {
|
|||
if (data && data.profile && data.profile.username) {
|
||||
navigate(`/user/@${data.profile.username}`);
|
||||
} else {
|
||||
openEditProfile();
|
||||
await openEditProfile();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
declare global {
|
||||
type MonochromeProgress<T = {}> = {
|
||||
type MonochromeProgress<T = object> = {
|
||||
stage: string;
|
||||
} & T;
|
||||
|
||||
type MonochromeProgressMessage<T = MonochromeProgress> = {
|
||||
type MonochromeProgressMessage<_T = MonochromeProgress> = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
|
|
|
|||
4236
js/settings.js
4236
js/settings.js
File diff suppressed because it is too large
Load diff
|
|
@ -118,25 +118,25 @@ export class SidePanelManager {
|
|||
return this.currentView === view && this.panel.classList.contains('active');
|
||||
}
|
||||
|
||||
refresh(view, renderControlsCallback, renderContentCallback, options = {}) {
|
||||
async refresh(view, renderControlsCallback, renderContentCallback, options = {}) {
|
||||
if (this.isActive(view)) {
|
||||
if (renderControlsCallback) {
|
||||
this.controlsElement.innerHTML = '';
|
||||
renderControlsCallback(this.controlsElement);
|
||||
await renderControlsCallback(this.controlsElement);
|
||||
}
|
||||
if (renderContentCallback) {
|
||||
if (!options.noClear) {
|
||||
this.contentElement.innerHTML = '';
|
||||
}
|
||||
renderContentCallback(this.contentElement);
|
||||
await renderContentCallback(this.contentElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateContent(view, renderContentCallback) {
|
||||
async updateContent(view, renderContentCallback) {
|
||||
if (this.isActive(view)) {
|
||||
this.contentElement.innerHTML = '';
|
||||
renderContentCallback(this.contentElement);
|
||||
await renderContentCallback(this.contentElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
357
js/storage.js
357
js/storage.js
|
|
@ -590,6 +590,72 @@ export const dynamicColorSettings = {
|
|||
},
|
||||
};
|
||||
|
||||
export const fullscreenCoverNoRoundSettings = {
|
||||
STORAGE_KEY: 'fullscreen-cover-no-round',
|
||||
|
||||
isEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(this.STORAGE_KEY) !== 'false';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
setEnabled(enabled) {
|
||||
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
};
|
||||
|
||||
export const fullscreenCoverVanillaTiltSettings = {
|
||||
STORAGE_KEY: 'fullscreen-cover-vanilla-tilt',
|
||||
|
||||
isEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(this.STORAGE_KEY) !== 'false';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
setEnabled(enabled) {
|
||||
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
};
|
||||
|
||||
export const fullscreenCoverTiltDistanceSettings = {
|
||||
STORAGE_KEY: 'fullscreen-cover-tilt-distance',
|
||||
|
||||
getValue() {
|
||||
try {
|
||||
const val = parseInt(localStorage.getItem(this.STORAGE_KEY));
|
||||
return val !== null && !isNaN(val) ? val : 10;
|
||||
} catch {
|
||||
return 10;
|
||||
}
|
||||
},
|
||||
|
||||
setValue(value) {
|
||||
localStorage.setItem(this.STORAGE_KEY, value);
|
||||
},
|
||||
};
|
||||
|
||||
export const fullscreenCoverTiltSpeedSettings = {
|
||||
STORAGE_KEY: 'fullscreen-cover-tilt-speed',
|
||||
|
||||
getValue() {
|
||||
try {
|
||||
const val = parseInt(localStorage.getItem(this.STORAGE_KEY));
|
||||
return val !== null && !isNaN(val) ? val : 240;
|
||||
} catch {
|
||||
return 240;
|
||||
}
|
||||
},
|
||||
|
||||
setValue(value) {
|
||||
localStorage.setItem(this.STORAGE_KEY, value);
|
||||
},
|
||||
};
|
||||
|
||||
export const cardSettings = {
|
||||
COMPACT_ARTIST_KEY: 'card-compact-artist',
|
||||
COMPACT_ALBUM_KEY: 'card-compact-album',
|
||||
|
|
@ -999,39 +1065,11 @@ export const visualizerSettings = {
|
|||
},
|
||||
};
|
||||
|
||||
export const playbackSettings = {
|
||||
FULLSCREEN_TILT_KEY: 'playback-fullscreen-tilt',
|
||||
PRELOAD_TIME_KEY: 'playback-preload-time',
|
||||
|
||||
isFullscreenTiltEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(this.FULLSCREEN_TILT_KEY) !== 'false';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
setFullscreenTiltEnabled(enabled) {
|
||||
localStorage.setItem(this.FULLSCREEN_TILT_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
getPreloadTime() {
|
||||
try {
|
||||
const val = localStorage.getItem(this.PRELOAD_TIME_KEY);
|
||||
return val ? parseInt(val, 10) : 15;
|
||||
} catch {
|
||||
return 15;
|
||||
}
|
||||
},
|
||||
|
||||
setPreloadTime(seconds) {
|
||||
localStorage.setItem(this.PRELOAD_TIME_KEY, seconds.toString());
|
||||
},
|
||||
};
|
||||
|
||||
export const equalizerSettings = {
|
||||
ENABLED_KEY: 'equalizer-enabled',
|
||||
GAINS_KEY: 'equalizer-gains',
|
||||
BAND_TYPES_KEY: 'equalizer-band-types',
|
||||
BAND_QS_KEY: 'equalizer-band-qs',
|
||||
PRESET_KEY: 'equalizer-preset',
|
||||
CUSTOM_PRESETS_KEY: 'equalizer-custom-presets',
|
||||
BAND_COUNT_KEY: 'equalizer-band-count',
|
||||
|
|
@ -1040,6 +1078,7 @@ export const equalizerSettings = {
|
|||
FREQ_MIN_KEY: 'equalizer-freq-min',
|
||||
FREQ_MAX_KEY: 'equalizer-freq-max',
|
||||
PREAMP_KEY: 'equalizer-preamp',
|
||||
CUSTOM_FREQUENCIES_KEY: 'equalizer-custom-frequencies',
|
||||
DEFAULT_BAND_COUNT: 16,
|
||||
MIN_BANDS: 3,
|
||||
MAX_BANDS: 32,
|
||||
|
|
@ -1308,6 +1347,100 @@ export const equalizerSettings = {
|
|||
}
|
||||
},
|
||||
|
||||
getCustomFrequencies(bandCount) {
|
||||
const count = bandCount || this.getBandCount();
|
||||
try {
|
||||
const stored = localStorage.getItem(this.CUSTOM_FREQUENCIES_KEY);
|
||||
if (stored) {
|
||||
const freqs = JSON.parse(stored);
|
||||
if (Array.isArray(freqs) && freqs.length === count) {
|
||||
return freqs;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
setCustomFrequencies(frequencies) {
|
||||
try {
|
||||
if (
|
||||
Array.isArray(frequencies) &&
|
||||
frequencies.length >= this.MIN_BANDS &&
|
||||
frequencies.length <= this.MAX_BANDS
|
||||
) {
|
||||
localStorage.setItem(this.CUSTOM_FREQUENCIES_KEY, JSON.stringify(frequencies));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[EQ] Failed to save custom frequencies:', e);
|
||||
}
|
||||
},
|
||||
|
||||
clearCustomFrequencies() {
|
||||
try {
|
||||
localStorage.removeItem(this.CUSTOM_FREQUENCIES_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
getBandTypes(bandCount) {
|
||||
const count = bandCount || this.getBandCount();
|
||||
try {
|
||||
const stored = localStorage.getItem(this.BAND_TYPES_KEY);
|
||||
if (stored) {
|
||||
const types = JSON.parse(stored);
|
||||
if (Array.isArray(types) && types.length === count) {
|
||||
return types;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return new Array(count).fill('peaking');
|
||||
},
|
||||
|
||||
setBandTypes(types) {
|
||||
try {
|
||||
if (Array.isArray(types) && types.length >= this.MIN_BANDS && types.length <= this.MAX_BANDS) {
|
||||
localStorage.setItem(this.BAND_TYPES_KEY, JSON.stringify(types));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[EQ] Failed to save band types:', e);
|
||||
}
|
||||
},
|
||||
|
||||
getBandQs(bandCount) {
|
||||
const count = bandCount || this.getBandCount();
|
||||
try {
|
||||
const stored = localStorage.getItem(this.BAND_QS_KEY);
|
||||
if (stored) {
|
||||
const qs = JSON.parse(stored);
|
||||
if (Array.isArray(qs) && qs.length === count) {
|
||||
return qs;
|
||||
}
|
||||
// Interpolate stored Qs to match requested band count instead of discarding
|
||||
if (Array.isArray(qs) && qs.length >= this.MIN_BANDS) {
|
||||
return this._interpolateGains(qs, count);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
setBandQs(qs) {
|
||||
try {
|
||||
if (Array.isArray(qs) && qs.length >= this.MIN_BANDS && qs.length <= this.MAX_BANDS) {
|
||||
localStorage.setItem(this.BAND_QS_KEY, JSON.stringify(qs));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[EQ] Failed to save band Qs:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Interpolate gains array to match target band count
|
||||
*/
|
||||
|
|
@ -1440,6 +1573,130 @@ export const equalizerSettings = {
|
|||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// AutoEQ Profile Storage
|
||||
// ========================================
|
||||
AUTOEQ_PROFILES_KEY: 'autoeq-saved-profiles',
|
||||
AUTOEQ_ACTIVE_PROFILE_KEY: 'autoeq-active-profile',
|
||||
AUTOEQ_SAMPLE_RATE_KEY: 'autoeq-sample-rate',
|
||||
|
||||
getAutoEQProfiles() {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.AUTOEQ_PROFILES_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
saveAutoEQProfile(profile) {
|
||||
try {
|
||||
const profiles = this.getAutoEQProfiles();
|
||||
const id = profile.id || 'autoeq_' + Date.now();
|
||||
const profileCopy = { ...profile, id };
|
||||
profiles[id] = profileCopy;
|
||||
localStorage.setItem(this.AUTOEQ_PROFILES_KEY, JSON.stringify(profiles));
|
||||
return id;
|
||||
} catch (e) {
|
||||
console.warn('[AutoEQ] Failed to save profile:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
deleteAutoEQProfile(profileId) {
|
||||
try {
|
||||
const profiles = this.getAutoEQProfiles();
|
||||
if (profiles[profileId]) {
|
||||
delete profiles[profileId];
|
||||
localStorage.setItem(this.AUTOEQ_PROFILES_KEY, JSON.stringify(profiles));
|
||||
if (this.getActiveAutoEQProfile() === profileId) {
|
||||
localStorage.removeItem(this.AUTOEQ_ACTIVE_PROFILE_KEY);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.warn('[AutoEQ] Failed to delete profile:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
getActiveAutoEQProfile() {
|
||||
try {
|
||||
return localStorage.getItem(this.AUTOEQ_ACTIVE_PROFILE_KEY) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setActiveAutoEQProfile(profileId) {
|
||||
if (profileId) {
|
||||
localStorage.setItem(this.AUTOEQ_ACTIVE_PROFILE_KEY, profileId);
|
||||
} else {
|
||||
localStorage.removeItem(this.AUTOEQ_ACTIVE_PROFILE_KEY);
|
||||
}
|
||||
},
|
||||
|
||||
getSampleRate() {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.AUTOEQ_SAMPLE_RATE_KEY);
|
||||
const val = parseInt(stored, 10);
|
||||
return [44100, 48000, 96000].includes(val) ? val : 48000;
|
||||
} catch {
|
||||
return 48000;
|
||||
}
|
||||
},
|
||||
|
||||
setSampleRate(rate) {
|
||||
localStorage.setItem(this.AUTOEQ_SAMPLE_RATE_KEY, rate.toString());
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Last Selected Headphone Persistence
|
||||
// ========================================
|
||||
AUTOEQ_LAST_HEADPHONE_KEY: 'autoeq-last-headphone',
|
||||
|
||||
/**
|
||||
* Save the last selected headphone entry + its measurement data
|
||||
* so it persists across page reloads without re-fetching from GitHub
|
||||
* @param {object} entry - {name, type, path, fileName}
|
||||
* @param {Array} measurementData - [{freq, gain}, ...]
|
||||
*/
|
||||
setLastHeadphone(entry, measurementData) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
this.AUTOEQ_LAST_HEADPHONE_KEY,
|
||||
JSON.stringify({
|
||||
entry,
|
||||
measurementData,
|
||||
savedAt: Date.now(),
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[AutoEQ] Failed to save last headphone:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve the last selected headphone entry + cached measurement data
|
||||
* @returns {{entry: object, measurementData: Array}|null}
|
||||
*/
|
||||
getLastHeadphone() {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
|
||||
if (!stored) return null;
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && parsed.entry && parsed.measurementData) return parsed;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
clearLastHeadphone() {
|
||||
localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
export const monoAudioSettings = {
|
||||
|
|
@ -1818,6 +2075,20 @@ export const homePageSettings = {
|
|||
setShuffleEditorsPicks(enabled) {
|
||||
localStorage.setItem(this.SHUFFLE_EDITORS_PICKS_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
EDITORS_PICKS_SOURCE_KEY: 'home-editors-picks-source',
|
||||
|
||||
getEditorsPicksSource() {
|
||||
try {
|
||||
return localStorage.getItem(this.EDITORS_PICKS_SOURCE_KEY) || 'current';
|
||||
} catch {
|
||||
return 'current';
|
||||
}
|
||||
},
|
||||
|
||||
setEditorsPicksSource(source) {
|
||||
localStorage.setItem(this.EDITORS_PICKS_SOURCE_KEY, source);
|
||||
},
|
||||
};
|
||||
|
||||
export const radioSettings = {
|
||||
|
|
@ -2382,18 +2653,18 @@ export const fontSettings = {
|
|||
document.documentElement.style.setProperty('--font-family', "'SF Pro Display', sans-serif");
|
||||
},
|
||||
|
||||
applyFont() {
|
||||
async applyFont() {
|
||||
const config = this.getConfig();
|
||||
|
||||
switch (config.type) {
|
||||
case 'google':
|
||||
this.loadGoogleFont(config.family);
|
||||
await this.loadGoogleFont(config.family);
|
||||
break;
|
||||
case 'url':
|
||||
this.loadFontFromUrl(config.url, config.family);
|
||||
await this.loadFontFromUrl(config.url, config.family);
|
||||
break;
|
||||
case 'uploaded':
|
||||
this.loadUploadedFont(config.fontId);
|
||||
await this.loadUploadedFont(config.fontId);
|
||||
break;
|
||||
case 'preset':
|
||||
default:
|
||||
|
|
@ -2573,13 +2844,13 @@ export const contentBlockingSettings = {
|
|||
|
||||
isArtistBlocked(artistId) {
|
||||
if (!artistId) return false;
|
||||
return this.getBlockedArtists().some((a) => a.id == artistId);
|
||||
return this.getBlockedArtists().some((a) => String(a.id) === String(artistId));
|
||||
},
|
||||
|
||||
blockArtist(artist) {
|
||||
if (!artist || !artist.id) return;
|
||||
const blocked = this.getBlockedArtists();
|
||||
if (!blocked.some((a) => a.id === artist.id)) {
|
||||
if (!blocked.some((a) => String(a.id) === String(artist.id))) {
|
||||
blocked.push({
|
||||
id: artist.id,
|
||||
name: artist.name || 'Unknown Artist',
|
||||
|
|
@ -2590,7 +2861,7 @@ export const contentBlockingSettings = {
|
|||
},
|
||||
|
||||
unblockArtist(artistId) {
|
||||
const blocked = this.getBlockedArtists().filter((a) => a.id != artistId);
|
||||
const blocked = this.getBlockedArtists().filter((a) => String(a.id) !== String(artistId));
|
||||
this.setBlockedArtists(blocked);
|
||||
},
|
||||
|
||||
|
|
@ -2610,13 +2881,13 @@ export const contentBlockingSettings = {
|
|||
|
||||
isTrackBlocked(trackId) {
|
||||
if (!trackId) return false;
|
||||
return this.getBlockedTracks().some((t) => t.id == trackId);
|
||||
return this.getBlockedTracks().some((t) => String(t.id) === String(trackId));
|
||||
},
|
||||
|
||||
blockTrack(track) {
|
||||
if (!track || !track.id) return;
|
||||
const blocked = this.getBlockedTracks();
|
||||
if (!blocked.some((t) => t.id == track.id)) {
|
||||
if (!blocked.some((t) => String(t.id) === String(track.id))) {
|
||||
blocked.push({
|
||||
id: track.id,
|
||||
title: track.title || 'Unknown Track',
|
||||
|
|
@ -2628,7 +2899,7 @@ export const contentBlockingSettings = {
|
|||
},
|
||||
|
||||
unblockTrack(trackId) {
|
||||
const blocked = this.getBlockedTracks().filter((t) => t.id != trackId);
|
||||
const blocked = this.getBlockedTracks().filter((t) => String(t.id) !== String(trackId));
|
||||
this.setBlockedTracks(blocked);
|
||||
},
|
||||
|
||||
|
|
@ -2648,13 +2919,13 @@ export const contentBlockingSettings = {
|
|||
|
||||
isAlbumBlocked(albumId) {
|
||||
if (!albumId) return false;
|
||||
return this.getBlockedAlbums().some((a) => a.id == albumId);
|
||||
return this.getBlockedAlbums().some((a) => String(a.id) === String(albumId));
|
||||
},
|
||||
|
||||
blockAlbum(album) {
|
||||
if (!album || !album.id) return;
|
||||
const blocked = this.getBlockedAlbums();
|
||||
if (!blocked.some((a) => a.id == album.id)) {
|
||||
if (!blocked.some((a) => String(a.id) === String(album.id))) {
|
||||
blocked.push({
|
||||
id: album.id,
|
||||
title: album.title || 'Unknown Album',
|
||||
|
|
@ -2666,7 +2937,7 @@ export const contentBlockingSettings = {
|
|||
},
|
||||
|
||||
unblockAlbum(albumId) {
|
||||
const blocked = this.getBlockedAlbums().filter((a) => a.id != albumId);
|
||||
const blocked = this.getBlockedAlbums().filter((a) => String(a.id) !== String(albumId));
|
||||
this.setBlockedAlbums(blocked);
|
||||
},
|
||||
|
||||
|
|
|
|||
28
js/taglib.ts
28
js/taglib.ts
|
|
@ -22,7 +22,11 @@ export async function withTimeout<T>(callback: () => Promise<T>, timeout: number
|
|||
})
|
||||
.catch((err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
if (err instanceof Error) {
|
||||
reject(err);
|
||||
} else {
|
||||
reject(new Error(String(err)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -33,7 +37,7 @@ function toUint8Array(audioData: ArrayBufferLike | Uint8Array) {
|
|||
}
|
||||
|
||||
return doTimed(
|
||||
`Converting audio data (${(audioData as any)?.constructor?.name}) to Uint8Array`,
|
||||
`Converting audio data (${(audioData as object)?.constructor?.name}) to Uint8Array`,
|
||||
() => new Uint8Array(audioData)
|
||||
);
|
||||
}
|
||||
|
|
@ -60,7 +64,7 @@ async function convertInputToTaglib<R = TagLibReadTypes>(
|
|||
return (await doTimedAsync('Reading File from FileSystemHandle as Uint8Array', async () => {
|
||||
const file = await audioData.getFile();
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
return await toUint8Array(arrayBuffer);
|
||||
return toUint8Array(arrayBuffer);
|
||||
})) as R;
|
||||
} else if (
|
||||
!(audioData instanceof Uint8Array) &&
|
||||
|
|
@ -69,7 +73,7 @@ async function convertInputToTaglib<R = TagLibReadTypes>(
|
|||
!('FileSystemFileEntry' in globalThis && audioData instanceof FileSystemFileEntry) &&
|
||||
!('FileSystemFileHandle' in globalThis && audioData instanceof FileSystemFileHandle)
|
||||
) {
|
||||
return toUint8Array(audioData as any) as R;
|
||||
return toUint8Array(audioData as unknown as ArrayBufferLike) as R;
|
||||
}
|
||||
|
||||
return audioData as R;
|
||||
|
|
@ -114,19 +118,19 @@ export async function addMetadataWithTagLib(
|
|||
if (error) {
|
||||
reject(new Error(error));
|
||||
} else {
|
||||
resolve(data!);
|
||||
resolve(data);
|
||||
}
|
||||
};
|
||||
worker.onerror = reject;
|
||||
worker.onmessageerror = reject;
|
||||
|
||||
const transferables: Transferable[] = [];
|
||||
if ((audioData as any)?.buffer instanceof ArrayBuffer) {
|
||||
transferables.push((audioData as any).buffer);
|
||||
if ((audioData as Uint8Array)?.buffer instanceof ArrayBuffer) {
|
||||
transferables.push((audioData as Uint8Array).buffer);
|
||||
}
|
||||
|
||||
if ((data as any).cover?.data?.buffer instanceof ArrayBuffer) {
|
||||
transferables.push((data as any).cover.data.buffer);
|
||||
if (data.cover?.data?.buffer instanceof ArrayBuffer) {
|
||||
transferables.push(data.cover.data.buffer);
|
||||
}
|
||||
|
||||
worker.postMessage({ ...data, type: 'Add', audioData, filename }, transferables);
|
||||
|
|
@ -168,15 +172,15 @@ export async function getMetadataWithTagLib(
|
|||
if (error) {
|
||||
reject(new Error(error));
|
||||
} else {
|
||||
resolve(data!);
|
||||
resolve(data);
|
||||
}
|
||||
};
|
||||
worker.onerror = reject;
|
||||
worker.onmessageerror = reject;
|
||||
|
||||
const transferables: Transferable[] = [];
|
||||
if ((audioData as any)?.buffer instanceof ArrayBuffer) {
|
||||
transferables.push((audioData as any).buffer);
|
||||
if ((audioData as Uint8Array)?.buffer instanceof ArrayBuffer) {
|
||||
transferables.push((audioData as Uint8Array).buffer);
|
||||
}
|
||||
worker.postMessage({ type: 'Get', audioData, filename }, transferables);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ export interface TagLibWorkerResponse<T> {
|
|||
|
||||
export interface TagLibMetadata {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
artist?: string | string[];
|
||||
writeArtistsSeparately?: boolean;
|
||||
albumTitle?: string;
|
||||
albumArtist?: string;
|
||||
trackNumber?: number;
|
||||
|
|
@ -51,7 +52,6 @@ export enum Mp4Stik {
|
|||
WhackedBookmark = 5,
|
||||
MusicVideo = 6,
|
||||
Movie = 9,
|
||||
ShortFilm = 9,
|
||||
TVShow = 10,
|
||||
Booklet = 11,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// filepath: /workspaces/monochrome/js/taglib.worker.ts
|
||||
declare var self: DedicatedWorkerGlobalScope;
|
||||
declare let self: DedicatedWorkerGlobalScope;
|
||||
|
||||
import { ByteVector } from '!/@dantheman827/taglib-ts/src/byteVector.js';
|
||||
import { Mp4Tag, Mp4Item } from '!/@dantheman827/taglib-ts/src/mp4/mp4Tag.js';
|
||||
import { Mp4Item } from '!/@dantheman827/taglib-ts/src/mp4/mp4Tag.js';
|
||||
import { Variant } from '!/@dantheman827/taglib-ts/src/toolkit/variant.js';
|
||||
import { doTimed, doTimedAsync } from './doTimed';
|
||||
import {
|
||||
|
|
@ -10,7 +10,6 @@ import {
|
|||
type _AddMetadataMessage,
|
||||
type _GetMetadataMessage,
|
||||
type AddMetadataMessage,
|
||||
type GetMetadataMessage,
|
||||
type TagLibFileResponse,
|
||||
type TagLibMetadata,
|
||||
type TagLibMetadataResponse,
|
||||
|
|
@ -18,6 +17,7 @@ import {
|
|||
type TagLibWorkerMessage,
|
||||
type TagLibWorkerResponse,
|
||||
} from './taglib.types';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { File as TagLibFile } from '!/@dantheman827/taglib-ts/src/file.js';
|
||||
import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js';
|
||||
import { ChunkedByteVectorStream } from '!/@dantheman827/taglib-ts/src/toolkit/chunkedByteVectorStream.js';
|
||||
|
|
@ -29,8 +29,8 @@ import { FileSystemFileHandleStream } from '!/@dantheman827/taglib-ts/src/toolki
|
|||
import { FlacFile } from '!/@dantheman827/taglib-ts/src/flac/flacFile.js';
|
||||
import { MpegFile } from '!/@dantheman827/taglib-ts/src/mpeg/mpegFile.js';
|
||||
import { Mp4File } from '!/@dantheman827/taglib-ts/src/mp4/mp4File.js';
|
||||
import { OggFile } from '!/@dantheman827/taglib-ts/src/ogg/oggFile.js';
|
||||
import { OggVorbisFile } from '!/@dantheman827/taglib-ts/src/ogg/vorbis/vorbisFile.js';
|
||||
import { WavFile } from '!/@dantheman827/taglib-ts/src/riff/wav/wavFile';
|
||||
|
||||
export const isWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
|
||||
|
||||
|
|
@ -38,9 +38,10 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
|
|||
const {
|
||||
audioData,
|
||||
audioRef,
|
||||
filename,
|
||||
filename: _filename,
|
||||
title,
|
||||
artist,
|
||||
writeArtistsSeparately = false,
|
||||
albumTitle,
|
||||
albumArtist,
|
||||
trackNumber,
|
||||
|
|
@ -74,17 +75,26 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
|
|||
}
|
||||
|
||||
const underlying = ref.file();
|
||||
const isFlac = underlying instanceof FlacFile;
|
||||
const isMp4 = underlying instanceof Mp4File;
|
||||
const isMpeg = underlying instanceof MpegFile;
|
||||
const isOgg = underlying instanceof OggVorbisFile;
|
||||
const _isWav = underlying instanceof WavFile;
|
||||
|
||||
const needsCombinedTrackDisc = isMp4 || isMpeg;
|
||||
|
||||
const artistArray = Array.isArray(artist) ? artist : artist ? [artist] : [];
|
||||
const supportsMultiValuedArtist = writeArtistsSeparately && (isFlac || isOgg || isMp4);
|
||||
|
||||
doTimed('Tagging file', () => {
|
||||
const props = ref.properties();
|
||||
|
||||
if (title) props.replace('TITLE', [title]);
|
||||
if (artist) props.replace('ARTIST', [artist]);
|
||||
if (artistArray.length)
|
||||
props.replace('ARTIST', supportsMultiValuedArtist ? artistArray : [artistArray.join('; ')]);
|
||||
if (albumTitle) props.replace('ALBUM', [albumTitle]);
|
||||
if (albumArtist || artist) props.replace('ALBUMARTIST', [albumArtist || artist!]);
|
||||
if (albumArtist || artistArray.length)
|
||||
props.replace('ALBUMARTIST', albumArtist ? [albumArtist] : [artistArray.join('; ')]);
|
||||
|
||||
if (trackNumber) {
|
||||
const trackStr =
|
||||
|
|
@ -127,7 +137,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
|
|||
if (copyright) props.replace('COPYRIGHT', [copyright]);
|
||||
if (isrc) props.replace('ISRC', [isrc]);
|
||||
if (isrc && isMp4) {
|
||||
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag;
|
||||
const mp4Tag = underlying.tag();
|
||||
mp4Tag.setItem('xid ', Mp4Item.fromStringList([`:isrc:${isrc}`]));
|
||||
}
|
||||
if (upc) props.replace('UPC', [upc]);
|
||||
|
|
@ -136,7 +146,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
|
|||
if (explicit !== undefined) {
|
||||
if (isMp4) {
|
||||
// rtng is a byte item — must be set directly on the Mp4Tag
|
||||
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag;
|
||||
const mp4Tag = underlying.tag();
|
||||
mp4Tag.setItem('rtng', Mp4Item.fromByte(explicit ? 1 : 0));
|
||||
} else {
|
||||
props.replace('ITUNESADVISORY', [explicit ? '1' : '0']);
|
||||
|
|
@ -144,7 +154,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
|
|||
}
|
||||
|
||||
if (stik != null && isMp4) {
|
||||
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag;
|
||||
const mp4Tag = underlying.tag();
|
||||
mp4Tag.setItem('stik', Mp4Item.fromByte(stik));
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +177,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
|
|||
await ref.save();
|
||||
});
|
||||
|
||||
const file = ref.file() as TagLibFile;
|
||||
const file = ref.file();
|
||||
if (!file) return audioData;
|
||||
const stream = file.stream();
|
||||
|
||||
|
|
@ -197,7 +207,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
|
|||
}
|
||||
|
||||
export async function getMetadataFromAudio(message: _GetMetadataMessage): Promise<TagLibReadMetadata> {
|
||||
const { audioData, audioRef, filename } = message;
|
||||
const { audioData, audioRef } = message;
|
||||
const data: TagLibReadMetadata = { duration: 0 };
|
||||
|
||||
const ref =
|
||||
|
|
@ -253,7 +263,7 @@ export async function getMetadataFromAudio(message: _GetMetadataMessage): Promis
|
|||
data.isrc = props.get('ISRC')?.[0] || undefined;
|
||||
|
||||
if (isMp4) {
|
||||
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag;
|
||||
const mp4Tag = underlying.tag();
|
||||
data.explicit = mp4Tag.item('rtng')?.toByte() === 1;
|
||||
} else {
|
||||
data.explicit = props.get('ITUNESADVISORY')?.[0] === '1';
|
||||
|
|
|
|||
|
|
@ -48,9 +48,9 @@ export class ThemeStore {
|
|||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('open-theme-store-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('open-theme-store-btn')?.addEventListener('click', async () => {
|
||||
this.modal.classList.add('active');
|
||||
this.loadThemes();
|
||||
await this.loadThemes();
|
||||
});
|
||||
|
||||
this.modal?.querySelector('.close-modal-btn')?.addEventListener('click', () => {
|
||||
|
|
@ -59,14 +59,14 @@ export class ThemeStore {
|
|||
|
||||
const tabs = this.modal?.querySelectorAll('.search-tab');
|
||||
tabs?.forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
tab.addEventListener('click', async () => {
|
||||
tabs.forEach((t) => t.classList.remove('active'));
|
||||
this.modal.querySelectorAll('.search-tab-content').forEach((c) => c.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
const contentId = tab.dataset.tab === 'browse' ? 'theme-store-browse' : 'theme-store-upload';
|
||||
document.getElementById(contentId)?.classList.add('active');
|
||||
if (tab.dataset.tab === 'upload') {
|
||||
this.checkAuth();
|
||||
await this.checkAuth();
|
||||
} else {
|
||||
this.resetEditState();
|
||||
}
|
||||
|
|
@ -82,9 +82,9 @@ export class ThemeStore {
|
|||
this.uploadForm?.addEventListener('submit', (e) => this.handleUpload(e));
|
||||
|
||||
if (authManager) {
|
||||
authManager.onAuthStateChanged(() => {
|
||||
authManager.onAuthStateChanged(async () => {
|
||||
if (this.modal.classList.contains('active')) {
|
||||
this.checkAuth();
|
||||
await this.checkAuth();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -231,10 +231,10 @@ export class ThemeStore {
|
|||
</div>
|
||||
`;
|
||||
|
||||
div.addEventListener('click', (e) => {
|
||||
div.addEventListener('click', async (e) => {
|
||||
if (e.target.closest('.delete-theme-btn')) {
|
||||
e.stopPropagation();
|
||||
this.deleteTheme(theme.id);
|
||||
await this.deleteTheme(theme.id);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('.edit-theme-btn')) {
|
||||
|
|
@ -266,7 +266,7 @@ export class ThemeStore {
|
|||
|
||||
await this.pb.collection('themes').delete(themeId, { f_id: fbUser.$id });
|
||||
alert('Theme deleted successfully.');
|
||||
this.loadThemes();
|
||||
await this.loadThemes();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete theme:', err);
|
||||
alert('Failed to delete theme. You might not have permission.');
|
||||
|
|
@ -467,6 +467,7 @@ export class ThemeStore {
|
|||
|
||||
// Force reflow to ensure theme changes are applied immediately
|
||||
document.documentElement.style.display = 'none';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
document.documentElement.offsetHeight;
|
||||
document.documentElement.style.display = '';
|
||||
|
||||
|
|
@ -572,7 +573,7 @@ export class ThemeStore {
|
|||
}
|
||||
|
||||
this.modal.querySelector('[data-tab="browse"]').click();
|
||||
this.loadThemes();
|
||||
await this.loadThemes();
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
console.error('Response data:', err.data);
|
||||
|
|
|
|||
|
|
@ -107,7 +107,11 @@ function transformErasImages(eras) {
|
|||
}
|
||||
|
||||
async function fetchTrackerData(sheetId) {
|
||||
const endpoints = ['https://trackerapi-1.artistgrid.cx/get/', 'https://trackerapi-2.artistgrid.cx/get/'];
|
||||
const endpoints = [
|
||||
'https://trackerapi-1.artistgrid.cx/get/',
|
||||
'https://trackerapi-2.artistgrid.cx/get/',
|
||||
'https://trackerapi-3.artistgrid.cx/get/',
|
||||
];
|
||||
|
||||
let lastError = null;
|
||||
for (const baseUrl of endpoints) {
|
||||
|
|
@ -276,7 +280,7 @@ function renderTrackerTracks(container, tracks) {
|
|||
}
|
||||
|
||||
// Create project card HTML - EXACTLY like album cards
|
||||
export function createProjectCardHTML(era, artist, sheetId, trackCount) {
|
||||
export function createProjectCardHTML(era, _artist, sheetId, trackCount) {
|
||||
const playBtnHTML = `
|
||||
<button class="play-btn card-play-btn" data-action="play-card" data-type="tracker-project" data-id="${encodeURIComponent(era.name)}" title="Play">
|
||||
${SVG_PLAY(20)}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
|
||||
if (playlistId && folderId) {
|
||||
const updatedFolder = await db.addPlaylistToFolder(folderId, playlistId);
|
||||
syncManager.syncUserFolder(updatedFolder, 'update');
|
||||
await syncManager.syncUserFolder(updatedFolder, 'update');
|
||||
const subtitle = folderCard.querySelector('.card-subtitle');
|
||||
if (subtitle) {
|
||||
subtitle.textContent = `${updatedFolder.playlists.length} playlists`;
|
||||
|
|
@ -112,7 +112,7 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
});
|
||||
|
||||
// Queue panel
|
||||
const renderQueueControls = (container) => {
|
||||
const renderQueueControls = async (container) => {
|
||||
const currentQueue = player.getCurrentQueue();
|
||||
const showActionBtns = currentQueue.length > 0;
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
const downloadBtn = container.querySelector('#download-queue-btn');
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', async () => {
|
||||
downloadTracks(currentQueue, api, downloadQualitySettings.getQuality());
|
||||
await downloadTracks(currentQueue, api, downloadQualitySettings.getQuality());
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
for (const track of currentQueue) {
|
||||
const wasAdded = await db.toggleFavorite('track', track);
|
||||
if (wasAdded) {
|
||||
syncManager.syncLibraryItem('track', track, true);
|
||||
await syncManager.syncLibraryItem('track', track, true);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
|
@ -163,7 +163,7 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
showNotification('All tracks in queue are already liked');
|
||||
}
|
||||
|
||||
refreshQueuePanel();
|
||||
await refreshQueuePanel();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +222,7 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
}
|
||||
|
||||
const updatedPlaylist = await db.getPlaylist(playlistId);
|
||||
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
|
||||
showNotification(`Added ${addedCount} tracks to playlist: ${playlistName}`);
|
||||
} catch (error) {
|
||||
|
|
@ -238,9 +238,9 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
|
||||
const clearBtn = container.querySelector('#clear-queue-btn');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
clearBtn.addEventListener('click', async () => {
|
||||
player.clearQueue();
|
||||
refreshQueuePanel();
|
||||
await refreshQueuePanel();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -283,7 +283,7 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
`;
|
||||
};
|
||||
|
||||
const attachQueueListeners = (container) => {
|
||||
const attachQueueListeners = async (container) => {
|
||||
if (container._queueListenersAttached) return;
|
||||
|
||||
container.addEventListener('click', async (e) => {
|
||||
|
|
@ -295,7 +295,7 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
if (removeBtn) {
|
||||
e.stopPropagation();
|
||||
player.removeFromQueue(index);
|
||||
refreshQueuePanel();
|
||||
await refreshQueuePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -305,12 +305,12 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
const track = player.getCurrentQueue()[index];
|
||||
if (track) {
|
||||
const added = await db.toggleFavorite('track', track);
|
||||
syncManager.syncLibraryItem('track', track, added);
|
||||
await syncManager.syncLibraryItem('track', track, added);
|
||||
|
||||
likeBtn.classList.toggle('active', added);
|
||||
likeBtn.innerHTML = added ? SVG_HEART_FILLED(20) : SVG_HEART(20);
|
||||
|
||||
hapticSuccess();
|
||||
await hapticSuccess();
|
||||
showNotification(added ? `Added to Liked: ${track.title}` : `Removed from Liked: ${track.title}`);
|
||||
}
|
||||
return;
|
||||
|
|
@ -319,7 +319,7 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
if (item.classList.contains('blocked')) return;
|
||||
|
||||
player.playAtIndex(index);
|
||||
refreshQueuePanel();
|
||||
await refreshQueuePanel();
|
||||
});
|
||||
|
||||
container.addEventListener('contextmenu', async (e) => {
|
||||
|
|
@ -369,14 +369,14 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
e.preventDefault();
|
||||
});
|
||||
|
||||
container.addEventListener('drop', (e) => {
|
||||
container.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
const item = e.target.closest('.queue-track-item');
|
||||
if (item && draggedQueueIndex !== null) {
|
||||
const index = parseInt(item.dataset.queueIndex);
|
||||
if (draggedQueueIndex !== index) {
|
||||
player.moveInQueue(draggedQueueIndex, index);
|
||||
refreshQueuePanel();
|
||||
await refreshQueuePanel();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -384,7 +384,7 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
container._queueListenersAttached = true;
|
||||
};
|
||||
|
||||
const renderQueueContent = (container, isUpdate = false) => {
|
||||
const renderQueueContent = async (container, isUpdate = false) => {
|
||||
const currentQueue = player.getCurrentQueue();
|
||||
|
||||
if (currentQueue.length === 0) {
|
||||
|
|
@ -395,7 +395,7 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
}
|
||||
|
||||
isQueueRendering = true;
|
||||
attachQueueListeners(container);
|
||||
await attachQueueListeners(container);
|
||||
|
||||
if (currentQueue.length > QUEUE_VIRTUALIZATION_THRESHOLD) {
|
||||
if (!isUpdate) {
|
||||
|
|
@ -422,26 +422,26 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
if (bottomObserver) bottomObserver.disconnect();
|
||||
|
||||
bottomObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
async (entries) => {
|
||||
if (entries[0].isIntersecting && !isQueueRendering && queueEndIndex < currentQueue.length) {
|
||||
queueEndIndex = Math.min(currentQueue.length, queueEndIndex + QUEUE_CHUNK_SIZE);
|
||||
if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) {
|
||||
queueStartIndex += QUEUE_CHUNK_SIZE;
|
||||
}
|
||||
renderQueueContent(container, true);
|
||||
await renderQueueContent(container, true);
|
||||
}
|
||||
},
|
||||
{ root: container, rootMargin: '200px' }
|
||||
);
|
||||
|
||||
topObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
async (entries) => {
|
||||
if (entries[0].isIntersecting && !isQueueRendering && queueStartIndex > 0) {
|
||||
queueStartIndex = Math.max(0, queueStartIndex - QUEUE_CHUNK_SIZE);
|
||||
if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) {
|
||||
queueEndIndex -= QUEUE_CHUNK_SIZE;
|
||||
}
|
||||
renderQueueContent(container, true);
|
||||
await renderQueueContent(container, true);
|
||||
}
|
||||
},
|
||||
{ root: container, rootMargin: '200px' }
|
||||
|
|
@ -469,8 +469,8 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
isQueueRendering = false;
|
||||
};
|
||||
|
||||
const refreshQueuePanel = () => {
|
||||
sidePanelManager.refresh('queue', renderQueueControls, renderQueueContent, { noClear: true });
|
||||
const refreshQueuePanel = async () => {
|
||||
await sidePanelManager.refresh('queue', renderQueueControls, renderQueueContent, { noClear: true });
|
||||
};
|
||||
|
||||
const openQueuePanel = () => {
|
||||
|
|
@ -489,9 +489,9 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
queueBtn.addEventListener('click', openQueuePanel);
|
||||
|
||||
// Expose renderQueue for external updates (e.g. shuffle, add to queue)
|
||||
window.renderQueueFunction = () => {
|
||||
window.renderQueueFunction = async () => {
|
||||
if (sidePanelManager.isActive('queue')) {
|
||||
refreshQueuePanel();
|
||||
await refreshQueuePanel();
|
||||
}
|
||||
|
||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||
|
|
@ -519,7 +519,7 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
if (playlistId && folderId) {
|
||||
try {
|
||||
const updatedFolder = await db.addPlaylistToFolder(folderId, playlistId);
|
||||
syncManager.syncUserFolder(updatedFolder, 'update');
|
||||
await syncManager.syncUserFolder(updatedFolder, 'update');
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
showNotification('Playlist added to folder');
|
||||
} catch (error) {
|
||||
|
|
@ -562,9 +562,11 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
document.getElementById(contentId)?.classList.add('active');
|
||||
|
||||
// Save active tab
|
||||
import('./storage.js').then(({ settingsUiState }) => {
|
||||
settingsUiState.setActiveTab(tab.dataset.tab);
|
||||
});
|
||||
import('./storage.js')
|
||||
.then(({ settingsUiState }) => {
|
||||
settingsUiState.setActiveTab(tab.dataset.tab);
|
||||
})
|
||||
.catch(console.error);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
16
js/utils.js
16
js/utils.js
|
|
@ -616,10 +616,10 @@ export const getShareUrl = (path) => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Builds a full artist string by combining the track's listed artists
|
||||
* Builds a full artist array by combining the track's listed artists
|
||||
* with any featured artists parsed from the title (feat./with).
|
||||
*/
|
||||
export function getFullArtistString(track) {
|
||||
export function getFullArtistArray(track) {
|
||||
const knownArtists =
|
||||
Array.isArray(track.artists) && track.artists.length > 0
|
||||
? track.artists.map((a) => (typeof a === 'string' ? a : a.name) || '').filter(Boolean)
|
||||
|
|
@ -646,6 +646,16 @@ export function getFullArtistString(track) {
|
|||
}
|
||||
}
|
||||
|
||||
return knownArtists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a full artist string by combining the track's listed artists
|
||||
* with any featured artists parsed from the title (feat./with).
|
||||
*/
|
||||
export function getFullArtistString(track) {
|
||||
const knownArtists = getFullArtistArray(track);
|
||||
|
||||
return knownArtists.join('; ') || null;
|
||||
}
|
||||
|
||||
|
|
@ -654,7 +664,7 @@ export function fetchBlob(url) {
|
|||
}
|
||||
|
||||
export async function fetchBlobURL(url) {
|
||||
return await URL.createObjectURL(await fetchBlob(url));
|
||||
return URL.createObjectURL(await fetchBlob(url));
|
||||
}
|
||||
|
||||
export function getMimeType(data) {
|
||||
|
|
|
|||
|
|
@ -133,14 +133,14 @@ export class Visualizer {
|
|||
this._currentContextType = type;
|
||||
}
|
||||
|
||||
start() {
|
||||
async start() {
|
||||
if (this.isActive) return;
|
||||
|
||||
if (!this.ctx) {
|
||||
this.initContext();
|
||||
}
|
||||
if (!this.audioContext) {
|
||||
this.init();
|
||||
await this.init();
|
||||
}
|
||||
|
||||
if (!this.analyser) {
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export function onButterchurnPresetsLoaded(callback) {
|
|||
}
|
||||
|
||||
// Start loading presets immediately when module is imported (lazy loaded)
|
||||
loadPresetsModule();
|
||||
loadPresetsModule().catch(console.error);
|
||||
|
||||
export class ButterchurnPreset {
|
||||
constructor() {
|
||||
|
|
@ -191,7 +191,7 @@ export class ButterchurnPreset {
|
|||
/**
|
||||
* Initialize Butterchurn with the given WebGL context
|
||||
*/
|
||||
init(canvas, gl, audioContext, sourceNode) {
|
||||
init(canvas, _gl, audioContext, sourceNode) {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
try {
|
||||
|
|
@ -418,7 +418,7 @@ export class ButterchurnPreset {
|
|||
/**
|
||||
* Main draw function called each animation frame
|
||||
*/
|
||||
draw(ctx, canvas, analyser, dataArray, params) {
|
||||
draw(_ctx, canvas, _analyser, _dataArray, params) {
|
||||
if (!this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ export class KawarpPreset {
|
|||
if (this.kawarp) this.kawarp.resize();
|
||||
}
|
||||
|
||||
draw(ctx, canvas, analyser, dataArray, stats) {
|
||||
draw(_ctx, canvas, analyser, _dataArray, stats) {
|
||||
if (!this.kawarp || !this.isInitialized) return;
|
||||
|
||||
this._ensureStarted();
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export class ParticlesPreset {
|
|||
// No cleanup needed
|
||||
}
|
||||
|
||||
draw(ctx, canvas, analyser, dataArray, params) {
|
||||
draw(ctx, canvas, _analyser, _dataArray, params) {
|
||||
const { width, height } = canvas;
|
||||
const { kick, intensity, primaryColor, mode } = params;
|
||||
const sensitivity = params.sensitivity || 1.0;
|
||||
|
|
|
|||
15316
package-lock.json
generated
15316
package-lock.json
generated
File diff suppressed because it is too large
Load diff
22
package.json
22
package.json
|
|
@ -5,7 +5,12 @@
|
|||
"description": "[<img src=\"https://github.com/monochrome-music/monochrome/blob/main/assets/512.png?raw=true\" alt=\"Monochrome Logo\">](https://monochrome.tf)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run --config=vite.config.ts",
|
||||
"test:headless": "HEADLESS=true vitest run --config=vite.config.ts",
|
||||
"test:watch": "vitest --config=vite.config.ts",
|
||||
"test:watch:headless": "HEADLESS=true vitest --config=vite.config.ts",
|
||||
"install:playwright": "playwright install chromium",
|
||||
"build": "vite build && vite-bundle-visualizer -o dist/assets/bundle-stats.html --open false",
|
||||
"postbuild": "node -e \"const fs = require('fs'); const path = require('path'); const src = 'extensions'; const dest = path.join('dist', 'Monochrome', 'extensions'); if (fs.existsSync(src)) { fs.mkdirSync(dest, { recursive: true }); fs.cpSync(src, dest, { recursive: true }); console.log('Extensions manually copied to ' + dest); }\"",
|
||||
"preview": "vite preview",
|
||||
"lint:js": "eslint .",
|
||||
|
|
@ -28,19 +33,23 @@
|
|||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@capacitor/cli": "^8.2.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@types/node": "^25.3.5",
|
||||
"@vitest/browser-playwright": "^4.1.2",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"formidable": "^3.5.4",
|
||||
"globals": "^17.4.0",
|
||||
"htmlhint": "^1.9.2",
|
||||
"miniflare": "^4.20260301.1",
|
||||
"playwright": "^1.58.2",
|
||||
"prettier": "^3.8.1",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint": "^17.6.0",
|
||||
"stylelint-config-standard": "^39.0.1",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-bundle-visualizer": "^1.2.1",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
},
|
||||
"overrides": {
|
||||
|
|
@ -54,13 +63,16 @@
|
|||
"@capacitor/core": "^8.2.0",
|
||||
"@capacitor/haptics": "^8.0.1",
|
||||
"@capacitor/ios": "^8.2.0",
|
||||
"@dantheman827/taglib-ts": "https://github.com/DanTheMan827/taglib-ts/archive/b4238b2627aceb97f58813258046f1259f68cab7.tar.gz",
|
||||
"@dantheman827/taglib-ts": "https://github.com/DanTheMan827/taglib-ts/archive/ebd0e369b706c127a280d4ad631977f8d12ff88f.tar.gz",
|
||||
"@ffmpeg/core": "^0.12.10",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@kawarp/core": "^1.1.1",
|
||||
"@svta/common-media-library": "^0.18.1",
|
||||
"@types/wicg-file-system-access": "^2023.10.7",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
||||
"@uimaxbai/am-lyrics": "^1.1.4",
|
||||
"@vitest/web-worker": "^4.1.2",
|
||||
"appwrite": "^23.0.0",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
|
|
@ -78,7 +90,9 @@
|
|||
"shaka-player": "^5.0.7",
|
||||
"simple-icons": "^16.12.0",
|
||||
"svgo": "^4.0.1",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"url-toolkit": "^2.2.5",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^13.0.0",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
189
public/editors-picks-old/2026-4-1-april-fools.json
Normal file
189
public/editors-picks-old/2026-4-1-april-fools.json
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
[
|
||||
{
|
||||
"type": "album",
|
||||
"id": 510893864,
|
||||
"title": "BULLY",
|
||||
"artist": { "id": 25022, "name": "Kanye West" },
|
||||
"releaseDate": "2026-03-28",
|
||||
"cover": "cf2f2c9c-ff67-44f6-83aa-a7622f8c6b64",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 403172104,
|
||||
"title": "From A Man's Perspective",
|
||||
"artist": { "id": 3561564, "name": "Dax" },
|
||||
"releaseDate": "2024-12-06",
|
||||
"cover": "c52a53ea-f021-44bf-8cef-fb31d3b82940",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 341529881,
|
||||
"title": "FACTS",
|
||||
"artist": { "id": 5691796, "name": "Tom MacDonald" },
|
||||
"releaseDate": "2024-01-26",
|
||||
"cover": "34198718-cc7d-47eb-9bf1-1dc5c26fc8a1",
|
||||
"explicit": false,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 452163300,
|
||||
"title": "Tackle Box",
|
||||
"artist": { "id": 44771714, "name": "JamWayne" },
|
||||
"releaseDate": "2025-09-01",
|
||||
"cover": "bf7d2e55-52dc-4e41-9f53-1db31918798e",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 106210035,
|
||||
"title": "Supermarket (Soundtrack)",
|
||||
"artist": { "id": 3533999, "name": "LOGIC" },
|
||||
"releaseDate": "2019-03-26",
|
||||
"cover": "bdd39738-7177-4836-bd7f-cd4fe4ccf535",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 138458381,
|
||||
"title": "JOKER",
|
||||
"artist": { "id": 39109746, "name": "Dax" },
|
||||
"releaseDate": "2020-05-06",
|
||||
"cover": "d6a39491-fece-4594-a538-9cfbce7c6c68",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 318467376,
|
||||
"title": "The Draco Tape 2",
|
||||
"artist": { "id": 22467779, "name": "60PERCENTHOMO" },
|
||||
"releaseDate": "2023-09-30",
|
||||
"cover": "9169e8ab-fdb0-49d1-a14f-82a2b541223c",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 456582219,
|
||||
"title": "bbno$",
|
||||
"artist": { "id": 8173944, "name": "bbno$" },
|
||||
"releaseDate": "2025-10-17",
|
||||
"cover": "8162c739-4bb2-4218-8915-93cb1f0d9eea",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 506216548,
|
||||
"title": "Declassified",
|
||||
"artist": { "id": 5691796, "name": "Tom MacDonald" },
|
||||
"releaseDate": "2026-03-13",
|
||||
"cover": "5cf188e5-1660-4a01-bacc-10ca81e7af73",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 486673299,
|
||||
"title": "Novelty",
|
||||
"artist": { "id": 3589848, "name": "Goofy" },
|
||||
"releaseDate": "2026-01-23",
|
||||
"cover": "dd543595-e604-440e-a24c-e2eaccda3804",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 440469641,
|
||||
"title": "Tha Carter VI",
|
||||
"artist": { "id": 27518, "name": "Lil Wayne" },
|
||||
"releaseDate": "2025-06-06",
|
||||
"cover": "f2807cfe-6df8-4ea0-ab32-fd0ad9e91072",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 384057608,
|
||||
"title": "FREE VIPER!",
|
||||
"artist": { "id": 10285483, "name": "Viper" },
|
||||
"releaseDate": "2024-08-28",
|
||||
"cover": "642d73d9-2a45-4598-bec3-033a09181bdf",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 460015533,
|
||||
"title": "CHARLIE",
|
||||
"artist": { "id": 5691796, "name": "Tom MacDonald" },
|
||||
"releaseDate": "2025-09-11",
|
||||
"cover": "18180e3d-e178-4bd3-aada-e26ca32bc8f4",
|
||||
"explicit": false,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 123319314,
|
||||
"title": "BABY G.O.A.T.",
|
||||
"artist": { "id": 9160626, "name": "Kevo Muney" },
|
||||
"releaseDate": "2019-12-12",
|
||||
"cover": "6c87aee6-76da-413e-a639-79b670b3e3d1",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 510493672,
|
||||
"title": "ADL",
|
||||
"artist": { "id": 9318056, "name": "Yeat" },
|
||||
"releaseDate": "2026-03-27",
|
||||
"cover": "b799025a-7ff6-494c-aa11-5e0461bbca40",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 440096189,
|
||||
"title": "Rebel",
|
||||
"artist": { "id": 52081927, "name": "EsDeeKid" },
|
||||
"releaseDate": "2025-06-20",
|
||||
"cover": "3433b56c-6386-4e21-90ee-f2369d4bfde7",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 85287313,
|
||||
"title": "Toilet",
|
||||
"artist": { "id": 9598562, "name": "Clown Core" },
|
||||
"releaseDate": "2018-03-03",
|
||||
"cover": "d6d32464-864e-422f-b8c3-0c30baa563b1",
|
||||
"explicit": false,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
}
|
||||
]
|
||||
32
public/editors-picks-old/index.json
Normal file
32
public/editors-picks-old/index.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
[
|
||||
{
|
||||
"file": "2026-4-3.json",
|
||||
"label": "Spring 2026",
|
||||
"date": "2026-04-03"
|
||||
},
|
||||
{
|
||||
"file": "2026-4-3.json",
|
||||
"label": "Spring 2026",
|
||||
"date": "2026-04-03"
|
||||
},
|
||||
{
|
||||
"file": "2026-4-3.json",
|
||||
"label": "Spring 2026",
|
||||
"date": "2026-04-03"
|
||||
},
|
||||
{
|
||||
"file": "2026-4-3.json",
|
||||
"label": "Spring 2026",
|
||||
"date": "2026-04-03"
|
||||
},
|
||||
{
|
||||
"file": "2026-3-20-before-april-fools.json",
|
||||
"label": "Before April Fools '26",
|
||||
"date": "2026-03-20"
|
||||
},
|
||||
{
|
||||
"file": "2026-4-1-april-fools.json",
|
||||
"label": "April Fools '26",
|
||||
"date": "2026-04-01"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,189 +1,258 @@
|
|||
[
|
||||
{
|
||||
"type": "album",
|
||||
"id": 324660713,
|
||||
"title": "JOECHILLWORLD",
|
||||
"artist": {
|
||||
"id": 40978758,
|
||||
"name": "Devon Hendryx"
|
||||
},
|
||||
"releaseDate": "2010-07-10",
|
||||
"cover": "25d45544-3e82-4184-b8c2-2c2c6f0f152a",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 15427733,
|
||||
"title": "Mysterious Phonk: The Chronicles of SpaceGhostPurrp",
|
||||
"artist": {
|
||||
"id": 4611745,
|
||||
"name": "Spaceghostpurrp"
|
||||
},
|
||||
"releaseDate": "2012-06-12",
|
||||
"cover": "c78b7543-1cd8-4921-9155-e81d421353a0",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 464178301,
|
||||
"title": "Never Forget",
|
||||
"artist": {
|
||||
"id": 5516508,
|
||||
"name": "Chris Travis"
|
||||
},
|
||||
"releaseDate": "2014-05-14",
|
||||
"cover": "4ab11f0d-0768-4cce-8de5-1894134d5994",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 75115890,
|
||||
"title": "Blood Shore Season 2",
|
||||
"artist": {
|
||||
"id": 6332342,
|
||||
"name": "Xavier Wulf"
|
||||
},
|
||||
"releaseDate": "2014-10-30",
|
||||
"cover": "517303e5-d541-4704-b552-026427e05fcb",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 410197513,
|
||||
"title": "THE PEAK",
|
||||
"artist": {
|
||||
"id": 33481052,
|
||||
"name": "smokedope2016"
|
||||
},
|
||||
"releaseDate": "2025-01-17",
|
||||
"cover": "ea18084d-36ec-4cea-98a7-fe4684246986",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 418729278,
|
||||
"title": "I LAY DOWN MY LIFE FOR YOU: DIRECTOR'S CUT",
|
||||
"artist": {
|
||||
"id": 7958797,
|
||||
"name": "JPEGMAFIA"
|
||||
},
|
||||
"releaseDate": "2025-02-03",
|
||||
"cover": "9c84302b-2584-4c0a-9db7-e648542f459f",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 504004321,
|
||||
"title": "Half Blood (BloodLuxe)",
|
||||
"artist": {
|
||||
"id": 50799233,
|
||||
"name": "slayr"
|
||||
},
|
||||
"releaseDate": "2025-11-05",
|
||||
"cover": "2767cc63-7e92-4a48-aa4b-806a3ea7ec1c",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 510893864,
|
||||
"title": "BULLY",
|
||||
"artist": { "id": 25022, "name": "Kanye West" },
|
||||
"artist": {
|
||||
"id": 25022,
|
||||
"name": "Kanye West"
|
||||
},
|
||||
"releaseDate": "2026-03-28",
|
||||
"cover": "cf2f2c9c-ff67-44f6-83aa-a7622f8c6b64",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 403172104,
|
||||
"title": "From A Man's Perspective",
|
||||
"artist": { "id": 3561564, "name": "Dax" },
|
||||
"releaseDate": "2024-12-06",
|
||||
"cover": "c52a53ea-f021-44bf-8cef-fb31d3b82940",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 341529881,
|
||||
"title": "FACTS",
|
||||
"artist": { "id": 5691796, "name": "Tom MacDonald" },
|
||||
"releaseDate": "2024-01-26",
|
||||
"cover": "34198718-cc7d-47eb-9bf1-1dc5c26fc8a1",
|
||||
"id": 325723583,
|
||||
"title": "Replica",
|
||||
"artist": {
|
||||
"id": 3715530,
|
||||
"name": "Oneohtrix Point Never"
|
||||
},
|
||||
"releaseDate": "2011-11-05",
|
||||
"cover": "95ceeae9-cac7-42dc-ae37-7c93c223f809",
|
||||
"explicit": false,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 452163300,
|
||||
"title": "Tackle Box",
|
||||
"artist": { "id": 44771714, "name": "JamWayne" },
|
||||
"releaseDate": "2025-09-01",
|
||||
"cover": "bf7d2e55-52dc-4e41-9f53-1db31918798e",
|
||||
"id": 336178142,
|
||||
"title": "Pirate This Album",
|
||||
"artist": {
|
||||
"id": 8622751,
|
||||
"name": "Shamana"
|
||||
},
|
||||
"releaseDate": "2023-12-25",
|
||||
"cover": "a8a647be-0331-4779-9a6e-31645a9abdab",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 106210035,
|
||||
"title": "Supermarket (Soundtrack)",
|
||||
"artist": { "id": 3533999, "name": "LOGIC" },
|
||||
"releaseDate": "2019-03-26",
|
||||
"cover": "bdd39738-7177-4836-bd7f-cd4fe4ccf535",
|
||||
"id": 106369871,
|
||||
"title": "Organic Thoughts from the Synthetic Mind",
|
||||
"artist": {
|
||||
"id": 6436013,
|
||||
"name": "Shinjuku Mad"
|
||||
},
|
||||
"releaseDate": "2009-07-01",
|
||||
"cover": "3acc888e-35da-40a8-a4b7-7ffd00576cc9",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 138458381,
|
||||
"title": "JOKER",
|
||||
"artist": { "id": 39109746, "name": "Dax" },
|
||||
"releaseDate": "2020-05-06",
|
||||
"cover": "d6a39491-fece-4594-a538-9cfbce7c6c68",
|
||||
"id": 423471869,
|
||||
"title": "pain",
|
||||
"artist": {
|
||||
"id": 44257324,
|
||||
"name": "bleood"
|
||||
},
|
||||
"releaseDate": "2025-03-11",
|
||||
"cover": "711b23ba-c473-44e6-a2f0-010fefa9c5b8",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 318467376,
|
||||
"title": "The Draco Tape 2",
|
||||
"artist": { "id": 22467779, "name": "60PERCENTHOMO" },
|
||||
"releaseDate": "2023-09-30",
|
||||
"cover": "9169e8ab-fdb0-49d1-a14f-82a2b541223c",
|
||||
"id": 250986538,
|
||||
"title": "Revolutionary, Vol. 1 (Bonus Edition)",
|
||||
"artist": {
|
||||
"id": 3604583,
|
||||
"name": "Immortal Technique"
|
||||
},
|
||||
"releaseDate": "2001-09-14",
|
||||
"cover": "e510dd6d-dcdf-4272-9c68-f4580f2fbd14",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 456582219,
|
||||
"title": "bbno$",
|
||||
"artist": { "id": 8173944, "name": "bbno$" },
|
||||
"releaseDate": "2025-10-17",
|
||||
"cover": "8162c739-4bb2-4218-8915-93cb1f0d9eea",
|
||||
"id": 509761344,
|
||||
"title": "EMOTIONS",
|
||||
"artist": {
|
||||
"id": 49124576,
|
||||
"name": "Nine Vicious"
|
||||
},
|
||||
"releaseDate": "2026-04-03",
|
||||
"cover": "f29b18d3-b19f-45b1-968a-0ad360647130",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 506216548,
|
||||
"title": "Declassified",
|
||||
"artist": { "id": 5691796, "name": "Tom MacDonald" },
|
||||
"releaseDate": "2026-03-13",
|
||||
"cover": "5cf188e5-1660-4a01-bacc-10ca81e7af73",
|
||||
"id": 15621057,
|
||||
"title": "Triple F Life: Friends, Fans & Family (Deluxe Version)",
|
||||
"artist": {
|
||||
"id": 3654061,
|
||||
"name": "Waka Flocka Flame"
|
||||
},
|
||||
"releaseDate": "2012-06-12",
|
||||
"cover": "3199b7de-5e3d-486c-acf1-870ff4c60572",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 486673299,
|
||||
"title": "Novelty",
|
||||
"artist": { "id": 3589848, "name": "Goofy" },
|
||||
"releaseDate": "2026-01-23",
|
||||
"cover": "dd543595-e604-440e-a24c-e2eaccda3804",
|
||||
"id": 103897783,
|
||||
"title": "Freewave 3",
|
||||
"artist": {
|
||||
"id": 7923685,
|
||||
"name": "Lucki"
|
||||
},
|
||||
"releaseDate": "2019-02-15",
|
||||
"cover": "1d481a33-8b20-4ee3-b04b-5ac6e0fc5e78",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 440469641,
|
||||
"title": "Tha Carter VI",
|
||||
"artist": { "id": 27518, "name": "Lil Wayne" },
|
||||
"releaseDate": "2025-06-06",
|
||||
"cover": "f2807cfe-6df8-4ea0-ab32-fd0ad9e91072",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 384057608,
|
||||
"title": "FREE VIPER!",
|
||||
"artist": { "id": 10285483, "name": "Viper" },
|
||||
"releaseDate": "2024-08-28",
|
||||
"cover": "642d73d9-2a45-4598-bec3-033a09181bdf",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 460015533,
|
||||
"title": "CHARLIE",
|
||||
"artist": { "id": 5691796, "name": "Tom MacDonald" },
|
||||
"releaseDate": "2025-09-11",
|
||||
"cover": "18180e3d-e178-4bd3-aada-e26ca32bc8f4",
|
||||
"explicit": false,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 123319314,
|
||||
"title": "BABY G.O.A.T.",
|
||||
"artist": { "id": 9160626, "name": "Kevo Muney" },
|
||||
"releaseDate": "2019-12-12",
|
||||
"cover": "6c87aee6-76da-413e-a639-79b670b3e3d1",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 510493672,
|
||||
"title": "ADL",
|
||||
"artist": { "id": 9318056, "name": "Yeat" },
|
||||
"releaseDate": "2026-03-27",
|
||||
"cover": "b799025a-7ff6-494c-aa11-5e0461bbca40",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 440096189,
|
||||
"title": "Rebel",
|
||||
"artist": { "id": 52081927, "name": "EsDeeKid" },
|
||||
"releaseDate": "2025-06-20",
|
||||
"cover": "3433b56c-6386-4e21-90ee-f2369d4bfde7",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 85287313,
|
||||
"title": "Toilet",
|
||||
"artist": { "id": 9598562, "name": "Clown Core" },
|
||||
"releaseDate": "2018-03-03",
|
||||
"cover": "d6d32464-864e-422f-b8c3-0c30baa563b1",
|
||||
"explicit": false,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS"] }
|
||||
"mediaMetadata": {
|
||||
"tags": ["LOSSLESS"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
1752
styles.css
1752
styles.css
File diff suppressed because it is too large
Load diff
|
|
@ -5,4 +5,4 @@ async function test() {
|
|||
const json = await res.json();
|
||||
console.log(JSON.stringify(json.data || {}));
|
||||
}
|
||||
test();
|
||||
void test();
|
||||
|
|
|
|||
22
tsconfig-eslint.json
Normal file
22
tsconfig-eslint.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable", "webworker"],
|
||||
"types": ["vite/client", "node", "@types/wicg-file-system-access"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"!/*": ["node_modules/*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**/*.ts", "*.ts", "**/*.js", "*.js"],
|
||||
"exclude": ["**/node_modules/*"]
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable", "webworker"],
|
||||
"types": ["vite/client", "node"],
|
||||
"types": ["vite/client", "node", "@types/wicg-file-system-access"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"!/*": ["node_modules/*"]
|
||||
|
|
@ -17,5 +17,6 @@
|
|||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["js/**/*.ts", "js/**/*.d.ts"]
|
||||
"include": ["**/*.ts", "*.ts", "**/*.js", "*.js"],
|
||||
"exclude": ["**/node_modules/*"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { loadEnv } from 'vite';
|
||||
import cookieSession from 'cookie-session';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, extname } from 'path';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { gzipSync, constants as zlibConstants } from 'zlib';
|
||||
import type { Plugin } from 'vite';
|
||||
import type { Plugin, ResolvedConfig } from 'vite';
|
||||
import mime from 'mime';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
|
|
@ -26,10 +26,14 @@ function hashString(input: string, algorithm = 'sha256'): string {
|
|||
*/
|
||||
export default function blobAssetPlugin(): Plugin {
|
||||
const devAssets = new Map<string, Buffer>();
|
||||
let resolvedConfig: ResolvedConfig | null = null;
|
||||
|
||||
return {
|
||||
name: 'vite-blob-asset',
|
||||
|
||||
async configResolved(config: ResolvedConfig) {
|
||||
resolvedConfig = config;
|
||||
},
|
||||
async load(id) {
|
||||
if (!id.includes('?blob-url')) return;
|
||||
|
||||
|
|
@ -45,7 +49,7 @@ export default function blobAssetPlugin(): Plugin {
|
|||
|
||||
let assetUrl: string;
|
||||
|
||||
if (this.meta.watchMode) {
|
||||
if (resolvedConfig?.command === 'serve') {
|
||||
/** dev server path */
|
||||
assetUrl = `/@blob/${hashString(absPath)}/${path.basename(filepath)}.gz`;
|
||||
devAssets.set(assetUrl, compressed);
|
||||
|
|
@ -106,7 +110,7 @@ export default function getBlobUrl() {
|
|||
|
||||
chunk.code = chunk.code.replace(
|
||||
/"__BLOB_ASSET_(.*?)__"/g,
|
||||
(_, refId) => `"${this.getFileName(refId)}"`
|
||||
(_, refId: string) => `"${this.getFileName(refId)}"`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { normalizePath, Plugin } from 'vite';
|
||||
import { normalizePath, type Plugin, type ResolvedConfig } from 'vite';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { optimize } from 'svgo';
|
||||
|
|
@ -30,7 +30,7 @@ function parseAttrs(str: string): Record<string, string> {
|
|||
* Merge attributes into root <svg>
|
||||
*/
|
||||
function mergeSvgAttributes(svg: string, attrs: Record<string, string>) {
|
||||
return svg.replace(/<svg([^>]*)>/i, (match, existingAttrs) => {
|
||||
return svg.replace(/<svg([^>]*)>/i, (_match, existingAttrs: string | undefined) => {
|
||||
// Size is shorthand for setting both width and height to the same value
|
||||
if (attrs['size']) {
|
||||
attrs['width'] = attrs['size'];
|
||||
|
|
@ -40,7 +40,7 @@ function mergeSvgAttributes(svg: string, attrs: Record<string, string>) {
|
|||
|
||||
const map = new Map<string, string>();
|
||||
|
||||
for (const [, name, value] of existingAttrs.matchAll(ATTR_REGEX)) {
|
||||
for (const [, name, value] of (existingAttrs ?? '').matchAll(ATTR_REGEX)) {
|
||||
map.set(name, value);
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +104,7 @@ function loadSvg<S extends boolean = true, T = S extends true ? string : Promise
|
|||
* Main plugin
|
||||
*/
|
||||
export default function viteSvgUsePlugin(): Plugin {
|
||||
let config: any;
|
||||
let config: ResolvedConfig;
|
||||
const watched = new Set<string>();
|
||||
|
||||
/**
|
||||
|
|
@ -117,10 +117,8 @@ export default function viteSvgUsePlugin(): Plugin {
|
|||
}
|
||||
// Check for alias
|
||||
if (config && config.resolve && config.resolve.alias) {
|
||||
for (const [_, { find, replacement }] of Object.entries<{ find: string; replacement: string }>(
|
||||
config.resolve.alias
|
||||
)) {
|
||||
if (src.startsWith(find)) {
|
||||
for (const [_, { find, replacement }] of config.resolve.alias.entries()) {
|
||||
if (typeof find === 'string' ? src.startsWith(find) : find.test(src)) {
|
||||
// Remove alias prefix and resolve
|
||||
const aliasedPath = src.replace(find, replacement);
|
||||
return normalizePath(path.resolve(root, aliasedPath.replace(/^\//, '')));
|
||||
|
|
@ -144,23 +142,26 @@ export default function viteSvgUsePlugin(): Plugin {
|
|||
transformIndexHtml: {
|
||||
order: 'pre',
|
||||
async handler(html, ctx) {
|
||||
return html.replace(SVG_USE_REGEX, (full, before, src, after) => {
|
||||
const attrs = {
|
||||
...parseAttrs(before || ''),
|
||||
...parseAttrs(after || ''),
|
||||
};
|
||||
return html.replace(
|
||||
SVG_USE_REGEX,
|
||||
(_full, before: string | undefined, src: string | undefined, after: string | undefined) => {
|
||||
const attrs = {
|
||||
...parseAttrs(before || ''),
|
||||
...parseAttrs(after || ''),
|
||||
};
|
||||
|
||||
delete attrs['use'];
|
||||
delete attrs['use'];
|
||||
|
||||
const filePath = resolveSvg(config.root, ctx.filename || '', src);
|
||||
const filePath = resolveSvg(config.root, ctx.filename || '', src);
|
||||
|
||||
watched.add(filePath);
|
||||
watched.add(filePath);
|
||||
|
||||
let svg = loadSvg(filePath);
|
||||
svg = mergeSvgAttributes(optimize(svg).data, attrs);
|
||||
let svg = loadSvg(filePath);
|
||||
svg = mergeSvgAttributes(optimize(svg).data, attrs);
|
||||
|
||||
return svg;
|
||||
});
|
||||
return svg;
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import uploadPlugin from './vite-plugin-upload.js';
|
|||
import blobAssetPlugin from './vite-plugin-blob.js';
|
||||
import svgUse from './vite-plugin-svg-use.js';
|
||||
import { execSync } from 'child_process';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
|
||||
function getGitCommitHash() {
|
||||
try {
|
||||
|
|
@ -15,13 +16,23 @@ function getGitCommitHash() {
|
|||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
export default defineConfig((_options) => {
|
||||
const commitHash = getGitCommitHash();
|
||||
|
||||
return {
|
||||
test: {
|
||||
// https://vitest.dev/guide/browser/
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
headless: !!process.env.HEADLESS,
|
||||
instances: [{ browser: 'chromium' }],
|
||||
},
|
||||
},
|
||||
base: './',
|
||||
define: {
|
||||
__COMMIT_HASH__: JSON.stringify(commitHash),
|
||||
__VITEST__: !!process.env.VITEST,
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
|
|
|
|||
Loading…
Reference in a new issue