Playlist Feature, Lyrics on Fullscreen & More

This commit is contained in:
Samidy 2025-12-31 12:26:05 +03:00
parent c33ef02dca
commit a27be3162d
9 changed files with 814 additions and 87 deletions

View file

@ -23,6 +23,7 @@
<div id="context-menu">
<ul>
<li data-action="toggle-like">Like</li>
<li data-action="add-to-playlist">Add to Playlist</li>
<li data-action="play-next">Play Next</li>
<li data-action="add-to-queue">Add to Queue</li>
<li data-action="download">Download</li>
@ -41,9 +42,18 @@
</div>
</div>
<div id="fullscreen-cover-overlay" style="display: none;">
<div class="fullscreen-cover-content">
<button id="close-fullscreen-cover-btn" title="Close">&times;</button>
<div id="fullscreen-cover-overlay" style="display: none;">
<div class="fullscreen-cover-content">
<button id="close-fullscreen-cover-btn" title="Close">&times;</button>
<button id="toggle-fullscreen-lyrics-btn" class="fullscreen-lyrics-toggle" title="Toggle Lyrics">
<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="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="8" y1="22" x2="16" y2="22"/>
</svg>
</button>
<div class="fullscreen-main-view">
<img id="fullscreen-cover-image" src="" alt="Album Cover">
<div class="fullscreen-track-info">
<h2 id="fullscreen-track-title"></h2>
@ -53,13 +63,26 @@
<span class="value"></span>
</div>
</div>
<div class="fullscreen-controls">
<!-- Controls will be cloned or managed here if needed, or we just rely on main controls -->
</div>
<div class="fullscreen-lyrics-container" id="fullscreen-lyrics-container" style="display: none;">
</div>
</div>
</div>
<div id="playlist-modal" class="modal" style="display: none;">
<div class="modal-overlay" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000;">
<div class="modal-content" style="background: var(--card); padding: 2rem; border-radius: var(--radius); max-width: 400px; width: 90%;">
<h3 id="playlist-modal-title">Create Playlist</h3>
<input type="text" id="playlist-name-input" placeholder="Playlist name" style="width: 100%; margin: 1rem 0; padding: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--background); color: var(--foreground);">
<div class="modal-actions" style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button id="playlist-modal-cancel" class="btn-secondary">Cancel</button>
<button id="playlist-modal-save" class="btn-primary">Save</button>
</div>
</div>
</div>
</div>
<div id="sidebar-overlay"></div>
<div class="app-container">
@ -177,6 +200,10 @@
<div id="page-library" class="page">
<h2 class="section-title">Your Library</h2>
<section class="content-section">
<h2 class="section-title">My Playlists <button id="create-playlist-btn" class="btn-secondary">Create Playlist</button></h2>
<div class="card-grid" id="my-playlists-container"></div>
</section>
<div class="search-tabs">
<button class="search-tab active" data-tab="tracks">Liked Tracks</button>
<button class="search-tab" data-tab="albums">Albums</button>

122
js/app.js
View file

@ -5,7 +5,7 @@ import { apiSettings, themeManager, nowPlayingSettings, trackListSettings } from
import { UIRenderer } from './ui.js';
import { Player } from './player.js';
import { LastFMScrobbler } from './lastfm.js';
import { LyricsManager, openLyricsPanel, clearLyricsPanelSync } from './lyrics.js';
import { LyricsManager, openLyricsPanel, clearLyricsPanelSync, renderLyricsInFullscreen, clearFullscreenLyricsSync } from './lyrics.js';
import { createRouter, updateTabTitle } from './router.js';
import { initializeSettings } from './settings.js';
import { initializePlayerEvents, initializeTrackInteractions, handleTrackAction } from './events.js';
@ -13,6 +13,8 @@ import { initializeUIInteractions } from './ui-interactions.js';
import { downloadAlbumAsZip, downloadDiscography, downloadPlaylistAsZip } from './downloads.js';
import { debounce, SVG_PLAY } from './utils.js';
import { sidePanelManager } from './side-panel.js';
import { db } from './db.js';
import { syncManager } from './firebase/sync.js';
function initializeCasting(audioPlayer, castBtn) {
if (!castBtn) return;
@ -229,7 +231,7 @@ document.addEventListener('DOMContentLoaded', async () => {
ui.closeFullscreenCover();
} else {
const nextTrack = player.getNextTrack();
ui.showFullscreenCover(player.currentTrack, nextTrack);
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer);
}
} else {
// Default to 'album' mode - navigate to album
@ -292,7 +294,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const fullscreenOverlay = document.getElementById('fullscreen-cover-overlay');
if (fullscreenOverlay && getComputedStyle(fullscreenOverlay).display !== 'none') {
const nextTrack = player.getNextTrack();
ui.showFullscreenCover(player.currentTrack, nextTrack);
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer);
}
});
@ -338,6 +340,111 @@ document.addEventListener('DOMContentLoaded', async () => {
btn.innerHTML = originalHTML;
}
}
if (e.target.closest('#create-playlist-btn')) {
const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Create Playlist';
document.getElementById('playlist-name-input').value = '';
modal.style.display = 'flex';
document.getElementById('playlist-name-input').focus();
}
if (e.target.closest('#playlist-modal-save')) {
const name = document.getElementById('playlist-name-input').value.trim();
if (name) {
const modal = document.getElementById('playlist-modal');
const editingId = modal.dataset.editingId;
if (editingId) {
// Edit
db.getPlaylist(editingId).then(async (playlist) => {
if (playlist) {
playlist.name = name;
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
syncManager.syncUserPlaylist(playlist, 'update');
ui.renderLibraryPage();
modal.style.display = 'none';
delete modal.dataset.editingId;
}
});
} else {
// Create
db.createPlaylist(name, [], '').then(playlist => {
syncManager.syncUserPlaylist(playlist, 'create');
ui.renderLibraryPage();
modal.style.display = 'none';
});
}
}
}
if (e.target.closest('#playlist-modal-cancel')) {
document.getElementById('playlist-modal').style.display = 'none';
}
if (e.target.closest('.edit-playlist-btn')) {
const card = e.target.closest('.user-playlist');
const playlistId = card.dataset.playlistId;
db.getPlaylist(playlistId).then(playlist => {
if (playlist) {
const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
document.getElementById('playlist-name-input').value = playlist.name;
modal.dataset.editingId = playlistId;
modal.style.display = 'flex';
document.getElementById('playlist-name-input').focus();
}
});
}
if (e.target.closest('.delete-playlist-btn')) {
const card = e.target.closest('.user-playlist');
const playlistId = card.dataset.playlistId;
if (confirm('Are you sure you want to delete this playlist?')) {
db.deletePlaylist(playlistId).then(() => {
syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
ui.renderLibraryPage();
});
}
}
if (e.target.closest('#edit-playlist-btn')) {
const playlistId = window.location.hash.split('/')[1];
db.getPlaylist(playlistId).then(playlist => {
if (playlist) {
const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
document.getElementById('playlist-name-input').value = playlist.name;
modal.dataset.editingId = playlistId;
modal.style.display = 'flex';
document.getElementById('playlist-name-input').focus();
}
});
}
if (e.target.closest('#delete-playlist-btn')) {
const playlistId = window.location.hash.split('/')[1];
if (confirm('Are you sure you want to delete this playlist?')) {
db.deletePlaylist(playlistId).then(() => {
window.location.hash = '#library';
});
}
}
if (e.target.closest('.remove-from-playlist-btn')) {
const btn = e.target.closest('.remove-from-playlist-btn');
const index = parseInt(btn.dataset.trackIndex);
const playlistId = window.location.hash.split('/')[1];
db.getPlaylist(playlistId).then(async (playlist) => {
if (playlist && playlist.tracks[index]) {
const trackId = playlist.tracks[index].id;
await db.removeTrackFromPlaylist(playlistId, trackId);
ui.renderPlaylistPage(playlistId);
}
});
}
if (e.target.closest('#play-playlist-btn')) {
const btn = e.target.closest('#play-playlist-btn');
if (btn.disabled) return;
@ -346,7 +453,14 @@ document.addEventListener('DOMContentLoaded', async () => {
if (!playlistId) return;
try {
const { tracks } = await api.getPlaylist(playlistId);
let tracks;
const userPlaylist = await db.getPlaylist(playlistId);
if (userPlaylist) {
tracks = userPlaylist.tracks;
} else {
const { tracks: apiTracks } = await api.getPlaylist(playlistId);
tracks = apiTracks;
}
if (tracks.length > 0) {
player.setQueue(tracks, 0);
document.getElementById('shuffle-btn').classList.remove('active');

View file

@ -1,7 +1,7 @@
export class MusicDatabase {
constructor() {
this.dbName = 'MonochromeDB';
this.version = 3;
this.version = 4;
this.db = null;
}
@ -45,6 +45,10 @@ export class MusicDatabase {
const store = db.createObjectStore('history_tracks', { keyPath: 'timestamp' });
store.createIndex('timestamp', 'timestamp', { unique: true });
}
if (!db.objectStoreNames.contains('user_playlists')) {
const store = db.createObjectStore('user_playlists', { keyPath: 'id' });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
};
});
}
@ -220,12 +224,14 @@ export class MusicDatabase {
const playlists = await this.getFavorites('playlist');
const history = await this.getHistory();
const userPlaylists = await this.getPlaylists();
const data = {
favorites_tracks: tracks.map(t => this._minifyItem('track', t)),
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)),
history_tracks: history.map(t => this._minifyItem('track', t))
history_tracks: history.map(t => this._minifyItem('track', t)),
user_playlists: userPlaylists
};
return data;
}
@ -255,6 +261,73 @@ export class MusicDatabase {
await importStore('favorites_artists', data.favorites_artists);
await importStore('favorites_playlists', data.favorites_playlists);
await importStore('history_tracks', data.history_tracks);
if (data.user_playlists) {
await importStore('user_playlists', data.user_playlists);
}
}
// User Playlists API
async createPlaylist(name, tracks = [], cover = '') {
const id = crypto.randomUUID();
const playlist = {
id: id,
name: name,
tracks: tracks.map(t => this._minifyItem('track', t)),
cover: cover,
createdAt: Date.now()
};
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist;
}
async addTrackToPlaylist(playlistId, track) {
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
if (!playlist) throw new Error('Playlist not found');
playlist.tracks = playlist.tracks || [];
const minifiedTrack = this._minifyItem('track', track);
if (playlist.tracks.some(t => t.id === track.id)) return;
playlist.tracks.push(minifiedTrack);
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist;
}
async removeTrackFromPlaylist(playlistId, trackId) {
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
if (!playlist) throw new Error('Playlist not found');
playlist.tracks = playlist.tracks || [];
playlist.tracks = playlist.tracks.filter(t => t.id !== trackId);
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist;
}
async deletePlaylist(playlistId) {
await this.performTransaction('user_playlists', 'readwrite', (store) => store.delete(playlistId));
}
async getPlaylist(playlistId) {
return await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
}
async getPlaylists() {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('user_playlists', 'readonly');
const store = transaction.objectStore('user_playlists');
const index = store.index('createdAt');
const request = index.getAll();
request.onsuccess = () => {
resolve(request.result.reverse()); // Newest first
};
request.onerror = () => reject(request.error);
});
}
async updatePlaylistName(playlistId, newName) {
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
if (!playlist) throw new Error('Playlist not found');
playlist.name = newName;
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist;
}
}

View file

@ -336,6 +336,9 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
} else if (type === 'playlist') {
const data = await api.getPlaylist(item.uuid);
tracks = data.tracks;
} else if (type === 'user-playlist') {
const playlist = await db.getPlaylist(item.id);
tracks = playlist ? playlist.tracks : [];
}
if (tracks.length > 0) {
@ -343,7 +346,8 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active');
player.playAtIndex(0);
showNotification(`Playing ${type}: ${item.title}`);
const name = type === 'user-playlist' ? item.name : item.title;
showNotification(`Playing ${type.replace('user-', '')}: ${name}`);
} else {
showNotification(`No tracks found in this ${type}`);
}
@ -430,6 +434,46 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
}
}
}
} else if (action === 'add-to-playlist') {
const playlists = await db.getPlaylists();
if (playlists.length === 0) {
showNotification('No playlists yet. Create one first.');
return;
}
const modal = document.createElement('div');
modal.className = 'playlist-select-modal';
modal.innerHTML = `
<div class="modal-overlay" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center;">
<div class="modal-content" style="background: var(--card); padding: 2rem; border-radius: var(--radius); max-width: 400px; width: 90%;">
<h3>Add to Playlist</h3>
<div id="playlist-list" style="margin: 1rem 0; max-height: 200px; overflow-y: auto;">
${playlists.map(p => `<div class="playlist-option" data-id="${p.id}" style="padding: 0.5rem; cursor: pointer; border-bottom: 1px solid var(--border);">${p.name}</div>`).join('')}
</div>
<div class="modal-actions" style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button id="cancel-add-playlist" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', async (e) => {
if (e.target.id === 'cancel-add-playlist') {
modal.remove();
return;
}
const option = e.target.closest('.playlist-option');
if (option) {
const playlistId = option.dataset.id;
await db.addTrackToPlaylist(playlistId, item);
const updatedPlaylist = await db.getPlaylist(playlistId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
showNotification(`Added to playlist: ${option.textContent}`);
modal.remove();
}
});
}
}

View file

@ -60,7 +60,8 @@ export class SyncManager {
favorites_albums: mergedData.library?.albums ? Object.values(mergedData.library.albums) : [],
favorites_artists: mergedData.library?.artists ? Object.values(mergedData.library.artists) : [],
favorites_playlists: mergedData.library?.playlists ? Object.values(mergedData.library.playlists) : [],
history_tracks: mergedData.history?.recentTracks ? Object.values(mergedData.history.recentTracks) : []
history_tracks: mergedData.history?.recentTracks ? Object.values(mergedData.history.recentTracks) : [],
user_playlists: mergedData.user_playlists ? Object.values(mergedData.user_playlists) : []
};
await db.importData(importData, true);
@ -119,6 +120,7 @@ export class SyncManager {
history: {
recentTracks: this.arrayToObject(mergeStores(local.history_tracks, cloud.history?.recentTracks, 'timestamp'), 'timestamp')
},
user_playlists: this.arrayToObject(mergeStores(local.user_playlists, cloud.user_playlists), 'id'),
// Settings are NOT synced (device specific)
lastUpdated: Date.now()
};
@ -181,6 +183,26 @@ export class SyncManager {
});
this.unsubscribeFunctions.push(() => off(historyRef, 'value', unsubHistory));
// Listen for changes in user playlists
const userPlaylistsRef = child(this.userRef, 'user_playlists');
const unsubUserPlaylists = onValue(userPlaylistsRef, (snapshot) => {
if (this.isSyncing) return;
const val = snapshot.val();
if (val) {
const importData = {
user_playlists: Object.values(val)
};
db.importData(importData, true).then(() => {
// Notify UI to refresh library
window.dispatchEvent(new Event('library-changed'));
});
}
});
this.unsubscribeFunctions.push(() => off(userPlaylistsRef, 'value', unsubUserPlaylists));
}
// --- Public API for Broadcasters ---
@ -232,6 +254,20 @@ export class SyncManager {
}
}
async syncUserPlaylist(playlist, action) {
if (!this.user || !this.userRef) return;
const id = playlist.id;
const path = `user_playlists/${id}`;
const itemRef = child(this.userRef, path);
if (action === 'create' || action === 'update') {
await set(itemRef, playlist);
} else if (action === 'delete') {
await remove(itemRef);
}
}
async clearCloudData() {
if (!this.user || !this.userRef) {
throw new Error("Not authenticated");

View file

@ -312,3 +312,118 @@ export function clearLyricsPanelSync(audioPlayer, panel) {
panel.lyricsCleanup = null;
}
}
export async function renderLyricsInFullscreen(track, audioPlayer, lyricsManager, container) {
container.innerHTML = '<div class="lyrics-loading">Loading lyrics...</div>';
try {
await lyricsManager.ensureComponentLoaded();
const title = track.title;
const artist = getTrackArtists(track);
const album = track.album?.title;
const durationMs = track.duration ? Math.round(track.duration * 1000) : undefined;
const isrc = track.isrc || '';
container.innerHTML = '';
const amLyrics = document.createElement('am-lyrics');
amLyrics.setAttribute('song-title', title);
amLyrics.setAttribute('song-artist', artist);
if (album) amLyrics.setAttribute('song-album', album);
if (durationMs) amLyrics.setAttribute('song-duration', durationMs);
amLyrics.setAttribute('query', `${title} ${artist}`.trim());
if (isrc) amLyrics.setAttribute('isrc', isrc);
amLyrics.setAttribute('highlight-color', '#93c5fd');
amLyrics.setAttribute('hover-background-color', 'rgba(59, 130, 246, 0.14)');
amLyrics.setAttribute('autoscroll', '');
amLyrics.setAttribute('interpolate', '');
amLyrics.style.height = '100%';
amLyrics.style.width = '100%';
container.appendChild(amLyrics);
setupFullscreenLyricsSync(track, audioPlayer, container, lyricsManager, amLyrics);
return amLyrics;
} catch (error) {
console.error('Failed to load lyrics in fullscreen:', error);
container.innerHTML = '<div class="lyrics-error">Failed to load lyrics</div>';
return null;
}
}
function setupFullscreenLyricsSync(track, audioPlayer, container, lyricsManager, amLyrics) {
let baseTimeMs = 0;
let lastTimestamp = performance.now();
const updateTime = () => {
const currentMs = audioPlayer.currentTime * 1000;
baseTimeMs = currentMs;
lastTimestamp = performance.now();
amLyrics.currentTime = currentMs;
};
const tick = () => {
if (!audioPlayer.paused) {
const now = performance.now();
const elapsed = now - lastTimestamp;
const nextMs = baseTimeMs + elapsed;
amLyrics.currentTime = nextMs;
lyricsManager.fullscreenAnimationFrameId = requestAnimationFrame(tick);
}
};
const onPlay = () => {
baseTimeMs = audioPlayer.currentTime * 1000;
lastTimestamp = performance.now();
tick();
};
const onPause = () => {
if (lyricsManager.fullscreenAnimationFrameId) {
cancelAnimationFrame(lyricsManager.fullscreenAnimationFrameId);
lyricsManager.fullscreenAnimationFrameId = null;
}
};
audioPlayer.addEventListener('timeupdate', updateTime);
audioPlayer.addEventListener('play', onPlay);
audioPlayer.addEventListener('pause', onPause);
audioPlayer.addEventListener('seeked', updateTime);
container.lyricsUpdateHandler = updateTime;
container.lyricsPlayHandler = onPlay;
container.lyricsPauseHandler = onPause;
container.lyricsSeekHandler = updateTime;
container.lyricsCleanup = () => {
if (lyricsManager.fullscreenAnimationFrameId) {
cancelAnimationFrame(lyricsManager.fullscreenAnimationFrameId);
lyricsManager.fullscreenAnimationFrameId = null;
}
audioPlayer.removeEventListener('timeupdate', updateTime);
audioPlayer.removeEventListener('play', onPlay);
audioPlayer.removeEventListener('pause', onPause);
audioPlayer.removeEventListener('seeked', updateTime);
};
amLyrics.addEventListener('line-click', (e) => {
if (e.detail && e.detail.timestamp) {
audioPlayer.currentTime = e.detail.timestamp / 1000;
audioPlayer.play();
}
});
if (!audioPlayer.paused) {
tick();
}
}
export function clearFullscreenLyricsSync(container) {
if (container && container.lyricsCleanup) {
container.lyricsCleanup();
container.lyricsCleanup = null;
}
}

View file

@ -17,6 +17,9 @@ export function createRouter(ui) {
case 'playlist':
ui.renderPlaylistPage(param);
break;
case 'userplaylist':
ui.renderPlaylistPage(param);
break;
case 'library':
ui.renderLibraryPage();
break;

322
js/ui.js
View file

@ -1,5 +1,6 @@
//js/ui.js
import { SVG_PLAY, SVG_DOWNLOAD, SVG_MENU, SVG_HEART, formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration } from './utils.js';
import { renderLyricsInFullscreen, clearFullscreenLyricsSync } from './lyrics.js';
import { recentActivityManager, backgroundSettings, trackListSettings } from './storage.js';
import { db } from './db.js';
@ -217,6 +218,74 @@ export class UIRenderer {
`;
}
createUserPlaylistCardHTML(playlist) {
return `
<div class="card user-playlist" data-playlist-id="${playlist.id}" data-href="#userplaylist/${playlist.id}" style="cursor: pointer;">
<div class="card-image-wrapper">
<img src="${playlist.cover || 'assets/appicon.png'}" alt="${playlist.name}" class="card-image" loading="lazy">
<button class="edit-playlist-btn" data-action="edit-playlist" title="Edit Playlist">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="delete-playlist-btn" data-action="delete-playlist" title="Delete Playlist">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
<button class="play-btn card-play-btn" data-action="play-card" data-type="user-playlist" data-id="${playlist.id}" title="Play">
${SVG_PLAY}
</button>
</div>
<h3 class="card-title">${playlist.name}</h3>
<p class="card-subtitle">${playlist.tracks ? playlist.tracks.length : 0} tracks</p>
</div>
`;
}
createAlbumCardHTML(album) {
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
let yearDisplay = '';
if (album.releaseDate) {
const date = new Date(album.releaseDate);
if (!isNaN(date.getTime())) {
yearDisplay = `${date.getFullYear()}`;
}
}
let typeLabel = '';
if (album.type === 'EP') {
typeLabel = ' • EP';
} else if (album.type === 'SINGLE') {
typeLabel = ' • Single';
} else if (!album.type && album.numberOfTracks) {
if (album.numberOfTracks <= 3) typeLabel = ' • Single';
else if (album.numberOfTracks <= 6) typeLabel = ' • EP';
}
return `
<div class="card" data-album-id="${album.id}" data-href="#album/${album.id}" style="cursor: pointer;">
<div class="card-image-wrapper">
<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>
<button class="play-btn card-play-btn" data-action="play-card" data-type="album" data-id="${album.id}" title="Play">
${SVG_PLAY}
</button>
</div>
<h3 class="card-title">${album.title} ${explicitBadge}</h3>
<p class="card-subtitle">${album.artist?.name ?? ''}</p>
<p class="card-subtitle">${yearDisplay}${typeLabel}</p>
</div>
`;
}
createArtistCardHTML(artist) {
return `
<div class="card artist" data-artist-id="${artist.id}" data-href="#artist/${artist.id}" style="cursor: pointer;">
@ -391,42 +460,78 @@ export class UIRenderer {
root.style.removeProperty('--track-hover-bg');
}
showFullscreenCover(track, nextTrack) {
if (!track) return;
const overlay = document.getElementById('fullscreen-cover-overlay');
const image = document.getElementById('fullscreen-cover-image');
const title = document.getElementById('fullscreen-track-title');
const artist = document.getElementById('fullscreen-track-artist');
const nextTrackEl = document.getElementById('fullscreen-next-track');
async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
if (!track) return;
const overlay = document.getElementById('fullscreen-cover-overlay');
const image = document.getElementById('fullscreen-cover-image');
const title = document.getElementById('fullscreen-track-title');
const artist = document.getElementById('fullscreen-track-artist');
const nextTrackEl = document.getElementById('fullscreen-next-track');
const lyricsContainer = document.getElementById('fullscreen-lyrics-container');
const lyricsToggleBtn = document.getElementById('toggle-fullscreen-lyrics-btn');
const coverUrl = this.api.getCoverUrl(track.album?.cover, '1280');
image.src = coverUrl;
title.textContent = track.title;
artist.textContent = track.artist?.name || 'Unknown Artist';
if (nextTrack) {
nextTrackEl.style.display = 'flex';
nextTrackEl.querySelector('.value').textContent = `${nextTrack.title}${nextTrack.artist?.name || 'Unknown'}`;
const coverUrl = this.api.getCoverUrl(track.album?.cover, '1280');
image.src = coverUrl;
title.textContent = track.title;
artist.textContent = track.artist?.name || 'Unknown Artist';
if (nextTrack) {
nextTrackEl.style.display = 'flex';
nextTrackEl.querySelector('.value').textContent = `${nextTrack.title}${nextTrack.artist?.name || 'Unknown'}`;
// Replay animation
nextTrackEl.classList.remove('animate-in');
void nextTrackEl.offsetWidth; // Trigger reflow
nextTrackEl.classList.add('animate-in');
} else {
nextTrackEl.style.display = 'none';
nextTrackEl.classList.remove('animate-in');
}
// Set the background image via CSS variable for the pseudo-element to use
overlay.style.setProperty('--bg-image', `url('${coverUrl}')`);
overlay.style.display = 'flex';
nextTrackEl.classList.remove('animate-in');
void nextTrackEl.offsetWidth;
nextTrackEl.classList.add('animate-in');
} else {
nextTrackEl.style.display = 'none';
nextTrackEl.classList.remove('animate-in');
}
overlay.style.setProperty('--bg-image', `url('${coverUrl}')`);
if (lyricsManager && audioPlayer) {
lyricsToggleBtn.style.display = 'flex';
lyricsContainer.style.display = 'none';
lyricsContainer.classList.remove('active');
lyricsToggleBtn.classList.remove('active');
const toggleLyrics = async () => {
const isActive = lyricsContainer.classList.contains('active');
if (isActive) {
lyricsContainer.classList.remove('active');
lyricsToggleBtn.classList.remove('active');
setTimeout(() => {
lyricsContainer.style.display = 'none';
clearFullscreenLyricsSync(lyricsContainer);
}, 300);
} else {
lyricsContainer.style.display = 'block';
setTimeout(() => lyricsContainer.classList.add('active'), 10);
lyricsToggleBtn.classList.add('active');
await renderLyricsInFullscreen(track, audioPlayer, lyricsManager, lyricsContainer);
}
};
const newToggleBtn = lyricsToggleBtn.cloneNode(true);
lyricsToggleBtn.parentNode.replaceChild(newToggleBtn, lyricsToggleBtn);
newToggleBtn.addEventListener('click', toggleLyrics);
} else {
lyricsToggleBtn.style.display = 'none';
}
overlay.style.display = 'flex';
}
closeFullscreenCover() {
document.getElementById('fullscreen-cover-overlay').style.display = 'none';
const overlay = document.getElementById('fullscreen-cover-overlay');
const lyricsContainer = document.getElementById('fullscreen-lyrics-container');
clearFullscreenLyricsSync(lyricsContainer);
lyricsContainer.style.display = 'none';
lyricsContainer.classList.remove('active');
lyricsContainer.innderHTML = '';
overlay.style.display = 'none';
}
showPage(pageId) {
@ -507,6 +612,14 @@ export class UIRenderer {
} else {
playlistsContainer.innerHTML = createPlaceholder('No liked playlists yet.');
}
const myPlaylistsContainer = document.getElementById('my-playlists-container');
const myPlaylists = await db.getPlaylists();
if (myPlaylists.length) {
myPlaylistsContainer.innerHTML = myPlaylists.map(p => this.createUserPlaylistCardHTML(p)).join('');
} else {
myPlaylistsContainer.innerHTML = createPlaceholder('No playlists yet. Create your first playlist!');
}
}
async renderHomePage() {
@ -863,50 +976,113 @@ export class UIRenderer {
`;
try {
const { playlist, tracks } = await this.api.getPlaylist(playlistId);
// Check if it's a user playlist
const userPlaylist = await db.getPlaylist(playlistId);
if (userPlaylist) {
// Render user playlist
imageEl.src = userPlaylist.cover || 'assets/appicon.png';
imageEl.style.backgroundColor = '';
const imageId = playlist.squareImage || playlist.image;
if (imageId) {
imageEl.src = this.api.getCoverUrl(imageId, '1080');
titleEl.textContent = userPlaylist.name;
this.adjustTitleFontSize(titleEl, userPlaylist.name);
const tracks = userPlaylist.tracks || [];
const totalDuration = calculateTotalDuration(tracks);
metaEl.textContent = `${tracks.length} tracks • ${formatDuration(totalDuration)}`;
descEl.textContent = '';
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
<span>Title</span>
<span class="duration-header">Duration</span>
</div>
`;
this.renderListWithTracks(tracklistContainer, tracks, true);
// Add remove buttons to tracks
const trackItems = tracklistContainer.querySelectorAll('.track-item');
trackItems.forEach((item, index) => {
const actionsDiv = item.querySelector('.track-item-actions');
const removeBtn = document.createElement('button');
removeBtn.className = 'track-action-btn remove-from-playlist-btn';
removeBtn.title = 'Remove from playlist';
removeBtn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
removeBtn.dataset.trackIndex = index;
actionsDiv.appendChild(removeBtn);
});
// Update header like button - hide for user playlists
const playlistLikeBtn = document.getElementById('like-playlist-btn');
if (playlistLikeBtn) {
playlistLikeBtn.style.display = 'none';
}
// Add edit and delete buttons
const actionsDiv = document.querySelector('.detail-header-actions');
const editBtn = document.createElement('button');
editBtn.id = 'edit-playlist-btn';
editBtn.className = 'btn-secondary';
editBtn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg><span>Edit</span>';
const deleteBtn = document.createElement('button');
deleteBtn.id = 'delete-playlist-btn';
deleteBtn.className = 'btn-secondary danger';
deleteBtn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg><span>Delete</span>';
actionsDiv.appendChild(editBtn);
actionsDiv.appendChild(deleteBtn);
recentActivityManager.addPlaylist({ title: userPlaylist.name, uuid: userPlaylist.id });
document.title = `${userPlaylist.name} - Monochrome`;
} else {
imageEl.src = 'assets/appicon.png';
// Render API playlist
const { playlist, tracks } = await this.api.getPlaylist(playlistId);
const imageId = playlist.squareImage || playlist.image;
if (imageId) {
imageEl.src = this.api.getCoverUrl(imageId, '1080');
} else {
imageEl.src = 'assets/appicon.png';
}
imageEl.style.backgroundColor = '';
titleEl.textContent = playlist.title;
this.adjustTitleFontSize(titleEl, playlist.title);
const totalDuration = calculateTotalDuration(tracks);
metaEl.textContent = `${playlist.numberOfTracks} tracks • ${formatDuration(totalDuration)}`;
descEl.textContent = playlist.description || '';
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
<span>Title</span>
<span class="duration-header">Duration</span>
</div>
`;
this.renderListWithTracks(tracklistContainer, tracks, true);
// 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);
playlistLikeBtn.style.display = 'flex';
}
// 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`;
}
imageEl.style.backgroundColor = '';
titleEl.textContent = playlist.title;
this.adjustTitleFontSize(titleEl, playlist.title);
const totalDuration = calculateTotalDuration(tracks);
metaEl.textContent = `${playlist.numberOfTracks} tracks • ${formatDuration(totalDuration)}`;
descEl.textContent = playlist.description || '';
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
<span>Title</span>
<span class="duration-header">Duration</span>
</div>
`;
this.renderListWithTracks(tracklistContainer, tracks, true);
// 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`;
} catch (error) {
console.error("Failed to load playlist:", error);
tracklistContainer.innerHTML = createPlaceholder(`Could not load playlist details. ${error.message}`);

View file

@ -3298,4 +3298,143 @@ img:not([src]), img[src=''] {
align-items: center;
width: 80%;
max-width: 100%;
}
.fullscreen-cover-content {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
position: relative;
}
.fullscreen-main-view {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
transition: flex 0.3s ease;
}
.fullscreen-lyrics-container {
position: absolute;
right: 0;
top: 0;
width: 0;
height: 100%;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(20px);
border-left: 1px solid rgba(255, 255, 255, 0.1);
overflow-y: auto;
overflow-x: hidden;
transition: width 0.3s ease;
padding: 0;
}
.fullscreen-lyrics-container.active {
width: 400px;
padding: 2rem;
}
.fullscreen-lyrics-container > * {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 2rem;
box-sizing: border-box;
}
.fullscreen-lyrics-container::-webkit-scrollbar {
width: 8px;
}
.fullscreen-lyrics-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.fullscreen-lyrics-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.fullscreen-lyrics-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
.fullscreen-lyrics-toggle {
position: absolute;
top: 1rem;
right: 4rem;
background: rgba(0, 0, 0, 0.5);
border: none;
color: white;
padding: 0.75rem;
border-radius: 50%;
cursor: pointer;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.fullscreen-lyrics-toggle:hover {
background: rgba(0, 0, 0, 0.7);
transform: scale(1.1);
}
.fullscreen-lyrics-toggle.active {
background: var(--primary);
}
@media (max-width: 768px) {
.fullscreen-cover-content {
flex-direction: column;
}
.fullscreen-lyrics-container {
top: auto;
bottom: 0;
left: 0;
width: 100%;
height: 0;
border-left: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
transition: height 0.3s ease;
}
.fullscreen-lyrics-container.active {
width: 50vh;
width: 100%;
}
.fullscreen-lyrics-toggle {
right: 3.5rem;
}
}
#playlist-modal {
opacity: 1;
animation-name: fadeInOpacity;
animation-iteration-count: 1;
animation-timing-function: ease-in;
animation-duration: 0.1s;
}
@keyframes fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}