* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE * Refactored desktop reader screens and state management into specialized components * Added image gallery to reader sidebar and refactored desktop PDF UI components * Implemented EPUB image gallery * Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic * Standardized UI styling and refactored app navigation layout * Implemented auto-hiding reader chrome and activity tracking in desktop * Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support. * Added PPTX support for desktop and refactored parsing into a shared module * Implement paid AI features and account management for the desktop application. * Implement AI Hub and enhanced Cloud TTS integration for Desktop * Implement streaming support for AI definition and summarization features * Implement support for password-protected PDFs and file actions in the desktop reader. * Implement cloud synchronization for desktop using Firestore and Google Drive * Implement PDF reflow and "Text View" for the desktop reader * Refactor OPDS logic to use SharedOpdsController * Optimize PDF tile rendering performance * Implement two-page spread support for PDF pagination * Implement two-page spread support for the PDF viewer * Improved shared spread zoom in PDF viewer * Improve PDF spread navigation with fling support and configurable page gaps * Add brightness control to PDF and EPUB readers * Refactor folder synchronization to use shared logic engine * Implement safe string formatting and validation for localized resources * Implement TTS chunk skip navigation * Implement deep-linking and playback controls for TTS media sessions * Implement start index for TTS playback * Improve TTS navigation, prefetching, and notification duration reporting * Implement TTS mini playback bar for background reading * Implement multi-window reader support for the desktop application * Improve desktop modal window management and visibility syncing * Implement localized string support for Desktop and shared UI * Implement language selection and persistence for Desktop * Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml * Implement localized banner messages and UI strings using resource-backed SharedText * Implement compact badge styling for small book covers * Refactor PDF native interaction and improve HTML import memory safety * fix language persistence * Refactor reader overflow menus to use section-based logic * Refactor PDF layout remapping and improve text box interaction * Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets * Centralize PDF annotation export mapping and improve metadata handling * Add support for threaded comments in PDF highlight annotations * Flatten highlight comments into a single thread for PDF export and allow author editing * Integrate page slider into reader chrome and persist toggle state * Handle fragments and queries in EPUB chapter paths * Implement dynamic, theme-aware coloring for the reader slider * Implement customizable app-wide font preference * Implement one-hand zoom gestures in the PDF viewer * Implement File Information dialog for PDF and EPUB readers * Bump version to 1.0.49 (53) * Refactor PDF reader logic into modular components * Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens * Add option to use PDF filenames as display names * Fix preservation of PDF filename display preference in library projection
2691 lines
137 KiB
Kotlin
2691 lines
137 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.annotation.StringRes
|
|
import androidx.compose.foundation.lazy.LazyListState
|
|
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.gestures.detectDragGestures
|
|
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.PaddingValues
|
|
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.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.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.lazy.rememberLazyListState
|
|
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.automirrored.filled.ArrowForward
|
|
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.Info
|
|
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.ScreenRotation
|
|
import androidx.compose.material.icons.filled.Settings
|
|
import androidx.compose.material.icons.filled.SkipNext
|
|
import androidx.compose.material.icons.filled.SkipPrevious
|
|
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.Slider
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
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.geometry.Offset
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.asImageBitmap
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
import androidx.compose.ui.layout.ContentScale
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
|
import androidx.compose.ui.platform.LocalContext
|
|
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.compose.ui.window.Dialog
|
|
import androidx.compose.ui.window.DialogProperties
|
|
import androidx.compose.ui.zIndex
|
|
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.areReaderAiFeaturesEnabled
|
|
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(@StringRes val titleRes: Int, val category: String) {
|
|
DICTIONARY(R.string.tool_external_apps, "Top Bar"),
|
|
THEME(R.string.tooltip_theme_desc, "Top Bar"),
|
|
BRIGHTNESS(R.string.tool_brightness, "Top Bar"),
|
|
SLIDER(R.string.tool_navigation_slider, "Bottom Bar"),
|
|
TOC(R.string.tool_sidebar, "Bottom Bar"),
|
|
FORMAT(R.string.content_desc_text_formatting, "Bottom Bar"),
|
|
SEARCH(R.string.action_search, "Bottom Bar"),
|
|
AI_FEATURES(R.string.ai_features_title, "Bottom Bar"),
|
|
TTS_CONTROLS(R.string.tool_tts_controls, "Bottom Bar"),
|
|
FILE_INFO(R.string.file_information, "Overflow Menu"),
|
|
READING_MODE(R.string.tool_reading_mode, "Overflow Menu"),
|
|
BOOKMARK(R.string.content_desc_bookmark, "Overflow Menu"),
|
|
TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"),
|
|
VOLUME_SCROLL(R.string.menu_volume_button_scrolling, "Overflow Menu"),
|
|
PAGE_TURN_ANIM(R.string.menu_realistic_page_turns, "Overflow Menu"),
|
|
KEEP_SCREEN_ON(R.string.menu_keep_screen_on, "Overflow Menu"),
|
|
VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"),
|
|
SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"),
|
|
AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"),
|
|
TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"),
|
|
TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu")
|
|
}
|
|
|
|
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
|
|
|
|
data class FlatToolItem(
|
|
val id: String,
|
|
val type: FlatItemType,
|
|
val tool: ReaderTool? = null,
|
|
val section: ToolbarSection? = null,
|
|
val title: String? = null,
|
|
@StringRes val titleRes: Int? = null
|
|
)
|
|
|
|
fun sanitizePlaceholders(list: List<FlatToolItem>): List<FlatToolItem> {
|
|
val result = mutableListOf<FlatToolItem>()
|
|
val sectionMap = mutableMapOf<ToolbarSection, MutableList<FlatToolItem>>()
|
|
ToolbarSection.entries.forEach { sectionMap[it] = mutableListOf() }
|
|
|
|
list.forEach { item ->
|
|
if (item.type == FlatItemType.TOOL) {
|
|
item.section?.let { sectionMap[it]?.add(item) }
|
|
}
|
|
}
|
|
|
|
ToolbarSection.entries.forEach { section ->
|
|
result.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes))
|
|
|
|
val tools = sectionMap[section] ?: emptyList()
|
|
if (tools.isEmpty()) {
|
|
result.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section))
|
|
} else {
|
|
result.addAll(tools)
|
|
}
|
|
}
|
|
|
|
// Maintain More menu items
|
|
list.filter { it.type == FlatItemType.MORE_HEADER || it.type == FlatItemType.MORE_TOOL }.forEach {
|
|
result.add(it)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
class DragDropState(
|
|
val lazyListState: LazyListState,
|
|
val onMove: (String, String) -> Unit
|
|
) {
|
|
var draggedItemId by mutableStateOf<String?>(null)
|
|
var dragOffset by mutableStateOf(Offset.Zero)
|
|
|
|
fun onDragStart(id: String) {
|
|
draggedItemId = id
|
|
dragOffset = Offset.Zero
|
|
}
|
|
|
|
fun onDrag(delta: Offset) {
|
|
val draggedId = draggedItemId ?: return
|
|
dragOffset += delta
|
|
|
|
val visibleItems = lazyListState.layoutInfo.visibleItemsInfo
|
|
val currentItem = visibleItems.find { it.key == draggedId } ?: return
|
|
|
|
val startY = currentItem.offset + dragOffset.y
|
|
val center = startY + currentItem.size / 2f
|
|
|
|
val targetItem = visibleItems.find {
|
|
it.key != draggedId && center >= it.offset && center <= (it.offset + it.size)
|
|
}
|
|
|
|
if (targetItem != null) {
|
|
onMove(draggedId, targetItem.key.toString())
|
|
// Adjust visual offset to prevent snapping when items swap in the layout
|
|
dragOffset = dragOffset.copy(y = dragOffset.y - (targetItem.offset - currentItem.offset))
|
|
}
|
|
}
|
|
|
|
fun onDragEnd() {
|
|
draggedItemId = null
|
|
dragOffset = Offset.Zero
|
|
}
|
|
}
|
|
|
|
private val epubToolbarTools = setOf(
|
|
ReaderTool.DICTIONARY,
|
|
ReaderTool.THEME,
|
|
ReaderTool.BRIGHTNESS,
|
|
ReaderTool.SLIDER,
|
|
ReaderTool.TOC,
|
|
ReaderTool.FORMAT,
|
|
ReaderTool.SEARCH,
|
|
ReaderTool.AI_FEATURES,
|
|
ReaderTool.TTS_CONTROLS,
|
|
ReaderTool.SCREEN_ORIENTATION
|
|
)
|
|
|
|
internal enum class EpubOverflowMenuSection {
|
|
CUSTOMIZE_TOOLBAR,
|
|
HIDDEN_TOOLS,
|
|
VIEW_ORIGINAL_PDF,
|
|
DELETE_TEXT_VIEW,
|
|
READING_MODE,
|
|
BOOKMARK,
|
|
TAP_TO_TURN,
|
|
VOLUME_SCROLL,
|
|
PAGE_TURN_ANIM,
|
|
KEEP_SCREEN_ON,
|
|
VISUAL_OPTIONS,
|
|
AUTO_SCROLL,
|
|
TTS_SETTINGS,
|
|
FILE_INFO
|
|
}
|
|
|
|
internal fun epubOverflowMenuSections(
|
|
hiddenTools: Set<String>,
|
|
hasHiddenToolbarTools: Boolean,
|
|
hasToggleReflow: Boolean,
|
|
hasDeleteReflow: Boolean,
|
|
hasFileInfo: Boolean = true
|
|
): List<EpubOverflowMenuSection> = buildList {
|
|
add(EpubOverflowMenuSection.CUSTOMIZE_TOOLBAR)
|
|
if (hasHiddenToolbarTools) add(EpubOverflowMenuSection.HIDDEN_TOOLS)
|
|
if (hasToggleReflow) add(EpubOverflowMenuSection.VIEW_ORIGINAL_PDF)
|
|
if (hasDeleteReflow) add(EpubOverflowMenuSection.DELETE_TEXT_VIEW)
|
|
if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) add(EpubOverflowMenuSection.READING_MODE)
|
|
if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) add(EpubOverflowMenuSection.BOOKMARK)
|
|
if (!hiddenTools.contains(ReaderTool.TAP_TO_TURN.name)) add(EpubOverflowMenuSection.TAP_TO_TURN)
|
|
if (!hiddenTools.contains(ReaderTool.VOLUME_SCROLL.name)) add(EpubOverflowMenuSection.VOLUME_SCROLL)
|
|
if (!hiddenTools.contains(ReaderTool.PAGE_TURN_ANIM.name)) add(EpubOverflowMenuSection.PAGE_TURN_ANIM)
|
|
if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) add(EpubOverflowMenuSection.KEEP_SCREEN_ON)
|
|
if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) add(EpubOverflowMenuSection.VISUAL_OPTIONS)
|
|
if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) add(EpubOverflowMenuSection.AUTO_SCROLL)
|
|
if (
|
|
!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name) ||
|
|
!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
|
|
) {
|
|
add(EpubOverflowMenuSection.TTS_SETTINGS)
|
|
}
|
|
if (hasFileInfo && !hiddenTools.contains(ReaderTool.FILE_INFO.name)) {
|
|
add(EpubOverflowMenuSection.FILE_INFO)
|
|
}
|
|
}
|
|
|
|
internal fun defaultReaderHiddenTools(): Set<String> = setOf(
|
|
ReaderTool.SCREEN_ORIENTATION.name,
|
|
ReaderTool.BRIGHTNESS.name
|
|
)
|
|
|
|
internal fun defaultReaderToolOrder(): List<ReaderTool> = ReaderTool.entries.toList()
|
|
|
|
internal fun defaultReaderBottomTools(): Set<String> {
|
|
return ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
|
}
|
|
|
|
internal fun buildReaderToolbarItems(
|
|
hiddenTools: Set<String>,
|
|
toolOrder: List<ReaderTool>,
|
|
bottomTools: Set<String>
|
|
): List<FlatToolItem> {
|
|
val toolbarTools = toolOrder.filter { it in epubToolbarTools }
|
|
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
|
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
|
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
|
val moreTools = toolOrder.filter { it !in epubToolbarTools }
|
|
|
|
val list = mutableListOf<FlatToolItem>()
|
|
|
|
ToolbarSection.entries.forEach { section ->
|
|
val tools = when (section) {
|
|
ToolbarSection.TOP -> topTools
|
|
ToolbarSection.BOTTOM -> bottomToolsList
|
|
ToolbarSection.HIDDEN -> hiddenToolsList
|
|
}
|
|
list.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes))
|
|
if (tools.isEmpty()) {
|
|
list.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section))
|
|
} else {
|
|
tools.forEach { tool ->
|
|
list.add(FlatToolItem("tool_${tool.name}", FlatItemType.TOOL, tool = tool, section = section))
|
|
}
|
|
}
|
|
}
|
|
|
|
list.add(FlatToolItem("more_header", FlatItemType.MORE_HEADER, titleRes = R.string.toolbar_more_menu))
|
|
moreTools.forEach { tool ->
|
|
list.add(FlatToolItem("more_${tool.name}", FlatItemType.MORE_TOOL, tool = tool))
|
|
}
|
|
|
|
return list
|
|
}
|
|
|
|
@Composable
|
|
fun EpubReaderTopBar(
|
|
isVisible: Boolean,
|
|
searchState: SearchState,
|
|
bookTitle: String,
|
|
currentRenderMode: RenderMode,
|
|
isBookmarked: Boolean,
|
|
isTtsActive: Boolean,
|
|
isSliderActive: Boolean,
|
|
tapToNavigateEnabled: Boolean,
|
|
volumeScrollEnabled: Boolean,
|
|
isPageTurnAnimationEnabled: Boolean,
|
|
isRightToLeftPagination: Boolean,
|
|
onNavigateBack: () -> Unit,
|
|
isKeepScreenOn: Boolean,
|
|
onToggleKeepScreenOn: (Boolean) -> Unit,
|
|
onCloseSearch: () -> Unit,
|
|
onChangeRenderMode: (RenderMode) -> Unit,
|
|
onToggleBookmark: () -> Unit,
|
|
onToggleTapToNavigate: (Boolean) -> Unit,
|
|
onToggleVolumeScroll: (Boolean) -> Unit,
|
|
onTogglePageTurnAnimation: (Boolean) -> Unit,
|
|
onSetRightToLeftPagination: (Boolean) -> Unit,
|
|
onStartAutoScroll: () -> Unit,
|
|
onOpenTtsSettings: () -> Unit,
|
|
onOpenTtsReplacements: () -> Unit,
|
|
onOpenDictionarySettings: () -> Unit,
|
|
onOpenThemeSettings: () -> Unit,
|
|
onOpenBrightness: () -> Unit,
|
|
onOpenVisualOptions: () -> Unit,
|
|
onOpenScreenOrientation: () -> Unit,
|
|
onOpenSlider: () -> Unit,
|
|
onOpenDrawer: () -> Unit,
|
|
onToggleFormat: () -> Unit,
|
|
onToggleSearch: () -> Unit,
|
|
onOpenAiHub: () -> Unit,
|
|
onToggleTts: () -> Unit,
|
|
onOpenFileInfo: () -> Unit,
|
|
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
|
|
hiddenTools: Set<String>,
|
|
toolOrder: List<ReaderTool>,
|
|
bottomTools: 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 = stringResource(R.string.action_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)
|
|
)
|
|
toolOrder
|
|
.filter { it in epubToolbarTools && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
|
.forEach { tool ->
|
|
when (tool) {
|
|
ReaderTool.DICTIONARY -> 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)
|
|
)
|
|
}
|
|
ReaderTool.THEME -> 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))
|
|
}
|
|
ReaderTool.BRIGHTNESS -> TooltipIconButton(
|
|
text = stringResource(R.string.reader_brightness_title),
|
|
description = stringResource(R.string.reader_brightness_system_desc),
|
|
onClick = onOpenBrightness
|
|
) {
|
|
Icon(painter = painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title))
|
|
}
|
|
ReaderTool.SLIDER -> TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_slider),
|
|
description = stringResource(R.string.tooltip_slider_desc),
|
|
onClick = onOpenSlider
|
|
) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.slider),
|
|
contentDescription = stringResource(R.string.content_desc_navigate_slider),
|
|
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
|
)
|
|
}
|
|
ReaderTool.TOC -> TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_toc),
|
|
description = stringResource(R.string.tooltip_toc_desc),
|
|
onClick = onOpenDrawer
|
|
) {
|
|
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_chapters_menu))
|
|
}
|
|
ReaderTool.FORMAT -> 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))
|
|
}
|
|
ReaderTool.SEARCH -> TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_search),
|
|
description = stringResource(R.string.tooltip_search_desc),
|
|
onClick = onToggleSearch
|
|
) {
|
|
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.tooltip_search))
|
|
}
|
|
ReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
|
|
TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_ai),
|
|
description = stringResource(R.string.tooltip_ai_desc),
|
|
onClick = onOpenAiHub
|
|
) {
|
|
Icon(painter = painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.ai_features_title))
|
|
}
|
|
}
|
|
ReaderTool.TTS_CONTROLS -> TooltipIconButton(
|
|
text = if (isTtsActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
|
|
description = if (isTtsActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
|
|
onClick = onToggleTts
|
|
) {
|
|
Icon(
|
|
painter = if (isTtsActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech),
|
|
contentDescription = if (isTtsActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts),
|
|
tint = if (isTtsActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
|
)
|
|
}
|
|
ReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
|
text = stringResource(R.string.menu_screen_orientation),
|
|
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
|
onClick = onOpenScreenOrientation
|
|
) {
|
|
Icon(
|
|
Icons.Default.ScreenRotation,
|
|
contentDescription = stringResource(R.string.menu_screen_orientation),
|
|
tint = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
}
|
|
else -> Unit
|
|
}
|
|
}
|
|
Box {
|
|
var showMoreMenu by remember { mutableStateOf(false) }
|
|
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
|
|
var showReadingModeExpanded by remember { mutableStateOf(false) }
|
|
var showTtsSettingsExpanded by remember { mutableStateOf(false) }
|
|
TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_more_options),
|
|
description = stringResource(R.string.tooltip_more_options_desc),
|
|
onClick = {
|
|
showHiddenToolsExpanded = false
|
|
showReadingModeExpanded = false
|
|
showTtsSettingsExpanded = false
|
|
showMoreMenu = true
|
|
}
|
|
) {
|
|
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.content_desc_more_options))
|
|
}
|
|
|
|
DropdownMenu(
|
|
expanded = showMoreMenu,
|
|
onDismissRequest = {
|
|
showHiddenToolsExpanded = false
|
|
showReadingModeExpanded = false
|
|
showTtsSettingsExpanded = false
|
|
showMoreMenu = false
|
|
}
|
|
) {
|
|
val hiddenToolbarTools = toolOrder.filter { it in epubToolbarTools && hiddenTools.contains(it.name) }
|
|
val showTtsVoiceSettings = !hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)
|
|
val showTtsReplacements = !hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
|
|
epubOverflowMenuSections(
|
|
hiddenTools = hiddenTools,
|
|
hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(),
|
|
hasToggleReflow = onToggleReflow != null,
|
|
hasDeleteReflow = onDeleteReflow != null
|
|
).forEachIndexed { index, section ->
|
|
if (index > 0) HorizontalDivider()
|
|
when (section) {
|
|
EpubOverflowMenuSection.CUSTOMIZE_TOOLBAR -> {
|
|
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))
|
|
}
|
|
)
|
|
}
|
|
EpubOverflowMenuSection.HIDDEN_TOOLS -> {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
|
|
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
|
|
trailingIcon = {
|
|
Icon(
|
|
Icons.Default.ArrowDropDown,
|
|
contentDescription = null,
|
|
modifier = Modifier.rotate(if (showHiddenToolsExpanded) 180f else 0f)
|
|
)
|
|
}
|
|
)
|
|
if (showHiddenToolsExpanded) {
|
|
hiddenToolbarTools.forEach { tool ->
|
|
HiddenEpubToolMenuItem(
|
|
tool = tool,
|
|
isSliderActive = isSliderActive,
|
|
showMoreMenu = {
|
|
showHiddenToolsExpanded = false
|
|
showMoreMenu = false
|
|
},
|
|
onOpenDictionarySettings = onOpenDictionarySettings,
|
|
onOpenThemeSettings = onOpenThemeSettings,
|
|
onOpenBrightness = onOpenBrightness,
|
|
onOpenSlider = onOpenSlider,
|
|
onOpenDrawer = onOpenDrawer,
|
|
onToggleFormat = onToggleFormat,
|
|
onToggleSearch = onToggleSearch,
|
|
onOpenAiHub = onOpenAiHub,
|
|
onToggleTts = onToggleTts,
|
|
onOpenScreenOrientation = onOpenScreenOrientation
|
|
)
|
|
}
|
|
}
|
|
}
|
|
EpubOverflowMenuSection.FILE_INFO -> {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.file_information)) },
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onOpenFileInfo()
|
|
},
|
|
leadingIcon = {
|
|
Icon(
|
|
imageVector = Icons.Default.Info,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
}
|
|
)
|
|
}
|
|
EpubOverflowMenuSection.VIEW_ORIGINAL_PDF -> {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_view_original_pdf)) },
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onToggleReflow?.invoke()
|
|
},
|
|
leadingIcon = {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.picture_as_pdf),
|
|
contentDescription = null,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
}
|
|
)
|
|
}
|
|
EpubOverflowMenuSection.DELETE_TEXT_VIEW -> {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_delete_text_view)) },
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onDeleteReflow?.invoke()
|
|
},
|
|
leadingIcon = {
|
|
Icon(
|
|
imageVector = Icons.Default.Delete,
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colorScheme.error
|
|
)
|
|
},
|
|
colors = androidx.compose.material3.MenuDefaults.itemColors(
|
|
textColor = MaterialTheme.colorScheme.error
|
|
)
|
|
)
|
|
}
|
|
EpubOverflowMenuSection.READING_MODE -> {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
|
|
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
|
|
trailingIcon = {
|
|
Icon(
|
|
Icons.Default.ArrowDropDown,
|
|
contentDescription = null,
|
|
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
|
|
)
|
|
}
|
|
)
|
|
if (showReadingModeExpanded) {
|
|
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 = {
|
|
onSetRightToLeftPagination(false)
|
|
showMoreMenu = false
|
|
onChangeRenderMode(RenderMode.PAGINATED)
|
|
},
|
|
trailingIcon = {
|
|
if (currentRenderMode == RenderMode.PAGINATED && !isRightToLeftPagination) {
|
|
Icon(
|
|
Icons.Default.Check,
|
|
contentDescription = stringResource(R.string.content_desc_selected)
|
|
)
|
|
}
|
|
})
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
|
|
enabled = !isTtsActive,
|
|
onClick = {
|
|
onSetRightToLeftPagination(true)
|
|
showMoreMenu = false
|
|
onChangeRenderMode(RenderMode.PAGINATED)
|
|
},
|
|
trailingIcon = {
|
|
if (currentRenderMode == RenderMode.PAGINATED && isRightToLeftPagination) {
|
|
Icon(
|
|
Icons.Default.Check,
|
|
contentDescription = stringResource(R.string.content_desc_selected)
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
EpubOverflowMenuSection.BOOKMARK -> {
|
|
DropdownMenuItem(text = {
|
|
Text(
|
|
if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(
|
|
R.string.menu_bookmark_this_page
|
|
)
|
|
)
|
|
}, onClick = {
|
|
showMoreMenu = false
|
|
onToggleBookmark()
|
|
})
|
|
}
|
|
EpubOverflowMenuSection.TAP_TO_TURN -> {
|
|
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)
|
|
)
|
|
})
|
|
}
|
|
EpubOverflowMenuSection.VOLUME_SCROLL -> {
|
|
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)
|
|
)
|
|
})
|
|
}
|
|
EpubOverflowMenuSection.PAGE_TURN_ANIM -> {
|
|
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)
|
|
)
|
|
})
|
|
}
|
|
EpubOverflowMenuSection.KEEP_SCREEN_ON -> {
|
|
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)
|
|
)
|
|
})
|
|
}
|
|
EpubOverflowMenuSection.VISUAL_OPTIONS -> {
|
|
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)
|
|
)
|
|
})
|
|
}
|
|
EpubOverflowMenuSection.AUTO_SCROLL -> {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_auto_scroll)) },
|
|
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onStartAutoScroll()
|
|
})
|
|
}
|
|
EpubOverflowMenuSection.TTS_SETTINGS -> {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_tts_settings)) },
|
|
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
|
|
leadingIcon = {
|
|
Icon(
|
|
Icons.Default.GraphicEq,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
},
|
|
trailingIcon = {
|
|
Icon(
|
|
Icons.Default.ArrowDropDown,
|
|
contentDescription = null,
|
|
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
|
|
)
|
|
}
|
|
)
|
|
if (showTtsSettingsExpanded) {
|
|
if (showTtsVoiceSettings) {
|
|
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)
|
|
)
|
|
}
|
|
)
|
|
}
|
|
if (showTtsReplacements) {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onOpenTtsReplacements()
|
|
},
|
|
leadingIcon = {
|
|
Icon(
|
|
Icons.Default.GraphicEq,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun EpubJumpHistoryBar(
|
|
modifier: Modifier = Modifier,
|
|
showStandardBars: Boolean,
|
|
searchStateActive: Boolean,
|
|
backLabel: String?,
|
|
forwardLabel: String?,
|
|
onBack: () -> Unit,
|
|
onForward: () -> Unit,
|
|
onClear: () -> Unit
|
|
) {
|
|
AnimatedVisibility(
|
|
visible = showStandardBars && !searchStateActive && (backLabel != null || forwardLabel != null),
|
|
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
|
|
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
|
|
modifier = modifier
|
|
) {
|
|
Surface(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
|
tonalElevation = 3.dp
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(40.dp)
|
|
.padding(horizontal = 8.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
TextButton(
|
|
onClick = onBack,
|
|
enabled = backLabel != null,
|
|
modifier = Modifier.weight(1f)
|
|
) {
|
|
Icon(
|
|
Icons.AutoMirrored.Filled.ArrowBack,
|
|
contentDescription = stringResource(R.string.content_desc_jump_back),
|
|
modifier = Modifier.size(16.dp)
|
|
)
|
|
Spacer(Modifier.width(4.dp))
|
|
Text(
|
|
text = backLabel.orEmpty(),
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
}
|
|
|
|
TextButton(
|
|
onClick = onClear,
|
|
modifier = Modifier.weight(1f)
|
|
) {
|
|
Icon(
|
|
Icons.Default.Close,
|
|
contentDescription = stringResource(R.string.action_clear),
|
|
modifier = Modifier.size(16.dp)
|
|
)
|
|
Spacer(Modifier.width(4.dp))
|
|
Text(stringResource(R.string.action_clear), maxLines = 1)
|
|
}
|
|
|
|
TextButton(
|
|
onClick = onForward,
|
|
enabled = forwardLabel != null,
|
|
modifier = Modifier.weight(1f)
|
|
) {
|
|
Text(
|
|
text = forwardLabel.orEmpty(),
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
Spacer(Modifier.width(4.dp))
|
|
Icon(
|
|
Icons.AutoMirrored.Filled.ArrowForward,
|
|
contentDescription = stringResource(R.string.content_desc_jump_forward),
|
|
modifier = Modifier.size(16.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,
|
|
isSliderActive: Boolean,
|
|
onOpenSlider: () -> Unit,
|
|
onOpenDrawer: () -> Unit,
|
|
onToggleFormat: () -> Unit,
|
|
onToggleSearch: () -> Unit,
|
|
onOpenAiHub: () -> Unit,
|
|
onOpenDictionarySettings: () -> Unit,
|
|
onOpenThemeSettings: () -> Unit,
|
|
onOpenBrightness: () -> Unit,
|
|
onToggleTts: () -> Unit,
|
|
onOpenScreenOrientation: () -> Unit,
|
|
hiddenTools: Set<String>,
|
|
toolOrder: List<ReaderTool>,
|
|
bottomTools: 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
|
|
) {
|
|
toolOrder
|
|
.filter { it in epubToolbarTools && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
|
.forEach { tool ->
|
|
when (tool) {
|
|
ReaderTool.DICTIONARY -> 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))
|
|
}
|
|
ReaderTool.THEME -> 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))
|
|
}
|
|
ReaderTool.BRIGHTNESS -> TooltipIconButton(
|
|
text = stringResource(R.string.reader_brightness_title),
|
|
description = stringResource(R.string.reader_brightness_system_desc),
|
|
onClick = onOpenBrightness
|
|
) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.contrast),
|
|
contentDescription = stringResource(R.string.reader_brightness_title)
|
|
)
|
|
}
|
|
ReaderTool.SLIDER -> TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_slider),
|
|
description = stringResource(R.string.tooltip_slider_desc),
|
|
onClick = onOpenSlider
|
|
) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.slider),
|
|
contentDescription = stringResource(R.string.content_desc_navigate_slider),
|
|
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
|
)
|
|
}
|
|
ReaderTool.TOC -> 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)
|
|
)
|
|
}
|
|
ReaderTool.FORMAT -> 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)
|
|
)
|
|
}
|
|
ReaderTool.SEARCH -> 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)
|
|
)
|
|
}
|
|
ReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
|
|
TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_ai),
|
|
description = stringResource(R.string.tooltip_ai_desc),
|
|
onClick = onOpenAiHub
|
|
) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.ai),
|
|
contentDescription = stringResource(R.string.ai_features_title)
|
|
)
|
|
}
|
|
}
|
|
ReaderTool.TTS_CONTROLS -> 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
|
|
)
|
|
}
|
|
ReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
|
|
text = stringResource(R.string.menu_screen_orientation),
|
|
description = stringResource(R.string.visual_options_screen_orientation_desc),
|
|
onClick = onOpenScreenOrientation
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.ScreenRotation,
|
|
contentDescription = stringResource(R.string.menu_screen_orientation)
|
|
)
|
|
}
|
|
else -> Unit
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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>,
|
|
onScrub: (Float) -> Unit,
|
|
onJumpToPage: (Int) -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
activeColor: Color = Color.Unspecified,
|
|
inactiveColor: Color = Color.Unspecified,
|
|
contentColor: Color = Color.Unspecified,
|
|
thumbnailSurfaceColor: Color = Color.Unspecified,
|
|
thumbnailContentColor: Color = Color.Unspecified
|
|
) {
|
|
val effectiveActiveColor = if (activeColor == Color.Unspecified) {
|
|
MaterialTheme.colorScheme.primary
|
|
} else {
|
|
activeColor
|
|
}
|
|
val effectiveInactiveColor = if (inactiveColor == Color.Unspecified) {
|
|
MaterialTheme.colorScheme.surfaceVariant
|
|
} else {
|
|
inactiveColor
|
|
}
|
|
val effectiveContentColor = if (contentColor == Color.Unspecified) {
|
|
MaterialTheme.colorScheme.onSurface
|
|
} else {
|
|
contentColor
|
|
}
|
|
val effectiveThumbnailSurfaceColor = if (thumbnailSurfaceColor == Color.Unspecified) {
|
|
MaterialTheme.colorScheme.surfaceVariant
|
|
} else {
|
|
thumbnailSurfaceColor
|
|
}
|
|
val effectiveThumbnailContentColor = if (thumbnailContentColor == Color.Unspecified) {
|
|
MaterialTheme.colorScheme.onSurfaceVariant
|
|
} else {
|
|
thumbnailContentColor
|
|
}
|
|
|
|
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)),
|
|
modifier = modifier
|
|
) {
|
|
Column(modifier = Modifier.fillMaxWidth()) {
|
|
Spacer(Modifier.height(72.dp))
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
|
|
.padding(horizontal = 32.dp, vertical = 14.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 = effectiveActiveColor,
|
|
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 = effectiveInactiveColor,
|
|
shape = trackShape
|
|
)
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth(fraction)
|
|
.fillMaxHeight()
|
|
.background(
|
|
color = effectiveActiveColor,
|
|
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,
|
|
borderColor = effectiveActiveColor,
|
|
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,
|
|
borderColor = effectiveActiveColor,
|
|
onClick = { onJumpToPage(sliderStartPage) }
|
|
) {
|
|
PaginatedThumbnailContent(
|
|
pageNumber = sliderStartPage,
|
|
chapterTitle = startPageChapterTitle,
|
|
surfaceColor = effectiveThumbnailSurfaceColor,
|
|
contentColor = effectiveThumbnailContentColor
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
Text(
|
|
text = "${sliderCurrentPage.roundToInt()} / $totalPages",
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = effectiveContentColor,
|
|
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,
|
|
borderColor: Color = Color.Unspecified,
|
|
onClick: () -> Unit,
|
|
content: @Composable () -> Unit
|
|
) {
|
|
val effectiveBorderColor = if (borderColor == Color.Unspecified) {
|
|
MaterialTheme.colorScheme.primary
|
|
} else {
|
|
borderColor
|
|
}
|
|
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, effectiveBorderColor)
|
|
) {
|
|
content()
|
|
}
|
|
Box(
|
|
modifier = Modifier
|
|
.offset(y = (-4).dp)
|
|
.size(8.dp)
|
|
.rotate(45f)
|
|
.background(effectiveBorderColor)
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun PaginatedThumbnailContent(
|
|
pageNumber: Int,
|
|
chapterTitle: String?,
|
|
surfaceColor: Color = Color.Unspecified,
|
|
contentColor: Color = Color.Unspecified
|
|
) {
|
|
val effectiveSurfaceColor = if (surfaceColor == Color.Unspecified) {
|
|
MaterialTheme.colorScheme.surfaceVariant
|
|
} else {
|
|
surfaceColor
|
|
}
|
|
val effectiveContentColor = if (contentColor == Color.Unspecified) {
|
|
MaterialTheme.colorScheme.onSurfaceVariant
|
|
} else {
|
|
contentColor
|
|
}
|
|
Surface(
|
|
modifier = Modifier.fillMaxSize(),
|
|
color = effectiveSurfaceColor,
|
|
contentColor = effectiveContentColor
|
|
) {
|
|
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 = stringResource(R.string.action_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>,
|
|
toolOrder: List<ReaderTool>,
|
|
bottomTools: Set<String>,
|
|
onUpdate: (Set<String>) -> Unit,
|
|
onOrderUpdate: (List<ReaderTool>) -> Unit,
|
|
onPlacementUpdate: (Set<String>) -> Unit,
|
|
onDismiss: () -> Unit
|
|
) {
|
|
var localHiddenTools by remember { mutableStateOf(hiddenTools) }
|
|
|
|
var flatItems by remember {
|
|
mutableStateOf(
|
|
buildReaderToolbarItems(
|
|
hiddenTools = hiddenTools,
|
|
toolOrder = toolOrder,
|
|
bottomTools = bottomTools
|
|
)
|
|
)
|
|
}
|
|
|
|
val commitDragDrop = {
|
|
val newHidden = localHiddenTools.filter { toolName ->
|
|
toolOrder.find { it.name == toolName } !in epubToolbarTools
|
|
}.toMutableSet()
|
|
|
|
val newBottom = mutableSetOf<String>()
|
|
val newOrder = mutableListOf<ReaderTool>()
|
|
|
|
flatItems.forEach { item ->
|
|
if (item.type == FlatItemType.TOOL && item.tool != null) {
|
|
newOrder.add(item.tool)
|
|
if (item.section == ToolbarSection.HIDDEN) newHidden.add(item.tool.name)
|
|
if (item.section == ToolbarSection.BOTTOM) newBottom.add(item.tool.name)
|
|
}
|
|
}
|
|
|
|
val moreTools = flatItems.filter { it.type == FlatItemType.MORE_TOOL }.mapNotNull { it.tool }
|
|
newOrder.addAll(moreTools)
|
|
|
|
localHiddenTools = newHidden
|
|
onUpdate(newHidden)
|
|
onPlacementUpdate(newBottom)
|
|
onOrderUpdate(newOrder)
|
|
}
|
|
|
|
val lazyListState = rememberLazyListState()
|
|
val dragDropState = remember {
|
|
DragDropState(lazyListState) { fromKey, toKey ->
|
|
val fromIndex = flatItems.indexOfFirst { it.id == fromKey }
|
|
val toIndex = flatItems.indexOfFirst { it.id == toKey }
|
|
if (fromIndex == -1 || toIndex == -1 || fromIndex == toIndex) return@DragDropState
|
|
|
|
val fromItem = flatItems[fromIndex]
|
|
if (fromItem.type != FlatItemType.TOOL) return@DragDropState
|
|
|
|
val toItem = flatItems[toIndex]
|
|
if (toItem.type == FlatItemType.MORE_HEADER || toItem.type == FlatItemType.MORE_TOOL) return@DragDropState
|
|
|
|
val newList = flatItems.toMutableList()
|
|
val movedItem = newList.removeAt(fromIndex)
|
|
|
|
val newToIndex = newList.indexOfFirst { it.id == toKey }
|
|
val insertIndex = if (fromIndex < toIndex) newToIndex + 1 else newToIndex
|
|
|
|
newList.add(insertIndex, movedItem)
|
|
|
|
var actualSection = movedItem.section
|
|
for (i in insertIndex downTo 0) {
|
|
val item = newList[i]
|
|
if (item.type == FlatItemType.SECTION_HEADER) {
|
|
actualSection = item.section
|
|
break
|
|
}
|
|
}
|
|
|
|
newList[insertIndex] = movedItem.copy(section = actualSection)
|
|
|
|
flatItems = newList
|
|
}
|
|
}
|
|
|
|
val resetToDefault = {
|
|
val defaultHiddenTools = defaultReaderHiddenTools()
|
|
val defaultToolOrder = defaultReaderToolOrder()
|
|
val defaultBottomTools = defaultReaderBottomTools()
|
|
|
|
localHiddenTools = defaultHiddenTools
|
|
flatItems = buildReaderToolbarItems(
|
|
hiddenTools = defaultHiddenTools,
|
|
toolOrder = defaultToolOrder,
|
|
bottomTools = defaultBottomTools
|
|
)
|
|
onUpdate(defaultHiddenTools)
|
|
onPlacementUpdate(defaultBottomTools)
|
|
onOrderUpdate(defaultToolOrder)
|
|
}
|
|
|
|
Dialog(
|
|
onDismissRequest = onDismiss,
|
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
|
) {
|
|
Surface(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.windowInsetsPadding(WindowInsets.navigationBars),
|
|
color = MaterialTheme.colorScheme.surface
|
|
) {
|
|
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp)) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.title_customize_toolbar),
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
fontWeight = FontWeight.Bold,
|
|
color = MaterialTheme.colorScheme.onSurface,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
TextButton(onClick = resetToDefault) {
|
|
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
Spacer(Modifier.width(4.dp))
|
|
Text(stringResource(R.string.action_reset))
|
|
}
|
|
IconButton(onClick = onDismiss) {
|
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
|
}
|
|
}
|
|
|
|
LazyColumn(
|
|
state = lazyListState,
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentPadding = PaddingValues(bottom = 24.dp)
|
|
) {
|
|
items(flatItems, key = { it.id }) { item ->
|
|
val isDragged = item.id == dragDropState.draggedItemId
|
|
|
|
val zIndex = if (isDragged) 1f else 0f
|
|
val elevation = if (isDragged) 8.dp else 0.dp
|
|
val scale = if (isDragged) 1.03f else 1f
|
|
val translationY = if (isDragged) dragDropState.dragOffset.y else 0f
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.then(if (isDragged) Modifier else Modifier.animateItem())
|
|
.zIndex(zIndex)
|
|
.graphicsLayer {
|
|
this.translationY = translationY
|
|
this.scaleX = scale
|
|
this.scaleY = scale
|
|
this.shadowElevation = elevation.toPx()
|
|
}
|
|
) {
|
|
when (item.type) {
|
|
FlatItemType.SECTION_HEADER -> {
|
|
val titleRes = item.titleRes
|
|
Text(
|
|
text = if (titleRes != null) stringResource(titleRes) else item.title.orEmpty(),
|
|
style = MaterialTheme.typography.titleMedium,
|
|
fontWeight = FontWeight.Bold,
|
|
color = MaterialTheme.colorScheme.onSurface,
|
|
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp, start = 4.dp)
|
|
)
|
|
}
|
|
FlatItemType.EMPTY_PLACEHOLDER -> {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(64.dp)
|
|
.padding(vertical = 4.dp)
|
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Text(stringResource(R.string.toolbar_drop_tools_here), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
}
|
|
FlatItemType.TOOL -> {
|
|
ToolbarDragRow(
|
|
tool = item.tool!!,
|
|
isDragging = isDragged,
|
|
onDragStart = { dragDropState.onDragStart(item.id) },
|
|
onDrag = { dragDropState.onDrag(it) },
|
|
onDragEnd = {
|
|
dragDropState.onDragEnd()
|
|
flatItems = sanitizePlaceholders(flatItems).toMutableList()
|
|
commitDragDrop()
|
|
}
|
|
)
|
|
}
|
|
FlatItemType.MORE_HEADER -> {
|
|
val titleRes = item.titleRes
|
|
Text(
|
|
text = if (titleRes != null) stringResource(titleRes) else item.title ?: stringResource(R.string.toolbar_more_menu),
|
|
style = MaterialTheme.typography.labelMedium,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
|
|
)
|
|
}
|
|
FlatItemType.MORE_TOOL -> {
|
|
MoreToolVisibilityRow(
|
|
title = stringResource(item.tool!!.titleRes),
|
|
visible = !localHiddenTools.contains(item.tool.name),
|
|
onToggle = {
|
|
localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
|
|
localHiddenTools - item.tool.name
|
|
} else {
|
|
localHiddenTools + item.tool.name
|
|
}
|
|
onUpdate(localHiddenTools)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ToolbarDragRow(
|
|
tool: ReaderTool,
|
|
isDragging: Boolean,
|
|
onDragStart: () -> Unit,
|
|
onDrag: (Offset) -> Unit,
|
|
onDragEnd: () -> Unit
|
|
) {
|
|
Surface(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(vertical = 4.dp),
|
|
shape = RoundedCornerShape(12.dp),
|
|
color = if (isDragging) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
|
) {
|
|
Row(
|
|
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
ToolPreviewIcon(tool)
|
|
Spacer(Modifier.width(16.dp))
|
|
Text(
|
|
text = stringResource(tool.titleRes),
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = MaterialTheme.colorScheme.onSurface,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
Icon(
|
|
Icons.Default.Menu,
|
|
contentDescription = stringResource(R.string.content_desc_drag_to_reorder),
|
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier
|
|
.size(32.dp)
|
|
.padding(6.dp)
|
|
.clip(CircleShape)
|
|
.pointerInput(tool) {
|
|
detectDragGestures(
|
|
onDragStart = { onDragStart() },
|
|
onDrag = { change, dragAmount ->
|
|
change.consume()
|
|
onDrag(dragAmount)
|
|
},
|
|
onDragEnd = onDragEnd,
|
|
onDragCancel = onDragEnd
|
|
)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun MoreToolVisibilityRow(
|
|
title: String,
|
|
visible: Boolean,
|
|
onToggle: () -> Unit
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clip(RoundedCornerShape(8.dp))
|
|
.clickable(onClick = onToggle)
|
|
.padding(vertical = 12.dp, horizontal = 8.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
text = title,
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = MaterialTheme.colorScheme.onSurface,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
if (visible) {
|
|
Icon(Icons.Default.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum class ToolbarSection(@StringRes val titleRes: Int) {
|
|
TOP(R.string.toolbar_top_bar),
|
|
BOTTOM(R.string.toolbar_bottom_bar),
|
|
HIDDEN(R.string.toolbar_hidden_tools)
|
|
}
|
|
|
|
@Composable
|
|
private fun ToolPreviewIcon(tool: ReaderTool, isSliderActive: Boolean = false) {
|
|
val title = stringResource(tool.titleRes)
|
|
when (tool) {
|
|
ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp))
|
|
ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp))
|
|
ReaderTool.BRIGHTNESS -> Icon(painterResource(id = R.drawable.contrast), contentDescription = title, modifier = Modifier.size(20.dp))
|
|
ReaderTool.SLIDER -> Icon(
|
|
painterResource(id = R.drawable.slider),
|
|
contentDescription = title,
|
|
modifier = Modifier.size(20.dp),
|
|
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
|
)
|
|
ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp))
|
|
ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = title, modifier = Modifier.size(20.dp))
|
|
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
|
|
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp))
|
|
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp))
|
|
ReaderTool.FILE_INFO -> Icon(Icons.Default.Info, contentDescription = title, modifier = Modifier.size(20.dp))
|
|
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp))
|
|
else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp))
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun HiddenEpubToolMenuItem(
|
|
tool: ReaderTool,
|
|
isSliderActive: Boolean,
|
|
showMoreMenu: () -> Unit,
|
|
onOpenDictionarySettings: () -> Unit,
|
|
onOpenThemeSettings: () -> Unit,
|
|
onOpenBrightness: () -> Unit,
|
|
onOpenSlider: () -> Unit,
|
|
onOpenDrawer: () -> Unit,
|
|
onToggleFormat: () -> Unit,
|
|
onToggleSearch: () -> Unit,
|
|
onOpenAiHub: () -> Unit,
|
|
onToggleTts: () -> Unit,
|
|
onOpenScreenOrientation: () -> Unit
|
|
) {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(tool.titleRes)) },
|
|
onClick = {
|
|
showMoreMenu()
|
|
when (tool) {
|
|
ReaderTool.DICTIONARY -> onOpenDictionarySettings()
|
|
ReaderTool.THEME -> onOpenThemeSettings()
|
|
ReaderTool.BRIGHTNESS -> onOpenBrightness()
|
|
ReaderTool.SLIDER -> onOpenSlider()
|
|
ReaderTool.TOC -> onOpenDrawer()
|
|
ReaderTool.FORMAT -> onToggleFormat()
|
|
ReaderTool.SEARCH -> onToggleSearch()
|
|
ReaderTool.AI_FEATURES -> onOpenAiHub()
|
|
ReaderTool.TTS_CONTROLS -> onToggleTts()
|
|
ReaderTool.SCREEN_ORIENTATION -> onOpenScreenOrientation()
|
|
else -> Unit
|
|
}
|
|
},
|
|
leadingIcon = { ToolPreviewIcon(tool, isSliderActive = isSliderActive) },
|
|
trailingIcon = if (tool == ReaderTool.SLIDER && isSliderActive) {
|
|
{
|
|
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
|
|
}
|
|
} else null
|
|
)
|
|
}
|
|
|
|
@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 = 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 progressPercent = ttsState.bookProgressPercent
|
|
val cleanChapterTitle = remember(ttsState.chapterTitle) {
|
|
ttsState.chapterTitle
|
|
?.lineSequence()
|
|
?.map { it.trim() }
|
|
?.filter { it.isNotBlank() }
|
|
?.joinToString(" - ")
|
|
?.takeIf { it.isNotBlank() }
|
|
}
|
|
val chapterLabel = remember(ttsState.chapterIndex, ttsState.totalChapters, cleanChapterTitle) {
|
|
val chapterNumber = ttsState.chapterIndex?.plus(1)
|
|
val totalChapters = ttsState.totalChapters
|
|
when {
|
|
chapterNumber != null && totalChapters != null -> buildString {
|
|
append("Chapter $chapterNumber of $totalChapters")
|
|
if (!cleanChapterTitle.isNullOrBlank()) append(": $cleanChapterTitle")
|
|
}
|
|
chapterNumber != null -> buildString {
|
|
append("Chapter $chapterNumber")
|
|
if (!cleanChapterTitle.isNullOrBlank()) append(": $cleanChapterTitle")
|
|
}
|
|
!cleanChapterTitle.isNullOrBlank() -> cleanChapterTitle
|
|
else -> null
|
|
}
|
|
}
|
|
val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) {
|
|
if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) {
|
|
"Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}"
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
val canSkipPreviousChunk = !ttsState.isLoading &&
|
|
ttsState.currentChunkIndex > 0 &&
|
|
ttsState.totalChunks > 0
|
|
val canSkipNextChunk = !ttsState.isLoading &&
|
|
ttsState.currentChunkIndex >= 0 &&
|
|
ttsState.currentChunkIndex < ttsState.totalChunks - 1
|
|
|
|
val saveAndApply = {
|
|
saveTtsSpeechRate(context, rate)
|
|
saveTtsPitch(context, pitch)
|
|
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
|
|
ttsController.setPlaybackParameters(rate, pitch)
|
|
} else {
|
|
ttsController.sliceAndRetainPosition()
|
|
}
|
|
}
|
|
|
|
Surface(
|
|
shape = RoundedCornerShape(28.dp),
|
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
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),
|
|
stringResource(R.string.content_desc_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,
|
|
shape = RoundedCornerShape(8.dp)
|
|
) {
|
|
Text(
|
|
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
|
|
stringResource(R.string.tts_mode_cloud_ai)
|
|
} else {
|
|
stringResource(R.string.tts_mode_device_native)
|
|
},
|
|
style = MaterialTheme.typography.labelMedium,
|
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
|
)
|
|
}
|
|
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
|
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() ?: stringResource(R.string.label_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,
|
|
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, stringResource(R.string.content_desc_collapse), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) {
|
|
Icon(Icons.Default.Close, stringResource(R.string.content_desc_stop_tts), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(Modifier.height(16.dp))
|
|
|
|
if (chapterLabel != null || progressPercent != null || chunkLabel != null) {
|
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
chapterLabel ?: "Reading",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onSurface,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
modifier = Modifier.weight(1f).padding(end = 8.dp)
|
|
)
|
|
Text(
|
|
listOfNotNull(progressPercent?.let { "$it%" }, chunkLabel).joinToString(" - "),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
maxLines = 1
|
|
)
|
|
}
|
|
}
|
|
|
|
Spacer(Modifier.height(16.dp))
|
|
}
|
|
|
|
// Middle Section: Controls
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Row(
|
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
IconButton(
|
|
enabled = canSkipPreviousChunk,
|
|
onClick = { ttsController.skipToPreviousChunk() },
|
|
modifier = Modifier.size(40.dp)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.SkipPrevious,
|
|
contentDescription = stringResource(R.string.content_desc_tts_previous_chunk),
|
|
modifier = Modifier.size(24.dp)
|
|
)
|
|
}
|
|
|
|
// 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),
|
|
stringResource(R.string.content_desc_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
|
|
)
|
|
}
|
|
|
|
IconButton(
|
|
enabled = canSkipNextChunk,
|
|
onClick = { ttsController.skipToNextChunk() },
|
|
modifier = Modifier.size(40.dp)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.SkipNext,
|
|
contentDescription = stringResource(R.string.content_desc_tts_next_chunk),
|
|
modifier = Modifier.size(24.dp)
|
|
)
|
|
}
|
|
}
|
|
|
|
Spacer(Modifier.width(12.dp))
|
|
|
|
// Unified Sliders Block
|
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
stringResource(R.string.tts_speed_short, "%.1f".format(rate)),
|
|
style = MaterialTheme.typography.labelMedium
|
|
)
|
|
IconButton(onClick = { rate = 1.0f; saveAndApply() }, modifier = Modifier.size(24.dp)) {
|
|
Icon(Icons.Default.Refresh, stringResource(R.string.content_desc_reset_speed), modifier = Modifier.size(16.dp))
|
|
}
|
|
}
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
IconButton(
|
|
onClick = { rate = ((rate * 10f).roundToInt() / 10f - 0.1f).coerceAtLeast(0.5f); saveAndApply() },
|
|
modifier = Modifier.size(32.dp)
|
|
) {
|
|
Icon(Icons.Default.Remove, contentDescription = null, modifier = Modifier.size(18.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,
|
|
modifier = Modifier.weight(1f).height(20.dp),
|
|
thumb = {
|
|
Box(
|
|
modifier = Modifier
|
|
.size(14.dp)
|
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
|
)
|
|
},
|
|
track = { sliderState ->
|
|
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(2.dp)
|
|
.background(
|
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.22f),
|
|
RoundedCornerShape(1.dp)
|
|
),
|
|
contentAlignment = Alignment.CenterStart
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth(fraction)
|
|
.height(2.dp)
|
|
.background(
|
|
MaterialTheme.colorScheme.primary,
|
|
RoundedCornerShape(1.dp)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
)
|
|
IconButton(
|
|
onClick = { rate = ((rate * 10f).roundToInt() / 10f + 0.1f).coerceAtMost(3.0f); saveAndApply() },
|
|
modifier = Modifier.size(32.dp)
|
|
) {
|
|
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
}
|
|
}
|
|
}
|
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
stringResource(R.string.tts_pitch_short, "%.1f".format(pitch)),
|
|
style = MaterialTheme.typography.labelMedium
|
|
)
|
|
IconButton(onClick = { pitch = 1.0f; saveAndApply() }, modifier = Modifier.size(24.dp)) {
|
|
Icon(Icons.Default.Refresh, stringResource(R.string.content_desc_reset_pitch), modifier = Modifier.size(16.dp))
|
|
}
|
|
}
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
IconButton(
|
|
onClick = { pitch = ((pitch * 10f).roundToInt() / 10f - 0.1f).coerceAtLeast(0.5f); saveAndApply() },
|
|
modifier = Modifier.size(32.dp)
|
|
) {
|
|
Icon(Icons.Default.Remove, contentDescription = null, modifier = Modifier.size(18.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,
|
|
modifier = Modifier.weight(1f).height(20.dp),
|
|
thumb = {
|
|
Box(
|
|
modifier = Modifier
|
|
.size(14.dp)
|
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
|
)
|
|
},
|
|
track = { sliderState ->
|
|
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(2.dp)
|
|
.background(
|
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.22f),
|
|
RoundedCornerShape(1.dp)
|
|
),
|
|
contentAlignment = Alignment.CenterStart
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth(fraction)
|
|
.height(2.dp)
|
|
.background(
|
|
MaterialTheme.colorScheme.primary,
|
|
RoundedCornerShape(1.dp)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
)
|
|
IconButton(
|
|
onClick = { pitch = ((pitch * 10f).roundToInt() / 10f + 0.1f).coerceAtMost(2.0f); saveAndApply() },
|
|
modifier = Modifier.size(32.dp)
|
|
) {
|
|
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|