feat: ABS full playback on Android — chapters, speed, sleep timer, progress sync

- AbsClient: add session API (startSession, closeSession, getProgress, saveProgress, getItemChapters) and data classes (AbsChapter, AbsAudioTrack, AbsPlaySession, AbsProgress)
- AbsPlayerController (new): standalone ExoPlayer controller for ABS audio; tracks global time across multi-track audiobooks, syncs progress to ABS every 30 s, chapter navigation, speed control, sleep timer
- AbsScreen: book detail screen with cover/meta/progress/chapter list/play button; AbsNowPlayingScreen overlay (progress bar, prev/+30s/play/-30s/next controls, speed picker, sleep timer, chapter list with auto-scroll)
- AppNavigation: create AbsPlayerController, wire up book detail and player overlay, hardware back button handling for all ABS sub-screens
- PrefsStore + SettingsViewModel + SettingsScreen: absChapterTrackMode toggle — progress bar shows current chapter length instead of full book
- README: update ABS section to cover both web and Android features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-16 00:15:30 +02:00
1 parent 3280ffee37
commit 820a75f40b
8 files changed
+1074 -27

No files matched your search

+7 -2
View File
@@ -50,8 +50,13 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
- Create, edit, delete playlists; add / remove tracks - Create, edit, delete playlists; add / remove tracks
### Audiobookshelf Integration ### Audiobookshelf Integration
- **Web:** browse libraries, stream audiobooks, chapter navigation, playback speed, sleep timer, progress sync - Browse libraries and audiobooks on both Web and Android
- **Android:** browse libraries and audiobook list (tab hidden until ABS is configured in Settings) - Stream audiobooks with chapter-by-chapter navigation
- Playback speed control (0.5× – 2.0×)
- Sleep timer
- Progress sync — current position saved back to ABS server every 30 s
- Chapter track mode — progress bar shows current chapter length instead of full book (toggle in Settings)
- Tab/section hidden until ABS is configured in Settings
### App — Web PWA ### App — Web PWA
- Installable on Android / iOS / desktop - Installable on Android / iOS / desktop
@@ -34,6 +34,7 @@ data class AbsMedia(
val metadata: AbsMediaMetadata = AbsMediaMetadata(), val metadata: AbsMediaMetadata = AbsMediaMetadata(),
val duration: Double = 0.0, val duration: Double = 0.0,
val numFiles: Int = 0, val numFiles: Int = 0,
val chapters: List<AbsChapter> = emptyList(),
) )
@Serializable @Serializable
@@ -48,6 +49,39 @@ data class AbsItemsResponse(
val total: Int = 0, val total: Int = 0,
) )
@Serializable
data class AbsChapter(
val id: Int = 0,
val start: Double = 0.0,
val end: Double = 0.0,
val title: String = "",
)
@Serializable
data class AbsAudioTrack(
val index: Int = 0,
val startOffset: Double = 0.0,
val duration: Double = 0.0,
val contentUrl: String = "",
val mimeType: String = "audio/mpeg",
)
@Serializable
data class AbsPlaySession(
val id: String = "",
val audioTracks: List<AbsAudioTrack> = emptyList(),
val chapters: List<AbsChapter> = emptyList(),
val currentTime: Double = 0.0,
)
@Serializable
data class AbsProgress(
val currentTime: Double = 0.0,
val duration: Double = 0.0,
val progress: Double = 0.0,
val isFinished: Boolean = false,
)
class AbsClient( class AbsClient(
val absUrl: String, val absUrl: String,
val apiKey: String, val apiKey: String,
@@ -55,10 +89,10 @@ class AbsClient(
) { ) {
private val http = HttpClient(Android) { private val http = HttpClient(Android) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; isLenient = true }) } install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; isLenient = true }) }
install(HttpTimeout) { requestTimeoutMillis = 12_000; connectTimeoutMillis = 8_000 } install(HttpTimeout) { requestTimeoutMillis = 30_000; connectTimeoutMillis = 8_000 }
} }
private suspend fun get(path: String): HttpResponse = private suspend fun apiGet(path: String): HttpResponse =
if (backendUrl.isNotBlank()) { if (backendUrl.isNotBlank()) {
http.get("$backendUrl/api/abs-proxy$path") { http.get("$backendUrl/api/abs-proxy$path") {
header("X-ABS-URL", absUrl) header("X-ABS-URL", absUrl)
@@ -70,19 +104,86 @@ class AbsClient(
} }
} }
suspend fun ping(): Boolean = runCatching { get("/api/me").status == HttpStatusCode.OK }.getOrDefault(false) private suspend fun apiPost(path: String, jsonBody: String): HttpResponse =
if (backendUrl.isNotBlank()) {
http.post("$backendUrl/api/abs-proxy$path") {
header("X-ABS-URL", absUrl)
header("X-ABS-Token", apiKey)
contentType(ContentType.Application.Json)
setBody(jsonBody)
}
} else {
http.post("$absUrl$path") {
header("Authorization", "Bearer $apiKey")
contentType(ContentType.Application.Json)
setBody(jsonBody)
}
}
private suspend fun apiPatch(path: String, jsonBody: String): HttpResponse =
if (backendUrl.isNotBlank()) {
http.patch("$backendUrl/api/abs-proxy$path") {
header("X-ABS-URL", absUrl)
header("X-ABS-Token", apiKey)
contentType(ContentType.Application.Json)
setBody(jsonBody)
}
} else {
http.patch("$absUrl$path") {
header("Authorization", "Bearer $apiKey")
contentType(ContentType.Application.Json)
setBody(jsonBody)
}
}
suspend fun ping(): Boolean = runCatching { apiGet("/api/me").status == HttpStatusCode.OK }.getOrDefault(false)
suspend fun getLibraries(): List<AbsLibrary> = runCatching { suspend fun getLibraries(): List<AbsLibrary> = runCatching {
get("/api/libraries").body<AbsLibrariesResponse>().libraries apiGet("/api/libraries").body<AbsLibrariesResponse>().libraries
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
suspend fun getLibraryItems(libId: String, page: Int = 0): AbsItemsResponse = runCatching { suspend fun getLibraryItems(libId: String, page: Int = 0): AbsItemsResponse = runCatching {
get("/api/libraries/$libId/items?expanded=1&limit=50&page=$page&sort=media.metadata.title&asc=1") apiGet("/api/libraries/$libId/items?expanded=1&limit=50&page=$page&sort=media.metadata.title&asc=1")
.body<AbsItemsResponse>() .body<AbsItemsResponse>()
}.getOrDefault(AbsItemsResponse()) }.getOrDefault(AbsItemsResponse())
suspend fun getItemChapters(itemId: String): List<AbsChapter> = runCatching {
apiGet("/api/items/$itemId?expanded=1").body<AbsLibraryItem>().media.chapters
}.getOrDefault(emptyList())
suspend fun startSession(itemId: String): AbsPlaySession = runCatching {
apiPost(
"/api/items/$itemId/play",
"""{"deviceInfo":{"clientName":"NovaSonic","clientVersion":"1.0"},"supportedMimeTypes":["audio/mpeg","audio/mp4","audio/aac","audio/ogg","audio/flac","audio/m4b"],"mediaPlayer":"html5","forceDirectPlay":true,"forceTranscode":false}"""
).body<AbsPlaySession>()
}.getOrThrow()
suspend fun closeSession(sessionId: String) {
runCatching { apiPost("/api/session/$sessionId/close", "{}") }
}
suspend fun getProgress(itemId: String): AbsProgress? = runCatching {
apiGet("/api/me/progress/$itemId").body<AbsProgress>()
}.getOrNull()
suspend fun saveProgress(itemId: String, currentTime: Double, duration: Double) {
val progress = if (duration > 0) (currentTime / duration).coerceAtMost(1.0) else 0.0
val finished = progress >= 0.99
runCatching {
apiPatch(
"/api/me/progress/$itemId",
"""{"currentTime":$currentTime,"duration":$duration,"progress":$progress,"isFinished":$finished,"lastUpdate":${System.currentTimeMillis()}}"""
)
}
}
fun coverUrl(itemId: String): String = fun coverUrl(itemId: String): String =
"$absUrl/api/items/$itemId/cover?token=${java.net.URLEncoder.encode(apiKey, "UTF-8")}" "$absUrl/api/items/$itemId/cover?token=${java.net.URLEncoder.encode(apiKey, "UTF-8")}"
fun audioTrackUrl(contentUrl: String): String {
val sep = if (contentUrl.contains('?')) "&" else "?"
return "$absUrl$contentUrl${sep}token=${java.net.URLEncoder.encode(apiKey, "UTF-8")}"
}
fun close() = http.close() fun close() = http.close()
} }
@@ -23,6 +23,7 @@ class PrefsStore(private val context: Context) {
val KEY_THEME = stringPreferencesKey("theme") val KEY_THEME = stringPreferencesKey("theme")
val KEY_ABS_URL = stringPreferencesKey("abs_url") val KEY_ABS_URL = stringPreferencesKey("abs_url")
val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key") val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key")
val KEY_ABS_CHAPTER_TRACK = booleanPreferencesKey("abs_chapter_track")
val KEY_LAST_SONG_ID = stringPreferencesKey("last_song_id") val KEY_LAST_SONG_ID = stringPreferencesKey("last_song_id")
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title") val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist") val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
@@ -44,6 +45,7 @@ class PrefsStore(private val context: Context) {
val theme: String = "dark-ocean", val theme: String = "dark-ocean",
val absUrl: String = "", val absUrl: String = "",
val absApiKey: String = "", val absApiKey: String = "",
val absChapterTrackMode: Boolean = false,
) )
data class LastPlayedSong( data class LastPlayedSong(
@@ -73,6 +75,7 @@ class PrefsStore(private val context: Context) {
theme = prefs[KEY_THEME] ?: "dark-ocean", theme = prefs[KEY_THEME] ?: "dark-ocean",
absUrl = prefs[KEY_ABS_URL] ?: "", absUrl = prefs[KEY_ABS_URL] ?: "",
absApiKey = prefs[KEY_ABS_API_KEY] ?: "", absApiKey = prefs[KEY_ABS_API_KEY] ?: "",
absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false,
) )
} }
@@ -106,6 +109,7 @@ class PrefsStore(private val context: Context) {
prefs[KEY_THEME] = settings.theme prefs[KEY_THEME] = settings.theme
prefs[KEY_ABS_URL] = settings.absUrl prefs[KEY_ABS_URL] = settings.absUrl
prefs[KEY_ABS_API_KEY] = settings.absApiKey prefs[KEY_ABS_API_KEY] = settings.absApiKey
prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode
} }
} }
@@ -0,0 +1,299 @@
package com.novasonic.app.player
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import com.novasonic.app.api.AbsChapter
import com.novasonic.app.api.AbsClient
import com.novasonic.app.api.AbsLibraryItem
import com.novasonic.app.api.AbsAudioTrack
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class AbsPlayState(
val isLoading: Boolean = false,
val isPlaying: Boolean = false,
val item: AbsLibraryItem? = null,
val chapters: List<AbsChapter> = emptyList(),
val duration: Double = 0.0,
val currentTime: Double = 0.0,
val chapterIdx: Int = 0,
val speed: Float = 1f,
val sleepRemainingSecs: Int? = null,
val chapterTrackMode: Boolean = false,
val sessionId: String? = null,
val error: String? = null,
)
class AbsPlayerController(context: Context) {
private val appContext = context.applicationContext
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private val _state = MutableStateFlow(AbsPlayState())
val state: StateFlow<AbsPlayState> = _state.asStateFlow()
private var exoPlayer: ExoPlayer? = null
private var tickerJob: Job? = null
private var syncJob: Job? = null
private var sleepJob: Job? = null
private var sleepTickJob: Job? = null
private var absClient: AbsClient? = null
private var audioTracks: List<AbsAudioTrack> = emptyList()
private var currentItemId: String? = null
fun setChapterTrackMode(enabled: Boolean) {
_state.value = _state.value.copy(chapterTrackMode = enabled)
}
suspend fun playItem(item: AbsLibraryItem, client: AbsClient) {
absClient = client
currentItemId = item.id
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
}
try {
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
audioTracks = tracks
currentItemId = item.id
_state.value = _state.value.copy(
isLoading = false,
sessionId = session.id,
chapters = chapters,
duration = duration,
currentTime = startTime,
chapterIdx = findChapterIdx(chapters, startTime),
)
if (tracks.isEmpty()) {
_state.value = _state.value.copy(error = "Keine Audiotracks gefunden")
return
}
val mediaItems = tracks.map { t ->
MediaItem.fromUri(client.audioTrackUrl(t.contentUrl))
}
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()
}
startSync()
} catch (e: Exception) {
_state.value = _state.value.copy(isLoading = false, error = e.message ?: "Fehler beim Laden")
}
}
fun togglePlay() {
val player = exoPlayer ?: return
if (player.isPlaying) player.pause() else player.play()
}
fun pause() { exoPlayer?.pause() }
fun resume() { exoPlayer?.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())
}
_state.value = _state.value.copy(
currentTime = clamped,
chapterIdx = findChapterIdx(_state.value.chapters, clamped),
)
}
fun nextChapter() {
val s = _state.value
val next = s.chapters.getOrNull(s.chapterIdx + 1) ?: return
seekToGlobal(next.start)
}
fun prevChapter() {
val s = _state.value
val cur = s.chapters.getOrNull(s.chapterIdx)
if (cur != null && s.currentTime - cur.start > 5) {
seekToGlobal(cur.start); return
}
val prev = s.chapters.getOrNull(s.chapterIdx - 1) ?: return
seekToGlobal(prev.start)
}
fun seekToChapter(idx: Int) {
val ch = _state.value.chapters.getOrNull(idx) ?: return
seekToGlobal(ch.start)
}
fun skipSecs(secs: Double) = seekToGlobal(_state.value.currentTime + secs)
fun setSpeed(speed: Float) {
_state.value = _state.value.copy(speed = speed)
exoPlayer?.setPlaybackSpeed(speed)
}
fun setSleepTimer(minutes: Int) {
cancelSleepInternal()
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()
_state.value = _state.value.copy(sleepRemainingSecs = null)
}
sleepTickJob = scope.launch {
var rem = totalSecs
while (rem > 0 && isActive) {
delay(1000)
rem--
_state.value = _state.value.copy(sleepRemainingSecs = rem)
}
}
}
fun cancelSleep() {
cancelSleepInternal()
_state.value = _state.value.copy(sleepRemainingSecs = null)
}
private fun cancelSleepInternal() {
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 }
private fun startSync() {
syncJob?.cancel()
syncJob = scope.launch {
while (isActive) {
delay(30_000)
doSync()
}
}
}
private fun stopSync() { syncJob?.cancel(); syncJob = null }
private fun doSync() {
val itemId = currentItemId ?: return
val client = absClient ?: return
val s = _state.value
scope.launch { runCatching { client.saveProgress(itemId, s.currentTime, s.duration) } }
}
fun close() {
scope.launch {
val sessionId = _state.value.sessionId
if (sessionId != null) {
doSync()
runCatching { absClient?.closeSession(sessionId) }
}
}
stopTicker()
stopSync()
cancelSleepInternal()
exoPlayer?.stop()
exoPlayer?.release()
exoPlayer = null
scope.cancel()
}
private fun findChapterIdx(chapters: List<AbsChapter>, time: Double): Int {
if (chapters.isEmpty()) return 0
var idx = 0
for (i in chapters.indices) { if (chapters[i].start <= time) idx = i }
return idx
}
private fun findTrack(tracks: List<AbsAudioTrack>, globalTime: Double): Pair<Int, Double> {
if (tracks.isEmpty()) return Pair(0, globalTime)
var idx = 0
for (i in tracks.indices) { if (tracks[i].startOffset <= globalTime) idx = i }
val local = (globalTime - tracks[idx].startOffset).coerceAtLeast(0.0)
return Pair(idx, local)
}
}
@@ -31,7 +31,9 @@ import androidx.navigation.*
import androidx.navigation.compose.* import androidx.navigation.compose.*
import com.novasonic.app.api.AbsClient import com.novasonic.app.api.AbsClient
import com.novasonic.app.api.AbsLibrary import com.novasonic.app.api.AbsLibrary
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.app import com.novasonic.app.app
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
@@ -80,9 +82,24 @@ fun AppNavigation() {
else null else null
} }
val absPlayerController = remember { AbsPlayerController(context) }
DisposableEffect(Unit) { onDispose { absPlayerController.close() } }
LaunchedEffect(appSettings.absChapterTrackMode) {
absPlayerController.setChapterTrackMode(appSettings.absChapterTrackMode)
}
val navController = rememberNavController() val navController = rememberNavController()
var showNowPlaying by remember { mutableStateOf(false) } var showNowPlaying by remember { mutableStateOf(false) }
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
var showNotifications by remember { mutableStateOf(false) }
var selectedAbsLibrary by remember { mutableStateOf<AbsLibrary?>(null) }
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
var showAbsPlayer by remember { mutableStateOf(false) }
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
DisposableEffect(showNowPlaying, backDispatcher) { DisposableEffect(showNowPlaying, backDispatcher) {
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {} if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
@@ -92,12 +109,30 @@ fun AppNavigation() {
backDispatcher.addCallback(cb) backDispatcher.addCallback(cb)
onDispose { cb.remove() } onDispose { cb.remove() }
} }
DisposableEffect(showAbsPlayer, backDispatcher) {
var contextMenuSong by remember { mutableStateOf<Song?>(null) } if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {}
var playlistPickerSong by remember { mutableStateOf<Song?>(null) } val cb = object : OnBackPressedCallback(true) {
var infoSheetSong by remember { mutableStateOf<Song?>(null) } override fun handleOnBackPressed() { showAbsPlayer = false }
var showNotifications by remember { mutableStateOf(false) } }
var selectedAbsLibrary by remember { mutableStateOf<AbsLibrary?>(null) } backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
DisposableEffect(selectedAbsItem, backDispatcher) {
if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {}
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { selectedAbsItem = null }
}
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
DisposableEffect(selectedAbsLibrary, backDispatcher) {
if (selectedAbsLibrary == null || backDispatcher == null) return@DisposableEffect onDispose {}
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { selectedAbsLibrary = null }
}
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600) fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000) fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
@@ -252,14 +287,26 @@ fun AppNavigation() {
) )
} }
composable(TopLevel.ABS.route) { composable(TopLevel.ABS.route) {
if (absClient != null && selectedAbsLibrary == null) { val client = absClient
AbsScreen(absClient = absClient, onLibraryClick = { selectedAbsLibrary = it }) when {
} else if (absClient != null && selectedAbsLibrary != null) { client == null -> {}
AbsLibraryScreen( selectedAbsItem != null -> AbsBookDetailScreen(
absClient = absClient, absClient = client,
libraryItem = selectedAbsItem!!,
absPlayerController = absPlayerController,
onPlay = { showAbsPlayer = true },
onBack = { selectedAbsItem = null },
)
selectedAbsLibrary != null -> AbsLibraryScreen(
absClient = client,
library = selectedAbsLibrary!!, library = selectedAbsLibrary!!,
onItemClick = { selectedAbsItem = it },
onBack = { selectedAbsLibrary = null }, onBack = { selectedAbsLibrary = null },
) )
else -> AbsScreen(
absClient = client,
onLibraryClick = { selectedAbsLibrary = it },
)
} }
} }
composable(TopLevel.SETTINGS.route) { SettingsScreen() } composable(TopLevel.SETTINGS.route) { SettingsScreen() }
@@ -358,6 +405,22 @@ fun AppNavigation() {
) )
} }
// ABS player overlay
AnimatedVisibility(
visible = showAbsPlayer,
enter = slideInVertically { it } + fadeIn(tween(220)),
exit = slideOutVertically { it } + fadeOut(tween(180)),
) {
val client = absClient
if (client != null) {
AbsNowPlayingScreen(
absPlayerController = absPlayerController,
absClient = client,
onDismiss = { showAbsPlayer = false },
)
}
}
contextMenuSong?.let { song -> contextMenuSong?.let { song ->
SongContextSheet( SongContextSheet(
song = song, song = song,
@@ -1,31 +1,49 @@
package com.novasonic.app.ui.screen package com.novasonic.app.ui.screen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
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.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
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.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.*
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.draw.clip
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.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
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.novasonic.app.api.AbsChapter
import com.novasonic.app.api.AbsClient import com.novasonic.app.api.AbsClient
import com.novasonic.app.api.AbsLibrary import com.novasonic.app.api.AbsLibrary
import com.novasonic.app.api.AbsLibraryItem 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 com.novasonic.app.ui.theme.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// ── Library list ─────────────────────────────────────────────────────────────
@Composable @Composable
fun AbsScreen( fun AbsScreen(
absClient: AbsClient, absClient: AbsClient,
@@ -49,15 +67,12 @@ fun AbsScreen(
} }
} }
Column( Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp)) {
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text( Text(
"Audiobookshelf", "Audiobookshelf",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface,
modifier = Modifier.padding(bottom = 16.dp), modifier = Modifier.padding(bottom = 16.dp),
) )
when { when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent) CircularProgressIndicator(color = NsAccent)
@@ -108,13 +123,15 @@ private fun LibraryRow(library: AbsLibrary, onClick: () -> Unit) {
} }
} }
// ── Library items list ────────────────────────────────────────────────────────
@Composable @Composable
fun AbsLibraryScreen( fun AbsLibraryScreen(
absClient: AbsClient, absClient: AbsClient,
library: AbsLibrary, library: AbsLibrary,
onItemClick: (AbsLibraryItem) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val scope = rememberCoroutineScope()
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) }
@@ -144,7 +161,6 @@ fun AbsLibraryScreen(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
when { when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent) CircularProgressIndicator(color = NsAccent)
@@ -160,7 +176,7 @@ fun AbsLibraryScreen(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(items) { item -> items(items) { item ->
AbsItemRow(item = item, absClient = absClient) AbsItemRow(item = item, absClient = absClient, onClick = { onItemClick(item) })
} }
} }
} }
@@ -168,7 +184,7 @@ fun AbsLibraryScreen(
} }
@Composable @Composable
private fun AbsItemRow(item: AbsLibraryItem, absClient: AbsClient) { private fun AbsItemRow(item: AbsLibraryItem, absClient: AbsClient, onClick: () -> Unit) {
val meta = item.media.metadata val meta = item.media.metadata
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -176,6 +192,7 @@ private fun AbsItemRow(item: AbsLibraryItem, absClient: AbsClient) {
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(NsSurface) .background(NsSurface)
.clickable(onClick = onClick)
.padding(10.dp), .padding(10.dp),
) { ) {
AsyncImage( AsyncImage(
@@ -205,5 +222,544 @@ private fun AbsItemRow(item: AbsLibraryItem, absClient: AbsClient) {
) )
} }
} }
Icon(
Icons.Default.ChevronRight, contentDescription = null,
tint = NsOnSurface2, modifier = Modifier.size(20.dp),
)
} }
} }
// ── Book detail ───────────────────────────────────────────────────────────────
@Composable
fun AbsBookDetailScreen(
absClient: AbsClient,
libraryItem: AbsLibraryItem,
absPlayerController: AbsPlayerController,
onPlay: () -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var chapters by remember { mutableStateOf(libraryItem.media.chapters) }
var progress by remember { mutableStateOf<Double?>(null) }
var isLoadingDetail by remember { mutableStateOf(libraryItem.media.chapters.isEmpty()) }
var isStartingPlay by remember { mutableStateOf(false) }
LaunchedEffect(libraryItem.id) {
if (chapters.isEmpty()) {
isLoadingDetail = true
chapters = runCatching { absClient.getItemChapters(libraryItem.id) }.getOrDefault(emptyList())
isLoadingDetail = false
}
progress = runCatching { absClient.getProgress(libraryItem.id)?.currentTime }.getOrNull()
}
val meta = libraryItem.media.metadata
val duration = libraryItem.media.duration
val progressPct = if (duration > 0 && (progress ?: 0.0) > 0) ((progress!! / duration) * 100).toInt() else 0
Column(modifier = Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 12.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
}
Text(
meta.title ?: "Hörbuch", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 18.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Header
item(key = "header") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
AsyncImage(
model = absClient.coverUrl(libraryItem.id),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(12.dp)).background(NsSurface2),
)
Column(Modifier.weight(1f)) {
Text(
text = meta.title ?: "Unbekannt",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, color = NsOnSurface,
)
if (!meta.authorName.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = meta.authorName, fontFamily = Outfit,
fontSize = 13.sp, color = NsOnSurface2,
)
}
if (duration > 0) {
Spacer(Modifier.height(4.dp))
val h = (duration / 3600).toInt()
val m = ((duration % 3600) / 60).toInt()
Text(
text = if (h > 0) "${h}h ${m}m" else "${m}m",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
)
}
if (progressPct in 1..98) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { progressPct / 100f },
modifier = Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)),
color = NsAccent, trackColor = NsAccent.copy(alpha = 0.2f),
)
Spacer(Modifier.height(2.dp))
Text(
text = "$progressPct% gehört",
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
)
}
}
}
}
// Play button
item(key = "play") {
Button(
onClick = {
isStartingPlay = true
scope.launch {
runCatching { absPlayerController.playItem(libraryItem, absClient) }
isStartingPlay = false
onPlay()
}
},
enabled = !isStartingPlay,
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
) {
if (isStartingPlay) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
} else {
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
if (progressPct > 5) "Weiterhören" else "Abspielen",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp,
)
}
}
}
// Description
if (!meta.description.isNullOrBlank()) {
item(key = "desc") {
Text(
text = meta.description,
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
lineHeight = 20.sp,
)
}
}
// Chapters
if (chapters.isNotEmpty()) {
item(key = "chHeader") {
Text(
"Kapitel (${chapters.size})",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 14.sp, color = NsOnSurface,
)
}
itemsIndexed(chapters, key = { idx, _ -> "ch_$idx" }) { idx, ch ->
ChapterDetailRow(
chapter = ch,
index = idx + 1,
onTap = {
scope.launch {
isStartingPlay = true
runCatching {
absPlayerController.playItem(libraryItem, absClient)
absPlayerController.seekToGlobal(ch.start)
}
isStartingPlay = false
onPlay()
}
},
)
}
} else if (isLoadingDetail) {
item(key = "chLoading") {
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = NsAccent)
}
}
}
}
}
}
@Composable
private fun ChapterDetailRow(chapter: AbsChapter, index: Int, onTap: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(NsSurface)
.clickable(onClick = onTap)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Text(
text = "$index",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 12.sp, color = NsAccent,
modifier = Modifier.width(28.dp),
)
Text(
text = chapter.title.ifBlank { "Kapitel $index" },
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
modifier = Modifier.weight(1f),
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
text = fmtSecs(chapter.start),
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
)
}
}
// ── ABS Now Playing overlay ───────────────────────────────────────────────────
@Composable
fun AbsNowPlayingScreen(
absPlayerController: AbsPlayerController,
absClient: AbsClient,
onDismiss: () -> Unit,
) {
val state by absPlayerController.state.collectAsStateWithLifecycle()
Box(
modifier = Modifier
.fillMaxSize()
.background(NsBackground)
.pointerInput(Unit) {
detectVerticalDragGestures { _, delta -> if (delta > 80f) onDismiss() }
},
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Top bar
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) {
IconButton(onClick = onDismiss) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Schließen", tint = NsOnSurface)
}
Text(
"Wird abgespielt", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface2,
modifier = Modifier.weight(1f), textAlign = TextAlign.Center,
)
Spacer(Modifier.width(48.dp))
}
Spacer(Modifier.height(20.dp))
// Cover
AsyncImage(
model = state.item?.let { absClient.coverUrl(it.id) },
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(220.dp)
.clip(RoundedCornerShape(16.dp))
.background(NsSurface2),
)
Spacer(Modifier.height(20.dp))
// Title / Author / Chapter
val meta = state.item?.media?.metadata
Text(
text = meta?.title ?: "Unbekannt",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = NsOnSurface,
maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
)
if (!meta?.authorName.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = meta?.authorName ?: "",
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, textAlign = TextAlign.Center,
)
}
val currentChapter = state.chapters.getOrNull(state.chapterIdx)
if (currentChapter != null && currentChapter.title.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = currentChapter.title,
fontFamily = Outfit, fontSize = 12.sp, color = NsAccent, textAlign = TextAlign.Center,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(20.dp))
// Progress bar
val chWin = chapterWindow(state)
val dispCur = if (chWin != null) (state.currentTime - chWin.first).coerceAtLeast(0.0) else state.currentTime
val dispDur = if (chWin != null) chWin.second - chWin.first else state.duration
val progress = if (dispDur > 0) (dispCur / dispDur).coerceIn(0.0, 1.0).toFloat() else 0f
Slider(
value = progress,
onValueChange = { v ->
val globalTarget = if (chWin != null) chWin.first + v * (chWin.second - chWin.first)
else v * state.duration
absPlayerController.seekToGlobal(globalTarget)
},
modifier = Modifier.fillMaxWidth(),
colors = SliderDefaults.colors(
thumbColor = NsAccent, activeTrackColor = NsAccent,
inactiveTrackColor = NsAccent.copy(alpha = 0.2f),
),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(fmtSecs(dispCur), fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
Text("-${fmtSecs(dispDur - dispCur)}", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
}
Spacer(Modifier.height(12.dp))
// Controls
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth(),
) {
IconButton(onClick = { absPlayerController.prevChapter() }) {
Icon(Icons.Default.SkipPrevious, contentDescription = "Voriges Kapitel", tint = NsOnSurface, modifier = Modifier.size(30.dp))
}
IconButton(onClick = { absPlayerController.skipSecs(-30.0) }) {
Icon(Icons.Default.Replay30, contentDescription = "-30s", tint = NsOnSurface, modifier = Modifier.size(30.dp))
}
Box(
Modifier.size(64.dp).clip(CircleShape).background(NsAccent)
.clickable { absPlayerController.togglePlay() },
contentAlignment = Alignment.Center,
) {
Icon(
if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null, tint = NsBackground, modifier = Modifier.size(34.dp),
)
}
IconButton(onClick = { absPlayerController.skipSecs(30.0) }) {
Icon(Icons.Default.Forward30, contentDescription = "+30s", tint = NsOnSurface, modifier = Modifier.size(30.dp))
}
IconButton(onClick = { absPlayerController.nextChapter() }) {
Icon(Icons.Default.SkipNext, contentDescription = "Nächstes Kapitel", tint = NsOnSurface, modifier = Modifier.size(30.dp))
}
}
Spacer(Modifier.height(12.dp))
// Speed + Sleep + Chapter track row
AbsBottomControls(state = state, absPlayerController = absPlayerController)
Spacer(Modifier.height(16.dp))
// Chapter list
if (state.chapters.isNotEmpty()) {
AbsChapterList(
state = state,
absPlayerController = absPlayerController,
modifier = Modifier.weight(1f),
)
}
}
// Loading overlay
if (state.isLoading) {
Box(Modifier.fillMaxSize().background(NsBackground.copy(alpha = 0.7f)), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
}
}
}
@Composable
private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlayerController) {
var showSpeedPicker by remember { mutableStateOf(false) }
var showSleepPicker by remember { mutableStateOf(false) }
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")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth(),
) {
// Speed
Box {
TextButton(onClick = { showSpeedPicker = !showSpeedPicker; showSleepPicker = false }) {
Text(
"${state.speed}×",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = if (state.speed != 1f) NsAccent else NsOnSurface2,
)
}
DropdownMenu(
expanded = showSpeedPicker,
onDismissRequest = { showSpeedPicker = false },
containerColor = NsSurface2,
) {
speedOptions.forEach { s ->
DropdownMenuItem(
text = { Text("${s}×", fontFamily = Outfit, color = if (s == state.speed) NsAccent else NsOnSurface) },
onClick = { absPlayerController.setSpeed(s); showSpeedPicker = false },
)
}
}
}
// Sleep timer
Box {
val sleepRem = state.sleepRemainingSecs
TextButton(onClick = { showSleepPicker = !showSleepPicker; showSpeedPicker = false }) {
Icon(
Icons.Default.Bedtime, contentDescription = "Sleep Timer",
tint = if (sleepRem != null) NsAccent else NsOnSurface2,
modifier = Modifier.size(18.dp),
)
if (sleepRem != null) {
Spacer(Modifier.width(4.dp))
val m = sleepRem / 60
val s = (sleepRem % 60).toString().padStart(2, '0')
Text("$m:$s", fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
}
}
DropdownMenu(
expanded = showSleepPicker,
onDismissRequest = { showSleepPicker = false },
containerColor = NsSurface2,
) {
if (state.sleepRemainingSecs != null) {
DropdownMenuItem(
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) },
onClick = { absPlayerController.setSleepTimer(mins); showSleepPicker = false },
)
}
}
}
// 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,
)
}
}
}
@Composable
private fun AbsChapterList(
state: AbsPlayState,
absPlayerController: AbsPlayerController,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
LaunchedEffect(state.chapterIdx) {
if (state.chapters.isNotEmpty()) {
listState.animateScrollToItem(state.chapterIdx.coerceAtLeast(0))
}
}
Column(modifier = modifier) {
Text(
"Kapitel",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.padding(bottom = 6.dp),
)
HorizontalDivider(color = NsBorder)
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
itemsIndexed(state.chapters) { idx, ch ->
val isActive = idx == state.chapterIdx
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(if (isActive) NsAccent.copy(alpha = 0.08f) else androidx.compose.ui.graphics.Color.Transparent)
.clickable { absPlayerController.seekToChapter(idx) }
.padding(horizontal = 4.dp, vertical = 10.dp),
) {
Text(
"${idx + 1}",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
color = if (isActive) NsAccent else NsOnSurface2,
modifier = Modifier.width(28.dp),
)
Text(
ch.title.ifBlank { "Kapitel ${idx + 1}" },
fontFamily = Outfit, fontSize = 13.sp,
color = if (isActive) NsAccent else NsOnSurface,
modifier = Modifier.weight(1f),
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
fmtSecs(ch.start),
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
)
}
HorizontalDivider(color = NsBorder.copy(alpha = 0.4f))
}
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
private fun chapterWindow(state: AbsPlayState): Pair<Double, Double>? {
if (!state.chapterTrackMode || state.chapters.isEmpty()) return null
val ch = state.chapters.getOrNull(state.chapterIdx) ?: return null
val next = state.chapters.getOrNull(state.chapterIdx + 1)
val end = next?.start ?: ch.end.takeIf { it > ch.start } ?: state.duration
if (end <= ch.start) return null
return Pair(ch.start, end)
}
internal fun fmtSecs(secs: Double): String {
val s = secs.toLong().coerceAtLeast(0)
val h = s / 3600
val m = (s % 3600) / 60
val sec = s % 60
return if (h > 0) "$h:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}"
else "$m:${sec.toString().padStart(2, '0')}"
}
@@ -153,6 +153,21 @@ fun SettingsScreen() {
) )
ConnStatusRow(status = state.absStatus, label = "Nicht konfiguriert", onTest = vm::testAbs) ConnStatusRow(status = state.absStatus, label = "Nicht konfiguriert", onTest = vm::testAbs)
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.weight(1f)) {
Text("Kapitel-Fortschrittsanzeige", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
Text("Fortschrittsbalken zeigt aktuelles Kapitel statt Gesamtlänge", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
Switch(
checked = state.absChapterTrackMode, onCheckedChange = vm::onAbsChapterTrackMode,
colors = SwitchDefaults.colors(
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
uncheckedThumbColor = NsOnSurface2,
),
)
}
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
// ── 🔑 Discover APIs ───────────────────────────────────────────────── // ── 🔑 Discover APIs ─────────────────────────────────────────────────
@@ -33,6 +33,7 @@ data class SettingsUiState(
val theme: String = "dark-ocean", val theme: String = "dark-ocean",
val absUrl: String = "", val absUrl: String = "",
val absApiKey: String = "", val absApiKey: String = "",
val absChapterTrackMode: Boolean = false,
val isSaving: Boolean = false, val isSaving: Boolean = false,
val savedOk: Boolean = false, val savedOk: Boolean = false,
val error: String? = null, val error: String? = null,
@@ -61,6 +62,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
theme = s.theme, theme = s.theme,
absUrl = s.absUrl, absUrl = s.absUrl,
absApiKey = s.absApiKey, absApiKey = s.absApiKey,
absChapterTrackMode = s.absChapterTrackMode,
) )
} }
} }
@@ -75,6 +77,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) } fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) } fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) } fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) }
fun onAbsChapterTrackMode(v: Boolean) = update { it.copy(absChapterTrackMode = v, savedOk = false) }
fun onTheme(id: String) { fun onTheme(id: String) {
update { it.copy(theme = id) } update { it.copy(theme = id) }
@@ -141,6 +144,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
theme = s.theme, theme = s.theme,
absUrl = s.absUrl.trimEnd('/'), absUrl = s.absUrl.trimEnd('/'),
absApiKey = s.absApiKey.trim(), absApiKey = s.absApiKey.trim(),
absChapterTrackMode = s.absChapterTrackMode,
)) ))
_state.value = _state.value.copy(isSaving = false, savedOk = true) _state.value = _state.value.copy(isSaving = false, savedOk = true)
} catch (e: Exception) { } catch (e: Exception) {