diff --git a/README.md b/README.md index 6c627fa..3bbf9f2 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,13 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye - Create, edit, delete playlists; add / remove tracks ### Audiobookshelf Integration -- **Web:** browse libraries, stream audiobooks, chapter navigation, playback speed, sleep timer, progress sync -- **Android:** browse libraries and audiobook list (tab hidden until ABS is configured in Settings) +- Browse libraries and audiobooks on both Web and Android +- 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 - Installable on Android / iOS / desktop diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/AbsClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/AbsClient.kt index 833e140..900acb8 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/AbsClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/AbsClient.kt @@ -34,6 +34,7 @@ data class AbsMedia( val metadata: AbsMediaMetadata = AbsMediaMetadata(), val duration: Double = 0.0, val numFiles: Int = 0, + val chapters: List = emptyList(), ) @Serializable @@ -48,6 +49,39 @@ data class AbsItemsResponse( 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 = emptyList(), + val chapters: List = 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( val absUrl: String, val apiKey: String, @@ -55,10 +89,10 @@ class AbsClient( ) { private val http = HttpClient(Android) { 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()) { http.get("$backendUrl/api/abs-proxy$path") { 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 = runCatching { - get("/api/libraries").body().libraries + apiGet("/api/libraries").body().libraries }.getOrDefault(emptyList()) 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() }.getOrDefault(AbsItemsResponse()) + suspend fun getItemChapters(itemId: String): List = runCatching { + apiGet("/api/items/$itemId?expanded=1").body().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() + }.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() + }.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 = "$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() } diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt index 0d70bdf..9371b28 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt @@ -21,8 +21,9 @@ class PrefsStore(private val context: Context) { val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode") val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count") val KEY_THEME = stringPreferencesKey("theme") - val KEY_ABS_URL = stringPreferencesKey("abs_url") - val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key") + val KEY_ABS_URL = stringPreferencesKey("abs_url") + 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_TITLE = stringPreferencesKey("last_song_title") val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist") @@ -42,8 +43,9 @@ class PrefsStore(private val context: Context) { val scrobbleMode: String = "30s", val showPlayCount: Boolean = false, val theme: String = "dark-ocean", - val absUrl: String = "", - val absApiKey: String = "", + val absUrl: String = "", + val absApiKey: String = "", + val absChapterTrackMode: Boolean = false, ) data class LastPlayedSong( @@ -71,8 +73,9 @@ class PrefsStore(private val context: Context) { scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s", showPlayCount = prefs[KEY_SHOW_COUNT] ?: false, theme = prefs[KEY_THEME] ?: "dark-ocean", - absUrl = prefs[KEY_ABS_URL] ?: "", - absApiKey = prefs[KEY_ABS_API_KEY] ?: "", + absUrl = prefs[KEY_ABS_URL] ?: "", + absApiKey = prefs[KEY_ABS_API_KEY] ?: "", + absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false, ) } @@ -104,8 +107,9 @@ class PrefsStore(private val context: Context) { prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode prefs[KEY_SHOW_COUNT] = settings.showPlayCount prefs[KEY_THEME] = settings.theme - prefs[KEY_ABS_URL] = settings.absUrl - prefs[KEY_ABS_API_KEY] = settings.absApiKey + prefs[KEY_ABS_URL] = settings.absUrl + prefs[KEY_ABS_API_KEY] = settings.absApiKey + prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode } } 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 new file mode 100644 index 0000000..129378d --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/player/AbsPlayerController.kt @@ -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 = 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 = _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 = 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, 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, globalTime: Double): Pair { + 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) + } +} 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 58ace70..44e60db 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 @@ -31,7 +31,9 @@ import androidx.navigation.* import androidx.navigation.compose.* import com.novasonic.app.api.AbsClient import com.novasonic.app.api.AbsLibrary +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.PrefsStore import com.novasonic.app.ui.component.MiniPlayer @@ -80,9 +82,24 @@ fun AppNavigation() { else null } + val absPlayerController = remember { AbsPlayerController(context) } + DisposableEffect(Unit) { onDispose { absPlayerController.close() } } + + LaunchedEffect(appSettings.absChapterTrackMode) { + absPlayerController.setChapterTrackMode(appSettings.absChapterTrackMode) + } + val navController = rememberNavController() var showNowPlaying by remember { mutableStateOf(false) } + var contextMenuSong by remember { mutableStateOf(null) } + var playlistPickerSong by remember { mutableStateOf(null) } + var infoSheetSong by remember { mutableStateOf(null) } + var showNotifications by remember { mutableStateOf(false) } + var selectedAbsLibrary by remember { mutableStateOf(null) } + var selectedAbsItem by remember { mutableStateOf(null) } + var showAbsPlayer by remember { mutableStateOf(false) } + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher DisposableEffect(showNowPlaying, backDispatcher) { if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {} @@ -92,12 +109,30 @@ fun AppNavigation() { backDispatcher.addCallback(cb) onDispose { cb.remove() } } - - var contextMenuSong by remember { mutableStateOf(null) } - var playlistPickerSong by remember { mutableStateOf(null) } - var infoSheetSong by remember { mutableStateOf(null) } - var showNotifications by remember { mutableStateOf(false) } - var selectedAbsLibrary by remember { mutableStateOf(null) } + DisposableEffect(showAbsPlayer, backDispatcher) { + if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {} + val cb = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { showAbsPlayer = false } + } + 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 heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000) @@ -252,13 +287,25 @@ fun AppNavigation() { ) } composable(TopLevel.ABS.route) { - if (absClient != null && selectedAbsLibrary == null) { - AbsScreen(absClient = absClient, onLibraryClick = { selectedAbsLibrary = it }) - } else if (absClient != null && selectedAbsLibrary != null) { - AbsLibraryScreen( - absClient = absClient, - library = selectedAbsLibrary!!, - onBack = { selectedAbsLibrary = null }, + val client = absClient + when { + client == null -> {} + selectedAbsItem != null -> AbsBookDetailScreen( + absClient = client, + libraryItem = selectedAbsItem!!, + absPlayerController = absPlayerController, + onPlay = { showAbsPlayer = true }, + onBack = { selectedAbsItem = null }, + ) + selectedAbsLibrary != null -> AbsLibraryScreen( + absClient = client, + library = selectedAbsLibrary!!, + onItemClick = { selectedAbsItem = it }, + onBack = { selectedAbsLibrary = null }, + ) + else -> AbsScreen( + absClient = client, + onLibraryClick = { selectedAbsLibrary = it }, ) } } @@ -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 -> SongContextSheet( song = song, 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 4a72892..8f89a0b 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 @@ -1,31 +1,49 @@ 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.clickable +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn 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.material.icons.Icons 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.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage +import com.novasonic.app.api.AbsChapter import com.novasonic.app.api.AbsClient import com.novasonic.app.api.AbsLibrary 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.launch +// ── Library list ───────────────────────────────────────────────────────────── + @Composable fun AbsScreen( absClient: AbsClient, @@ -49,15 +67,12 @@ fun AbsScreen( } } - Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp), - ) { + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp)) { Text( "Audiobookshelf", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface, modifier = Modifier.padding(bottom = 16.dp), ) - when { isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(color = NsAccent) @@ -108,13 +123,15 @@ private fun LibraryRow(library: AbsLibrary, onClick: () -> Unit) { } } +// ── Library items list ──────────────────────────────────────────────────────── + @Composable fun AbsLibraryScreen( absClient: AbsClient, library: AbsLibrary, + onItemClick: (AbsLibraryItem) -> Unit, onBack: () -> Unit, ) { - val scope = rememberCoroutineScope() var items by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var error by remember { mutableStateOf(null) } @@ -144,7 +161,6 @@ fun AbsLibraryScreen( modifier = Modifier.weight(1f), ) } - when { isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(color = NsAccent) @@ -160,7 +176,7 @@ fun AbsLibraryScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(items) { item -> - AbsItemRow(item = item, absClient = absClient) + AbsItemRow(item = item, absClient = absClient, onClick = { onItemClick(item) }) } } } @@ -168,7 +184,7 @@ fun AbsLibraryScreen( } @Composable -private fun AbsItemRow(item: AbsLibraryItem, absClient: AbsClient) { +private fun AbsItemRow(item: AbsLibraryItem, absClient: AbsClient, onClick: () -> Unit) { val meta = item.media.metadata Row( verticalAlignment = Alignment.CenterVertically, @@ -176,6 +192,7 @@ private fun AbsItemRow(item: AbsLibraryItem, absClient: AbsClient) { .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(NsSurface) + .clickable(onClick = onClick) .padding(10.dp), ) { 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(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? { + 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')}" +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt index e3b1f5c..765b39e 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt @@ -153,6 +153,21 @@ fun SettingsScreen() { ) 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)) // ── 🔑 Discover APIs ───────────────────────────────────────────────── diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt index 1ace4be..b95243c 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt @@ -31,9 +31,10 @@ data class SettingsUiState( val scrobbleMode: String = "30s", val showPlayCount: Boolean = false, val theme: String = "dark-ocean", - val absUrl: String = "", - val absApiKey: String = "", - val isSaving: Boolean = false, + val absUrl: String = "", + val absApiKey: String = "", + val absChapterTrackMode: Boolean = false, + val isSaving: Boolean = false, val savedOk: Boolean = false, val error: String? = null, val absStatus: ConnStatus = ConnStatus.Idle, @@ -59,8 +60,9 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { scrobbleMode = s.scrobbleMode, showPlayCount = s.showPlayCount, theme = s.theme, - absUrl = s.absUrl, - absApiKey = s.absApiKey, + absUrl = s.absUrl, + absApiKey = s.absApiKey, + absChapterTrackMode = s.absChapterTrackMode, ) } } @@ -73,8 +75,9 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false, backendStatus = ConnStatus.Idle) } fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = 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 onAbsApiKey(v: String) = update { it.copy(absApiKey = 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 onAbsChapterTrackMode(v: Boolean) = update { it.copy(absChapterTrackMode = v, savedOk = false) } fun onTheme(id: String) { update { it.copy(theme = id) } @@ -139,8 +142,9 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { scrobbleMode = s.scrobbleMode, showPlayCount = s.showPlayCount, theme = s.theme, - absUrl = s.absUrl.trimEnd('/'), - absApiKey = s.absApiKey.trim(), + absUrl = s.absUrl.trimEnd('/'), + absApiKey = s.absApiKey.trim(), + absChapterTrackMode = s.absChapterTrackMode, )) _state.value = _state.value.copy(isSaving = false, savedOk = true) } catch (e: Exception) {