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:
1 parent
3280ffee37
commit
820a75f40b
8 files changed
+1093
-46
No files matched your search
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -21,8 +21,9 @@ class PrefsStore(private val context: Context) {
|
|||||||
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
|
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
|
||||||
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
|
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
|
||||||
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")
|
||||||
@@ -42,8 +43,9 @@ class PrefsStore(private val context: Context) {
|
|||||||
val scrobbleMode: String = "30s",
|
val scrobbleMode: String = "30s",
|
||||||
val showPlayCount: Boolean = false,
|
val showPlayCount: Boolean = false,
|
||||||
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(
|
||||||
@@ -71,8 +73,9 @@ class PrefsStore(private val context: Context) {
|
|||||||
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
|
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
|
||||||
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
||||||
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +107,9 @@ class PrefsStore(private val context: Context) {
|
|||||||
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
|
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
|
||||||
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
||||||
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,13 +287,25 @@ 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,
|
||||||
library = selectedAbsLibrary!!,
|
libraryItem = selectedAbsItem!!,
|
||||||
onBack = { selectedAbsLibrary = null },
|
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 ->
|
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 ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ data class SettingsUiState(
|
|||||||
val scrobbleMode: String = "30s",
|
val scrobbleMode: String = "30s",
|
||||||
val showPlayCount: Boolean = false,
|
val showPlayCount: Boolean = false,
|
||||||
val theme: String = "dark-ocean",
|
val theme: String = "dark-ocean",
|
||||||
val absUrl: String = "",
|
val absUrl: String = "",
|
||||||
val absApiKey: String = "",
|
val absApiKey: String = "",
|
||||||
val isSaving: Boolean = false,
|
val absChapterTrackMode: Boolean = false,
|
||||||
|
val isSaving: Boolean = false,
|
||||||
val savedOk: Boolean = false,
|
val savedOk: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val absStatus: ConnStatus = ConnStatus.Idle,
|
val absStatus: ConnStatus = ConnStatus.Idle,
|
||||||
@@ -59,8 +60,9 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
scrobbleMode = s.scrobbleMode,
|
scrobbleMode = s.scrobbleMode,
|
||||||
showPlayCount = s.showPlayCount,
|
showPlayCount = s.showPlayCount,
|
||||||
theme = s.theme,
|
theme = s.theme,
|
||||||
absUrl = s.absUrl,
|
absUrl = s.absUrl,
|
||||||
absApiKey = s.absApiKey,
|
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 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 onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
|
||||||
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) }
|
||||||
@@ -139,8 +142,9 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
scrobbleMode = s.scrobbleMode,
|
scrobbleMode = s.scrobbleMode,
|
||||||
showPlayCount = s.showPlayCount,
|
showPlayCount = s.showPlayCount,
|
||||||
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) {
|
||||||
|
|||||||
Reference in new issue
Block a user