Merge pull request #16 from JulienMaille/play-next

feat: add play next functionality, inline track actions, and notifications
This commit is contained in:
Samidy 2025-12-24 05:09:38 -08:00 committed by GitHub
commit 63528a879d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 253 additions and 36 deletions

View file

@ -22,6 +22,7 @@
<audio id="audio-player"></audio>
<div id="context-menu">
<ul>
<li data-action="play-next">Play Next</li>
<li data-action="add-to-queue">Add to Queue</li>
<li data-action="download">Download</li>
</ul>
@ -324,6 +325,16 @@
<option value="karaoke">Karaoke Mode</option>
</select>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Track List Actions</span>
<span class="description">Choose between a dropdown menu or inline buttons for track actions</span>
</div>
<select id="track-list-actions-mode">
<option value="dropdown">Dropdown Menu</option>
<option value="inline">Inline Buttons</option>
</select>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Download Lyrics</span>

View file

@ -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);

View file

@ -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 = `
<div style="display: flex; align-items: start;">
${message}
</div>
`;
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();

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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) {

View file

@ -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',

View file

@ -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 = `
<div class="track-actions-inline">
<button class="track-action-btn" data-action="play-next" title="Play Next">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 6h6" />
<path d="M5 3v6" />
<path d="M11 6h10" />
<path d="M3 12h18" />
<path d="M3 18h18" />
</svg>
</button>
<button class="track-action-btn" data-action="add-to-queue" title="Add to Queue">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18" />
<path d="M3 12h18" />
<path d="M3 18h10" />
<path d="M16 18h6" />
<path d="M19 15v6" />
</svg>
</button>
<button class="track-action-btn" data-action="download" title="Download">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
</div>
<button class="track-menu-btn" type="button" title="More options">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
</svg>
</button>
`;
return `
<div class="track-item" data-track-id="${track.id}">
${trackNumberHTML}
@ -98,13 +135,9 @@ export class UIRenderer {
</div>
</div>
<div class="track-item-duration">${formatTime(track.duration)}</div>
<button class="track-menu-btn" type="button" title="More options">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
</svg>
</button>
<div class="track-item-actions">
${actionsHTML}
</div>
</div>
`;
}

View file

@ -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) {