feat: notification nav + ABS search + HTML desc fix + brighter NowPlaying bg

- Notification sheet: add 'Zum Album →' / 'Öffnen →' button; tapping searches
  Navidrome and navigates to the matching album/playlist
- AppNotification: add navType/navAlbum/navArtist/navId fields (mirrors web notify.js)
- ImporterViewModel: store resolved album/artist/type in notification after download
- ABS Android: search bar with 400ms debounce, server-side search via /search endpoint
- AbsClient: add searchLibraryItems() for library-specific search
- AbsScreen/abs.js: strip HTML tags from book descriptions (<p> tags no longer visible)
- NowPlayingScreen: vibrant palette swatch + lighter scrim (0.15) + wider gradient
  for more visible dominant color background

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-17 01:38:58 +02:00
1 parent 72feac41bb
commit ec9c0add9e
22 files changed
+1008 -251

No files matched your search

+3
View File
@@ -96,6 +96,9 @@ dependencies {
// Palette (dominant cover color extraction in NowPlaying)
implementation("androidx.palette:palette-ktx:1.0.0")
// MediaStyle for ABS notification (already transitive via media3-session, made explicit)
implementation("androidx.media:media:1.7.0")
// Google Fonts (Outfit)
implementation(libs.compose.ui.google.fonts)
}
+1
View File
@@ -43,6 +43,7 @@
</intent-filter>
</service>
<!-- Android Auto -->
<meta-data
android:name="com.google.android.gms.car.application"
@@ -2,6 +2,7 @@ package com.novasonic.app
import android.app.Application
import com.novasonic.app.data.MusicRepository
import com.novasonic.app.player.AbsPlayerController
import com.novasonic.app.player.PlayerController
class NovaSonicApp : Application() {
@@ -10,12 +11,15 @@ class NovaSonicApp : Application() {
private set
lateinit var playerController: PlayerController
private set
lateinit var absPlayerController: AbsPlayerController
private set
override fun onCreate() {
super.onCreate()
repository = MusicRepository(applicationContext)
playerController = PlayerController(applicationContext, repository)
repository = MusicRepository(applicationContext)
playerController = PlayerController(applicationContext, repository)
playerController.connect()
absPlayerController = AbsPlayerController(applicationContext)
}
}
@@ -49,6 +49,16 @@ data class AbsItemsResponse(
val total: Int = 0,
)
@Serializable
data class AbsSearchBookResult(
val libraryItem: AbsLibraryItem = AbsLibraryItem(),
)
@Serializable
data class AbsSearchResponse(
val book: List<AbsSearchBookResult> = emptyList(),
)
@Serializable
data class AbsChapter(
val id: Int = 0,
@@ -147,6 +157,12 @@ class AbsClient(
.body<AbsItemsResponse>()
}.getOrDefault(AbsItemsResponse())
suspend fun searchLibraryItems(libId: String, query: String): List<AbsLibraryItem> = runCatching {
val q = java.net.URLEncoder.encode(query.trim(), "UTF-8")
apiGet("/api/libraries/$libId/search?q=$q&limit=40")
.body<AbsSearchResponse>().book.map { it.libraryItem }
}.getOrDefault(emptyList())
suspend fun getItemChapters(itemId: String): List<AbsChapter> = runCatching {
apiGet("/api/items/$itemId?expanded=1").body<AbsLibraryItem>().media.chapters
}.getOrDefault(emptyList())
@@ -16,6 +16,11 @@ data class AppNotification(
val message: String = "",
val timestamp: Long = System.currentTimeMillis(),
val read: Boolean = false,
// Navigation — mirrors web notify.js fields
val navType: String = "", // "track" | "album" | "playlist"
val navAlbum: String = "",
val navArtist: String = "",
val navId: String = "", // playlist ID if known
)
object NotificationStore {
@@ -1,11 +1,10 @@
package com.novasonic.app.player
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.common.MediaMetadata
import com.novasonic.app.app
import com.novasonic.app.api.AbsChapter
import com.novasonic.app.api.AbsClient
import com.novasonic.app.api.AbsLibraryItem
@@ -33,53 +32,65 @@ data class AbsPlayState(
class AbsPlayerController(context: Context) {
private val appContext = context.applicationContext
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
var scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private set
private val _state = MutableStateFlow(AbsPlayState())
val state: StateFlow<AbsPlayState> = _state.asStateFlow()
private var exoPlayer: ExoPlayer? = null
private var tickerJob: Job? = null
private var syncJob: Job? = null
private var sleepJob: Job? = null
private var sleepTickJob: Job? = null
val isActive: Boolean get() = _state.value.item != null
private var absClient: AbsClient? = null
private var audioTracks: List<AbsAudioTrack> = emptyList()
private var currentItemId: String? = null
private var isClosed = false
private var positionObserverJob: Job? = null
private var syncJob: Job? = null
private var sleepJob: Job? = null
private var sleepTickJob: Job? = null
fun currentCoverUrl(): String? {
val item = _state.value.item ?: return null
val base = absClient?.coverUrl(item.id) ?: return null
return "$base&width=512"
}
fun setChapterTrackMode(enabled: Boolean) {
_state.value = _state.value.copy(chapterTrackMode = enabled)
}
suspend fun playItem(item: AbsLibraryItem, client: AbsClient) {
if (isClosed) {
isClosed = false
scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
}
absClient = client
currentItemId = item.id
val prevSpeed = _state.value.speed
val chTrackMode = _state.value.chapterTrackMode
val prevSpeed = _state.value.speed
val chTrackMode = _state.value.chapterTrackMode
_state.value = AbsPlayState(isLoading = true, item = item, speed = prevSpeed, chapterTrackMode = chTrackMode)
// Release any existing player on main thread
withContext(Dispatchers.Main) {
stopTicker()
stopSync()
cancelSleepInternal()
exoPlayer?.stop()
exoPlayer?.release()
exoPlayer = null
}
// Cancel in-progress jobs from a previous session
positionObserverJob?.cancel()
stopSync()
cancelSleepInternal()
try {
val session = client.startSession(item.id)
val tracks = session.audioTracks
val session = client.startSession(item.id)
val tracks = session.audioTracks
val chapters = session.chapters.ifEmpty { client.getItemChapters(item.id) }
val duration = tracks.sumOf { it.duration }.let { if (it > 0.0) it else item.media.duration }
val saved = client.getProgress(item.id)
val startTime = if ((saved?.currentTime ?: 0.0) > 10.0) saved!!.currentTime else 0.0
val startTime = when {
(saved?.currentTime ?: 0.0) > 10.0 -> saved!!.currentTime
session.currentTime > 10.0 -> session.currentTime
else -> 0.0
}
audioTracks = tracks
audioTracks = tracks
currentItemId = item.id
_state.value = _state.value.copy(
@@ -96,22 +107,35 @@ class AbsPlayerController(context: Context) {
return
}
val meta = item.media.metadata
val artworkUri = client.coverUrl(item.id)?.let { Uri.parse(it) }
// Each ABS track gets mediaId "abs:<contentUrl>" so PlayerController can detect
// ABS items and skip Navidrome scrobbling / last-song tracking.
val mediaItems = tracks.map { t ->
MediaItem.fromUri(client.audioTrackUrl(t.contentUrl))
MediaItem.Builder()
.setMediaId("abs:${t.contentUrl}")
.setUri(client.audioTrackUrl(t.contentUrl))
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(meta?.title ?: "Hörbuch")
.setArtist(meta?.authorName ?: "")
.setArtworkUri(artworkUri)
.build()
)
.build()
}
withContext(Dispatchers.Main) {
val player = buildPlayer()
exoPlayer = player
player.setMediaItems(mediaItems)
player.prepare()
val (trackIdx, localSecs) = findTrack(tracks, startTime)
player.seekTo(trackIdx, (localSecs * 1000).toLong())
player.setPlaybackSpeed(_state.value.speed)
player.play()
}
val (trackIdx, localSecs) = findTrack(tracks, startTime)
// Route audio through MusicService — same notification, same mini player
appContext.app.playerController.playAbsItems(
items = mediaItems,
startIndex = trackIdx,
startPositionMs = (localSecs * 1000).toLong(),
speed = prevSpeed,
)
startPositionObserver()
startSync()
} catch (e: Exception) {
@@ -119,23 +143,42 @@ class AbsPlayerController(context: Context) {
}
}
fun togglePlay() {
val player = exoPlayer ?: return
if (player.isPlaying) player.pause() else player.play()
// ── Position observer — keeps AbsPlayState in sync with MusicService player ──
private fun startPositionObserver() {
positionObserverJob?.cancel()
positionObserverJob = scope.launch {
appContext.app.playerController.state.collect { playerState ->
val s = _state.value
if (s.item == null || audioTracks.isEmpty()) return@collect
val trackIdx = playerState.currentIndex.coerceAtLeast(0)
val track = audioTracks.getOrNull(trackIdx) ?: return@collect
val localSecs = playerState.positionMs / 1000.0
val globalTime = track.startOffset + localSecs
val chapterIdx = findChapterIdx(s.chapters, globalTime)
_state.value = s.copy(
isPlaying = playerState.isPlaying,
currentTime = globalTime,
chapterIdx = chapterIdx,
)
if (playerState.isPlaying) startSync() else { stopSync(); doSync() }
}
}
}
fun pause() { exoPlayer?.pause() }
fun resume() { exoPlayer?.play() }
// ── Playback controls — delegate to PlayerController ─────────────────────
fun togglePlay() = appContext.app.playerController.togglePlay()
fun pause() = appContext.app.playerController.pause()
fun resume() = appContext.app.playerController.play()
fun seekToGlobal(globalSecs: Double) {
val player = exoPlayer ?: return
val clamped = globalSecs.coerceIn(0.0, _state.value.duration)
val (trackIdx, localSecs) = findTrack(audioTracks, clamped)
if (player.currentMediaItemIndex != trackIdx) {
player.seekTo(trackIdx, (localSecs * 1000).toLong())
} else {
player.seekTo((localSecs * 1000).toLong())
}
appContext.app.playerController.seekToAbs(trackIdx, (localSecs * 1000).toLong())
_state.value = _state.value.copy(
currentTime = clamped,
chapterIdx = findChapterIdx(_state.value.chapters, clamped),
@@ -143,17 +186,15 @@ class AbsPlayerController(context: Context) {
}
fun nextChapter() {
val s = _state.value
val s = _state.value
val next = s.chapters.getOrNull(s.chapterIdx + 1) ?: return
seekToGlobal(next.start)
}
fun prevChapter() {
val s = _state.value
val s = _state.value
val cur = s.chapters.getOrNull(s.chapterIdx)
if (cur != null && s.currentTime - cur.start > 5) {
seekToGlobal(cur.start); return
}
if (cur != null && s.currentTime - cur.start > 5) { seekToGlobal(cur.start); return }
val prev = s.chapters.getOrNull(s.chapterIdx - 1) ?: return
seekToGlobal(prev.start)
}
@@ -167,21 +208,19 @@ class AbsPlayerController(context: Context) {
fun setSpeed(speed: Float) {
_state.value = _state.value.copy(speed = speed)
exoPlayer?.setPlaybackSpeed(speed)
appContext.app.playerController.setSpeed(speed)
}
// ── Sleep timer ───────────────────────────────────────────────────────────
fun setSleepTimer(minutes: Int) {
cancelSleepInternal()
if (minutes <= 0) {
_state.value = _state.value.copy(sleepRemainingSecs = null)
return
}
if (minutes <= 0) { _state.value = _state.value.copy(sleepRemainingSecs = null); return }
val totalSecs = minutes * 60
_state.value = _state.value.copy(sleepRemainingSecs = totalSecs)
sleepJob = scope.launch {
delay(totalSecs * 1000L)
exoPlayer?.pause()
appContext.app.playerController.pause()
_state.value = _state.value.copy(sleepRemainingSecs = null)
}
sleepTickJob = scope.launch {
@@ -200,55 +239,14 @@ class AbsPlayerController(context: Context) {
}
private fun cancelSleepInternal() {
sleepJob?.cancel(); sleepJob = null
sleepJob?.cancel(); sleepJob = null
sleepTickJob?.cancel(); sleepTickJob = null
}
private fun buildPlayer(): ExoPlayer {
val attr = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
.setUsage(C.USAGE_MEDIA)
.build()
return ExoPlayer.Builder(appContext)
.setAudioAttributes(attr, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
.build()
.also { player ->
player.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_state.value = _state.value.copy(isPlaying = isPlaying)
if (isPlaying) startTicker() else { stopTicker(); doSync() }
}
})
}
}
private fun updateCurrentTime() {
val player = exoPlayer ?: return
val trackIdx = player.currentMediaItemIndex
val track = audioTracks.getOrNull(trackIdx) ?: return
val localSecs = player.currentPosition / 1000.0
val globalTime = track.startOffset + localSecs
val newChapterIdx = findChapterIdx(_state.value.chapters, globalTime)
if (globalTime != _state.value.currentTime || newChapterIdx != _state.value.chapterIdx) {
_state.value = _state.value.copy(currentTime = globalTime, chapterIdx = newChapterIdx)
}
}
private fun startTicker() {
tickerJob?.cancel()
tickerJob = scope.launch {
while (isActive) {
delay(500)
updateCurrentTime()
}
}
}
private fun stopTicker() { tickerJob?.cancel(); tickerJob = null }
// ── Progress sync to ABS server ───────────────────────────────────────────
private fun startSync() {
syncJob?.cancel()
if (syncJob?.isActive == true) return
syncJob = scope.launch {
while (isActive) {
delay(30_000)
@@ -256,6 +254,7 @@ class AbsPlayerController(context: Context) {
}
}
}
private fun stopSync() { syncJob?.cancel(); syncJob = null }
private fun doSync() {
@@ -265,23 +264,36 @@ class AbsPlayerController(context: Context) {
scope.launch { runCatching { client.saveProgress(itemId, s.currentTime, s.duration) } }
}
// ── Close ─────────────────────────────────────────────────────────────────
fun close() {
scope.launch {
val sessionId = _state.value.sessionId
if (sessionId != null) {
doSync()
runCatching { absClient?.closeSession(sessionId) }
if (isClosed) return
isClosed = true
// Save progress and close ABS session on a detached scope so it survives
// cancellation of the main scope below.
val sessionId = _state.value.sessionId
val client = absClient
val itemId = currentItemId
val ct = _state.value.currentTime
val dur = _state.value.duration
if (sessionId != null && client != null && itemId != null) {
CoroutineScope(Dispatchers.IO).launch {
runCatching { client.saveProgress(itemId, ct, dur) }
runCatching { client.closeSession(sessionId) }
}
}
stopTicker()
positionObserverJob?.cancel()
stopSync()
cancelSleepInternal()
exoPlayer?.stop()
exoPlayer?.release()
exoPlayer = null
appContext.app.playerController.stop()
_state.value = AbsPlayState()
scope.cancel()
}
// ── Helpers ───────────────────────────────────────────────────────────────
private fun findChapterIdx(chapters: List<AbsChapter>, time: Double): Int {
if (chapters.isEmpty()) return 0
var idx = 0
@@ -0,0 +1,156 @@
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,8 +1,15 @@
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
@@ -15,9 +22,12 @@ import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import com.google.common.util.concurrent.SettableFuture
import com.novasonic.app.NovaSonicApp
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() {
@@ -33,6 +43,14 @@ 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"
}
private lateinit var player: ExoPlayer
@@ -40,9 +58,16 @@ class MusicService : MediaLibraryService() {
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
override fun onCreate() {
super.onCreate()
repo = MusicRepository(applicationContext)
createNotificationChannel()
val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent("NovaSonic-Android/1.0")
@@ -82,11 +107,140 @@ class MusicService : MediaLibraryService() {
.setSessionActivity(activityIntent)
.build()
// Keep credentials cached so coverArtUrl/streamUrl work when Auto calls us
// without the UI running (AppNavigation never subscribes on this repo instance).
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)
}
}
}
}
// 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)
}
// 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,
)
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)
}
}
}
}
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) }
}
}
override fun onGetSession(info: MediaSession.ControllerInfo) = session
@@ -155,6 +309,21 @@ class MusicService : MediaLibraryService() {
val album = repo.getAlbum(mediaId.removePrefix(ITEM_PREFIX_ALBUM))
album.toMediaItem(repo)
}
mediaId.startsWith(ITEM_PREFIX_PL) -> {
val pl = repo.getPlaylist(mediaId.removePrefix(ITEM_PREFIX_PL))
val artUri = pl.coverArt?.let { repo.coverArtUrl(it, 300)?.let { url -> android.net.Uri.parse(url) } }
MediaItem.Builder()
.setMediaId(mediaId)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(pl.name)
.setArtworkUri(artUri)
.setIsPlayable(false)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.build()
).build()
}
else -> null
}
if (item != null) settable.set(LibraryResult.ofItem(item, null))
@@ -343,7 +512,7 @@ fun com.novasonic.app.api.model.Playlist.toMediaItem(repo: MusicRepository): Med
MediaMetadata.Builder()
.setTitle(name)
.setArtworkUri(artUri)
.setIsPlayable(true)
.setIsPlayable(false) // browse-only: Auto opens the song list instead of playing immediately
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.build()
@@ -1,4 +1,4 @@
package com.novasonic.app.player
package com.novasonic.app.player
import android.content.ComponentName
import android.content.Context
@@ -7,6 +7,7 @@ import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.novasonic.app.NovaSonicApp
import com.novasonic.app.api.model.Song
import com.novasonic.app.data.MusicRepository
import kotlinx.coroutines.*
@@ -58,6 +59,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
// ── Playback commands ─────────────────────────────────────────────────────
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
// If ABS is active, close it — user explicitly switched to music
(context.applicationContext as? NovaSonicApp)?.absPlayerController?.let { abs ->
if (abs.isActive) abs.close()
}
val ctrl = controller ?: return
queue = songs
originalQueue = songs
@@ -70,6 +75,35 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
syncState()
}
/** Called by AbsPlayerController to route ABS audio through this service. */
fun playAbsItems(
items: List<MediaItem>,
startIndex: Int,
startPositionMs: Long,
speed: Float = 1f,
) {
val ctrl = controller ?: return
queue = emptyList()
originalQueue = emptyList()
isManuallyShuffled = false
ctrl.setMediaItems(items, startIndex, startPositionMs)
ctrl.prepare()
ctrl.setPlaybackSpeed(speed)
ctrl.play()
syncState()
}
/** Stop playback and clear the queue (used when ABS session ends). */
fun stop() {
val ctrl = controller ?: return
ctrl.setPlaybackSpeed(1f)
ctrl.pause()
ctrl.clearMediaItems()
queue = emptyList()
originalQueue = emptyList()
syncState()
}
fun play() = controller?.play()
fun pause() = controller?.pause()
fun togglePlay() = controller?.let { if (it.isPlaying) it.pause() else it.play() }
@@ -78,6 +112,11 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
fun skipPrev() = controller?.seekToPreviousMediaItem()
fun seekToItem(index: Int) = controller?.seekToDefaultPosition(index)
/** Seek to a specific track index and position within that track (for ABS chapter navigation). */
fun seekToAbs(windowIndex: Int, positionMs: Long) = controller?.seekTo(windowIndex, positionMs)
fun setSpeed(speed: Float) = controller?.setPlaybackSpeed(speed)
fun setVolume(v: Float) {
val clamped = v.coerceIn(0f, 1f)
controller?.volume = clamped
@@ -113,7 +152,6 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
val currentIdx = ctrl.currentMediaItemIndex
if (!isManuallyShuffled) {
// Turn ON: shuffle everything after the current song
isManuallyShuffled = true
val played = queue.take(currentIdx + 1)
val remaining = queue.drop(currentIdx + 1).shuffled()
@@ -121,7 +159,6 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
} else {
// Turn OFF: restore original order from the current song's position
isManuallyShuffled = false
val currentSong = queue.getOrNull(currentIdx)
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
@@ -165,7 +202,6 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
}.getOrDefault(emptyList()).filter { it.id !in recentIds }
}
// Small library fallback: allow repeats rather than stopping
if (candidates.isEmpty()) {
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
}
@@ -252,6 +288,9 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
syncState()
checkRadioRefill()
val ctrl = controller ?: return
// Skip scrobble and last-song tracking for ABS tracks (mediaId starts with "abs:")
val mediaId = ctrl.currentMediaItem?.mediaId ?: return
if (mediaId.startsWith("abs:")) return
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
val dur = ctrl.duration.coerceAtLeast(0)
scheduleScrobble(song, dur)
@@ -264,7 +303,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
val ctrl = controller ?: return
val ctrlCount = ctrl.mediaItemCount
// Rebuild queue from controller when items were set externally (e.g. Android Auto)
// Rebuild queue from controller when items were set externally (e.g. Android Auto or ABS)
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED && ctrlCount != queue.size) {
queue = (0 until ctrlCount).map { i -> songFromMediaItem(ctrl.getMediaItemAt(i)) }
originalQueue = queue
@@ -273,4 +312,4 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
syncState()
}
}
}
}
@@ -34,6 +34,7 @@ import com.novasonic.app.api.AbsLibraryItem
import com.novasonic.app.api.model.Song
import com.novasonic.app.player.AbsPlayerController
import com.novasonic.app.app
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.PrefsStore
import com.novasonic.app.ui.component.MiniPlayer
import com.novasonic.app.ui.component.NotificationSheet
@@ -81,7 +82,8 @@ fun AppNavigation() {
else null
}
val absPlayerController = remember { AbsPlayerController(context) }
val absPlayerController = context.app.absPlayerController
val absState by absPlayerController.state.collectAsStateWithLifecycle()
DisposableEffect(Unit) { onDispose { absPlayerController.close() } }
LaunchedEffect(appSettings.absChapterTrackMode) {
@@ -156,8 +158,12 @@ fun AppNavigation() {
bottomBar = {
if (isTopLevel) {
Column {
// MiniPlayer: show active or last played song
val showMiniPlayer = playerState.currentSong != null ||
// ABS audio routes through MusicService — one notification, one mini player.
// When an ABS book is loaded, tapping the mini player opens the ABS overlay
// instead of the music NowPlaying screen.
val hasAbsItem = absState.item != null
val showMiniPlayer = playerState.isPlaying ||
playerState.currentSong != null ||
(lastPlayedSong != null && !showNowPlaying)
AnimatedVisibility(
visible = showMiniPlayer,
@@ -167,10 +173,10 @@ fun AppNavigation() {
MiniPlayer(
state = playerState,
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
onTap = { showNowPlaying = true },
onTogglePlay = { player.togglePlay() },
onSkipPrev = { player.skipPrev() },
onSkipNext = { player.skipNext() },
onTap = { if (hasAbsItem) showAbsPlayer = true else showNowPlaying = true },
onTogglePlay = { if (hasAbsItem) absPlayerController.togglePlay() else player.togglePlay() },
onSkipPrev = { if (hasAbsItem) absPlayerController.prevChapter() else player.skipPrev() },
onSkipNext = { if (hasAbsItem) absPlayerController.nextChapter() else player.skipNext() },
lastSong = if (playerState.currentSong == null) lastPlayedSong else null,
lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) },
onPlayLast = {
@@ -452,7 +458,34 @@ fun AppNavigation() {
}
if (showNotifications) {
NotificationSheet(onDismiss = { showNotifications = false })
NotificationSheet(
onDismiss = { showNotifications = false },
onNavigate = { n ->
showNotifications = false
scope.launch {
runCatching {
when {
n.navType == "playlist" && n.navId.isNotBlank() -> {
navController.navigate("playlist/${n.navId}")
}
n.navAlbum.isNotBlank() -> {
val res = repo.search(n.navAlbum)
val albums = res.album
val norm = { s: String -> s.lowercase().trim() }
val match = albums.firstOrNull {
norm(it.name) == norm(n.navAlbum) &&
(n.navArtist.isBlank() || norm(it.artist ?: "") == norm(n.navArtist))
} ?: albums.firstOrNull { norm(it.name) == norm(n.navAlbum) }
?: albums.firstOrNull()
if (match != null) {
navController.navigate("album/${match.id}")
}
}
}
}
}
},
)
}
}
}
@@ -0,0 +1,107 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.api.AbsClient
import com.novasonic.app.player.AbsPlayState
import com.novasonic.app.ui.theme.*
@Composable
fun AbsMiniPlayer(
state: AbsPlayState,
absClient: AbsClient,
onTap: () -> Unit,
onTogglePlay: () -> Unit,
modifier: Modifier = Modifier,
) {
val item = state.item ?: return
val meta = item.media.metadata
val chapterName = state.chapters.getOrNull(state.chapterIdx)?.title
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(top = 6.dp, bottom = 4.dp)
.shadow(12.dp, RoundedCornerShape(18.dp))
.clip(RoundedCornerShape(18.dp))
.background(NsSurface),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onTap)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AsyncImage(
model = absClient.coverUrl(item.id),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(8.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = meta?.title ?: "Hörbuch",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
text = chapterName ?: meta?.authorName ?: "",
fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(NsAccent)
.clickable(onClick = onTogglePlay),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp),
)
}
}
if (state.duration > 0) {
LinearProgressIndicator(
progress = { (state.currentTime / state.duration).toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().height(3.dp),
color = NsAccent,
trackColor = NsAccent.copy(alpha = 0.15f),
)
}
}
}
@@ -1,17 +1,20 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -26,7 +29,10 @@ import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationSheet(onDismiss: () -> Unit) {
fun NotificationSheet(
onDismiss: () -> Unit,
onNavigate: (AppNotification) -> Unit = {},
) {
val items by NotificationStore.items.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { NotificationStore.markAllRead() }
@@ -73,7 +79,11 @@ fun NotificationSheet(onDismiss: () -> Unit) {
modifier = Modifier.navigationBarsPadding(),
) {
items(items, key = { it.id }) { n ->
NotifCard(n, onDelete = { NotificationStore.remove(n.id) })
NotifCard(
n = n,
onDelete = { NotificationStore.remove(n.id) },
onNavigate = { onNavigate(n); onDismiss() },
)
}
}
}
@@ -81,73 +91,108 @@ fun NotificationSheet(onDismiss: () -> Unit) {
}
@Composable
private fun NotifCard(n: AppNotification, onDelete: () -> Unit) {
Row(
verticalAlignment = Alignment.Top,
private fun NotifCard(n: AppNotification, onDelete: () -> Unit, onNavigate: () -> Unit) {
val hasNav = n.status == "done" && n.navType.isNotBlank()
val navLabel = when (n.navType) {
"playlist" -> "Öffnen →"
else -> "Zum Album →"
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(if (!n.read) NsAccent.copy(alpha = 0.05f) else Color.Transparent)
.padding(horizontal = 16.dp, vertical = 12.dp),
.background(if (!n.read) NsAccent.copy(alpha = 0.05f) else Color.Transparent),
) {
Box(
contentAlignment = Alignment.Center,
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier
.size(36.dp)
.background(
color = if (n.status == "done") Color(0xFF22C55E).copy(0.15f)
else if (n.status == "error") Color(0xFFEF4444).copy(0.15f)
else NsAccent.copy(0.15f),
shape = RoundedCornerShape(8.dp),
),
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = if (hasNav) 6.dp else 12.dp),
) {
Text(
text = when (n.status) {
"done" -> "✓"
"error" -> "✕"
else -> "…"
},
fontSize = 14.sp,
color = when (n.status) {
"done" -> Color(0xFF22C55E)
"error" -> Color(0xFFEF4444)
else -> NsAccent
},
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
n.title,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface,
)
if (n.artist.isNotBlank()) {
Text(n.artist, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
if (n.message.isNotBlank() && n.status != "pending") {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.background(
color = if (n.status == "done") Color(0xFF22C55E).copy(0.15f)
else if (n.status == "error") Color(0xFFEF4444).copy(0.15f)
else NsAccent.copy(0.15f),
shape = RoundedCornerShape(8.dp),
),
) {
Text(
n.message,
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 2.dp),
text = when (n.status) {
"done" -> "✓"
"error" -> "✕"
else -> "…"
},
fontSize = 14.sp,
color = when (n.status) {
"done" -> Color(0xFF22C55E)
"error" -> Color(0xFFEF4444)
else -> NsAccent
},
fontWeight = FontWeight.Bold,
)
}
Text(
formatTs(n.timestamp),
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2.copy(alpha = 0.55f),
modifier = Modifier.padding(top = 4.dp),
)
}
if (n.status != "pending") {
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, null, tint = NsOnSurface2, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
n.title,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface,
)
if (n.artist.isNotBlank()) {
Text(n.artist, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
if (n.message.isNotBlank() && n.status != "pending") {
Text(
n.message,
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 2.dp),
)
}
Text(
formatTs(n.timestamp),
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2.copy(alpha = 0.55f),
modifier = Modifier.padding(top = 4.dp),
)
}
if (n.status != "pending") {
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, null, tint = NsOnSurface2, modifier = Modifier.size(16.dp))
}
}
}
if (hasNav) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(start = 64.dp, end = 16.dp, bottom = 12.dp)
.clip(RoundedCornerShape(8.dp))
.background(NsAccent.copy(alpha = 0.12f))
.clickable(onClick = onNavigate)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
navLabel,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 12.sp, color = NsAccent,
)
Spacer(Modifier.width(4.dp))
Icon(
Icons.AutoMirrored.Filled.ArrowForward, null,
tint = NsAccent, modifier = Modifier.size(13.dp),
)
}
}
HorizontalDivider(
color = NsBorder.copy(alpha = 0.4f),
modifier = Modifier.padding(horizontal = 16.dp),
)
}
HorizontalDivider(
color = NsBorder.copy(alpha = 0.4f),
modifier = Modifier.padding(horizontal = 16.dp),
)
}
private val _fmt = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN)
@@ -12,7 +12,9 @@ import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
@@ -29,6 +31,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -42,8 +45,12 @@ import com.novasonic.app.api.AbsLibraryItem
import com.novasonic.app.player.AbsPlayState
import com.novasonic.app.player.AbsPlayerController
import com.novasonic.app.ui.theme.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private fun String.stripHtml(): String =
replace(Regex("<[^>]+>"), " ").replace(Regex("\\s+"), " ").trim()
// ── Library list ─────────────────────────────────────────────────────────────
@Composable
@@ -106,29 +113,41 @@ fun AbsBooksScreen(
var items by remember { mutableStateOf<List<AbsLibraryItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var query by remember { mutableStateOf("") }
// Initial load: fetch libraries, then immediately load first library's items
// Initial load: fetch libraries + first library's items
LaunchedEffect(Unit) {
isLoading = true
val libs = runCatching { absClient.getLibraries() }.getOrDefault(emptyList())
libraries = libs
val first = libs.firstOrNull()
if (first != null) {
val r = runCatching { absClient.getLibraryItems(first.id).results }
items = r.getOrDefault(emptyList())
error = r.exceptionOrNull()?.message
}
val first = libs.firstOrNull() ?: run { isLoading = false; return@LaunchedEffect }
val r = runCatching { absClient.getLibraryItems(first.id).results }
items = r.getOrDefault(emptyList())
error = r.exceptionOrNull()?.message
isLoading = false
}
// Reload when user switches library tab (only after initial load)
// Reload when user switches library tab
LaunchedEffect(selectedLibIdx) {
val lib = libraries.getOrNull(selectedLibIdx) ?: return@LaunchedEffect
isLoading = true
error = null
val r = runCatching { absClient.getLibraryItems(lib.id).results }
items = r.getOrDefault(emptyList())
error = r.exceptionOrNull()?.message
query = ""
isLoading = true; error = null
items = runCatching { absClient.getLibraryItems(lib.id).results }.getOrDefault(emptyList())
isLoading = false
}
// Debounced search — 400ms after last keystroke
LaunchedEffect(query) {
val lib = libraries.getOrNull(selectedLibIdx) ?: return@LaunchedEffect
if (query.isBlank()) {
isLoading = true; error = null
items = runCatching { absClient.getLibraryItems(lib.id).results }.getOrDefault(emptyList())
isLoading = false
return@LaunchedEffect
}
delay(400)
isLoading = true; error = null
items = runCatching { absClient.searchLibraryItems(lib.id, query) }.getOrDefault(emptyList())
isLoading = false
}
@@ -138,6 +157,33 @@ fun AbsBooksScreen(
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface,
)
Spacer(Modifier.height(10.dp))
// Search bar
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Suchen…", fontFamily = Outfit, color = NsOnSurface3) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = NsOnSurface2) },
trailingIcon = if (query.isNotEmpty()) {{
IconButton(onClick = { query = "" }) {
Icon(Icons.Default.Clear, contentDescription = "Löschen", tint = NsOnSurface2)
}
}} else null,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsBorder,
focusedTextColor = NsOnSurface,
unfocusedTextColor = NsOnSurface,
cursorColor = NsAccent,
focusedContainerColor = NsSurface,
unfocusedContainerColor = NsSurface,
),
)
// Library switcher — only shown when more than one library exists
if (libraries.size > 1) {
Spacer(Modifier.height(10.dp))
@@ -174,7 +220,10 @@ fun AbsBooksScreen(
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
}
items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Bücher gefunden", fontFamily = Outfit, color = NsOnSurface2)
Text(
if (query.isNotBlank()) "Keine Ergebnisse für \"$query\"" else "Keine Bücher gefunden",
fontFamily = Outfit, color = NsOnSurface2,
)
}
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(items) { item ->
@@ -446,11 +495,11 @@ fun AbsBookDetailScreen(
}
}
// Description
// Description (strip HTML tags from ABS markdown/HTML content)
if (!meta.description.isNullOrBlank()) {
item(key = "desc") {
Text(
text = meta.description,
text = meta.description.stripHtml(),
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
lineHeight = 20.sp,
)
@@ -694,19 +743,57 @@ fun AbsNowPlayingScreen(
@Composable
private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlayerController) {
var showSpeedPicker by remember { mutableStateOf(false) }
var showSleepPicker by remember { mutableStateOf(false) }
var showSpeedPicker by remember { mutableStateOf(false) }
var showSleepPicker by remember { mutableStateOf(false) }
var showCustomSleepDialog by remember { mutableStateOf(false) }
var customMinutesText by remember { mutableStateOf("") }
val speedOptions = listOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f)
val sleepOptions = listOf(5 to "5 min", 15 to "15 min", 30 to "30 min", 60 to "1 Stunde")
val sleepOptions = listOf(5 to "5 min", 10 to "10 min", 15 to "15 min", 30 to "30 min", 60 to "1 Stunde")
if (showCustomSleepDialog) {
AlertDialog(
onDismissRequest = { showCustomSleepDialog = false },
title = { Text("Sleep Timer", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
Column {
Text("Minuten eingeben:", fontFamily = Outfit, fontSize = 14.sp, color = NsOnSurface2)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = customMinutesText,
onValueChange = { v -> if (v.all { it.isDigit() } && v.length <= 4) customMinutesText = v },
label = { Text("Minuten", fontFamily = Outfit) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent, focusedLabelColor = NsAccent, cursorColor = NsAccent,
unfocusedBorderColor = NsOnSurface3, focusedTextColor = NsOnSurface, unfocusedTextColor = NsOnSurface,
),
)
}
},
confirmButton = {
TextButton(onClick = {
val mins = customMinutesText.toIntOrNull()
if (mins != null && mins > 0) absPlayerController.setSleepTimer(mins)
showCustomSleepDialog = false
}) { Text("Starten", color = NsAccent, fontFamily = Outfit) }
},
dismissButton = {
TextButton(onClick = { showCustomSleepDialog = false }) {
Text("Abbrechen", fontFamily = Outfit, color = NsOnSurface2)
}
},
containerColor = NsSurface,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth(),
) {
// Speed
Box {
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
TextButton(onClick = { showSpeedPicker = !showSpeedPicker; showSleepPicker = false }) {
Text(
"${state.speed}×",
@@ -721,7 +808,7 @@ private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlaye
) {
speedOptions.forEach { s ->
DropdownMenuItem(
text = { Text("${s}×", fontFamily = Outfit, color = if (s == state.speed) NsAccent else NsOnSurface) },
text = { Text("${s}×", fontFamily = Outfit, color = if (s == state.speed) NsAccent else NsOnSurface) },
onClick = { absPlayerController.setSpeed(s); showSpeedPicker = false },
)
}
@@ -729,7 +816,7 @@ private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlaye
}
// Sleep timer
Box {
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
val sleepRem = state.sleepRemainingSecs
TextButton(onClick = { showSleepPicker = !showSleepPicker; showSpeedPicker = false }) {
Icon(
@@ -751,31 +838,37 @@ private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlaye
) {
if (state.sleepRemainingSecs != null) {
DropdownMenuItem(
text = { Text("Abbrechen", fontFamily = Outfit, color = MaterialTheme.colorScheme.error) },
text = { Text("Abbrechen", fontFamily = Outfit, color = MaterialTheme.colorScheme.error) },
onClick = { absPlayerController.cancelSleep(); showSleepPicker = false },
)
}
sleepOptions.forEach { (mins, label) ->
DropdownMenuItem(
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
onClick = { absPlayerController.setSleepTimer(mins); showSleepPicker = false },
)
}
DropdownMenuItem(
text = { Text("Benutzerdefiniert…", fontFamily = Outfit, color = NsOnSurface) },
onClick = { showSleepPicker = false; customMinutesText = ""; showCustomSleepDialog = true },
)
}
}
// Chapter track mode toggle
TextButton(onClick = { absPlayerController.setChapterTrackMode(!state.chapterTrackMode) }) {
Icon(
Icons.Default.Bookmark, contentDescription = "Kapitel-Modus",
tint = if (state.chapterTrackMode) NsAccent else NsOnSurface2,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(4.dp))
Text(
"Kapitel", fontFamily = Outfit, fontSize = 12.sp,
color = if (state.chapterTrackMode) NsAccent else NsOnSurface2,
)
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
TextButton(onClick = { absPlayerController.setChapterTrackMode(!state.chapterTrackMode) }) {
Icon(
Icons.Default.Bookmark, contentDescription = "Kapitel-Modus",
tint = if (state.chapterTrackMode) NsAccent else NsOnSurface2,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(4.dp))
Text(
"Kapitel", fontFamily = Outfit, fontSize = 12.sp,
color = if (state.chapterTrackMode) NsAccent else NsOnSurface2,
)
}
}
}
}
@@ -102,13 +102,20 @@ fun NowPlayingScreen(
if (result is SuccessResult) {
val bitmap = (result.drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect
val palette = withContext(Dispatchers.Default) { Palette.from(bitmap).generate() }
val swatch = palette.darkMutedSwatch ?: palette.mutedSwatch ?: palette.dominantSwatch
val swatch = palette.vibrantSwatch ?: palette.mutedSwatch ?: palette.dominantSwatch
swatch?.rgb?.let { dominantColor = Color(it) }
}
} catch (_: Exception) {}
}
val rawBgColor = dominantColor ?: Color(0xFF0D0D1A)
// Blend vibrant color toward darker for the background without losing the hue
val rawBgColor = dominantColor?.let { c ->
Color(
red = (c.red * 0.55f).coerceIn(0f, 1f),
green = (c.green * 0.55f).coerceIn(0f, 1f),
blue = (c.blue * 0.55f).coerceIn(0f, 1f),
)
} ?: Color(0xFF0D0D1A)
val animatedBgColor by animateColorAsState(
targetValue = rawBgColor,
animationSpec = tween(600),
@@ -116,18 +123,18 @@ fun NowPlayingScreen(
)
Box(Modifier.fillMaxSize().background(NsBackground)) {
// Dominant-color gradient background
// Dominant-color gradient — covers top 70% of screen
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to animatedBgColor,
0.55f to animatedBgColor.copy(alpha = 0.65f),
0.65f to animatedBgColor.copy(alpha = 0.5f),
1f to NsBackground,
)
)
)
// Dark scrim for readability
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.28f)))
// Light scrim for readability
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.15f)))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -257,14 +264,7 @@ fun NowPlayingScreen(
)
}.filter { it.name.isNotBlank() }
// De-duplicate: if multiple entries share the same ID (Navidrome
// sometimes copies the primary artistId to all entries), only the
// first occurrence keeps it — others become non-navigable.
val seenIds = mutableSetOf<String>()
val artistList = raw.map { a ->
if (a.id.isNotBlank() && seenIds.add(a.id)) a
else a.copy(id = "")
}
val artistList = raw
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -411,13 +411,21 @@ fun NowPlayingScreen(
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs + 200L }.coerceAtLeast(0)
else -1
// Absolute scroll: always lands on the exact pixel, no accumulation lag
// Smooth continuous scroll: snap to active line, then drift toward the next
LaunchedEffect(activeIdx) {
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
// Center of active line sits at 30% from top (clearly upper area)
val targetY = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f
- lyricsViewH * 0.30f).coerceAtLeast(0f)
lyricsScrollState.animateScrollTo(targetY.toInt(), tween<Float>(280, easing = FastOutSlowInEasing))
fun targetFor(idx: Int) = (lyricsPaddingPx + idx * lineHeightPx + lineHeightPx / 2f
- lyricsViewH * 0.30f).coerceAtLeast(0f).toInt()
// Phase 1: snap to the active line
lyricsScrollState.animateScrollTo(targetFor(activeIdx), tween(380, easing = FastOutSlowInEasing))
// Phase 2: drift to the next line's position over this line's remaining duration
val nextLine = lyricsState.lines.getOrNull(activeIdx + 1) ?: return@LaunchedEffect
val thisLine = lyricsState.lines[activeIdx]
val driftMs = (nextLine.timeMs - thisLine.timeMs - 380L).coerceIn(50L, 12_000L)
lyricsScrollState.animateScrollTo(targetFor(activeIdx + 1), tween(driftMs.toInt(), easing = LinearEasing))
}
when {
@@ -137,12 +137,16 @@ 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",
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
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
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"/>
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M6,19h4L10,5H6v14zM14,5v14h4L18,5h-4z"/>
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M8,5.14v14l11,-7z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<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"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M6,6 L14.5,12 L6,18 Z M16,6 L18,6 L18,18 L16,18 Z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M6,6 L8,6 L8,18 L6,18 Z M9.5,12 L18,18 L18,6 Z"/>
</vector>
+7 -1
View File
@@ -2,6 +2,12 @@ import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { cancelMusicAutoplay } from '../player/player.js';
function stripHtml(html) {
const div = document.createElement('div');
div.innerHTML = html;
return (div.textContent || div.innerText || '').trim();
}
// ─── HTTP HELPERS ───
// API calls are routed through the NovaSonic backend proxy to avoid CORS restrictions.
// Audio streaming uses <audio src> directly (HTML media elements bypass CORS).
@@ -609,7 +615,7 @@ export async function loadAbsBookDetail(itemId) {
</button>
</div>
</div>
${meta.description ? `<div class="abs-detail-desc">${esc(meta.description)}</div>` : ''}
${meta.description ? `<div class="abs-detail-desc">${esc(stripHtml(meta.description))}</div>` : ''}
${chapters.length ? `
<div class="abs-detail-chapters">
<h3 class="abs-detail-chapters-title">Kapitel <span class="abs-ch-count">${chapters.length}</span></h3>