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": "..",
"dockerfile": "./Dockerfile"
},
"postCreateCommand": "git config --local core.editor \"code --wait\" && git config --local commit.gpgsign false && npm install && bun install",
"postCreateCommand": "git config --local core.editor \"code --wait\" && git config --local commit.gpgsign false && bun install",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "anthropic.claude-code"]

62
.github/ISSUE_TEMPLATE/BUG-REPORT.yml vendored Normal file
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
with:
fetch-depth: 1
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
ref: ${{ github.head_ref || github.ref }}
- name: Setup Bun

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

6
.gitignore vendored
View file

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

3
.vscode/extensions.json vendored Normal file
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.
> **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

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 globals from 'globals';
import prettierConfig from 'eslint-config-prettier';
import tsParser from '@typescript-eslint/parser';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default [
export default defineConfig(
{
ignores: ['**/dist/**', '**/node_modules/**', '**/legacy/**', '**/bin/**', '**/www/**', '**/public/lib/**'],
},
js.configs.recommended,
prettierConfig,
tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parser: tsParser, // 👈 REQUIRED
parserOptions: {
project: './tsconfig-eslint.json', // 👈 REQUIRED
},
ecmaVersion: 2022,
sourceType: 'module',
globals: {
@ -18,8 +26,38 @@ export default [
},
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-console': ['warn', { allow: ['log', 'warn', 'error'] }],
'@typescript-eslint/no-unused-vars': [
'warn',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'no-console': ['warn', { allow: ['log', 'warn', 'error', 'debug', 'trace', 'time', 'timeEnd'] }],
'no-empty': ['error', { allowEmptyCatch: true }],
'no-async-promise-executor': 'warn',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'off',
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-namespace': 'off',
},
},
];
{
files: ['**/*.js'],
rules: {
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/prefer-promise-reject-errors': 'off',
},
}
);

34
functions/about/index.js Normal file
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 author = feed.author || feed.ownerName || '';
const episodeCount = feed.episodeCount || 0;
const rawDescription = feed.description || '';
const _rawDescription = feed.description || '';
const description = author
? `Podcast by ${author}${episodeCount} Episodes\nListen on Monochrome`
: `Podcast • ${episodeCount} Episodes\nListen on Monochrome`;

34
functions/recent/index.js Normal file
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 PUBLIC_COLLECTION = 'public_playlists';
function safeParseTracks(tracksData) {
if (!tracksData) return [];
if (Array.isArray(tracksData)) return tracksData;
if (typeof tracksData === 'string') {
try {
return JSON.parse(tracksData);
} catch {
return [];
}
}
return [];
}
function parseDuration(durationStr) {
if (!durationStr || durationStr === 'N/A' || typeof durationStr !== 'string') return 0;
const parts = durationStr.split(':');
if (parts.length === 2) {
return parseInt(parts[0]) * 60 + parseInt(parts[1]);
}
if (parts.length === 3) {
return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]);
}
return 0;
}
function calculatePlaylistDuration(tracks) {
let totalSeconds = 0;
for (const track of tracks) {
const duration = track.duration || track.durationSeconds || 0;
if (typeof duration === 'number') {
totalSeconds += duration;
} else if (typeof duration === 'string') {
totalSeconds += parseDuration(duration);
}
}
return totalSeconds;
}
function formatDuration(seconds) {
if (!seconds || seconds <= 0) return '0 min';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours} hr ${minutes} min`;
}
return `${minutes} min`;
}
export async function onRequest(context) {
const { request, params, env } = context;
const userAgent = request.headers.get('User-Agent') || '';
@ -37,14 +85,10 @@ export async function onRequest(context) {
(extraData && (extraData.title || extraData.name)) ||
'Untitled Playlist';
let tracks = [];
try {
tracks = record.tracks ? JSON.parse(record.tracks) : [];
} catch {
tracks = [];
}
let tracks = safeParseTracks(record.tracks);
const trackCount = tracks.length;
const totalDuration = calculatePlaylistDuration(tracks);
const durationStr = formatDuration(totalDuration);
let rawCover = record.image || record.cover || record.playlist_cover || '';
if (!rawCover && extraData && typeof extraData === 'object') {
@ -70,7 +114,7 @@ export async function onRequest(context) {
imageUrl = 'https://monochrome.tf/assets/appicon.png';
}
const description = `Playlist • ${trackCount} Tracks\nListen on Monochrome`;
const description = `Playlist • ${trackCount} Tracks${durationStr}\nListen on Monochrome`;
const pageUrl = new URL(request.url).href;
const metaHtml = `

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

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

View file

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

View file

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

View file

@ -128,7 +128,7 @@ const syncManager = {
}
},
safeParseInternal(str, fieldName, fallback) {
safeParseInternal(str, _fieldName, fallback) {
if (!str) return fallback;
if (typeof str !== 'string') return str;
try {
@ -136,7 +136,7 @@ const syncManager = {
} catch {
try {
// Recovery attempt: replace illegal internal quotes in name/title fields
const recovered = str.replace(/(:\s*")(.+?)("(?=\s*[,}\n\r]))/g, (match, p1, p2, p3) => {
const recovered = str.replace(/(:\s*")(.+?)("(?=\s*[,}\n\r]))/g, (_match, p1, p2, p3) => {
const escapedContent = p2.replace(/(?<!\\)"/g, '\\"');
return p1 + escapedContent + p3;
});
@ -156,6 +156,8 @@ const syncManager = {
(jsFriendly.trim().startsWith('[') || jsFriendly.trim().startsWith('{')) &&
!jsFriendly.match(/function|=>|window|document|alert|eval/)
) {
// TODO: maybe this could be parsed as json5?
// eslint-disable-next-line @typescript-eslint/no-implied-eval
return new Function('return ' + jsFriendly)();
}
}
@ -565,11 +567,6 @@ const syncManager = {
if (cloudData) {
let database = db;
if (typeof database === 'function') {
database = await database();
} else {
database = await database;
}
const localData = {
tracks: (await database.getAll('favorites_tracks')) || [],

View file

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

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

181
js/app.js
View file

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

View file

@ -239,9 +239,10 @@ class AudioContextManager {
// Create biquad filters for each frequency band
this.filters = this.frequencies.map((freq, index) => {
const filter = this.audioContext.createBiquadFilter();
filter.type = 'peaking';
filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
filter.frequency.value = freq;
filter.Q.value = this._calculateQ(index);
filter.Q.value =
this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
filter.gain.value = this.currentGains[index] || 0;
return filter;
});
@ -312,10 +313,10 @@ class AudioContextManager {
try {
this.audioContext = new AudioContext(highResOptions);
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
} catch (e) {
} catch {
try {
this.audioContext = new AudioContext({ latencyHint: 'playback' });
} catch (e2) {
} catch {
this.audioContext = new AudioContext();
}
}
@ -358,7 +359,9 @@ class AudioContextManager {
if (this.source) {
try {
this.source.disconnect();
} catch (e) {}
} catch {
// node may already be disconnected
}
}
this.audio = audioElement;
@ -386,7 +389,9 @@ class AudioContextManager {
// Disconnect everything first
try {
this.source.disconnect();
} catch (e) {}
} catch {
// node may already be disconnected
}
this.outputNode.disconnect();
if (this.volumeNode) {
this.volumeNode.disconnect();
@ -405,16 +410,23 @@ class AudioContextManager {
// Apply mono audio if enabled
if (this.isMonoAudioEnabled && this.monoMergerNode) {
// Create a gain node to mix channels before the merger
const monoGain = this.audioContext.createGain();
monoGain.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
// Reuse persistent gain node to avoid leaking AudioNodes
if (!this.monoGainNode) {
this.monoGainNode = this.audioContext.createGain();
this.monoGainNode.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
}
try {
this.monoGainNode.disconnect();
} catch {
/* not connected */
}
// Connect source to mono gain
this.source.connect(monoGain);
this.source.connect(this.monoGainNode);
// Connect mono gain to both inputs of the merger
monoGain.connect(this.monoMergerNode, 0, 0);
monoGain.connect(this.monoMergerNode, 0, 1);
this.monoGainNode.connect(this.monoMergerNode, 0, 0);
this.monoGainNode.connect(this.monoMergerNode, 0, 1);
lastNode = this.monoMergerNode;
console.log('[AudioContext] Mono audio enabled');
@ -573,6 +585,57 @@ class AudioContextManager {
return equalizerSettings.getRange();
}
/**
* Calculate biquad filter magnitude response in dB at a given frequency
*/
_biquadResponseDb(f, band, sr) {
if (!band.enabled || !band.type) return 0;
const w = (2 * Math.PI * band.freq) / sr;
const p = (2 * Math.PI * f) / sr;
const s = Math.sin(w) / (2 * band.q);
const A = Math.pow(10, band.gain / 40);
const c = Math.cos(w);
let b0, b1, b2, a0, a1, a2;
const t = band.type[0];
if (t === 'p') {
b0 = 1 + s * A;
b1 = -2 * c;
b2 = 1 - s * A;
a0 = 1 + s / A;
a1 = -2 * c;
a2 = 1 - s / A;
} else if (t === 'l') {
const sq = 2 * Math.sqrt(A) * s;
b0 = A * (A + 1 - (A - 1) * c + sq);
b1 = 2 * A * (A - 1 - (A + 1) * c);
b2 = A * (A + 1 - (A - 1) * c - sq);
a0 = A + 1 + (A - 1) * c + sq;
a1 = -2 * (A - 1 + (A + 1) * c);
a2 = A + 1 + (A - 1) * c - sq;
} else if (t === 'h') {
const sq = 2 * Math.sqrt(A) * s;
b0 = A * (A + 1 + (A - 1) * c + sq);
b1 = -2 * A * (A - 1 + (A + 1) * c);
b2 = A * (A + 1 + (A - 1) * c - sq);
a0 = A + 1 - (A - 1) * c + sq;
a1 = 2 * (A - 1 - (A + 1) * c);
a2 = A + 1 - (A - 1) * c - sq;
} else {
return 0;
}
const _a0 = 1 / a0;
const b0n = b0 * _a0,
b1n = b1 * _a0,
b2n = b2 * _a0;
const a1n = a1 * _a0,
a2n = a2 * _a0;
const cp = Math.cos(p),
c2p = Math.cos(2 * p);
const n = b0n * b0n + b1n * b1n + b2n * b2n + 2 * (b0n * b1n + b1n * b2n) * cp + 2 * b0n * b2n * c2p;
const d = 1 + a1n * a1n + a2n * a2n + 2 * (a1n + a1n * a2n) * cp + 2 * a2n * c2p;
return 10 * Math.log10(n / d);
}
/**
* Clamp gain to valid range
*/
@ -665,8 +728,11 @@ class AudioContextManager {
this.isEQEnabled = equalizerSettings.isEnabled();
this.bandCount = equalizerSettings.getBandCount();
this.freqRange = equalizerSettings.getFreqRange();
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
const customFreqs = equalizerSettings.getCustomFrequencies(this.bandCount);
this.frequencies = customFreqs || generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
this.currentGains = equalizerSettings.getGains(this.bandCount);
this.currentTypes = equalizerSettings.getBandTypes(this.bandCount);
this.currentQs = equalizerSettings.getBandQs(this.bandCount);
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
this.preamp = equalizerSettings.getPreamp();
}
@ -697,7 +763,89 @@ class AudioContextManager {
}
/**
* Called when the app enters the background (screen lock, app switch).
* Apply AutoEQ-generated bands to the equalizer
* Unlike regular presets, AutoEQ bands have specific frequencies, gains, and Q values
* @param {Array<{id: number, type: string, freq: number, gain: number, q: number, enabled: boolean}>} bands
* @returns {string} Exported text representation of the applied EQ
*/
applyAutoEQBands(bands, skipPreamp = false) {
if (!bands || bands.length === 0) return '';
const enabledBands = bands.filter((b) => b.enabled);
const count = Math.max(equalizerSettings.MIN_BANDS, Math.min(equalizerSettings.MAX_BANDS, enabledBands.length));
// Calculate preamp: negative of cumulative peak gain across all bands to prevent clipping
let cumulativePeak = 0;
if (!skipPreamp) {
const sr = this.audioContext?.sampleRate ?? 48000;
// Sweep log-spaced frequencies (24 points/octave from 20-20kHz) to catch narrow peaks
for (let f = 20; f <= 20000; f *= Math.pow(2, 1 / 24)) {
let sum = 0;
for (const b of enabledBands) {
sum += this._biquadResponseDb(f, b, sr);
}
if (sum > cumulativePeak) cumulativePeak = sum;
}
}
const preamp = skipPreamp
? equalizerSettings.getPreamp()
: cumulativePeak > 0
? -Math.round(cumulativePeak * 10) / 10
: 0;
// Sort bands by frequency so index order is deterministic
const sortedBands = [...enabledBands].sort((a, b) => a.freq - b.freq);
// Build normalized band descriptor arrays
const newFrequencies = sortedBands
.slice(0, count)
.map((b) => Math.round(Math.min(b.freq, (this.audioContext?.sampleRate ?? 48000) / 2 - 1)));
const newTypes = sortedBands.slice(0, count).map((b) => b.type || 'peaking');
const newQs = sortedBands.slice(0, count).map((b) => b.q);
const newGains = sortedBands.slice(0, count).map((b) => this._clampGain(b.gain));
// Update band count via class setter to trigger equalizer-band-count-changed event
if (count !== this.bandCount) {
this.setBandCount(count);
}
// Override frequencies, types, and Qs with band-specific values
this.frequencies = newFrequencies;
this.currentTypes = newTypes;
this.currentQs = newQs;
this.currentGains = newGains;
// Rebuild EQ so _createEQ picks up the new types/Qs
if (this.isInitialized && this.audioContext) {
this._destroyEQ();
this._createEQ();
this._connectGraph();
}
// Apply preamp (skip if caller manages preamp externally)
if (!skipPreamp) {
this.setPreamp(preamp);
}
// Persist normalized band descriptors to settings store
equalizerSettings.setCustomFrequencies(this.frequencies);
equalizerSettings.setGains(this.currentGains);
equalizerSettings.setBandTypes(this.currentTypes);
equalizerSettings.setBandQs(this.currentQs);
// Generate export text using the actual applied preamp value
const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`];
sortedBands.forEach((band, index) => {
if (index >= count) return;
const filterType = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK';
lines.push(
`Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
);
});
return lines.join('\n');
}
/**
* Export equalizer settings to text format
* @returns {string} Exported settings in text format
@ -709,8 +857,13 @@ class AudioContextManager {
this.frequencies.forEach((freq, index) => {
const gain = this.currentGains[index] || 0;
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
const filterType = type === 'lowshelf' ? 'LS' : type === 'highshelf' ? 'HS' : 'PK';
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
const filterNum = index + 1;
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`);
lines.push(
`Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`
);
});
return lines.join('\n');
@ -760,24 +913,42 @@ class AudioContextManager {
this.setPreamp(preamp);
// If different number of bands, adjust
if (filters.length !== this.bandCount) {
const newCount = Math.max(
equalizerSettings.MIN_BANDS,
Math.min(equalizerSettings.MAX_BANDS, filters.length)
);
const newCount = Math.max(
equalizerSettings.MIN_BANDS,
Math.min(equalizerSettings.MAX_BANDS, filters.length)
);
if (newCount !== this.bandCount) {
this.setBandCount(newCount);
}
// Extract gains from filters
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
this.setAllGains(gains);
// Apply per-band frequencies, types, Qs, and gains from import
const sliced = filters.slice(0, this.bandCount);
const typeMap = {
PK: 'peaking',
LS: 'lowshelf',
LSC: 'lowshelf',
LSF: 'lowshelf',
HS: 'highshelf',
HSC: 'highshelf',
HSF: 'highshelf',
};
this.frequencies = sliced.map((f) => f.freq);
this.currentTypes = sliced.map((f) => typeMap[f.type] || 'peaking');
this.currentQs = sliced.map((f) => f.q);
this.currentGains = sliced.map((f) => this._clampGain(f.gain));
// Store filter frequencies if different
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
// Rebuild EQ chain to apply new frequencies, types, and Qs
if (this.isInitialized && this.audioContext) {
this._destroyEQ();
this._createEQ();
this._connectGraph();
}
// Persist all band settings
equalizerSettings.setGains(this.currentGains);
equalizerSettings.setBandTypes(this.currentTypes);
equalizerSettings.setBandQs(this.currentQs);
return true;
} catch (e) {
console.warn('[AudioContext] Failed to import EQ settings:', e);

4394
js/autoeq-data.js Normal file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,8 +17,9 @@ import { ZipStreamWriter, ZipBlobWriter, FolderPickerWriter, SequentialFileWrite
import { FfmpegProgress } from './ffmpeg.types.js';
import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './progressEvents.js';
import { db } from './db.js';
import { modernSettings } from './ModernSettings.js';
import { BulkDownloadMethod, modernSettings } from './ModernSettings.js';
import { SVG_CLOSE } from './icons.ts';
import { MusicAPI } from './music-api.js';
import { LyricsManager } from './lyrics.js';
const downloadTasks = new Map();
@ -166,7 +167,7 @@ export function showNotification(message) {
}, 1500);
}
export function addDownloadTask(trackId, track, filename, api, abortController) {
export function addDownloadTask(trackId, track, _filename, api, abortController) {
const container = createDownloadNotification();
const taskEl = document.createElement('div');
@ -507,7 +508,7 @@ async function createSingleTrackFolderWriter() {
const method = modernSettings.bulkDownloadMethod;
const hasFolderPicker = 'showDirectoryPicker' in window;
if (method === 'local') {
if (method === BulkDownloadMethod.LocalMedia) {
const localHandle = await db.getSetting('local_folder_handle');
if (hasFolderPicker && localHandle && typeof localHandle.requestPermission === 'function') {
try {
@ -520,7 +521,7 @@ async function createSingleTrackFolderWriter() {
return null;
}
if (method === 'folder' && hasFolderPicker) {
if (method === BulkDownloadMethod.Folder && hasFolderPicker) {
const rememberFolder = modernSettings.rememberBulkDownloadFolder;
const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null;
// Try to reuse the saved handle silently first.
@ -563,7 +564,7 @@ async function createBulkWriter(folderName) {
const hasFolderPicker = 'showDirectoryPicker' in window;
// ── Local Media Folder method ────────────────────────────────────────────
if (method === 'local') {
if (method === BulkDownloadMethod.LocalMedia) {
const localHandle = await db.getSetting('local_folder_handle');
if (hasFolderPicker) {
// Browser mode: try to reuse the stored handle with write permission
@ -593,7 +594,7 @@ async function createBulkWriter(folderName) {
}
// ── Folder Picker method ─────────────────────────────────────────────────
if (method === 'folder' && hasFolderPicker) {
if (method === BulkDownloadMethod.Folder && hasFolderPicker) {
const rememberFolder = modernSettings.rememberBulkDownloadFolder;
const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null;
try {
@ -613,7 +614,7 @@ async function createBulkWriter(folderName) {
}
}
if (method === 'individual') {
if (method === BulkDownloadMethod.Individual) {
return SequentialFileWriter;
}
// method === 'zip' (or folder picker unavailable as fallback)
@ -658,7 +659,7 @@ async function startBulkDownload({
completeBulkDownload(notification, true);
// If the download went to the local media folder, refresh the local library.
if (modernSettings.bulkDownloadMethod === 'local') {
if (modernSettings.bulkDownloadMethod === BulkDownloadMethod.LocalMedia) {
window.refreshLocalMediaFolder?.();
}
} catch (error) {
@ -671,7 +672,7 @@ async function startBulkDownload({
}
}
export async function downloadTracks(tracks, api, quality, lyricsManager = null) {
export async function downloadTracks(tracks, api, quality, _lyricsManager = null) {
const folderName = `Queue - ${new Date().toISOString().slice(0, 10)}`;
await startBulkDownload({
tracks,
@ -686,7 +687,7 @@ export async function downloadTracks(tracks, api, quality, lyricsManager = null)
});
}
export async function downloadAlbum(album, tracks, api, quality, lyricsManager = null) {
export async function downloadAlbum(album, tracks, api, quality, _lyricsManager = null) {
const releaseDateStr =
album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
@ -711,7 +712,7 @@ export async function downloadAlbum(album, tracks, api, quality, lyricsManager =
});
}
export async function downloadPlaylist(playlist, tracks, api, quality, lyricsManager = null) {
export async function downloadPlaylist(playlist, tracks, api, quality, _lyricsManager = null) {
const folderName = formatPathTemplate(modernSettings.folderTemplate, {
albumTitle: playlist.title,
albumArtist: 'Playlist',
@ -1012,19 +1013,49 @@ function completeBulkDownload(notifEl, success = true, message = null) {
}
}
export async function downloadTrackWithMetadata(track, quality, api, lyricsManager = null, abortController = null) {
/**
* Downloads a track with metadata and optionally lyrics.
* @async
* @param {Object} track - The track object to download
* @param {string} quality - The desired audio quality for download
* @param {MusicAPI | LosslessAPI} [api=MusicAPI.instance] - The API instance to use for downloading
* @param {Object} [lyricsManager=null] - Optional manager for fetching and processing lyrics
* @param {AbortController} [abortController=null] - Optional abort controller for cancelling the download
* @returns {Promise<void>}
* @throws {Error} If the download fails (except for AbortError)
* @description
* This function:
* - Validates that a track is provided
* - Prevents duplicate downloads of the same track
* - Enriches track metadata via the API
* - Downloads the audio blob with progress tracking
* - Organizes the file into subfolders based on the folder template
* - Optionally downloads and saves lyrics in LRC format
* - Updates the local media folder cache if using LocalMedia download method
* - Handles errors gracefully and updates download task status
*/
export async function downloadTrackWithMetadata(
track,
quality,
api = MusicAPI.instance,
lyricsManager = null,
abortController = null
) {
if (!track) {
alert('No track is currently playing');
return;
}
/** @type {LosslessAPI} */
const tidalAPI = api.tidalAPI || api;
const downloadKey = `track-${track.id}`;
if (ongoingDownloads.has(downloadKey)) {
showNotification('This track is already being downloaded');
return;
}
const { enrichedTrack } = await api.tidalAPI.enrichTrack(track, { downloadQuality: quality });
const { enrichedTrack } = await tidalAPI.enrichTrack(track, { downloadQuality: quality });
const filename = buildTrackFilename(enrichedTrack, quality);
const controller = abortController || new AbortController();
@ -1089,7 +1120,7 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
// If the target is the local media folder, do a cheap partial update:
// pass the downloaded blob and base filename so only this one track's metadata
// is read and inserted into localFilesCache instead of re-walking the whole folder.
if (modernSettings.bulkDownloadMethod === 'local') {
if (modernSettings.bulkDownloadMethod === BulkDownloadMethod.LocalMedia) {
window.refreshLocalMediaFolder?.(blob, finalFilename);
}
@ -1105,7 +1136,7 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
}
}
export async function downloadLikedTracks(tracks, api, quality, lyricsManager = null) {
export async function downloadLikedTracks(tracks, api, quality, _lyricsManager = null) {
const folderName = `Liked Tracks - ${new Date().toISOString().slice(0, 10)}`;
await startBulkDownload({
tracks,

View file

@ -621,8 +621,9 @@ export class Equalizer {
this.frequencies.forEach((freq, index) => {
const gain = this.currentGains[index] || 0;
const q = this.filters[index] ? this.filters[index].Q.value : this._calculateQ(index);
const filterNum = index + 1;
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`);
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`);
});
return lines.join('\n');
@ -680,15 +681,52 @@ export class Equalizer {
this.setBandCount(newCount);
}
// Extract gains from filters
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
// Apply imported filter frequencies directly instead of regenerating
const sliced = filters.slice(0, this.bandCount);
const newFreqs = sliced.map((f) => f.freq);
this.frequencies = newFreqs;
this.frequencyLabels = generateFrequencyLabels(newFreqs);
// Update filter frequencies on the actual biquad nodes
if (this.filters.length === newFreqs.length) {
newFreqs.forEach((freq, i) => {
if (this.filters[i]) {
this.filters[i].frequency.value = freq;
}
});
}
// Extract and apply gains, types, and Qs
const gains = sliced.map((f) => f.gain);
this.setAllGains(gains);
// Store filter frequencies if different
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
// Apply filter types (PK/LS/HS -> peaking/lowshelf/highshelf)
const typeMap = { PK: 'peaking', LS: 'lowshelf', HS: 'highshelf', LSC: 'lowshelf', HSC: 'highshelf' };
const types = sliced.map((f) => typeMap[f.type] || 'peaking');
this.currentTypes = types;
if (this.filters.length === types.length) {
types.forEach((type, i) => {
if (this.filters[i]) this.filters[i].type = type;
});
}
equalizerSettings.setBandTypes(types);
// Apply Q values
const qs = sliced.map((f) => f.q);
this.currentQs = qs;
if (this.filters.length === qs.length) {
qs.forEach((q, i) => {
if (this.filters[i]) this.filters[i].Q.value = q;
});
}
equalizerSettings.setBandQs(qs);
// Persist custom frequencies and update freqRange
equalizerSettings.setCustomFrequencies(newFreqs);
const minFreq = Math.min(...newFreqs);
const maxFreq = Math.max(...newFreqs);
this.freqRange = { min: minFreq, max: maxFreq };
equalizerSettings.setFreqRange(minFreq, maxFreq);
return true;
} catch (e) {

View file

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

View file

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

19
js/ffmpeg.test.ts Normal file
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 loadingPromise = null;
@ -99,6 +99,7 @@ self.onmessage = async (e) => {
const {
audioData,
extraFiles = [],
rawArgs,
args = [],
output = {
name: 'output',
@ -123,7 +124,7 @@ self.onmessage = async (e) => {
await ffmpeg.writeFile(file.name, new Uint8Array(file.data));
}
const ffmpegArgs = ['-i', 'input', ...args, ...(output.name ? [output.name] : [])];
const ffmpegArgs = rawArgs || ['-i', 'input', ...args, ...(output.name ? [output.name] : [])];
self.postMessage({ type: 'command', command: ffmpegArgs });
const exitCode = await ffmpeg.exec(ffmpegArgs);

View file

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

8
js/global.d.ts vendored
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. */
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 _NotificationStyle = null;
// Single stored promise subsequent calls reuse the same one
// Single stored promise - subsequent calls reuse the same one
const _ready = import('@capacitor/haptics')
.then((mod) => {
_Haptics = mod.Haptics;
@ -13,14 +13,14 @@ const _ready = import('@capacitor/haptics')
_NotificationStyle = mod.NotificationStyle;
})
.catch(() => {
// Not in Capacitor or haptics not available fall back to navigator.vibrate
// Not in Capacitor or haptics not available - fall back to navigator.vibrate
});
function vibrateFallback(ms) {
if (navigator.vibrate) navigator.vibrate(ms);
}
/** Light tap for toggles, menu opens */
/** Light tap - for toggles, menu opens */
export async function hapticLight() {
await _ready;
try {
@ -32,7 +32,7 @@ export async function hapticLight() {
vibrateFallback(30);
}
/** Medium impact for play/pause, skip */
/** Medium impact - for play/pause, skip */
export async function hapticMedium() {
await _ready;
try {
@ -44,7 +44,7 @@ export async function hapticMedium() {
vibrateFallback(50);
}
/** Success notification for like/unlike, add to queue */
/** Success notification - for like/unlike, add to queue */
export async function hapticSuccess() {
await _ready;
try {
@ -56,7 +56,7 @@ export async function hapticSuccess() {
vibrateFallback(40);
}
/** Long press replaces navigator.vibrate(50) for track selection */
/** Long press - replaces navigator.vibrate(50) for track selection */
export async function hapticLongPress() {
await _ready;
try {

16
js/indexedIterator.ts Normal file
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) {
this.clearScrobbleTimer();
this.scrobbleTimer = setTimeout(() => {
this.scrobbleCurrentTrack();
this.scrobbleTimer = setTimeout(async () => {
await this.scrobbleCurrentTrack();
}, delay);
}
@ -350,9 +350,9 @@ export class LastFMScrobbler {
}
}
onTrackChange(track) {
async onTrackChange(track) {
if (!this.isAuthenticated()) return;
this.updateNowPlaying(track);
await this.updateNowPlaying(track);
}
onPlaybackStop() {

View file

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

View file

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

View file

@ -19,20 +19,24 @@ class Modal {
<h3 style="margin-bottom: 1rem; font-size: 1.5rem;">${title}</h3>
<div class="modal-body" style="margin-bottom: 2rem; color: var(--muted-foreground); line-height: 1.5;">${content}</div>
<div class="modal-actions" style="display: flex; flex-direction: column; gap: 0.75rem;">
${actions.map((a, i) => `
${actions
.map(
(a, i) => `
<button class="btn-${a.type || 'secondary'} modal-action-btn" data-index="${i}" style="width: 100%; padding: 0.8rem; font-weight: 600;">${a.label}</button>
`).join('')}
`
)
.join('')}
</div>
</div>
`;
document.body.appendChild(modal);
const cleanup = (val) => {
modal.remove();
resolve(val);
};
modal.querySelectorAll('.modal-action-btn').forEach(btn => {
modal.querySelectorAll('.modal-action-btn').forEach((btn) => {
btn.onclick = () => {
const action = actions[btn.dataset.index];
if (action.callback) {
@ -52,7 +56,7 @@ class Modal {
return this.show({
title,
content: message,
actions: [{ label: 'OK', type: 'primary' }]
actions: [{ label: 'OK', type: 'primary' }],
});
}
@ -62,8 +66,8 @@ class Modal {
content: message,
actions: [
{ label: confirmLabel, type: type },
{ label: 'Cancel', type: 'secondary', callback: () => false }
]
{ label: 'Cancel', type: 'secondary', callback: () => false },
],
});
}
}
@ -82,7 +86,7 @@ export class ListeningPartyManager {
this.isJoining = false;
this.isInternalSync = false;
this.originalSafePlay = null;
this.setupEventListeners();
}
@ -92,7 +96,7 @@ export class ListeningPartyManager {
document.getElementById('copy-party-link-btn')?.addEventListener('click', () => this.copyInviteLink());
document.getElementById('party-chat-send-btn')?.addEventListener('click', () => this.sendChatMessage());
document.getElementById('party-chat-input')?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.sendChatMessage();
if (e.key === 'Enter') this.sendChatMessage().catch(console.error);
});
}
@ -100,13 +104,13 @@ export class ListeningPartyManager {
const nameInput = document.getElementById('party-name-input');
const user = authManager.user;
if (!user) {
Modal.alert('Login Required', 'You must be logged in to host a listening party.');
await Modal.alert('Login Required', 'You must be logged in to host a listening party.');
return;
}
const pbUser = await syncManager._getUserRecord(user.$id);
if (!pbUser) {
Modal.alert('Sync Error', 'Failed to sync user data. Please try again.');
await Modal.alert('Sync Error', 'Failed to sync user data. Please try again.');
return;
}
@ -119,25 +123,27 @@ export class ListeningPartyManager {
is_playing: player.currentTrack ? !player.activeElement.paused : false,
playback_time: player.activeElement.currentTime || 0,
playback_timestamp: Date.now(),
queue: player.queue?.map(t => syncManager._minifyItem('track', t)) || []
queue: player.queue?.map((t) => syncManager._minifyItem('track', t)) || [],
};
if (currentTrack) partyData.current_track = currentTrack;
try {
const party = await pb.collection('parties').create(partyData, { f_id: user.$id });
navigate(`/party/${party.id}`);
} catch (e) { console.error('Create error:', e); }
} catch (e) {
console.error('Create error:', e);
}
}
async joinParty(partyId) {
if (this.currentParty?.id === partyId || this.isJoining) return;
this.isJoining = true;
try {
const user = authManager.user;
const f_id = user ? user.$id : 'guest';
const party = await pb.collection('parties').getOne(partyId, { expand: 'host', f_id });
const confirmed = await this.showJoinModal(user);
if (!confirmed) {
this.isJoining = false;
@ -148,33 +154,39 @@ export class ListeningPartyManager {
this.currentParty = party;
const pbUser = user ? await syncManager._getUserRecord(user.$id) : null;
this.isHost = pbUser && pbUser.id === party.host;
const profile = confirmed.profile || await this.getMemberProfile(pbUser);
const memberData = { party: partyId, name: profile.name, avatar_url: profile.avatar_url, is_host: !!this.isHost, last_seen: Date.now() };
const profile = confirmed.profile || (await this.getMemberProfile(pbUser));
const memberData = {
party: partyId,
name: profile.name,
avatar_url: profile.avatar_url,
is_host: !!this.isHost,
last_seen: Date.now(),
};
if (pbUser?.id) memberData.user = pbUser.id;
const member = await pb.collection('party_members').create(memberData, { f_id });
this.memberId = member.id;
this.setupSubscriptions(partyId);
this.startHeartbeat();
this.renderPartyUI();
this.loadInitialData(partyId);
await this.loadInitialData(partyId);
if (!this.isHost) {
this.lockControls();
this.setupGuestSyncInterception();
if (party.current_track) {
await audioContextManager.resume();
this.syncWithHost(party);
await this.syncWithHost(party);
}
}
} catch (error) {
console.error('Join error:', error);
Modal.alert('Error', 'Failed to join the party. It may have ended.');
navigate('/parties');
} finally {
this.isJoining = false;
} catch (error) {
console.error('Join error:', error);
await Modal.alert('Error', 'Failed to join the party. It may have ended.');
navigate('/parties');
} finally {
this.isJoining = false;
}
}
@ -187,7 +199,7 @@ export class ListeningPartyManager {
);
return confirmed ? { profile: null } : false;
} else {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const cached = localStorage.getItem('party_guest_profile');
const defaultName = cached ? JSON.parse(cached).name : '';
@ -198,19 +210,24 @@ export class ListeningPartyManager {
<input type="text" id="guest-name-input" class="template-input" value="${defaultName}" placeholder="Your nickname" style="width: 100%; text-align: center;">
`,
actions: [
{
label: 'Join Party',
{
label: 'Join Party',
type: 'primary',
callback: (modal) => {
const name = modal.querySelector('#guest-name-input').value.trim() || 'Guest';
const profile = { name, avatar_url: `https://api.dicebear.com/9.x/identicon/svg?seed=${name}` };
const profile = {
name,
avatar_url: `https://api.dicebear.com/9.x/identicon/svg?seed=${name}`,
};
localStorage.setItem('party_guest_profile', JSON.stringify(profile));
return { profile };
}
},
},
{ label: 'Cancel', type: 'secondary', callback: () => false }
]
}).then(resolve);
{ label: 'Cancel', type: 'secondary', callback: () => false },
],
})
.then(resolve)
.catch(reject);
});
}
}
@ -227,57 +244,100 @@ export class ListeningPartyManager {
async getMemberProfile(pbUser = null) {
const user = authManager.user;
if (user) {
const name = pbUser?.display_name || pbUser?.username || user.displayName || user.email?.split('@')[0] || 'Member';
const avatar = pbUser?.avatar_url || user.photoURL || `https://api.dicebear.com/9.x/identicon/svg?seed=${name}`;
const name =
pbUser?.display_name || pbUser?.username || user.displayName || user.email?.split('@')[0] || 'Member';
const avatar =
pbUser?.avatar_url || user.photoURL || `https://api.dicebear.com/9.x/identicon/svg?seed=${name}`;
return { name, avatar_url: avatar };
}
const cached = localStorage.getItem('party_guest_profile');
return cached ? JSON.parse(cached) : { name: 'Guest', avatar_url: 'https://api.dicebear.com/9.x/identicon/svg?seed=Guest' };
return cached
? JSON.parse(cached)
: { name: 'Guest', avatar_url: 'https://api.dicebear.com/9.x/identicon/svg?seed=Guest' };
}
setupSubscriptions(partyId) {
this.unsubscribeFunctions.forEach(unsub => unsub());
this.unsubscribeFunctions.forEach((unsub) => unsub());
this.unsubscribeFunctions = [];
const f_id = authManager.user ? authManager.user.$id : 'guest';
pb.collection('parties').subscribe(partyId, (e) => {
if (e.action === 'update') {
this.currentParty = e.record;
if (!this.isHost) this.syncWithHost(e.record);
this.updatePartyHeader();
} else if (e.action === 'delete') {
Modal.alert('Party Ended', 'The host has ended the listening party.');
this.leaveParty(false);
}
}, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub));
pb.collection('parties')
.subscribe(
partyId,
async (e) => {
if (e.action === 'update') {
this.currentParty = e.record;
if (!this.isHost) await this.syncWithHost(e.record);
this.updatePartyHeader();
} else if (e.action === 'delete') {
await Modal.alert('Party Ended', 'The host has ended the listening party.');
await this.leaveParty(false);
}
},
{ f_id }
)
.then((unsub) => this.unsubscribeFunctions.push(unsub))
.catch(console.error);
pb.collection('party_members').subscribe('*', (e) => {
if (e.record.party === partyId) this.loadMembers();
}, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub));
pb.collection('party_members')
.subscribe(
'*',
async (e) => {
if (e.record.party === partyId) await this.loadMembers();
},
{ f_id }
)
.then((unsub) => this.unsubscribeFunctions.push(unsub))
.catch(console.error);
pb.collection('party_messages').subscribe('*', (e) => {
if (e.record.party === partyId && e.action === 'create') this.addChatMessage(e.record);
}, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub));
pb.collection('party_messages')
.subscribe(
'*',
(e) => {
if (e.record.party === partyId && e.action === 'create') this.addChatMessage(e.record);
},
{ f_id }
)
.then((unsub) => this.unsubscribeFunctions.push(unsub))
.catch(console.error);
pb.collection('party_requests').subscribe('*', (e) => {
if (e.record.party === partyId) this.loadRequests();
}, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub));
pb.collection('party_requests')
.subscribe(
'*',
async (e) => {
if (e.record.party === partyId) await this.loadRequests();
},
{ f_id }
)
.then((unsub) => this.unsubscribeFunctions.push(unsub))
.catch(console.error);
}
async loadInitialData(partyId) { this.loadMembers(); this.loadMessages(); this.loadRequests(); }
async loadInitialData(_partyId) {
await this.loadMembers();
await this.loadMessages();
await this.loadRequests();
}
async loadMembers() {
const f_id = authManager.user ? authManager.user.$id : 'guest';
this.members = await pb.collection('party_members').getFullList({ filter: `party = "${this.currentParty.id}"`, sort: '-is_host,name', f_id });
this.members = await pb
.collection('party_members')
.getFullList({ filter: `party = "${this.currentParty.id}"`, sort: '-is_host,name', f_id });
this.renderMembers();
}
async loadMessages() {
const f_id = authManager.user ? authManager.user.$id : 'guest';
const res = await pb.collection('party_messages').getList(1, 50, { filter: `party = "${this.currentParty.id}"`, sort: '-created', f_id });
const res = await pb
.collection('party_messages')
.getList(1, 50, { filter: `party = "${this.currentParty.id}"`, sort: '-created', f_id });
this.messages = res.items.reverse();
const container = document.getElementById('party-chat-messages');
if (container) { container.innerHTML = ''; this.messages.forEach(m => this.addChatMessage(m)); }
if (container) {
container.innerHTML = '';
this.messages.forEach((m) => this.addChatMessage(m));
}
}
async loadRequests() {
@ -286,26 +346,36 @@ export class ListeningPartyManager {
this.requests = await pb.collection('party_requests').getFullList({
filter: `party = "${this.currentParty.id}"`,
sort: 'created',
f_id: f_id
f_id: f_id,
});
this.renderRequests();
} catch (e) { console.error('Failed to load requests:', e); }
} catch (e) {
console.error('Failed to load requests:', e);
}
}
renderPartyUI() {
this.updatePartyHeader(); this.renderMembers(); this.renderRequests(); this.showPartyIndicator();
if (this.isHost) { this.unlockControls(); this.setupHostPlayerSync(); }
else { this.lockControls(); this.setupGuestPlayerInterferenceCheck(); }
this.updatePartyHeader();
this.renderMembers();
this.renderRequests();
this.showPartyIndicator();
if (this.isHost) {
this.unlockControls();
this.setupHostPlayerSync();
} else {
this.lockControls();
this.setupGuestPlayerInterferenceCheck();
}
}
updatePartyHeader() {
const titleEl = document.getElementById('party-title');
const countEl = document.getElementById('party-member-count');
const metaEl = document.getElementById('party-meta');
if (titleEl) titleEl.textContent = this.currentParty.name;
if (countEl) countEl.textContent = this.members.length;
if (metaEl) {
const host = this.currentParty.expand?.host;
const hostName = host?.display_name || host?.username || 'Unknown';
@ -325,11 +395,15 @@ export class ListeningPartyManager {
<div class="track-title" style="font-size: 1.8rem; font-weight: 700; margin-bottom: 0.5rem">${track.title}</div>
<div class="track-artist" style="font-size: 1.2rem; color: var(--muted-foreground)">${getTrackArtists(track)}</div>
</div>
${!this.currentParty.is_playing ? `
${
!this.currentParty.is_playing
? `
<div style="display: flex; align-items: center; gap: 0.5rem; color: var(--primary); font-weight: 600; text-transform: uppercase; letter-spacing: 1px; font-size: 0.9rem">
${SVG_PAUSE(24)} Paused
</div>
` : ''}
`
: ''
}
</div>
`;
} else {
@ -341,7 +415,12 @@ export class ListeningPartyManager {
renderMembers() {
const list = document.getElementById('party-members-list');
if (!list) return;
list.innerHTML = this.members.map(m => `<div class="member-item" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: var(--background-secondary); border-radius: var(--radius); border: 1px solid var(--border)"><img src="${m.avatar_url}" style="width: 40px; height: 40px; border-radius: 50%; background: var(--background-modifier-accent)"><div style="flex: 1; overflow: hidden"><div style="font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">${m.name}</div>${m.is_host ? '<div style="color: var(--primary); font-size: 0.7rem; font-weight: bold; text-transform: uppercase;">Host</div>' : '<div style="color: var(--muted-foreground); font-size: 0.7rem">Listening</div>'}</div></div>`).join('');
list.innerHTML = this.members
.map(
(m) =>
`<div class="member-item" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: var(--background-secondary); border-radius: var(--radius); border: 1px solid var(--border)"><img src="${m.avatar_url}" style="width: 40px; height: 40px; border-radius: 50%; background: var(--background-modifier-accent)"><div style="flex: 1; overflow: hidden"><div style="font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">${m.name}</div>${m.is_host ? '<div style="color: var(--primary); font-size: 0.7rem; font-weight: bold; text-transform: uppercase;">Host</div>' : '<div style="color: var(--muted-foreground); font-size: 0.7rem">Listening</div>'}</div></div>`
)
.join('');
}
renderRequests() {
@ -351,13 +430,14 @@ export class ListeningPartyManager {
list.innerHTML = `<div style="padding: 2rem; text-align: center; color: var(--muted-foreground); font-size: 0.9rem">No requests yet. Right-click a song to request!</div>`;
return;
}
list.innerHTML = this.requests.map(r => {
try {
const api = Player.instance.api;
const artists = getTrackArtists(r.track);
const coverUrl = api.getCoverUrl(r.track.artwork || r.track.cover || r.track.album?.cover);
return `<div class="track-item" style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; border-bottom: 1px solid var(--border)">
list.innerHTML = this.requests
.map((r) => {
try {
const api = Player.instance.api;
const artists = getTrackArtists(r.track);
const coverUrl = api.getCoverUrl(r.track.artwork || r.track.cover || r.track.album?.cover);
return `<div class="track-item" style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; border-bottom: 1px solid var(--border)">
<img src="${coverUrl}" style="width: 48px; height: 48px; border-radius: 4px; object-fit: cover; flex-shrink: 0;">
<div class="track-info" style="flex: 1; min-width: 0;">
<div class="track-title" style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${r.track.title || 'Unknown Title'}</div>
@ -365,20 +445,25 @@ export class ListeningPartyManager {
</div>
${this.isHost ? `<button class="btn-primary btn-sm add-request-btn" data-req-id="${r.id}" style="padding: 0.4rem 1rem; font-size: 0.8rem; flex-shrink: 0; white-space: nowrap;">Add to Queue</button>` : ''}
</div>`;
} catch (e) { return ''; }
}).join('');
} catch (_e) {
return '';
}
})
.join('');
if (this.isHost) {
const f_id = authManager.user ? authManager.user.$id : 'guest';
list.querySelectorAll('.add-request-btn').forEach(btn => btn.addEventListener('click', async (e) => {
const reqId = e.currentTarget.dataset.reqId;
const req = this.requests.find(r => r.id === reqId);
if (req) {
Player.instance.addToQueue(req.track);
showNotification(`Added "${req.track.title}" to queue`);
await pb.collection('party_requests').delete(req.id, { f_id });
}
}));
list.querySelectorAll('.add-request-btn').forEach((btn) =>
btn.addEventListener('click', async (e) => {
const reqId = e.currentTarget.dataset.reqId;
const req = this.requests.find((r) => r.id === reqId);
if (req) {
Player.instance.addToQueue(req.track);
showNotification(`Added "${req.track.title}" to queue`);
await pb.collection('party_requests').delete(req.id, { f_id });
}
})
);
}
}
@ -387,15 +472,17 @@ export class ListeningPartyManager {
if (!container) return;
const div = document.createElement('div');
div.className = 'chat-msg';
const urlRegex = /(https?:\/\/[^\s]+)/g;
let content = escapeHtml(msg.content);
content = content.replace(urlRegex, (url) => {
if (url.match(/\.(jpeg|jpg|gif|png|webp|svg)(\?.*)?$/i)) {
return `<a href="${url}" target="_blank" class="chat-link">${url}</a><img src="${url}" style="max-width: 100%; border-radius: 8px; margin-top: 8px; display: block; cursor: pointer" onclick="window.open('${url}')">`;
}
const ytMatch = url.match(/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/i);
const ytMatch = url.match(
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/i
);
if (ytMatch) {
return `<a href="${url}" target="_blank" class="chat-link">${url}</a><iframe style="width: 100%; aspect-ratio: 16/9; border-radius: 8px; margin-top: 8px; border: none" src="https://www.youtube.com/embed/${ytMatch[1]}" allowfullscreen></iframe>`;
}
@ -407,7 +494,7 @@ export class ListeningPartyManager {
}
return `<a href="${url}" target="_blank" class="chat-link" style="color: var(--primary); text-decoration: underline;">${url}</a>`;
});
div.innerHTML = `
<div style="font-weight: 600; font-size: 0.75rem; color: var(--primary); margin-bottom: 2px">${escapeHtml(msg.sender_name)}</div>
<div style="background: var(--background-modifier-accent); padding: 0.6rem 0.8rem; border-radius: 0.75rem; display: inline-block; max-width: 100%; word-break: break-word; font-size: 0.9rem; line-height: 1.4">
@ -421,10 +508,15 @@ export class ListeningPartyManager {
async sendChatMessage() {
const input = document.getElementById('party-chat-input');
if (!input || !input.value.trim()) return;
const content = input.value.trim(); input.value = '';
const content = input.value.trim();
input.value = '';
const profile = await this.getMemberProfile();
const f_id = authManager.user ? authManager.user.$id : 'guest';
try { await pb.collection('party_messages').create({ party: this.currentParty.id, sender_name: profile.name, content }, { f_id }); } catch (e) {}
try {
await pb
.collection('party_messages')
.create({ party: this.currentParty.id, sender_name: profile.name, content }, { f_id });
} catch (_e) {}
}
async requestSong(track) {
@ -433,13 +525,18 @@ export class ListeningPartyManager {
const f_id = authManager.user ? authManager.user.$id : 'guest';
try {
const minifiedTrack = syncManager._minifyItem('track', track);
await pb.collection('party_requests').create({
party: this.currentParty.id,
track: minifiedTrack,
requested_by: profile.name
}, { f_id });
await pb.collection('party_requests').create(
{
party: this.currentParty.id,
track: minifiedTrack,
requested_by: profile.name,
},
{ f_id }
);
showNotification(`Requested "${track.title}"`);
} catch (e) { console.error('Request error:', e); }
} catch (e) {
console.error('Request error:', e);
}
}
async syncWithHost(party) {
@ -448,23 +545,28 @@ export class ListeningPartyManager {
try {
const player = Player.instance;
const el = player.activeElement;
if (!party.current_track) { if (player.currentTrack) el.pause(); return; }
if (!party.current_track) {
if (player.currentTrack) el.pause();
return;
}
const currentId = String(player.currentTrack?.id || '');
const targetId = String(party.current_track.id || '');
if (currentId !== targetId) {
const cleanedTrack = { ...party.current_track };
delete cleanedTrack.audioUrl; delete cleanedTrack.streamUrl; delete cleanedTrack.remoteUrl;
delete cleanedTrack.audioUrl;
delete cleanedTrack.streamUrl;
delete cleanedTrack.remoteUrl;
player.setQueue([cleanedTrack], 0);
await player.playTrackFromQueue(party.playback_time);
if (!party.is_playing) el.pause();
return;
return;
}
if (party.is_playing) {
if (el.paused) {
const success = await player.safePlay(el);
const _success = await player.safePlay(el);
}
const latency = (Date.now() - party.playback_timestamp) / 1000;
const targetTime = party.is_playing ? party.playback_time + latency : party.playback_time;
@ -473,17 +575,57 @@ export class ListeningPartyManager {
if (!el.paused) el.pause();
if (Math.abs(el.currentTime - party.playback_time) > 0.5) el.currentTime = party.playback_time;
}
} catch (e) { console.error('Sync error:', e); } finally { this.isInternalSync = false; }
} catch (e) {
console.error('Sync error:', e);
} finally {
this.isInternalSync = false;
}
}
lockControls() {
const selectors = ['.play-pause-btn', '#next-btn', '#prev-btn', '#shuffle-btn', '#repeat-btn', '#progress-bar', '#fs-play-pause-btn', '#fs-next-btn', '#fs-prev-btn', '#fs-shuffle-btn', '#fs-repeat-btn', '#fs-progress-bar'];
selectors.forEach(s => document.querySelectorAll(s).forEach(el => { el.style.opacity = '0.5'; el.style.pointerEvents = 'none'; }));
const selectors = [
'.play-pause-btn',
'#next-btn',
'#prev-btn',
'#shuffle-btn',
'#repeat-btn',
'#progress-bar',
'#fs-play-pause-btn',
'#fs-next-btn',
'#fs-prev-btn',
'#fs-shuffle-btn',
'#fs-repeat-btn',
'#fs-progress-bar',
];
selectors.forEach((s) =>
document.querySelectorAll(s).forEach((el) => {
el.style.opacity = '0.5';
el.style.pointerEvents = 'none';
})
);
}
unlockControls() {
const selectors = ['.play-pause-btn', '#next-btn', '#prev-btn', '#shuffle-btn', '#repeat-btn', '#progress-bar', '#fs-play-pause-btn', '#fs-next-btn', '#fs-prev-btn', '#fs-shuffle-btn', '#fs-repeat-btn', '#fs-progress-bar'];
selectors.forEach(s => document.querySelectorAll(s).forEach(el => { el.style.opacity = '1'; el.style.pointerEvents = 'auto'; }));
const selectors = [
'.play-pause-btn',
'#next-btn',
'#prev-btn',
'#shuffle-btn',
'#repeat-btn',
'#progress-bar',
'#fs-play-pause-btn',
'#fs-next-btn',
'#fs-prev-btn',
'#fs-shuffle-btn',
'#fs-repeat-btn',
'#fs-progress-bar',
];
selectors.forEach((s) =>
document.querySelectorAll(s).forEach((el) => {
el.style.opacity = '1';
el.style.pointerEvents = 'auto';
})
);
}
setupHostPlayerSync() {
@ -493,16 +635,20 @@ export class ListeningPartyManager {
const el = player.activeElement;
const sharedTrack = player.currentTrack ? syncManager._minifyItem('track', player.currentTrack) : null;
try {
await pb.collection('parties').update(this.currentParty.id, {
current_track: sharedTrack,
is_playing: !el.paused,
playback_time: el.currentTime,
playback_timestamp: Date.now(),
queue: player.queue?.map(t => syncManager._minifyItem('track', t)) || []
}, { f_id: authManager.user?.$id });
} catch (e) {}
await pb.collection('parties').update(
this.currentParty.id,
{
current_track: sharedTrack,
is_playing: !el.paused,
playback_time: el.currentTime,
playback_timestamp: Date.now(),
queue: player.queue?.map((t) => syncManager._minifyItem('track', t)) || [],
},
{ f_id: authManager.user?.$id }
);
} catch (_e) {}
};
['play', 'pause', 'seeked'].forEach(ev => {
['play', 'pause', 'seeked'].forEach((ev) => {
player.audio.addEventListener(ev, updateParty);
if (player.video) player.video.addEventListener(ev, updateParty);
});
@ -520,9 +666,14 @@ export class ListeningPartyManager {
const originalPlayTrackFromQueue = player.playTrackFromQueue.bind(player);
player.playTrackFromQueue = async (...args) => {
if (this.currentParty && !this.isHost && !this.isInternalSync) {
const leave = await Modal.confirm('Leave Party?', 'Playing a song will cause you to leave the listening party. Are you sure?', 'Leave and Play', 'danger');
const leave = await Modal.confirm(
'Leave Party?',
'Playing a song will cause you to leave the listening party. Are you sure?',
'Leave and Play',
'danger'
);
if (!leave) return;
this.leaveParty();
await this.leaveParty();
}
return await originalPlayTrackFromQueue(...args);
};
@ -531,43 +682,64 @@ export class ListeningPartyManager {
startHeartbeat() {
this.heartbeatInterval = setInterval(async () => {
if (!this.memberId) return;
try { await pb.collection('party_members').update(this.memberId, { last_seen: Date.now() }, { f_id: authManager.user?.$id || 'guest' }); } catch (e) {}
try {
await pb
.collection('party_members')
.update(this.memberId, { last_seen: Date.now() }, { f_id: authManager.user?.$id || 'guest' });
} catch (_e) {}
}, 30000);
}
async leaveParty(shouldCleanup = true) {
const f_id = authManager.user?.$id || 'guest';
if (this.isHost && shouldCleanup) {
const end = await Modal.confirm('End Party?', 'Leaving will end the party for everyone. Are you sure?', 'End Party', 'danger');
const end = await Modal.confirm(
'End Party?',
'Leaving will end the party for everyone. Are you sure?',
'End Party',
'danger'
);
if (!end) return;
try {
const cleanup = async (coll) => {
const items = await pb.collection(coll).getFullList({ filter: `party = "${this.currentParty.id}"`, f_id });
const items = await pb
.collection(coll)
.getFullList({ filter: `party = "${this.currentParty.id}"`, f_id });
for (const i of items) await pb.collection(coll).delete(i.id, { f_id });
};
await cleanup('party_members'); await cleanup('party_messages'); await cleanup('party_requests');
await cleanup('party_members');
await cleanup('party_messages');
await cleanup('party_requests');
await pb.collection('parties').delete(this.currentParty.id, { f_id });
} catch (e) {}
} catch (_e) {}
} else if (this.memberId) {
try { await pb.collection('party_members').delete(this.memberId, { f_id }); } catch (e) {}
try {
await pb.collection('party_members').delete(this.memberId, { f_id });
} catch (_e) {}
}
this.restorePlayerMethods();
this.unlockControls();
this.unsubscribeFunctions.forEach(unsub => unsub());
this.unsubscribeFunctions.forEach((unsub) => unsub());
this.unsubscribeFunctions = [];
clearInterval(this.syncInterval); clearInterval(this.heartbeatInterval);
this.currentParty = null; this.isHost = false; this.memberId = null;
clearInterval(this.syncInterval);
clearInterval(this.heartbeatInterval);
this.currentParty = null;
this.isHost = false;
this.memberId = null;
document.getElementById('party-indicator')?.remove();
navigate('/parties');
}
restorePlayerMethods() {
const player = Player.instance;
if (this.originalSafePlay) { player.safePlay = this.originalSafePlay; this.originalSafePlay = null; }
if (this.originalSafePlay) {
player.safePlay = this.originalSafePlay;
this.originalSafePlay = null;
}
}
copyInviteLink() {
navigator.clipboard.writeText(`${window.location.origin}/party/${this.currentParty.id}`);
navigator.clipboard.writeText(`${window.location.origin}/party/${this.currentParty.id}`).catch(console.error);
showNotification('Invite link copied!');
}
@ -580,7 +752,7 @@ export class ListeningPartyManager {
document.body.appendChild(indicator);
indicator.onclick = () => navigate(`/party/${this.currentParty.id}`);
}
indicator.innerHTML = `
<div class="party-indicator-content">
<span class="party-indicator-label">Listening Party</span>

View file

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

View file

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

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

View file

@ -546,9 +546,9 @@ export function createStringAtom(type, value, truncateType = true) {
export function createUserAtom(namespace, name, value) {
const encoder = new TextEncoder();
const dashBytes = encoder.encode('----'); // User-defined atom type
const _dashBytes = encoder.encode('----'); // User-defined atom type
const namespaceBytes = encoder.encode(namespace);
const meanBytes = encoder.encode('mean'); // Standard 'mean' atom for namespace
const _meanBytes = encoder.encode('mean'); // Standard 'mean' atom for namespace
const nameBytes = encoder.encode(name);
const valueBytes = encoder.encode('\x00\x00\x00\x01\x00\x00\x00\x00' + value);

View file

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

View file

@ -4,8 +4,45 @@ import { LosslessAPI } from './api.js';
import { PodcastsAPI } from './podcasts-api.js';
import { musicProviderSettings } from './storage.js';
/**
* MusicAPI - Singleton class that provides a unified interface for accessing music streaming services.
*
* Supports multiple providers (primarily Tidal) and includes functionality for searching,
* retrieving metadata, streaming, and managing playlists, artists, albums, tracks, and podcasts.
*
* @class MusicAPI
* @classdesc Manages API interactions with music providers and provides caching mechanisms
* for cover artwork and video metadata.
*
* @example
* // Initialize the MusicAPI
* await MusicAPI.initialize(settings);
*
* // Get the singleton instance
* const api = MusicAPI.instance;
*
* // Search for tracks
* const results = await api.search('query');
*
* // Get a specific track
* const track = await api.getTrack('track-id');
*
* // Get stream URL
* const streamUrl = await api.getStreamUrl('track-id', 'HIGH');
*
* @property {LosslessAPI} tidalAPI - The Tidal API instance
* @property {PodcastsAPI} podcastsAPI - The Podcasts API instance
* @property {Object} _settings - Configuration settings
* @property {Map} videoArtworkCache - Cache for video artwork data
*
* @throws {Error} Throws if instance is accessed before initialization
* @throws {Error} Throws if initialize is called more than once
*/
export class MusicAPI {
static #instance = null;
/**
* @type {MusicAPI}
*/
static get instance() {
if (!MusicAPI.#instance) {
throw new Error('MusicAPI not initialized. Call MusicAPI.initialize(settings) first.');
@ -35,7 +72,7 @@ export class MusicAPI {
}
// Get the appropriate API based on provider
getAPI(provider = null) {
getAPI() {
return this.tidalAPI;
}
@ -101,31 +138,31 @@ export class MusicAPI {
}
// Get methods
async getTrack(id, quality, provider = null) {
async getTrack(id, quality) {
const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id);
return api.getTrack(cleanId, quality);
}
async getTrackMetadata(id, provider = null) {
async getTrackMetadata(id) {
const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id);
return api.getTrackMetadata(cleanId);
}
async getAlbum(id, provider = null) {
async getAlbum(id) {
const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id);
return api.getAlbum(cleanId);
}
async getArtist(id, provider = null) {
async getArtist(id) {
const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id);
return api.getArtist(cleanId);
}
async getArtistBiography(id, provider = null) {
async getArtistBiography(id) {
const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id);
if (typeof api.getArtistBiography === 'function') {
@ -134,13 +171,13 @@ export class MusicAPI {
return null;
}
async getVideo(id, provider = null) {
async getVideo(id) {
const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id);
return api.getVideo(cleanId);
}
async getVideoStreamUrl(id, provider = null) {
async getVideoStreamUrl(id) {
const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id);
if (typeof api.getVideoStreamUrl === 'function') {
@ -157,7 +194,7 @@ export class MusicAPI {
return this.tidalAPI.getPlaylist(id);
}
async getMix(id, _provider = null) {
async getMix(id) {
// Mixes are always Tidal for now
return this.tidalAPI.getMix(id);
}
@ -172,7 +209,7 @@ export class MusicAPI {
}
// Stream methods
async getStreamUrl(id, quality, provider = null) {
async getStreamUrl(id, quality) {
const api = this.getAPI();
const cleanId = this.stripProviderPrefix(id);
return api.getStreamUrl(cleanId, quality);

View file

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

View file

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

View file

@ -248,30 +248,31 @@ export async function loadProfile(username) {
}
if (profile.lastfm_username && profile.privacy?.lastfm !== 'private') {
fetchLastFmRecentTracks(profile.lastfm_username).then(async (tracks) => {
if (tracks.length > 0) {
recentSection.style.display = 'block';
recentContainer.innerHTML = tracks
.map((track, index) => {
const isNowPlaying = track['@attr']?.nowplaying === 'true';
let image = getLastFmImage(track.image);
const hasImage = !!image;
if (!image) image = '/assets/appicon.png';
fetchLastFmRecentTracks(profile.lastfm_username)
.then(async (tracks) => {
if (tracks.length > 0) {
recentSection.style.display = 'block';
recentContainer.innerHTML = tracks
.map((track, index) => {
const isNowPlaying = track['@attr']?.nowplaying === 'true';
let image = getLastFmImage(track.image);
const hasImage = !!image;
if (!image) image = '/assets/appicon.png';
track._imgId = `scrobble-img-${index}`;
track._needsCover = !hasImage;
track._imgId = `scrobble-img-${index}`;
track._needsCover = !hasImage;
let dateDisplay = '';
if (isNowPlaying) dateDisplay = 'Scrobbling now';
else if (track.date) {
const date = new Date(track.date.uts * 1000);
dateDisplay =
date.toLocaleDateString() +
' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
let dateDisplay = '';
if (isNowPlaying) dateDisplay = 'Scrobbling now';
else if (track.date) {
const date = new Date(track.date.uts * 1000);
dateDisplay =
date.toLocaleDateString() +
' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return `
return `
<div class="track-item lastfm-track" data-title="${escapeHtml(track.name)}" data-artist="${escapeHtml(track.artist?.['#text'] || track.artist?.name || '')}" style="grid-template-columns: 40px 1fr auto; cursor: pointer;">
<img id="${track._imgId}" src="${image}" class="track-item-cover" style="width: 40px; height: 40px; border-radius: 4px;" loading="lazy" onerror="this.src='/assets/appicon.png'">
<div class="track-item-info">
@ -283,39 +284,45 @@ export async function loadProfile(username) {
<div class="track-item-duration" style="font-size: 0.8rem; min-width: auto;">${dateDisplay}</div>
</div>
`;
})
.join('');
})
.join('');
recentContainer.querySelectorAll('.track-item').forEach((item) => {
item.addEventListener('click', () => handleTrackClick(item.dataset.title, item.dataset.artist));
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
recentContainer.querySelectorAll('.track-item').forEach((item) => {
item.addEventListener('click', () => handleTrackClick(item.dataset.title, item.dataset.artist));
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
});
});
});
for (const track of tracks) {
if (track._needsCover) {
fetchFallbackCover(track.name, track.artist?.['#text'] || track.artist?.name, track._imgId);
for (const track of tracks) {
if (track._needsCover) {
await fetchFallbackCover(
track.name,
track.artist?.['#text'] || track.artist?.name,
track._imgId
);
}
}
}
}
});
})
.catch(console.error);
fetchLastFmTopArtists(profile.lastfm_username).then(async (artists) => {
if (artists.length > 0 && topArtistsSection && topArtistsContainer) {
topArtistsSection.style.display = 'block';
topArtistsContainer.innerHTML = artists
.map((artist, index) => {
let image = getLastFmImage(artist.image);
const hasImage = !!image;
if (!image) image = '/assets/appicon.png';
fetchLastFmTopArtists(profile.lastfm_username)
.then(async (artists) => {
if (artists.length > 0 && topArtistsSection && topArtistsContainer) {
topArtistsSection.style.display = 'block';
topArtistsContainer.innerHTML = artists
.map((artist, index) => {
let image = getLastFmImage(artist.image);
const hasImage = !!image;
if (!image) image = '/assets/appicon.png';
const imgId = `top-artist-img-${index}`;
artist._imgId = imgId;
artist._needsCover = !hasImage;
const imgId = `top-artist-img-${index}`;
artist._imgId = imgId;
artist._needsCover = !hasImage;
return `
return `
<div class="card artist lastfm-card" data-name="${escapeHtml(artist.name)}" style="cursor: pointer;">
<div class="card-image-wrapper">
<img id="${imgId}" src="${image}" class="card-image" loading="lazy" onerror="this.src='/assets/appicon.png'">
@ -326,45 +333,47 @@ export async function loadProfile(username) {
</div>
</div>
`;
})
.join('');
})
.join('');
topArtistsContainer.querySelectorAll('.card').forEach((card) => {
card.addEventListener('click', () => handleArtistClick(card.dataset.name));
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
topArtistsContainer.querySelectorAll('.card').forEach((card) => {
card.addEventListener('click', () => handleArtistClick(card.dataset.name));
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
});
});
});
for (const artist of artists) {
if (artist._needsCover) {
fetchFallbackArtistImage(artist.name, artist._imgId);
for (const artist of artists) {
if (artist._needsCover) {
await fetchFallbackArtistImage(artist.name, artist._imgId);
}
}
}
}
});
})
.catch(console.error);
fetchLastFmTopAlbums(profile.lastfm_username).then(async (albums) => {
if (albums.length > 0 && topAlbumsSection && topAlbumsContainer) {
topAlbumsSection.style.display = 'block';
topAlbumsContainer.innerHTML = albums
.map((album, index) => {
let image = getLastFmImage(album.image);
const hasImage = !!image;
if (!image) image = '/assets/appicon.png';
fetchLastFmTopAlbums(profile.lastfm_username)
.then(async (albums) => {
if (albums.length > 0 && topAlbumsSection && topAlbumsContainer) {
topAlbumsSection.style.display = 'block';
topAlbumsContainer.innerHTML = albums
.map((album, index) => {
let image = getLastFmImage(album.image);
const hasImage = !!image;
if (!image) image = '/assets/appicon.png';
const imgId = `top-album-img-${index}`;
album._imgId = imgId;
album._needsCover = !hasImage;
const imgId = `top-album-img-${index}`;
album._imgId = imgId;
album._needsCover = !hasImage;
const artistName =
album.artist?.name ||
album.artist?.['#text'] ||
(typeof album.artist === 'string' ? album.artist : 'Unknown Artist');
album._artistName = artistName;
const artistName =
album.artist?.name ||
album.artist?.['#text'] ||
(typeof album.artist === 'string' ? album.artist : 'Unknown Artist');
album._artistName = artistName;
return `
return `
<div class="card lastfm-card" data-name="${escapeHtml(album.name)}" data-artist="${escapeHtml(artistName)}" style="cursor: pointer;">
<div class="card-image-wrapper">
<img id="${imgId}" src="${image}" class="card-image" loading="lazy" onerror="this.src='/assets/appicon.png'">
@ -375,45 +384,47 @@ export async function loadProfile(username) {
</div>
</div>
`;
})
.join('');
})
.join('');
topAlbumsContainer.querySelectorAll('.card').forEach((card) => {
card.addEventListener('click', () => handleAlbumClick(card.dataset.name, card.dataset.artist));
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
topAlbumsContainer.querySelectorAll('.card').forEach((card) => {
card.addEventListener('click', () => handleAlbumClick(card.dataset.name, card.dataset.artist));
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
});
});
});
for (const album of albums) {
if (album._needsCover) {
fetchFallbackAlbumCover(album.name, album._artistName, album._imgId);
for (const album of albums) {
if (album._needsCover) {
await fetchFallbackAlbumCover(album.name, album._artistName, album._imgId);
}
}
}
}
});
})
.catch(console.error);
fetchLastFmTopTracks(profile.lastfm_username).then(async (tracks) => {
if (tracks.length > 0 && topTracksSection && topTracksContainer) {
topTracksSection.style.display = 'block';
topTracksContainer.innerHTML = tracks
.map((track, index) => {
let image = getLastFmImage(track.image);
const hasImage = !!image;
if (!image) image = '/assets/appicon.png';
fetchLastFmTopTracks(profile.lastfm_username)
.then(async (tracks) => {
if (tracks.length > 0 && topTracksSection && topTracksContainer) {
topTracksSection.style.display = 'block';
topTracksContainer.innerHTML = tracks
.map((track, index) => {
let image = getLastFmImage(track.image);
const hasImage = !!image;
if (!image) image = '/assets/appicon.png';
const imgId = `top-track-img-${index}`;
track._imgId = imgId;
track._needsCover = !hasImage;
const imgId = `top-track-img-${index}`;
track._imgId = imgId;
track._needsCover = !hasImage;
const artistName =
track.artist?.name ||
track.artist?.['#text'] ||
(typeof track.artist === 'string' ? track.artist : 'Unknown Artist');
track._artistName = artistName;
const artistName =
track.artist?.name ||
track.artist?.['#text'] ||
(typeof track.artist === 'string' ? track.artist : 'Unknown Artist');
track._artistName = artistName;
return `
return `
<div class="track-item lastfm-track" data-title="${escapeHtml(track.name)}" data-artist="${escapeHtml(artistName)}" style="grid-template-columns: 40px 1fr auto; cursor: pointer;">
<img id="${imgId}" src="${image}" class="track-item-cover" style="width: 40px; height: 40px; border-radius: 4px;" loading="lazy" onerror="this.src='/assets/appicon.png'">
<div class="track-item-info">
@ -425,24 +436,25 @@ export async function loadProfile(username) {
<div class="track-item-duration" style="font-size: 0.8rem; min-width: auto;">${parseInt(track.playcount).toLocaleString()} plays</div>
</div>
`;
})
.join('');
})
.join('');
topTracksContainer.querySelectorAll('.track-item').forEach((item) => {
item.addEventListener('click', () => handleTrackClick(item.dataset.title, item.dataset.artist));
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
topTracksContainer.querySelectorAll('.track-item').forEach((item) => {
item.addEventListener('click', () => handleTrackClick(item.dataset.title, item.dataset.artist));
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
});
});
});
for (const track of tracks) {
if (track._needsCover) {
fetchFallbackCover(track.name, track._artistName, track._imgId);
for (const track of tracks) {
if (track._needsCover) {
await fetchFallbackCover(track.name, track._artistName, track._imgId);
}
}
}
}
});
})
.catch(console.error);
}
const currentUser = await syncManager.getUserData();
@ -483,8 +495,8 @@ export async function loadProfile(username) {
}
}
export function openEditProfile() {
syncManager.getUserData().then((data) => {
export async function openEditProfile() {
await syncManager.getUserData().then((data) => {
if (!data || !data.profile) return;
const p = data.profile;
@ -566,7 +578,7 @@ async function saveProfile() {
try {
await syncManager.updateProfile(data);
editProfileModal.classList.remove('active');
loadProfile(newUsername);
await loadProfile(newUsername);
if (window.location.pathname.includes('/user/@')) {
window.history.replaceState(null, '', `/user/@${newUsername}`);
@ -589,7 +601,7 @@ viewMyProfileBtn.addEventListener('click', async () => {
if (data && data.profile && data.profile.username) {
navigate(`/user/@${data.profile.username}`);
} else {
openEditProfile();
await openEditProfile();
}
});

View file

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

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');
}
refresh(view, renderControlsCallback, renderContentCallback, options = {}) {
async refresh(view, renderControlsCallback, renderContentCallback, options = {}) {
if (this.isActive(view)) {
if (renderControlsCallback) {
this.controlsElement.innerHTML = '';
renderControlsCallback(this.controlsElement);
await renderControlsCallback(this.controlsElement);
}
if (renderContentCallback) {
if (!options.noClear) {
this.contentElement.innerHTML = '';
}
renderContentCallback(this.contentElement);
await renderContentCallback(this.contentElement);
}
}
}
updateContent(view, renderContentCallback) {
async updateContent(view, renderContentCallback) {
if (this.isActive(view)) {
this.contentElement.innerHTML = '';
renderContentCallback(this.contentElement);
await renderContentCallback(this.contentElement);
}
}
}

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 = {
COMPACT_ARTIST_KEY: 'card-compact-artist',
COMPACT_ALBUM_KEY: 'card-compact-album',
@ -999,39 +1065,11 @@ export const visualizerSettings = {
},
};
export const playbackSettings = {
FULLSCREEN_TILT_KEY: 'playback-fullscreen-tilt',
PRELOAD_TIME_KEY: 'playback-preload-time',
isFullscreenTiltEnabled() {
try {
return localStorage.getItem(this.FULLSCREEN_TILT_KEY) !== 'false';
} catch {
return true;
}
},
setFullscreenTiltEnabled(enabled) {
localStorage.setItem(this.FULLSCREEN_TILT_KEY, enabled ? 'true' : 'false');
},
getPreloadTime() {
try {
const val = localStorage.getItem(this.PRELOAD_TIME_KEY);
return val ? parseInt(val, 10) : 15;
} catch {
return 15;
}
},
setPreloadTime(seconds) {
localStorage.setItem(this.PRELOAD_TIME_KEY, seconds.toString());
},
};
export const equalizerSettings = {
ENABLED_KEY: 'equalizer-enabled',
GAINS_KEY: 'equalizer-gains',
BAND_TYPES_KEY: 'equalizer-band-types',
BAND_QS_KEY: 'equalizer-band-qs',
PRESET_KEY: 'equalizer-preset',
CUSTOM_PRESETS_KEY: 'equalizer-custom-presets',
BAND_COUNT_KEY: 'equalizer-band-count',
@ -1040,6 +1078,7 @@ export const equalizerSettings = {
FREQ_MIN_KEY: 'equalizer-freq-min',
FREQ_MAX_KEY: 'equalizer-freq-max',
PREAMP_KEY: 'equalizer-preamp',
CUSTOM_FREQUENCIES_KEY: 'equalizer-custom-frequencies',
DEFAULT_BAND_COUNT: 16,
MIN_BANDS: 3,
MAX_BANDS: 32,
@ -1308,6 +1347,100 @@ export const equalizerSettings = {
}
},
getCustomFrequencies(bandCount) {
const count = bandCount || this.getBandCount();
try {
const stored = localStorage.getItem(this.CUSTOM_FREQUENCIES_KEY);
if (stored) {
const freqs = JSON.parse(stored);
if (Array.isArray(freqs) && freqs.length === count) {
return freqs;
}
}
} catch {
/* ignore */
}
return null;
},
setCustomFrequencies(frequencies) {
try {
if (
Array.isArray(frequencies) &&
frequencies.length >= this.MIN_BANDS &&
frequencies.length <= this.MAX_BANDS
) {
localStorage.setItem(this.CUSTOM_FREQUENCIES_KEY, JSON.stringify(frequencies));
}
} catch (e) {
console.warn('[EQ] Failed to save custom frequencies:', e);
}
},
clearCustomFrequencies() {
try {
localStorage.removeItem(this.CUSTOM_FREQUENCIES_KEY);
} catch {
/* ignore */
}
},
getBandTypes(bandCount) {
const count = bandCount || this.getBandCount();
try {
const stored = localStorage.getItem(this.BAND_TYPES_KEY);
if (stored) {
const types = JSON.parse(stored);
if (Array.isArray(types) && types.length === count) {
return types;
}
}
} catch {
/* ignore */
}
return new Array(count).fill('peaking');
},
setBandTypes(types) {
try {
if (Array.isArray(types) && types.length >= this.MIN_BANDS && types.length <= this.MAX_BANDS) {
localStorage.setItem(this.BAND_TYPES_KEY, JSON.stringify(types));
}
} catch (e) {
console.warn('[EQ] Failed to save band types:', e);
}
},
getBandQs(bandCount) {
const count = bandCount || this.getBandCount();
try {
const stored = localStorage.getItem(this.BAND_QS_KEY);
if (stored) {
const qs = JSON.parse(stored);
if (Array.isArray(qs) && qs.length === count) {
return qs;
}
// Interpolate stored Qs to match requested band count instead of discarding
if (Array.isArray(qs) && qs.length >= this.MIN_BANDS) {
return this._interpolateGains(qs, count);
}
}
} catch {
/* ignore */
}
return null;
},
setBandQs(qs) {
try {
if (Array.isArray(qs) && qs.length >= this.MIN_BANDS && qs.length <= this.MAX_BANDS) {
localStorage.setItem(this.BAND_QS_KEY, JSON.stringify(qs));
}
} catch (e) {
console.warn('[EQ] Failed to save band Qs:', e);
}
},
/**
* Interpolate gains array to match target band count
*/
@ -1440,6 +1573,130 @@ export const equalizerSettings = {
return false;
}
},
// ========================================
// AutoEQ Profile Storage
// ========================================
AUTOEQ_PROFILES_KEY: 'autoeq-saved-profiles',
AUTOEQ_ACTIVE_PROFILE_KEY: 'autoeq-active-profile',
AUTOEQ_SAMPLE_RATE_KEY: 'autoeq-sample-rate',
getAutoEQProfiles() {
try {
const stored = localStorage.getItem(this.AUTOEQ_PROFILES_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
},
saveAutoEQProfile(profile) {
try {
const profiles = this.getAutoEQProfiles();
const id = profile.id || 'autoeq_' + Date.now();
const profileCopy = { ...profile, id };
profiles[id] = profileCopy;
localStorage.setItem(this.AUTOEQ_PROFILES_KEY, JSON.stringify(profiles));
return id;
} catch (e) {
console.warn('[AutoEQ] Failed to save profile:', e);
return false;
}
},
deleteAutoEQProfile(profileId) {
try {
const profiles = this.getAutoEQProfiles();
if (profiles[profileId]) {
delete profiles[profileId];
localStorage.setItem(this.AUTOEQ_PROFILES_KEY, JSON.stringify(profiles));
if (this.getActiveAutoEQProfile() === profileId) {
localStorage.removeItem(this.AUTOEQ_ACTIVE_PROFILE_KEY);
}
return true;
}
return false;
} catch (e) {
console.warn('[AutoEQ] Failed to delete profile:', e);
return false;
}
},
getActiveAutoEQProfile() {
try {
return localStorage.getItem(this.AUTOEQ_ACTIVE_PROFILE_KEY) || null;
} catch {
return null;
}
},
setActiveAutoEQProfile(profileId) {
if (profileId) {
localStorage.setItem(this.AUTOEQ_ACTIVE_PROFILE_KEY, profileId);
} else {
localStorage.removeItem(this.AUTOEQ_ACTIVE_PROFILE_KEY);
}
},
getSampleRate() {
try {
const stored = localStorage.getItem(this.AUTOEQ_SAMPLE_RATE_KEY);
const val = parseInt(stored, 10);
return [44100, 48000, 96000].includes(val) ? val : 48000;
} catch {
return 48000;
}
},
setSampleRate(rate) {
localStorage.setItem(this.AUTOEQ_SAMPLE_RATE_KEY, rate.toString());
},
// ========================================
// Last Selected Headphone Persistence
// ========================================
AUTOEQ_LAST_HEADPHONE_KEY: 'autoeq-last-headphone',
/**
* Save the last selected headphone entry + its measurement data
* so it persists across page reloads without re-fetching from GitHub
* @param {object} entry - {name, type, path, fileName}
* @param {Array} measurementData - [{freq, gain}, ...]
*/
setLastHeadphone(entry, measurementData) {
try {
localStorage.setItem(
this.AUTOEQ_LAST_HEADPHONE_KEY,
JSON.stringify({
entry,
measurementData,
savedAt: Date.now(),
})
);
} catch (e) {
console.warn('[AutoEQ] Failed to save last headphone:', e);
}
},
/**
* Retrieve the last selected headphone entry + cached measurement data
* @returns {{entry: object, measurementData: Array}|null}
*/
getLastHeadphone() {
try {
const stored = localStorage.getItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
if (!stored) return null;
const parsed = JSON.parse(stored);
if (parsed && parsed.entry && parsed.measurementData) return parsed;
return null;
} catch {
return null;
}
},
clearLastHeadphone() {
localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
},
};
export const monoAudioSettings = {
@ -1818,6 +2075,20 @@ export const homePageSettings = {
setShuffleEditorsPicks(enabled) {
localStorage.setItem(this.SHUFFLE_EDITORS_PICKS_KEY, enabled ? 'true' : 'false');
},
EDITORS_PICKS_SOURCE_KEY: 'home-editors-picks-source',
getEditorsPicksSource() {
try {
return localStorage.getItem(this.EDITORS_PICKS_SOURCE_KEY) || 'current';
} catch {
return 'current';
}
},
setEditorsPicksSource(source) {
localStorage.setItem(this.EDITORS_PICKS_SOURCE_KEY, source);
},
};
export const radioSettings = {
@ -2382,18 +2653,18 @@ export const fontSettings = {
document.documentElement.style.setProperty('--font-family', "'SF Pro Display', sans-serif");
},
applyFont() {
async applyFont() {
const config = this.getConfig();
switch (config.type) {
case 'google':
this.loadGoogleFont(config.family);
await this.loadGoogleFont(config.family);
break;
case 'url':
this.loadFontFromUrl(config.url, config.family);
await this.loadFontFromUrl(config.url, config.family);
break;
case 'uploaded':
this.loadUploadedFont(config.fontId);
await this.loadUploadedFont(config.fontId);
break;
case 'preset':
default:
@ -2573,13 +2844,13 @@ export const contentBlockingSettings = {
isArtistBlocked(artistId) {
if (!artistId) return false;
return this.getBlockedArtists().some((a) => a.id == artistId);
return this.getBlockedArtists().some((a) => String(a.id) === String(artistId));
},
blockArtist(artist) {
if (!artist || !artist.id) return;
const blocked = this.getBlockedArtists();
if (!blocked.some((a) => a.id === artist.id)) {
if (!blocked.some((a) => String(a.id) === String(artist.id))) {
blocked.push({
id: artist.id,
name: artist.name || 'Unknown Artist',
@ -2590,7 +2861,7 @@ export const contentBlockingSettings = {
},
unblockArtist(artistId) {
const blocked = this.getBlockedArtists().filter((a) => a.id != artistId);
const blocked = this.getBlockedArtists().filter((a) => String(a.id) !== String(artistId));
this.setBlockedArtists(blocked);
},
@ -2610,13 +2881,13 @@ export const contentBlockingSettings = {
isTrackBlocked(trackId) {
if (!trackId) return false;
return this.getBlockedTracks().some((t) => t.id == trackId);
return this.getBlockedTracks().some((t) => String(t.id) === String(trackId));
},
blockTrack(track) {
if (!track || !track.id) return;
const blocked = this.getBlockedTracks();
if (!blocked.some((t) => t.id == track.id)) {
if (!blocked.some((t) => String(t.id) === String(track.id))) {
blocked.push({
id: track.id,
title: track.title || 'Unknown Track',
@ -2628,7 +2899,7 @@ export const contentBlockingSettings = {
},
unblockTrack(trackId) {
const blocked = this.getBlockedTracks().filter((t) => t.id != trackId);
const blocked = this.getBlockedTracks().filter((t) => String(t.id) !== String(trackId));
this.setBlockedTracks(blocked);
},
@ -2648,13 +2919,13 @@ export const contentBlockingSettings = {
isAlbumBlocked(albumId) {
if (!albumId) return false;
return this.getBlockedAlbums().some((a) => a.id == albumId);
return this.getBlockedAlbums().some((a) => String(a.id) === String(albumId));
},
blockAlbum(album) {
if (!album || !album.id) return;
const blocked = this.getBlockedAlbums();
if (!blocked.some((a) => a.id == album.id)) {
if (!blocked.some((a) => String(a.id) === String(album.id))) {
blocked.push({
id: album.id,
title: album.title || 'Unknown Album',
@ -2666,7 +2937,7 @@ export const contentBlockingSettings = {
},
unblockAlbum(albumId) {
const blocked = this.getBlockedAlbums().filter((a) => a.id != albumId);
const blocked = this.getBlockedAlbums().filter((a) => String(a.id) !== String(albumId));
this.setBlockedAlbums(blocked);
},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

15316
package-lock.json generated

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)",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run --config=vite.config.ts",
"test:headless": "HEADLESS=true vitest run --config=vite.config.ts",
"test:watch": "vitest --config=vite.config.ts",
"test:watch:headless": "HEADLESS=true vitest --config=vite.config.ts",
"install:playwright": "playwright install chromium",
"build": "vite build && vite-bundle-visualizer -o dist/assets/bundle-stats.html --open false",
"postbuild": "node -e \"const fs = require('fs'); const path = require('path'); const src = 'extensions'; const dest = path.join('dist', 'Monochrome', 'extensions'); if (fs.existsSync(src)) { fs.mkdirSync(dest, { recursive: true }); fs.cpSync(src, dest, { recursive: true }); console.log('Extensions manually copied to ' + dest); }\"",
"preview": "vite preview",
"lint:js": "eslint .",
@ -28,19 +33,23 @@
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.2.0",
"@testing-library/dom": "^10.4.1",
"@types/node": "^25.3.5",
"@vitest/browser-playwright": "^4.1.2",
"eslint": "^9.39.3",
"eslint-config-prettier": "^10.1.8",
"formidable": "^3.5.4",
"globals": "^17.4.0",
"htmlhint": "^1.9.2",
"miniflare": "^4.20260301.1",
"playwright": "^1.58.2",
"prettier": "^3.8.1",
"stylelint": "^16.26.1",
"stylelint": "^17.6.0",
"stylelint-config-standard": "^39.0.1",
"stylelint-config-standard-scss": "^16.0.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-pwa": "^1.2.0"
},
"overrides": {
@ -54,13 +63,16 @@
"@capacitor/core": "^8.2.0",
"@capacitor/haptics": "^8.0.1",
"@capacitor/ios": "^8.2.0",
"@dantheman827/taglib-ts": "https://github.com/DanTheMan827/taglib-ts/archive/b4238b2627aceb97f58813258046f1259f68cab7.tar.gz",
"@dantheman827/taglib-ts": "https://github.com/DanTheMan827/taglib-ts/archive/ebd0e369b706c127a280d4ad631977f8d12ff88f.tar.gz",
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@kawarp/core": "^1.1.1",
"@svta/common-media-library": "^0.18.1",
"@types/wicg-file-system-access": "^2023.10.7",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@uimaxbai/am-lyrics": "^1.1.4",
"@vitest/web-worker": "^4.1.2",
"appwrite": "^23.0.0",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
@ -78,7 +90,9 @@
"shaka-player": "^5.0.7",
"simple-icons": "^16.12.0",
"svgo": "^4.0.1",
"typescript-eslint": "^8.57.2",
"url-toolkit": "^2.2.5",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"vitest": "^4.1.2"
}
}

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",
"id": 510893864,
"title": "BULLY",
"artist": { "id": 25022, "name": "Kanye West" },
"artist": {
"id": 25022,
"name": "Kanye West"
},
"releaseDate": "2026-03-28",
"cover": "cf2f2c9c-ff67-44f6-83aa-a7622f8c6b64",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
"mediaMetadata": {
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
}
},
{
"type": "album",
"id": 403172104,
"title": "From A Man's Perspective",
"artist": { "id": 3561564, "name": "Dax" },
"releaseDate": "2024-12-06",
"cover": "c52a53ea-f021-44bf-8cef-fb31d3b82940",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
},
{
"type": "album",
"id": 341529881,
"title": "FACTS",
"artist": { "id": 5691796, "name": "Tom MacDonald" },
"releaseDate": "2024-01-26",
"cover": "34198718-cc7d-47eb-9bf1-1dc5c26fc8a1",
"id": 325723583,
"title": "Replica",
"artist": {
"id": 3715530,
"name": "Oneohtrix Point Never"
},
"releaseDate": "2011-11-05",
"cover": "95ceeae9-cac7-42dc-ae37-7c93c223f809",
"explicit": false,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
"mediaMetadata": {
"tags": ["LOSSLESS"]
}
},
{
"type": "album",
"id": 452163300,
"title": "Tackle Box",
"artist": { "id": 44771714, "name": "JamWayne" },
"releaseDate": "2025-09-01",
"cover": "bf7d2e55-52dc-4e41-9f53-1db31918798e",
"id": 336178142,
"title": "Pirate This Album",
"artist": {
"id": 8622751,
"name": "Shamana"
},
"releaseDate": "2023-12-25",
"cover": "a8a647be-0331-4779-9a6e-31645a9abdab",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
"mediaMetadata": {
"tags": ["LOSSLESS", "HIRES_LOSSLESS"]
}
},
{
"type": "album",
"id": 106210035,
"title": "Supermarket (Soundtrack)",
"artist": { "id": 3533999, "name": "LOGIC" },
"releaseDate": "2019-03-26",
"cover": "bdd39738-7177-4836-bd7f-cd4fe4ccf535",
"id": 106369871,
"title": "Organic Thoughts from the Synthetic Mind",
"artist": {
"id": 6436013,
"name": "Shinjuku Mad"
},
"releaseDate": "2009-07-01",
"cover": "3acc888e-35da-40a8-a4b7-7ffd00576cc9",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
"mediaMetadata": {
"tags": ["LOSSLESS"]
}
},
{
"type": "album",
"id": 138458381,
"title": "JOKER",
"artist": { "id": 39109746, "name": "Dax" },
"releaseDate": "2020-05-06",
"cover": "d6a39491-fece-4594-a538-9cfbce7c6c68",
"id": 423471869,
"title": "pain",
"artist": {
"id": 44257324,
"name": "bleood"
},
"releaseDate": "2025-03-11",
"cover": "711b23ba-c473-44e6-a2f0-010fefa9c5b8",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
"mediaMetadata": {
"tags": ["LOSSLESS"]
}
},
{
"type": "album",
"id": 318467376,
"title": "The Draco Tape 2",
"artist": { "id": 22467779, "name": "60PERCENTHOMO" },
"releaseDate": "2023-09-30",
"cover": "9169e8ab-fdb0-49d1-a14f-82a2b541223c",
"id": 250986538,
"title": "Revolutionary, Vol. 1 (Bonus Edition)",
"artist": {
"id": 3604583,
"name": "Immortal Technique"
},
"releaseDate": "2001-09-14",
"cover": "e510dd6d-dcdf-4272-9c68-f4580f2fbd14",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
"mediaMetadata": {
"tags": ["LOSSLESS"]
}
},
{
"type": "album",
"id": 456582219,
"title": "bbno$",
"artist": { "id": 8173944, "name": "bbno$" },
"releaseDate": "2025-10-17",
"cover": "8162c739-4bb2-4218-8915-93cb1f0d9eea",
"id": 509761344,
"title": "EMOTIONS",
"artist": {
"id": 49124576,
"name": "Nine Vicious"
},
"releaseDate": "2026-04-03",
"cover": "f29b18d3-b19f-45b1-968a-0ad360647130",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
"mediaMetadata": {
"tags": ["LOSSLESS"]
}
},
{
"type": "album",
"id": 506216548,
"title": "Declassified",
"artist": { "id": 5691796, "name": "Tom MacDonald" },
"releaseDate": "2026-03-13",
"cover": "5cf188e5-1660-4a01-bacc-10ca81e7af73",
"id": 15621057,
"title": "Triple F Life: Friends, Fans & Family (Deluxe Version)",
"artist": {
"id": 3654061,
"name": "Waka Flocka Flame"
},
"releaseDate": "2012-06-12",
"cover": "3199b7de-5e3d-486c-acf1-870ff4c60572",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
"mediaMetadata": {
"tags": ["LOSSLESS"]
}
},
{
"type": "album",
"id": 486673299,
"title": "Novelty",
"artist": { "id": 3589848, "name": "Goofy" },
"releaseDate": "2026-01-23",
"cover": "dd543595-e604-440e-a24c-e2eaccda3804",
"id": 103897783,
"title": "Freewave 3",
"artist": {
"id": 7923685,
"name": "Lucki"
},
"releaseDate": "2019-02-15",
"cover": "1d481a33-8b20-4ee3-b04b-5ac6e0fc5e78",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
},
{
"type": "album",
"id": 440469641,
"title": "Tha Carter VI",
"artist": { "id": 27518, "name": "Lil Wayne" },
"releaseDate": "2025-06-06",
"cover": "f2807cfe-6df8-4ea0-ab32-fd0ad9e91072",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
},
{
"type": "album",
"id": 384057608,
"title": "FREE VIPER!",
"artist": { "id": 10285483, "name": "Viper" },
"releaseDate": "2024-08-28",
"cover": "642d73d9-2a45-4598-bec3-033a09181bdf",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
},
{
"type": "album",
"id": 460015533,
"title": "CHARLIE",
"artist": { "id": 5691796, "name": "Tom MacDonald" },
"releaseDate": "2025-09-11",
"cover": "18180e3d-e178-4bd3-aada-e26ca32bc8f4",
"explicit": false,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
},
{
"type": "album",
"id": 123319314,
"title": "BABY G.O.A.T.",
"artist": { "id": 9160626, "name": "Kevo Muney" },
"releaseDate": "2019-12-12",
"cover": "6c87aee6-76da-413e-a639-79b670b3e3d1",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
},
{
"type": "album",
"id": 510493672,
"title": "ADL",
"artist": { "id": 9318056, "name": "Yeat" },
"releaseDate": "2026-03-27",
"cover": "b799025a-7ff6-494c-aa11-5e0461bbca40",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
},
{
"type": "album",
"id": 440096189,
"title": "Rebel",
"artist": { "id": 52081927, "name": "EsDeeKid" },
"releaseDate": "2025-06-20",
"cover": "3433b56c-6386-4e21-90ee-f2369d4bfde7",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
},
{
"type": "album",
"id": 85287313,
"title": "Toilet",
"artist": { "id": 9598562, "name": "Clown Core" },
"releaseDate": "2018-03-03",
"cover": "d6d32464-864e-422f-b8c3-0c30baa563b1",
"explicit": false,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS"] }
"mediaMetadata": {
"tags": ["LOSSLESS"]
}
}
]

1752
styles.css

File diff suppressed because it is too large Load diff

View file

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

22
tsconfig-eslint.json Normal file
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",
"moduleResolution": "Bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable", "webworker"],
"types": ["vite/client", "node"],
"types": ["vite/client", "node", "@types/wicg-file-system-access"],
"baseUrl": ".",
"paths": {
"!/*": ["node_modules/*"]
@ -17,5 +17,6 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["js/**/*.ts", "js/**/*.d.ts"]
"include": ["**/*.ts", "*.ts", "**/*.js", "*.js"],
"exclude": ["**/node_modules/*"]
}

View file

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

View file

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

View file

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

View file

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