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

This commit is contained in:
Samidy 2026-02-24 01:57:43 +03:00
commit 38434f5419
16 changed files with 1055 additions and 153 deletions

View file

@ -218,3 +218,13 @@ We welcome contributions from the community! Please see our [Contributing Guide]
<p align="center">
Made with ❤️ by the Monochrome team
</p>
## Star History
<a href="https://www.star-history.com/#monochrome-music/monochrome&type=date&logscale&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=monochrome-music/monochrome&type=date&theme=dark&logscale&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=monochrome-music/monochrome&type=date&logscale&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=monochrome-music/monochrome&type=date&logscale&legend=top-left" />
</picture>
</a>

View file

@ -38,8 +38,8 @@ export async function onRequest(context) {
const uploaded = form.get('file');
if (!uploaded) return jsonError('No file provided', 400);
if (uploaded.size > 500 * 1024 * 1024) {
return jsonError('File exceeds 500MB', 400);
if (uploaded.size > 100 * 1024 * 1024) {
return jsonError('File exceeds 100MB', 400);
}
file = await uploaded.arrayBuffer();

View file

@ -2600,8 +2600,31 @@
id="playlist-section-recommended"
style="display: none; margin-top: 3rem"
>
<h2 class="section-title">Recommended Songs</h2>
<p style="color: grey; margin-bottom: 15px">Suggested Songs From Your Playlist</p>
<div class="section-header-row">
<div>
<h2 class="section-title">Recommended Songs</h2>
<p style="color: grey; margin-bottom: 15px">Suggested Songs From Your Playlist</p>
</div>
<button
id="refresh-recommended-songs-btn"
class="btn-secondary"
title="Refresh Recommendations"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
</button>
</div>
<div class="track-list" id="playlist-detail-recommended"></div>
</section>
</section>
@ -4643,6 +4666,18 @@
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Separate Discs in ZIP</span>
<span class="description"
>Put tracks in Disc folders when a release has multiple discs</span
>
</div>
<label class="toggle-switch">
<input type="checkbox" id="separate-discs-zip-toggle" />
<span class="slider"></span>
</label>
</div>
</div>
</div>
</div>

View file

@ -957,15 +957,25 @@ export class LosslessAPI {
const recommendedTracks = [];
const seenTrackIds = new Set(tracks.map((t) => t.id));
const artistsToProcess = artists.slice(0, Math.min(5, artists.length));
// Shuffle artists if refreshing to get different results
let shuffledArtists = artists;
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) => {
try {
console.log(`Fetching tracks for artist: ${artist.name} (ID: ${artist.id})`);
const artistData = await this.getArtist(artist.id, { lightweight: true, skipCache: options.skipCache });
const artistData = await this.getArtist(artist.id, { lightweight: true, skipCache: options.refresh });
if (artistData && artistData.tracks && artistData.tracks.length > 0) {
const newTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id)).slice(0, 4);
return newTracks;
const availableTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id));
// Shuffle and pick different tracks when refreshing
const shuffled = options.refresh
? availableTracks.sort(() => Math.random() - 0.5)
: availableTracks;
return shuffled.slice(0, 4);
} else {
console.warn(`No tracks found for artist ${artist.name}`);
return [];

137
js/app.js
View file

@ -49,7 +49,15 @@ import {
trackOpenLyrics,
trackCloseLyrics,
} from './analytics.js';
import { parseCSV, parseJSPF, parseXSPF, parseXML, parseM3U } from './playlist-importer.js';
import {
parseCSV,
parseJSPF,
parseXSPF,
parseXML,
parseM3U,
parseDynamicCSV,
importToLibrary,
} from './playlist-importer.js';
// Lazy-loaded modules
let settingsModule = null;
@ -840,7 +848,74 @@ document.addEventListener('DOMContentLoaded', async () => {
if (e.target.closest('#shuffle-artist-btn')) {
const btn = e.target.closest('#shuffle-artist-btn');
if (btn.disabled) return;
document.getElementById('play-artist-radio-btn')?.click();
const artistId = window.location.pathname.split('/')[2];
if (!artistId) return;
btn.disabled = true;
const originalHTML = btn.innerHTML;
btn.innerHTML =
'<svg class="animate-spin" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Shuffling...</span>';
try {
const artist = await api.getArtist(artistId);
const allReleases = [...(artist.albums || []), ...(artist.eps || [])];
const trackSet = new Set();
const allTracks = [];
// Fetch full artist discography tracks (albums + EPs), deduped by track ID.
const chunkSize = 8;
for (let i = 0; i < allReleases.length; i += chunkSize) {
const chunk = allReleases.slice(i, i + chunkSize);
await Promise.all(
chunk.map(async (album) => {
try {
const { tracks } = await api.getAlbum(album.id);
tracks.forEach((track) => {
if (!trackSet.has(track.id)) {
trackSet.add(track.id);
allTracks.push(track);
}
});
} catch (err) {
console.warn(`Failed to fetch tracks for album ${album.title}:`, err);
}
})
);
}
// Fallback to artist top tracks if discography fetch yields nothing.
if (allTracks.length === 0 && Array.isArray(artist.tracks)) {
artist.tracks.forEach((track) => {
if (!trackSet.has(track.id)) {
trackSet.add(track.id);
allTracks.push(track);
}
});
}
if (allTracks.length === 0) {
throw new Error('No tracks found for this artist');
}
const shuffledTracks = [...allTracks].sort(() => Math.random() - 0.5);
player.setQueue(shuffledTracks, 0);
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active');
player.shuffleActive = false;
player.playTrackFromQueue();
const { showNotification } = await loadDownloadsModule();
showNotification('Shuffling artist discography');
} catch (error) {
console.error('Failed to shuffle artist tracks:', error);
const { showNotification } = await loadDownloadsModule();
showNotification('Failed to shuffle artist tracks');
} finally {
if (document.body.contains(btn)) {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
}
if (e.target.closest('#download-mix-btn')) {
const btn = e.target.closest('#download-mix-btn');
@ -1252,8 +1327,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}, 1000);
}
} else if (csvFileInput.files.length > 0) {
// Import from CSV
importSource = 'csv_import';
const file = csvFileInput.files[0];
const {
progressElement,
@ -1273,20 +1346,60 @@ document.addEventListener('DOMContentLoaded', async () => {
const csvText = await file.text();
const lines = csvText.trim().split('\n');
const totalTracks = Math.max(0, lines.length - 1);
progressTotal.textContent = totalTracks.toString();
const totalItems = Math.max(0, lines.length - 1);
progressTotal.textContent = totalItems.toString();
const result = await parseCSV(csvText, api, (progress) => {
const percentage = totalTracks > 0 ? (progress.current / totalTracks) * 100 : 0;
const result = await parseDynamicCSV(csvText, api, (progress) => {
const percentage = totalItems > 0 ? (progress.current / totalItems) * 100 : 0;
progressFill.style.width = `${Math.min(percentage, 100)}%`;
progressCurrent.textContent = progress.current.toString();
currentTrackElement.textContent = progress.currentTrack;
if (currentArtistElement)
currentArtistElement.textContent = progress.currentArtist || '';
currentTrackElement.textContent = progress.currentItem;
if (currentArtistElement) {
currentArtistElement.textContent = progress.type
? `Importing ${progress.type}...`
: '';
}
});
const hasMultipleTypes =
result.tracks.length > 0 && (result.albums.length > 0 || result.artists.length > 0);
if (hasMultipleTypes) {
currentTrackElement.textContent = 'Adding to library...';
const importResults = await importToLibrary(result, db, (progress) => {
if (progress.action === 'playlist') {
currentTrackElement.textContent = `Creating playlist: ${progress.item}`;
} else {
currentTrackElement.textContent = `Adding ${progress.action}: ${progress.item}`;
}
});
console.log('Import results:', importResults);
const summary = [];
if (importResults.tracks.added > 0)
summary.push(`${importResults.tracks.added} tracks`);
if (importResults.albums.added > 0)
summary.push(`${importResults.albums.added} albums`);
if (importResults.artists.added > 0)
summary.push(`${importResults.artists.added} artists`);
if (importResults.playlists.created > 0)
summary.push(`${importResults.playlists.created} playlists`);
alert(
`Imported to library:\n${summary.join(', ')}\n\n${
result.missingItems.length > 0
? `${result.missingItems.length} items could not be found.`
: ''
}`
);
progressElement.style.display = 'none';
return;
}
tracks = result.tracks;
const missingTracks = result.missingTracks;
const missingTracks = result.missingItems.filter((i) => i.type === 'track');
if (tracks.length === 0) {
alert('No valid tracks found in the CSV file! Please check the format.');

View file

@ -31,6 +31,88 @@ async function loadClientZip() {
}
}
function toPositiveInt(value) {
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function getExplicitTrackDiscNumber(track) {
const candidates = [
track?.volumeNumber,
track?.discNumber,
track?.mediaNumber,
track?.media_number,
track?.volume,
track?.disc,
track?.volume?.number,
track?.disc?.number,
track?.media?.number,
track?.disc,
track?.disc_no,
track?.discNo,
track?.disc_number,
track?.mediaMetadata?.discNumber,
];
for (const candidate of candidates) {
const parsed = toPositiveInt(candidate);
if (parsed) return parsed;
}
return null;
}
async function createDiscLayoutContext(tracks, api) {
if (!playlistSettings.shouldSeparateDiscsInZip()) {
return { separateByDisc: false, resolveDiscNumber: () => 1 };
}
const explicitDiscNumbers = tracks.map((track) => getExplicitTrackDiscNumber(track));
const explicitDistinct = new Set(explicitDiscNumbers.filter(Boolean));
if (explicitDistinct.size > 1) {
return {
separateByDisc: true,
resolveDiscNumber: (index) => explicitDiscNumbers[index] || 1,
};
}
// Some providers omit disc fields in album payload but include them in full track metadata.
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) {
return {
separateByDisc: true,
resolveDiscNumber: (index) => hydratedDiscNumbers[index] || explicitDiscNumbers[index] || 1,
};
}
return { separateByDisc: false, resolveDiscNumber: () => 1 };
}
function getDiscFolderName(discNumber) {
return `Disc ${discNumber}`;
}
function buildZipTrackPath(rootFolder, filename, separateByDisc, discNumber = 1) {
if (!separateByDisc) return `${rootFolder}/${filename}`;
return `${rootFolder}/${getDiscFolderName(discNumber)}/${filename}`;
}
function getPlaylistAudioExtension(quality) {
return quality === 'LOW' || quality === 'HIGH' ? 'm4a' : 'flac';
}
function createDownloadNotification() {
if (!downloadNotificationContainer) {
downloadNotificationContainer = document.createElement('div');
@ -190,6 +272,26 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
};
try {
const fullTrack = await api.getTrackMetadata(track.id);
if (fullTrack) {
enrichedTrack = {
...fullTrack,
...enrichedTrack,
artist: enrichedTrack.artist || fullTrack.artist,
album: {
...(fullTrack.album || {}),
...(enrichedTrack.album || {}),
},
// Preserve explicit disc fields from either source
discNumber: enrichedTrack.discNumber ?? fullTrack.discNumber,
volumeNumber: enrichedTrack.volumeNumber ?? fullTrack.volumeNumber,
};
}
} catch {
// Non-fatal: continue with best available track payload
}
if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) {
try {
const albumData = await api.getAlbum(enrichedTrack.album.id);
@ -323,9 +425,21 @@ async function bulkDownloadToZipStream(
// Generate playlist files first
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
const playlistAudioExtension = getPlaylistAudioExtension(quality);
const discLayout = await createDiscLayoutContext(tracks, api);
const separateByDisc = discLayout.separateByDisc;
const playlistPathResolver = separateByDisc
? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}`
: null;
if (playlistSettings.shouldGenerateM3U()) {
const m3uContent = generateM3U(metadata || { title: folderName }, tracks, useRelativePaths);
const m3uContent = generateM3U(
metadata || { title: folderName },
tracks,
useRelativePaths,
playlistPathResolver,
playlistAudioExtension
);
yield {
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`,
lastModified: new Date(),
@ -334,7 +448,13 @@ async function bulkDownloadToZipStream(
}
if (playlistSettings.shouldGenerateM3U8()) {
const m3u8Content = generateM3U8(metadata || { title: folderName }, tracks, useRelativePaths);
const m3u8Content = generateM3U8(
metadata || { title: folderName },
tracks,
useRelativePaths,
playlistPathResolver,
playlistAudioExtension
);
yield {
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`,
lastModified: new Date(),
@ -382,7 +502,12 @@ async function bulkDownloadToZipStream(
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
const filename = buildTrackFilename(track, quality, extension);
yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob };
const discNumber = discLayout.resolveDiscNumber(i);
yield {
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
lastModified: new Date(),
input: blob,
};
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
@ -392,7 +517,7 @@ async function bulkDownloadToZipStream(
if (lrcContent) {
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
yield {
name: `${folderName}/${lrcFilename}`,
name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
lastModified: new Date(),
input: lrcContent,
};
@ -442,9 +567,21 @@ async function bulkDownloadToZipBlob(
// Generate playlist files first
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
const playlistAudioExtension = getPlaylistAudioExtension(quality);
const discLayout = await createDiscLayoutContext(tracks, api);
const separateByDisc = discLayout.separateByDisc;
const playlistPathResolver = separateByDisc
? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}`
: null;
if (playlistSettings.shouldGenerateM3U()) {
const m3uContent = generateM3U(metadata || { title: folderName }, tracks, useRelativePaths);
const m3uContent = generateM3U(
metadata || { title: folderName },
tracks,
useRelativePaths,
playlistPathResolver,
playlistAudioExtension
);
yield {
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`,
lastModified: new Date(),
@ -453,7 +590,13 @@ async function bulkDownloadToZipBlob(
}
if (playlistSettings.shouldGenerateM3U8()) {
const m3u8Content = generateM3U8(metadata || { title: folderName }, tracks, useRelativePaths);
const m3u8Content = generateM3U8(
metadata || { title: folderName },
tracks,
useRelativePaths,
playlistPathResolver,
playlistAudioExtension
);
yield {
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`,
lastModified: new Date(),
@ -501,7 +644,12 @@ async function bulkDownloadToZipBlob(
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
const filename = buildTrackFilename(track, quality, extension);
yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob };
const discNumber = discLayout.resolveDiscNumber(i);
yield {
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
lastModified: new Date(),
input: blob,
};
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
@ -511,7 +659,7 @@ async function bulkDownloadToZipBlob(
if (lrcContent) {
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
yield {
name: `${folderName}/${lrcFilename}`,
name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
lastModified: new Date(),
input: lrcContent,
};
@ -562,9 +710,21 @@ async function bulkDownloadToZipNeutralino(
// Generate playlist files first
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
const playlistAudioExtension = getPlaylistAudioExtension(quality);
const discLayout = await createDiscLayoutContext(tracks, api);
const separateByDisc = discLayout.separateByDisc;
const playlistPathResolver = separateByDisc
? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}`
: null;
if (playlistSettings.shouldGenerateM3U()) {
const m3uContent = generateM3U(metadata || { title: folderName }, tracks, useRelativePaths);
const m3uContent = generateM3U(
metadata || { title: folderName },
tracks,
useRelativePaths,
playlistPathResolver,
playlistAudioExtension
);
yield {
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`,
lastModified: new Date(),
@ -573,7 +733,13 @@ async function bulkDownloadToZipNeutralino(
}
if (playlistSettings.shouldGenerateM3U8()) {
const m3u8Content = generateM3U8(metadata || { title: folderName }, tracks, useRelativePaths);
const m3u8Content = generateM3U8(
metadata || { title: folderName },
tracks,
useRelativePaths,
playlistPathResolver,
playlistAudioExtension
);
yield {
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`,
lastModified: new Date(),
@ -621,7 +787,12 @@ async function bulkDownloadToZipNeutralino(
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
const filename = buildTrackFilename(track, quality, extension);
yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob };
const discNumber = discLayout.resolveDiscNumber(i);
yield {
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
lastModified: new Date(),
input: blob,
};
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
@ -631,7 +802,7 @@ async function bulkDownloadToZipNeutralino(
if (lrcContent) {
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
yield {
name: `${folderName}/${lrcFilename}`,
name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
lastModified: new Date(),
input: lrcContent,
};
@ -718,8 +889,9 @@ async function startBulkDownload(
const isNeutralino = window.NL_MODE === true;
const hasFileSystemAccess =
'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
const useZip = hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual();
const useZipBlob = !hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual();
const forceIndividual = bulkDownloadSettings.shouldForceIndividual();
const useZip = hasFileSystemAccess && !forceIndividual;
const useZipBlob = !hasFileSystemAccess && !forceIndividual;
if (isNeutralino) {
// Neutralino Native Logic
@ -871,9 +1043,22 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
// Generate playlist files for each album
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
const playlistAudioExtension = getPlaylistAudioExtension(quality);
const discLayout = await createDiscLayoutContext(tracks, api);
const separateByDisc = discLayout.separateByDisc;
const playlistPathResolver = separateByDisc
? (_track, filename, index) =>
`${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}`
: null;
if (playlistSettings.shouldGenerateM3U()) {
const m3uContent = generateM3U(fullAlbum, tracks, useRelativePaths);
const m3uContent = generateM3U(
fullAlbum,
tracks,
useRelativePaths,
playlistPathResolver,
playlistAudioExtension
);
yield {
name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u`,
lastModified: new Date(),
@ -882,7 +1067,13 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
}
if (playlistSettings.shouldGenerateM3U8()) {
const m3u8Content = generateM3U8(fullAlbum, tracks, useRelativePaths);
const m3u8Content = generateM3U8(
fullAlbum,
tracks,
useRelativePaths,
playlistPathResolver,
playlistAudioExtension
);
yield {
name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u8`,
lastModified: new Date(),
@ -918,12 +1109,18 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
};
}
for (const track of tracks) {
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (signal.aborted) break;
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
const filename = buildTrackFilename(track, quality, extension);
yield { name: `${fullFolderPath}/${filename}`, lastModified: new Date(), input: blob };
const discNumber = discLayout.resolveDiscNumber(i);
yield {
name: buildZipTrackPath(fullFolderPath, filename, separateByDisc, discNumber),
lastModified: new Date(),
input: blob,
};
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
@ -933,7 +1130,12 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
if (lrcContent) {
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
yield {
name: `${fullFolderPath}/${lrcFilename}`,
name: buildZipTrackPath(
fullFolderPath,
lrcFilename,
separateByDisc,
discNumber
),
lastModified: new Date(),
input: lrcContent,
};
@ -1097,6 +1299,25 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
};
try {
const fullTrack = await api.getTrackMetadata(track.id);
if (fullTrack) {
enrichedTrack = {
...fullTrack,
...enrichedTrack,
artist: enrichedTrack.artist || fullTrack.artist,
album: {
...(fullTrack.album || {}),
...(enrichedTrack.album || {}),
},
discNumber: enrichedTrack.discNumber ?? fullTrack.discNumber,
volumeNumber: enrichedTrack.volumeNumber ?? fullTrack.volumeNumber,
};
}
} catch {
// Continue with available track payload
}
if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) {
try {
const albumData = await api.getAlbum(enrichedTrack.album.id);

View file

@ -5,6 +5,40 @@ const DEFAULT_TITLE = 'Unknown Title';
const DEFAULT_ARTIST = 'Unknown Artist';
const DEFAULT_ALBUM = 'Unknown Album';
/**
* Builds a full artist string by combining the track's listed artists
* with any featured artists parsed from the title (feat./with).
*/
function getFullArtistString(track) {
const knownArtists =
Array.isArray(track.artists) && track.artists.length > 0
? track.artists.map((a) => (typeof a === 'string' ? a : a.name) || '').filter(Boolean)
: track.artist?.name
? [track.artist.name]
: [];
// Parse featured artists from title, e.g. "Song (feat. A, B & C)" or "(with X & Y)"
// Note: splitting on '&' may incorrectly fragment compound artist names like "Simon & Garfunkel".
const featPattern = /\(\s*(?:feat\.?|ft\.?|with)\s+(.+?)\s*\)/gi;
const allFeatArtists = [...(track.title?.matchAll(featPattern) ?? [])].flatMap((m) =>
m[1]
.split(/\s*[,&]\s*/)
.map((s) => s.trim())
.filter(Boolean)
);
if (allFeatArtists.length > 0) {
const knownLower = new Set(knownArtists.map((n) => n.toLowerCase()));
for (const feat of allFeatArtists) {
if (!knownLower.has(feat.toLowerCase())) {
knownArtists.push(feat);
knownLower.add(feat.toLowerCase());
}
}
}
return knownArtists.join('; ') || null;
}
/**
* Adds metadata tags to audio files (FLAC or M4A)
* @param {Blob} audioBlob - The audio file blob
@ -544,13 +578,15 @@ function parseFlacBlocks(dataView) {
function createVorbisCommentBlock(track) {
// Vorbis comment structure
const comments = [];
const discNumber = track.volumeNumber ?? track.discNumber;
// Add standard tags
if (track.title) {
comments.push(['TITLE', track.title]);
}
if (track.artist?.name) {
comments.push(['ARTIST', track.artist.name]);
const artistStr = getFullArtistString(track);
if (artistStr) {
comments.push(['ARTIST', artistStr]);
}
if (track.album?.title) {
comments.push(['ALBUM', track.album.title]);
@ -562,6 +598,9 @@ function createVorbisCommentBlock(track) {
if (track.trackNumber) {
comments.push(['TRACKNUMBER', String(track.trackNumber)]);
}
if (discNumber) {
comments.push(['DISCNUMBER', String(discNumber)]);
}
if (track.album?.numberOfTracks) {
comments.push(['TRACKTOTAL', String(track.album.numberOfTracks)]);
}
@ -906,7 +945,7 @@ function createMp4MetadataAtoms(track) {
const tags = {
'©nam': track.title || DEFAULT_TITLE,
'©ART': track.artist?.name || DEFAULT_ARTIST,
'©ART': getFullArtistString(track) || DEFAULT_ARTIST,
'©alb': track.album?.title || DEFAULT_ALBUM,
aART: track.album?.artist?.name || track.artist?.name || DEFAULT_ARTIST,
};
@ -920,7 +959,18 @@ function createMp4MetadataAtoms(track) {
}
if (track.trackNumber) {
tags['trkn'] = track.trackNumber;
tags['trkn'] = {
current: track.trackNumber,
total: track.album?.numberOfTracks,
};
}
const discNumber = track.volumeNumber ?? track.discNumber;
if (discNumber) {
tags['disk'] = {
current: discNumber,
total: 0,
};
}
const releaseDateStr =
@ -1055,7 +1105,7 @@ function createMetadataBlock(metadataAtoms) {
// Text tags
for (const [key, value] of Object.entries(tags)) {
if (key === 'trkn') {
if (key === 'trkn' || key === 'disk') {
ilstChildren.push(createIntAtom(key, value));
} else {
ilstChildren.push(createStringAtom(key, value));
@ -1190,7 +1240,7 @@ function createStringAtom(type, value) {
}
function createIntAtom(type, value) {
// trkn is special: data is 8 bytes.
// trkn/disk are special: data is 8 bytes.
// reserved(2) + track(2) + total(2) + reserved(2)
const dataSize = 16 + 8;
const atomSize = 8 + dataSize;
@ -1214,16 +1264,18 @@ function createIntAtom(type, value) {
buf[offset++] = 0;
buf[offset++] = 0;
// Track data
buf[offset++] = 0;
buf[offset++] = 0;
// Track num
const trk = parseInt(value) || 0;
buf[offset++] = (trk >> 8) & 0xff;
buf[offset++] = trk & 0xff;
// Total (0 for now)
const current = typeof value === 'object' ? value.current : value;
const total = typeof value === 'object' ? value.total : 0;
// Numbering payload (track/disc number + total)
buf[offset++] = 0;
buf[offset++] = 0;
const numberValue = parseInt(current, 10) || 0;
buf[offset++] = (numberValue >> 8) & 0xff;
buf[offset++] = numberValue & 0xff;
const totalValue = parseInt(total, 10) || 0;
buf[offset++] = (totalValue >> 8) & 0xff;
buf[offset++] = totalValue & 0xff;
buf[offset++] = 0;
buf[offset++] = 0;

View file

@ -681,7 +681,10 @@ export class Player {
tracksToShuffle.splice(this.currentQueueIndex, 1);
}
tracksToShuffle.sort(() => Math.random() - 0.5);
for (let i = tracksToShuffle.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tracksToShuffle[i], tracksToShuffle[j]] = [tracksToShuffle[j], tracksToShuffle[i]];
}
if (currentTrack) {
this.shuffledQueue = [currentTrack, ...tracksToShuffle];

View file

@ -5,9 +5,11 @@ import { sanitizeForFilename } from './utils.js';
* @param {Object} playlist - Playlist metadata (title, artist, etc.)
* @param {Array} tracks - Array of track objects
* @param {boolean} useRelativePaths - Whether to use relative paths
* @param {Function|null} pathResolver - Optional resolver for per-track relative path
* @param {string} audioExtension - Audio file extension used in generated paths
* @returns {string} M3U content
*/
export function generateM3U(playlist, tracks, useRelativePaths = true) {
export function generateM3U(playlist, tracks, useRelativePaths = true, pathResolver = null, audioExtension = 'flac') {
let content = '#EXTM3U\n';
if (playlist.title) {
@ -29,8 +31,9 @@ export function generateM3U(playlist, tracks, useRelativePaths = true) {
content += `#EXTINF:${duration},${displayName}\n`;
const filename = getTrackFilename(track, index + 1);
const path = useRelativePaths ? filename : filename;
const filename = getTrackFilename(track, index + 1, audioExtension);
const relativePath = typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename;
const path = useRelativePaths ? relativePath : relativePath;
content += `${path}\n\n`;
});
@ -43,9 +46,11 @@ export function generateM3U(playlist, tracks, useRelativePaths = true) {
* @param {Object} playlist - Playlist metadata
* @param {Array} tracks - Array of track objects
* @param {boolean} useRelativePaths - Whether to use relative paths
* @param {Function|null} pathResolver - Optional resolver for per-track relative path
* @param {string} audioExtension - Audio file extension used in generated paths
* @returns {string} M3U8 content
*/
export function generateM3U8(playlist, tracks, useRelativePaths = true) {
export function generateM3U8(playlist, tracks, useRelativePaths = true, pathResolver = null, audioExtension = 'flac') {
let content = '#EXTM3U\n';
content += '#EXT-X-VERSION:3\n';
content += '#EXT-X-PLAYLIST-TYPE:VOD\n';
@ -72,8 +77,9 @@ export function generateM3U8(playlist, tracks, useRelativePaths = true) {
content += `#EXTINF:${duration}.000,${displayName}\n`;
const filename = getTrackFilename(track, index + 1);
const path = useRelativePaths ? filename : filename;
const filename = getTrackFilename(track, index + 1, audioExtension);
const relativePath = typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename;
const path = useRelativePaths ? relativePath : relativePath;
content += `${path}\n\n`;
});
@ -242,7 +248,7 @@ function getTrackArtists(track) {
/**
* Helper function to get track filename
*/
function getTrackFilename(track, trackNumber = 1) {
function getTrackFilename(track, trackNumber = 1, audioExtension = 'flac') {
const paddedNumber = String(trackNumber).padStart(2, '0');
const artists = getTrackArtists(track);
const title = track.title || 'Unknown Title';
@ -250,7 +256,7 @@ function getTrackFilename(track, trackNumber = 1) {
const sanitizedArtists = sanitizeForFilename(artists);
const sanitizedTitle = sanitizeForFilename(title);
return `${paddedNumber} - ${sanitizedArtists} - ${sanitizedTitle}.flac`;
return `${paddedNumber} - ${sanitizedArtists} - ${sanitizedTitle}.${audioExtension}`;
}
/**

View file

@ -105,11 +105,336 @@ export function generateXML(playlist, tracks) {
* @param {Function} onProgress - Progress callback
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
*/
export async function parseCSV(csvText, api, onProgress) {
const lines = csvText.trim().split('\n');
if (lines.length < 2) return { tracks: [], missingTracks: [] };
const HEADER_MAPPINGS = {
track: ['track name', 'title', 'song', 'name', 'track', 'track title'],
artist: ['artist name(s)', 'artist name', 'artist', 'artists', 'creator', 'artist names'],
album: ['album', 'album name'],
type: ['type', 'category', 'kind'],
isrc: ['isrc', 'isrc code'],
spotifyId: ['spotify - id', 'spotify id', 'spotify_id', 'spotifyid'],
playlistName: ['playlist name', 'playlist', 'playlist title'],
duration: ['duration', 'length', 'time'],
};
function normalizeHeader(header) {
return header
.toLowerCase()
.trim()
.replace(/[_\s]+/g, ' ');
}
function mapHeaders(rawHeaders) {
const mapped = {};
rawHeaders.forEach((header, index) => {
const normalized = normalizeHeader(header);
for (const [key, aliases] of Object.entries(HEADER_MAPPINGS)) {
if (aliases.includes(normalized)) {
mapped[key] = index;
break;
}
}
});
return mapped;
}
function detectCSVFormat(mappedHeaders) {
const hasType = mappedHeaders.type !== undefined;
const hasTrack = mappedHeaders.track !== undefined;
const hasArtist = mappedHeaders.artist !== undefined;
const hasAlbum = mappedHeaders.album !== undefined;
if (hasTrack && hasArtist) {
return {
format: 'library',
hasMultipleTypes: hasType,
supportsTracks: true,
supportsAlbums: hasAlbum,
supportsArtists: hasArtist && !hasTrack,
};
}
if (hasArtist && !hasTrack) {
return {
format: 'artists',
hasMultipleTypes: false,
supportsTracks: false,
supportsAlbums: false,
supportsArtists: true,
};
}
return {
format: 'playlist',
hasMultipleTypes: false,
supportsTracks: true,
supportsAlbums: false,
supportsArtists: false,
};
}
export async function parseDynamicCSV(csvText, api, onProgress) {
const lines = csvText.trim().split('\n');
if (lines.length < 2) {
return {
format: 'unknown',
tracks: [],
albums: [],
artists: [],
missingItems: [],
playlists: {},
};
}
const parseLine = (text) => {
const values = [];
let current = '';
let inQuote = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === '"') {
inQuote = !inQuote;
} else if (char === ',' && !inQuote) {
values.push(current);
current = '';
} else {
current += char;
}
}
values.push(current);
return values.map((v) => v.trim().replace(/^"|"$/g, '').replace(/""/g, '"').trim());
};
const rawHeaders = parseLine(lines[0]);
const mappedHeaders = mapHeaders(rawHeaders);
const formatInfo = detectCSVFormat(mappedHeaders);
const rows = lines.slice(1);
const tracks = [];
const albums = [];
const artists = [];
const missingItems = [];
const playlists = {};
const totalItems = rows.length;
const getItemType = (values) => {
if (mappedHeaders.type !== undefined) {
const typeValue = values[mappedHeaders.type]?.toLowerCase().trim();
if (typeValue === 'album' || typeValue === 'favorite album') return 'album';
if (typeValue === 'artist' || typeValue === 'favorite artist') return 'artist';
if (typeValue === 'track' || typeValue === 'favorite' || typeValue === 'favorite track') return 'track';
}
const hasTrackName = mappedHeaders.track !== undefined && values[mappedHeaders.track];
const hasArtistName = mappedHeaders.artist !== undefined && values[mappedHeaders.artist];
const hasAlbumName = mappedHeaders.album !== undefined && values[mappedHeaders.album];
if (hasTrackName && hasArtistName) return 'track';
if (hasAlbumName && hasArtistName && !hasTrackName) return 'album';
if (hasArtistName && !hasTrackName && !hasAlbumName) return 'artist';
return 'track';
};
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row.trim()) continue;
const values = parseLine(row);
const itemType = getItemType(values);
const trackName = mappedHeaders.track !== undefined ? values[mappedHeaders.track] : '';
const artistName = mappedHeaders.artist !== undefined ? values[mappedHeaders.artist] : '';
const albumName = mappedHeaders.album !== undefined ? values[mappedHeaders.album] : '';
const isrc = mappedHeaders.isrc !== undefined ? values[mappedHeaders.isrc] : '';
const playlistName = mappedHeaders.playlistName !== undefined ? values[mappedHeaders.playlistName] : '';
if (onProgress) {
onProgress({
current: i,
total: totalItems,
currentItem: trackName || artistName || albumName || 'Unknown item',
type: itemType,
});
}
await new Promise((resolve) => setTimeout(resolve, 300));
try {
if (itemType === 'track') {
let foundTrack = null;
if (isrc) {
const searchResult = await api.searchTracks(`isrc:${isrc}`);
if (searchResult.items && searchResult.items.length > 0) {
foundTrack = searchResult.items.find((t) => t.isrc === isrc) || searchResult.items[0];
}
}
if (!foundTrack && trackName && artistName) {
const searchQuery = `"${trackName}" ${artistName}`.trim();
const searchResult = await api.searchTracks(searchQuery);
if (searchResult.items && searchResult.items.length > 0) {
foundTrack = searchResult.items[0];
}
}
if (foundTrack) {
tracks.push(foundTrack);
if (playlistName) {
if (!playlists[playlistName]) {
playlists[playlistName] = [];
}
playlists[playlistName].push(foundTrack);
}
} else {
missingItems.push({
type: 'track',
title: trackName,
artist: artistName,
album: albumName,
isrc: isrc,
});
}
} else if (itemType === 'album') {
let foundAlbum = null;
if (artistName && albumName) {
const searchQuery = `"${albumName}" ${artistName}`.trim();
const searchResult = await api.searchAlbums(searchQuery);
if (searchResult.items && searchResult.items.length > 0) {
foundAlbum = searchResult.items[0];
}
}
if (foundAlbum) {
albums.push(foundAlbum);
} else {
missingItems.push({
type: 'album',
title: albumName,
artist: artistName,
});
}
} else if (itemType === 'artist') {
let foundArtist = null;
if (artistName) {
const searchResult = await api.searchArtists(artistName);
if (searchResult.items && searchResult.items.length > 0) {
foundArtist = searchResult.items[0];
}
}
if (foundArtist) {
artists.push(foundArtist);
} else {
missingItems.push({
type: 'artist',
name: artistName,
});
}
}
} catch {
missingItems.push({
type: itemType,
title: trackName || albumName,
artist: artistName,
});
}
}
return {
format: formatInfo.format,
tracks,
albums,
artists,
missingItems,
playlists,
stats: {
totalItems,
tracksFound: tracks.length,
albumsFound: albums.length,
artistsFound: artists.length,
missingCount: missingItems.length,
playlistCount: Object.keys(playlists).length,
},
};
}
export async function importToLibrary(csvResult, db, onProgress) {
const results = {
tracks: { added: 0, failed: 0 },
albums: { added: 0, failed: 0 },
artists: { added: 0, failed: 0 },
playlists: { created: 0, tracksAdded: 0 },
};
const addedTrackIds = new Set();
const addedAlbumIds = new Set();
const addedArtistIds = new Set();
for (const track of csvResult.tracks) {
if (!addedTrackIds.has(track.id)) {
try {
await db.toggleFavorite('track', track);
addedTrackIds.add(track.id);
results.tracks.added++;
} catch {
results.tracks.failed++;
}
}
if (onProgress) onProgress({ action: 'track', item: track.title });
}
for (const album of csvResult.albums) {
if (!addedAlbumIds.has(album.id)) {
try {
await db.toggleFavorite('album', album);
addedAlbumIds.add(album.id);
results.albums.added++;
} catch {
results.albums.failed++;
}
}
if (onProgress) onProgress({ action: 'album', item: album.title });
}
for (const artist of csvResult.artists) {
if (!addedArtistIds.has(artist.id)) {
try {
await db.toggleFavorite('artist', artist);
addedArtistIds.add(artist.id);
results.artists.added++;
} catch {
results.artists.failed++;
}
}
if (onProgress) onProgress({ action: 'artist', item: artist.name });
}
for (const [playlistName, playlistTracks] of Object.entries(csvResult.playlists)) {
if (playlistTracks.length > 0) {
try {
await db.createPlaylist(playlistName, playlistTracks);
results.playlists.created++;
results.playlists.tracksAdded += playlistTracks.length;
} catch {
console.warn(`Failed to create playlist: ${playlistName}`);
}
}
if (onProgress) onProgress({ action: 'playlist', item: playlistName });
}
return results;
}
export async function parseCSV(csvText, api, onProgress) {
const lines = csvText.trim().split('\n');
if (lines.length < 2) return { tracks: [], missingTracks: [] };
// Robust CSV line parser that respects quotes
const parseLine = (text) => {
const values = [];
let current = '';
@ -129,7 +454,6 @@ export async function parseCSV(csvText, api, onProgress) {
}
values.push(current);
// Clean up quotes: remove surrounding quotes and unescape double quotes if any
return values.map((v) => v.trim().replace(/^"|"$/g, '').replace(/""/g, '"').trim());
};
@ -185,7 +509,6 @@ export async function parseCSV(csvText, api, onProgress) {
});
}
// Search for the track
if (trackTitle && artistNames) {
await new Promise((resolve) => setTimeout(resolve, 300));

View file

@ -2588,6 +2588,14 @@ export function initializeSettings(scrobbler, player, api, ui) {
});
}
const separateDiscsZipToggle = document.getElementById('separate-discs-zip-toggle');
if (separateDiscsZipToggle) {
separateDiscsZipToggle.checked = playlistSettings.shouldSeparateDiscsInZip();
separateDiscsZipToggle.addEventListener('change', (e) => {
playlistSettings.setSeparateDiscsInZip(e.target.checked);
});
}
// API settings
document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('refresh-speed-test-btn');

View file

@ -638,6 +638,7 @@ export const playlistSettings = {
NFO_KEY: 'playlist-generate-nfo',
JSON_KEY: 'playlist-generate-json',
RELATIVE_PATHS_KEY: 'playlist-relative-paths',
SEPARATE_DISCS_KEY: 'playlist-separate-discs-in-zip',
shouldGenerateM3U() {
try {
@ -689,6 +690,15 @@ export const playlistSettings = {
}
},
shouldSeparateDiscsInZip() {
try {
const val = localStorage.getItem(this.SEPARATE_DISCS_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setGenerateM3U(enabled) {
localStorage.setItem(this.M3U_KEY, enabled ? 'true' : 'false');
},
@ -712,6 +722,10 @@ export const playlistSettings = {
setUseRelativePaths(enabled) {
localStorage.setItem(this.RELATIVE_PATHS_KEY, enabled ? 'true' : 'false');
},
setSeparateDiscsInZip(enabled) {
localStorage.setItem(this.SEPARATE_DISCS_KEY, enabled ? 'true' : 'false');
},
};
export const visualizerSettings = {

View file

@ -473,75 +473,94 @@ export function initializeUIInteractions(player, api, ui) {
});
});
// Tooltip for truncated text
let tooltipEl = document.getElementById('custom-tooltip');
if (!tooltipEl) {
tooltipEl = document.createElement('div');
tooltipEl.id = 'custom-tooltip';
document.body.appendChild(tooltipEl);
// Tooltip for truncated text (desktop hover only)
const canUseHoverTooltips = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
let tooltipEl = null;
if (canUseHoverTooltips) {
tooltipEl = document.getElementById('custom-tooltip');
if (!tooltipEl) {
tooltipEl = document.createElement('div');
tooltipEl.id = 'custom-tooltip';
document.body.appendChild(tooltipEl);
}
const updateTooltipPosition = (e) => {
const x = e.clientX + 15;
const y = e.clientY + 15;
// Prevent going off-screen
const rect = tooltipEl.getBoundingClientRect();
const winWidth = window.innerWidth;
const winHeight = window.innerHeight;
let finalX = x;
let finalY = y;
if (x + rect.width > winWidth) {
finalX = e.clientX - rect.width - 10;
}
if (y + rect.height > winHeight) {
finalY = e.clientY - rect.height - 10;
}
// Ensure it stays within viewport
if (finalX < 5) finalX = 5;
if (finalY < 5) finalY = 5;
if (finalX + rect.width > winWidth - 5) finalX = winWidth - rect.width - 5;
if (finalY + rect.height > winHeight - 5) finalY = winHeight - rect.height - 5;
tooltipEl.style.transform = `translate(${finalX}px, ${finalY}px)`;
// Reset top/left to 0 since we use transform
tooltipEl.style.top = '0';
tooltipEl.style.left = '0';
};
document.body.addEventListener('mouseover', (e) => {
const selector =
'.card-title, .card-subtitle, .track-item-details .title, .track-item-details .artist, .now-playing-bar .title, .now-playing-bar .artist, .now-playing-bar .album, .pinned-item-name';
const target = e.target.closest(selector);
if (target) {
// Remove native title if present to avoid double tooltip
if (target.hasAttribute('title')) {
target.removeAttribute('title');
}
if (target.scrollWidth > target.clientWidth) {
tooltipEl.innerHTML = target.innerHTML.trim();
tooltipEl.classList.add('visible');
updateTooltipPosition(e);
const moveHandler = (moveEvent) => {
updateTooltipPosition(moveEvent);
};
const outHandler = () => {
tooltipEl.classList.remove('visible');
target.removeEventListener('mousemove', moveHandler);
target.removeEventListener('mouseleave', outHandler);
target.removeEventListener('click', outHandler);
};
target.addEventListener('mousemove', moveHandler);
target.addEventListener('mouseleave', outHandler);
target.addEventListener('click', outHandler);
}
}
});
}
const updateTooltipPosition = (e) => {
const x = e.clientX + 15;
const y = e.clientY + 15;
// Prevent going off-screen
const rect = tooltipEl.getBoundingClientRect();
const winWidth = window.innerWidth;
const winHeight = window.innerHeight;
let finalX = x;
let finalY = y;
if (x + rect.width > winWidth) {
finalX = e.clientX - rect.width - 10;
// Hide tooltip and context menu on any click to be safe
document.addEventListener('mousedown', (e) => {
if (tooltipEl) {
tooltipEl.classList.remove('visible');
}
if (y + rect.height > winHeight) {
finalY = e.clientY - rect.height - 10;
}
// Ensure it stays within viewport
if (finalX < 5) finalX = 5;
if (finalY < 5) finalY = 5;
if (finalX + rect.width > winWidth - 5) finalX = winWidth - rect.width - 5;
if (finalY + rect.height > winHeight - 5) finalY = winHeight - rect.height - 5;
tooltipEl.style.transform = `translate(${finalX}px, ${finalY}px)`;
// Reset top/left to 0 since we use transform
tooltipEl.style.top = '0';
tooltipEl.style.left = '0';
};
document.body.addEventListener('mouseover', (e) => {
const selector =
'.card-title, .card-subtitle, .track-item-details .title, .track-item-details .artist, .now-playing-bar .title, .now-playing-bar .artist, .now-playing-bar .album, .pinned-item-name';
const target = e.target.closest(selector);
if (target) {
// Remove native title if present to avoid double tooltip
if (target.hasAttribute('title')) {
target.removeAttribute('title');
}
if (target.scrollWidth > target.clientWidth) {
tooltipEl.innerHTML = target.innerHTML.trim();
tooltipEl.classList.add('visible');
updateTooltipPosition(e);
const moveHandler = (moveEvent) => {
updateTooltipPosition(moveEvent);
};
const outHandler = () => {
tooltipEl.classList.remove('visible');
target.removeEventListener('mousemove', moveHandler);
target.removeEventListener('mouseleave', outHandler);
};
target.addEventListener('mousemove', moveHandler);
target.addEventListener('mouseleave', outHandler);
}
const contextMenu = document.getElementById('context-menu');
if (contextMenu && contextMenu.style.display === 'block' && !contextMenu.contains(e.target)) {
contextMenu.style.display = 'none';
}
});
}

View file

@ -501,7 +501,7 @@ export class UIRenderer {
});
}
createUserPlaylistCardHTML(playlist) {
createUserPlaylistCardHTML(playlist, customSubtitle = null) {
let imageHTML = '';
if (playlist.cover) {
imageHTML = `<img src="${playlist.cover}" alt="${playlist.name}" class="card-image" loading="lazy">`;
@ -538,6 +538,8 @@ export class UIRenderer {
}
const isCompact = cardSettings.isCompactAlbum();
const subtitle =
customSubtitle || `${playlist.tracks ? playlist.tracks.length : playlist.numberOfTracks || 0} tracks`;
return this.createBaseCardHTML({
type: 'user-playlist', // Note: data-type logic in base might need adjustment if it uses this for buttons.
@ -545,7 +547,7 @@ export class UIRenderer {
id: playlist.id,
href: `/userplaylist/${playlist.id}`,
title: escapeHtml(playlist.name),
subtitle: `${playlist.tracks ? playlist.tracks.length : playlist.numberOfTracks || 0} tracks`,
subtitle,
imageHTML: imageHTML,
actionButtonsHTML: `
<button class="edit-playlist-btn" data-action="edit-playlist" title="Edit Playlist">
@ -1791,6 +1793,26 @@ export class UIRenderer {
itemsToStore.push({ el: null, data: track, type: 'track' });
}
}
} else if (item.type === 'user-playlist') {
if (item.id && item.name) {
const playlist = {
id: item.id,
name: item.name,
cover: item.cover,
tracks: item.tracks || [],
numberOfTracks: item.numberOfTracks || (item.tracks ? item.tracks.length : 0),
};
const subtitle = item.username ? `by ${item.username}` : null;
cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle));
itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' });
} else {
const playlist = await syncManager.getPublicPlaylist(item.id);
if (playlist) {
const subtitle = item.username ? `by ${item.username}` : null;
cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle));
itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' });
}
}
}
} catch (e) {
console.warn(`Failed to load ${item.type} ${item.id}:`, e);
@ -2413,7 +2435,7 @@ export class UIRenderer {
}
}
async loadRecommendedSongsForPlaylist(tracks) {
async loadRecommendedSongsForPlaylist(tracks, forceRefresh = false) {
const recommendedSection = document.getElementById('playlist-section-recommended');
const recommendedContainer = document.getElementById('playlist-detail-recommended');
@ -2422,8 +2444,14 @@ export class UIRenderer {
return;
}
if (forceRefresh) {
recommendedContainer.innerHTML = this.createSkeletonTracks(5, true);
}
try {
let recommendedTracks = await this.api.getRecommendedTracksForPlaylist(tracks, 20);
let recommendedTracks = await this.api.getRecommendedTracksForPlaylist(tracks, 20, {
refresh: forceRefresh,
});
// Filter out blocked tracks
const { contentBlockingSettings } = await import('./storage.js');
@ -2689,6 +2717,18 @@ export class UIRenderer {
// Load recommended songs thingy
if (ownedPlaylist) {
this.loadRecommendedSongsForPlaylist(tracks);
const refreshBtn = document.getElementById('refresh-recommended-songs-btn');
if (refreshBtn) {
refreshBtn.onclick = async () => {
const icon = refreshBtn.querySelector('svg');
if (icon) icon.style.animation = 'spin 1s linear infinite';
refreshBtn.disabled = true;
await this.loadRecommendedSongsForPlaylist(tracks, true);
if (icon) icon.style.animation = '';
refreshBtn.disabled = false;
};
}
}
// Render Actions (Sort, Shuffle, Edit, Delete, Share)
@ -3836,11 +3876,23 @@ export class UIRenderer {
if (!instances || instances.length === 0) return '';
const listHtml = instances
.map((url, index) => {
.map((instance, index) => {
const isObject = instance && typeof instance === 'object';
const instanceUrl = isObject ? instance.url || '' : String(instance || '');
const instanceName = isObject
? instance.name || instance.displayName || instance.id || instanceUrl
: instanceUrl;
const instanceVersion = isObject && instance.version ? String(instance.version) : '';
const safeName = escapeHtml(instanceName || 'Unknown instance');
const safeUrl = escapeHtml(instanceUrl || '');
const safeVersion = escapeHtml(instanceVersion);
return `
<li data-index="${index}" data-type="${type}">
<div style="flex: 1; min-width: 0;">
<div class="instance-url">${url}</div>
<div class="instance-url">${safeName}</div>
${safeUrl && safeUrl !== safeName ? `<div style="font-size: 0.8rem; color: var(--muted-foreground); margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${safeUrl}</div>` : ''}
${safeVersion ? `<div style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.1rem;">v${safeVersion}</div>` : ''}
</div>
<div class="controls">
<button class="move-up" title="Move Up" ${index === 0 ? 'disabled' : ''}>

View file

@ -1,4 +1,11 @@
[
{
"type": "user-playlist",
"id": "e64ed040-57ab-4583-b047-8fb590b04750",
"name": "2edi",
"cover": "https://i.imgur.gg/jgYh3K6-favicon_(2).png",
"username": "edideaur"
},
{
"type": "album",
"id": 89313048,
@ -12,9 +19,20 @@
},
{
"type": "album",
"id": 118353565,
"id": 324660713,
"title": "JOECHILLWORLD",
"artist": { "id": 3972883, "name": "Devon Hendryx" },
"releaseDate": "2010-08-10",
"cover": "25d45544-3e82-4184-b8c2-2c2c6f0f152a",
"explicit": true,
"audioQuality": "LOSSLESS",
"mediaMetadata": { "tags": ["LOSSLESS", "HIRES_LOSSLESS"] }
},
{
"type": "album",
"id": 418729278,
"title": "I LAY DOWN MY LIFE FOR YOU: DIRECTOR'S CUT",
"artist": { "id": 439890147, "name": "JPEGMAFIA" },
"artist": { "id": 7958797, "name": "JPEGMAFIA" },
"releaseDate": "2025-02-03",
"cover": "9c84302b-2584-4c0a-9db7-e648542f459f",
"explicit": true,

View file

@ -1627,6 +1627,17 @@ input[type='search']::-webkit-search-cancel-button {
margin-bottom: var(--spacing-lg);
}
.section-header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-md);
}
.section-header-row .section-title {
margin-bottom: 0;
}
.search-tabs {
display: flex;
gap: var(--spacing-xs);
@ -1987,7 +1998,7 @@ input[type='search']::-webkit-search-cancel-button {
}
#playlist-detail-recommended .track-item {
grid-template-columns: 40px 1fr 32px 64px;
grid-template-columns: 40px 1fr 32px auto;
}
@media (max-width: 1100px) {
@ -2079,7 +2090,7 @@ input[type='search']::-webkit-search-cancel-button {
/* Track Item Standardization */
.track-item {
display: grid;
grid-template-columns: 40px 1fr 60px 40px;
grid-template-columns: 40px 1fr 60px auto;
gap: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-sm);
@ -2163,7 +2174,6 @@ input[type='search']::-webkit-search-cancel-button {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
}
@ -2240,12 +2250,6 @@ input[type='search']::-webkit-search-cancel-button {
color: var(--foreground);
}
/* Editable Playlist Track Items (with remove button) */
.is-editable .track-list-header,
.is-editable .track-item {
grid-template-columns: 40px 1fr 80px 90px;
}
.detail-header {
display: flex;
align-items: flex-start;
@ -4598,7 +4602,7 @@ input:checked + .slider::before {
#playlist-detail-tracklist .track-list-header {
display: grid;
grid-template-columns: 40px 1fr 80px 48px;
grid-template-columns: 40px 1fr 80px auto;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm);
@ -5469,6 +5473,18 @@ img[src=''] {
}
@media (max-width: 768px) {
html,
body {
height: auto;
min-height: 100%;
overflow-x: hidden;
overflow-y: auto;
}
body {
position: static;
}
.player-controls .progress-container {
order: -1;
max-width: none;
@ -5504,13 +5520,15 @@ img[src=''] {
'header' auto
'main' 1fr
'player' auto / 1fr;
height: 100vh;
height: 100dvh;
height: auto;
min-height: 100vh;
min-height: 100dvh;
}
.main-content {
padding: var(--spacing-md);
grid-area: main;
overflow-y: visible;
}
.main-header {