IMP: liked mixes

This commit is contained in:
Julien Maille 2026-01-04 19:17:43 +01:00
parent 6ee3c57bc5
commit 3f1124f1f0
6 changed files with 134 additions and 25 deletions

View file

@ -177,7 +177,7 @@
<div class="card-grid" id="home-recent-artists"></div>
</section>
<section class="content-section">
<h2 class="section-title">Recent Playlists</h2>
<h2 class="section-title">Recent Playlists and Mixes</h2>
<div class="card-grid" id="home-recent-playlists"></div>
</section>
</div>
@ -216,7 +216,7 @@
<button class="search-tab active" data-tab="tracks">Liked Tracks</button>
<button class="search-tab" data-tab="albums">Albums</button>
<button class="search-tab" data-tab="artists">Artists</button>
<button class="search-tab" data-tab="playlists">Playlists</button>
<button class="search-tab" data-tab="playlists">Playlists and Mixes</button>
</div>
<div class="search-tab-content active" id="library-tab-tracks">
<div class="track-list" id="library-tracks-container"></div>
@ -324,6 +324,9 @@
<button id="download-mix-btn" class="btn-primary">
<span>Download</span>
</button>
<button id="like-mix-btn" class="btn-secondary like-btn" data-action="toggle-like" data-type="mix" title="Save to Favorites" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="heart-icon"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
</button>
</div>
</div>
</header>

View file

@ -1,7 +1,7 @@
export class MusicDatabase {
constructor() {
this.dbName = 'MonochromeDB';
this.version = 4;
this.version = 5;
this.db = null;
}
@ -41,6 +41,10 @@ export class MusicDatabase {
const store = db.createObjectStore('favorites_playlists', { keyPath: 'uuid' });
store.createIndex('addedAt', 'addedAt', { unique: false });
}
if (!db.objectStoreNames.contains('favorites_mixes')) {
const store = db.createObjectStore('favorites_mixes', { keyPath: 'id' });
store.createIndex('addedAt', 'addedAt', { unique: false });
}
if (!db.objectStoreNames.contains('history_tracks')) {
const store = db.createObjectStore('history_tracks', { keyPath: 'timestamp' });
store.createIndex('timestamp', 'timestamp', { unique: true });
@ -107,7 +111,8 @@ export class MusicDatabase {
// Favorites API
async toggleFavorite(type, item) {
const storeName = `favorites_${type}s`; // tracks, albums, artists
const plural = type === 'mix' ? 'mixes' : `${type}s`;
const storeName = `favorites_${plural}`;
const key = type === 'playlist' ? item.uuid : item.id;
const exists = await this.isFavorite(type, key);
@ -123,7 +128,8 @@ export class MusicDatabase {
}
async isFavorite(type, id) {
const storeName = `favorites_${type}s`;
const plural = type === 'mix' ? 'mixes' : `${type}s`;
const storeName = `favorites_${plural}`;
try {
const result = await this.performTransaction(storeName, 'readonly', (store) => store.get(id));
return !!result;
@ -133,7 +139,8 @@ export class MusicDatabase {
}
async getFavorites(type) {
const storeName = `favorites_${type}s`;
const plural = type === 'mix' ? 'mixes' : `${type}s`;
const storeName = `favorites_${plural}`;
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly');
@ -214,6 +221,18 @@ export class MusicDatabase {
};
}
if (type === 'mix') {
return {
id: item.id,
addedAt: item.addedAt,
title: item.title,
subTitle: item.subTitle,
description: item.description,
mixType: item.mixType,
cover: item.cover
};
}
return item;
}
@ -222,6 +241,7 @@ export class MusicDatabase {
const albums = await this.getFavorites('album');
const artists = await this.getFavorites('artist');
const playlists = await this.getFavorites('playlist');
const mixes = await this.getFavorites('mix');
const history = await this.getHistory();
const userPlaylists = await this.getPlaylists();
@ -230,6 +250,7 @@ export class MusicDatabase {
favorites_albums: albums.map(a => this._minifyItem('album', a)),
favorites_artists: artists.map(a => this._minifyItem('artist', a)),
favorites_playlists: playlists.map(p => this._minifyItem('playlist', p)),
favorites_mixes: mixes.map(m => this._minifyItem('mix', m)),
history_tracks: history.map(t => this._minifyItem('track', t)),
user_playlists: userPlaylists
};
@ -260,6 +281,7 @@ export class MusicDatabase {
await importStore('favorites_albums', data.favorites_albums);
await importStore('favorites_artists', data.favorites_artists);
await importStore('favorites_playlists', data.favorites_playlists);
await importStore('favorites_mixes', data.favorites_mixes);
await importStore('history_tracks', data.history_tracks);
if (data.user_playlists) {
await importStore('user_playlists', data.user_playlists);

View file

@ -540,6 +540,9 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
} else if (type === 'playlist') {
const data = await api.getPlaylist(id);
item = data.playlist;
} else if (type === 'mix') {
const data = await api.getMix(id);
item = data.mix;
}
} catch (err) { console.error(err); }
}

View file

@ -249,11 +249,12 @@ export const recentActivityManager = {
_get() {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
const parsed = data ? JSON.parse(data) : { artists: [], albums: [], playlists: [] };
const parsed = data ? JSON.parse(data) : { artists: [], albums: [], playlists: [], mixes: [] };
if (!parsed.playlists) parsed.playlists = [];
if (!parsed.mixes) parsed.mixes = [];
return parsed;
} catch (e) {
return { artists: [], albums: [], playlists: [] };
return { artists: [], albums: [], playlists: [], mixes: [] };
}
},
@ -283,6 +284,10 @@ export const recentActivityManager = {
addPlaylist(playlist) {
this._add('playlists', playlist);
},
addMix(mix) {
this._add('mixes', mix);
}
};

107
js/ui.js
View file

@ -207,6 +207,27 @@ export class UIRenderer {
`;
}
createMixCardHTML(mix) {
const imageSrc = mix.cover || 'assets/appicon.png';
const description = mix.subTitle || mix.description || '';
return `
<div class="card" data-mix-id="${mix.id}" data-href="#mix/${mix.id}" style="cursor: pointer;">
<div class="card-image-wrapper">
<img src="${imageSrc}" alt="${mix.title}" class="card-image" loading="lazy">
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="mix" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
<button class="play-btn card-play-btn" data-action="play-card" data-type="mix" data-id="${mix.id}" title="Play">
${SVG_PLAY}
</button>
</div>
<h3 class="card-title">${mix.title}</h3>
<p class="card-subtitle">${description}</p>
</div>
`;
}
createUserPlaylistCardHTML(playlist) {
let imageHTML = '';
@ -607,8 +628,22 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
}
const likedPlaylists = await db.getFavorites('playlist');
if (likedPlaylists.length) {
playlistsContainer.innerHTML = likedPlaylists.map(p => this.createPlaylistCardHTML(p)).join('');
const likedMixes = await db.getFavorites('mix');
let mixedContent = [];
if (likedPlaylists.length) mixedContent.push(...likedPlaylists.map(p => ({ ...p, _type: 'playlist' })));
if (likedMixes.length) mixedContent.push(...likedMixes.map(m => ({ ...m, _type: 'mix' })));
// Sort by addedAt descending
mixedContent.sort((a, b) => b.addedAt - a.addedAt);
if (mixedContent.length) {
playlistsContainer.innerHTML = mixedContent.map(item => {
return item._type === 'playlist'
? this.createPlaylistCardHTML(item)
: this.createMixCardHTML(item);
}).join('');
likedPlaylists.forEach(playlist => {
const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`);
if (el) {
@ -616,8 +651,16 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
this.updateLikeState(el, 'playlist', playlist.uuid);
}
});
likedMixes.forEach(mix => {
const el = playlistsContainer.querySelector(`[data-mix-id="${mix.id}"]`);
if (el) {
trackDataStore.set(el, mix);
this.updateLikeState(el, 'mix', mix.id);
}
});
} else {
playlistsContainer.innerHTML = createPlaceholder('No liked playlists yet.');
playlistsContainer.innerHTML = createPlaceholder('No liked playlists or mixes yet.');
}
const myPlaylistsContainer = document.getElementById('my-playlists-container');
@ -670,26 +713,47 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
}
if (playlistsContainer) {
if (recents.playlists && recents.playlists.length) {
playlistsContainer.innerHTML = recents.playlists.map(playlist => {
if (playlist.isUserPlaylist) {
return this.createUserPlaylistCardHTML(playlist);
const playlists = recents.playlists || [];
const mixes = recents.mixes || [];
// Note: Since we don't have a unified timestamp for recents in the separate arrays without normalizing,
// we will just display playlists then mixes, or interleave them if we wanted to be fancy.
// But usually recents are just lists.
// Let's just concatenate them.
const combinedRecents = [...playlists, ...mixes]; // Order: Playlists then Mixes
if (combinedRecents.length) {
playlistsContainer.innerHTML = combinedRecents.map(item => {
if (item.isUserPlaylist) {
return this.createUserPlaylistCardHTML(item);
}
return this.createPlaylistCardHTML(playlist);
if (item.mixType) { // It's a mix
return this.createMixCardHTML(item);
}
return this.createPlaylistCardHTML(item);
}).join('');
recents.playlists.forEach(playlist => {
const id = playlist.isUserPlaylist ? playlist.id : playlist.uuid;
const el = playlistsContainer.querySelector(`[data-playlist-id="${id}"]`);
if (el) {
trackDataStore.set(el, playlist);
if (!playlist.isUserPlaylist) {
this.updateLikeState(el, 'playlist', playlist.uuid);
combinedRecents.forEach(item => {
if (item.isUserPlaylist) {
const el = playlistsContainer.querySelector(`[data-playlist-id="${item.id}"]`);
if (el) trackDataStore.set(el, item);
} else if (item.mixType) {
const el = playlistsContainer.querySelector(`[data-mix-id="${item.id}"]`);
if (el) {
trackDataStore.set(el, item);
this.updateLikeState(el, 'mix', item.id);
}
} else {
const el = playlistsContainer.querySelector(`[data-playlist-id="${item.uuid}"]`);
if (el) {
trackDataStore.set(el, item);
this.updateLikeState(el, 'playlist', item.uuid);
}
}
});
} else {
playlistsContainer.innerHTML = createPlaceholder("You haven't viewed any playlists yet.");
playlistsContainer.innerHTML = createPlaceholder("You haven't viewed any playlists or mixes yet.");
}
}
}
@ -1245,6 +1309,17 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
this.player.setQueue(tracks, 0);
this.player.playTrackFromQueue();
};
recentActivityManager.addMix(mix);
// Update header like button
const mixLikeBtn = document.getElementById('like-mix-btn');
if (mixLikeBtn) {
mixLikeBtn.style.display = 'flex';
const isLiked = await db.isFavorite('mix', mix.id);
mixLikeBtn.innerHTML = this.createHeartIcon(isLiked);
mixLikeBtn.classList.toggle('active', isLiked);
}
document.title = `${displayTitle} - Monochrome`;
} catch (error) {

3
sw.js
View file

@ -1,5 +1,5 @@
// sw.js
const SW_VERSION = 'monochrome-v7'; // Note To Self: Change Every Deploy
const SW_VERSION = 'monochrome-v8'; // Note To Self: Change Every Deploy
const CACHE_NAME = `monochrome-${SW_VERSION}`;
const ASSETS = [
@ -23,6 +23,7 @@ const ASSETS = [
'/js/downloads.js',
'/js/db.js',
'/js/metadata.js',
'/js/vibrant-color.js',
'/manifest.json',
'/assets/logo.svg',