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 = `
${i+1}
${cv ? `` : '♪'}
${esc(s.title)}
${esc(s.artist||'')}
`; + item.innerHTML = `
${i+1}
${cv ? `` : '♪'}
${esc(s.title)}
${esc(s.artist||'')}
` + QUEUE_WERKZEUGE; item.onclick = () => { S.qIdx=i; playCurrent(); renderNpQueue(); }; el.appendChild(item); }); + macheQueueBearbeitbar('np-queue-list'); el.querySelector('.nq-active')?.scrollIntoView({block:'nearest'}); } diff --git a/src/player/player.js b/src/player/player.js index 1bfcf70..c8b32f3 100644 --- a/src/player/player.js +++ b/src/player/player.js @@ -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 = `
${i+1}
${cv ? `` : '♪'}
${esc(dispTitle(s))}
${esc(dispArtist(s))}
`; + item.innerHTML = `
${i+1}
${cv ? `` : '♪'}
${esc(dispTitle(s))}
${esc(dispArtist(s))}
` + 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}