Merge pull request #24 from JulienMaille/fav-lib
feat: add a library of liked song, artist, albums, playlists
This commit is contained in:
commit
9e6b4c2950
11 changed files with 762 additions and 72 deletions
57
index.html
57
index.html
|
|
@ -22,6 +22,7 @@
|
||||||
<audio id="audio-player"></audio>
|
<audio id="audio-player"></audio>
|
||||||
<div id="context-menu">
|
<div id="context-menu">
|
||||||
<ul>
|
<ul>
|
||||||
|
<li data-action="toggle-like">Like</li>
|
||||||
<li data-action="play-next">Play Next</li>
|
<li data-action="play-next">Play Next</li>
|
||||||
<li data-action="add-to-queue">Add to Queue</li>
|
<li data-action="add-to-queue">Add to Queue</li>
|
||||||
<li data-action="download">Download</li>
|
<li data-action="download">Download</li>
|
||||||
|
|
@ -31,7 +32,10 @@
|
||||||
<div id="queue-modal">
|
<div id="queue-modal">
|
||||||
<div id="queue-modal-header">
|
<div id="queue-modal-header">
|
||||||
<h3>Queue</h3>
|
<h3>Queue</h3>
|
||||||
<button id="close-queue-btn">×</button>
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<button id="clear-queue-btn" class="btn-secondary">Clear All</button>
|
||||||
|
<button id="close-queue-btn">×</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="queue-list"></div>
|
<div id="queue-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -77,6 +81,12 @@
|
||||||
<span>Home</span>
|
<span>Home</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="#library">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/></svg>
|
||||||
|
<span>Library</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="#settings">
|
<a href="#settings">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
|
@ -159,6 +169,28 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="page-library" class="page">
|
||||||
|
<h2 class="section-title">Your Library</h2>
|
||||||
|
<div class="search-tabs">
|
||||||
|
<button class="search-tab active" data-tab="tracks">Liked Songs</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>
|
||||||
|
</div>
|
||||||
|
<div class="search-tab-content active" id="library-tab-tracks">
|
||||||
|
<div class="track-list" id="library-tracks-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="search-tab-content" id="library-tab-albums">
|
||||||
|
<div class="card-grid" id="library-albums-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="search-tab-content" id="library-tab-artists">
|
||||||
|
<div class="card-grid" id="library-artists-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="search-tab-content" id="library-tab-playlists">
|
||||||
|
<div class="card-grid" id="library-playlists-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="page-album" class="page">
|
<div id="page-album" class="page">
|
||||||
<header class="detail-header">
|
<header class="detail-header">
|
||||||
<img id="album-detail-image" src="" alt="" class="detail-header-image">
|
<img id="album-detail-image" src="" alt="" class="detail-header-image">
|
||||||
|
|
@ -173,6 +205,9 @@
|
||||||
<button id="download-album-btn" class="btn-primary">
|
<button id="download-album-btn" class="btn-primary">
|
||||||
<span>Download Album</span>
|
<span>Download Album</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="like-album-btn" class="btn-secondary like-btn" data-action="toggle-like" data-type="album" title="Save to Library">
|
||||||
|
<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>
|
||||||
|
|
@ -190,9 +225,13 @@
|
||||||
<button id="play-playlist-btn" class="btn-primary">
|
<button id="play-playlist-btn" class="btn-primary">
|
||||||
<span>Play</span>
|
<span>Play</span>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Like button not typically for own playlists, but good for "followed" ones if we support that. For now, maybe skip or add "Edit" if it's user playlist -->
|
||||||
<button id="download-playlist-btn" class="btn-primary">
|
<button id="download-playlist-btn" class="btn-primary">
|
||||||
<span>Download</span>
|
<span>Download</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="like-playlist-btn" class="btn-secondary like-btn" data-action="toggle-like" data-type="playlist" title="Save to Library">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -215,6 +254,9 @@
|
||||||
<button id="download-discography-btn" class="btn-primary">
|
<button id="download-discography-btn" class="btn-primary">
|
||||||
<span>Download Discography</span>
|
<span>Download Discography</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="like-artist-btn" class="btn-secondary like-btn" data-action="toggle-like" data-type="artist" title="Save to Library">
|
||||||
|
<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>
|
||||||
|
|
@ -370,6 +412,17 @@
|
||||||
</div>
|
</div>
|
||||||
<button id="clear-cache-btn" class="btn-secondary">Clear Cache</button>
|
<button id="clear-cache-btn" class="btn-secondary">Clear Cache</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Backup & Restore</span>
|
||||||
|
<span class="description">Export or import your library and playlists as JSON</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button id="export-library-btn" class="btn-secondary">Export</button>
|
||||||
|
<button id="import-library-btn" class="btn-secondary">Import</button>
|
||||||
|
<input type="file" id="import-library-input" style="display: none;" accept=".json">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="api-instance-manager">
|
<div id="api-instance-manager">
|
||||||
<div class="setting-item" style="padding-bottom: 1rem; border: none;">
|
<div class="setting-item" style="padding-bottom: 1rem; border: none;">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
|
|
@ -466,6 +519,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="volume-controls">
|
<div class="volume-controls">
|
||||||
|
<button id="now-playing-like-btn" class="like-btn" data-action="toggle-like" title="Save to Library" style="display: none;">
|
||||||
|
</button>
|
||||||
<button id="download-current-btn" title="Download current track" class="desktop-only">
|
<button id="download-current-btn" title="Download current track" class="desktop-only">
|
||||||
<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">
|
<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">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
|
|
||||||
18
js/app.js
18
js/app.js
|
|
@ -30,7 +30,16 @@ function initializeCasting(audioPlayer, castBtn) {
|
||||||
});
|
});
|
||||||
|
|
||||||
castBtn.addEventListener('click', () => {
|
castBtn.addEventListener('click', () => {
|
||||||
|
if (!audioPlayer.src) {
|
||||||
|
alert('Please play a track first to enable casting.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
audioPlayer.remote.prompt().catch(err => {
|
audioPlayer.remote.prompt().catch(err => {
|
||||||
|
if (err.name === 'NotAllowedError') return;
|
||||||
|
if (err.name === 'NotFoundError') {
|
||||||
|
alert('No remote playback devices (Chromecast/AirPlay) were found on your network.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log('Cast prompt error:', err);
|
console.log('Cast prompt error:', err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -191,13 +200,18 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
initializeSettings(scrobbler, player, api, ui);
|
initializeSettings(scrobbler, player, api, ui);
|
||||||
initializePlayerEvents(player, audioPlayer, scrobbler);
|
initializePlayerEvents(player, audioPlayer, scrobbler);
|
||||||
initializeTrackInteractions(player, api, document.querySelector('.main-content'), document.getElementById('context-menu'), lyricsManager);
|
initializeTrackInteractions(player, api, document.querySelector('.main-content'), document.getElementById('context-menu'), lyricsManager, ui);
|
||||||
initializeUIInteractions(player, api);
|
initializeUIInteractions(player, api);
|
||||||
initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel);
|
initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel);
|
||||||
|
|
||||||
const castBtn = document.getElementById('cast-btn');
|
const castBtn = document.getElementById('cast-btn');
|
||||||
initializeCasting(audioPlayer, castBtn);
|
initializeCasting(audioPlayer, castBtn);
|
||||||
|
|
||||||
|
// Restore UI state for the current track (like button, theme)
|
||||||
|
if (player.currentTrack) {
|
||||||
|
ui.setCurrentTrack(player.currentTrack);
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelector('.now-playing-bar .cover').addEventListener('click', async () => {
|
document.querySelector('.now-playing-bar .cover').addEventListener('click', async () => {
|
||||||
if (!player.currentTrack) {
|
if (!player.currentTrack) {
|
||||||
alert('No track is currently playing');
|
alert('No track is currently playing');
|
||||||
|
|
@ -275,7 +289,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
document.getElementById('download-current-btn')?.addEventListener('click', () => {
|
document.getElementById('download-current-btn')?.addEventListener('click', () => {
|
||||||
if (player.currentTrack) {
|
if (player.currentTrack) {
|
||||||
handleTrackAction('download', player.currentTrack, player, api, lyricsManager);
|
handleTrackAction('download', player.currentTrack, player, api, lyricsManager, 'track', ui);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
139
js/db.js
Normal file
139
js/db.js
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
export class MusicDatabase {
|
||||||
|
constructor() {
|
||||||
|
this.dbName = 'MonochromeDB';
|
||||||
|
this.version = 2;
|
||||||
|
this.db = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async open() {
|
||||||
|
if (this.db) return this.db;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(this.dbName, this.version);
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
console.error("Database error:", event.target.error);
|
||||||
|
reject(event.target.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
this.db = event.target.result;
|
||||||
|
resolve(this.db);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
|
||||||
|
// Favorites stores
|
||||||
|
if (!db.objectStoreNames.contains('favorites_tracks')) {
|
||||||
|
const store = db.createObjectStore('favorites_tracks', { keyPath: 'id' });
|
||||||
|
store.createIndex('addedAt', 'addedAt', { unique: false });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('favorites_albums')) {
|
||||||
|
const store = db.createObjectStore('favorites_albums', { keyPath: 'id' });
|
||||||
|
store.createIndex('addedAt', 'addedAt', { unique: false });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('favorites_artists')) {
|
||||||
|
const store = db.createObjectStore('favorites_artists', { keyPath: 'id' });
|
||||||
|
store.createIndex('addedAt', 'addedAt', { unique: false });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('favorites_playlists')) {
|
||||||
|
const store = db.createObjectStore('favorites_playlists', { keyPath: 'uuid' });
|
||||||
|
store.createIndex('addedAt', 'addedAt', { unique: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic Helper
|
||||||
|
async performTransaction(storeName, mode, callback) {
|
||||||
|
const db = await this.open();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(storeName, mode);
|
||||||
|
const store = transaction.objectStore(storeName);
|
||||||
|
const request = callback(store);
|
||||||
|
|
||||||
|
transaction.oncomplete = () => {
|
||||||
|
resolve(request?.result);
|
||||||
|
};
|
||||||
|
transaction.onerror = (event) => {
|
||||||
|
reject(event.target.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites API
|
||||||
|
async toggleFavorite(type, item) {
|
||||||
|
const storeName = `favorites_${type}s`; // tracks, albums, artists
|
||||||
|
const key = type === 'playlist' ? item.uuid : item.id;
|
||||||
|
const exists = await this.isFavorite(type, key);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
await this.performTransaction(storeName, 'readwrite', (store) => store.delete(key));
|
||||||
|
return false; // Removed
|
||||||
|
} else {
|
||||||
|
const entry = { ...item, addedAt: Date.now() };
|
||||||
|
await this.performTransaction(storeName, 'readwrite', (store) => store.put(entry));
|
||||||
|
return true; // Added
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isFavorite(type, id) {
|
||||||
|
const storeName = `favorites_${type}s`;
|
||||||
|
try {
|
||||||
|
const result = await this.performTransaction(storeName, 'readonly', (store) => store.get(id));
|
||||||
|
return !!result;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFavorites(type) {
|
||||||
|
const storeName = `favorites_${type}s`;
|
||||||
|
const db = await this.open();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(storeName, 'readonly');
|
||||||
|
const store = transaction.objectStore(storeName);
|
||||||
|
const index = store.index('addedAt');
|
||||||
|
const request = index.getAll(); // Returns sorted by addedAt ascending
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
// Reverse to show newest first
|
||||||
|
resolve(request.result.reverse());
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportData() {
|
||||||
|
const data = {
|
||||||
|
favorites_tracks: await this.getFavorites('track'),
|
||||||
|
favorites_albums: await this.getFavorites('album'),
|
||||||
|
favorites_artists: await this.getFavorites('artist'),
|
||||||
|
favorites_playlists: await this.getFavorites('playlist')
|
||||||
|
};
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async importData(data) {
|
||||||
|
// Clear existing? Or merge? Prompt says "Sync" or "Export/Import".
|
||||||
|
// Let's merge by put (replaces if ID exists).
|
||||||
|
const db = await this.open();
|
||||||
|
|
||||||
|
const importStore = async (storeName, items) => {
|
||||||
|
if (!items || !Array.isArray(items)) return;
|
||||||
|
const transaction = db.transaction(storeName, 'readwrite');
|
||||||
|
const store = transaction.objectStore(storeName);
|
||||||
|
for (const item of items) {
|
||||||
|
store.put(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await importStore('favorites_tracks', data.favorites_tracks);
|
||||||
|
await importStore('favorites_albums', data.favorites_albums);
|
||||||
|
await importStore('favorites_artists', data.favorites_artists);
|
||||||
|
await importStore('favorites_playlists', data.favorites_playlists);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = new MusicDatabase();
|
||||||
171
js/events.js
171
js/events.js
|
|
@ -1,9 +1,10 @@
|
||||||
//js/events.js
|
//js/events.js
|
||||||
import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore } from './utils.js';
|
import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore, RATE_LIMIT_ERROR_MESSAGE, buildTrackFilename, getTrackTitle } from './utils.js';
|
||||||
import { lastFMStorage } from './storage.js';
|
import { lastFMStorage } from './storage.js';
|
||||||
import { showNotification, downloadTrackWithMetadata } from './downloads.js';
|
import { showNotification, downloadTrackWithMetadata } from './downloads.js';
|
||||||
import { lyricsSettings } from './storage.js';
|
import { lyricsSettings } from './storage.js';
|
||||||
import { updateTabTitle } from './router.js';
|
import { updateTabTitle } from './router.js';
|
||||||
|
import { db } from './db.js';
|
||||||
|
|
||||||
export function initializePlayerEvents(player, audioPlayer, scrobbler) {
|
export function initializePlayerEvents(player, audioPlayer, scrobbler) {
|
||||||
const playPauseBtn = document.querySelector('.play-pause-btn');
|
const playPauseBtn = document.querySelector('.play-pause-btn');
|
||||||
|
|
@ -249,9 +250,14 @@ function initializeSmoothSliders(audioPlayer, player) {
|
||||||
progressBar.addEventListener('click', e => {
|
progressBar.addEventListener('click', e => {
|
||||||
if (!isSeeking) {
|
if (!isSeeking) {
|
||||||
seek(progressBar, e, position => {
|
seek(progressBar, e, position => {
|
||||||
if (!isNaN(audioPlayer.duration)) {
|
if (!isNaN(audioPlayer.duration) && audioPlayer.duration > 0 && audioPlayer.duration !== Infinity) {
|
||||||
audioPlayer.currentTime = position * audioPlayer.duration;
|
audioPlayer.currentTime = position * audioPlayer.duration;
|
||||||
player.updateMediaSessionPositionState();
|
player.updateMediaSessionPositionState();
|
||||||
|
} else if (player.currentTrack && player.currentTrack.duration) {
|
||||||
|
const targetTime = position * player.currentTrack.duration;
|
||||||
|
const progressFill = document.querySelector('.progress-fill');
|
||||||
|
if (progressFill) progressFill.style.width = `${position * 100}%`;
|
||||||
|
player.playTrackFromQueue(targetTime);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -290,33 +296,128 @@ function initializeSmoothSliders(audioPlayer, player) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleTrackAction(action, track, player, api, lyricsManager) {
|
export async function handleTrackAction(action, item, player, api, lyricsManager, type = 'track', ui = null) {
|
||||||
if (!track) return;
|
if (!item) return;
|
||||||
|
|
||||||
if (action === 'add-to-queue') {
|
if (action === 'add-to-queue') {
|
||||||
player.addToQueue(track);
|
player.addToQueue(item);
|
||||||
renderQueue(player);
|
renderQueue(player);
|
||||||
showNotification(`Added to queue: ${track.title}`);
|
showNotification(`Added to queue: ${item.title}`);
|
||||||
} else if (action === 'play-next') {
|
} else if (action === 'play-next') {
|
||||||
player.addNextToQueue(track);
|
player.addNextToQueue(item);
|
||||||
renderQueue(player);
|
renderQueue(player);
|
||||||
showNotification(`Playing next: ${track.title}`);
|
showNotification(`Playing next: ${item.title}`);
|
||||||
} else if (action === 'download') {
|
} else if (action === 'download') {
|
||||||
await downloadTrackWithMetadata(track, player.quality, api, lyricsManager);
|
await downloadTrackWithMetadata(item, player.quality, api, lyricsManager);
|
||||||
|
} else if (action === 'toggle-like') {
|
||||||
|
const added = await db.toggleFavorite(type, item);
|
||||||
|
|
||||||
|
// Update all instances of this item's like button on the page
|
||||||
|
const id = type === 'playlist' ? item.uuid : item.id;
|
||||||
|
const selector = type === 'track'
|
||||||
|
? `[data-track-id="${id}"] .like-btn`
|
||||||
|
: `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
|
||||||
|
|
||||||
|
// Also check header buttons
|
||||||
|
const headerBtn = document.getElementById(`like-${type}-btn`);
|
||||||
|
|
||||||
|
const elementsToUpdate = [...document.querySelectorAll(selector)];
|
||||||
|
if (headerBtn) elementsToUpdate.push(headerBtn);
|
||||||
|
|
||||||
|
const nowPlayingLikeBtn = document.getElementById('now-playing-like-btn');
|
||||||
|
if (nowPlayingLikeBtn && type === 'track' && player?.currentTrack?.id === item.id) {
|
||||||
|
elementsToUpdate.push(nowPlayingLikeBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
elementsToUpdate.forEach(btn => {
|
||||||
|
const heartIcon = btn.querySelector('svg');
|
||||||
|
if (heartIcon) {
|
||||||
|
heartIcon.classList.toggle('filled', added);
|
||||||
|
if (heartIcon.hasAttribute('fill')) {
|
||||||
|
heartIcon.setAttribute('fill', added ? 'currentColor' : 'none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
btn.classList.toggle('active', added);
|
||||||
|
btn.title = added ? 'Remove from Library' : 'Add to Library';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Library Page Update
|
||||||
|
if (window.location.hash === '#library') {
|
||||||
|
const itemSelector = type === 'track'
|
||||||
|
? `.track-item[data-track-id="${id}"]`
|
||||||
|
: `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
|
||||||
|
|
||||||
|
const itemEl = document.querySelector(itemSelector);
|
||||||
|
|
||||||
|
if (!added && itemEl) {
|
||||||
|
// Remove item
|
||||||
|
const container = itemEl.parentElement;
|
||||||
|
itemEl.remove();
|
||||||
|
if (container && container.children.length === 0) {
|
||||||
|
const msg = type === 'track' ? 'No liked songs yet.' : `No liked ${type}s yet.`;
|
||||||
|
container.innerHTML = `<div class="placeholder-text">${msg}</div>`;
|
||||||
|
}
|
||||||
|
} else if (added && !itemEl && ui && type === 'track') {
|
||||||
|
// Add item (specifically for tracks currently)
|
||||||
|
const tracksContainer = document.getElementById('library-tracks-container');
|
||||||
|
if (tracksContainer) {
|
||||||
|
// Remove placeholder if it exists
|
||||||
|
const placeholder = tracksContainer.querySelector('.placeholder-text');
|
||||||
|
if (placeholder) placeholder.remove();
|
||||||
|
|
||||||
|
// Create track element
|
||||||
|
const index = tracksContainer.children.length;
|
||||||
|
const trackHTML = ui.createTrackItemHTML(item, index, true, false);
|
||||||
|
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = trackHTML;
|
||||||
|
const newEl = tempDiv.firstElementChild;
|
||||||
|
|
||||||
|
if (newEl) {
|
||||||
|
tracksContainer.appendChild(newEl);
|
||||||
|
trackDataStore.set(newEl, item);
|
||||||
|
ui.updateLikeState(newEl, 'track', item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager) {
|
export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager, ui) {
|
||||||
let contextTrack = null;
|
let contextTrack = null;
|
||||||
|
|
||||||
mainContent.addEventListener('click', e => {
|
mainContent.addEventListener('click', async e => {
|
||||||
const actionBtn = e.target.closest('.track-action-btn');
|
const actionBtn = e.target.closest('.track-action-btn, .like-btn');
|
||||||
if (actionBtn) {
|
if (actionBtn && actionBtn.dataset.action) {
|
||||||
|
e.preventDefault(); // Prevent card navigation
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const trackItem = actionBtn.closest('.track-item');
|
const itemElement = actionBtn.closest('.track-item, .card');
|
||||||
if (trackItem) {
|
const action = actionBtn.dataset.action;
|
||||||
const track = trackDataStore.get(trackItem);
|
const type = actionBtn.dataset.type || 'track';
|
||||||
handleTrackAction(actionBtn.dataset.action, track, player, api, lyricsManager);
|
|
||||||
|
let item = itemElement ? trackDataStore.get(itemElement) : null;
|
||||||
|
|
||||||
|
// If no item from element (e.g. header buttons), try to get from hash
|
||||||
|
if (!item && action === 'toggle-like') {
|
||||||
|
const id = window.location.hash.split('/')[1];
|
||||||
|
if (id) {
|
||||||
|
try {
|
||||||
|
if (type === 'album') {
|
||||||
|
const data = await api.getAlbum(id);
|
||||||
|
item = data.album;
|
||||||
|
} else if (type === 'artist') {
|
||||||
|
item = await api.getArtist(id);
|
||||||
|
} else if (type === 'playlist') {
|
||||||
|
const data = await api.getPlaylist(id);
|
||||||
|
item = data.playlist;
|
||||||
|
}
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
await handleTrackAction(action, item, player, api, lyricsManager, type, ui);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -328,6 +429,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
if (trackItem && !trackItem.dataset.queueIndex) {
|
if (trackItem && !trackItem.dataset.queueIndex) {
|
||||||
contextTrack = trackDataStore.get(trackItem);
|
contextTrack = trackDataStore.get(trackItem);
|
||||||
if (contextTrack) {
|
if (contextTrack) {
|
||||||
|
await updateContextMenuLikeState(contextMenu, contextTrack);
|
||||||
const rect = menuBtn.getBoundingClientRect();
|
const rect = menuBtn.getBoundingClientRect();
|
||||||
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
|
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
|
||||||
}
|
}
|
||||||
|
|
@ -350,15 +452,28 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
player.playTrackFromQueue();
|
player.playTrackFromQueue();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const card = e.target.closest('.card');
|
||||||
|
if (card) {
|
||||||
|
const href = card.dataset.href;
|
||||||
|
if (href) {
|
||||||
|
// Allow native links inside card to work if any exist
|
||||||
|
if (e.target.closest('a')) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.hash = href;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mainContent.addEventListener('contextmenu', e => {
|
mainContent.addEventListener('contextmenu', async e => {
|
||||||
const trackItem = e.target.closest('.track-item');
|
const trackItem = e.target.closest('.track-item');
|
||||||
if (trackItem && !trackItem.dataset.queueIndex) {
|
if (trackItem && !trackItem.dataset.queueIndex) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
contextTrack = trackDataStore.get(trackItem);
|
contextTrack = trackDataStore.get(trackItem);
|
||||||
|
|
||||||
if (contextTrack) {
|
if (contextTrack) {
|
||||||
|
await updateContextMenuLikeState(contextMenu, contextTrack);
|
||||||
positionMenu(contextMenu, e.pageX, e.pageY);
|
positionMenu(contextMenu, e.pageX, e.pageY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -372,7 +487,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const action = e.target.dataset.action;
|
const action = e.target.dataset.action;
|
||||||
if (action && contextTrack) {
|
if (action && contextTrack) {
|
||||||
await handleTrackAction(action, contextTrack, player, api, lyricsManager);
|
await handleTrackAction(action, contextTrack, player, api, lyricsManager, 'track', ui);
|
||||||
}
|
}
|
||||||
contextMenu.style.display = 'none';
|
contextMenu.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
@ -391,6 +506,16 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
window.location.hash = `#artist/${track.artist.id}`;
|
window.location.hash = `#artist/${track.artist.id}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nowPlayingLikeBtn = document.getElementById('now-playing-like-btn');
|
||||||
|
if (nowPlayingLikeBtn) {
|
||||||
|
nowPlayingLikeBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (player.currentTrack) {
|
||||||
|
await handleTrackAction('toggle-like', player.currentTrack, player, api, lyricsManager, 'track', ui);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderQueue(player) {
|
function renderQueue(player) {
|
||||||
|
|
@ -407,6 +532,14 @@ function formatTime(seconds) {
|
||||||
return `${m}:${String(s).padStart(2, '0')}`;
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateContextMenuLikeState(menu, track) {
|
||||||
|
const likeItem = menu.querySelector('[data-action="toggle-like"]');
|
||||||
|
if (likeItem) {
|
||||||
|
const isLiked = await db.isFavorite('track', track.id);
|
||||||
|
likeItem.textContent = isLiked ? 'Remove from Library' : 'Add to Library';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function positionMenu(menu, x, y, anchorRect = null) {
|
function positionMenu(menu, x, y, anchorRect = null) {
|
||||||
// Temporarily show to measure dimensions
|
// Temporarily show to measure dimensions
|
||||||
menu.style.visibility = 'hidden';
|
menu.style.visibility = 'hidden';
|
||||||
|
|
|
||||||
46
js/player.js
46
js/player.js
|
|
@ -52,6 +52,8 @@ export class Player {
|
||||||
if (coverEl) coverEl.src = this.api.getCoverUrl(track.album?.cover, '1280');
|
if (coverEl) coverEl.src = this.api.getCoverUrl(track.album?.cover, '1280');
|
||||||
if (titleEl) titleEl.textContent = trackTitle;
|
if (titleEl) titleEl.textContent = trackTitle;
|
||||||
if (artistEl) artistEl.textContent = trackArtists;
|
if (artistEl) artistEl.textContent = trackArtists;
|
||||||
|
const totalDurationEl = document.getElementById('total-duration');
|
||||||
|
if (totalDurationEl) totalDurationEl.textContent = formatTime(track.duration);
|
||||||
document.title = `${trackTitle} • ${track.artist?.name || 'Unknown'}`;
|
document.title = `${trackTitle} • ${track.artist?.name || 'Unknown'}`;
|
||||||
|
|
||||||
this.updatePlayingTrackIndicator();
|
this.updatePlayingTrackIndicator();
|
||||||
|
|
@ -154,7 +156,7 @@ export class Player {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async playTrackFromQueue() {
|
async playTrackFromQueue(startTime = 0) {
|
||||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||||
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -193,6 +195,9 @@ export class Player {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.audio.src = streamUrl;
|
this.audio.src = streamUrl;
|
||||||
|
if (startTime > 0) {
|
||||||
|
this.audio.currentTime = startTime;
|
||||||
|
}
|
||||||
await this.audio.play();
|
await this.audio.play();
|
||||||
|
|
||||||
this.updateMediaSessionPlaybackState();
|
this.updateMediaSessionPlaybackState();
|
||||||
|
|
@ -348,22 +353,37 @@ export class Player {
|
||||||
|
|
||||||
removeFromQueue(index) {
|
removeFromQueue(index) {
|
||||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||||
|
|
||||||
if (index < 0 || index >= currentQueue.length) return;
|
// If removing current track
|
||||||
|
if (index === this.currentQueueIndex) {
|
||||||
if (this.shuffleActive) {
|
// If playing, we might want to stop or just let it finish?
|
||||||
this.shuffledQueue.splice(index, 1);
|
// For now, let's just remove it.
|
||||||
} else {
|
// If it's the last track, playback will stop naturally or we handle it?
|
||||||
this.queue.splice(index, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index < this.currentQueueIndex) {
|
if (index < this.currentQueueIndex) {
|
||||||
this.currentQueueIndex--;
|
this.currentQueueIndex--;
|
||||||
} else if (index === this.currentQueueIndex) {
|
|
||||||
if (currentQueue.length > 0) {
|
|
||||||
this.playTrackFromQueue();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removedTrack = currentQueue.splice(index, 1)[0];
|
||||||
|
|
||||||
|
if (this.shuffleActive) {
|
||||||
|
// Also remove from original queue
|
||||||
|
const originalIndex = this.originalQueueBeforeShuffle.findIndex(t => t.id === removedTrack.id); // Simple ID check
|
||||||
|
if (originalIndex !== -1) {
|
||||||
|
this.originalQueueBeforeShuffle.splice(originalIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveQueueState();
|
||||||
|
this.preloadNextTracks();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearQueue() {
|
||||||
|
this.queue = [];
|
||||||
|
this.shuffledQueue = [];
|
||||||
|
this.originalQueueBeforeShuffle = [];
|
||||||
|
this.currentQueueIndex = -1;
|
||||||
this.saveQueueState();
|
this.saveQueueState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ export function createRouter(ui) {
|
||||||
case 'playlist':
|
case 'playlist':
|
||||||
ui.renderPlaylistPage(param);
|
ui.renderPlaylistPage(param);
|
||||||
break;
|
break;
|
||||||
|
case 'library':
|
||||||
|
ui.renderLibraryPage();
|
||||||
|
break;
|
||||||
case 'home':
|
case 'home':
|
||||||
ui.renderHomePage();
|
ui.renderHomePage();
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
//js/settings
|
//js/settings
|
||||||
import { themeManager, lastFMStorage, nowPlayingSettings, lyricsSettings, backgroundSettings, trackListSettings } from './storage.js';
|
import { themeManager, lastFMStorage, nowPlayingSettings, lyricsSettings, backgroundSettings, trackListSettings } from './storage.js';
|
||||||
|
import { db } from './db.js';
|
||||||
|
|
||||||
export function initializeSettings(scrobbler, player, api, ui) {
|
export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
const lastfmConnectBtn = document.getElementById('lastfm-connect-btn');
|
const lastfmConnectBtn = document.getElementById('lastfm-connect-btn');
|
||||||
|
|
@ -289,4 +290,40 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Backup & Restore
|
||||||
|
document.getElementById('export-library-btn')?.addEventListener('click', async () => {
|
||||||
|
const data = await db.exportData();
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `monochrome-library-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
const importInput = document.getElementById('import-library-input');
|
||||||
|
document.getElementById('import-library-btn')?.addEventListener('click', () => {
|
||||||
|
importInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
importInput?.addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.target.result);
|
||||||
|
await db.importData(data);
|
||||||
|
alert('Library imported successfully!');
|
||||||
|
window.location.reload(); // Simple way to refresh all state
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Import failed:', err);
|
||||||
|
alert('Failed to import library. Please check the file format.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//js/ui-interactions.js
|
//js/ui-interactions.js
|
||||||
import { SVG_CLOSE, formatTime, trackDataStore, getTrackTitle, getTrackArtists } from './utils.js';
|
import { SVG_CLOSE, SVG_BIN, formatTime, trackDataStore, getTrackTitle, getTrackArtists } from './utils.js';
|
||||||
|
|
||||||
export function initializeUIInteractions(player, api) {
|
export function initializeUIInteractions(player, api) {
|
||||||
const sidebar = document.querySelector('.sidebar');
|
const sidebar = document.querySelector('.sidebar');
|
||||||
|
|
@ -8,6 +8,7 @@ export function initializeUIInteractions(player, api) {
|
||||||
const queueBtn = document.getElementById('queue-btn');
|
const queueBtn = document.getElementById('queue-btn');
|
||||||
const queueModalOverlay = document.getElementById('queue-modal-overlay');
|
const queueModalOverlay = document.getElementById('queue-modal-overlay');
|
||||||
const closeQueueBtn = document.getElementById('close-queue-btn');
|
const closeQueueBtn = document.getElementById('close-queue-btn');
|
||||||
|
const clearQueueBtn = document.getElementById('clear-queue-btn');
|
||||||
const queueList = document.getElementById('queue-list');
|
const queueList = document.getElementById('queue-list');
|
||||||
|
|
||||||
let draggedQueueIndex = null;
|
let draggedQueueIndex = null;
|
||||||
|
|
@ -40,6 +41,13 @@ export function initializeUIInteractions(player, api) {
|
||||||
closeQueueBtn.addEventListener('click', () => {
|
closeQueueBtn.addEventListener('click', () => {
|
||||||
queueModalOverlay.style.display = 'none';
|
queueModalOverlay.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (clearQueueBtn) {
|
||||||
|
clearQueueBtn.addEventListener('click', () => {
|
||||||
|
player.clearQueue();
|
||||||
|
renderQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
queueModalOverlay.addEventListener('click', e => {
|
queueModalOverlay.addEventListener('click', e => {
|
||||||
if (e.target === queueModalOverlay) {
|
if (e.target === queueModalOverlay) {
|
||||||
|
|
@ -50,6 +58,10 @@ export function initializeUIInteractions(player, api) {
|
||||||
function renderQueue() {
|
function renderQueue() {
|
||||||
const currentQueue = player.getCurrentQueue();
|
const currentQueue = player.getCurrentQueue();
|
||||||
|
|
||||||
|
if (clearQueueBtn) {
|
||||||
|
clearQueueBtn.style.display = currentQueue.length > 0 ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (currentQueue.length === 0) {
|
if (currentQueue.length === 0) {
|
||||||
queueList.innerHTML = '<div class="placeholder-text">Queue is empty.</div>';
|
queueList.innerHTML = '<div class="placeholder-text">Queue is empty.</div>';
|
||||||
return;
|
return;
|
||||||
|
|
@ -78,7 +90,7 @@ export function initializeUIInteractions(player, api) {
|
||||||
</div>
|
</div>
|
||||||
<div class="track-item-duration">${formatTime(track.duration)}</div>
|
<div class="track-item-duration">${formatTime(track.duration)}</div>
|
||||||
<button class="queue-remove-btn" data-track-index="${index}" title="Remove from queue">
|
<button class="queue-remove-btn" data-track-index="${index}" title="Remove from queue">
|
||||||
${SVG_CLOSE}
|
${SVG_BIN}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -128,14 +140,20 @@ export function initializeUIInteractions(player, api) {
|
||||||
// Make renderQueue available globally for other modules
|
// Make renderQueue available globally for other modules
|
||||||
window.renderQueueFunction = renderQueue;
|
window.renderQueueFunction = renderQueue;
|
||||||
|
|
||||||
// Search tabs
|
// Search and Library tabs
|
||||||
document.querySelectorAll('.search-tab').forEach(tab => {
|
document.querySelectorAll('.search-tab').forEach(tab => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener('click', () => {
|
||||||
document.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active'));
|
const page = tab.closest('.page');
|
||||||
document.querySelectorAll('.search-tab-content').forEach(c => c.classList.remove('active'));
|
if (!page) return;
|
||||||
|
|
||||||
|
page.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
page.querySelectorAll('.search-tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
tab.classList.add('active');
|
tab.classList.add('active');
|
||||||
document.getElementById(`search-tab-${tab.dataset.tab}`).classList.add('active');
|
|
||||||
|
const prefix = page.id === 'page-library' ? 'library-tab-' : 'search-tab-';
|
||||||
|
const contentId = `${prefix}${tab.dataset.tab}`;
|
||||||
|
document.getElementById(contentId)?.classList.add('active');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
248
js/ui.js
248
js/ui.js
|
|
@ -1,6 +1,7 @@
|
||||||
//js/ui.js
|
//js/ui.js
|
||||||
import { SVG_PLAY, SVG_DOWNLOAD, SVG_MENU, formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration } from './utils.js';
|
import { SVG_PLAY, SVG_DOWNLOAD, SVG_MENU, SVG_HEART, formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration } from './utils.js';
|
||||||
import { recentActivityManager, backgroundSettings, trackListSettings } from './storage.js';
|
import { recentActivityManager, backgroundSettings, trackListSettings } from './storage.js';
|
||||||
|
import { db } from './db.js';
|
||||||
|
|
||||||
export class UIRenderer {
|
export class UIRenderer {
|
||||||
constructor(api, player) {
|
constructor(api, player) {
|
||||||
|
|
@ -10,9 +11,38 @@ export class UIRenderer {
|
||||||
this.searchAbortController = null;
|
this.searchAbortController = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper for Heart Icon
|
||||||
|
createHeartIcon(filled = false) {
|
||||||
|
if (filled) {
|
||||||
|
return SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"');
|
||||||
|
}
|
||||||
|
return SVG_HEART;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLikeState(element, type, id) {
|
||||||
|
const isLiked = await db.isFavorite(type, id);
|
||||||
|
const btn = element.querySelector('.like-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.innerHTML = this.createHeartIcon(isLiked);
|
||||||
|
btn.classList.toggle('active', isLiked);
|
||||||
|
btn.title = isLiked ? 'Remove from Library' : 'Add to Library';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setCurrentTrack(track) {
|
setCurrentTrack(track) {
|
||||||
this.currentTrack = track;
|
this.currentTrack = track;
|
||||||
this.updateGlobalTheme();
|
this.updateGlobalTheme();
|
||||||
|
|
||||||
|
const likeBtn = document.getElementById('now-playing-like-btn');
|
||||||
|
if (likeBtn) {
|
||||||
|
if (track) {
|
||||||
|
likeBtn.style.display = 'flex';
|
||||||
|
// Use the centralized update logic if possible, or manual here
|
||||||
|
this.updateLikeState(likeBtn.parentElement, 'track', track.id);
|
||||||
|
} else {
|
||||||
|
likeBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateGlobalTheme() {
|
updateGlobalTheme() {
|
||||||
|
|
@ -86,6 +116,9 @@ export class UIRenderer {
|
||||||
|
|
||||||
const actionsHTML = `
|
const actionsHTML = `
|
||||||
<div class="track-actions-inline">
|
<div class="track-actions-inline">
|
||||||
|
<button class="track-action-btn like-btn" data-action="toggle-like" title="Add to Library">
|
||||||
|
${this.createHeartIcon(false)}
|
||||||
|
</button>
|
||||||
<button class="track-action-btn" data-action="play-next" title="Play Next">
|
<button class="track-action-btn" data-action="play-next" title="Play Next">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M2 6h6" />
|
<path d="M2 6h6" />
|
||||||
|
|
@ -154,38 +187,47 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<a href="#album/${album.id}" class="card">
|
<div class="card" data-album-id="${album.id}" data-href="#album/${album.id}" style="cursor: pointer;">
|
||||||
<div class="card-image-wrapper">
|
<div class="card-image-wrapper">
|
||||||
<img src="${this.api.getCoverUrl(album.cover, '320')}" alt="${album.title}" class="card-image" loading="lazy">
|
<img src="${this.api.getCoverUrl(album.cover, '320')}" alt="${album.title}" class="card-image" loading="lazy">
|
||||||
|
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="album" title="Add to Library">
|
||||||
|
${this.createHeartIcon(false)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="card-title">${album.title} ${explicitBadge}</h3>
|
<h3 class="card-title">${album.title} ${explicitBadge}</h3>
|
||||||
<p class="card-subtitle">${album.artist?.name ?? ''}</p>
|
<p class="card-subtitle">${album.artist?.name ?? ''}</p>
|
||||||
<p class="card-subtitle">${yearDisplay}${typeLabel}</p>
|
<p class="card-subtitle">${yearDisplay}${typeLabel}</p>
|
||||||
</a>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
createPlaylistCardHTML(playlist) {
|
createPlaylistCardHTML(playlist) {
|
||||||
const imageId = playlist.squareImage || playlist.image || playlist.uuid; // Fallback or use a specific cover getter if needed
|
const imageId = playlist.squareImage || playlist.image || playlist.uuid; // Fallback or use a specific cover getter if needed
|
||||||
return `
|
return `
|
||||||
<a href="#playlist/${playlist.uuid}" class="card">
|
<div class="card" data-playlist-id="${playlist.uuid}" data-href="#playlist/${playlist.uuid}" style="cursor: pointer;">
|
||||||
<div class="card-image-wrapper">
|
<div class="card-image-wrapper">
|
||||||
<img src="${this.api.getCoverUrl(imageId, '320')}" alt="${playlist.title}" class="card-image" loading="lazy">
|
<img src="${this.api.getCoverUrl(imageId, '320')}" alt="${playlist.title}" class="card-image" loading="lazy">
|
||||||
|
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="playlist" title="Add to Library">
|
||||||
|
${this.createHeartIcon(false)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="card-title">${playlist.title}</h3>
|
<h3 class="card-title">${playlist.title}</h3>
|
||||||
<p class="card-subtitle">${playlist.numberOfTracks || 0} tracks</p>
|
<p class="card-subtitle">${playlist.numberOfTracks || 0} tracks</p>
|
||||||
</a>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
createArtistCardHTML(artist) {
|
createArtistCardHTML(artist) {
|
||||||
return `
|
return `
|
||||||
<a href="#artist/${artist.id}" class="card artist">
|
<div class="card artist" data-artist-id="${artist.id}" data-href="#artist/${artist.id}" style="cursor: pointer;">
|
||||||
<div class="card-image-wrapper">
|
<div class="card-image-wrapper">
|
||||||
<img src="${this.api.getArtistPictureUrl(artist.picture, '320')}" alt="${artist.name}" class="card-image" loading="lazy">
|
<img src="${this.api.getArtistPictureUrl(artist.picture, '320')}" alt="${artist.name}" class="card-image" loading="lazy">
|
||||||
|
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="artist" title="Add to Library">
|
||||||
|
${this.createHeartIcon(false)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="card-title">${artist.name}</h3>
|
<h3 class="card-title">${artist.name}</h3>
|
||||||
</a>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,7 +284,11 @@ export class UIRenderer {
|
||||||
|
|
||||||
tracks.forEach(track => {
|
tracks.forEach(track => {
|
||||||
const element = container.querySelector(`[data-track-id="${track.id}"]`);
|
const element = container.querySelector(`[data-track-id="${track.id}"]`);
|
||||||
if (element) trackDataStore.set(element, track);
|
if (element) {
|
||||||
|
trackDataStore.set(element, track);
|
||||||
|
// Async update for like button
|
||||||
|
this.updateLikeState(element, 'track', track.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -405,6 +451,65 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async renderLibraryPage() {
|
||||||
|
this.showPage('library');
|
||||||
|
|
||||||
|
const playlistsContainer = document.getElementById('library-playlists-container');
|
||||||
|
const tracksContainer = document.getElementById('library-tracks-container');
|
||||||
|
const albumsContainer = document.getElementById('library-albums-container');
|
||||||
|
const artistsContainer = document.getElementById('library-artists-container');
|
||||||
|
|
||||||
|
// Render Favorites
|
||||||
|
const likedPlaylists = await db.getFavorites('playlist');
|
||||||
|
if (likedPlaylists.length) {
|
||||||
|
playlistsContainer.innerHTML = likedPlaylists.map(p => this.createPlaylistCardHTML(p)).join('');
|
||||||
|
likedPlaylists.forEach(playlist => {
|
||||||
|
const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, playlist);
|
||||||
|
this.updateLikeState(el, 'playlist', playlist.uuid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
playlistsContainer.innerHTML = createPlaceholder('No liked playlists yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const likedTracks = await db.getFavorites('track');
|
||||||
|
if (likedTracks.length) {
|
||||||
|
this.renderListWithTracks(tracksContainer, likedTracks, true);
|
||||||
|
} else {
|
||||||
|
tracksContainer.innerHTML = createPlaceholder('No liked songs yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const likedAlbums = await db.getFavorites('album');
|
||||||
|
if (likedAlbums.length) {
|
||||||
|
albumsContainer.innerHTML = likedAlbums.map(a => this.createAlbumCardHTML(a)).join('');
|
||||||
|
likedAlbums.forEach(album => {
|
||||||
|
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, album);
|
||||||
|
this.updateLikeState(el, 'album', album.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
albumsContainer.innerHTML = createPlaceholder('No liked albums yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const likedArtists = await db.getFavorites('artist');
|
||||||
|
if (likedArtists.length) {
|
||||||
|
artistsContainer.innerHTML = likedArtists.map(a => this.createArtistCardHTML(a)).join('');
|
||||||
|
likedArtists.forEach(artist => {
|
||||||
|
const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, artist);
|
||||||
|
this.updateLikeState(el, 'artist', artist.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
artistsContainer.innerHTML = createPlaceholder('No liked artists yet.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async renderHomePage() {
|
async renderHomePage() {
|
||||||
this.showPage('home');
|
this.showPage('home');
|
||||||
const recents = recentActivityManager.getRecents();
|
const recents = recentActivityManager.getRecents();
|
||||||
|
|
@ -413,18 +518,45 @@ export class UIRenderer {
|
||||||
const artistsContainer = document.getElementById('home-recent-artists');
|
const artistsContainer = document.getElementById('home-recent-artists');
|
||||||
const playlistsContainer = document.getElementById('home-recent-playlists');
|
const playlistsContainer = document.getElementById('home-recent-playlists');
|
||||||
|
|
||||||
albumsContainer.innerHTML = recents.albums.length
|
if (recents.albums.length) {
|
||||||
? recents.albums.map(album => this.createAlbumCardHTML(album)).join('')
|
albumsContainer.innerHTML = recents.albums.map(album => this.createAlbumCardHTML(album)).join('');
|
||||||
: createPlaceholder("You haven't viewed any albums yet. Search for music to get started!");
|
recents.albums.forEach(album => {
|
||||||
|
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, album);
|
||||||
|
this.updateLikeState(el, 'album', album.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
albumsContainer.innerHTML = createPlaceholder("You haven't viewed any albums yet. Search for music to get started!");
|
||||||
|
}
|
||||||
|
|
||||||
artistsContainer.innerHTML = recents.artists.length
|
if (recents.artists.length) {
|
||||||
? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('')
|
artistsContainer.innerHTML = recents.artists.map(artist => this.createArtistCardHTML(artist)).join('');
|
||||||
: createPlaceholder("You haven't viewed any artists yet. Search for music to get started!");
|
recents.artists.forEach(artist => {
|
||||||
|
const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, artist);
|
||||||
|
this.updateLikeState(el, 'artist', artist.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
artistsContainer.innerHTML = createPlaceholder("You haven't viewed any artists yet. Search for music to get started!");
|
||||||
|
}
|
||||||
|
|
||||||
if (playlistsContainer) {
|
if (playlistsContainer) {
|
||||||
playlistsContainer.innerHTML = recents.playlists && recents.playlists.length
|
if (recents.playlists && recents.playlists.length) {
|
||||||
? recents.playlists.map(playlist => this.createPlaylistCardHTML(playlist)).join('')
|
playlistsContainer.innerHTML = recents.playlists.map(playlist => this.createPlaylistCardHTML(playlist)).join('');
|
||||||
: createPlaceholder("You haven't viewed any playlists yet. Search for music to get started!");
|
recents.playlists.forEach(playlist => {
|
||||||
|
const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, playlist);
|
||||||
|
this.updateLikeState(el, 'playlist', playlist.uuid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
playlistsContainer.innerHTML = createPlaceholder("You haven't viewed any playlists yet. Search for music to get started!");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -498,14 +630,38 @@ export class UIRenderer {
|
||||||
? finalArtists.map(artist => this.createArtistCardHTML(artist)).join('')
|
? finalArtists.map(artist => this.createArtistCardHTML(artist)).join('')
|
||||||
: createPlaceholder('No artists found.');
|
: createPlaceholder('No artists found.');
|
||||||
|
|
||||||
|
finalArtists.forEach(artist => {
|
||||||
|
const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, artist);
|
||||||
|
this.updateLikeState(el, 'artist', artist.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
albumsContainer.innerHTML = finalAlbums.length
|
albumsContainer.innerHTML = finalAlbums.length
|
||||||
? finalAlbums.map(album => this.createAlbumCardHTML(album)).join('')
|
? finalAlbums.map(album => this.createAlbumCardHTML(album)).join('')
|
||||||
: createPlaceholder('No albums found.');
|
: createPlaceholder('No albums found.');
|
||||||
|
|
||||||
|
finalAlbums.forEach(album => {
|
||||||
|
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, album);
|
||||||
|
this.updateLikeState(el, 'album', album.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
playlistsContainer.innerHTML = finalPlaylists.length
|
playlistsContainer.innerHTML = finalPlaylists.length
|
||||||
? finalPlaylists.map(playlist => this.createPlaylistCardHTML(playlist)).join('')
|
? finalPlaylists.map(playlist => this.createPlaylistCardHTML(playlist)).join('')
|
||||||
: createPlaceholder('No playlists found.');
|
: createPlaceholder('No playlists found.');
|
||||||
|
|
||||||
|
finalPlaylists.forEach(playlist => {
|
||||||
|
const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, playlist);
|
||||||
|
this.updateLikeState(el, 'playlist', playlist.uuid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') return;
|
if (error.name === 'AbortError') return;
|
||||||
console.error("Search failed:", error);
|
console.error("Search failed:", error);
|
||||||
|
|
@ -603,6 +759,14 @@ export class UIRenderer {
|
||||||
this.renderListWithTracks(tracklistContainer, tracks, false);
|
this.renderListWithTracks(tracklistContainer, tracks, false);
|
||||||
|
|
||||||
recentActivityManager.addAlbum(album);
|
recentActivityManager.addAlbum(album);
|
||||||
|
|
||||||
|
// Update header like button
|
||||||
|
const albumLikeBtn = document.getElementById('like-album-btn');
|
||||||
|
if (albumLikeBtn) {
|
||||||
|
const isLiked = await db.isFavorite('album', album.id);
|
||||||
|
albumLikeBtn.innerHTML = this.createHeartIcon(isLiked);
|
||||||
|
albumLikeBtn.classList.toggle('active', isLiked);
|
||||||
|
}
|
||||||
|
|
||||||
document.title = `${album.title} - ${album.artist.name} - Monochrome`;
|
document.title = `${album.title} - ${album.artist.name} - Monochrome`;
|
||||||
|
|
||||||
|
|
@ -649,6 +813,14 @@ export class UIRenderer {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.getElementById('page-album').appendChild(section);
|
document.getElementById('page-album').appendChild(section);
|
||||||
|
|
||||||
|
filtered.forEach(a => {
|
||||||
|
const el = section.querySelector(`[data-album-id="${a.id}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, a);
|
||||||
|
this.updateLikeState(el, 'album', a.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
renderSection(`More albums from ${album.artist.name}`, artistData.albums);
|
renderSection(`More albums from ${album.artist.name}`, artistData.albums);
|
||||||
|
|
@ -667,7 +839,6 @@ export class UIRenderer {
|
||||||
|
|
||||||
async renderPlaylistPage(playlistId) {
|
async renderPlaylistPage(playlistId) {
|
||||||
this.showPage('playlist');
|
this.showPage('playlist');
|
||||||
|
|
||||||
const imageEl = document.getElementById('playlist-detail-image');
|
const imageEl = document.getElementById('playlist-detail-image');
|
||||||
const titleEl = document.getElementById('playlist-detail-title');
|
const titleEl = document.getElementById('playlist-detail-title');
|
||||||
const metaEl = document.getElementById('playlist-detail-meta');
|
const metaEl = document.getElementById('playlist-detail-meta');
|
||||||
|
|
@ -696,7 +867,11 @@ export class UIRenderer {
|
||||||
const { playlist, tracks } = await this.api.getPlaylist(playlistId);
|
const { playlist, tracks } = await this.api.getPlaylist(playlistId);
|
||||||
|
|
||||||
const imageId = playlist.squareImage || playlist.image;
|
const imageId = playlist.squareImage || playlist.image;
|
||||||
imageEl.src = this.api.getCoverUrl(imageId, '1080');
|
if (imageId) {
|
||||||
|
imageEl.src = this.api.getCoverUrl(imageId, '1080');
|
||||||
|
} else {
|
||||||
|
imageEl.src = 'assets/appicon.png';
|
||||||
|
}
|
||||||
imageEl.style.backgroundColor = '';
|
imageEl.style.backgroundColor = '';
|
||||||
|
|
||||||
titleEl.textContent = playlist.title;
|
titleEl.textContent = playlist.title;
|
||||||
|
|
@ -716,8 +891,22 @@ export class UIRenderer {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.renderListWithTracks(tracklistContainer, tracks, true);
|
this.renderListWithTracks(tracklistContainer, tracks, true);
|
||||||
recentActivityManager.addPlaylist(playlist);
|
|
||||||
|
|
||||||
|
// Update header like button
|
||||||
|
const playlistLikeBtn = document.getElementById('like-playlist-btn');
|
||||||
|
if (playlistLikeBtn) {
|
||||||
|
const isLiked = await db.isFavorite('playlist', playlist.uuid);
|
||||||
|
playlistLikeBtn.innerHTML = this.createHeartIcon(isLiked);
|
||||||
|
playlistLikeBtn.classList.toggle('active', isLiked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide Delete button
|
||||||
|
const deleteBtn = document.getElementById('delete-playlist-btn');
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
recentActivityManager.addPlaylist(playlist);
|
||||||
document.title = `${playlist.title || 'Artist Mix'} - Monochrome`;
|
document.title = `${playlist.title || 'Artist Mix'} - Monochrome`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load playlist:", error);
|
console.error("Failed to load playlist:", error);
|
||||||
|
|
@ -768,6 +957,17 @@ export class UIRenderer {
|
||||||
|
|
||||||
this.renderListWithTracks(tracksContainer, artist.tracks, true);
|
this.renderListWithTracks(tracksContainer, artist.tracks, true);
|
||||||
|
|
||||||
|
// Update header like button
|
||||||
|
const artistLikeBtn = document.getElementById('like-artist-btn');
|
||||||
|
if (artistLikeBtn) {
|
||||||
|
const isLiked = await db.isFavorite('artist', artist.id);
|
||||||
|
artistLikeBtn.innerHTML = this.createHeartIcon(isLiked);
|
||||||
|
artistLikeBtn.classList.toggle('active', isLiked);
|
||||||
|
}
|
||||||
|
|
||||||
|
albumsContainer.innerHTML = artist.albums.map(album =>
|
||||||
|
this.createAlbumCardHTML(album)
|
||||||
|
).join('');
|
||||||
// Render Albums
|
// Render Albums
|
||||||
albumsContainer.innerHTML = artist.albums.length
|
albumsContainer.innerHTML = artist.albums.length
|
||||||
? artist.albums.map(album => this.createAlbumCardHTML(album)).join('')
|
? artist.albums.map(album => this.createAlbumCardHTML(album)).join('')
|
||||||
|
|
@ -783,6 +983,14 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
artist.albums.forEach(album => {
|
||||||
|
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, album);
|
||||||
|
this.updateLikeState(el, 'album', album.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
recentActivityManager.addArtist(artist);
|
recentActivityManager.addArtist(artist);
|
||||||
|
|
||||||
document.title = `${artist.name} - Monochrome`;
|
document.title = `${artist.name} - Monochrome`;
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ export const SVG_VOLUME = '<svg xmlns="http://www.w3.org/2000/svg" width="20" he
|
||||||
export const SVG_MUTE = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><line x1="23" y1="9" x2="17" y2="15"></line><line x1="17" y1="9" x2="23" y2="15"></line></svg>';
|
export const SVG_MUTE = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><line x1="23" y1="9" x2="17" y2="15"></line><line x1="17" y1="9" x2="23" y2="15"></line></svg>';
|
||||||
export const SVG_DOWNLOAD = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
|
export const SVG_DOWNLOAD = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
|
||||||
export const SVG_MENU = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>';
|
export const SVG_MENU = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>';
|
||||||
|
export const SVG_HEART = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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>';
|
||||||
export const SVG_CLOSE = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
|
export const SVG_CLOSE = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
|
||||||
|
export const SVG_BIN = '<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"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>';
|
||||||
|
|
||||||
export const formatTime = (seconds) => {
|
export const formatTime = (seconds) => {
|
||||||
if (isNaN(seconds)) return '0:00';
|
if (isNaN(seconds)) return '0:00';
|
||||||
|
|
|
||||||
83
styles.css
83
styles.css
|
|
@ -528,6 +528,43 @@ body.has-page-background .track-item:hover {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-like-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.25) !important;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border-radius: 50% !important;
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
z-index: 10;
|
||||||
|
color: white !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .card-like-btn,
|
||||||
|
.card-like-btn.active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-like-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.7) !important;
|
||||||
|
transform: scale(1.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-like-btn.active {
|
||||||
|
color: #ef4444 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.card-image {
|
.card-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1/1;
|
aspect-ratio: 1/1;
|
||||||
|
|
@ -562,6 +599,24 @@ body.has-page-background .track-item:hover {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heart-icon {
|
||||||
|
transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart-icon.filled {
|
||||||
|
color: #ef4444;
|
||||||
|
fill: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item:hover .like-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-btn.active .heart-icon {
|
||||||
|
color: #ef4444;
|
||||||
|
fill: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
.explicit-badge {
|
.explicit-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -724,10 +779,14 @@ body.has-page-background .track-item:hover {
|
||||||
.track-actions-inline {
|
.track-actions-inline {
|
||||||
display: none; /* Controlled by data-track-actions-mode */
|
display: none; /* Controlled by data-track-actions-mode */
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
opacity: 0.2; /* Barely visible instead of invisible */
|
opacity: 0.2;
|
||||||
transition: opacity var(--transition);
|
transition: opacity var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.track-action-btn.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
[data-track-actions-mode="inline"] .track-actions-inline {
|
[data-track-actions-mode="inline"] .track-actions-inline {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
@ -1105,7 +1164,7 @@ input:checked + .slider::before {
|
||||||
.player-controls .buttons button#repeat-btn.repeat-one::after {
|
.player-controls .buttons button#repeat-btn.repeat-one::after {
|
||||||
content: '1';
|
content: '1';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 0.6rem;
|
font-size: 0.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1372,12 +1431,10 @@ input:checked + .slider::before {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
background: color-mix(in srgb, var(--card), transparent 25%);
|
background: color-mix(in srgb, var(--card), transparent 80%);
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
backdrop-filter: blur(12px);
|
border: 1px solid color-mix(in srgb, var(--card), transparent 70%);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--border), transparent 50%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#fullscreen-track-title {
|
#fullscreen-track-title {
|
||||||
|
|
@ -1390,14 +1447,14 @@ input:checked + .slider::before {
|
||||||
|
|
||||||
#fullscreen-track-artist {
|
#fullscreen-track-artist {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
color: var(--muted-foreground);
|
color: var(--primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
#fullscreen-next-track {
|
#fullscreen-next-track {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--muted-foreground);
|
color: var(--primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
|
|
@ -1463,7 +1520,7 @@ input:checked + .slider::before {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#queue-modal-header button {
|
#queue-modal-header #close-queue-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
|
|
@ -1478,7 +1535,12 @@ input:checked + .slider::before {
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
#queue-modal-header button:hover {
|
#queue-modal-header #clear-queue-btn {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#queue-modal-header #clear-queue-btn:hover,
|
||||||
|
#queue-modal-header #close-queue-btn:hover {
|
||||||
background-color: var(--secondary);
|
background-color: var(--secondary);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
@ -1562,7 +1624,6 @@ input:checked + .slider::before {
|
||||||
.placeholder-text {
|
.placeholder-text {
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-text.loading {
|
.placeholder-text.loading {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue