diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/navigation/NavGraph.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/navigation/NavGraph.kt index 33caadd..eb4baff 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/ui/navigation/NavGraph.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/navigation/NavGraph.kt @@ -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() }) + } } } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudioPlayerScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudioPlayerScreen.kt new file mode 100644 index 0000000..b996b4e --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudioPlayerScreen.kt @@ -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(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) +} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksScreen.kt index 3bdb590..0396d09 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksScreen.kt @@ -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" } } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksViewModel.kt index a1aee35..0d65aaa 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksViewModel.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksViewModel.kt @@ -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, +) + @HiltViewModel class AudiobooksViewModel @Inject constructor( + application: Application, private val repository: BookshelfApiRepository, - ) : ViewModel() { + ) : AndroidViewModel(application) { private val _uiState = MutableStateFlow(AudiobooksUiState()) val uiState: StateFlow = _uiState.asStateFlow() - init { - loadAudiobooks() - } + private val _openPlayer = MutableSharedFlow(extraBufferCapacity = 1) + val openPlayer: SharedFlow = _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}") } } } diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchScreen.kt index 0bd85ea..8b71d2c 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchScreen.kt @@ -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) } } } diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchViewModel.kt index 0a42a8a..936b1e7 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchViewModel.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchViewModel.kt @@ -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) } } }