Merge branch 'combined-fixes' into copilot/fix-m3u-generation-logic
This commit is contained in:
commit
4def7b8e51
18 changed files with 903 additions and 267 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://monochrome.tf">
|
<a href="https://monochrome.tf">
|
||||||
<img src="https://github.com/monochrome-music/monochrome/blob/main/public/assets/512.png?raw=true" alt="Monochrome Logo" width="150px">
|
<img src="https://github.com/monochrome-music/monochrome/blob/main/public/assets/512.png?raw=true" alt="Monochrome Logo" width="150px">
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -5108,7 +5108,6 @@
|
||||||
<select id="download-quality-setting">
|
<select id="download-quality-setting">
|
||||||
<option value="HI_RES_LOSSLESS">Hi-Res FLAC (24-bit)</option>
|
<option value="HI_RES_LOSSLESS">Hi-Res FLAC (24-bit)</option>
|
||||||
<option value="LOSSLESS">FLAC (Lossless)</option>
|
<option value="LOSSLESS">FLAC (Lossless)</option>
|
||||||
<option value="MP3_320">MP3 320kbps</option>
|
|
||||||
<option value="HIGH">AAC 320kbps</option>
|
<option value="HIGH">AAC 320kbps</option>
|
||||||
<option value="LOW">AAC 96kbps</option>
|
<option value="LOW">AAC 96kbps</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -5118,11 +5117,7 @@
|
||||||
<span class="label">Lossless Container</span>
|
<span class="label">Lossless Container</span>
|
||||||
<span class="description">Container format for lossless downloads</span>
|
<span class="description">Container format for lossless downloads</span>
|
||||||
</div>
|
</div>
|
||||||
<select id="lossless-container-setting">
|
<select id="lossless-container-setting"></select>
|
||||||
<option value="flac">FLAC</option>
|
|
||||||
<option value="alac">Apple Lossless</option>
|
|
||||||
<option value="nochange">Don't change</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
|
|
|
||||||
151
js/api.js
151
js/api.js
|
|
@ -11,9 +11,16 @@ import { APICache } from './cache.js';
|
||||||
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
||||||
import { DashDownloader } from './dash-downloader.js';
|
import { DashDownloader } from './dash-downloader.js';
|
||||||
import { HlsDownloader } from './hls-downloader.js';
|
import { HlsDownloader } from './hls-downloader.js';
|
||||||
import { encodeToMp3, MP3EncodingError } from './mp3-encoder.js';
|
import { MP3EncodingError } from './mp3-encoder.js';
|
||||||
import { ffmpeg, loadFfmpeg } from './ffmpeg.js';
|
import { loadFfmpeg, FfmpegError } from './ffmpeg.js';
|
||||||
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
|
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
|
||||||
|
import {
|
||||||
|
isCustomFormat,
|
||||||
|
getCustomFormat,
|
||||||
|
transcodeWithCustomFormat,
|
||||||
|
getContainerFormat,
|
||||||
|
transcodeWithContainerFormat,
|
||||||
|
} from './ffmpegFormats.ts';
|
||||||
|
|
||||||
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
||||||
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
|
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
|
||||||
|
|
@ -1097,13 +1104,8 @@ export class LosslessAPI {
|
||||||
const recommendedTracks = [];
|
const recommendedTracks = [];
|
||||||
const seenTrackIds = new Set(tracks.map((t) => t.id));
|
const seenTrackIds = new Set(tracks.map((t) => t.id));
|
||||||
|
|
||||||
// Shuffle artists if refreshing to get different results
|
const shuffledArtists = [...artists].sort(() => Math.random() - 0.5);
|
||||||
let shuffledArtists = artists;
|
const artistsToProcess = shuffledArtists.slice(0, Math.min(15, shuffledArtists.length));
|
||||||
if (options.refresh) {
|
|
||||||
shuffledArtists = [...artists].sort(() => Math.random() - 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
const artistsToProcess = shuffledArtists.slice(0, Math.min(5, shuffledArtists.length));
|
|
||||||
|
|
||||||
const artistPromises = artistsToProcess.map(async (artist) => {
|
const artistPromises = artistsToProcess.map(async (artist) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1111,11 +1113,19 @@ export class LosslessAPI {
|
||||||
const artistData = await this.getArtist(artist.id, { lightweight: true, skipCache: options.refresh });
|
const artistData = await this.getArtist(artist.id, { lightweight: true, skipCache: options.refresh });
|
||||||
if (artistData && artistData.tracks && artistData.tracks.length > 0) {
|
if (artistData && artistData.tracks && artistData.tracks.length > 0) {
|
||||||
const availableTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id));
|
const availableTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id));
|
||||||
// Shuffle and pick different tracks when refreshing
|
|
||||||
const shuffled = options.refresh
|
const newTracks = options.knownTrackIds
|
||||||
? availableTracks.sort(() => Math.random() - 0.5)
|
? availableTracks.filter((t) => !options.knownTrackIds.has(t.id))
|
||||||
: availableTracks;
|
: availableTracks;
|
||||||
return shuffled.slice(0, 4);
|
const knownTracks = options.knownTrackIds
|
||||||
|
? availableTracks.filter((t) => options.knownTrackIds.has(t.id))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const shuffledNew = [...newTracks].sort(() => Math.random() - 0.5);
|
||||||
|
const shuffledKnown = [...knownTracks].sort(() => Math.random() - 0.5);
|
||||||
|
|
||||||
|
const combined = [...shuffledNew, ...shuffledKnown];
|
||||||
|
return combined.slice(0, 2);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`No tracks found for artist ${artist.name}`);
|
console.warn(`No tracks found for artist ${artist.name}`);
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -1291,8 +1301,8 @@ export class LosslessAPI {
|
||||||
const isVideo = track?.type === 'video';
|
const isVideo = track?.type === 'video';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert
|
// Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
|
||||||
const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
|
const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
|
||||||
|
|
||||||
let lookup;
|
let lookup;
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
|
|
@ -1413,50 +1423,38 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isVideo) {
|
if (!isVideo) {
|
||||||
// Convert to MP3 320kbps if requested
|
// Transcode to custom format if requested
|
||||||
if (quality === 'MP3_320') {
|
if (isCustomFormat(quality)) {
|
||||||
try {
|
const format = getCustomFormat(quality);
|
||||||
blob = await encodeToMp3(blob, onProgress, options.signal);
|
if (format) {
|
||||||
} catch (encodingError) {
|
try {
|
||||||
if (onProgress) {
|
blob = await transcodeWithCustomFormat(blob, format, onProgress, options.signal);
|
||||||
onProgress({
|
} catch (encodingError) {
|
||||||
stage: 'error',
|
if (onProgress) {
|
||||||
message: `Encoding failed: ${encodingError.message}`,
|
onProgress({
|
||||||
});
|
stage: 'error',
|
||||||
|
message: `Encoding failed: ${encodingError.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw encodingError;
|
||||||
}
|
}
|
||||||
throw encodingError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quality.endsWith('LOSSLESS')) {
|
if (quality.endsWith('LOSSLESS')) {
|
||||||
try {
|
try {
|
||||||
switch (losslessContainerSettings.getContainer()) {
|
const containerFmt = getContainerFormat(losslessContainerSettings.getContainer());
|
||||||
case 'flac':
|
if (containerFmt) {
|
||||||
if ((await getExtensionFromBlob(blob)) != 'flac') {
|
if (await containerFmt.needsTranscode(blob)) {
|
||||||
blob = await ffmpeg(
|
blob = await transcodeWithContainerFormat(
|
||||||
blob,
|
|
||||||
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
|
|
||||||
'output.flac',
|
|
||||||
'audio/flac',
|
|
||||||
onProgress,
|
|
||||||
options.signal
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
blob = await rebuildFlacWithoutMetadata(blob);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'alac':
|
|
||||||
blob = await ffmpeg(
|
|
||||||
blob,
|
blob,
|
||||||
{ args: ['-c:a', 'alac'] },
|
containerFmt,
|
||||||
'output.m4a',
|
|
||||||
'audio/mp4',
|
|
||||||
onProgress,
|
onProgress,
|
||||||
options.signal
|
options.signal
|
||||||
);
|
);
|
||||||
break;
|
} else if ((await getExtensionFromBlob(blob)) == 'flac') {
|
||||||
default:
|
blob = await rebuildFlacWithoutMetadata(blob);
|
||||||
break;
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === 'AbortError') {
|
if (error?.name === 'AbortError') {
|
||||||
|
|
@ -1486,6 +1484,55 @@ export class LosslessAPI {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
track.album?.id &&
|
||||||
|
(track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Broad disc-field resolver — mirrors getExplicitTrackDiscNumber in downloads.js
|
||||||
|
const resolveDiscNumber = (t) => {
|
||||||
|
const candidates = [
|
||||||
|
t.volumeNumber,
|
||||||
|
t.discNumber,
|
||||||
|
t.mediaNumber,
|
||||||
|
t.media_number,
|
||||||
|
t.volume,
|
||||||
|
t.disc,
|
||||||
|
t.disc_no,
|
||||||
|
t.discNo,
|
||||||
|
t.disc_number,
|
||||||
|
t.mediaMetadata?.discNumber,
|
||||||
|
];
|
||||||
|
for (const c of candidates) {
|
||||||
|
const parsed = parseInt(c, 10);
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const albumData = await this.getAlbum(track.album.id);
|
||||||
|
if (albumData.tracks?.length > 0) {
|
||||||
|
const discTrackCounts = new Map();
|
||||||
|
let maxDiscNumber = 0;
|
||||||
|
for (const t of albumData.tracks) {
|
||||||
|
const dn = resolveDiscNumber(t);
|
||||||
|
discTrackCounts.set(dn, (discTrackCounts.get(dn) || 0) + 1);
|
||||||
|
if (dn > maxDiscNumber) maxDiscNumber = dn;
|
||||||
|
}
|
||||||
|
const totalDiscs = maxDiscNumber || 1;
|
||||||
|
const discNumber = resolveDiscNumber(track);
|
||||||
|
enrichedTrack.album = {
|
||||||
|
...(enrichedTrack.album || {}),
|
||||||
|
totalDiscs: track.album?.totalDiscs ?? totalDiscs,
|
||||||
|
numberOfTracksOnDisc:
|
||||||
|
track.album?.numberOfTracksOnDisc ?? discTrackCounts.get(discNumber),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to fetch album for disc info:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
|
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1507,7 +1554,11 @@ export class LosslessAPI {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
console.error('Download failed:', error);
|
console.error('Download failed:', error);
|
||||||
if (error instanceof MP3EncodingError || error.code === 'MP3_ENCODING_FAILED') {
|
if (
|
||||||
|
error instanceof MP3EncodingError ||
|
||||||
|
error instanceof FfmpegError ||
|
||||||
|
error.code === 'MP3_ENCODING_FAILED'
|
||||||
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
|
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
|
||||||
|
|
|
||||||
13
js/customFormats.ts
Normal file
13
js/customFormats.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Re-exports for backwards compatibility – canonical source is ffmpegFormats.ts
|
||||||
|
export {
|
||||||
|
type ProgressEvent,
|
||||||
|
type CustomFormat,
|
||||||
|
type ContainerFormat,
|
||||||
|
customFormats,
|
||||||
|
containerFormats,
|
||||||
|
isCustomFormat,
|
||||||
|
getCustomFormat,
|
||||||
|
getContainerFormat,
|
||||||
|
transcodeWithCustomFormat,
|
||||||
|
transcodeWithContainerFormat,
|
||||||
|
} from './ffmpegFormats';
|
||||||
158
js/downloads.js
158
js/downloads.js
|
|
@ -17,8 +17,14 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
||||||
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
|
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
|
||||||
import { DashDownloader } from './dash-downloader.js';
|
import { DashDownloader } from './dash-downloader.js';
|
||||||
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
|
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
|
||||||
import { encodeToMp3 } from './mp3-encoder.js';
|
import { loadFfmpeg } from './ffmpeg.js';
|
||||||
import { ffmpeg, loadFfmpeg } from './ffmpeg.js';
|
import {
|
||||||
|
isCustomFormat,
|
||||||
|
getCustomFormat,
|
||||||
|
transcodeWithCustomFormat,
|
||||||
|
getContainerFormat,
|
||||||
|
transcodeWithContainerFormat,
|
||||||
|
} from './ffmpegFormats.ts';
|
||||||
|
|
||||||
const downloadTasks = new Map();
|
const downloadTasks = new Map();
|
||||||
const bulkDownloadTasks = new Map();
|
const bulkDownloadTasks = new Map();
|
||||||
|
|
@ -74,6 +80,63 @@ async function createDiscLayoutContext(tracks, api) {
|
||||||
return { separateByDisc: false, resolveDiscNumber: () => 1 };
|
return { separateByDisc: false, resolveDiscNumber: () => 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function computeDiscInfo(tracks, api = null) {
|
||||||
|
// First pass: collect explicit disc numbers from the raw track objects.
|
||||||
|
const explicitDiscNumbers = tracks.map((track) => getExplicitTrackDiscNumber(track));
|
||||||
|
const explicitDistinct = new Set(explicitDiscNumbers.filter(Boolean));
|
||||||
|
|
||||||
|
let resolvedDiscNumbers = explicitDiscNumbers;
|
||||||
|
|
||||||
|
// Some providers omit disc fields in the album payload. When we can't
|
||||||
|
// distinguish discs from the raw data and an API instance is provided,
|
||||||
|
// hydrate missing disc numbers via full-track metadata (mirrors the logic
|
||||||
|
// in createDiscLayoutContext).
|
||||||
|
if (explicitDistinct.size <= 1 && api) {
|
||||||
|
const hydratedDiscNumbers = await Promise.all(
|
||||||
|
tracks.map(async (track, index) => {
|
||||||
|
if (explicitDiscNumbers[index]) return explicitDiscNumbers[index];
|
||||||
|
try {
|
||||||
|
const fullTrack = await api.getTrackMetadata(track.id);
|
||||||
|
return getExplicitTrackDiscNumber(fullTrack);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const hydratedDistinct = new Set(hydratedDiscNumbers.filter(Boolean));
|
||||||
|
if (hydratedDistinct.size > 1) {
|
||||||
|
resolvedDiscNumbers = hydratedDiscNumbers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracksPerDisc = new Map();
|
||||||
|
let maxDiscNumber = 0;
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
const discNumber = resolvedDiscNumbers[i] || 1;
|
||||||
|
tracksPerDisc.set(discNumber, (tracksPerDisc.get(discNumber) || 0) + 1);
|
||||||
|
if (discNumber > maxDiscNumber) {
|
||||||
|
maxDiscNumber = discNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalDiscs: maxDiscNumber || 1, tracksPerDisc, resolvedDiscNumbers };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function annotateTracksWithDiscInfo(tracks, api = null) {
|
||||||
|
const { totalDiscs, tracksPerDisc, resolvedDiscNumbers } = await computeDiscInfo(tracks, api);
|
||||||
|
return tracks.map((track, index) => {
|
||||||
|
const discNumber = resolvedDiscNumbers[index] || 1;
|
||||||
|
return {
|
||||||
|
...track,
|
||||||
|
album: {
|
||||||
|
...(track.album || {}),
|
||||||
|
totalDiscs,
|
||||||
|
numberOfTracksOnDisc: tracksPerDisc.get(discNumber),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getDiscFolderName(discNumber) {
|
function getDiscFolderName(discNumber) {
|
||||||
return `Disc ${discNumber}`;
|
return `Disc ${discNumber}`;
|
||||||
}
|
}
|
||||||
|
|
@ -265,8 +328,8 @@ async function downloadTrackBlob(
|
||||||
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
|
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
|
||||||
};
|
};
|
||||||
|
|
||||||
// MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert
|
// Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
|
||||||
const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
|
const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fullTrack = await api.getTrackMetadata(track.id);
|
const fullTrack = await api.getTrackMetadata(track.id);
|
||||||
|
|
@ -288,15 +351,24 @@ async function downloadTrackBlob(
|
||||||
// Non-fatal: continue with best available track payload
|
// Non-fatal: continue with best available track payload
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) {
|
if (enrichedTrack.album?.id) {
|
||||||
try {
|
try {
|
||||||
const albumData = await api.getAlbum(enrichedTrack.album.id);
|
const albumData = await api.getAlbum(enrichedTrack.album.id);
|
||||||
if (albumData.album) {
|
if (albumData.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist)) {
|
||||||
enrichedTrack.album = {
|
enrichedTrack.album = {
|
||||||
...enrichedTrack.album,
|
...enrichedTrack.album,
|
||||||
...albumData.album,
|
...albumData.album,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (albumData.tracks?.length > 0) {
|
||||||
|
const { totalDiscs, tracksPerDisc } = await computeDiscInfo(albumData.tracks, api);
|
||||||
|
const discNumber = getExplicitTrackDiscNumber(enrichedTrack) || 1;
|
||||||
|
enrichedTrack.album = {
|
||||||
|
...enrichedTrack.album,
|
||||||
|
totalDiscs,
|
||||||
|
numberOfTracksOnDisc: tracksPerDisc.get(discNumber),
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to fetch album data for metadata:', error);
|
console.warn('Failed to fetch album data for metadata:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -346,40 +418,23 @@ async function downloadTrackBlob(
|
||||||
blob = await response.blob();
|
blob = await response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to MP3 320kbps if requested
|
// Transcode to custom format if requested
|
||||||
if (quality === 'MP3_320') {
|
if (isCustomFormat(quality)) {
|
||||||
blob = await encodeToMp3(blob, onProgress || (() => undefined), signal);
|
const format = getCustomFormat(quality);
|
||||||
|
if (format) {
|
||||||
|
blob = await transcodeWithCustomFormat(blob, format, onProgress || (() => undefined), signal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quality.endsWith('LOSSLESS')) {
|
if (quality.endsWith('LOSSLESS')) {
|
||||||
try {
|
try {
|
||||||
switch (losslessContainerSettings.getContainer()) {
|
const containerFmt = getContainerFormat(losslessContainerSettings.getContainer());
|
||||||
case 'flac':
|
if (containerFmt) {
|
||||||
if ((await getExtensionFromBlob(blob)) != 'flac') {
|
if (await containerFmt.needsTranscode(blob)) {
|
||||||
blob = await ffmpeg(
|
blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal);
|
||||||
blob,
|
} else if ((await getExtensionFromBlob(blob)) == 'flac') {
|
||||||
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
|
blob = await rebuildFlacWithoutMetadata(blob);
|
||||||
'output.flac',
|
}
|
||||||
'audio/flac',
|
|
||||||
onProgress,
|
|
||||||
signal
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
blob = await rebuildFlacWithoutMetadata(blob);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'alac':
|
|
||||||
blob = await ffmpeg(
|
|
||||||
blob,
|
|
||||||
{ args: ['-c:a', 'alac'] },
|
|
||||||
'output.m4a',
|
|
||||||
'audio/mp4',
|
|
||||||
onProgress,
|
|
||||||
signal
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === 'AbortError') {
|
if (error?.name === 'AbortError') {
|
||||||
|
|
@ -1082,7 +1137,17 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
|
||||||
});
|
});
|
||||||
|
|
||||||
const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId);
|
const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId);
|
||||||
await startBulkDownload(tracks, folderName, api, quality, lyricsManager, 'album', album.title, coverBlob, album);
|
await startBulkDownload(
|
||||||
|
await annotateTracksWithDiscInfo(tracks, api),
|
||||||
|
folderName,
|
||||||
|
api,
|
||||||
|
quality,
|
||||||
|
lyricsManager,
|
||||||
|
'album',
|
||||||
|
album.title,
|
||||||
|
coverBlob,
|
||||||
|
album
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) {
|
export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) {
|
||||||
|
|
@ -1124,7 +1189,8 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title);
|
updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
|
const { album: fullAlbum, tracks: rawTracks } = await api.getAlbum(album.id);
|
||||||
|
const tracks = await annotateTracksWithDiscInfo(rawTracks, api);
|
||||||
const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover);
|
const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover);
|
||||||
const releaseDateStr =
|
const releaseDateStr =
|
||||||
fullAlbum.releaseDate ||
|
fullAlbum.releaseDate ||
|
||||||
|
|
@ -1286,7 +1352,8 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
const album = selectedReleases[albumIndex];
|
const album = selectedReleases[albumIndex];
|
||||||
updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title);
|
updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title);
|
||||||
const { tracks } = await api.getAlbum(album.id);
|
const { tracks: rawTracks } = await api.getAlbum(album.id);
|
||||||
|
const tracks = await annotateTracksWithDiscInfo(rawTracks, api);
|
||||||
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
||||||
}
|
}
|
||||||
completeBulkDownload(notification, true);
|
completeBulkDownload(notification, true);
|
||||||
|
|
@ -1430,15 +1497,24 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
||||||
// Continue with available track payload
|
// Continue with available track payload
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) {
|
if (enrichedTrack.album?.id) {
|
||||||
try {
|
try {
|
||||||
const albumData = await api.getAlbum(enrichedTrack.album.id);
|
const albumData = await api.getAlbum(enrichedTrack.album.id);
|
||||||
if (albumData.album) {
|
if (albumData.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist)) {
|
||||||
enrichedTrack.album = {
|
enrichedTrack.album = {
|
||||||
...enrichedTrack.album,
|
...enrichedTrack.album,
|
||||||
...albumData.album,
|
...albumData.album,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (albumData.tracks?.length > 0) {
|
||||||
|
const { totalDiscs, tracksPerDisc } = await computeDiscInfo(albumData.tracks, api);
|
||||||
|
const discNumber = getExplicitTrackDiscNumber(enrichedTrack) || 1;
|
||||||
|
enrichedTrack.album = {
|
||||||
|
...enrichedTrack.album,
|
||||||
|
totalDiscs,
|
||||||
|
numberOfTracksOnDisc: tracksPerDisc.get(discNumber),
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to fetch album data for metadata:', error);
|
console.warn('Failed to fetch album data for metadata:', error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
js/events.js
30
js/events.js
|
|
@ -202,7 +202,20 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
console.error(`Media playback error (${element.id}):`, errorMsg, e);
|
console.error(`Media playback error (${element.id}):`, errorMsg, e);
|
||||||
playPauseBtn.innerHTML = SVG_PLAY;
|
playPauseBtn.innerHTML = SVG_PLAY;
|
||||||
|
|
||||||
|
const canFallback =
|
||||||
|
player.quality === 'HI_RES_LOSSLESS' &&
|
||||||
|
errorMsg.includes('Source not supported') &&
|
||||||
|
errorMsg.includes('0x80004005') &&
|
||||||
|
!player.isFallbackRetry;
|
||||||
|
|
||||||
|
if (canFallback) {
|
||||||
|
console.warn('Hi-Res failed due to DASH.js Error (FUCK DASH)');
|
||||||
|
}
|
||||||
|
|
||||||
if (player.currentTrack && error && error.code !== 1) {
|
if (player.currentTrack && error && error.code !== 1) {
|
||||||
|
if (player.isFallbackInProgress || canFallback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.warn('Skipping to next track due to playback error');
|
console.warn('Skipping to next track due to playback error');
|
||||||
setTimeout(() => player.playNext(), 1000);
|
setTimeout(() => player.playNext(), 1000);
|
||||||
}
|
}
|
||||||
|
|
@ -384,6 +397,23 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
updateWaveform();
|
updateWaveform();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (volumeBtn) {
|
||||||
|
volumeBtn.addEventListener('click', () => {
|
||||||
|
const activeEl = player.activeElement;
|
||||||
|
activeEl.muted = !activeEl.muted;
|
||||||
|
localStorage.setItem('muted', activeEl.muted);
|
||||||
|
|
||||||
|
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
|
||||||
|
if (inactiveEl) inactiveEl.muted = activeEl.muted;
|
||||||
|
|
||||||
|
updateVolumeUI();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const isMuted = localStorage.getItem('muted') === 'true';
|
||||||
|
audioPlayer.muted = isMuted;
|
||||||
|
if (player.video) player.video.muted = isMuted;
|
||||||
|
updateVolumeUI();
|
||||||
|
|
||||||
initializeSmoothSliders(player);
|
initializeSmoothSliders(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
229
js/ffmpegFormats.ts
Normal file
229
js/ffmpegFormats.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { ffmpeg } from './ffmpeg';
|
||||||
|
import { getExtensionFromBlob } from './utils';
|
||||||
|
|
||||||
|
export interface ProgressEvent {
|
||||||
|
stage?: string;
|
||||||
|
message?: string;
|
||||||
|
progress?: number;
|
||||||
|
receivedBytes?: number;
|
||||||
|
totalBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomFormat {
|
||||||
|
/** Human-readable label shown in the UI */
|
||||||
|
displayName: string;
|
||||||
|
/** Internal identifier, must start with `FFMPEG_` */
|
||||||
|
internalName: string;
|
||||||
|
/** Arguments passed to ffmpeg (excluding input/output file args) */
|
||||||
|
ffmpegArgs: string[];
|
||||||
|
/** Output filename used when calling ffmpeg */
|
||||||
|
outputFilename: string;
|
||||||
|
/** MIME type of the encoded output */
|
||||||
|
outputMime: string;
|
||||||
|
/** File extension of the encoded output */
|
||||||
|
extension: string;
|
||||||
|
/** Category label used for grouping in the UI (e.g. 'MP3', 'OGG', 'AAC') */
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A container format definition for lossless re-muxing/re-encoding.
|
||||||
|
* Extends CustomFormat with a callback that decides whether ffmpeg needs to run
|
||||||
|
* at all (e.g. FLAC can skip if the source is already FLAC).
|
||||||
|
*/
|
||||||
|
export interface ContainerFormat extends Omit<CustomFormat, 'category'> {
|
||||||
|
/**
|
||||||
|
* Returns true when the source blob must be passed through ffmpeg to produce
|
||||||
|
* the desired container. Return false to skip the ffmpeg step (the caller
|
||||||
|
* may still apply a lightweight metadata-strip pass instead).
|
||||||
|
*/
|
||||||
|
needsTranscode: (blob: Blob) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customFormats: CustomFormat[] = [
|
||||||
|
{
|
||||||
|
displayName: 'MP3 320kbps',
|
||||||
|
internalName: 'FFMPEG_MP3_320',
|
||||||
|
ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'],
|
||||||
|
outputFilename: 'output.mp3',
|
||||||
|
outputMime: 'audio/mpeg',
|
||||||
|
extension: 'mp3',
|
||||||
|
category: 'MP3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'MP3 256kbps',
|
||||||
|
internalName: 'FFMPEG_MP3_256',
|
||||||
|
ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '256k', '-ar', '44100'],
|
||||||
|
outputFilename: 'output.mp3',
|
||||||
|
outputMime: 'audio/mpeg',
|
||||||
|
extension: 'mp3',
|
||||||
|
category: 'MP3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'MP3 128kbps',
|
||||||
|
internalName: 'FFMPEG_MP3_128',
|
||||||
|
ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '128k', '-ar', '44100'],
|
||||||
|
outputFilename: 'output.mp3',
|
||||||
|
outputMime: 'audio/mpeg',
|
||||||
|
extension: 'mp3',
|
||||||
|
category: 'MP3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'OGG 320kbps',
|
||||||
|
internalName: 'FFMPEG_OGG_320',
|
||||||
|
ffmpegArgs: [
|
||||||
|
'-map_metadata',
|
||||||
|
'-1',
|
||||||
|
'-c:a',
|
||||||
|
'libvorbis',
|
||||||
|
'-b:a',
|
||||||
|
'320k',
|
||||||
|
'-minrate',
|
||||||
|
'320k',
|
||||||
|
'-maxrate',
|
||||||
|
'320k',
|
||||||
|
],
|
||||||
|
outputFilename: 'output.ogg',
|
||||||
|
outputMime: 'audio/ogg',
|
||||||
|
extension: 'ogg',
|
||||||
|
category: 'OGG',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'OGG 256kbps',
|
||||||
|
internalName: 'FFMPEG_OGG_256',
|
||||||
|
ffmpegArgs: [
|
||||||
|
'-map_metadata',
|
||||||
|
'-1',
|
||||||
|
'-c:a',
|
||||||
|
'libvorbis',
|
||||||
|
'-b:a',
|
||||||
|
'256k',
|
||||||
|
'-minrate',
|
||||||
|
'256k',
|
||||||
|
'-maxrate',
|
||||||
|
'256k',
|
||||||
|
],
|
||||||
|
outputFilename: 'output.ogg',
|
||||||
|
outputMime: 'audio/ogg',
|
||||||
|
extension: 'ogg',
|
||||||
|
category: 'OGG',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'OGG 128kbps',
|
||||||
|
internalName: 'FFMPEG_OGG_128',
|
||||||
|
ffmpegArgs: [
|
||||||
|
'-map_metadata',
|
||||||
|
'-1',
|
||||||
|
'-c:a',
|
||||||
|
'libvorbis',
|
||||||
|
'-b:a',
|
||||||
|
'128k',
|
||||||
|
'-minrate',
|
||||||
|
'128k',
|
||||||
|
'-maxrate',
|
||||||
|
'128k',
|
||||||
|
],
|
||||||
|
outputFilename: 'output.ogg',
|
||||||
|
outputMime: 'audio/ogg',
|
||||||
|
extension: 'ogg',
|
||||||
|
category: 'OGG',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'AAC 256kbps',
|
||||||
|
internalName: 'FFMPEG_AAC_256',
|
||||||
|
ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '256k'],
|
||||||
|
outputFilename: 'output.m4a',
|
||||||
|
outputMime: 'audio/mp4',
|
||||||
|
extension: 'm4a',
|
||||||
|
category: 'AAC',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container format definitions for lossless re-muxing. Each entry describes
|
||||||
|
* the ffmpeg arguments needed to produce that container and provides a
|
||||||
|
* `needsTranscode` predicate so callers can skip the ffmpeg step when the
|
||||||
|
* source is already in the correct container.
|
||||||
|
*/
|
||||||
|
export const containerFormats: ContainerFormat[] = [
|
||||||
|
{
|
||||||
|
displayName: 'FLAC',
|
||||||
|
internalName: 'flac',
|
||||||
|
ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'],
|
||||||
|
outputFilename: 'output.flac',
|
||||||
|
outputMime: 'audio/flac',
|
||||||
|
extension: 'flac',
|
||||||
|
// Only transcode when the source is NOT already a FLAC file.
|
||||||
|
needsTranscode: async (blob) => (await getExtensionFromBlob(blob)) !== 'flac',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'FLAC - Max Compression',
|
||||||
|
internalName: 'flac_max',
|
||||||
|
// `-compression_level 12` is the highest FLAC compression level; audio
|
||||||
|
// data is bit-identical to the source — only the compressed size changes.
|
||||||
|
ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'],
|
||||||
|
outputFilename: 'output.flac',
|
||||||
|
outputMime: 'audio/flac',
|
||||||
|
extension: 'flac',
|
||||||
|
needsTranscode: async () => true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Apple Lossless',
|
||||||
|
internalName: 'alac',
|
||||||
|
ffmpegArgs: ['-c:a', 'alac'],
|
||||||
|
outputFilename: 'output.m4a',
|
||||||
|
outputMime: 'audio/mp4',
|
||||||
|
extension: 'm4a',
|
||||||
|
needsTranscode: async () => true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: "Don't change",
|
||||||
|
internalName: 'nochange',
|
||||||
|
ffmpegArgs: [],
|
||||||
|
outputFilename: '',
|
||||||
|
outputMime: '',
|
||||||
|
extension: '',
|
||||||
|
needsTranscode: async () => false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */
|
||||||
|
export function isCustomFormat(quality: string): boolean {
|
||||||
|
return getCustomFormat(quality) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks up a custom format by its internal name, or returns undefined */
|
||||||
|
export function getCustomFormat(internalName: string): CustomFormat | undefined {
|
||||||
|
return customFormats.find((f) => f.internalName === internalName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks up a container format by its internal name, or returns undefined */
|
||||||
|
export function getContainerFormat(internalName: string): ContainerFormat | undefined {
|
||||||
|
return containerFormats.find((f) => f.internalName === internalName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcodes an audio blob using the specified custom format via ffmpeg.
|
||||||
|
* Throws if ffmpeg fails during transcoding.
|
||||||
|
*/
|
||||||
|
export async function transcodeWithCustomFormat(
|
||||||
|
audioBlob: Blob,
|
||||||
|
format: CustomFormat,
|
||||||
|
onProgress: ((progress: ProgressEvent) => void) | null = null,
|
||||||
|
signal: AbortSignal | null = null
|
||||||
|
): Promise<Blob> {
|
||||||
|
return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-muxes / re-encodes an audio blob into the specified container format via ffmpeg.
|
||||||
|
* Throws if ffmpeg fails during transcoding.
|
||||||
|
*/
|
||||||
|
export async function transcodeWithContainerFormat(
|
||||||
|
audioBlob: Blob,
|
||||||
|
format: ContainerFormat,
|
||||||
|
onProgress: ((progress: ProgressEvent) => void) | null = null,
|
||||||
|
signal: AbortSignal | null = null
|
||||||
|
): Promise<Blob> {
|
||||||
|
return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal);
|
||||||
|
}
|
||||||
|
|
@ -55,7 +55,8 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
|
||||||
data.albumArtist = track.album?.artist?.name || track.artist?.name;
|
data.albumArtist = track.album?.artist?.name || track.artist?.name;
|
||||||
data.trackNumber = track.trackNumber;
|
data.trackNumber = track.trackNumber;
|
||||||
data.discNumber = getTrackDiscNumber(track) || undefined;
|
data.discNumber = getTrackDiscNumber(track) || undefined;
|
||||||
data.totalTracks = track.album.numberOfTracks;
|
data.totalTracks = track.album.numberOfTracksOnDisc ?? track.album.numberOfTracks;
|
||||||
|
data.totalDiscs = track.album.totalDiscs;
|
||||||
data.copyright = track.copyright;
|
data.copyright = track.copyright;
|
||||||
data.isrc = track.isrc;
|
data.isrc = track.isrc;
|
||||||
data.explicit = Boolean(track.explicit);
|
data.explicit = Boolean(track.explicit);
|
||||||
|
|
|
||||||
78
js/player.js
78
js/player.js
|
|
@ -40,6 +40,7 @@ export class Player {
|
||||||
this.currentRgValues = null;
|
this.currentRgValues = null;
|
||||||
this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7');
|
this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7');
|
||||||
this.isFallbackRetry = false;
|
this.isFallbackRetry = false;
|
||||||
|
this.isFallbackInProgress = false;
|
||||||
this.autoplayBlocked = false;
|
this.autoplayBlocked = false;
|
||||||
this.isIOS = typeof window !== 'undefined' && window.__IS_IOS__ === true;
|
this.isIOS = typeof window !== 'undefined' && window.__IS_IOS__ === true;
|
||||||
this.isPwa =
|
this.isPwa =
|
||||||
|
|
@ -580,7 +581,11 @@ export class Player {
|
||||||
await this.playTrackFromQueue();
|
await this.playTrackFromQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
async playTrackFromQueue(startTime = 0, recursiveCount = 0) {
|
async playTrackFromQueue(startTime = 0, recursiveCount = 0, isRetry = false) {
|
||||||
|
if (!isRetry) {
|
||||||
|
this.isFallbackRetry = false;
|
||||||
|
}
|
||||||
|
|
||||||
const currentSequence = ++this.playbackSequence;
|
const currentSequence = ++this.playbackSequence;
|
||||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||||
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
||||||
|
|
@ -895,6 +900,24 @@ export class Player {
|
||||||
this.autoplayBlocked = true;
|
this.autoplayBlocked = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.quality === 'HI_RES_LOSSLESS' && !this.isFallbackRetry) {
|
||||||
|
this.isFallbackRetry = true;
|
||||||
|
const originalQuality = this.quality;
|
||||||
|
this.quality = 'LOSSLESS';
|
||||||
|
this.isFallbackInProgress = true;
|
||||||
|
try {
|
||||||
|
await this.playTrackFromQueue(startTime, recursiveCount, true);
|
||||||
|
return;
|
||||||
|
} catch (retryError) {
|
||||||
|
} finally {
|
||||||
|
this.quality = originalQuality;
|
||||||
|
this.isFallbackRetry = false;
|
||||||
|
this.isFallbackInProgress = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.error(`Could not play track: ${trackTitle}`, error);
|
console.error(`Could not play track: ${trackTitle}`, error);
|
||||||
// Skip to next track on unexpected error
|
// Skip to next track on unexpected error
|
||||||
if (recursiveCount < currentQueue.length) {
|
if (recursiveCount < currentQueue.length) {
|
||||||
|
|
@ -984,13 +1007,15 @@ export class Player {
|
||||||
const pickedSeeds = await this.pickRadioSeeds();
|
const pickedSeeds = await this.pickRadioSeeds();
|
||||||
if (pickedSeeds.length > 0) {
|
if (pickedSeeds.length > 0) {
|
||||||
this.radioSeeds = pickedSeeds;
|
this.radioSeeds = pickedSeeds;
|
||||||
this.setQueue(pickedSeeds, 0, true);
|
const initialQueue = [...pickedSeeds].sort(() => 0.5 - Math.random()).slice(0, 5);
|
||||||
|
this.setQueue(initialQueue, 0, true);
|
||||||
this.playAtIndex(0);
|
this.playAtIndex(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.radioSeeds = Array.isArray(seeds) ? seeds : [seeds];
|
this.radioSeeds = Array.isArray(seeds) ? seeds : [seeds];
|
||||||
this.wipeQueue();
|
this.wipeQueue();
|
||||||
this.setQueue(this.radioSeeds, 0, true);
|
const initialQueue = Array.isArray(seeds) ? seeds.slice(0, 5) : [seeds];
|
||||||
|
this.setQueue(initialQueue, 0, true);
|
||||||
this.playAtIndex(0);
|
this.playAtIndex(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1021,41 +1046,40 @@ export class Player {
|
||||||
this.radioSeeds = await this.pickRadioSeeds();
|
this.radioSeeds = await this.pickRadioSeeds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shuffledSeeds = [...this.radioSeeds].sort(() => 0.5 - Math.random());
|
||||||
const seeds =
|
const seeds =
|
||||||
this.radioSeeds.length > 0 ? this.radioSeeds : this.currentTrack ? [this.currentTrack] : [];
|
shuffledSeeds.length > 0 ? shuffledSeeds.slice(0, 5) : this.currentTrack ? [this.currentTrack] : [];
|
||||||
|
|
||||||
if (seeds.length === 0) {
|
if (seeds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 10);
|
const [favorites, userPlaylists, history] = await Promise.all([
|
||||||
|
db.getFavorites('track'),
|
||||||
|
db.getAll('user_playlists'),
|
||||||
|
db.getHistory(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const knownTrackIds = new Set([
|
||||||
|
...favorites.map((t) => t.id),
|
||||||
|
...userPlaylists.flatMap((p) => (p.tracks || []).map((t) => t.id)),
|
||||||
|
...history.map((t) => t.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
|
||||||
|
knownTrackIds: knownTrackIds,
|
||||||
|
});
|
||||||
|
|
||||||
if (recommendations && recommendations.length > 0) {
|
if (recommendations && recommendations.length > 0) {
|
||||||
const currentQueueIds = new Set(this.getCurrentQueue().map((t) => t.id));
|
const currentQueueIds = new Set(this.getCurrentQueue().map((t) => t.id));
|
||||||
|
|
||||||
const [favorites, userPlaylists, history] = await Promise.all([
|
let newTracks = recommendations.filter((t) => {
|
||||||
db.getFavorites('track'),
|
return !currentQueueIds.has(t.id);
|
||||||
db.getAll('user_playlists'),
|
|
||||||
db.getHistory(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const knownTrackIds = new Set([
|
|
||||||
...favorites.map((t) => t.id),
|
|
||||||
...userPlaylists.flatMap((p) => (p.tracks || []).map((t) => t.id)),
|
|
||||||
...history.map((t) => t.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const newTracks = recommendations.filter((t) => {
|
|
||||||
if (currentQueueIds.has(t.id)) return false;
|
|
||||||
|
|
||||||
if (knownTrackIds.has(t.id)) {
|
|
||||||
return Math.random() < 0.05;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (newTracks.length > 0) {
|
if (newTracks.length > 0) {
|
||||||
this.addToQueue(newTracks);
|
const tracksToAdd = newTracks.sort(() => 0.5 - Math.random()).slice(0, 5);
|
||||||
|
this.addToQueue(tracksToAdd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -1112,7 +1136,7 @@ export class Player {
|
||||||
potentialSeeds.find((s) => s.id === id)
|
potentialSeeds.find((s) => s.id === id)
|
||||||
);
|
);
|
||||||
|
|
||||||
return uniqueSeeds.sort(() => 0.5 - Math.random()).slice(0, 5);
|
return uniqueSeeds.sort(() => 0.5 - Math.random()).slice(0, 50);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to pick radio seeds:', error);
|
console.error('Failed to pick radio seeds:', error);
|
||||||
return this.currentTrack ? [this.currentTrack] : [];
|
return this.currentTrack ? [this.currentTrack] : [];
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,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 { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.js';
|
import { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.js';
|
||||||
|
import { containerFormats, customFormats } from './ffmpegFormats.ts';
|
||||||
|
|
||||||
export function initializeSettings(scrobbler, player, api, ui) {
|
export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
// Restore last active settings tab
|
// Restore last active settings tab
|
||||||
|
|
@ -800,6 +801,63 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
// Download Quality setting
|
// Download Quality setting
|
||||||
const downloadQualitySetting = document.getElementById('download-quality-setting');
|
const downloadQualitySetting = document.getElementById('download-quality-setting');
|
||||||
if (downloadQualitySetting) {
|
if (downloadQualitySetting) {
|
||||||
|
// Assign categories to the static (native) options already in the HTML
|
||||||
|
const staticCategories = {
|
||||||
|
HI_RES_LOSSLESS: 'Lossless',
|
||||||
|
LOSSLESS: 'Lossless',
|
||||||
|
HIGH: 'AAC',
|
||||||
|
LOW: 'AAC',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect static options first (preserving their original order)
|
||||||
|
const allOptions = Array.from(downloadQualitySetting.options).map((opt) => ({
|
||||||
|
value: opt.value,
|
||||||
|
text: opt.textContent,
|
||||||
|
category: staticCategories[opt.value] || 'Other',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Append custom (ffmpeg-transcoded) format options
|
||||||
|
for (const fmt of customFormats) {
|
||||||
|
allOptions.push({ value: fmt.internalName, text: fmt.displayName, category: fmt.category });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by category order first, then by bitrate descending within each category
|
||||||
|
// so higher-quality options always appear before lower-quality ones.
|
||||||
|
// Options without an explicit kbps value (lossless) use Infinity so they
|
||||||
|
// sort to the top; ties fall back to display-name descending.
|
||||||
|
const getBitrate = (text) => {
|
||||||
|
const m = text.match(/(\d+)\s*kbps/i);
|
||||||
|
return m ? parseInt(m[1], 10) : Infinity;
|
||||||
|
};
|
||||||
|
const categoryOrder = ['Lossless', 'AAC', 'MP3', 'OGG'];
|
||||||
|
allOptions.sort((a, b) => {
|
||||||
|
const ai = categoryOrder.indexOf(a.category);
|
||||||
|
const bi = categoryOrder.indexOf(b.category);
|
||||||
|
const categoryDiff = (ai === -1 ? categoryOrder.length : ai) - (bi === -1 ? categoryOrder.length : bi);
|
||||||
|
if (categoryDiff !== 0) return categoryDiff;
|
||||||
|
const bitrateA = getBitrate(a.text);
|
||||||
|
const bitrateB = getBitrate(b.text);
|
||||||
|
if (bitrateA !== bitrateB) return bitrateB - bitrateA;
|
||||||
|
return b.text.localeCompare(a.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rebuild the select with optgroup elements per category
|
||||||
|
downloadQualitySetting.innerHTML = '';
|
||||||
|
let currentGroup = null;
|
||||||
|
let currentCategory = null;
|
||||||
|
for (const opt of allOptions) {
|
||||||
|
if (opt.category !== currentCategory) {
|
||||||
|
currentCategory = opt.category;
|
||||||
|
currentGroup = document.createElement('optgroup');
|
||||||
|
currentGroup.label = opt.category;
|
||||||
|
downloadQualitySetting.appendChild(currentGroup);
|
||||||
|
}
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = opt.value;
|
||||||
|
option.textContent = opt.text;
|
||||||
|
currentGroup.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
downloadQualitySetting.value = downloadQualitySettings.getQuality();
|
downloadQualitySetting.value = downloadQualitySettings.getQuality();
|
||||||
|
|
||||||
downloadQualitySetting.addEventListener('change', (e) => {
|
downloadQualitySetting.addEventListener('change', (e) => {
|
||||||
|
|
@ -809,6 +867,13 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
|
|
||||||
const losslessContainerSetting = document.getElementById('lossless-container-setting');
|
const losslessContainerSetting = document.getElementById('lossless-container-setting');
|
||||||
if (losslessContainerSetting) {
|
if (losslessContainerSetting) {
|
||||||
|
for (const { internalName, displayName } of containerFormats) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = internalName;
|
||||||
|
option.textContent = displayName;
|
||||||
|
losslessContainerSetting.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
losslessContainerSetting.value = losslessContainerSettings.getContainer();
|
losslessContainerSetting.value = losslessContainerSettings.getContainer();
|
||||||
|
|
||||||
losslessContainerSetting.addEventListener('change', (e) => {
|
losslessContainerSetting.addEventListener('change', (e) => {
|
||||||
|
|
@ -2274,6 +2339,9 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
ui.visualizer.setPreset(val);
|
ui.visualizer.setPreset(val);
|
||||||
}
|
}
|
||||||
updateButterchurnSettingsVisibility();
|
updateButterchurnSettingsVisibility();
|
||||||
|
|
||||||
|
//Since changing the preset breaks the visualizer, a location.reload() is added to make sure that it works
|
||||||
|
window.location.reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,14 +61,16 @@ 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) {
|
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);
|
renderControlsCallback(this.controlsElement);
|
||||||
}
|
}
|
||||||
if (renderContentCallback) {
|
if (renderContentCallback) {
|
||||||
this.contentElement.innerHTML = '';
|
if (!options.noClear) {
|
||||||
|
this.contentElement.innerHTML = '';
|
||||||
|
}
|
||||||
renderContentCallback(this.contentElement);
|
renderContentCallback(this.contentElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -539,7 +539,13 @@ export const downloadQualitySettings = {
|
||||||
STORAGE_KEY: 'download-quality',
|
STORAGE_KEY: 'download-quality',
|
||||||
getQuality() {
|
getQuality() {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(this.STORAGE_KEY) || 'HI_RES_LOSSLESS';
|
const stored = localStorage.getItem(this.STORAGE_KEY) || 'HI_RES_LOSSLESS';
|
||||||
|
// Migrate legacy value to renamed format
|
||||||
|
if (stored === 'MP3_320') {
|
||||||
|
this.setQuality('FFMPEG_MP3_320');
|
||||||
|
return 'FFMPEG_MP3_320';
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
} catch {
|
} catch {
|
||||||
return 'HI_RES_LOSSLESS';
|
return 'HI_RES_LOSSLESS';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,15 @@ export function initializeUIInteractions(player, api, ui) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let draggedQueueIndex = null;
|
let draggedQueueIndex = null;
|
||||||
|
let queueStartIndex = 0;
|
||||||
|
let queueEndIndex = 1000;
|
||||||
|
let isQueueRendering = false;
|
||||||
|
let topObserver = null;
|
||||||
|
let bottomObserver = null;
|
||||||
|
const QUEUE_VIRTUALIZATION_THRESHOLD = 1500;
|
||||||
|
const QUEUE_MAX_RENDERED = 1000;
|
||||||
|
const QUEUE_CHUNK_SIZE = 200;
|
||||||
|
const ESTIMATED_ITEM_HEIGHT = 58;
|
||||||
|
|
||||||
// Sidebar mobile
|
// Sidebar mobile
|
||||||
hamburgerBtn.addEventListener('click', () => {
|
hamburgerBtn.addEventListener('click', () => {
|
||||||
|
|
@ -232,66 +241,223 @@ export function initializeUIInteractions(player, api, ui) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderQueueContent = (container) => {
|
const renderQueueItemHTML = (track, index) => {
|
||||||
|
const isPlaying = index === player.currentQueueIndex;
|
||||||
|
const isBlocked = contentBlockingSettings?.shouldHideTrack(track);
|
||||||
|
const trackTitle = getTrackTitle(track);
|
||||||
|
const trackArtists = getTrackArtists(track, { fallback: 'Unknown' });
|
||||||
|
const qualityBadge = createQualityBadgeHTML(track);
|
||||||
|
const blockedTitle = isBlocked
|
||||||
|
? `title="Blocked: ${contentBlockingSettings.isTrackBlocked(track.id) ? 'Track blocked' : contentBlockingSettings.isArtistBlocked(track.artist?.id) ? 'Artist blocked' : 'Album blocked'}"`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const isVideo = track.type === 'video';
|
||||||
|
const coverUrl =
|
||||||
|
isVideo && track.imageId ? api.getVideoCoverUrl(track.imageId) : api.getCoverUrl(track.album?.cover);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="queue-track-item ${isPlaying ? 'playing' : ''} ${isBlocked ? 'blocked' : ''}" data-queue-index="${index}" data-track-id="${track.id}" draggable="${isBlocked ? 'false' : 'true'}" ${blockedTitle}>
|
||||||
|
<div class="drag-handle">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="5" y1="8" x2="19" y2="8"></line>
|
||||||
|
<line x1="5" y1="16" x2="19" y2="16"></line>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="track-item-info">
|
||||||
|
<img src="${coverUrl}"
|
||||||
|
class="track-item-cover" loading="lazy">
|
||||||
|
<div class="track-item-details">
|
||||||
|
<div class="title">${escapeHtml(trackTitle)} ${qualityBadge}</div>
|
||||||
|
<div class="artist">${escapeHtml(trackArtists)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="track-item-duration">${isBlocked ? '--:--' : formatTime(track.duration)}</div>
|
||||||
|
<button class="queue-like-btn" data-action="toggle-like" title="Add to Liked">
|
||||||
|
${SVG_HEART}
|
||||||
|
</button>
|
||||||
|
<button class="queue-remove-btn" data-track-index="${index}" title="Remove from queue">
|
||||||
|
${SVG_BIN}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachQueueListeners = (container) => {
|
||||||
|
if (container._queueListenersAttached) return;
|
||||||
|
|
||||||
|
container.addEventListener('click', async (e) => {
|
||||||
|
const item = e.target.closest('.queue-track-item');
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const index = parseInt(item.dataset.queueIndex);
|
||||||
|
const removeBtn = e.target.closest('.queue-remove-btn');
|
||||||
|
if (removeBtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
player.removeFromQueue(index);
|
||||||
|
refreshQueuePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const likeBtn = e.target.closest('.queue-like-btn');
|
||||||
|
if (likeBtn && likeBtn.dataset.action === 'toggle-like') {
|
||||||
|
e.stopPropagation();
|
||||||
|
const track = player.getCurrentQueue()[index];
|
||||||
|
if (track) {
|
||||||
|
const added = await db.toggleFavorite('track', track);
|
||||||
|
syncManager.syncLibraryItem('track', track, added);
|
||||||
|
|
||||||
|
likeBtn.classList.toggle('active', added);
|
||||||
|
likeBtn.innerHTML = added
|
||||||
|
? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"')
|
||||||
|
: SVG_HEART;
|
||||||
|
|
||||||
|
showNotification(added ? `Added to Liked: ${track.title}` : `Removed from Liked: ${track.title}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.classList.contains('blocked')) return;
|
||||||
|
|
||||||
|
player.playAtIndex(index);
|
||||||
|
refreshQueuePanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('contextmenu', async (e) => {
|
||||||
|
const item = e.target.closest('.queue-track-item');
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const index = parseInt(item.dataset.queueIndex);
|
||||||
|
const contextMenu = document.getElementById('context-menu');
|
||||||
|
if (contextMenu) {
|
||||||
|
const track = player.getCurrentQueue()[index];
|
||||||
|
if (track) {
|
||||||
|
const isLiked = await db.isFavorite('track', track.id);
|
||||||
|
const likeItem = contextMenu.querySelector('li[data-action="toggle-like"]');
|
||||||
|
if (likeItem) {
|
||||||
|
likeItem.textContent = isLiked ? 'Unlike' : 'Like';
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackMixItem = contextMenu.querySelector('li[data-action="track-mix"]');
|
||||||
|
if (trackMixItem) {
|
||||||
|
const hasMix = track.mixes && track.mixes.TRACK_MIX;
|
||||||
|
trackMixItem.style.display = hasMix ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
positionMenu(contextMenu, e.clientX, e.clientY);
|
||||||
|
contextMenu._contextTrack = track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('dragstart', (e) => {
|
||||||
|
const item = e.target.closest('.queue-track-item');
|
||||||
|
if (item) {
|
||||||
|
draggedQueueIndex = parseInt(item.dataset.queueIndex);
|
||||||
|
item.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('dragend', (e) => {
|
||||||
|
const item = e.target.closest('.queue-track-item');
|
||||||
|
if (item) {
|
||||||
|
item.style.opacity = '1';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const item = e.target.closest('.queue-track-item');
|
||||||
|
if (item && draggedQueueIndex !== null) {
|
||||||
|
const index = parseInt(item.dataset.queueIndex);
|
||||||
|
if (draggedQueueIndex !== index) {
|
||||||
|
player.moveInQueue(draggedQueueIndex, index);
|
||||||
|
refreshQueuePanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
container._queueListenersAttached = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderQueueContent = (container, isUpdate = false) => {
|
||||||
const currentQueue = player.getCurrentQueue();
|
const currentQueue = player.getCurrentQueue();
|
||||||
|
|
||||||
if (currentQueue.length === 0) {
|
if (currentQueue.length === 0) {
|
||||||
container.innerHTML = '<div class="placeholder-text">Queue is empty.</div>';
|
container.innerHTML = '<div class="placeholder-text">Queue is empty.</div>';
|
||||||
|
queueStartIndex = 0;
|
||||||
|
queueEndIndex = QUEUE_MAX_RENDERED;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = currentQueue
|
isQueueRendering = true;
|
||||||
.map((track, index) => {
|
attachQueueListeners(container);
|
||||||
const isPlaying = index === player.currentQueueIndex;
|
|
||||||
const isBlocked = contentBlockingSettings?.shouldHideTrack(track);
|
|
||||||
const trackTitle = getTrackTitle(track);
|
|
||||||
const trackArtists = getTrackArtists(track, { fallback: 'Unknown' });
|
|
||||||
const qualityBadge = createQualityBadgeHTML(track);
|
|
||||||
const blockedTitle = isBlocked
|
|
||||||
? `title="Blocked: ${contentBlockingSettings.isTrackBlocked(track.id) ? 'Track blocked' : contentBlockingSettings.isArtistBlocked(track.artist?.id) ? 'Artist blocked' : 'Album blocked'}"`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const isVideo = track.type === 'video';
|
if (currentQueue.length > QUEUE_VIRTUALIZATION_THRESHOLD) {
|
||||||
const coverUrl =
|
if (!isUpdate) {
|
||||||
isVideo && track.imageId
|
const currentIndex = player.currentQueueIndex || 0;
|
||||||
? api.getVideoCoverUrl(track.imageId)
|
queueStartIndex = Math.max(0, Math.floor((currentIndex - QUEUE_MAX_RENDERED / 2) / 100) * 100);
|
||||||
: api.getCoverUrl(track.album?.cover);
|
queueEndIndex = Math.min(currentQueue.length, queueStartIndex + QUEUE_MAX_RENDERED);
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
const visibleTracks = currentQueue.slice(queueStartIndex, queueEndIndex);
|
||||||
<div class="queue-track-item ${isPlaying ? 'playing' : ''} ${isBlocked ? 'blocked' : ''}" data-queue-index="${index}" data-track-id="${track.id}" draggable="${isBlocked ? 'false' : 'true'}" ${blockedTitle}>
|
const topSpacerHeight = queueStartIndex * ESTIMATED_ITEM_HEIGHT;
|
||||||
<div class="drag-handle">
|
const bottomSpacerHeight = (currentQueue.length - queueEndIndex) * ESTIMATED_ITEM_HEIGHT;
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<line x1="5" y1="8" x2="19" y2="8"></line>
|
container.innerHTML = `
|
||||||
<line x1="5" y1="16" x2="19" y2="16"></line>
|
<div class="queue-virtual-container" style="padding: 0.5rem">
|
||||||
</svg>
|
<div id="queue-top-sentinel" style="height: 20px; margin-top: ${topSpacerHeight}px"></div>
|
||||||
|
<div class="queue-items-wrapper">
|
||||||
|
${visibleTracks.map((track, i) => renderQueueItemHTML(track, queueStartIndex + i)).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="track-item-info">
|
<div id="queue-bottom-sentinel" style="height: 20px; margin-bottom: ${bottomSpacerHeight}px"></div>
|
||||||
<img src="${coverUrl}"
|
|
||||||
class="track-item-cover" loading="lazy">
|
|
||||||
<div class="track-item-details">
|
|
||||||
<div class="title">${escapeHtml(trackTitle)} ${qualityBadge}</div>
|
|
||||||
<div class="artist">${escapeHtml(trackArtists)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="track-item-duration">${isBlocked ? '--:--' : formatTime(track.duration)}</div>
|
|
||||||
<button class="queue-like-btn" data-action="toggle-like" title="Add to Liked">
|
|
||||||
${SVG_HEART}
|
|
||||||
</button>
|
|
||||||
<button class="queue-remove-btn" data-track-index="${index}" title="Remove from queue">
|
|
||||||
${SVG_BIN}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
container.innerHTML = html;
|
if (topObserver) topObserver.disconnect();
|
||||||
|
if (bottomObserver) bottomObserver.disconnect();
|
||||||
|
|
||||||
|
bottomObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && !isQueueRendering && queueEndIndex < currentQueue.length) {
|
||||||
|
queueEndIndex = Math.min(currentQueue.length, queueEndIndex + QUEUE_CHUNK_SIZE);
|
||||||
|
if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) {
|
||||||
|
queueStartIndex += QUEUE_CHUNK_SIZE;
|
||||||
|
}
|
||||||
|
renderQueueContent(container, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: container, rootMargin: '200px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
topObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && !isQueueRendering && queueStartIndex > 0) {
|
||||||
|
queueStartIndex = Math.max(0, queueStartIndex - QUEUE_CHUNK_SIZE);
|
||||||
|
if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) {
|
||||||
|
queueEndIndex -= QUEUE_CHUNK_SIZE;
|
||||||
|
}
|
||||||
|
renderQueueContent(container, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: container, rootMargin: '200px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
topObserver.observe(container.querySelector('#queue-top-sentinel'));
|
||||||
|
bottomObserver.observe(container.querySelector('#queue-bottom-sentinel'));
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `<div style="padding: 0.5rem">${currentQueue.map((track, index) => renderQueueItemHTML(track, index)).join('')}</div>`;
|
||||||
|
if (topObserver) topObserver.disconnect();
|
||||||
|
if (bottomObserver) bottomObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
container.querySelectorAll('.queue-track-item').forEach(async (item) => {
|
container.querySelectorAll('.queue-track-item').forEach(async (item) => {
|
||||||
const index = parseInt(item.dataset.queueIndex);
|
const index = parseInt(item.dataset.queueIndex);
|
||||||
const track = player.getCurrentQueue()[index];
|
const track = currentQueue[index];
|
||||||
|
|
||||||
// Update like button state
|
|
||||||
const likeBtn = item.querySelector('.queue-like-btn');
|
const likeBtn = item.querySelector('.queue-like-btn');
|
||||||
if (likeBtn && track) {
|
if (likeBtn && track) {
|
||||||
const isLiked = await db.isFavorite('track', track.id);
|
const isLiked = await db.isFavorite('track', track.id);
|
||||||
|
|
@ -300,101 +466,26 @@ export function initializeUIInteractions(player, api, ui) {
|
||||||
? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"')
|
? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"')
|
||||||
: SVG_HEART;
|
: SVG_HEART;
|
||||||
}
|
}
|
||||||
|
|
||||||
item.addEventListener('click', async (e) => {
|
|
||||||
const removeBtn = e.target.closest('.queue-remove-btn');
|
|
||||||
if (removeBtn) {
|
|
||||||
e.stopPropagation();
|
|
||||||
player.removeFromQueue(index);
|
|
||||||
refreshQueuePanel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const likeBtn = e.target.closest('.queue-like-btn');
|
|
||||||
if (likeBtn && likeBtn.dataset.action === 'toggle-like') {
|
|
||||||
e.stopPropagation();
|
|
||||||
const track = player.getCurrentQueue()[index];
|
|
||||||
if (track) {
|
|
||||||
const added = await db.toggleFavorite('track', track);
|
|
||||||
syncManager.syncLibraryItem('track', track, added);
|
|
||||||
|
|
||||||
// Update button state
|
|
||||||
likeBtn.classList.toggle('active', added);
|
|
||||||
likeBtn.innerHTML = added
|
|
||||||
? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"')
|
|
||||||
: SVG_HEART;
|
|
||||||
|
|
||||||
showNotification(
|
|
||||||
added ? `Added to Liked: ${track.title}` : `Removed from Liked: ${track.title}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't play blocked tracks
|
|
||||||
if (item.classList.contains('blocked')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
player.playAtIndex(index);
|
|
||||||
refreshQueuePanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
item.addEventListener('contextmenu', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const contextMenu = document.getElementById('context-menu');
|
|
||||||
if (contextMenu) {
|
|
||||||
const track = player.getCurrentQueue()[index];
|
|
||||||
if (track) {
|
|
||||||
const isLiked = await db.isFavorite('track', track.id);
|
|
||||||
const likeItem = contextMenu.querySelector('li[data-action="toggle-like"]');
|
|
||||||
if (likeItem) {
|
|
||||||
likeItem.textContent = isLiked ? 'Unlike' : 'Like';
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackMixItem = contextMenu.querySelector('li[data-action="track-mix"]');
|
|
||||||
if (trackMixItem) {
|
|
||||||
const hasMix = track.mixes && track.mixes.TRACK_MIX;
|
|
||||||
trackMixItem.style.display = hasMix ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
positionMenu(contextMenu, e.clientX, e.clientY);
|
|
||||||
|
|
||||||
contextMenu._contextTrack = track;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
item.addEventListener('dragstart', () => {
|
|
||||||
draggedQueueIndex = index;
|
|
||||||
item.style.opacity = '0.5';
|
|
||||||
});
|
|
||||||
|
|
||||||
item.addEventListener('dragend', () => {
|
|
||||||
item.style.opacity = '1';
|
|
||||||
});
|
|
||||||
|
|
||||||
item.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
item.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (draggedQueueIndex !== null && draggedQueueIndex !== index) {
|
|
||||||
player.moveInQueue(draggedQueueIndex, index);
|
|
||||||
refreshQueuePanel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
isQueueRendering = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshQueuePanel = () => {
|
const refreshQueuePanel = () => {
|
||||||
sidePanelManager.refresh('queue', renderQueueControls, renderQueueContent);
|
sidePanelManager.refresh('queue', renderQueueControls, renderQueueContent, { noClear: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const openQueuePanel = () => {
|
const openQueuePanel = () => {
|
||||||
trackOpenQueue();
|
trackOpenQueue();
|
||||||
sidePanelManager.open('queue', 'Queue', renderQueueControls, renderQueueContent);
|
sidePanelManager.open('queue', 'Queue', renderQueueControls, renderQueueContent);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const container = document.getElementById('side-panel-content');
|
||||||
|
const playingItem = container?.querySelector('.queue-track-item.playing');
|
||||||
|
if (playingItem) {
|
||||||
|
playingItem.scrollIntoView({ block: 'center', behavior: 'auto' });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
queueBtn.addEventListener('click', openQueuePanel);
|
queueBtn.addEventListener('click', openQueuePanel);
|
||||||
|
|
|
||||||
30
js/ui.js
30
js/ui.js
|
|
@ -1242,6 +1242,20 @@ export class UIRenderer {
|
||||||
showButton();
|
showButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleUI = (e) => {
|
||||||
|
if (e) e.stopPropagation();
|
||||||
|
isUIHidden = !isUIHidden;
|
||||||
|
overlay.classList.toggle('ui-hidden', isUIHidden);
|
||||||
|
toggleBtn.classList.toggle('active', isUIHidden);
|
||||||
|
toggleBtn.title = isUIHidden ? 'Show UI' : 'Hide UI';
|
||||||
|
|
||||||
|
if (isUIHidden) {
|
||||||
|
hideButton();
|
||||||
|
} else {
|
||||||
|
showButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Mouse move handler
|
// Mouse move handler
|
||||||
const handleMouseMove = (e) => {
|
const handleMouseMove = (e) => {
|
||||||
const rect = overlay.getBoundingClientRect();
|
const rect = overlay.getBoundingClientRect();
|
||||||
|
|
@ -2188,9 +2202,21 @@ export class UIRenderer {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const seeds = providedSeeds || (await this.getSeeds());
|
const seeds = providedSeeds || (await this.getSeeds());
|
||||||
const trackSeeds = seeds.slice(0, 5);
|
|
||||||
const recommendedTracks = await this.api.getRecommendedTracksForPlaylist(trackSeeds, 20, {
|
const [favorites, playlists, history] = await Promise.all([
|
||||||
|
db.getFavorites('track'),
|
||||||
|
db.getPlaylists(true),
|
||||||
|
db.getHistory(),
|
||||||
|
]);
|
||||||
|
const knownTrackIds = new Set([
|
||||||
|
...favorites.map((t) => t.id),
|
||||||
|
...playlists.flatMap((p) => (p.tracks || []).map((t) => t.id)),
|
||||||
|
...history.map((t) => t.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const recommendedTracks = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
|
||||||
skipCache: forceRefresh,
|
skipCache: forceRefresh,
|
||||||
|
knownTrackIds: knownTrackIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredTracks = await this.filterUserContent(recommendedTracks, 'track');
|
const filteredTracks = await this.filterUserContent(recommendedTracks, 'track');
|
||||||
|
|
|
||||||
20
js/utils.js
20
js/utils.js
|
|
@ -108,6 +108,17 @@ export const detectAudioFormat = (view, mimeType = '') => {
|
||||||
return 'flac';
|
return 'flac';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for OGG signature: "OggS" (0x4F 0x67 0x67 0x53)
|
||||||
|
if (
|
||||||
|
view.byteLength >= 4 &&
|
||||||
|
view.getUint8(0) === 0x4f && // O
|
||||||
|
view.getUint8(1) === 0x67 && // g
|
||||||
|
view.getUint8(2) === 0x67 && // g
|
||||||
|
view.getUint8(3) === 0x53 // S
|
||||||
|
) {
|
||||||
|
return 'ogg';
|
||||||
|
}
|
||||||
|
|
||||||
// Check for MP4/M4A signature: "ftyp" at offset 4
|
// Check for MP4/M4A signature: "ftyp" at offset 4
|
||||||
if (
|
if (
|
||||||
view.byteLength >= 8 &&
|
view.byteLength >= 8 &&
|
||||||
|
|
@ -153,6 +164,7 @@ export const detectAudioFormat = (view, mimeType = '') => {
|
||||||
|
|
||||||
// Fallback to MIME type
|
// Fallback to MIME type
|
||||||
if (mimeType === 'audio/flac') return 'flac';
|
if (mimeType === 'audio/flac') return 'flac';
|
||||||
|
if (mimeType === 'audio/ogg') return 'ogg';
|
||||||
if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4';
|
if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4';
|
||||||
if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3';
|
if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3';
|
||||||
|
|
||||||
|
|
@ -177,8 +189,10 @@ export const getExtensionFromBlob = async (blob) => {
|
||||||
if (format) return format;
|
if (format) return format;
|
||||||
|
|
||||||
if (blob.type.includes('video')) return 'mp4';
|
if (blob.type.includes('video')) return 'mp4';
|
||||||
if (blob.type === 'audio/mp4' || blob.type === 'audio/x-m4a') return 'm4a';
|
if (mimeType === 'audio/flac') return 'flac';
|
||||||
if (blob.type === 'audio/mpeg' || blob.type === 'audio/mp3') return 'mp3';
|
if (mimeType === 'audio/ogg') return 'ogg';
|
||||||
|
if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4';
|
||||||
|
if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3';
|
||||||
|
|
||||||
return 'flac';
|
return 'flac';
|
||||||
};
|
};
|
||||||
|
|
@ -188,8 +202,6 @@ export const getExtensionForQuality = (quality) => {
|
||||||
case 'LOW':
|
case 'LOW':
|
||||||
case 'HIGH':
|
case 'HIGH':
|
||||||
return 'm4a';
|
return 'm4a';
|
||||||
case 'MP3_320':
|
|
||||||
return 'mp3';
|
|
||||||
default:
|
default:
|
||||||
return 'flac';
|
return 'flac';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,8 @@ export class LCDPreset {
|
||||||
this.initWebGL(width, height);
|
this.initWebGL(width, height);
|
||||||
// Attach WebGL canvas to same parent as main canvas
|
// Attach WebGL canvas to same parent as main canvas
|
||||||
if (this.glCanvas && canvas.parentElement) {
|
if (this.glCanvas && canvas.parentElement) {
|
||||||
canvas.parentElement.style.position = 'relative';
|
//This position:relative was causing the visual bugs and problems in the lcd visualiser.
|
||||||
|
// canvas.parentElement.style.position = 'relative';
|
||||||
canvas.parentElement.appendChild(this.glCanvas);
|
canvas.parentElement.appendChild(this.glCanvas);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,5 +152,16 @@
|
||||||
"explicit": false,
|
"explicit": false,
|
||||||
"audioQuality": "LOSSLESS",
|
"audioQuality": "LOSSLESS",
|
||||||
"mediaMetadata": { "tags": ["LOSSLESS", "HIRES_LOSSLESS"] }
|
"mediaMetadata": { "tags": ["LOSSLESS", "HIRES_LOSSLESS"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"id": "344201347",
|
||||||
|
"title": "Flex Musix (FLXTRA)",
|
||||||
|
"artist": { "id": 27836827, "name": "OsamaSon" },
|
||||||
|
"releaseDate": "2024-02-16",
|
||||||
|
"cover": "5d1812fc-b9f9-4467-ac78-90d78ea542e4",
|
||||||
|
"explicit": true,
|
||||||
|
"audioQuality": "LOSSLESS",
|
||||||
|
"mediaMetadata": { "tags": ["LOSSLESS", "HIRES_LOSSLESS"] }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -7590,7 +7590,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
||||||
|
|
||||||
/* EQ Response Curve Canvas */
|
/* EQ Response Curve Canvas */
|
||||||
.eq-response-canvas {
|
.eq-response-canvas {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: var(--spacing-md);
|
top: var(--spacing-md);
|
||||||
left: 4px;
|
left: 4px;
|
||||||
width: calc(100% - 8px);
|
width: calc(100% - 8px);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue