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
+971 -412

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 - File renamed and moved automatically; Navidrome rescan triggered on save
### Playlists ### 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 ### Audiobookshelf Integration
- Browse libraries and audiobooks on both Web and Android - 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) ### App — Android (native Jetpack Compose)
- Media3 / ExoPlayer backend - 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 - 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.contentnegotiation.*
import io.ktor.client.plugins.logging.* import io.ktor.client.plugins.logging.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.security.MessageDigest import java.security.MessageDigest
import kotlin.random.Random import kotlin.random.Random
@@ -167,6 +170,58 @@ class SubsonicClient(
?: throw SubsonicException(-1, "Failed to create playlist") ?: 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() fun close() = http.close()
private suspend fun getList(path: String, params: List<Pair<String, String>>): SubsonicResponse { 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 getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId) 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 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? { fun coverArtUrl(id: String?, size: Int = 300): String? {
val creds = lastCredentials ?: return null val creds = lastCredentials ?: return null
@@ -49,12 +49,16 @@ object NotificationStore {
jobId: String, jobId: String,
title: String, title: String,
artist: String, artist: String,
navType: String = "",
navAlbum: String = "",
navArtist: String = "",
navId: String = "",
) { ) {
add(AppNotification(id = jobId, title = title, artist = artist, status = "pending")) add(AppNotification(id = jobId, title = title, artist = artist, status = "pending"))
scope.launch { scope.launch {
val client = ImporterClient(backendUrl) val client = ImporterClient(backendUrl)
try { 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) delay(1500)
val st: DownloadStatus = runCatching { client.status(jobId) }.getOrNull() val st: DownloadStatus = runCatching { client.status(jobId) }.getOrNull()
?: return@repeat ?: return@repeat
@@ -63,6 +67,8 @@ object NotificationStore {
replace(AppNotification( replace(AppNotification(
id = jobId, title = title, artist = artist, id = jobId, title = title, artist = artist,
status = "done", message = st.message ?: "Fertig!", status = "done", message = st.message ?: "Fertig!",
navType = navType, navAlbum = navAlbum,
navArtist = navArtist, navId = navId,
)) ))
return@launch return@launch
} }
@@ -70,6 +76,8 @@ object NotificationStore {
replace(AppNotification( replace(AppNotification(
id = jobId, title = title, artist = artist, id = jobId, title = title, artist = artist,
status = "error", message = st.message ?: "Fehler", status = "error", message = st.message ?: "Fehler",
navType = navType, navAlbum = navAlbum,
navArtist = navArtist, navId = navId,
)) ))
return@launch 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 package com.novasonic.app.player
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.core.app.NotificationCompat
import androidx.media3.common.* import androidx.media3.common.*
import androidx.media3.datasource.DataSourceBitmapLoader import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
@@ -27,7 +20,6 @@ import com.novasonic.app.R
import com.novasonic.app.data.MusicRepository import com.novasonic.app.data.MusicRepository
import com.novasonic.app.ui.MainActivity import com.novasonic.app.ui.MainActivity
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.net.URL
import java.util.concurrent.Executors import java.util.concurrent.Executors
class MusicService : MediaLibraryService() { class MusicService : MediaLibraryService() {
@@ -43,31 +35,23 @@ class MusicService : MediaLibraryService() {
// Pseudo-items for play-all / shuffle at top of each list // Pseudo-items for play-all / shuffle at top of each list
const val PLAY_ALL_PREFIX = "playall:" const val PLAY_ALL_PREFIX = "playall:"
const val SHUFFLE_PREFIX = "shuffle:" const val SHUFFLE_PREFIX = "shuffle:"
// ABS notification commands (delivered via onStartCommand) // ABS session commands (handled via onCustomCommand)
const val NOTIFICATION_CHANNEL_ID = "novasonic_playback" const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
const val NOTIFICATION_ID = 1001 const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER" const val ACTION_ABS_SKIP_FWD = "novasonic.abs.SKIP_FORWARD"
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK" const val ACTION_ABS_NEXT_CH = "novasonic.abs.NEXT_CHAPTER"
const val ACTION_ABS_SKIP_FWD = "novasonic.abs.SKIP_FORWARD" const val ACTION_ABS_TOGGLE = "novasonic.abs.TOGGLE_PLAY"
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 player: ExoPlayer
private lateinit var session: MediaLibrarySession private lateinit var session: MediaLibrarySession
private lateinit var repo: MusicRepository private lateinit var repo: MusicRepository
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var lastAbsChapterIdx = -1
// ABS notification cover cache
private var absCoverBitmap: Bitmap? = null
private var lastCoverUrl: String? = null
private var lastNotifChapterIdx = -1
private var lastNotifIsPlaying: Boolean? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
repo = MusicRepository(applicationContext) repo = MusicRepository(applicationContext)
createNotificationChannel()
val httpFactory = DefaultHttpDataSource.Factory() val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent("NovaSonic-Android/1.0") .setUserAgent("NovaSonic-Android/1.0")
@@ -94,6 +78,8 @@ class MusicService : MediaLibraryService() {
.setWakeMode(C.WAKE_MODE_NETWORK) .setWakeMode(C.WAKE_MODE_NETWORK)
.build() .build()
val wrappedPlayer = AbsAwareForwardingPlayer(player)
val activityIntent = PendingIntent.getActivity( val activityIntent = PendingIntent.getActivity(
this, 0, this, 0,
Intent(this, MainActivity::class.java).apply { Intent(this, MainActivity::class.java).apply {
@@ -102,7 +88,7 @@ class MusicService : MediaLibraryService() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
session = MediaLibrarySession.Builder(this, player, LibraryCallback()) session = MediaLibrarySession.Builder(this, wrappedPlayer, LibraryCallback())
.setBitmapLoader(bitmapLoader) .setBitmapLoader(bitmapLoader)
.setSessionActivity(activityIntent) .setSessionActivity(activityIntent)
.build() .build()
@@ -110,136 +96,136 @@ class MusicService : MediaLibraryService() {
scope.launch { scope.launch {
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds } repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
} }
// Observe ABS state — update notification when chapter or play state changes
scope.launch { scope.launch {
(applicationContext as NovaSonicApp).absPlayerController.state.collect { absState -> (applicationContext as NovaSonicApp).absPlayerController.state.collect { s ->
if (absState.item == null) { session.setCustomLayout(if (s.item != null) buildAbsCustomLayout() else ImmutableList.of())
lastNotifChapterIdx = -1
lastNotifIsPlaying = null val newIdx = if (s.item != null) s.chapterIdx else -1
lastCoverUrl = null if (newIdx != lastAbsChapterIdx) {
absCoverBitmap = null lastAbsChapterIdx = newIdx
return@collect if (s.item != null) {
} val chapter = s.chapters.getOrNull(s.chapterIdx)
val chapterChanged = absState.chapterIdx != lastNotifChapterIdx val bookTitle = s.item.media.metadata?.title ?: "Hörbuch"
val playStateChanged = absState.isPlaying != lastNotifIsPlaying val idx = player.currentMediaItemIndex.takeIf { it >= 0 } ?: return@collect
if (chapterChanged || playStateChanged) { val cur = player.getMediaItemAt(idx)
lastNotifChapterIdx = absState.chapterIdx player.replaceMediaItem(idx, cur.buildUpon()
lastNotifIsPlaying = absState.isPlaying .setMediaMetadata(
loadCoverIfNeeded(absState) MediaMetadata.Builder()
postAbsNotification(absState) .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() private fun buildAbsButtons(isPlaying: Boolean): ImmutableList<CommandButton> {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val ppIcon = if (isPlaying) R.drawable.ic_abs_pause else R.drawable.ic_abs_play
val action = intent?.action val ppLabel = if (isPlaying) "Pause" else "Abspielen"
if (action != null && action.startsWith("novasonic.abs.")) { return ImmutableList.of(
val abs = (applicationContext as NovaSonicApp).absPlayerController CommandButton.Builder()
when (action) { .setSessionCommand(SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY))
ACTION_ABS_PREV_CH -> abs.prevChapter() .setIconResId(R.drawable.ic_abs_skip_prev)
ACTION_ABS_SKIP_BACK -> abs.skipSecs(-30.0) .setDisplayName("Kapitel zurück")
ACTION_ABS_SKIP_FWD -> abs.skipSecs(30.0) .build(),
ACTION_ABS_NEXT_CH -> abs.nextChapter() CommandButton.Builder()
ACTION_ABS_TOGGLE -> abs.togglePlay() .setSessionCommand(SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY))
} .setIconResId(R.drawable.ic_abs_rewind)
return START_NOT_STICKY .setDisplayName("30s zurück")
} .setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 0) })
return super.onStartCommand(intent, flags, startId) .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 // 4-button layout for the Samsung media widget — play/pause is the standard player control.
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { // prevOrCustom → Kapitel zurück, custom0 → 30s zurück, custom1 → 30s vor, nextOrCustom → Kapitel vor.
val absState = (applicationContext as NovaSonicApp).absPlayerController.state.value // Samsung One UI slot mapping (empirically determined):
if (absState.item != null) { // prevOrCustom → custom[2], custom0 → custom[0], custom1 → custom[1], nextOrCustom → custom[3]
val notif = buildAbsNotification(absState) // Desired display: Kapitel zurück | 30s zurück | play | 30s vor | Kapitel vor
if (startInForegroundRequired || absState.isPlaying) { // Required layout order: [30s zurück, 30s vor, Kapitel zurück, Kapitel vor]
startForeground(NOTIFICATION_ID, notif) private fun buildAbsCustomLayout(): ImmutableList<CommandButton> = ImmutableList.of(
} else { CommandButton.Builder()
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notif) .setSessionCommand(SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY))
} .setIconResId(R.drawable.ic_abs_rewind)
return .setDisplayName("30s zurück")
} .build(),
super.onUpdateNotification(session, startInForegroundRequired) CommandButton.Builder()
} .setSessionCommand(SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_forward)
private fun postAbsNotification(absState: AbsPlayState) { .setDisplayName("30s vor")
val notif = buildAbsNotification(absState) .build(),
if (absState.isPlaying) { CommandButton.Builder()
startForeground(NOTIFICATION_ID, notif) .setSessionCommand(SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY))
} else { .setIconResId(R.drawable.ic_abs_skip_prev)
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notif) .setDisplayName("Kapitel zurück")
} .build(),
} CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY))
private fun buildAbsNotification(absState: AbsPlayState): Notification { .setIconResId(R.drawable.ic_abs_skip_next)
val meta = absState.item?.media?.metadata .setDisplayName("Kapitel vor")
val title = meta?.title ?: "Hörbuch" .build(),
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,
) )
private fun loadCoverIfNeeded(absState: AbsPlayState) { // Removes SEEK_TO_PREV/NEXT from available commands when ABS is active so Samsung One UI
val url = (applicationContext as NovaSonicApp).absPlayerController.currentCoverUrl() ?: return // replaces its standard prev/next slots with our first/last custom layout buttons.
if (url == lastCoverUrl) return private inner class AbsAwareForwardingPlayer(player: ExoPlayer) : ForwardingPlayer(player) {
lastCoverUrl = url override fun getAvailableCommands(): Player.Commands {
absCoverBitmap = null val base = super.getAvailableCommands()
scope.launch(Dispatchers.IO) { val isAbs = currentMediaItem?.mediaId?.startsWith("abs:") == true ||
runCatching { (applicationContext as NovaSonicApp).absPlayerController.state.value.item != null
val bmp = BitmapFactory.decodeStream(URL(url).openStream()) return if (isAbs) {
withContext(Dispatchers.Main) { base.buildUpon()
absCoverBitmap = bmp .remove(Player.COMMAND_SEEK_TO_PREVIOUS)
val current = (applicationContext as NovaSonicApp).absPlayerController.state.value .remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
if (current.item != null) postAbsNotification(current) .remove(Player.COMMAND_SEEK_TO_NEXT)
} .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
} .build()
} else base
} }
} }
private fun createNotificationChannel() { // Subclasses DNMP so we stay inside the class hierarchy Media3 expects.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // createNotification() stores the session and always passes ImmutableList.of() to super
val nm = getSystemService(NotificationManager::class.java) ?: return // so DNMP never appends setCustomLayout() buttons after getMediaButtons().
if (nm.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) return // getMediaButtons() detects ABS via currentMediaItem.mediaId — reliable, no state race.
NotificationChannel(NOTIFICATION_CHANNEL_ID, "Wiedergabe", NotificationManager.IMPORTANCE_LOW).apply { // getMediaButtons() receives the full MediaSession — detect ABS via currentMediaItem.mediaId.
description = "Musik & Hörbücher" // No createNotification() override needed. setCustomLayout() is always called with empty list
setShowBadge(false) // (in the onCreate observer) so DNMP appends zero extra buttons after getMediaButtons().
}.also { nm.createNotificationChannel(it) } 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 { 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( override fun onGetLibraryRoot(
session: MediaLibrarySession, session: MediaLibrarySession,
browser: MediaSession.ControllerInfo, browser: MediaSession.ControllerInfo,
@@ -146,9 +146,10 @@ fun AppNavigation() {
center = Offset.Unspecified, radius = 900f, 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) { val navItems = remember(absConfigured) {
TopLevel.entries.filter { it != TopLevel.ABS || absConfigured } TopLevel.entries.filter { it != TopLevel.ABS || absConfigured }
.filter { it != TopLevel.SETTINGS }
} }
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@@ -234,6 +235,10 @@ fun AppNavigation() {
HomeScreen( HomeScreen(
coverUrl = { album -> coverUrl(album.coverArt) }, coverUrl = { album -> coverUrl(album.coverArt) },
onBellClick = { showNotifications = true }, 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}") }, onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onAlbumPlay = { album -> onAlbumPlay = { album ->
scope.launch { runCatching { scope.launch { runCatching {
@@ -342,6 +347,7 @@ fun AppNavigation() {
onPlay = { songs, idx -> player.playSongs(songs, idx) }, onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) }, onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu, onShowContextMenu = ::showContextMenu,
onDeleted = { navController.popBackStack() },
onBack = { 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.background
import androidx.compose.foundation.clickable 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.Icons
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -77,6 +78,7 @@ fun HomeScreen(
onGenrePlay: ((Genre) -> Unit)? = null, onGenrePlay: ((Genre) -> Unit)? = null,
onShowAllGenres: (() -> Unit)? = null, onShowAllGenres: (() -> Unit)? = null,
onBellClick: () -> Unit = {}, onBellClick: () -> Unit = {},
onSettingsClick: () -> Unit = {},
refreshKey: Int = 0, refreshKey: Int = 0,
) { ) {
val vm: HomeViewModel = viewModel() val vm: HomeViewModel = viewModel()
@@ -122,6 +124,9 @@ fun HomeScreen(
tint = if (unreadCount > 0) NsAccent else NsOnSurface2) 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 return@LazyColumn
} }
// Hero card — matches PWA .home-hero (170px height) // Hero card — pool = frequent(5) ∪ recentlyPlayed(5), label from web
state.heroAlbum?.let { hero -> state.heroAlbum?.let { hero ->
item { item {
HeroCard( HeroCard(
album = hero, album = hero,
label = state.heroLabel,
coverUrl = coverUrl(hero), coverUrl = coverUrl(hero),
onPlay = { onAlbumPlay(hero) }, onPlay = { onAlbumPlay(hero) },
onClick = { onAlbumClick(hero) }, onClick = { onAlbumClick(hero) },
@@ -200,10 +206,10 @@ fun HomeScreen(
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
} }
} }
if (state.frequentAlbums.isNotEmpty()) { if (state.recentlyPlayedAlbums.isNotEmpty()) {
item { SectionHeader("Meistgespielt") } item { SectionHeader("Zuletzt gehört") }
item { item {
AlbumRow(albums = state.frequentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick) AlbumRow(albums = state.recentlyPlayedAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
} }
} }
@@ -211,6 +217,13 @@ fun HomeScreen(
item { SectionHeader("Entdecken") } item { SectionHeader("Entdecken") }
item { item {
AlbumRow(albums = state.randomAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick) 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)) Spacer(Modifier.height(16.dp))
} }
} }
@@ -221,7 +234,7 @@ fun HomeScreen(
// background = blur(8px) brightness(.45) + horizontal gradient overlay // background = blur(8px) brightness(.45) + horizontal gradient overlay
// cover = 100x100, border-radius:9px // cover = 100x100, border-radius:9px
@Composable @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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -230,25 +243,20 @@ private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClic
.clip(RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp))
.clickable(onClick = onClick), .clickable(onClick = onClick),
) { ) {
// Blurred background image — matches .home-hero-bg
AsyncImage( AsyncImage(
model = coverUrl, contentDescription = null, model = coverUrl, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().blur(16.dp), 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( Box(
modifier = Modifier.fillMaxSize().background( modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(Color(0xE107070F), Color(0x4D07070F))) Brush.horizontalGradient(listOf(Color(0xE107070F), Color(0x4D07070F)))
) )
) )
// Content row
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp), modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp),
) { ) {
// Cover art — 100dp, matches .home-hero-cover (100px, border-radius:9px)
AsyncImage( AsyncImage(
model = coverUrl, contentDescription = album.name, model = coverUrl, contentDescription = album.name,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
@@ -258,39 +266,42 @@ private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClic
.background(NsSurface2), .background(NsSurface2),
) )
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) { Row(
// Label — matches .home-hero-label verticalAlignment = Alignment.CenterVertically,
Text( modifier = Modifier.weight(1f),
text = "AKTUELL", ) {
fontFamily = Outfit, Column(Modifier.weight(1f)) {
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(
text = album.artist!!, text = label,
fontFamily = Outfit, fontFamily = Outfit,
fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = NsOnSurface2, fontSize = 10.sp,
maxLines = 1, 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, 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)) Spacer(Modifier.width(12.dp))
// Play button — matches .home-hero-play (42px circle, accent bg)
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier 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 @Composable
private fun SectionHeader(title: String) { private fun SectionHeader(title: String) {
Text( Text(
@@ -412,4 +422,4 @@ private fun SectionHeader(title: String) {
color = NsOnSurface2, color = NsOnSurface2,
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 12.dp), modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 12.dp),
) )
} }
@@ -11,6 +11,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.border 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -18,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.api.model.Playlist
import com.novasonic.app.ui.component.AlbumCard import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.AlbumSort
import com.novasonic.app.viewmodel.LibraryTab import com.novasonic.app.viewmodel.LibraryTab
import com.novasonic.app.viewmodel.LibraryViewModel import com.novasonic.app.viewmodel.LibraryViewModel
@@ -42,6 +47,9 @@ fun LibraryScreen(
val vm: LibraryViewModel = viewModel() val vm: LibraryViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
var showCreateDialog by remember { mutableStateOf(false) }
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
TabRow( TabRow(
selectedTabIndex = state.tab.ordinal, selectedTabIndex = state.tab.ordinal,
@@ -77,20 +85,64 @@ fun LibraryScreen(
} }
when (state.tab) { when (state.tab) {
LibraryTab.ALBUMS -> LazyVerticalGrid( LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
columns = GridCells.Adaptive(minSize = 150.dp), // Sort chips
contentPadding = PaddingValues(16.dp), androidx.compose.foundation.lazy.LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
modifier = Modifier.fillMaxSize(), ) {
) { items(AlbumSort.entries) { s ->
items(state.albums, key = { it.id }) { album -> FilterChip(
AlbumCard( selected = state.albumSort == s,
album = album, onClick = { vm.setAlbumSort(s) },
coverUrl = coverUrl(album.coverArt), label = {
width = 150.dp, Text(when (s) {
onClick = { onAlbumClick(album) }, 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( LibraryTab.PLAYLISTS -> LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp), 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), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(), 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 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* 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.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -40,10 +45,25 @@ fun PlaylistDetailScreen(
onPlay: (List<Song>, Int) -> Unit, onPlay: (List<Song>, Int) -> Unit,
onStarToggle: (Song) -> Unit, onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null, onShowContextMenu: ((Song) -> Unit)? = null,
onDeleted: () -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val vm: PlaylistViewModel = viewModel() val vm: PlaylistViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle() 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) { if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -108,6 +128,48 @@ fun PlaylistDetailScreen(
tint = Color.White, modifier = Modifier.size(18.dp), 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( Column(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
@@ -141,7 +203,7 @@ fun PlaylistDetailScreen(
} }
} }
// ── Play / Shuffle row ──────────────────────────────────────────── // ── Play / Shuffle / Edit-mode row ────────────────────────────────
Row( Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
@@ -166,22 +228,137 @@ fun PlaylistDetailScreen(
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent) 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) HorizontalDivider(color = NsBorder)
} }
itemsIndexed(pl.entry, key = { _, s -> s.id }) { idx, song -> itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
SongRow( Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
song = song, Box(Modifier.weight(1f)) {
coverUrl = coverUrl(song.coverArt), SongRow(
isPlaying = playerState.currentSong?.id == song.id, song = song,
starred = song.starred != null, coverUrl = coverUrl(song.coverArt),
onPlay = { onPlay(pl.entry, idx) }, isPlaying = playerState.currentSong?.id == song.id,
onStarToggle = { onStarToggle(song) }, starred = song.starred != null,
onMore = onShowContextMenu?.let { cb -> { cb(song) } }, 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)) } 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( data class HomeUiState(
val heroAlbum: Album? = null, val heroAlbum: Album? = null,
val heroLabel: String = "",
val recentAlbums: List<Album> = emptyList(), val recentAlbums: List<Album> = emptyList(),
val recentlyPlayedAlbums: List<Album> = emptyList(),
val frequentAlbums: List<Album> = emptyList(), val frequentAlbums: List<Album> = emptyList(),
val randomAlbums: List<Album> = emptyList(), val randomAlbums: List<Album> = emptyList(),
val genres: List<Genre> = emptyList(), val genres: List<Genre> = emptyList(),
@@ -42,28 +44,36 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null) _state.value = _state.value.copy(isLoading = true, error = null)
try { try {
val recentDef = async { repo.getRecentAlbums(20) } val recentDef = async { repo.getRecentAlbums(20) }
val frequentDef = async { repo.getFrequentAlbums(20) } val recentlyPlayedDef = async { runCatching { repo.getRecentlyPlayedAlbums(5) }.getOrDefault(emptyList()) }
val randomDef = async { repo.getRandomAlbums(20) } val frequentDef = async { repo.getFrequentAlbums(20) }
val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) } val randomDef = async { repo.getRandomAlbums(20) }
val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) }
val recent = recentDef.await() val recent = recentDef.await()
val frequent = frequentDef.await() val recentlyPlayed = recentlyPlayedDef.await()
val random = randomDef.await() val frequent = frequentDef.await()
val genres = genresDef.await() val random = randomDef.await()
val genres = genresDef.await()
// Hero rotates between first recent and first frequent album // Hero pool = frequent.take(5) ∪ recentlyPlayed.take(5), deduped — matches web
val hero = if (System.currentTimeMillis() % 2 == 0L) recent.firstOrNull() val frequentIds = frequent.take(5).map { it.id }.toSet()
else frequent.firstOrNull() ?: recent.firstOrNull() 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( _state.value = _state.value.copy(
heroAlbum = hero, heroAlbum = hero,
recentAlbums = recent, heroLabel = heroLabel,
frequentAlbums = frequent, recentAlbums = recent,
randomAlbums = random, recentlyPlayedAlbums = recentlyPlayed,
genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount }, frequentAlbums = frequent,
isLoading = false, randomAlbums = random,
error = null, genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount },
isLoading = false,
error = null,
) )
} catch (e: Exception) { } catch (e: Exception) {
_state.value = HomeUiState(error = e.message) _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.DownloadRequest
import com.novasonic.app.api.model.ImportResolveResponse import com.novasonic.app.api.model.ImportResolveResponse
import com.novasonic.app.app import com.novasonic.app.app
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.NotificationStore import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -110,6 +109,17 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
tracks = resolved.tracks, 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) pollJob(client, jobId)
} catch (e: Exception) { } catch (e: Exception) {
_state.value = _state.value.copy( _state.value = _state.value.copy(
@@ -137,17 +147,6 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
) )
if (st.status == "done" || st.status == "error") { if (st.status == "done" || st.status == "error") {
_state.value = _state.value.copy(isDownloading = false) _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 return
} }
} }
@@ -12,10 +12,12 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
enum class LibraryTab { PLAYLISTS, ALBUMS, ARTISTS } enum class LibraryTab { PLAYLISTS, ALBUMS, ARTISTS }
enum class AlbumSort { NEWEST, OLDEST, AZ, ARTIST }
data class LibraryUiState( data class LibraryUiState(
val tab: LibraryTab = LibraryTab.PLAYLISTS, val tab: LibraryTab = LibraryTab.PLAYLISTS,
val albums: List<Album> = emptyList(), val albums: List<Album> = emptyList(),
val albumSort: AlbumSort = AlbumSort.AZ,
val artistIndexes: List<ArtistIndex> = emptyList(), val artistIndexes: List<ArtistIndex> = emptyList(),
val playlists: List<Playlist> = emptyList(), val playlists: List<Playlist> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -31,6 +33,10 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
init { loadPlaylists() } init { loadPlaylists() }
fun setAlbumSort(sort: AlbumSort) {
_state.value = _state.value.copy(albumSort = sort)
}
fun selectTab(tab: LibraryTab) { fun selectTab(tab: LibraryTab) {
_state.value = _state.value.copy(tab = tab) _state.value = _state.value.copy(tab = tab)
when (tab) { when (tab) {
@@ -69,4 +75,21 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(error = e.message, isLoading = false) _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( data class PlaylistUiState(
val playlist: PlaylistWithSongs? = null, val playlist: PlaylistWithSongs? = null,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isSaving: Boolean = false,
val deleted: Boolean = false,
val error: String? = null, val error: String? = null,
) )
@@ -24,18 +26,102 @@ class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : Androi
private val _state = MutableStateFlow(PlaylistUiState()) private val _state = MutableStateFlow(PlaylistUiState())
val state: StateFlow<PlaylistUiState> = _state val state: StateFlow<PlaylistUiState> = _state
init { init { load() }
fun load() {
val id = playlistId val id = playlistId
if (id.isNullOrBlank()) { if (id.isNullOrBlank()) {
_state.value = PlaylistUiState(isLoading = false, error = "Keine Playlist-ID übergeben") _state.value = PlaylistUiState(isLoading = false, error = "Keine Playlist-ID übergeben")
} else { return
viewModelScope.launch { }
try { viewModelScope.launch {
val pl = repo.getPlaylist(id) _state.value = _state.value.copy(isLoading = true, error = null)
_state.value = PlaylistUiState(playlist = pl, isLoading = false) try {
} catch (e: Exception) { val pl = repo.getPlaylist(id)
_state.value = PlaylistUiState(isLoading = false, error = e.message ?: e.toString()) _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:height="24dp"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <group
android:fillColor="#FFFFFF" android:pivotX="12"
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"/> 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> </vector>