multi-track selection

This commit is contained in:
edidealt 2026-03-20 22:28:08 +00:00
parent cc2f28a798
commit ab11ff6a37
5 changed files with 499 additions and 6 deletions

View file

@ -8,7 +8,13 @@ import {
getShareUrl,
escapeHtml,
} from './utils.js';
import { lastFMStorage, libreFmSettings, listenBrainzSettings, waveformSettings } from './storage.js';
import {
lastFMStorage,
libreFmSettings,
listenBrainzSettings,
waveformSettings,
keyboardShortcuts,
} from './storage.js';
import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js';
import { downloadQualitySettings } from './storage.js';
import { updateTabTitle, navigate } from './router.js';
@ -47,10 +53,191 @@ import {
trackStartMix,
trackEvent,
} from './analytics.js';
import { SVG_BIN, SVG_MUTE, SVG_PAUSE, SVG_PLAY, SVG_VOLUME } from './icons.js';
import { SVG_BIN, SVG_MUTE, SVG_PAUSE, SVG_PLAY, SVG_VOLUME, SVG_CHECKBOX, SVG_CHECKBOX_CHECKED } from './icons.js';
let currentTrackIdForWaveform = null;
const trackSelection = {
selectedIds: new Set(),
lastClickedId: null,
isSelecting: false,
};
function isMultiSelectToggle(e) {
const shortcut = keyboardShortcuts.getShortcutForAction('multiSelectToggle');
if (!shortcut) return e.ctrlKey || e.metaKey;
const key = e.key?.toLowerCase();
const shortcutKey = shortcut.key?.toLowerCase();
if (['control', 'shift', 'alt', 'meta'].includes(shortcutKey)) {
if (shortcut.ctrl && !(e.ctrlKey || e.metaKey)) return false;
if (shortcut.shift && !e.shiftKey) return false;
if (shortcut.alt && !e.altKey) return false;
return true;
}
return (
(shortcut.ctrl ? e.ctrlKey || e.metaKey : !e.ctrlKey && !e.metaKey) &&
(shortcut.shift ? e.shiftKey : !e.shiftKey) &&
(shortcut.alt ? e.altKey : !e.altKey) &&
key === shortcutKey
);
}
function isMultiSelectRange(e) {
const shortcut = keyboardShortcuts.getShortcutForAction('multiSelectRange');
if (!shortcut) return e.shiftKey;
const key = e.key?.toLowerCase();
const shortcutKey = shortcut.key?.toLowerCase();
if (['control', 'shift', 'alt', 'meta'].includes(shortcutKey)) {
if (shortcut.ctrl && !(e.ctrlKey || e.metaKey)) return false;
if (shortcut.shift && !e.shiftKey) return false;
if (shortcut.alt && !e.altKey) return false;
return true;
}
return (
(shortcut.ctrl ? e.ctrlKey || e.metaKey : !e.ctrlKey && !e.metaKey) &&
(shortcut.shift ? e.shiftKey : !e.shiftKey) &&
(shortcut.alt ? e.altKey : !e.altKey) &&
key === shortcutKey
);
}
function getSelectedTracks() {
return Array.from(trackSelection.selectedIds);
}
function updateCheckbox(checkbox, checked) {
if (checkbox) {
checkbox.innerHTML = checked ? SVG_CHECKBOX_CHECKED(18) : SVG_CHECKBOX(18);
checkbox.classList.toggle('checked', checked);
}
}
function toggleTrackSelection(trackItem, ctrlHeld, shiftHeld) {
const trackId = trackItem.dataset.trackId;
const isSelected = trackSelection.selectedIds.has(trackId);
if (ctrlHeld) {
if (isSelected) {
trackSelection.selectedIds.delete(trackId);
trackItem.classList.remove('selected');
updateCheckbox(trackItem.querySelector('.track-checkbox'), false);
} else {
trackSelection.selectedIds.add(trackId);
trackItem.classList.add('selected');
updateCheckbox(trackItem.querySelector('.track-checkbox'), true);
}
trackSelection.lastClickedId = trackId;
} else if (shiftHeld && trackSelection.lastClickedId && trackSelection.lastClickedId !== trackId) {
const parentList = trackItem.closest('.track-list') || trackItem.closest('#main-content');
const allTrackElements = Array.from(parentList.querySelectorAll('.track-item'));
const lastIndex = allTrackElements.findIndex((el) => el.dataset.trackId === trackSelection.lastClickedId);
const currentIndex = allTrackElements.findIndex((el) => el.dataset.trackId === trackId);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
for (let i = start; i <= end; i++) {
const el = allTrackElements[i];
trackSelection.selectedIds.add(el.dataset.trackId);
el.classList.add('selected');
updateCheckbox(el.querySelector('.track-checkbox'), true);
}
}
} else {
if (!isSelected) {
trackSelection.selectedIds.add(trackId);
trackItem.classList.add('selected');
updateCheckbox(trackItem.querySelector('.track-checkbox'), true);
} else {
trackSelection.selectedIds.delete(trackId);
trackItem.classList.remove('selected');
updateCheckbox(trackItem.querySelector('.track-checkbox'), false);
}
trackSelection.lastClickedId = trackId;
}
trackSelection.isSelecting = trackSelection.selectedIds.size > 0;
document.body.classList.toggle('multi-select-mode', trackSelection.isSelecting);
}
function showMultiSelectPlaylistModal(tracks) {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.style.cssText =
'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 10000;';
modal.innerHTML = `
<div class="modal-content" style="background: var(--card); border-radius: var(--radius); padding: 1.5rem; min-width: 350px; max-width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.75rem;">
<h3 style="margin: 0;">Add to Playlist</h3>
<button class="modal-close" style="background: none; border: none; color: var(--foreground); font-size: 1.5rem; cursor: pointer; padding: 0; line-height: 1;">&times;</button>
</div>
<div class="playlist-body" style="max-height: 300px; overflow-y: auto;">
<div class="create-new-playlist" style="padding: 12px; cursor: pointer; border-bottom: 1px solid var(--border); color: var(--primary); font-weight: 500;">
+ Create new playlist
</div>
<div class="playlist-list"></div>
</div>
</div>
`;
const closeModal = () => {
modal.remove();
document.body.style.overflow = '';
};
modal.querySelector('.modal-close').addEventListener('click', closeModal);
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
document.body.appendChild(modal);
document.body.style.overflow = 'hidden';
db.getPlaylists(true).then((playlists) => {
const listEl = modal.querySelector('.playlist-list');
if (playlists.length === 0) {
listEl.innerHTML = '<div style="padding: 12px; color: var(--muted-foreground);">No playlists yet</div>';
} else {
listEl.innerHTML = playlists
.map(
(p) => `
<div class="playlist-item" data-playlist-id="${p.id}" style="padding: 12px; cursor: pointer; border-bottom: 1px solid var(--border);">
<span>${escapeHtml(p.name)}</span>
<span style="color: var(--muted-foreground); font-size: 0.85rem; margin-left: 8px;">${p.tracks?.length || 0} tracks</span>
</div>
`
)
.join('');
}
listEl.querySelectorAll('.playlist-item').forEach((item) => {
item.addEventListener('click', async () => {
const playlistId = item.dataset.playlistId;
for (const track of tracks) {
await db.addTrackToPlaylist(playlistId, track);
}
syncManager.syncUserPlaylist(await db.getPlaylist(playlistId), 'update');
showNotification(`Added ${tracks.length} tracks to playlist`);
closeModal();
});
});
});
modal.querySelector('.create-new-playlist').addEventListener('click', () => {
const name = prompt('Playlist name:');
if (name) {
db.createPlaylist(name, tracks).then((playlist) => {
showNotification(`Created playlist "${name}" with ${tracks.length} tracks`);
closeModal();
});
}
});
}
export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
const playPauseBtn = document.querySelector('.now-playing-bar .play-pause-btn');
const nextBtn = document.getElementById('next-btn');
@ -74,6 +261,104 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
volumeFill.style.width = `${effectiveVolume}%`;
};
function clearSelection() {
trackSelection.selectedIds.clear();
trackSelection.lastClickedId = null;
trackSelection.isSelecting = false;
document.body.classList.remove('multi-select-mode');
document.querySelectorAll('.track-item.selected').forEach((el) => {
el.classList.remove('selected');
});
document.querySelectorAll('.track-checkbox').forEach((checkbox) => {
checkbox.innerHTML = SVG_CHECKBOX(18);
checkbox.classList.remove('checked');
});
updateSelectionBar();
}
function updateSelectionBar() {
let bar = document.getElementById('selection-bar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'selection-bar';
bar.className = 'selection-bar';
bar.innerHTML = `
<span class="selection-count">0 selected</span>
<div class="selection-actions">
<button data-action="play-selected">Play</button>
<button data-action="add-to-queue-selected">Add to queue</button>
<button data-action="add-to-playlist-selected">Add to playlist</button>
<button data-action="download-selected">Download</button>
<button data-action="like-selected">Like</button>
</div>
<button data-action="clear-selection" style="margin-left: 8px;">Clear</button>
`;
document.body.appendChild(bar);
bar.querySelectorAll('button').forEach((btn) => {
btn.addEventListener('click', () => handleSelectionAction(btn.dataset.action));
});
}
const count = trackSelection.selectedIds.size;
bar.querySelector('.selection-count').textContent = `${count} selected`;
bar.classList.toggle('visible', count > 0);
}
function handleSelectionAction(action) {
const selectedIds = getSelectedTracks();
if (selectedIds.length === 0) return;
const mainContent = document.getElementById('main-content');
const selectedTracks = [];
mainContent.querySelectorAll('.track-item').forEach((item) => {
if (trackSelection.selectedIds.has(item.dataset.trackId)) {
const track = trackDataStore.get(item);
if (track) selectedTracks.push(track);
}
});
switch (action) {
case 'play-selected':
if (selectedTracks.length > 0) {
player.setQueue(selectedTracks, 0);
document.getElementById('shuffle-btn').classList.remove('active');
player.playTrackFromQueue();
}
break;
case 'add-to-queue-selected':
if (selectedTracks.length > 0) {
player.addToQueue(selectedTracks);
if (window.renderQueueFunction) window.renderQueueFunction();
showNotification(`Added ${selectedTracks.length} tracks to queue`);
}
break;
case 'add-to-playlist-selected':
if (selectedTracks.length > 0) {
showMultiSelectPlaylistModal(selectedTracks);
}
break;
case 'download-selected':
if (selectedTracks.length > 0) {
selectedTracks.forEach((track) => {
downloadTrackWithMetadata(track, downloadQualitySettings.getQuality(), api, lyricsManager);
});
showNotification(`Downloading ${selectedTracks.length} tracks`);
}
break;
case 'like-selected':
selectedTracks.forEach(async (track) => {
const added = await db.toggleFavorite('track', track);
syncManager.syncLibraryItem('track', track, added);
});
showNotification(`Liked ${selectedTracks.length} tracks`);
break;
case 'clear-selection':
clearSelection();
break;
}
}
if (homeStartRadioBtn) {
homeStartRadioBtn.addEventListener('click', async () => {
await player.enableRadio();
@ -1774,6 +2059,14 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
}
contextMenu._contextTrack = contextTrack;
contextMenu._contextType = menuBtn.dataset.type || trackItem.dataset.type || 'track';
if (trackSelection.isSelecting && trackSelection.selectedIds.size > 0) {
const selectedTracks = [];
document.querySelectorAll('.track-item.selected').forEach((item) => {
const track = trackDataStore.get(item);
if (track) selectedTracks.push(track);
});
contextMenu._selectedTracks = selectedTracks;
}
await updateContextMenuLikeState(contextMenu, contextTrack);
const rect = menuBtn.getBoundingClientRect();
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
@ -1782,6 +2075,16 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
return;
}
const checkbox = e.target.closest('.track-checkbox');
if (checkbox) {
e.stopPropagation();
const trackItem = checkbox.closest('.track-item');
if (trackItem) {
toggleTrackSelection(trackItem, isMultiSelectToggle(e), isMultiSelectRange(e));
}
return;
}
const trackItem = e.target.closest('.track-item');
if (trackItem && (trackItem.classList.contains('unavailable') || trackItem.classList.contains('blocked'))) {
return;
@ -1795,6 +2098,22 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
const clickedTrackId = trackItem.dataset.trackId;
const isSearch = window.location.pathname.startsWith('/search/');
if (isMultiSelectToggle(e)) {
e.preventDefault();
toggleTrackSelection(trackItem, true, isMultiSelectRange(e));
return;
}
if (isMultiSelectRange(e) && trackSelection.isSelecting) {
e.preventDefault();
toggleTrackSelection(trackItem, false, true);
return;
}
if (trackSelection.isSelecting) {
return;
}
if (isSearch) {
const clickedTrack = trackDataStore.get(trackItem);
if (clickedTrack) {
@ -1886,6 +2205,15 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
contextMenu._originalHTML = null;
}
// Store selected tracks for multi-select actions
let selectedTracks = [];
if (trackSelection.isSelecting && trackSelection.selectedIds.size > 0) {
document.querySelectorAll('.track-item.selected').forEach((item) => {
const track = trackDataStore.get(item);
if (track) selectedTracks.push(track);
});
}
// Hide actions for unavailable tracks
const unavailableActions = ['play-next', 'add-to-queue', 'download', 'track-mix'];
contextMenu.querySelectorAll('[data-action]').forEach((btn) => {
@ -1896,6 +2224,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
contextMenu._contextTrack = contextTrack;
contextMenu._contextType = contextTrack.type || 'track';
contextMenu._selectedTracks = selectedTracks;
await updateContextMenuLikeState(contextMenu, contextTrack);
positionMenu(contextMenu, e.clientX, e.clientY);
}
@ -1933,7 +2262,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
}
});
document.addEventListener('click', () => {
document.addEventListener('click', (e) => {
if (contextMenu.style.display === 'block') {
if (contextMenu._originalHTML) {
contextMenu.innerHTML = contextMenu._originalHTML;
@ -1942,6 +2271,21 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
contextMenu._contextType = null;
contextMenu._originalHTML = null;
}
if (
trackSelection.isSelecting &&
!e.target.closest('.track-item') &&
!e.target.closest('.selection-bar') &&
!e.target.closest('.track-checkbox')
) {
clearSelection();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && trackSelection.isSelecting) {
clearSelection();
}
});
contextMenu.addEventListener('click', async (e) => {
@ -1983,9 +2327,55 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
}
if (action && track) {
// Track context menu action
trackContextMenuAction(action, type, track);
await handleTrackAction(action, track, player, api, lyricsManager, type, ui, scrobbler, target.dataset);
const selectedTracks = contextMenu._selectedTracks || [];
const isMultiSelect = selectedTracks.length > 1;
if (isMultiSelect) {
// Handle multi-select actions
switch (action) {
case 'play-next':
selectedTracks.forEach((t) => {
trackPlayNext(t);
player.addNextToQueue(t);
});
if (window.renderQueueFunction) window.renderQueueFunction();
showNotification(`Playing next: ${selectedTracks.length} tracks`);
clearSelection();
break;
case 'add-to-queue':
player.addToQueue(selectedTracks);
if (window.renderQueueFunction) window.renderQueueFunction();
showNotification(`Added ${selectedTracks.length} tracks to queue`);
clearSelection();
break;
case 'toggle-like':
selectedTracks.forEach(async (t) => {
const added = await db.toggleFavorite('track', t);
syncManager.syncLibraryItem('track', t, added);
});
showNotification(`Liked ${selectedTracks.length} tracks`);
clearSelection();
break;
case 'add-to-playlist':
showMultiSelectPlaylistModal(selectedTracks);
clearSelection();
break;
case 'download':
selectedTracks.forEach((t) => {
downloadTrackWithMetadata(t, downloadQualitySettings.getQuality(), api, lyricsManager);
});
showNotification(`Downloading ${selectedTracks.length} tracks`);
clearSelection();
break;
default:
clearSelection();
break;
}
} else {
// Track context menu action
trackContextMenuAction(action, type, track);
await handleTrackAction(action, track, player, api, lyricsManager, type, ui, scrobbler, target.dataset);
}
}
// Reset menu state before closing
@ -1995,6 +2385,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
}
contextMenu.style.display = 'none';
contextMenu._contextType = null;
contextMenu._selectedTracks = null;
});
// Now playing bar interactions

View file

@ -1,6 +1,9 @@
export { default as SVG_ANIMATE_SPIN } from '../images/animate-spin.svg?svg&icon';
export { default as SVG_APPLE } from '../images/apple.svg?svg&icon';
export { default as SVG_BIN } from '!lucide/trash-2.svg?svg&icon';
export { default as SVG_CHECK } from '!lucide/check.svg?svg&icon';
export { default as SVG_CHECKBOX } from '!lucide/square.svg?svg&icon';
export { default as SVG_CHECKBOX_CHECKED } from '!lucide/check-square.svg?svg&icon';
export { default as SVG_CLOCK } from '!lucide/clock.svg?svg&icon';
export { default as SVG_CLOSE } from '!lucide/x.svg?svg&icon';
export { default as SVG_DOWNLOAD } from '!lucide/download.svg?svg&icon';

View file

@ -2747,6 +2747,14 @@ export const keyboardShortcuts = {
alt: false,
description: 'Toggle visualizer auto-cycle',
},
multiSelectToggle: {
key: 'control',
shift: false,
ctrl: true,
alt: false,
description: 'Toggle track selection (individual)',
},
multiSelectRange: { key: 'shift', shift: true, ctrl: false, alt: false, description: 'Select track range' },
},
getShortcuts() {

View file

@ -82,6 +82,7 @@ import {
SVG_CLOCK,
SVG_MOVE_UP,
SVG_MOVE_DOWN,
SVG_CHECKBOX,
} from './icons.js';
function sortTracks(tracks, sortType) {
@ -397,6 +398,7 @@ export class UIRenderer {
? `<span class="video-item-icon" title="Music Video" style="display: inline-flex; align-items: center; margin-right: 4px; color: var(--muted-foreground);">${SVG_VIDEO(14)}</span>`
: '';
const trackNumberHTML = `<div class="track-number">${showCover ? trackImageHTML : displayIndex}</div>`;
const checkboxHTML = `<div class="track-checkbox" data-action="toggle-select">${SVG_CHECKBOX(18)}</div>`;
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
const qualityBadge = createQualityBadgeHTML(track);
const trackTitle = getTrackTitle(track);
@ -437,6 +439,7 @@ export class UIRenderer {
${track.isLocal ? 'data-is-local="true"' : ''}
${isUnavailable ? 'title="This track is currently unavailable"' : ''}
${blockedTitle}>
${checkboxHTML}
${trackNumberHTML}
<div class="track-item-info">
<div class="track-item-details">

View file

@ -2156,6 +2156,7 @@ input[type='search']::-webkit-search-cancel-button {
transform var(--transition-fast);
cursor: pointer;
border: 1px solid transparent;
position: relative;
}
.track-item:hover {
@ -2200,6 +2201,93 @@ input[type='search']::-webkit-search-cancel-button {
pointer-events: auto;
}
.track-checkbox {
display: none;
width: 18px;
height: 18px;
cursor: pointer;
color: var(--muted-foreground);
flex-shrink: 0;
transition: color var(--transition-fast);
position: absolute;
left: 8px;
z-index: 1;
}
.multi-select-mode .track-checkbox,
body.multi-select-mode .track-checkbox {
display: flex !important;
align-items: center;
justify-content: center;
}
.track-checkbox:hover {
color: var(--foreground);
}
.track-checkbox.checked {
color: var(--primary);
}
.track-item.selected {
background-color: rgb(var(--highlight-rgb), 0.1);
}
body.multi-select-mode .track-item {
cursor: default;
padding-left: 36px;
}
body.multi-select-mode .track-item:hover {
transform: none;
}
.selection-bar {
display: none;
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
background: var(--primary);
color: var(--primary-foreground);
padding: 12px 20px;
border-radius: 24px;
font-size: 0.9rem;
font-weight: 500;
z-index: 1000;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
gap: 12px;
align-items: center;
}
.selection-bar.visible {
display: flex;
}
.selection-bar .selection-count {
color: var(--primary-foreground);
}
.selection-bar .selection-actions {
display: flex;
gap: 8px;
}
.selection-bar button {
background: transparent;
border: 1px solid var(--primary-foreground);
color: var(--primary-foreground);
padding: 6px 12px;
border-radius: 16px;
cursor: pointer;
font-size: 0.85rem;
transition: background var(--transition-fast);
}
.selection-bar button:hover {
background: rgba(255, 255, 255, 0.2);
}
.track-number {
color: var(--muted-foreground);
text-align: center;