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

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

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

No files matched your search

@@ -8,7 +8,10 @@ import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.security.MessageDigest
import kotlin.random.Random
@@ -167,6 +170,58 @@ class SubsonicClient(
?: throw SubsonicException(-1, "Failed to create playlist")
}
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) =
getList("updatePlaylist.view", listOfNotNull(
"playlistId" to playlistId,
"name" to name,
comment?.let { "comment" to it },
))
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) =
getList("updatePlaylist.view",
listOf("playlistId" to playlistId) + indices.map { "songIndexToRemove" to it.toString() })
suspend fun deletePlaylist(playlistId: String) =
getList("deletePlaylist.view", listOf("id" to playlistId))
// ── Navidrome REST API (cover art) ────────────────────────────────────────
@Serializable private data class NdLoginReq(val username: String, val password: String)
@Serializable private data class NdLoginResp(val token: String = "")
private var _ndToken: String? = null
private suspend fun ndToken(): String {
_ndToken?.let { return it }
val resp = http.post("$serverUrl/auth/login") {
contentType(ContentType.Application.Json)
setBody(NdLoginReq(username, password))
}.body<NdLoginResp>()
val token = resp.token.ifBlank { throw Exception("Navidrome login fehlgeschlagen") }
_ndToken = token
return token
}
suspend fun uploadPlaylistCover(playlistId: String, imageBytes: ByteArray, mimeType: String) {
val token = ndToken()
http.post("$serverUrl/api/playlist/$playlistId/image") {
header("X-Nd-Authorization", "Bearer $token")
setBody(MultiPartFormDataContent(formData {
append("image", imageBytes, Headers.build {
append(HttpHeaders.ContentType, mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"cover\"")
})
}))
}
}
suspend fun removePlaylistCover(playlistId: String) {
val token = ndToken()
http.delete("$serverUrl/api/playlist/$playlistId/image") {
header("X-Nd-Authorization", "Bearer $token")
}
}
fun close() = http.close()
private suspend fun getList(path: String, params: List<Pair<String, String>>): SubsonicResponse {
@@ -58,6 +58,11 @@ class MusicRepository(private val context: Context) {
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) = api().updatePlaylistMeta(playlistId, name, comment)
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) = api().removeSongsFromPlaylist(playlistId, indices)
suspend fun deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId)
suspend fun uploadPlaylistCover(playlistId: String, imageBytes: ByteArray, mimeType: String) = api().uploadPlaylistCover(playlistId, imageBytes, mimeType)
suspend fun removePlaylistCover(playlistId: String) = api().removePlaylistCover(playlistId)
fun coverArtUrl(id: String?, size: Int = 300): String? {
val creds = lastCredentials ?: return null
@@ -49,12 +49,16 @@ object NotificationStore {
jobId: String,
title: String,
artist: String,
navType: String = "",
navAlbum: String = "",
navArtist: String = "",
navId: String = "",
) {
add(AppNotification(id = jobId, title = title, artist = artist, status = "pending"))
scope.launch {
val client = ImporterClient(backendUrl)
try {
repeat(240) { // up to ~6 minutes at 1.5 s interval
repeat(400) { // up to ~10 minutes at 1.5 s interval
delay(1500)
val st: DownloadStatus = runCatching { client.status(jobId) }.getOrNull()
?: return@repeat
@@ -63,6 +67,8 @@ object NotificationStore {
replace(AppNotification(
id = jobId, title = title, artist = artist,
status = "done", message = st.message ?: "Fertig!",
navType = navType, navAlbum = navAlbum,
navArtist = navArtist, navId = navId,
))
return@launch
}
@@ -70,6 +76,8 @@ object NotificationStore {
replace(AppNotification(
id = jobId, title = title, artist = artist,
status = "error", message = st.message ?: "Fehler",
navType = navType, navAlbum = navAlbum,
navArtist = navArtist, navId = navId,
))
return@launch
}
@@ -1,156 +0,0 @@
package com.novasonic.app.player
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.novasonic.app.R
import com.novasonic.app.app
import com.novasonic.app.ui.MainActivity
import kotlinx.coroutines.*
import java.net.HttpURLConnection
import java.net.URL
class AbsPlayerService : Service() {
companion object {
const val NOTIFICATION_ID = 1002
const val CHANNEL_ID = "abs_playback"
const val ACTION_PREV_CH = "abs.prev_chapter"
const val ACTION_SKIP_BACK = "abs.skip_back"
const val ACTION_SKIP_FWD = "abs.skip_fwd"
const val ACTION_NEXT_CH = "abs.next_chapter"
const val ACTION_TOGGLE = "abs.toggle_play"
}
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var stateJob: Job? = null
private var coverBitmap: Bitmap? = null
private var lastCoverUrl: String? = null
private var destroyed = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
val channel = NotificationChannel(
CHANNEL_ID, "Hörbuch Wiedergabe", NotificationManager.IMPORTANCE_LOW
).apply {
description = "Audiobookshelf Wiedergabe"
setShowBadge(false)
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Handle notification button taps
val ctrl = app.absPlayerController
when (intent?.action) {
ACTION_PREV_CH -> ctrl.prevChapter()
ACTION_SKIP_BACK -> ctrl.skipSecs(-30.0)
ACTION_SKIP_FWD -> ctrl.skipSecs(30.0)
ACTION_NEXT_CH -> ctrl.nextChapter()
ACTION_TOGGLE -> ctrl.togglePlay()
}
// startForeground must be called synchronously in onStartCommand — no deferral allowed.
// Using a plain Service (not MediaSessionService) gives us direct control over this.
startForeground(NOTIFICATION_ID, buildNotification(ctrl.state.value))
// Start observing state to keep notification in sync
if (stateJob == null) {
stateJob = scope.launch {
ctrl.state.collect { s ->
if (s.item == null) {
stopSelf()
} else {
updateNotification(buildNotification(s))
}
}
}
fetchCover(ctrl.currentCoverUrl())
}
return START_STICKY
}
private fun updateNotification(n: Notification) {
if (!destroyed) {
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.notify(NOTIFICATION_ID, n)
}
}
private fun fetchCover(coverUrl: String?) {
if (coverUrl == null || coverUrl == lastCoverUrl) return
lastCoverUrl = coverUrl
scope.launch(Dispatchers.IO) {
try {
val conn = URL(coverUrl).openConnection() as HttpURLConnection
conn.connectTimeout = 5_000
conn.readTimeout = 10_000
conn.connect()
val bmp = BitmapFactory.decodeStream(conn.inputStream)
conn.disconnect()
if (bmp != null) withContext(Dispatchers.Main) {
coverBitmap = bmp
updateNotification(buildNotification(app.absPlayerController.state.value))
}
} catch (_: Exception) {}
}
}
private fun buildNotification(state: AbsPlayState): Notification {
val meta = state.item?.media?.metadata
val chapter = state.chapters.getOrNull(state.chapterIdx)
val openPi = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java)
.apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val playIcon = if (state.isPlaying) R.drawable.ic_abs_pause else R.drawable.ic_abs_play
val playLabel = if (state.isPlaying) "Pause" else "Abspielen"
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(meta?.title ?: "Hörbuch")
.setContentText(chapter?.title ?: meta?.authorName ?: "")
.setLargeIcon(coverBitmap)
.setContentIntent(openPi)
.setOngoing(true)
.setSilent(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(R.drawable.ic_abs_skip_prev, "Voriges Kapitel", svcPi(ACTION_PREV_CH, 0))
.addAction(R.drawable.ic_abs_rewind, "−30s", svcPi(ACTION_SKIP_BACK, 1))
.addAction(playIcon, playLabel, svcPi(ACTION_TOGGLE, 2))
.addAction(R.drawable.ic_abs_forward, "+30s", svcPi(ACTION_SKIP_FWD, 3))
.addAction(R.drawable.ic_abs_skip_next, "Nächstes Kapitel", svcPi(ACTION_NEXT_CH, 4))
.build()
}
private fun svcPi(action: String, requestCode: Int): PendingIntent =
PendingIntent.getService(
this, requestCode,
Intent(this, AbsPlayerService::class.java).setAction(action),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
override fun onDestroy() {
if (destroyed) return
destroyed = true
stateJob?.cancel()
scope.cancel()
app.absPlayerController.close()
super.onDestroy()
}
}
@@ -1,15 +1,8 @@
package com.novasonic.app.player
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import androidx.core.app.NotificationCompat
import androidx.media3.common.*
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.DefaultDataSource
@@ -27,7 +20,6 @@ import com.novasonic.app.R
import com.novasonic.app.data.MusicRepository
import com.novasonic.app.ui.MainActivity
import kotlinx.coroutines.*
import java.net.URL
import java.util.concurrent.Executors
class MusicService : MediaLibraryService() {
@@ -43,31 +35,23 @@ class MusicService : MediaLibraryService() {
// Pseudo-items for play-all / shuffle at top of each list
const val PLAY_ALL_PREFIX = "playall:"
const val SHUFFLE_PREFIX = "shuffle:"
// ABS notification commands (delivered via onStartCommand)
const val NOTIFICATION_CHANNEL_ID = "novasonic_playback"
const val NOTIFICATION_ID = 1001
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
const val ACTION_ABS_SKIP_FWD = "novasonic.abs.SKIP_FORWARD"
const val ACTION_ABS_NEXT_CH = "novasonic.abs.NEXT_CHAPTER"
const val ACTION_ABS_TOGGLE = "novasonic.abs.TOGGLE_PLAY"
// ABS session commands (handled via onCustomCommand)
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
const val ACTION_ABS_SKIP_FWD = "novasonic.abs.SKIP_FORWARD"
const val ACTION_ABS_NEXT_CH = "novasonic.abs.NEXT_CHAPTER"
const val ACTION_ABS_TOGGLE = "novasonic.abs.TOGGLE_PLAY"
}
private lateinit var player: ExoPlayer
private lateinit var session: MediaLibrarySession
private lateinit var repo: MusicRepository
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
// ABS notification cover cache
private var absCoverBitmap: Bitmap? = null
private var lastCoverUrl: String? = null
private var lastNotifChapterIdx = -1
private var lastNotifIsPlaying: Boolean? = null
private var lastAbsChapterIdx = -1
override fun onCreate() {
super.onCreate()
repo = MusicRepository(applicationContext)
createNotificationChannel()
val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent("NovaSonic-Android/1.0")
@@ -94,6 +78,8 @@ class MusicService : MediaLibraryService() {
.setWakeMode(C.WAKE_MODE_NETWORK)
.build()
val wrappedPlayer = AbsAwareForwardingPlayer(player)
val activityIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
@@ -102,7 +88,7 @@ class MusicService : MediaLibraryService() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
session = MediaLibrarySession.Builder(this, player, LibraryCallback())
session = MediaLibrarySession.Builder(this, wrappedPlayer, LibraryCallback())
.setBitmapLoader(bitmapLoader)
.setSessionActivity(activityIntent)
.build()
@@ -110,136 +96,136 @@ class MusicService : MediaLibraryService() {
scope.launch {
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
}
// Observe ABS state — update notification when chapter or play state changes
scope.launch {
(applicationContext as NovaSonicApp).absPlayerController.state.collect { absState ->
if (absState.item == null) {
lastNotifChapterIdx = -1
lastNotifIsPlaying = null
lastCoverUrl = null
absCoverBitmap = null
return@collect
}
val chapterChanged = absState.chapterIdx != lastNotifChapterIdx
val playStateChanged = absState.isPlaying != lastNotifIsPlaying
if (chapterChanged || playStateChanged) {
lastNotifChapterIdx = absState.chapterIdx
lastNotifIsPlaying = absState.isPlaying
loadCoverIfNeeded(absState)
postAbsNotification(absState)
(applicationContext as NovaSonicApp).absPlayerController.state.collect { s ->
session.setCustomLayout(if (s.item != null) buildAbsCustomLayout() else ImmutableList.of())
val newIdx = if (s.item != null) s.chapterIdx else -1
if (newIdx != lastAbsChapterIdx) {
lastAbsChapterIdx = newIdx
if (s.item != null) {
val chapter = s.chapters.getOrNull(s.chapterIdx)
val bookTitle = s.item.media.metadata?.title ?: "Hörbuch"
val idx = player.currentMediaItemIndex.takeIf { it >= 0 } ?: return@collect
val cur = player.getMediaItemAt(idx)
player.replaceMediaItem(idx, cur.buildUpon()
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(chapter?.title?.takeIf { it.isNotBlank() } ?: bookTitle)
.setArtist(if (!chapter?.title.isNullOrBlank()) bookTitle else cur.mediaMetadata.artist)
.setArtworkUri(cur.mediaMetadata.artworkUri)
.build()
).build()
)
}
}
}
}
setMediaNotificationProvider(AbsAwareDnmp())
}
// ABS button intents are delivered here via startService()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action
if (action != null && action.startsWith("novasonic.abs.")) {
val abs = (applicationContext as NovaSonicApp).absPlayerController
when (action) {
ACTION_ABS_PREV_CH -> abs.prevChapter()
ACTION_ABS_SKIP_BACK -> abs.skipSecs(-30.0)
ACTION_ABS_SKIP_FWD -> abs.skipSecs(30.0)
ACTION_ABS_NEXT_CH -> abs.nextChapter()
ACTION_ABS_TOGGLE -> abs.togglePlay()
}
return START_NOT_STICKY
}
return super.onStartCommand(intent, flags, startId)
private fun buildAbsButtons(isPlaying: Boolean): ImmutableList<CommandButton> {
val ppIcon = if (isPlaying) R.drawable.ic_abs_pause else R.drawable.ic_abs_play
val ppLabel = if (isPlaying) "Pause" else "Abspielen"
return ImmutableList.of(
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_skip_prev)
.setDisplayName("Kapitel zurück")
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_rewind)
.setDisplayName("30s zurück")
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 0) })
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_TOGGLE, Bundle.EMPTY))
.setIconResId(ppIcon)
.setDisplayName(ppLabel)
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 1) })
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_forward)
.setDisplayName("30s vor")
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 2) })
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_skip_next)
.setDisplayName("Kapitel vor")
.build(),
)
}
// Intercept Media3's notification update when ABS is active
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
val absState = (applicationContext as NovaSonicApp).absPlayerController.state.value
if (absState.item != null) {
val notif = buildAbsNotification(absState)
if (startInForegroundRequired || absState.isPlaying) {
startForeground(NOTIFICATION_ID, notif)
} else {
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notif)
}
return
}
super.onUpdateNotification(session, startInForegroundRequired)
}
private fun postAbsNotification(absState: AbsPlayState) {
val notif = buildAbsNotification(absState)
if (absState.isPlaying) {
startForeground(NOTIFICATION_ID, notif)
} else {
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notif)
}
}
private fun buildAbsNotification(absState: AbsPlayState): Notification {
val meta = absState.item?.media?.metadata
val title = meta?.title ?: "Hörbuch"
val chapter = absState.chapters.getOrNull(absState.chapterIdx)?.title
val artist = meta?.authorName ?: ""
val playPauseIcon = if (absState.isPlaying) R.drawable.ic_abs_pause else R.drawable.ic_abs_play
val playPauseLabel = if (absState.isPlaying) "Pause" else "Abspielen"
// Action indices: 0=prev_ch, 1=skip_back, 2=toggle, 3=skip_fwd, 4=next_ch
// Compact view shows indices 1, 2, 3 → -30s | play/pause | +30s
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(chapter ?: title)
.setContentText(if (chapter != null) title else artist)
.setLargeIcon(absCoverBitmap)
.setOngoing(absState.isPlaying)
.setOnlyAlertOnce(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
))
.addAction(R.drawable.ic_abs_skip_prev, "Kapitel zurück", absPendingIntent(ACTION_ABS_PREV_CH))
.addAction(R.drawable.ic_abs_rewind, "30s zurück", absPendingIntent(ACTION_ABS_SKIP_BACK))
.addAction(playPauseIcon, playPauseLabel, absPendingIntent(ACTION_ABS_TOGGLE))
.addAction(R.drawable.ic_abs_forward, "30s vor", absPendingIntent(ACTION_ABS_SKIP_FWD))
.addAction(R.drawable.ic_abs_skip_next, "Kapitel vor", absPendingIntent(ACTION_ABS_NEXT_CH))
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(1, 2, 3)
)
.build()
}
private fun absPendingIntent(action: String): PendingIntent = PendingIntent.getService(
this, action.hashCode(),
Intent(this, MusicService::class.java).setAction(action),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
// 4-button layout for the Samsung media widget — play/pause is the standard player control.
// prevOrCustom → Kapitel zurück, custom0 → 30s zurück, custom1 → 30s vor, nextOrCustom → Kapitel vor.
// Samsung One UI slot mapping (empirically determined):
// prevOrCustom → custom[2], custom0 → custom[0], custom1 → custom[1], nextOrCustom → custom[3]
// Desired display: Kapitel zurück | 30s zurück | play | 30s vor | Kapitel vor
// Required layout order: [30s zurück, 30s vor, Kapitel zurück, Kapitel vor]
private fun buildAbsCustomLayout(): ImmutableList<CommandButton> = ImmutableList.of(
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_rewind)
.setDisplayName("30s zurück")
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_forward)
.setDisplayName("30s vor")
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_skip_prev)
.setDisplayName("Kapitel zurück")
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_skip_next)
.setDisplayName("Kapitel vor")
.build(),
)
private fun loadCoverIfNeeded(absState: AbsPlayState) {
val url = (applicationContext as NovaSonicApp).absPlayerController.currentCoverUrl() ?: return
if (url == lastCoverUrl) return
lastCoverUrl = url
absCoverBitmap = null
scope.launch(Dispatchers.IO) {
runCatching {
val bmp = BitmapFactory.decodeStream(URL(url).openStream())
withContext(Dispatchers.Main) {
absCoverBitmap = bmp
val current = (applicationContext as NovaSonicApp).absPlayerController.state.value
if (current.item != null) postAbsNotification(current)
}
}
// Removes SEEK_TO_PREV/NEXT from available commands when ABS is active so Samsung One UI
// replaces its standard prev/next slots with our first/last custom layout buttons.
private inner class AbsAwareForwardingPlayer(player: ExoPlayer) : ForwardingPlayer(player) {
override fun getAvailableCommands(): Player.Commands {
val base = super.getAvailableCommands()
val isAbs = currentMediaItem?.mediaId?.startsWith("abs:") == true ||
(applicationContext as NovaSonicApp).absPlayerController.state.value.item != null
return if (isAbs) {
base.buildUpon()
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.remove(Player.COMMAND_SEEK_TO_NEXT)
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.build()
} else base
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) return
NotificationChannel(NOTIFICATION_CHANNEL_ID, "Wiedergabe", NotificationManager.IMPORTANCE_LOW).apply {
description = "Musik & Hörbücher"
setShowBadge(false)
}.also { nm.createNotificationChannel(it) }
// Subclasses DNMP so we stay inside the class hierarchy Media3 expects.
// createNotification() stores the session and always passes ImmutableList.of() to super
// so DNMP never appends setCustomLayout() buttons after getMediaButtons().
// getMediaButtons() detects ABS via currentMediaItem.mediaId — reliable, no state race.
// getMediaButtons() receives the full MediaSession — detect ABS via currentMediaItem.mediaId.
// No createNotification() override needed. setCustomLayout() is always called with empty list
// (in the onCreate observer) so DNMP appends zero extra buttons after getMediaButtons().
private inner class AbsAwareDnmp : DefaultMediaNotificationProvider(this@MusicService) {
override fun getMediaButtons(
mediaSession: MediaSession,
playerCommands: Player.Commands,
customLayout: ImmutableList<CommandButton>,
showPauseButton: Boolean,
): ImmutableList<CommandButton> {
val mediaId = mediaSession.player.currentMediaItem?.mediaId
val absItem = (applicationContext as NovaSonicApp).absPlayerController.state.value.item
android.util.Log.e("ABS_NOTIF", "getMediaButtons: mediaId=$mediaId absItem=${absItem?.id} customLayout.size=${customLayout.size}")
val isAbs = mediaId?.startsWith("abs:") == true || absItem != null
if (!isAbs) return super.getMediaButtons(mediaSession, playerCommands, ImmutableList.of(), showPauseButton)
return buildAbsButtons(mediaSession.player.isPlaying)
}
}
@@ -258,6 +244,41 @@ class MusicService : MediaLibraryService() {
private inner class LibraryCallback : MediaLibrarySession.Callback {
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo,
): MediaSession.ConnectionResult {
val absCommands = listOf(
SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY),
SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY),
SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY),
SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY),
SessionCommand(ACTION_ABS_TOGGLE, Bundle.EMPTY),
)
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(
MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
.addSessionCommands(absCommands).build()
).build()
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle,
): ListenableFuture<SessionResult> {
val abs = (applicationContext as NovaSonicApp).absPlayerController
when (customCommand.customAction) {
ACTION_ABS_PREV_CH -> abs.prevChapter()
ACTION_ABS_SKIP_BACK -> abs.skipSecs(-30.0)
ACTION_ABS_SKIP_FWD -> abs.skipSecs(30.0)
ACTION_ABS_NEXT_CH -> abs.nextChapter()
ACTION_ABS_TOGGLE -> abs.togglePlay()
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
@@ -146,9 +146,10 @@ fun AppNavigation() {
center = Offset.Unspecified, radius = 900f,
)
// Nav items — ABS tab only shown when configured
// Nav items — ABS tab only shown when configured; Settings moved to HomeScreen header
val navItems = remember(absConfigured) {
TopLevel.entries.filter { it != TopLevel.ABS || absConfigured }
.filter { it != TopLevel.SETTINGS }
}
Box(Modifier.fillMaxSize()) {
@@ -234,6 +235,10 @@ fun AppNavigation() {
HomeScreen(
coverUrl = { album -> coverUrl(album.coverArt) },
onBellClick = { showNotifications = true },
onSettingsClick = { navController.navigate(TopLevel.SETTINGS.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true; restoreState = true
} },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onAlbumPlay = { album ->
scope.launch { runCatching {
@@ -342,6 +347,7 @@ fun AppNavigation() {
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onDeleted = { navController.popBackStack() },
onBack = { navController.popBackStack() },
)
}
@@ -1,4 +1,4 @@
package com.novasonic.app.ui.screen
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -77,6 +78,7 @@ fun HomeScreen(
onGenrePlay: ((Genre) -> Unit)? = null,
onShowAllGenres: (() -> Unit)? = null,
onBellClick: () -> Unit = {},
onSettingsClick: () -> Unit = {},
refreshKey: Int = 0,
) {
val vm: HomeViewModel = viewModel()
@@ -122,6 +124,9 @@ fun HomeScreen(
tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
}
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, "Einstellungen", tint = NsOnSurface2)
}
}
}
@@ -152,11 +157,12 @@ fun HomeScreen(
return@LazyColumn
}
// Hero card — matches PWA .home-hero (170px height)
// Hero card — pool = frequent(5) ∪ recentlyPlayed(5), label from web
state.heroAlbum?.let { hero ->
item {
HeroCard(
album = hero,
label = state.heroLabel,
coverUrl = coverUrl(hero),
onPlay = { onAlbumPlay(hero) },
onClick = { onAlbumClick(hero) },
@@ -200,10 +206,10 @@ fun HomeScreen(
Spacer(Modifier.height(28.dp))
}
}
if (state.frequentAlbums.isNotEmpty()) {
item { SectionHeader("Meistgespielt") }
if (state.recentlyPlayedAlbums.isNotEmpty()) {
item { SectionHeader("Zuletzt gehört") }
item {
AlbumRow(albums = state.frequentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
AlbumRow(albums = state.recentlyPlayedAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(28.dp))
}
}
@@ -211,6 +217,13 @@ fun HomeScreen(
item { SectionHeader("Entdecken") }
item {
AlbumRow(albums = state.randomAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(28.dp))
}
}
if (state.frequentAlbums.isNotEmpty()) {
item { SectionHeader("Meistgespielt") }
item {
AlbumRow(albums = state.frequentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(16.dp))
}
}
@@ -221,7 +234,7 @@ fun HomeScreen(
// background = blur(8px) brightness(.45) + horizontal gradient overlay
// cover = 100x100, border-radius:9px
@Composable
private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClick: () -> Unit) {
private fun HeroCard(album: Album, label: String, coverUrl: String?, onPlay: () -> Unit, onClick: () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -230,25 +243,20 @@ private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClic
.clip(RoundedCornerShape(14.dp))
.clickable(onClick = onClick),
) {
// Blurred background image — matches .home-hero-bg
AsyncImage(
model = coverUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().blur(16.dp),
)
// Gradient overlay — matches .home-hero-overlay
// linear-gradient(to right, rgba(7,7,15,.88) 0%, rgba(7,7,15,.3) 100%)
Box(
modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(Color(0xE107070F), Color(0x4D07070F)))
)
)
// Content row
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp),
) {
// Cover art — 100dp, matches .home-hero-cover (100px, border-radius:9px)
AsyncImage(
model = coverUrl, contentDescription = album.name,
contentScale = ContentScale.Crop,
@@ -258,39 +266,42 @@ private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClic
.background(NsSurface2),
)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
// Label — matches .home-hero-label
Text(
text = "AKTUELL",
fontFamily = Outfit,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 1.sp,
color = NsAccent,
)
Spacer(Modifier.height(4.dp))
Text(
text = album.name,
fontFamily = Outfit,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
letterSpacing = (-0.3).sp,
color = NsOnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
Column(Modifier.weight(1f)) {
Text(
text = album.artist!!,
text = label,
fontFamily = Outfit,
fontSize = 13.sp,
color = NsOnSurface2,
maxLines = 1,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 1.sp,
color = NsAccent,
)
Spacer(Modifier.height(4.dp))
Text(
text = album.name,
fontFamily = Outfit,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
letterSpacing = (-0.3).sp,
color = NsOnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Text(
text = album.artist!!,
fontFamily = Outfit,
fontSize = 13.sp,
color = NsOnSurface2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.height(12.dp))
// Play button — matches .home-hero-play (42px circle, accent bg)
Spacer(Modifier.width(12.dp))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
@@ -400,7 +411,6 @@ private fun AlbumRow(albums: List<Album>, coverUrl: (Album) -> String?, onAlbumC
}
}
// PWA: .section-header { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:1.5px }
@Composable
private fun SectionHeader(title: String) {
Text(
@@ -412,4 +422,4 @@ private fun SectionHeader(title: String) {
color = NsOnSurface2,
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.RoundedCornerShape
import androidx.compose.foundation.border
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -18,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -29,6 +33,7 @@ import com.novasonic.app.api.model.Artist
import com.novasonic.app.api.model.Playlist
import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.AlbumSort
import com.novasonic.app.viewmodel.LibraryTab
import com.novasonic.app.viewmodel.LibraryViewModel
@@ -42,6 +47,9 @@ fun LibraryScreen(
val vm: LibraryViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
var showCreateDialog by remember { mutableStateOf(false) }
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
TabRow(
selectedTabIndex = state.tab.ordinal,
@@ -77,20 +85,64 @@ fun LibraryScreen(
}
when (state.tab) {
LibraryTab.ALBUMS -> LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
items(state.albums, key = { it.id }) { album ->
AlbumCard(
album = album,
coverUrl = coverUrl(album.coverArt),
width = 150.dp,
onClick = { onAlbumClick(album) },
)
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
// Sort chips
androidx.compose.foundation.lazy.LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
items(AlbumSort.entries) { s ->
FilterChip(
selected = state.albumSort == s,
onClick = { vm.setAlbumSort(s) },
label = {
Text(when (s) {
AlbumSort.NEWEST -> "Neueste"
AlbumSort.OLDEST -> "Älteste"
AlbumSort.AZ -> "A-Z"
AlbumSort.ARTIST -> "Künstler"
}, fontSize = 12.sp)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent,
selectedLeadingIconColor = NsAccent,
),
)
}
}
val sortedAlbums = remember(state.albums, state.albumSort) {
when (state.albumSort) {
AlbumSort.NEWEST -> state.albums.sortedWith(
compareByDescending<com.novasonic.app.api.model.Album> { it.year ?: 0 }
.thenBy { it.name.lowercase() }
)
AlbumSort.OLDEST -> state.albums.sortedWith(
compareBy<com.novasonic.app.api.model.Album> { it.year ?: Int.MAX_VALUE }
.thenBy { it.name.lowercase() }
)
AlbumSort.AZ -> state.albums.sortedBy { it.name.lowercase() }
AlbumSort.ARTIST -> state.albums.sortedWith(
compareBy<com.novasonic.app.api.model.Album> { it.artist?.lowercase() ?: "" }
.thenBy { it.name.lowercase() }
)
}
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
items(sortedAlbums, key = { it.id }) { album ->
AlbumCard(
album = album,
coverUrl = coverUrl(album.coverArt),
width = 150.dp,
onClick = { onAlbumClick(album) },
)
}
}
}
@@ -137,7 +189,7 @@ fun LibraryScreen(
LibraryTab.PLAYLISTS -> LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
contentPadding = PaddingValues(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 80.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
@@ -152,6 +204,61 @@ fun LibraryScreen(
}
}
}
} // end Column
if (state.tab == LibraryTab.PLAYLISTS) {
FloatingActionButton(
onClick = { showCreateDialog = true },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
containerColor = NsAccent,
) {
Icon(Icons.Default.Add, "Neue Playlist", tint = NsBackground)
}
}
} // end Box
// ── Create playlist dialog ────────────────────────────────────────────────
if (showCreateDialog) {
var nameInput by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showCreateDialog = false },
title = { Text("Neue Playlist", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
OutlinedTextField(
value = nameInput,
onValueChange = { nameInput = it },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
),
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = {
vm.createPlaylist(nameInput)
showCreateDialog = false
},
enabled = nameInput.isNotBlank(),
) {
Text("Erstellen", color = NsAccent, fontFamily = Outfit, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showCreateDialog = false }) {
Text("Abbrechen", color = NsOnSurface2)
}
},
containerColor = NsSurface,
)
}
}
@@ -1,5 +1,8 @@
package com.novasonic.app.ui.screen
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -7,10 +10,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -20,7 +23,9 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -40,10 +45,25 @@ fun PlaylistDetailScreen(
onPlay: (List<Song>, Int) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
onDeleted: () -> Unit = {},
onBack: () -> Unit,
) {
val vm: PlaylistViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val context = LocalContext.current
var editMode by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri ?: return@rememberLauncherForActivityResult
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult
vm.uploadCover(bytes, mimeType)
}
LaunchedEffect(state.deleted) { if (state.deleted) onDeleted() }
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -108,6 +128,48 @@ fun PlaylistDetailScreen(
tint = Color.White, modifier = Modifier.size(18.dp),
)
}
// Rename + Cover + Delete buttons (top-right)
Row(
Modifier
.statusBarsPadding()
.padding(end = 14.dp, top = 14.dp)
.align(Alignment.TopEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { showRenameDialog = true },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Edit, "Umbenennen",
tint = Color.White, modifier = Modifier.size(18.dp))
}
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { imagePicker.launch("image/*") },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.AddAPhoto, "Cover hochladen",
tint = Color.White, modifier = Modifier.size(18.dp))
}
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { showDeleteDialog = true },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Delete, "Löschen",
tint = Color.White, modifier = Modifier.size(18.dp))
}
}
Column(
Modifier
.align(Alignment.BottomStart)
@@ -141,7 +203,7 @@ fun PlaylistDetailScreen(
}
}
// ── Play / Shuffle row ────────────────────────────────────────────
// ── Play / Shuffle / Edit-mode row ────────────────────────────────
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
@@ -166,22 +228,137 @@ fun PlaylistDetailScreen(
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
}
// Toggle edit mode for removing songs
OutlinedButton(
onClick = { editMode = !editMode },
modifier = Modifier.height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
) {
Icon(
if (editMode) Icons.Default.Done else Icons.Default.Edit,
null,
modifier = Modifier.size(18.dp),
tint = if (editMode) NsAccent else NsOnSurface2,
)
}
}
HorizontalDivider(color = NsBorder)
}
itemsIndexed(pl.entry, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(pl.entry, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(pl.entry, idx) },
onStarToggle = { onStarToggle(song) },
onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null,
)
}
if (editMode) {
IconButton(
onClick = { vm.removeSong(idx) },
modifier = Modifier.padding(end = 4.dp),
) {
Icon(
Icons.Default.RemoveCircleOutline,
"Entfernen",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(22.dp),
)
}
}
}
}
item { Spacer(Modifier.height(16.dp)) }
}
// ── Rename dialog ─────────────────────────────────────────────────────────
if (showRenameDialog) {
var nameInput by remember { mutableStateOf(pl.name) }
AlertDialog(
onDismissRequest = { showRenameDialog = false },
title = { Text("Playlist bearbeiten", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = nameInput,
onValueChange = { nameInput = it },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
),
modifier = Modifier.fillMaxWidth(),
)
if (!pl.coverArt.isNullOrBlank()) {
TextButton(
onClick = { vm.removeCover(); showRenameDialog = false },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.HideImage, null,
tint = NsOnSurface2, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Cover entfernen", color = NsOnSurface2, fontSize = 13.sp)
}
}
}
},
confirmButton = {
TextButton(
onClick = { vm.rename(nameInput); showRenameDialog = false },
enabled = nameInput.isNotBlank(),
) {
Text("Speichern", color = NsAccent, fontFamily = Outfit, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showRenameDialog = false }) {
Text("Abbrechen", color = NsOnSurface2)
}
},
containerColor = NsSurface,
)
}
// ── Delete confirmation dialog ────────────────────────────────────────────
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Playlist löschen?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
Text(
"\"${pl.name}\" wird dauerhaft gelöscht.",
fontFamily = Outfit, color = NsOnSurface2,
)
},
confirmButton = {
TextButton(
onClick = { showDeleteDialog = false; vm.delete() },
enabled = !state.isSaving,
) {
Text("Löschen", color = MaterialTheme.colorScheme.error,
fontFamily = Outfit, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Abbrechen", color = NsOnSurface2)
}
},
containerColor = NsSurface,
)
}
}
@@ -13,7 +13,9 @@ import kotlinx.coroutines.launch
data class HomeUiState(
val heroAlbum: Album? = null,
val heroLabel: String = "",
val recentAlbums: List<Album> = emptyList(),
val recentlyPlayedAlbums: List<Album> = emptyList(),
val frequentAlbums: List<Album> = emptyList(),
val randomAlbums: List<Album> = emptyList(),
val genres: List<Genre> = emptyList(),
@@ -42,28 +44,36 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val recentDef = async { repo.getRecentAlbums(20) }
val frequentDef = async { repo.getFrequentAlbums(20) }
val randomDef = async { repo.getRandomAlbums(20) }
val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) }
val recentDef = async { repo.getRecentAlbums(20) }
val recentlyPlayedDef = async { runCatching { repo.getRecentlyPlayedAlbums(5) }.getOrDefault(emptyList()) }
val frequentDef = async { repo.getFrequentAlbums(20) }
val randomDef = async { repo.getRandomAlbums(20) }
val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) }
val recent = recentDef.await()
val frequent = frequentDef.await()
val random = randomDef.await()
val genres = genresDef.await()
val recent = recentDef.await()
val recentlyPlayed = recentlyPlayedDef.await()
val frequent = frequentDef.await()
val random = randomDef.await()
val genres = genresDef.await()
// Hero rotates between first recent and first frequent album
val hero = if (System.currentTimeMillis() % 2 == 0L) recent.firstOrNull()
else frequent.firstOrNull() ?: recent.firstOrNull()
// Hero pool = frequent.take(5) ∪ recentlyPlayed.take(5), deduped — matches web
val frequentIds = frequent.take(5).map { it.id }.toSet()
val seen = mutableSetOf<String>()
val pool = (frequent.take(5) + recentlyPlayed.take(5))
.filter { it.id.isNotBlank() && seen.add(it.id) }
val hero = pool.randomOrNull()
val heroLabel = if (hero != null && frequentIds.contains(hero.id)) "MEISTGESPIELT" else "ZULETZT GEHÖRT"
_state.value = _state.value.copy(
heroAlbum = hero,
recentAlbums = recent,
frequentAlbums = frequent,
randomAlbums = random,
genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount },
isLoading = false,
error = null,
heroAlbum = hero,
heroLabel = heroLabel,
recentAlbums = recent,
recentlyPlayedAlbums = recentlyPlayed,
frequentAlbums = frequent,
randomAlbums = random,
genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount },
isLoading = false,
error = null,
)
} catch (e: Exception) {
_state.value = HomeUiState(error = e.message)
@@ -7,7 +7,6 @@ import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ImportResolveResponse
import com.novasonic.app.app
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -110,6 +109,17 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
tracks = resolved.tracks,
)
)
// App-lifetime poll → notification delivered even if user navigates away
NotificationStore.startPoll(
backendUrl = s.backendUrl,
jobId = jobId,
title = resolved.name ?: "Import",
artist = resolved.artist ?: "",
navType = resolved.type ?: "",
navAlbum = resolved.name ?: "",
navArtist = resolved.artist ?: "",
)
// In-screen progress poll (cancelled on navigation — only for UI)
pollJob(client, jobId)
} catch (e: Exception) {
_state.value = _state.value.copy(
@@ -137,17 +147,6 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
)
if (st.status == "done" || st.status == "error") {
_state.value = _state.value.copy(isDownloading = false)
val res = s.resolved
NotificationStore.add(AppNotification(
id = jobId,
title = title,
artist = artist,
status = st.status ?: "error",
message = st.message ?: if (st.status == "done") "Fertig!" else "Fehler",
navType = res?.type ?: "",
navAlbum = res?.name ?: "",
navArtist = res?.artist ?: "",
))
return
}
}
@@ -12,10 +12,12 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
enum class LibraryTab { PLAYLISTS, ALBUMS, ARTISTS }
enum class AlbumSort { NEWEST, OLDEST, AZ, ARTIST }
data class LibraryUiState(
val tab: LibraryTab = LibraryTab.PLAYLISTS,
val albums: List<Album> = emptyList(),
val albumSort: AlbumSort = AlbumSort.AZ,
val artistIndexes: List<ArtistIndex> = emptyList(),
val playlists: List<Playlist> = emptyList(),
val isLoading: Boolean = false,
@@ -31,6 +33,10 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
init { loadPlaylists() }
fun setAlbumSort(sort: AlbumSort) {
_state.value = _state.value.copy(albumSort = sort)
}
fun selectTab(tab: LibraryTab) {
_state.value = _state.value.copy(tab = tab)
when (tab) {
@@ -69,4 +75,21 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(error = e.message, isLoading = false)
}
}
fun createPlaylist(name: String, onSuccess: (String) -> Unit = {}) {
val trimmed = name.trim()
if (trimmed.isBlank()) return
viewModelScope.launch {
try {
val newPl = repo.createPlaylist(trimmed, emptyList())
val pls = runCatching { repo.getPlaylists() }.getOrDefault(_state.value.playlists)
_state.value = _state.value.copy(playlists = pls)
onSuccess(newPl.id)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message)
}
}
}
fun reloadPlaylists() = loadPlaylists()
}
@@ -13,6 +13,8 @@ import kotlinx.coroutines.launch
data class PlaylistUiState(
val playlist: PlaylistWithSongs? = null,
val isLoading: Boolean = true,
val isSaving: Boolean = false,
val deleted: Boolean = false,
val error: String? = null,
)
@@ -24,18 +26,102 @@ class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : Androi
private val _state = MutableStateFlow(PlaylistUiState())
val state: StateFlow<PlaylistUiState> = _state
init {
init { load() }
fun load() {
val id = playlistId
if (id.isNullOrBlank()) {
_state.value = PlaylistUiState(isLoading = false, error = "Keine Playlist-ID übergeben")
} else {
viewModelScope.launch {
try {
val pl = repo.getPlaylist(id)
_state.value = PlaylistUiState(playlist = pl, isLoading = false)
} catch (e: Exception) {
_state.value = PlaylistUiState(isLoading = false, error = e.message ?: e.toString())
}
return
}
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val pl = repo.getPlaylist(id)
_state.value = PlaylistUiState(playlist = pl, isLoading = false)
} catch (e: Exception) {
_state.value = PlaylistUiState(isLoading = false, error = e.message ?: e.toString())
}
}
}
fun rename(newName: String) {
val id = playlistId ?: return
val pl = _state.value.playlist ?: return
val trimmed = newName.trim()
if (trimmed.isBlank()) return
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
try {
repo.updatePlaylistMeta(id, trimmed, pl.comment)
_state.value = _state.value.copy(
playlist = pl.copy(name = trimmed),
isSaving = false,
)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun delete() {
val id = playlistId ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
try {
repo.deletePlaylist(id)
_state.value = _state.value.copy(deleted = true, isSaving = false)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun uploadCover(imageBytes: ByteArray, mimeType: String) {
val id = playlistId ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
try {
repo.uploadPlaylistCover(id, imageBytes, mimeType)
// Reload to get the updated coverArt ID from Navidrome
val pl = repo.getPlaylist(id)
_state.value = _state.value.copy(playlist = pl, isSaving = false)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun removeCover() {
val id = playlistId ?: return
val pl = _state.value.playlist ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
try {
repo.removePlaylistCover(id)
_state.value = _state.value.copy(
playlist = pl.copy(coverArt = null),
isSaving = false,
)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun removeSong(index: Int) {
val id = playlistId ?: return
val pl = _state.value.playlist ?: return
if (index < 0 || index >= pl.entry.size) return
viewModelScope.launch {
try {
repo.removeSongsFromPlaylist(id, listOf(index))
val newSongs = pl.entry.toMutableList().also { it.removeAt(index) }
_state.value = _state.value.copy(
playlist = pl.copy(entry = newSongs, songCount = newSongs.size)
)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message)
}
}
}
@@ -4,7 +4,11 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M18,13C18,16.31 15.31,19 12,19C8.69,19 6,16.31 6,13C6,9.69 8.69,7 12,7L12,11L17,6L12,1L12,5C7.58,5 4,8.58 4,13C4,17.42 7.58,21 12,21C16.42,21 20,17.42 20,13L18,13Z"/>
<group
android:pivotX="12"
android:scaleX="-1">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,5L12,1L7,6L12,11L12,7C15.31,7 18,9.69 18,13C18,16.31 15.31,19 12,19C8.69,19 6,16.31 6,13L4,13C4,17.42 7.58,21 12,21C16.42,21 20,17.42 20,13C20,8.58 16.42,5 12,5Z"/>
</group>
</vector>