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>
</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 id="page-mix" class="page">
<header class="detail-header">
@ -1404,7 +1414,7 @@
</a>
</div>
<div class="about-footer">
<p class="version">Version 1.3.0</p>
<p class="version">Version 1.4.0</p>
<p class="disclaimer">
This is an independent client and is not affiliated with or endorsed by TIDAL or any
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) {
if (!apiResponse || typeof apiResponse !== 'object') {
return apiResponse;

View file

@ -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;
}
});
}

View file

@ -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);
};
});
}
}

View file

@ -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);
}
}

View file

@ -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}%`);

View file

@ -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'));
});

150
js/ui.js
View file

@ -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 = '<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) {
this.showPage('playlist');
const imageEl = document.getElementById('playlist-detail-image');
@ -1288,6 +1363,11 @@ export class UIRenderer {
playlistLikeBtn.style.display = 'none';
}
// Load recommended songs thingy
if (ownedPlaylist) {
this.loadRecommendedSongsForPlaylist(tracks);
}
// Render Actions (Shuffle, Edit, Delete, Share)
// If it is owned, isOwned = true.
// If it is public, isPublic is in playlistData.
@ -1372,6 +1452,12 @@ export class UIRenderer {
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)
this.updatePlaylistHeaderActions(playlist, false, tracks, false);
@ -1806,26 +1892,34 @@ export class UIRenderer {
if (!draggedElement) return;
// Get new order from DOM
const newTrackItems = Array.from(container.querySelectorAll('.track-item'));
const newTracks = newTrackItems.map((item) => {
const originalIndex = parseInt(item.dataset.index);
return tracks[originalIndex];
});
try {
// Get new order from DOM
const newTrackItems = Array.from(container.querySelectorAll('.track-item'));
const newTracks = newTrackItems.map((item) => {
const originalIndex = parseInt(item.dataset.index);
return tracks[originalIndex];
});
newTrackItems.forEach((item, index) => {
item.dataset.index = index;
});
newTrackItems.forEach((item, index) => {
item.dataset.index = index;
});
tracks.length = 0;
tracks.push(...newTracks);
tracks.splice(0, tracks.length, ...newTracks);
// Save to DB
await db.updatePlaylistTracks(playlistId, newTracks);
syncManager.syncUserPlaylist({ id: playlistId, tracks: newTracks }, 'update');
// Save to DB
const updatedPlaylist = await db.updatePlaylistTracks(playlistId, newTracks);
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
draggedElement = null;
draggedIndex = -1;
draggedElement = null;
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);