book-reader/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
Aryan d16bdd70d4
V1.0.46 oss (#253)
* 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)
2026-04-29 16:37:53 +05:30

1601 lines
79 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
*/
// EpubReaderControls.kt
package com.aryan.reader.epubreader
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import android.webkit.WebView
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.ArrowUpward
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.Delete
import androidx.compose.material.icons.filled.GraphicEq
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.Refresh
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.draw.rotate
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.createBitmap
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.BuildConfig
import com.aryan.reader.R
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.loadNativeVoice
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.math.roundToInt
enum class ReaderTool(val title: String, val category: String) {
DICTIONARY("External Apps", "Top Bar"),
THEME("Theme Settings", "Top Bar"),
SLIDER("Navigation Slider", "Bottom Bar"),
TOC("Sidebar", "Bottom Bar"),
FORMAT("Text Formatting", "Bottom Bar"),
SEARCH("Search", "Bottom Bar"),
AI_FEATURES("AI Features", "Bottom Bar"),
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
READING_MODE("Reading Mode", "Overflow Menu"),
BOOKMARK("Bookmark", "Overflow Menu"),
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
VOLUME_SCROLL("Volume Button Scrolling", "Overflow Menu"),
PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"),
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu")
}
@Composable
fun EpubReaderTopBar(
isVisible: Boolean,
searchState: SearchState,
bookTitle: String,
currentRenderMode: RenderMode,
isBookmarked: Boolean,
isTtsActive: Boolean,
tapToNavigateEnabled: Boolean,
volumeScrollEnabled: Boolean,
isPageTurnAnimationEnabled: Boolean,
onNavigateBack: () -> Unit,
isKeepScreenOn: Boolean,
onToggleKeepScreenOn: (Boolean) -> Unit,
onCloseSearch: () -> Unit,
onChangeRenderMode: (RenderMode) -> Unit,
onToggleBookmark: () -> Unit,
onToggleTapToNavigate: (Boolean) -> Unit,
onToggleVolumeScroll: (Boolean) -> Unit,
onTogglePageTurnAnimation: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onOpenVisualOptions: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
hiddenTools: Set<String>,
onCustomizeTools: () -> Unit,
modifier: Modifier = Modifier,
onToggleReflow: (() -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null,
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(animationSpec = tween(200)) { -it } + fadeIn(animationSpec = tween(200)),
exit = slideOutVertically(animationSpec = tween(200)) { -it } + fadeOut(animationSpec = tween(200)),
modifier = modifier
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(55.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp
) {
Row(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (searchState.isSearchActive) {
SearchTopBar(
searchState = searchState,
focusRequester = searchFocusRequester,
onCloseSearch = onCloseSearch
)
} else {
TooltipIconButton(
text = stringResource(R.string.tooltip_back),
description = stringResource(R.string.tooltip_back_desc),
onClick = onNavigateBack
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Spacer(Modifier.width(8.dp))
Text(
text = bookTitle.take(40) + if (bookTitle.length > 40) "..." else "",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (!hiddenTools.contains(ReaderTool.DICTIONARY.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onOpenDictionarySettings
) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = stringResource(R.string.content_desc_dictionary_settings)
)
}
}
if (!hiddenTools.contains(ReaderTool.THEME.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc),
onClick = onOpenThemeSettings
) {
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
}
}
Box {
var showMoreMenu by remember { mutableStateOf(false) }
TooltipIconButton(
text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc),
onClick = { showMoreMenu = true }
) {
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.content_desc_more_options))
}
DropdownMenu(
expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.title_customize_toolbar)) },
onClick = {
showMoreMenu = false
onCustomizeTools()
},
leadingIcon = {
Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp))
}
)
HorizontalDivider()
if (onToggleReflow != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_view_original_pdf)) },
onClick = {
showMoreMenu = false
onToggleReflow()
},
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.picture_as_pdf),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
HorizontalDivider()
}
onDeleteReflow?.let {
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_delete_text_view)) },
onClick = {
showMoreMenu = false
it()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
colors = androidx.compose.material3.MenuDefaults.itemColors(
textColor = MaterialTheme.colorScheme.error
)
)
}
if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
},
trailingIcon = {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
})
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onChangeRenderMode(RenderMode.PAGINATED)
},
trailingIcon = {
if (currentRenderMode == RenderMode.PAGINATED) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
})
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) {
DropdownMenuItem(text = {
Text(
if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(
R.string.menu_bookmark_this_page
)
)
}, onClick = {
showMoreMenu = false
onToggleBookmark()
})
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.TAP_TO_TURN.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
enabled = currentRenderMode == RenderMode.PAGINATED,
onClick = {
onToggleTapToNavigate(!tapToNavigateEnabled)
showMoreMenu = false
},
trailingIcon = {
if (tapToNavigateEnabled) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
)
})
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.VOLUME_SCROLL.name)) {
DropdownMenuItem(
text = {
Text(
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) stringResource(
R.string.menu_volume_button_scrolling
)
else stringResource(R.string.menu_volume_button_page_turn)
)
},
enabled = true,
onClick = {
onToggleVolumeScroll(!volumeScrollEnabled)
showMoreMenu = false
},
trailingIcon = {
if (volumeScrollEnabled) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
)
})
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.PAGE_TURN_ANIM.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_realistic_page_turns)) },
enabled = currentRenderMode == RenderMode.PAGINATED,
onClick = {
onTogglePageTurnAnimation(!isPageTurnAnimationEnabled)
showMoreMenu = false
},
trailingIcon = {
if (isPageTurnAnimationEnabled) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
)
})
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
onClick = {
onToggleKeepScreenOn(!isKeepScreenOn)
showMoreMenu = false
},
trailingIcon = {
if (isKeepScreenOn) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
)
})
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_visual_options)) },
onClick = {
showMoreMenu = false
onOpenVisualOptions()
},
leadingIcon = {
Icon(
Icons.Default.Visibility,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
})
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_auto_scroll)) },
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
onClick = {
showMoreMenu = false
onStartAutoScroll()
})
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onOpenTtsSettings()
},
leadingIcon = {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
}
}
}
}
}
}
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun EpubReaderBottomBar(
isVisible: Boolean,
currentRenderMode: RenderMode,
isTtsSessionActive: Boolean,
ttsState: TtsState,
isProUser: Boolean,
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit,
onToggleFormat: () -> Unit,
onToggleSearch: () -> Unit,
onOpenAiHub: () -> Unit,
onToggleTts: () -> Unit,
hiddenTools: Set<String>,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)),
exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)),
modifier = modifier
) {
Surface(
modifier = Modifier.fillMaxWidth().height(45.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp
) {
Row(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
) {
if (!hiddenTools.contains(ReaderTool.SLIDER.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = onOpenSlider,
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
) {
Icon(
painter = painterResource(id = R.drawable.slider),
contentDescription = stringResource(R.string.content_desc_navigate_slider)
)
}
}
if (!hiddenTools.contains(ReaderTool.TOC.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = onOpenDrawer
) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.content_desc_chapters_menu)
)
}
}
if (!hiddenTools.contains(ReaderTool.FORMAT.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_format),
description = stringResource(R.string.tooltip_format_desc),
onClick = onToggleFormat
) {
Icon(
painter = painterResource(id = R.drawable.format_size),
contentDescription = stringResource(R.string.content_desc_text_formatting)
)
}
}
if (!hiddenTools.contains(ReaderTool.SEARCH.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = onToggleSearch
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.tooltip_search)
)
}
}
if (!hiddenTools.contains(ReaderTool.AI_FEATURES.name)) {
@Suppress(
"KotlinConstantConditions",
"SimplifyBooleanWithConstants"
) if (BuildConfig.FLAVOR != "oss") {
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = onOpenAiHub
) {
Icon(
painter = painterResource(id = R.drawable.ai),
contentDescription = "AI Features"
)
}
}
}
if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) {
TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
else stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
else stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts
) {
Icon(
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(
id = R.drawable.text_to_speech
),
contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(
R.string.content_desc_start_tts
),
tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@SuppressLint("UnusedBoxWithConstraintsScope")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EpubReaderPageSlider(
isVisible: Boolean,
currentRenderMode: RenderMode,
totalPages: Int,
sliderCurrentPage: Float,
sliderStartPage: Int,
startPageThumbnail: Bitmap?,
paginator: IPaginator?,
chapters: List<EpubChapter>,
onClose: () -> Unit,
onScrub: (Float) -> Unit,
onJumpToPage: (Int) -> Unit
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200))
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onClose() }
)
IconButton(
onClick = onClose,
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Top + WindowInsetsSides.Start))
.padding(8.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_desc_exit_slider)
)
}
// Bottom controls
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
BoxWithConstraints(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Slider(
value = sliderCurrentPage,
onValueChange = onScrub,
valueRange = 1f..(totalPages.toFloat().coerceAtLeast(1f)),
steps = if (totalPages > 2) totalPages - 2 else 0,
modifier = Modifier.fillMaxWidth(),
thumb = {
Surface(
modifier = Modifier.size(20.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {}
},
track = { sliderState ->
val trackHeight = 2.dp
val trackShape = RoundedCornerShape(trackHeight)
val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
val fraction = if (range == 0f) 0f else {
((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(trackHeight)
.background(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
shape = trackShape
)
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.fillMaxHeight()
.background(
color = MaterialTheme.colorScheme.primary,
shape = trackShape
)
)
}
}
)
// Thumbnail Indicator
val startPageOffsetFraction = if (totalPages > 1) {
(sliderStartPage - 1).toFloat() / (totalPages - 1)
} else {
0f
}
val thumbWidth = 20.dp
val trackWidth = maxWidth - thumbWidth
val startPagePixelPosition = (trackWidth * startPageOffsetFraction) + (thumbWidth / 2)
val thumbnailModifier = Modifier
.graphicsLayer { clip = false }
.align(Alignment.TopStart)
.offset(
x = startPagePixelPosition - (45.dp / 2),
y = (-72).dp
)
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
startPageThumbnail?.let { thumbnail ->
ThumbnailWithIndicator(
modifier = thumbnailModifier,
onClick = { onJumpToPage(sliderStartPage) }
) {
Image(
bitmap = thumbnail.asImageBitmap(),
contentDescription = stringResource(R.string.content_desc_start_page_thumbnail),
contentScale = ContentScale.FillBounds,
modifier = Modifier.fillMaxSize()
)
}
}
} else {
val startPageChapterIndex = remember(sliderStartPage, paginator) {
(paginator as? BookPaginator)?.findChapterIndexForPage(sliderStartPage - 1)
}
val startPageChapterTitle = remember(startPageChapterIndex) {
startPageChapterIndex?.let { chapters.getOrNull(it)?.title }
}
ThumbnailWithIndicator(
modifier = thumbnailModifier,
onClick = { onJumpToPage(sliderStartPage) }
) {
PaginatedThumbnailContent(
pageNumber = sliderStartPage,
chapterTitle = startPageChapterTitle
)
}
}
}
Text(
text = "${sliderCurrentPage.roundToInt()} / $totalPages",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 18.sp
)
}
}
}
}
}
// --- Helpers moved from Screen ---
@Composable
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(modifier: Modifier = Modifier, onClick: () -> Unit, content: @Composable () -> 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)
) {
content()
}
Box(
modifier = Modifier
.offset(y = (-4).dp)
.size(8.dp)
.rotate(45f)
.background(borderColor)
)
}
}
@Composable
private fun PaginatedThumbnailContent(pageNumber: Int, chapterTitle: String?) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
) {
Column(
modifier = Modifier.padding(4.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (chapterTitle != null) {
Text(
text = chapterTitle,
style = MaterialTheme.typography.labelSmall,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
lineHeight = 10.sp
)
Spacer(modifier = Modifier.height(4.dp))
}
Text(
text = "$pageNumber",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold
)
}
}
}
suspend fun captureWebViewVisibleArea(webView: WebView): Bitmap? {
return withContext(Dispatchers.Main) {
if (webView.width <= 0 || webView.height <= 0) return@withContext null
try {
val thumbnailWidth = 180
val thumbnailHeight = 256
val bitmap = createBitmap(thumbnailWidth, thumbnailHeight)
val canvas = Canvas(bitmap)
val scale = thumbnailWidth.toFloat() / webView.width.toFloat()
canvas.scale(scale, scale)
canvas.translate(-webView.scrollX.toFloat(), -webView.scrollY.toFloat())
webView.draw(canvas)
bitmap
} catch (e: Exception) {
Timber.e(e, "Failed to capture webview content")
null
}
}
}
@Composable
fun SpeedDropdown(
label: String,
currentValue: Float,
options: List<Float>,
onValueChange: (Float) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box {
Text(
text = "$label: ${currentValue}x",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clickable { expanded = true }
.padding(4.dp)
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { opt ->
DropdownMenuItem(
text = { Text("${opt}x") },
onClick = {
onValueChange(opt)
expanded = false
}
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AutoScrollControls(
isPlaying: Boolean,
onPlayPauseToggle: () -> Unit,
speed: Float,
minSpeed: Float,
maxSpeed: Float,
onSpeedChange: (Float) -> Unit,
onMinSpeedChange: (Float) -> Unit,
onMaxSpeedChange: (Float) -> Unit,
onClose: () -> Unit,
isCollapsed: Boolean,
onCollapseChange: (Boolean) -> Unit,
isMusicianMode: Boolean,
onMusicianModeToggle: () -> Unit,
useSlider: Boolean,
onInputModeToggle: () -> Unit,
isLocalMode: Boolean,
onLocalModeToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
isTempPaused: Boolean = false,
onScrollToTop: (() -> Unit)? = null
) {
val backgroundAlpha = 0.6f
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = backgroundAlpha),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
modifier = modifier
.widthIn(max = 400.dp)
.animateContentSize()
) {
AnimatedContent(
targetState = isCollapsed,
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
},
label = "AutoScrollUnified"
) { collapsed ->
if (collapsed) {
Row(
modifier = Modifier
.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
IconButton(
onClick = { onCollapseChange(false) },
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.ChevronLeft,
contentDescription = stringResource(R.string.content_desc_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
onClick = onPlayPauseToggle,
modifier = Modifier.size(36.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) stringResource(R.string.tooltip_tts_pause) else stringResource(R.string.content_desc_start_playback),
modifier = Modifier.size(20.dp)
)
}
if (isTempPaused && isPlaying) {
CircularProgressIndicator(
modifier = Modifier.size(36.dp),
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f),
strokeWidth = 2.dp
)
}
}
}
} else {
Column(
modifier = Modifier.padding(16.dp)
) {
// Top Row: Label & Tools
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box {
var showModeMenu by remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable { showModeMenu = true }
.padding(4.dp)
) {
Text(
text = if (isLocalMode) stringResource(R.string.auto_scroll_local_speed) else stringResource(R.string.auto_scroll_global_speed),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = stringResource(R.string.content_desc_select_mode),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
DropdownMenu(
expanded = showModeMenu,
onDismissRequest = { showModeMenu = false }
) {
DropdownMenuItem(
text = {
Column {
Text(stringResource(R.string.auto_scroll_global_speed), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.auto_scroll_applies_all_files), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
onClick = {
onLocalModeToggle(false)
showModeMenu = false
},
trailingIcon = {
if (!isLocalMode) Icon(Icons.Default.Check, contentDescription = null)
}
)
HorizontalDivider()
DropdownMenuItem(
text = {
Column {
Text(stringResource(R.string.auto_scroll_local_speed), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.auto_scroll_saved_for_file), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
onClick = {
onLocalModeToggle(true)
showModeMenu = false
},
trailingIcon = {
if (isLocalMode) Icon(Icons.Default.Check, contentDescription = null)
}
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (onScrollToTop != null) {
IconButton(
onClick = onScrollToTop,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.ArrowUpward,
contentDescription = "Scroll to Top",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(
onClick = onMusicianModeToggle,
modifier = Modifier.size(32.dp)
) {
Icon(
painter = painterResource(id = R.drawable.music_note),
contentDescription = if (isMusicianMode) stringResource(R.string.content_desc_disable_musician_mode) else stringResource(R.string.content_desc_enable_musician_mode),
modifier = Modifier.size(18.dp),
tint = if (isMusicianMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(
onClick = onInputModeToggle,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.SwapHoriz,
contentDescription = stringResource(R.string.content_desc_swap_controls),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(
onClick = { onCollapseChange(true) },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = stringResource(R.string.content_desc_collapse),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(
onClick = onClose,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.action_close),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
}
}
}
Spacer(Modifier.height(16.dp))
// Bottom Row: Controls
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Play/Pause
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
onClick = onPlayPauseToggle,
modifier = Modifier.size(48.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) stringResource(R.string.tooltip_tts_pause) else stringResource(R.string.content_desc_start_playback),
modifier = Modifier.size(24.dp)
)
}
if (isTempPaused && isPlaying) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
strokeWidth = 3.dp
)
}
}
// Speed Controls
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val speedOptions = listOf(0.1f, 0.5f, 1f, 1.5f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
SpeedDropdown(
label = stringResource(R.string.label_min),
currentValue = minSpeed,
options = speedOptions,
onValueChange = onMinSpeedChange
)
SpeedDropdown(
label = stringResource(R.string.label_max),
currentValue = maxSpeed,
options = speedOptions,
onValueChange = onMaxSpeedChange
)
}
Spacer(Modifier.height(4.dp))
val safeMax = maxSpeed.coerceAtLeast(minSpeed + 0.1f)
if (useSlider) {
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 steps = ((safeMax - minSpeed) / 0.1f).roundToInt() - 1
Slider(
value = speed,
onValueChange = { onSpeedChange((it * 10f).roundToInt() / 10f) },
valueRange = minSpeed..safeMax,
steps = if (steps > 0) steps else 0,
modifier = Modifier.weight(1f),
thumb = {
Box(
modifier = Modifier
.size(20.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
)
},
track = { sliderState ->
val fraction = (sliderState.value - sliderState.valueRange.start) /
(sliderState.valueRange.endInclusive - sliderState.valueRange.start)
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.background(
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f),
RoundedCornerShape(2.dp)
),
contentAlignment = Alignment.CenterStart
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.height(4.dp)
.background(
MaterialTheme.colorScheme.primary,
RoundedCornerShape(2.dp)
)
)
}
}
)
}
} else {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.height(48.dp).fillMaxWidth()
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(
onClick = { onSpeedChange((speed - 0.1f).coerceAtLeast(minSpeed)) },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Default.Remove, stringResource(R.string.content_desc_slower))
}
Text(
text = "%.1fx".format(speed),
style = MaterialTheme.typography.titleMedium.copy(fontFeatureSettings = "tnum"),
textAlign = TextAlign.Center
)
IconButton(
onClick = { onSpeedChange((speed + 0.1f).coerceAtMost(safeMax)) },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Default.Add, stringResource(R.string.content_desc_faster))
}
}
}
}
}
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomizeToolsSheet(
hiddenTools: Set<String>,
onUpdate: (Set<String>) -> Unit,
onDismiss: () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
Text(
text = "Customize Toolbar",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.desc_customize_toolbar),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(modifier = Modifier.fillMaxWidth()) {
ReaderTool.entries.groupBy { it.category }.forEach { (category, tools) ->
item {
Text(
text = category,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
}
items(tools) { tool ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable {
val newSet = hiddenTools.toMutableSet()
if (newSet.contains(tool.name)) newSet.remove(tool.name)
else newSet.add(tool.name)
onUpdate(newSet)
}
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = tool.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Switch(
checked = !hiddenTools.contains(tool.name),
onCheckedChange = { isVisible ->
val newSet = hiddenTools.toMutableSet()
if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name)
onUpdate(newSet)
}
)
}
}
}
}
}
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TtsOverlayControls(
ttsController: com.aryan.reader.tts.TtsController,
ttsState: TtsState,
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
isCollapsed: Boolean,
onCollapseChange: (Boolean) -> Unit,
onLocateCurrentChunk: () -> Unit,
onOpenTtsSettings: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
credits: Int
) {
val context = androidx.compose.ui.platform.LocalContext.current
var rate by remember { mutableFloatStateOf(loadTtsSpeechRate(context)) }
var pitch by remember { mutableFloatStateOf(loadTtsPitch(context)) }
var isDraggingRate by remember { mutableStateOf(false) }
var isDraggingPitch by remember { mutableStateOf(false) }
val activeMode = try { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.valueOf(ttsState.ttsMode) } catch(_: Exception) { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD }
val saveAndApply = {
saveTtsSpeechRate(context, rate)
saveTtsPitch(context, pitch)
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
ttsController.setPlaybackParameters(rate, pitch)
} else {
ttsController.sliceAndRetainPosition()
}
}
val backgroundAlpha = 0.6f
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = backgroundAlpha),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
modifier = modifier.widthIn(max = 400.dp).animateContentSize()
) {
AnimatedContent(
targetState = isCollapsed,
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
label = "TtsOverlayUnified"
) { collapsed ->
if (collapsed) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
IconButton(
onClick = { onCollapseChange(false) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.ChevronLeft, "Expand", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() },
modifier = Modifier.size(36.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
"Play/Pause",
modifier = Modifier.size(20.dp)
)
}
if (ttsState.isLoading) CircularProgressIndicator(
modifier = Modifier.size(36.dp),
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f),
strokeWidth = 2.dp
)
}
}
} else {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f),
shape = RoundedCornerShape(8.dp)
) {
Text(
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) "✨ Cloud" else "📱 Device",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
Surface(
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f),
shape = RoundedCornerShape(8.dp)
) {
val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
GEMINI_TTS_SPEAKERS.find { it.id == ttsState.speakerId }?.name ?: ttsState.speakerId
} else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: "Default"
Text(
voiceName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).widthIn(max = 100.dp)
)
}
if (BuildConfig.FLAVOR != "oss" && activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.7f),
shape = RoundedCornerShape(8.dp)
) {
Text(
"$credits",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(onClick = onLocateCurrentChunk, modifier = Modifier.size(32.dp)) {
Icon(
painterResource(R.drawable.pin_drop),
"Locate current chunk",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.ChevronRight, "Collapse", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, "Stop TTS", tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
}
}
}
Spacer(Modifier.height(16.dp))
// Middle Section: Controls
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Giant Play/Pause
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() },
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
"Play/Pause",
modifier = Modifier.size(28.dp)
)
}
if (ttsState.isLoading) CircularProgressIndicator(
modifier = Modifier.size(56.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
strokeWidth = 3.dp
)
}
Spacer(Modifier.width(16.dp))
// Unified Sliders Block
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Spd: %.1fx".format(rate), style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(62.dp))
Slider(
value = rate,
onValueChange = {
rate = it; if (!isDraggingRate && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
isDraggingRate = true; ttsController.pause()
}
},
onValueChangeFinished = { isDraggingRate = false; saveAndApply() },
valueRange = 0.5f..3.0f,
steps = 24,
modifier = Modifier.weight(1f).height(24.dp)
)
IconButton(onClick = { rate = 1.0f; saveAndApply() }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Refresh, "Reset Speed", modifier = Modifier.size(16.dp))
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Ptch: %.1fx".format(pitch), style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(62.dp))
Slider(
value = pitch,
onValueChange = {
pitch = it; if (!isDraggingPitch && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
isDraggingPitch = true; ttsController.pause()
}
},
onValueChangeFinished = { isDraggingPitch = false; saveAndApply() },
valueRange = 0.5f..2.0f,
steps = 14,
modifier = Modifier.weight(1f).height(24.dp)
)
IconButton(onClick = { pitch = 1.0f; saveAndApply() }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Refresh, "Reset Pitch", modifier = Modifier.size(16.dp))
}
}
}
}
}
}
}
}
}