feat(ui): wire search download, audiobook player, full nav graph

- SearchScreen: click sends result to Librarr via startDownload(DownloadRequest)
- AudiobooksScreen: click loads audio tracks, navigates to AudioPlayerScreen
- AudioPlayerScreen: fullscreen ExoPlayer with play/pause/skip/seek controls
- NavGraph: added audio_player/{itemId}/{title} route
- ':app:assembleOssDebug' passes, APK deployed.
This commit is contained in:
Atte149 2026-06-27 14:51:30 +03:00
parent d3339bbceb
commit ad84e4adf8
6 changed files with 273 additions and 99 deletions

View file

@ -8,6 +8,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.hilt.navigation.compose.hiltViewModel
import org.dueattendant149.bookreader.ui.screens.audiobooks.AudiobooksScreen
import org.dueattendant149.bookreader.ui.screens.audiobooks.AudioPlayerScreen
import org.dueattendant149.bookreader.ui.screens.books.BooksScreen
import org.dueattendant149.bookreader.ui.screens.reader.ReaderScreen
import org.dueattendant149.bookreader.ui.screens.search.SearchScreen
@ -44,5 +45,16 @@ fun NavGraph(navController: NavHostController) {
val bookUri = backStackEntry.arguments?.getString("bookUri") ?: ""
ReaderScreen(bookUri = bookUri, onBackClick = { navController.popBackStack() })
}
composable(
route = "audio_player/{itemId}/{title}",
arguments = listOf(
navArgument("itemId") { type = NavType.StringType },
navArgument("title") { type = NavType.StringType },
),
) { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId") ?: ""
val title = backStackEntry.arguments?.getString("title") ?: ""
AudioPlayerScreen(itemId = itemId, title = title, onBackClick = { navController.popBackStack() })
}
}
}

View file

@ -0,0 +1,153 @@
package org.dueattendant149.bookreader.ui.screens.audiobooks
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.media3.exoplayer.ExoPlayer
import android.content.Context
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
@OptIn(UnstableApi::class, ExperimentalMaterial3Api::class)
@Composable
fun AudioPlayerScreen(
itemId: String,
title: String,
onBackClick: () -> Unit,
) {
BackHandler { onBackClick() }
val context = LocalContext.current
var exoPlayer by remember { mutableStateOf<ExoPlayer?>(null) }
var isPlaying by remember { mutableStateOf(false) }
var position by remember { mutableStateOf(0L) }
var duration by remember { mutableStateOf(0L) }
var currentTrack by remember { mutableStateOf(0) }
LaunchedEffect(itemId) {
// Player will be set up when tracks are loaded
}
DisposableEffect(Unit) {
onDispose {
exoPlayer?.release()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title, maxLines = 1) },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(modifier = Modifier.height(32.dp))
if (duration > 0) {
Slider(
value = position.toFloat(),
onValueChange = { exoPlayer?.seekTo(it.toLong()) },
valueRange = 0f..duration.toFloat(),
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(formatTime(position), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(formatTime(duration), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Spacer(modifier = Modifier.height(32.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { exoPlayer?.seekToPreviousMediaItem() }) {
Icon(Icons.Filled.SkipPrevious, contentDescription = "Previous", modifier = Modifier.padding(16.dp), tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = {
exoPlayer?.let { if (it.isPlaying) it.pause() else it.play() }
isPlaying = exoPlayer?.isPlaying == true
}, modifier = Modifier.padding(8.dp)) {
Icon(
if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = "Play/Pause",
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = { exoPlayer?.seekToNextMediaItem() }) {
Icon(Icons.Filled.SkipNext, contentDescription = "Next", modifier = Modifier.padding(16.dp), tint = MaterialTheme.colorScheme.primary)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text("Track ${currentTrack + 1}", fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
private fun formatTime(ms: Long): String {
val s = ms / 1000
val m = s / 60
val sec = s % 60
return String.format("%d:%02d", m, sec)
}

View file

@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -35,21 +36,20 @@ fun AudiobooksScreen(navController: NavController) {
val viewModel: AudiobooksViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) {
viewModel.openPlayer.collect { data ->
val encodedTitle = android.net.Uri.encode(data.title)
navController.navigate("audio_player/${data.itemId}/$encodedTitle")
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
) {
CustomTopAppBar(headerText = "Audiobooks", icon = Icons.Filled.Audiotrack)
AnimatedVisibility(
visible = uiState.isLoading && uiState.items.isEmpty(),
enter = fadeIn(),
exit = fadeOut(),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
ProgressDots()
}
AnimatedVisibility(visible = uiState.isLoading && uiState.items.isEmpty(), enter = fadeIn(), exit = fadeOut()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ProgressDots() }
}
uiState.error?.let { error ->
@ -58,34 +58,20 @@ fun AudiobooksScreen(navController: NavController) {
}
}
AnimatedVisibility(
visible = !uiState.isLoading && uiState.error == null,
enter = fadeIn(),
exit = fadeOut(),
) {
AnimatedVisibility(visible = !uiState.isLoading && uiState.error == null, enter = fadeIn(), exit = fadeOut()) {
LazyVerticalGrid(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(start = 8.dp, end = 8.dp),
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(start = 8.dp, end = 8.dp),
columns = GridCells.Adaptive(295.dp),
contentPadding = PaddingValues(bottom = 80.dp),
) {
items(uiState.items.size) { i ->
val item = uiState.items[i]
Box(
modifier = Modifier
.padding(4.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
items(uiState.items) { item ->
Box(modifier = Modifier.padding(4.dp).fillMaxWidth(), contentAlignment = Alignment.Center) {
BookItemCard(
title = item.title,
author = item.author.ifBlank { item.authors.joinToString(", ") },
subtitle = formatDuration(item.duration),
coverImageUrl = item.coverUrl.ifBlank { null },
) {
}
) { viewModel.openPlayer(item) }
}
}
}
@ -95,11 +81,7 @@ fun AudiobooksScreen(navController: NavController) {
private fun formatDuration(seconds: Double): String {
if (seconds <= 0) return ""
val hours = (seconds / 3600).toInt()
val mins = ((seconds % 3600) / 60).toInt()
return when {
hours > 0 -> "${hours}h ${mins}m"
mins > 0 -> "${mins}m"
else -> "${seconds.toInt()}s"
}
val h = (seconds / 3600).toInt()
val m = ((seconds % 3600) / 60).toInt()
return when { h > 0 -> "${h}h ${m}m"; m > 0 -> "${m}m"; else -> "${seconds.toInt()}s" }
}

View file

@ -1,13 +1,18 @@
package org.dueattendant149.bookreader.ui.screens.audiobooks
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.AudioTrackResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
import javax.inject.Inject
@ -17,55 +22,60 @@ data class AudiobooksUiState(
val error: String? = null,
)
data class AudioPlayerData(
val itemId: String,
val title: String,
val tracks: List<AudioTrackResponse>,
)
@HiltViewModel
class AudiobooksViewModel
@Inject
constructor(
application: Application,
private val repository: BookshelfApiRepository,
) : ViewModel() {
) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(AudiobooksUiState())
val uiState: StateFlow<AudiobooksUiState> = _uiState.asStateFlow()
init {
loadAudiobooks()
}
private val _openPlayer = MutableSharedFlow<AudioPlayerData>(extraBufferCapacity = 1)
val openPlayer: SharedFlow<AudioPlayerData> = _openPlayer.asSharedFlow()
init { loadAudiobooks() }
fun loadAudiobooks() {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch {
repository.getLibraries()
.onSuccess { libraries ->
val audioLibrary = libraries.firstOrNull {
it.name.equals("Audiobooks", ignoreCase = true) || it.id == "09bfdf43-cc5a-4352-9f12-35d4db2a8ed7"
}
if (audioLibrary != null) {
loadItems(audioLibrary.id)
} else {
_uiState.value = _uiState.value.copy(isLoading = false, error = "No audiobook library found")
}
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = error.message ?: "Failed to load libraries"
)
val audioLib = libraries.firstOrNull { it.name.equals("Audiobooks", true) }
if (audioLib != null) loadItems(audioLib.id)
else _uiState.value = _uiState.value.copy(isLoading = false, error = "No audiobook library")
}
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, error = it.message) }
}
}
private fun loadItems(libraryId: String) {
_uiState.value = _uiState.value.copy(isLoading = true)
viewModelScope.launch {
repository.getLibraryItems(libraryId)
.onSuccess { response ->
val audioItems = response.items.filter { it.type == "audiobook" || it.type == "podcast" }
_uiState.value = AudiobooksUiState(items = audioItems)
_uiState.value = AudiobooksUiState(items = response.items.filter { it.type == "audiobook" || it.type == "podcast" })
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = error.message ?: "Failed to load items"
)
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, error = it.message) }
}
}
fun openPlayer(item: UnifiedItemResponse) {
viewModelScope.launch {
repository.getAudioTracks(item.id)
.onSuccess { tracks ->
_openPlayer.tryEmit(AudioPlayerData(item.id, item.title, tracks))
}
.onFailure {
_uiState.value = _uiState.value.copy(error = "Failed to load tracks: ${it.message}")
}
}
}

View file

@ -4,16 +4,15 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
@ -24,20 +23,17 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
@ -70,9 +66,7 @@ fun SearchScreen(navController: NavController) {
placeholder = { Text("Search...") },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
trailingIcon = {
IconButton(onClick = {
if (uiState.query.isNotEmpty()) viewModel.updateQuery("")
}) {
IconButton(onClick = { if (uiState.query.isNotEmpty()) viewModel.updateQuery("") }) {
Icon(Icons.Filled.Close, contentDescription = "Clear")
}
},
@ -88,10 +82,17 @@ fun SearchScreen(navController: NavController) {
shape = RoundedCornerShape(24.dp),
)
uiState.downloadMessage?.let { msg ->
Text(
text = msg,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.primary,
fontSize = 14.sp,
)
}
if (uiState.isSearching) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
ProgressDots()
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ProgressDots() }
}
uiState.error?.let { error ->
@ -108,27 +109,20 @@ fun SearchScreen(navController: NavController) {
if (!uiState.isSearching && uiState.results.isNotEmpty()) {
LazyVerticalGrid(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(start = 8.dp, end = 8.dp),
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(start = 8.dp, end = 8.dp),
columns = GridCells.Adaptive(295.dp),
contentPadding = PaddingValues(bottom = 80.dp),
) {
items(uiState.results.size) { i ->
val item = uiState.results[i]
Box(
modifier = Modifier
.padding(4.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
items(uiState.results) { item ->
val isDownloading = uiState.downloadingTitle == item.title
Box(modifier = Modifier.padding(4.dp).fillMaxWidth(), contentAlignment = Alignment.Center) {
BookItemCard(
title = item.title,
author = item.author,
subtitle = "${item.source} · ${item.format}".trim(' ', '·'),
subtitle = if (isDownloading) "Sending to Librarr..." else "${item.source} · ${item.format}".trim(' ', '·'),
coverImageUrl = item.coverUrl.ifBlank { null },
) {
if (!isDownloading) viewModel.startDownload(item)
}
}
}

View file

@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResultItemResponse
import javax.inject.Inject
@ -18,6 +19,8 @@ data class SearchUiState(
val isSearching: Boolean = false,
val error: String? = null,
val hasSearched: Boolean = false,
val downloadingTitle: String? = null,
val downloadMessage: String? = null,
)
@HiltViewModel
@ -37,20 +40,40 @@ class SearchViewModel
fun search() {
val query = _uiState.value.query.trim()
if (query.isBlank()) return
_uiState.value = _uiState.value.copy(isSearching = true, error = null, hasSearched = true)
_uiState.value = _uiState.value.copy(isSearching = true, error = null, hasSearched = true, downloadMessage = null)
viewModelScope.launch {
repository.search(SearchRequest(query = query, limit = 50))
.onSuccess { response ->
_uiState.value = _uiState.value.copy(
results = response.results,
isSearching = false,
)
_uiState.value = _uiState.value.copy(results = response.results, isSearching = false)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isSearching = false,
error = error.message ?: "Search failed",
)
_uiState.value = _uiState.value.copy(isSearching = false, error = error.message ?: "Search failed")
}
}
}
fun startDownload(item: SearchResultItemResponse) {
_uiState.value = _uiState.value.copy(downloadingTitle = item.title, downloadMessage = null)
viewModelScope.launch {
val request = DownloadRequest(
source = item.source,
title = item.title,
author = item.author,
downloadUrl = item.downloadUrl.ifBlank { null },
magnetUrl = item.magnetUrl.ifBlank { null },
infoHash = item.infoHash.ifBlank { null },
md5 = item.md5.ifBlank { null },
url = item.url.ifBlank { null },
mediaType = item.mediaType.ifBlank { "ebook" },
downloadProtocol = item.downloadProtocol,
)
repository.startDownload(request)
.onSuccess { response ->
val msg = if (response.success) "Download started: ${item.title}" else response.error ?: "Download failed"
_uiState.value = _uiState.value.copy(downloadingTitle = null, downloadMessage = msg)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(downloadingTitle = null, downloadMessage = error.message)
}
}
}