From 6be7981b8a2c1aa71d4c712599057426c36ae10f Mon Sep 17 00:00:00 2001 From: Honey Date: Thu, 18 Jun 2026 19:04:36 +0200 Subject: [PATCH] feat(android): playlist mgmt, album sort, home hero parity, notification fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 200 ++++++++++++ README.md | 8 +- .../com/novasonic/app/api/SubsonicClient.kt | 55 ++++ .../com/novasonic/app/data/MusicRepository.kt | 5 + .../novasonic/app/data/NotificationStore.kt | 10 +- .../novasonic/app/player/AbsPlayerService.kt | 156 --------- .../com/novasonic/app/player/MusicService.kt | 303 ++++++++++-------- .../com/novasonic/app/ui/AppNavigation.kt | 8 +- .../com/novasonic/app/ui/screen/HomeScreen.kt | 92 +++--- .../novasonic/app/ui/screen/LibraryScreen.kt | 137 +++++++- .../app/ui/screen/PlaylistDetailScreen.kt | 203 +++++++++++- .../novasonic/app/viewmodel/HomeViewModel.kt | 46 +-- .../app/viewmodel/ImporterViewModel.kt | 23 +- .../app/viewmodel/LibraryViewModel.kt | 23 ++ .../app/viewmodel/PlaylistViewModel.kt | 104 +++++- .../src/main/res/drawable/ic_abs_forward.xml | 10 +- 16 files changed, 971 insertions(+), 412 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 android/app/src/main/kotlin/com/novasonic/app/player/AbsPlayerService.kt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cf23e65 --- /dev/null +++ b/CHANGELOG.md @@ -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 (`

`, `

`) 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)_ diff --git a/README.md b/README.md index 3bbf9f2..8f6b560 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt index 43245c5..9cc1bc2 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt @@ -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) = + 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() + 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>): SubsonicResponse { diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt index 295d418..2f98d8b 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt @@ -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) = 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) = 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 diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/NotificationStore.kt b/android/app/src/main/kotlin/com/novasonic/app/data/NotificationStore.kt index 690cd49..60dd1c8 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/NotificationStore.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/NotificationStore.kt @@ -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 } diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/AbsPlayerService.kt b/android/app/src/main/kotlin/com/novasonic/app/player/AbsPlayerService.kt deleted file mode 100644 index 498909c..0000000 --- a/android/app/src/main/kotlin/com/novasonic/app/player/AbsPlayerService.kt +++ /dev/null @@ -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() - } -} diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt index 8850c42..f3f8489 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt @@ -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 { + 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 = 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, + showPauseButton: Boolean, + ): ImmutableList { + 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 { + 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, diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index 7552b52..147a33a 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -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() }, ) } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt index 7f15eff..0cffcce 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt @@ -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, 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( @@ -412,4 +422,4 @@ private fun SectionHeader(title: String) { color = NsOnSurface2, modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 12.dp), ) -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt index 1761f16..7a509c4 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt @@ -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 { it.year ?: 0 } + .thenBy { it.name.lowercase() } + ) + AlbumSort.OLDEST -> state.albums.sortedWith( + compareBy { it.year ?: Int.MAX_VALUE } + .thenBy { it.name.lowercase() } + ) + AlbumSort.AZ -> state.albums.sortedBy { it.name.lowercase() } + AlbumSort.ARTIST -> state.albums.sortedWith( + compareBy { 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, + ) } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt index 287096c..7c1bf5f 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt @@ -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, 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, + ) + } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt index 2be771c..8c52117 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt @@ -13,7 +13,9 @@ import kotlinx.coroutines.launch data class HomeUiState( val heroAlbum: Album? = null, + val heroLabel: String = "", val recentAlbums: List = emptyList(), + val recentlyPlayedAlbums: List = emptyList(), val frequentAlbums: List = emptyList(), val randomAlbums: List = emptyList(), val genres: List = 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() + 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) diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt index b9b5cdb..504155e 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt @@ -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 } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt index 79089d3..0e34b75 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt @@ -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 = emptyList(), + val albumSort: AlbumSort = AlbumSort.AZ, val artistIndexes: List = emptyList(), val playlists: List = 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() } diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt index f0b1a5d..666745a 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt @@ -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 = _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) } } } diff --git a/android/app/src/main/res/drawable/ic_abs_forward.xml b/android/app/src/main/res/drawable/ic_abs_forward.xml index a16b5c3..5e33865 100644 --- a/android/app/src/main/res/drawable/ic_abs_forward.xml +++ b/android/app/src/main/res/drawable/ic_abs_forward.xml @@ -4,7 +4,11 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - + + +