Playlist Feature, Lyrics on Fullscreen & More
This commit is contained in:
parent
c33ef02dca
commit
a27be3162d
9 changed files with 814 additions and 87 deletions
39
index.html
39
index.html
|
|
@ -23,6 +23,7 @@
|
||||||
<div id="context-menu">
|
<div id="context-menu">
|
||||||
<ul>
|
<ul>
|
||||||
<li data-action="toggle-like">Like</li>
|
<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="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>
|
||||||
|
|
@ -41,9 +42,18 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="fullscreen-cover-overlay" style="display: none;">
|
<div id="fullscreen-cover-overlay" style="display: none;">
|
||||||
<div class="fullscreen-cover-content">
|
<div class="fullscreen-cover-content">
|
||||||
<button id="close-fullscreen-cover-btn" title="Close">×</button>
|
<button id="close-fullscreen-cover-btn" title="Close">×</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">
|
<img id="fullscreen-cover-image" src="" alt="Album Cover">
|
||||||
<div class="fullscreen-track-info">
|
<div class="fullscreen-track-info">
|
||||||
<h2 id="fullscreen-track-title"></h2>
|
<h2 id="fullscreen-track-title"></h2>
|
||||||
|
|
@ -53,13 +63,26 @@
|
||||||
<span class="value"></span>
|
<span class="value"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fullscreen-controls">
|
</div>
|
||||||
<!-- Controls will be cloned or managed here if needed, or we just rely on main controls -->
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div id="sidebar-overlay"></div>
|
<div id="sidebar-overlay"></div>
|
||||||
|
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
|
|
@ -177,6 +200,10 @@
|
||||||
|
|
||||||
<div id="page-library" class="page">
|
<div id="page-library" class="page">
|
||||||
<h2 class="section-title">Your Library</h2>
|
<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">
|
<div class="search-tabs">
|
||||||
<button class="search-tab active" data-tab="tracks">Liked Tracks</button>
|
<button class="search-tab active" data-tab="tracks">Liked Tracks</button>
|
||||||
<button class="search-tab" data-tab="albums">Albums</button>
|
<button class="search-tab" data-tab="albums">Albums</button>
|
||||||
|
|
|
||||||
122
js/app.js
122
js/app.js
|
|
@ -5,7 +5,7 @@ import { apiSettings, themeManager, nowPlayingSettings, trackListSettings } from
|
||||||
import { UIRenderer } from './ui.js';
|
import { UIRenderer } from './ui.js';
|
||||||
import { Player } from './player.js';
|
import { Player } from './player.js';
|
||||||
import { LastFMScrobbler } from './lastfm.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 { createRouter, updateTabTitle } from './router.js';
|
||||||
import { initializeSettings } from './settings.js';
|
import { initializeSettings } from './settings.js';
|
||||||
import { initializePlayerEvents, initializeTrackInteractions, handleTrackAction } from './events.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 { downloadAlbumAsZip, downloadDiscography, downloadPlaylistAsZip } from './downloads.js';
|
||||||
import { debounce, SVG_PLAY } from './utils.js';
|
import { debounce, SVG_PLAY } from './utils.js';
|
||||||
import { sidePanelManager } from './side-panel.js';
|
import { sidePanelManager } from './side-panel.js';
|
||||||
|
import { db } from './db.js';
|
||||||
|
import { syncManager } from './firebase/sync.js';
|
||||||
|
|
||||||
function initializeCasting(audioPlayer, castBtn) {
|
function initializeCasting(audioPlayer, castBtn) {
|
||||||
if (!castBtn) return;
|
if (!castBtn) return;
|
||||||
|
|
@ -229,7 +231,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
ui.closeFullscreenCover();
|
ui.closeFullscreenCover();
|
||||||
} else {
|
} else {
|
||||||
const nextTrack = player.getNextTrack();
|
const nextTrack = player.getNextTrack();
|
||||||
ui.showFullscreenCover(player.currentTrack, nextTrack);
|
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Default to 'album' mode - navigate to album
|
// Default to 'album' mode - navigate to album
|
||||||
|
|
@ -292,7 +294,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const fullscreenOverlay = document.getElementById('fullscreen-cover-overlay');
|
const fullscreenOverlay = document.getElementById('fullscreen-cover-overlay');
|
||||||
if (fullscreenOverlay && getComputedStyle(fullscreenOverlay).display !== 'none') {
|
if (fullscreenOverlay && getComputedStyle(fullscreenOverlay).display !== 'none') {
|
||||||
const nextTrack = player.getNextTrack();
|
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;
|
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')) {
|
if (e.target.closest('#play-playlist-btn')) {
|
||||||
const btn = e.target.closest('#play-playlist-btn');
|
const btn = e.target.closest('#play-playlist-btn');
|
||||||
if (btn.disabled) return;
|
if (btn.disabled) return;
|
||||||
|
|
@ -346,7 +453,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
if (!playlistId) return;
|
if (!playlistId) return;
|
||||||
|
|
||||||
try {
|
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) {
|
if (tracks.length > 0) {
|
||||||
player.setQueue(tracks, 0);
|
player.setQueue(tracks, 0);
|
||||||
document.getElementById('shuffle-btn').classList.remove('active');
|
document.getElementById('shuffle-btn').classList.remove('active');
|
||||||
|
|
|
||||||
77
js/db.js
77
js/db.js
|
|
@ -1,7 +1,7 @@
|
||||||
export class MusicDatabase {
|
export class MusicDatabase {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.dbName = 'MonochromeDB';
|
this.dbName = 'MonochromeDB';
|
||||||
this.version = 3;
|
this.version = 4;
|
||||||
this.db = null;
|
this.db = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,6 +45,10 @@ export class MusicDatabase {
|
||||||
const store = db.createObjectStore('history_tracks', { keyPath: 'timestamp' });
|
const store = db.createObjectStore('history_tracks', { keyPath: 'timestamp' });
|
||||||
store.createIndex('timestamp', 'timestamp', { unique: true });
|
store.createIndex('timestamp', 'timestamp', { unique: true });
|
||||||
}
|
}
|
||||||
|
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 playlists = await this.getFavorites('playlist');
|
||||||
const history = await this.getHistory();
|
const history = await this.getHistory();
|
||||||
|
|
||||||
|
const userPlaylists = await this.getPlaylists();
|
||||||
const data = {
|
const data = {
|
||||||
favorites_tracks: tracks.map(t => this._minifyItem('track', t)),
|
favorites_tracks: tracks.map(t => this._minifyItem('track', t)),
|
||||||
favorites_albums: albums.map(a => this._minifyItem('album', a)),
|
favorites_albums: albums.map(a => this._minifyItem('album', a)),
|
||||||
favorites_artists: artists.map(a => this._minifyItem('artist', a)),
|
favorites_artists: artists.map(a => this._minifyItem('artist', a)),
|
||||||
favorites_playlists: playlists.map(p => this._minifyItem('playlist', p)),
|
favorites_playlists: playlists.map(p => this._minifyItem('playlist', p)),
|
||||||
history_tracks: history.map(t => this._minifyItem('track', t))
|
history_tracks: history.map(t => this._minifyItem('track', t)),
|
||||||
|
user_playlists: userPlaylists
|
||||||
};
|
};
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
@ -255,6 +261,73 @@ export class MusicDatabase {
|
||||||
await importStore('favorites_artists', data.favorites_artists);
|
await importStore('favorites_artists', data.favorites_artists);
|
||||||
await importStore('favorites_playlists', data.favorites_playlists);
|
await importStore('favorites_playlists', data.favorites_playlists);
|
||||||
await importStore('history_tracks', data.history_tracks);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
46
js/events.js
46
js/events.js
|
|
@ -336,6 +336,9 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
|
||||||
} else if (type === 'playlist') {
|
} else if (type === 'playlist') {
|
||||||
const data = await api.getPlaylist(item.uuid);
|
const data = await api.getPlaylist(item.uuid);
|
||||||
tracks = data.tracks;
|
tracks = data.tracks;
|
||||||
|
} else if (type === 'user-playlist') {
|
||||||
|
const playlist = await db.getPlaylist(item.id);
|
||||||
|
tracks = playlist ? playlist.tracks : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tracks.length > 0) {
|
if (tracks.length > 0) {
|
||||||
|
|
@ -343,7 +346,8 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
|
||||||
const shuffleBtn = document.getElementById('shuffle-btn');
|
const shuffleBtn = document.getElementById('shuffle-btn');
|
||||||
if (shuffleBtn) shuffleBtn.classList.remove('active');
|
if (shuffleBtn) shuffleBtn.classList.remove('active');
|
||||||
player.playAtIndex(0);
|
player.playAtIndex(0);
|
||||||
showNotification(`Playing ${type}: ${item.title}`);
|
const name = type === 'user-playlist' ? item.name : item.title;
|
||||||
|
showNotification(`Playing ${type.replace('user-', '')}: ${name}`);
|
||||||
} else {
|
} else {
|
||||||
showNotification(`No tracks found in this ${type}`);
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,8 @@ export class SyncManager {
|
||||||
favorites_albums: mergedData.library?.albums ? Object.values(mergedData.library.albums) : [],
|
favorites_albums: mergedData.library?.albums ? Object.values(mergedData.library.albums) : [],
|
||||||
favorites_artists: mergedData.library?.artists ? Object.values(mergedData.library.artists) : [],
|
favorites_artists: mergedData.library?.artists ? Object.values(mergedData.library.artists) : [],
|
||||||
favorites_playlists: mergedData.library?.playlists ? Object.values(mergedData.library.playlists) : [],
|
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);
|
await db.importData(importData, true);
|
||||||
|
|
@ -119,6 +120,7 @@ export class SyncManager {
|
||||||
history: {
|
history: {
|
||||||
recentTracks: this.arrayToObject(mergeStores(local.history_tracks, cloud.history?.recentTracks, 'timestamp'), 'timestamp')
|
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)
|
// Settings are NOT synced (device specific)
|
||||||
lastUpdated: Date.now()
|
lastUpdated: Date.now()
|
||||||
};
|
};
|
||||||
|
|
@ -181,6 +183,26 @@ export class SyncManager {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.unsubscribeFunctions.push(() => off(historyRef, 'value', unsubHistory));
|
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 ---
|
// --- 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() {
|
async clearCloudData() {
|
||||||
if (!this.user || !this.userRef) {
|
if (!this.user || !this.userRef) {
|
||||||
throw new Error("Not authenticated");
|
throw new Error("Not authenticated");
|
||||||
|
|
|
||||||
115
js/lyrics.js
115
js/lyrics.js
|
|
@ -312,3 +312,118 @@ export function clearLyricsPanelSync(audioPlayer, panel) {
|
||||||
panel.lyricsCleanup = null;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,9 @@ export function createRouter(ui) {
|
||||||
case 'playlist':
|
case 'playlist':
|
||||||
ui.renderPlaylistPage(param);
|
ui.renderPlaylistPage(param);
|
||||||
break;
|
break;
|
||||||
|
case 'userplaylist':
|
||||||
|
ui.renderPlaylistPage(param);
|
||||||
|
break;
|
||||||
case 'library':
|
case 'library':
|
||||||
ui.renderLibraryPage();
|
ui.renderLibraryPage();
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
318
js/ui.js
318
js/ui.js
|
|
@ -1,5 +1,6 @@
|
||||||
//js/ui.js
|
//js/ui.js
|
||||||
import { SVG_PLAY, SVG_DOWNLOAD, SVG_MENU, SVG_HEART, 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 { renderLyricsInFullscreen, clearFullscreenLyricsSync } from './lyrics.js';
|
||||||
import { recentActivityManager, backgroundSettings, trackListSettings } from './storage.js';
|
import { recentActivityManager, backgroundSettings, trackListSettings } from './storage.js';
|
||||||
import { db } from './db.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) {
|
createArtistCardHTML(artist) {
|
||||||
return `
|
return `
|
||||||
<div class="card artist" data-artist-id="${artist.id}" data-href="#artist/${artist.id}" style="cursor: pointer;">
|
<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');
|
root.style.removeProperty('--track-hover-bg');
|
||||||
}
|
}
|
||||||
|
|
||||||
showFullscreenCover(track, nextTrack) {
|
async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
if (!track) return;
|
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 overlay = document.getElementById('fullscreen-cover-overlay');
|
const coverUrl = this.api.getCoverUrl(track.album?.cover, '1280');
|
||||||
const image = document.getElementById('fullscreen-cover-image');
|
image.src = coverUrl;
|
||||||
const title = document.getElementById('fullscreen-track-title');
|
title.textContent = track.title;
|
||||||
const artist = document.getElementById('fullscreen-track-artist');
|
artist.textContent = track.artist?.name || 'Unknown Artist';
|
||||||
const nextTrackEl = document.getElementById('fullscreen-next-track');
|
|
||||||
|
|
||||||
const coverUrl = this.api.getCoverUrl(track.album?.cover, '1280');
|
if (nextTrack) {
|
||||||
|
nextTrackEl.style.display = 'flex';
|
||||||
|
nextTrackEl.querySelector('.value').textContent = `${nextTrack.title} • ${nextTrack.artist?.name || 'Unknown'}`;
|
||||||
|
|
||||||
image.src = coverUrl;
|
nextTrackEl.classList.remove('animate-in');
|
||||||
title.textContent = track.title;
|
void nextTrackEl.offsetWidth;
|
||||||
artist.textContent = track.artist?.name || 'Unknown Artist';
|
nextTrackEl.classList.add('animate-in');
|
||||||
|
} else {
|
||||||
if (nextTrack) {
|
nextTrackEl.style.display = 'none';
|
||||||
nextTrackEl.style.display = 'flex';
|
nextTrackEl.classList.remove('animate-in');
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
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) {
|
showPage(pageId) {
|
||||||
|
|
@ -507,6 +612,14 @@ export class UIRenderer {
|
||||||
} else {
|
} else {
|
||||||
playlistsContainer.innerHTML = createPlaceholder('No liked playlists yet.');
|
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() {
|
async renderHomePage() {
|
||||||
|
|
@ -863,50 +976,113 @@ export class UIRenderer {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
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;
|
titleEl.textContent = userPlaylist.name;
|
||||||
if (imageId) {
|
this.adjustTitleFontSize(titleEl, userPlaylist.name);
|
||||||
imageEl.src = this.api.getCoverUrl(imageId, '1080');
|
|
||||||
|
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 {
|
} 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) {
|
} catch (error) {
|
||||||
console.error("Failed to load playlist:", error);
|
console.error("Failed to load playlist:", error);
|
||||||
tracklistContainer.innerHTML = createPlaceholder(`Could not load playlist details. ${error.message}`);
|
tracklistContainer.innerHTML = createPlaceholder(`Could not load playlist details. ${error.message}`);
|
||||||
|
|
|
||||||
139
styles.css
139
styles.css
|
|
@ -3299,3 +3299,142 @@ img:not([src]), img[src=''] {
|
||||||
width: 80%;
|
width: 80%;
|
||||||
max-width: 100%;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue