add recommended playlist songs, improvements, fixes & more
This commit is contained in:
parent
3e03d4fbab
commit
f182304c7e
8 changed files with 354 additions and 42 deletions
12
index.html
12
index.html
|
|
@ -800,6 +800,16 @@
|
||||||
</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">
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
86
js/api.js
86
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) {
|
normalizeTrackResponse(apiResponse) {
|
||||||
if (!apiResponse || typeof apiResponse !== 'object') {
|
if (!apiResponse || typeof apiResponse !== 'object') {
|
||||||
return apiResponse;
|
return apiResponse;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
js/db.js
39
js/db.js
|
|
@ -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) => {
|
||||||
|
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;
|
playlist.tracks = tracks;
|
||||||
this._updatePlaylistMetadata(playlist);
|
this._updatePlaylistMetadata(playlist);
|
||||||
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
const putRequest = store.put(playlist);
|
||||||
return playlist;
|
putRequest.onsuccess = () => {
|
||||||
|
resolve(playlist);
|
||||||
|
};
|
||||||
|
putRequest.onerror = () => {
|
||||||
|
reject(putRequest.error);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getRequest.onerror = () => {
|
||||||
|
reject(getRequest.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
transaction.onerror = (event) => {
|
||||||
|
reject(event.target.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
js/events.js
20
js/events.js
|
|
@ -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}%`);
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
126
js/ui.js
126
js/ui.js
|
|
@ -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,6 +1892,7 @@ export class UIRenderer {
|
||||||
|
|
||||||
if (!draggedElement) return;
|
if (!draggedElement) return;
|
||||||
|
|
||||||
|
try {
|
||||||
// Get new order from DOM
|
// Get new order from DOM
|
||||||
const newTrackItems = Array.from(container.querySelectorAll('.track-item'));
|
const newTrackItems = Array.from(container.querySelectorAll('.track-item'));
|
||||||
const newTracks = newTrackItems.map((item) => {
|
const newTracks = newTrackItems.map((item) => {
|
||||||
|
|
@ -1817,15 +1904,22 @@ export class UIRenderer {
|
||||||
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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue