fix(linting): fix js linting issues

This commit is contained in:
Daniel 2026-04-03 16:05:29 -05:00 committed by edideaur
parent ddc986bc52
commit 648e47e1d8
65 changed files with 1202 additions and 1037 deletions

View file

@ -1,5 +1,5 @@
export async function onRequest(context) { export async function onRequest(context) {
const { request, env } = context; const { request } = context;
const pageUrl = request.url; const pageUrl = request.url;
const metaHtml = ` const metaHtml = `

View file

@ -1,5 +1,5 @@
export async function onRequest(context) { export async function onRequest(context) {
const { request, env } = context; const { request } = context;
const pageUrl = request.url; const pageUrl = request.url;
const metaHtml = ` const metaHtml = `

View file

@ -1,5 +1,5 @@
export async function onRequest(context) { export async function onRequest(context) {
const { request, env } = context; const { request } = context;
const pageUrl = request.url; const pageUrl = request.url;
const metaHtml = ` const metaHtml = `

View file

@ -1,5 +1,5 @@
export async function onRequest(context) { export async function onRequest(context) {
const { request, env } = context; const { request } = context;
const pageUrl = request.url; const pageUrl = request.url;
const metaHtml = ` const metaHtml = `

View file

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

View file

@ -1,5 +1,5 @@
export async function onRequest(context) { export async function onRequest(context) {
const { request, env } = context; const { request } = context;
const pageUrl = request.url; const pageUrl = request.url;
const metaHtml = ` const metaHtml = `

View file

@ -1,5 +1,5 @@
export async function onRequest(context) { export async function onRequest(context) {
const { request, env } = context; const { request } = context;
const pageUrl = request.url; const pageUrl = request.url;
const metaHtml = ` const metaHtml = `

View file

@ -1,7 +1,7 @@
// functions/unreleased/[sheetId]/[projectName].js // functions/unreleased/[sheetId]/[projectName].js
const ARTISTS_NDJSON_URL = 'https://assets.artistgrid.cx/artists.ndjson'; const ARTISTS_NDJSON_URL = 'https://assets.artistgrid.cx/artists.ndjson';
const ASSETS_BASE_URL = 'https://assets.artistgrid.cx'; const _ASSETS_BASE_URL = 'https://assets.artistgrid.cx';
const TRACKER_API_ENDPOINTS = [ const TRACKER_API_ENDPOINTS = [
'https://trackerapi-1.artistgrid.cx/get/', 'https://trackerapi-1.artistgrid.cx/get/',
'https://trackerapi-2.artistgrid.cx/get/', 'https://trackerapi-2.artistgrid.cx/get/',
@ -14,7 +14,7 @@ function getSheetId(url) {
return match ? match[1] : null; return match ? match[1] : null;
} }
function normalizeArtistName(name) { function _normalizeArtistName(name) {
return name.toLowerCase().replace(/[^a-z0-9]/g, ''); return name.toLowerCase().replace(/[^a-z0-9]/g, '');
} }
@ -62,7 +62,7 @@ async function fetchTrackerData(sheetId) {
} }
return data; return data;
} catch (e) { } catch (e) {
console.warn(`Failed to fetch from ${baseUrl}, trying next...`); console.warn(`Failed to fetch from ${baseUrl}, trying next...`, e);
} }
} }
return null; return null;

View file

@ -1,5 +1,5 @@
export async function onRequest(context) { export async function onRequest(context) {
const { request, env } = context; const { request } = context;
const pageUrl = request.url; const pageUrl = request.url;
const metaHtml = ` const metaHtml = `

View file

@ -1,11 +1,12 @@
import { expect, suite, test } from 'vitest'; import { expect, test } from 'vitest';
import { HiFiClient, TidalResponse } from './HiFi'; import { HiFiClient, TidalResponse } from './HiFi';
import type { Album, PlaybackInfo, Track } from './container-classes';
const ARTIST_ID = 3523908; // deadmau5 const ARTIST_ID = 3523908; // deadmau5
const ALBUM_ID = 433360012; // deadmau5 - 4x4=12 const ALBUM_ID = 433360012; // deadmau5 - 4x4=12
const ALBUM_ATMOS = 463900719; // Taylor Swift - The Life of a Showgirl const _ALBUM_ATMOS = 463900719; // Taylor Swift - The Life of a Showgirl
const TRACK_ATMOS = 463900720; // Taylor Swift - The Fate of Ophelia const TRACK_ATMOS = 463900720; // Taylor Swift - The Fate of Ophelia
const TRACK_NO_LOSSLESS = 31097959; // deadmau5 - while(1<2) const _TRACK_NO_LOSSLESS = 31097959; // deadmau5 - while(1<2)
const TRACK_VIDEO = 466464180; // Taylow Swift - The Fate of Ophelia const TRACK_VIDEO = 466464180; // Taylow Swift - The Fate of Ophelia
const TRACK_LOSSLESS = 31097949; // deadmau5 - Avaritia const TRACK_LOSSLESS = 31097949; // deadmau5 - Avaritia
const PLAYLIST_ID = '36ea71a8-445e-41a4-82ab-6628c581535d'; // Pop Hits const PLAYLIST_ID = '36ea71a8-445e-41a4-82ab-6628c581535d'; // Pop Hits
@ -19,25 +20,25 @@ function checkVersion({ version }: { version?: string }) {
expect(version).equals(HiFiClient.API_VERSION); expect(version).equals(HiFiClient.API_VERSION);
} }
async function getJson(res: Response | Promise<Response>) { async function _getJson(res: Response | Promise<Response>) {
res = await res; res = await res;
expect(res).toBeInstanceOf(Response); expect(res).toBeInstanceOf(Response);
expect(res.ok).toBeTruthy(); expect(res.ok).toBeTruthy();
return await res.json(); return (await res.json()) as object;
} }
async function checkRoute( async function checkRoute(
route: string, route: string,
routeResult: () => Promise<any>, routeResult: () => Promise<Response>,
checks: (data: any) => Promise<void>, checks: (data: object) => Promise<void>,
mainKey: string | null = 'data' mainKey: string | null = 'data'
) { ) {
const routeData = await instance.query(route); const routeData = await instance.query(route);
const routeRes = await routeResult(); const routeRes = (await routeResult()) as unknown;
expect(routeData).toBeInstanceOf(TidalResponse); expect(routeData).toBeInstanceOf(TidalResponse);
expect(routeData).toEqual(routeRes); expect(routeData).toEqual(routeRes);
const json = await routeData.json(); const json = (await routeData.json()) as object;
checkVersion(json); checkVersion(json);
if (mainKey != null) { if (mainKey != null) {
@ -71,7 +72,7 @@ test('Fetch atmos track info', async () => {
await checkRoute( await checkRoute(
`/info/?id=${TRACK_ATMOS}`, `/info/?id=${TRACK_ATMOS}`,
() => instance.getInfo(TRACK_ATMOS), () => instance.getInfo(TRACK_ATMOS),
async (info) => { async (info: { data: Track }) => {
expect(info.data.audioModes).toContain('DOLBY_ATMOS'); expect(info.data.audioModes).toContain('DOLBY_ATMOS');
} }
); );
@ -81,8 +82,8 @@ test('Fetch track', async () => {
await checkRoute( await checkRoute(
`/track/?id=${TRACK_LOSSLESS}`, `/track/?id=${TRACK_LOSSLESS}`,
() => instance.getTrack(TRACK_LOSSLESS), () => instance.getTrack(TRACK_LOSSLESS),
async (track) => { async (track: { data: PlaybackInfo }) => {
expect(track.data.trackId).toBe(TRACK_LOSSLESS); expect(track?.data?.trackId).toBe(TRACK_LOSSLESS);
expect(track.data.assetPresentation).toBeTypeOf('string'); expect(track.data.assetPresentation).toBeTypeOf('string');
expect(track.data.audioQuality).toBeTypeOf('string'); expect(track.data.audioQuality).toBeTypeOf('string');
expect(track.data.manifestMimeType).toBeTypeOf('string'); expect(track.data.manifestMimeType).toBeTypeOf('string');
@ -102,7 +103,7 @@ test.skipIf(!instance.refreshToken)('Fetch recommendations', async () => {
await checkRoute( await checkRoute(
`/recommendations/?id=${ARTIST_ID}`, `/recommendations/?id=${ARTIST_ID}`,
() => instance.getRecommendations(ARTIST_ID), () => instance.getRecommendations(ARTIST_ID),
async (rec) => {} async (_data) => {}
); );
}); });
@ -110,7 +111,7 @@ test('Fetch similar artists', async () => {
await checkRoute( await checkRoute(
`/artist/similar/?id=${ARTIST_ID}`, `/artist/similar/?id=${ARTIST_ID}`,
() => instance.getSimilarArtists(ARTIST_ID), () => instance.getSimilarArtists(ARTIST_ID),
async (rec) => {}, async (_data) => {},
'artists' 'artists'
); );
}); });
@ -119,7 +120,7 @@ test('Fetch similar albums', async () => {
await checkRoute( await checkRoute(
`/album/similar/?id=${ALBUM_ID}`, `/album/similar/?id=${ALBUM_ID}`,
() => instance.getSimilarAlbums(ALBUM_ID), () => instance.getSimilarAlbums(ALBUM_ID),
async (rec) => {}, async (_data) => {},
'albums' 'albums'
); );
}); });
@ -128,7 +129,7 @@ test('Fetch artist info', async () => {
await checkRoute( await checkRoute(
`/artist/?id=${ARTIST_ID}`, `/artist/?id=${ARTIST_ID}`,
() => instance.getArtist(ARTIST_ID), () => instance.getArtist(ARTIST_ID),
async (info) => { async (info: { cover: string }) => {
expect(info).toHaveProperty('cover'); expect(info).toHaveProperty('cover');
expect(info.cover).not.toBeUndefined(); expect(info.cover).not.toBeUndefined();
}, },
@ -144,7 +145,7 @@ test('Search', async () => {
instance.search({ instance.search({
q: query, q: query,
}), }),
async (res) => {} async (_res) => {}
); );
}); });
@ -152,7 +153,7 @@ test('Fetch album info', async () => {
await checkRoute( await checkRoute(
`/album/?id=${ALBUM_ID}`, `/album/?id=${ALBUM_ID}`,
() => instance.getAlbum(ALBUM_ID), () => instance.getAlbum(ALBUM_ID),
async (info) => { async (info: { data: Album }) => {
expect(info.data).toHaveProperty('cover'); expect(info.data).toHaveProperty('cover');
expect(info.data.cover).not.toBeUndefined(); expect(info.data.cover).not.toBeUndefined();
} }
@ -163,7 +164,7 @@ test('Fetch playlist info', async () => {
await checkRoute( await checkRoute(
`/playlist/?id=${PLAYLIST_ID}`, `/playlist/?id=${PLAYLIST_ID}`,
() => instance.getPlaylist(PLAYLIST_ID), () => instance.getPlaylist(PLAYLIST_ID),
async (info) => { async (info: { playlist: { image: string } }) => {
expect(info.playlist).toHaveProperty('image'); expect(info.playlist).toHaveProperty('image');
expect(info.playlist.image).not.toBeUndefined(); expect(info.playlist.image).not.toBeUndefined();
}, },
@ -175,7 +176,7 @@ test.skipIf(!instance.refreshToken)('Fetch lyrics ', async () => {
await checkRoute( await checkRoute(
`/lyrics/?id=${TRACK_ATMOS}`, `/lyrics/?id=${TRACK_ATMOS}`,
() => instance.getLyrics(TRACK_ATMOS), () => instance.getLyrics(TRACK_ATMOS),
async (info) => {}, async (_info) => {},
'lyrics' 'lyrics'
); );
}); });
@ -184,7 +185,7 @@ test('Fetch video ', async () => {
await checkRoute( await checkRoute(
`/video/?id=${TRACK_VIDEO}`, `/video/?id=${TRACK_VIDEO}`,
() => instance.getVideo(TRACK_VIDEO), () => instance.getVideo(TRACK_VIDEO),
async (info) => {}, async (_info) => {},
'video' 'video'
); );
}); });
@ -193,7 +194,7 @@ test('Fetch track manifests ', async () => {
await checkRoute( await checkRoute(
`/trackManifests/?id=${TRACK_LOSSLESS}`, `/trackManifests/?id=${TRACK_LOSSLESS}`,
() => instance.getTrackManifest(TRACK_LOSSLESS), () => instance.getTrackManifest(TRACK_LOSSLESS),
async (info) => {}, async (_info) => {},
'data' 'data'
); );
}); });

View file

@ -1,3 +1,10 @@
/* 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 */
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
type Params = Record<string, string | number | undefined | null>; type Params = Record<string, string | number | undefined | null>;
@ -170,7 +177,7 @@ class HiFiClient {
scope?: string; scope?: string;
signal?: AbortSignal; signal?: AbortSignal;
force?: boolean; force?: boolean;
}) { }): Promise<string | null> {
if (!force && this.token && (this.appTokenExpiry < 0 || Date.now() < this.appTokenExpiry)) return this.token; if (!force && this.token && (this.appTokenExpiry < 0 || Date.now() < this.appTokenExpiry)) return this.token;
return await (this.#tokenPromise ??= (async () => { return await (this.#tokenPromise ??= (async () => {
@ -654,7 +661,7 @@ class HiFiClient {
}; };
const data = await this.#fetchJson(url, params, signal); const data = await this.#fetchJson(url, params, signal);
return HiFiClient.#jsonResponse({ version: API_VERSION, data: data }); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: data });
} }
#buildCoverEntry(cover_slug: string, name?: string | null, track_id?: number | null) { #buildCoverEntry(cover_slug: string, name?: string | null, track_id?: number | null) {

View file

@ -12,9 +12,9 @@ import { db } from './db';
* *
* @template C The accumulated shape of the settings object. * @template C The accumulated shape of the settings object.
*/ */
class ModernSettings<C extends object = {}> { class ModernSettings<C extends object = object> {
/** Internal map of pending async operations keyed by unique symbols. */ /** Internal map of pending async operations keyed by unique symbols. */
#pending: Record<symbol, Promise<any>> = {}; #pending: Record<symbol, Promise<void>> = {};
/** Whether new properties are prevented from being added. */ /** Whether new properties are prevented from being added. */
#finalized: boolean = false; #finalized: boolean = false;
@ -51,7 +51,7 @@ class ModernSettings<C extends object = {}> {
* @param callback Function producing the promise to track. * @param callback Function producing the promise to track.
* @returns The created promise. * @returns The created promise.
*/ */
#addPending<C extends Promise<any>>(callback: () => C): C { #addPending<C extends Promise<void>>(callback: () => C): C {
const sym = Symbol(); const sym = Symbol();
return (this.#pending[sym] = callback().finally(() => { return (this.#pending[sym] = callback().finally(() => {
@ -145,14 +145,14 @@ class ModernSettings<C extends object = {}> {
const legacyValue = localStorage.getItem(legacy?.key ?? backingKey ?? key); const legacyValue = localStorage.getItem(legacy?.key ?? backingKey ?? key);
if (legacyValue !== null) { if (legacyValue !== null) {
db.saveSetting(backingKey ?? key, legacy.transformer!(legacyValue)); await db.saveSetting(backingKey ?? key, legacy.transformer(legacyValue));
localStorage.removeItem(legacy?.key ?? backingKey ?? key); localStorage.removeItem(legacy?.key ?? backingKey ?? key);
} }
} }
} }
try { try {
value = (await db.getSetting(backingKey ?? key)) ?? defaultValue; value = ((await db.getSetting(backingKey ?? key)) as T) ?? defaultValue;
} catch { } catch {
value = defaultValue; value = defaultValue;
} }
@ -162,7 +162,7 @@ class ModernSettings<C extends object = {}> {
get: () => (getter ? getter(value, typed as ModernSettings<C> & C & Record<K, T>) : value), get: () => (getter ? getter(value, typed as ModernSettings<C> & C & Record<K, T>) : value),
set: (newValue: T) => { set: (newValue: T) => {
value = setter ? setter(newValue, typed as ModernSettings<C> & C & Record<K, T>) : newValue; value = setter ? setter(newValue, typed as ModernSettings<C> & C & Record<K, T>) : newValue;
this.#addPending(() => db.saveSetting(backingKey ?? key, value)); void this.#addPending(() => db.saveSetting(backingKey ?? key, value));
}, },
enumerable: true, enumerable: true,
}); });

View file

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

View file

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

View file

@ -5,10 +5,7 @@ import {
delay, delay,
isTrackUnavailable, isTrackUnavailable,
getExtensionFromBlob, getExtensionFromBlob,
getTrackTitle,
getFullArtistString,
getTrackDiscNumber, getTrackDiscNumber,
getMimeType,
} from './utils.js'; } from './utils.js';
import { preferDolbyAtmosSettings, trackDateSettings } from './storage.js'; import { preferDolbyAtmosSettings, trackDateSettings } from './storage.js';
import { APICache } from './cache.js'; import { APICache } from './cache.js';
@ -36,7 +33,6 @@ import {
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
export { resolveDownloadTotalBytes }; export { resolveDownloadTotalBytes };
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
export class LosslessAPI { export class LosslessAPI {
constructor(settings) { constructor(settings) {
@ -48,8 +44,8 @@ export class LosslessAPI {
this.streamCache = new Map(); this.streamCache = new Map();
setInterval( setInterval(
() => { async () => {
this.cache.clearExpired(); await this.cache.clearExpired();
this.pruneStreamCache(); this.pruneStreamCache();
}, },
1000 * 60 * 5 1000 * 60 * 5
@ -492,7 +488,7 @@ export class LosslessAPI {
await this.cache.set('search_all', query, results); await this.cache.set('search_all', query, results);
return results; return results;
} catch (error) { } catch (_error) {
// Fallback to individual searches if the backend proxy doesn't support ?q= or throws // Fallback to individual searches if the backend proxy doesn't support ?q= or throws
const [tracks, videos, artists, albums, playlists] = await Promise.all([ const [tracks, videos, artists, albums, playlists] = await Promise.all([
this.searchTracks(query, options).catch(() => ({ items: [] })), this.searchTracks(query, options).catch(() => ({ items: [] })),
@ -1558,7 +1554,7 @@ export class LosslessAPI {
} else { } else {
throw new Error('No URI in trackManifests response'); throw new Error('No URI in trackManifests response');
} }
} catch (err) { } catch (_err) {
// Fallback to /track endpoint // Fallback to /track endpoint
} }

View file

@ -2,7 +2,6 @@ import { expect, test, suite, vi } from 'vitest';
import { apiSettings, preferDolbyAtmosSettings, losslessContainerSettings } from './storage.js'; import { apiSettings, preferDolbyAtmosSettings, losslessContainerSettings } from './storage.js';
import { MusicAPI } from './music-api.js'; import { MusicAPI } from './music-api.js';
import { LyricsManager } from './lyrics.js'; import { LyricsManager } from './lyrics.js';
import type { LosslessAPI } from './api.js';
import { HiFiClient } from './HiFi.js'; import { HiFiClient } from './HiFi.js';
import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js'; import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js';
import { Mp4File } from '!/@dantheman827/taglib-ts/src/mp4/mp4File.js'; import { Mp4File } from '!/@dantheman827/taglib-ts/src/mp4/mp4File.js';
@ -13,6 +12,7 @@ import { ByteVector, StringType } from '!/@dantheman827/taglib-ts/src/byteVector
import { Mp4Codec } from '!/@dantheman827/taglib-ts/src/mp4/mp4Properties.js'; import { Mp4Codec } from '!/@dantheman827/taglib-ts/src/mp4/mp4Properties.js';
import { OggFile } from '!/@dantheman827/taglib-ts/src/ogg/oggFile.js'; import { OggFile } from '!/@dantheman827/taglib-ts/src/ogg/oggFile.js';
import { ffmpeg } from './ffmpeg.js'; import { ffmpeg } from './ffmpeg.js';
import type { Track } from './container-classes.js';
vi.mock(import('./storage.js'), async (importOriginal) => { vi.mock(import('./storage.js'), async (importOriginal) => {
const mod = await importOriginal(); const mod = await importOriginal();
@ -46,26 +46,25 @@ vi.mock(import('./doTimed.js'), async (importOriginal) => {
return { return {
...mod, ...mod,
doTimed: (label: string, fn: () => any) => { doTimed: function <T>(_label: string, fn: () => T): T {
return fn() as any; return fn();
}, },
doTimedAsync<T, R = T extends Promise<T> ? Promise<T> : T>( doTimedAsync<T, R = T extends Promise<T> ? Promise<T> : T>(
message: string, _message: string,
callback: () => R, callback: () => R,
throwError: boolean = false throwError: boolean = false
): R { ): R {
return new Promise<R>(async (resolve, reject) => { return new Promise<R>((resolve, reject) => {
try { Promise.resolve()
const ret = await callback(); .then(callback)
resolve(ret); .then(resolve)
} catch (err) { .catch((err) => {
if (throwError) { if (throwError) {
reject(err); reject(err as Error);
return; } else {
} resolve(undefined);
}
resolve(undefined); });
}
}) as R; }) as R;
}, },
} satisfies typeof import('./doTimed.js'); } satisfies typeof import('./doTimed.js');
@ -99,15 +98,14 @@ suite('Track Downloads', async () => {
const TRACK_ATMOS = 463900720; // Taylor Swift - The Fate of Ophelia const TRACK_ATMOS = 463900720; // Taylor Swift - The Fate of Ophelia
const TRACK_NO_LOSSLESS = 31097959; // deadmau5 - while(1<2) const TRACK_NO_LOSSLESS = 31097959; // deadmau5 - while(1<2)
const { LosslessAPI } = await import('./api.js');
await MusicAPI.initialize(apiSettings); await MusicAPI.initialize(apiSettings);
await LyricsManager.initialize(apiSettings); await LyricsManager.initialize(apiSettings);
await HiFiClient.initialize(); await HiFiClient.initialize();
const api: LosslessAPI = MusicAPI.instance.tidalAPI; const api = MusicAPI.instance.tidalAPI;
async function downloadTrack(trackId: number, quality: string) { async function downloadTrack(trackId: number, quality: string) {
const track = await (await HiFiClient.instance.getInfo(trackId)).json(); const track = (await (await HiFiClient.instance.getInfo(trackId)).json()) as { data: Track };
return await api.downloadTrack(trackId.toString(), quality, undefined, { return await api.downloadTrack(trackId.toString(), quality, undefined, {
track: track.data, track: track.data,
triggerDownload: false, triggerDownload: false,
@ -276,7 +274,9 @@ suite('Track Downloads', async () => {
ffmpegCalls: 1, ffmpegCalls: 1,
}, },
])('$display_quality', async ({ quality, container, preferDolbyAtmos, trackId, detection, ffmpegCalls }) => { ])('$display_quality', async ({ quality, container, preferDolbyAtmos, trackId, detection, ffmpegCalls }) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
vi.mocked(preferDolbyAtmosSettings.isEnabled).mockReturnValue(preferDolbyAtmos); vi.mocked(preferDolbyAtmosSettings.isEnabled).mockReturnValue(preferDolbyAtmos);
// eslint-disable-next-line @typescript-eslint/unbound-method
vi.mocked(losslessContainerSettings.getContainer).mockReturnValue(container); vi.mocked(losslessContainerSettings.getContainer).mockReturnValue(container);
const blob = await downloadTrack(trackId, quality); const blob = await downloadTrack(trackId, quality);
@ -286,7 +286,6 @@ suite('Track Downloads', async () => {
expect(file.isValid).toBe(true); expect(file.isValid).toBe(true);
let trak: Mp4Atom | null = null;
let stsd: Mp4Atom | null = null; let stsd: Mp4Atom | null = null;
let stsdData: ByteVector | null = null; let stsdData: ByteVector | null = null;
@ -313,13 +312,13 @@ suite('Track Downloads', async () => {
trak = null; trak = null;
} }
expect(trak).toBeInstanceOf(Mp4Atom); expect(trak).toBeInstanceOf(Mp4Atom);
stsd = trak!.find('mdia', 'minf', 'stbl', 'stsd'); stsd = trak.find('mdia', 'minf', 'stbl', 'stsd');
expect(stsd).toBeInstanceOf(Mp4Atom); expect(stsd).toBeInstanceOf(Mp4Atom);
await stream.seek(stsd.offset); await stream.seek(stsd.offset);
stsdData = await stream.readBlock(stsd.length); stsdData = await stream.readBlock(stsd.length);
} }
stream.seek(streamPosition); await stream.seek(streamPosition);
switch (detection) { switch (detection) {
case Detection.DolbyAtmos: { case Detection.DolbyAtmos: {

165
js/app.js
View file

@ -33,7 +33,6 @@ import { authManager } from './accounts/auth.js';
import { registerSW } from 'virtual:pwa-register'; import { registerSW } from 'virtual:pwa-register';
import { openEditProfile } from './profile.js'; import { openEditProfile } from './profile.js';
import { ThemeStore } from './themeStore.js'; import { ThemeStore } from './themeStore.js';
import { partyManager } from './listening-party.js';
import './commandPalette.js'; import './commandPalette.js';
import { initTracker } from './tracker.js'; import { initTracker } from './tracker.js';
import { import {
@ -406,6 +405,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Populate commit info // Populate commit info
{ {
const repo = 'https://github.com/monochrome-music/monochrome'; const repo = 'https://github.com/monochrome-music/monochrome';
// eslint-disable-next-line no-undef
const hash = typeof __COMMIT_HASH__ !== 'undefined' ? __COMMIT_HASH__ : 'dev'; const hash = typeof __COMMIT_HASH__ !== 'undefined' ? __COMMIT_HASH__ : 'dev';
const commitLink = const commitLink =
hash !== 'dev' && hash !== 'unknown' hash !== 'dev' && hash !== 'unknown'
@ -469,8 +469,7 @@ document.addEventListener('DOMContentLoaded', async () => {
await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality); await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality);
// Initialize tracker // Initialize tracker
initTracker(); initTracker().catch(console.error);
const castBtn = document.getElementById('cast-btn'); const castBtn = document.getElementById('cast-btn');
initializeCasting(audioPlayer, castBtn); initializeCasting(audioPlayer, castBtn);
@ -585,7 +584,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// checks for a saved handle and (in browser mode) requests read permission, // checks for a saved handle and (in browser mode) requests read permission,
// so this is a silent no-op when no folder is configured or permission is not // so this is a silent no-op when no folder is configured or permission is not
// yet granted. // yet granted.
scanLocalMediaFolder(); scanLocalMediaFolder().catch(console.error);
const scrobbler = new MultiScrobbler(); const scrobbler = new MultiScrobbler();
window.monochromeScrobbler = scrobbler; window.monochromeScrobbler = scrobbler;
@ -995,9 +994,9 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
}); });
document.getElementById('download-current-btn')?.addEventListener('click', () => { document.getElementById('download-current-btn')?.addEventListener('click', async () => {
if (Player.instance.currentTrack) { if (Player.instance.currentTrack) {
handleTrackAction( await handleTrackAction(
'download', 'download',
Player.instance.currentTrack, Player.instance.currentTrack,
Player.instance, Player.instance,
@ -1420,14 +1419,14 @@ document.addEventListener('DOMContentLoaded', async () => {
if (editingId) { if (editingId) {
// Edit // Edit
const cover = document.getElementById('playlist-cover-input').value.trim(); const cover = document.getElementById('playlist-cover-input').value.trim();
db.getPlaylist(editingId).then(async (playlist) => { await db.getPlaylist(editingId).then(async (playlist) => {
if (playlist) { if (playlist) {
playlist.name = name; playlist.name = name;
playlist.cover = cover; playlist.cover = cover;
playlist.description = description; playlist.description = description;
await handlePublicStatus(playlist); await handlePublicStatus(playlist);
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
syncManager.syncUserPlaylist(playlist, 'update'); await syncManager.syncUserPlaylist(playlist, 'update');
UIRenderer.instance.renderLibraryPage(); UIRenderer.instance.renderLibraryPage();
// Also update current page if we are on it // Also update current page if we are on it
if (window.location.pathname === `/userplaylist/${editingId}`) { if (window.location.pathname === `/userplaylist/${editingId}`) {
@ -1948,7 +1947,7 @@ document.addEventListener('DOMContentLoaded', async () => {
console.log(`Added ${tracks.length} tracks (including pending)`); console.log(`Added ${tracks.length} tracks (including pending)`);
} }
db.createPlaylist(name, tracks, cover, description).then(async (playlist) => { await db.createPlaylist(name, tracks, cover, description).then(async (playlist) => {
await handlePublicStatus(playlist); await handlePublicStatus(playlist);
// Update DB again with isPublic flag // Update DB again with isPublic flag
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
@ -1971,7 +1970,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (e.target.closest('.edit-playlist-btn')) { if (e.target.closest('.edit-playlist-btn')) {
const card = e.target.closest('.user-playlist'); const card = e.target.closest('.user-playlist');
const playlistId = card.dataset.userPlaylistId; const playlistId = card.dataset.userPlaylistId;
db.getPlaylist(playlistId).then(async (playlist) => { await db.getPlaylist(playlistId).then(async (playlist) => {
if (playlist) { if (playlist) {
const modal = document.getElementById('playlist-modal'); const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist'; document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
@ -1991,7 +1990,10 @@ document.addEventListener('DOMContentLoaded', async () => {
shareBtn.style.display = playlist.isPublic ? 'flex' : 'none'; shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
shareBtn.onclick = () => { shareBtn.onclick = () => {
const url = getShareUrl(`/userplaylist/${playlist.id}`); const url = getShareUrl(`/userplaylist/${playlist.id}`);
navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!')); navigator.clipboard
.writeText(url)
.then(() => alert('Link copied to clipboard!'))
.catch(console.error);
}; };
} }
@ -2030,73 +2032,77 @@ document.addEventListener('DOMContentLoaded', async () => {
const card = e.target.closest('.user-playlist'); const card = e.target.closest('.user-playlist');
const playlistId = card.dataset.userPlaylistId; const playlistId = card.dataset.userPlaylistId;
if (confirm('Are you sure you want to delete this playlist?')) { if (confirm('Are you sure you want to delete this playlist?')) {
db.deletePlaylist(playlistId).then(() => { await db.deletePlaylist(playlistId);
syncManager.syncUserPlaylist({ id: playlistId }, 'delete'); await syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
UIRenderer.instance.renderLibraryPage(); UIRenderer.instance.renderLibraryPage();
});
} }
} }
if (e.target.closest('#edit-playlist-btn')) { if (e.target.closest('#edit-playlist-btn')) {
const playlistId = window.location.pathname.split('/')[2]; const playlistId = window.location.pathname.split('/')[2];
db.getPlaylist(playlistId).then((playlist) => { await db
if (playlist) { .getPlaylist(playlistId)
const modal = document.getElementById('playlist-modal'); .then((playlist) => {
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist'; if (playlist) {
document.getElementById('playlist-name-input').value = playlist.name; const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-cover-input').value = playlist.cover || ''; document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
document.getElementById('playlist-description-input').value = playlist.description || ''; 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 publicToggle = document.getElementById('playlist-public-toggle');
const shareBtn = document.getElementById('playlist-share-btn'); const shareBtn = document.getElementById('playlist-share-btn');
if (publicToggle) publicToggle.checked = !!playlist.isPublic; if (publicToggle) publicToggle.checked = !!playlist.isPublic;
if (shareBtn) { if (shareBtn) {
shareBtn.style.display = playlist.isPublic ? 'flex' : 'none'; shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
shareBtn.onclick = () => { shareBtn.onclick = async () => {
const url = getShareUrl(`/userplaylist/${playlist.id}`); const url = getShareUrl(`/userplaylist/${playlist.id}`);
navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!')); await navigator.clipboard
}; .writeText(url)
.then(() => alert('Link copied to clipboard!'))
.catch(console.error);
};
}
// 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 .catch(console.error);
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();
}
});
} }
if (e.target.closest('#delete-playlist-btn')) { if (e.target.closest('#delete-playlist-btn')) {
const playlistId = window.location.pathname.split('/')[2]; const playlistId = window.location.pathname.split('/')[2];
if (confirm('Are you sure you want to delete this playlist?')) { if (confirm('Are you sure you want to delete this playlist?')) {
db.deletePlaylist(playlistId).then(() => { await db.deletePlaylist(playlistId);
syncManager.syncUserPlaylist({ id: playlistId }, 'delete'); await syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
navigate('/library'); navigate('/library');
});
} }
} }
@ -2105,7 +2111,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const btn = e.target.closest('.remove-from-playlist-btn'); const btn = e.target.closest('.remove-from-playlist-btn');
const playlistId = window.location.pathname.split('/')[2]; const playlistId = window.location.pathname.split('/')[2];
db.getPlaylist(playlistId).then(async (playlist) => { await db.getPlaylist(playlistId).then(async (playlist) => {
let trackId = null; let trackId = null;
let trackType = null; let trackType = null;
@ -2124,7 +2130,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (trackId) { if (trackId) {
const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId, trackType); const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId, trackType);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
const scrollTop = document.querySelector('.main-content').scrollTop; const scrollTop = document.querySelector('.main-content').scrollTop;
await UIRenderer.instance.renderPlaylistPage(playlistId, 'user'); await UIRenderer.instance.renderPlaylistPage(playlistId, 'user');
document.querySelector('.main-content').scrollTop = scrollTop; document.querySelector('.main-content').scrollTop = scrollTop;
@ -2645,7 +2651,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// PWA Update Logic // PWA Update Logic
if (window.__AUTH_GATE__) { if (window.__AUTH_GATE__) {
disablePwaForAuthGate(); await disablePwaForAuthGate().catch(console.error);
} else { } else {
const updateSW = registerSW({ const updateSW = registerSW({
onNeedRefresh() { onNeedRefresh() {
@ -2763,10 +2769,10 @@ document.addEventListener('DOMContentLoaded', async () => {
); );
}); });
} else { } else {
headerAccountBtn.addEventListener('click', (e) => { headerAccountBtn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
headerAccountDropdown.classList.toggle('active'); headerAccountDropdown.classList.toggle('active');
updateAccountDropdown(); await updateAccountDropdown();
}); });
} }
@ -2839,8 +2845,8 @@ document.addEventListener('DOMContentLoaded', async () => {
<button class="btn-primary" id="header-create-profile">Create Profile</button> <button class="btn-primary" id="header-create-profile">Create Profile</button>
<button class="btn-secondary danger" id="header-sign-out">Sign Out</button> <button class="btn-secondary danger" id="header-sign-out">Sign Out</button>
`; `;
document.getElementById('header-create-profile').onclick = () => { document.getElementById('header-create-profile').onclick = async () => {
openEditProfile(); openEditProfile().catch(console.error);
headerAccountDropdown.classList.remove('active'); headerAccountDropdown.classList.remove('active');
}; };
} }
@ -2928,7 +2934,7 @@ function showMissingTracksNotification(missingTracks, playlistName) {
const newCopyBtn = copyBtn.cloneNode(true); const newCopyBtn = copyBtn.cloneNode(true);
copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn); copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn);
newCopyBtn.addEventListener('click', () => { newCopyBtn.addEventListener('click', async () => {
const header = `Missing songs from ${playlistName} import:\n\n`; const header = `Missing songs from ${playlistName} import:\n\n`;
const textToCopy = const textToCopy =
header + header +
@ -2940,11 +2946,14 @@ function showMissingTracksNotification(missingTracks, playlistName) {
}) })
.join('\n'); .join('\n');
navigator.clipboard.writeText(textToCopy).then(() => { await navigator.clipboard
const originalText = newCopyBtn.textContent; .writeText(textToCopy)
newCopyBtn.textContent = 'Copied!'; .then(async () => {
setTimeout(() => (newCopyBtn.textContent = originalText), 2000); const originalText = newCopyBtn.textContent;
}); newCopyBtn.textContent = 'Copied!';
setTimeout(() => (newCopyBtn.textContent = originalText), 2000);
})
.catch(console.error);
}); });
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -783,7 +783,6 @@ export class MusicDatabase {
} }
// Return lightweight copy without tracks // Return lightweight copy without tracks
// eslint-disable-next-line no-unused-vars
const { tracks, ...minified } = playlist; const { tracks, ...minified } = playlist;
return minified; return minified;
}); });

View file

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

View file

@ -17,10 +17,10 @@ import { ZipStreamWriter, ZipBlobWriter, FolderPickerWriter, SequentialFileWrite
import { FfmpegProgress } from './ffmpeg.types.js'; import { FfmpegProgress } from './ffmpeg.types.js';
import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './progressEvents.js'; import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './progressEvents.js';
import { db } from './db.js'; import { db } from './db.js';
import { modernSettings } from './ModernSettings.js'; import { BulkDownloadMethod, modernSettings } from './ModernSettings.js';
import { SVG_CLOSE } from './icons.ts'; import { SVG_CLOSE } from './icons.ts';
import { LyricsManager } from './lyrics.js';
import { MusicAPI } from './music-api.js'; import { MusicAPI } from './music-api.js';
import { LyricsManager } from './lyrics.js';
const downloadTasks = new Map(); const downloadTasks = new Map();
const bulkDownloadTasks = new Map(); const bulkDownloadTasks = new Map();
@ -167,7 +167,7 @@ export function showNotification(message) {
}, 1500); }, 1500);
} }
export function addDownloadTask(trackId, track, filename, api, abortController) { export function addDownloadTask(trackId, track, _filename, api, abortController) {
const container = createDownloadNotification(); const container = createDownloadNotification();
const taskEl = document.createElement('div'); const taskEl = document.createElement('div');
@ -508,7 +508,7 @@ async function createSingleTrackFolderWriter() {
const method = modernSettings.bulkDownloadMethod; const method = modernSettings.bulkDownloadMethod;
const hasFolderPicker = 'showDirectoryPicker' in window; const hasFolderPicker = 'showDirectoryPicker' in window;
if (method === 'local') { if (method === BulkDownloadMethod.LocalMedia) {
const localHandle = await db.getSetting('local_folder_handle'); const localHandle = await db.getSetting('local_folder_handle');
if (hasFolderPicker && localHandle && typeof localHandle.requestPermission === 'function') { if (hasFolderPicker && localHandle && typeof localHandle.requestPermission === 'function') {
try { try {
@ -521,7 +521,7 @@ async function createSingleTrackFolderWriter() {
return null; return null;
} }
if (method === 'folder' && hasFolderPicker) { if (method === BulkDownloadMethod.Folder && hasFolderPicker) {
const rememberFolder = modernSettings.rememberBulkDownloadFolder; const rememberFolder = modernSettings.rememberBulkDownloadFolder;
const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null; const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null;
// Try to reuse the saved handle silently first. // Try to reuse the saved handle silently first.
@ -564,7 +564,7 @@ async function createBulkWriter(folderName) {
const hasFolderPicker = 'showDirectoryPicker' in window; const hasFolderPicker = 'showDirectoryPicker' in window;
// ── Local Media Folder method ──────────────────────────────────────────── // ── Local Media Folder method ────────────────────────────────────────────
if (method === 'local') { if (method === BulkDownloadMethod.LocalMedia) {
const localHandle = await db.getSetting('local_folder_handle'); const localHandle = await db.getSetting('local_folder_handle');
if (hasFolderPicker) { if (hasFolderPicker) {
// Browser mode: try to reuse the stored handle with write permission // Browser mode: try to reuse the stored handle with write permission
@ -594,7 +594,7 @@ async function createBulkWriter(folderName) {
} }
// ── Folder Picker method ───────────────────────────────────────────────── // ── Folder Picker method ─────────────────────────────────────────────────
if (method === 'folder' && hasFolderPicker) { if (method === BulkDownloadMethod.Folder && hasFolderPicker) {
const rememberFolder = modernSettings.rememberBulkDownloadFolder; const rememberFolder = modernSettings.rememberBulkDownloadFolder;
const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null; const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null;
try { try {
@ -614,7 +614,7 @@ async function createBulkWriter(folderName) {
} }
} }
if (method === 'individual') { if (method === BulkDownloadMethod.Individual) {
return SequentialFileWriter; return SequentialFileWriter;
} }
// method === 'zip' (or folder picker unavailable as fallback) // method === 'zip' (or folder picker unavailable as fallback)
@ -659,7 +659,7 @@ async function startBulkDownload({
completeBulkDownload(notification, true); completeBulkDownload(notification, true);
// If the download went to the local media folder, refresh the local library. // If the download went to the local media folder, refresh the local library.
if (modernSettings.bulkDownloadMethod === 'local') { if (modernSettings.bulkDownloadMethod === BulkDownloadMethod.LocalMedia) {
window.refreshLocalMediaFolder?.(); window.refreshLocalMediaFolder?.();
} }
} catch (error) { } catch (error) {
@ -672,7 +672,7 @@ async function startBulkDownload({
} }
} }
export async function downloadTracks(tracks, api, quality, lyricsManager = null) { export async function downloadTracks(tracks, api, quality, _lyricsManager = null) {
const folderName = `Queue - ${new Date().toISOString().slice(0, 10)}`; const folderName = `Queue - ${new Date().toISOString().slice(0, 10)}`;
await startBulkDownload({ await startBulkDownload({
tracks, tracks,
@ -687,7 +687,7 @@ export async function downloadTracks(tracks, api, quality, lyricsManager = null)
}); });
} }
export async function downloadAlbum(album, tracks, api, quality, lyricsManager = null) { export async function downloadAlbum(album, tracks, api, quality, _lyricsManager = null) {
const releaseDateStr = const releaseDateStr =
album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : ''); album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null; const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
@ -712,7 +712,7 @@ export async function downloadAlbum(album, tracks, api, quality, lyricsManager =
}); });
} }
export async function downloadPlaylist(playlist, tracks, api, quality, lyricsManager = null) { export async function downloadPlaylist(playlist, tracks, api, quality, _lyricsManager = null) {
const folderName = formatPathTemplate(modernSettings.folderTemplate, { const folderName = formatPathTemplate(modernSettings.folderTemplate, {
albumTitle: playlist.title, albumTitle: playlist.title,
albumArtist: 'Playlist', albumArtist: 'Playlist',
@ -1120,7 +1120,7 @@ export async function downloadTrackWithMetadata(
// If the target is the local media folder, do a cheap partial update: // If the target is the local media folder, do a cheap partial update:
// pass the downloaded blob and base filename so only this one track's metadata // pass the downloaded blob and base filename so only this one track's metadata
// is read and inserted into localFilesCache instead of re-walking the whole folder. // is read and inserted into localFilesCache instead of re-walking the whole folder.
if (modernSettings.bulkDownloadMethod === 'local') { if (modernSettings.bulkDownloadMethod === BulkDownloadMethod.LocalMedia) {
window.refreshLocalMediaFolder?.(blob, finalFilename); window.refreshLocalMediaFolder?.(blob, finalFilename);
} }
@ -1136,7 +1136,7 @@ export async function downloadTrackWithMetadata(
} }
} }
export async function downloadLikedTracks(tracks, api, quality, lyricsManager = null) { export async function downloadLikedTracks(tracks, api, quality, _lyricsManager = null) {
const folderName = `Liked Tracks - ${new Date().toISOString().slice(0, 10)}`; const folderName = `Liked Tracks - ${new Date().toISOString().slice(0, 10)}`;
await startBulkDownload({ await startBulkDownload({
tracks, tracks,

View file

@ -56,6 +56,9 @@ import {
} from './analytics.js'; } from './analytics.js';
import { SVG_BIN, SVG_MUTE, SVG_PAUSE, SVG_PLAY, SVG_VOLUME, SVG_CHECKBOX, SVG_CHECKBOX_CHECKED } from './icons.js'; import { SVG_BIN, SVG_MUTE, SVG_PAUSE, SVG_PLAY, SVG_VOLUME, SVG_CHECKBOX, SVG_CHECKBOX_CHECKED } from './icons.js';
import { partyManager } from './listening-party.js'; import { partyManager } from './listening-party.js';
import { MusicAPI } from './music-api.js';
import { LyricsManager } from './lyrics.js';
import { Player } from './player.js';
let currentTrackIdForWaveform = null; let currentTrackIdForWaveform = null;
@ -78,21 +81,21 @@ function handleTrackTouchStart(e) {
isLongPress = false; isLongPress = false;
longPressTrackItem = trackItem; longPressTrackItem = trackItem;
longPressTimer = setTimeout(() => { longPressTimer = setTimeout(async () => {
isLongPress = true; isLongPress = true;
toggleTrackSelection(trackItem, true, false); toggleTrackSelection(trackItem, true, false);
hapticLongPress(); await hapticLongPress();
}, LONG_PRESS_DURATION); }, LONG_PRESS_DURATION);
} }
function handleTrackTouchMove(e) { function handleTrackTouchMove(_e) {
if (longPressTimer) { if (longPressTimer) {
clearTimeout(longPressTimer); clearTimeout(longPressTimer);
longPressTimer = null; longPressTimer = null;
} }
} }
function handleTrackTouchEnd(e) { function handleTrackTouchEnd(_e) {
if (longPressTimer) { if (longPressTimer) {
clearTimeout(longPressTimer); clearTimeout(longPressTimer);
longPressTimer = null; longPressTimer = null;
@ -204,7 +207,7 @@ function toggleTrackSelection(trackItem, ctrlHeld, shiftHeld) {
document.body.classList.toggle('multi-select-mode', trackSelection.isSelecting); document.body.classList.toggle('multi-select-mode', trackSelection.isSelecting);
} }
function showMultiSelectPlaylistModal(tracks) { async function showMultiSelectPlaylistModal(tracks) {
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'modal-overlay'; modal.className = 'modal-overlay';
modal.style.cssText = modal.style.cssText =
@ -237,7 +240,7 @@ function showMultiSelectPlaylistModal(tracks) {
document.body.appendChild(modal); document.body.appendChild(modal);
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
db.getPlaylists(true).then((playlists) => { await db.getPlaylists(true).then((playlists) => {
const listEl = modal.querySelector('.playlist-list'); const listEl = modal.querySelector('.playlist-list');
if (playlists.length === 0) { if (playlists.length === 0) {
listEl.innerHTML = '<div style="padding: 12px; color: var(--muted-foreground);">No playlists yet</div>'; listEl.innerHTML = '<div style="padding: 12px; color: var(--muted-foreground);">No playlists yet</div>';
@ -260,17 +263,17 @@ function showMultiSelectPlaylistModal(tracks) {
for (const track of tracks) { for (const track of tracks) {
await db.addTrackToPlaylist(playlistId, track); await db.addTrackToPlaylist(playlistId, track);
} }
syncManager.syncUserPlaylist(await db.getPlaylist(playlistId), 'update'); await syncManager.syncUserPlaylist(await db.getPlaylist(playlistId), 'update');
showNotification(`Added ${tracks.length} tracks to playlist`); showNotification(`Added ${tracks.length} tracks to playlist`);
closeModal(); closeModal();
}); });
}); });
}); });
modal.querySelector('.create-new-playlist').addEventListener('click', () => { modal.querySelector('.create-new-playlist').addEventListener('click', async () => {
const name = prompt('Playlist name:'); const name = prompt('Playlist name:');
if (name) { if (name) {
db.createPlaylist(name, tracks).then((playlist) => { await db.createPlaylist(name, tracks).then((_playlist) => {
showNotification(`Created playlist "${name}" with ${tracks.length} tracks`); showNotification(`Created playlist "${name}" with ${tracks.length} tracks`);
closeModal(); closeModal();
}); });
@ -278,127 +281,132 @@ function showMultiSelectPlaylistModal(tracks) {
}); });
} }
export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { const playPauseBtn = document.querySelector('.now-playing-bar .play-pause-btn');
const playPauseBtn = document.querySelector('.now-playing-bar .play-pause-btn'); const nextBtn = document.getElementById('next-btn');
const nextBtn = document.getElementById('next-btn'); const prevBtn = document.getElementById('prev-btn');
const prevBtn = document.getElementById('prev-btn'); const shuffleBtn = document.getElementById('shuffle-btn');
const shuffleBtn = document.getElementById('shuffle-btn'); const repeatBtn = document.getElementById('repeat-btn');
const repeatBtn = document.getElementById('repeat-btn'); const homeStartRadioBtn = document.getElementById('home-start-infinite-radio-btn');
const homeStartRadioBtn = document.getElementById('home-start-infinite-radio-btn'); const sleepTimerBtnDesktop = document.getElementById('sleep-timer-btn-desktop');
const sleepTimerBtnDesktop = document.getElementById('sleep-timer-btn-desktop');
const volumeBar = document.getElementById('volume-bar'); const _volumeBar = document.getElementById('volume-bar');
const volumeFill = document.getElementById('volume-fill'); const volumeFill = document.getElementById('volume-fill');
const volumeBtn = document.getElementById('volume-btn'); const volumeBtn = document.getElementById('volume-btn');
const updateVolumeUI = () => { const updateVolumeUI = () => {
const activeEl = player.activeElement; const activeEl = Player.instance.activeElement;
const { muted } = activeEl; const { muted } = activeEl;
const volume = player.userVolume; const volume = Player.instance.userVolume;
volumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE(20) : SVG_VOLUME(20); volumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE(20) : SVG_VOLUME(20);
const effectiveVolume = muted ? 0 : volume * 100; const effectiveVolume = muted ? 0 : volume * 100;
volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`); volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`);
volumeFill.style.width = `${effectiveVolume}%`; volumeFill.style.width = `${effectiveVolume}%`;
}; };
function clearSelection() { function clearSelection() {
trackSelection.selectedIds.clear(); trackSelection.selectedIds.clear();
trackSelection.lastClickedId = null; trackSelection.lastClickedId = null;
trackSelection.isSelecting = false; trackSelection.isSelecting = false;
document.body.classList.remove('multi-select-mode'); document.body.classList.remove('multi-select-mode');
document.querySelectorAll('.track-item.selected').forEach((el) => { document.querySelectorAll('.track-item.selected').forEach((el) => {
el.classList.remove('selected'); el.classList.remove('selected');
}); });
document.querySelectorAll('.track-checkbox').forEach((checkbox) => { document.querySelectorAll('.track-checkbox').forEach((checkbox) => {
checkbox.innerHTML = SVG_CHECKBOX(18); checkbox.innerHTML = SVG_CHECKBOX(18);
checkbox.classList.remove('checked'); checkbox.classList.remove('checked');
}); });
updateSelectionBar(); updateSelectionBar();
} }
function updateSelectionBar() { function updateSelectionBar() {
let bar = document.getElementById('selection-bar'); let bar = document.getElementById('selection-bar');
if (!bar) { if (!bar) {
bar = document.createElement('div'); bar = document.createElement('div');
bar.id = 'selection-bar'; bar.id = 'selection-bar';
bar.className = 'selection-bar'; bar.className = 'selection-bar';
bar.innerHTML = ` bar.innerHTML = `
<span class="selection-count">0 selected</span> <span class="selection-count">0 selected</span>
<div class="selection-actions"> <div class="selection-actions">
<button data-action="play-selected">Play</button> <button data-action="play-selected">Play</button>
<button data-action="add-to-queue-selected">Add to queue</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="add-to-playlist-selected">Add to playlist</button>
<button data-action="download-selected">Download</button> <button data-action="download-selected">Download</button>
<button data-action="like-selected">Like</button> <button data-action="like-selected">Like</button>
</div> </div>
<button data-action="clear-selection" style="margin-left: 8px;">Clear</button> <button data-action="clear-selection" style="margin-left: 8px;">Clear</button>
`; `;
document.body.appendChild(bar); document.body.appendChild(bar);
bar.querySelectorAll('button').forEach((btn) => { bar.querySelectorAll('button').forEach((btn) => {
btn.addEventListener('click', () => handleSelectionAction(btn.dataset.action)); 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);
}
}); });
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) { if (homeStartRadioBtn) {
homeStartRadioBtn.addEventListener('click', async () => { homeStartRadioBtn.addEventListener('click', async () => {
await player.enableRadio(); await player.enableRadio();
@ -417,14 +425,14 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
} }
}); });
element.addEventListener('play', () => { element.addEventListener('play', async () => {
if (player.activeElement !== element) return; if (player.activeElement !== element) return;
// Initialize audio context manager for EQ (only once) // Initialize audio context manager for EQ (only once)
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
audioContextManager.init(element); audioContextManager.init(element);
} }
audioContextManager.resume(); await audioContextManager.resume();
if (player.currentTrack) { if (player.currentTrack) {
// Track play event // Track play event
@ -435,7 +443,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
scrobbler.updateNowPlaying(player.currentTrack); scrobbler.updateNowPlaying(player.currentTrack);
} }
updateWaveform(); await updateWaveform();
} }
playPauseBtn.innerHTML = SVG_PAUSE(20); playPauseBtn.innerHTML = SVG_PAUSE(20);
@ -479,7 +487,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) { if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) {
historyLoggedTrackId = player.currentTrack.id; historyLoggedTrackId = player.currentTrack.id;
const historyEntry = await db.addToHistory(player.currentTrack); const historyEntry = await db.addToHistory(player.currentTrack);
syncManager.syncHistoryItem(historyEntry); await syncManager.syncHistoryItem(historyEntry);
if (window.location.hash === '#recent') { if (window.location.hash === '#recent') {
ui.renderRecentPage(); ui.renderRecentPage();
@ -554,31 +562,31 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
setupMediaListeners(player.video); setupMediaListeners(player.video);
} }
playPauseBtn.addEventListener('click', () => { playPauseBtn.addEventListener('click', async () => {
hapticMedium(); await hapticMedium();
player.handlePlayPause(); player.handlePlayPause();
}); });
nextBtn.addEventListener('click', () => { nextBtn.addEventListener('click', async () => {
hapticMedium(); await hapticMedium();
trackSkipTrack(player.currentTrack, 'next'); trackSkipTrack(player.currentTrack, 'next');
player.playNext(); player.playNext();
}); });
prevBtn.addEventListener('click', () => { prevBtn.addEventListener('click', async () => {
hapticMedium(); await hapticMedium();
trackSkipTrack(player.currentTrack, 'previous'); trackSkipTrack(player.currentTrack, 'previous');
player.playPrev(); player.playPrev();
}); });
shuffleBtn.addEventListener('click', () => { shuffleBtn.addEventListener('click', async () => {
hapticLight(); await hapticLight();
player.toggleShuffle(); player.toggleShuffle();
trackToggleShuffle(player.shuffleActive); trackToggleShuffle(player.shuffleActive);
shuffleBtn.classList.toggle('active', player.shuffleActive); shuffleBtn.classList.toggle('active', player.shuffleActive);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
}); });
repeatBtn.addEventListener('click', () => { repeatBtn.addEventListener('click', async () => {
hapticLight(); await hapticLight();
const mode = player.toggleRepeat(); const mode = player.toggleRepeat();
trackToggleRepeat(mode === REPEAT_MODE.OFF ? 'off' : mode === REPEAT_MODE.ALL ? 'all' : 'one'); trackToggleRepeat(mode === REPEAT_MODE.OFF ? 'off' : mode === REPEAT_MODE.ALL ? 'all' : 'one');
repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF); repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF);
@ -709,7 +717,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
} }
}; };
window.addEventListener('waveform-toggle', (e) => { window.addEventListener('waveform-toggle', async (e) => {
if (!e.detail.enabled) { if (!e.detail.enabled) {
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const playerControls = document.querySelector('.player-controls'); const playerControls = document.querySelector('.player-controls');
@ -722,7 +730,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
playerControls.classList.remove('waveform-loaded'); playerControls.classList.remove('waveform-loaded');
} }
} }
updateWaveform(); await updateWaveform();
}); });
if (volumeBtn) { if (volumeBtn) {
@ -1102,7 +1110,7 @@ export async function showAddToPlaylistModal(track) {
e.stopPropagation(); e.stopPropagation();
await db.removeTrackFromPlaylist(playlistId, track.id); await db.removeTrackFromPlaylist(playlistId, track.id);
const updatedPlaylist = await db.getPlaylist(playlistId); const updatedPlaylist = await db.getPlaylist(playlistId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
showNotification(`Removed from playlist: ${option.querySelector('span').textContent}`); showNotification(`Removed from playlist: ${option.querySelector('span').textContent}`);
await renderModal(); await renderModal();
} else { } else {
@ -1110,7 +1118,7 @@ export async function showAddToPlaylistModal(track) {
await db.addTrackToPlaylist(playlistId, track); await db.addTrackToPlaylist(playlistId, track);
const updatedPlaylist = await db.getPlaylist(playlistId); const updatedPlaylist = await db.getPlaylist(playlistId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
showNotification(`Added to playlist: ${option.querySelector('span').textContent}`); showNotification(`Added to playlist: ${option.querySelector('span').textContent}`);
closeModal(); closeModal();
} }
@ -1272,14 +1280,14 @@ export async function handleTrackAction(
if (action === 'add-to-queue') { if (action === 'add-to-queue') {
player.addToQueue(tracks); player.addToQueue(tracks);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Added ${tracks.length} tracks to queue`); showNotification(`Added ${tracks.length} tracks to queue`);
return; return;
} }
if (action === 'play-next') { if (action === 'play-next') {
player.addNextToQueue(tracks); player.addNextToQueue(tracks);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Playing next: ${tracks.length} tracks`); showNotification(`Playing next: ${tracks.length} tracks`);
return; return;
} }
@ -1345,12 +1353,12 @@ export async function handleTrackAction(
if (action === 'add-to-queue') { if (action === 'add-to-queue') {
trackAddToQueue(item, 'end'); trackAddToQueue(item, 'end');
player.addToQueue(item); player.addToQueue(item);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Added to queue: ${item.title}`); showNotification(`Added to queue: ${item.title}`);
} else if (action === 'play-next') { } else if (action === 'play-next') {
trackPlayNext(item); trackPlayNext(item);
player.addNextToQueue(item); player.addNextToQueue(item);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Playing next: ${item.title}`); showNotification(`Playing next: ${item.title}`);
} else if (action === 'play-card') { } else if (action === 'play-card') {
player.setQueue([item], 0); player.setQueue([item], 0);
@ -1368,7 +1376,7 @@ export async function handleTrackAction(
await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager); await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager);
} else if (action === 'toggle-like') { } else if (action === 'toggle-like') {
const added = await db.toggleFavorite(type, item); const added = await db.toggleFavorite(type, item);
syncManager.syncLibraryItem(type, item, added); await syncManager.syncLibraryItem(type, item, added);
// Track like/unlike // Track like/unlike
if (added) { if (added) {
@ -1624,7 +1632,7 @@ export async function handleTrackAction(
e.stopPropagation(); e.stopPropagation();
await db.removeTrackFromPlaylist(playlistId, item.id); await db.removeTrackFromPlaylist(playlistId, item.id);
const updatedPlaylist = await db.getPlaylist(playlistId); const updatedPlaylist = await db.getPlaylist(playlistId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
showNotification(`Removed from playlist: ${option.querySelector('span').textContent}`); showNotification(`Removed from playlist: ${option.querySelector('span').textContent}`);
await renderModal(); await renderModal();
} else { } else {
@ -1632,7 +1640,7 @@ export async function handleTrackAction(
await db.addTrackToPlaylist(playlistId, item); await db.addTrackToPlaylist(playlistId, item);
const updatedPlaylist = await db.getPlaylist(playlistId); const updatedPlaylist = await db.getPlaylist(playlistId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
showNotification(`Added to playlist: ${option.querySelector('span').textContent}`); showNotification(`Added to playlist: ${option.querySelector('span').textContent}`);
closeModal(); closeModal();
} }
@ -1670,9 +1678,12 @@ export async function handleTrackAction(
const url = getShareUrl(storedHref ? storedHref : `/${typeForUrl}/${item.id || item.uuid}`); const url = getShareUrl(storedHref ? storedHref : `/${typeForUrl}/${item.id || item.uuid}`);
trackCopyLink(type, item.id || item.uuid); trackCopyLink(type, item.id || item.uuid);
navigator.clipboard.writeText(url).then(() => { await navigator.clipboard
showNotification('Link copied to clipboard!'); .writeText(url)
}); .then(() => {
showNotification('Link copied to clipboard!');
})
.catch(console.error);
} else if (action === 'open-in-new-tab') { } else if (action === 'open-in-new-tab') {
// Use stored href from card if available, otherwise construct URL // Use stored href from card if available, otherwise construct URL
const contextMenu = document.getElementById('context-menu'); const contextMenu = document.getElementById('context-menu');
@ -2412,7 +2423,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
positionMenu(contextMenu, e.clientX, e.clientY); positionMenu(contextMenu, e.clientX, e.clientY);
}); });
document.addEventListener('click', (e) => { document.addEventListener('click', async (e) => {
if (contextMenu.style.display === 'block') { if (contextMenu.style.display === 'block') {
if (contextMenu._originalHTML) { if (contextMenu._originalHTML) {
contextMenu.innerHTML = contextMenu._originalHTML; contextMenu.innerHTML = contextMenu._originalHTML;
@ -2432,7 +2443,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
} }
}); });
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', async (e) => {
if (e.key === 'Escape' && trackSelection.isSelecting) { if (e.key === 'Escape' && trackSelection.isSelecting) {
clearSelection(); clearSelection();
} }
@ -2488,34 +2499,39 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
trackPlayNext(t); trackPlayNext(t);
player.addNextToQueue(t); player.addNextToQueue(t);
}); });
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Playing next: ${selectedTracks.length} tracks`); showNotification(`Playing next: ${selectedTracks.length} tracks`);
clearSelection(); clearSelection();
break; break;
case 'add-to-queue': case 'add-to-queue':
player.addToQueue(selectedTracks); player.addToQueue(selectedTracks);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Added ${selectedTracks.length} tracks to queue`); showNotification(`Added ${selectedTracks.length} tracks to queue`);
clearSelection(); clearSelection();
break; break;
case 'toggle-like': case 'toggle-like':
selectedTracks.forEach(async (t) => { selectedTracks.forEach(async (t) => {
const added = await db.toggleFavorite('track', t); const added = await db.toggleFavorite('track', t);
syncManager.syncLibraryItem('track', t, added); await syncManager.syncLibraryItem('track', t, added);
}); });
showNotification(`Liked ${selectedTracks.length} tracks`); showNotification(`Liked ${selectedTracks.length} tracks`);
clearSelection(); clearSelection();
break; break;
case 'add-to-playlist': case 'add-to-playlist':
showMultiSelectPlaylistModal(selectedTracks); await showMultiSelectPlaylistModal(selectedTracks);
clearSelection(); clearSelection();
break; break;
case 'download': case 'download':
selectedTracks.forEach((t) => {
downloadTrackWithMetadata(t, downloadQualitySettings.getQuality(), api, lyricsManager);
});
showNotification(`Downloading ${selectedTracks.length} tracks`); showNotification(`Downloading ${selectedTracks.length} tracks`);
clearSelection(); clearSelection();
for (const track of selectedTracks) {
await downloadTrackWithMetadata(
track,
downloadQualitySettings.getQuality(),
api,
lyricsManager
);
}
break; break;
default: default:
clearSelection(); clearSelection();

View file

@ -114,7 +114,7 @@ async function ffmpegWorker(
reject(new FfmpegError('Worker failed: ' + error.message)); reject(new FfmpegError('Worker failed: ' + error.message));
}; };
(async () => { void (async () => {
const transferables = []; const transferables = [];
if (audioData) transferables.push(audioData); if (audioData) transferables.push(audioData);
for (const f of extraFiles) { for (const f of extraFiles) {

View file

@ -1,9 +1,9 @@
import { expect, test, suite } from 'vitest'; import { expect, test } from 'vitest';
import { ffmpeg } from './ffmpeg'; import { ffmpeg } from './ffmpeg';
test('Run `ffmpeg --help`', async () => { test('Run `ffmpeg --help`', async () => {
const lines: string[] = []; const lines: string[] = [];
const info = await ffmpeg(null, { await ffmpeg(null, {
rawArgs: ['--help'], rawArgs: ['--help'],
logConsole: false, logConsole: false,
outputName: null, outputName: null,

View file

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

4
js/global.d.ts vendored
View file

@ -31,3 +31,7 @@ declare module 'https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm' {
type WithRequiredKeys<T> = { type WithRequiredKeys<T> = {
[K in keyof T]-?: T[K] | undefined; [K in keyof T]-?: T[K] | undefined;
}; };
declare global {
const __COMMIT_HASH__: string | undefined;
}

16
js/indexedIterator.ts Normal file
View file

@ -0,0 +1,16 @@
/**
* A generic iterator that yields the index, total count, and item for any finite iterable.
*
* @template T - The type of items in the iterable.
* @param iterable - The iterable to process.
* @returns A generator that yields an object with index, total, and item.
*/
export default function* indexedIterator<T>(
iterable: Iterable<T>
): Generator<{ index: number; total: number; item: T }> {
const array = Array.from(iterable); // Convert the iterable to an array
const total = array.length; // Get the total count of items
for (let index = 0; index < total; index++) {
yield { index, total, item: array[index] }; // Yield index, total, and item
}
}

View file

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

View file

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

View file

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

View file

@ -96,7 +96,7 @@ export class ListeningPartyManager {
document.getElementById('copy-party-link-btn')?.addEventListener('click', () => this.copyInviteLink()); document.getElementById('copy-party-link-btn')?.addEventListener('click', () => this.copyInviteLink());
document.getElementById('party-chat-send-btn')?.addEventListener('click', () => this.sendChatMessage()); document.getElementById('party-chat-send-btn')?.addEventListener('click', () => this.sendChatMessage());
document.getElementById('party-chat-input')?.addEventListener('keypress', (e) => { document.getElementById('party-chat-input')?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.sendChatMessage(); if (e.key === 'Enter') this.sendChatMessage().catch(console.error);
}); });
} }
@ -104,13 +104,13 @@ export class ListeningPartyManager {
const nameInput = document.getElementById('party-name-input'); const nameInput = document.getElementById('party-name-input');
const user = authManager.user; const user = authManager.user;
if (!user) { if (!user) {
Modal.alert('Login Required', 'You must be logged in to host a listening party.'); await Modal.alert('Login Required', 'You must be logged in to host a listening party.');
return; return;
} }
const pbUser = await syncManager._getUserRecord(user.$id); const pbUser = await syncManager._getUserRecord(user.$id);
if (!pbUser) { if (!pbUser) {
Modal.alert('Sync Error', 'Failed to sync user data. Please try again.'); await Modal.alert('Sync Error', 'Failed to sync user data. Please try again.');
return; return;
} }
@ -171,19 +171,19 @@ export class ListeningPartyManager {
this.setupSubscriptions(partyId); this.setupSubscriptions(partyId);
this.startHeartbeat(); this.startHeartbeat();
this.renderPartyUI(); this.renderPartyUI();
this.loadInitialData(partyId); await this.loadInitialData(partyId);
if (!this.isHost) { if (!this.isHost) {
this.lockControls(); this.lockControls();
this.setupGuestSyncInterception(); this.setupGuestSyncInterception();
if (party.current_track) { if (party.current_track) {
await audioContextManager.resume(); await audioContextManager.resume();
this.syncWithHost(party); await this.syncWithHost(party);
} }
} }
} catch (error) { } catch (error) {
console.error('Join error:', error); console.error('Join error:', error);
Modal.alert('Error', 'Failed to join the party. It may have ended.'); await Modal.alert('Error', 'Failed to join the party. It may have ended.');
navigate('/parties'); navigate('/parties');
} finally { } finally {
this.isJoining = false; this.isJoining = false;
@ -199,7 +199,7 @@ export class ListeningPartyManager {
); );
return confirmed ? { profile: null } : false; return confirmed ? { profile: null } : false;
} else { } else {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const cached = localStorage.getItem('party_guest_profile'); const cached = localStorage.getItem('party_guest_profile');
const defaultName = cached ? JSON.parse(cached).name : ''; const defaultName = cached ? JSON.parse(cached).name : '';
@ -225,7 +225,9 @@ export class ListeningPartyManager {
}, },
{ label: 'Cancel', type: 'secondary', callback: () => false }, { label: 'Cancel', type: 'secondary', callback: () => false },
], ],
}).then(resolve); })
.then(resolve)
.catch(reject);
}); });
} }
} }
@ -262,29 +264,31 @@ export class ListeningPartyManager {
pb.collection('parties') pb.collection('parties')
.subscribe( .subscribe(
partyId, partyId,
(e) => { async (e) => {
if (e.action === 'update') { if (e.action === 'update') {
this.currentParty = e.record; this.currentParty = e.record;
if (!this.isHost) this.syncWithHost(e.record); if (!this.isHost) await this.syncWithHost(e.record);
this.updatePartyHeader(); this.updatePartyHeader();
} else if (e.action === 'delete') { } else if (e.action === 'delete') {
Modal.alert('Party Ended', 'The host has ended the listening party.'); await Modal.alert('Party Ended', 'The host has ended the listening party.');
this.leaveParty(false); await this.leaveParty(false);
} }
}, },
{ f_id } { f_id }
) )
.then((unsub) => this.unsubscribeFunctions.push(unsub)); .then((unsub) => this.unsubscribeFunctions.push(unsub))
.catch(console.error);
pb.collection('party_members') pb.collection('party_members')
.subscribe( .subscribe(
'*', '*',
(e) => { async (e) => {
if (e.record.party === partyId) this.loadMembers(); if (e.record.party === partyId) await this.loadMembers();
}, },
{ f_id } { f_id }
) )
.then((unsub) => this.unsubscribeFunctions.push(unsub)); .then((unsub) => this.unsubscribeFunctions.push(unsub))
.catch(console.error);
pb.collection('party_messages') pb.collection('party_messages')
.subscribe( .subscribe(
@ -294,23 +298,25 @@ export class ListeningPartyManager {
}, },
{ f_id } { f_id }
) )
.then((unsub) => this.unsubscribeFunctions.push(unsub)); .then((unsub) => this.unsubscribeFunctions.push(unsub))
.catch(console.error);
pb.collection('party_requests') pb.collection('party_requests')
.subscribe( .subscribe(
'*', '*',
(e) => { async (e) => {
if (e.record.party === partyId) this.loadRequests(); if (e.record.party === partyId) await this.loadRequests();
}, },
{ f_id } { f_id }
) )
.then((unsub) => this.unsubscribeFunctions.push(unsub)); .then((unsub) => this.unsubscribeFunctions.push(unsub))
.catch(console.error);
} }
async loadInitialData(partyId) { async loadInitialData(_partyId) {
this.loadMembers(); await this.loadMembers();
this.loadMessages(); await this.loadMessages();
this.loadRequests(); await this.loadRequests();
} }
async loadMembers() { async loadMembers() {
@ -439,7 +445,7 @@ export class ListeningPartyManager {
</div> </div>
${this.isHost ? `<button class="btn-primary btn-sm add-request-btn" data-req-id="${r.id}" style="padding: 0.4rem 1rem; font-size: 0.8rem; flex-shrink: 0; white-space: nowrap;">Add to Queue</button>` : ''} ${this.isHost ? `<button class="btn-primary btn-sm add-request-btn" data-req-id="${r.id}" style="padding: 0.4rem 1rem; font-size: 0.8rem; flex-shrink: 0; white-space: nowrap;">Add to Queue</button>` : ''}
</div>`; </div>`;
} catch (e) { } catch (_e) {
return ''; return '';
} }
}) })
@ -510,7 +516,7 @@ export class ListeningPartyManager {
await pb await pb
.collection('party_messages') .collection('party_messages')
.create({ party: this.currentParty.id, sender_name: profile.name, content }, { f_id }); .create({ party: this.currentParty.id, sender_name: profile.name, content }, { f_id });
} catch (e) {} } catch (_e) {}
} }
async requestSong(track) { async requestSong(track) {
@ -560,7 +566,7 @@ export class ListeningPartyManager {
if (party.is_playing) { if (party.is_playing) {
if (el.paused) { if (el.paused) {
const success = await player.safePlay(el); const _success = await player.safePlay(el);
} }
const latency = (Date.now() - party.playback_timestamp) / 1000; const latency = (Date.now() - party.playback_timestamp) / 1000;
const targetTime = party.is_playing ? party.playback_time + latency : party.playback_time; const targetTime = party.is_playing ? party.playback_time + latency : party.playback_time;
@ -640,7 +646,7 @@ export class ListeningPartyManager {
}, },
{ f_id: authManager.user?.$id } { f_id: authManager.user?.$id }
); );
} catch (e) {} } catch (_e) {}
}; };
['play', 'pause', 'seeked'].forEach((ev) => { ['play', 'pause', 'seeked'].forEach((ev) => {
player.audio.addEventListener(ev, updateParty); player.audio.addEventListener(ev, updateParty);
@ -667,7 +673,7 @@ export class ListeningPartyManager {
'danger' 'danger'
); );
if (!leave) return; if (!leave) return;
this.leaveParty(); await this.leaveParty();
} }
return await originalPlayTrackFromQueue(...args); return await originalPlayTrackFromQueue(...args);
}; };
@ -680,7 +686,7 @@ export class ListeningPartyManager {
await pb await pb
.collection('party_members') .collection('party_members')
.update(this.memberId, { last_seen: Date.now() }, { f_id: authManager.user?.$id || 'guest' }); .update(this.memberId, { last_seen: Date.now() }, { f_id: authManager.user?.$id || 'guest' });
} catch (e) {} } catch (_e) {}
}, 30000); }, 30000);
} }
@ -705,11 +711,11 @@ export class ListeningPartyManager {
await cleanup('party_messages'); await cleanup('party_messages');
await cleanup('party_requests'); await cleanup('party_requests');
await pb.collection('parties').delete(this.currentParty.id, { f_id }); await pb.collection('parties').delete(this.currentParty.id, { f_id });
} catch (e) {} } catch (_e) {}
} else if (this.memberId) { } else if (this.memberId) {
try { try {
await pb.collection('party_members').delete(this.memberId, { f_id }); await pb.collection('party_members').delete(this.memberId, { f_id });
} catch (e) {} } catch (_e) {}
} }
this.restorePlayerMethods(); this.restorePlayerMethods();
this.unlockControls(); this.unlockControls();
@ -733,7 +739,7 @@ export class ListeningPartyManager {
} }
copyInviteLink() { copyInviteLink() {
navigator.clipboard.writeText(`${window.location.origin}/party/${this.currentParty.id}`); navigator.clipboard.writeText(`${window.location.origin}/party/${this.currentParty.id}`).catch(console.error);
showNotification('Invite link copied!'); showNotification('Invite link copied!');
} }

View file

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

View file

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

View file

@ -37,7 +37,7 @@ export function prefetchMetadataObjects(track, api, coverBlob = null) {
* @param {string} quality - Audio quality * @param {string} quality - Audio quality
* @returns {Promise<Blob>} - Audio blob with embedded metadata * @returns {Promise<Blob>} - Audio blob with embedded metadata
*/ */
export async function addMetadataToAudio(audioBlob, track, api, _quality, prefetchPromises) { export async function addMetadataToAudio(audioBlob, track, _api, _quality, prefetchPromises) {
const { coverFetch, lyricsFetch } = prefetchPromises; const { coverFetch, lyricsFetch } = prefetchPromises;
/** /**

View file

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

View file

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

View file

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

View file

@ -133,7 +133,7 @@ export class Player {
} }
this.loadQueueState(); this.loadQueueState();
this.setupMediaSession(); await this.setupMediaSession();
this.radioEnabled = radioSettings.isEnabled(); this.radioEnabled = radioSettings.isEnabled();
this.radioSeeds = []; this.radioSeeds = [];
@ -142,19 +142,19 @@ export class Player {
this.playbackSequence = 0; this.playbackSequence = 0;
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', async () => {
this.saveQueueState(); await this.saveQueueState();
}); });
// Handle visibility change for iOS - AudioContext gets suspended when screen locks // Handle visibility change for iOS - AudioContext gets suspended when screen locks
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', async () => {
const el = this.activeElement; const el = this.activeElement;
if (document.visibilityState === 'visible' && !el.paused) { if (document.visibilityState === 'visible' && !el.paused) {
// Ensure audio context is resumed when user returns to the app // Ensure audio context is resumed when user returns to the app
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
audioContextManager.init(el); audioContextManager.init(el);
} }
audioContextManager.resume(); await audioContextManager.resume();
} }
if (document.visibilityState === 'visible' && this.autoplayBlocked) { if (document.visibilityState === 'visible' && this.autoplayBlocked) {
this.autoplayBlocked = false; this.autoplayBlocked = false;
@ -370,7 +370,7 @@ export class Player {
} }
} }
saveQueueState() { async saveQueueState() {
queueManager.saveQueue({ queueManager.saveQueue({
queue: this.queue, queue: this.queue,
shuffledQueue: this.shuffledQueue, shuffledQueue: this.shuffledQueue,
@ -381,14 +381,14 @@ export class Player {
}); });
if (window.renderQueueFunction) { if (window.renderQueueFunction) {
window.renderQueueFunction(); await window.renderQueueFunction();
} }
} }
setupMediaSession() { async setupMediaSession() {
if (!('mediaSession' in navigator)) return; if (!('mediaSession' in navigator)) return;
const setHandlers = () => { const setHandlers = async () => {
navigator.mediaSession.setActionHandler('play', async () => { navigator.mediaSession.setActionHandler('play', async () => {
const el = this.activeElement; const el = this.activeElement;
// Initialize and resume audio context first (required for iOS lock screen) // Initialize and resume audio context first (required for iOS lock screen)
@ -404,7 +404,7 @@ export class Player {
} catch (e) { } catch (e) {
console.error('MediaSession play failed:', e); console.error('MediaSession play failed:', e);
// If play fails, try to handle it like a regular play/pause // If play fails, try to handle it like a regular play/pause
this.handlePlayPause(); await this.handlePlayPause();
} }
}); });
@ -429,7 +429,7 @@ export class Player {
this.applyReplayGain(); this.applyReplayGain();
} }
await audioContextManager.resume(); await audioContextManager.resume();
this.playNext(); await this.playNext();
}); });
if (!this.isIOS) { if (!this.isIOS) {
@ -465,7 +465,7 @@ export class Player {
this.video.addEventListener('playing', () => setHandlers(), { once: true }); this.video.addEventListener('playing', () => setHandlers(), { once: true });
} }
} else { } else {
setHandlers(); await setHandlers();
} }
} }
@ -542,7 +542,7 @@ export class Player {
video.play().catch(() => {}); video.play().catch(() => {});
await this.setupVideoQualitySelector(); await this.setupVideoQualitySelector();
}); });
this.hls.on(Hls.Events.ERROR, (event, data) => { this.hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) { if (data.fatal) {
console.warn('HLS fatal error:', data.type); console.warn('HLS fatal error:', data.type);
if (fallbackImg) video.replaceWith(fallbackImg); if (fallbackImg) video.replaceWith(fallbackImg);
@ -578,7 +578,7 @@ export class Player {
const levels = this.hls.levels; const levels = this.hls.levels;
const qualityLabels = [ const qualityLabels = [
'Auto', 'Auto',
...levels.map((level, i) => { ...levels.map((level) => {
const height = level.height || 0; const height = level.height || 0;
const bandwidth = level.bitrate || 0; const bandwidth = level.bitrate || 0;
if (height >= 1080) return '1080p'; if (height >= 1080) return '1080p';
@ -645,7 +645,7 @@ export class Player {
artist: video.artist || (video.artists && video.artists[0]) || 'Unknown Artist', artist: video.artist || (video.artists && video.artists[0]) || 'Unknown Artist',
album: video.album || { title: 'Video', cover: video.image || video.cover }, album: video.album || { title: 'Video', cover: video.image || video.cover },
}; };
this.setQueue([videoTrack], 0); await this.setQueue([videoTrack], 0);
await this.playTrackFromQueue(); await this.playTrackFromQueue();
} }
@ -663,7 +663,7 @@ export class Player {
const track = currentQueue[this.currentQueueIndex]; const track = currentQueue[this.currentQueueIndex];
if (track.isUnavailable) { if (track.isUnavailable) {
console.warn(`Attempted to play unavailable track: ${track.title}. Skipping...`); console.warn(`Attempted to play unavailable track: ${track.title}. Skipping...`);
this.playNext(); await this.playNext();
return; return;
} }
@ -671,7 +671,7 @@ export class Player {
const { contentBlockingSettings } = await import('./storage.js'); const { contentBlockingSettings } = await import('./storage.js');
if (contentBlockingSettings.shouldHideTrack(track)) { if (contentBlockingSettings.shouldHideTrack(track)) {
console.warn(`Attempted to play blocked track: ${track.title}. Skipping...`); console.warn(`Attempted to play blocked track: ${track.title}. Skipping...`);
this.playNext(); await this.playNext();
return; return;
} }
@ -694,15 +694,15 @@ export class Player {
this.currentQueueIndex >= currentQueue.length - 1 this.currentQueueIndex >= currentQueue.length - 1
) { ) {
console.log('[playTrackFromQueue] Fetching more tracks!'); console.log('[playTrackFromQueue] Fetching more tracks!');
this.fetchMoreArtistPopularTracks().then((newTracks) => { await this.fetchMoreArtistPopularTracks().then(async (newTracks) => {
console.log('[playTrackFromQueue] Got tracks:', newTracks?.length); console.log('[playTrackFromQueue] Got tracks:', newTracks?.length);
if (newTracks && newTracks.length > 0) { if (newTracks && newTracks.length > 0) {
this.addToQueue(newTracks); await this.addToQueue(newTracks);
} }
}); });
} }
this.saveQueueState(); await this.saveQueueState();
this.currentTrack = track; this.currentTrack = track;
@ -818,7 +818,7 @@ export class Player {
if (!streamUrl) { if (!streamUrl) {
console.warn(`Podcast episode ${trackTitle} audio URL is missing. Skipping.`); console.warn(`Podcast episode ${trackTitle} audio URL is missing. Skipping.`);
track.isUnavailable = true; track.isUnavailable = true;
this.playNext(); await this.playNext();
return; return;
} }
@ -851,7 +851,7 @@ export class Player {
if (!streamUrl) { if (!streamUrl) {
console.warn(`Track ${trackTitle} audio URL is missing. Skipping.`); console.warn(`Track ${trackTitle} audio URL is missing. Skipping.`);
track.isUnavailable = true; track.isUnavailable = true;
this.playNext(); await this.playNext();
return; return;
} }
@ -1018,7 +1018,7 @@ export class Player {
} }
} }
this.preloadNextTracks(); void this.preloadNextTracks().catch(console.error);
} catch (error) { } catch (error) {
if (this.playbackSequence !== currentSequence) return; if (this.playbackSequence !== currentSequence) return;
if (error && (error.name === 'NotAllowedError' || error.name === 'AbortError')) { if (error && (error.name === 'NotAllowedError' || error.name === 'AbortError')) {
@ -1041,6 +1041,8 @@ export class Player {
this.isFallbackRetry = false; this.isFallbackRetry = false;
this.isFallbackInProgress = false; this.isFallbackInProgress = false;
} }
return;
} }
console.error(`Could not play track: ${trackTitle}`, error); console.error(`Could not play track: ${trackTitle}`, error);
@ -1051,33 +1053,33 @@ export class Player {
} }
} }
playAtIndex(index) { async playAtIndex(index) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (index >= 0 && index < currentQueue.length) { if (index >= 0 && index < currentQueue.length) {
this.currentQueueIndex = index; this.currentQueueIndex = index;
this.playTrackFromQueue(0, 0); await this.playTrackFromQueue(0, 0);
} }
} }
playNext(recursiveCount = 0) { async playNext(recursiveCount = 0) {
const currentQueue = this.getCurrentQueue(); const currentQueue = this.getCurrentQueue();
const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1; const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1;
if (recursiveCount > currentQueue.length) { if (recursiveCount > currentQueue.length) {
if (this.radioEnabled && isLastTrack) { if (this.radioEnabled && isLastTrack) {
this.fetchRadioRecommendations().then(() => { this.fetchRadioRecommendations().then(async () => {
const updatedQueue = this.getCurrentQueue(); const updatedQueue = this.getCurrentQueue();
if (this.currentQueueIndex < updatedQueue.length - 1) { if (this.currentQueueIndex < updatedQueue.length - 1) {
this.playNext(0); await this.playNext(0);
} }
}); });
return; return;
} }
if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) { if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) {
this.fetchMoreArtistPopularTracks().then((newTracks) => { await this.fetchMoreArtistPopularTracks().then(async (newTracks) => {
if (newTracks && newTracks.length > 0) { if (newTracks && newTracks.length > 0) {
this.addToQueue(newTracks); await this.addToQueue(newTracks);
this.playNext(0); await this.playNext(0);
} else { } else {
this.activeElement.pause(); this.activeElement.pause();
} }
@ -1088,52 +1090,54 @@ export class Player {
return; return;
} }
import('./storage.js').then(({ contentBlockingSettings }) => { import('./storage.js')
if ( .then(async ({ contentBlockingSettings }) => {
this.repeatMode === REPEAT_MODE.ONE && if (
!currentQueue[this.currentQueueIndex]?.isUnavailable && this.repeatMode === REPEAT_MODE.ONE &&
!contentBlockingSettings.shouldHideTrack(currentQueue[this.currentQueueIndex]) !currentQueue[this.currentQueueIndex]?.isUnavailable &&
) { !contentBlockingSettings.shouldHideTrack(currentQueue[this.currentQueueIndex])
this.playTrackFromQueue(0, recursiveCount); ) {
return; await 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);
} }
} else if (this.radioEnabled) {
this.fetchRadioRecommendations().then(() => { if (!isLastTrack) {
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)
this.currentQueueIndex++; this.currentQueueIndex++;
this.playTrackFromQueue(0, recursiveCount); const track = currentQueue[this.currentQueueIndex];
}); if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
return; return this.playNext(recursiveCount + 1);
} else if (this.repeatMode === REPEAT_MODE.ALL) { }
this.currentQueueIndex = 0; } else if (this.radioEnabled) {
const track = currentQueue[this.currentQueueIndex]; this.fetchRadioRecommendations().then(async () => {
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) { const updatedQueue = this.getCurrentQueue();
return this.playNext(recursiveCount + 1); 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 = []) { async enableRadio(seeds = []) {
@ -1141,20 +1145,20 @@ export class Player {
radioSettings.setEnabled(true); radioSettings.setEnabled(true);
if (seeds.length === 0) { if (seeds.length === 0) {
this.wipeQueue(); await this.wipeQueue();
const pickedSeeds = await this.pickRadioSeeds(); const pickedSeeds = await this.pickRadioSeeds();
if (pickedSeeds.length > 0) { if (pickedSeeds.length > 0) {
this.radioSeeds = pickedSeeds; this.radioSeeds = pickedSeeds;
const initialQueue = [...pickedSeeds].sort(() => 0.5 - Math.random()).slice(0, 5); const initialQueue = [...pickedSeeds].sort(() => 0.5 - Math.random()).slice(0, 5);
this.setQueue(initialQueue, 0, true); await this.setQueue(initialQueue, 0, true);
this.playAtIndex(0); await this.playAtIndex(0);
} }
} else { } else {
this.radioSeeds = Array.isArray(seeds) ? seeds : [seeds]; this.radioSeeds = Array.isArray(seeds) ? seeds : [seeds];
this.wipeQueue(); await this.wipeQueue();
const initialQueue = Array.isArray(seeds) ? seeds.slice(0, 5) : [seeds]; const initialQueue = Array.isArray(seeds) ? seeds.slice(0, 5) : [seeds];
this.setQueue(initialQueue, 0, true); await this.setQueue(initialQueue, 0, true);
this.playAtIndex(0); await this.playAtIndex(0);
} }
const currentQueue = this.getCurrentQueue(); const currentQueue = this.getCurrentQueue();
@ -1217,7 +1221,7 @@ export class Player {
if (newTracks.length > 0) { if (newTracks.length > 0) {
const tracksToAdd = newTracks.sort(() => 0.5 - Math.random()).slice(0, 5); const tracksToAdd = newTracks.sort(() => 0.5 - Math.random()).slice(0, 5);
this.addToQueue(tracksToAdd); await this.addToQueue(tracksToAdd);
} }
} }
} catch (error) { } catch (error) {
@ -1304,13 +1308,15 @@ export class Player {
return; return;
} }
import('./storage.js').then(({ contentBlockingSettings }) => { import('./storage.js')
const track = currentQueue[this.currentQueueIndex]; .then(async ({ contentBlockingSettings }) => {
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) { const track = currentQueue[this.currentQueueIndex];
return this.playPrev(recursiveCount + 1); if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
} return this.playPrev(recursiveCount + 1);
this.playTrackFromQueue(0, recursiveCount); }
}); await this.playTrackFromQueue(0, recursiveCount);
})
.catch(console.error);
} }
} }
@ -1318,28 +1324,28 @@ export class Player {
return this.currentTrack?.type === 'video' ? this.video : this.audio; return this.currentTrack?.type === 'video' ? this.video : this.audio;
} }
handlePlayPause() { async handlePlayPause() {
const el = this.activeElement; const el = this.activeElement;
const hasSource = el.src || el.currentSrc || el.srcObject || this.shakaInitialized; const hasSource = el.src || el.currentSrc || el.srcObject || this.shakaInitialized;
if (!hasSource || el.error) { if (!hasSource || el.error) {
if (this.currentTrack) { if (this.currentTrack) {
this.playTrackFromQueue(0, 0); await this.playTrackFromQueue(0, 0);
} }
return; return;
} }
if (el.paused) { if (el.paused) {
this.safePlay(el).catch((e) => { this.safePlay(el).catch(async (e) => {
if (e.name === 'NotAllowedError' || e.name === 'AbortError') return; if (e.name === 'NotAllowedError' || e.name === 'AbortError') return;
console.error('Play failed, reloading track:', e); console.error('Play failed, reloading track:', e);
if (this.currentTrack) { if (this.currentTrack) {
this.playTrackFromQueue(0, 0); await this.playTrackFromQueue(0, 0);
} }
}); });
} else { } else {
el.pause(); el.pause();
this.saveQueueState(); await this.saveQueueState();
} }
} }
@ -1358,7 +1364,7 @@ export class Player {
this.updateMediaSessionPositionState(); this.updateMediaSessionPositionState();
} }
toggleShuffle() { async toggleShuffle() {
this.shuffleActive = !this.shuffleActive; this.shuffleActive = !this.shuffleActive;
if (this.shuffleActive) { if (this.shuffleActive) {
@ -1389,17 +1395,17 @@ export class Player {
} }
this.preloadCache.clear(); this.preloadCache.clear();
this.preloadNextTracks(); void this.preloadNextTracks().catch(console.error);
this.saveQueueState(); await this.saveQueueState();
} }
toggleRepeat() { async toggleRepeat() {
this.repeatMode = (this.repeatMode + 1) % 3; this.repeatMode = (this.repeatMode + 1) % 3;
this.saveQueueState(); await this.saveQueueState();
return this.repeatMode; return this.repeatMode;
} }
setQueue(tracks, startIndex = 0, isRadio = false) { async setQueue(tracks, startIndex = 0, isRadio = false) {
if (!isRadio) { if (!isRadio) {
this.disableRadio(); this.disableRadio();
} }
@ -1407,7 +1413,7 @@ export class Player {
this.currentQueueIndex = startIndex; this.currentQueueIndex = startIndex;
this.shuffleActive = false; this.shuffleActive = false;
this.preloadCache.clear(); this.preloadCache.clear();
this.saveQueueState(); await this.saveQueueState();
} }
setArtistPopularTracksContext(artistId, initialTracks, offset = 15, hasMore = true) { setArtistPopularTracksContext(artistId, initialTracks, offset = 15, hasMore = true) {
@ -1474,7 +1480,7 @@ export class Player {
} }
} }
addToQueue(trackOrTracks) { async addToQueue(trackOrTracks) {
const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks]; const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks];
this.queue.push(...tracks); this.queue.push(...tracks);
@ -1485,12 +1491,12 @@ export class Player {
if (!this.currentTrack || this.currentQueueIndex === -1) { if (!this.currentTrack || this.currentQueueIndex === -1) {
this.currentQueueIndex = this.getCurrentQueue().length - tracks.length; this.currentQueueIndex = this.getCurrentQueue().length - tracks.length;
this.playTrackFromQueue(0, 0); await this.playTrackFromQueue(0, 0);
} }
this.saveQueueState(); await this.saveQueueState();
} }
addNextToQueue(trackOrTracks) { async addNextToQueue(trackOrTracks) {
const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks]; const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks];
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
const insertIndex = this.currentQueueIndex + 1; const insertIndex = this.currentQueueIndex + 1;
@ -1504,11 +1510,11 @@ export class Player {
this.originalQueueBeforeShuffle.push(...tracks); // Sync original queue this.originalQueueBeforeShuffle.push(...tracks); // Sync original queue
} }
this.saveQueueState(); await this.saveQueueState();
this.preloadNextTracks(); // Update preload since next track changed void this.preloadNextTracks().catch(console.error); // Update preload since next track changed
} }
removeFromQueue(index) { async removeFromQueue(index) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
// If removing current track // If removing current track
@ -1532,11 +1538,11 @@ export class Player {
} }
} }
this.saveQueueState(); await this.saveQueueState();
this.preloadNextTracks(); void this.preloadNextTracks().catch(console.error);
} }
clearQueue() { async clearQueue() {
if (this.currentTrack) { if (this.currentTrack) {
this.queue = [this.currentTrack]; this.queue = [this.currentTrack];
@ -1556,10 +1562,10 @@ export class Player {
} }
this.preloadCache.clear(); this.preloadCache.clear();
this.saveQueueState(); await this.saveQueueState();
} }
wipeQueue() { async wipeQueue() {
const el = this.activeElement; const el = this.activeElement;
el.pause(); el.pause();
el.src = ''; el.src = '';
@ -1568,16 +1574,16 @@ export class Player {
this.shuffledQueue = []; this.shuffledQueue = [];
this.originalQueueBeforeShuffle = []; this.originalQueueBeforeShuffle = [];
this.currentQueueIndex = -1; this.currentQueueIndex = -1;
this.saveQueueState(); await this.saveQueueState();
if (UIRenderer.instance) { if (UIRenderer.instance) {
UIRenderer.instance.setCurrentTrack(null); UIRenderer.instance.setCurrentTrack(null);
} }
if (window.renderQueueFunction) { if (window.renderQueueFunction) {
window.renderQueueFunction(); await window.renderQueueFunction();
} }
} }
moveInQueue(fromIndex, toIndex) { async moveInQueue(fromIndex, toIndex) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (fromIndex < 0 || fromIndex >= currentQueue.length) return; if (fromIndex < 0 || fromIndex >= currentQueue.length) return;
@ -1593,7 +1599,7 @@ export class Player {
} else if (fromIndex > this.currentQueueIndex && toIndex <= this.currentQueueIndex) { } else if (fromIndex > this.currentQueueIndex && toIndex <= this.currentQueueIndex) {
this.currentQueueIndex++; this.currentQueueIndex++;
} }
this.saveQueueState(); await this.saveQueueState();
} }
getCurrentQueue() { getCurrentQueue() {

View file

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

View file

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

View file

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

View file

@ -48,7 +48,7 @@ import { db } from './db.js';
import { authManager } from './accounts/auth.js'; import { authManager } from './accounts/auth.js';
import { syncManager } from './accounts/pocketbase.js'; import { syncManager } from './accounts/pocketbase.js';
import { containerFormats, customFormats } from './ffmpegFormats.ts'; import { containerFormats, customFormats } from './ffmpegFormats.ts';
import { modernSettings } from './ModernSettings.js'; import { BulkDownloadMethod, modernSettings } from './ModernSettings.js';
async function getButterchurnPresets(...args) { async function getButterchurnPresets(...args) {
const butterchurnModule = await import('./visualizers/butterchurn.js'); const butterchurnModule = await import('./visualizers/butterchurn.js');
@ -943,10 +943,10 @@ export async function initializeSettings(scrobbler, player, api, ui) {
const showQualityBadgesToggle = document.getElementById('show-quality-badges-toggle'); const showQualityBadgesToggle = document.getElementById('show-quality-badges-toggle');
if (showQualityBadgesToggle) { if (showQualityBadgesToggle) {
showQualityBadgesToggle.checked = qualityBadgeSettings.isEnabled(); showQualityBadgesToggle.checked = qualityBadgeSettings.isEnabled();
showQualityBadgesToggle.addEventListener('change', (e) => { showQualityBadgesToggle.addEventListener('change', async (e) => {
qualityBadgeSettings.setEnabled(e.target.checked); qualityBadgeSettings.setEnabled(e.target.checked);
// Re-render queue if available, but don't force navigation to library // Re-render queue if available, but don't force navigation to library
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) await window.renderQueueFunction();
}); });
} }
@ -979,15 +979,15 @@ export async function initializeSettings(scrobbler, player, api, ui) {
if (!forceZipBlobSettingItem) return; if (!forceZipBlobSettingItem) return;
const method = modernSettings.bulkDownloadMethod; const method = modernSettings.bulkDownloadMethod;
// Only relevant when zip method is selected and the browser supports streaming // Only relevant when zip method is selected and the browser supports streaming
const visible = method === 'zip' && hasFileSystemAccess; const visible = method === BulkDownloadMethod.Zip && hasFileSystemAccess;
forceZipBlobSettingItem.style.display = visible ? '' : 'none'; forceZipBlobSettingItem.style.display = visible ? '' : 'none';
} }
/** Shows/hides folder-picker-specific and folder-method settings */ /** Shows/hides folder-picker-specific and folder-method settings */
async function updateFolderMethodVisibility() { async function updateFolderMethodVisibility() {
const method = modernSettings.bulkDownloadMethod; const method = modernSettings.bulkDownloadMethod;
const isFolderMethod = method === 'folder'; const isFolderMethod = method === BulkDownloadMethod.Folder;
const isFolderOrLocal = isFolderMethod || method === 'local'; const isFolderOrLocal = isFolderMethod || method === BulkDownloadMethod.LocalMedia;
if (rememberFolderSetting) { if (rememberFolderSetting) {
rememberFolderSetting.style.display = isFolderMethod && hasFolderPicker ? '' : 'none'; rememberFolderSetting.style.display = isFolderMethod && hasFolderPicker ? '' : 'none';
@ -1022,8 +1022,8 @@ export async function initializeSettings(scrobbler, player, api, ui) {
} }
// If the stored method is 'folder' or 'local' without native support, fall back to 'zip' // If the stored method is 'folder' or 'local' without native support, fall back to 'zip'
const currentMethod = modernSettings.bulkDownloadMethod; const currentMethod = modernSettings.bulkDownloadMethod;
if (currentMethod === 'folder' || currentMethod === 'local') { if (currentMethod === BulkDownloadMethod.Folder || currentMethod === BulkDownloadMethod.LocalMedia) {
modernSettings.bulkDownloadMethod = 'zip'; modernSettings.bulkDownloadMethod = BulkDownloadMethod.Zip;
} }
} }
bulkDownloadMethod.value = modernSettings.bulkDownloadMethod; bulkDownloadMethod.value = modernSettings.bulkDownloadMethod;
@ -1033,7 +1033,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
modernSettings.bulkDownloadMethod = newMethod; modernSettings.bulkDownloadMethod = newMethod;
// When switching to 'local', prompt to select the local media folder if not yet configured // When switching to 'local', prompt to select the local media folder if not yet configured
if (newMethod === 'local') { if (newMethod === BulkDownloadMethod.LocalMedia) {
const existingHandle = await db.getSetting('local_folder_handle'); const existingHandle = await db.getSetting('local_folder_handle');
if (!existingHandle) { if (!existingHandle) {
let picked = false; let picked = false;
@ -1329,12 +1329,12 @@ export async function initializeSettings(scrobbler, player, api, ui) {
autoeqHeadphoneSelect.appendChild(optgroup); autoeqHeadphoneSelect.appendChild(optgroup);
// When user picks a popular headphone from the dropdown, load it // When user picks a popular headphone from the dropdown, load it
autoeqHeadphoneSelect.addEventListener('change', () => { autoeqHeadphoneSelect.addEventListener('change', async () => {
const selected = autoeqHeadphoneSelect.value; const selected = autoeqHeadphoneSelect.value;
if (!selected) return; if (!selected) return;
const popularEntry = POPULAR_HEADPHONES.find((hp) => hp.name === selected); const popularEntry = POPULAR_HEADPHONES.find((hp) => hp.name === selected);
if (popularEntry && (!autoeqSelectedEntry || autoeqSelectedEntry.name !== selected)) { if (popularEntry && (!autoeqSelectedEntry || autoeqSelectedEntry.name !== selected)) {
loadHeadphoneEntry(popularEntry); await loadHeadphoneEntry(popularEntry);
} }
}); });
} }
@ -2433,7 +2433,12 @@ export async function initializeSettings(scrobbler, player, api, ui) {
} }
const x = freqToX(f, pw); const x = freqToX(f, pw);
const y = mid - (Math.max(-dbRange, Math.min(dbRange, total)) / dbRange) * mid * 0.9; const y = mid - (Math.max(-dbRange, Math.min(dbRange, total)) / dbRange) * mid * 0.9;
first ? (ctx.moveTo(x, y), (first = false)) : ctx.lineTo(x, y); if (first) {
ctx.moveTo(x, y);
first = false;
} else {
ctx.lineTo(x, y);
}
} }
ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 2; ctx.lineWidth = 2;
@ -2650,7 +2655,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
modelMap.get(baseName).push(entry); modelMap.get(baseName).push(entry);
}); });
modelMap.forEach((variants, name) => { modelMap.forEach(async (variants, name) => {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
const rawFirstChar = name[0]?.toUpperCase() || '#'; const rawFirstChar = name[0]?.toUpperCase() || '#';
const firstLetter = /^[A-Z]$/.test(rawFirstChar) ? rawFirstChar : '#'; const firstLetter = /^[A-Z]$/.test(rawFirstChar) ? rawFirstChar : '#';
@ -2676,19 +2681,19 @@ export async function initializeSettings(scrobbler, player, api, ui) {
const subList = document.createElement('div'); const subList = document.createElement('div');
subList.className = 'autoeq-db-sub-list'; subList.className = 'autoeq-db-sub-list';
variants.forEach((entry) => { for (const entry of variants) {
const subItem = document.createElement('div'); const subItem = document.createElement('div');
subItem.className = 'autoeq-db-sub-item'; subItem.className = 'autoeq-db-sub-item';
// Extract source from parentheses // Extract source from parentheses
const sourceMatch = entry.name.match(/\(([^)]+)\)\s*$/); const sourceMatch = await entry.name.match(/\(([^)]+)\)\s*$/);
const source = sourceMatch ? sourceMatch[1] : entry.type; const source = sourceMatch ? sourceMatch[1] : entry.type;
subItem.innerHTML = `<span>${entry.name}</span><span class="sub-source">${source}</span>`; subItem.innerHTML = `<span>${entry.name}</span><span class="sub-source">${source}</span>`;
subItem.addEventListener('click', (e) => { subItem.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
loadHeadphoneEntry(entry); await loadHeadphoneEntry(entry);
}); });
subList.appendChild(subItem); subList.appendChild(subItem);
}); }
wrapper.appendChild(subList); wrapper.appendChild(subList);
@ -4409,7 +4414,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
if (initParaProfiles) initParaProfiles.style.display = 'none'; if (initParaProfiles) initParaProfiles.style.display = 'none';
// Auto-load headphone database // Auto-load headphone database
loadFullDatabase(); await loadFullDatabase();
// Auto-load default popular headphone if no saved profile is active // Auto-load default popular headphone if no saved profile is active
const activeProfileId = equalizerSettings.getActiveAutoEQProfile(); const activeProfileId = equalizerSettings.getActiveAutoEQProfile();
@ -4432,7 +4437,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
if (autoeqRunBtn) autoeqRunBtn.disabled = false; if (autoeqRunBtn) autoeqRunBtn.disabled = false;
requestAnimationFrame(drawAutoEQGraph); requestAnimationFrame(drawAutoEQGraph);
} else if (POPULAR_HEADPHONES.length > 0) { } else if (POPULAR_HEADPHONES.length > 0) {
loadHeadphoneEntry(POPULAR_HEADPHONES[0]); await loadHeadphoneEntry(POPULAR_HEADPHONES[0]);
} }
} }
@ -4990,7 +4995,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
const currentSource = homePageSettings.getEditorsPicksSource(); const currentSource = homePageSettings.getEditorsPicksSource();
editorsPicksSourceSelect.value = currentSource; editorsPicksSourceSelect.value = currentSource;
} }
populateEditorsPicksSource(); await populateEditorsPicksSource();
editorsPicksSourceSelect.addEventListener('change', (e) => { editorsPicksSourceSelect.addEventListener('change', (e) => {
homePageSettings.setEditorsPicksSource(e.target.value); homePageSettings.setEditorsPicksSource(e.target.value);
@ -5365,7 +5370,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
try { try {
await syncManager.clearCloudData(); await syncManager.clearCloudData();
alert('Cloud data cleared successfully.'); alert('Cloud data cleared successfully.');
authManager.signOut(); await authManager.signOut();
} catch (error) { } catch (error) {
console.error('Failed to clear cloud data:', error); console.error('Failed to clear cloud data:', error);
alert('Failed to clear cloud data: ' + error.message); alert('Failed to clear cloud data: ' + error.message);
@ -5716,7 +5721,7 @@ function initializeFontSettings() {
}); });
// Google Fonts apply // Google Fonts apply
fontGoogleApply.addEventListener('click', () => { fontGoogleApply.addEventListener('click', async () => {
const input = fontGoogleInput.value.trim(); const input = fontGoogleInput.value.trim();
if (!input) return; if (!input) return;
@ -5735,16 +5740,16 @@ function initializeFontSettings() {
// Not a URL, treat as font name // Not a URL, treat as font name
} }
fontSettings.loadGoogleFont(fontName); await fontSettings.loadGoogleFont(fontName);
}); });
// URL font apply // URL font apply
fontUrlApply.addEventListener('click', () => { fontUrlApply.addEventListener('click', async () => {
const url = fontUrlInput.value.trim(); const url = fontUrlInput.value.trim();
const name = fontUrlName.value.trim(); const name = fontUrlName.value.trim();
if (!url) return; if (!url) return;
fontSettings.loadFontFromUrl(url, name || 'CustomFont'); await fontSettings.loadFontFromUrl(url, name || 'CustomFont');
}); });
// File upload // File upload

View file

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

View file

@ -2653,18 +2653,18 @@ export const fontSettings = {
document.documentElement.style.setProperty('--font-family', "'SF Pro Display', sans-serif"); document.documentElement.style.setProperty('--font-family', "'SF Pro Display', sans-serif");
}, },
applyFont() { async applyFont() {
const config = this.getConfig(); const config = this.getConfig();
switch (config.type) { switch (config.type) {
case 'google': case 'google':
this.loadGoogleFont(config.family); await this.loadGoogleFont(config.family);
break; break;
case 'url': case 'url':
this.loadFontFromUrl(config.url, config.family); await this.loadFontFromUrl(config.url, config.family);
break; break;
case 'uploaded': case 'uploaded':
this.loadUploadedFont(config.fontId); await this.loadUploadedFont(config.fontId);
break; break;
case 'preset': case 'preset':
default: default:

View file

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

View file

@ -52,7 +52,6 @@ export enum Mp4Stik {
WhackedBookmark = 5, WhackedBookmark = 5,
MusicVideo = 6, MusicVideo = 6,
Movie = 9, Movie = 9,
ShortFilm = 9,
TVShow = 10, TVShow = 10,
Booklet = 11, Booklet = 11,
} }

View file

@ -1,8 +1,8 @@
// filepath: /workspaces/monochrome/js/taglib.worker.ts // filepath: /workspaces/monochrome/js/taglib.worker.ts
declare var self: DedicatedWorkerGlobalScope; declare let self: DedicatedWorkerGlobalScope;
import { ByteVector } from '!/@dantheman827/taglib-ts/src/byteVector.js'; import { ByteVector } from '!/@dantheman827/taglib-ts/src/byteVector.js';
import { Mp4Tag, Mp4Item } from '!/@dantheman827/taglib-ts/src/mp4/mp4Tag.js'; import { Mp4Item } from '!/@dantheman827/taglib-ts/src/mp4/mp4Tag.js';
import { Variant } from '!/@dantheman827/taglib-ts/src/toolkit/variant.js'; import { Variant } from '!/@dantheman827/taglib-ts/src/toolkit/variant.js';
import { doTimed, doTimedAsync } from './doTimed'; import { doTimed, doTimedAsync } from './doTimed';
import { import {
@ -10,7 +10,6 @@ import {
type _AddMetadataMessage, type _AddMetadataMessage,
type _GetMetadataMessage, type _GetMetadataMessage,
type AddMetadataMessage, type AddMetadataMessage,
type GetMetadataMessage,
type TagLibFileResponse, type TagLibFileResponse,
type TagLibMetadata, type TagLibMetadata,
type TagLibMetadataResponse, type TagLibMetadataResponse,
@ -18,6 +17,7 @@ import {
type TagLibWorkerMessage, type TagLibWorkerMessage,
type TagLibWorkerResponse, type TagLibWorkerResponse,
} from './taglib.types'; } from './taglib.types';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { File as TagLibFile } from '!/@dantheman827/taglib-ts/src/file.js'; import { File as TagLibFile } from '!/@dantheman827/taglib-ts/src/file.js';
import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js'; import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js';
import { ChunkedByteVectorStream } from '!/@dantheman827/taglib-ts/src/toolkit/chunkedByteVectorStream.js'; import { ChunkedByteVectorStream } from '!/@dantheman827/taglib-ts/src/toolkit/chunkedByteVectorStream.js';
@ -38,7 +38,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
const { const {
audioData, audioData,
audioRef, audioRef,
filename, filename: _filename,
title, title,
artist, artist,
writeArtistsSeparately = false, writeArtistsSeparately = false,
@ -79,7 +79,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
const isMp4 = underlying instanceof Mp4File; const isMp4 = underlying instanceof Mp4File;
const isMpeg = underlying instanceof MpegFile; const isMpeg = underlying instanceof MpegFile;
const isOgg = underlying instanceof OggVorbisFile; const isOgg = underlying instanceof OggVorbisFile;
const isWav = underlying instanceof WavFile; const _isWav = underlying instanceof WavFile;
const needsCombinedTrackDisc = isMp4 || isMpeg; const needsCombinedTrackDisc = isMp4 || isMpeg;
@ -137,7 +137,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
if (copyright) props.replace('COPYRIGHT', [copyright]); if (copyright) props.replace('COPYRIGHT', [copyright]);
if (isrc) props.replace('ISRC', [isrc]); if (isrc) props.replace('ISRC', [isrc]);
if (isrc && isMp4) { if (isrc && isMp4) {
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag; const mp4Tag = underlying.tag();
mp4Tag.setItem('xid ', Mp4Item.fromStringList([`:isrc:${isrc}`])); mp4Tag.setItem('xid ', Mp4Item.fromStringList([`:isrc:${isrc}`]));
} }
if (upc) props.replace('UPC', [upc]); if (upc) props.replace('UPC', [upc]);
@ -145,8 +145,8 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
if (explicit !== undefined) { if (explicit !== undefined) {
if (isMp4) { if (isMp4) {
// rtng is a byte item - must be set directly on the Mp4Tag // rtng is a byte item must be set directly on the Mp4Tag
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag; const mp4Tag = underlying.tag();
mp4Tag.setItem('rtng', Mp4Item.fromByte(explicit ? 1 : 0)); mp4Tag.setItem('rtng', Mp4Item.fromByte(explicit ? 1 : 0));
} else { } else {
props.replace('ITUNESADVISORY', [explicit ? '1' : '0']); props.replace('ITUNESADVISORY', [explicit ? '1' : '0']);
@ -154,7 +154,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
} }
if (stik != null && isMp4) { if (stik != null && isMp4) {
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag; const mp4Tag = underlying.tag();
mp4Tag.setItem('stik', Mp4Item.fromByte(stik)); mp4Tag.setItem('stik', Mp4Item.fromByte(stik));
} }
@ -177,7 +177,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
await ref.save(); await ref.save();
}); });
const file = ref.file() as TagLibFile; const file = ref.file();
if (!file) return audioData; if (!file) return audioData;
const stream = file.stream(); const stream = file.stream();
@ -207,7 +207,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
} }
export async function getMetadataFromAudio(message: _GetMetadataMessage): Promise<TagLibReadMetadata> { export async function getMetadataFromAudio(message: _GetMetadataMessage): Promise<TagLibReadMetadata> {
const { audioData, audioRef, filename } = message; const { audioData, audioRef } = message;
const data: TagLibReadMetadata = { duration: 0 }; const data: TagLibReadMetadata = { duration: 0 };
const ref = const ref =
@ -263,7 +263,7 @@ export async function getMetadataFromAudio(message: _GetMetadataMessage): Promis
data.isrc = props.get('ISRC')?.[0] || undefined; data.isrc = props.get('ISRC')?.[0] || undefined;
if (isMp4) { if (isMp4) {
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag; const mp4Tag = underlying.tag();
data.explicit = mp4Tag.item('rtng')?.toByte() === 1; data.explicit = mp4Tag.item('rtng')?.toByte() === 1;
} else { } else {
data.explicit = props.get('ITUNESADVISORY')?.[0] === '1'; data.explicit = props.get('ITUNESADVISORY')?.[0] === '1';

View file

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

View file

@ -280,7 +280,7 @@ function renderTrackerTracks(container, tracks) {
} }
// Create project card HTML - EXACTLY like album cards // Create project card HTML - EXACTLY like album cards
export function createProjectCardHTML(era, artist, sheetId, trackCount) { export function createProjectCardHTML(era, _artist, sheetId, trackCount) {
const playBtnHTML = ` const playBtnHTML = `
<button class="play-btn card-play-btn" data-action="play-card" data-type="tracker-project" data-id="${encodeURIComponent(era.name)}" title="Play"> <button class="play-btn card-play-btn" data-action="play-card" data-type="tracker-project" data-id="${encodeURIComponent(era.name)}" title="Play">
${SVG_PLAY(20)} ${SVG_PLAY(20)}

View file

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

421
js/ui.js

File diff suppressed because it is too large Load diff

View file

@ -664,7 +664,7 @@ export function fetchBlob(url) {
} }
export async function fetchBlobURL(url) { export async function fetchBlobURL(url) {
return await URL.createObjectURL(await fetchBlob(url)); return URL.createObjectURL(await fetchBlob(url));
} }
export function getMimeType(data) { export function getMimeType(data) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -110,7 +110,7 @@ export default function getBlobUrl() {
chunk.code = chunk.code.replace( chunk.code = chunk.code.replace(
/"__BLOB_ASSET_(.*?)__"/g, /"__BLOB_ASSET_(.*?)__"/g,
(_, refId) => `"${this.getFileName(refId)}"` (_, refId: string) => `"${this.getFileName(refId)}"`
); );
} }
}, },

View file

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

View file

@ -16,7 +16,7 @@ function getGitCommitHash() {
} }
} }
export default defineConfig(({ mode }) => { export default defineConfig((_options) => {
const commitHash = getGitCommitHash(); const commitHash = getGitCommitHash();
return { return {