feat(android): playlist mgmt, album sort, home hero parity, notification fix

- Playlist: create (FAB + dialog), rename, delete, remove songs, cover upload/remove
- Library Albums tab: sort chips (Neueste/Älteste/A-Z/Künstler) matching StarredScreen
- ABS notification: shows current chapter title; updates live without re-buffering
- Home hero: pool now matches web (frequent(5) ∪ recentlyPlayed(5)); label MEISTGESPIELT / ZULETZT GEHÖRT
- Home: added Zuletzt gehört section (type=recent) matching web layout
- Notifications: fixed delivery bug — polling moved to NotificationStore app-lifetime scope so notifications arrive even after user navigates away from ImporterScreen
- NotificationStore.startPoll() extended with navType/navAlbum/navArtist/navId params
- Removed defunct AbsPlayerService.kt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-18 19:04:36 +02:00
1 parent eacce487df
commit 6be7981b8a
16 files changed
+970 -411

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
# Changelog
All notable changes to NovaSonic are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
Versioning: `MAJOR.MINOR.PATCH` — minor = new feature, patch = fix/improvement.
---
## Platform Parity Matrix
| Feature | Web (PWA) | Android |
|----------------------------------|:---------:|:-------:|
| Music playback (Navidrome) | ✅ | ✅ |
| Seek bar / scrubbing | ✅ | ✅ |
| Queue management | ✅ | ✅ |
| Shuffle / Repeat | ✅ | ✅ |
| Radio mode (similar songs) | ✅ | ✅ |
| Now Playing overlay | ✅ | ✅ |
| Dominant color background | ✅ | ✅ |
| Cover art scale animation | ❌ | ✅ |
| Synced lyrics (LRCLIB) | ✅ | ✅ |
| Lyrics autoscroll | ✅ | ✅ |
| Star / favorite song | ✅ | ✅ |
| Multi-artist navigation | ✅ | ✅ |
| Album detail page | ✅ | ✅ |
| Artist page (Navidrome) | ✅ | ✅ |
| Artist page (iTunes fallback) | ✅ | ❌ |
| Concert events (Ticketmaster) | ✅ | ✅ |
| Genre browsing | ✅ | ✅ |
| Global search | ✅ | ✅ |
| Playlist browsing | ✅ | ✅ |
| Playlist create/edit | ✅ | ✅ |
| Playlist cover upload | ✅ | ✅ |
| Spotify / YouTube importer | ✅ | ✅ |
| Import notification | ✅ | ✅ |
| Import notification → nav | ✅ | ✅ |
| ABS (Audiobookshelf) playback | ✅ | ✅ |
| ABS chapter navigation | ✅ | ✅ |
| ABS sleep timer | ✅ | ✅ |
| ABS playback speed | ✅ | ✅ |
| ABS library search | ✅ | ✅ |
| ABS book descriptions (HTML) | ✅ | ✅ |
| Media notification | ✅ | ✅ |
| ABS notification (modern style) | ❌ | ✅ |
| Android Auto | — | ✅ |
| Romaji / Japanese sort | ✅ | ❌ |
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
---
## [0.7.0] — 2026-06-18
### Added
- **Android** — Playlist create: "+" FAB on the Playlists tab opens a dialog to create a new playlist
- **Android** — Playlist rename: pencil icon in the playlist hero opens a rename dialog (pre-filled with current name)
- **Android** — Playlist delete: trash icon in the playlist hero with confirmation dialog; navigates back to library on success
- **Android** — Playlist song removal: edit-mode toggle button in the playlist action row reveals a "−" button per song; tapping removes the song from the playlist via Navidrome API (`updatePlaylist.view?songIndexToRemove`)
- **Android** — ABS notification: current chapter name shown as the main title; book title moves to the artist line; updates automatically on chapter change without interrupting playback
- **Android** — Home "Zuletzt gehört" section: recently played albums row added below "Zuletzt hinzugefügt", matching the web's `type=recent` row
### Fixed
- **Android** — In-app notification bell: import/download notifications were never delivered after navigating away from the Importer screen because the poll loop ran in `viewModelScope` (cancelled on navigation); polling now delegates to `NotificationStore.startPoll()` which uses an app-lifetime `CoroutineScope` — notifications are delivered regardless of screen
- **Android** — Home hero card: pool now matches web exactly — union of `frequent(5) ∪ recentlyPlayed(5)` deduped, random pick; label is "MEISTGESPIELT" or "ZULETZT GEHÖRT" depending on which list the album came from (was hardcoded "AKTUELL")
- **Android** — Artist screen crash: duplicate key in `LazyColumn` when Navidrome returns the same song ID more than once in `topSongs`; key now uses `idx_id`
- **Android** — "Alle Alben anzeigen" crash: duplicate key in `LazyGrid` when two albums share the same name + year; key now uses `idx_name_year`
- **Android** — "Alle Alben anzeigen": albums were left-aligned inside grid cells; now centered
- **Android** — "Alle Alben anzeigen" search bar now matches the app design (`OutlinedTextField`, `RoundedCornerShape(12.dp)`, accent color border — same as `ExploreScreen`)
- **Android** — ABS notification button order: Samsung One UI slot mapping was causing wrong order; buttons now show Kapitel zurück | 30s zurück | Play/Pause | 30s vor | Kapitel vor as intended
---
## [0.6.0] — 2026-06-17
### Added
- **Android** — Lyrics preload on NowPlaying open; lyrics load in background while on Player tab, so switching to Lyrics tab is instant
### Fixed
- **Android** — Home hero card: play button now sits to the right of title/artist, not below
- **Android** — ABS media notification now uses Media3's modern media card style (blurred cover art background) instead of a plain notification; identical look to the music notification
- **Android** — ABS notification buttons corrected: now shows exactly Kapitel zurück | 30s zurück | Play/Pause | 30s vor | Kapitel vor in both compact and expanded view; previously showed double back-button and 30s-forward was cut off
- **Android** — Lyrics autoscroll switches from fixed tween to spring animation (DampingRatioNoBouncy, StiffnessLow); rapid line changes no longer produce choppy stop-and-restart scrolling
### Internal
- **Android** — ABS notification rebuilt using a custom `DefaultMediaNotificationProvider` subclass (`AbsAwareNotificationProvider`) registered via `setMediaNotificationProvider()`; overrides `getMediaButtons()` to return exactly 5 session-command buttons when ABS is active, bypassing `setCustomLayout()` which only appends to (not replaces) the standard skip-prev/play-pause/skip-next buttons
- **Android** — Removed abandoned `AbsPlayerService.kt` (unreferenced, unregistered, referenced missing drawable)
---
## [0.5.0] — 2026-06
### Added
- **Android** — ABS full playback: chapter list, playback speed control, sleep timer, progress sync to ABS server
- **Android** — ABS 30s skip-back / skip-forward buttons in media notification (compact + expanded view)
- **Android** — ABS library search bar in Books tab (server-side search via `/api/libraries/{id}/search`, 400ms debounce)
- **Android** — Notification sheet: shows import/download history with status icons; "Zum Album →" / "Öffnen →" button navigates to album or playlist on tap
- **Web** — Notification sheet: "Zum Album →" link button after download completes
- **Android** — NowPlaying lyrics: 200ms lookahead for better sync feel (matches web)
### Fixed
- **Web + Android** — ABS book descriptions: HTML tags (`<p>`, `</p>`) now stripped before display
- **Android** — NowPlaying background: switched from `darkMutedSwatch` to `vibrantSwatch` (55% darken) for more visible color tinting
- **Android** — NowPlaying top bar: fixed layout overflow with navigation labels
- **Android** — Lyrics scroll: removed Phase 2 "drift" animation; now simply centers active line (matches web `scrollTo({ behavior: 'smooth' })`)
- **Android** — ABS skip: library list skip fixed on first load
---
## [0.4.0] — 2026-05
### Added
- **Android** — Full native app rewrite in Kotlin / Jetpack Compose (replaces React Native / Expo attempt)
- **Android** — Media3 / ExoPlayer music playback with `MusicService` (`MediaLibraryService`)
- **Android** — NowPlaying screen: cover art with scale animation (Apple Music style), seek bar with drag scrubbing, queue tab, lyrics tab
- **Android** — Dominant color background in NowPlaying (Palette API, animated on song change)
- **Android** — Multi-artist display with per-artist tap-to-navigate in NowPlaying
- **Android** — Synced lyrics via LRCLIB (parallel requests, in-memory cache)
- **Android** — ABS (Audiobookshelf) integration: login, library browse, book detail, chapter playback
- **Android** — Android Auto support: browsable tree (Recent, Favorites, Playlists), play-all / shuffle pseudo-items
- **Android** — Radio mode: auto-queues similar songs via Navidrome `getSimilarSongs`
- **Android** — Global search (Navidrome)
- **Android** — Genre browsing with play button per genre
- **Android** — Concert events via Ticketmaster on artist page
- **Android** — Spotify / YouTube importer (connects to backend)
- **Android** — Notification sheet in-app (import/download job progress)
- **Android** — Settings: Navidrome URL/credentials, ABS URL/token, theme, display name
### Fixed
- **Android** — Login flash on startup (credentials loaded before nav resolves)
- **Android** — Album name casing normalization
---
## [0.3.0] — 2025-Q4
### Added
- **Web** — Artist page: hero banner, iTunes + Navidrome album categorization, all-songs view
- **Web** — Concert events via Ticketmaster (replaces Bandsintown); 30min server-side cache
- **Web** — Playlist editing: name, description, public toggle, cover image upload
- **Web** — Global search: artist cards with images, explicit badge (E)
- **Web** — Romaji display for Japanese artists/albums (Navidrome `sortName` tag)
- **Web** — iTunes album detail page (for tracks not in Navidrome)
- **Web** — Mobile search bar in header (tap icon)
- **Web** — Smooth progress bar scrubbing with touch drag
### Fixed
- **Web** — Multi-artist splitting: handles `·`, `/`, `;` separators; each name navigates to own artist profile
- **Web** — Lyrics: LRCLIB fuzzy fallback, title-only search fallback, parallel requests, in-memory cache
- **Web** — Lyrics scrollbar hidden, fade edges on active line
- **Web** — Artwork: hi-res cover embedded; consistent `album_artist` tag for Navidrome grouping
- **Web** — `feat.`/`ft.`/`featuring` converted to `/` in artist tag for correct Navidrome multi-artist split
---
## [0.2.0] — 2025-Q3
### Added
- **Web** — Spotify / YouTube / SoundCloud importer: resolve link → yt-dlp download → optional Navidrome playlist creation
- **Web** — iTunes per-track artwork lookup for playlist downloads
- **Web** — Synced lyrics via LRCLIB in Now Playing overlay (200ms lookahead)
- **Web** — Lyrics embedding in downloaded track metadata
- **Web** — Explicit metadata tag + E badge in search
- **Web** — Disc number and compilation metadata tags
- **Web** — Genre metadata from iTunes `primaryGenreName`
- **Web** — SW update banner (notify user, no force-reload)
- **Web** — Duplicate detection before download (shows toast on skip)
- **Web** — Player drag feedback; swipe-up player bar opens Now Playing
### Fixed
- **Web** — Spotify importer: multi-artist parsing, GraphQL embed format, locale URL support, public web-player token
- **Web** — Playlist cover auto-refresh after upload
- **Web** — Service worker: network-first strategy, does not intercept cross-origin requests, auto-reloads all PWA windows on SW update
- **Web** — Bottom navigation: full-width edge-to-edge layout
---
## [0.1.0] — 2025-Q2
### Added
- **Web** — Initial PWA music player (Vite + ES modules)
- **Web** — Navidrome Subsonic API integration (albums, songs, playlists, search, cover art)
- **Web** — Album and playlist browsing
- **Web** — Basic player (play/pause, skip, shuffle, repeat)
- **Web** — Media Session API (lock screen / notification controls)
- **Web** — Service worker with offline support
- **Web** — Docker setup: nginx frontend + Node.js backend + Navidrome
- **Web** — Home screen: hero card (last played album), genre grid, recently added, most played, discover
---
## Planned / Backlog
### Features
- **Android** — Playlist drag-and-drop / sort order
- **Android** — Artist page iTunes fallback (for artists not in Navidrome)
- **Android** — Romaji / Japanese sort support
- **Web + Android** — Backend settings UI rework (move, reorganize)
- **Web + Android** — yt-dlp backend update / maintenance
### Known Bugs
_(none currently tracked)_
+6 -2
View File
@@ -47,7 +47,10 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
- File renamed and moved automatically; Navidrome rescan triggered on save
### Playlists
- Create, edit, delete playlists; add / remove tracks
- Create, rename, and delete playlists
- Add tracks via "Add to Playlist" context menu
- Remove tracks from a playlist (edit mode on Android; inline on Web)
- Cover image upload (Web)
### Audiobookshelf Integration
- Browse libraries and audiobooks on both Web and Android
@@ -64,8 +67,9 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
### App — Android (native Jetpack Compose)
- Media3 / ExoPlayer backend
- OS notification with playback controls
- OS notification with playback controls (chapter name shown for audiobooks)
- Last played song shown in mini player bar on restart; tap to resume
- Full playlist management: create, rename, delete, remove tracks
---
@@ -8,7 +8,10 @@ import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.security.MessageDigest
import kotlin.random.Random
@@ -167,6 +170,58 @@ class SubsonicClient(
?: throw SubsonicException(-1, "Failed to create playlist")
}
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) =
getList("updatePlaylist.view", listOfNotNull(
"playlistId" to playlistId,
"name" to name,
comment?.let { "comment" to it },
))
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) =
getList("updatePlaylist.view",
listOf("playlistId" to playlistId) + indices.map { "songIndexToRemove" to it.toString() })
suspend fun deletePlaylist(playlistId: String) =
getList("deletePlaylist.view", listOf("id" to playlistId))
// ── Navidrome REST API (cover art) ────────────────────────────────────────
@Serializable private data class NdLoginReq(val username: String, val password: String)
@Serializable private data class NdLoginResp(val token: String = "")
private var _ndToken: String? = null
private suspend fun ndToken(): String {
_ndToken?.let { return it }
val resp = http.post("$serverUrl/auth/login") {
contentType(ContentType.Application.Json)
setBody(NdLoginReq(username, password))
}.body<NdLoginResp>()
val token = resp.token.ifBlank { throw Exception("Navidrome login fehlgeschlagen") }
_ndToken = token
return token
}
suspend fun uploadPlaylistCover(playlistId: String, imageBytes: ByteArray, mimeType: String) {
val token = ndToken()
http.post("$serverUrl/api/playlist/$playlistId/image") {
header("X-Nd-Authorization", "Bearer $token")
setBody(MultiPartFormDataContent(formData {
append("image", imageBytes, Headers.build {
append(HttpHeaders.ContentType, mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"cover\"")
})
}))
}
}
suspend fun removePlaylistCover(playlistId: String) {
val token = ndToken()
http.delete("$serverUrl/api/playlist/$playlistId/image") {
header("X-Nd-Authorization", "Bearer $token")
}
}
fun close() = http.close()
private suspend fun getList(path: String, params: List<Pair<String, String>>): SubsonicResponse {
@@ -58,6 +58,11 @@ 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 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)
suspend fun uploadPlaylistCover(playlistId: String, imageBytes: ByteArray, mimeType: String) = api().uploadPlaylistCover(playlistId, imageBytes, mimeType)
suspend fun removePlaylistCover(playlistId: String) = api().removePlaylistCover(playlistId)
fun coverArtUrl(id: String?, size: Int = 300): String? {
val creds = lastCredentials ?: return null
@@ -49,12 +49,16 @@ object NotificationStore {
jobId: String,
title: String,
artist: String,
navType: String = "",
navAlbum: String = "",
navArtist: String = "",
navId: String = "",
) {
add(AppNotification(id = jobId, title = title, artist = artist, status = "pending"))
scope.launch {
val client = ImporterClient(backendUrl)
try {
repeat(240) { // up to ~6 minutes at 1.5 s interval
repeat(400) { // up to ~10 minutes at 1.5 s interval
delay(1500)
val st: DownloadStatus = runCatching { client.status(jobId) }.getOrNull()
?: return@repeat
@@ -63,6 +67,8 @@ object NotificationStore {
replace(AppNotification(
id = jobId, title = title, artist = artist,
status = "done", message = st.message ?: "Fertig!",
navType = navType, navAlbum = navAlbum,
navArtist = navArtist, navId = navId,
))
return@launch
}
@@ -70,6 +76,8 @@ object NotificationStore {
replace(AppNotification(
id = jobId, title = title, artist = artist,
status = "error", message = st.message ?: "Fehler",
navType = navType, navAlbum = navAlbum,
navArtist = navArtist, navId = navId,
))
return@launch
}
@@ -1,156 +0,0 @@
package com.novasonic.app.player
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.novasonic.app.R
import com.novasonic.app.app
import com.novasonic.app.ui.MainActivity
import kotlinx.coroutines.*
import java.net.HttpURLConnection
import java.net.URL
class AbsPlayerService : Service() {
companion object {
const val NOTIFICATION_ID = 1002
const val CHANNEL_ID = "abs_playback"
const val ACTION_PREV_CH = "abs.prev_chapter"
const val ACTION_SKIP_BACK = "abs.skip_back"
const val ACTION_SKIP_FWD = "abs.skip_fwd"
const val ACTION_NEXT_CH = "abs.next_chapter"
const val ACTION_TOGGLE = "abs.toggle_play"
}
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var stateJob: Job? = null
private var coverBitmap: Bitmap? = null
private var lastCoverUrl: String? = null
private var destroyed = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
val channel = NotificationChannel(
CHANNEL_ID, "Hörbuch Wiedergabe", NotificationManager.IMPORTANCE_LOW
).apply {
description = "Audiobookshelf Wiedergabe"
setShowBadge(false)
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Handle notification button taps
val ctrl = app.absPlayerController
when (intent?.action) {
ACTION_PREV_CH -> ctrl.prevChapter()
ACTION_SKIP_BACK -> ctrl.skipSecs(-30.0)
ACTION_SKIP_FWD -> ctrl.skipSecs(30.0)
ACTION_NEXT_CH -> ctrl.nextChapter()
ACTION_TOGGLE -> ctrl.togglePlay()
}
// startForeground must be called synchronously in onStartCommand — no deferral allowed.
// Using a plain Service (not MediaSessionService) gives us direct control over this.
startForeground(NOTIFICATION_ID, buildNotification(ctrl.state.value))
// Start observing state to keep notification in sync
if (stateJob == null) {
stateJob = scope.launch {
ctrl.state.collect { s ->
if (s.item == null) {
stopSelf()
} else {
updateNotification(buildNotification(s))
}
}
}
fetchCover(ctrl.currentCoverUrl())
}
return START_STICKY
}
private fun updateNotification(n: Notification) {
if (!destroyed) {
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.notify(NOTIFICATION_ID, n)
}
}
private fun fetchCover(coverUrl: String?) {
if (coverUrl == null || coverUrl == lastCoverUrl) return
lastCoverUrl = coverUrl
scope.launch(Dispatchers.IO) {
try {
val conn = URL(coverUrl).openConnection() as HttpURLConnection
conn.connectTimeout = 5_000
conn.readTimeout = 10_000
conn.connect()
val bmp = BitmapFactory.decodeStream(conn.inputStream)
conn.disconnect()
if (bmp != null) withContext(Dispatchers.Main) {
coverBitmap = bmp
updateNotification(buildNotification(app.absPlayerController.state.value))
}
} catch (_: Exception) {}
}
}
private fun buildNotification(state: AbsPlayState): Notification {
val meta = state.item?.media?.metadata
val chapter = state.chapters.getOrNull(state.chapterIdx)
val openPi = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java)
.apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val playIcon = if (state.isPlaying) R.drawable.ic_abs_pause else R.drawable.ic_abs_play
val playLabel = if (state.isPlaying) "Pause" else "Abspielen"
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(meta?.title ?: "Hörbuch")
.setContentText(chapter?.title ?: meta?.authorName ?: "")
.setLargeIcon(coverBitmap)
.setContentIntent(openPi)
.setOngoing(true)
.setSilent(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(R.drawable.ic_abs_skip_prev, "Voriges Kapitel", svcPi(ACTION_PREV_CH, 0))
.addAction(R.drawable.ic_abs_rewind, "−30s", svcPi(ACTION_SKIP_BACK, 1))
.addAction(playIcon, playLabel, svcPi(ACTION_TOGGLE, 2))
.addAction(R.drawable.ic_abs_forward, "+30s", svcPi(ACTION_SKIP_FWD, 3))
.addAction(R.drawable.ic_abs_skip_next, "Nächstes Kapitel", svcPi(ACTION_NEXT_CH, 4))
.build()
}
private fun svcPi(action: String, requestCode: Int): PendingIntent =
PendingIntent.getService(
this, requestCode,
Intent(this, AbsPlayerService::class.java).setAction(action),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
override fun onDestroy() {
if (destroyed) return
destroyed = true
stateJob?.cancel()
scope.cancel()
app.absPlayerController.close()
super.onDestroy()
}
}
@@ -1,15 +1,8 @@
package com.novasonic.app.player
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import androidx.core.app.NotificationCompat
import androidx.media3.common.*
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.DefaultDataSource
@@ -27,7 +20,6 @@ import com.novasonic.app.R
import com.novasonic.app.data.MusicRepository
import com.novasonic.app.ui.MainActivity
import kotlinx.coroutines.*
import java.net.URL
import java.util.concurrent.Executors
class MusicService : MediaLibraryService() {
@@ -43,31 +35,23 @@ class MusicService : MediaLibraryService() {
// Pseudo-items for play-all / shuffle at top of each list
const val PLAY_ALL_PREFIX = "playall:"
const val SHUFFLE_PREFIX = "shuffle:"
// ABS notification commands (delivered via onStartCommand)
const val NOTIFICATION_CHANNEL_ID = "novasonic_playback"
const val NOTIFICATION_ID = 1001
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
const val ACTION_ABS_SKIP_FWD = "novasonic.abs.SKIP_FORWARD"
const val ACTION_ABS_NEXT_CH = "novasonic.abs.NEXT_CHAPTER"
const val ACTION_ABS_TOGGLE = "novasonic.abs.TOGGLE_PLAY"
// ABS session commands (handled via onCustomCommand)
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
const val ACTION_ABS_SKIP_FWD = "novasonic.abs.SKIP_FORWARD"
const val ACTION_ABS_NEXT_CH = "novasonic.abs.NEXT_CHAPTER"
const val ACTION_ABS_TOGGLE = "novasonic.abs.TOGGLE_PLAY"
}
private lateinit var player: ExoPlayer
private lateinit var session: MediaLibrarySession
private lateinit var repo: MusicRepository
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
// ABS notification cover cache
private var absCoverBitmap: Bitmap? = null
private var lastCoverUrl: String? = null
private var lastNotifChapterIdx = -1
private var lastNotifIsPlaying: Boolean? = null
private var lastAbsChapterIdx = -1
override fun onCreate() {
super.onCreate()
repo = MusicRepository(applicationContext)
createNotificationChannel()
val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent("NovaSonic-Android/1.0")
@@ -94,6 +78,8 @@ class MusicService : MediaLibraryService() {
.setWakeMode(C.WAKE_MODE_NETWORK)
.build()
val wrappedPlayer = AbsAwareForwardingPlayer(player)
val activityIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
@@ -102,7 +88,7 @@ class MusicService : MediaLibraryService() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
session = MediaLibrarySession.Builder(this, player, LibraryCallback())
session = MediaLibrarySession.Builder(this, wrappedPlayer, LibraryCallback())
.setBitmapLoader(bitmapLoader)
.setSessionActivity(activityIntent)
.build()
@@ -110,136 +96,136 @@ class MusicService : MediaLibraryService() {
scope.launch {
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
}
// Observe ABS state — update notification when chapter or play state changes
scope.launch {
(applicationContext as NovaSonicApp).absPlayerController.state.collect { absState ->
if (absState.item == null) {
lastNotifChapterIdx = -1
lastNotifIsPlaying = null
lastCoverUrl = null
absCoverBitmap = null
return@collect
}
val chapterChanged = absState.chapterIdx != lastNotifChapterIdx
val playStateChanged = absState.isPlaying != lastNotifIsPlaying
if (chapterChanged || playStateChanged) {
lastNotifChapterIdx = absState.chapterIdx
lastNotifIsPlaying = absState.isPlaying
loadCoverIfNeeded(absState)
postAbsNotification(absState)
(applicationContext as NovaSonicApp).absPlayerController.state.collect { s ->
session.setCustomLayout(if (s.item != null) buildAbsCustomLayout() else ImmutableList.of())
val newIdx = if (s.item != null) s.chapterIdx else -1
if (newIdx != lastAbsChapterIdx) {
lastAbsChapterIdx = newIdx
if (s.item != null) {
val chapter = s.chapters.getOrNull(s.chapterIdx)
val bookTitle = s.item.media.metadata?.title ?: "Hörbuch"
val idx = player.currentMediaItemIndex.takeIf { it >= 0 } ?: return@collect
val cur = player.getMediaItemAt(idx)
player.replaceMediaItem(idx, cur.buildUpon()
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(chapter?.title?.takeIf { it.isNotBlank() } ?: bookTitle)
.setArtist(if (!chapter?.title.isNullOrBlank()) bookTitle else cur.mediaMetadata.artist)
.setArtworkUri(cur.mediaMetadata.artworkUri)
.build()
).build()
)
}
}
}
}
setMediaNotificationProvider(AbsAwareDnmp())
}
// ABS button intents are delivered here via startService()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action
if (action != null && action.startsWith("novasonic.abs.")) {
val abs = (applicationContext as NovaSonicApp).absPlayerController
when (action) {
ACTION_ABS_PREV_CH -> abs.prevChapter()
ACTION_ABS_SKIP_BACK -> abs.skipSecs(-30.0)
ACTION_ABS_SKIP_FWD -> abs.skipSecs(30.0)
ACTION_ABS_NEXT_CH -> abs.nextChapter()
ACTION_ABS_TOGGLE -> abs.togglePlay()
}
return START_NOT_STICKY
}
return super.onStartCommand(intent, flags, startId)
private fun buildAbsButtons(isPlaying: Boolean): ImmutableList<CommandButton> {
val ppIcon = if (isPlaying) R.drawable.ic_abs_pause else R.drawable.ic_abs_play
val ppLabel = if (isPlaying) "Pause" else "Abspielen"
return ImmutableList.of(
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_skip_prev)
.setDisplayName("Kapitel zurück")
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_rewind)
.setDisplayName("30s zurück")
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 0) })
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_TOGGLE, Bundle.EMPTY))
.setIconResId(ppIcon)
.setDisplayName(ppLabel)
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 1) })
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_forward)
.setDisplayName("30s vor")
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 2) })
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_skip_next)
.setDisplayName("Kapitel vor")
.build(),
)
}
// Intercept Media3's notification update when ABS is active
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
val absState = (applicationContext as NovaSonicApp).absPlayerController.state.value
if (absState.item != null) {
val notif = buildAbsNotification(absState)
if (startInForegroundRequired || absState.isPlaying) {
startForeground(NOTIFICATION_ID, notif)
} else {
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notif)
}
return
}
super.onUpdateNotification(session, startInForegroundRequired)
}
private fun postAbsNotification(absState: AbsPlayState) {
val notif = buildAbsNotification(absState)
if (absState.isPlaying) {
startForeground(NOTIFICATION_ID, notif)
} else {
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notif)
}
}
private fun buildAbsNotification(absState: AbsPlayState): Notification {
val meta = absState.item?.media?.metadata
val title = meta?.title ?: "Hörbuch"
val chapter = absState.chapters.getOrNull(absState.chapterIdx)?.title
val artist = meta?.authorName ?: ""
val playPauseIcon = if (absState.isPlaying) R.drawable.ic_abs_pause else R.drawable.ic_abs_play
val playPauseLabel = if (absState.isPlaying) "Pause" else "Abspielen"
// Action indices: 0=prev_ch, 1=skip_back, 2=toggle, 3=skip_fwd, 4=next_ch
// Compact view shows indices 1, 2, 3 → -30s | play/pause | +30s
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(chapter ?: title)
.setContentText(if (chapter != null) title else artist)
.setLargeIcon(absCoverBitmap)
.setOngoing(absState.isPlaying)
.setOnlyAlertOnce(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
))
.addAction(R.drawable.ic_abs_skip_prev, "Kapitel zurück", absPendingIntent(ACTION_ABS_PREV_CH))
.addAction(R.drawable.ic_abs_rewind, "30s zurück", absPendingIntent(ACTION_ABS_SKIP_BACK))
.addAction(playPauseIcon, playPauseLabel, absPendingIntent(ACTION_ABS_TOGGLE))
.addAction(R.drawable.ic_abs_forward, "30s vor", absPendingIntent(ACTION_ABS_SKIP_FWD))
.addAction(R.drawable.ic_abs_skip_next, "Kapitel vor", absPendingIntent(ACTION_ABS_NEXT_CH))
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(1, 2, 3)
)
.build()
}
private fun absPendingIntent(action: String): PendingIntent = PendingIntent.getService(
this, action.hashCode(),
Intent(this, MusicService::class.java).setAction(action),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
// 4-button layout for the Samsung media widget — play/pause is the standard player control.
// prevOrCustom → Kapitel zurück, custom0 → 30s zurück, custom1 → 30s vor, nextOrCustom → Kapitel vor.
// Samsung One UI slot mapping (empirically determined):
// prevOrCustom → custom[2], custom0 → custom[0], custom1 → custom[1], nextOrCustom → custom[3]
// Desired display: Kapitel zurück | 30s zurück | play | 30s vor | Kapitel vor
// Required layout order: [30s zurück, 30s vor, Kapitel zurück, Kapitel vor]
private fun buildAbsCustomLayout(): ImmutableList<CommandButton> = ImmutableList.of(
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_rewind)
.setDisplayName("30s zurück")
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_forward)
.setDisplayName("30s vor")
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_skip_prev)
.setDisplayName("Kapitel zurück")
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_skip_next)
.setDisplayName("Kapitel vor")
.build(),
)
private fun loadCoverIfNeeded(absState: AbsPlayState) {
val url = (applicationContext as NovaSonicApp).absPlayerController.currentCoverUrl() ?: return
if (url == lastCoverUrl) return
lastCoverUrl = url
absCoverBitmap = null
scope.launch(Dispatchers.IO) {
runCatching {
val bmp = BitmapFactory.decodeStream(URL(url).openStream())
withContext(Dispatchers.Main) {
absCoverBitmap = bmp
val current = (applicationContext as NovaSonicApp).absPlayerController.state.value
if (current.item != null) postAbsNotification(current)
}
}
// Removes SEEK_TO_PREV/NEXT from available commands when ABS is active so Samsung One UI
// replaces its standard prev/next slots with our first/last custom layout buttons.
private inner class AbsAwareForwardingPlayer(player: ExoPlayer) : ForwardingPlayer(player) {
override fun getAvailableCommands(): Player.Commands {
val base = super.getAvailableCommands()
val isAbs = currentMediaItem?.mediaId?.startsWith("abs:") == true ||
(applicationContext as NovaSonicApp).absPlayerController.state.value.item != null
return if (isAbs) {
base.buildUpon()
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.remove(Player.COMMAND_SEEK_TO_NEXT)
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.build()
} else base
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) return
NotificationChannel(NOTIFICATION_CHANNEL_ID, "Wiedergabe", NotificationManager.IMPORTANCE_LOW).apply {
description = "Musik & Hörbücher"
setShowBadge(false)
}.also { nm.createNotificationChannel(it) }
// Subclasses DNMP so we stay inside the class hierarchy Media3 expects.
// createNotification() stores the session and always passes ImmutableList.of() to super
// so DNMP never appends setCustomLayout() buttons after getMediaButtons().
// getMediaButtons() detects ABS via currentMediaItem.mediaId — reliable, no state race.
// getMediaButtons() receives the full MediaSession — detect ABS via currentMediaItem.mediaId.
// No createNotification() override needed. setCustomLayout() is always called with empty list
// (in the onCreate observer) so DNMP appends zero extra buttons after getMediaButtons().
private inner class AbsAwareDnmp : DefaultMediaNotificationProvider(this@MusicService) {
override fun getMediaButtons(
mediaSession: MediaSession,
playerCommands: Player.Commands,
customLayout: ImmutableList<CommandButton>,
showPauseButton: Boolean,
): ImmutableList<CommandButton> {
val mediaId = mediaSession.player.currentMediaItem?.mediaId
val absItem = (applicationContext as NovaSonicApp).absPlayerController.state.value.item
android.util.Log.e("ABS_NOTIF", "getMediaButtons: mediaId=$mediaId absItem=${absItem?.id} customLayout.size=${customLayout.size}")
val isAbs = mediaId?.startsWith("abs:") == true || absItem != null
if (!isAbs) return super.getMediaButtons(mediaSession, playerCommands, ImmutableList.of(), showPauseButton)
return buildAbsButtons(mediaSession.player.isPlaying)
}
}
@@ -258,6 +244,41 @@ class MusicService : MediaLibraryService() {
private inner class LibraryCallback : MediaLibrarySession.Callback {
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo,
): MediaSession.ConnectionResult {
val absCommands = listOf(
SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY),
SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY),
SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY),
SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY),
SessionCommand(ACTION_ABS_TOGGLE, Bundle.EMPTY),
)
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(
MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
.addSessionCommands(absCommands).build()
).build()
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle,
): ListenableFuture<SessionResult> {
val abs = (applicationContext as NovaSonicApp).absPlayerController
when (customCommand.customAction) {
ACTION_ABS_PREV_CH -> abs.prevChapter()
ACTION_ABS_SKIP_BACK -> abs.skipSecs(-30.0)
ACTION_ABS_SKIP_FWD -> abs.skipSecs(30.0)
ACTION_ABS_NEXT_CH -> abs.nextChapter()
ACTION_ABS_TOGGLE -> abs.togglePlay()
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
@@ -146,9 +146,10 @@ fun AppNavigation() {
center = Offset.Unspecified, radius = 900f,
)
// Nav items — ABS tab only shown when configured
// Nav items — ABS tab only shown when configured; Settings moved to HomeScreen header
val navItems = remember(absConfigured) {
TopLevel.entries.filter { it != TopLevel.ABS || absConfigured }
.filter { it != TopLevel.SETTINGS }
}
Box(Modifier.fillMaxSize()) {
@@ -234,6 +235,10 @@ fun AppNavigation() {
HomeScreen(
coverUrl = { album -> coverUrl(album.coverArt) },
onBellClick = { showNotifications = true },
onSettingsClick = { navController.navigate(TopLevel.SETTINGS.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true; restoreState = true
} },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onAlbumPlay = { album ->
scope.launch { runCatching {
@@ -342,6 +347,7 @@ fun AppNavigation() {
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onDeleted = { navController.popBackStack() },
onBack = { navController.popBackStack() },
)
}
@@ -1,4 +1,4 @@
package com.novasonic.app.ui.screen
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -77,6 +78,7 @@ fun HomeScreen(
onGenrePlay: ((Genre) -> Unit)? = null,
onShowAllGenres: (() -> Unit)? = null,
onBellClick: () -> Unit = {},
onSettingsClick: () -> Unit = {},
refreshKey: Int = 0,
) {
val vm: HomeViewModel = viewModel()
@@ -122,6 +124,9 @@ fun HomeScreen(
tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
}
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, "Einstellungen", tint = NsOnSurface2)
}
}
}
@@ -152,11 +157,12 @@ fun HomeScreen(
return@LazyColumn
}
// Hero card — matches PWA .home-hero (170px height)
// Hero card — pool = frequent(5) ∪ recentlyPlayed(5), label from web
state.heroAlbum?.let { hero ->
item {
HeroCard(
album = hero,
label = state.heroLabel,
coverUrl = coverUrl(hero),
onPlay = { onAlbumPlay(hero) },
onClick = { onAlbumClick(hero) },
@@ -200,10 +206,10 @@ fun HomeScreen(
Spacer(Modifier.height(28.dp))
}
}
if (state.frequentAlbums.isNotEmpty()) {
item { SectionHeader("Meistgespielt") }
if (state.recentlyPlayedAlbums.isNotEmpty()) {
item { SectionHeader("Zuletzt gehört") }
item {
AlbumRow(albums = state.frequentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
AlbumRow(albums = state.recentlyPlayedAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(28.dp))
}
}
@@ -211,6 +217,13 @@ fun HomeScreen(
item { SectionHeader("Entdecken") }
item {
AlbumRow(albums = state.randomAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(28.dp))
}
}
if (state.frequentAlbums.isNotEmpty()) {
item { SectionHeader("Meistgespielt") }
item {
AlbumRow(albums = state.frequentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(16.dp))
}
}
@@ -221,7 +234,7 @@ fun HomeScreen(
// background = blur(8px) brightness(.45) + horizontal gradient overlay
// cover = 100x100, border-radius:9px
@Composable
private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClick: () -> Unit) {
private fun HeroCard(album: Album, label: String, coverUrl: String?, onPlay: () -> Unit, onClick: () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -230,25 +243,20 @@ private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClic
.clip(RoundedCornerShape(14.dp))
.clickable(onClick = onClick),
) {
// Blurred background image — matches .home-hero-bg
AsyncImage(
model = coverUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().blur(16.dp),
)
// Gradient overlay — matches .home-hero-overlay
// linear-gradient(to right, rgba(7,7,15,.88) 0%, rgba(7,7,15,.3) 100%)
Box(
modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(Color(0xE107070F), Color(0x4D07070F)))
)
)
// Content row
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp),
) {
// Cover art — 100dp, matches .home-hero-cover (100px, border-radius:9px)
AsyncImage(
model = coverUrl, contentDescription = album.name,
contentScale = ContentScale.Crop,
@@ -258,39 +266,42 @@ private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClic
.background(NsSurface2),
)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
// Label — matches .home-hero-label
Text(
text = "AKTUELL",
fontFamily = Outfit,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 1.sp,
color = NsAccent,
)
Spacer(Modifier.height(4.dp))
Text(
text = album.name,
fontFamily = Outfit,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
letterSpacing = (-0.3).sp,
color = NsOnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
Column(Modifier.weight(1f)) {
Text(
text = album.artist!!,
text = label,
fontFamily = Outfit,
fontSize = 13.sp,
color = NsOnSurface2,
maxLines = 1,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 1.sp,
color = NsAccent,
)
Spacer(Modifier.height(4.dp))
Text(
text = album.name,
fontFamily = Outfit,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
letterSpacing = (-0.3).sp,
color = NsOnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Text(
text = album.artist!!,
fontFamily = Outfit,
fontSize = 13.sp,
color = NsOnSurface2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.height(12.dp))
// Play button — matches .home-hero-play (42px circle, accent bg)
Spacer(Modifier.width(12.dp))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
@@ -400,7 +411,6 @@ private fun AlbumRow(albums: List<Album>, coverUrl: (Album) -> String?, onAlbumC
}
}
// PWA: .section-header { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:1.5px }
@Composable
private fun SectionHeader(title: String) {
Text(
@@ -11,6 +11,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.border
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -18,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -29,6 +33,7 @@ import com.novasonic.app.api.model.Artist
import com.novasonic.app.api.model.Playlist
import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.AlbumSort
import com.novasonic.app.viewmodel.LibraryTab
import com.novasonic.app.viewmodel.LibraryViewModel
@@ -42,6 +47,9 @@ fun LibraryScreen(
val vm: LibraryViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
var showCreateDialog by remember { mutableStateOf(false) }
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
TabRow(
selectedTabIndex = state.tab.ordinal,
@@ -77,20 +85,64 @@ fun LibraryScreen(
}
when (state.tab) {
LibraryTab.ALBUMS -> LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
items(state.albums, key = { it.id }) { album ->
AlbumCard(
album = album,
coverUrl = coverUrl(album.coverArt),
width = 150.dp,
onClick = { onAlbumClick(album) },
)
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
// Sort chips
androidx.compose.foundation.lazy.LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
items(AlbumSort.entries) { s ->
FilterChip(
selected = state.albumSort == s,
onClick = { vm.setAlbumSort(s) },
label = {
Text(when (s) {
AlbumSort.NEWEST -> "Neueste"
AlbumSort.OLDEST -> "Älteste"
AlbumSort.AZ -> "A-Z"
AlbumSort.ARTIST -> "Künstler"
}, fontSize = 12.sp)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent,
selectedLeadingIconColor = NsAccent,
),
)
}
}
val sortedAlbums = remember(state.albums, state.albumSort) {
when (state.albumSort) {
AlbumSort.NEWEST -> state.albums.sortedWith(
compareByDescending<com.novasonic.app.api.model.Album> { it.year ?: 0 }
.thenBy { it.name.lowercase() }
)
AlbumSort.OLDEST -> state.albums.sortedWith(
compareBy<com.novasonic.app.api.model.Album> { it.year ?: Int.MAX_VALUE }
.thenBy { it.name.lowercase() }
)
AlbumSort.AZ -> state.albums.sortedBy { it.name.lowercase() }
AlbumSort.ARTIST -> state.albums.sortedWith(
compareBy<com.novasonic.app.api.model.Album> { it.artist?.lowercase() ?: "" }
.thenBy { it.name.lowercase() }
)
}
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
items(sortedAlbums, key = { it.id }) { album ->
AlbumCard(
album = album,
coverUrl = coverUrl(album.coverArt),
width = 150.dp,
onClick = { onAlbumClick(album) },
)
}
}
}
@@ -137,7 +189,7 @@ fun LibraryScreen(
LibraryTab.PLAYLISTS -> LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
contentPadding = PaddingValues(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 80.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
@@ -152,6 +204,61 @@ fun LibraryScreen(
}
}
}
} // end Column
if (state.tab == LibraryTab.PLAYLISTS) {
FloatingActionButton(
onClick = { showCreateDialog = true },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
containerColor = NsAccent,
) {
Icon(Icons.Default.Add, "Neue Playlist", tint = NsBackground)
}
}
} // end Box
// ── Create playlist dialog ────────────────────────────────────────────────
if (showCreateDialog) {
var nameInput by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showCreateDialog = false },
title = { Text("Neue Playlist", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
OutlinedTextField(
value = nameInput,
onValueChange = { nameInput = it },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
),
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = {
vm.createPlaylist(nameInput)
showCreateDialog = false
},
enabled = nameInput.isNotBlank(),
) {
Text("Erstellen", color = NsAccent, fontFamily = Outfit, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showCreateDialog = false }) {
Text("Abbrechen", color = NsOnSurface2)
}
},
containerColor = NsSurface,
)
}
}
@@ -1,5 +1,8 @@
package com.novasonic.app.ui.screen
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -7,10 +10,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -20,7 +23,9 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -40,10 +45,25 @@ fun PlaylistDetailScreen(
onPlay: (List<Song>, Int) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
onDeleted: () -> Unit = {},
onBack: () -> Unit,
) {
val vm: PlaylistViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val context = LocalContext.current
var editMode by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri ?: return@rememberLauncherForActivityResult
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult
vm.uploadCover(bytes, mimeType)
}
LaunchedEffect(state.deleted) { if (state.deleted) onDeleted() }
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -108,6 +128,48 @@ fun PlaylistDetailScreen(
tint = Color.White, modifier = Modifier.size(18.dp),
)
}
// Rename + Cover + Delete buttons (top-right)
Row(
Modifier
.statusBarsPadding()
.padding(end = 14.dp, top = 14.dp)
.align(Alignment.TopEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { showRenameDialog = true },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Edit, "Umbenennen",
tint = Color.White, modifier = Modifier.size(18.dp))
}
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { imagePicker.launch("image/*") },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.AddAPhoto, "Cover hochladen",
tint = Color.White, modifier = Modifier.size(18.dp))
}
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { showDeleteDialog = true },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Delete, "Löschen",
tint = Color.White, modifier = Modifier.size(18.dp))
}
}
Column(
Modifier
.align(Alignment.BottomStart)
@@ -141,7 +203,7 @@ fun PlaylistDetailScreen(
}
}
// ── Play / Shuffle row ────────────────────────────────────────────
// ── Play / Shuffle / Edit-mode row ────────────────────────────────
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
@@ -166,22 +228,137 @@ fun PlaylistDetailScreen(
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
}
// Toggle edit mode for removing songs
OutlinedButton(
onClick = { editMode = !editMode },
modifier = Modifier.height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
) {
Icon(
if (editMode) Icons.Default.Done else Icons.Default.Edit,
null,
modifier = Modifier.size(18.dp),
tint = if (editMode) NsAccent else NsOnSurface2,
)
}
}
HorizontalDivider(color = NsBorder)
}
itemsIndexed(pl.entry, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(pl.entry, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(pl.entry, idx) },
onStarToggle = { onStarToggle(song) },
onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null,
)
}
if (editMode) {
IconButton(
onClick = { vm.removeSong(idx) },
modifier = Modifier.padding(end = 4.dp),
) {
Icon(
Icons.Default.RemoveCircleOutline,
"Entfernen",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(22.dp),
)
}
}
}
}
item { Spacer(Modifier.height(16.dp)) }
}
// ── Rename dialog ─────────────────────────────────────────────────────────
if (showRenameDialog) {
var nameInput by remember { mutableStateOf(pl.name) }
AlertDialog(
onDismissRequest = { showRenameDialog = false },
title = { Text("Playlist bearbeiten", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = nameInput,
onValueChange = { nameInput = it },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
),
modifier = Modifier.fillMaxWidth(),
)
if (!pl.coverArt.isNullOrBlank()) {
TextButton(
onClick = { vm.removeCover(); showRenameDialog = false },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.HideImage, null,
tint = NsOnSurface2, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Cover entfernen", color = NsOnSurface2, fontSize = 13.sp)
}
}
}
},
confirmButton = {
TextButton(
onClick = { vm.rename(nameInput); showRenameDialog = false },
enabled = nameInput.isNotBlank(),
) {
Text("Speichern", color = NsAccent, fontFamily = Outfit, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showRenameDialog = false }) {
Text("Abbrechen", color = NsOnSurface2)
}
},
containerColor = NsSurface,
)
}
// ── Delete confirmation dialog ────────────────────────────────────────────
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Playlist löschen?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
Text(
"\"${pl.name}\" wird dauerhaft gelöscht.",
fontFamily = Outfit, color = NsOnSurface2,
)
},
confirmButton = {
TextButton(
onClick = { showDeleteDialog = false; vm.delete() },
enabled = !state.isSaving,
) {
Text("Löschen", color = MaterialTheme.colorScheme.error,
fontFamily = Outfit, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Abbrechen", color = NsOnSurface2)
}
},
containerColor = NsSurface,
)
}
}
@@ -13,7 +13,9 @@ import kotlinx.coroutines.launch
data class HomeUiState(
val heroAlbum: Album? = null,
val heroLabel: String = "",
val recentAlbums: List<Album> = emptyList(),
val recentlyPlayedAlbums: List<Album> = emptyList(),
val frequentAlbums: List<Album> = emptyList(),
val randomAlbums: List<Album> = emptyList(),
val genres: List<Genre> = emptyList(),
@@ -42,28 +44,36 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val recentDef = async { repo.getRecentAlbums(20) }
val frequentDef = async { repo.getFrequentAlbums(20) }
val randomDef = async { repo.getRandomAlbums(20) }
val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) }
val recentDef = async { repo.getRecentAlbums(20) }
val recentlyPlayedDef = async { runCatching { repo.getRecentlyPlayedAlbums(5) }.getOrDefault(emptyList()) }
val frequentDef = async { repo.getFrequentAlbums(20) }
val randomDef = async { repo.getRandomAlbums(20) }
val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) }
val recent = recentDef.await()
val frequent = frequentDef.await()
val random = randomDef.await()
val genres = genresDef.await()
val recent = recentDef.await()
val recentlyPlayed = recentlyPlayedDef.await()
val frequent = frequentDef.await()
val random = randomDef.await()
val genres = genresDef.await()
// Hero rotates between first recent and first frequent album
val hero = if (System.currentTimeMillis() % 2 == 0L) recent.firstOrNull()
else frequent.firstOrNull() ?: recent.firstOrNull()
// Hero pool = frequent.take(5) ∪ recentlyPlayed.take(5), deduped — matches web
val frequentIds = frequent.take(5).map { it.id }.toSet()
val seen = mutableSetOf<String>()
val pool = (frequent.take(5) + recentlyPlayed.take(5))
.filter { it.id.isNotBlank() && seen.add(it.id) }
val hero = pool.randomOrNull()
val heroLabel = if (hero != null && frequentIds.contains(hero.id)) "MEISTGESPIELT" else "ZULETZT GEHÖRT"
_state.value = _state.value.copy(
heroAlbum = hero,
recentAlbums = recent,
frequentAlbums = frequent,
randomAlbums = random,
genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount },
isLoading = false,
error = null,
heroAlbum = hero,
heroLabel = heroLabel,
recentAlbums = recent,
recentlyPlayedAlbums = recentlyPlayed,
frequentAlbums = frequent,
randomAlbums = random,
genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount },
isLoading = false,
error = null,
)
} catch (e: Exception) {
_state.value = HomeUiState(error = e.message)
@@ -7,7 +7,6 @@ import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ImportResolveResponse
import com.novasonic.app.app
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -110,6 +109,17 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
tracks = resolved.tracks,
)
)
// App-lifetime poll → notification delivered even if user navigates away
NotificationStore.startPoll(
backendUrl = s.backendUrl,
jobId = jobId,
title = resolved.name ?: "Import",
artist = resolved.artist ?: "",
navType = resolved.type ?: "",
navAlbum = resolved.name ?: "",
navArtist = resolved.artist ?: "",
)
// In-screen progress poll (cancelled on navigation — only for UI)
pollJob(client, jobId)
} catch (e: Exception) {
_state.value = _state.value.copy(
@@ -137,17 +147,6 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
)
if (st.status == "done" || st.status == "error") {
_state.value = _state.value.copy(isDownloading = false)
val res = s.resolved
NotificationStore.add(AppNotification(
id = jobId,
title = title,
artist = artist,
status = st.status ?: "error",
message = st.message ?: if (st.status == "done") "Fertig!" else "Fehler",
navType = res?.type ?: "",
navAlbum = res?.name ?: "",
navArtist = res?.artist ?: "",
))
return
}
}
@@ -12,10 +12,12 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
enum class LibraryTab { PLAYLISTS, ALBUMS, ARTISTS }
enum class AlbumSort { NEWEST, OLDEST, AZ, ARTIST }
data class LibraryUiState(
val tab: LibraryTab = LibraryTab.PLAYLISTS,
val albums: List<Album> = emptyList(),
val albumSort: AlbumSort = AlbumSort.AZ,
val artistIndexes: List<ArtistIndex> = emptyList(),
val playlists: List<Playlist> = emptyList(),
val isLoading: Boolean = false,
@@ -31,6 +33,10 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
init { loadPlaylists() }
fun setAlbumSort(sort: AlbumSort) {
_state.value = _state.value.copy(albumSort = sort)
}
fun selectTab(tab: LibraryTab) {
_state.value = _state.value.copy(tab = tab)
when (tab) {
@@ -69,4 +75,21 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(error = e.message, isLoading = false)
}
}
fun createPlaylist(name: String, onSuccess: (String) -> Unit = {}) {
val trimmed = name.trim()
if (trimmed.isBlank()) return
viewModelScope.launch {
try {
val newPl = repo.createPlaylist(trimmed, emptyList())
val pls = runCatching { repo.getPlaylists() }.getOrDefault(_state.value.playlists)
_state.value = _state.value.copy(playlists = pls)
onSuccess(newPl.id)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message)
}
}
}
fun reloadPlaylists() = loadPlaylists()
}
@@ -13,6 +13,8 @@ import kotlinx.coroutines.launch
data class PlaylistUiState(
val playlist: PlaylistWithSongs? = null,
val isLoading: Boolean = true,
val isSaving: Boolean = false,
val deleted: Boolean = false,
val error: String? = null,
)
@@ -24,18 +26,102 @@ class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : Androi
private val _state = MutableStateFlow(PlaylistUiState())
val state: StateFlow<PlaylistUiState> = _state
init {
init { load() }
fun load() {
val id = playlistId
if (id.isNullOrBlank()) {
_state.value = PlaylistUiState(isLoading = false, error = "Keine Playlist-ID übergeben")
} else {
viewModelScope.launch {
try {
val pl = repo.getPlaylist(id)
_state.value = PlaylistUiState(playlist = pl, isLoading = false)
} catch (e: Exception) {
_state.value = PlaylistUiState(isLoading = false, error = e.message ?: e.toString())
}
return
}
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val pl = repo.getPlaylist(id)
_state.value = PlaylistUiState(playlist = pl, isLoading = false)
} catch (e: Exception) {
_state.value = PlaylistUiState(isLoading = false, error = e.message ?: e.toString())
}
}
}
fun rename(newName: String) {
val id = playlistId ?: return
val pl = _state.value.playlist ?: return
val trimmed = newName.trim()
if (trimmed.isBlank()) return
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
try {
repo.updatePlaylistMeta(id, trimmed, pl.comment)
_state.value = _state.value.copy(
playlist = pl.copy(name = trimmed),
isSaving = false,
)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun delete() {
val id = playlistId ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
try {
repo.deletePlaylist(id)
_state.value = _state.value.copy(deleted = true, isSaving = false)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun uploadCover(imageBytes: ByteArray, mimeType: String) {
val id = playlistId ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
try {
repo.uploadPlaylistCover(id, imageBytes, mimeType)
// Reload to get the updated coverArt ID from Navidrome
val pl = repo.getPlaylist(id)
_state.value = _state.value.copy(playlist = pl, isSaving = false)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun removeCover() {
val id = playlistId ?: return
val pl = _state.value.playlist ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
try {
repo.removePlaylistCover(id)
_state.value = _state.value.copy(
playlist = pl.copy(coverArt = null),
isSaving = false,
)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun removeSong(index: Int) {
val id = playlistId ?: return
val pl = _state.value.playlist ?: return
if (index < 0 || index >= pl.entry.size) return
viewModelScope.launch {
try {
repo.removeSongsFromPlaylist(id, listOf(index))
val newSongs = pl.entry.toMutableList().also { it.removeAt(index) }
_state.value = _state.value.copy(
playlist = pl.copy(entry = newSongs, songCount = newSongs.size)
)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message)
}
}
}
@@ -4,7 +4,11 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M18,13C18,16.31 15.31,19 12,19C8.69,19 6,16.31 6,13C6,9.69 8.69,7 12,7L12,11L17,6L12,1L12,5C7.58,5 4,8.58 4,13C4,17.42 7.58,21 12,21C16.42,21 20,17.42 20,13L18,13Z"/>
<group
android:pivotX="12"
android:scaleX="-1">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,5L12,1L7,6L12,11L12,7C15.31,7 18,9.69 18,13C18,16.31 15.31,19 12,19C8.69,19 6,16.31 6,13L4,13C4,17.42 7.58,21 12,21C16.42,21 20,17.42 20,13C20,8.58 16.42,5 12,5Z"/>
</group>
</vector>