fix(linting): fix js linting issues
This commit is contained in:
parent
ddc986bc52
commit
648e47e1d8
65 changed files with 1202 additions and 1037 deletions
|
|
@ -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 = `
|
||||||
|
|
|
||||||
|
|
@ -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 = `
|
||||||
|
|
|
||||||
|
|
@ -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 = `
|
||||||
|
|
|
||||||
|
|
@ -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 = `
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
|
|
|
||||||
|
|
@ -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 = `
|
||||||
|
|
|
||||||
|
|
@ -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 = `
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 = `
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
11
js/HiFi.ts
11
js/HiFi.ts
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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')) || [],
|
||||||
|
|
|
||||||
12
js/api.js
12
js/api.js
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
165
js/app.js
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
js/db.js
1
js/db.js
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
334
js/events.js
334
js/events.js
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
4
js/global.d.ts
vendored
|
|
@ -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
16
js/indexedIterator.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* A generic iterator that yields the index, total count, and item for any finite iterable.
|
||||||
|
*
|
||||||
|
* @template T - The type of items in the iterable.
|
||||||
|
* @param iterable - The iterable to process.
|
||||||
|
* @returns A generator that yields an object with index, total, and item.
|
||||||
|
*/
|
||||||
|
export default function* indexedIterator<T>(
|
||||||
|
iterable: Iterable<T>
|
||||||
|
): Generator<{ index: number; total: number; item: T }> {
|
||||||
|
const array = Array.from(iterable); // Convert the iterable to an array
|
||||||
|
const total = array.length; // Get the total count of items
|
||||||
|
for (let index = 0; index < total; index++) {
|
||||||
|
yield { index, total, item: array[index] }; // Yield index, total, and item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -284,8 +284,8 @@ export class LastFMScrobbler {
|
||||||
scheduleScrobble(delay) {
|
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() {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
17
js/lyrics.js
17
js/lyrics.js
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
236
js/player.js
236
js/player.js
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
258
js/profile.js
258
js/profile.js
|
|
@ -248,30 +248,31 @@ export async function loadProfile(username) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profile.lastfm_username && profile.privacy?.lastfm !== 'private') {
|
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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
28
js/taglib.ts
28
js/taglib.ts
|
|
@ -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);
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}"`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ function getGitCommitHash() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig((_options) => {
|
||||||
const commitHash = getGitCommitHash();
|
const commitHash = getGitCommitHash();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue