Merge pull request #16 from JulienMaille/play-next
feat: add play next functionality, inline track actions, and notifications
This commit is contained in:
commit
63528a879d
9 changed files with 253 additions and 36 deletions
11
index.html
11
index.html
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
62
js/events.js
62
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
js/player.js
20
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
49
js/ui.js
49
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 = `
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
93
styles.css
93
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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue