diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2fe8444..c82cc04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 6f86b72..97249ba 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -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.
diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt
index f720445..22d6ee8 100644
--- a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt
+++ b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt
@@ -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) {
diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt
index a00655f..be03e55 100644
--- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt
+++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt
@@ -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
+ }
+ }
+ },
+ )
}
}
}
diff --git a/src/player/nowplaying.js b/src/player/nowplaying.js
index e1e4a86..1790313 100644
--- a/src/player/nowplaying.js
+++ b/src/player/nowplaying.js
@@ -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 = `
` + 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);
diff --git a/src/player/queueEdit.js b/src/player/queueEdit.js
new file mode 100644
index 0000000..42930a4
--- /dev/null
+++ b/src/player/queueEdit.js
@@ -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 =
+ '' +
+ '' +
+ '' +
+ '';
diff --git a/src/player/queueMath.js b/src/player/queueMath.js
new file mode 100644
index 0000000..203515b
--- /dev/null
+++ b/src/player/queueMath.js
@@ -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 };
+}
diff --git a/styles.css b/styles.css
index f477dcc..ef23c3c 100644
--- a/styles.css
+++ b/styles.css
@@ -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}