diff --git a/README.md b/README.md index aa3f2ca..2f6b085 100644 --- a/README.md +++ b/README.md @@ -218,3 +218,13 @@ We welcome contributions from the community! Please see our [Contributing Guide]

Made with ❤️ by the Monochrome team

+ +## Star History + + + + + + Star History Chart + + diff --git a/functions/upload.js b/functions/upload.js index e784fe1..8d5cea4 100644 --- a/functions/upload.js +++ b/functions/upload.js @@ -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(); diff --git a/index.html b/index.html index 4f1eca5..b9acf8d 100644 --- a/index.html +++ b/index.html @@ -2600,8 +2600,31 @@ id="playlist-section-recommended" style="display: none; margin-top: 3rem" > -

Recommended Songs

-

Suggested Songs From Your Playlist

+
+
+

Recommended Songs

+

Suggested Songs From Your Playlist

+
+ +
@@ -4643,6 +4666,18 @@ +
+
+ Separate Discs in ZIP + Put tracks in Disc folders when a release has multiple discs +
+ +
diff --git a/js/api.js b/js/api.js index 76d533b..1a76857 100644 --- a/js/api.js +++ b/js/api.js @@ -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 []; diff --git a/js/app.js b/js/app.js index c0940bf..1cd8910 100644 --- a/js/app.js +++ b/js/app.js @@ -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 = + 'Shuffling...'; + + 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.'); diff --git a/js/downloads.js b/js/downloads.js index 559fb72..49908b5 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -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); diff --git a/js/metadata.js b/js/metadata.js index ee72a2a..6cba6bb 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -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; diff --git a/js/player.js b/js/player.js index 1a4689b..7fdcab5 100644 --- a/js/player.js +++ b/js/player.js @@ -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]; diff --git a/js/playlist-generator.js b/js/playlist-generator.js index 731a193..3b22b21 100644 --- a/js/playlist-generator.js +++ b/js/playlist-generator.js @@ -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}`; } /** diff --git a/js/playlist-importer.js b/js/playlist-importer.js index 201f4c4..2e76b23 100644 --- a/js/playlist-importer.js +++ b/js/playlist-importer.js @@ -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)); diff --git a/js/settings.js b/js/settings.js index c05f7c6..e867b14 100644 --- a/js/settings.js +++ b/js/settings.js @@ -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'); diff --git a/js/storage.js b/js/storage.js index 536133c..e590063 100644 --- a/js/storage.js +++ b/js/storage.js @@ -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 = { diff --git a/js/ui-interactions.js b/js/ui-interactions.js index b795529..6b7e08c 100644 --- a/js/ui-interactions.js +++ b/js/ui-interactions.js @@ -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'; } }); } diff --git a/js/ui.js b/js/ui.js index e1ba2e1..a71e73e 100644 --- a/js/ui.js +++ b/js/ui.js @@ -501,7 +501,7 @@ export class UIRenderer { }); } - createUserPlaylistCardHTML(playlist) { + createUserPlaylistCardHTML(playlist, customSubtitle = null) { let imageHTML = ''; if (playlist.cover) { imageHTML = `${playlist.name}`; @@ -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: `