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
+917 -160

No files matched your search

+3
View File
@@ -96,6 +96,9 @@ dependencies {
// Palette (dominant cover color extraction in NowPlaying) // Palette (dominant cover color extraction in NowPlaying)
implementation("androidx.palette:palette-ktx:1.0.0") 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) // Google Fonts (Outfit)
implementation(libs.compose.ui.google.fonts) implementation(libs.compose.ui.google.fonts)
} }
+1
View File
@@ -43,6 +43,7 @@
</intent-filter> </intent-filter>
</service> </service>
<!-- Android Auto --> <!-- Android Auto -->
<meta-data <meta-data
android:name="com.google.android.gms.car.application" android:name="com.google.android.gms.car.application"
@@ -2,6 +2,7 @@ package com.novasonic.app
import android.app.Application import android.app.Application
import com.novasonic.app.data.MusicRepository import com.novasonic.app.data.MusicRepository
import com.novasonic.app.player.AbsPlayerController
import com.novasonic.app.player.PlayerController import com.novasonic.app.player.PlayerController
class NovaSonicApp : Application() { class NovaSonicApp : Application() {
@@ -10,12 +11,15 @@ class NovaSonicApp : Application() {
private set private set
lateinit var playerController: PlayerController lateinit var playerController: PlayerController
private set private set
lateinit var absPlayerController: AbsPlayerController
private set
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
repository = MusicRepository(applicationContext) repository = MusicRepository(applicationContext)
playerController = PlayerController(applicationContext, repository) playerController = PlayerController(applicationContext, repository)
playerController.connect() playerController.connect()
absPlayerController = AbsPlayerController(applicationContext)
} }
} }
@@ -49,6 +49,16 @@ data class AbsItemsResponse(
val total: Int = 0, val total: Int = 0,
) )
@Serializable
data class AbsSearchBookResult(
val libraryItem: AbsLibraryItem = AbsLibraryItem(),
)
@Serializable
data class AbsSearchResponse(
val book: List<AbsSearchBookResult> = emptyList(),
)
@Serializable @Serializable
data class AbsChapter( data class AbsChapter(
val id: Int = 0, val id: Int = 0,
@@ -147,6 +157,12 @@ class AbsClient(
.body<AbsItemsResponse>() .body<AbsItemsResponse>()
}.getOrDefault(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 { suspend fun getItemChapters(itemId: String): List<AbsChapter> = runCatching {
apiGet("/api/items/$itemId?expanded=1").body<AbsLibraryItem>().media.chapters apiGet("/api/items/$itemId?expanded=1").body<AbsLibraryItem>().media.chapters
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
@@ -16,6 +16,11 @@ data class AppNotification(
val message: String = "", val message: String = "",
val timestamp: Long = System.currentTimeMillis(), val timestamp: Long = System.currentTimeMillis(),
val read: Boolean = false, 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 { object NotificationStore {
@@ -1,11 +1,10 @@
package com.novasonic.app.player package com.novasonic.app.player
import android.content.Context import android.content.Context
import androidx.media3.common.AudioAttributes import android.net.Uri
import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.MediaMetadata
import androidx.media3.exoplayer.ExoPlayer import com.novasonic.app.app
import com.novasonic.app.api.AbsChapter import com.novasonic.app.api.AbsChapter
import com.novasonic.app.api.AbsClient import com.novasonic.app.api.AbsClient
import com.novasonic.app.api.AbsLibraryItem import com.novasonic.app.api.AbsLibraryItem
@@ -33,26 +32,39 @@ data class AbsPlayState(
class AbsPlayerController(context: Context) { class AbsPlayerController(context: Context) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) var scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private set
private val _state = MutableStateFlow(AbsPlayState()) private val _state = MutableStateFlow(AbsPlayState())
val state: StateFlow<AbsPlayState> = _state.asStateFlow() val state: StateFlow<AbsPlayState> = _state.asStateFlow()
private var exoPlayer: ExoPlayer? = null val isActive: Boolean get() = _state.value.item != null
private var tickerJob: Job? = null
private var syncJob: Job? = null
private var sleepJob: Job? = null
private var sleepTickJob: Job? = null
private var absClient: AbsClient? = null private var absClient: AbsClient? = null
private var audioTracks: List<AbsAudioTrack> = emptyList() private var audioTracks: List<AbsAudioTrack> = emptyList()
private var currentItemId: String? = null 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) { fun setChapterTrackMode(enabled: Boolean) {
_state.value = _state.value.copy(chapterTrackMode = enabled) _state.value = _state.value.copy(chapterTrackMode = enabled)
} }
suspend fun playItem(item: AbsLibraryItem, client: AbsClient) { suspend fun playItem(item: AbsLibraryItem, client: AbsClient) {
if (isClosed) {
isClosed = false
scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
}
absClient = client absClient = client
currentItemId = item.id currentItemId = item.id
@@ -60,15 +72,10 @@ class AbsPlayerController(context: Context) {
val chTrackMode = _state.value.chapterTrackMode val chTrackMode = _state.value.chapterTrackMode
_state.value = AbsPlayState(isLoading = true, item = item, speed = prevSpeed, chapterTrackMode = chTrackMode) _state.value = AbsPlayState(isLoading = true, item = item, speed = prevSpeed, chapterTrackMode = chTrackMode)
// Release any existing player on main thread // Cancel in-progress jobs from a previous session
withContext(Dispatchers.Main) { positionObserverJob?.cancel()
stopTicker()
stopSync() stopSync()
cancelSleepInternal() cancelSleepInternal()
exoPlayer?.stop()
exoPlayer?.release()
exoPlayer = null
}
try { try {
val session = client.startSession(item.id) val session = client.startSession(item.id)
@@ -77,7 +84,11 @@ class AbsPlayerController(context: Context) {
val duration = tracks.sumOf { it.duration }.let { if (it > 0.0) it else item.media.duration } val duration = tracks.sumOf { it.duration }.let { if (it > 0.0) it else item.media.duration }
val saved = client.getProgress(item.id) 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 currentItemId = item.id
@@ -96,22 +107,35 @@ class AbsPlayerController(context: Context) {
return return
} }
val mediaItems = tracks.map { t -> val meta = item.media.metadata
MediaItem.fromUri(client.audioTrackUrl(t.contentUrl)) val artworkUri = client.coverUrl(item.id)?.let { Uri.parse(it) }
}
withContext(Dispatchers.Main) { // Each ABS track gets mediaId "abs:<contentUrl>" so PlayerController can detect
val player = buildPlayer() // ABS items and skip Navidrome scrobbling / last-song tracking.
exoPlayer = player val mediaItems = tracks.map { t ->
player.setMediaItems(mediaItems) MediaItem.Builder()
player.prepare() .setMediaId("abs:${t.contentUrl}")
.setUri(client.audioTrackUrl(t.contentUrl))
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(meta?.title ?: "Hörbuch")
.setArtist(meta?.authorName ?: "")
.setArtworkUri(artworkUri)
.build()
)
.build()
}
val (trackIdx, localSecs) = findTrack(tracks, startTime) val (trackIdx, localSecs) = findTrack(tracks, startTime)
player.seekTo(trackIdx, (localSecs * 1000).toLong()) // Route audio through MusicService — same notification, same mini player
player.setPlaybackSpeed(_state.value.speed) appContext.app.playerController.playAbsItems(
player.play() items = mediaItems,
} startIndex = trackIdx,
startPositionMs = (localSecs * 1000).toLong(),
speed = prevSpeed,
)
startPositionObserver()
startSync() startSync()
} catch (e: Exception) { } catch (e: Exception) {
@@ -119,23 +143,42 @@ class AbsPlayerController(context: Context) {
} }
} }
fun togglePlay() { // ── Position observer — keeps AbsPlayState in sync with MusicService player ──
val player = exoPlayer ?: return
if (player.isPlaying) player.pause() else player.play() 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() } // ── Playback controls — delegate to PlayerController ─────────────────────
fun resume() { exoPlayer?.play() }
fun togglePlay() = appContext.app.playerController.togglePlay()
fun pause() = appContext.app.playerController.pause()
fun resume() = appContext.app.playerController.play()
fun seekToGlobal(globalSecs: Double) { fun seekToGlobal(globalSecs: Double) {
val player = exoPlayer ?: return
val clamped = globalSecs.coerceIn(0.0, _state.value.duration) val clamped = globalSecs.coerceIn(0.0, _state.value.duration)
val (trackIdx, localSecs) = findTrack(audioTracks, clamped) val (trackIdx, localSecs) = findTrack(audioTracks, clamped)
if (player.currentMediaItemIndex != trackIdx) { appContext.app.playerController.seekToAbs(trackIdx, (localSecs * 1000).toLong())
player.seekTo(trackIdx, (localSecs * 1000).toLong())
} else {
player.seekTo((localSecs * 1000).toLong())
}
_state.value = _state.value.copy( _state.value = _state.value.copy(
currentTime = clamped, currentTime = clamped,
chapterIdx = findChapterIdx(_state.value.chapters, clamped), chapterIdx = findChapterIdx(_state.value.chapters, clamped),
@@ -151,9 +194,7 @@ class AbsPlayerController(context: Context) {
fun prevChapter() { fun prevChapter() {
val s = _state.value val s = _state.value
val cur = s.chapters.getOrNull(s.chapterIdx) val cur = s.chapters.getOrNull(s.chapterIdx)
if (cur != null && s.currentTime - cur.start > 5) { if (cur != null && s.currentTime - cur.start > 5) { seekToGlobal(cur.start); return }
seekToGlobal(cur.start); return
}
val prev = s.chapters.getOrNull(s.chapterIdx - 1) ?: return val prev = s.chapters.getOrNull(s.chapterIdx - 1) ?: return
seekToGlobal(prev.start) seekToGlobal(prev.start)
} }
@@ -167,21 +208,19 @@ class AbsPlayerController(context: Context) {
fun setSpeed(speed: Float) { fun setSpeed(speed: Float) {
_state.value = _state.value.copy(speed = speed) _state.value = _state.value.copy(speed = speed)
exoPlayer?.setPlaybackSpeed(speed) appContext.app.playerController.setSpeed(speed)
} }
// ── Sleep timer ───────────────────────────────────────────────────────────
fun setSleepTimer(minutes: Int) { fun setSleepTimer(minutes: Int) {
cancelSleepInternal() cancelSleepInternal()
if (minutes <= 0) { if (minutes <= 0) { _state.value = _state.value.copy(sleepRemainingSecs = null); return }
_state.value = _state.value.copy(sleepRemainingSecs = null)
return
}
val totalSecs = minutes * 60 val totalSecs = minutes * 60
_state.value = _state.value.copy(sleepRemainingSecs = totalSecs) _state.value = _state.value.copy(sleepRemainingSecs = totalSecs)
sleepJob = scope.launch { sleepJob = scope.launch {
delay(totalSecs * 1000L) delay(totalSecs * 1000L)
exoPlayer?.pause() appContext.app.playerController.pause()
_state.value = _state.value.copy(sleepRemainingSecs = null) _state.value = _state.value.copy(sleepRemainingSecs = null)
} }
sleepTickJob = scope.launch { sleepTickJob = scope.launch {
@@ -204,51 +243,10 @@ class AbsPlayerController(context: Context) {
sleepTickJob?.cancel(); sleepTickJob = null sleepTickJob?.cancel(); sleepTickJob = null
} }
private fun buildPlayer(): ExoPlayer { // ── Progress sync to ABS server ───────────────────────────────────────────
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 }
private fun startSync() { private fun startSync() {
syncJob?.cancel() if (syncJob?.isActive == true) return
syncJob = scope.launch { syncJob = scope.launch {
while (isActive) { while (isActive) {
delay(30_000) delay(30_000)
@@ -256,6 +254,7 @@ class AbsPlayerController(context: Context) {
} }
} }
} }
private fun stopSync() { syncJob?.cancel(); syncJob = null } private fun stopSync() { syncJob?.cancel(); syncJob = null }
private fun doSync() { private fun doSync() {
@@ -265,23 +264,36 @@ class AbsPlayerController(context: Context) {
scope.launch { runCatching { client.saveProgress(itemId, s.currentTime, s.duration) } } scope.launch { runCatching { client.saveProgress(itemId, s.currentTime, s.duration) } }
} }
// ── Close ─────────────────────────────────────────────────────────────────
fun close() { fun close() {
scope.launch { 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 sessionId = _state.value.sessionId
if (sessionId != null) { val client = absClient
doSync() val itemId = currentItemId
runCatching { absClient?.closeSession(sessionId) } 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() stopSync()
cancelSleepInternal() cancelSleepInternal()
exoPlayer?.stop() appContext.app.playerController.stop()
exoPlayer?.release() _state.value = AbsPlayState()
exoPlayer = null
scope.cancel() scope.cancel()
} }
// ── Helpers ───────────────────────────────────────────────────────────────
private fun findChapterIdx(chapters: List<AbsChapter>, time: Double): Int { private fun findChapterIdx(chapters: List<AbsChapter>, time: Double): Int {
if (chapters.isEmpty()) return 0 if (chapters.isEmpty()) return 0
var idx = 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 package com.novasonic.app.player
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.core.app.NotificationCompat
import androidx.media3.common.* import androidx.media3.common.*
import androidx.media3.datasource.DataSourceBitmapLoader import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
@@ -15,9 +22,12 @@ import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
import com.google.common.util.concurrent.SettableFuture 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.data.MusicRepository
import com.novasonic.app.ui.MainActivity import com.novasonic.app.ui.MainActivity
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.net.URL
import java.util.concurrent.Executors import java.util.concurrent.Executors
class MusicService : MediaLibraryService() { class MusicService : MediaLibraryService() {
@@ -33,6 +43,14 @@ class MusicService : MediaLibraryService() {
// Pseudo-items for play-all / shuffle at top of each list // Pseudo-items for play-all / shuffle at top of each list
const val PLAY_ALL_PREFIX = "playall:" const val PLAY_ALL_PREFIX = "playall:"
const val SHUFFLE_PREFIX = "shuffle:" const val SHUFFLE_PREFIX = "shuffle:"
// ABS notification commands (delivered via onStartCommand)
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 private lateinit var player: ExoPlayer
@@ -40,9 +58,16 @@ class MusicService : MediaLibraryService() {
private lateinit var repo: MusicRepository private lateinit var repo: MusicRepository
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) 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() { override fun onCreate() {
super.onCreate() super.onCreate()
repo = MusicRepository(applicationContext) repo = MusicRepository(applicationContext)
createNotificationChannel()
val httpFactory = DefaultHttpDataSource.Factory() val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent("NovaSonic-Android/1.0") .setUserAgent("NovaSonic-Android/1.0")
@@ -82,11 +107,140 @@ class MusicService : MediaLibraryService() {
.setSessionActivity(activityIntent) .setSessionActivity(activityIntent)
.build() .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 { scope.launch {
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds } repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
} }
// Observe ABS state — update notification when chapter or play state changes
scope.launch {
(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 override fun onGetSession(info: MediaSession.ControllerInfo) = session
@@ -155,6 +309,21 @@ class MusicService : MediaLibraryService() {
val album = repo.getAlbum(mediaId.removePrefix(ITEM_PREFIX_ALBUM)) val album = repo.getAlbum(mediaId.removePrefix(ITEM_PREFIX_ALBUM))
album.toMediaItem(repo) 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 else -> null
} }
if (item != null) settable.set(LibraryResult.ofItem(item, 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() MediaMetadata.Builder()
.setTitle(name) .setTitle(name)
.setArtworkUri(artUri) .setArtworkUri(artUri)
.setIsPlayable(true) .setIsPlayable(false) // browse-only: Auto opens the song list instead of playing immediately
.setIsBrowsable(true) .setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.build() .build()
@@ -1,4 +1,4 @@
package com.novasonic.app.player package com.novasonic.app.player
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
@@ -7,6 +7,7 @@ import androidx.media3.common.Player
import androidx.media3.common.Timeline import androidx.media3.common.Timeline
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.novasonic.app.NovaSonicApp
import com.novasonic.app.api.model.Song import com.novasonic.app.api.model.Song
import com.novasonic.app.data.MusicRepository import com.novasonic.app.data.MusicRepository
import kotlinx.coroutines.* import kotlinx.coroutines.*
@@ -58,6 +59,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
// ── Playback commands ───────────────────────────────────────────────────── // ── Playback commands ─────────────────────────────────────────────────────
fun playSongs(songs: List<Song>, startIndex: Int = 0) { 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 val ctrl = controller ?: return
queue = songs queue = songs
originalQueue = songs originalQueue = songs
@@ -70,6 +75,35 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
syncState() 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 play() = controller?.play()
fun pause() = controller?.pause() fun pause() = controller?.pause()
fun togglePlay() = controller?.let { if (it.isPlaying) it.pause() else it.play() } 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 skipPrev() = controller?.seekToPreviousMediaItem()
fun seekToItem(index: Int) = controller?.seekToDefaultPosition(index) 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) { fun setVolume(v: Float) {
val clamped = v.coerceIn(0f, 1f) val clamped = v.coerceIn(0f, 1f)
controller?.volume = clamped controller?.volume = clamped
@@ -113,7 +152,6 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
val currentIdx = ctrl.currentMediaItemIndex val currentIdx = ctrl.currentMediaItemIndex
if (!isManuallyShuffled) { if (!isManuallyShuffled) {
// Turn ON: shuffle everything after the current song
isManuallyShuffled = true isManuallyShuffled = true
val played = queue.take(currentIdx + 1) val played = queue.take(currentIdx + 1)
val remaining = queue.drop(currentIdx + 1).shuffled() 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) for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) } remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
} else { } else {
// Turn OFF: restore original order from the current song's position
isManuallyShuffled = false isManuallyShuffled = false
val currentSong = queue.getOrNull(currentIdx) val currentSong = queue.getOrNull(currentIdx)
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0) 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 } }.getOrDefault(emptyList()).filter { it.id !in recentIds }
} }
// Small library fallback: allow repeats rather than stopping
if (candidates.isEmpty()) { if (candidates.isEmpty()) {
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList()) candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
} }
@@ -252,6 +288,9 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
syncState() syncState()
checkRadioRefill() checkRadioRefill()
val ctrl = controller ?: return 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 song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
val dur = ctrl.duration.coerceAtLeast(0) val dur = ctrl.duration.coerceAtLeast(0)
scheduleScrobble(song, dur) scheduleScrobble(song, dur)
@@ -264,7 +303,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
override fun onTimelineChanged(timeline: Timeline, reason: Int) { override fun onTimelineChanged(timeline: Timeline, reason: Int) {
val ctrl = controller ?: return val ctrl = controller ?: return
val ctrlCount = ctrl.mediaItemCount 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) { if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED && ctrlCount != queue.size) {
queue = (0 until ctrlCount).map { i -> songFromMediaItem(ctrl.getMediaItemAt(i)) } queue = (0 until ctrlCount).map { i -> songFromMediaItem(ctrl.getMediaItemAt(i)) }
originalQueue = queue originalQueue = queue
@@ -34,6 +34,7 @@ import com.novasonic.app.api.AbsLibraryItem
import com.novasonic.app.api.model.Song import com.novasonic.app.api.model.Song
import com.novasonic.app.player.AbsPlayerController import com.novasonic.app.player.AbsPlayerController
import com.novasonic.app.app import com.novasonic.app.app
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.PrefsStore import com.novasonic.app.data.PrefsStore
import com.novasonic.app.ui.component.MiniPlayer import com.novasonic.app.ui.component.MiniPlayer
import com.novasonic.app.ui.component.NotificationSheet import com.novasonic.app.ui.component.NotificationSheet
@@ -81,7 +82,8 @@ fun AppNavigation() {
else null else null
} }
val absPlayerController = remember { AbsPlayerController(context) } val absPlayerController = context.app.absPlayerController
val absState by absPlayerController.state.collectAsStateWithLifecycle()
DisposableEffect(Unit) { onDispose { absPlayerController.close() } } DisposableEffect(Unit) { onDispose { absPlayerController.close() } }
LaunchedEffect(appSettings.absChapterTrackMode) { LaunchedEffect(appSettings.absChapterTrackMode) {
@@ -156,8 +158,12 @@ fun AppNavigation() {
bottomBar = { bottomBar = {
if (isTopLevel) { if (isTopLevel) {
Column { Column {
// MiniPlayer: show active or last played song // ABS audio routes through MusicService — one notification, one mini player.
val showMiniPlayer = playerState.currentSong != null || // 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) (lastPlayedSong != null && !showNowPlaying)
AnimatedVisibility( AnimatedVisibility(
visible = showMiniPlayer, visible = showMiniPlayer,
@@ -167,10 +173,10 @@ fun AppNavigation() {
MiniPlayer( MiniPlayer(
state = playerState, state = playerState,
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri, coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
onTap = { showNowPlaying = true }, onTap = { if (hasAbsItem) showAbsPlayer = true else showNowPlaying = true },
onTogglePlay = { player.togglePlay() }, onTogglePlay = { if (hasAbsItem) absPlayerController.togglePlay() else player.togglePlay() },
onSkipPrev = { player.skipPrev() }, onSkipPrev = { if (hasAbsItem) absPlayerController.prevChapter() else player.skipPrev() },
onSkipNext = { player.skipNext() }, onSkipNext = { if (hasAbsItem) absPlayerController.nextChapter() else player.skipNext() },
lastSong = if (playerState.currentSong == null) lastPlayedSong else null, lastSong = if (playerState.currentSong == null) lastPlayedSong else null,
lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) }, lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) },
onPlayLast = { onPlayLast = {
@@ -452,7 +458,34 @@ fun AppNavigation() {
} }
if (showNotifications) { 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 package com.novasonic.app.ui.component
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.Close
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -26,7 +29,10 @@ import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NotificationSheet(onDismiss: () -> Unit) { fun NotificationSheet(
onDismiss: () -> Unit,
onNavigate: (AppNotification) -> Unit = {},
) {
val items by NotificationStore.items.collectAsStateWithLifecycle() val items by NotificationStore.items.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { NotificationStore.markAllRead() } LaunchedEffect(Unit) { NotificationStore.markAllRead() }
@@ -73,7 +79,11 @@ fun NotificationSheet(onDismiss: () -> Unit) {
modifier = Modifier.navigationBarsPadding(), modifier = Modifier.navigationBarsPadding(),
) { ) {
items(items, key = { it.id }) { n -> 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,13 +91,23 @@ fun NotificationSheet(onDismiss: () -> Unit) {
} }
@Composable @Composable
private fun NotifCard(n: AppNotification, onDelete: () -> Unit) { 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),
) {
Row( Row(
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(if (!n.read) NsAccent.copy(alpha = 0.05f) else Color.Transparent) .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = if (hasNav) 6.dp else 12.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
) { ) {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -144,11 +164,36 @@ private fun NotifCard(n: AppNotification, onDelete: () -> Unit) {
} }
} }
} }
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( HorizontalDivider(
color = NsBorder.copy(alpha = 0.4f), color = NsBorder.copy(alpha = 0.4f),
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
) )
} }
}
private val _fmt = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN) private val _fmt = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN)
private fun formatTs(ts: Long): String = _fmt.format(Date(ts)) private fun formatTs(ts: Long): String = _fmt.format(Date(ts))
@@ -12,7 +12,9 @@ import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState 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.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -42,8 +45,12 @@ import com.novasonic.app.api.AbsLibraryItem
import com.novasonic.app.player.AbsPlayState import com.novasonic.app.player.AbsPlayState
import com.novasonic.app.player.AbsPlayerController import com.novasonic.app.player.AbsPlayerController
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private fun String.stripHtml(): String =
replace(Regex("<[^>]+>"), " ").replace(Regex("\\s+"), " ").trim()
// ── Library list ───────────────────────────────────────────────────────────── // ── Library list ─────────────────────────────────────────────────────────────
@Composable @Composable
@@ -106,29 +113,41 @@ fun AbsBooksScreen(
var items by remember { mutableStateOf<List<AbsLibraryItem>>(emptyList()) } var items by remember { mutableStateOf<List<AbsLibraryItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) } 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) { LaunchedEffect(Unit) {
isLoading = true isLoading = true
val libs = runCatching { absClient.getLibraries() }.getOrDefault(emptyList()) val libs = runCatching { absClient.getLibraries() }.getOrDefault(emptyList())
libraries = libs libraries = libs
val first = libs.firstOrNull() val first = libs.firstOrNull() ?: run { isLoading = false; return@LaunchedEffect }
if (first != null) {
val r = runCatching { absClient.getLibraryItems(first.id).results } val r = runCatching { absClient.getLibraryItems(first.id).results }
items = r.getOrDefault(emptyList()) items = r.getOrDefault(emptyList())
error = r.exceptionOrNull()?.message error = r.exceptionOrNull()?.message
}
isLoading = false isLoading = false
} }
// Reload when user switches library tab (only after initial load) // Reload when user switches library tab
LaunchedEffect(selectedLibIdx) { LaunchedEffect(selectedLibIdx) {
val lib = libraries.getOrNull(selectedLibIdx) ?: return@LaunchedEffect val lib = libraries.getOrNull(selectedLibIdx) ?: return@LaunchedEffect
isLoading = true query = ""
error = null isLoading = true; error = null
val r = runCatching { absClient.getLibraryItems(lib.id).results } items = runCatching { absClient.getLibraryItems(lib.id).results }.getOrDefault(emptyList())
items = r.getOrDefault(emptyList()) isLoading = false
error = r.exceptionOrNull()?.message }
// 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 isLoading = false
} }
@@ -138,6 +157,33 @@ fun AbsBooksScreen(
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface, 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 // Library switcher — only shown when more than one library exists
if (libraries.size > 1) { if (libraries.size > 1) {
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
@@ -174,7 +220,10 @@ fun AbsBooksScreen(
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error) Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
} }
items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 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)) { else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(items) { item -> items(items) { item ->
@@ -446,11 +495,11 @@ fun AbsBookDetailScreen(
} }
} }
// Description // Description (strip HTML tags from ABS markdown/HTML content)
if (!meta.description.isNullOrBlank()) { if (!meta.description.isNullOrBlank()) {
item(key = "desc") { item(key = "desc") {
Text( Text(
text = meta.description, text = meta.description.stripHtml(),
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
lineHeight = 20.sp, lineHeight = 20.sp,
) )
@@ -696,17 +745,55 @@ fun AbsNowPlayingScreen(
private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlayerController) { private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlayerController) {
var showSpeedPicker by remember { mutableStateOf(false) } var showSpeedPicker by remember { mutableStateOf(false) }
var showSleepPicker 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 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
// Speed // Speed
Box { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
TextButton(onClick = { showSpeedPicker = !showSpeedPicker; showSleepPicker = false }) { TextButton(onClick = { showSpeedPicker = !showSpeedPicker; showSleepPicker = false }) {
Text( Text(
"${state.speed}×", "${state.speed}×",
@@ -729,7 +816,7 @@ private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlaye
} }
// Sleep timer // Sleep timer
Box { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
val sleepRem = state.sleepRemainingSecs val sleepRem = state.sleepRemainingSecs
TextButton(onClick = { showSleepPicker = !showSleepPicker; showSpeedPicker = false }) { TextButton(onClick = { showSleepPicker = !showSleepPicker; showSpeedPicker = false }) {
Icon( Icon(
@@ -761,10 +848,15 @@ private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlaye
onClick = { absPlayerController.setSleepTimer(mins); showSleepPicker = false }, onClick = { absPlayerController.setSleepTimer(mins); showSleepPicker = false },
) )
} }
DropdownMenuItem(
text = { Text("Benutzerdefiniert…", fontFamily = Outfit, color = NsOnSurface) },
onClick = { showSleepPicker = false; customMinutesText = ""; showCustomSleepDialog = true },
)
} }
} }
// Chapter track mode toggle // Chapter track mode toggle
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
TextButton(onClick = { absPlayerController.setChapterTrackMode(!state.chapterTrackMode) }) { TextButton(onClick = { absPlayerController.setChapterTrackMode(!state.chapterTrackMode) }) {
Icon( Icon(
Icons.Default.Bookmark, contentDescription = "Kapitel-Modus", Icons.Default.Bookmark, contentDescription = "Kapitel-Modus",
@@ -779,6 +871,7 @@ private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlaye
} }
} }
} }
}
@Composable @Composable
private fun AbsChapterList( private fun AbsChapterList(
@@ -102,13 +102,20 @@ fun NowPlayingScreen(
if (result is SuccessResult) { if (result is SuccessResult) {
val bitmap = (result.drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect val bitmap = (result.drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect
val palette = withContext(Dispatchers.Default) { Palette.from(bitmap).generate() } 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) } swatch?.rgb?.let { dominantColor = Color(it) }
} }
} catch (_: Exception) {} } 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( val animatedBgColor by animateColorAsState(
targetValue = rawBgColor, targetValue = rawBgColor,
animationSpec = tween(600), animationSpec = tween(600),
@@ -116,18 +123,18 @@ fun NowPlayingScreen(
) )
Box(Modifier.fillMaxSize().background(NsBackground)) { Box(Modifier.fillMaxSize().background(NsBackground)) {
// Dominant-color gradient background // Dominant-color gradient — covers top 70% of screen
Box( Box(
Modifier.fillMaxSize().background( Modifier.fillMaxSize().background(
Brush.verticalGradient( Brush.verticalGradient(
0f to animatedBgColor, 0f to animatedBgColor,
0.55f to animatedBgColor.copy(alpha = 0.65f), 0.65f to animatedBgColor.copy(alpha = 0.5f),
1f to NsBackground, 1f to NsBackground,
) )
) )
) )
// Dark scrim for readability // Light scrim for readability
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.28f))) Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.15f)))
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -257,14 +264,7 @@ fun NowPlayingScreen(
) )
}.filter { it.name.isNotBlank() } }.filter { it.name.isNotBlank() }
// De-duplicate: if multiple entries share the same ID (Navidrome val artistList = raw
// 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 = "")
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -411,13 +411,21 @@ fun NowPlayingScreen(
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs + 200L }.coerceAtLeast(0) lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs + 200L }.coerceAtLeast(0)
else -1 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) { LaunchedEffect(activeIdx) {
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect 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 fun targetFor(idx: Int) = (lyricsPaddingPx + idx * lineHeightPx + lineHeightPx / 2f
- lyricsViewH * 0.30f).coerceAtLeast(0f) - lyricsViewH * 0.30f).coerceAtLeast(0f).toInt()
lyricsScrollState.animateScrollTo(targetY.toInt(), tween<Float>(280, easing = FastOutSlowInEasing))
// 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 { when {
@@ -137,12 +137,16 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
) )
if (st.status == "done" || st.status == "error") { if (st.status == "done" || st.status == "error") {
_state.value = _state.value.copy(isDownloading = false) _state.value = _state.value.copy(isDownloading = false)
val res = s.resolved
NotificationStore.add(AppNotification( NotificationStore.add(AppNotification(
id = jobId, id = jobId,
title = title, title = title,
artist = artist, artist = artist,
status = st.status ?: "error", status = st.status ?: "error",
message = st.message ?: if (st.status == "done") "Fertig!" else "Fehler", message = st.message ?: if (st.status == "done") "Fertig!" else "Fehler",
navType = res?.type ?: "",
navAlbum = res?.name ?: "",
navArtist = res?.artist ?: "",
)) ))
return 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 { esc, toast } from '../utils.js';
import { cancelMusicAutoplay } from '../player/player.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 ─── // ─── HTTP HELPERS ───
// API calls are routed through the NovaSonic backend proxy to avoid CORS restrictions. // API calls are routed through the NovaSonic backend proxy to avoid CORS restrictions.
// Audio streaming uses <audio src> directly (HTML media elements bypass CORS). // Audio streaming uses <audio src> directly (HTML media elements bypass CORS).
@@ -609,7 +615,7 @@ export async function loadAbsBookDetail(itemId) {
</button> </button>
</div> </div>
</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 ? ` ${chapters.length ? `
<div class="abs-detail-chapters"> <div class="abs-detail-chapters">
<h3 class="abs-detail-chapters-title">Kapitel <span class="abs-ch-count">${chapters.length}</span></h3> <h3 class="abs-detail-chapters-title">Kapitel <span class="abs-ch-count">${chapters.length}</span></h3>