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> <div class="card-grid" id="home-recent-artists"></div>
</section> </section>
<section class="content-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> <div class="card-grid" id="home-recent-playlists"></div>
</section> </section>
</div> </div>
@ -216,7 +216,7 @@
<button class="search-tab active" data-tab="tracks">Liked Tracks</button> <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="albums">Albums</button>
<button class="search-tab" data-tab="artists">Artists</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>
<div class="search-tab-content active" id="library-tab-tracks"> <div class="search-tab-content active" id="library-tab-tracks">
<div class="track-list" id="library-tracks-container"></div> <div class="track-list" id="library-tracks-container"></div>
@ -324,6 +324,9 @@
<button id="download-mix-btn" class="btn-primary"> <button id="download-mix-btn" class="btn-primary">
<span>Download</span> <span>Download</span>
</button> </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>
</div> </div>
</header> </header>

View file

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

View file

@ -249,11 +249,12 @@ export const recentActivityManager = {
_get() { _get() {
try { try {
const data = localStorage.getItem(this.STORAGE_KEY); 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.playlists) parsed.playlists = [];
if (!parsed.mixes) parsed.mixes = [];
return parsed; return parsed;
} catch (e) { } catch (e) {
return { artists: [], albums: [], playlists: [] }; return { artists: [], albums: [], playlists: [], mixes: [] };
} }
}, },
@ -283,6 +284,10 @@ export const recentActivityManager = {
addPlaylist(playlist) { addPlaylist(playlist) {
this._add('playlists', 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) { createUserPlaylistCardHTML(playlist) {
let imageHTML = ''; let imageHTML = '';
@ -607,8 +628,22 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
} }
const likedPlaylists = await db.getFavorites('playlist'); const likedPlaylists = await db.getFavorites('playlist');
if (likedPlaylists.length) { const likedMixes = await db.getFavorites('mix');
playlistsContainer.innerHTML = likedPlaylists.map(p => this.createPlaylistCardHTML(p)).join('');
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 => { likedPlaylists.forEach(playlist => {
const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`); const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`);
if (el) { if (el) {
@ -616,8 +651,16 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
this.updateLikeState(el, 'playlist', playlist.uuid); 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 { } else {
playlistsContainer.innerHTML = createPlaceholder('No liked playlists yet.'); playlistsContainer.innerHTML = createPlaceholder('No liked playlists or mixes yet.');
} }
const myPlaylistsContainer = document.getElementById('my-playlists-container'); const myPlaylistsContainer = document.getElementById('my-playlists-container');
@ -670,26 +713,47 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
} }
if (playlistsContainer) { if (playlistsContainer) {
if (recents.playlists && recents.playlists.length) { const playlists = recents.playlists || [];
playlistsContainer.innerHTML = recents.playlists.map(playlist => { const mixes = recents.mixes || [];
if (playlist.isUserPlaylist) {
return this.createUserPlaylistCardHTML(playlist); // 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(''); }).join('');
recents.playlists.forEach(playlist => { combinedRecents.forEach(item => {
const id = playlist.isUserPlaylist ? playlist.id : playlist.uuid; if (item.isUserPlaylist) {
const el = playlistsContainer.querySelector(`[data-playlist-id="${id}"]`); const el = playlistsContainer.querySelector(`[data-playlist-id="${item.id}"]`);
if (el) { if (el) trackDataStore.set(el, item);
trackDataStore.set(el, playlist); } else if (item.mixType) {
if (!playlist.isUserPlaylist) { const el = playlistsContainer.querySelector(`[data-mix-id="${item.id}"]`);
this.updateLikeState(el, 'playlist', playlist.uuid); 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 { } 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.setQueue(tracks, 0);
this.player.playTrackFromQueue(); 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`; document.title = `${displayTitle} - Monochrome`;
} catch (error) { } catch (error) {

3
sw.js
View file

@ -1,5 +1,5 @@
// sw.js // 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 CACHE_NAME = `monochrome-${SW_VERSION}`;
const ASSETS = [ const ASSETS = [
@ -23,6 +23,7 @@ const ASSETS = [
'/js/downloads.js', '/js/downloads.js',
'/js/db.js', '/js/db.js',
'/js/metadata.js', '/js/metadata.js',
'/js/vibrant-color.js',
'/manifest.json', '/manifest.json',
'/assets/logo.svg', '/assets/logo.svg',