Epub page turn animation (#23)
* Added realistic page turn animation option in epub pagination mode * Added volume key navigation support for paginated render mode and added page turn animation to "tap to change page" and "volume button to change page"
This commit is contained in:
parent
7d26e10c07
commit
1ce353ad44
6 changed files with 1203 additions and 926 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -18,3 +18,6 @@ local.properties
|
||||||
google-services.json
|
google-services.json
|
||||||
.kotlin/
|
.kotlin/
|
||||||
.idea/
|
.idea/
|
||||||
|
CLA.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
cla.yml
|
||||||
|
|
@ -25,8 +25,8 @@ android {
|
||||||
applicationId = "com.aryan.reader"
|
applicationId = "com.aryan.reader"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 34
|
versionCode = 35
|
||||||
versionName = "1.0.33"
|
versionName = "1.0.34"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
|
|
|
||||||
|
|
@ -136,12 +136,14 @@ fun EpubReaderTopBar(
|
||||||
isTtsActive: Boolean,
|
isTtsActive: Boolean,
|
||||||
tapToNavigateEnabled: Boolean,
|
tapToNavigateEnabled: Boolean,
|
||||||
volumeScrollEnabled: Boolean,
|
volumeScrollEnabled: Boolean,
|
||||||
|
isPageTurnAnimationEnabled: Boolean,
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
onCloseSearch: () -> Unit,
|
onCloseSearch: () -> Unit,
|
||||||
onChangeRenderMode: (RenderMode) -> Unit,
|
onChangeRenderMode: (RenderMode) -> Unit,
|
||||||
onToggleBookmark: () -> Unit,
|
onToggleBookmark: () -> Unit,
|
||||||
onToggleTapToNavigate: (Boolean) -> Unit,
|
onToggleTapToNavigate: (Boolean) -> Unit,
|
||||||
onToggleVolumeScroll: (Boolean) -> Unit,
|
onToggleVolumeScroll: (Boolean) -> Unit,
|
||||||
|
onTogglePageTurnAnimation: (Boolean) -> Unit,
|
||||||
onStartAutoScroll: () -> Unit,
|
onStartAutoScroll: () -> Unit,
|
||||||
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
|
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
|
|
@ -232,8 +234,13 @@ fun EpubReaderTopBar(
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Volume Button Scrolling") },
|
text = {
|
||||||
enabled = currentRenderMode == RenderMode.VERTICAL_SCROLL,
|
Text(
|
||||||
|
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) "Volume Button Scrolling"
|
||||||
|
else "Volume Button Page Turn"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = true,
|
||||||
onClick = {
|
onClick = {
|
||||||
onToggleVolumeScroll(!volumeScrollEnabled)
|
onToggleVolumeScroll(!volumeScrollEnabled)
|
||||||
showMoreMenu = false
|
showMoreMenu = false
|
||||||
|
|
@ -241,6 +248,18 @@ fun EpubReaderTopBar(
|
||||||
trailingIcon = { if (volumeScrollEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
|
trailingIcon = { if (volumeScrollEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Realistic Page Turns") },
|
||||||
|
enabled = currentRenderMode == RenderMode.PAGINATED,
|
||||||
|
onClick = {
|
||||||
|
onTogglePageTurnAnimation(!isPageTurnAnimationEnabled)
|
||||||
|
showMoreMenu = false
|
||||||
|
},
|
||||||
|
trailingIcon = { if (isPageTurnAnimationEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Auto Scroll") },
|
text = { Text("Auto Scroll") },
|
||||||
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
|
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
|
|
@ -175,6 +176,17 @@ private const val AUTO_SCROLL_LOCKED_KEY = "auto_scroll_locked"
|
||||||
private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
|
private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
|
||||||
private const val AUTO_SCROLL_MIN_SPEED_KEY = "auto_scroll_min_speed"
|
private const val AUTO_SCROLL_MIN_SPEED_KEY = "auto_scroll_min_speed"
|
||||||
private const val AUTO_SCROLL_MAX_SPEED_KEY = "auto_scroll_max_speed"
|
private const val AUTO_SCROLL_MAX_SPEED_KEY = "auto_scroll_max_speed"
|
||||||
|
private const val PAGE_TURN_ANIMATION_KEY = "page_turn_animation_enabled"
|
||||||
|
|
||||||
|
private fun savePageTurnAnimationSetting(context: Context, isEnabled: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PAGE_TURN_ANIMATION_KEY, isEnabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPageTurnAnimationSetting(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PAGE_TURN_ANIMATION_KEY, false)
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveAutoScrollMinSpeed(context: Context, speed: Float) {
|
private fun saveAutoScrollMinSpeed(context: Context, speed: Float) {
|
||||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
@ -309,6 +321,10 @@ fun EpubReaderHost(
|
||||||
mutableStateOf(loadTapToNavigateSetting(context))
|
mutableStateOf(loadTapToNavigateSetting(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isPageTurnAnimationEnabled by remember {
|
||||||
|
mutableStateOf(loadPageTurnAnimationSetting(context))
|
||||||
|
}
|
||||||
|
|
||||||
val locatorConverter = remember(context) {
|
val locatorConverter = remember(context) {
|
||||||
LocatorConverter(
|
LocatorConverter(
|
||||||
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
|
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
|
||||||
|
|
@ -1109,6 +1125,14 @@ fun EpubReaderHost(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(paginatedPagerState.currentPage, currentRenderMode) {
|
||||||
|
if (currentRenderMode == RenderMode.PAGINATED && volumeScrollEnabled) {
|
||||||
|
delay(200)
|
||||||
|
containerFocusRequester.requestFocus()
|
||||||
|
Timber.d("Paginated: Page changed to ${paginatedPagerState.currentPage}, re-requesting focus for volume keys.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = true) {
|
BackHandler(enabled = true) {
|
||||||
if (isPageSliderVisible) {
|
if (isPageSliderVisible) {
|
||||||
isPageSliderVisible = false
|
isPageSliderVisible = false
|
||||||
|
|
@ -1467,9 +1491,31 @@ fun EpubReaderHost(
|
||||||
scope.launch {
|
scope.launch {
|
||||||
initialScrollTargetForChapter = target
|
initialScrollTargetForChapter = target
|
||||||
if (target == ChapterScrollPosition.START) currentScrollYPosition = 0
|
if (target == ChapterScrollPosition.START) currentScrollYPosition = 0
|
||||||
|
|
||||||
currentChapterIndex += offset
|
currentChapterIndex += offset
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onNextPage = {
|
||||||
|
scope.launch {
|
||||||
|
val pageCount = paginatedPagerState.pageCount
|
||||||
|
if (pageCount > 0) {
|
||||||
|
val targetPage = (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1)
|
||||||
|
if (targetPage != paginatedPagerState.currentPage) {
|
||||||
|
if (isPageTurnAnimationEnabled) {
|
||||||
|
paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700))
|
||||||
|
} else paginatedPagerState.scrollToPage(targetPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPrevPage = {
|
||||||
|
scope.launch {
|
||||||
|
val targetPage = (paginatedPagerState.currentPage - 1).coerceAtLeast(0)
|
||||||
|
if (targetPage != paginatedPagerState.currentPage) {
|
||||||
|
if (isPageTurnAnimationEnabled) {
|
||||||
|
paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700))
|
||||||
|
} else paginatedPagerState.scrollToPage(targetPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|
@ -2170,6 +2216,7 @@ fun EpubReaderHost(
|
||||||
textAlign = currentTextAlign,
|
textAlign = currentTextAlign,
|
||||||
activeHighlightPalette = currentHighlightPalette,
|
activeHighlightPalette = currentHighlightPalette,
|
||||||
onUpdatePalette = onUpdateHighlightPalette,
|
onUpdatePalette = onUpdateHighlightPalette,
|
||||||
|
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
||||||
ttsHighlightInfo = TtsHighlightInfo(
|
ttsHighlightInfo = TtsHighlightInfo(
|
||||||
text = ttsState.currentText ?: "",
|
text = ttsState.currentText ?: "",
|
||||||
cfi = ttsState.sourceCfi ?: "",
|
cfi = ttsState.sourceCfi ?: "",
|
||||||
|
|
@ -2182,9 +2229,15 @@ fun EpubReaderHost(
|
||||||
},
|
},
|
||||||
onTap = { tapOffset ->
|
onTap = { tapOffset ->
|
||||||
Timber.d("PaginatedReaderScreen onTap called with offset: $tapOffset")
|
Timber.d("PaginatedReaderScreen onTap called with offset: $tapOffset")
|
||||||
|
|
||||||
|
if (volumeScrollEnabled) {
|
||||||
|
containerFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
if (tapOffset == null || !tapToNavigateEnabled) {
|
if (tapOffset == null || !tapToNavigateEnabled) {
|
||||||
Timber.d("Condition met: Toggling bars. tapOffset is null: ${tapOffset == null}, tapToNavigateEnabled: $tapToNavigateEnabled")
|
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
|
if (volumeScrollEnabled) containerFocusRequester.requestFocus()
|
||||||
|
|
||||||
if (showBars || showFormatAdjustmentBars) {
|
if (showBars || showFormatAdjustmentBars) {
|
||||||
showBars = false
|
showBars = false
|
||||||
showFormatAdjustmentBars = false
|
showFormatAdjustmentBars = false
|
||||||
|
|
@ -2192,37 +2245,34 @@ fun EpubReaderHost(
|
||||||
showBars = true
|
showBars = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Handle zoned navigation since tapOffset is not null and the feature is enabled.
|
|
||||||
Timber.d("Condition met: Handling zoned navigation.")
|
|
||||||
val oneQuarterWidthPx = constraints.maxWidth / 4f
|
val oneQuarterWidthPx = constraints.maxWidth / 4f
|
||||||
when {
|
when {
|
||||||
tapOffset.x < oneQuarterWidthPx -> {
|
tapOffset.x < oneQuarterWidthPx -> {
|
||||||
// Left 25%: go to previous page
|
|
||||||
Timber.d("Tapped left zone. Navigating to previous page.")
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val targetPage = (paginatedPagerState.currentPage - 1).coerceAtLeast(0)
|
val targetPage = (paginatedPagerState.currentPage - 1).coerceAtLeast(0)
|
||||||
if (targetPage != paginatedPagerState.currentPage) {
|
if (targetPage != paginatedPagerState.currentPage) {
|
||||||
paginatedPagerState.scrollToPage(targetPage)
|
if (isPageTurnAnimationEnabled) {
|
||||||
|
paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700))
|
||||||
|
} else paginatedPagerState.scrollToPage(targetPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tapOffset.x > (constraints.maxWidth - oneQuarterWidthPx) -> {
|
tapOffset.x > (constraints.maxWidth - oneQuarterWidthPx) -> {
|
||||||
// Right 25%: go to next page
|
|
||||||
Timber.d("Tapped right zone. Navigating to next page.")
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val pageCount = paginatedPagerState.pageCount
|
val pageCount = paginatedPagerState.pageCount
|
||||||
if (pageCount > 0) {
|
if (pageCount > 0) {
|
||||||
val targetPage = (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1)
|
val targetPage = (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1)
|
||||||
if (targetPage != paginatedPagerState.currentPage) {
|
if (targetPage != paginatedPagerState.currentPage) {
|
||||||
paginatedPagerState.scrollToPage(targetPage)
|
if (isPageTurnAnimationEnabled) {
|
||||||
|
paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700))
|
||||||
|
} else paginatedPagerState.scrollToPage(targetPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Middle 50%: toggle bars
|
|
||||||
Timber.d("Tapped middle zone. Toggling bars.")
|
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
|
if (volumeScrollEnabled) containerFocusRequester.requestFocus()
|
||||||
if (showBars || showFormatAdjustmentBars) {
|
if (showBars || showFormatAdjustmentBars) {
|
||||||
showBars = false
|
showBars = false
|
||||||
showFormatAdjustmentBars = false
|
showFormatAdjustmentBars = false
|
||||||
|
|
@ -2635,6 +2685,7 @@ fun EpubReaderHost(
|
||||||
isTtsActive = isTtsSessionActive,
|
isTtsActive = isTtsSessionActive,
|
||||||
tapToNavigateEnabled = tapToNavigateEnabled,
|
tapToNavigateEnabled = tapToNavigateEnabled,
|
||||||
volumeScrollEnabled = volumeScrollEnabled,
|
volumeScrollEnabled = volumeScrollEnabled,
|
||||||
|
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
||||||
onNavigateBack = { triggerSaveAndExit() },
|
onNavigateBack = { triggerSaveAndExit() },
|
||||||
onCloseSearch = {
|
onCloseSearch = {
|
||||||
searchState.isSearchActive = false
|
searchState.isSearchActive = false
|
||||||
|
|
@ -2675,6 +2726,10 @@ fun EpubReaderHost(
|
||||||
tapToNavigateEnabled = enabled
|
tapToNavigateEnabled = enabled
|
||||||
saveTapToNavigateSetting(context, enabled)
|
saveTapToNavigateSetting(context, enabled)
|
||||||
},
|
},
|
||||||
|
onTogglePageTurnAnimation = { enabled ->
|
||||||
|
isPageTurnAnimationEnabled = enabled
|
||||||
|
savePageTurnAnimationSetting(context, enabled)
|
||||||
|
},
|
||||||
onToggleVolumeScroll = { enabled ->
|
onToggleVolumeScroll = { enabled ->
|
||||||
volumeScrollEnabled = enabled
|
volumeScrollEnabled = enabled
|
||||||
saveVolumeScrollSetting(context, enabled)
|
saveVolumeScrollSetting(context, enabled)
|
||||||
|
|
|
||||||
|
|
@ -100,10 +100,11 @@ fun Modifier.volumeScrollHandler(
|
||||||
currentChapterIndex: Int,
|
currentChapterIndex: Int,
|
||||||
totalChapters: Int,
|
totalChapters: Int,
|
||||||
onScrollBy: (Int) -> Unit,
|
onScrollBy: (Int) -> Unit,
|
||||||
onNavigateChapter: (offset: Int, scrollTarget: ChapterScrollPosition) -> Unit
|
onNavigateChapter: (offset: Int, scrollTarget: ChapterScrollPosition) -> Unit,
|
||||||
|
onNextPage: () -> Unit = {},
|
||||||
|
onPrevPage: () -> Unit = {}
|
||||||
): Modifier = this.onPreviewKeyEvent { keyEvent ->
|
): Modifier = this.onPreviewKeyEvent { keyEvent ->
|
||||||
val shouldHandle = volumeScrollEnabled &&
|
val shouldHandle = volumeScrollEnabled &&
|
||||||
renderMode == RenderMode.VERTICAL_SCROLL &&
|
|
||||||
!isTtsActive &&
|
!isTtsActive &&
|
||||||
!isMusicActive
|
!isMusicActive
|
||||||
|
|
||||||
|
|
@ -115,26 +116,23 @@ fun Modifier.volumeScrollHandler(
|
||||||
if (!isVolumeKey) return@onPreviewKeyEvent false
|
if (!isVolumeKey) return@onPreviewKeyEvent false
|
||||||
|
|
||||||
if (keyEvent.type == KeyEventType.KeyDown) {
|
if (keyEvent.type == KeyEventType.KeyDown) {
|
||||||
val direction = if (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) 1 else -1
|
val isNext = keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
|
||||||
|
|
||||||
|
if (renderMode == RenderMode.VERTICAL_SCROLL) {
|
||||||
|
val direction = if (isNext) 1 else -1
|
||||||
val isAtBottom = (currentScrollY + currentClientHeight) >= (currentScrollHeight - 2)
|
val isAtBottom = (currentScrollY + currentClientHeight) >= (currentScrollHeight - 2)
|
||||||
|
|
||||||
Timber.d("Dir: $direction, AtBottom: $isAtBottom, Y: $currentScrollY")
|
|
||||||
|
|
||||||
if (direction == -1 && currentScrollY == 0) {
|
if (direction == -1 && currentScrollY == 0) {
|
||||||
// Top -> Prev Chapter
|
if (currentChapterIndex > 0) onNavigateChapter(-1, ChapterScrollPosition.END)
|
||||||
if (currentChapterIndex > 0) {
|
|
||||||
onNavigateChapter(-1, ChapterScrollPosition.END)
|
|
||||||
}
|
|
||||||
} else if (direction == 1 && isAtBottom) {
|
} else if (direction == 1 && isAtBottom) {
|
||||||
// Bottom -> Next Chapter
|
if (currentChapterIndex < totalChapters - 1) onNavigateChapter(1, ChapterScrollPosition.START)
|
||||||
if (currentChapterIndex < totalChapters - 1) {
|
|
||||||
onNavigateChapter(1, ChapterScrollPosition.START)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Scroll
|
|
||||||
val scrollAmount = (currentClientHeight * 0.25).toInt() * direction
|
val scrollAmount = (currentClientHeight * 0.25).toInt() * direction
|
||||||
onScrollBy(scrollAmount)
|
onScrollBy(scrollAmount)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (isNext) onNextPage() else onPrevPage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
@ -84,16 +84,23 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.graphics.ColorMatrix
|
import androidx.compose.ui.graphics.ColorMatrix
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
import androidx.compose.ui.graphics.PathEffect
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||||
|
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
|
@ -138,6 +145,7 @@ import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.unit.toSize
|
import androidx.compose.ui.unit.toSize
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupPositionProvider
|
import androidx.compose.ui.window.PopupPositionProvider
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
|
@ -478,6 +486,7 @@ fun PaginatedReaderScreen(
|
||||||
book: EpubBook,
|
book: EpubBook,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
|
isPageTurnAnimationEnabled: Boolean,
|
||||||
searchQuery: String,
|
searchQuery: String,
|
||||||
fontSizeMultiplier: Float,
|
fontSizeMultiplier: Float,
|
||||||
lineHeightMultiplier: Float,
|
lineHeightMultiplier: Float,
|
||||||
|
|
@ -736,6 +745,7 @@ fun PaginatedReaderScreen(
|
||||||
PaginatedReaderContent(
|
PaginatedReaderContent(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
|
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
ttsHighlightInfo = ttsHighlightInfo,
|
ttsHighlightInfo = ttsHighlightInfo,
|
||||||
textStyle = textStyle,
|
textStyle = textStyle,
|
||||||
|
|
@ -1292,6 +1302,7 @@ private fun TextWithEmphasis(
|
||||||
internal fun PaginatedReaderContent(
|
internal fun PaginatedReaderContent(
|
||||||
uiState: PaginatedReaderUiState,
|
uiState: PaginatedReaderUiState,
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
|
isPageTurnAnimationEnabled: Boolean,
|
||||||
searchQuery: String,
|
searchQuery: String,
|
||||||
ttsHighlightInfo: TtsHighlightInfo?,
|
ttsHighlightInfo: TtsHighlightInfo?,
|
||||||
textStyle: TextStyle,
|
textStyle: TextStyle,
|
||||||
|
|
@ -1380,13 +1391,15 @@ internal fun PaginatedReaderContent(
|
||||||
}, onHide = { menuState = null })
|
}, onHide = { menuState = null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pageTurnTouchY by remember { mutableStateOf<Float?>(null) }
|
||||||
|
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (uiState.totalPageCount > 0) {
|
if (uiState.totalPageCount > 0) {
|
||||||
val generation = uiState.generation
|
uiState.generation
|
||||||
|
|
||||||
val realClipboard: Clipboard = LocalClipboard.current
|
val realClipboard: Clipboard = LocalClipboard.current
|
||||||
var isForDictionary by remember { mutableStateOf(false) }
|
var isForDictionary by remember { mutableStateOf(false) }
|
||||||
|
|
@ -1430,17 +1443,42 @@ internal fun PaginatedReaderContent(
|
||||||
LocalTextToolbar provides textToolbar, LocalClipboard provides dictionaryClipboard
|
LocalTextToolbar provides textToolbar, LocalClipboard provides dictionaryClipboard
|
||||||
) {
|
) {
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
state = pagerState, modifier = Modifier.fillMaxSize()
|
state = pagerState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
val down = event.changes.firstOrNull { it.pressed }
|
||||||
|
if (down != null) {
|
||||||
|
pageTurnTouchY = down.position.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beyondViewportPageCount = 1
|
||||||
) { pageIndex ->
|
) { pageIndex ->
|
||||||
|
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||||
|
val zIndex = -pageOffset
|
||||||
|
|
||||||
|
val pageModifier = if (isPageTurnAnimationEnabled) {
|
||||||
|
Modifier
|
||||||
|
.zIndex(zIndex)
|
||||||
|
.realisticBookPage(pagerState, pageIndex, isDarkTheme, pageTurnTouchY) // UPDATED
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
|
||||||
var pageContent by remember { mutableStateOf<Page?>(null) }
|
var pageContent by remember { mutableStateOf<Page?>(null) }
|
||||||
var currentChapterPath by remember { mutableStateOf<String?>(null) }
|
var currentChapterPath by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(pageIndex, generation) {
|
LaunchedEffect(pageIndex, uiState.generation) {
|
||||||
pageContent = onGetPage(pageIndex)
|
pageContent = onGetPage(pageIndex)
|
||||||
onGetChapterPath(pageIndex)?.let { currentChapterPath = it }
|
onGetChapterPath(pageIndex)?.let { currentChapterPath = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
// SelectionContainer will now use our custom dictionaryClipboardManager
|
Box(modifier = Modifier.fillMaxSize().then(pageModifier)) {
|
||||||
SelectionContainer(modifier = Modifier.fillMaxSize()) {
|
SelectionContainer(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(modifier = Modifier
|
Box(modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -2463,6 +2501,7 @@ internal fun PaginatedReaderContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
menuState?.let { state ->
|
menuState?.let { state ->
|
||||||
Popup(popupPositionProvider = remember(state.rect, density) {
|
Popup(popupPositionProvider = remember(state.rect, density) {
|
||||||
SmartPopupPositionProvider(state.rect, density)
|
SmartPopupPositionProvider(state.rect, density)
|
||||||
|
|
@ -3282,3 +3321,166 @@ private fun RenderFlexChildBlock(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
private fun Modifier.realisticBookPage(
|
||||||
|
pagerState: PagerState,
|
||||||
|
pageIndex: Int,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
touchY: Float?
|
||||||
|
): Modifier = composed {
|
||||||
|
val frontPath = remember { Path() }
|
||||||
|
val backPath = remember { Path() }
|
||||||
|
val reflectedScreenPath = remember { Path() }
|
||||||
|
|
||||||
|
this
|
||||||
|
.graphicsLayer {
|
||||||
|
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||||
|
|
||||||
|
if (pageOffset <= 1f && pageOffset > -1f) {
|
||||||
|
translationX = -pageOffset * size.width
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageOffset != 0f) {
|
||||||
|
shadowElevation = 10f
|
||||||
|
shape = androidx.compose.ui.graphics.RectangleShape
|
||||||
|
clip = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.drawWithContent {
|
||||||
|
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||||
|
val paperColor = if (isDarkTheme) Color(0xFF121212) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
if (abs(pageOffset) < 0.001f) {
|
||||||
|
drawRect(color = paperColor)
|
||||||
|
drawContent()
|
||||||
|
}
|
||||||
|
else if (pageOffset < 0f && pageOffset > -1f) {
|
||||||
|
val progress = -pageOffset
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
|
||||||
|
val startY = touchY ?: h
|
||||||
|
val centerDist = ((startY - h / 2f) / (h / 2f)).coerceIn(-1f, 1f)
|
||||||
|
|
||||||
|
val cornerY = if (centerDist >= 0) h else 0f
|
||||||
|
|
||||||
|
val dragX = w - w * 2.2f * progress
|
||||||
|
val dragY = cornerY - h * 0.5f * progress * centerDist
|
||||||
|
|
||||||
|
val midX = (w + dragX) / 2f
|
||||||
|
val midY = (cornerY + dragY) / 2f
|
||||||
|
|
||||||
|
val dx = w - dragX
|
||||||
|
val dy = cornerY - dragY
|
||||||
|
val nLen = kotlin.math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
if (nLen > 0f) {
|
||||||
|
val nx = dx / nLen
|
||||||
|
val ny = dy / nLen
|
||||||
|
|
||||||
|
val huge = w * 3f
|
||||||
|
val vx = -ny
|
||||||
|
|
||||||
|
val p1X = midX + vx * huge
|
||||||
|
val p1Y = midY + nx * huge
|
||||||
|
val p2X = midX - vx * huge
|
||||||
|
val p2Y = midY - nx * huge
|
||||||
|
|
||||||
|
frontPath.rewind()
|
||||||
|
frontPath.moveTo(p1X, p1Y)
|
||||||
|
frontPath.lineTo(p2X, p2Y)
|
||||||
|
frontPath.lineTo(p2X - nx * huge, p2Y - ny * huge)
|
||||||
|
frontPath.lineTo(p1X - nx * huge, p1Y - ny * huge)
|
||||||
|
frontPath.close()
|
||||||
|
|
||||||
|
clipPath(frontPath) {
|
||||||
|
drawRect(color = paperColor)
|
||||||
|
this@drawWithContent.drawContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
val shadowWidth = (40.dp.toPx() * (1f - progress)).coerceAtLeast(10.dp.toPx())
|
||||||
|
backPath.rewind()
|
||||||
|
backPath.moveTo(p1X, p1Y)
|
||||||
|
backPath.lineTo(p2X, p2Y)
|
||||||
|
backPath.lineTo(p2X + nx * huge, p2Y + ny * huge)
|
||||||
|
backPath.lineTo(p1X + nx * huge, p1Y + ny * huge)
|
||||||
|
backPath.close()
|
||||||
|
|
||||||
|
val dropShadowBrush = Brush.linearGradient(
|
||||||
|
colors = listOf(Color.Black.copy(alpha = 0.4f), Color.Transparent),
|
||||||
|
start = Offset(midX, midY),
|
||||||
|
end = Offset(midX + nx * shadowWidth, midY + ny * shadowWidth)
|
||||||
|
)
|
||||||
|
clipRect(0f, 0f, w, h) {
|
||||||
|
drawPath(backPath, dropShadowBrush)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reflect(px: Float, py: Float): Offset {
|
||||||
|
val vX = px - midX
|
||||||
|
val vY = py - midY
|
||||||
|
val dist = vX * nx + vY * ny
|
||||||
|
return Offset(px - 2 * dist * nx, py - 2 * dist * ny)
|
||||||
|
}
|
||||||
|
|
||||||
|
val rTL = reflect(0f, 0f)
|
||||||
|
val rTR = reflect(w, 0f)
|
||||||
|
val rBR = reflect(w, h)
|
||||||
|
val rBL = reflect(0f, h)
|
||||||
|
|
||||||
|
reflectedScreenPath.rewind()
|
||||||
|
reflectedScreenPath.moveTo(rTL.x, rTL.y)
|
||||||
|
reflectedScreenPath.lineTo(rTR.x, rTR.y)
|
||||||
|
reflectedScreenPath.lineTo(rBR.x, rBR.y)
|
||||||
|
reflectedScreenPath.lineTo(rBL.x, rBL.y)
|
||||||
|
reflectedScreenPath.close()
|
||||||
|
|
||||||
|
clipRect(0f, 0f, w, h) {
|
||||||
|
clipPath(frontPath) {
|
||||||
|
val flapColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
|
||||||
|
drawPath(reflectedScreenPath, color = flapColor)
|
||||||
|
|
||||||
|
val innerShadowWidth = shadowWidth * 0.7f
|
||||||
|
val innerShadowBrush = Brush.linearGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Black.copy(alpha = 0.25f),
|
||||||
|
Color.Black.copy(alpha = 0.05f),
|
||||||
|
Color.Transparent
|
||||||
|
),
|
||||||
|
start = Offset(midX, midY),
|
||||||
|
end = Offset(midX - nx * innerShadowWidth, midY - ny * innerShadowWidth)
|
||||||
|
)
|
||||||
|
drawPath(reflectedScreenPath, innerShadowBrush)
|
||||||
|
|
||||||
|
drawPath(
|
||||||
|
path = reflectedScreenPath,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.15f) else Color.Black.copy(alpha = 0.15f),
|
||||||
|
style = Stroke(width = 1.dp.toPx())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
drawLine(
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f),
|
||||||
|
start = Offset(p1X, p1Y),
|
||||||
|
end = Offset(p2X, p2Y),
|
||||||
|
strokeWidth = 1.dp.toPx()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
drawRect(color = paperColor)
|
||||||
|
drawContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (pageOffset > 0f && pageOffset <= 1f) {
|
||||||
|
drawRect(color = paperColor)
|
||||||
|
drawContent()
|
||||||
|
val dimAlpha = (0.25f * pageOffset).coerceIn(0f, 0.4f)
|
||||||
|
drawRect(color = Color.Black.copy(alpha = dimAlpha))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
drawRect(color = paperColor)
|
||||||
|
drawContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue