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:
1 parent
eacce487df
commit
6be7981b8a
16 files changed
+971
-412
No files matched your search
+200
@@ -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)_
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user