* add a UI trigger for demo annotations * Implement internal link navigation * Simplify bottom padding logic in `EpubReaderScreen` for vertical scroll mode * Add support for Calibre metadata * Implement advanced library management with Room-backed collections, tags, and smart rules. * Display book series information in book details and remove tags from home screen * Implement zoom reset functionality in PDF viewer * Add a "Pages" thumbnail view to the PDF navigation drawer * Implement jump-back navigation in PDF viewer * MainViewModel: disable FolderSyncWorker, add log export, and enhance PDF position logging * Replace Gson with Kotlin Serialization in SmartCollectionEngine * Implement ONNX-based speech bubble detection * Implement speech bubble detection in PDF viewer * Implement "Smart Comic Zoom" feature for PDF manga/comic reading * Implement reader session persistence and restoration in `MainViewModel` * Add tap-to-turn page feature to PDF viewer * Improve book cache management and recovery * Refactor top overlay padding logic in `PdfViewerScreen` * Implement stylus eraser support in PDF reader * Refactor ReaderTextFormatPanel to use ModalBottomSheet and add new formatting controls * Introduce a customizable horizontal margin setting for the EPUB reader * Refine folder sync and metadata handling for better conflict resolution and stability. * Introduce user-adjustable image scaling for the EPUB reader * Use maxWidthPx as default width fallback for blocks * Improve cross-page text selection and header styling in the paginated reader * Refactor speech bubble detection and UI to support segmentation masks and interactive scaling * Improve speech bubble detection masking and rendering quality * Update ONNX speech bubble detector to use `.ort` model and optimize inference * Improve pagination accuracy * Implement hierarchical folder navigation for the library. * Refactor library item layout for improved space efficiency * Add support for browsing and downloading Google Fonts * Persist library landing state and add shelf search functionality * Fix base tts sample. * Move SpeechBubbleDetector to main source set and update ONNX dependency * Implement a "locate" feature and improve synchronization for TTS (Text-to-Speech) playback across EPUB and PDF readers. * Refine TTS synchronization and voice management in the EPUB reader * Adjust OCR checks for OSS flavor and optimize external dictionary intent flags * Add "Locate" button to PDF drawer's pages tab and move the page number to bottom right of thumbnail * fix various crashes * Refactor SpeechBubbleDetector into product flavors * Implement speech bubble detection caching and background prefetching * Implement on-demand download for Bubble Zoom ML model * Add smooth animation for PDF speech bubble expansion * fixes #252 * hide Google Fonts option, in FontsScreen, for offline variant * Bump version to 1.0.46(46)
283 lines
No EOL
10 KiB
Kotlin
283 lines
No EOL
10 KiB
Kotlin
package com.aryan.reader.pdf
|
|
|
|
import android.graphics.Bitmap
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
import androidx.compose.animation.core.animateFloatAsState
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
import androidx.compose.foundation.BorderStroke
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.gestures.Orientation
|
|
import androidx.compose.foundation.gestures.draggable
|
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxHeight
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
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.width
|
|
import androidx.compose.foundation.lazy.LazyListState
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.derivedStateOf
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.alpha
|
|
import androidx.compose.ui.draw.rotate
|
|
import androidx.compose.ui.graphics.asImageBitmap
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
import androidx.compose.ui.layout.ContentScale
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.res.painterResource
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.RectangleShape
|
|
import androidx.compose.ui.unit.dp
|
|
import com.aryan.reader.R
|
|
|
|
private data class ScrollbarCalculations(
|
|
val thumbHeight: Float,
|
|
val thumbOffset: Float,
|
|
val contentHeight: Float,
|
|
val viewportHeight: Float
|
|
)
|
|
|
|
@Composable
|
|
fun VerticalScrollbar(
|
|
listState: LazyListState,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val interactionSource = remember { MutableInteractionSource() }
|
|
val isDragged by interactionSource.collectIsDraggedAsState()
|
|
|
|
val scrollbarState by remember {
|
|
derivedStateOf {
|
|
val layoutInfo = listState.layoutInfo
|
|
val totalItems = layoutInfo.totalItemsCount
|
|
val visibleItemsInfo = layoutInfo.visibleItemsInfo
|
|
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
|
|
|
|
if (totalItems == 0 || visibleItemsInfo.isEmpty() || viewportHeight <= 0f) {
|
|
return@derivedStateOf null
|
|
}
|
|
|
|
// Estimate total height
|
|
val averageItemHeight = visibleItemsInfo.sumOf { it.size } / visibleItemsInfo.size.toFloat()
|
|
val estimatedContentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
|
|
val viewportRatio = viewportHeight / estimatedContentHeight
|
|
|
|
if (viewportRatio >= 1f) return@derivedStateOf null
|
|
|
|
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
|
|
|
|
val firstItemIndex = listState.firstVisibleItemIndex
|
|
val firstItemOffset = listState.firstVisibleItemScrollOffset
|
|
val currentScrollPixels = (firstItemIndex * averageItemHeight) + firstItemOffset
|
|
val maxScrollPixels = estimatedContentHeight - viewportHeight
|
|
val scrollProgress = (currentScrollPixels / maxScrollPixels).coerceIn(0f, 1f)
|
|
val trackHeight = viewportHeight - thumbHeight
|
|
val thumbOffset = trackHeight * scrollProgress
|
|
|
|
ScrollbarCalculations(
|
|
thumbHeight = thumbHeight,
|
|
thumbOffset = thumbOffset,
|
|
contentHeight = estimatedContentHeight,
|
|
viewportHeight = viewportHeight
|
|
)
|
|
}
|
|
}
|
|
|
|
val targetAlpha = if (listState.isScrollInProgress || isDragged) 1f else 0f
|
|
val alpha by animateFloatAsState(
|
|
targetValue = targetAlpha,
|
|
animationSpec = tween(durationMillis = 200),
|
|
label = "ScrollbarAlpha"
|
|
)
|
|
|
|
if (scrollbarState != null) {
|
|
val state = scrollbarState!!
|
|
val draggableState = rememberDraggableState { delta ->
|
|
val trackHeight = state.viewportHeight - state.thumbHeight
|
|
if (trackHeight > 0) {
|
|
val scrollRatio = delta / trackHeight
|
|
val totalScrollableDistance = state.contentHeight - state.viewportHeight
|
|
val scrollDelta = scrollRatio * totalScrollableDistance
|
|
listState.dispatchRawDelta(scrollDelta)
|
|
}
|
|
}
|
|
|
|
Box(
|
|
modifier = modifier
|
|
.width(30.dp)
|
|
.fillMaxHeight()
|
|
.draggable(
|
|
state = draggableState,
|
|
orientation = Orientation.Vertical,
|
|
interactionSource = interactionSource
|
|
)
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.align(Alignment.TopEnd)
|
|
.graphicsLayer { translationY = state.thumbOffset }
|
|
.padding(end = 4.dp)
|
|
.width(6.dp)
|
|
.height(with(LocalDensity.current) { state.thumbHeight.toDp() })
|
|
.alpha(alpha)
|
|
.background(
|
|
color = if (isDragged) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
|
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
|
shape = RoundedCornerShape(100)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.clickable(
|
|
indication = null, interactionSource = remember { MutableInteractionSource() }) {},
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Column(
|
|
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier
|
|
.background(
|
|
color = MaterialTheme.colorScheme.surface.copy(
|
|
alpha = 0.9f
|
|
), shape = RoundedCornerShape(16.dp)
|
|
)
|
|
.padding(horizontal = 24.dp, vertical = 16.dp)
|
|
) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.slider),
|
|
contentDescription = null,
|
|
modifier = Modifier.size(48.dp),
|
|
tint = MaterialTheme.colorScheme.primary
|
|
)
|
|
Spacer(Modifier.height(12.dp))
|
|
Text(
|
|
text = "Page $currentPage of $totalPages",
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
color = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
internal fun ThumbnailWithIndicator(
|
|
thumbnail: Bitmap, modifier: Modifier = Modifier, onClick: () -> Unit
|
|
) {
|
|
val borderColor = MaterialTheme.colorScheme.primary
|
|
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
|
Surface(
|
|
modifier = Modifier
|
|
.width(45.dp)
|
|
.height(64.dp)
|
|
.clickable(onClick = onClick),
|
|
shape = RoundedCornerShape(4.dp),
|
|
border = BorderStroke(2.dp, borderColor)
|
|
) {
|
|
Image(
|
|
bitmap = thumbnail.asImageBitmap(),
|
|
contentDescription = "Start page thumbnail",
|
|
contentScale = ContentScale.FillBounds,
|
|
modifier = Modifier.fillMaxSize()
|
|
)
|
|
}
|
|
Box(modifier = Modifier
|
|
.offset(y = (-4).dp)
|
|
.size(8.dp)
|
|
.rotate(45f)
|
|
.background(borderColor))
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
internal fun BookmarkButton(
|
|
isBookmarked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier
|
|
) {
|
|
Box(
|
|
modifier = modifier
|
|
.width(48.dp)
|
|
.height(48.dp)
|
|
.clip(RectangleShape)
|
|
.clickable(
|
|
interactionSource = remember { MutableInteractionSource() },
|
|
indication = null,
|
|
onClick = onClick
|
|
), contentAlignment = Alignment.TopCenter
|
|
) {
|
|
AnimatedVisibility(visible = isBookmarked, enter = fadeIn(), exit = fadeOut()) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.bookmark),
|
|
contentDescription = "Bookmark",
|
|
modifier = Modifier.size(24.dp),
|
|
tint = MaterialTheme.colorScheme.primary
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
internal fun ZoomPercentageIndicator(
|
|
percentage: Int,
|
|
onResetZoomClick: () -> Unit
|
|
) {
|
|
Surface(
|
|
shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f)
|
|
) {
|
|
androidx.compose.foundation.layout.Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
|
) {
|
|
Text(
|
|
text = "$percentage%",
|
|
color = Color.White,
|
|
style = MaterialTheme.typography.bodyLarge
|
|
)
|
|
|
|
Spacer(modifier = Modifier.width(8.dp))
|
|
|
|
// Divider
|
|
Box(
|
|
modifier = Modifier
|
|
.width(1.dp)
|
|
.height(16.dp)
|
|
.background(Color.White.copy(alpha = 0.5f))
|
|
)
|
|
|
|
Spacer(modifier = Modifier.width(8.dp))
|
|
|
|
// Reset Zoom Button
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.zoom_out),
|
|
contentDescription = "Reset Zoom",
|
|
tint = Color.White,
|
|
modifier = Modifier
|
|
.size(20.dp)
|
|
.clip(RoundedCornerShape(4.dp))
|
|
.clickable(onClick = onResetZoomClick)
|
|
)
|
|
}
|
|
}
|
|
} |