multi-track selection
This commit is contained in:
parent
cc2f28a798
commit
ab11ff6a37
5 changed files with 499 additions and 6 deletions
403
js/events.js
403
js/events.js
|
|
@ -8,7 +8,13 @@ import {
|
||||||
getShareUrl,
|
getShareUrl,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
} from './utils.js';
|
} 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 { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js';
|
||||||
import { downloadQualitySettings } from './storage.js';
|
import { downloadQualitySettings } from './storage.js';
|
||||||
import { updateTabTitle, navigate } from './router.js';
|
import { updateTabTitle, navigate } from './router.js';
|
||||||
|
|
@ -47,10 +53,191 @@ import {
|
||||||
trackStartMix,
|
trackStartMix,
|
||||||
trackEvent,
|
trackEvent,
|
||||||
} from './analytics.js';
|
} 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;
|
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;">×</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) {
|
export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
const playPauseBtn = document.querySelector('.now-playing-bar .play-pause-btn');
|
const playPauseBtn = document.querySelector('.now-playing-bar .play-pause-btn');
|
||||||
const nextBtn = document.getElementById('next-btn');
|
const nextBtn = document.getElementById('next-btn');
|
||||||
|
|
@ -74,6 +261,104 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
volumeFill.style.width = `${effectiveVolume}%`;
|
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) {
|
if (homeStartRadioBtn) {
|
||||||
homeStartRadioBtn.addEventListener('click', async () => {
|
homeStartRadioBtn.addEventListener('click', async () => {
|
||||||
await player.enableRadio();
|
await player.enableRadio();
|
||||||
|
|
@ -1774,6 +2059,14 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
}
|
}
|
||||||
contextMenu._contextTrack = contextTrack;
|
contextMenu._contextTrack = contextTrack;
|
||||||
contextMenu._contextType = menuBtn.dataset.type || trackItem.dataset.type || 'track';
|
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);
|
await updateContextMenuLikeState(contextMenu, contextTrack);
|
||||||
const rect = menuBtn.getBoundingClientRect();
|
const rect = menuBtn.getBoundingClientRect();
|
||||||
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
|
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
|
||||||
|
|
@ -1782,6 +2075,16 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
return;
|
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');
|
const trackItem = e.target.closest('.track-item');
|
||||||
if (trackItem && (trackItem.classList.contains('unavailable') || trackItem.classList.contains('blocked'))) {
|
if (trackItem && (trackItem.classList.contains('unavailable') || trackItem.classList.contains('blocked'))) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1795,6 +2098,22 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
const clickedTrackId = trackItem.dataset.trackId;
|
const clickedTrackId = trackItem.dataset.trackId;
|
||||||
const isSearch = window.location.pathname.startsWith('/search/');
|
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) {
|
if (isSearch) {
|
||||||
const clickedTrack = trackDataStore.get(trackItem);
|
const clickedTrack = trackDataStore.get(trackItem);
|
||||||
if (clickedTrack) {
|
if (clickedTrack) {
|
||||||
|
|
@ -1886,6 +2205,15 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
contextMenu._originalHTML = null;
|
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
|
// Hide actions for unavailable tracks
|
||||||
const unavailableActions = ['play-next', 'add-to-queue', 'download', 'track-mix'];
|
const unavailableActions = ['play-next', 'add-to-queue', 'download', 'track-mix'];
|
||||||
contextMenu.querySelectorAll('[data-action]').forEach((btn) => {
|
contextMenu.querySelectorAll('[data-action]').forEach((btn) => {
|
||||||
|
|
@ -1896,6 +2224,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
|
|
||||||
contextMenu._contextTrack = contextTrack;
|
contextMenu._contextTrack = contextTrack;
|
||||||
contextMenu._contextType = contextTrack.type || 'track';
|
contextMenu._contextType = contextTrack.type || 'track';
|
||||||
|
contextMenu._selectedTracks = selectedTracks;
|
||||||
await updateContextMenuLikeState(contextMenu, contextTrack);
|
await updateContextMenuLikeState(contextMenu, contextTrack);
|
||||||
positionMenu(contextMenu, e.clientX, e.clientY);
|
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.style.display === 'block') {
|
||||||
if (contextMenu._originalHTML) {
|
if (contextMenu._originalHTML) {
|
||||||
contextMenu.innerHTML = contextMenu._originalHTML;
|
contextMenu.innerHTML = contextMenu._originalHTML;
|
||||||
|
|
@ -1942,6 +2271,21 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
contextMenu._contextType = null;
|
contextMenu._contextType = null;
|
||||||
contextMenu._originalHTML = 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) => {
|
contextMenu.addEventListener('click', async (e) => {
|
||||||
|
|
@ -1983,9 +2327,55 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action && track) {
|
if (action && track) {
|
||||||
// Track context menu action
|
const selectedTracks = contextMenu._selectedTracks || [];
|
||||||
trackContextMenuAction(action, type, track);
|
const isMultiSelect = selectedTracks.length > 1;
|
||||||
await handleTrackAction(action, track, player, api, lyricsManager, type, ui, scrobbler, target.dataset);
|
|
||||||
|
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
|
// Reset menu state before closing
|
||||||
|
|
@ -1995,6 +2385,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
}
|
}
|
||||||
contextMenu.style.display = 'none';
|
contextMenu.style.display = 'none';
|
||||||
contextMenu._contextType = null;
|
contextMenu._contextType = null;
|
||||||
|
contextMenu._selectedTracks = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Now playing bar interactions
|
// Now playing bar interactions
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
export { default as SVG_ANIMATE_SPIN } from '../images/animate-spin.svg?svg&icon';
|
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_APPLE } from '../images/apple.svg?svg&icon';
|
||||||
export { default as SVG_BIN } from '!lucide/trash-2.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_CLOCK } from '!lucide/clock.svg?svg&icon';
|
||||||
export { default as SVG_CLOSE } from '!lucide/x.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';
|
export { default as SVG_DOWNLOAD } from '!lucide/download.svg?svg&icon';
|
||||||
|
|
|
||||||
|
|
@ -2747,6 +2747,14 @@ export const keyboardShortcuts = {
|
||||||
alt: false,
|
alt: false,
|
||||||
description: 'Toggle visualizer auto-cycle',
|
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() {
|
getShortcuts() {
|
||||||
|
|
|
||||||
3
js/ui.js
3
js/ui.js
|
|
@ -82,6 +82,7 @@ import {
|
||||||
SVG_CLOCK,
|
SVG_CLOCK,
|
||||||
SVG_MOVE_UP,
|
SVG_MOVE_UP,
|
||||||
SVG_MOVE_DOWN,
|
SVG_MOVE_DOWN,
|
||||||
|
SVG_CHECKBOX,
|
||||||
} from './icons.js';
|
} from './icons.js';
|
||||||
|
|
||||||
function sortTracks(tracks, sortType) {
|
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>`
|
? `<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 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 explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
|
||||||
const qualityBadge = createQualityBadgeHTML(track);
|
const qualityBadge = createQualityBadgeHTML(track);
|
||||||
const trackTitle = getTrackTitle(track);
|
const trackTitle = getTrackTitle(track);
|
||||||
|
|
@ -437,6 +439,7 @@ export class UIRenderer {
|
||||||
${track.isLocal ? 'data-is-local="true"' : ''}
|
${track.isLocal ? 'data-is-local="true"' : ''}
|
||||||
${isUnavailable ? 'title="This track is currently unavailable"' : ''}
|
${isUnavailable ? 'title="This track is currently unavailable"' : ''}
|
||||||
${blockedTitle}>
|
${blockedTitle}>
|
||||||
|
${checkboxHTML}
|
||||||
${trackNumberHTML}
|
${trackNumberHTML}
|
||||||
<div class="track-item-info">
|
<div class="track-item-info">
|
||||||
<div class="track-item-details">
|
<div class="track-item-details">
|
||||||
|
|
|
||||||
88
styles.css
88
styles.css
|
|
@ -2156,6 +2156,7 @@ input[type='search']::-webkit-search-cancel-button {
|
||||||
transform var(--transition-fast);
|
transform var(--transition-fast);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-item:hover {
|
.track-item:hover {
|
||||||
|
|
@ -2200,6 +2201,93 @@ input[type='search']::-webkit-search-cancel-button {
|
||||||
pointer-events: auto;
|
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 {
|
.track-number {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue