IMP: card three dot menu

This commit is contained in:
Julien Maille 2026-01-25 21:43:09 +01:00
parent d5489edf01
commit a6736d571f
11 changed files with 2192 additions and 2792 deletions

View file

@ -51,7 +51,7 @@ class ServerAPI {
try {
const response = await this.fetchWithRetry(`/album/${id}`);
return await response.json();
} catch (e) {
} catch {
const response = await this.fetchWithRetry(`/album?id=${id}`);
return await response.json();
}

View file

@ -51,7 +51,7 @@ class ServerAPI {
try {
const response = await this.fetchWithRetry(`/artist/${id}`);
return await response.json();
} catch (e) {
} catch {
const response = await this.fetchWithRetry(`/artist?id=${id}`);
return await response.json();
}

View file

@ -51,7 +51,7 @@ class ServerAPI {
try {
const response = await this.fetchWithRetry(`/playlist/${id}`);
return await response.json();
} catch (e) {
} catch {
// Fallback to query param style
const response = await this.fetchWithRetry(`/playlist?id=${id}`);
return await response.json();

4577
index.html

File diff suppressed because one or more lines are too long

View file

@ -334,7 +334,7 @@ document.addEventListener('DOMContentLoaded', async () => {
initializeUIInteractions(player, api, ui);
initializeKeyboardShortcuts(player, audioPlayer);
initTracker(player, ui);
initTracker(player);
const castBtn = document.getElementById('cast-btn');
initializeCasting(audioPlayer, castBtn);

View file

@ -10,7 +10,12 @@ import {
SVG_BIN,
} from './utils.js';
import { lastFMStorage, waveformSettings } from './storage.js';
import { showNotification, downloadTrackWithMetadata } from './downloads.js';
import {
showNotification,
downloadTrackWithMetadata,
downloadAlbumAsZip,
downloadPlaylistAsZip,
} from './downloads.js';
import { downloadQualitySettings } from './storage.js';
import { updateTabTitle, navigate } from './router.js';
import { db } from './db.js';
@ -116,6 +121,12 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
audioPlayer.addEventListener('error', (e) => {
console.error('Audio playback error:', e);
playPauseBtn.innerHTML = SVG_PLAY;
// Skip to next track on error to prevent queue stalling
if (player.currentTrack) {
console.warn('Skipping to next track due to playback error');
setTimeout(() => player.playNext(), 1000); // Small delay to avoid rapid skipping
}
});
playPauseBtn.addEventListener('click', () => player.handlePlayPause());
@ -548,6 +559,123 @@ export async function handleTrackAction(
return;
}
if (action === 'track-mix' && type === 'track') {
if (item.mixes && item.mixes.TRACK_MIX) {
navigate(`/mix/${item.mixes.TRACK_MIX}`);
}
return;
}
// Collection Actions (Album, Playlist, Mix)
const isCollection = ['album', 'playlist', 'user-playlist', 'mix'].includes(type);
const collectionActions = [
'play-card',
'shuffle-play-card',
'add-to-queue',
'play-next',
'download',
'start-mix',
];
if (isCollection && collectionActions.includes(action)) {
try {
let tracks = [];
let collectionItem = item;
if (type === 'album') {
const data = await api.getAlbum(item.id);
tracks = data.tracks;
collectionItem = data.album || item;
} else if (type === 'playlist') {
const data = await api.getPlaylist(item.uuid);
tracks = data.tracks;
collectionItem = data.playlist || item;
} else if (type === 'user-playlist') {
let playlist = await db.getPlaylist(item.id);
if (!playlist) {
try {
playlist = await syncManager.getPublicPlaylist(item.id);
} catch {
/* ignore */
}
}
tracks = playlist ? playlist.tracks : item.tracks || [];
collectionItem = playlist || item;
} else if (type === 'mix') {
const data = await api.getMix(item.id);
tracks = data.tracks;
collectionItem = data.mix || item;
}
if (tracks.length === 0 && action !== 'start-mix') {
showNotification(`No tracks found in this ${type}`);
return;
}
if (action === 'download') {
if (type === 'album') {
await downloadAlbumAsZip(collectionItem, tracks, api, downloadQualitySettings.getQuality(), lyricsManager);
} else {
await downloadPlaylistAsZip(collectionItem, tracks, api, downloadQualitySettings.getQuality(), lyricsManager);
}
return;
}
if (action === 'add-to-queue') {
player.addToQueue(tracks);
if (window.renderQueueFunction) window.renderQueueFunction();
showNotification(`Added ${tracks.length} tracks to queue`);
return;
}
if (action === 'play-next') {
player.addNextToQueue(tracks);
if (window.renderQueueFunction) window.renderQueueFunction();
showNotification(`Playing next: ${tracks.length} tracks`);
return;
}
if (action === 'start-mix') {
if (type === 'album' && collectionItem.artist?.id) {
const artistData = await api.getArtist(collectionItem.artist.id);
if (artistData.mixes?.ARTIST_MIX) {
navigate(`/mix/${artistData.mixes.ARTIST_MIX}`);
return;
}
}
// Fallback to item's own page or first track's mix
if (tracks.length > 0 && tracks[0].mixes?.TRACK_MIX) {
navigate(`/mix/${tracks[0].mixes.TRACK_MIX}`);
} else {
navigate(`/${type.replace('user-', '')}/${item.id || item.uuid}`);
}
return;
}
// play-card and shuffle-play-card
if (action === 'shuffle-play-card') {
player.shuffleActive = true;
const tracksToShuffle = [...tracks];
tracksToShuffle.sort(() => Math.random() - 0.5);
player.setQueue(tracksToShuffle, 0);
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.add('active');
} else {
player.setQueue(tracks, 0);
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active');
}
player.playAtIndex(0);
const name = type === 'user-playlist' ? collectionItem.name : collectionItem.title;
showNotification(`Playing ${type.replace('user-', '')}: ${name}`);
} catch (error) {
console.error('Failed to handle collection action:', error);
showNotification(`Failed to process ${type} action`);
}
return;
}
// Individual Track Actions
if (action === 'add-to-queue') {
player.addToQueue(item);
if (window.renderQueueFunction) window.renderQueueFunction();
@ -556,53 +684,20 @@ export async function handleTrackAction(
player.addNextToQueue(item);
if (window.renderQueueFunction) window.renderQueueFunction();
showNotification(`Playing next: ${item.title}`);
} else if (action === 'track-mix') {
if (item.mixes && item.mixes.TRACK_MIX) {
navigate(`/mix/${item.mixes.TRACK_MIX}`);
}
} else if (action === 'play-card') {
try {
let tracks = [];
if (type === 'album') {
const data = await api.getAlbum(item.id);
tracks = data.tracks;
} else if (type === 'playlist') {
const data = await api.getPlaylist(item.uuid);
tracks = data.tracks;
} else if (type === 'user-playlist') {
let playlist = await db.getPlaylist(item.id);
if (!playlist) {
try {
playlist = await syncManager.getPublicPlaylist(item.id);
} catch {
// Ignore
}
}
tracks = playlist ? playlist.tracks : item.tracks || [];
if (playlist) item.name = playlist.name;
} else if (type === 'mix') {
const data = await api.getMix(item.id);
tracks = data.tracks;
if (data.mix) item.title = data.mix.title;
}
if (tracks.length > 0) {
player.setQueue(tracks, 0);
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active');
player.playAtIndex(0);
const name = type === 'user-playlist' ? item.name : item.title;
showNotification(`Playing ${type.replace('user-', '')}: ${name}`);
} else {
showNotification(`No tracks found in this ${type}`);
}
} catch (error) {
console.error('Failed to play card:', error);
showNotification(`Failed to play ${type}`);
player.setQueue([item], 0);
player.playAtIndex(0);
showNotification(`Playing track: ${item.title}`);
} else if (action === 'start-mix') {
if (item.mixes?.TRACK_MIX) {
navigate(`/mix/${item.mixes.TRACK_MIX}`);
} else {
showNotification('No mix available for this track');
}
} else if (action === 'download') {
await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager);
} else if (action === 'toggle-like') {
}
else if (action === 'toggle-like') {
const added = await db.toggleFavorite(type, item);
syncManager.syncLibraryItem(type, item, added);
@ -714,10 +809,9 @@ export async function handleTrackAction(
return `
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
<span>${p.name}</span>
${
alreadyContains
? `<button class="remove-from-playlist-btn-modal" title="Remove from playlist" style="background: transparent; border: none; color: inherit; cursor: pointer; padding: 4px; display: flex; align-items: center;">${SVG_BIN}</button>`
: ''
${alreadyContains
? `<button class="remove-from-playlist-btn-modal" title="Remove from playlist" style="background: transparent; border: none; color: inherit; cursor: pointer; padding: 4px; display: flex; align-items: center;">${SVG_BIN}</button>`
: ''
}
</div>
`;
@ -802,6 +896,25 @@ async function updateContextMenuLikeState(contextMenu, contextTrack) {
const hasMix = contextTrack.mixes && contextTrack.mixes.TRACK_MIX;
trackMixItem.style.display = hasMix ? 'block' : 'none';
}
// Filter items based on type
const type = contextMenu._contextType || 'track';
contextMenu.querySelectorAll('li[data-action]').forEach((item) => {
const filter = item.dataset.typeFilter;
if (filter) {
const types = filter.split(',');
item.style.display = types.includes(type) ? 'block' : 'none';
} else {
item.style.display = 'block';
}
// Update labels for Like/Save
if (item.dataset.action === 'toggle-like') {
const labelKey = `label${type.charAt(0).toUpperCase() + type.slice(1).replace('User-playlist', 'Playlist')}`;
const label = item.dataset[labelKey] || item.dataset.labelTrack || 'Like';
item.textContent = label;
}
});
}
export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager, ui, scrobbler) {
@ -850,6 +963,30 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
return;
}
const cardMenuBtn = e.target.closest('.card-menu-btn');
if (cardMenuBtn) {
e.stopPropagation();
const card = cardMenuBtn.closest('.card');
const type = cardMenuBtn.dataset.type;
const id = cardMenuBtn.dataset.id;
let item = card ? trackDataStore.get(card) : null;
if (!item) {
// Fallback: create a shell item
item = { id, uuid: id, title: card.querySelector('.card-title')?.textContent || 'Item' };
}
contextTrack = item;
contextMenu._contextTrack = item;
contextMenu._contextType = type;
await updateContextMenuLikeState(contextMenu, item);
const rect = cardMenuBtn.getBoundingClientRect();
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
return;
}
const menuBtn = e.target.closest('.track-menu-btn');
if (menuBtn) {
e.stopPropagation();
@ -940,12 +1077,48 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
});
contextMenu._contextTrack = contextTrack;
contextMenu._contextType = 'track';
await updateContextMenuLikeState(contextMenu, contextTrack);
positionMenu(contextMenu, e.pageX, e.pageY);
}
}
});
mainContent.addEventListener('contextmenu', async (e) => {
const trackItem = e.target.closest('.track-item, .queue-track-item');
const card = e.target.closest('.card');
if (trackItem) {
e.preventDefault();
if (trackItem.classList.contains('queue-track-item')) {
const queueIndex = parseInt(trackItem.dataset.queueIndex);
contextTrack = player.getCurrentQueue()[queueIndex];
} else {
contextTrack = trackDataStore.get(trackItem);
}
if (contextTrack) {
if (contextTrack.isLocal) return;
contextMenu._contextTrack = contextTrack;
contextMenu._contextType = 'track';
await updateContextMenuLikeState(contextMenu, contextTrack);
positionMenu(contextMenu, e.pageX, e.pageY);
}
} else if (card) {
e.preventDefault();
const type = card.dataset.albumId ? 'album' : card.dataset.playlistId ? 'playlist' : card.dataset.mixId ? 'mix' : card.dataset.href ? card.dataset.href.split('/')[1] : 'item';
const id = card.dataset.albumId || card.dataset.playlistId || card.dataset.mixId;
const item = trackDataStore.get(card) || { id, uuid: id, title: card.querySelector('.card-title')?.textContent };
contextTrack = item;
contextMenu._contextTrack = item;
contextMenu._contextType = type.replace('userplaylist', 'user-playlist');
await updateContextMenuLikeState(contextMenu, item);
positionMenu(contextMenu, e.pageX, e.pageY);
}
});
document.addEventListener('click', () => {
contextMenu.style.display = 'none';
});
@ -957,10 +1130,12 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
const action = target.dataset.action;
const track = contextMenu._contextTrack || contextTrack;
const type = contextMenu._contextType || 'track';
if (action && track) {
await handleTrackAction(action, track, player, api, lyricsManager, 'track', ui, scrobbler);
await handleTrackAction(action, track, player, api, lyricsManager, type, ui, scrobbler);
}
contextMenu.style.display = 'none';
contextMenu._contextType = null;
});
// Now playing bar interactions

View file

@ -1,4 +1,4 @@
import { getExtensionForQuality, getCoverBlob } from './utils.js';
import { getCoverBlob } from './utils.js';
const VENDOR_STRING = 'Monochrome';
const DEFAULT_TITLE = 'Unknown Title';
@ -14,8 +14,6 @@ const DEFAULT_ALBUM = 'Unknown Album';
* @returns {Promise<Blob>} - Audio blob with embedded metadata
*/
export async function addMetadataToAudio(audioBlob, track, api, quality) {
const extension = getExtensionForQuality(quality);
if (quality === 'HI_RES_LOSSLESS') {
return await addFlacMetadata(audioBlob, track, api);
}

View file

@ -244,7 +244,7 @@ export class Player {
// Warm connection/cache
// For Blob URLs (DASH), this head request is not needed and can cause errors.
if (!streamUrl.startsWith('blob:')) {
fetch(streamUrl, { method: 'HEAD', signal: this.preloadAbortController.signal }).catch(() => {});
fetch(streamUrl, { method: 'HEAD', signal: this.preloadAbortController.signal }).catch(() => { });
}
} catch (error) {
if (error.name !== 'AbortError') {
@ -254,7 +254,7 @@ export class Player {
}
}
async playTrackFromQueue(startTime = 0) {
async playTrackFromQueue(startTime = 0, recursiveCount = 0) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
return;
@ -306,6 +306,8 @@ export class Player {
document.title = `${trackTitle}${getTrackArtists(track)}`;
this.updatePlayingTrackIndicator();
this.updateMediaSession(track);
this.updateMediaSessionPlaybackState();
try {
let streamUrl;
@ -417,12 +419,13 @@ export class Player {
}
}
// Update Media Session AFTER play starts to ensure metadata is captured
this.updateMediaSession(track);
this.updateMediaSessionPlaybackState();
this.preloadNextTracks();
} catch (error) {
console.error(`Could not play track: ${trackTitle}`, error);
// Skip to next track on unexpected error
if (recursiveCount < currentQueue.length) {
setTimeout(() => this.playNext(recursiveCount + 1), 1000);
}
}
}
@ -430,7 +433,7 @@ export class Player {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (index >= 0 && index < currentQueue.length) {
this.currentQueueIndex = index;
this.playTrackFromQueue();
this.playTrackFromQueue(0, 0);
}
}
@ -445,7 +448,7 @@ export class Player {
}
if (this.repeatMode === REPEAT_MODE.ONE && !currentQueue[this.currentQueueIndex]?.isUnavailable) {
this.playTrackFromQueue();
this.playTrackFromQueue(0, recursiveCount);
return;
}
@ -465,7 +468,7 @@ export class Player {
return;
}
this.playTrackFromQueue();
this.playTrackFromQueue(0, recursiveCount);
}
playPrev(recursiveCount = 0) {
@ -486,14 +489,14 @@ export class Player {
if (currentQueue[this.currentQueueIndex].isUnavailable) {
return this.playPrev(recursiveCount + 1);
}
this.playTrackFromQueue();
this.playTrackFromQueue(0, recursiveCount);
}
}
handlePlayPause() {
if (!this.audio.src || this.audio.error) {
if (this.currentTrack) {
this.playTrackFromQueue();
this.playTrackFromQueue(0, 0);
}
return;
}
@ -503,7 +506,7 @@ export class Player {
if (e.name === 'NotAllowedError' || e.name === 'AbortError') return;
console.error('Play failed, reloading track:', e);
if (this.currentTrack) {
this.playTrackFromQueue();
this.playTrackFromQueue(0, 0);
}
});
} else {
@ -571,30 +574,29 @@ export class Player {
this.saveQueueState();
}
addToQueue(track) {
this.queue.push(track);
addToQueue(trackOrTracks) {
const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks];
this.queue.push(...tracks);
if (!this.currentTrack || this.currentQueueIndex === -1) {
this.currentQueueIndex = this.queue.length - 1;
this.playTrackFromQueue();
this.currentQueueIndex = this.queue.length - tracks.length;
this.playTrackFromQueue(0, 0);
}
this.saveQueueState();
}
addNextToQueue(track) {
addNextToQueue(trackOrTracks) {
const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks];
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
const insertIndex = this.currentQueueIndex + 1;
// Insert after current track
currentQueue.splice(insertIndex, 0, track);
currentQueue.splice(insertIndex, 0, ...tracks);
// If we are shuffling, we might want to also add it to the original queue for consistency,
// though syncing that is tricky. The standard logic often just appends to the active queue view.
if (this.shuffleActive) {
this.originalQueueBeforeShuffle.push(track); // Just append to end of main list? Or logic needed.
// Simplest is to just modify the active playing queue.
} else {
// In linear mode, `currentQueue` IS `this.queue`
this.originalQueueBeforeShuffle.push(...tracks); // Sync original queue
}
this.saveQueueState();

View file

@ -3,7 +3,6 @@ import { escapeHtml, SVG_DOWNLOAD } from './utils.js';
let artistsData = [];
let globalPlayer = null;
let globalUi = null;
async function loadArtistsData() {
try {
@ -17,7 +16,7 @@ async function loadArtistsData() {
.map((line) => {
try {
return JSON.parse(line);
} catch (e) {
} catch {
return null;
}
})
@ -487,9 +486,8 @@ function showEraSongs(era, artistName) {
closeBtn.onclick = closeModal;
}
export async function initTracker(player, ui) {
export async function initTracker(player) {
globalPlayer = player;
globalUi = ui;
await loadArtistsData();
const checkAndRenderTracker = async () => {

View file

@ -302,16 +302,17 @@ export class UIRenderer {
<button class="play-btn card-play-btn" data-action="play-card" data-type="${type}" data-id="${id}" title="Play">
${SVG_PLAY}
</button>
<button class="card-menu-btn" data-action="card-menu" data-type="${type}" data-id="${id}" title="Menu">
${SVG_MENU}
</button>
`
: '';
const cardContent =
type === 'artist'
? `<h4 class="card-title">${title}</h4>`
: `<div class="card-info">
<h4 class="card-title">${title}</h4>
<p class="card-subtitle">${subtitle}</p>
</div>`;
const cardContent = `
<div class="card-info">
<h4 class="card-title">${title}</h4>
${subtitle ? `<p class="card-subtitle">${subtitle}</p>` : ''}
</div>`;
// In compact mode, move the play button outside the wrapper to position it on the right side of the card
const buttonsInWrapper = !isCompact ? playBtnHTML : '';
@ -1532,10 +1533,10 @@ export class UIRenderer {
dateDisplay =
window.innerWidth > 768
? releaseDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
year: 'numeric',
month: 'long',
day: 'numeric',
})
: year;
}
}
@ -2233,6 +2234,14 @@ export class UIRenderer {
if (similar && similar.length > 0) {
similarContainer.innerHTML = similar.map((a) => this.createArtistCardHTML(a)).join('');
similarSection.style.display = 'block';
similar.forEach((a) => {
const el = similarContainer.querySelector(`[data-artist-id="${a.id}"]`);
if (el) {
trackDataStore.set(el, a);
this.updateLikeState(el, 'artist', a.id);
}
});
} else {
similarSection.style.display = 'none';
}
@ -2259,9 +2268,9 @@ export class UIRenderer {
<span>${artist.popularity}% popularity</span>
<div class="artist-tags">
${(artist.artistRoles || [])
.filter((role) => role.category)
.map((role) => `<span class="artist-tag">${role.category}</span>`)
.join('')}
.filter((role) => role.category)
.map((role) => `<span class="artist-tag">${role.category}</span>`)
.join('')}
</div>
`;

View file

@ -735,9 +735,19 @@ body.has-page-background .track-item:hover {
.card-like-btn {
position: absolute;
top: 2%;
right: 2%;
background: rgb(0, 0, 0, 0.25) !important;
top: 2%;
}
.card-menu-btn {
position: absolute;
left: 2%;
top: 2%;
}
.card-like-btn,
.card-menu-btn {
background: rgb(0, 0, 0, 0.4) !important;
backdrop-filter: blur(8px);
border-radius: 50% !important;
width: 32px !important;
@ -752,15 +762,18 @@ body.has-page-background .track-item:hover {
z-index: 10;
color: white !important;
border: none !important;
cursor: pointer;
}
.card:hover .card-like-btn,
.card:hover .card-menu-btn,
.card-like-btn.active {
opacity: 1;
transform: scale(1);
}
.card-like-btn:hover {
.card-like-btn:hover,
.card-menu-btn:hover {
background: rgb(0, 0, 0, 0.7) !important;
transform: scale(1.1) !important;
}
@ -991,6 +1004,13 @@ body.has-page-background .track-item:hover {
/* Reset in case inherited */
}
.album-content-layout .content-section .card-grid .card.compact {
display: inline-flex;
vertical-align: top;
width: 220px;
margin-right: var(--spacing-sm);
}
.album-content-layout .section-title {
font-size: 1.4rem;
margin-bottom: var(--spacing-md);
@ -2929,15 +2949,6 @@ input:checked + .slider::before {
color: var(--foreground);
}
/* Specific Panel Overrides if needed */
.lyrics-panel {
/* Inherits side-panel */
}
.queue-panel {
/* Inherits side-panel */
}
/* Synced lyrics styling with Apple Music animations */
.synced-line {
padding: 0.5rem 0;