v1.0.45-oss (#221)

* Implemented ML-based comic panel detection and a panel popup viewer.

* Optimized `ComicPanelDetector` for performance and memory efficiency.

* Optimized comic panel detection performance by introducing a dedicated single-thread dispatcher for ML tasks, replacing mutex-based synchronization.

* Refactored system UI handling and layout padding in `PdfViewerScreen`.

* Refactor `PdfViewerScreen.kt` by extracting components and logic into specialized files.

* replace hardcoded padding with dynamic header height and adjust IME layout logic

* Improve `PdfTextBox` interaction and visual consistency during zoom and pan.

* improve PDF text box dragging and scaling behavior across zoom levels

* optimize color scheme calculation using remember and expand text dimming coverage

* Implement in-app language selection and per-app language preferences.

* Improve PDF lock stability in `PdfVerticalReader`

* Implement "Preserve Image Colors" option for PDF themes

* Optimize TOC locate in ReaderDrawer

* Update library and home screen UI components

* smoother page turn animation in epub pagination mode

* Implement automatic page skipping for TTS when no text is found in PDF viewer

* Refine status bar handling and window insets across main screens

* Optimize ViewModel initialization and integrate AndroidX SplashScreen

* Move ComicPanelDetector to debug source set and introduce IPanelDetector interface

* Optimize TOC scrolling in PdfNavigationDrawerContent

* Bump version to 1.0.45(45)
This commit is contained in:
Aryan 2026-04-22 14:11:52 +05:30 committed by GitHub
parent 71e3614ad6
commit cb7de86224
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 4795 additions and 3447 deletions

View file

@ -21,7 +21,8 @@ package com.aryan.reader.pdf
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@ -52,7 +53,12 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
@ -78,6 +84,49 @@ enum class HandlePosition {
TOP, BOTTOM, AUTO
}
// Eagerly consumes pointer events so parent scaled pan/zoom gestures don't intercept it
suspend fun PointerInputScope.detectEagerDragGestures(
onDragStart: (Offset) -> Unit,
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
onDrag: (PointerInputChange, Offset) -> Unit
) {
awaitEachGesture {
var dragStarted = false
try {
val down = awaitFirstDown(requireUnconsumed = false)
down.consume() // Consume immediately
onDragStart(down.position)
dragStarted = true
val pointerId = down.id
var canceled = false
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == pointerId }
if (change == null) {
canceled = true
break
}
if (change.changedToUp()) {
change.consume()
break
}
if (change.positionChanged()) {
val dragAmount = change.position - change.previousPosition
change.consume()
onDrag(change, dragAmount)
}
}
if (canceled) onDragCancel() else onDragEnd()
dragStarted = false
} finally {
if (dragStarted) {
onDragCancel()
}
}
}
}
@Composable
fun ResizableTextBox(
box: PdfTextBox,
@ -86,6 +135,7 @@ fun ResizableTextBox(
isDarkMode: Boolean,
pageWidthPx: Float,
pageHeightPx: Float,
scale: Float = 1f,
onBoundsChanged: (Rect) -> Unit,
onTextChanged: (String) -> Unit,
onSelect: () -> Unit,
@ -101,8 +151,9 @@ fun ResizableTextBox(
val density = LocalDensity.current
val focusRequester = remember { FocusRequester() }
val handleSize = 10.dp
val handleTouchSize = 40.dp
// Counter-scale fixed sizes so they render proportionally regardless of the zoom level
val handleSize = (10f / scale).dp
val handleTouchSize = (40f / scale).dp
val handleSizePx = with(density) { handleSize.toPx() }
val halfHandlePx = handleSizePx / 2f
val handleTouchSizePx = with(density) { handleTouchSize.toPx() }
@ -126,7 +177,9 @@ fun ResizableTextBox(
}
}
}
androidx.compose.runtime.SideEffect {
Timber.tag("PdfTextBoxDebug").v("ResizableTextBox Recompose [ID: ${box.id}] | isSelected=$isSelected | scale=$scale | pagePx=${pageWidthPx}x${pageHeightPx} | bounds=${box.relativeBounds}")
}
var currentRectPx by remember {
mutableStateOf(
Rect(
@ -152,6 +205,8 @@ fun ResizableTextBox(
right = box.relativeBounds.right * pageWidthPx,
bottom = box.relativeBounds.bottom * pageHeightPx
)
Timber.tag("PdfTextBoxDebug").d("LaunchedEffect bounds recalculation [ID: ${box.id}] | currentRectPx=$newPx")
if (kotlin.math.abs(newPx.left - currentRectPx.left) > 1f ||
kotlin.math.abs(newPx.top - currentRectPx.top) > 1f ||
kotlin.math.abs(newPx.width - currentRectPx.width) > 1f ||
@ -162,20 +217,19 @@ fun ResizableTextBox(
}
}
val requiredBottomSpacePx = with(density) { 60.dp.toPx() }
val requiredBottomSpacePx = with(density) { 60.dp.toPx() } / scale
val isHandleAtTop by remember(currentRectPx, pageHeightPx, handlePosition) {
derivedStateOf {
when (handlePosition) {
// Freeze handle position while dragging to prevent UI jumping
var isHandleAtTop by remember { mutableStateOf(false) }
LaunchedEffect(currentRectPx, pageHeightPx, handlePosition, requiredBottomSpacePx, isDraggingOrResizing) {
if (!isDraggingOrResizing) {
isHandleAtTop = when (handlePosition) {
HandlePosition.TOP -> true
HandlePosition.BOTTOM -> false
HandlePosition.AUTO -> {
if (pageHeightPx <= 0f) {
false
} else {
val spaceBelow = pageHeightPx - currentRectPx.bottom
spaceBelow < requiredBottomSpacePx
}
if (pageHeightPx <= 0f) false
else (pageHeightPx - currentRectPx.bottom) < requiredBottomSpacePx
}
}
}
@ -184,11 +238,9 @@ fun ResizableTextBox(
Box(
modifier = modifier
.zIndex(if (isSelected) 10f else 0f)
.offset {
IntOffset(
(currentRectPx.left - halfHandlePx).roundToInt(),
(currentRectPx.top - halfHandlePx).roundToInt()
)
.graphicsLayer {
translationX = currentRectPx.left - halfHandlePx
translationY = currentRectPx.top - halfHandlePx
}
.size(
width = with(density) { (currentRectPx.width + handleSizePx).toDp() },
@ -201,10 +253,13 @@ fun ResizableTextBox(
.fillMaxSize()
.padding(handleSize / 2)
.pointerInput(Unit) {
detectTapGestures { onSelect() }
detectTapGestures {
Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]")
onSelect()
}
}
.then(
if (isSelected) Modifier.border(1.5.dp, borderColor) else Modifier
if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier
)
) {
BasicTextField(
@ -220,7 +275,7 @@ fun ResizableTextBox(
background = box.backgroundColor,
fontFamily = fontFamily,
fontSize = with(LocalDensity.current) {
(box.fontSize * pageHeightPx).coerceAtLeast(10f).toSp()
(box.fontSize * pageHeightPx).toSp()
},
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
@ -267,8 +322,11 @@ fun ResizableTextBox(
}
.size(handleTouchSize)
.pointerInput(onBoundsChanged) {
detectDragGestures(
onDragStart = { isDraggingOrResizing = true },
detectEagerDragGestures(
onDragStart = {
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle")
isDraggingOrResizing = true
},
onDragEnd = {
isDraggingOrResizing = false
val normalized = Rect(
@ -277,40 +335,42 @@ fun ResizableTextBox(
right = currentRectPx.right / pageWidthPx,
bottom = currentRectPx.bottom / pageHeightPx
)
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized")
onBoundsChanged(normalized)
},
onDragCancel = { isDraggingOrResizing = false }
) { change, dragAmount ->
change.consume()
Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount")
var l = currentRectPx.left
var t = currentRectPx.top
var r = currentRectPx.right
var b = currentRectPx.bottom
val dx = dragAmount.x
val dy = dragAmount.y
val minSize = 50f
val minSize = 50f / scale
when (handle) {
ResizeHandle.TOP_LEFT -> {
l = (l + dx).coerceIn(0f, r - minSize)
t = (t + dy).coerceIn(0f, b - minSize)
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
}
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, b - minSize)
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
ResizeHandle.TOP_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
t = (t + dy).coerceIn(0f, b - minSize)
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
}
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, pageWidthPx)
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
ResizeHandle.BOTTOM_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
}
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, pageHeightPx)
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
ResizeHandle.BOTTOM_LEFT -> {
l = (l + dx).coerceIn(0f, r - minSize)
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
}
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, r - minSize)
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
else -> {}
}
currentRectPx = Rect(l, t, r, b)
@ -328,13 +388,15 @@ fun ResizableTextBox(
DragPill(
isDarkMode = isDarkMode,
scale = scale,
modifier = Modifier
.align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter)
.offset(y = if (isHandleAtTop) (-32).dp else 32.dp)
.offset(y = if (isHandleAtTop) (-32f / scale).dp else (32f / scale).dp)
.zIndex(20f)
.pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) {
detectDragGestures(
detectEagerDragGestures(
onDragStart = { offset ->
Timber.tag("PdfTextBoxDebug").d("DragPill DragStart [ID: ${box.id}] at offset=$offset")
isDraggingOrResizing = true
onDragStart(offset)
},
@ -346,6 +408,7 @@ fun ResizableTextBox(
right = currentRectPx.right / pageWidthPx,
bottom = currentRectPx.bottom / pageHeightPx
)
Timber.tag("PdfTextBoxDebug").d("DragPill DragEnd[ID: ${box.id}] finalNormalized=$normalized")
onBoundsChanged(normalized)
onDragEnd()
},
@ -354,13 +417,12 @@ fun ResizableTextBox(
onDragCancel()
}
) { change, dragAmount ->
change.consume()
val w = currentRectPx.width
val h = currentRectPx.height
val rawLeft = currentRectPx.left + dragAmount.x
val rawTop = currentRectPx.top + dragAmount.y
val newLeft = rawLeft.coerceIn(0f, pageWidthPx - w)
val newTop = rawTop.coerceIn(0f, pageHeightPx - h)
val newLeft = rawLeft.coerceIn(0f, maxOf(0f, pageWidthPx - w))
val newTop = rawTop.coerceIn(0f, maxOf(0f, pageHeightPx - h))
val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h)
currentRectPx = newRect
onDrag(dragAmount, newRect)
@ -374,21 +436,22 @@ fun ResizableTextBox(
@Composable
private fun DragPill(
modifier: Modifier = Modifier,
isDarkMode: Boolean
isDarkMode: Boolean,
scale: Float = 1f
) {
Surface(
modifier = modifier
.size(width = 48.dp, height = 24.dp),
.size(width = (48f / scale).dp, height = (24f / scale).dp),
shape = CircleShape,
color = if (isDarkMode) Color.White else Color.Black,
contentColor = if (isDarkMode) Color.Black else Color.White,
shadowElevation = 4.dp
shadowElevation = (4f / scale).dp
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(id = R.drawable.drag_handle),
contentDescription = "Drag to move text box",
modifier = Modifier.size(20.dp)
modifier = Modifier.size((20f / scale).dp)
)
}
}