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:
parent
d3339bbceb
commit
ad84e4adf8
6 changed files with 273 additions and 99 deletions
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue