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:
1 parent
72feac41bb
commit
ec9c0add9e
22 files changed
+917
-160
No files matched your search
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user