Auto scroll update (#7)

* updated auto-scroll logic for EPUB and PDF

* Refactored auto-scroll to use temporary debounced pauses instead of hard stops on manual interaction.
* Updated tap handling to allow toggling bars without interrupting scroll.
* Added a 2-second orientation pause on EPUB chapter transitions.
* Enhanced AutoScrollControls UI with circular progress overlay to indicate the "temporarily paused" state.

* Refactored auto-scroll controls

This commit refactors the auto-scroll controls for both EPUB and PDF readers, introducing a more advanced and user-friendly interface.

Key changes:
- Redesigned the auto-scroll controls UI into a collapsible and lockable card.
- Added a "lock" feature to hide controls during scrolling.
- Introduced an alternative slider-based input for speed adjustment, which can be toggled.
- Persisted the lock and input mode states in SharedPreferences.
- Improved the EPUB auto-scroll mechanism using `translate3d` for smoother sub-pixel scrolling.
This commit is contained in:
Aryan 2026-02-26 17:16:01 +05:30 committed by GitHub
parent 1f111034f1
commit de376e2ace
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 410 additions and 238 deletions

View file

@ -17,6 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
// EpubReaderControls.kt
package com.aryan.reader.epubreader
import android.annotation.SuppressLint
@ -71,12 +72,16 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@ -655,6 +660,7 @@ suspend fun captureWebViewVisibleArea(webView: WebView): Bitmap? {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AutoScrollControls(
isPlaying: Boolean,
@ -664,15 +670,23 @@ fun AutoScrollControls(
onClose: () -> Unit,
isCollapsed: Boolean,
onCollapseChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
isLocked: Boolean,
onLockToggle: () -> Unit,
useSlider: Boolean,
onInputModeToggle: () -> Unit,
modifier: Modifier = Modifier,
maxSpeed: Float = 10f,
isTempPaused: Boolean = false,
) {
Surface(
shape = RoundedCornerShape(50),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 6.dp,
tonalElevation = 8.dp,
shadowElevation = 6.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier.animateContentSize()
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
modifier = modifier
.widthIn(max = 400.dp)
.animateContentSize()
) {
AnimatedContent(
targetState = isCollapsed,
@ -681,117 +695,227 @@ fun AutoScrollControls(
},
label = "AutoScrollUnified"
) { collapsed ->
Row(
modifier = Modifier.padding(6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (collapsed) {
if (collapsed) {
// COLLAPSED STATE: Ultra-Compact Pill
Row(
modifier = Modifier
.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
// Expand Button
IconButton(
onClick = { onCollapseChange(false) },
modifier = Modifier.size(40.dp)
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.ChevronLeft,
contentDescription = "Expand",
tint = MaterialTheme.colorScheme.onSurface
)
}
FilledIconButton(
onClick = onPlayPauseToggle,
modifier = Modifier.size(40.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(24.dp)
)
}
} else {
IconButton(
onClick = onClose,
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
Box(
modifier = Modifier
.width(1.dp)
.height(24.dp)
.background(MaterialTheme.colorScheme.outlineVariant)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp)
) {
IconButton(
onClick = { onSpeedChange((speed - 0.1f).coerceAtLeast(0.1f)) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Remove, "Slower", modifier = Modifier.size(18.dp))
}
Text(
text = "%.1fx".format(speed),
style = MaterialTheme.typography.labelLarge.copy(fontFeatureSettings = "tnum"),
modifier = Modifier.widthIn(min = 40.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
IconButton(
onClick = { onSpeedChange((speed + 0.1f).coerceAtMost(10f)) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Add, "Faster", modifier = Modifier.size(18.dp))
}
}
Box(
modifier = Modifier
.width(1.dp)
.height(24.dp)
.background(MaterialTheme.colorScheme.outlineVariant)
)
FilledIconButton(
onClick = onPlayPauseToggle,
modifier = Modifier.size(40.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(24.dp)
)
}
IconButton(
onClick = { onCollapseChange(true) },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Collapse",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Play/Pause Mini
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
onClick = onPlayPauseToggle,
modifier = Modifier.size(36.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(20.dp)
)
}
if (isTempPaused) {
CircularProgressIndicator(
modifier = Modifier.size(36.dp),
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f),
strokeWidth = 2.dp
)
}
}
}
} else {
// EXPANDED STATE: Card-like Layout
Column(
modifier = Modifier.padding(16.dp)
) {
// Top Row: Utility Actions (Right aligned)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Label
Text(
text = "Auto Scroll",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
// Tools Row
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
// Lock
IconButton(
onClick = onLockToggle,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = if (isLocked) Icons.Default.LockOpen else Icons.Default.Lock,
contentDescription = if (isLocked) "Unlock" else "Lock",
modifier = Modifier.size(18.dp),
tint = if (isLocked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Swap Input
IconButton(
onClick = onInputModeToggle,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.SwapHoriz,
contentDescription = "Swap Controls",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Collapse
IconButton(
onClick = { onCollapseChange(true) },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Collapse",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Close
IconButton(
onClick = onClose,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
}
}
}
Spacer(Modifier.height(16.dp))
// Bottom Row: Primary Controls
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// 1. Play/Pause Button
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
onClick = onPlayPauseToggle,
modifier = Modifier.size(48.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(24.dp)
)
}
if (isTempPaused) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f),
strokeWidth = 3.dp
)
}
}
// 2. Speed Controls (Weight to fill space)
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
if (useSlider) {
// Slider Mode: Text + Slider
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "%.1fx".format(speed),
style = MaterialTheme.typography.titleMedium.copy(fontFeatureSettings = "tnum"),
modifier = Modifier.width(45.dp),
textAlign = TextAlign.End
)
val minSpeed = 0.1f
val steps = ((maxSpeed - minSpeed) / 0.1f).roundToInt() - 1
Slider(
value = speed,
onValueChange = { onSpeedChange((it * 10f).roundToInt() / 10f) },
valueRange = minSpeed..maxSpeed,
steps = if (steps > 0) steps else 0,
modifier = Modifier.weight(1f),
thumb = {
Surface(
modifier = Modifier.size(20.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
shadowElevation = 2.dp
) {}
}
)
}
} else {
// Stepper Mode: Segmented Pill
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.height(48.dp).fillMaxWidth()
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(
onClick = { onSpeedChange((speed - 0.1f).coerceAtLeast(0.1f)) },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Default.Remove, "Slower")
}
Text(
text = "%.1fx".format(speed),
style = MaterialTheme.typography.titleMedium.copy(fontFeatureSettings = "tnum"),
textAlign = TextAlign.Center
)
IconButton(
onClick = { onSpeedChange((speed + 0.1f).coerceAtMost(10f)) },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Default.Add, "Faster")
}
}
}
}
}
}
}
}
}

View file

@ -17,6 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
// EpubReaderScreen.kt
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead")
package com.aryan.reader.epubreader
@ -171,6 +172,30 @@ import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import androidx.compose.ui.BiasAlignment
import androidx.core.content.edit
private const val AUTO_SCROLL_LOCKED_KEY = "auto_scroll_locked"
private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
private fun saveAutoScrollLocked(context: Context, isLocked: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(AUTO_SCROLL_LOCKED_KEY, isLocked)}
}
private fun loadAutoScrollLocked(context: Context): Boolean {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(AUTO_SCROLL_LOCKED_KEY, false)
}
private fun saveAutoScrollUseSlider(context: Context, useSlider: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(AUTO_SCROLL_USE_SLIDER_KEY, useSlider) }
}
private fun loadAutoScrollUseSlider(context: Context): Boolean {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(AUTO_SCROLL_USE_SLIDER_KEY, false)
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
@ -607,9 +632,14 @@ fun EpubReaderHost(
var isAutoScrollModeActive by remember { mutableStateOf(false) }
var isAutoScrollPlaying by remember { mutableStateOf(false) }
var isAutoScrollTempPaused by remember { mutableStateOf(false) }
val autoScrollResumeJob = remember { mutableStateOf<Job?>(null) }
var autoScrollSpeed by remember { mutableFloatStateOf(loadAutoScrollSpeed(context)) }
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isAutoScrollLocked by remember { mutableStateOf(loadAutoScrollLocked(context)) }
var autoScrollUseSlider by remember { mutableStateOf(loadAutoScrollUseSlider(context)) }
DisposableEffect(Unit) {
onDispose {
Timber.d("Disposing sample MediaPlayer.")
@ -618,10 +648,28 @@ fun EpubReaderHost(
}
fun updateAutoScrollState(playing: Boolean, speed: Float) {
updateAutoScrollJs(webViewRefForTts, playing, speed)
val effectivePlaying = playing && !isAutoScrollTempPaused
updateAutoScrollJs(webViewRefForTts, effectivePlaying, speed)
}
LaunchedEffect(isAutoScrollModeActive, isAutoScrollPlaying, autoScrollSpeed) {
fun triggerAutoScrollTempPause(durationMs: Long) {
if (!isAutoScrollModeActive || !isAutoScrollPlaying) return
autoScrollResumeJob.value?.cancel()
isAutoScrollTempPaused = true
updateAutoScrollState(isAutoScrollPlaying, autoScrollSpeed)
autoScrollResumeJob.value = scope.launch {
delay(durationMs)
if (isActive && isAutoScrollModeActive && isAutoScrollPlaying) {
isAutoScrollTempPaused = false
@Suppress("KotlinConstantConditions") updateAutoScrollState(isAutoScrollPlaying, autoScrollSpeed)
}
}
}
LaunchedEffect(isAutoScrollModeActive, isAutoScrollPlaying, autoScrollSpeed, isAutoScrollTempPaused) {
if (isAutoScrollModeActive) {
updateAutoScrollState(isAutoScrollPlaying, autoScrollSpeed)
} else {
@ -629,12 +677,6 @@ fun EpubReaderHost(
}
}
fun pauseAutoScroll() {
if (isAutoScrollModeActive && isAutoScrollPlaying) {
isAutoScrollPlaying = false
}
}
fun startTts() {
if (isAutoScrollModeActive) {
isAutoScrollModeActive = false
@ -1615,11 +1657,8 @@ fun EpubReaderHost(
}
if (isAutoScrollModeActive && isAutoScrollPlaying) {
Timber.d("Continuing Auto-Scroll for new chapter.")
scope.launch {
delay(300)
updateAutoScrollState(true, autoScrollSpeed)
}
Timber.d("Continuing Auto-Scroll for new chapter with delay.")
triggerAutoScrollTempPause(1000L)
}
},
onTap = {
@ -1637,7 +1676,6 @@ fun EpubReaderHost(
Timber.d("Chapter tapped, showing main bars.")
}
}
pauseAutoScroll()
},
onPotentialScroll = {
if (showBars) {
@ -1645,7 +1683,9 @@ fun EpubReaderHost(
showFormatAdjustmentBars = false
Timber.d("Scroll/Drag detected, hiding bars.")
}
pauseAutoScroll()
if (isAutoScrollModeActive && isAutoScrollPlaying) {
triggerAutoScrollTempPause(1000L)
}
},
onAutoScrollChapterEnd = {
Timber.d("Screen: onAutoScrollChapterEnd triggered. Current Index: $currentChapterIndex")
@ -2620,6 +2660,7 @@ fun EpubReaderHost(
isAutoScrollModeActive = true
isAutoScrollPlaying = true
showBars = false
showBars = true
},
searchFocusRequester = searchFocusRequester,
modifier = Modifier.align(Alignment.TopCenter)
@ -2635,8 +2676,10 @@ fun EpubReaderHost(
label = "AutoScrollAlignAnimation"
)
val isAutoScrollControlsVisible = isAutoScrollModeActive && (!isAutoScrollLocked || showBars)
AnimatedVisibility(
visible = isAutoScrollModeActive,
visible = isAutoScrollControlsVisible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = Modifier
@ -2646,8 +2689,19 @@ fun EpubReaderHost(
) {
AutoScrollControls(
isPlaying = isAutoScrollPlaying,
onPlayPauseToggle = { isAutoScrollPlaying = !isAutoScrollPlaying },
isTempPaused = isAutoScrollTempPaused,
onPlayPauseToggle = {
if (isAutoScrollPlaying) {
isAutoScrollPlaying = false
isAutoScrollTempPaused = false
autoScrollResumeJob.value?.cancel()
} else {
isAutoScrollPlaying = true
isAutoScrollTempPaused = false
}
},
speed = autoScrollSpeed,
maxSpeed = 10f,
onSpeedChange = {
autoScrollSpeed = it
saveAutoScrollSpeed(context, it)
@ -2658,7 +2712,17 @@ fun EpubReaderHost(
showBars = true
},
isCollapsed = isAutoScrollCollapsed,
onCollapseChange = { isAutoScrollCollapsed = it }
onCollapseChange = { isAutoScrollCollapsed = it },
isLocked = isAutoScrollLocked,
onLockToggle = {
isAutoScrollLocked = !isAutoScrollLocked
saveAutoScrollLocked(context, isAutoScrollLocked)
},
useSlider = autoScrollUseSlider,
onInputModeToggle = {
autoScrollUseSlider = !autoScrollUseSlider
saveAutoScrollUseSlider(context, autoScrollUseSlider)
}
)
}