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.navigation.navArgument
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import org.dueattendant149.bookreader.ui.screens.audiobooks.AudiobooksScreen 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.books.BooksScreen
import org.dueattendant149.bookreader.ui.screens.reader.ReaderScreen import org.dueattendant149.bookreader.ui.screens.reader.ReaderScreen
import org.dueattendant149.bookreader.ui.screens.search.SearchScreen import org.dueattendant149.bookreader.ui.screens.search.SearchScreen
@ -44,5 +45,16 @@ fun NavGraph(navController: NavHostController) {
val bookUri = backStackEntry.arguments?.getString("bookUri") ?: "" val bookUri = backStackEntry.arguments?.getString("bookUri") ?: ""
ReaderScreen(bookUri = bookUri, onBackClick = { navController.popBackStack() }) 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -35,21 +36,20 @@ fun AudiobooksScreen(navController: NavController) {
val viewModel: AudiobooksViewModel = hiltViewModel() val viewModel: AudiobooksViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle() 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( Column(
modifier = Modifier modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) { ) {
CustomTopAppBar(headerText = "Audiobooks", icon = Icons.Filled.Audiotrack) CustomTopAppBar(headerText = "Audiobooks", icon = Icons.Filled.Audiotrack)
AnimatedVisibility( AnimatedVisibility(visible = uiState.isLoading && uiState.items.isEmpty(), enter = fadeIn(), exit = fadeOut()) {
visible = uiState.isLoading && uiState.items.isEmpty(), Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ProgressDots() }
enter = fadeIn(),
exit = fadeOut(),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
ProgressDots()
}
} }
uiState.error?.let { error -> uiState.error?.let { error ->
@ -58,34 +58,20 @@ fun AudiobooksScreen(navController: NavController) {
} }
} }
AnimatedVisibility( AnimatedVisibility(visible = !uiState.isLoading && uiState.error == null, enter = fadeIn(), exit = fadeOut()) {
visible = !uiState.isLoading && uiState.error == null,
enter = fadeIn(),
exit = fadeOut(),
) {
LazyVerticalGrid( LazyVerticalGrid(
modifier = Modifier modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(start = 8.dp, end = 8.dp),
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(start = 8.dp, end = 8.dp),
columns = GridCells.Adaptive(295.dp), columns = GridCells.Adaptive(295.dp),
contentPadding = PaddingValues(bottom = 80.dp), contentPadding = PaddingValues(bottom = 80.dp),
) { ) {
items(uiState.items.size) { i -> items(uiState.items) { item ->
val item = uiState.items[i] Box(modifier = Modifier.padding(4.dp).fillMaxWidth(), contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.padding(4.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
BookItemCard( BookItemCard(
title = item.title, title = item.title,
author = item.author.ifBlank { item.authors.joinToString(", ") }, author = item.author.ifBlank { item.authors.joinToString(", ") },
subtitle = formatDuration(item.duration), subtitle = formatDuration(item.duration),
coverImageUrl = item.coverUrl.ifBlank { null }, coverImageUrl = item.coverUrl.ifBlank { null },
) { ) { viewModel.openPlayer(item) }
}
} }
} }
} }
@ -95,11 +81,7 @@ fun AudiobooksScreen(navController: NavController) {
private fun formatDuration(seconds: Double): String { private fun formatDuration(seconds: Double): String {
if (seconds <= 0) return "" if (seconds <= 0) return ""
val hours = (seconds / 3600).toInt() val h = (seconds / 3600).toInt()
val mins = ((seconds % 3600) / 60).toInt() val m = ((seconds % 3600) / 60).toInt()
return when { return when { h > 0 -> "${h}h ${m}m"; m > 0 -> "${m}m"; else -> "${seconds.toInt()}s" }
hours > 0 -> "${hours}h ${mins}m"
mins > 0 -> "${mins}m"
else -> "${seconds.toInt()}s"
}
} }

View file

@ -1,13 +1,18 @@
package org.dueattendant149.bookreader.ui.screens.audiobooks package org.dueattendant149.bookreader.ui.screens.audiobooks
import androidx.lifecycle.ViewModel import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository 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 org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
import javax.inject.Inject import javax.inject.Inject
@ -17,55 +22,60 @@ data class AudiobooksUiState(
val error: String? = null, val error: String? = null,
) )
data class AudioPlayerData(
val itemId: String,
val title: String,
val tracks: List<AudioTrackResponse>,
)
@HiltViewModel @HiltViewModel
class AudiobooksViewModel class AudiobooksViewModel
@Inject @Inject
constructor( constructor(
application: Application,
private val repository: BookshelfApiRepository, private val repository: BookshelfApiRepository,
) : ViewModel() { ) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(AudiobooksUiState()) private val _uiState = MutableStateFlow(AudiobooksUiState())
val uiState: StateFlow<AudiobooksUiState> = _uiState.asStateFlow() val uiState: StateFlow<AudiobooksUiState> = _uiState.asStateFlow()
init { private val _openPlayer = MutableSharedFlow<AudioPlayerData>(extraBufferCapacity = 1)
loadAudiobooks() val openPlayer: SharedFlow<AudioPlayerData> = _openPlayer.asSharedFlow()
}
init { loadAudiobooks() }
fun loadAudiobooks() { fun loadAudiobooks() {
_uiState.value = _uiState.value.copy(isLoading = true, error = null) _uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch { viewModelScope.launch {
repository.getLibraries() repository.getLibraries()
.onSuccess { libraries -> .onSuccess { libraries ->
val audioLibrary = libraries.firstOrNull { val audioLib = libraries.firstOrNull { it.name.equals("Audiobooks", true) }
it.name.equals("Audiobooks", ignoreCase = true) || it.id == "09bfdf43-cc5a-4352-9f12-35d4db2a8ed7" if (audioLib != null) loadItems(audioLib.id)
} else _uiState.value = _uiState.value.copy(isLoading = false, error = "No audiobook library")
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"
)
} }
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, error = it.message) }
} }
} }
private fun loadItems(libraryId: String) { private fun loadItems(libraryId: String) {
_uiState.value = _uiState.value.copy(isLoading = true)
viewModelScope.launch { viewModelScope.launch {
repository.getLibraryItems(libraryId) repository.getLibraryItems(libraryId)
.onSuccess { response -> .onSuccess { response ->
val audioItems = response.items.filter { it.type == "audiobook" || it.type == "podcast" } _uiState.value = AudiobooksUiState(items = response.items.filter { it.type == "audiobook" || it.type == "podcast" })
_uiState.value = AudiobooksUiState(items = audioItems)
} }
.onFailure { error -> .onFailure { _uiState.value = _uiState.value.copy(isLoading = false, error = it.message) }
_uiState.value = _uiState.value.copy( }
isLoading = false, }
error = error.message ?: "Failed to load items"
) 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search 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.OutlinedTextFieldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction 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.dp
import androidx.compose.ui.unit.sp 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
@ -70,9 +66,7 @@ fun SearchScreen(navController: NavController) {
placeholder = { Text("Search...") }, placeholder = { Text("Search...") },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") }, leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
trailingIcon = { trailingIcon = {
IconButton(onClick = { IconButton(onClick = { if (uiState.query.isNotEmpty()) viewModel.updateQuery("") }) {
if (uiState.query.isNotEmpty()) viewModel.updateQuery("")
}) {
Icon(Icons.Filled.Close, contentDescription = "Clear") Icon(Icons.Filled.Close, contentDescription = "Clear")
} }
}, },
@ -88,10 +82,17 @@ fun SearchScreen(navController: NavController) {
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
) )
if (uiState.isSearching) { uiState.downloadMessage?.let { msg ->
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(
ProgressDots() 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() }
} }
uiState.error?.let { error -> uiState.error?.let { error ->
@ -108,27 +109,20 @@ fun SearchScreen(navController: NavController) {
if (!uiState.isSearching && uiState.results.isNotEmpty()) { if (!uiState.isSearching && uiState.results.isNotEmpty()) {
LazyVerticalGrid( LazyVerticalGrid(
modifier = Modifier modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(start = 8.dp, end = 8.dp),
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(start = 8.dp, end = 8.dp),
columns = GridCells.Adaptive(295.dp), columns = GridCells.Adaptive(295.dp),
contentPadding = PaddingValues(bottom = 80.dp), contentPadding = PaddingValues(bottom = 80.dp),
) { ) {
items(uiState.results.size) { i -> items(uiState.results) { item ->
val item = uiState.results[i] val isDownloading = uiState.downloadingTitle == item.title
Box( Box(modifier = Modifier.padding(4.dp).fillMaxWidth(), contentAlignment = Alignment.Center) {
modifier = Modifier
.padding(4.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
BookItemCard( BookItemCard(
title = item.title, title = item.title,
author = item.author, 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 }, 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.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository 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.SearchRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResultItemResponse import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResultItemResponse
import javax.inject.Inject import javax.inject.Inject
@ -18,6 +19,8 @@ data class SearchUiState(
val isSearching: Boolean = false, val isSearching: Boolean = false,
val error: String? = null, val error: String? = null,
val hasSearched: Boolean = false, val hasSearched: Boolean = false,
val downloadingTitle: String? = null,
val downloadMessage: String? = null,
) )
@HiltViewModel @HiltViewModel
@ -37,20 +40,40 @@ class SearchViewModel
fun search() { fun search() {
val query = _uiState.value.query.trim() val query = _uiState.value.query.trim()
if (query.isBlank()) return 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 { viewModelScope.launch {
repository.search(SearchRequest(query = query, limit = 50)) repository.search(SearchRequest(query = query, limit = 50))
.onSuccess { response -> .onSuccess { response ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(results = response.results, isSearching = false)
results = response.results,
isSearching = false,
)
} }
.onFailure { error -> .onFailure { error ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(isSearching = false, error = error.message ?: "Search failed")
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)
} }
} }
} }