diff --git a/index.html b/index.html index f755577..ae79a18 100644 --- a/index.html +++ b/index.html @@ -110,6 +110,10 @@

Recent Albums

+
+

Recent Playlists

+
+

Recent Artists

@@ -122,6 +126,7 @@ +
@@ -132,6 +137,9 @@
+
+
+
diff --git a/instances.json b/instances.json index 0d2579d..aee2a42 100644 --- a/instances.json +++ b/instances.json @@ -1,14 +1,10 @@ [ - "https://aether.squid.wtf", - "https://zeus.squid.wtf", - "https://kraken.squid.wtf", + "https://triton.squid.wtf", "https://wolf.qqdl.site", "https://maus.qqdl.site", "https://vogel.qqdl.site", "https://katze.qqdl.site", "https://hund.qqdl.site", - "https://phoenix.squid.wtf", - "https://shiva.squid.wtf", - "https://chaos.squid.wtf", - "https://tidal.kinoplus.online" + "https://tidal.kinoplus.online", + "https://tidal-api.binimum.org" ] diff --git a/js/api.js b/js/api.js index aa1a2c0..2a14ea2 100644 --- a/js/api.js +++ b/js/api.js @@ -157,6 +157,10 @@ export class LosslessAPI { return album; } + preparePlaylist(playlist) { + return playlist; + } + prepareArtist(artist) { if (!artist.type && Array.isArray(artist.artistTypes) && artist.artistTypes.length > 0) { return { ...artist, type: artist.artistTypes[0] }; @@ -278,6 +282,27 @@ export class LosslessAPI { } } + async searchPlaylists(query) { + const cached = await this.cache.get('search_playlists', query); + if (cached) return cached; + + try { + const response = await this.fetchWithRetry(`/search/?p=${encodeURIComponent(query)}`); + const data = await response.json(); + const normalized = this.normalizeSearchResponse(data, 'playlists'); + const result = { + ...normalized, + items: normalized.items.map(p => this.preparePlaylist(p)) + }; + + await this.cache.set('search_playlists', query, result); + return result; + } catch (error) { + console.error('Playlist search failed:', error); + return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 }; + } + } + async getAlbum(id) { const cached = await this.cache.get('album', id); if (cached) return cached; @@ -354,26 +379,40 @@ export class LosslessAPI { // Unwrap the data property if it exists const data = jsonData.data || jsonData; - const entries = Array.isArray(data) ? data : [data]; + + let playlist = null; + let tracksSection = null; - let playlist, tracksSection; - - for (const entry of entries) { - if (!entry || typeof entry !== 'object') continue; - - if (!playlist && ('uuid' in entry || 'numberOfTracks' in entry || 'title' in entry && 'id' in entry)) { - playlist = entry; - } - - if (!tracksSection && 'items' in entry) { - tracksSection = entry; - } + // Check for direct playlist property (common in v2 responses) + if (data.playlist) { + playlist = data.playlist; } - // If still no playlist found, try using the first valid entry - if (!playlist && entries.length > 0) { + // Check for direct items property + if (data.items) { + tracksSection = { items: data.items }; + } + + // Fallback: iterate if we still missed something or if structure is flat array + if (!playlist || !tracksSection) { + const entries = Array.isArray(data) ? data : [data]; for (const entry of entries) { - if (entry && typeof entry === 'object' && ('id' in entry || 'uuid' in entry)) { + if (!entry || typeof entry !== 'object') continue; + + if (!playlist && ('uuid' in entry || 'numberOfTracks' in entry || ('title' in entry && 'id' in entry))) { + playlist = entry; + } + + if (!tracksSection && 'items' in entry) { + tracksSection = entry; + } + } + } + + // Fallback 2: If we have a list of entries but no explicit playlist object, try to find one that looks like a playlist + if (!playlist && Array.isArray(data)) { + for (const entry of data) { + if (entry && typeof entry === 'object' && ('uuid' in entry || 'numberOfTracks' in entry)) { playlist = entry; break; } diff --git a/js/events.js b/js/events.js index 209f0d4..33814f3 100644 --- a/js/events.js +++ b/js/events.js @@ -11,6 +11,21 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler) { const shuffleBtn = document.getElementById('shuffle-btn'); const repeatBtn = document.getElementById('repeat-btn'); + // Sync UI with player state on load + if (player.shuffleActive) { + shuffleBtn.classList.add('active'); + } + + if (player.repeatMode !== REPEAT_MODE.OFF) { + repeatBtn.classList.add('active'); + if (player.repeatMode === REPEAT_MODE.ONE) { + repeatBtn.classList.add('repeat-one'); + } + repeatBtn.title = player.repeatMode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One'; + } else { + repeatBtn.title = 'Repeat'; + } + audioPlayer.addEventListener('play', () => { if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled() && player.currentTrack) { scrobbler.updateNowPlaying(player.currentTrack); diff --git a/js/player.js b/js/player.js index 35d6c40..c67b689 100644 --- a/js/player.js +++ b/js/player.js @@ -1,5 +1,6 @@ //js/player.js import { REPEAT_MODE, formatTime, getTrackArtists, getTrackTitle} from './utils.js'; +import { queueManager } from './storage.js'; export class Player { constructor(audioElement, api, quality = 'LOSSLESS') { @@ -16,7 +17,58 @@ export class Player { this.preloadAbortController = null; this.currentTrack = null; + this.loadQueueState(); this.setupMediaSession(); + + window.addEventListener('beforeunload', () => { + this.saveQueueState(); + }); + } + + loadQueueState() { + const savedState = queueManager.getQueue(); + if (savedState) { + this.queue = savedState.queue || []; + this.shuffledQueue = savedState.shuffledQueue || []; + this.originalQueueBeforeShuffle = savedState.originalQueueBeforeShuffle || []; + this.currentQueueIndex = savedState.currentQueueIndex ?? -1; + this.shuffleActive = savedState.shuffleActive || false; + this.repeatMode = savedState.repeatMode || REPEAT_MODE.OFF; + + // Restore current track if queue exists and index is valid + const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; + if (this.currentQueueIndex >= 0 && this.currentQueueIndex < currentQueue.length) { + this.currentTrack = currentQueue[this.currentQueueIndex]; + + // Restore UI + const track = this.currentTrack; + const trackTitle = getTrackTitle(track); + const trackArtists = getTrackArtists(track); + + const coverEl = document.querySelector('.now-playing-bar .cover'); + const titleEl = document.querySelector('.now-playing-bar .title'); + const artistEl = document.querySelector('.now-playing-bar .artist'); + + if (coverEl) coverEl.src = this.api.getCoverUrl(track.album?.cover, '1280'); + if (titleEl) titleEl.textContent = trackTitle; + if (artistEl) artistEl.textContent = trackArtists; + document.title = `${trackTitle} • ${track.artist?.name || 'Unknown'}`; + + this.updatePlayingTrackIndicator(); + this.updateMediaSession(track); + } + } + } + + saveQueueState() { + queueManager.saveQueue({ + queue: this.queue, + shuffledQueue: this.shuffledQueue, + originalQueueBeforeShuffle: this.originalQueueBeforeShuffle, + currentQueueIndex: this.currentQueueIndex, + shuffleActive: this.shuffleActive, + repeatMode: this.repeatMode + }); } setupMediaSession() { @@ -105,6 +157,8 @@ export class Player { return; } + this.saveQueueState(); + const track = currentQueue[this.currentQueueIndex]; this.currentTrack = track; @@ -187,12 +241,23 @@ export class Player { } handlePlayPause() { - if (!this.audio.src) return; + if (!this.audio.src || this.audio.error) { + if (this.currentTrack) { + this.playTrackFromQueue(); + } + return; + } if (this.audio.paused) { - this.audio.play().catch(console.error); + this.audio.play().catch(e => { + console.error("Play failed, reloading track:", e); + if (this.currentTrack) { + this.playTrackFromQueue(); + } + }); } else { this.audio.pause(); + this.saveQueueState(); } } @@ -230,10 +295,12 @@ export class Player { this.preloadCache.clear(); this.preloadNextTracks(); + this.saveQueueState(); } toggleRepeat() { this.repeatMode = (this.repeatMode + 1) % 3; + this.saveQueueState(); return this.repeatMode; } @@ -242,6 +309,7 @@ export class Player { this.currentQueueIndex = startIndex; this.shuffleActive = false; this.preloadCache.clear(); + this.saveQueueState(); } addToQueue(track) { @@ -251,6 +319,7 @@ export class Player { this.currentQueueIndex = this.queue.length - 1; this.playTrackFromQueue(); } + this.saveQueueState(); } removeFromQueue(index) { @@ -271,6 +340,7 @@ export class Player { this.playTrackFromQueue(); } } + this.saveQueueState(); } moveInQueue(fromIndex, toIndex) { @@ -289,6 +359,7 @@ export class Player { } else if (fromIndex > this.currentQueueIndex && toIndex <= this.currentQueueIndex) { this.currentQueueIndex++; } + this.saveQueueState(); } getCurrentQueue() { diff --git a/js/storage.js b/js/storage.js index 58660e4..c8fcfec 100644 --- a/js/storage.js +++ b/js/storage.js @@ -193,9 +193,11 @@ export const recentActivityManager = { _get() { try { const data = localStorage.getItem(this.STORAGE_KEY); - return data ? JSON.parse(data) : { artists: [], albums: [] }; + const parsed = data ? JSON.parse(data) : { artists: [], albums: [], playlists: [] }; + if (!parsed.playlists) parsed.playlists = []; + return parsed; } catch (e) { - return { artists: [], albums: [] }; + return { artists: [], albums: [], playlists: [] }; } }, @@ -221,6 +223,10 @@ export const recentActivityManager = { addAlbum(album) { this._add('albums', album); + }, + + addPlaylist(playlist) { + this._add('playlists', playlist); } }; @@ -326,6 +332,36 @@ export const lyricsSettings = { } }; +export const queueManager = { + STORAGE_KEY: 'monochrome-queue', + + getQueue() { + try { + const data = localStorage.getItem(this.STORAGE_KEY); + return data ? JSON.parse(data) : null; + } catch (e) { + return null; + } + }, + + saveQueue(queueState) { + try { + // Only save essential data to avoid quota limits + const minimalState = { + queue: queueState.queue, + shuffledQueue: queueState.shuffledQueue, + originalQueueBeforeShuffle: queueState.originalQueueBeforeShuffle, + currentQueueIndex: queueState.currentQueueIndex, + shuffleActive: queueState.shuffleActive, + repeatMode: queueState.repeatMode + }; + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(minimalState)); + } catch (e) { + console.warn('Failed to save queue to localStorage:', e); + } + } +}; + // System theme listener if (typeof window !== 'undefined' && window.matchMedia) { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => { diff --git a/js/ui.js b/js/ui.js index d3474cf..4c1aa43 100644 --- a/js/ui.js +++ b/js/ui.js @@ -103,6 +103,19 @@ export class UIRenderer { `; } + createPlaylistCardHTML(playlist) { + const imageId = playlist.squareImage || playlist.image || playlist.uuid; // Fallback or use a specific cover getter if needed + return ` + +
+ ${playlist.title} +
+

${playlist.title}

+

${playlist.numberOfTracks || 0} tracks

+
+ `; + } + createArtistCardHTML(artist) { return ` @@ -193,6 +206,7 @@ export class UIRenderer { const albumsContainer = document.getElementById('home-recent-albums'); const artistsContainer = document.getElementById('home-recent-artists'); + const playlistsContainer = document.getElementById('home-recent-playlists'); albumsContainer.innerHTML = recents.albums.length ? recents.albums.map(album => this.createAlbumCardHTML(album)).join('') @@ -201,6 +215,12 @@ export class UIRenderer { artistsContainer.innerHTML = recents.artists.length ? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('') : createPlaceholder("You haven't viewed any artists yet. Search for music to get started!"); + + if (playlistsContainer) { + playlistsContainer.innerHTML = recents.playlists && recents.playlists.length + ? recents.playlists.map(playlist => this.createPlaylistCardHTML(playlist)).join('') + : createPlaceholder("You haven't viewed any playlists yet. Search for music to get started!"); + } } async renderSearchPage(query) { @@ -210,21 +230,25 @@ export class UIRenderer { const tracksContainer = document.getElementById('search-tracks-container'); const artistsContainer = document.getElementById('search-artists-container'); const albumsContainer = document.getElementById('search-albums-container'); + const playlistsContainer = document.getElementById('search-playlists-container'); tracksContainer.innerHTML = this.createSkeletonTracks(8, true); artistsContainer.innerHTML = this.createSkeletonCards(6, true); albumsContainer.innerHTML = this.createSkeletonCards(6, false); + playlistsContainer.innerHTML = this.createSkeletonCards(6, false); try { - const [tracksResult, artistsResult, albumsResult] = await Promise.all([ + const [tracksResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([ this.api.searchTracks(query), this.api.searchArtists(query), - this.api.searchAlbums(query) + this.api.searchAlbums(query), + this.api.searchPlaylists(query) ]); let finalTracks = tracksResult.items; let finalArtists = artistsResult.items; let finalAlbums = albumsResult.items; + let finalPlaylists = playlistsResult.items; if (finalArtists.length === 0 && finalTracks.length > 0) { const artistMap = new Map(); @@ -267,12 +291,17 @@ export class UIRenderer { ? finalAlbums.map(album => this.createAlbumCardHTML(album)).join('') : createPlaceholder('No albums found.'); + playlistsContainer.innerHTML = finalPlaylists.length + ? finalPlaylists.map(playlist => this.createPlaylistCardHTML(playlist)).join('') + : createPlaceholder('No playlists found.'); + } catch (error) { console.error("Search failed:", error); const errorMsg = createPlaceholder(`Error during search. ${error.message}`); tracksContainer.innerHTML = errorMsg; artistsContainer.innerHTML = errorMsg; albumsContainer.innerHTML = errorMsg; + playlistsContainer.innerHTML = errorMsg; } } @@ -351,6 +380,46 @@ export class UIRenderer { recentActivityManager.addAlbum(album); document.title = `${album.title} - ${album.artist.name} - Monochrome`; + + // "More from Artist" Section + try { + // Remove any existing "More from" section if re-rendering + const existingMoreSection = document.getElementById('album-more-from-artist'); + if (existingMoreSection) existingMoreSection.remove(); + + const moreSection = document.createElement('section'); + moreSection.id = 'album-more-from-artist'; + moreSection.className = 'content-section'; + moreSection.style.marginTop = '3rem'; + moreSection.innerHTML = ` +

More from ${album.artist.name}

+
+ ${this.createSkeletonCards(6, false)} +
+ `; + document.getElementById('page-album').appendChild(moreSection); + + const artistData = await this.api.getArtist(album.artist.id); + // Filter out current album and duplicates + const otherAlbums = artistData.albums + .filter(a => a.id != album.id) + .filter((a, index, self) => + index === self.findIndex((t) => t.title === a.title) // Dedup by title + ) + .slice(0, 12); // Limit to 12 + + const moreContainer = document.getElementById('album-more-albums'); + + if (otherAlbums.length > 0) { + moreContainer.innerHTML = otherAlbums.map(a => this.createAlbumCardHTML(a)).join(''); + } else { + moreSection.remove(); // Remove section if no other albums + } + } catch (err) { + console.warn('Failed to load "More from artist":', err); + document.getElementById('album-more-from-artist')?.remove(); + } + } catch (error) { console.error("Failed to load album:", error); tracklistContainer.innerHTML = createPlaceholder(`Could not load album details. ${error.message}`); @@ -405,10 +474,11 @@ async renderPlaylistPage(playlistId) {
`; - this.renderListWithTracks(tracklistContainer, tracks, true); - - document.title = `${playlist.title} - Monochrome`; - } catch (error) { + this.renderListWithTracks(tracklistContainer, tracks, true); + + recentActivityManager.addPlaylist(playlist); + + document.title = `${playlist.title || 'Artist Mix'} - Monochrome`; } catch (error) { console.error("Failed to load playlist:", error); tracklistContainer.innerHTML = createPlaceholder(`Could not load playlist details. ${error.message}`); } diff --git a/styles.css b/styles.css index 347e5d1..04ea83e 100644 --- a/styles.css +++ b/styles.css @@ -666,8 +666,8 @@ kbd { display: flex; align-items: flex-end; gap: var(--spacing-xl); - margin-bottom: var(--spacing-2xl); - padding-bottom: var(--spacing-xl); + margin-bottom: var(--spacing-lg); + padding-bottom: var(--spacing-md); } .detail-header-image { @@ -2622,15 +2622,16 @@ input:checked + .slider::before { .now-playing-bar .cover:hover::after { opacity: 1; } + #page-playlist .detail-header { display: flex; align-items: flex-end; gap: var(--spacing-xl); - margin-bottom: var(--spacing-2xl); - padding-bottom: var(--spacing-xl); + margin-bottom: var(--spacing-lg); + padding-bottom: var(--spacing-md); } -#playlist-detail-image { +#page-playlist .detail-cover { width: 200px; height: 200px; flex-shrink: 0;