add recommended playlist songs, improvements, fixes & more

This commit is contained in:
Samidy 2026-01-12 02:33:52 +03:00
parent 3e03d4fbab
commit f182304c7e
8 changed files with 354 additions and 42 deletions

View file

@ -799,8 +799,18 @@
</div> </div>
</div> </div>
</header> </header>
<div id="playlist-detail-tracklist" class="track-list"></div> <div id="playlist-detail-tracklist" class="track-list"></div>
<section
class="content-section"
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="track-list" id="playlist-detail-recommended"></div>
</section> </section>
</section>
<section id="page-mix" class="page"> <section id="page-mix" class="page">
<header class="detail-header"> <header class="detail-header">
@ -1404,7 +1414,7 @@
</a> </a>
</div> </div>
<div class="about-footer"> <div class="about-footer">
<p class="version">Version 1.3.0</p> <p class="version">Version 1.4.0</p>
<p class="disclaimer"> <p class="disclaimer">
This is an independent client and is not affiliated with or endorsed by TIDAL or any This is an independent client and is not affiliated with or endorsed by TIDAL or any
music streaming service. music streaming service.

View file

@ -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) { normalizeTrackResponse(apiResponse) {
if (!apiResponse || typeof apiResponse !== 'object') { if (!apiResponse || typeof apiResponse !== 'object') {
return apiResponse; return apiResponse;

View file

@ -663,7 +663,9 @@ document.addEventListener('DOMContentLoaded', async () => {
const trackId = playlist.tracks[index].id; const trackId = playlist.tracks[index].id;
const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId); const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); 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;
} }
}); });
} }

View file

@ -172,16 +172,23 @@ export class MusicDatabase {
duration: item.duration, duration: item.duration,
explicit: item.explicit, explicit: item.explicit,
// Keep minimal artist info // 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 })) || [], artists: item.artists?.map((a) => ({ id: a.id, name: a.name })) || [],
// Keep minimal album info // Keep minimal album info
album: item.album album: item.album
? { ? {
id: item.album.id, id: item.album.id,
title: item.album.title,
cover: item.album.cover, cover: item.album.cover,
releaseDate: item.album.releaseDate || null, releaseDate: item.album.releaseDate || null,
vibrantColor: item.album.vibrantColor || null, vibrantColor: item.album.vibrantColor || null,
artist: item.album.artist,
numberOfTracks: item.album.numberOfTracks,
} }
: null, : null,
copyright: item.copyright,
isrc: item.isrc,
trackNumber: item.trackNumber,
// Fallback date // Fallback date
streamStartDate: item.streamStartDate || null, streamStartDate: item.streamStartDate || null,
// Keep version if exists // Keep version if exists
@ -421,12 +428,36 @@ export class MusicDatabase {
} }
async updatePlaylistTracks(playlistId, tracks) { async updatePlaylistTracks(playlistId, tracks) {
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId)); const db = await this.open();
if (!playlist) throw new Error('Playlist not found'); return new Promise((resolve, reject) => {
playlist.tracks = tracks; const transaction = db.transaction('user_playlists', 'readwrite');
this._updatePlaylistMetadata(playlist); const store = transaction.objectStore('user_playlists');
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist; 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);
};
});
} }
} }

View file

@ -14,6 +14,7 @@ import { addMetadataToAudio } from './metadata.js';
const downloadTasks = new Map(); const downloadTasks = new Map();
const bulkDownloadTasks = new Map(); const bulkDownloadTasks = new Map();
const ongoingDownloads = new Set();
let downloadNotificationContainer = null; let downloadNotificationContainer = null;
/** /**
@ -191,6 +192,25 @@ function removeBulkDownloadTask(notifEl) {
} }
async function downloadTrackBlob(track, quality, api, lyricsManager = null, signal = null) { 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); const lookup = await api.getTrack(track.id, quality);
let streamUrl; let streamUrl;
@ -211,7 +231,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
let blob = await response.blob(); let blob = await response.blob();
// Add metadata to the blob // Add metadata to the blob
blob = await addMetadataToAudio(blob, track, api, quality); blob = await addMetadataToAudio(blob, enrichedTrack, api, quality);
return blob; 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) { export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) {
const releaseDateStr = const releaseDateStr =
album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : ''); album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
@ -572,16 +613,42 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
return; 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(); const controller = abortController || new AbortController();
ongoingDownloads.add(downloadKey);
try { 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, { await api.downloadTrack(track.id, quality, filename, {
signal: controller.signal, signal: controller.signal,
track: track, track: enrichedTrack,
onProgress: (progress) => { onProgress: (progress) => {
updateDownloadProgress(track.id, 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.'; error.message === RATE_LIMIT_ERROR_MESSAGE ? error.message : 'Download failed. Please try again.';
completeDownloadTask(track.id, false, errorMsg); completeDownloadTask(track.id, false, errorMsg);
} }
} finally {
ongoingDownloads.delete(downloadKey);
} }
} }

View file

@ -355,6 +355,10 @@ function initializeSmoothSliders(audioPlayer, player) {
if (isAdjustingVolume) { if (isAdjustingVolume) {
seek(volumeBar, e, (position) => { seek(volumeBar, e, (position) => {
if (audioPlayer.muted) {
audioPlayer.muted = false;
localStorage.setItem('muted', false);
}
player.setVolume(position); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`); volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
@ -377,6 +381,10 @@ function initializeSmoothSliders(audioPlayer, player) {
const touch = e.touches[0]; const touch = e.touches[0];
const rect = volumeBar.getBoundingClientRect(); const rect = volumeBar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); 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); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`); volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
@ -433,6 +441,10 @@ function initializeSmoothSliders(audioPlayer, player) {
volumeBar.addEventListener('mousedown', (e) => { volumeBar.addEventListener('mousedown', (e) => {
isAdjustingVolume = true; isAdjustingVolume = true;
seek(volumeBar, e, (position) => { seek(volumeBar, e, (position) => {
if (audioPlayer.muted) {
audioPlayer.muted = false;
localStorage.setItem('muted', false);
}
player.setVolume(position); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`); volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
@ -445,6 +457,10 @@ function initializeSmoothSliders(audioPlayer, player) {
const touch = e.touches[0]; const touch = e.touches[0];
const rect = volumeBar.getBoundingClientRect(); const rect = volumeBar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); 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); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`); volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
@ -453,6 +469,10 @@ function initializeSmoothSliders(audioPlayer, player) {
volumeBar.addEventListener('click', (e) => { volumeBar.addEventListener('click', (e) => {
if (!isAdjustingVolume) { if (!isAdjustingVolume) {
seek(volumeBar, e, (position) => { seek(volumeBar, e, (position) => {
if (audioPlayer.muted) {
audioPlayer.muted = false;
localStorage.setItem('muted', false);
}
player.setVolume(position); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`); volumeBar.style.setProperty('--volume-level', `${position * 100}%`);

View file

@ -176,7 +176,7 @@ export class SyncManager {
favorites_artists: val.artists ? Object.values(val.artists) : [], favorites_artists: val.artists ? Object.values(val.artists) : [],
favorites_playlists: val.playlists ? Object.values(val.playlists) : [], favorites_playlists: val.playlists ? Object.values(val.playlists) : [],
}; };
db.importData(importData, true).then(() => { db.importData(importData, false).then(() => {
// Notify UI to refresh // Notify UI to refresh
window.dispatchEvent(new Event('library-changed')); window.dispatchEvent(new Event('library-changed'));
}); });

150
js/ui.js
View file

@ -645,23 +645,11 @@ export class UIRenderer {
} }
overlay.style.display = 'flex'; overlay.style.display = 'flex';
// hide player when in fullscreen
const nowPlayingBar = document.querySelector('.now-playing-bar');
if (nowPlayingBar) {
nowPlayingBar.style.display = 'none';
}
} }
closeFullscreenCover() { closeFullscreenCover() {
const overlay = document.getElementById('fullscreen-cover-overlay'); const overlay = document.getElementById('fullscreen-cover-overlay');
overlay.style.display = 'none'; overlay.style.display = 'none';
// show player whrn not in fullscreen
const nowPlayingBar = document.querySelector('.now-playing-bar');
if (nowPlayingBar) {
nowPlayingBar.style.display = '';
}
} }
showPage(pageId) { 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 = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>';
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 = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
<span>Title</span>
<span class="duration-header">Duration</span>
</div>
`;
this.renderListWithTracks(tracklistContainer, updatedPlaylist.tracks, true);
if (document.querySelector('.remove-from-playlist-btn')) {
this.enableTrackReordering(tracklistContainer, updatedPlaylist.tracks, playlistId, syncManager);
}
// Update the playlist metadata
const metaEl = document.getElementById('playlist-detail-meta');
if (metaEl) {
const totalDuration = calculateTotalDuration(updatedPlaylist.tracks);
metaEl.textContent = `${updatedPlaylist.tracks.length} tracks • ${formatDuration(totalDuration)}`;
}
}
showNotification(`Added "${trackData.title}" to playlist`);
}
} catch (error) {
console.error('Failed to add track to playlist:', error);
showNotification('Failed to add track to playlist');
}
}
};
const menuBtn = actionsDiv.querySelector('.track-menu-btn');
if (menuBtn) {
actionsDiv.insertBefore(addToPlaylistBtn, menuBtn);
} else {
actionsDiv.appendChild(addToPlaylistBtn);
}
}
});
recommendedSection.style.display = 'block';
} else {
recommendedSection.style.display = 'none';
}
} catch (error) {
console.error('Failed to load recommended songs:', error);
recommendedSection.style.display = 'none';
}
}
async renderPlaylistPage(playlistId, source = null) { async renderPlaylistPage(playlistId, source = null) {
this.showPage('playlist'); this.showPage('playlist');
const imageEl = document.getElementById('playlist-detail-image'); const imageEl = document.getElementById('playlist-detail-image');
@ -1288,6 +1363,11 @@ export class UIRenderer {
playlistLikeBtn.style.display = 'none'; playlistLikeBtn.style.display = 'none';
} }
// Load recommended songs thingy
if (ownedPlaylist) {
this.loadRecommendedSongsForPlaylist(tracks);
}
// Render Actions (Shuffle, Edit, Delete, Share) // Render Actions (Shuffle, Edit, Delete, Share)
// If it is owned, isOwned = true. // If it is owned, isOwned = true.
// If it is public, isPublic is in playlistData. // If it is public, isPublic is in playlistData.
@ -1372,6 +1452,12 @@ export class UIRenderer {
deleteBtn.style.display = 'none'; deleteBtn.style.display = 'none';
} }
// Hide recommended songs section for tidal playlists
const recommendedSection = document.getElementById('playlist-section-recommended');
if (recommendedSection) {
recommendedSection.style.display = 'none';
}
// Render Actions (Shuffle + Share) // Render Actions (Shuffle + Share)
this.updatePlaylistHeaderActions(playlist, false, tracks, false); this.updatePlaylistHeaderActions(playlist, false, tracks, false);
@ -1806,26 +1892,34 @@ export class UIRenderer {
if (!draggedElement) return; if (!draggedElement) return;
// Get new order from DOM try {
const newTrackItems = Array.from(container.querySelectorAll('.track-item')); // Get new order from DOM
const newTracks = newTrackItems.map((item) => { const newTrackItems = Array.from(container.querySelectorAll('.track-item'));
const originalIndex = parseInt(item.dataset.index); const newTracks = newTrackItems.map((item) => {
return tracks[originalIndex]; const originalIndex = parseInt(item.dataset.index);
}); return tracks[originalIndex];
});
newTrackItems.forEach((item, index) => { newTrackItems.forEach((item, index) => {
item.dataset.index = index; item.dataset.index = index;
}); });
tracks.length = 0; tracks.splice(0, tracks.length, ...newTracks);
tracks.push(...newTracks);
// Save to DB // Save to DB
await db.updatePlaylistTracks(playlistId, newTracks); const updatedPlaylist = await db.updatePlaylistTracks(playlistId, newTracks);
syncManager.syncUserPlaylist({ id: playlistId, tracks: newTracks }, 'update'); syncManager.syncUserPlaylist(updatedPlaylist, 'update');
draggedElement = null; draggedElement = null;
draggedIndex = -1; draggedIndex = -1;
} catch (error) {
console.error('Error updating playlist tracks:', error);
if (draggedElement) {
draggedElement.classList.remove('dragging');
draggedElement = null;
}
draggedIndex = -1;
}
}; };
container.addEventListener('dragstart', dragStart); container.addEventListener('dragstart', dragStart);