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 = ` + + `; + + 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;