diff --git a/js/events.js b/js/events.js
index 5103f43..c8e7ba3 100644
--- a/js/events.js
+++ b/js/events.js
@@ -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 = `
+
+
+
Add to Playlist
+
+
+
+
+ + Create new playlist
+
+
+
+
+ `;
+
+ 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 = 'No playlists yet
';
+ } else {
+ listEl.innerHTML = playlists
+ .map(
+ (p) => `
+
+ ${escapeHtml(p.name)}
+ ${p.tracks?.length || 0} tracks
+
+ `
+ )
+ .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 = `
+ 0 selected
+
+
+
+
+
+
+
+
+ `;
+ 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
diff --git a/js/icons.ts b/js/icons.ts
index 0533f29..0c6d5e3 100644
--- a/js/icons.ts
+++ b/js/icons.ts
@@ -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';
diff --git a/js/storage.js b/js/storage.js
index dd4942a..8483a25 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -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() {
diff --git a/js/ui.js b/js/ui.js
index 0f625ab..770252a 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -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 {
? `${SVG_VIDEO(14)}`
: '';
const trackNumberHTML = `${showCover ? trackImageHTML : displayIndex}
`;
+ const checkboxHTML = `${SVG_CHECKBOX(18)}
`;
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}
diff --git a/styles.css b/styles.css
index 4917c89..121499d 100644
--- a/styles.css
+++ b/styles.css
@@ -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;