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.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;