diff --git a/index.html b/index.html index 13c2d42..d9fd39d 100644 --- a/index.html +++ b/index.html @@ -799,8 +799,18 @@ -
+ + +Suggested Songs From Your Playlist.
+Version 1.3.0
+Version 1.4.0
This is an independent client and is not affiliated with or endorsed by TIDAL or any music streaming service. diff --git a/js/api.js b/js/api.js index 84400dc..6390509 100644 --- a/js/api.js +++ b/js/api.js @@ -688,6 +688,92 @@ export class LosslessAPI { } } + async getRecommendedTracksForPlaylist(tracks, limit = 20) { + const artistMap = new Map(); + + // Check if tracks already have artist info (some might) + for (const track of tracks) { + if (track.artist && track.artist.id) { + artistMap.set(track.artist.id, track.artist); + } + if (track.artists && Array.isArray(track.artists)) { + for (const artist of track.artists) { + if (artist.id) { + artistMap.set(artist.id, artist); + } + } + } + } + + if (artistMap.size < 3) { + console.log('Not enough artists from stored data, trying search approach...'); + + for (const track of tracks.slice(0, 5)) { + try { + // Search for the track to get full metadata + const searchQuery = `"${track.title}" ${track.artist?.name || ''}`.trim(); + const searchResult = await this.searchTracks(searchQuery, { signal: AbortSignal.timeout(5000) }); + + if (searchResult.items && searchResult.items.length > 0) { + const foundTrack = searchResult.items[0]; + if (foundTrack.artist && foundTrack.artist.id) { + artistMap.set(foundTrack.artist.id, foundTrack.artist); + } + if (foundTrack.artists && Array.isArray(foundTrack.artists)) { + for (const artist of foundTrack.artists) { + if (artist.id) { + artistMap.set(artist.id, artist); + } + } + } + } + } catch (e) { + console.warn(`Search failed for track "${track.title}":`, e); + } + } + } + + const artists = Array.from(artistMap.values()); + console.log(`Found ${artists.length} unique artists from ${tracks.length} tracks`); + + if (artists.length === 0) { + console.log('No artists found, cannot generate recommendations'); + return []; + } + + const recommendedTracks = []; + const seenTrackIds = new Set(tracks.map(t => t.id)); + + const artistsToProcess = artists.slice(0, Math.min(5, artists.length)); + console.log(`Processing ${artistsToProcess.length} artists for recommendations`); + + for (const artist of artistsToProcess) { + try { + console.log(`Fetching tracks for artist: ${artist.name} (ID: ${artist.id})`); + const artistData = await this.getArtist(artist.id); + if (artistData && artistData.tracks && artistData.tracks.length > 0) { + + const newTracks = artistData.tracks + .filter(track => !seenTrackIds.has(track.id)) + .slice(0, 4); + + console.log(`Found ${newTracks.length} new tracks from ${artist.name}`); + recommendedTracks.push(...newTracks); + seenTrackIds.add(...newTracks.map(t => t.id)); + } else { + console.warn(`No tracks found for artist ${artist.name}`); + } + } catch (e) { + console.warn(`Failed to get tracks for artist ${artist.name}:`, e); + } + } + + console.log(`Total recommended tracks found: ${recommendedTracks.length}`); + + const shuffled = recommendedTracks.sort(() => 0.5 - Math.random()); + return shuffled.slice(0, limit); + } + normalizeTrackResponse(apiResponse) { if (!apiResponse || typeof apiResponse !== 'object') { return apiResponse; diff --git a/js/app.js b/js/app.js index 70c42e0..eca40d4 100644 --- a/js/app.js +++ b/js/app.js @@ -663,7 +663,9 @@ document.addEventListener('DOMContentLoaded', async () => { const trackId = playlist.tracks[index].id; const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId); syncManager.syncUserPlaylist(updatedPlaylist, 'update'); - ui.renderPlaylistPage(playlistId, 'user'); + const scrollTop = document.querySelector('.main-content').scrollTop; + await ui.renderPlaylistPage(playlistId, 'user'); + document.querySelector('.main-content').scrollTop = scrollTop; } }); } diff --git a/js/db.js b/js/db.js index f207654..d0e61a0 100644 --- a/js/db.js +++ b/js/db.js @@ -172,16 +172,23 @@ export class MusicDatabase { duration: item.duration, explicit: item.explicit, // Keep minimal artist info + artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0] : null), artists: item.artists?.map((a) => ({ id: a.id, name: a.name })) || [], // Keep minimal album info album: item.album ? { id: item.album.id, + title: item.album.title, cover: item.album.cover, releaseDate: item.album.releaseDate || null, vibrantColor: item.album.vibrantColor || null, + artist: item.album.artist, + numberOfTracks: item.album.numberOfTracks, } : null, + copyright: item.copyright, + isrc: item.isrc, + trackNumber: item.trackNumber, // Fallback date streamStartDate: item.streamStartDate || null, // Keep version if exists @@ -421,12 +428,36 @@ export class MusicDatabase { } async updatePlaylistTracks(playlistId, tracks) { - const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId)); - if (!playlist) throw new Error('Playlist not found'); - playlist.tracks = tracks; - this._updatePlaylistMetadata(playlist); - await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); - return playlist; + const db = await this.open(); + return new Promise((resolve, reject) => { + const transaction = db.transaction('user_playlists', 'readwrite'); + const store = transaction.objectStore('user_playlists'); + + const getRequest = store.get(playlistId); + getRequest.onsuccess = () => { + const playlist = getRequest.result; + if (!playlist) { + reject(new Error('Playlist not found')); + return; + } + playlist.tracks = tracks; + this._updatePlaylistMetadata(playlist); + const putRequest = store.put(playlist); + putRequest.onsuccess = () => { + resolve(playlist); + }; + putRequest.onerror = () => { + reject(putRequest.error); + }; + }; + getRequest.onerror = () => { + reject(getRequest.error); + }; + + transaction.onerror = (event) => { + reject(event.target.error); + }; + }); } } diff --git a/js/downloads.js b/js/downloads.js index fdb29e3..81414f7 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -14,6 +14,7 @@ import { addMetadataToAudio } from './metadata.js'; const downloadTasks = new Map(); const bulkDownloadTasks = new Map(); +const ongoingDownloads = new Set(); let downloadNotificationContainer = null; /** @@ -191,6 +192,25 @@ function removeBulkDownloadTask(notifEl) { } async function downloadTrackBlob(track, quality, api, lyricsManager = null, signal = null) { + let enrichedTrack = { + ...track, + artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null), + }; + + if (enrichedTrack.album && !enrichedTrack.album.title && enrichedTrack.album.id) { + try { + const albumData = await api.getAlbum(enrichedTrack.album.id); + if (albumData.album) { + enrichedTrack.album = { + ...enrichedTrack.album, + ...albumData.album, + }; + } + } catch (error) { + console.warn('Failed to fetch album data for metadata:', error); + } + } + const lookup = await api.getTrack(track.id, quality); let streamUrl; @@ -211,7 +231,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign let blob = await response.blob(); // Add metadata to the blob - blob = await addMetadataToAudio(blob, track, api, quality); + blob = await addMetadataToAudio(blob, enrichedTrack, api, quality); return blob; } @@ -341,6 +361,27 @@ async function downloadTracksToZip( } } +export async function downloadTracks(tracks, api, quality, lyricsManager = null) { + const folderName = `Queue - ${new Date().toISOString().slice(0, 10)}`; + + const initResult = await initializeZipDownload(folderName, tracks.length >= 20); + if (!initResult) return; + const { zip, fileHandle } = initResult; + + const notification = createBulkDownloadNotification('queue', 'Queue', tracks.length); + + try { + await downloadTracksToZip(zip, tracks, folderName, api, quality, lyricsManager, notification); + await generateAndDownloadZip(zip, folderName, notification, tracks.length, fileHandle); + } catch (error) { + if (error.name === 'AbortError') { + return; + } + completeBulkDownload(notification, false, error.message); + throw error; + } +} + export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) { const releaseDateStr = album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : ''); @@ -572,16 +613,42 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag return; } - const filename = buildTrackFilename(track, quality); + const downloadKey = `track-${track.id}`; + if (ongoingDownloads.has(downloadKey)) { + showNotification('This track is already being downloaded'); + return; + } + + let enrichedTrack = { + ...track, + artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null), + }; + + if (enrichedTrack.album && !enrichedTrack.album.title && enrichedTrack.album.id) { + try { + const albumData = await api.getAlbum(enrichedTrack.album.id); + if (albumData.album) { + enrichedTrack.album = { + ...enrichedTrack.album, + ...albumData.album, + }; + } + } catch (error) { + console.warn('Failed to fetch album data for metadata:', error); + } + } + + const filename = buildTrackFilename(enrichedTrack, quality); const controller = abortController || new AbortController(); + ongoingDownloads.add(downloadKey); try { - const { taskEl } = addDownloadTask(track.id, track, filename, api, controller); + const { taskEl } = addDownloadTask(track.id, enrichedTrack, filename, api, controller); await api.downloadTrack(track.id, quality, filename, { signal: controller.signal, - track: track, + track: enrichedTrack, onProgress: (progress) => { updateDownloadProgress(track.id, progress); }, @@ -605,5 +672,7 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag error.message === RATE_LIMIT_ERROR_MESSAGE ? error.message : 'Download failed. Please try again.'; completeDownloadTask(track.id, false, errorMsg); } + } finally { + ongoingDownloads.delete(downloadKey); } } diff --git a/js/events.js b/js/events.js index 126b514..c01a321 100644 --- a/js/events.js +++ b/js/events.js @@ -355,6 +355,10 @@ function initializeSmoothSliders(audioPlayer, player) { if (isAdjustingVolume) { seek(volumeBar, e, (position) => { + if (audioPlayer.muted) { + audioPlayer.muted = false; + localStorage.setItem('muted', false); + } player.setVolume(position); volumeFill.style.width = `${position * 100}%`; volumeBar.style.setProperty('--volume-level', `${position * 100}%`); @@ -377,6 +381,10 @@ function initializeSmoothSliders(audioPlayer, player) { const touch = e.touches[0]; const rect = volumeBar.getBoundingClientRect(); const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); + if (audioPlayer.muted) { + audioPlayer.muted = false; + localStorage.setItem('muted', false); + } player.setVolume(position); volumeFill.style.width = `${position * 100}%`; volumeBar.style.setProperty('--volume-level', `${position * 100}%`); @@ -433,6 +441,10 @@ function initializeSmoothSliders(audioPlayer, player) { volumeBar.addEventListener('mousedown', (e) => { isAdjustingVolume = true; seek(volumeBar, e, (position) => { + if (audioPlayer.muted) { + audioPlayer.muted = false; + localStorage.setItem('muted', false); + } player.setVolume(position); volumeFill.style.width = `${position * 100}%`; volumeBar.style.setProperty('--volume-level', `${position * 100}%`); @@ -445,6 +457,10 @@ function initializeSmoothSliders(audioPlayer, player) { const touch = e.touches[0]; const rect = volumeBar.getBoundingClientRect(); const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); + if (audioPlayer.muted) { + audioPlayer.muted = false; + localStorage.setItem('muted', false); + } player.setVolume(position); volumeFill.style.width = `${position * 100}%`; volumeBar.style.setProperty('--volume-level', `${position * 100}%`); @@ -453,6 +469,10 @@ function initializeSmoothSliders(audioPlayer, player) { volumeBar.addEventListener('click', (e) => { if (!isAdjustingVolume) { seek(volumeBar, e, (position) => { + if (audioPlayer.muted) { + audioPlayer.muted = false; + localStorage.setItem('muted', false); + } player.setVolume(position); volumeFill.style.width = `${position * 100}%`; volumeBar.style.setProperty('--volume-level', `${position * 100}%`); diff --git a/js/firebase/sync.js b/js/firebase/sync.js index 88f4bf0..c07d647 100644 --- a/js/firebase/sync.js +++ b/js/firebase/sync.js @@ -176,7 +176,7 @@ export class SyncManager { favorites_artists: val.artists ? Object.values(val.artists) : [], favorites_playlists: val.playlists ? Object.values(val.playlists) : [], }; - db.importData(importData, true).then(() => { + db.importData(importData, false).then(() => { // Notify UI to refresh window.dispatchEvent(new Event('library-changed')); }); diff --git a/js/ui.js b/js/ui.js index f9f81cc..55f1cfe 100644 --- a/js/ui.js +++ b/js/ui.js @@ -645,23 +645,11 @@ export class UIRenderer { } overlay.style.display = 'flex'; - - // hide player when in fullscreen - const nowPlayingBar = document.querySelector('.now-playing-bar'); - if (nowPlayingBar) { - nowPlayingBar.style.display = 'none'; - } } closeFullscreenCover() { const overlay = document.getElementById('fullscreen-cover-overlay'); overlay.style.display = 'none'; - - // show player whrn not in fullscreen - const nowPlayingBar = document.querySelector('.now-playing-bar'); - if (nowPlayingBar) { - nowPlayingBar.style.display = ''; - } } showPage(pageId) { @@ -1186,6 +1174,93 @@ export class UIRenderer { } } + async loadRecommendedSongsForPlaylist(tracks) { + const recommendedSection = document.getElementById('playlist-section-recommended'); + const recommendedContainer = document.getElementById('playlist-detail-recommended'); + + if (!recommendedSection || !recommendedContainer) { + console.warn('Recommended songs section not found in DOM'); + return; + } + + try { + const recommendedTracks = await this.api.getRecommendedTracksForPlaylist(tracks, 20); + + if (recommendedTracks.length > 0) { + this.renderListWithTracks(recommendedContainer, recommendedTracks, true); + + const trackItems = recommendedContainer.querySelectorAll('.track-item'); + trackItems.forEach((item) => { + const actionsDiv = item.querySelector('.track-item-actions'); + if (actionsDiv) { + const addToPlaylistBtn = document.createElement('button'); + addToPlaylistBtn.className = 'track-action-btn add-to-playlist-btn'; + addToPlaylistBtn.title = 'Add to this playlist'; + addToPlaylistBtn.innerHTML = ''; + addToPlaylistBtn.onclick = async (e) => { + e.stopPropagation(); + const trackData = trackDataStore.get(item); + if (trackData) { + try { + const hash = window.location.hash; + const playlistMatch = hash.match(/#userplaylist\/([^/]+)/); + if (playlistMatch) { + const playlistId = playlistMatch[1]; + await db.addTrackToPlaylist(playlistId, trackData); + const updatedPlaylist = await db.getPlaylist(playlistId); + syncManager.syncUserPlaylist(updatedPlaylist, 'update'); + + const tracklistContainer = document.getElementById('playlist-detail-tracklist'); + if (tracklistContainer && updatedPlaylist.tracks) { + tracklistContainer.innerHTML = ` +