feat(web+android): Warteschlange bearbeiten — ziehen und entfernen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-22 20:31:08 +02:00
1 parent 77a3bb33f0
commit d5b5c8cf84
9 files changed
+330 -7

No files matched your search

+28 -1
View File
@@ -16,6 +16,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
| Music playback (Navidrome) | ✅ | ✅ |
| Seek bar / scrubbing | ✅ | ✅ |
| Queue management | ✅ | ✅ |
| Warteschlange bearbeiten (ziehen/löschen) | ✅ | ✅ |
| Shuffle / Repeat | ✅ | ✅ |
| Radio mode (similar songs) | ✅ | ✅ |
| Now Playing overlay | ✅ | ✅ |
@@ -79,7 +80,33 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
## [3.1] — 2026-09-22
**Android:** 1.15 (versionCode 18) → 1.18 (versionCode 21). 1.16 brachte den Rollbalken, 1.17 und 1.18 machten ihn tatsächlich bedienbar.
**Android:** 1.15 (versionCode 18) → 1.19 (versionCode 22). 1.16 brachte den Rollbalken, 1.17 und 1.18 machten ihn bedienbar, 1.19 bringt das Bearbeiten der Warteschlange.
### Added
- **Die Warteschlange lässt sich bearbeiten (Web + Android)** — bisher war sie eine reine Anzeige:
reinlegen ging, umsortieren oder wieder herausnehmen nicht. Jetzt trägt jede Zeile einen Griff
zum Ziehen und ein ✕ zum Entfernen, auf beiden Plattformen und in beiden Web-Listen (Seitenleiste
und Now-Playing).
- **Der Zeiger auf den laufenden Titel wandert mit.** Das ist der Teil, bei dem ein Fehler nicht
auffällt, sondern später den falschen Song spielt: fällt ein Titel **vor** dem laufenden weg,
rutscht dieser eine Stelle vor; trifft es den laufenden selbst, rückt der nächste nach (am
Ende der Liste der davor); beim Verschieben wird der Zeiger über die **Position** des laufenden
Titels neu bestimmt statt gerechnet — das stimmt auch, wenn er selbst gezogen wurde.
- Diese Rechnung liegt im Web als reines Modul ohne Importe in `src/player/queueMath.js`, damit
sie ohne DOM und ohne Wiedergabe prüfbar ist: **12 Fälle, alle grün.**
- **Web: Zeiger-Ereignisse statt HTML5-Drag-and-Drop.** Dessen `dragstart` gibt es auf
Touch-Geräten gar nicht — die Warteschlange wäre am Handy unbearbeitbar geblieben.
`setPointerCapture` bedient Maus und Finger über denselben Weg. Die Zeilen zwischen Start und
Ziel weichen aus, damit die Lücke dorthin wandert, wo man loslässt.
- **Android: getauscht wird laufend**, sobald der Finger eine Zeilenhöhe zurückgelegt hat — so
braucht es kein Geisterbild, die Liste zeigt die neue Reihenfolge sofort. Gezogen wird am
Griff, nicht an der Zeile, sonst ließen sich Ziehen und Scrollen nicht auseinanderhalten; und
der erste Fingerkontakt wird verbraucht, sonst beansprucht die `LazyColumn` die Geste (dieselbe
Falle wie beim Rollbalken).
- Im Player laufen die lokale Liste und Media3 im Gleichschritt (`moveMediaItem`,
`removeMediaItem`). Liefen sie auseinander, zeigte die Oberfläche den falschen Song an, weil
der laufende Index vom Controller kommt, der Titel dazu aber aus der lokalen Liste.
### Changed
- **Der Rollbalken ist greifbar (Web)** — er war 4 px breit. Sichtbar reichte das, mit der Maus war
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.novasonic.app"
minSdk = 26
targetSdk = 35
versionCode = 21
versionName = "1.18"
versionCode = 22
versionName = "1.19"
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
// Platzhalter stehen und der App-Link greift schlicht nicht.
@@ -204,6 +204,40 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
ctrl.addMediaItem(song.toMediaItem(repo))
}
// ── Warteschlange bearbeiten ────────────────────────────────────────────
// queue (lokal) und die MediaItems des Controllers muessen im Gleichschritt bleiben:
// syncState() liest den laufenden Index vom Controller, den Titel dazu aber aus queue.
// Liefen die beiden auseinander, zeigte die Oberflaeche den falschen Song an.
/** Entfernt Position [index]. Traf es den laufenden Titel, rueckt Media3 selbst weiter. */
fun entferneAusQueue(index: Int) {
val ctrl = controller ?: return
if (index !in queue.indices) return
val raus = queue[index]
queue = queue.toMutableList().apply { removeAt(index) }
// originalQueue haelt die Reihenfolge fuer das Zuruecknehmen des Mischens — dort denselben
// Titel EINMAL entfernen, sonst taucht er beim Entmischen wieder auf.
originalQueue = originalQueue.toMutableList().apply {
val i = indexOfFirst { it.id == raus.id }
if (i >= 0) removeAt(i)
}
ctrl.removeMediaItem(index)
if (queue.isEmpty()) {
ctrl.stop()
ctrl.clearMediaItems()
}
syncState()
}
/** Verschiebt Position [von] auf [nach]. */
fun verschiebeInQueue(von: Int, nach: Int) {
val ctrl = controller ?: return
if (von == nach || von !in queue.indices || nach !in queue.indices) return
queue = queue.toMutableList().apply { add(nach, removeAt(von)) }
ctrl.moveMediaItem(von, nach)
syncState()
}
fun toggleRepeat() {
val ctrl = controller ?: return
ctrl.repeatMode = when (ctrl.repeatMode) {
@@ -60,6 +60,11 @@ import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.LyricsViewModel
import kotlinx.coroutines.launch
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.ui.input.pointer.positionChange
private enum class NpTab { PLAYER, LYRICS, QUEUE }
@@ -573,18 +578,38 @@ fun NowPlayingScreen(
color = Color.White.copy(0.4f))
}
} else {
// ── Warteschlange bearbeiten ────────────────────────────
// Gezogen wird am Griff, nicht an der Zeile: die Liste selbst scrollt, und
// beides an derselben Stelle liesse sich nicht auseinanderhalten.
// Getauscht wird LAUFEND, sobald der Finger eine Zeilenhoehe zurueckgelegt
// hat — dadurch braucht es kein Geisterbild, die Liste zeigt die neue
// Reihenfolge sofort.
val zeilePx = with(LocalDensity.current) { 56.dp.toPx() }
var ziehIdx by remember { mutableIntStateOf(-1) }
var ziehOffset by remember { mutableFloatStateOf(0f) }
val queueLaenge = rememberUpdatedState(state.queue.size)
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(vertical = 4.dp),
) {
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
val active = state.currentIndex == idx
val wirdGezogen = ziehIdx == idx
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
if (wirdGezogen) { translationY = ziehOffset; shadowElevation = 12f }
}
.clip(RoundedCornerShape(10.dp))
.background(if (active) Color.White.copy(0.08f) else Color.Transparent)
.background(
when {
wirdGezogen -> Color.White.copy(0.16f)
active -> Color.White.copy(0.08f)
else -> Color.Transparent
}
)
.clickable { controller.seekToItem(idx) }
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
@@ -612,6 +637,53 @@ fun NowPlayingScreen(
Icon(Icons.AutoMirrored.Filled.VolumeUp, null,
tint = NsAccent, modifier = Modifier.size(16.dp))
}
IconButton(
onClick = { controller.entferneAusQueue(idx) },
modifier = Modifier.size(32.dp),
) {
Icon(Icons.Default.Close, "Aus der Warteschlange entfernen",
tint = Color.White.copy(0.45f), modifier = Modifier.size(16.dp))
}
Icon(
Icons.Default.DragHandle, "Zum Verschieben ziehen",
tint = Color.White.copy(if (wirdGezogen) 0.9f else 0.45f),
modifier = Modifier
.size(32.dp)
.padding(6.dp)
.pointerInput(Unit) {
awaitEachGesture {
// Der erste Kontakt wird sofort verbraucht, sonst
// beansprucht die LazyColumn die Geste, sobald die
// Wischschwelle faellt.
val start = awaitFirstDown(requireUnconsumed = false)
start.consume()
var aktuell = idx
var rest = 0f
ziehIdx = aktuell
try {
while (true) {
val ev = awaitPointerEvent()
val p = ev.changes.firstOrNull { it.id == start.id } ?: break
if (!p.pressed) break
rest += p.positionChange().y
p.consume()
while (rest >= zeilePx && aktuell < queueLaenge.value - 1) {
controller.verschiebeInQueue(aktuell, aktuell + 1)
aktuell++; rest -= zeilePx
}
while (rest <= -zeilePx && aktuell > 0) {
controller.verschiebeInQueue(aktuell, aktuell - 1)
aktuell--; rest += zeilePx
}
ziehIdx = aktuell
ziehOffset = rest
}
} finally {
ziehIdx = -1; ziehOffset = 0f
}
}
},
)
}
}
}
+4 -1
View File
@@ -5,6 +5,7 @@ import { coverUrl } from '../api.js';
import { playCurrent, _syncNpDirect, dlSongLocal, npToggleStar, _syncOverlayState, isPlaying, seekToMs, updatePlayerUI } from './player.js';
import { navigate } from '../views/views.js';
import { openPlModal, showSongInfo } from '../ui/ui.js';
import { macheQueueBearbeitbar, QUEUE_WERKZEUGE } from './queueEdit.js';
// ─── NOW PLAYING OVERLAY ───
export function openNowPlaying() {
@@ -313,11 +314,13 @@ export function renderNpQueue() {
S.queue.forEach((s, i) => {
const item = document.createElement('div');
item.className = 'np-queue-item' + (i===S.qIdx ? ' nq-active' : '');
item.dataset.qidx = i;
const cv = coverUrl(s.coverArt, 128);
item.innerHTML = `<div class="nqi-num">${i+1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist||'')}</div></div>`;
item.innerHTML = `<div class="nqi-num">${i+1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist||'')}</div></div>` + QUEUE_WERKZEUGE;
item.onclick = () => { S.qIdx=i; playCurrent(); renderNpQueue(); };
el.appendChild(item);
});
macheQueueBearbeitbar('np-queue-list');
el.querySelector('.nq-active')?.scrollIntoView({block:'nearest'});
}
+43 -2
View File
@@ -3,8 +3,10 @@ import { esc, formatDur, toast } from '../utils.js';
import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from '../api.js';
// views.js/ui.js circular imports — safe (only used inside function bodies)
import { loadRecommendations } from '../views/views.js';
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js';
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying, renderNpQueue } from './nowplaying.js';
import { logPlay } from './scrobbleLog.js';
import { macheQueueBearbeitbar, QUEUE_WERKZEUGE } from './queueEdit.js';
import { queueNachEntfernen, queueNachVerschieben } from './queueMath.js';
// ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
// True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl).
@@ -1187,14 +1189,53 @@ export function renderQueue() {
S.queue.forEach((s, i) => {
const item = document.createElement('div');
item.className = 'queue-item' + (i === S.qIdx ? ' active' : '');
item.dataset.qidx = i;
const cv = coverUrl(s.coverArt, 128);
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(dispTitle(s))}</div><div class="qi-artist">${esc(dispArtist(s))}</div></div>`;
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(dispTitle(s))}</div><div class="qi-artist">${esc(dispArtist(s))}</div></div>` + QUEUE_WERKZEUGE;
item.onclick = () => { S.qIdx = i; playCurrent(); };
el.appendChild(item);
});
macheQueueBearbeitbar('queue-list');
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
}
// ─── WARTESCHLANGE BEARBEITEN ─────────────────────────────────────────────
// S.queue ist eine einfache Liste, S.qIdx zeigt auf den laufenden Titel. Beim Aendern muss der
// Zeiger mitwandern — sonst spielt nach dem Entfernen eines Titels weiter oben ploetzlich der
// falsche. Gemerkt wird deshalb die ID des laufenden Titels, nicht seine Position.
/** Entfernt den Titel an Position i. Traf es den laufenden, laeuft der naechste an. */
export function queueEntfernen(i) {
if (i < 0 || i >= S.queue.length) return;
const { qIdx, warAktuell } = queueNachEntfernen(S.queue.length, S.qIdx, i);
S.queue.splice(i, 1);
S.qIdx = qIdx;
_renderedQueueKey = '';
if (!S.queue.length) {
S.qIdx = -1;
try { S._audioEls?.forEach(a => { a.pause(); }); } catch {}
setPlayIcon(false);
renderQueue(); renderNpQueue();
toast('Warteschlange geleert');
return;
}
if (warAktuell) playCurrent();
renderQueue(); renderNpQueue();
}
/** Verschiebt den Titel von Position `von` auf Position `nach`. */
export function queueVerschieben(von, nach) {
const n = S.queue.length;
if (von === nach || von < 0 || von >= n || nach < 0 || nach >= n) return;
const { qIdx } = queueNachVerschieben(S.queue.map(x => x.id), S.qIdx, von, nach);
const [titel] = S.queue.splice(von, 1);
S.queue.splice(nach, 0, titel);
S.qIdx = qIdx;
_renderedQueueKey = '';
renderQueue(); renderNpQueue();
}
export function toggleQueue() {
S.queueOpen = !S.queueOpen;
document.getElementById('queue-panel').classList.toggle('open', S.queueOpen);
+90
View File
@@ -0,0 +1,90 @@
import { queueEntfernen, queueVerschieben } from './player.js';
// ─── WARTESCHLANGE BEARBEITEN (Web) ─────────────────────────────────────────
// Ziehen und Entfernen fuer beide Queue-Listen: die Seitenleiste und die im Now-Playing.
//
// Bewusst ueber Zeiger-Ereignisse statt HTML5-Drag-and-Drop: dessen `dragstart` gibt es auf
// Touch-Geraeten gar nicht, die Warteschlange waere am Handy also unbearbeitbar geblieben.
// `setPointerCapture` liefert dafuer Maus und Finger ueber denselben Weg.
const ZIEH_SCHWELLE = 4; // px, bevor aus einem Tippen ein Ziehen wird
export function macheQueueBearbeitbar(listeId) {
const liste = document.getElementById(listeId);
if (!liste || liste.dataset.bearbeitbar) return;
liste.dataset.bearbeitbar = '1';
liste.addEventListener('click', (e) => {
const weg = e.target.closest('.q-del');
if (!weg) return;
e.stopPropagation();
const zeile = weg.closest('[data-qidx]');
if (zeile) queueEntfernen(Number(zeile.dataset.qidx));
});
liste.addEventListener('pointerdown', (e) => {
const griff = e.target.closest('.q-grip');
if (!griff || e.button > 0) return;
const zeile = griff.closest('[data-qidx]');
if (!zeile) return;
e.preventDefault();
e.stopPropagation();
ziehen(liste, zeile, e);
});
}
function ziehen(liste, zeile, start) {
const zeilen = [...liste.querySelectorAll('[data-qidx]')];
const vonIdx = Number(zeile.dataset.qidx);
const hoehe = zeile.offsetHeight || 1;
const startY = start.clientY;
let zielIdx = vonIdx;
let aktiv = false;
const bewegen = (e) => {
const dy = e.clientY - startY;
if (!aktiv) {
if (Math.abs(dy) < ZIEH_SCHWELLE) return;
aktiv = true;
zeile.classList.add('q-dragging');
liste.classList.add('q-dragging-aktiv');
}
zeile.style.transform = `translateY(${dy}px)`;
zielIdx = Math.max(0, Math.min(zeilen.length - 1, vonIdx + Math.round(dy / hoehe)));
// Die Zeilen zwischen Start und Ziel weichen um eine Zeilenhoehe aus, damit die Luecke
// dorthin wandert, wo losgelassen wird.
zeilen.forEach((z, i) => {
if (z === zeile) return;
let v = 0;
if (vonIdx < zielIdx && i > vonIdx && i <= zielIdx) v = -hoehe;
else if (vonIdx > zielIdx && i >= zielIdx && i < vonIdx) v = hoehe;
z.style.transform = v ? `translateY(${v}px)` : '';
});
};
const beenden = () => {
liste.releasePointerCapture?.(start.pointerId);
window.removeEventListener('pointermove', bewegen);
window.removeEventListener('pointerup', beenden);
window.removeEventListener('pointercancel', beenden);
zeilen.forEach(z => { z.style.transform = ''; z.classList.remove('q-dragging'); });
liste.classList.remove('q-dragging-aktiv');
if (aktiv && zielIdx !== vonIdx) queueVerschieben(vonIdx, zielIdx);
};
try { liste.setPointerCapture(start.pointerId); } catch {}
window.addEventListener('pointermove', bewegen);
window.addEventListener('pointerup', beenden);
window.addEventListener('pointercancel', beenden);
}
/** Griff und Entfernen-Knopf als HTML — in beiden Listen identisch. */
export const QUEUE_WERKZEUGE =
'<button class="q-del" title="Aus der Warteschlange entfernen" aria-label="Entfernen">' +
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' +
'</button>' +
'<span class="q-grip" title="Zum Verschieben ziehen" aria-label="Verschieben">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/></svg>' +
'</span>';
+37
View File
@@ -0,0 +1,37 @@
// Reine Indexrechnung der Warteschlange — bewusst OHNE Importe, damit sie sich ohne DOM und
// ohne Wiedergabe pruefen laesst. Hier steckt der Teil, bei dem ein Fehler nicht auffaellt,
// sondern spaeter den falschen Titel spielt.
/**
* Entfernt Position `i`. Liefert den neuen Zeiger und ob der LAUFENDE Titel getroffen wurde.
* Der Zeiger wandert mit: faellt ein Titel VOR dem laufenden weg, rutscht dieser eine Stelle vor.
*/
export function queueNachEntfernen(laenge, qIdx, i) {
if (i < 0 || i >= laenge) return { qIdx, warAktuell: false, leer: laenge === 0 };
const warAktuell = i === qIdx;
const neueLaenge = laenge - 1;
if (neueLaenge === 0) return { qIdx: -1, warAktuell, leer: true };
let neu = qIdx;
if (!warAktuell && i < qIdx) neu--;
// Traf es den laufenden Titel, zeigt der Index jetzt auf den nachrueckenden — am Ende der
// Liste gibt es keinen, dann auf den davor.
if (warAktuell) neu = Math.min(neu, neueLaenge - 1);
return { qIdx: neu, warAktuell, leer: false };
}
/**
* Verschiebt `von` nach `nach` und liefert die neue Reihenfolge samt nachgefuehrtem Zeiger.
* Der Zeiger wird ueber die POSITION des laufenden Titels neu bestimmt, nicht gerechnet — das
* stimmt auch, wenn er selbst gezogen wurde oder ein anderer ueber ihn hinweggezogen ist.
*/
export function queueNachVerschieben(ids, qIdx, von, nach) {
const n = ids.length;
if (von === nach || von < 0 || von >= n || nach < 0 || nach >= n) return { ids, qIdx };
const laufende = ids[qIdx];
const neu = ids.slice();
const [x] = neu.splice(von, 1);
neu.splice(nach, 0, x);
const gefunden = neu.indexOf(laufende);
return { ids: neu, qIdx: gefunden >= 0 ? gefunden : qIdx };
}
+19
View File
@@ -1228,3 +1228,22 @@ button:focus{outline:none}
.dublette-box .btn-danger{width:100%;padding:9px 14px;background:transparent;border:1px solid var(--danger);border-radius:var(--radius-sm);color:var(--danger);font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;transition:background .2s}
.dublette-box .btn-danger:hover:not(:disabled){background:rgba(224,85,85,.12)}
.dublette-box .btn-danger:disabled{opacity:.55;cursor:default}
/* ── Warteschlange bearbeiten ── */
/* Griff und Entfernen-Knopf sitzen am rechten Rand beider Queue-Listen. Am Zeigergeraet
erscheinen sie erst beim Ueberfahren, damit die Liste ruhig bleibt; auf Touch-Geraeten gibt es
kein Ueberfahren, dort stehen sie immer da. */
.queue-item,.np-queue-item{position:relative}
.q-del,.q-grip{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:26px;height:26px;border:none;background:none;color:var(--text3);border-radius:6px;cursor:pointer;opacity:0;transition:opacity .15s,color .15s,background .15s}
.q-grip{cursor:grab;touch-action:none}
.queue-item:hover .q-del,.queue-item:hover .q-grip,
.np-queue-item:hover .q-del,.np-queue-item:hover .q-grip{opacity:1}
.q-del:hover{color:var(--danger);background:rgba(224,85,85,.12)}
.q-grip:hover{color:var(--text)}
@media(hover:none){.q-del,.q-grip{opacity:.65}}
/* Die gezogene Zeile schwebt ueber den anderen; die weichenden gleiten, sie nicht — sonst
haengt sie dem Finger hinterher. */
.q-dragging{z-index:5;background:var(--surface2)!important;box-shadow:0 8px 24px rgba(0,0,0,.45);cursor:grabbing}
.q-dragging .q-grip{cursor:grabbing}
.q-dragging-aktiv [data-qidx]:not(.q-dragging){transition:transform .16s ease}
.q-dragging-aktiv{user-select:none}