feat(android): Songs loeschen und Playlist aus dem Import, plus Web-Scrollfix
Android holt zwei Sachen nach, die es im Web laengst gibt: - Song von der Festplatte loeschen, im Kontextmenue unten abgesetzt und rot, mit derselben Sicherheitsabfrage wie im Web. Laeuft ueber /api/delete-song und erscheint nur mit konfiguriertem Backend — Navidrome allein loescht nichts. - Playlist direkt aus dem Import anlegen: Haekchen plus Namensfeld ab zwei Titeln, danach Scan anstossen, Titel suchen, Playlist aus den gefundenen. Nicht gefundene werden gezaehlt statt die Erstellung scheitern zu lassen. Dazu der Web-Fix zur Album-Position: die gespeicherte Scrollposition wurde in genau einem requestAnimationFrame gesetzt. Solange Cover und Eintraege noch nachluden, war scrollHeight kleiner als das Ziel und der Browser kappte den Wert stillschweigend — man landete wieder oben. Jetzt wird er ueber bis zu ~0,5 s erneut gesetzt, bis er sitzt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a05c15df93
commit
aae358c4c7
12 files changed
+1031
-12
No files matched your search
@@ -8,6 +8,7 @@ import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@@ -54,5 +55,18 @@ class ImporterClient(private val backendUrl: String) {
|
||||
suspend fun status(jobId: String): DownloadStatus =
|
||||
http.get("${base()}/api/status/$jobId").body()
|
||||
|
||||
/**
|
||||
* Loescht einen Song endgueltig von der Platte — derselbe Endpunkt, den der Web-Client nutzt.
|
||||
* Das Backend braucht die Navidrome-Zugangsdaten, weil es den Dateipfad ueber getSong aufloest.
|
||||
* Wirft bei HTTP != 2xx (Ktor `expectSuccess` ist hier aus, deshalb der explizite Check).
|
||||
*/
|
||||
suspend fun deleteSong(ndUrl: String, user: String, pass: String, songId: String) {
|
||||
val res = http.post("${base()}/api/delete-song") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(DeleteSongRequest(ndUrl.trimEnd('/'), user, pass, songId))
|
||||
}
|
||||
if (!res.status.isSuccess()) error("HTTP ${res.status.value}")
|
||||
}
|
||||
|
||||
fun close() = http.close()
|
||||
}
|
||||
@@ -163,6 +163,9 @@ class SubsonicClient(
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) =
|
||||
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
|
||||
|
||||
/** Stoesst Navidromes Bibliotheks-Scan an — nach einem Import, damit frische Dateien auffindbar sind. */
|
||||
suspend fun startScan() = get("startScan.view")
|
||||
|
||||
suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs {
|
||||
val params = mutableListOf("name" to name)
|
||||
songIds.forEach { params += "songId" to it }
|
||||
|
||||
@@ -42,6 +42,15 @@ data class DownloadRequest(
|
||||
@Serializable
|
||||
data class ResolveRequest(val url: String)
|
||||
|
||||
/** Body fuer POST {backend}/api/delete-song — loescht die Datei von der Platte (wie im Web). */
|
||||
@Serializable
|
||||
data class DeleteSongRequest(
|
||||
val ndUrl: String,
|
||||
val user: String,
|
||||
val pass: String,
|
||||
val songId: String,
|
||||
)
|
||||
|
||||
/** Response from GET {backend}/api/status/{jobId} */
|
||||
@Serializable
|
||||
data class DownloadStatus(
|
||||
|
||||
@@ -106,6 +106,7 @@ class MusicRepository(private val context: Context) {
|
||||
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
|
||||
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
|
||||
suspend fun startScan() = api().startScan()
|
||||
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) = api().updatePlaylistMeta(playlistId, name, comment)
|
||||
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) = api().removeSongsFromPlaylist(playlistId, indices)
|
||||
suspend fun deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId)
|
||||
|
||||
@@ -128,6 +128,7 @@ fun AppNavigation() {
|
||||
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
|
||||
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
|
||||
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
|
||||
var songToDelete by remember { mutableStateOf<Song?>(null) }
|
||||
var showNotifications by remember { mutableStateOf(false) }
|
||||
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
|
||||
var showAbsPlayer by remember { mutableStateOf(false) }
|
||||
@@ -582,6 +583,44 @@ fun AppNavigation() {
|
||||
Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
contextMenuSong = null
|
||||
},
|
||||
// Nur mit Backend: das Loeschen laeuft ueber /api/delete-song, Navidrome allein
|
||||
// kann keine Dateien entfernen.
|
||||
onDelete = if (appSettings.backendUrl.isNotBlank()) {
|
||||
{ songToDelete = song; contextMenuSong = null }
|
||||
} else null,
|
||||
)
|
||||
}
|
||||
|
||||
// Sicherheitsabfrage vor dem endgueltigen Loeschen — Wortlaut wie im Web.
|
||||
songToDelete?.let { song ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { songToDelete = null },
|
||||
title = { Text("Song löschen") },
|
||||
text = { Text("\"${song.title}\" wird unwiderruflich von der Festplatte gelöscht. " +
|
||||
"Leere Album- und Künstlerordner werden ebenfalls entfernt.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
songToDelete = null
|
||||
scope.launch {
|
||||
val creds = repo.lastCredentials
|
||||
val client = com.novasonic.app.api.ImporterClient(appSettings.backendUrl)
|
||||
val res = runCatching {
|
||||
client.deleteSong(creds?.serverUrl ?: "", creds?.username ?: "",
|
||||
creds?.password ?: "", song.id)
|
||||
}
|
||||
client.close()
|
||||
Toast.makeText(
|
||||
context,
|
||||
res.fold({ "🗑 \"${song.title}\" gelöscht" },
|
||||
{ "Löschen fehlgeschlagen: ${it.message}" }),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
// Navidrome braucht einen Moment zum Nachscannen, dann die Ansicht neu laden.
|
||||
if (res.isSuccess) { kotlinx.coroutines.delay(3000); homeRefreshKey++ }
|
||||
}
|
||||
}) { Text("Löschen", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { songToDelete = null }) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ fun SongContextSheet(
|
||||
onGoToAlbum: (() -> Unit)? = null,
|
||||
onShowInfo: (() -> Unit)? = null,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -79,6 +80,15 @@ fun SongContextSheet(
|
||||
onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) }
|
||||
}
|
||||
|
||||
// Loeschen steht bewusst unten, abgesetzt und rot: es entfernt die Datei unwiderruflich von
|
||||
// der Platte. Die Sicherheitsabfrage sitzt beim Aufrufer, wie im Web auch.
|
||||
onDelete?.let {
|
||||
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
|
||||
CtxItem(Icons.Default.DeleteForever, "Von Festplatte löschen",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
onClick = { onDismiss(); it() })
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,38 @@ fun ImporterScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist aus dem Import anlegen — wie im Web-Importer. Nur ab zwei Titeln sinnvoll;
|
||||
// ein einzelner Track ergibt keine Playlist.
|
||||
if (r.tracks.size > 1) {
|
||||
item {
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = state.createPlaylist,
|
||||
onCheckedChange = vm::setCreatePlaylist,
|
||||
colors = CheckboxDefaults.colors(checkedColor = brand,
|
||||
checkmarkColor = Color.Black),
|
||||
)
|
||||
Text("Playlist daraus anlegen", fontFamily = Outfit, fontSize = 14.sp,
|
||||
color = NsOnSurface)
|
||||
}
|
||||
if (state.createPlaylist) {
|
||||
OutlinedTextField(
|
||||
value = state.playlistName.ifBlank { r.name ?: "" },
|
||||
onValueChange = vm::setPlaylistName,
|
||||
label = { Text("Playlist-Name", fontFamily = Outfit) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
state.playlistStatus?.let {
|
||||
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download button / job progress
|
||||
item {
|
||||
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
|
||||
|
||||
@@ -29,6 +29,11 @@ data class ImporterUiState(
|
||||
val jobTotal: Int = 0,
|
||||
// 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null
|
||||
val previewingId: Long? = null,
|
||||
|
||||
// Playlist aus dem Import anlegen (wie im Web-Importer). Nur sinnvoll ab zwei Titeln.
|
||||
val createPlaylist: Boolean = true,
|
||||
val playlistName: String = "",
|
||||
val playlistStatus: String? = null,
|
||||
)
|
||||
|
||||
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
@@ -69,6 +74,9 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
fun stopPreview() = preview.stop()
|
||||
|
||||
fun setCreatePlaylist(v: Boolean) { _state.value = _state.value.copy(createPlaylist = v) }
|
||||
fun setPlaylistName(v: String) { _state.value = _state.value.copy(playlistName = v) }
|
||||
|
||||
fun onLink(v: String) {
|
||||
_state.value = _state.value.copy(
|
||||
link = v,
|
||||
@@ -173,6 +181,11 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
)
|
||||
if (st.status == "done" || st.status == "error") {
|
||||
_state.value = _state.value.copy(isDownloading = false)
|
||||
val cur = _state.value
|
||||
if (st.status == "done" && cur.createPlaylist && cur.resolved!!.tracks.size > 1) {
|
||||
buildPlaylist(cur.playlistName.ifBlank { cur.resolved.name ?: "Import" },
|
||||
cur.resolved.tracks)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -182,6 +195,47 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt nach dem Import eine Navidrome-Playlist aus den importierten Titeln an — dieselbe Abfolge
|
||||
* wie im Web: Scan anstossen, kurz warten, jeden Titel suchen, gefundene IDs in eine neue
|
||||
* Playlist. Titel, die Navidrome (noch) nicht kennt, werden gezaehlt statt zu scheitern.
|
||||
*/
|
||||
private suspend fun buildPlaylist(name: String, tracks: List<ImportTrack>) {
|
||||
_state.value = _state.value.copy(playlistStatus = "Navidrome scannt…")
|
||||
runCatching { repo.startScan() }
|
||||
delay(4000)
|
||||
|
||||
val norm = { s: String -> s.lowercase().replace(Regex("[^\\p{L}\\p{N}]+"), "") }
|
||||
val ids = mutableListOf<String>()
|
||||
var fehlend = 0
|
||||
for (t in tracks) {
|
||||
val titel = t.trackName ?: continue
|
||||
val treffer = runCatching {
|
||||
val q = listOfNotNull(t.artistName, titel).joinToString(" ")
|
||||
val gefunden = repo.search(q).song
|
||||
gefunden.firstOrNull { norm(it.title) == norm(titel) }
|
||||
?: repo.search(titel).song.firstOrNull { norm(it.title) == norm(titel) }
|
||||
}.getOrNull()
|
||||
if (treffer != null) ids += treffer.id else fehlend++
|
||||
_state.value = _state.value.copy(
|
||||
playlistStatus = "Playlist: ${ids.size}/${tracks.size} gefunden…")
|
||||
}
|
||||
|
||||
if (ids.isEmpty()) {
|
||||
_state.value = _state.value.copy(
|
||||
playlistStatus = "Keine Titel in Navidrome gefunden — Playlist nicht angelegt.")
|
||||
return
|
||||
}
|
||||
val res = runCatching { repo.createPlaylist(name, ids) }
|
||||
_state.value = _state.value.copy(
|
||||
playlistStatus = res.fold(
|
||||
{ "Playlist \"$name\" angelegt: ${ids.size}/${tracks.size} Titel" +
|
||||
if (fehlend > 0) " ($fehlend nicht gefunden)" else "" },
|
||||
{ "Playlist fehlgeschlagen: ${it.message}" },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapError(code: String): String = when (code) {
|
||||
"EDITORIAL_PLAYLIST" ->
|
||||
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
|
||||
|
||||
Reference in new issue
Block a user