Merge branch 'main' of github.com:monochrome-music/monochrome

This commit is contained in:
Samidy 2026-04-04 22:50:07 +03:00
commit 90dc00ba5a
98 changed files with 15162 additions and 18680 deletions

View file

@ -4,7 +4,7 @@
"context": "..", "context": "..",
"dockerfile": "./Dockerfile" "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": { "customizations": {
"vscode": { "vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "anthropic.claude-code"] "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "anthropic.claude-code"]

62
.github/ISSUE_TEMPLATE/BUG-REPORT.yml vendored Normal file
View 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
View 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

View file

@ -19,6 +19,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
ref: ${{ github.head_ref || github.ref }} ref: ${{ github.head_ref || github.ref }}
- name: Setup Bun - name: Setup Bun

41
.github/workflows/tests.yml vendored Normal file
View 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

4
.gitignore vendored
View file

@ -3,15 +3,17 @@ dist
.DS_Store .DS_Store
*.local *.local
.vite .vite
.vscode
.claude .claude
# Docker # Docker
.env .env
# Neutralino # Neutralino
.tmp/ .tmp/
.vitest-attachments/
**/__screenshots__/*
bin/ bin/
*.log *.log
.storage/ .storage/
auth_storage/ auth_storage/
www www
neutralino.js neutralino.js
package-lock.json

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

15
.vscode/launch.json vendored Normal file
View 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
View 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
}
}

View file

@ -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. > **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 ### Fork Workflow

477
bun.lock

File diff suppressed because it is too large Load diff

23
editors-picks-input.txt Normal file
View 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

View file

@ -1,15 +1,23 @@
import js from '@eslint/js'; import js from '@eslint/js';
import globals from 'globals'; import globals from 'globals';
import prettierConfig from 'eslint-config-prettier'; 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/**'], ignores: ['**/dist/**', '**/node_modules/**', '**/legacy/**', '**/bin/**', '**/www/**', '**/public/lib/**'],
}, },
js.configs.recommended, js.configs.recommended,
prettierConfig, prettierConfig,
tseslint.configs.recommendedTypeChecked,
{ {
languageOptions: { languageOptions: {
parser: tsParser, // 👈 REQUIRED
parserOptions: {
project: './tsconfig-eslint.json', // 👈 REQUIRED
},
ecmaVersion: 2022, ecmaVersion: 2022,
sourceType: 'module', sourceType: 'module',
globals: { globals: {
@ -18,8 +26,38 @@ export default [
}, },
}, },
rules: { rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': [
'no-console': ['warn', { allow: ['log', 'warn', 'error'] }], '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
View 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
View 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' },
});
}

View 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' },
});
}

View 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' },
});
}

View file

@ -50,7 +50,7 @@ export async function onRequest(context) {
const title = feed.title; const title = feed.title;
const author = feed.author || feed.ownerName || ''; const author = feed.author || feed.ownerName || '';
const episodeCount = feed.episodeCount || 0; const episodeCount = feed.episodeCount || 0;
const rawDescription = feed.description || ''; const _rawDescription = feed.description || '';
const description = author const description = author
? `Podcast by ${author}${episodeCount} Episodes\nListen on Monochrome` ? `Podcast by ${author}${episodeCount} Episodes\nListen on Monochrome`
: `Podcast • ${episodeCount} Episodes\nListen on Monochrome`; : `Podcast • ${episodeCount} Episodes\nListen on Monochrome`;

34
functions/recent/index.js Normal file
View 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' },
});
}

View 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' },
});
}

View 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));
}

View 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));
}

View 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' },
});
}

View file

@ -3,6 +3,54 @@
const POCKETBASE_URL = 'https://data.samidy.xyz'; const POCKETBASE_URL = 'https://data.samidy.xyz';
const PUBLIC_COLLECTION = 'public_playlists'; 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) { export async function onRequest(context) {
const { request, params, env } = context; const { request, params, env } = context;
const userAgent = request.headers.get('User-Agent') || ''; const userAgent = request.headers.get('User-Agent') || '';
@ -37,14 +85,10 @@ export async function onRequest(context) {
(extraData && (extraData.title || extraData.name)) || (extraData && (extraData.title || extraData.name)) ||
'Untitled Playlist'; 'Untitled Playlist';
let tracks = []; let tracks = safeParseTracks(record.tracks);
try {
tracks = record.tracks ? JSON.parse(record.tracks) : [];
} catch {
tracks = [];
}
const trackCount = tracks.length; const trackCount = tracks.length;
const totalDuration = calculatePlaylistDuration(tracks);
const durationStr = formatDuration(totalDuration);
let rawCover = record.image || record.cover || record.playlist_cover || ''; let rawCover = record.image || record.cover || record.playlist_cover || '';
if (!rawCover && extraData && typeof extraData === 'object') { if (!rawCover && extraData && typeof extraData === 'object') {
@ -70,7 +114,7 @@ export async function onRequest(context) {
imageUrl = 'https://monochrome.tf/assets/appicon.png'; 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 pageUrl = new URL(request.url).href;
const metaHtml = ` const metaHtml = `

225
gen-editors-picks.py Normal file
View 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")

1130
index.html

File diff suppressed because it is too large Load diff

200
js/HiFi.test.ts Normal file
View 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'
);
});

View file

@ -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'; import { EventEmitter } from 'events';
const BROWSER_CLIENT_ID = 'txNoH4kkV41MfH25';
const BROWSER_CLIENT_SECRET = 'dQjy0MinCEvxi1O4UmxvxWnDjt4cgHBPw8ll6nYBk98=';
type Params = Record<string, string | number | undefined | null>; type Params = Record<string, string | number | undefined | null>;
@ -37,6 +40,10 @@ export enum HiFiClientEvents {
} }
class HiFiClient { 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 #instance: HiFiClient | null = null;
static get instance() { static get instance() {
if (!HiFiClient.#instance) { if (!HiFiClient.#instance) {
@ -57,6 +64,7 @@ class HiFiClient {
readonly #albumTracksMax = 20; readonly #albumTracksMax = 20;
readonly #albumTracksQueue: Array<() => void> = []; readonly #albumTracksQueue: Array<() => void> = [];
readonly #countryCode: string; readonly #countryCode: string;
readonly #locale: string;
readonly #clientId: string; readonly #clientId: string;
readonly #clientSecret: string; readonly #clientSecret: string;
readonly #emitter = new EventEmitter(); readonly #emitter = new EventEmitter();
@ -149,7 +157,7 @@ class HiFiClient {
setToken({ token, tokenExpiry, refreshToken }: HiFiClient.TokenOptions & HiFiClient.RefreshTokenOptions) { setToken({ token, tokenExpiry, refreshToken }: HiFiClient.TokenOptions & HiFiClient.RefreshTokenOptions) {
this.token = token; this.token = token;
this.appTokenExpiry = this.appTokenExpiry; this.appTokenExpiry = tokenExpiry;
this.refreshToken = refreshToken; this.refreshToken = refreshToken;
} }
@ -158,8 +166,8 @@ class HiFiClient {
} }
async #fetchAppToken({ async #fetchAppToken({
clientId = BROWSER_CLIENT_ID, clientId = HiFiClient.BROWSER_CLIENT_ID,
clientSecret = BROWSER_CLIENT_SECRET, clientSecret = HiFiClient.BROWSER_CLIENT_SECRET,
refreshToken, refreshToken,
scope = 'r_usr+w_usr+w_sub', scope = 'r_usr+w_usr+w_sub',
signal = new AbortController().signal, signal = new AbortController().signal,
@ -169,7 +177,7 @@ class HiFiClient {
scope?: string; scope?: string;
signal?: AbortSignal; signal?: AbortSignal;
force?: boolean; force?: boolean;
}) { }): Promise<string | null> {
if (!force && this.token && (this.appTokenExpiry < 0 || Date.now() < this.appTokenExpiry)) return this.token; if (!force && this.token && (this.appTokenExpiry < 0 || Date.now() < this.appTokenExpiry)) return this.token;
return await (this.#tokenPromise ??= (async () => { return await (this.#tokenPromise ??= (async () => {
@ -216,16 +224,37 @@ class HiFiClient {
} }
static #getOptions({ static #getOptions({
locale = 'en_US',
countryCode = 'US', countryCode = 'US',
baseUrl = null, baseUrl = null,
clientId = BROWSER_CLIENT_ID, clientId = HiFiClient.BROWSER_CLIENT_ID,
clientSecret = BROWSER_CLIENT_SECRET, clientSecret = HiFiClient.BROWSER_CLIENT_SECRET,
token, token,
tokenExpiry, tokenExpiry,
refreshToken: tokenRefresh, refreshToken,
storage = [], storage = [],
}: HiFiClient.ConstructorOptions = {}) { }: HiFiClient.ConstructorOptions = {}): WithRequiredKeys<HiFiClient.ConstructorOptions> {
return { countryCode, baseUrl, clientId, clientSecret, token, tokenExpiry, tokenRefresh, storage }; 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( async #fetchAuthenticated(
@ -239,7 +268,7 @@ class HiFiClient {
while (true) { while (true) {
const unauthorized = res?.status === 401; const unauthorized = res?.status === 401;
const previousResponse = res; const previousResponse = res;
const token = await await this.#fetchAppToken({ const token = await this.#fetchAppToken({
clientId: this.#clientId, clientId: this.#clientId,
clientSecret: this.#clientSecret, clientSecret: this.#clientSecret,
signal, signal,
@ -279,15 +308,16 @@ class HiFiClient {
} }
constructor(options: HiFiClient.ConstructorOptions = {}) { 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); HiFiClient.#getOptions(options);
this.#locale = locale;
this.#countryCode = countryCode; this.#countryCode = countryCode;
this.#baseUrl = baseUrl; this.#baseUrl = baseUrl;
this.#clientId = clientId; this.#clientId = clientId;
this.#clientSecret = clientSecret; this.#clientSecret = clientSecret;
this.token = token; this.token = token;
this.appTokenExpiry = tokenExpiry; this.appTokenExpiry = tokenExpiry;
this.refreshToken = tokenRefresh; this.refreshToken = refreshToken;
for (const store of !Array.isArray(storage) ? [storage] : storage) { for (const store of !Array.isArray(storage) ? [storage] : storage) {
this.#useStorage(store); this.#useStorage(store);
@ -334,7 +364,7 @@ class HiFiClient {
async getInfo(id: number, signal?: AbortSignal) { async getInfo(id: number, signal?: AbortSignal) {
const url = `https://api.tidal.com/v1/tracks/${id}/`; const url = `https://api.tidal.com/v1/tracks/${id}/`;
const data = await this.#fetchJson(url, { countryCode: this.#countryCode }, signal); 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) { async getTrack(id: number, quality = 'HI_RES_LOSSLESS', immersiveAudio: boolean = false, signal?: AbortSignal) {
@ -347,7 +377,7 @@ class HiFiClient {
immersiveAudio: String(immersiveAudio), immersiveAudio: String(immersiveAudio),
}; };
const data = await this.#fetchJson(url, params, signal); 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( async getTrackManifest(
@ -382,7 +412,7 @@ class HiFiClient {
drmData.certificateUrl = url; drmData.certificateUrl = url;
} }
return HiFiClient.#jsonResponse({ version: API_VERSION, data: res }); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: res });
} }
async getWidevine() { async getWidevine() {
@ -392,7 +422,7 @@ class HiFiClient {
async getRecommendations(id: number, signal?: AbortSignal) { async getRecommendations(id: number, signal?: AbortSignal) {
const url = `https://api.tidal.com/v1/tracks/${id}/recommendations`; const url = `https://api.tidal.com/v1/tracks/${id}/recommendations`;
const data = await this.#fetchJson(url, { limit: '20', countryCode: this.#countryCode }, signal); 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) { 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) { 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( 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 // 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) => { const fetchAlbumTracks = async (album_id: number) => {
return await this.#withAlbumTrackSlot(async () => { return await this.#withAlbumTrackSlot(async () => {
@ -613,7 +650,18 @@ class HiFiClient {
if (Array.isArray(t)) tracks.push(...t); 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) { #buildCoverEntry(cover_slug: string, name?: string | null, track_id?: number | null) {
@ -640,7 +688,7 @@ class HiFiClient {
const cover_slug = album.cover; const cover_slug = album.cover;
if (!cover_slug) throw new ResponseError(404, 'Cover not found'); if (!cover_slug) throw new ResponseError(404, 'Cover not found');
const entry = this.#buildCoverEntry(cover_slug, album.title || track_data.title, album.id || id); 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( const search_data = await this.#fetchJson(
@ -658,7 +706,7 @@ class HiFiClient {
covers.push(this.#buildCoverEntry(cover_slug, track.title, track.id)); covers.push(this.#buildCoverEntry(cover_slug, track.title, track.id));
} }
if (!covers.length) throw new ResponseError(404, 'Cover not found'); 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( async search(
@ -690,7 +738,7 @@ class HiFiClient {
}, },
signal signal
); );
return HiFiClient.#jsonResponse({ version: API_VERSION, data: res }); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: res });
} catch (err: any) { } catch (err: any) {
if (err.status && ![400, 404].includes(err.status)) throw err; if (err.status && ![400, 404].includes(err.status)) throw err;
// fallback to text search // fallback to text search
@ -705,7 +753,7 @@ class HiFiClient {
}, },
signal 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]> = [ const mapping: Array<[string | undefined, string, Params]> = [
@ -746,7 +794,7 @@ class HiFiClient {
for (const [val, url, params] of mapping) { for (const [val, url, params] of mapping) {
if (val) { if (val) {
const data = await this.#fetchJson(url, params, signal); 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); if (Array.isArray(pageItems)) allItems.push(...pageItems);
} }
albumData.items = allItems; 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) { async getMix(id: string, signal?: AbortSignal) {
@ -803,7 +851,7 @@ class HiFiClient {
} }
} }
return HiFiClient.#jsonResponse({ return HiFiClient.#jsonResponse({
version: API_VERSION, version: HiFiClient.API_VERSION,
mix: header, mix: header,
items: items.map((it: any) => (it.item ? it.item : it)), 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), this.#fetchJson(itemsUrl, { countryCode: this.#countryCode, limit, offset }, signal),
]); ]);
const items = (itemsData && itemsData.items) || itemsData; 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) // simplified artist/cover/lyrics/video/topvideos/similar methods (same pattern)
@ -833,7 +881,7 @@ class HiFiClient {
err.status = 404; err.status = 404;
throw err; 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) { async getVideo(id: number, quality = 'HIGH', mode = 'STREAM', presentation = 'FULL', signal?: AbortSignal) {
@ -843,7 +891,7 @@ class HiFiClient {
{ videoquality: quality, playbackmode: mode, assetpresentation: presentation }, { videoquality: quality, playbackmode: mode, assetpresentation: presentation },
signal signal
); );
return HiFiClient.#jsonResponse({ version: API_VERSION, video: data }); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, video: data });
} }
async getTopVideos( async getTopVideos(
@ -867,7 +915,7 @@ class HiFiClient {
} }
} }
return HiFiClient.#jsonResponse({ return HiFiClient.#jsonResponse({
version: API_VERSION, version: HiFiClient.API_VERSION,
videos: videos.slice(offset, offset + limit), videos: videos.slice(offset, offset + limit),
total: videos.length, total: videos.length,
}); });
@ -886,7 +934,10 @@ class HiFiClient {
switch (pathname) { switch (pathname) {
case '/': case '/':
return new TidalResponse( 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': case '/info':
return new TidalResponse(await this.getInfo(Number(qp.id))); return new TidalResponse(await this.getInfo(Number(qp.id)));
@ -902,6 +953,8 @@ class HiFiClient {
return new TidalResponse( return new TidalResponse(
await this.getSimilarAlbums(Number(qp.id), qp.cursor ?? undefined, signal) 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': case '/artist':
return new TidalResponse( return new TidalResponse(
await this.getArtist( await this.getArtist(
@ -1007,8 +1060,13 @@ namespace HiFiClient {
clientSecret?: string; clientSecret?: string;
} }
export interface ConstructorOptions extends ClientOptions, TokenOptions, RefreshTokenOptions { export interface LocaleOptions {
locale?: string;
countryCode?: string; countryCode?: string;
}
export interface ConstructorOptions
extends LocaleOptions, RefreshTokenOptions, ClientOptions, TokenOptions, RefreshTokenOptions {
baseUrl?: string; baseUrl?: string;
storage?: Pick<Storage, 'setItem' | 'removeItem'>[] | Pick<Storage, 'setItem' | 'removeItem'>; storage?: Pick<Storage, 'setItem' | 'removeItem'>[] | Pick<Storage, 'setItem' | 'removeItem'>;
} }

View file

@ -12,9 +12,9 @@ import { db } from './db';
* *
* @template C The accumulated shape of the settings object. * @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. */ /** 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. */ /** Whether new properties are prevented from being added. */
#finalized: boolean = false; #finalized: boolean = false;
@ -51,7 +51,7 @@ class ModernSettings<C extends object = {}> {
* @param callback Function producing the promise to track. * @param callback Function producing the promise to track.
* @returns The created promise. * @returns The created promise.
*/ */
#addPending<C extends Promise<any>>(callback: () => C): C { #addPending<C extends Promise<void>>(callback: () => C): C {
const sym = Symbol(); const sym = Symbol();
return (this.#pending[sym] = callback().finally(() => { return (this.#pending[sym] = callback().finally(() => {
@ -145,14 +145,14 @@ class ModernSettings<C extends object = {}> {
const legacyValue = localStorage.getItem(legacy?.key ?? backingKey ?? key); const legacyValue = localStorage.getItem(legacy?.key ?? backingKey ?? key);
if (legacyValue !== null) { if (legacyValue !== null) {
db.saveSetting(backingKey ?? key, legacy.transformer!(legacyValue)); await db.saveSetting(backingKey ?? key, legacy.transformer(legacyValue));
localStorage.removeItem(legacy?.key ?? backingKey ?? key); localStorage.removeItem(legacy?.key ?? backingKey ?? key);
} }
} }
} }
try { try {
value = (await db.getSetting(backingKey ?? key)) ?? defaultValue; value = ((await db.getSetting(backingKey ?? key)) as T) ?? defaultValue;
} catch { } catch {
value = defaultValue; value = defaultValue;
} }
@ -162,7 +162,7 @@ class ModernSettings<C extends object = {}> {
get: () => (getter ? getter(value, typed as ModernSettings<C> & C & Record<K, T>) : value), get: () => (getter ? getter(value, typed as ModernSettings<C> & C & Record<K, T>) : value),
set: (newValue: T) => { set: (newValue: T) => {
value = setter ? setter(newValue, typed as ModernSettings<C> & C & Record<K, T>) : newValue; 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, enumerable: true,
}); });
@ -261,6 +261,7 @@ export const modernSettings = new ModernSettings()
transformer: String, transformer: String,
}, },
}) })
.addProperty('writeArtistsSeparately', false)
.finalize() as ModernSettings & { .finalize() as ModernSettings & {
/** The last used directory handle for bulk downloads */ /** The last used directory handle for bulk downloads */
bulkDownloadFolder: FileSystemDirectoryHandle | null; bulkDownloadFolder: FileSystemDirectoryHandle | null;
@ -286,4 +287,7 @@ export const modernSettings = new ModernSettings()
/** Filename template for downloads */ /** Filename template for downloads */
filenameTemplate: string; filenameTemplate: string;
/** Whether to write multiple artists to downloaded files */
writeArtistsSeparately: boolean;
}; };

View file

@ -5,7 +5,7 @@ export class AuthManager {
constructor() { constructor() {
this.user = null; this.user = null;
this.authListeners = []; this.authListeners = [];
this.init(); this.init().catch(console.error);
} }
async init() { async init() {

View file

@ -128,7 +128,7 @@ const syncManager = {
} }
}, },
safeParseInternal(str, fieldName, fallback) { safeParseInternal(str, _fieldName, fallback) {
if (!str) return fallback; if (!str) return fallback;
if (typeof str !== 'string') return str; if (typeof str !== 'string') return str;
try { try {
@ -136,7 +136,7 @@ const syncManager = {
} catch { } catch {
try { try {
// Recovery attempt: replace illegal internal quotes in name/title fields // 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, '\\"'); const escapedContent = p2.replace(/(?<!\\)"/g, '\\"');
return p1 + escapedContent + p3; return p1 + escapedContent + p3;
}); });
@ -156,6 +156,8 @@ const syncManager = {
(jsFriendly.trim().startsWith('[') || jsFriendly.trim().startsWith('{')) && (jsFriendly.trim().startsWith('[') || jsFriendly.trim().startsWith('{')) &&
!jsFriendly.match(/function|=>|window|document|alert|eval/) !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)(); return new Function('return ' + jsFriendly)();
} }
} }
@ -565,11 +567,6 @@ const syncManager = {
if (cloudData) { if (cloudData) {
let database = db; let database = db;
if (typeof database === 'function') {
database = await database();
} else {
database = await database;
}
const localData = { const localData = {
tracks: (await database.getAll('favorites_tracks')) || [], tracks: (await database.getAll('favorites_tracks')) || [],

View file

@ -5,10 +5,7 @@ import {
delay, delay,
isTrackUnavailable, isTrackUnavailable,
getExtensionFromBlob, getExtensionFromBlob,
getTrackTitle,
getFullArtistString,
getTrackDiscNumber, getTrackDiscNumber,
getMimeType,
} from './utils.js'; } from './utils.js';
import { preferDolbyAtmosSettings, trackDateSettings } from './storage.js'; import { preferDolbyAtmosSettings, trackDateSettings } from './storage.js';
import { APICache } from './cache.js'; import { APICache } from './cache.js';
@ -36,7 +33,6 @@ import {
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
export { resolveDownloadTotalBytes }; export { resolveDownloadTotalBytes };
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
export class LosslessAPI { export class LosslessAPI {
constructor(settings) { constructor(settings) {
@ -48,8 +44,8 @@ export class LosslessAPI {
this.streamCache = new Map(); this.streamCache = new Map();
setInterval( setInterval(
() => { async () => {
this.cache.clearExpired(); await this.cache.clearExpired();
this.pruneStreamCache(); this.pruneStreamCache();
}, },
1000 * 60 * 5 1000 * 60 * 5
@ -492,7 +488,7 @@ export class LosslessAPI {
await this.cache.set('search_all', query, results); await this.cache.set('search_all', query, results);
return results; return results;
} catch (error) { } catch (_error) {
// Fallback to individual searches if the backend proxy doesn't support ?q= or throws // Fallback to individual searches if the backend proxy doesn't support ?q= or throws
const [tracks, videos, artists, albums, playlists] = await Promise.all([ const [tracks, videos, artists, albums, playlists] = await Promise.all([
this.searchTracks(query, options).catch(() => ({ items: [] })), this.searchTracks(query, options).catch(() => ({ items: [] })),
@ -1240,15 +1236,10 @@ export class LosslessAPI {
if (cached) return cached; if (cached) return cached;
try { try {
const url = `https://api.tidal.com/v1/artists/${artistId}/bio?locale=en_US&countryCode=GB`; const response = await HiFiClient.instance.query(`/artist/bio/?id=${artistId}`);
const response = await fetch(url, {
headers: {
'X-Tidal-Token': TIDAL_V2_TOKEN,
},
});
if (response.ok) { if (response.ok) {
const data = await response.json(); const { data } = await response.json();
if (data && data.text) { if (data && data.text) {
const bio = { const bio = {
text: data.text, text: data.text,
@ -1500,7 +1491,9 @@ export class LosslessAPI {
a.canPlayType('audio/mp4; codecs="ec-3"') || a.canPlayType('audio/mp4; codecs="eac3"') 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 = []; const paramsArray = [];
@ -1561,7 +1554,7 @@ export class LosslessAPI {
} else { } else {
throw new Error('No URI in trackManifests response'); throw new Error('No URI in trackManifests response');
} }
} catch (err) { } catch (_err) {
// Fallback to /track endpoint // Fallback to /track endpoint
} }

456
js/api.test.ts Normal file
View 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');
}
});
});

View file

@ -33,7 +33,6 @@ import { authManager } from './accounts/auth.js';
import { registerSW } from 'virtual:pwa-register'; import { registerSW } from 'virtual:pwa-register';
import { openEditProfile } from './profile.js'; import { openEditProfile } from './profile.js';
import { ThemeStore } from './themeStore.js'; import { ThemeStore } from './themeStore.js';
import { partyManager } from './listening-party.js';
import './commandPalette.js'; import './commandPalette.js';
import { initTracker } from './tracker.js'; import { initTracker } from './tracker.js';
import { import {
@ -131,12 +130,12 @@ async function fetchcontributors() {
const response = await fetch('https://api.samidy.com/api/contributors'); const response = await fetch('https://api.samidy.com/api/contributors');
const data1 = await response.json(); 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 => { data.forEach((user) => {
const userDIV = document.createElement("div"); const userDIV = document.createElement('div');
userDIV.innerHTML = ` userDIV.innerHTML = `
<a href="${user.html_url}" target="_blank"> <a href="${user.html_url}" target="_blank">
<img src="${user.avatar_url}" alt="${user.login}" width="50" style="border-radius: 50%;"> <img src="${user.avatar_url}" alt="${user.login}" width="50" style="border-radius: 50%;">
@ -148,7 +147,6 @@ async function fetchcontributors() {
}); });
} }
async function loadMetadataModule() { async function loadMetadataModule() {
if (!metadataModule) { if (!metadataModule) {
metadataModule = await import('./metadata.js'); metadataModule = await import('./metadata.js');
@ -278,7 +276,7 @@ function initializeKeyboardShortcuts(player, _audioPlayer) {
}, },
lyrics: () => { lyrics: () => {
trackKeyboardShortcut('L'); trackKeyboardShortcut('L');
document.querySelector('.now-playing-bar .cover')?.click(); document.getElementById('toggle-lyrics-btn')?.click();
}, },
search: () => { search: () => {
trackKeyboardShortcut('/'); trackKeyboardShortcut('/');
@ -428,6 +426,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Populate commit info // Populate commit info
{ {
const repo = 'https://github.com/monochrome-music/monochrome'; const repo = 'https://github.com/monochrome-music/monochrome';
// eslint-disable-next-line no-undef
const hash = typeof __COMMIT_HASH__ !== 'undefined' ? __COMMIT_HASH__ : 'dev'; const hash = typeof __COMMIT_HASH__ !== 'undefined' ? __COMMIT_HASH__ : 'dev';
const commitLink = const commitLink =
hash !== 'dev' && hash !== 'unknown' hash !== 'dev' && hash !== 'unknown'
@ -491,11 +490,9 @@ document.addEventListener('DOMContentLoaded', async () => {
await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality); await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality);
// Initialize tracker // Initialize tracker
initTracker(); initTracker().catch(console.error);
// Initialize Contributor List
fetchcontributors(); fetchcontributors();
const castBtn = document.getElementById('cast-btn'); const castBtn = document.getElementById('cast-btn');
initializeCasting(audioPlayer, castBtn); initializeCasting(audioPlayer, castBtn);
@ -575,8 +572,8 @@ document.addEventListener('DOMContentLoaded', async () => {
* having to manually re-scan. * having to manually re-scan.
* *
* When called with a `blob` and `filename` (single-track download case) it * When called with a `blob` and `filename` (single-track download case) it
* performs a cheap partial update reading metadata only from that one file * 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 * 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 * 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. * `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, // 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 // so this is a silent no-op when no folder is configured or permission is not
// yet granted. // yet granted.
scanLocalMediaFolder(); scanLocalMediaFolder().catch(console.error);
const scrobbler = new MultiScrobbler(); const scrobbler = new MultiScrobbler();
window.monochromeScrobbler = scrobbler; 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) { if (Player.instance.currentTrack) {
handleTrackAction( await handleTrackAction(
'download', 'download',
Player.instance.currentTrack, Player.instance.currentTrack,
Player.instance, Player.instance,
@ -1445,14 +1442,14 @@ document.addEventListener('DOMContentLoaded', async () => {
if (editingId) { if (editingId) {
// Edit // Edit
const cover = document.getElementById('playlist-cover-input').value.trim(); const cover = document.getElementById('playlist-cover-input').value.trim();
db.getPlaylist(editingId).then(async (playlist) => { await db.getPlaylist(editingId).then(async (playlist) => {
if (playlist) { if (playlist) {
playlist.name = name; playlist.name = name;
playlist.cover = cover; playlist.cover = cover;
playlist.description = description; playlist.description = description;
await handlePublicStatus(playlist); await handlePublicStatus(playlist);
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
syncManager.syncUserPlaylist(playlist, 'update'); await syncManager.syncUserPlaylist(playlist, 'update');
UIRenderer.instance.renderLibraryPage(); UIRenderer.instance.renderLibraryPage();
// Also update current page if we are on it // Also update current page if we are on it
if (window.location.pathname === `/userplaylist/${editingId}`) { if (window.location.pathname === `/userplaylist/${editingId}`) {
@ -1973,7 +1970,7 @@ document.addEventListener('DOMContentLoaded', async () => {
console.log(`Added ${tracks.length} tracks (including pending)`); 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); await handlePublicStatus(playlist);
// Update DB again with isPublic flag // Update DB again with isPublic flag
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); 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')) { if (e.target.closest('.edit-playlist-btn')) {
const card = e.target.closest('.user-playlist'); const card = e.target.closest('.user-playlist');
const playlistId = card.dataset.userPlaylistId; const playlistId = card.dataset.userPlaylistId;
db.getPlaylist(playlistId).then(async (playlist) => { await db.getPlaylist(playlistId).then(async (playlist) => {
if (playlist) { if (playlist) {
const modal = document.getElementById('playlist-modal'); const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist'; document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
@ -2016,7 +2013,10 @@ document.addEventListener('DOMContentLoaded', async () => {
shareBtn.style.display = playlist.isPublic ? 'flex' : 'none'; shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
shareBtn.onclick = () => { shareBtn.onclick = () => {
const url = getShareUrl(`/userplaylist/${playlist.id}`); 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,16 +2055,17 @@ document.addEventListener('DOMContentLoaded', async () => {
const card = e.target.closest('.user-playlist'); const card = e.target.closest('.user-playlist');
const playlistId = card.dataset.userPlaylistId; const playlistId = card.dataset.userPlaylistId;
if (confirm('Are you sure you want to delete this playlist?')) { if (confirm('Are you sure you want to delete this playlist?')) {
db.deletePlaylist(playlistId).then(() => { await db.deletePlaylist(playlistId);
syncManager.syncUserPlaylist({ id: playlistId }, 'delete'); await syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
UIRenderer.instance.renderLibraryPage(); UIRenderer.instance.renderLibraryPage();
});
} }
} }
if (e.target.closest('#edit-playlist-btn')) { if (e.target.closest('#edit-playlist-btn')) {
const playlistId = window.location.pathname.split('/')[2]; const playlistId = window.location.pathname.split('/')[2];
db.getPlaylist(playlistId).then((playlist) => { await db
.getPlaylist(playlistId)
.then((playlist) => {
if (playlist) { if (playlist) {
const modal = document.getElementById('playlist-modal'); const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist'; document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
@ -2078,9 +2079,12 @@ document.addEventListener('DOMContentLoaded', async () => {
if (publicToggle) publicToggle.checked = !!playlist.isPublic; if (publicToggle) publicToggle.checked = !!playlist.isPublic;
if (shareBtn) { if (shareBtn) {
shareBtn.style.display = playlist.isPublic ? 'flex' : 'none'; shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
shareBtn.onclick = () => { shareBtn.onclick = async () => {
const url = getShareUrl(`/userplaylist/${playlist.id}`); const url = getShareUrl(`/userplaylist/${playlist.id}`);
navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!')); await navigator.clipboard
.writeText(url)
.then(() => alert('Link copied to clipboard!'))
.catch(console.error);
}; };
} }
@ -2112,16 +2116,16 @@ document.addEventListener('DOMContentLoaded', async () => {
modal.classList.add('active'); modal.classList.add('active');
document.getElementById('playlist-name-input').focus(); document.getElementById('playlist-name-input').focus();
} }
}); })
.catch(console.error);
} }
if (e.target.closest('#delete-playlist-btn')) { if (e.target.closest('#delete-playlist-btn')) {
const playlistId = window.location.pathname.split('/')[2]; const playlistId = window.location.pathname.split('/')[2];
if (confirm('Are you sure you want to delete this playlist?')) { if (confirm('Are you sure you want to delete this playlist?')) {
db.deletePlaylist(playlistId).then(() => { await db.deletePlaylist(playlistId);
syncManager.syncUserPlaylist({ id: playlistId }, 'delete'); await syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
navigate('/library'); navigate('/library');
});
} }
} }
@ -2130,7 +2134,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const btn = e.target.closest('.remove-from-playlist-btn'); const btn = e.target.closest('.remove-from-playlist-btn');
const playlistId = window.location.pathname.split('/')[2]; const playlistId = window.location.pathname.split('/')[2];
db.getPlaylist(playlistId).then(async (playlist) => { await db.getPlaylist(playlistId).then(async (playlist) => {
let trackId = null; let trackId = null;
let trackType = null; let trackType = null;
@ -2149,7 +2153,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (trackId) { if (trackId) {
const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId, trackType); const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId, trackType);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
const scrollTop = document.querySelector('.main-content').scrollTop; const scrollTop = document.querySelector('.main-content').scrollTop;
await UIRenderer.instance.renderPlaylistPage(playlistId, 'user'); await UIRenderer.instance.renderPlaylistPage(playlistId, 'user');
document.querySelector('.main-content').scrollTop = scrollTop; document.querySelector('.main-content').scrollTop = scrollTop;
@ -2670,7 +2674,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// PWA Update Logic // PWA Update Logic
if (window.__AUTH_GATE__) { if (window.__AUTH_GATE__) {
disablePwaForAuthGate(); await disablePwaForAuthGate().catch(console.error);
} else { } else {
const updateSW = registerSW({ const updateSW = registerSW({
onNeedRefresh() { onNeedRefresh() {
@ -2788,10 +2792,10 @@ document.addEventListener('DOMContentLoaded', async () => {
); );
}); });
} else { } else {
headerAccountBtn.addEventListener('click', (e) => { headerAccountBtn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
headerAccountDropdown.classList.toggle('active'); 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-primary" id="header-create-profile">Create Profile</button>
<button class="btn-secondary danger" id="header-sign-out">Sign Out</button> <button class="btn-secondary danger" id="header-sign-out">Sign Out</button>
`; `;
document.getElementById('header-create-profile').onclick = () => { document.getElementById('header-create-profile').onclick = async () => {
openEditProfile(); openEditProfile().catch(console.error);
headerAccountDropdown.classList.remove('active'); headerAccountDropdown.classList.remove('active');
}; };
} }
@ -2953,7 +2957,7 @@ function showMissingTracksNotification(missingTracks, playlistName) {
const newCopyBtn = copyBtn.cloneNode(true); const newCopyBtn = copyBtn.cloneNode(true);
copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn); copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn);
newCopyBtn.addEventListener('click', () => { newCopyBtn.addEventListener('click', async () => {
const header = `Missing songs from ${playlistName} import:\n\n`; const header = `Missing songs from ${playlistName} import:\n\n`;
const textToCopy = const textToCopy =
header + header +
@ -2965,11 +2969,14 @@ function showMissingTracksNotification(missingTracks, playlistName) {
}) })
.join('\n'); .join('\n');
navigator.clipboard.writeText(textToCopy).then(() => { await navigator.clipboard
.writeText(textToCopy)
.then(async () => {
const originalText = newCopyBtn.textContent; const originalText = newCopyBtn.textContent;
newCopyBtn.textContent = 'Copied!'; newCopyBtn.textContent = 'Copied!';
setTimeout(() => (newCopyBtn.textContent = originalText), 2000); setTimeout(() => (newCopyBtn.textContent = originalText), 2000);
}); })
.catch(console.error);
}); });
} }

View file

@ -239,9 +239,10 @@ class AudioContextManager {
// Create biquad filters for each frequency band // Create biquad filters for each frequency band
this.filters = this.frequencies.map((freq, index) => { this.filters = this.frequencies.map((freq, index) => {
const filter = this.audioContext.createBiquadFilter(); const filter = this.audioContext.createBiquadFilter();
filter.type = 'peaking'; filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
filter.frequency.value = freq; 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; filter.gain.value = this.currentGains[index] || 0;
return filter; return filter;
}); });
@ -312,10 +313,10 @@ class AudioContextManager {
try { try {
this.audioContext = new AudioContext(highResOptions); this.audioContext = new AudioContext(highResOptions);
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`); console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
} catch (e) { } catch {
try { try {
this.audioContext = new AudioContext({ latencyHint: 'playback' }); this.audioContext = new AudioContext({ latencyHint: 'playback' });
} catch (e2) { } catch {
this.audioContext = new AudioContext(); this.audioContext = new AudioContext();
} }
} }
@ -358,7 +359,9 @@ class AudioContextManager {
if (this.source) { if (this.source) {
try { try {
this.source.disconnect(); this.source.disconnect();
} catch (e) {} } catch {
// node may already be disconnected
}
} }
this.audio = audioElement; this.audio = audioElement;
@ -386,7 +389,9 @@ class AudioContextManager {
// Disconnect everything first // Disconnect everything first
try { try {
this.source.disconnect(); this.source.disconnect();
} catch (e) {} } catch {
// node may already be disconnected
}
this.outputNode.disconnect(); this.outputNode.disconnect();
if (this.volumeNode) { if (this.volumeNode) {
this.volumeNode.disconnect(); this.volumeNode.disconnect();
@ -405,16 +410,23 @@ class AudioContextManager {
// Apply mono audio if enabled // Apply mono audio if enabled
if (this.isMonoAudioEnabled && this.monoMergerNode) { if (this.isMonoAudioEnabled && this.monoMergerNode) {
// Create a gain node to mix channels before the merger // Reuse persistent gain node to avoid leaking AudioNodes
const monoGain = this.audioContext.createGain(); if (!this.monoGainNode) {
monoGain.gain.value = 0.5; // Reduce volume to prevent clipping when mixing 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 // Connect source to mono gain
this.source.connect(monoGain); this.source.connect(this.monoGainNode);
// Connect mono gain to both inputs of the merger // Connect mono gain to both inputs of the merger
monoGain.connect(this.monoMergerNode, 0, 0); this.monoGainNode.connect(this.monoMergerNode, 0, 0);
monoGain.connect(this.monoMergerNode, 0, 1); this.monoGainNode.connect(this.monoMergerNode, 0, 1);
lastNode = this.monoMergerNode; lastNode = this.monoMergerNode;
console.log('[AudioContext] Mono audio enabled'); console.log('[AudioContext] Mono audio enabled');
@ -573,6 +585,57 @@ class AudioContextManager {
return equalizerSettings.getRange(); 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 * Clamp gain to valid range
*/ */
@ -665,8 +728,11 @@ class AudioContextManager {
this.isEQEnabled = equalizerSettings.isEnabled(); this.isEQEnabled = equalizerSettings.isEnabled();
this.bandCount = equalizerSettings.getBandCount(); this.bandCount = equalizerSettings.getBandCount();
this.freqRange = equalizerSettings.getFreqRange(); 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.currentGains = equalizerSettings.getGains(this.bandCount);
this.currentTypes = equalizerSettings.getBandTypes(this.bandCount);
this.currentQs = equalizerSettings.getBandQs(this.bandCount);
this.isMonoAudioEnabled = monoAudioSettings.isEnabled(); this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
this.preamp = equalizerSettings.getPreamp(); 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 * Export equalizer settings to text format
* @returns {string} Exported settings in text format * @returns {string} Exported settings in text format
@ -709,8 +857,13 @@ class AudioContextManager {
this.frequencies.forEach((freq, index) => { this.frequencies.forEach((freq, index) => {
const gain = this.currentGains[index] || 0; 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; 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'); return lines.join('\n');
@ -760,24 +913,42 @@ class AudioContextManager {
this.setPreamp(preamp); this.setPreamp(preamp);
// If different number of bands, adjust // If different number of bands, adjust
if (filters.length !== this.bandCount) {
const newCount = Math.max( const newCount = Math.max(
equalizerSettings.MIN_BANDS, equalizerSettings.MIN_BANDS,
Math.min(equalizerSettings.MAX_BANDS, filters.length) Math.min(equalizerSettings.MAX_BANDS, filters.length)
); );
if (newCount !== this.bandCount) {
this.setBandCount(newCount); this.setBandCount(newCount);
} }
// Extract gains from filters // Apply per-band frequencies, types, Qs, and gains from import
const gains = filters.slice(0, this.bandCount).map((f) => f.gain); const sliced = filters.slice(0, this.bandCount);
this.setAllGains(gains); 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 // Rebuild EQ chain to apply new frequencies, types, and Qs
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq); if (this.isInitialized && this.audioContext) {
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) { this._destroyEQ();
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]); this._createEQ();
this._connectGraph();
} }
// Persist all band settings
equalizerSettings.setGains(this.currentGains);
equalizerSettings.setBandTypes(this.currentTypes);
equalizerSettings.setBandQs(this.currentQs);
return true; return true;
} catch (e) { } catch (e) {
console.warn('[AudioContext] Failed to import EQ settings:', e); console.warn('[AudioContext] Failed to import EQ settings:', e);

4394
js/autoeq-data.js Normal file

File diff suppressed because it is too large Load diff

270
js/autoeq-engine.js Normal file
View 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
View 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 };

View file

@ -69,9 +69,7 @@ export class ZipStreamWriter implements IBulkDownloadWriter {
constructor(private readonly suggestedFilename: string) {} constructor(private readonly suggestedFilename: string) {}
async write(files: AsyncIterable<WriterEntry>): Promise<void> { async write(files: AsyncIterable<WriterEntry>): Promise<void> {
// showSaveFilePicker is part of the File System Access API (not yet in all TS DOM libs) const fileHandle = await window.showSaveFilePicker({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fileHandle = await (window as any).showSaveFilePicker({
suggestedName: this.suggestedFilename, suggestedName: this.suggestedFilename,
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }], 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 // Try to re-use a saved handle first
if (savedHandle) { if (savedHandle) {
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any const permission = await savedHandle.requestPermission({ mode: 'readwrite' });
const permission = await (savedHandle as any).requestPermission({ mode: 'readwrite' });
if (permission === 'granted') { if (permission === 'granted') {
return new FolderPickerWriter(savedHandle); 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) // 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 { try {
const dirHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker({ const dirHandle: FileSystemDirectoryHandle = await window.showDirectoryPicker({
mode: 'readwrite', mode: 'readwrite',
}); });
return new FolderPickerWriter(dirHandle); return new FolderPickerWriter(dirHandle);

View file

@ -7,7 +7,7 @@ export class APICache {
this.dbName = 'monochrome-cache'; this.dbName = 'monochrome-cache';
this.dbVersion = 1; this.dbVersion = 1;
this.db = null; this.db = null;
this.initDB(); this.initDB().catch(console.error);
} }
async initDB() { async initDB() {

View file

@ -338,9 +338,9 @@ class CommandPalette {
icon: 'trash', icon: 'trash',
label: 'Clear Queue', label: 'Clear Queue',
keywords: ['wipe', 'clear', 'empty', 'queue'], keywords: ['wipe', 'clear', 'empty', 'queue'],
action: () => { action: async () => {
Player.instance.wipeQueue(); 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'], keywords: ['edit', 'profile', 'username', 'avatar', 'display name'],
action: async () => { action: async () => {
const { openEditProfile } = await import('./profile.js'); const { openEditProfile } = await import('./profile.js');
openEditProfile(); await openEditProfile();
}, },
}, },
{ {
@ -780,7 +780,7 @@ class CommandPalette {
this.updateSelection(); this.updateSelection();
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
this.executeSelected(); this.executeSelected().catch(console.error);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
if (this.settingsMode) { if (this.settingsMode) {
this.settingsMode = false; 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.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.selectedIndex = index;
this.executeSelected(); await this.executeSelected();
}); });
el.addEventListener('mouseenter', () => { el.addEventListener('mouseenter', () => {
@ -1171,14 +1171,14 @@ class CommandPalette {
if (opt.dataset.theme === theme) opt.classList.add('active'); if (opt.dataset.theme === theme) opt.classList.add('active');
else opt.classList.remove('active'); else opt.classList.remove('active');
}); });
this.notify(`Theme set to ${theme}`); await this.notify(`Theme set to ${theme}`);
} }
async toggleVisualizer() { async toggleVisualizer() {
const { visualizerSettings } = await import('./storage.js'); const { visualizerSettings } = await import('./storage.js');
const current = visualizerSettings.isEnabled(); const current = visualizerSettings.isEnabled();
visualizerSettings.setEnabled(!current); visualizerSettings.setEnabled(!current);
this.notify(`Visualizer ${!current ? 'enabled' : 'disabled'}`); await this.notify(`Visualizer ${!current ? 'enabled' : 'disabled'}`);
const overlay = document.getElementById('fullscreen-cover-overlay'); const overlay = document.getElementById('fullscreen-cover-overlay');
if (overlay && getComputedStyle(overlay).display !== 'none') { if (overlay && getComputedStyle(overlay).display !== 'none') {
@ -1192,7 +1192,7 @@ class CommandPalette {
if (UIRenderer.instance.visualizer) { if (UIRenderer.instance.visualizer) {
UIRenderer.instance.visualizer.setPreset(preset); UIRenderer.instance.visualizer.setPreset(preset);
} }
this.notify(`Visualizer preset: ${preset}`); await this.notify(`Visualizer preset: ${preset}`);
} }
async setQuality(quality) { async setQuality(quality) {
@ -1225,13 +1225,13 @@ class CommandPalette {
const downloadSelect = document.getElementById('download-quality-setting'); const downloadSelect = document.getElementById('download-quality-setting');
if (downloadSelect) downloadSelect.value = dlQuality; 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) { if (Player.instance) {
Player.instance.setSleepTimer(minutes); 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(); const queue = player.getCurrentQueue();
if (queue.length === 0) { if (queue.length === 0) {
this.notify('Queue is empty'); await this.notify('Queue is empty');
return; return;
} }
@ -1250,7 +1250,7 @@ class CommandPalette {
const scrobbler = window.monochromeScrobbler; const scrobbler = window.monochromeScrobbler;
let likedCount = 0; let likedCount = 0;
this.notify('Liking all tracks in queue...'); await this.notify('Liking all tracks in queue...');
for (const track of queue) { for (const track of queue) {
const isLiked = await db.isFavorite('track', track.id); const isLiked = await db.isFavorite('track', track.id);
if (!isLiked) { if (!isLiked) {
@ -1258,7 +1258,7 @@ class CommandPalette {
likedCount++; likedCount++;
} }
} }
this.notify(`Liked ${likedCount} new track(s)`); await this.notify(`Liked ${likedCount} new track(s)`);
} }
async downloadQueue() { async downloadQueue() {
@ -1268,40 +1268,39 @@ class CommandPalette {
const queue = player.getCurrentQueue(); const queue = player.getCurrentQueue();
if (queue.length === 0) { if (queue.length === 0) {
this.notify('Queue is empty'); await this.notify('Queue is empty');
return; return;
} }
const { downloadTracks } = await import('./downloads.js'); const { downloadTracks } = await import('./downloads.js');
const { downloadQualitySettings } = await import('./storage.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() { async createPlaylist() {
const name = `New Playlist ${new Date().toLocaleDateString()}`; const name = `New Playlist ${new Date().toLocaleDateString()}`;
await db.createPlaylist(name); await db.createPlaylist(name);
navigate('/library'); navigate('/library');
this.notify('Playlist created'); await this.notify('Playlist created');
} }
async createFolder() { async createFolder() {
const name = `New Folder ${new Date().toLocaleDateString()}`; const name = `New Folder ${new Date().toLocaleDateString()}`;
await db.createFolder(name); await db.createFolder(name);
navigate('/library'); navigate('/library');
this.notify('Folder created'); await this.notify('Folder created');
} }
async clearCache() { async clearCache() {
const api = UIRenderer.instance.api; const api = UIRenderer.instance.api;
if (api) { if (api) {
await api.clearCache(); await api.clearCache();
this.notify('Cache cleared'); await this.notify('Cache cleared');
} }
} }
async notify(message) { async notify(message) {
const { showNotification } = await import('./downloads.js'); await import('./downloads.js').then((m) => m.showNotification(message)).catch(console.error);
showNotification(message);
} }
} }

View file

@ -85,7 +85,7 @@ export class MediaMetadata extends BaseContainer {
} }
export class Artist extends BaseContainer { export class Artist extends BaseContainer {
handle: any; handle: unknown;
id: number; id: number;
name: string; name: string;
picture: string; picture: string;
@ -99,6 +99,7 @@ export class Artist extends BaseContainer {
export class EnrichedTrack extends Track { export class EnrichedTrack extends Track {
declare album: TrackAlbum | EnrichedAlbum; declare album: TrackAlbum | EnrichedAlbum;
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-redundant-type-constituents
declare replayGain: any | ReplayGain; declare replayGain: any | ReplayGain;
constructor(data: object) { constructor(data: object) {

View file

@ -204,13 +204,13 @@ export class DashDownloader {
const resolveTemplate = (template: string, number: number, time: number): string => { const resolveTemplate = (template: string, number: number, time: number): string => {
return template return template
.replace(/\$RepresentationID\$/g, repId ?? '') .replace(/\$RepresentationID\$/g, repId ?? '')
.replace(/\$Number(?:%0([0-9]+)d)?\$/g, (_, width) => { .replace(/\$Number(?:%0([0-9]+)d)?\$/g, (_, width: string) => {
if (width) { if (width) {
return number.toString().padStart(parseInt(width), '0'); return number.toString().padStart(parseInt(width), '0');
} }
return number.toString(); return number.toString();
}) })
.replace(/\$Time(?:%0([0-9]+)d)?\$/g, (_, width) => { .replace(/\$Time(?:%0([0-9]+)d)?\$/g, (_, width: string) => {
if (width) { if (width) {
return time.toString().padStart(parseInt(width), '0'); return time.toString().padStart(parseInt(width), '0');
} }

View file

@ -24,7 +24,7 @@ export class MusicDatabase {
request.onupgradeneeded = (event) => { request.onupgradeneeded = (event) => {
const db = event.target.result; 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')) { if (db.objectStoreNames.contains('track_ratings')) {
db.deleteObjectStore('track_ratings'); db.deleteObjectStore('track_ratings');
} }
@ -783,7 +783,6 @@ export class MusicDatabase {
} }
// Return lightweight copy without tracks // Return lightweight copy without tracks
// eslint-disable-next-line no-unused-vars
const { tracks, ...minified } = playlist; const { tracks, ...minified } = playlist;
return minified; return minified;
}); });

View file

@ -24,7 +24,8 @@ export function doTimedAsync<T, R = T extends Promise<T> ? Promise<T> : T>(
throwError: boolean = false throwError: boolean = false
): R { ): R {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
return new Promise(async (resolve, reject) => { return new Promise((resolve, reject) => {
(async () => {
const hiddenId = InvisibleCodec.encode(v7()); const hiddenId = InvisibleCodec.encode(v7());
console.time(message + hiddenId); console.time(message + hiddenId);
try { try {
@ -33,15 +34,20 @@ export function doTimedAsync<T, R = T extends Promise<T> ? Promise<T> : T>(
} catch (err) { } catch (err) {
console.error(`Error in timed operation "${message}":`, err); console.error(`Error in timed operation "${message}":`, err);
if (throwError) { if (throwError) {
if (err instanceof Error) {
reject(err); reject(err);
} else {
reject(new Error(String(err)));
}
} else { } else {
resolve(undefined as R); resolve(undefined as R);
} }
} finally { } finally {
console.timeEnd(message + hiddenId); console.timeEnd(message + hiddenId);
} }
})().catch(reject);
}) as R; }) as R;
} else { } else {
return callback() as R; return callback();
} }
} }

View file

@ -17,8 +17,9 @@ import { ZipStreamWriter, ZipBlobWriter, FolderPickerWriter, SequentialFileWrite
import { FfmpegProgress } from './ffmpeg.types.js'; import { FfmpegProgress } from './ffmpeg.types.js';
import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './progressEvents.js'; import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './progressEvents.js';
import { db } from './db.js'; import { db } from './db.js';
import { modernSettings } from './ModernSettings.js'; import { BulkDownloadMethod, modernSettings } from './ModernSettings.js';
import { SVG_CLOSE } from './icons.ts'; import { SVG_CLOSE } from './icons.ts';
import { MusicAPI } from './music-api.js';
import { LyricsManager } from './lyrics.js'; import { LyricsManager } from './lyrics.js';
const downloadTasks = new Map(); const downloadTasks = new Map();
@ -166,7 +167,7 @@ export function showNotification(message) {
}, 1500); }, 1500);
} }
export function addDownloadTask(trackId, track, filename, api, abortController) { export function addDownloadTask(trackId, track, _filename, api, abortController) {
const container = createDownloadNotification(); const container = createDownloadNotification();
const taskEl = document.createElement('div'); const taskEl = document.createElement('div');
@ -507,7 +508,7 @@ async function createSingleTrackFolderWriter() {
const method = modernSettings.bulkDownloadMethod; const method = modernSettings.bulkDownloadMethod;
const hasFolderPicker = 'showDirectoryPicker' in window; const hasFolderPicker = 'showDirectoryPicker' in window;
if (method === 'local') { if (method === BulkDownloadMethod.LocalMedia) {
const localHandle = await db.getSetting('local_folder_handle'); const localHandle = await db.getSetting('local_folder_handle');
if (hasFolderPicker && localHandle && typeof localHandle.requestPermission === 'function') { if (hasFolderPicker && localHandle && typeof localHandle.requestPermission === 'function') {
try { try {
@ -520,7 +521,7 @@ async function createSingleTrackFolderWriter() {
return null; return null;
} }
if (method === 'folder' && hasFolderPicker) { if (method === BulkDownloadMethod.Folder && hasFolderPicker) {
const rememberFolder = modernSettings.rememberBulkDownloadFolder; const rememberFolder = modernSettings.rememberBulkDownloadFolder;
const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null; const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null;
// Try to reuse the saved handle silently first. // Try to reuse the saved handle silently first.
@ -563,7 +564,7 @@ async function createBulkWriter(folderName) {
const hasFolderPicker = 'showDirectoryPicker' in window; const hasFolderPicker = 'showDirectoryPicker' in window;
// ── Local Media Folder method ──────────────────────────────────────────── // ── Local Media Folder method ────────────────────────────────────────────
if (method === 'local') { if (method === BulkDownloadMethod.LocalMedia) {
const localHandle = await db.getSetting('local_folder_handle'); const localHandle = await db.getSetting('local_folder_handle');
if (hasFolderPicker) { if (hasFolderPicker) {
// Browser mode: try to reuse the stored handle with write permission // Browser mode: try to reuse the stored handle with write permission
@ -593,7 +594,7 @@ async function createBulkWriter(folderName) {
} }
// ── Folder Picker method ───────────────────────────────────────────────── // ── Folder Picker method ─────────────────────────────────────────────────
if (method === 'folder' && hasFolderPicker) { if (method === BulkDownloadMethod.Folder && hasFolderPicker) {
const rememberFolder = modernSettings.rememberBulkDownloadFolder; const rememberFolder = modernSettings.rememberBulkDownloadFolder;
const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null; const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null;
try { try {
@ -613,7 +614,7 @@ async function createBulkWriter(folderName) {
} }
} }
if (method === 'individual') { if (method === BulkDownloadMethod.Individual) {
return SequentialFileWriter; return SequentialFileWriter;
} }
// method === 'zip' (or folder picker unavailable as fallback) // method === 'zip' (or folder picker unavailable as fallback)
@ -658,7 +659,7 @@ async function startBulkDownload({
completeBulkDownload(notification, true); completeBulkDownload(notification, true);
// If the download went to the local media folder, refresh the local library. // If the download went to the local media folder, refresh the local library.
if (modernSettings.bulkDownloadMethod === 'local') { if (modernSettings.bulkDownloadMethod === BulkDownloadMethod.LocalMedia) {
window.refreshLocalMediaFolder?.(); window.refreshLocalMediaFolder?.();
} }
} catch (error) { } 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)}`; const folderName = `Queue - ${new Date().toISOString().slice(0, 10)}`;
await startBulkDownload({ await startBulkDownload({
tracks, 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 = const releaseDateStr =
album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : ''); album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null; 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, { const folderName = formatPathTemplate(modernSettings.folderTemplate, {
albumTitle: playlist.title, albumTitle: playlist.title,
albumArtist: 'Playlist', 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) { if (!track) {
alert('No track is currently playing'); alert('No track is currently playing');
return; return;
} }
/** @type {LosslessAPI} */
const tidalAPI = api.tidalAPI || api;
const downloadKey = `track-${track.id}`; const downloadKey = `track-${track.id}`;
if (ongoingDownloads.has(downloadKey)) { if (ongoingDownloads.has(downloadKey)) {
showNotification('This track is already being downloaded'); showNotification('This track is already being downloaded');
return; return;
} }
const { enrichedTrack } = await api.tidalAPI.enrichTrack(track, { downloadQuality: quality }); const { enrichedTrack } = await tidalAPI.enrichTrack(track, { downloadQuality: quality });
const filename = buildTrackFilename(enrichedTrack, quality); const filename = buildTrackFilename(enrichedTrack, quality);
const controller = abortController || new AbortController(); 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: // 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 // 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. // 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); 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)}`; const folderName = `Liked Tracks - ${new Date().toISOString().slice(0, 10)}`;
await startBulkDownload({ await startBulkDownload({
tracks, tracks,

View file

@ -621,8 +621,9 @@ export class Equalizer {
this.frequencies.forEach((freq, index) => { this.frequencies.forEach((freq, index) => {
const gain = this.currentGains[index] || 0; const gain = this.currentGains[index] || 0;
const q = this.filters[index] ? this.filters[index].Q.value : this._calculateQ(index);
const filterNum = index + 1; 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'); return lines.join('\n');
@ -680,15 +681,52 @@ export class Equalizer {
this.setBandCount(newCount); this.setBandCount(newCount);
} }
// Extract gains from filters // Apply imported filter frequencies directly instead of regenerating
const gains = filters.slice(0, this.bandCount).map((f) => f.gain); 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); this.setAllGains(gains);
// Store filter frequencies if different // Apply filter types (PK/LS/HS -> peaking/lowshelf/highshelf)
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq); const typeMap = { PK: 'peaking', LS: 'lowshelf', HS: 'highshelf', LSC: 'lowshelf', HSC: 'highshelf' };
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) { const types = sliced.map((f) => typeMap[f.type] || 'peaking');
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]); 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; return true;
} catch (e) { } catch (e) {

View file

@ -56,6 +56,9 @@ import {
} from './analytics.js'; } from './analytics.js';
import { SVG_BIN, SVG_MUTE, SVG_PAUSE, SVG_PLAY, SVG_VOLUME, SVG_CHECKBOX, SVG_CHECKBOX_CHECKED } from './icons.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 { partyManager } from './listening-party.js';
import { MusicAPI } from './music-api.js';
import { LyricsManager } from './lyrics.js';
import { Player } from './player.js';
let currentTrackIdForWaveform = null; let currentTrackIdForWaveform = null;
@ -73,26 +76,26 @@ const LONG_PRESS_DURATION = 500;
function handleTrackTouchStart(e) { function handleTrackTouchStart(e) {
if (!('ontouchstart' in window)) return; if (!('ontouchstart' in window)) return;
const trackItem = e.target.closest('.track-item'); 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; isLongPress = false;
longPressTrackItem = trackItem; longPressTrackItem = trackItem;
longPressTimer = setTimeout(() => { longPressTimer = setTimeout(async () => {
isLongPress = true; isLongPress = true;
toggleTrackSelection(trackItem, true, false); toggleTrackSelection(trackItem, true, false);
hapticLongPress(); await hapticLongPress();
}, LONG_PRESS_DURATION); }, LONG_PRESS_DURATION);
} }
function handleTrackTouchMove(e) { function handleTrackTouchMove(_e) {
if (longPressTimer) { if (longPressTimer) {
clearTimeout(longPressTimer); clearTimeout(longPressTimer);
longPressTimer = null; longPressTimer = null;
} }
} }
function handleTrackTouchEnd(e) { function handleTrackTouchEnd(_e) {
if (longPressTimer) { if (longPressTimer) {
clearTimeout(longPressTimer); clearTimeout(longPressTimer);
longPressTimer = null; longPressTimer = null;
@ -204,7 +207,7 @@ function toggleTrackSelection(trackItem, ctrlHeld, shiftHeld) {
document.body.classList.toggle('multi-select-mode', trackSelection.isSelecting); document.body.classList.toggle('multi-select-mode', trackSelection.isSelecting);
} }
function showMultiSelectPlaylistModal(tracks) { async function showMultiSelectPlaylistModal(tracks) {
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'modal-overlay'; modal.className = 'modal-overlay';
modal.style.cssText = modal.style.cssText =
@ -237,7 +240,7 @@ function showMultiSelectPlaylistModal(tracks) {
document.body.appendChild(modal); document.body.appendChild(modal);
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
db.getPlaylists(true).then((playlists) => { await db.getPlaylists(true).then((playlists) => {
const listEl = modal.querySelector('.playlist-list'); const listEl = modal.querySelector('.playlist-list');
if (playlists.length === 0) { if (playlists.length === 0) {
listEl.innerHTML = '<div style="padding: 12px; color: var(--muted-foreground);">No playlists yet</div>'; 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) { for (const track of tracks) {
await db.addTrackToPlaylist(playlistId, track); 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`); showNotification(`Added ${tracks.length} tracks to playlist`);
closeModal(); closeModal();
}); });
}); });
}); });
modal.querySelector('.create-new-playlist').addEventListener('click', () => { modal.querySelector('.create-new-playlist').addEventListener('click', async () => {
const name = prompt('Playlist name:'); const name = prompt('Playlist name:');
if (name) { if (name) {
db.createPlaylist(name, tracks).then((playlist) => { await db.createPlaylist(name, tracks).then((_playlist) => {
showNotification(`Created playlist "${name}" with ${tracks.length} tracks`); showNotification(`Created playlist "${name}" with ${tracks.length} tracks`);
closeModal(); closeModal();
}); });
@ -278,7 +281,6 @@ function showMultiSelectPlaylistModal(tracks) {
}); });
} }
export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
const playPauseBtn = document.querySelector('.now-playing-bar .play-pause-btn'); const playPauseBtn = document.querySelector('.now-playing-bar .play-pause-btn');
const nextBtn = document.getElementById('next-btn'); const nextBtn = document.getElementById('next-btn');
const prevBtn = document.getElementById('prev-btn'); const prevBtn = document.getElementById('prev-btn');
@ -287,14 +289,14 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
const homeStartRadioBtn = document.getElementById('home-start-infinite-radio-btn'); const homeStartRadioBtn = document.getElementById('home-start-infinite-radio-btn');
const sleepTimerBtnDesktop = document.getElementById('sleep-timer-btn-desktop'); const sleepTimerBtnDesktop = document.getElementById('sleep-timer-btn-desktop');
const volumeBar = document.getElementById('volume-bar'); const _volumeBar = document.getElementById('volume-bar');
const volumeFill = document.getElementById('volume-fill'); const volumeFill = document.getElementById('volume-fill');
const volumeBtn = document.getElementById('volume-btn'); const volumeBtn = document.getElementById('volume-btn');
const updateVolumeUI = () => { const updateVolumeUI = () => {
const activeEl = player.activeElement; const activeEl = Player.instance.activeElement;
const { muted } = activeEl; const { muted } = activeEl;
const volume = player.userVolume; const volume = Player.instance.userVolume;
volumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE(20) : SVG_VOLUME(20); volumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE(20) : SVG_VOLUME(20);
const effectiveVolume = muted ? 0 : volume * 100; const effectiveVolume = muted ? 0 : volume * 100;
volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`); volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`);
@ -345,7 +347,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
bar.classList.toggle('visible', count > 0); bar.classList.toggle('visible', count > 0);
} }
function handleSelectionAction(action) { async function handleSelectionAction(action) {
const selectedIds = getSelectedTracks(); const selectedIds = getSelectedTracks();
if (selectedIds.length === 0) return; if (selectedIds.length === 0) return;
@ -361,36 +363,41 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
switch (action) { switch (action) {
case 'play-selected': case 'play-selected':
if (selectedTracks.length > 0) { if (selectedTracks.length > 0) {
player.setQueue(selectedTracks, 0); Player.instance.setQueue(selectedTracks, 0);
document.getElementById('shuffle-btn').classList.remove('active'); document.getElementById('shuffle-btn').classList.remove('active');
player.playTrackFromQueue(); Player.instance.playTrackFromQueue();
} }
break; break;
case 'add-to-queue-selected': case 'add-to-queue-selected':
if (selectedTracks.length > 0) { if (selectedTracks.length > 0) {
player.addToQueue(selectedTracks); Player.instance.addToQueue(selectedTracks);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Added ${selectedTracks.length} tracks to queue`); showNotification(`Added ${selectedTracks.length} tracks to queue`);
} }
break; break;
case 'add-to-playlist-selected': case 'add-to-playlist-selected':
if (selectedTracks.length > 0) { if (selectedTracks.length > 0) {
showMultiSelectPlaylistModal(selectedTracks); await showMultiSelectPlaylistModal(selectedTracks);
} }
break; break;
case 'download-selected': case 'download-selected':
if (selectedTracks.length > 0) { if (selectedTracks.length > 0) {
selectedTracks.forEach((track) => {
downloadTrackWithMetadata(track, downloadQualitySettings.getQuality(), api, lyricsManager);
});
showNotification(`Downloading ${selectedTracks.length} tracks`); showNotification(`Downloading ${selectedTracks.length} tracks`);
for (const track of selectedTracks) {
await downloadTrackWithMetadata(
track,
downloadQualitySettings.getQuality(),
MusicAPI.instance.tidalAPI,
LyricsManager.instance
);
}
} }
break; break;
case 'like-selected': case 'like-selected':
selectedTracks.forEach(async (track) => { for (const track of selectedTracks) {
const added = await db.toggleFavorite('track', track); const added = await db.toggleFavorite('track', track);
syncManager.syncLibraryItem('track', track, added); await syncManager.syncLibraryItem('track', track, added);
}); }
showNotification(`Liked ${selectedTracks.length} tracks`); showNotification(`Liked ${selectedTracks.length} tracks`);
break; break;
case 'clear-selection': case 'clear-selection':
@ -399,6 +406,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
} }
} }
export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
if (homeStartRadioBtn) { if (homeStartRadioBtn) {
homeStartRadioBtn.addEventListener('click', async () => { homeStartRadioBtn.addEventListener('click', async () => {
await player.enableRadio(); 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; if (player.activeElement !== element) return;
// Initialize audio context manager for EQ (only once) // Initialize audio context manager for EQ (only once)
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
audioContextManager.init(element); audioContextManager.init(element);
} }
audioContextManager.resume(); await audioContextManager.resume();
if (player.currentTrack) { if (player.currentTrack) {
// Track play event // Track play event
@ -435,7 +443,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
scrobbler.updateNowPlaying(player.currentTrack); scrobbler.updateNowPlaying(player.currentTrack);
} }
updateWaveform(); await updateWaveform();
} }
playPauseBtn.innerHTML = SVG_PAUSE(20); 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) { if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) {
historyLoggedTrackId = player.currentTrack.id; historyLoggedTrackId = player.currentTrack.id;
const historyEntry = await db.addToHistory(player.currentTrack); const historyEntry = await db.addToHistory(player.currentTrack);
syncManager.syncHistoryItem(historyEntry); await syncManager.syncHistoryItem(historyEntry);
if (window.location.hash === '#recent') { if (window.location.hash === '#recent') {
ui.renderRecentPage(); ui.renderRecentPage();
@ -554,31 +562,31 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
setupMediaListeners(player.video); setupMediaListeners(player.video);
} }
playPauseBtn.addEventListener('click', () => { playPauseBtn.addEventListener('click', async () => {
hapticMedium(); await hapticMedium();
player.handlePlayPause(); player.handlePlayPause();
}); });
nextBtn.addEventListener('click', () => { nextBtn.addEventListener('click', async () => {
hapticMedium(); await hapticMedium();
trackSkipTrack(player.currentTrack, 'next'); trackSkipTrack(player.currentTrack, 'next');
player.playNext(); player.playNext();
}); });
prevBtn.addEventListener('click', () => { prevBtn.addEventListener('click', async () => {
hapticMedium(); await hapticMedium();
trackSkipTrack(player.currentTrack, 'previous'); trackSkipTrack(player.currentTrack, 'previous');
player.playPrev(); player.playPrev();
}); });
shuffleBtn.addEventListener('click', () => { shuffleBtn.addEventListener('click', async () => {
hapticLight(); await hapticLight();
player.toggleShuffle(); player.toggleShuffle();
trackToggleShuffle(player.shuffleActive); trackToggleShuffle(player.shuffleActive);
shuffleBtn.classList.toggle('active', player.shuffleActive); shuffleBtn.classList.toggle('active', player.shuffleActive);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
}); });
repeatBtn.addEventListener('click', () => { repeatBtn.addEventListener('click', async () => {
hapticLight(); await hapticLight();
const mode = player.toggleRepeat(); const mode = player.toggleRepeat();
trackToggleRepeat(mode === REPEAT_MODE.OFF ? 'off' : mode === REPEAT_MODE.ALL ? 'all' : 'one'); trackToggleRepeat(mode === REPEAT_MODE.OFF ? 'off' : mode === REPEAT_MODE.ALL ? 'all' : 'one');
repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF); repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF);
@ -655,7 +663,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
progressBar.style.maskImage = ''; progressBar.style.maskImage = '';
try { 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); const waveformData = await waveformGenerator.getWaveform(streamUrl, player.currentTrack.id);
if (waveformData && currentTrackIdForWaveform === 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) { if (!e.detail.enabled) {
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const playerControls = document.querySelector('.player-controls'); const playerControls = document.querySelector('.player-controls');
@ -722,7 +730,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
playerControls.classList.remove('waveform-loaded'); playerControls.classList.remove('waveform-loaded');
} }
} }
updateWaveform(); await updateWaveform();
}); });
if (volumeBtn) { if (volumeBtn) {
@ -1102,7 +1110,7 @@ export async function showAddToPlaylistModal(track) {
e.stopPropagation(); e.stopPropagation();
await db.removeTrackFromPlaylist(playlistId, track.id); await db.removeTrackFromPlaylist(playlistId, track.id);
const updatedPlaylist = await db.getPlaylist(playlistId); const updatedPlaylist = await db.getPlaylist(playlistId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
showNotification(`Removed from playlist: ${option.querySelector('span').textContent}`); showNotification(`Removed from playlist: ${option.querySelector('span').textContent}`);
await renderModal(); await renderModal();
} else { } else {
@ -1110,7 +1118,7 @@ export async function showAddToPlaylistModal(track) {
await db.addTrackToPlaylist(playlistId, track); await db.addTrackToPlaylist(playlistId, track);
const updatedPlaylist = await db.getPlaylist(playlistId); const updatedPlaylist = await db.getPlaylist(playlistId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
showNotification(`Added to playlist: ${option.querySelector('span').textContent}`); showNotification(`Added to playlist: ${option.querySelector('span').textContent}`);
closeModal(); closeModal();
} }
@ -1272,14 +1280,14 @@ export async function handleTrackAction(
if (action === 'add-to-queue') { if (action === 'add-to-queue') {
player.addToQueue(tracks); player.addToQueue(tracks);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Added ${tracks.length} tracks to queue`); showNotification(`Added ${tracks.length} tracks to queue`);
return; return;
} }
if (action === 'play-next') { if (action === 'play-next') {
player.addNextToQueue(tracks); player.addNextToQueue(tracks);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Playing next: ${tracks.length} tracks`); showNotification(`Playing next: ${tracks.length} tracks`);
return; return;
} }
@ -1336,7 +1344,8 @@ export async function handleTrackAction(
// Individual Track Actions // Individual Track Actions
// Check if track/artist is blocked // Check if track/artist is blocked
const { contentBlockingSettings } = await import('./storage.js'); 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'); showNotification('This track is blocked');
return; return;
} }
@ -1344,12 +1353,12 @@ export async function handleTrackAction(
if (action === 'add-to-queue') { if (action === 'add-to-queue') {
trackAddToQueue(item, 'end'); trackAddToQueue(item, 'end');
player.addToQueue(item); player.addToQueue(item);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Added to queue: ${item.title}`); showNotification(`Added to queue: ${item.title}`);
} else if (action === 'play-next') { } else if (action === 'play-next') {
trackPlayNext(item); trackPlayNext(item);
player.addNextToQueue(item); player.addNextToQueue(item);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Playing next: ${item.title}`); showNotification(`Playing next: ${item.title}`);
} else if (action === 'play-card') { } else if (action === 'play-card') {
player.setQueue([item], 0); player.setQueue([item], 0);
@ -1367,7 +1376,7 @@ export async function handleTrackAction(
await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager); await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager);
} else if (action === 'toggle-like') { } else if (action === 'toggle-like') {
const added = await db.toggleFavorite(type, item); const added = await db.toggleFavorite(type, item);
syncManager.syncLibraryItem(type, item, added); await syncManager.syncLibraryItem(type, item, added);
// Track like/unlike // Track like/unlike
if (added) { if (added) {
@ -1623,7 +1632,7 @@ export async function handleTrackAction(
e.stopPropagation(); e.stopPropagation();
await db.removeTrackFromPlaylist(playlistId, item.id); await db.removeTrackFromPlaylist(playlistId, item.id);
const updatedPlaylist = await db.getPlaylist(playlistId); const updatedPlaylist = await db.getPlaylist(playlistId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
showNotification(`Removed from playlist: ${option.querySelector('span').textContent}`); showNotification(`Removed from playlist: ${option.querySelector('span').textContent}`);
await renderModal(); await renderModal();
} else { } else {
@ -1631,7 +1640,7 @@ export async function handleTrackAction(
await db.addTrackToPlaylist(playlistId, item); await db.addTrackToPlaylist(playlistId, item);
const updatedPlaylist = await db.getPlaylist(playlistId); const updatedPlaylist = await db.getPlaylist(playlistId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
showNotification(`Added to playlist: ${option.querySelector('span').textContent}`); showNotification(`Added to playlist: ${option.querySelector('span').textContent}`);
closeModal(); closeModal();
} }
@ -1669,9 +1678,12 @@ export async function handleTrackAction(
const url = getShareUrl(storedHref ? storedHref : `/${typeForUrl}/${item.id || item.uuid}`); const url = getShareUrl(storedHref ? storedHref : `/${typeForUrl}/${item.id || item.uuid}`);
trackCopyLink(type, item.id || item.uuid); trackCopyLink(type, item.id || item.uuid);
navigator.clipboard.writeText(url).then(() => { await navigator.clipboard
.writeText(url)
.then(() => {
showNotification('Link copied to clipboard!'); showNotification('Link copied to clipboard!');
}); })
.catch(console.error);
} else if (action === 'open-in-new-tab') { } else if (action === 'open-in-new-tab') {
// Use stored href from card if available, otherwise construct URL // Use stored href from card if available, otherwise construct URL
const contextMenu = document.getElementById('context-menu'); const contextMenu = document.getElementById('context-menu');
@ -2165,7 +2177,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
} }
const trackItem = e.target.closest('.track-item'); const trackItem = e.target.closest('.track-item');
if (trackItem && (trackItem.classList.contains('unavailable') || trackItem.classList.contains('blocked'))) { if (trackItem && trackItem.classList.contains('unavailable')) {
return; return;
} }
if (isLongPress && longPressTrackItem === trackItem) { if (isLongPress && longPressTrackItem === trackItem) {
@ -2173,6 +2185,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
} }
if ( if (
trackItem && trackItem &&
!trackItem.classList.contains('blocked') &&
!trackItem.dataset.queueIndex && !trackItem.dataset.queueIndex &&
!e.target.closest('.remove-from-playlist-btn') && !e.target.closest('.remove-from-playlist-btn') &&
!e.target.closest('.artist-link') && !e.target.closest('.artist-link') &&
@ -2256,17 +2269,13 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
const card = e.target.closest('.card'); const card = e.target.closest('.card');
if (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')) { if (e.target.closest('.edit-playlist-btn') || e.target.closest('.delete-playlist-btn')) {
return; return;
} }
const libraryTracksContainer = card.closest('#library-tracks-container'); const libraryTracksContainer = card.closest('#library-tracks-container');
if (libraryTracksContainer && card.dataset.trackId) { if (libraryTracksContainer && card.dataset.trackId) {
if (card.classList.contains('blocked')) return;
if ( if (
e.target.closest('.like-btn') || e.target.closest('.like-btn') ||
e.target.closest('.card-play-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.style.display === 'block') {
if (contextMenu._originalHTML) { if (contextMenu._originalHTML) {
contextMenu.innerHTML = 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) { if (e.key === 'Escape' && trackSelection.isSelecting) {
clearSelection(); clearSelection();
} }
@ -2458,34 +2499,39 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
trackPlayNext(t); trackPlayNext(t);
player.addNextToQueue(t); player.addNextToQueue(t);
}); });
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Playing next: ${selectedTracks.length} tracks`); showNotification(`Playing next: ${selectedTracks.length} tracks`);
clearSelection(); clearSelection();
break; break;
case 'add-to-queue': case 'add-to-queue':
player.addToQueue(selectedTracks); player.addToQueue(selectedTracks);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Added ${selectedTracks.length} tracks to queue`); showNotification(`Added ${selectedTracks.length} tracks to queue`);
clearSelection(); clearSelection();
break; break;
case 'toggle-like': case 'toggle-like':
selectedTracks.forEach(async (t) => { selectedTracks.forEach(async (t) => {
const added = await db.toggleFavorite('track', t); const added = await db.toggleFavorite('track', t);
syncManager.syncLibraryItem('track', t, added); await syncManager.syncLibraryItem('track', t, added);
}); });
showNotification(`Liked ${selectedTracks.length} tracks`); showNotification(`Liked ${selectedTracks.length} tracks`);
clearSelection(); clearSelection();
break; break;
case 'add-to-playlist': case 'add-to-playlist':
showMultiSelectPlaylistModal(selectedTracks); await showMultiSelectPlaylistModal(selectedTracks);
clearSelection(); clearSelection();
break; break;
case 'download': case 'download':
selectedTracks.forEach((t) => {
downloadTrackWithMetadata(t, downloadQualitySettings.getQuality(), api, lyricsManager);
});
showNotification(`Downloading ${selectedTracks.length} tracks`); showNotification(`Downloading ${selectedTracks.length} tracks`);
clearSelection(); clearSelection();
for (const track of selectedTracks) {
await downloadTrackWithMetadata(
track,
downloadQualitySettings.getQuality(),
api,
lyricsManager
);
}
break; break;
default: default:
clearSelection(); clearSelection();

View file

@ -49,7 +49,8 @@ async function ffmpegWorker(
onProgress = null, onProgress = null,
signal = null, signal = null,
extraFiles = [], extraFiles = [],
logConsole = true logConsole = true,
rawArgs = false
) { ) {
const audioData = audioBlob ? await audioBlob.arrayBuffer() : null; const audioData = audioBlob ? await audioBlob.arrayBuffer() : null;
const assets = loadFfmpeg(); const assets = loadFfmpeg();
@ -113,7 +114,7 @@ async function ffmpegWorker(
reject(new FfmpegError('Worker failed: ' + error.message)); reject(new FfmpegError('Worker failed: ' + error.message));
}; };
(async () => { void (async () => {
const transferables = []; const transferables = [];
if (audioData) transferables.push(audioData); if (audioData) transferables.push(audioData);
for (const f of extraFiles) { for (const f of extraFiles) {
@ -128,7 +129,7 @@ async function ffmpegWorker(
{ {
audioData, audioData,
extraFiles, extraFiles,
args, ...(rawArgs ? { rawArgs: args } : { args }),
output: { output: {
name: outputName, name: outputName,
mime: outputMime, mime: outputMime,
@ -153,6 +154,7 @@ async function ffmpegWorker(
* @param {AbortSignal|null} [opts.signal=null] - Optional abort signal to cancel encoding * @param {AbortSignal|null} [opts.signal=null] - Optional abort signal to cancel encoding
* @param {Array} [opts.extraFiles=[]] - Additional files to provide to FFmpeg * @param {Array} [opts.extraFiles=[]] - Additional files to provide to FFmpeg
* @param {Boolean} [opts.logConsole=true] - Whether to log FFmpeg output to the console * @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 * @returns {Promise<Blob>} Encoded audio blob
* @throws {FfmpegError} If Web Workers are not available * @throws {FfmpegError} If Web Workers are not available
* @throws {Error} If FFmpeg encoding fails * @throws {Error} If FFmpeg encoding fails
@ -167,6 +169,7 @@ export async function ffmpeg(
signal = null, signal = null,
extraFiles = [], extraFiles = [],
logConsole = true, logConsole = true,
rawArgs = null,
} = {} } = {}
) { ) {
try { try {
@ -174,13 +177,14 @@ export async function ffmpeg(
if (typeof Worker !== 'undefined') { if (typeof Worker !== 'undefined') {
return await ffmpegWorker( return await ffmpegWorker(
audioBlob, audioBlob,
args, rawArgs || args,
outputName, outputName,
outputMime, outputMime,
onProgress, onProgress,
signal, signal,
extraFiles, extraFiles,
logConsole logConsole,
!!rawArgs
); );
} }

19
js/ffmpeg.test.ts Normal file
View 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);
});

View file

@ -1,4 +1,4 @@
import { FFmpeg } from '@ffmpeg/ffmpeg'; import { FFmpeg } from '!/@ffmpeg/ffmpeg/dist/esm/classes.js';
let ffmpeg = null; let ffmpeg = null;
let loadingPromise = null; let loadingPromise = null;
@ -99,6 +99,7 @@ self.onmessage = async (e) => {
const { const {
audioData, audioData,
extraFiles = [], extraFiles = [],
rawArgs,
args = [], args = [],
output = { output = {
name: 'output', name: 'output',
@ -123,7 +124,7 @@ self.onmessage = async (e) => {
await ffmpeg.writeFile(file.name, new Uint8Array(file.data)); 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 }); self.postMessage({ type: 'command', command: ffmpegArgs });
const exitCode = await ffmpeg.exec(ffmpegArgs); const exitCode = await ffmpeg.exec(ffmpegArgs);

View file

@ -151,7 +151,7 @@ if (import.meta.env.DEV) {
export const containerFormats: Record<string, ContainerFormat> = { export const containerFormats: Record<string, ContainerFormat> = {
flac: { flac: {
displayName: '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', outputFilename: 'output.flac',
outputMime: 'audio/flac', outputMime: 'audio/flac',
extension: 'flac', extension: 'flac',
@ -183,6 +183,11 @@ export function getContainerFormat(internalName: string): ContainerFormat | unde
return containerFormats[internalName]; return containerFormats[internalName];
} }
export interface ExtraFile {
name: string;
data: ArrayBuffer | Uint8Array;
}
/** /**
* Transcodes an audio blob using the specified custom format via ffmpeg. * Transcodes an audio blob using the specified custom format via ffmpeg.
* Throws if ffmpeg fails during transcoding. * Throws if ffmpeg fails during transcoding.
@ -192,7 +197,7 @@ export async function transcodeWithCustomFormat(
format: CustomFormat, format: CustomFormat,
onProgress: ((progress: ProgressEvent) => void) | null = null, onProgress: ((progress: ProgressEvent) => void) | null = null,
signal: AbortSignal | null = null, signal: AbortSignal | null = null,
extraFiles: any[] = [] extraFiles: ExtraFile[] = []
): Promise<Blob> { ): Promise<Blob> {
return ffmpeg(audioBlob, { return ffmpeg(audioBlob, {
args: format.ffmpegArgs, args: format.ffmpegArgs,
@ -213,7 +218,7 @@ export async function transcodeWithContainerFormat(
format: ContainerFormat, format: ContainerFormat,
onProgress: ((progress: ProgressEvent) => void) | null = null, onProgress: ((progress: ProgressEvent) => void) | null = null,
signal: AbortSignal | null = null, signal: AbortSignal | null = null,
extraFiles: any[] = [] extraFiles: ExtraFile[] = []
): Promise<Blob> { ): Promise<Blob> {
return ffmpeg(audioBlob, { return ffmpeg(audioBlob, {
args: format.ffmpegArgs, args: format.ffmpegArgs,

8
js/global.d.ts vendored
View file

@ -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. */ /** Creates a ZIP stream from an async iterable of file entries. */
export function downloadZip(files: AsyncIterable<object>): Response; export function downloadZip(files: AsyncIterable<object>): Response;
} }
type WithRequiredKeys<T> = {
[K in keyof T]-?: T[K] | undefined;
};
declare global {
const __COMMIT_HASH__: string | undefined;
}

View file

@ -5,7 +5,7 @@ let _Haptics = null;
let _ImpactStyle = null; let _ImpactStyle = null;
let _NotificationStyle = 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') const _ready = import('@capacitor/haptics')
.then((mod) => { .then((mod) => {
_Haptics = mod.Haptics; _Haptics = mod.Haptics;
@ -13,14 +13,14 @@ const _ready = import('@capacitor/haptics')
_NotificationStyle = mod.NotificationStyle; _NotificationStyle = mod.NotificationStyle;
}) })
.catch(() => { .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) { function vibrateFallback(ms) {
if (navigator.vibrate) navigator.vibrate(ms); if (navigator.vibrate) navigator.vibrate(ms);
} }
/** Light tap for toggles, menu opens */ /** Light tap - for toggles, menu opens */
export async function hapticLight() { export async function hapticLight() {
await _ready; await _ready;
try { try {
@ -32,7 +32,7 @@ export async function hapticLight() {
vibrateFallback(30); vibrateFallback(30);
} }
/** Medium impact for play/pause, skip */ /** Medium impact - for play/pause, skip */
export async function hapticMedium() { export async function hapticMedium() {
await _ready; await _ready;
try { try {
@ -44,7 +44,7 @@ export async function hapticMedium() {
vibrateFallback(50); vibrateFallback(50);
} }
/** Success notification for like/unlike, add to queue */ /** Success notification - for like/unlike, add to queue */
export async function hapticSuccess() { export async function hapticSuccess() {
await _ready; await _ready;
try { try {
@ -56,7 +56,7 @@ export async function hapticSuccess() {
vibrateFallback(40); vibrateFallback(40);
} }
/** Long press replaces navigator.vibrate(50) for track selection */ /** Long press - replaces navigator.vibrate(50) for track selection */
export async function hapticLongPress() { export async function hapticLongPress() {
await _ready; await _ready;
try { try {

16
js/indexedIterator.ts Normal file
View 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
}
}

View file

@ -284,8 +284,8 @@ export class LastFMScrobbler {
scheduleScrobble(delay) { scheduleScrobble(delay) {
this.clearScrobbleTimer(); this.clearScrobbleTimer();
this.scrobbleTimer = setTimeout(() => { this.scrobbleTimer = setTimeout(async () => {
this.scrobbleCurrentTrack(); await this.scrobbleCurrentTrack();
}, delay); }, delay);
} }
@ -350,9 +350,9 @@ export class LastFMScrobbler {
} }
} }
onTrackChange(track) { async onTrackChange(track) {
if (!this.isAuthenticated()) return; if (!this.isAuthenticated()) return;
this.updateNowPlaying(track); await this.updateNowPlaying(track);
} }
onPlaybackStop() { onPlaybackStop() {

View file

@ -216,8 +216,8 @@ export class LibreFmScrobbler {
scheduleScrobble(delay) { scheduleScrobble(delay) {
this.clearScrobbleTimer(); this.clearScrobbleTimer();
this.scrobbleTimer = setTimeout(() => { this.scrobbleTimer = setTimeout(async () => {
this.scrobbleCurrentTrack(); await this.scrobbleCurrentTrack();
}, delay); }, delay);
} }
@ -282,9 +282,9 @@ export class LibreFmScrobbler {
} }
} }
onTrackChange(track) { async onTrackChange(track) {
if (!this.isAuthenticated()) return; if (!this.isAuthenticated()) return;
this.updateNowPlaying(track); await this.updateNowPlaying(track);
} }
onPlaybackStop() { onPlaybackStop() {

View file

@ -209,8 +209,8 @@ export class ListenBrainzScrobbler {
scheduleScrobble(delay) { scheduleScrobble(delay) {
this.clearScrobbleTimer(); this.clearScrobbleTimer();
this.scrobbleTimer = setTimeout(() => { this.scrobbleTimer = setTimeout(async () => {
this.scrobbleCurrentTrack(); await this.scrobbleCurrentTrack();
}, delay); }, delay);
} }
@ -235,8 +235,8 @@ export class ListenBrainzScrobbler {
} }
} }
onTrackChange(track) { async onTrackChange(track) {
this.updateNowPlaying(track); await this.updateNowPlaying(track);
} }
onPlaybackStop() { onPlaybackStop() {

View file

@ -19,9 +19,13 @@ class Modal {
<h3 style="margin-bottom: 1rem; font-size: 1.5rem;">${title}</h3> <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-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;"> <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> <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>
</div> </div>
`; `;
@ -32,7 +36,7 @@ class Modal {
resolve(val); resolve(val);
}; };
modal.querySelectorAll('.modal-action-btn').forEach(btn => { modal.querySelectorAll('.modal-action-btn').forEach((btn) => {
btn.onclick = () => { btn.onclick = () => {
const action = actions[btn.dataset.index]; const action = actions[btn.dataset.index];
if (action.callback) { if (action.callback) {
@ -52,7 +56,7 @@ class Modal {
return this.show({ return this.show({
title, title,
content: message, content: message,
actions: [{ label: 'OK', type: 'primary' }] actions: [{ label: 'OK', type: 'primary' }],
}); });
} }
@ -62,8 +66,8 @@ class Modal {
content: message, content: message,
actions: [ actions: [
{ label: confirmLabel, type: type }, { label: confirmLabel, type: type },
{ label: 'Cancel', type: 'secondary', callback: () => false } { label: 'Cancel', type: 'secondary', callback: () => false },
] ],
}); });
} }
} }
@ -92,7 +96,7 @@ export class ListeningPartyManager {
document.getElementById('copy-party-link-btn')?.addEventListener('click', () => this.copyInviteLink()); document.getElementById('copy-party-link-btn')?.addEventListener('click', () => this.copyInviteLink());
document.getElementById('party-chat-send-btn')?.addEventListener('click', () => this.sendChatMessage()); document.getElementById('party-chat-send-btn')?.addEventListener('click', () => this.sendChatMessage());
document.getElementById('party-chat-input')?.addEventListener('keypress', (e) => { 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 nameInput = document.getElementById('party-name-input');
const user = authManager.user; const user = authManager.user;
if (!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; return;
} }
const pbUser = await syncManager._getUserRecord(user.$id); const pbUser = await syncManager._getUserRecord(user.$id);
if (!pbUser) { 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; return;
} }
@ -119,14 +123,16 @@ export class ListeningPartyManager {
is_playing: player.currentTrack ? !player.activeElement.paused : false, is_playing: player.currentTrack ? !player.activeElement.paused : false,
playback_time: player.activeElement.currentTime || 0, playback_time: player.activeElement.currentTime || 0,
playback_timestamp: Date.now(), 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; if (currentTrack) partyData.current_track = currentTrack;
try { try {
const party = await pb.collection('parties').create(partyData, { f_id: user.$id }); const party = await pb.collection('parties').create(partyData, { f_id: user.$id });
navigate(`/party/${party.id}`); navigate(`/party/${party.id}`);
} catch (e) { console.error('Create error:', e); } } catch (e) {
console.error('Create error:', e);
}
} }
async joinParty(partyId) { async joinParty(partyId) {
@ -149,8 +155,14 @@ export class ListeningPartyManager {
const pbUser = user ? await syncManager._getUserRecord(user.$id) : null; const pbUser = user ? await syncManager._getUserRecord(user.$id) : null;
this.isHost = pbUser && pbUser.id === party.host; this.isHost = pbUser && pbUser.id === party.host;
const profile = confirmed.profile || await this.getMemberProfile(pbUser); 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 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; if (pbUser?.id) memberData.user = pbUser.id;
const member = await pb.collection('party_members').create(memberData, { f_id }); const member = await pb.collection('party_members').create(memberData, { f_id });
@ -159,19 +171,19 @@ export class ListeningPartyManager {
this.setupSubscriptions(partyId); this.setupSubscriptions(partyId);
this.startHeartbeat(); this.startHeartbeat();
this.renderPartyUI(); this.renderPartyUI();
this.loadInitialData(partyId); await this.loadInitialData(partyId);
if (!this.isHost) { if (!this.isHost) {
this.lockControls(); this.lockControls();
this.setupGuestSyncInterception(); this.setupGuestSyncInterception();
if (party.current_track) { if (party.current_track) {
await audioContextManager.resume(); await audioContextManager.resume();
this.syncWithHost(party); await this.syncWithHost(party);
} }
} }
} catch (error) { } catch (error) {
console.error('Join error:', error); console.error('Join error:', error);
Modal.alert('Error', 'Failed to join the party. It may have ended.'); await Modal.alert('Error', 'Failed to join the party. It may have ended.');
navigate('/parties'); navigate('/parties');
} finally { } finally {
this.isJoining = false; this.isJoining = false;
@ -187,7 +199,7 @@ export class ListeningPartyManager {
); );
return confirmed ? { profile: null } : false; return confirmed ? { profile: null } : false;
} else { } else {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const cached = localStorage.getItem('party_guest_profile'); const cached = localStorage.getItem('party_guest_profile');
const defaultName = cached ? JSON.parse(cached).name : ''; const defaultName = cached ? JSON.parse(cached).name : '';
@ -203,14 +215,19 @@ export class ListeningPartyManager {
type: 'primary', type: 'primary',
callback: (modal) => { callback: (modal) => {
const name = modal.querySelector('#guest-name-input').value.trim() || 'Guest'; 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)); localStorage.setItem('party_guest_profile', JSON.stringify(profile));
return { profile }; return { profile };
}
}, },
{ label: 'Cancel', type: 'secondary', callback: () => false } },
] { label: 'Cancel', type: 'secondary', callback: () => false },
}).then(resolve); ],
})
.then(resolve)
.catch(reject);
}); });
} }
} }
@ -227,57 +244,100 @@ export class ListeningPartyManager {
async getMemberProfile(pbUser = null) { async getMemberProfile(pbUser = null) {
const user = authManager.user; const user = authManager.user;
if (user) { if (user) {
const name = pbUser?.display_name || pbUser?.username || user.displayName || user.email?.split('@')[0] || 'Member'; const name =
const avatar = pbUser?.avatar_url || user.photoURL || `https://api.dicebear.com/9.x/identicon/svg?seed=${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 }; return { name, avatar_url: avatar };
} }
const cached = localStorage.getItem('party_guest_profile'); 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) { setupSubscriptions(partyId) {
this.unsubscribeFunctions.forEach(unsub => unsub()); this.unsubscribeFunctions.forEach((unsub) => unsub());
this.unsubscribeFunctions = []; this.unsubscribeFunctions = [];
const f_id = authManager.user ? authManager.user.$id : 'guest'; const f_id = authManager.user ? authManager.user.$id : 'guest';
pb.collection('parties').subscribe(partyId, (e) => { pb.collection('parties')
.subscribe(
partyId,
async (e) => {
if (e.action === 'update') { if (e.action === 'update') {
this.currentParty = e.record; this.currentParty = e.record;
if (!this.isHost) this.syncWithHost(e.record); if (!this.isHost) await this.syncWithHost(e.record);
this.updatePartyHeader(); this.updatePartyHeader();
} else if (e.action === 'delete') { } else if (e.action === 'delete') {
Modal.alert('Party Ended', 'The host has ended the listening party.'); await Modal.alert('Party Ended', 'The host has ended the listening party.');
this.leaveParty(false); await this.leaveParty(false);
} }
}, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub)); },
{ f_id }
)
.then((unsub) => this.unsubscribeFunctions.push(unsub))
.catch(console.error);
pb.collection('party_members').subscribe('*', (e) => { pb.collection('party_members')
if (e.record.party === partyId) this.loadMembers(); .subscribe(
}, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub)); '*',
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) => { pb.collection('party_messages')
.subscribe(
'*',
(e) => {
if (e.record.party === partyId && e.action === 'create') this.addChatMessage(e.record); if (e.record.party === partyId && e.action === 'create') this.addChatMessage(e.record);
}, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub)); },
{ f_id }
)
.then((unsub) => this.unsubscribeFunctions.push(unsub))
.catch(console.error);
pb.collection('party_requests').subscribe('*', (e) => { pb.collection('party_requests')
if (e.record.party === partyId) this.loadRequests(); .subscribe(
}, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub)); '*',
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() { async loadMembers() {
const f_id = authManager.user ? authManager.user.$id : 'guest'; 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(); this.renderMembers();
} }
async loadMessages() { async loadMessages() {
const f_id = authManager.user ? authManager.user.$id : 'guest'; 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(); this.messages = res.items.reverse();
const container = document.getElementById('party-chat-messages'); 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() { async loadRequests() {
@ -286,16 +346,26 @@ export class ListeningPartyManager {
this.requests = await pb.collection('party_requests').getFullList({ this.requests = await pb.collection('party_requests').getFullList({
filter: `party = "${this.currentParty.id}"`, filter: `party = "${this.currentParty.id}"`,
sort: 'created', sort: 'created',
f_id: f_id f_id: f_id,
}); });
this.renderRequests(); this.renderRequests();
} catch (e) { console.error('Failed to load requests:', e); } } catch (e) {
console.error('Failed to load requests:', e);
}
} }
renderPartyUI() { renderPartyUI() {
this.updatePartyHeader(); this.renderMembers(); this.renderRequests(); this.showPartyIndicator(); this.updatePartyHeader();
if (this.isHost) { this.unlockControls(); this.setupHostPlayerSync(); } this.renderMembers();
else { this.lockControls(); this.setupGuestPlayerInterferenceCheck(); } this.renderRequests();
this.showPartyIndicator();
if (this.isHost) {
this.unlockControls();
this.setupHostPlayerSync();
} else {
this.lockControls();
this.setupGuestPlayerInterferenceCheck();
}
} }
updatePartyHeader() { updatePartyHeader() {
@ -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-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 class="track-artist" style="font-size: 1.2rem; color: var(--muted-foreground)">${getTrackArtists(track)}</div>
</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"> <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 ${SVG_PAUSE(24)} Paused
</div> </div>
` : ''} `
: ''
}
</div> </div>
`; `;
} else { } else {
@ -341,7 +415,12 @@ export class ListeningPartyManager {
renderMembers() { renderMembers() {
const list = document.getElementById('party-members-list'); const list = document.getElementById('party-members-list');
if (!list) return; 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() { renderRequests() {
@ -352,7 +431,8 @@ export class ListeningPartyManager {
return; return;
} }
list.innerHTML = this.requests.map(r => { list.innerHTML = this.requests
.map((r) => {
try { try {
const api = Player.instance.api; const api = Player.instance.api;
const artists = getTrackArtists(r.track); const artists = getTrackArtists(r.track);
@ -365,20 +445,25 @@ export class ListeningPartyManager {
</div> </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>` : ''} ${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>`; </div>`;
} catch (e) { return ''; } } catch (_e) {
}).join(''); return '';
}
})
.join('');
if (this.isHost) { if (this.isHost) {
const f_id = authManager.user ? authManager.user.$id : 'guest'; const f_id = authManager.user ? authManager.user.$id : 'guest';
list.querySelectorAll('.add-request-btn').forEach(btn => btn.addEventListener('click', async (e) => { list.querySelectorAll('.add-request-btn').forEach((btn) =>
btn.addEventListener('click', async (e) => {
const reqId = e.currentTarget.dataset.reqId; const reqId = e.currentTarget.dataset.reqId;
const req = this.requests.find(r => r.id === reqId); const req = this.requests.find((r) => r.id === reqId);
if (req) { if (req) {
Player.instance.addToQueue(req.track); Player.instance.addToQueue(req.track);
showNotification(`Added "${req.track.title}" to queue`); showNotification(`Added "${req.track.title}" to queue`);
await pb.collection('party_requests').delete(req.id, { f_id }); await pb.collection('party_requests').delete(req.id, { f_id });
} }
})); })
);
} }
} }
@ -395,7 +480,9 @@ export class ListeningPartyManager {
if (url.match(/\.(jpeg|jpg|gif|png|webp|svg)(\?.*)?$/i)) { 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}')">`; 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) { 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>`; 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>`;
} }
@ -421,10 +508,15 @@ export class ListeningPartyManager {
async sendChatMessage() { async sendChatMessage() {
const input = document.getElementById('party-chat-input'); const input = document.getElementById('party-chat-input');
if (!input || !input.value.trim()) return; 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 profile = await this.getMemberProfile();
const f_id = authManager.user ? authManager.user.$id : 'guest'; 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) { async requestSong(track) {
@ -433,13 +525,18 @@ export class ListeningPartyManager {
const f_id = authManager.user ? authManager.user.$id : 'guest'; const f_id = authManager.user ? authManager.user.$id : 'guest';
try { try {
const minifiedTrack = syncManager._minifyItem('track', track); const minifiedTrack = syncManager._minifyItem('track', track);
await pb.collection('party_requests').create({ await pb.collection('party_requests').create(
{
party: this.currentParty.id, party: this.currentParty.id,
track: minifiedTrack, track: minifiedTrack,
requested_by: profile.name requested_by: profile.name,
}, { f_id }); },
{ f_id }
);
showNotification(`Requested "${track.title}"`); showNotification(`Requested "${track.title}"`);
} catch (e) { console.error('Request error:', e); } } catch (e) {
console.error('Request error:', e);
}
} }
async syncWithHost(party) { async syncWithHost(party) {
@ -448,14 +545,19 @@ export class ListeningPartyManager {
try { try {
const player = Player.instance; const player = Player.instance;
const el = player.activeElement; 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 currentId = String(player.currentTrack?.id || '');
const targetId = String(party.current_track.id || ''); const targetId = String(party.current_track.id || '');
if (currentId !== targetId) { if (currentId !== targetId) {
const cleanedTrack = { ...party.current_track }; 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); player.setQueue([cleanedTrack], 0);
await player.playTrackFromQueue(party.playback_time); await player.playTrackFromQueue(party.playback_time);
if (!party.is_playing) el.pause(); if (!party.is_playing) el.pause();
@ -464,7 +566,7 @@ export class ListeningPartyManager {
if (party.is_playing) { if (party.is_playing) {
if (el.paused) { if (el.paused) {
const success = await player.safePlay(el); const _success = await player.safePlay(el);
} }
const latency = (Date.now() - party.playback_timestamp) / 1000; const latency = (Date.now() - party.playback_timestamp) / 1000;
const targetTime = party.is_playing ? party.playback_time + latency : party.playback_time; 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 (!el.paused) el.pause();
if (Math.abs(el.currentTime - party.playback_time) > 0.5) el.currentTime = party.playback_time; 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() { 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']; const selectors = [
selectors.forEach(s => document.querySelectorAll(s).forEach(el => { el.style.opacity = '0.5'; el.style.pointerEvents = 'none'; })); '.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() { 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']; const selectors = [
selectors.forEach(s => document.querySelectorAll(s).forEach(el => { el.style.opacity = '1'; el.style.pointerEvents = 'auto'; })); '.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() { setupHostPlayerSync() {
@ -493,16 +635,20 @@ export class ListeningPartyManager {
const el = player.activeElement; const el = player.activeElement;
const sharedTrack = player.currentTrack ? syncManager._minifyItem('track', player.currentTrack) : null; const sharedTrack = player.currentTrack ? syncManager._minifyItem('track', player.currentTrack) : null;
try { try {
await pb.collection('parties').update(this.currentParty.id, { await pb.collection('parties').update(
this.currentParty.id,
{
current_track: sharedTrack, current_track: sharedTrack,
is_playing: !el.paused, is_playing: !el.paused,
playback_time: el.currentTime, playback_time: el.currentTime,
playback_timestamp: Date.now(), playback_timestamp: Date.now(),
queue: player.queue?.map(t => syncManager._minifyItem('track', t)) || [] queue: player.queue?.map((t) => syncManager._minifyItem('track', t)) || [],
}, { f_id: authManager.user?.$id }); },
} catch (e) {} { f_id: authManager.user?.$id }
);
} catch (_e) {}
}; };
['play', 'pause', 'seeked'].forEach(ev => { ['play', 'pause', 'seeked'].forEach((ev) => {
player.audio.addEventListener(ev, updateParty); player.audio.addEventListener(ev, updateParty);
if (player.video) player.video.addEventListener(ev, updateParty); if (player.video) player.video.addEventListener(ev, updateParty);
}); });
@ -520,9 +666,14 @@ export class ListeningPartyManager {
const originalPlayTrackFromQueue = player.playTrackFromQueue.bind(player); const originalPlayTrackFromQueue = player.playTrackFromQueue.bind(player);
player.playTrackFromQueue = async (...args) => { player.playTrackFromQueue = async (...args) => {
if (this.currentParty && !this.isHost && !this.isInternalSync) { 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; if (!leave) return;
this.leaveParty(); await this.leaveParty();
} }
return await originalPlayTrackFromQueue(...args); return await originalPlayTrackFromQueue(...args);
}; };
@ -531,43 +682,64 @@ export class ListeningPartyManager {
startHeartbeat() { startHeartbeat() {
this.heartbeatInterval = setInterval(async () => { this.heartbeatInterval = setInterval(async () => {
if (!this.memberId) return; 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); }, 30000);
} }
async leaveParty(shouldCleanup = true) { async leaveParty(shouldCleanup = true) {
const f_id = authManager.user?.$id || 'guest'; const f_id = authManager.user?.$id || 'guest';
if (this.isHost && shouldCleanup) { 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; if (!end) return;
try { try {
const cleanup = async (coll) => { 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 }); 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 }); await pb.collection('parties').delete(this.currentParty.id, { f_id });
} catch (e) {} } catch (_e) {}
} else if (this.memberId) { } 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.restorePlayerMethods();
this.unlockControls(); this.unlockControls();
this.unsubscribeFunctions.forEach(unsub => unsub()); this.unsubscribeFunctions.forEach((unsub) => unsub());
this.unsubscribeFunctions = []; this.unsubscribeFunctions = [];
clearInterval(this.syncInterval); clearInterval(this.heartbeatInterval); clearInterval(this.syncInterval);
this.currentParty = null; this.isHost = false; this.memberId = null; clearInterval(this.heartbeatInterval);
this.currentParty = null;
this.isHost = false;
this.memberId = null;
document.getElementById('party-indicator')?.remove(); document.getElementById('party-indicator')?.remove();
navigate('/parties'); navigate('/parties');
} }
restorePlayerMethods() { restorePlayerMethods() {
const player = Player.instance; 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() { 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!'); showNotification('Invite link copied!');
} }

View file

@ -10,7 +10,7 @@ import {
SVG_GLOBE, SVG_GLOBE,
} from './icons.js'; } from './icons.js';
import { sidePanelManager } from './side-panel.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 // Check if text contains Japanese, Chinese, or Korean characters
function containsAsianText(text) { function containsAsianText(text) {
@ -246,6 +246,7 @@ export class LyricsManager {
// Monkey-patch XMLHttpRequest to redirect dictionary requests to CDN // Monkey-patch XMLHttpRequest to redirect dictionary requests to CDN
// Kuromoji uses XHR, not fetch, for loading dictionary files // Kuromoji uses XHR, not fetch, for loading dictionary files
if (!window._originalXHROpen) { if (!window._originalXHROpen) {
// eslint-disable-next-line @typescript-eslint/unbound-method
window._originalXHROpen = XMLHttpRequest.prototype.open; window._originalXHROpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...rest) { XMLHttpRequest.prototype.open = function (method, url, ...rest) {
const urlStr = url.toString(); const urlStr = url.toString();
@ -264,7 +265,7 @@ export class LyricsManager {
if (!window._originalFetch) { if (!window._originalFetch) {
window._originalFetch = window.fetch; window._originalFetch = window.fetch;
window.fetch = async (url, options) => { 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')) { if (urlStr.includes('/dict/') && urlStr.includes('.dat.gz')) {
const filename = urlStr.split('/').pop(); const filename = urlStr.split('/').pop();
const cdnUrl = `https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/${filename}`; 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 // Setup MutationObserver to convert lyrics in am-lyrics component
setupLyricsObserver(amLyricsElement) { async setupLyricsObserver(amLyricsElement) {
this.stopLyricsObserver(); this.stopLyricsObserver();
if (!amLyricsElement) return; if (!amLyricsElement) return;
@ -575,7 +576,7 @@ export class LyricsManager {
await this.convertLyricsContent(amLyricsElement); await this.convertLyricsContent(amLyricsElement);
} }
if (this.isGeniusMode && this.currentGeniusData) { if (this.isGeniusMode && this.currentGeniusData) {
this.applyGeniusAnnotations(amLyricsElement, this.currentGeniusData.referents); await this.applyGeniusAnnotations(amLyricsElement, this.currentGeniusData.referents);
} }
}, 100); }, 100);
}); });
@ -591,10 +592,10 @@ export class LyricsManager {
// Initial conversion if Romaji mode is enabled - single attempt, no periodic polling // Initial conversion if Romaji mode is enabled - single attempt, no periodic polling
if (this.isRomajiMode) { if (this.isRomajiMode) {
this.convertLyricsContent(amLyricsElement); await this.convertLyricsContent(amLyricsElement);
} }
if (this.isGeniusMode && this.currentGeniusData) { 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 (amLyricsElement) {
if (this.isRomajiMode) { if (this.isRomajiMode) {
// Turning ON: Setup observer and convert immediately // Turning ON: Setup observer and convert immediately
this.setupLyricsObserver(amLyricsElement); await this.setupLyricsObserver(amLyricsElement);
await this.convertLyricsContent(amLyricsElement); await this.convertLyricsContent(amLyricsElement);
} else { } else {
// Turning OFF: Stop observer // 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) { if (panel && panel.lyricsCleanup) {
panel.lyricsCleanup(); panel.lyricsCleanup();
panel.lyricsCleanup = null; panel.lyricsCleanup = null;

View file

@ -135,8 +135,8 @@ export class MalojaScrobbler {
scheduleScrobble(delay) { scheduleScrobble(delay) {
this.clearScrobbleTimer(); this.clearScrobbleTimer();
this.scrobbleTimer = setTimeout(() => { this.scrobbleTimer = setTimeout(async () => {
this.scrobbleCurrentTrack(); await this.scrobbleCurrentTrack();
}, delay); }, delay);
} }
@ -161,8 +161,8 @@ export class MalojaScrobbler {
} }
} }
onTrackChange(track) { async onTrackChange(track) {
this.updateNowPlaying(track); await this.updateNowPlaying(track);
} }
onPlaybackStop() { onPlaybackStop() {

View file

@ -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 { addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
import { LyricsManager } from './lyrics.js'; import { LyricsManager } from './lyrics.js';
import { Mp4Stik } from './taglib.types.ts'; import { Mp4Stik } from './taglib.types.ts';
import { modernSettings } from './ModernSettings.js';
/** /**
* @typedef {import('./container-classes.ts').Track} Track * @typedef {import('./container-classes.ts').Track} Track
@ -29,19 +37,21 @@ export function prefetchMetadataObjects(track, api, coverBlob = null) {
* @param {string} quality - Audio quality * @param {string} quality - Audio quality
* @returns {Promise<Blob>} - Audio blob with embedded metadata * @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; const { coverFetch, lyricsFetch } = prefetchPromises;
/** /**
* @type {TagLibMetadata} * @type {TagLibMetadata}
*/ */
const data = {}; const data = {
writeArtistsSeparately: modernSettings.writeArtistsSeparately,
};
try { try {
data.title = getTrackTitle(track); data.title = getTrackTitle(track);
data.artist = getFullArtistString(track); data.artist = getFullArtistArray(track);
data.albumTitle = track.album?.title; 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.trackNumber = track.trackNumber;
data.discNumber = track.volumeNumber ?? track.discNumber; data.discNumber = track.volumeNumber ?? track.discNumber;
data.totalTracks = track.album?.numberOfTracksOnDisc ?? track.album?.numberOfTracks; data.totalTracks = track.album?.numberOfTracksOnDisc ?? track.album?.numberOfTracks;

View file

@ -546,9 +546,9 @@ export function createStringAtom(type, value, truncateType = true) {
export function createUserAtom(namespace, name, value) { export function createUserAtom(namespace, name, value) {
const encoder = new TextEncoder(); 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 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 nameBytes = encoder.encode(name);
const valueBytes = encoder.encode('\x00\x00\x00\x01\x00\x00\x00\x00' + value); const valueBytes = encoder.encode('\x00\x00\x00\x01\x00\x00\x00\x00' + value);

View file

@ -32,18 +32,26 @@ export class MultiScrobbler {
); );
} }
updateNowPlaying(track) { async updateNowPlaying(track) {
this.lastfm.updateNowPlaying(track); await Promise.allSettled(
this.listenbrainz.updateNowPlaying(track); [
this.maloja.updateNowPlaying(track); this.lastfm.updateNowPlaying(track),
this.librefm.updateNowPlaying(track); this.listenbrainz.updateNowPlaying(track),
this.maloja.updateNowPlaying(track),
this.librefm.updateNowPlaying(track),
].map((p) => p.catch(console.error))
);
} }
onTrackChange(track) { async onTrackChange(track) {
this.lastfm.onTrackChange(track); await Promise.allSettled(
this.listenbrainz.onTrackChange(track); [
this.maloja.onTrackChange(track); this.lastfm.onTrackChange(track),
this.librefm.onTrackChange(track); this.listenbrainz.onTrackChange(track),
this.maloja.onTrackChange(track),
this.librefm.onTrackChange(track),
].map((p) => p.catch(console.error))
);
} }
onPlaybackStop() { onPlaybackStop() {
@ -55,9 +63,11 @@ export class MultiScrobbler {
// Love/Like tracks on all services that support it // Love/Like tracks on all services that support it
async loveTrack(track) { async loveTrack(track) {
await this.lastfm.loveTrack(track); await Promise.allSettled(
await this.librefm.loveTrack(track); [this.lastfm.loveTrack(track), this.librefm.loveTrack(track), this.listenbrainz.loveTrack(track)].map((p) =>
await this.listenbrainz.loveTrack(track); p.catch(console.error)
)
);
// Maloja feedback could be added here when supported // Maloja feedback could be added here when supported
} }
} }

View file

@ -4,8 +4,45 @@ import { LosslessAPI } from './api.js';
import { PodcastsAPI } from './podcasts-api.js'; import { PodcastsAPI } from './podcasts-api.js';
import { musicProviderSettings } from './storage.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 { export class MusicAPI {
static #instance = null; static #instance = null;
/**
* @type {MusicAPI}
*/
static get instance() { static get instance() {
if (!MusicAPI.#instance) { if (!MusicAPI.#instance) {
throw new Error('MusicAPI not initialized. Call MusicAPI.initialize(settings) first.'); 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 // Get the appropriate API based on provider
getAPI(provider = null) { getAPI() {
return this.tidalAPI; return this.tidalAPI;
} }
@ -101,31 +138,31 @@ export class MusicAPI {
} }
// Get methods // Get methods
async getTrack(id, quality, provider = null) { async getTrack(id, quality) {
const api = this.getAPI(); const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id); const cleanId = this.stripProviderPrefix(id);
return api.getTrack(cleanId, quality); return api.getTrack(cleanId, quality);
} }
async getTrackMetadata(id, provider = null) { async getTrackMetadata(id) {
const api = this.getAPI(); const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id); const cleanId = this.stripProviderPrefix(id);
return api.getTrackMetadata(cleanId); return api.getTrackMetadata(cleanId);
} }
async getAlbum(id, provider = null) { async getAlbum(id) {
const api = this.getAPI(); const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id); const cleanId = this.stripProviderPrefix(id);
return api.getAlbum(cleanId); return api.getAlbum(cleanId);
} }
async getArtist(id, provider = null) { async getArtist(id) {
const api = this.getAPI(); const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id); const cleanId = this.stripProviderPrefix(id);
return api.getArtist(cleanId); return api.getArtist(cleanId);
} }
async getArtistBiography(id, provider = null) { async getArtistBiography(id) {
const api = this.getAPI(); const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id); const cleanId = this.stripProviderPrefix(id);
if (typeof api.getArtistBiography === 'function') { if (typeof api.getArtistBiography === 'function') {
@ -134,13 +171,13 @@ export class MusicAPI {
return null; return null;
} }
async getVideo(id, provider = null) { async getVideo(id) {
const api = this.getAPI(); const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id); const cleanId = this.stripProviderPrefix(id);
return api.getVideo(cleanId); return api.getVideo(cleanId);
} }
async getVideoStreamUrl(id, provider = null) { async getVideoStreamUrl(id) {
const api = this.getAPI(); const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id); const cleanId = this.stripProviderPrefix(id);
if (typeof api.getVideoStreamUrl === 'function') { if (typeof api.getVideoStreamUrl === 'function') {
@ -157,7 +194,7 @@ export class MusicAPI {
return this.tidalAPI.getPlaylist(id); return this.tidalAPI.getPlaylist(id);
} }
async getMix(id, _provider = null) { async getMix(id) {
// Mixes are always Tidal for now // Mixes are always Tidal for now
return this.tidalAPI.getMix(id); return this.tidalAPI.getMix(id);
} }
@ -172,7 +209,7 @@ export class MusicAPI {
} }
// Stream methods // Stream methods
async getStreamUrl(id, quality, provider = null) { async getStreamUrl(id, quality) {
const api = this.getAPI(); const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id); const cleanId = this.stripProviderPrefix(id);
return api.getStreamUrl(cleanId, quality); return api.getStreamUrl(cleanId, quality);

View file

@ -16,7 +16,6 @@ import {
exponentialVolumeSettings, exponentialVolumeSettings,
audioEffectsSettings, audioEffectsSettings,
radioSettings, radioSettings,
playbackSettings,
} from './storage.js'; } from './storage.js';
import { audioContextManager } from './audio-context.js'; import { audioContextManager } from './audio-context.js';
import { isIos, isSafari } from './platform-detection.js'; import { isIos, isSafari } from './platform-detection.js';
@ -49,7 +48,6 @@ export class Player {
this.repeatMode = REPEAT_MODE.OFF; this.repeatMode = REPEAT_MODE.OFF;
this.preloadCache = new Map(); this.preloadCache = new Map();
this.preloadAbortController = null; this.preloadAbortController = null;
this._lastPreloadTime = null;
this.currentTrack = null; this.currentTrack = null;
this.currentRgValues = null; this.currentRgValues = null;
this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7'); this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7');
@ -108,6 +106,7 @@ export class Player {
bufferingGoal: 30, bufferingGoal: 30,
rebufferingGoal: 2, rebufferingGoal: 2,
bufferBehind: 30, bufferBehind: 30,
jumpLargeGaps: true,
}, },
abr: { abr: {
enabled: true, enabled: true,
@ -134,7 +133,7 @@ export class Player {
} }
this.loadQueueState(); this.loadQueueState();
this.setupMediaSession(); await this.setupMediaSession();
this.radioEnabled = radioSettings.isEnabled(); this.radioEnabled = radioSettings.isEnabled();
this.radioSeeds = []; this.radioSeeds = [];
@ -143,18 +142,19 @@ export class Player {
this.playbackSequence = 0; this.playbackSequence = 0;
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', async () => {
this.saveQueueState(); await this.saveQueueState();
}); });
// Handle visibility change for iOS - AudioContext gets suspended when screen locks // Handle visibility change for iOS - AudioContext gets suspended when screen locks
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', async () => {
const el = this.activeElement; const el = this.activeElement;
if (document.visibilityState === 'visible' && !el.paused) { if (document.visibilityState === 'visible' && !el.paused) {
// Ensure audio context is resumed when user returns to the app
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
audioContextManager.init(el); audioContextManager.init(el);
} }
audioContextManager.resume(); await audioContextManager.resume();
} }
if (document.visibilityState === 'visible' && this.autoplayBlocked) { if (document.visibilityState === 'visible' && this.autoplayBlocked) {
this.autoplayBlocked = false; 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(); this._setupVideoSync();
} }
@ -188,7 +177,9 @@ export class Player {
if (this.video.readyState >= 2 && (this.audio.readyState > 0 || this.audio.src)) { if (this.video.readyState >= 2 && (this.audio.readyState > 0 || this.audio.src)) {
this.audio.currentTime = this.video.currentTime; 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 }); const syncedEvent = new Event(eventName, { bubbles: e.bubbles, cancelable: e.cancelable });
@ -379,7 +370,7 @@ export class Player {
} }
} }
saveQueueState() { async saveQueueState() {
queueManager.saveQueue({ queueManager.saveQueue({
queue: this.queue, queue: this.queue,
shuffledQueue: this.shuffledQueue, shuffledQueue: this.shuffledQueue,
@ -390,14 +381,14 @@ export class Player {
}); });
if (window.renderQueueFunction) { if (window.renderQueueFunction) {
window.renderQueueFunction(); await window.renderQueueFunction();
} }
} }
setupMediaSession() { async setupMediaSession() {
if (!('mediaSession' in navigator)) return; if (!('mediaSession' in navigator)) return;
const setHandlers = () => { const setHandlers = async () => {
navigator.mediaSession.setActionHandler('play', async () => { navigator.mediaSession.setActionHandler('play', async () => {
const el = this.activeElement; const el = this.activeElement;
// Initialize and resume audio context first (required for iOS lock screen) // Initialize and resume audio context first (required for iOS lock screen)
@ -413,7 +404,7 @@ export class Player {
} catch (e) { } catch (e) {
console.error('MediaSession play failed:', e); console.error('MediaSession play failed:', e);
// If play fails, try to handle it like a regular play/pause // 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(); this.applyReplayGain();
} }
await audioContextManager.resume(); await audioContextManager.resume();
this.playNext(); await this.playNext();
}); });
if (!this.isIOS) { if (!this.isIOS) {
@ -474,7 +465,7 @@ export class Player {
this.video.addEventListener('playing', () => setHandlers(), { once: true }); this.video.addEventListener('playing', () => setHandlers(), { once: true });
} }
} else { } 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) { async setupHlsVideo(video, result, fallbackImg) {
const url = result.videoUrl || result.hlsUrl || result; const url = result.videoUrl || result.hlsUrl || result;
const Hls = (await import('hls.js')).default; const Hls = (await import('hls.js')).default;
@ -566,7 +542,7 @@ export class Player {
video.play().catch(() => {}); video.play().catch(() => {});
await this.setupVideoQualitySelector(); await this.setupVideoQualitySelector();
}); });
this.hls.on(Hls.Events.ERROR, (event, data) => { this.hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) { if (data.fatal) {
console.warn('HLS fatal error:', data.type); console.warn('HLS fatal error:', data.type);
if (fallbackImg) video.replaceWith(fallbackImg); if (fallbackImg) video.replaceWith(fallbackImg);
@ -602,7 +578,7 @@ export class Player {
const levels = this.hls.levels; const levels = this.hls.levels;
const qualityLabels = [ const qualityLabels = [
'Auto', 'Auto',
...levels.map((level, i) => { ...levels.map((level) => {
const height = level.height || 0; const height = level.height || 0;
const bandwidth = level.bitrate || 0; const bandwidth = level.bitrate || 0;
if (height >= 1080) return '1080p'; if (height >= 1080) return '1080p';
@ -669,7 +645,7 @@ export class Player {
artist: video.artist || (video.artists && video.artists[0]) || 'Unknown Artist', artist: video.artist || (video.artists && video.artists[0]) || 'Unknown Artist',
album: video.album || { title: 'Video', cover: video.image || video.cover }, album: video.album || { title: 'Video', cover: video.image || video.cover },
}; };
this.setQueue([videoTrack], 0); await this.setQueue([videoTrack], 0);
await this.playTrackFromQueue(); await this.playTrackFromQueue();
} }
@ -687,7 +663,7 @@ export class Player {
const track = currentQueue[this.currentQueueIndex]; const track = currentQueue[this.currentQueueIndex];
if (track.isUnavailable) { if (track.isUnavailable) {
console.warn(`Attempted to play unavailable track: ${track.title}. Skipping...`); console.warn(`Attempted to play unavailable track: ${track.title}. Skipping...`);
this.playNext(); await this.playNext();
return; return;
} }
@ -695,7 +671,7 @@ export class Player {
const { contentBlockingSettings } = await import('./storage.js'); const { contentBlockingSettings } = await import('./storage.js');
if (contentBlockingSettings.shouldHideTrack(track)) { if (contentBlockingSettings.shouldHideTrack(track)) {
console.warn(`Attempted to play blocked track: ${track.title}. Skipping...`); console.warn(`Attempted to play blocked track: ${track.title}. Skipping...`);
this.playNext(); await this.playNext();
return; return;
} }
@ -718,15 +694,15 @@ export class Player {
this.currentQueueIndex >= currentQueue.length - 1 this.currentQueueIndex >= currentQueue.length - 1
) { ) {
console.log('[playTrackFromQueue] Fetching more tracks!'); console.log('[playTrackFromQueue] Fetching more tracks!');
this.fetchMoreArtistPopularTracks().then((newTracks) => { await this.fetchMoreArtistPopularTracks().then(async (newTracks) => {
console.log('[playTrackFromQueue] Got tracks:', newTracks?.length); console.log('[playTrackFromQueue] Got tracks:', newTracks?.length);
if (newTracks && newTracks.length > 0) { if (newTracks && newTracks.length > 0) {
this.addToQueue(newTracks); await this.addToQueue(newTracks);
} }
}); });
} }
this.saveQueueState(); await this.saveQueueState();
this.currentTrack = track; this.currentTrack = track;
@ -842,7 +818,7 @@ export class Player {
if (!streamUrl) { if (!streamUrl) {
console.warn(`Podcast episode ${trackTitle} audio URL is missing. Skipping.`); console.warn(`Podcast episode ${trackTitle} audio URL is missing. Skipping.`);
track.isUnavailable = true; track.isUnavailable = true;
this.playNext(); await this.playNext();
return; return;
} }
@ -875,7 +851,7 @@ export class Player {
if (!streamUrl) { if (!streamUrl) {
console.warn(`Track ${trackTitle} audio URL is missing. Skipping.`); console.warn(`Track ${trackTitle} audio URL is missing. Skipping.`);
track.isUnavailable = true; track.isUnavailable = true;
this.playNext(); await this.playNext();
return; return;
} }
@ -1042,7 +1018,7 @@ export class Player {
} }
} }
this.preloadNextTracks(); void this.preloadNextTracks().catch(console.error);
} catch (error) { } catch (error) {
if (this.playbackSequence !== currentSequence) return; if (this.playbackSequence !== currentSequence) return;
if (error && (error.name === 'NotAllowedError' || error.name === 'AbortError')) { if (error && (error.name === 'NotAllowedError' || error.name === 'AbortError')) {
@ -1058,13 +1034,15 @@ export class Player {
try { try {
await this.playTrackFromQueue(startTime, recursiveCount, true); await this.playTrackFromQueue(startTime, recursiveCount, true);
return; return;
} catch (retryError) { } catch {
// LOSSLESS fallback also failed - fall through to error handling below
} finally { } finally {
this.quality = originalQuality; this.quality = originalQuality;
this.isFallbackRetry = false; this.isFallbackRetry = false;
this.isFallbackInProgress = false; this.isFallbackInProgress = false;
return;
} }
return;
} }
console.error(`Could not play track: ${trackTitle}`, error); 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; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (index >= 0 && index < currentQueue.length) { if (index >= 0 && index < currentQueue.length) {
this.currentQueueIndex = index; this.currentQueueIndex = index;
this.playTrackFromQueue(0, 0); await this.playTrackFromQueue(0, 0);
} }
} }
playNext(recursiveCount = 0) { async playNext(recursiveCount = 0) {
const currentQueue = this.getCurrentQueue(); const currentQueue = this.getCurrentQueue();
const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1; const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1;
if (recursiveCount > currentQueue.length) { if (recursiveCount > currentQueue.length) {
if (this.radioEnabled && isLastTrack) { if (this.radioEnabled && isLastTrack) {
this.fetchRadioRecommendations().then(() => { this.fetchRadioRecommendations().then(async () => {
const updatedQueue = this.getCurrentQueue(); const updatedQueue = this.getCurrentQueue();
if (this.currentQueueIndex < updatedQueue.length - 1) { if (this.currentQueueIndex < updatedQueue.length - 1) {
this.playNext(0); await this.playNext(0);
} }
}); });
return; return;
} }
if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) { if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) {
this.fetchMoreArtistPopularTracks().then((newTracks) => { await this.fetchMoreArtistPopularTracks().then(async (newTracks) => {
if (newTracks && newTracks.length > 0) { if (newTracks && newTracks.length > 0) {
this.addToQueue(newTracks); await this.addToQueue(newTracks);
this.playNext(0); await this.playNext(0);
} else { } else {
this.activeElement.pause(); this.activeElement.pause();
} }
@ -1112,13 +1090,14 @@ export class Player {
return; return;
} }
import('./storage.js').then(({ contentBlockingSettings }) => { import('./storage.js')
.then(async ({ contentBlockingSettings }) => {
if ( if (
this.repeatMode === REPEAT_MODE.ONE && this.repeatMode === REPEAT_MODE.ONE &&
!currentQueue[this.currentQueueIndex]?.isUnavailable && !currentQueue[this.currentQueueIndex]?.isUnavailable &&
!contentBlockingSettings.shouldHideTrack(currentQueue[this.currentQueueIndex]) !contentBlockingSettings.shouldHideTrack(currentQueue[this.currentQueueIndex])
) { ) {
this.playTrackFromQueue(0, recursiveCount); await this.playTrackFromQueue(0, recursiveCount);
return; return;
} }
@ -1129,21 +1108,21 @@ export class Player {
return this.playNext(recursiveCount + 1); return this.playNext(recursiveCount + 1);
} }
} else if (this.radioEnabled) { } else if (this.radioEnabled) {
this.fetchRadioRecommendations().then(() => { this.fetchRadioRecommendations().then(async () => {
const updatedQueue = this.getCurrentQueue(); const updatedQueue = this.getCurrentQueue();
if (this.currentQueueIndex < updatedQueue.length - 1) { if (this.currentQueueIndex < updatedQueue.length - 1) {
this.playNext(0); await this.playNext(0);
} }
}); });
return; return;
} else if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) { } else if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) {
this.fetchMoreArtistPopularTracks().then((newTracks) => { await this.fetchMoreArtistPopularTracks().then(async (newTracks) => {
if (newTracks && newTracks.length > 0) { if (newTracks && newTracks.length > 0) {
this.addToQueue(newTracks); await this.addToQueue(newTracks);
} }
// Now play the next track (which is now at currentQueueIndex + 1 if tracks were added) // Now play the next track (which is now at currentQueueIndex + 1 if tracks were added)
this.currentQueueIndex++; this.currentQueueIndex++;
this.playTrackFromQueue(0, recursiveCount); await this.playTrackFromQueue(0, recursiveCount);
}); });
return; return;
} else if (this.repeatMode === REPEAT_MODE.ALL) { } else if (this.repeatMode === REPEAT_MODE.ALL) {
@ -1156,8 +1135,9 @@ export class Player {
return; return;
} }
this.playTrackFromQueue(0, recursiveCount); await this.playTrackFromQueue(0, recursiveCount);
}); })
.catch(console.error);
} }
async enableRadio(seeds = []) { async enableRadio(seeds = []) {
@ -1165,20 +1145,20 @@ export class Player {
radioSettings.setEnabled(true); radioSettings.setEnabled(true);
if (seeds.length === 0) { if (seeds.length === 0) {
this.wipeQueue(); await this.wipeQueue();
const pickedSeeds = await this.pickRadioSeeds(); const pickedSeeds = await this.pickRadioSeeds();
if (pickedSeeds.length > 0) { if (pickedSeeds.length > 0) {
this.radioSeeds = pickedSeeds; this.radioSeeds = pickedSeeds;
const initialQueue = [...pickedSeeds].sort(() => 0.5 - Math.random()).slice(0, 5); const initialQueue = [...pickedSeeds].sort(() => 0.5 - Math.random()).slice(0, 5);
this.setQueue(initialQueue, 0, true); await this.setQueue(initialQueue, 0, true);
this.playAtIndex(0); await this.playAtIndex(0);
} }
} else { } else {
this.radioSeeds = Array.isArray(seeds) ? seeds : [seeds]; this.radioSeeds = Array.isArray(seeds) ? seeds : [seeds];
this.wipeQueue(); await this.wipeQueue();
const initialQueue = Array.isArray(seeds) ? seeds.slice(0, 5) : [seeds]; const initialQueue = Array.isArray(seeds) ? seeds.slice(0, 5) : [seeds];
this.setQueue(initialQueue, 0, true); await this.setQueue(initialQueue, 0, true);
this.playAtIndex(0); await this.playAtIndex(0);
} }
const currentQueue = this.getCurrentQueue(); const currentQueue = this.getCurrentQueue();
@ -1241,7 +1221,7 @@ export class Player {
if (newTracks.length > 0) { if (newTracks.length > 0) {
const tracksToAdd = newTracks.sort(() => 0.5 - Math.random()).slice(0, 5); const tracksToAdd = newTracks.sort(() => 0.5 - Math.random()).slice(0, 5);
this.addToQueue(tracksToAdd); await this.addToQueue(tracksToAdd);
} }
} }
} catch (error) { } catch (error) {
@ -1328,13 +1308,15 @@ export class Player {
return; return;
} }
import('./storage.js').then(({ contentBlockingSettings }) => { import('./storage.js')
.then(async ({ contentBlockingSettings }) => {
const track = currentQueue[this.currentQueueIndex]; const track = currentQueue[this.currentQueueIndex];
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) { if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
return this.playPrev(recursiveCount + 1); return this.playPrev(recursiveCount + 1);
} }
this.playTrackFromQueue(0, recursiveCount); await this.playTrackFromQueue(0, recursiveCount);
}); })
.catch(console.error);
} }
} }
@ -1342,28 +1324,28 @@ export class Player {
return this.currentTrack?.type === 'video' ? this.video : this.audio; return this.currentTrack?.type === 'video' ? this.video : this.audio;
} }
handlePlayPause() { async handlePlayPause() {
const el = this.activeElement; const el = this.activeElement;
const hasSource = el.src || el.currentSrc || el.srcObject || this.shakaInitialized; const hasSource = el.src || el.currentSrc || el.srcObject || this.shakaInitialized;
if (!hasSource || el.error) { if (!hasSource || el.error) {
if (this.currentTrack) { if (this.currentTrack) {
this.playTrackFromQueue(0, 0); await this.playTrackFromQueue(0, 0);
} }
return; return;
} }
if (el.paused) { if (el.paused) {
this.safePlay(el).catch((e) => { this.safePlay(el).catch(async (e) => {
if (e.name === 'NotAllowedError' || e.name === 'AbortError') return; if (e.name === 'NotAllowedError' || e.name === 'AbortError') return;
console.error('Play failed, reloading track:', e); console.error('Play failed, reloading track:', e);
if (this.currentTrack) { if (this.currentTrack) {
this.playTrackFromQueue(0, 0); await this.playTrackFromQueue(0, 0);
} }
}); });
} else { } else {
el.pause(); el.pause();
this.saveQueueState(); await this.saveQueueState();
} }
} }
@ -1382,7 +1364,7 @@ export class Player {
this.updateMediaSessionPositionState(); this.updateMediaSessionPositionState();
} }
toggleShuffle() { async toggleShuffle() {
this.shuffleActive = !this.shuffleActive; this.shuffleActive = !this.shuffleActive;
if (this.shuffleActive) { if (this.shuffleActive) {
@ -1413,17 +1395,17 @@ export class Player {
} }
this.preloadCache.clear(); this.preloadCache.clear();
this.preloadNextTracks(); void this.preloadNextTracks().catch(console.error);
this.saveQueueState(); await this.saveQueueState();
} }
toggleRepeat() { async toggleRepeat() {
this.repeatMode = (this.repeatMode + 1) % 3; this.repeatMode = (this.repeatMode + 1) % 3;
this.saveQueueState(); await this.saveQueueState();
return this.repeatMode; return this.repeatMode;
} }
setQueue(tracks, startIndex = 0, isRadio = false) { async setQueue(tracks, startIndex = 0, isRadio = false) {
if (!isRadio) { if (!isRadio) {
this.disableRadio(); this.disableRadio();
} }
@ -1431,7 +1413,7 @@ export class Player {
this.currentQueueIndex = startIndex; this.currentQueueIndex = startIndex;
this.shuffleActive = false; this.shuffleActive = false;
this.preloadCache.clear(); this.preloadCache.clear();
this.saveQueueState(); await this.saveQueueState();
} }
setArtistPopularTracksContext(artistId, initialTracks, offset = 15, hasMore = true) { 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]; const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks];
this.queue.push(...tracks); this.queue.push(...tracks);
@ -1509,12 +1491,12 @@ export class Player {
if (!this.currentTrack || this.currentQueueIndex === -1) { if (!this.currentTrack || this.currentQueueIndex === -1) {
this.currentQueueIndex = this.getCurrentQueue().length - tracks.length; 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 tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks];
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
const insertIndex = this.currentQueueIndex + 1; const insertIndex = this.currentQueueIndex + 1;
@ -1528,11 +1510,11 @@ export class Player {
this.originalQueueBeforeShuffle.push(...tracks); // Sync original queue this.originalQueueBeforeShuffle.push(...tracks); // Sync original queue
} }
this.saveQueueState(); await this.saveQueueState();
this.preloadNextTracks(); // Update preload since next track changed 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; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
// If removing current track // If removing current track
@ -1556,11 +1538,11 @@ export class Player {
} }
} }
this.saveQueueState(); await this.saveQueueState();
this.preloadNextTracks(); void this.preloadNextTracks().catch(console.error);
} }
clearQueue() { async clearQueue() {
if (this.currentTrack) { if (this.currentTrack) {
this.queue = [this.currentTrack]; this.queue = [this.currentTrack];
@ -1580,10 +1562,10 @@ export class Player {
} }
this.preloadCache.clear(); this.preloadCache.clear();
this.saveQueueState(); await this.saveQueueState();
} }
wipeQueue() { async wipeQueue() {
const el = this.activeElement; const el = this.activeElement;
el.pause(); el.pause();
el.src = ''; el.src = '';
@ -1592,16 +1574,16 @@ export class Player {
this.shuffledQueue = []; this.shuffledQueue = [];
this.originalQueueBeforeShuffle = []; this.originalQueueBeforeShuffle = [];
this.currentQueueIndex = -1; this.currentQueueIndex = -1;
this.saveQueueState(); await this.saveQueueState();
if (UIRenderer.instance) { if (UIRenderer.instance) {
UIRenderer.instance.setCurrentTrack(null); UIRenderer.instance.setCurrentTrack(null);
} }
if (window.renderQueueFunction) { if (window.renderQueueFunction) {
window.renderQueueFunction(); await window.renderQueueFunction();
} }
} }
moveInQueue(fromIndex, toIndex) { async moveInQueue(fromIndex, toIndex) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (fromIndex < 0 || fromIndex >= currentQueue.length) return; if (fromIndex < 0 || fromIndex >= currentQueue.length) return;
@ -1617,7 +1599,7 @@ export class Player {
} else if (fromIndex > this.currentQueueIndex && toIndex <= this.currentQueueIndex) { } else if (fromIndex > this.currentQueueIndex && toIndex <= this.currentQueueIndex) {
this.currentQueueIndex++; this.currentQueueIndex++;
} }
this.saveQueueState(); await this.saveQueueState();
} }
getCurrentQueue() { getCurrentQueue() {
@ -1772,7 +1754,9 @@ export class Player {
a.canPlayType('audio/mp4; codecs="ec-3"') || a.canPlayType('audio/mp4; codecs="eac3"') 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; let isAtmosPlaying = isTrackAtmos && deviceSupportsAtmos;
const q = this.quality || localStorage.getItem('adaptive-playback-quality') || 'auto'; 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 // Re-enable ABR so it can dynamically downgrade within that new codec family if needed
this.shakaPlayer.configure({ abr: { enabled: true } }); this.shakaPlayer.configure({ abr: { enabled: true } });
} }
} catch (e) { } catch {
// fail silently on abr checks // fail silently on abr checks
} }
} }

View file

@ -41,11 +41,11 @@ function getTrackArtists(track) {
/** /**
* Generates CSV playlist export * Generates CSV playlist export
* @param {Object} playlist - Playlist metadata * @param {Object} _playlist - Playlist metadata
* @param {Array} tracks - Array of track objects * @param {Array} tracks - Array of track objects
* @returns {string} CSV content * @returns {string} CSV content
*/ */
export function generateCSV(playlist, tracks) { export function generateCSV(_playlist, tracks) {
const headers = ['Track Name', 'Artist Name(s)', 'Album', 'Duration']; const headers = ['Track Name', 'Artist Name(s)', 'Album', 'Duration'];
let content = headers.map((h) => `"${h}"`).join(',') + '\n'; let content = headers.map((h) => `"${h}"`).join(',') + '\n';

View file

@ -248,7 +248,8 @@ export async function loadProfile(username) {
} }
if (profile.lastfm_username && profile.privacy?.lastfm !== 'private') { if (profile.lastfm_username && profile.privacy?.lastfm !== 'private') {
fetchLastFmRecentTracks(profile.lastfm_username).then(async (tracks) => { fetchLastFmRecentTracks(profile.lastfm_username)
.then(async (tracks) => {
if (tracks.length > 0) { if (tracks.length > 0) {
recentSection.style.display = 'block'; recentSection.style.display = 'block';
recentContainer.innerHTML = tracks recentContainer.innerHTML = tracks
@ -296,13 +297,19 @@ export async function loadProfile(username) {
for (const track of tracks) { for (const track of tracks) {
if (track._needsCover) { if (track._needsCover) {
fetchFallbackCover(track.name, track.artist?.['#text'] || track.artist?.name, track._imgId); await fetchFallbackCover(
track.name,
track.artist?.['#text'] || track.artist?.name,
track._imgId
);
} }
} }
} }
}); })
.catch(console.error);
fetchLastFmTopArtists(profile.lastfm_username).then(async (artists) => { fetchLastFmTopArtists(profile.lastfm_username)
.then(async (artists) => {
if (artists.length > 0 && topArtistsSection && topArtistsContainer) { if (artists.length > 0 && topArtistsSection && topArtistsContainer) {
topArtistsSection.style.display = 'block'; topArtistsSection.style.display = 'block';
topArtistsContainer.innerHTML = artists topArtistsContainer.innerHTML = artists
@ -339,13 +346,15 @@ export async function loadProfile(username) {
for (const artist of artists) { for (const artist of artists) {
if (artist._needsCover) { if (artist._needsCover) {
fetchFallbackArtistImage(artist.name, artist._imgId); await fetchFallbackArtistImage(artist.name, artist._imgId);
} }
} }
} }
}); })
.catch(console.error);
fetchLastFmTopAlbums(profile.lastfm_username).then(async (albums) => { fetchLastFmTopAlbums(profile.lastfm_username)
.then(async (albums) => {
if (albums.length > 0 && topAlbumsSection && topAlbumsContainer) { if (albums.length > 0 && topAlbumsSection && topAlbumsContainer) {
topAlbumsSection.style.display = 'block'; topAlbumsSection.style.display = 'block';
topAlbumsContainer.innerHTML = albums topAlbumsContainer.innerHTML = albums
@ -388,13 +397,15 @@ export async function loadProfile(username) {
for (const album of albums) { for (const album of albums) {
if (album._needsCover) { if (album._needsCover) {
fetchFallbackAlbumCover(album.name, album._artistName, album._imgId); await fetchFallbackAlbumCover(album.name, album._artistName, album._imgId);
} }
} }
} }
}); })
.catch(console.error);
fetchLastFmTopTracks(profile.lastfm_username).then(async (tracks) => { fetchLastFmTopTracks(profile.lastfm_username)
.then(async (tracks) => {
if (tracks.length > 0 && topTracksSection && topTracksContainer) { if (tracks.length > 0 && topTracksSection && topTracksContainer) {
topTracksSection.style.display = 'block'; topTracksSection.style.display = 'block';
topTracksContainer.innerHTML = tracks topTracksContainer.innerHTML = tracks
@ -438,11 +449,12 @@ export async function loadProfile(username) {
for (const track of tracks) { for (const track of tracks) {
if (track._needsCover) { if (track._needsCover) {
fetchFallbackCover(track.name, track._artistName, track._imgId); await fetchFallbackCover(track.name, track._artistName, track._imgId);
} }
} }
} }
}); })
.catch(console.error);
} }
const currentUser = await syncManager.getUserData(); const currentUser = await syncManager.getUserData();
@ -483,8 +495,8 @@ export async function loadProfile(username) {
} }
} }
export function openEditProfile() { export async function openEditProfile() {
syncManager.getUserData().then((data) => { await syncManager.getUserData().then((data) => {
if (!data || !data.profile) return; if (!data || !data.profile) return;
const p = data.profile; const p = data.profile;
@ -566,7 +578,7 @@ async function saveProfile() {
try { try {
await syncManager.updateProfile(data); await syncManager.updateProfile(data);
editProfileModal.classList.remove('active'); editProfileModal.classList.remove('active');
loadProfile(newUsername); await loadProfile(newUsername);
if (window.location.pathname.includes('/user/@')) { if (window.location.pathname.includes('/user/@')) {
window.history.replaceState(null, '', `/user/@${newUsername}`); window.history.replaceState(null, '', `/user/@${newUsername}`);
@ -589,7 +601,7 @@ viewMyProfileBtn.addEventListener('click', async () => {
if (data && data.profile && data.profile.username) { if (data && data.profile && data.profile.username) {
navigate(`/user/@${data.profile.username}`); navigate(`/user/@${data.profile.username}`);
} else { } else {
openEditProfile(); await openEditProfile();
} }
}); });

View file

@ -1,9 +1,9 @@
declare global { declare global {
type MonochromeProgress<T = {}> = { type MonochromeProgress<T = object> = {
stage: string; stage: string;
} & T; } & T;
type MonochromeProgressMessage<T = MonochromeProgress> = { type MonochromeProgressMessage<_T = MonochromeProgress> = {
message: string; message: string;
}; };

File diff suppressed because it is too large Load diff

View file

@ -118,25 +118,25 @@ export class SidePanelManager {
return this.currentView === view && this.panel.classList.contains('active'); 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 (this.isActive(view)) {
if (renderControlsCallback) { if (renderControlsCallback) {
this.controlsElement.innerHTML = ''; this.controlsElement.innerHTML = '';
renderControlsCallback(this.controlsElement); await renderControlsCallback(this.controlsElement);
} }
if (renderContentCallback) { if (renderContentCallback) {
if (!options.noClear) { if (!options.noClear) {
this.contentElement.innerHTML = ''; this.contentElement.innerHTML = '';
} }
renderContentCallback(this.contentElement); await renderContentCallback(this.contentElement);
} }
} }
} }
updateContent(view, renderContentCallback) { async updateContent(view, renderContentCallback) {
if (this.isActive(view)) { if (this.isActive(view)) {
this.contentElement.innerHTML = ''; this.contentElement.innerHTML = '';
renderContentCallback(this.contentElement); await renderContentCallback(this.contentElement);
} }
} }
} }

View file

@ -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 = { export const cardSettings = {
COMPACT_ARTIST_KEY: 'card-compact-artist', COMPACT_ARTIST_KEY: 'card-compact-artist',
COMPACT_ALBUM_KEY: 'card-compact-album', 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 = { export const equalizerSettings = {
ENABLED_KEY: 'equalizer-enabled', ENABLED_KEY: 'equalizer-enabled',
GAINS_KEY: 'equalizer-gains', GAINS_KEY: 'equalizer-gains',
BAND_TYPES_KEY: 'equalizer-band-types',
BAND_QS_KEY: 'equalizer-band-qs',
PRESET_KEY: 'equalizer-preset', PRESET_KEY: 'equalizer-preset',
CUSTOM_PRESETS_KEY: 'equalizer-custom-presets', CUSTOM_PRESETS_KEY: 'equalizer-custom-presets',
BAND_COUNT_KEY: 'equalizer-band-count', BAND_COUNT_KEY: 'equalizer-band-count',
@ -1040,6 +1078,7 @@ export const equalizerSettings = {
FREQ_MIN_KEY: 'equalizer-freq-min', FREQ_MIN_KEY: 'equalizer-freq-min',
FREQ_MAX_KEY: 'equalizer-freq-max', FREQ_MAX_KEY: 'equalizer-freq-max',
PREAMP_KEY: 'equalizer-preamp', PREAMP_KEY: 'equalizer-preamp',
CUSTOM_FREQUENCIES_KEY: 'equalizer-custom-frequencies',
DEFAULT_BAND_COUNT: 16, DEFAULT_BAND_COUNT: 16,
MIN_BANDS: 3, MIN_BANDS: 3,
MAX_BANDS: 32, 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 * Interpolate gains array to match target band count
*/ */
@ -1440,6 +1573,130 @@ export const equalizerSettings = {
return false; 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 = { export const monoAudioSettings = {
@ -1818,6 +2075,20 @@ export const homePageSettings = {
setShuffleEditorsPicks(enabled) { setShuffleEditorsPicks(enabled) {
localStorage.setItem(this.SHUFFLE_EDITORS_PICKS_KEY, enabled ? 'true' : 'false'); 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 = { export const radioSettings = {
@ -2382,18 +2653,18 @@ export const fontSettings = {
document.documentElement.style.setProperty('--font-family', "'SF Pro Display', sans-serif"); document.documentElement.style.setProperty('--font-family', "'SF Pro Display', sans-serif");
}, },
applyFont() { async applyFont() {
const config = this.getConfig(); const config = this.getConfig();
switch (config.type) { switch (config.type) {
case 'google': case 'google':
this.loadGoogleFont(config.family); await this.loadGoogleFont(config.family);
break; break;
case 'url': case 'url':
this.loadFontFromUrl(config.url, config.family); await this.loadFontFromUrl(config.url, config.family);
break; break;
case 'uploaded': case 'uploaded':
this.loadUploadedFont(config.fontId); await this.loadUploadedFont(config.fontId);
break; break;
case 'preset': case 'preset':
default: default:
@ -2573,13 +2844,13 @@ export const contentBlockingSettings = {
isArtistBlocked(artistId) { isArtistBlocked(artistId) {
if (!artistId) return false; if (!artistId) return false;
return this.getBlockedArtists().some((a) => a.id == artistId); return this.getBlockedArtists().some((a) => String(a.id) === String(artistId));
}, },
blockArtist(artist) { blockArtist(artist) {
if (!artist || !artist.id) return; if (!artist || !artist.id) return;
const blocked = this.getBlockedArtists(); const blocked = this.getBlockedArtists();
if (!blocked.some((a) => a.id === artist.id)) { if (!blocked.some((a) => String(a.id) === String(artist.id))) {
blocked.push({ blocked.push({
id: artist.id, id: artist.id,
name: artist.name || 'Unknown Artist', name: artist.name || 'Unknown Artist',
@ -2590,7 +2861,7 @@ export const contentBlockingSettings = {
}, },
unblockArtist(artistId) { 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); this.setBlockedArtists(blocked);
}, },
@ -2610,13 +2881,13 @@ export const contentBlockingSettings = {
isTrackBlocked(trackId) { isTrackBlocked(trackId) {
if (!trackId) return false; if (!trackId) return false;
return this.getBlockedTracks().some((t) => t.id == trackId); return this.getBlockedTracks().some((t) => String(t.id) === String(trackId));
}, },
blockTrack(track) { blockTrack(track) {
if (!track || !track.id) return; if (!track || !track.id) return;
const blocked = this.getBlockedTracks(); const blocked = this.getBlockedTracks();
if (!blocked.some((t) => t.id == track.id)) { if (!blocked.some((t) => String(t.id) === String(track.id))) {
blocked.push({ blocked.push({
id: track.id, id: track.id,
title: track.title || 'Unknown Track', title: track.title || 'Unknown Track',
@ -2628,7 +2899,7 @@ export const contentBlockingSettings = {
}, },
unblockTrack(trackId) { 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); this.setBlockedTracks(blocked);
}, },
@ -2648,13 +2919,13 @@ export const contentBlockingSettings = {
isAlbumBlocked(albumId) { isAlbumBlocked(albumId) {
if (!albumId) return false; if (!albumId) return false;
return this.getBlockedAlbums().some((a) => a.id == albumId); return this.getBlockedAlbums().some((a) => String(a.id) === String(albumId));
}, },
blockAlbum(album) { blockAlbum(album) {
if (!album || !album.id) return; if (!album || !album.id) return;
const blocked = this.getBlockedAlbums(); const blocked = this.getBlockedAlbums();
if (!blocked.some((a) => a.id == album.id)) { if (!blocked.some((a) => String(a.id) === String(album.id))) {
blocked.push({ blocked.push({
id: album.id, id: album.id,
title: album.title || 'Unknown Album', title: album.title || 'Unknown Album',
@ -2666,7 +2937,7 @@ export const contentBlockingSettings = {
}, },
unblockAlbum(albumId) { 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); this.setBlockedAlbums(blocked);
}, },

View file

@ -22,7 +22,11 @@ export async function withTimeout<T>(callback: () => Promise<T>, timeout: number
}) })
.catch((err) => { .catch((err) => {
clearTimeout(timer); clearTimeout(timer);
if (err instanceof Error) {
reject(err); reject(err);
} else {
reject(new Error(String(err)));
}
}); });
}); });
} }
@ -33,7 +37,7 @@ function toUint8Array(audioData: ArrayBufferLike | Uint8Array) {
} }
return doTimed( 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) () => new Uint8Array(audioData)
); );
} }
@ -60,7 +64,7 @@ async function convertInputToTaglib<R = TagLibReadTypes>(
return (await doTimedAsync('Reading File from FileSystemHandle as Uint8Array', async () => { return (await doTimedAsync('Reading File from FileSystemHandle as Uint8Array', async () => {
const file = await audioData.getFile(); const file = await audioData.getFile();
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
return await toUint8Array(arrayBuffer); return toUint8Array(arrayBuffer);
})) as R; })) as R;
} else if ( } else if (
!(audioData instanceof Uint8Array) && !(audioData instanceof Uint8Array) &&
@ -69,7 +73,7 @@ async function convertInputToTaglib<R = TagLibReadTypes>(
!('FileSystemFileEntry' in globalThis && audioData instanceof FileSystemFileEntry) && !('FileSystemFileEntry' in globalThis && audioData instanceof FileSystemFileEntry) &&
!('FileSystemFileHandle' in globalThis && audioData instanceof FileSystemFileHandle) !('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; return audioData as R;
@ -114,19 +118,19 @@ export async function addMetadataWithTagLib(
if (error) { if (error) {
reject(new Error(error)); reject(new Error(error));
} else { } else {
resolve(data!); resolve(data);
} }
}; };
worker.onerror = reject; worker.onerror = reject;
worker.onmessageerror = reject; worker.onmessageerror = reject;
const transferables: Transferable[] = []; const transferables: Transferable[] = [];
if ((audioData as any)?.buffer instanceof ArrayBuffer) { if ((audioData as Uint8Array)?.buffer instanceof ArrayBuffer) {
transferables.push((audioData as any).buffer); transferables.push((audioData as Uint8Array).buffer);
} }
if ((data as any).cover?.data?.buffer instanceof ArrayBuffer) { if (data.cover?.data?.buffer instanceof ArrayBuffer) {
transferables.push((data as any).cover.data.buffer); transferables.push(data.cover.data.buffer);
} }
worker.postMessage({ ...data, type: 'Add', audioData, filename }, transferables); worker.postMessage({ ...data, type: 'Add', audioData, filename }, transferables);
@ -168,15 +172,15 @@ export async function getMetadataWithTagLib(
if (error) { if (error) {
reject(new Error(error)); reject(new Error(error));
} else { } else {
resolve(data!); resolve(data);
} }
}; };
worker.onerror = reject; worker.onerror = reject;
worker.onmessageerror = reject; worker.onmessageerror = reject;
const transferables: Transferable[] = []; const transferables: Transferable[] = [];
if ((audioData as any)?.buffer instanceof ArrayBuffer) { if ((audioData as Uint8Array)?.buffer instanceof ArrayBuffer) {
transferables.push((audioData as any).buffer); transferables.push((audioData as Uint8Array).buffer);
} }
worker.postMessage({ type: 'Get', audioData, filename }, transferables); worker.postMessage({ type: 'Get', audioData, filename }, transferables);
}), }),

View file

@ -16,7 +16,8 @@ export interface TagLibWorkerResponse<T> {
export interface TagLibMetadata { export interface TagLibMetadata {
title?: string; title?: string;
artist?: string; artist?: string | string[];
writeArtistsSeparately?: boolean;
albumTitle?: string; albumTitle?: string;
albumArtist?: string; albumArtist?: string;
trackNumber?: number; trackNumber?: number;
@ -51,7 +52,6 @@ export enum Mp4Stik {
WhackedBookmark = 5, WhackedBookmark = 5,
MusicVideo = 6, MusicVideo = 6,
Movie = 9, Movie = 9,
ShortFilm = 9,
TVShow = 10, TVShow = 10,
Booklet = 11, Booklet = 11,
} }

View file

@ -1,8 +1,8 @@
// filepath: /workspaces/monochrome/js/taglib.worker.ts // 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 { 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 { Variant } from '!/@dantheman827/taglib-ts/src/toolkit/variant.js';
import { doTimed, doTimedAsync } from './doTimed'; import { doTimed, doTimedAsync } from './doTimed';
import { import {
@ -10,7 +10,6 @@ import {
type _AddMetadataMessage, type _AddMetadataMessage,
type _GetMetadataMessage, type _GetMetadataMessage,
type AddMetadataMessage, type AddMetadataMessage,
type GetMetadataMessage,
type TagLibFileResponse, type TagLibFileResponse,
type TagLibMetadata, type TagLibMetadata,
type TagLibMetadataResponse, type TagLibMetadataResponse,
@ -18,6 +17,7 @@ import {
type TagLibWorkerMessage, type TagLibWorkerMessage,
type TagLibWorkerResponse, type TagLibWorkerResponse,
} from './taglib.types'; } from './taglib.types';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { File as TagLibFile } from '!/@dantheman827/taglib-ts/src/file.js'; import { File as TagLibFile } from '!/@dantheman827/taglib-ts/src/file.js';
import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js'; import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js';
import { ChunkedByteVectorStream } from '!/@dantheman827/taglib-ts/src/toolkit/chunkedByteVectorStream.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 { FlacFile } from '!/@dantheman827/taglib-ts/src/flac/flacFile.js';
import { MpegFile } from '!/@dantheman827/taglib-ts/src/mpeg/mpegFile.js'; import { MpegFile } from '!/@dantheman827/taglib-ts/src/mpeg/mpegFile.js';
import { Mp4File } from '!/@dantheman827/taglib-ts/src/mp4/mp4File.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 { 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; export const isWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
@ -38,9 +38,10 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
const { const {
audioData, audioData,
audioRef, audioRef,
filename, filename: _filename,
title, title,
artist, artist,
writeArtistsSeparately = false,
albumTitle, albumTitle,
albumArtist, albumArtist,
trackNumber, trackNumber,
@ -74,17 +75,26 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
} }
const underlying = ref.file(); const underlying = ref.file();
const isFlac = underlying instanceof FlacFile;
const isMp4 = underlying instanceof Mp4File; const isMp4 = underlying instanceof Mp4File;
const isMpeg = underlying instanceof MpegFile; const isMpeg = underlying instanceof MpegFile;
const isOgg = underlying instanceof OggVorbisFile;
const _isWav = underlying instanceof WavFile;
const needsCombinedTrackDisc = isMp4 || isMpeg; const needsCombinedTrackDisc = isMp4 || isMpeg;
const artistArray = Array.isArray(artist) ? artist : artist ? [artist] : [];
const supportsMultiValuedArtist = writeArtistsSeparately && (isFlac || isOgg || isMp4);
doTimed('Tagging file', () => { doTimed('Tagging file', () => {
const props = ref.properties(); const props = ref.properties();
if (title) props.replace('TITLE', [title]); 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 (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) { if (trackNumber) {
const trackStr = const trackStr =
@ -127,7 +137,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
if (copyright) props.replace('COPYRIGHT', [copyright]); if (copyright) props.replace('COPYRIGHT', [copyright]);
if (isrc) props.replace('ISRC', [isrc]); if (isrc) props.replace('ISRC', [isrc]);
if (isrc && isMp4) { if (isrc && isMp4) {
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag; const mp4Tag = underlying.tag();
mp4Tag.setItem('xid ', Mp4Item.fromStringList([`:isrc:${isrc}`])); mp4Tag.setItem('xid ', Mp4Item.fromStringList([`:isrc:${isrc}`]));
} }
if (upc) props.replace('UPC', [upc]); if (upc) props.replace('UPC', [upc]);
@ -136,7 +146,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
if (explicit !== undefined) { if (explicit !== undefined) {
if (isMp4) { if (isMp4) {
// rtng is a byte item — must be set directly on the Mp4Tag // 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)); mp4Tag.setItem('rtng', Mp4Item.fromByte(explicit ? 1 : 0));
} else { } else {
props.replace('ITUNESADVISORY', [explicit ? '1' : '0']); props.replace('ITUNESADVISORY', [explicit ? '1' : '0']);
@ -144,7 +154,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
} }
if (stik != null && isMp4) { if (stik != null && isMp4) {
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag; const mp4Tag = underlying.tag();
mp4Tag.setItem('stik', Mp4Item.fromByte(stik)); mp4Tag.setItem('stik', Mp4Item.fromByte(stik));
} }
@ -167,7 +177,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
await ref.save(); await ref.save();
}); });
const file = ref.file() as TagLibFile; const file = ref.file();
if (!file) return audioData; if (!file) return audioData;
const stream = file.stream(); const stream = file.stream();
@ -197,7 +207,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
} }
export async function getMetadataFromAudio(message: _GetMetadataMessage): Promise<TagLibReadMetadata> { export async function getMetadataFromAudio(message: _GetMetadataMessage): Promise<TagLibReadMetadata> {
const { audioData, audioRef, filename } = message; const { audioData, audioRef } = message;
const data: TagLibReadMetadata = { duration: 0 }; const data: TagLibReadMetadata = { duration: 0 };
const ref = const ref =
@ -253,7 +263,7 @@ export async function getMetadataFromAudio(message: _GetMetadataMessage): Promis
data.isrc = props.get('ISRC')?.[0] || undefined; data.isrc = props.get('ISRC')?.[0] || undefined;
if (isMp4) { if (isMp4) {
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag; const mp4Tag = underlying.tag();
data.explicit = mp4Tag.item('rtng')?.toByte() === 1; data.explicit = mp4Tag.item('rtng')?.toByte() === 1;
} else { } else {
data.explicit = props.get('ITUNESADVISORY')?.[0] === '1'; data.explicit = props.get('ITUNESADVISORY')?.[0] === '1';

View file

@ -48,9 +48,9 @@ export class ThemeStore {
} }
init() { init() {
document.getElementById('open-theme-store-btn')?.addEventListener('click', () => { document.getElementById('open-theme-store-btn')?.addEventListener('click', async () => {
this.modal.classList.add('active'); this.modal.classList.add('active');
this.loadThemes(); await this.loadThemes();
}); });
this.modal?.querySelector('.close-modal-btn')?.addEventListener('click', () => { this.modal?.querySelector('.close-modal-btn')?.addEventListener('click', () => {
@ -59,14 +59,14 @@ export class ThemeStore {
const tabs = this.modal?.querySelectorAll('.search-tab'); const tabs = this.modal?.querySelectorAll('.search-tab');
tabs?.forEach((tab) => { tabs?.forEach((tab) => {
tab.addEventListener('click', () => { tab.addEventListener('click', async () => {
tabs.forEach((t) => t.classList.remove('active')); tabs.forEach((t) => t.classList.remove('active'));
this.modal.querySelectorAll('.search-tab-content').forEach((c) => c.classList.remove('active')); this.modal.querySelectorAll('.search-tab-content').forEach((c) => c.classList.remove('active'));
tab.classList.add('active'); tab.classList.add('active');
const contentId = tab.dataset.tab === 'browse' ? 'theme-store-browse' : 'theme-store-upload'; const contentId = tab.dataset.tab === 'browse' ? 'theme-store-browse' : 'theme-store-upload';
document.getElementById(contentId)?.classList.add('active'); document.getElementById(contentId)?.classList.add('active');
if (tab.dataset.tab === 'upload') { if (tab.dataset.tab === 'upload') {
this.checkAuth(); await this.checkAuth();
} else { } else {
this.resetEditState(); this.resetEditState();
} }
@ -82,9 +82,9 @@ export class ThemeStore {
this.uploadForm?.addEventListener('submit', (e) => this.handleUpload(e)); this.uploadForm?.addEventListener('submit', (e) => this.handleUpload(e));
if (authManager) { if (authManager) {
authManager.onAuthStateChanged(() => { authManager.onAuthStateChanged(async () => {
if (this.modal.classList.contains('active')) { if (this.modal.classList.contains('active')) {
this.checkAuth(); await this.checkAuth();
} }
}); });
} }
@ -231,10 +231,10 @@ export class ThemeStore {
</div> </div>
`; `;
div.addEventListener('click', (e) => { div.addEventListener('click', async (e) => {
if (e.target.closest('.delete-theme-btn')) { if (e.target.closest('.delete-theme-btn')) {
e.stopPropagation(); e.stopPropagation();
this.deleteTheme(theme.id); await this.deleteTheme(theme.id);
return; return;
} }
if (e.target.closest('.edit-theme-btn')) { 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 }); await this.pb.collection('themes').delete(themeId, { f_id: fbUser.$id });
alert('Theme deleted successfully.'); alert('Theme deleted successfully.');
this.loadThemes(); await this.loadThemes();
} catch (err) { } catch (err) {
console.error('Failed to delete theme:', err); console.error('Failed to delete theme:', err);
alert('Failed to delete theme. You might not have permission.'); 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 // Force reflow to ensure theme changes are applied immediately
document.documentElement.style.display = 'none'; document.documentElement.style.display = 'none';
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
document.documentElement.offsetHeight; document.documentElement.offsetHeight;
document.documentElement.style.display = ''; document.documentElement.style.display = '';
@ -572,7 +573,7 @@ export class ThemeStore {
} }
this.modal.querySelector('[data-tab="browse"]').click(); this.modal.querySelector('[data-tab="browse"]').click();
this.loadThemes(); await this.loadThemes();
} catch (err) { } catch (err) {
console.error('Upload failed:', err); console.error('Upload failed:', err);
console.error('Response data:', err.data); console.error('Response data:', err.data);

View file

@ -107,7 +107,11 @@ function transformErasImages(eras) {
} }
async function fetchTrackerData(sheetId) { 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; let lastError = null;
for (const baseUrl of endpoints) { for (const baseUrl of endpoints) {
@ -276,7 +280,7 @@ function renderTrackerTracks(container, tracks) {
} }
// Create project card HTML - EXACTLY like album cards // Create project card HTML - EXACTLY like album cards
export function createProjectCardHTML(era, artist, sheetId, trackCount) { export function createProjectCardHTML(era, _artist, sheetId, trackCount) {
const playBtnHTML = ` const playBtnHTML = `
<button class="play-btn card-play-btn" data-action="play-card" data-type="tracker-project" data-id="${encodeURIComponent(era.name)}" title="Play"> <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)} ${SVG_PLAY(20)}

View file

@ -66,7 +66,7 @@ export function initializeUIInteractions(player, api, ui) {
if (playlistId && folderId) { if (playlistId && folderId) {
const updatedFolder = await db.addPlaylistToFolder(folderId, playlistId); const updatedFolder = await db.addPlaylistToFolder(folderId, playlistId);
syncManager.syncUserFolder(updatedFolder, 'update'); await syncManager.syncUserFolder(updatedFolder, 'update');
const subtitle = folderCard.querySelector('.card-subtitle'); const subtitle = folderCard.querySelector('.card-subtitle');
if (subtitle) { if (subtitle) {
subtitle.textContent = `${updatedFolder.playlists.length} playlists`; subtitle.textContent = `${updatedFolder.playlists.length} playlists`;
@ -112,7 +112,7 @@ export function initializeUIInteractions(player, api, ui) {
}); });
// Queue panel // Queue panel
const renderQueueControls = (container) => { const renderQueueControls = async (container) => {
const currentQueue = player.getCurrentQueue(); const currentQueue = player.getCurrentQueue();
const showActionBtns = currentQueue.length > 0; const showActionBtns = currentQueue.length > 0;
@ -141,7 +141,7 @@ export function initializeUIInteractions(player, api, ui) {
const downloadBtn = container.querySelector('#download-queue-btn'); const downloadBtn = container.querySelector('#download-queue-btn');
if (downloadBtn) { if (downloadBtn) {
downloadBtn.addEventListener('click', async () => { 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) { for (const track of currentQueue) {
const wasAdded = await db.toggleFavorite('track', track); const wasAdded = await db.toggleFavorite('track', track);
if (wasAdded) { if (wasAdded) {
syncManager.syncLibraryItem('track', track, true); await syncManager.syncLibraryItem('track', track, true);
addedCount++; addedCount++;
} }
} }
@ -163,7 +163,7 @@ export function initializeUIInteractions(player, api, ui) {
showNotification('All tracks in queue are already liked'); 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); const updatedPlaylist = await db.getPlaylist(playlistId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
showNotification(`Added ${addedCount} tracks to playlist: ${playlistName}`); showNotification(`Added ${addedCount} tracks to playlist: ${playlistName}`);
} catch (error) { } catch (error) {
@ -238,9 +238,9 @@ export function initializeUIInteractions(player, api, ui) {
const clearBtn = container.querySelector('#clear-queue-btn'); const clearBtn = container.querySelector('#clear-queue-btn');
if (clearBtn) { if (clearBtn) {
clearBtn.addEventListener('click', () => { clearBtn.addEventListener('click', async () => {
player.clearQueue(); 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; if (container._queueListenersAttached) return;
container.addEventListener('click', async (e) => { container.addEventListener('click', async (e) => {
@ -295,7 +295,7 @@ export function initializeUIInteractions(player, api, ui) {
if (removeBtn) { if (removeBtn) {
e.stopPropagation(); e.stopPropagation();
player.removeFromQueue(index); player.removeFromQueue(index);
refreshQueuePanel(); await refreshQueuePanel();
return; return;
} }
@ -305,12 +305,12 @@ export function initializeUIInteractions(player, api, ui) {
const track = player.getCurrentQueue()[index]; const track = player.getCurrentQueue()[index];
if (track) { if (track) {
const added = await db.toggleFavorite('track', 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.classList.toggle('active', added);
likeBtn.innerHTML = added ? SVG_HEART_FILLED(20) : SVG_HEART(20); 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}`); showNotification(added ? `Added to Liked: ${track.title}` : `Removed from Liked: ${track.title}`);
} }
return; return;
@ -319,7 +319,7 @@ export function initializeUIInteractions(player, api, ui) {
if (item.classList.contains('blocked')) return; if (item.classList.contains('blocked')) return;
player.playAtIndex(index); player.playAtIndex(index);
refreshQueuePanel(); await refreshQueuePanel();
}); });
container.addEventListener('contextmenu', async (e) => { container.addEventListener('contextmenu', async (e) => {
@ -369,14 +369,14 @@ export function initializeUIInteractions(player, api, ui) {
e.preventDefault(); e.preventDefault();
}); });
container.addEventListener('drop', (e) => { container.addEventListener('drop', async (e) => {
e.preventDefault(); e.preventDefault();
const item = e.target.closest('.queue-track-item'); const item = e.target.closest('.queue-track-item');
if (item && draggedQueueIndex !== null) { if (item && draggedQueueIndex !== null) {
const index = parseInt(item.dataset.queueIndex); const index = parseInt(item.dataset.queueIndex);
if (draggedQueueIndex !== index) { if (draggedQueueIndex !== index) {
player.moveInQueue(draggedQueueIndex, index); player.moveInQueue(draggedQueueIndex, index);
refreshQueuePanel(); await refreshQueuePanel();
} }
} }
}); });
@ -384,7 +384,7 @@ export function initializeUIInteractions(player, api, ui) {
container._queueListenersAttached = true; container._queueListenersAttached = true;
}; };
const renderQueueContent = (container, isUpdate = false) => { const renderQueueContent = async (container, isUpdate = false) => {
const currentQueue = player.getCurrentQueue(); const currentQueue = player.getCurrentQueue();
if (currentQueue.length === 0) { if (currentQueue.length === 0) {
@ -395,7 +395,7 @@ export function initializeUIInteractions(player, api, ui) {
} }
isQueueRendering = true; isQueueRendering = true;
attachQueueListeners(container); await attachQueueListeners(container);
if (currentQueue.length > QUEUE_VIRTUALIZATION_THRESHOLD) { if (currentQueue.length > QUEUE_VIRTUALIZATION_THRESHOLD) {
if (!isUpdate) { if (!isUpdate) {
@ -422,26 +422,26 @@ export function initializeUIInteractions(player, api, ui) {
if (bottomObserver) bottomObserver.disconnect(); if (bottomObserver) bottomObserver.disconnect();
bottomObserver = new IntersectionObserver( bottomObserver = new IntersectionObserver(
(entries) => { async (entries) => {
if (entries[0].isIntersecting && !isQueueRendering && queueEndIndex < currentQueue.length) { if (entries[0].isIntersecting && !isQueueRendering && queueEndIndex < currentQueue.length) {
queueEndIndex = Math.min(currentQueue.length, queueEndIndex + QUEUE_CHUNK_SIZE); queueEndIndex = Math.min(currentQueue.length, queueEndIndex + QUEUE_CHUNK_SIZE);
if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) { if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) {
queueStartIndex += QUEUE_CHUNK_SIZE; queueStartIndex += QUEUE_CHUNK_SIZE;
} }
renderQueueContent(container, true); await renderQueueContent(container, true);
} }
}, },
{ root: container, rootMargin: '200px' } { root: container, rootMargin: '200px' }
); );
topObserver = new IntersectionObserver( topObserver = new IntersectionObserver(
(entries) => { async (entries) => {
if (entries[0].isIntersecting && !isQueueRendering && queueStartIndex > 0) { if (entries[0].isIntersecting && !isQueueRendering && queueStartIndex > 0) {
queueStartIndex = Math.max(0, queueStartIndex - QUEUE_CHUNK_SIZE); queueStartIndex = Math.max(0, queueStartIndex - QUEUE_CHUNK_SIZE);
if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) { if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) {
queueEndIndex -= QUEUE_CHUNK_SIZE; queueEndIndex -= QUEUE_CHUNK_SIZE;
} }
renderQueueContent(container, true); await renderQueueContent(container, true);
} }
}, },
{ root: container, rootMargin: '200px' } { root: container, rootMargin: '200px' }
@ -469,8 +469,8 @@ export function initializeUIInteractions(player, api, ui) {
isQueueRendering = false; isQueueRendering = false;
}; };
const refreshQueuePanel = () => { const refreshQueuePanel = async () => {
sidePanelManager.refresh('queue', renderQueueControls, renderQueueContent, { noClear: true }); await sidePanelManager.refresh('queue', renderQueueControls, renderQueueContent, { noClear: true });
}; };
const openQueuePanel = () => { const openQueuePanel = () => {
@ -489,9 +489,9 @@ export function initializeUIInteractions(player, api, ui) {
queueBtn.addEventListener('click', openQueuePanel); queueBtn.addEventListener('click', openQueuePanel);
// Expose renderQueue for external updates (e.g. shuffle, add to queue) // Expose renderQueue for external updates (e.g. shuffle, add to queue)
window.renderQueueFunction = () => { window.renderQueueFunction = async () => {
if (sidePanelManager.isActive('queue')) { if (sidePanelManager.isActive('queue')) {
refreshQueuePanel(); await refreshQueuePanel();
} }
const overlay = document.getElementById('fullscreen-cover-overlay'); const overlay = document.getElementById('fullscreen-cover-overlay');
@ -519,7 +519,7 @@ export function initializeUIInteractions(player, api, ui) {
if (playlistId && folderId) { if (playlistId && folderId) {
try { try {
const updatedFolder = await db.addPlaylistToFolder(folderId, playlistId); const updatedFolder = await db.addPlaylistToFolder(folderId, playlistId);
syncManager.syncUserFolder(updatedFolder, 'update'); await syncManager.syncUserFolder(updatedFolder, 'update');
window.dispatchEvent(new HashChangeEvent('hashchange')); window.dispatchEvent(new HashChangeEvent('hashchange'));
showNotification('Playlist added to folder'); showNotification('Playlist added to folder');
} catch (error) { } catch (error) {
@ -562,9 +562,11 @@ export function initializeUIInteractions(player, api, ui) {
document.getElementById(contentId)?.classList.add('active'); document.getElementById(contentId)?.classList.add('active');
// Save active tab // Save active tab
import('./storage.js').then(({ settingsUiState }) => { import('./storage.js')
.then(({ settingsUiState }) => {
settingsUiState.setActiveTab(tab.dataset.tab); settingsUiState.setActiveTab(tab.dataset.tab);
}); })
.catch(console.error);
}); });
}); });

531
js/ui.js

File diff suppressed because it is too large Load diff

View file

@ -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). * with any featured artists parsed from the title (feat./with).
*/ */
export function getFullArtistString(track) { export function getFullArtistArray(track) {
const knownArtists = const knownArtists =
Array.isArray(track.artists) && track.artists.length > 0 Array.isArray(track.artists) && track.artists.length > 0
? track.artists.map((a) => (typeof a === 'string' ? a : a.name) || '').filter(Boolean) ? 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; return knownArtists.join('; ') || null;
} }
@ -654,7 +664,7 @@ export function fetchBlob(url) {
} }
export async function fetchBlobURL(url) { export async function fetchBlobURL(url) {
return await URL.createObjectURL(await fetchBlob(url)); return URL.createObjectURL(await fetchBlob(url));
} }
export function getMimeType(data) { export function getMimeType(data) {

View file

@ -133,14 +133,14 @@ export class Visualizer {
this._currentContextType = type; this._currentContextType = type;
} }
start() { async start() {
if (this.isActive) return; if (this.isActive) return;
if (!this.ctx) { if (!this.ctx) {
this.initContext(); this.initContext();
} }
if (!this.audioContext) { if (!this.audioContext) {
this.init(); await this.init();
} }
if (!this.analyser) { if (!this.analyser) {

View file

@ -92,7 +92,7 @@ export function onButterchurnPresetsLoaded(callback) {
} }
// Start loading presets immediately when module is imported (lazy loaded) // Start loading presets immediately when module is imported (lazy loaded)
loadPresetsModule(); loadPresetsModule().catch(console.error);
export class ButterchurnPreset { export class ButterchurnPreset {
constructor() { constructor() {
@ -191,7 +191,7 @@ export class ButterchurnPreset {
/** /**
* Initialize Butterchurn with the given WebGL context * Initialize Butterchurn with the given WebGL context
*/ */
init(canvas, gl, audioContext, sourceNode) { init(canvas, _gl, audioContext, sourceNode) {
if (this.isInitialized) return; if (this.isInitialized) return;
try { try {
@ -418,7 +418,7 @@ export class ButterchurnPreset {
/** /**
* Main draw function called each animation frame * Main draw function called each animation frame
*/ */
draw(ctx, canvas, analyser, dataArray, params) { draw(_ctx, canvas, _analyser, _dataArray, params) {
if (!this.isInitialized) { if (!this.isInitialized) {
return; return;
} }

View file

@ -125,7 +125,7 @@ export class KawarpPreset {
if (this.kawarp) this.kawarp.resize(); if (this.kawarp) this.kawarp.resize();
} }
draw(ctx, canvas, analyser, dataArray, stats) { draw(_ctx, canvas, analyser, _dataArray, stats) {
if (!this.kawarp || !this.isInitialized) return; if (!this.kawarp || !this.isInitialized) return;
this._ensureStarted(); this._ensureStarted();

View file

@ -14,7 +14,7 @@ export class ParticlesPreset {
// No cleanup needed // No cleanup needed
} }
draw(ctx, canvas, analyser, dataArray, params) { draw(ctx, canvas, _analyser, _dataArray, params) {
const { width, height } = canvas; const { width, height } = canvas;
const { kick, intensity, primaryColor, mode } = params; const { kick, intensity, primaryColor, mode } = params;
const sensitivity = params.sensitivity || 1.0; const sensitivity = params.sensitivity || 1.0;

15316
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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)", "description": "[<img src=\"https://github.com/monochrome-music/monochrome/blob/main/assets/512.png?raw=true\" alt=\"Monochrome Logo\">](https://monochrome.tf)",
"scripts": { "scripts": {
"dev": "vite", "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); }\"", "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", "preview": "vite preview",
"lint:js": "eslint .", "lint:js": "eslint .",
@ -28,19 +33,23 @@
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.2.0", "@capacitor/cli": "^8.2.0",
"@testing-library/dom": "^10.4.1",
"@types/node": "^25.3.5", "@types/node": "^25.3.5",
"@vitest/browser-playwright": "^4.1.2",
"eslint": "^9.39.3", "eslint": "^9.39.3",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"formidable": "^3.5.4", "formidable": "^3.5.4",
"globals": "^17.4.0", "globals": "^17.4.0",
"htmlhint": "^1.9.2", "htmlhint": "^1.9.2",
"miniflare": "^4.20260301.1", "miniflare": "^4.20260301.1",
"playwright": "^1.58.2",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"stylelint": "^16.26.1", "stylelint": "^17.6.0",
"stylelint-config-standard": "^39.0.1", "stylelint-config-standard": "^39.0.1",
"stylelint-config-standard-scss": "^16.0.0", "stylelint-config-standard-scss": "^16.0.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.2.0"
}, },
"overrides": { "overrides": {
@ -54,13 +63,16 @@
"@capacitor/core": "^8.2.0", "@capacitor/core": "^8.2.0",
"@capacitor/haptics": "^8.0.1", "@capacitor/haptics": "^8.0.1",
"@capacitor/ios": "^8.2.0", "@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/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2", "@ffmpeg/util": "^0.12.2",
"@kawarp/core": "^1.1.1", "@kawarp/core": "^1.1.1",
"@svta/common-media-library": "^0.18.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", "@uimaxbai/am-lyrics": "^1.1.4",
"@vitest/web-worker": "^4.1.2",
"appwrite": "^23.0.0", "appwrite": "^23.0.0",
"butterchurn": "^2.6.7", "butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7", "butterchurn-presets": "^2.4.7",
@ -78,7 +90,9 @@
"shaka-player": "^5.0.7", "shaka-player": "^5.0.7",
"simple-icons": "^16.12.0", "simple-icons": "^16.12.0",
"svgo": "^4.0.1", "svgo": "^4.0.1",
"typescript-eslint": "^8.57.2",
"url-toolkit": "^2.2.5", "url-toolkit": "^2.2.5",
"uuid": "^13.0.0" "uuid": "^13.0.0",
"vitest": "^4.1.2"
} }
} }

View 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"] }
}
]

View 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"
}
]

View file

@ -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", "type": "album",
"id": 510893864, "id": 510893864,
"title": "BULLY", "title": "BULLY",
"artist": { "id": 25022, "name": "Kanye West" }, "artist": {
"id": 25022,
"name": "Kanye West"
},
"releaseDate": "2026-03-28", "releaseDate": "2026-03-28",
"cover": "cf2f2c9c-ff67-44f6-83aa-a7622f8c6b64", "cover": "cf2f2c9c-ff67-44f6-83aa-a7622f8c6b64",
"explicit": true, "explicit": true,
"audioQuality": "LOSSLESS", "audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] } "mediaMetadata": {
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
}
}, },
{ {
"type": "album", "type": "album",
"id": 403172104, "id": 325723583,
"title": "From A Man's Perspective", "title": "Replica",
"artist": { "id": 3561564, "name": "Dax" }, "artist": {
"releaseDate": "2024-12-06", "id": 3715530,
"cover": "c52a53ea-f021-44bf-8cef-fb31d3b82940", "name": "Oneohtrix Point Never"
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
}, },
{ "releaseDate": "2011-11-05",
"type": "album", "cover": "95ceeae9-cac7-42dc-ae37-7c93c223f809",
"id": 341529881,
"title": "FACTS",
"artist": { "id": 5691796, "name": "Tom MacDonald" },
"releaseDate": "2024-01-26",
"cover": "34198718-cc7d-47eb-9bf1-1dc5c26fc8a1",
"explicit": false, "explicit": false,
"audioQuality": "LOSSLESS", "audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] } "mediaMetadata": {
"tags": ["LOSSLESS"]
}
}, },
{ {
"type": "album", "type": "album",
"id": 452163300, "id": 336178142,
"title": "Tackle Box", "title": "Pirate This Album",
"artist": { "id": 44771714, "name": "JamWayne" }, "artist": {
"releaseDate": "2025-09-01", "id": 8622751,
"cover": "bf7d2e55-52dc-4e41-9f53-1db31918798e", "name": "Shamana"
},
"releaseDate": "2023-12-25",
"cover": "a8a647be-0331-4779-9a6e-31645a9abdab",
"explicit": true, "explicit": true,
"audioQuality": "LOSSLESS", "audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] } "mediaMetadata": {
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
}
}, },
{ {
"type": "album", "type": "album",
"id": 106210035, "id": 106369871,
"title": "Supermarket (Soundtrack)", "title": "Organic Thoughts from the Synthetic Mind",
"artist": { "id": 3533999, "name": "LOGIC" }, "artist": {
"releaseDate": "2019-03-26", "id": 6436013,
"cover": "bdd39738-7177-4836-bd7f-cd4fe4ccf535", "name": "Shinjuku Mad"
},
"releaseDate": "2009-07-01",
"cover": "3acc888e-35da-40a8-a4b7-7ffd00576cc9",
"explicit": true, "explicit": true,
"audioQuality": "LOSSLESS", "audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] } "mediaMetadata": {
"tags": ["LOSSLESS"]
}
}, },
{ {
"type": "album", "type": "album",
"id": 138458381, "id": 423471869,
"title": "JOKER", "title": "pain",
"artist": { "id": 39109746, "name": "Dax" }, "artist": {
"releaseDate": "2020-05-06", "id": 44257324,
"cover": "d6a39491-fece-4594-a538-9cfbce7c6c68", "name": "bleood"
},
"releaseDate": "2025-03-11",
"cover": "711b23ba-c473-44e6-a2f0-010fefa9c5b8",
"explicit": true, "explicit": true,
"audioQuality": "LOSSLESS", "audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] } "mediaMetadata": {
"tags": ["LOSSLESS"]
}
}, },
{ {
"type": "album", "type": "album",
"id": 318467376, "id": 250986538,
"title": "The Draco Tape 2", "title": "Revolutionary, Vol. 1 (Bonus Edition)",
"artist": { "id": 22467779, "name": "60PERCENTHOMO" }, "artist": {
"releaseDate": "2023-09-30", "id": 3604583,
"cover": "9169e8ab-fdb0-49d1-a14f-82a2b541223c", "name": "Immortal Technique"
},
"releaseDate": "2001-09-14",
"cover": "e510dd6d-dcdf-4272-9c68-f4580f2fbd14",
"explicit": true, "explicit": true,
"audioQuality": "LOSSLESS", "audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] } "mediaMetadata": {
"tags": ["LOSSLESS"]
}
}, },
{ {
"type": "album", "type": "album",
"id": 456582219, "id": 509761344,
"title": "bbno$", "title": "EMOTIONS",
"artist": { "id": 8173944, "name": "bbno$" }, "artist": {
"releaseDate": "2025-10-17", "id": 49124576,
"cover": "8162c739-4bb2-4218-8915-93cb1f0d9eea", "name": "Nine Vicious"
},
"releaseDate": "2026-04-03",
"cover": "f29b18d3-b19f-45b1-968a-0ad360647130",
"explicit": true, "explicit": true,
"audioQuality": "LOSSLESS", "audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] } "mediaMetadata": {
"tags": ["LOSSLESS"]
}
}, },
{ {
"type": "album", "type": "album",
"id": 506216548, "id": 15621057,
"title": "Declassified", "title": "Triple F Life: Friends, Fans & Family (Deluxe Version)",
"artist": { "id": 5691796, "name": "Tom MacDonald" }, "artist": {
"releaseDate": "2026-03-13", "id": 3654061,
"cover": "5cf188e5-1660-4a01-bacc-10ca81e7af73", "name": "Waka Flocka Flame"
},
"releaseDate": "2012-06-12",
"cover": "3199b7de-5e3d-486c-acf1-870ff4c60572",
"explicit": true, "explicit": true,
"audioQuality": "LOSSLESS", "audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] } "mediaMetadata": {
"tags": ["LOSSLESS"]
}
}, },
{ {
"type": "album", "type": "album",
"id": 486673299, "id": 103897783,
"title": "Novelty", "title": "Freewave 3",
"artist": { "id": 3589848, "name": "Goofy" }, "artist": {
"releaseDate": "2026-01-23", "id": 7923685,
"cover": "dd543595-e604-440e-a24c-e2eaccda3804", "name": "Lucki"
},
"releaseDate": "2019-02-15",
"cover": "1d481a33-8b20-4ee3-b04b-5ac6e0fc5e78",
"explicit": true, "explicit": true,
"audioQuality": "LOSSLESS", "audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["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"] }
} }
] ]

1748
styles.css

File diff suppressed because it is too large Load diff

View file

@ -5,4 +5,4 @@ async function test() {
const json = await res.json(); const json = await res.json();
console.log(JSON.stringify(json.data || {})); console.log(JSON.stringify(json.data || {}));
} }
test(); void test();

22
tsconfig-eslint.json Normal file
View 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/*"]
}

View file

@ -4,7 +4,7 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable", "webworker"], "lib": ["ESNext", "DOM", "DOM.Iterable", "webworker"],
"types": ["vite/client", "node"], "types": ["vite/client", "node", "@types/wicg-file-system-access"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"!/*": ["node_modules/*"] "!/*": ["node_modules/*"]
@ -17,5 +17,6 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["js/**/*.ts", "js/**/*.d.ts"] "include": ["**/*.ts", "*.ts", "**/*.js", "*.js"],
"exclude": ["**/node_modules/*"]
} }

View file

@ -1,6 +1,5 @@
import { loadEnv } from 'vite'; import { loadEnv } from 'vite';
import cookieSession from 'cookie-session'; import cookieSession from 'cookie-session';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { readFileSync, existsSync } from 'fs'; import { readFileSync, existsSync } from 'fs';
import { join, extname } from 'path'; import { join, extname } from 'path';

View file

@ -1,7 +1,7 @@
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { gzipSync, constants as zlibConstants } from 'zlib'; import { gzipSync, constants as zlibConstants } from 'zlib';
import type { Plugin } from 'vite'; import type { Plugin, ResolvedConfig } from 'vite';
import mime from 'mime'; import mime from 'mime';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
@ -26,10 +26,14 @@ function hashString(input: string, algorithm = 'sha256'): string {
*/ */
export default function blobAssetPlugin(): Plugin { export default function blobAssetPlugin(): Plugin {
const devAssets = new Map<string, Buffer>(); const devAssets = new Map<string, Buffer>();
let resolvedConfig: ResolvedConfig | null = null;
return { return {
name: 'vite-blob-asset', name: 'vite-blob-asset',
async configResolved(config: ResolvedConfig) {
resolvedConfig = config;
},
async load(id) { async load(id) {
if (!id.includes('?blob-url')) return; if (!id.includes('?blob-url')) return;
@ -45,7 +49,7 @@ export default function blobAssetPlugin(): Plugin {
let assetUrl: string; let assetUrl: string;
if (this.meta.watchMode) { if (resolvedConfig?.command === 'serve') {
/** dev server path */ /** dev server path */
assetUrl = `/@blob/${hashString(absPath)}/${path.basename(filepath)}.gz`; assetUrl = `/@blob/${hashString(absPath)}/${path.basename(filepath)}.gz`;
devAssets.set(assetUrl, compressed); devAssets.set(assetUrl, compressed);
@ -106,7 +110,7 @@ export default function getBlobUrl() {
chunk.code = chunk.code.replace( chunk.code = chunk.code.replace(
/"__BLOB_ASSET_(.*?)__"/g, /"__BLOB_ASSET_(.*?)__"/g,
(_, refId) => `"${this.getFileName(refId)}"` (_, refId: string) => `"${this.getFileName(refId)}"`
); );
} }
}, },

View file

@ -1,4 +1,4 @@
import { normalizePath, Plugin } from 'vite'; import { normalizePath, type Plugin, type ResolvedConfig } from 'vite';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { optimize } from 'svgo'; import { optimize } from 'svgo';
@ -30,7 +30,7 @@ function parseAttrs(str: string): Record<string, string> {
* Merge attributes into root <svg> * Merge attributes into root <svg>
*/ */
function mergeSvgAttributes(svg: string, attrs: Record<string, string>) { 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 // Size is shorthand for setting both width and height to the same value
if (attrs['size']) { if (attrs['size']) {
attrs['width'] = attrs['size']; attrs['width'] = attrs['size'];
@ -40,7 +40,7 @@ function mergeSvgAttributes(svg: string, attrs: Record<string, string>) {
const map = new Map<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); map.set(name, value);
} }
@ -104,7 +104,7 @@ function loadSvg<S extends boolean = true, T = S extends true ? string : Promise
* Main plugin * Main plugin
*/ */
export default function viteSvgUsePlugin(): Plugin { export default function viteSvgUsePlugin(): Plugin {
let config: any; let config: ResolvedConfig;
const watched = new Set<string>(); const watched = new Set<string>();
/** /**
@ -117,10 +117,8 @@ export default function viteSvgUsePlugin(): Plugin {
} }
// Check for alias // Check for alias
if (config && config.resolve && config.resolve.alias) { if (config && config.resolve && config.resolve.alias) {
for (const [_, { find, replacement }] of Object.entries<{ find: string; replacement: string }>( for (const [_, { find, replacement }] of config.resolve.alias.entries()) {
config.resolve.alias if (typeof find === 'string' ? src.startsWith(find) : find.test(src)) {
)) {
if (src.startsWith(find)) {
// Remove alias prefix and resolve // Remove alias prefix and resolve
const aliasedPath = src.replace(find, replacement); const aliasedPath = src.replace(find, replacement);
return normalizePath(path.resolve(root, aliasedPath.replace(/^\//, ''))); return normalizePath(path.resolve(root, aliasedPath.replace(/^\//, '')));
@ -144,7 +142,9 @@ export default function viteSvgUsePlugin(): Plugin {
transformIndexHtml: { transformIndexHtml: {
order: 'pre', order: 'pre',
async handler(html, ctx) { async handler(html, ctx) {
return html.replace(SVG_USE_REGEX, (full, before, src, after) => { return html.replace(
SVG_USE_REGEX,
(_full, before: string | undefined, src: string | undefined, after: string | undefined) => {
const attrs = { const attrs = {
...parseAttrs(before || ''), ...parseAttrs(before || ''),
...parseAttrs(after || ''), ...parseAttrs(after || ''),
@ -160,7 +160,8 @@ export default function viteSvgUsePlugin(): Plugin {
svg = mergeSvgAttributes(optimize(svg).data, attrs); svg = mergeSvgAttributes(optimize(svg).data, attrs);
return svg; return svg;
}); }
);
}, },
}, },

View file

@ -6,6 +6,7 @@ import uploadPlugin from './vite-plugin-upload.js';
import blobAssetPlugin from './vite-plugin-blob.js'; import blobAssetPlugin from './vite-plugin-blob.js';
import svgUse from './vite-plugin-svg-use.js'; import svgUse from './vite-plugin-svg-use.js';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { playwright } from '@vitest/browser-playwright';
function getGitCommitHash() { function getGitCommitHash() {
try { try {
@ -15,13 +16,23 @@ function getGitCommitHash() {
} }
} }
export default defineConfig(({ mode }) => { export default defineConfig((_options) => {
const commitHash = getGitCommitHash(); const commitHash = getGitCommitHash();
return { return {
test: {
// https://vitest.dev/guide/browser/
browser: {
enabled: true,
provider: playwright(),
headless: !!process.env.HEADLESS,
instances: [{ browser: 'chromium' }],
},
},
base: './', base: './',
define: { define: {
__COMMIT_HASH__: JSON.stringify(commitHash), __COMMIT_HASH__: JSON.stringify(commitHash),
__VITEST__: !!process.env.VITEST,
}, },
worker: { worker: {
format: 'es', format: 'es',