From ec9c0add9e2fda0c0ee4e2692149f68541a40617 Mon Sep 17 00:00:00 2001 From: Honey Date: Wed, 17 Jun 2026 01:38:58 +0200 Subject: [PATCH] feat: notification nav + ABS search + HTML desc fix + brighter NowPlaying bg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 (

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

${esc(meta.description)}
` : ''} + ${meta.description ? `
${esc(stripHtml(meta.description))}
` : ''} ${chapters.length ? `

Kapitel ${chapters.length}