From 15315ab0c8d2bc013a3d5b4a24b128802642c31c Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Wed, 24 Dec 2025 00:55:48 +0100 Subject: [PATCH] feat: add play next functionality, inline track actions, and notifications --- index.html | 11 ++++++ js/app.js | 3 +- js/downloads.js | 21 +++++++++++ js/events.js | 62 +++++++++++++++++++++++++++++++-- js/player.js | 20 +++++++++++ js/settings.js | 11 +++++- js/storage.js | 19 ++++++++++ js/ui.js | 49 +++++++++++++++++++++----- styles.css | 93 ++++++++++++++++++++++++++++++++++++------------- 9 files changed, 253 insertions(+), 36 deletions(-) diff --git a/index.html b/index.html index da63e13..d069548 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,7 @@
@@ -324,6 +325,16 @@
+
+
+ Track List Actions + Choose between a dropdown menu or inline buttons for track actions +
+ +
Download Lyrics diff --git a/js/app.js b/js/app.js index 40feda1..2856fb5 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,7 @@ //js/app.js import { LosslessAPI } from './api.js'; -import { apiSettings, themeManager, nowPlayingSettings } from './storage.js'; +import { apiSettings, themeManager, nowPlayingSettings, trackListSettings } from './storage.js'; import { UIRenderer } from './ui.js'; import { Player } from './player.js'; import { LastFMScrobbler } from './lastfm.js'; @@ -202,6 +202,7 @@ document.addEventListener('DOMContentLoaded', async () => { const currentTheme = themeManager.getTheme(); themeManager.setTheme(currentTheme); + trackListSettings.getMode(); initializeSettings(scrobbler, player, api, ui); initializePlayerEvents(player, audioPlayer, scrobbler); diff --git a/js/downloads.js b/js/downloads.js index 39e84a6..ceb8a8f 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -24,6 +24,27 @@ function createDownloadNotification() { return downloadNotificationContainer; } +export function showNotification(message) { + const container = createDownloadNotification(); + + const notifEl = document.createElement('div'); + notifEl.className = 'download-task'; + + notifEl.innerHTML = ` +
+ ${message} +
+ `; + + container.appendChild(notifEl); + + // Auto remove + setTimeout(() => { + notifEl.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => notifEl.remove(), 300); + }, 1500); +} + export function addDownloadTask(trackId, track, filename, api) { const container = createDownloadNotification(); diff --git a/js/events.js b/js/events.js index 821aa2c..e11cd6a 100644 --- a/js/events.js +++ b/js/events.js @@ -1,7 +1,7 @@ //js/events.js import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore, RATE_LIMIT_ERROR_MESSAGE, buildTrackFilename } from './utils.js'; import { lastFMStorage } from './storage.js'; -import { addDownloadTask, updateDownloadProgress, completeDownloadTask, downloadTrackWithMetadata } from './downloads.js'; +import { addDownloadTask, updateDownloadProgress, completeDownloadTask, showNotification, downloadTrackWithMetadata } from './downloads.js'; import { lyricsSettings } from './storage.js'; import { updateTabTitle } from './router.js'; @@ -283,6 +283,29 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen let contextTrack = null; mainContent.addEventListener('click', e => { + const actionBtn = e.target.closest('.track-action-btn'); + if (actionBtn) { + e.stopPropagation(); + const trackItem = actionBtn.closest('.track-item'); + if (trackItem) { + const track = trackDataStore.get(trackItem); + const action = actionBtn.dataset.action; + + if (action === 'add-to-queue' && track) { + player.addToQueue(track); + renderQueue(player); + showNotification(`Added to queue: ${track.title}`); + } else if (action === 'play-next' && track) { + player.addNextToQueue(track); + renderQueue(player); + showNotification(`Playing next: ${track.title}`); + } else if (action === 'download' && track) { + handleDownload(track, player, api); + } + } + return; + } + const menuBtn = e.target.closest('.track-menu-btn'); if (menuBtn) { e.stopPropagation(); @@ -334,9 +357,14 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen e.stopPropagation(); const action = e.target.dataset.action; - if (action === 'add-to-queue' && contextTrack) { + if (action === 'play-next' && contextTrack) { + player.addNextToQueue(contextTrack); + renderQueue(player); + showNotification(`Playing next: ${contextTrack.title}`); + } else if (action === 'add-to-queue' && contextTrack) { player.addToQueue(contextTrack); renderQueue(player); + showNotification(`Added to queue: ${contextTrack.title}`); } else if (action === 'download' && contextTrack) { await downloadTrackWithMetadata(contextTrack, player.quality, api, lyricsManager); } @@ -412,3 +440,33 @@ function positionMenu(menu, x, y, anchorRect = null) { menu.style.left = `${left}px`; menu.style.visibility = 'visible'; } + +async function handleDownload(track, player, api) { + const quality = player.quality; + const filename = buildTrackFilename(track, quality); + + try { + const { taskEl, abortController } = addDownloadTask( + track.id, + track, + filename, + api + ); + + await api.downloadTrack(track.id, quality, filename, { + signal: abortController.signal, + onProgress: (progress) => { + updateDownloadProgress(track.id, progress); + } + }); + + completeDownloadTask(track.id, true); + } catch (error) { + if (error.name !== 'AbortError') { + const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE + ? error.message + : 'Download failed. Please try again.'; + completeDownloadTask(track.id, false, errorMsg); + } + } +} diff --git a/js/player.js b/js/player.js index 8cb5090..8f17b4f 100644 --- a/js/player.js +++ b/js/player.js @@ -322,6 +322,26 @@ export class Player { this.saveQueueState(); } + addNextToQueue(track) { + const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; + const insertIndex = this.currentQueueIndex + 1; + + // Insert after current track + currentQueue.splice(insertIndex, 0, track); + + // 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.saveQueueState(); + this.preloadNextTracks(); // Update preload since next track changed + } + removeFromQueue(index) { const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; diff --git a/js/settings.js b/js/settings.js index e8dd8ec..6c3aa96 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1,5 +1,5 @@ //js/settings -import { themeManager, lastFMStorage, nowPlayingSettings, lyricsSettings, backgroundSettings } from './storage.js'; +import { themeManager, lastFMStorage, nowPlayingSettings, lyricsSettings, backgroundSettings, trackListSettings } from './storage.js'; export function initializeSettings(scrobbler, player, api, ui) { const lastfmConnectBtn = document.getElementById('lastfm-connect-btn'); @@ -176,6 +176,15 @@ export function initializeSettings(scrobbler, player, api, ui) { }); } + // Track List Actions Mode + const trackListActionsMode = document.getElementById('track-list-actions-mode'); + if (trackListActionsMode) { + trackListActionsMode.value = trackListSettings.getMode(); + trackListActionsMode.addEventListener('change', (e) => { + trackListSettings.setMode(e.target.value); + }); + } + // Download Lyrics Toggle const downloadLyricsToggle = document.getElementById('download-lyrics-toggle'); if (downloadLyricsToggle) { diff --git a/js/storage.js b/js/storage.js index 1ceb671..ef58215 100644 --- a/js/storage.js +++ b/js/storage.js @@ -349,6 +349,25 @@ export const backgroundSettings = { } }; +export const trackListSettings = { + STORAGE_KEY: 'track-list-actions-mode', + + getMode() { + try { + const mode = localStorage.getItem(this.STORAGE_KEY) || 'dropdown'; + document.documentElement.setAttribute('data-track-actions-mode', mode); + return mode; + } catch (e) { + return 'dropdown'; + } + }, + + setMode(mode) { + localStorage.setItem(this.STORAGE_KEY, mode); + document.documentElement.setAttribute('data-track-actions-mode', mode); + } +}; + export const queueManager = { STORAGE_KEY: 'monochrome-queue', diff --git a/js/ui.js b/js/ui.js index 78c067c..603f1f4 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1,6 +1,6 @@ //js/ui.js import { formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration } from './utils.js'; -import { recentActivityManager, backgroundSettings } from './storage.js'; +import { recentActivityManager, backgroundSettings, trackListSettings } from './storage.js'; export class UIRenderer { constructor(api) { @@ -85,6 +85,43 @@ export class UIRenderer { } } + const actionsHTML = ` +
+ + + +
+ + `; + return `
${trackNumberHTML} @@ -98,13 +135,9 @@ export class UIRenderer {
${formatTime(track.duration)}
- +
+ ${actionsHTML} +
`; } diff --git a/styles.css b/styles.css index df56d4f..3972300 100644 --- a/styles.css +++ b/styles.css @@ -1,7 +1,7 @@ :root { - --spacing-xs: 0.5rem; - --spacing-sm: 0.75rem; + --spacing-xs: 0.4rem; + --spacing-sm: 0.5rem; --spacing-md: 1rem; --spacing-lg: 1.5rem; --spacing-xl: 2rem; @@ -682,9 +682,17 @@ body.has-page-background .track-item:hover { } .track-item-duration { - font-size: 0.9rem; color: var(--muted-foreground); justify-self: flex-end; + font-variant-numeric: tabular-nums; +} + +.track-item-actions { + justify-self: flex-end; + display: flex; + align-items: center; + min-width: 40px; + justify-content: flex-end; } .track-menu-btn { @@ -695,13 +703,17 @@ body.has-page-background .track-item:hover { padding: 0.5rem; border-radius: var(--radius); transition: all var(--transition); - display: flex; + display: none; /* Controlled by data-track-actions-mode */ align-items: center; justify-content: center; opacity: 0; z-index: 10; } +[data-track-actions-mode="dropdown"] .track-menu-btn { + display: flex; +} + .track-item:hover .track-menu-btn { opacity: 1; } @@ -717,6 +729,46 @@ body.has-page-background .track-item:hover { color: var(--foreground); } +.track-actions-inline { + display: none; /* Controlled by data-track-actions-mode */ + gap: 0.25rem; + opacity: 0.2; /* Barely visible instead of invisible */ + transition: opacity var(--transition); +} + +[data-track-actions-mode="inline"] .track-actions-inline { + display: flex; +} + +.track-item:hover .track-actions-inline { + opacity: 1; +} + +@media (hover: none) { + .track-actions-inline { + opacity: 1; + } +} + +.track-action-btn { + background: transparent; + border: none; + color: var(--muted-foreground); + cursor: pointer; + padding: 0.5rem; + border-radius: var(--radius); + transition: all var(--transition); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; /* Prevent buttons from squishing */ +} + +.track-action-btn:hover { + background-color: var(--secondary); + color: var(--foreground); +} + .detail-header { display: flex; align-items: flex-end; @@ -2280,16 +2332,11 @@ input:checked + .slider::before { } .track-item { - grid-template-columns: 28px 1fr 45px 32px; + grid-template-columns: 28px 1fr auto auto; gap: var(--spacing-sm); padding: var(--spacing-sm); } - .track-number { - font-size: 0.8rem; - width: 28px; - } - .track-item-info { gap: var(--spacing-sm); min-width: 0; @@ -2306,16 +2353,7 @@ input:checked + .slider::before { overflow: hidden; } - .track-item-details .title { - font-size: 0.85rem; - } - - .track-item-details .artist { - font-size: 0.75rem; - } - .track-item-duration { - font-size: 0.75rem; text-align: right; white-space: nowrap; } @@ -2330,6 +2368,10 @@ input:checked + .slider::before { height: 18px; } + .track-action-btn { + padding: 0.25rem; + } + .queue-track-item { grid-template-columns: 24px 1fr 40px 28px; gap: var(--spacing-sm); @@ -2433,11 +2475,10 @@ input:checked + .slider::before { .track-item { grid-template-columns: 24px 1fr 40px 28px; gap: 0.375rem; - padding: 0.5rem; + padding: var(--spacing-xs); } .track-number { - font-size: 0.75rem; width: 24px; } @@ -2447,15 +2488,15 @@ input:checked + .slider::before { } .track-item-details .title { - font-size: 0.8rem; + font-size: 0.85rem; } .track-item-details .artist { - font-size: 0.7rem; + font-size: 0.75rem; } .track-item-duration { - font-size: 0.7rem; + font-size: 0.75rem; } .track-menu-btn { @@ -2481,6 +2522,10 @@ input:checked + .slider::before { width: 32px; height: 32px; } + + [data-track-actions-mode="inline"] .track-actions-inline .track-action-btn:not([data-action="play-next"]) { + display: none; + } } @media (max-width: 360px) {