book-reader/app/src/main/java/com/aryan/reader/LibraryScreen.kt
Aryan 15264a31ae
Support opds (#133)
* Add OPDS catalog support for book discovery and downloading

* Implement OPDS search, improved book discovery, and enhanced UI details.

* Improve OPDS search and catalog management

* Add authentication support and extended metadata to the OPDS reader.

* Add support for OPDS 2.0 (JSON) feeds

* Enhance OPDS book downloading with progress tracking and multi-format support.

* Enhance OPDS book management and UI

* Add support for OPDS-PSE (Page Streaming Extension) streaming

* Improve OPDS streaming and catalog management
2026-03-30 21:42:06 +05:30

2727 lines
No EOL
118 KiB
Kotlin

/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
// LibraryScreen.kt
package com.aryan.reader
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import com.aryan.reader.opds.OpdsViewModel
import com.aryan.reader.opds.OpdsEntry
import com.aryan.reader.opds.OpdsCatalog
import org.jsoup.Jsoup
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.AssistChip
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.opds.OpdsAcquisition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private fun getBookCountString(count: Int): String {
return if (count == 1) "1 book" else "$count books"
}
@Composable
fun LibraryScreen(
viewModel: MainViewModel,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedItems = uiState.contextualActionItems
val isContextualModeActive = selectedItems.isNotEmpty()
val selectedShelves = uiState.contextualActionShelfNames
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
val sortOrder = uiState.sortOrder
val shelves = uiState.shelves
val rawLibraryFiles = uiState.rawLibraryFiles
val pagerState = rememberPagerState(
initialPage = uiState.libraryScreenStartPage,
pageCount = { 4 }
)
val containsFolderItems = remember(selectedItems) {
selectedItems.any { it.sourceFolderUri != null }
}
LaunchedEffect(uiState.libraryScreenStartPage) {
if (pagerState.currentPage != uiState.libraryScreenStartPage) {
pagerState.animateScrollToPage(uiState.libraryScreenStartPage)
}
}
val scope = rememberCoroutineScope()
var showFilterSheet by remember { mutableStateOf(false) }
val isSearchActive = uiState.isSearchActive
val searchQuery = uiState.searchQuery
val pickFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri?.let {
viewModel.addSyncedFolder(it)
}
}
val onSelectSyncFolderClick = {
try {
pickFolderLauncher.launch(null)
} catch (_: android.content.ActivityNotFoundException) {
viewModel.showBanner("Your device doesn't support folder selection. You can still import files individually.", isError = true)
}
}
val pickFileLauncher = rememberFilePickerLauncher { uris ->
if (isContextualModeActive) {
viewModel.clearContextualAction()
}
uris.forEach { uri ->
viewModel.onFileSelected(uri, isFromRecent = false)
}
}
val fallbackFilePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris ->
if (isContextualModeActive) {
viewModel.clearContextualAction()
}
uris.forEach { uri ->
viewModel.onFileSelected(uri, isFromRecent = false)
}
}
val onSelectFileClick = {
if (isContextualModeActive) {
viewModel.clearContextualAction()
}
try {
pickFileLauncher.launch(arrayOf("*/*"))
} catch (_: android.content.ActivityNotFoundException) {
Timber.w("OpenDocument picker failed. Falling back to GetMultipleContents.")
try {
fallbackFilePickerLauncher.launch("*/*")
} catch (_: android.content.ActivityNotFoundException) {
viewModel.showBanner("No file manager found. Please install a file manager app.", isError = true)
}
}
}
LaunchedEffect(pagerState) {
androidx.compose.runtime.snapshotFlow { pagerState.settledPage }
.collect { page ->
viewModel.setLibraryScreenPage(page)
}
}
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
var showDeleteShelvesDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
BackHandler(enabled = isContextualModeActive) {
viewModel.clearContextualAction()
}
BackHandler(enabled = isShelfContextualModeActive) {
viewModel.clearShelfContextualAction()
}
BackHandler(enabled = isSearchActive) {
viewModel.setSearchActive(false)
}
Box(modifier = Modifier.fillMaxSize()) {
LibraryScreenContent(
recentFiles = uiState.allRecentFiles,
rawLibraryFiles = rawLibraryFiles,
shelves = shelves,
selectedItems = selectedItems,
selectedShelves = selectedShelves,
sortOrder = sortOrder,
libraryFilters = uiState.libraryFilters,
pinnedLibraryBookIds = uiState.pinnedLibraryBookIds,
pagerState = pagerState,
scope = scope,
searchQuery = searchQuery,
isSearchActive = isSearchActive,
onSearchQueryChange = viewModel::onSearchQueryChange,
onSearchActiveChange = viewModel::setSearchActive,
onSortOrderChange = viewModel::setSortOrder,
onFilterClick = { showFilterSheet = true },
onClearFilters = { viewModel.updateLibraryFilters(LibraryFilters()) },
onRemoveFilter = { viewModel.updateLibraryFilters(it) },
onPinClick = { viewModel.togglePinForContextualItems(isHome = false) },
onClearSelection = { viewModel.clearContextualAction() },
onItemClick = viewModel::onRecentFileClicked,
onItemLongClick = viewModel::onRecentItemLongPress,
onInfoClick = {
if (selectedItems.size == 1) {
itemForInfoDialog = selectedItems.first()
showInfoDialog = true
}
},
onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllLibraryFiles() },
onShelfClick = viewModel::onShelfClick,
onShelfLongClick = viewModel::onShelfLongPress,
onClearShelfSelection = viewModel::clearShelfContextualAction,
onDeleteShelves = { showDeleteShelvesDialog = true },
onNewShelfClick = viewModel::showCreateShelfDialog,
onSelectFileClick = onSelectFileClick,
onScanNowClick = viewModel::scanSyncedFolder,
onSyncMetadataClick = viewModel::syncFolderMetadata,
onSelectSyncFolderClick = onSelectSyncFolderClick,
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
syncedFolders = uiState.syncedFolders,
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
downloadingBookIds = uiState.downloadingBookIds,
lastFolderScanTime = uiState.lastFolderScanTime,
isLoading = uiState.isLoading,
isRefreshing = uiState.isRefreshing,
onOpdsBookDownloaded = { uri, title ->
viewModel.showBanner("Downloaded $title")
viewModel.onFileSelected(uri, isFromRecent = false)
},
onStreamOpdsBook = { entry, catalog ->
viewModel.streamOpdsBook(
bookId = entry.id,
title = entry.title,
urlTemplate = entry.pseUrlTemplate!!,
pageCount = entry.pseCount!!,
catalogId = catalog?.id
)
},
onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog
)
if (uiState.showCreateShelfDialog) {
CreateShelfDialog(
onConfirm = viewModel::createShelf,
onDismiss = viewModel::dismissCreateShelfDialog
)
}
if (showDeleteConfirmDialog) {
DeleteConfirmationDialog(
count = selectedItems.size,
onConfirm = {
viewModel.deleteContextualItemsPermanently()
showDeleteConfirmDialog = false
},
onDismiss = { showDeleteConfirmDialog = false },
isPermanentDelete = true,
containsFolderItems = containsFolderItems
)
}
if (showFilterSheet) {
LibraryFilterSheet(
filters = uiState.libraryFilters,
syncedFolders = uiState.syncedFolders,
onApply = { viewModel.updateLibraryFilters(it) },
onDismiss = { showFilterSheet = false }
)
}
if (showDeleteShelvesDialog) {
DeleteShelvesConfirmationDialog(
count = selectedShelves.size,
onConfirm = {
viewModel.deleteSelectedShelves()
showDeleteShelvesDialog = false
},
onDismiss = { showDeleteShelvesDialog = false }
)
}
itemForInfoDialog?.let { item ->
if (showInfoDialog) {
FileInfoDialog(
item = item,
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName)
}
)
}
}
CustomTopBanner(bannerMessage = uiState.bannerMessage)
}
}
@Composable
fun ShelfScreen(
viewModel: MainViewModel,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedItems = uiState.contextualActionItems
val viewingShelfName = uiState.viewingShelfName
val isAddingBooks = uiState.isAddingBooksToShelf
val shelves = uiState.shelves
val sortOrder = uiState.sortOrder
val showRenameDialogFor = uiState.showRenameShelfDialogFor
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
BackHandler(enabled = true) {
when {
selectedItems.isNotEmpty() -> viewModel.clearContextualAction()
isAddingBooks -> viewModel.dismissAddBooksToShelf()
else -> viewModel.unselectShelf()
}
}
val currentShelf = shelves.find { it.name == viewingShelfName }
Box(modifier = Modifier.fillMaxSize()) {
if (viewingShelfName != null && currentShelf != null) {
if (isAddingBooks) {
AddBooksModeScreen(
shelfName = viewingShelfName,
availableBooks = uiState.booksAvailableForAdding,
selectedBookUris = uiState.booksSelectedForAdding,
currentSource = uiState.addBooksSource,
sortOrder = sortOrder,
onSortOrderChange = viewModel::setSortOrder,
onSourceChange = viewModel::setAddBooksSource,
onBookClick = { item -> viewModel.toggleBookSelectionForAdding(item.bookId) },
onBack = viewModel::dismissAddBooksToShelf,
onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfName) },
downloadingBookIds = uiState.downloadingBookIds
)
} else {
ShelfDetailScreen(
shelf = currentShelf,
selectedItems = selectedItems,
sortOrder = sortOrder,
onSortOrderChange = viewModel::setSortOrder,
onBack = viewModel::unselectShelf,
onAddBooksClick = viewModel::showAddBooksToShelf,
onBookClick = viewModel::onRecentFileClicked,
onBookLongClick = viewModel::onRecentItemLongPress,
onClearSelection = viewModel::clearContextualAction,
onInfoClick = {
if (selectedItems.size == 1) {
itemForInfoDialog = selectedItems.first()
showInfoDialog = true
}
},
onDeleteClick = { showRemoveFromShelfDialog = true },
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.name) },
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.name) },
downloadingBookIds = uiState.downloadingBookIds
)
}
}
if (showRenameDialogFor != null) {
RenameShelfDialog(
initialName = showRenameDialogFor,
onConfirm = { newName -> viewModel.renameShelf(showRenameDialogFor, newName) },
onDismiss = viewModel::dismissRenameShelfDialog
)
}
if (showDeleteDialogFor != null) {
DeleteShelfConfirmationDialog(
shelfName = showDeleteDialogFor,
onConfirm = { viewModel.deleteShelf(showDeleteDialogFor) },
onDismiss = viewModel::dismissDeleteShelfDialog
)
}
if (showRemoveFromShelfDialog) {
RemoveFromShelfConfirmationDialog(
count = selectedItems.size,
shelfName = currentShelf?.name ?: "",
onConfirm = {
viewModel.removeContextualItemsFromShelf()
showRemoveFromShelfDialog = false
},
onDismiss = { showRemoveFromShelfDialog = false }
)
}
itemForInfoDialog?.let { item ->
if (showInfoDialog) {
FileInfoDialog(
item = item,
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName)
}
)
}
}
CustomTopBanner(bannerMessage = uiState.bannerMessage)
}
}
@Suppress("unused")
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LibraryScreenContent(
recentFiles: List<RecentFileItem>,
rawLibraryFiles: List<RecentFileItem>,
shelves: List<Shelf>,
selectedItems: Set<RecentFileItem>,
selectedShelves: Set<String>,
sortOrder: SortOrder,
libraryFilters: LibraryFilters,
pinnedLibraryBookIds: Set<String>,
pagerState: PagerState,
scope: CoroutineScope,
searchQuery: String,
isSearchActive: Boolean,
onSearchQueryChange: (String) -> Unit,
onSearchActiveChange: (Boolean) -> Unit,
onSortOrderChange: (SortOrder) -> Unit,
onFilterClick: () -> Unit,
onClearFilters: () -> Unit,
onRemoveFilter: (LibraryFilters) -> Unit,
onPinClick: () -> Unit,
onClearSelection: () -> Unit,
onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit,
onInfoClick: () -> Unit,
onDeleteClick: () -> Unit,
onSelectAllClick: () -> Unit,
onShelfClick: (Shelf) -> Unit,
onShelfLongClick: (Shelf) -> Unit,
onClearShelfSelection: () -> Unit,
onDeleteShelves: () -> Unit,
onNewShelfClick: () -> Unit,
onSelectFileClick: () -> Unit,
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
onSelectSyncFolderClick: () -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onDisconnectSyncFolderClick: () -> Unit,
downloadingBookIds: Set<String>,
lastFolderScanTime: Long?,
isLoading: Boolean,
isRefreshing: Boolean,
syncedFolders: List<SyncedFolder>,
onRemoveFolderClick: (SyncedFolder) -> Unit,
onOpdsBookDownloaded: (Uri, String) -> Unit,
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
) {
val isBookContextualModeActive = selectedItems.isNotEmpty()
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
var showSortMenu by remember { mutableStateOf(false) }
val tabTitles = listOf("All Books", "Shelves", "Folders", "Catalogs")
val searchFocusRequester = remember { FocusRequester() }
var textFieldValue by remember(isSearchActive) {
mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
}
LaunchedEffect(searchQuery) {
if (textFieldValue.text != searchQuery) {
textFieldValue = textFieldValue.copy(
text = searchQuery,
selection = TextRange(searchQuery.length)
)
}
}
LaunchedEffect(isSearchActive) {
if (isSearchActive) {
searchFocusRequester.requestFocus()
}
}
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
Column {
if (isBookContextualModeActive) {
ContextualTopAppBar(
selectedItemCount = selectedItems.size,
onNavIconClick = onClearSelection,
onPinClick = onPinClick,
onInfoClick = onInfoClick,
onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick
)
} else if (isShelfContextualModeActive && pagerState.currentPage == 1) {
ContextualTopAppBar(
selectedItemCount = selectedShelves.size,
onNavIconClick = onClearShelfSelection,
onDeleteClick = onDeleteShelves
)
} else if (isSearchActive) {
Surface(
shadowElevation = 4.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { onSearchActiveChange(false) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search")
}
OutlinedTextField(
value = textFieldValue,
onValueChange = {
textFieldValue = it
onSearchQueryChange(it.text)
},
placeholder = { Text("Search title or author...") },
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp)
.focusRequester(searchFocusRequester),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchQueryChange("") }) {
Icon(Icons.Default.Close, contentDescription = "Clear query")
}
}
}
)
}
}
} else {
CustomTopAppBar(
title = { Text("Library") },
actions = {
if (pagerState.currentPage == 0) {
IconButton(onClick = onFilterClick) {
Icon(Icons.Default.FilterList, contentDescription = "Filter")
}
Box {
TextButton(onClick = { showSortMenu = true }) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = "Sort",
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(sortOrder.displayName)
}
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
SortOrder.entries.forEach { order ->
DropdownMenuItem(
text = { Text(order.displayName) },
onClick = {
onSortOrderChange(order)
showSortMenu = false
},
trailingIcon = {
if (order == sortOrder) {
Icon(
Icons.Default.Check,
contentDescription = "Selected"
)
}
}
)
}
}
}
IconButton(onClick = { onSearchActiveChange(true) }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
}
}
)
TabRow(selectedTabIndex = pagerState.currentPage) {
tabTitles.forEachIndexed { index, title ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
scope.launch { pagerState.animateScrollToPage(index) }
},
text = { Text(title) }
)
}
}
androidx.compose.animation.AnimatedVisibility(
visible = libraryFilters.isActive && pagerState.currentPage == 0
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 16.dp, vertical = 8.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (libraryFilters.fileTypes.isNotEmpty()) {
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(fileTypes = emptySet())) },
label = { Text("Types: ${libraryFilters.fileTypes.joinToString { it.name }}") },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
)
}
if (libraryFilters.sourceFolders.isNotEmpty()) {
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(sourceFolders = emptySet())) },
label = { Text("Folders: ${libraryFilters.sourceFolders.size}") },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
)
}
if (libraryFilters.readStatus != ReadStatusFilter.ALL) {
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(readStatus = ReadStatusFilter.ALL)) },
label = { Text("Status: ${libraryFilters.readStatus.displayName}") },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
)
}
}
}
}
}
},
floatingActionButton = {
if (!isBookContextualModeActive && !isShelfContextualModeActive) {
when (pagerState.currentPage) {
0 -> {
if (recentFiles.isNotEmpty()) {
ExtendedFloatingActionButton(
text = { Text("Add file") },
icon = { Icon(Icons.Default.Add, contentDescription = "Add file") },
onClick = onSelectFileClick,
modifier = Modifier.padding(16.dp)
)
}
}
1 -> {
ExtendedFloatingActionButton(
text = { Text("New shelf") },
icon = { Icon(Icons.Default.Add, contentDescription = "New shelf") },
onClick = onNewShelfClick,
modifier = Modifier.padding(16.dp)
)
}
}
}
}
) { paddingValues ->
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
key = { it }
) { page ->
when (page) {
0 -> {
if (recentFiles.isEmpty() && searchQuery.isNotEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No results found for \"$searchQuery\"")
}
} else if (recentFiles.isEmpty()) {
EmptyState(
title = "Your Library is Empty",
message = "Select a PDF, EPUB, MOBI, or AZW3 file from your device to get started.",
onSelectFileClick = onSelectFileClick,
modifier = Modifier.fillMaxSize()
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(recentFiles, key = { it.bookId }) { item ->
LibraryListItem(
item = item,
isSelected = selectedItems.any { it.bookId == item.bookId },
isPinned = item.bookId in pinnedLibraryBookIds,
onItemClick = { onItemClick(item) },
onItemLongClick = { onItemLongClick(item) },
isDownloading = item.bookId in downloadingBookIds
)
}
}
}
}
1 -> {
ShelvesScreen(
shelves = shelves,
onShelfClick = onShelfClick,
onShelfLongClick = onShelfLongClick,
selectedShelves = selectedShelves
)
}
2 -> {
FolderSyncScreen(
syncedFolders = syncedFolders,
allRecentFiles = rawLibraryFiles,
onAddFolderClick = onSelectSyncFolderClick,
onRemoveFolderClick = onRemoveFolderClick,
onEditFolderFiltersClick = onEditFolderFiltersClick,
onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick,
isLoading = isLoading || isRefreshing
)
}
3 -> {
OpdsTab(
localLibraryFiles = rawLibraryFiles,
onBookDownloaded = onOpdsBookDownloaded,
onReadBook = onItemClick,
onStreamBook = onStreamOpdsBook,
onDeleteCatalogStreams = onDeleteCatalogStreams
)
}
}
}
}
}
@Composable
private fun ShelvesScreen(
shelves: List<Shelf>,
onShelfClick: (Shelf) -> Unit,
onShelfLongClick: (Shelf) -> Unit,
selectedShelves: Set<String>,
) {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(shelves, key = { it.name }) { shelf ->
ShelfListItem(
shelf = shelf,
isSelected = shelf.name in selectedShelves,
onItemClick = { onShelfClick(shelf) },
onItemLongClick = { onShelfLongClick(shelf) }
)
}
}
}
@Composable
private fun CreateShelfDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var text by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create New Shelf") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
placeholder = { Text("Shelf Name") },
singleLine = true,
modifier = Modifier.focusRequester(focusRequester)
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(text) },
enabled = text.isNotBlank()
) {
Text("Create")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
}
}
@Composable
private fun ShelfDetailScreen(
shelf: Shelf,
selectedItems: Set<RecentFileItem>,
sortOrder: SortOrder,
onSortOrderChange: (SortOrder) -> Unit,
onBack: () -> Unit,
onAddBooksClick: () -> Unit,
onBookClick: (RecentFileItem) -> Unit,
onBookLongClick: (RecentFileItem) -> Unit,
onClearSelection: () -> Unit,
onInfoClick: () -> Unit,
onDeleteClick: () -> Unit,
onRenameShelf: () -> Unit,
onDeleteShelf: () -> Unit,
downloadingBookIds: Set<String>,
) {
val isContextualModeActive = selectedItems.isNotEmpty()
var showSortMenu by remember { mutableStateOf(false) }
var showMoreMenu by remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier.statusBarsPadding(),
topBar = {
if (isContextualModeActive) {
ContextualTopAppBar(
selectedItemCount = selectedItems.size,
onNavIconClick = onClearSelection,
onInfoClick = onInfoClick,
onDeleteClick = onDeleteClick
)
} else {
CustomTopAppBar(
title = {
Column {
Text(
text = shelf.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = getBookCountString(shelf.bookCount),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
Box {
TextButton(onClick = { showSortMenu = true }) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = "Sort",
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(sortOrder.displayName)
}
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
SortOrder.entries.forEach { order ->
DropdownMenuItem(
text = { Text(order.displayName) },
onClick = {
onSortOrderChange(order)
showSortMenu = false
},
trailingIcon = {
if (order == sortOrder) {
Icon(
Icons.Default.Check,
contentDescription = "Selected"
)
}
}
)
}
}
}
if (shelf.name != "Unshelved") {
Box {
IconButton(onClick = { showMoreMenu = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More options"
)
}
DropdownMenu(
expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false }
) {
DropdownMenuItem(
text = { Text("Rename shelf") },
onClick = {
onRenameShelf()
showMoreMenu = false
}
)
DropdownMenuItem(
text = { Text("Delete shelf") },
onClick = {
onDeleteShelf()
showMoreMenu = false
}
)
}
}
}
}
)
}
},
floatingActionButton = {
if (shelf.name != "Unshelved" && !isContextualModeActive) {
ExtendedFloatingActionButton(
onClick = onAddBooksClick,
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("Add books") }
)
}
}
) { paddingValues ->
if (shelf.books.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text("This shelf is empty", style = MaterialTheme.typography.bodyLarge)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(shelf.books, key = { it.bookId }) { item ->
LibraryListItem(
item = item,
isSelected = selectedItems.any { it.bookId == item.bookId },
onItemClick = { onBookClick(item) },
onItemLongClick = { onBookLongClick(item) },
isDownloading = item.bookId in downloadingBookIds
)
}
}
}
}
}
@Composable
private fun AddBooksModeScreen(
shelfName: String,
availableBooks: List<RecentFileItem>,
selectedBookUris: Set<String>,
currentSource: AddBooksSource,
sortOrder: SortOrder,
onSortOrderChange: (SortOrder) -> Unit,
onSourceChange: (AddBooksSource) -> Unit,
onBookClick: (RecentFileItem) -> Unit,
onBack: () -> Unit,
onAddSelectedBooks: () -> Unit,
downloadingBookIds: Set<String>,
) {
var showSortMenu by remember { mutableStateOf(false) }
var showSourceMenu by remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier.statusBarsPadding(),
topBar = {
CustomTopAppBar(
title = { Text("Add to $shelfName") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
Box {
TextButton(onClick = { showSortMenu = true }) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = "Sort",
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(sortOrder.displayName)
}
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
SortOrder.entries.forEach { order ->
DropdownMenuItem(
text = { Text(order.displayName) },
onClick = {
onSortOrderChange(order)
showSortMenu = false
},
trailingIcon = {
if (order == sortOrder) {
Icon(Icons.Default.Check, contentDescription = "Selected")
}
}
)
}
}
}
Box {
IconButton(onClick = { showSourceMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "More options")
}
DropdownMenu(
expanded = showSourceMenu,
onDismissRequest = { showSourceMenu = false }
) {
AddBooksSource.entries.forEach { source ->
DropdownMenuItem(
text = { Text(source.displayName) },
onClick = {
onSourceChange(source)
showSourceMenu = false
},
trailingIcon = {
if (source == currentSource) {
Icon(Icons.Default.Check, contentDescription = "Selected")
}
}
)
}
}
}
}
)
},
floatingActionButton = {
if (selectedBookUris.isNotEmpty()) {
ExtendedFloatingActionButton(
text = { Text("ADD (${selectedBookUris.size})") },
icon = { Icon(Icons.Default.Check, contentDescription = "Add books") },
onClick = onAddSelectedBooks
)
}
}
) { paddingValues ->
if (availableBooks.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text(
text = if (currentSource == AddBooksSource.UNSHELVED) "No unshelved books to add" else "All books are already in this shelf",
style = MaterialTheme.typography.bodyLarge
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(availableBooks, key = { it.bookId }) { item ->
val isSelected = item.bookId in selectedBookUris
LibraryListItem(
item = item,
isSelected = isSelected,
onItemClick = { onBookClick(item) },
onItemLongClick = { onBookClick(item) },
isDownloading = item.bookId in downloadingBookIds
)
}
}
}
}
}
@Composable
private fun ShelfCover(shelf: Shelf) {
val context = LocalContext.current
val placeholder = R.drawable.epub_placeholder
val booksForCovers = shelf.books.take(4).reversed()
val coverWidth = 52.dp
val coverHeight = 75.dp
val horizontalOffset = 12.dp
val maxWidth = coverWidth + (horizontalOffset * (4 - 1))
Box(
modifier = Modifier
.width(maxWidth)
.height(coverHeight),
contentAlignment = Alignment.CenterStart
) {
if (booksForCovers.size <= 1) {
val imageModel = remember(shelf.topBook?.coverImagePath) {
shelf.topBook?.coverImagePath?.let { File(it) } ?: placeholder
}
AsyncImage(
model = ImageRequest.Builder(context)
.data(imageModel)
.error(placeholder)
.fallback(placeholder)
.crossfade(true)
.build(),
contentDescription = "${shelf.name} shelf cover",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = coverWidth, height = coverHeight)
.clip(MaterialTheme.shapes.small)
)
} else {
Box(
modifier = Modifier
.width(coverWidth + (horizontalOffset * (booksForCovers.size - 1)))
.height(coverHeight)
) {
booksForCovers.forEachIndexed { index, book ->
val imageModel = remember(book.coverImagePath) {
book.coverImagePath?.let { File(it) } ?: placeholder
}
Surface(
shape = MaterialTheme.shapes.small,
shadowElevation = 4.dp,
modifier = Modifier
.size(width = coverWidth, height = coverHeight)
.align(Alignment.CenterEnd)
.offset(x = -horizontalOffset * index)
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(imageModel)
.error(placeholder)
.fallback(placeholder)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop
)
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ShelfListItem(
shelf: Shelf,
isSelected: Boolean,
onItemClick: () -> Unit,
onItemLongClick: () -> Unit,
) {
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = if (isSelected) 8.dp else 2.dp,
shadowElevation = 4.dp,
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onItemClick,
onLongClick = {
if (shelf.name != "Unshelved") {
onItemLongClick()
}
}
)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ShelfCover(shelf = shelf)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = shelf.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = getBookCountString(shelf.bookCount),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun LibraryListItem(
item: RecentFileItem,
isSelected: Boolean,
isPinned: Boolean = false,
onItemClick: () -> Unit,
onItemLongClick: () -> Unit,
isDownloading: Boolean,
) {
val context = LocalContext.current
val placeholder = when (item.type) {
FileType.PDF -> R.drawable.pdf_placeholder
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7 -> R.drawable.epub_placeholder
}
val imageModel = remember(item.coverImagePath) {
item.coverImagePath?.let { File(it) } ?: placeholder
}
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = if (isSelected) 8.dp else 2.dp,
shadowElevation = 4.dp,
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.combinedClickable(
onClick = onItemClick,
onLongClick = onItemLongClick
)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.Top
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(imageModel)
.error(placeholder)
.fallback(placeholder)
.crossfade(true)
.build(),
contentDescription = item.displayName,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 70.dp, height = 100.dp)
.clip(MaterialTheme.shapes.small)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (item.sourceFolderUri != null) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.width(4.dp))
}
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
if (isOpdsStream) {
Icon(
imageVector = Icons.Default.Cloud,
contentDescription = "OPDS Stream",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(modifier = Modifier.width(4.dp))
}
if (isPinned) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = "Pinned",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = item.customName ?: item.title ?: item.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
if (!item.isAvailable) {
Spacer(modifier = Modifier.width(8.dp))
if (isDownloading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp))
} else {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = "Not available locally",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
item.author?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
item.progressPercentage?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${it.toInt()}% complete",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
@Composable
private fun RenameShelfDialog(
initialName: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit
) {
var textFieldValue by remember {
mutableStateOf(
TextFieldValue(
text = initialName,
selection = TextRange(initialName.length)
)
)
}
val focusRequester = remember { FocusRequester() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename Shelf") },
text = {
OutlinedTextField(
value = textFieldValue,
onValueChange = { textFieldValue = it },
placeholder = { Text("Shelf Name") },
singleLine = true,
modifier = Modifier.focusRequester(focusRequester)
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(textFieldValue.text) },
enabled = textFieldValue.text.isNotBlank() && textFieldValue.text != initialName
) {
Text("Rename")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
}
}
@Composable
private fun DeleteShelfConfirmationDialog(
shelfName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete Shelf?") },
text = { Text("Are you sure you want to delete the '$shelfName' shelf? All books will be moved to Unshelved.") },
confirmButton = {
TextButton(onClick = onConfirm) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
private fun RemoveFromShelfConfirmationDialog(
count: Int,
shelfName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val bookStr = if (count == 1) "book" else "books"
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Remove from Shelf?") },
text = { Text("Are you sure you want to remove $count $bookStr from the '$shelfName' shelf? The book(s) will remain in your library and appear under Unshelved.") },
confirmButton = {
TextButton(onClick = onConfirm) { Text("Remove") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
private fun DeleteShelvesConfirmationDialog(
count: Int,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val shelfStr = if (count == 1) "shelf" else "shelves"
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete $shelfStr?") },
text = { Text("Are you sure you want to delete the $count selected $shelfStr? All books within will be moved to Unshelved.") },
confirmButton = {
TextButton(onClick = onConfirm) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
private fun FolderSyncScreen(
syncedFolders: List<SyncedFolder>,
allRecentFiles: List<RecentFileItem>,
onAddFolderClick: () -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
isLoading: Boolean
) {
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
Scaffold(
floatingActionButton = {
if (syncedFolders.size < 3) {
ExtendedFloatingActionButton(
text = { Text("Add Folder") },
icon = { Icon(Icons.Default.Add, "Add") },
onClick = onAddFolderClick
)
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (syncedFolders.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
FilledTonalButton(
onClick = onScanNowClick,
enabled = !isLoading,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.Search, null, modifier = Modifier.size(18.dp))
}
Spacer(modifier = Modifier.width(8.dp))
Text(if (isLoading) "Scanning..." else "Scan All")
}
androidx.compose.material3.OutlinedButton(
onClick = onSyncMetadataClick,
enabled = !isLoading,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
Icon(painterResource(id = R.drawable.sync), null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Sync Meta")
}
}
} else {
EmptyState(
title = "Sync Local Folders",
message = "Connect local folders to create a live library. Episteme will monitor files and sync progress.",
onSelectFileClick = onAddFolderClick,
primaryButtonText = "Select Folder",
modifier = Modifier.fillMaxSize()
)
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 80.dp)
) {
items(syncedFolders, key = { it.uriString }) { folder ->
FolderCard(
folder = folder,
allRecentFiles = allRecentFiles,
onRemoveClick = onRemoveFolderClick,
onEditFiltersClick = { editingFolder = folder }
)
}
}
}
}
if (editingFolder != null) {
EditFolderFiltersDialog(
folder = editingFolder!!,
onConfirm = { newFilters ->
onEditFolderFiltersClick(editingFolder!!, newFilters)
editingFolder = null
},
onDismiss = { editingFolder = null }
)
}
}
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun FolderCard(
folder: SyncedFolder,
allRecentFiles: List<RecentFileItem>,
onRemoveClick: (SyncedFolder) -> Unit,
onEditFiltersClick: (SyncedFolder) -> Unit
) {
var showMenu by remember { mutableStateOf(false) }
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
val lastScanText = if (folder.lastScanTime == 0L) "Never" else dateFormat.format(Date(folder.lastScanTime))
val folderFiles = remember(allRecentFiles, folder.uriString) {
allRecentFiles.filter { it.sourceFolderUri == folder.uriString }
}
val totalBooks = folderFiles.size
val countsByType = remember(folderFiles) {
folderFiles.groupBy { it.type }.mapValues { it.value.size }
}
androidx.compose.material3.ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Icon(
imageVector = Icons.Default.FolderSpecial,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = folder.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(Icons.Default.MoreVert, "Options")
}
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
DropdownMenuItem(
text = { Text("Edit Filters") },
onClick = {
showMenu = false
onEditFiltersClick(folder)
}
)
DropdownMenuItem(
text = { Text("Remove Folder") },
onClick = {
showMenu = false
onRemoveClick(folder)
},
colors = androidx.compose.material3.MenuDefaults.itemColors(
textColor = MaterialTheme.colorScheme.error
)
)
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "LAST SYNC",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
}
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
Text(
text = "BOOKS",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Text(text = totalBooks.toString(), style = MaterialTheme.typography.bodyMedium)
}
}
if (countsByType.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
countsByType.forEach { (type, count) ->
AssistChip(
onClick = { },
label = { Text("${type.name}: $count") }
)
}
}
}
}
}
}
@Composable
private fun EditFolderFiltersDialog(
folder: SyncedFolder,
onConfirm: (Set<FileType>) -> Unit,
onDismiss: () -> Unit
) {
var selectedTypes by remember { mutableStateOf(folder.allowedFileTypes) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Filter File Types") },
text = {
Column {
Text(
text = "Select the file types you want to sync from this folder:",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(8.dp))
FileType.entries.forEach { type ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
selectedTypes = if (type in selectedTypes) selectedTypes - type else selectedTypes + type
}
.padding(vertical = 4.dp)
) {
androidx.compose.material3.Checkbox(
checked = type in selectedTypes,
onCheckedChange = { checked ->
selectedTypes = if (checked) selectedTypes + type else selectedTypes - type
}
)
Spacer(modifier = Modifier.width(8.dp))
Text(type.name)
}
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(selectedTypes) },
enabled = selectedTypes.isNotEmpty()
) { Text("Save") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryFilterSheet(
filters: LibraryFilters,
syncedFolders: List<SyncedFolder>,
onApply: (LibraryFilters) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var currentFilters by remember { mutableStateOf(filters) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Filter Library", style = MaterialTheme.typography.titleLarge)
Text("File Type", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FileType.entries.forEach { type ->
FilterChip(
selected = type in currentFilters.fileTypes,
onClick = {
val newSet = if (type in currentFilters.fileTypes) currentFilters.fileTypes - type else currentFilters.fileTypes + type
currentFilters = currentFilters.copy(fileTypes = newSet)
},
label = { Text(type.name) }
)
}
}
if (syncedFolders.isNotEmpty()) {
Text("Source Folder", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
syncedFolders.forEach { folder ->
FilterChip(
selected = folder.uriString in currentFilters.sourceFolders,
onClick = {
val newSet = if (folder.uriString in currentFilters.sourceFolders) currentFilters.sourceFolders - folder.uriString else currentFilters.sourceFolders + folder.uriString
currentFilters = currentFilters.copy(sourceFolders = newSet)
},
label = { Text(folder.name) }
)
}
}
}
Text("Read Status", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ReadStatusFilter.entries.forEach { status ->
FilterChip(
selected = currentFilters.readStatus == status,
onClick = { currentFilters = currentFilters.copy(readStatus = status) },
label = { Text(status.displayName) }
)
}
}
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { currentFilters = LibraryFilters() }) {
Text("Clear All")
}
Spacer(modifier = Modifier.width(8.dp))
androidx.compose.material3.Button(onClick = { onApply(currentFilters); onDismiss() }) {
Text("Apply")
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@Composable
fun OpdsTab(
localLibraryFiles: List<RecentFileItem>,
onBookDownloaded: (Uri, String) -> Unit,
onReadBook: (RecentFileItem) -> Unit,
onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
opdsViewModel: OpdsViewModel = viewModel()
) {
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
val downloadingState by opdsViewModel.downloadingState.collectAsStateWithLifecycle()
val downloadingEntries by opdsViewModel.downloadingEntries.collectAsStateWithLifecycle()
val context = LocalContext.current
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
var showCatalogDialog by remember { mutableStateOf(false) }
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
var catalogToDelete by remember { mutableStateOf<OpdsCatalog?>(null) }
BackHandler(enabled = uiState.isViewingCatalog) {
opdsViewModel.navigateBack()
}
Box(modifier = Modifier.fillMaxSize()) {
if (!uiState.isViewingCatalog) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 88.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(uiState.catalogs, key = { it.id }) { catalog ->
OpdsCatalogCard(
catalog = catalog,
onClick = { opdsViewModel.openCatalog(catalog) },
onEdit = if (catalog.isDefault) null else {
{
editingCatalog = catalog
showCatalogDialog = true
}
},
onDelete = if (catalog.isDefault) null else {
{ catalogToDelete = catalog }
})
}
}
ExtendedFloatingActionButton(
text = { Text("Add Catalog") },
icon = { Icon(Icons.Default.Add, "Add") },
onClick = {
editingCatalog = null
showCatalogDialog = true
},
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
)
}
} else {
// Screen 2: Viewing a specific feed/catalog
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Surface(
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
var showSearch by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
val searchFocusRequester = remember { FocusRequester() }
LaunchedEffect(showSearch) {
if (showSearch) {
delay(100)
searchFocusRequester.requestFocus()
}
}
Box(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().height(64.dp)
.padding(horizontal = 4.dp)
) {
IconButton(onClick = {
if (showSearch) {
showSearch = false
query = ""
} else {
opdsViewModel.navigateBack()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
if (showSearch) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search catalog...") },
modifier = Modifier.weight(1f).padding(vertical = 4.dp)
.focusRequester(searchFocusRequester),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
trailingIcon = {
IconButton(onClick = {
if (query.isNotBlank()) {
opdsViewModel.search(query)
showSearch = false
query = ""
}
}) {
Icon(Icons.Default.Search, "Search")
}
},
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Search
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onSearch = {
if (query.isNotBlank()) {
opdsViewModel.search(query)
showSearch = false
query = ""
}
})
)
} else {
Text(
text = uiState.currentFeed?.title ?: "Loading...",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
)
if (uiState.searchUrlTemplate != null) {
IconButton(onClick = { showSearch = true }) {
Icon(Icons.Default.Search, "Search")
}
}
}
}
if (uiState.isLoading) {
androidx.compose.material3.LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter)
)
}
}
}
if (uiState.currentFeed?.entries?.isEmpty() == true && !uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("This feed is empty.")
}
} else {
val facets = uiState.currentFeed?.facets ?: emptyList()
if (facets.isNotEmpty()) {
val groups = facets.groupBy { it.group }
LazyRow(
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
groups.forEach { (groupName, groupFacets) ->
item(key = groupName) {
var expanded by remember { mutableStateOf(false) }
val activeFacet = groupFacets.find { it.isActive }
?: groupFacets.firstOrNull()
Box {
FilterChip(
selected = activeFacet?.isActive == true,
onClick = { expanded = true },
label = { Text("${groupName}: ${activeFacet?.title ?: "Select"}") },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
null
)
})
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }) {
groupFacets.forEach { facet ->
DropdownMenuItem(
text = { Text(facet.title) },
onClick = {
expanded = false
opdsViewModel.openFeedUrl(facet.url)
},
trailingIcon = if (facet.isActive) {
{ Icon(Icons.Default.Check, null) }
} else null)
}
}
}
}
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val entries = uiState.currentFeed?.entries ?: emptyList()
itemsIndexed(
entries,
key = { index, item -> "${item.id}_$index" }) { index, entry ->
if (index == entries.lastIndex) {
LaunchedEffect(index) { opdsViewModel.loadNextPage() }
}
if (entry.isNavigation) {
OpdsNavigationCard(entry) { opdsViewModel.openFeedUrl(it) }
} else {
OpdsBookCard(
entry = entry,
localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[entry.id],
onDownloadClick = { acquisition ->
opdsViewModel.downloadBook(
entry, acquisition, context
) { downloadedUri ->
onBookDownloaded(downloadedUri, entry.title)
}
},
onReadClick = onReadBook,
onStreamClick = {
onStreamBook(
entry,
uiState.currentCatalog
)
},
onClick = { selectedEntry = entry })
}
}
}
}
}
}
}
// Error Banner overlay
uiState.errorMessage?.let { error ->
LaunchedEffect(error) {
delay(4000)
opdsViewModel.clearError()
}
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp)
.padding(bottom = 70.dp)
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp)
)
}
}
if (selectedEntry != null) {
OpdsBookDetailsSheet(
entry = selectedEntry!!,
localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[selectedEntry!!.id],
onDownloadFormat = { acquisition ->
opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
onBookDownloaded(downloadedUri, selectedEntry!!.title)
}
},
onReadClick = onReadBook,
onStreamClick = { selectedEntry?.let { onStreamBook(it, uiState.currentCatalog) } },
onAuthorOrCategoryClick = { url, fallbackName ->
if (url != null) opdsViewModel.openFeedUrl(url)
else opdsViewModel.search(fallbackName)
selectedEntry = null
},
onDismiss = { selectedEntry = null }
)
}
}
// Dynamic Add/Edit Dialog
if (showCatalogDialog) {
var newTitle by remember(editingCatalog) { mutableStateOf(editingCatalog?.title ?: "") }
var newUrl by remember(editingCatalog) { mutableStateOf(editingCatalog?.url ?: "") }
var newUsername by remember(editingCatalog) { mutableStateOf(editingCatalog?.username ?: "") }
var newPassword by remember(editingCatalog) { mutableStateOf(editingCatalog?.password ?: "") }
val isEditMode = editingCatalog != null
AlertDialog(
onDismissRequest = {
showCatalogDialog = false
editingCatalog = null
},
title = { Text(if (isEditMode) "Edit Catalog" else "Add OPDS Catalog") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
label = { Text("Catalog Name") },
singleLine = true
)
OutlinedTextField(
value = newUrl,
onValueChange = { newUrl = it },
label = { Text("URL") },
placeholder = { Text("e.g. http://192.168.1.50:8080/opds") },
singleLine = true
)
Text(
"Authentication (Optional)",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp)
)
OutlinedTextField(
value = newUsername,
onValueChange = { newUsername = it },
label = { Text("Username") },
singleLine = true
)
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = { Text("Password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password)
)
}
},
confirmButton = {
TextButton(
onClick = {
if (isEditMode) {
opdsViewModel.updateCatalog(editingCatalog!!.id, newTitle, newUrl, newUsername, newPassword)
} else {
opdsViewModel.addCatalog(newTitle, newUrl, newUsername, newPassword)
}
showCatalogDialog = false
editingCatalog = null
},
enabled = newTitle.isNotBlank() && newUrl.isNotBlank()
) { Text("Save") }
},
dismissButton = {
TextButton(onClick = {
showCatalogDialog = false
editingCatalog = null
}) { Text("Cancel") }
}
)
}
if (catalogToDelete != null) {
val streamedBooksCount = localLibraryFiles.count { it.uriString?.contains("catalogId=${catalogToDelete!!.id}") == true }
AlertDialog(
onDismissRequest = { catalogToDelete = null },
title = { Text("Delete Catalog") },
text = {
Column {
Text("Are you sure you want to delete '${catalogToDelete!!.title}'?")
if (streamedBooksCount > 0) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Deleting this catalog will also permanently remove $streamedBooksCount streaming books associated with it from your library.",
color = MaterialTheme.colorScheme.error
)
}
}
},
confirmButton = {
TextButton(
onClick = {
opdsViewModel.removeCatalog(catalogToDelete!!.id)
if (streamedBooksCount > 0) {
onDeleteCatalogStreams(catalogToDelete!!.id)
}
catalogToDelete = null
},
colors = androidx.compose.material3.ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { catalogToDelete = null }) { Text("Cancel") }
}
)
}
}
@Composable
fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp)
) {
Icon(Icons.Default.FolderSpecial, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(catalog.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (catalog.isDefault) {
Spacer(modifier = Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = "Preset",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
Text(catalog.url, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (onEdit != null) {
IconButton(onClick = onEdit) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
}
if (onDelete != null) {
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Remove")
}
}
}
}
}
@Composable
fun OpdsNavigationCard(entry: OpdsEntry, onClick: (String) -> Unit) {
Surface(
onClick = { entry.navigationUrl?.let { onClick(it) } },
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp)
) {
Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(entry.title, style = MaterialTheme.typography.titleMedium)
entry.summary?.let {
val cleanSummary = remember(it) { Jsoup.parse(it).text() }
Text(cleanSummary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
@Composable
fun OpdsBookCard(
entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>,
downloadState: OpdsViewModel.DownloadState?,
onDownloadClick: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit,
onClick: () -> Unit
) {
val libraryItem = remember(entry, localLibraryFiles) {
localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
}
val isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress
val uniqueAcquisitions = remember(entry.acquisitions) {
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
}
var showFormatMenu by remember { mutableStateOf(false) }
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.padding(12.dp)) {
AsyncImage(
model = entry.coverUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 70.dp, height = 100.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceVariant)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
entry.author?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
entry.summary?.let {
val cleanSummary = remember(it) { Jsoup.parse(it).text() }
Text(cleanSummary, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
}
Spacer(modifier = Modifier.height(8.dp))
if (libraryItem != null) {
androidx.compose.material3.OutlinedButton(
onClick = { onReadClick(libraryItem) },
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Read")
}
} else if (isDownloading) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Downloading...", style = MaterialTheme.typography.labelMedium)
Spacer(modifier = Modifier.weight(1f))
if (progress != null) {
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
}
}
Spacer(modifier = Modifier.height(4.dp))
if (progress != null) {
androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
} else {
androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (entry.isStreamable) {
FilledTonalButton(
onClick = onStreamClick,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Stream")
}
}
Box {
FilledTonalButton(
onClick = {
if (uniqueAcquisitions.size == 1) {
onDownloadClick(uniqueAcquisitions.first())
} else if (uniqueAcquisitions.size > 1) {
showFormatMenu = true
}
},
enabled = uniqueAcquisitions.isNotEmpty(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
if (uniqueAcquisitions.isEmpty()) {
Icon(Icons.Default.Info, null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Unavailable")
} else {
Icon(Icons.Default.Add, null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Download")
}
}
}
DropdownMenu(
expanded = showFormatMenu,
onDismissRequest = { showFormatMenu = false }
) {
uniqueAcquisitions.forEach { acq ->
DropdownMenuItem(
text = { Text(acq.formatName) },
onClick = {
showFormatMenu = false
onDownloadClick(acq)
}
)
}
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OpdsBookDetailsSheet(
entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>,
downloadState: OpdsViewModel.DownloadState?,
onDownloadFormat: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit,
onAuthorOrCategoryClick: (String?, String) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val libraryItem = remember(entry, localLibraryFiles) {
localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
}
val isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress
val uniqueAcquisitions = remember(entry.acquisitions) {
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
AsyncImage(
model = entry.coverUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 110.dp, height = 160.dp)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant)
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
lineHeight = 28.sp
)
if (entry.authors.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
entry.authors.forEach { author ->
Text(
text = author.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable {
onAuthorOrCategoryClick(author.url, author.name)
}
)
}
}
}
entry.series?.takeIf { it.isNotBlank() }?.let { series ->
Spacer(modifier = Modifier.height(8.dp))
val seriesText = if (!entry.seriesIndex.isNullOrBlank()) "$series #${entry.seriesIndex}" else series
Text(
text = seriesText,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.clickable {
onAuthorOrCategoryClick(null, series)
}
)
}
}
}
if (libraryItem != null) {
androidx.compose.material3.Button(
onClick = {
onDismiss()
onReadClick(libraryItem)
},
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium
) {
Icon(Icons.Default.Check, contentDescription = "Read")
Spacer(modifier = Modifier.width(8.dp))
Text("Read", fontWeight = FontWeight.Bold)
}
}
if (isDownloading) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Downloading...", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.weight(1f))
if (progress != null) {
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.titleMedium)
}
}
Spacer(modifier = Modifier.height(8.dp))
if (progress != null) {
androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth().height(8.dp))
} else {
androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(8.dp))
}
}
} else if (uniqueAcquisitions.isNotEmpty() || entry.isStreamable) {
if (entry.isStreamable) {
androidx.compose.material3.Button(
onClick = {
onStreamClick()
onDismiss()
},
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium
) {
Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Stream Now", fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(16.dp))
}
if (uniqueAcquisitions.isNotEmpty()) {
Text(
"Download Format",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
uniqueAcquisitions.forEach { acq ->
FilledTonalButton(onClick = { onDownloadFormat(acq) }) {
Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(acq.formatName, fontWeight = FontWeight.Bold)
}
}
}
}
} else {
Text("No supported formats available.", color = MaterialTheme.colorScheme.error)
}
if (entry.categories.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
entry.categories.distinct().forEach { category ->
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
onClick = { onAuthorOrCategoryClick(null, category) }
) {
Text(
text = category,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
}
}
}
val hasSecondaryMeta = !entry.publisher.isNullOrBlank() || !entry.published.isNullOrBlank() || !entry.language.isNullOrBlank()
if (hasSecondaryMeta) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
entry.publisher?.takeIf { it.isNotBlank() }?.let {
Column(modifier = Modifier.weight(1f)) {
Text("PUBLISHER", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
entry.published?.takeIf { it.isNotBlank() }?.let {
Column(modifier = Modifier.weight(1f)) {
Text("PUBLISHED", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
val cleanDate = it.substringBefore("T")
Text(cleanDate, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
entry.language?.takeIf { it.isNotBlank() }?.let {
Column(modifier = Modifier.weight(1f)) {
Text("LANGUAGE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(it.uppercase(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
}
}
}
if (!entry.summary.isNullOrBlank()) {
Text("Synopsis", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
val cleanSummary = remember(entry.summary) {
val preProcessed = entry.summary
.replace("<br>", "\n")
.replace("</p>", "\n\n")
Jsoup.parse(preProcessed).text().trim()
}
Text(
text = cleanSummary,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
lineHeight = 24.sp,
modifier = Modifier.padding(bottom = 48.dp)
)
} else {
Spacer(modifier = Modifier.height(48.dp))
}
}
}
}