Add excessive playlist management (dragging files around, move marker, ability to select multiple files at once (ctrl/shift)

This commit is contained in:
Armin 2026-07-06 21:49:15 +02:00
commit 4d273f085b

View file

@ -19,8 +19,10 @@
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #111; color: #e0e0e0; height: 100vh; overflow: hidden; display: flex; flex-direction: column; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #111; color: #e0e0e0; height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
.btn { background: linear-gradient(to bottom, #3a3a3a, #282828); border: 1px solid #484848; color: #e0e0e0; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px; white-space: nowrap; box-shadow: 0 3px 0 #181818, 0 4px 6px rgba(0,0,0,0.4); } .btn { background: linear-gradient(to bottom, #3a3a3a, #282828); border: 1px solid #484848; color: #e0e0e0; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px; white-space: nowrap; box-shadow: 0 3px 0 #181818, 0 4px 6px rgba(0,0,0,0.4); }
.btn:hover { background: linear-gradient(to bottom, #404040, #2e2e2e); } .btn:hover { background: linear-gradient(to bottom, #404040, #2e2e2e); }
.btn:active { background: linear-gradient(to bottom, #282828, #3a3a3a); box-shadow: 0 1px 0 #181818, 0 2px 4px rgba(0,0,0,0.4); transform: translateY(2px); }
.btn-primary { background: linear-gradient(to bottom, #2a7dff, #185dd5); border-color: #1a6df5; color: #fff; box-shadow: 0 3px 0 #0f4ab0, 0 4px 6px rgba(0,0,0,0.4); } .btn-primary { background: linear-gradient(to bottom, #2a7dff, #185dd5); border-color: #1a6df5; color: #fff; box-shadow: 0 3px 0 #0f4ab0, 0 4px 6px rgba(0,0,0,0.4); }
.btn-primary:hover { background: linear-gradient(to bottom, #3a8dff, #1a6de5); } .btn-primary:hover { background: linear-gradient(to bottom, #3a8dff, #1a6de5); }
.btn-primary:active { background: linear-gradient(to bottom, #185dd5, #2a7dff); box-shadow: 0 1px 0 #0f4ab0, 0 2px 4px rgba(0,0,0,0.4); transform: translateY(2px); }
.btn-sm { padding: 4px 10px; font-size: 11px; } .btn-sm { padding: 4px 10px; font-size: 11px; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
.brand { font-size: 15px; font-weight: 700; color: #999; letter-spacing: 1px; user-select: none; } .brand { font-size: 15px; font-weight: 700; color: #999; letter-spacing: 1px; user-select: none; }
@ -43,16 +45,18 @@ canvas { width: 100%; height: 100px; border-radius: 6px; display: block; }
.progress-row .progress-bar { flex: 1; height: 6px; background: #333; border-radius: 3px; cursor: pointer; } .progress-row .progress-bar { flex: 1; height: 6px; background: #333; border-radius: 3px; cursor: pointer; }
.progress-row .progress-bar .fill { height: 100%; background: #1a6df5; border-radius: 3px; width: 0%; } .progress-row .progress-bar .fill { height: 100%; background: #1a6df5; border-radius: 3px; width: 0%; }
.progress-row .progress-bar:hover .fill { background: #3a8df5; } .progress-row .progress-bar:hover .fill { background: #3a8df5; }
#noiz-overlay { position: fixed; inset: 0; display: flex; justify-content: center; align-items: center; background: transparent; pointer-events: none; z-index: 9999; animation: fadeout 1s ease 2s forwards; } #noiz-overlay { position: fixed; opacity: 0.75; inset: 0; display: flex; justify-content: center; align-items: center; background: transparent; pointer-events: none; z-index: 9999; animation: fadeout 750ms ease 750ms forwards; }
#noiz-overlay img { max-width: 50vw; max-height: 50vh; } #noiz-overlay img { max-width: 50vw; max-height: 50vh; filter: drop-shadow(0 8px 16px rgba(0,0,0,0.5)); }
@keyframes fadeout { from { opacity: 1; } to { opacity: 0; visibility: hidden; } } @keyframes fadeout { from { opacity: 0.75; transform: scale(1); } to { opacity: 0; transform: scale(1.75); visibility: hidden; } }
#timeRemaining { color: #555; } #timeRemaining { color: #555; }
#vuMeter { width: 100%; height: 20px; border-radius: 2px; display: block; } #vuMeter { width: 100%; height: 20px; border-radius: 2px; display: block; }
.controls { display: flex; align-items: center; gap: 16px; align-self: flex-end; } .controls { display: flex; align-items: center; gap: 16px; align-self: flex-end; }
.controls button { background: linear-gradient(to bottom, #3a3a3a, #222); border: none; color: #ccc; cursor: pointer; padding: 6px; font-size: 14px; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 0 #1a1a1a, 0 3px 4px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.06); } .controls button { background: linear-gradient(to bottom, #3a3a3a, #222); border: none; color: #ccc; cursor: pointer; padding: 6px; font-size: 14px; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 0 #1a1a1a, 0 3px 4px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.06); }
.controls button:hover { color: #fff; background: linear-gradient(to bottom, #444, #2a2a2a); } .controls button:hover { color: #fff; background: linear-gradient(to bottom, #444, #2a2a2a); }
.controls button:active { background: linear-gradient(to bottom, #222, #3a3a3a); box-shadow: 0 1px 0 #1a1a1a, 0 1px 2px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.06); transform: translateY(1px); }
.controls .play-btn { width: 44px; height: 44px; background: linear-gradient(to bottom, #2a7dff, #185dd5); color: #fff; font-size: 16px; border: 1px solid #1a6df5; box-shadow: 0 3px 0 #0f4ab0, 0 4px 6px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.15); } .controls .play-btn { width: 44px; height: 44px; background: linear-gradient(to bottom, #2a7dff, #185dd5); color: #fff; font-size: 16px; border: 1px solid #1a6df5; box-shadow: 0 3px 0 #0f4ab0, 0 4px 6px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.15); }
.controls .play-btn:hover { background: linear-gradient(to bottom, #3a8dff, #2a6de5); } .controls .play-btn:hover { background: linear-gradient(to bottom, #3a8dff, #2a6de5); }
.controls .play-btn:active { background: linear-gradient(to bottom, #185dd5, #2a7dff); box-shadow: 0 1px 0 #0f4ab0, 0 2px 4px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.15); transform: translateY(2px); }
.volume-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; width: 100%; max-width: 200px; align-self: flex-end; } .volume-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; width: 100%; max-width: 200px; align-self: flex-end; }
.volume-row input[type="range"] { flex: 1; height: 3px; -webkit-appearance: none; appearance: none; background: #333; border-radius: 2px; outline: none; } .volume-row input[type="range"] { flex: 1; height: 3px; -webkit-appearance: none; appearance: none; background: #333; border-radius: 2px; outline: none; }
.volume-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #1a6df5; cursor: pointer; } .volume-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #1a6df5; cursor: pointer; }
@ -74,15 +78,25 @@ canvas { width: 100%; height: 100px; border-radius: 6px; display: block; }
.playlist-body::-webkit-scrollbar { width: 6px; } .playlist-body::-webkit-scrollbar { width: 6px; }
.playlist-body::-webkit-scrollbar-track { background: transparent; } .playlist-body::-webkit-scrollbar-track { background: transparent; }
.playlist-body::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; } .playlist-body::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
.track-item { display: grid; grid-template-columns: 36px minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) 60px 60px; gap: 8px; padding: 6px 24px; cursor: pointer; font-size: 12px; border-left: 3px solid transparent; transition: background .08s; align-items: center; } .track-item { display: grid; grid-template-columns: 36px minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) 60px 60px; gap: 8px; padding: 6px 24px; cursor: pointer; font-size: 12px; border-left: 3px solid transparent; transition: background .08s; align-items: center; user-select: none; }
.track-item:hover { background: #1a1a1a; } .track-item:hover { background: #1a1a1a; }
.track-item.active { background: linear-gradient(to bottom, #0f3a0f, #082208); border-left-color: #33ff55; } .track-item.active { background: linear-gradient(to bottom, #0f3a0f, #082208); border-left-color: #33ff55; }
.track-item.active .col-title { color: #55ff77; } .track-item.active .col-title { color: #55ff77; font-weight: 700; }
.track-item.active .col-artist { color: #33dd55; } .track-item.active .col-artist { color: #33dd55; font-weight: 600; }
.track-item.active .col-idx { color: #55ff77; } .track-item.active .col-idx { color: #55ff77; font-weight: 700; }
.track-item.active .col-time { color: #33dd55; } .track-item.active .col-time { color: #33dd55; font-weight: 600; }
.track-item.active .col-genre { color: #33dd55; } .track-item.active .col-genre { color: #33dd55; }
.track-item.active .col-file { color: #33cc55; } .track-item.active .col-file { color: #33cc55; font-weight: 600; }
.track-item.selected { background: linear-gradient(to bottom, #1a3a5a, #0f2440); border-left-color: #3399ff; }
.track-item.selected .col-title { color: #77bbff; font-weight: 600; }
.track-item.selected .col-artist { color: #5599dd; }
.track-item.selected .col-idx { color: #5599dd; }
.track-item.selected .col-time { color: #5599dd; }
.track-item.selected .col-file { color: #4488cc; }
.track-item.active.selected { background: linear-gradient(to bottom, #1a6a1a, #0d3d0d); border-left-color: #66ff88; }
.track-item.active.selected .col-title { color: #88ffaa; }
.track-item.dragging { opacity: 0.4; }
.track-item.drag-over { border-top: 2px solid #22bb44; }
.track-item .col-idx { color: #444; text-align: right; font-size: 10px; } .track-item .col-idx { color: #444; text-align: right; font-size: 10px; }
.track-item .col-title { color: #ccc; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .track-item .col-title { color: #ccc; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-item .col-artist { color: #999; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .track-item .col-artist { color: #999; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@ -223,7 +237,8 @@ footer .footer-track { flex: 1; min-width: 0; white-space: nowrap; overflow: hid
<div class="add-bar"> <div class="add-bar">
<button class="btn btn-primary" id="addFolderBtn">+ Folder</button> <button class="btn btn-primary" id="addFolderBtn">+ Folder</button>
<button class="btn" id="addFilesBtn">+ Files</button> <button class="btn" id="addFilesBtn">+ Files</button>
<button class="btn" id="clearBtn">Isolate</button> <button class="btn" id="isolateBtn">Isolate</button>
<button class="btn" id="deleteBtn">Delete</button>
</div> </div>
</div> </div>
@ -538,12 +553,16 @@ drawSpectrum();
let tracks = []; let tracks = [];
let currentIndex = -1; let currentIndex = -1;
let selectedIndices = new Set();
let anchorTrack = null;
let dragIndices = null;
let dirHandle = null; let dirHandle = null;
const addFolderBtn = document.getElementById('addFolderBtn'); const addFolderBtn = document.getElementById('addFolderBtn');
const addFilesBtn = document.getElementById('addFilesBtn'); const addFilesBtn = document.getElementById('addFilesBtn');
const folderPicker = document.getElementById('folderPicker'); const folderPicker = document.getElementById('folderPicker');
const clearBtn = document.getElementById('clearBtn'); const isolateBtn = document.getElementById('isolateBtn');
const deleteBtn = document.getElementById('deleteBtn');
const sortSelect = document.getElementById('sortSelect'); const sortSelect = document.getElementById('sortSelect');
const scanning = document.getElementById('scanning'); const scanning = document.getElementById('scanning');
const scanningText = scanning.querySelector('span'); const scanningText = scanning.querySelector('span');
@ -925,15 +944,11 @@ function addTracks(newTracks) {
showError('No audio files found.'); showError('No audio files found.');
return; return;
} }
const wasEmpty = tracks.length === 0;
tracks = tracks.concat(newTracks); tracks = tracks.concat(newTracks);
renderTracks(); renderTracks();
if (wasEmpty) {
playTrack(0);
}
} }
function clearTracks() { function isolateTracks() {
tracks.forEach((t, i) => { tracks.forEach((t, i) => {
if (i !== currentIndex) URL.revokeObjectURL(t.url); if (i !== currentIndex) URL.revokeObjectURL(t.url);
}); });
@ -941,12 +956,45 @@ function clearTracks() {
const current = tracks[currentIndex]; const current = tracks[currentIndex];
tracks = [current]; tracks = [current];
currentIndex = 0; currentIndex = 0;
selectedIndices.clear();
} else { } else {
tracks = []; tracks = [];
selectedIndices.clear();
} }
renderTracks(); renderTracks();
} }
function deleteTrack() {
if (selectedIndices.size === 0) return;
if (selectedIndices.size === tracks.length) {
tracks.forEach(t => URL.revokeObjectURL(t.url));
tracks = [];
currentIndex = -1;
selectedIndices.clear();
renderTracks();
return;
}
const sortedSel = [...selectedIndices].sort((a, b) => a - b);
const willDeleteCurrent = selectedIndices.has(currentIndex);
const playingTrack = currentIndex >= 0 ? tracks[currentIndex] : null;
for (const idx of sortedSel) URL.revokeObjectURL(tracks[idx].url);
for (let j = sortedSel.length - 1; j >= 0; j--) tracks.splice(sortedSel[j], 1);
if (willDeleteCurrent) {
if (tracks.length > 0) {
currentIndex = Math.min(sortedSel[0], tracks.length - 1);
playTrack(currentIndex);
} else {
currentIndex = -1;
renderTracks();
}
} else {
const removedBefore = sortedSel.filter(idx => idx < currentIndex).length;
currentIndex -= removedBefore;
renderTracks();
}
selectedIndices.clear();
}
function getSortFn(field) { function getSortFn(field) {
switch (field) { switch (field) {
case 'idx': return (a, b) => tracks.indexOf(a) - tracks.indexOf(b); case 'idx': return (a, b) => tracks.indexOf(a) - tracks.indexOf(b);
@ -966,10 +1014,93 @@ function renderTracks() {
trackCount.textContent = tracks.length; trackCount.textContent = tracks.length;
sorted.forEach((t, i) => { sorted.forEach((t, i) => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'track-item' + (t === tracks[currentIndex] ? ' active' : '');
const realIdx = tracks.indexOf(t); const realIdx = tracks.indexOf(t);
const classes = ['track-item'];
if (t === tracks[currentIndex]) classes.push('active');
if (selectedIndices.has(realIdx)) classes.push('selected');
div.className = classes.join(' ');
div.innerHTML = `<span class="col-idx">${realIdx + 1}</span><span class="col-title">${escHtml(t.name)}</span><span class="col-artist">${escHtml(t.artist || '-')}</span><span class="col-file">${escHtml(t.filename)}</span><span class="col-time">${t.playtime ? fmtTime(t.playtime) : '-'}</span><span class="col-genre">${escHtml(t.genre || '-')}</span>`; div.innerHTML = `<span class="col-idx">${realIdx + 1}</span><span class="col-title">${escHtml(t.name)}</span><span class="col-artist">${escHtml(t.artist || '-')}</span><span class="col-file">${escHtml(t.filename)}</span><span class="col-time">${t.playtime ? fmtTime(t.playtime) : '-'}</span><span class="col-genre">${escHtml(t.genre || '-')}</span>`;
div.addEventListener('click', () => playTrack(realIdx)); div.addEventListener('mousedown', (e) => {
if (e.detail > 1) return;
const ctrl = e.ctrlKey || e.metaKey;
const shift = e.shiftKey;
if (shift && anchorTrack) {
const sorted2 = [...tracks].sort(getSortFn(sortField));
if (!sortAsc) sorted2.reverse();
const anchorPos = sorted2.indexOf(anchorTrack);
const clickPos = i;
const lo = Math.min(anchorPos, clickPos);
const hi = Math.max(anchorPos, clickPos);
selectedIndices.clear();
for (let j = lo; j <= hi; j++) selectedIndices.add(tracks.indexOf(sorted2[j]));
renderTracks();
return;
}
if (ctrl) {
if (selectedIndices.has(realIdx)) selectedIndices.delete(realIdx);
else selectedIndices.add(realIdx);
anchorTrack = t;
renderTracks();
return;
}
if (!selectedIndices.has(realIdx)) {
selectedIndices.clear();
selectedIndices.add(realIdx);
anchorTrack = t;
renderTracks();
}
});
div.addEventListener('dblclick', () => {
selectedIndices.clear();
selectedIndices.add(realIdx);
anchorTrack = t;
playTrack(realIdx);
});
div.draggable = true;
div.addEventListener('dragstart', (e) => {
if (!selectedIndices.has(realIdx)) {
selectedIndices.clear();
selectedIndices.add(realIdx);
anchorTrack = t;
renderTracks();
}
dragIndices = new Set(selectedIndices);
isInternalDrag = true;
div.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
dragIndices = null;
isInternalDrag = false;
document.querySelectorAll('.track-item.drag-over').forEach(el => el.classList.remove('drag-over'));
});
div.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
document.querySelectorAll('.track-item.drag-over').forEach(el => el.classList.remove('drag-over'));
div.classList.add('drag-over');
});
div.addEventListener('dragleave', () => {
div.classList.remove('drag-over');
});
div.addEventListener('drop', (e) => {
e.preventDefault();
div.classList.remove('drag-over');
if (!dragIndices || dragIndices.size === 0 || dragIndices.has(realIdx)) return;
const indices = [...dragIndices].sort((a, b) => a - b);
const draggedTracks = indices.map(idx => tracks[idx]);
const wasPlaying = currentIndex >= 0 ? tracks[currentIndex] : null;
for (let j = indices.length - 1; j >= 0; j--) tracks.splice(indices[j], 1);
const targetIdx = tracks.indexOf(t);
tracks.splice(targetIdx, 0, ...draggedTracks);
if (wasPlaying) currentIndex = tracks.indexOf(wasPlaying);
selectedIndices.clear();
for (const tr of draggedTracks) selectedIndices.add(tracks.indexOf(tr));
anchorTrack = draggedTracks[0];
dragIndices = null;
renderTracks();
});
trackList.appendChild(div); trackList.appendChild(div);
}); });
const hasT = tracks.length > 0; const hasT = tracks.length > 0;
@ -1105,7 +1236,19 @@ audioElB.addEventListener('pause', onPause);
playBtn.addEventListener('click', () => { playBtn.addEventListener('click', () => {
if (tracks.length === 0) return; if (tracks.length === 0) return;
if (audio.paused) audio.play().catch(() => {}); else audio.pause(); const selArr = [...selectedIndices];
const sel = selArr.length > 0 ? selArr[0] : -1;
if (sel >= 0 && sel !== currentIndex) {
playTrack(sel);
} else if (currentIndex < 0 && sel >= 0) {
playTrack(sel);
} else if (currentIndex < 0) {
playTrack(0);
} else if (audio.paused) {
audio.play().catch(() => {});
} else {
audio.pause();
}
}); });
prevBtn.addEventListener('click', prevTrack); prevBtn.addEventListener('click', prevTrack);
@ -1151,7 +1294,8 @@ volumeSlider.addEventListener('input', () => {
addFolderBtn.addEventListener('click', addFolder); addFolderBtn.addEventListener('click', addFolder);
addFilesBtn.addEventListener('click', addFiles); addFilesBtn.addEventListener('click', addFiles);
clearBtn.addEventListener('click', clearTracks); isolateBtn.addEventListener('click', isolateTracks);
deleteBtn.addEventListener('click', deleteTrack);
sortSelect.addEventListener('change', () => { sortSelect.addEventListener('change', () => {
if (sortSelect.value === sortField) { sortAsc = !sortAsc; } if (sortSelect.value === sortField) { sortAsc = !sortAsc; }
@ -1168,9 +1312,11 @@ shuffleSelect.addEventListener('change', () => { shuffleOn = shuffleSelect.value
volDisplay.textContent = Math.round(parseFloat(volumeSlider.value) * 100); volDisplay.textContent = Math.round(parseFloat(volumeSlider.value) * 100);
let dragCounter = 0; let dragCounter = 0;
let isInternalDrag = false;
const content = document.querySelector('.content'); const content = document.querySelector('.content');
content.addEventListener('dragenter', (e) => { content.addEventListener('dragenter', (e) => {
if (isInternalDrag) return;
e.preventDefault(); e.preventDefault();
dragCounter++; dragCounter++;
content.style.outline = '2px dashed #1a6df5'; content.style.outline = '2px dashed #1a6df5';
@ -1178,14 +1324,19 @@ content.addEventListener('dragenter', (e) => {
}); });
content.addEventListener('dragleave', (e) => { content.addEventListener('dragleave', (e) => {
if (isInternalDrag) return;
e.preventDefault(); e.preventDefault();
dragCounter--; dragCounter--;
if (dragCounter <= 0) { dragCounter = 0; content.style.outline = 'none'; } if (dragCounter <= 0) { dragCounter = 0; content.style.outline = 'none'; }
}); });
content.addEventListener('dragover', (e) => e.preventDefault()); content.addEventListener('dragover', (e) => {
if (isInternalDrag) { e.preventDefault(); return; }
e.preventDefault();
});
content.addEventListener('drop', async (e) => { content.addEventListener('drop', async (e) => {
if (isInternalDrag) return;
e.preventDefault(); e.preventDefault();
dragCounter = 0; dragCounter = 0;
content.style.outline = 'none'; content.style.outline = 'none';