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.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() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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.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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue