* Optimized PDF tile rendering performance and quality. * Added support for adjusting TTS voice speed and pitch. * Updated the TTS settings UI and playback logic to support real-time parameter adjustments. * Added horizontal scrolling to the PDF viewer bottom toolbar and updated tool arrangement to use fixed spacing. * Refined cross-page selection and text extraction in `PaginatedReader` * Updated EPUB reader styling logic to refine typography and layout controls. * Bump version to 1.0.42(43)
1642 lines
No EOL
79 KiB
Kotlin
1642 lines
No EOL
79 KiB
Kotlin
/*
|
|
* Episteme Reader - A native Android document reader.
|
|
* Copyright (C) 2026 Episteme
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*
|
|
* mail: epistemereader@gmail.com
|
|
*/
|
|
// EpubReaderControls.kt
|
|
package com.aryan.reader.epubreader
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.graphics.Bitmap
|
|
import android.graphics.Canvas
|
|
import android.os.Build
|
|
import android.speech.tts.TextToSpeech
|
|
import android.webkit.WebView
|
|
import androidx.annotation.RequiresApi
|
|
import androidx.compose.animation.AnimatedContent
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
import androidx.compose.animation.animateContentSize
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
import androidx.compose.animation.slideInVertically
|
|
import androidx.compose.animation.slideOutVertically
|
|
import androidx.compose.animation.togetherWith
|
|
import androidx.compose.foundation.BorderStroke
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.WindowInsets
|
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
|
import androidx.compose.foundation.layout.asPaddingValues
|
|
import androidx.compose.foundation.layout.fillMaxHeight
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.navigationBars
|
|
import androidx.compose.foundation.layout.offset
|
|
import androidx.compose.foundation.layout.only
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.statusBars
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.layout.widthIn
|
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
import androidx.compose.material.icons.filled.Add
|
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
import androidx.compose.material.icons.filled.ArrowUpward
|
|
import androidx.compose.material.icons.filled.Check
|
|
import androidx.compose.material.icons.filled.ChevronLeft
|
|
import androidx.compose.material.icons.filled.ChevronRight
|
|
import androidx.compose.material.icons.filled.Close
|
|
import androidx.compose.material.icons.filled.Delete
|
|
import androidx.compose.material.icons.filled.GraphicEq
|
|
import androidx.compose.material.icons.filled.Menu
|
|
import androidx.compose.material.icons.filled.MoreVert
|
|
import androidx.compose.material.icons.filled.Pause
|
|
import androidx.compose.material.icons.filled.PlayArrow
|
|
import androidx.compose.material.icons.filled.Refresh
|
|
import androidx.compose.material.icons.filled.Remove
|
|
import androidx.compose.material.icons.filled.Search
|
|
import androidx.compose.material.icons.filled.Settings
|
|
import androidx.compose.material.icons.filled.SwapHoriz
|
|
import androidx.compose.material.icons.filled.Tune
|
|
import androidx.compose.material.icons.filled.Visibility
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.DropdownMenu
|
|
import androidx.compose.material3.DropdownMenuItem
|
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
import androidx.compose.material3.FilledIconButton
|
|
import androidx.compose.material3.HorizontalDivider
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.IconButtonDefaults
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.ModalBottomSheet
|
|
import androidx.compose.material3.OutlinedButton
|
|
import androidx.compose.material3.Slider
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Switch
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.collectAsState
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableFloatStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.draw.rotate
|
|
import androidx.compose.ui.graphics.asImageBitmap
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
import androidx.compose.ui.layout.ContentScale
|
|
import androidx.compose.ui.res.painterResource
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.core.graphics.createBitmap
|
|
import androidx.media3.common.util.UnstableApi
|
|
import com.aryan.reader.BuildConfig
|
|
import com.aryan.reader.R
|
|
import com.aryan.reader.RenderMode
|
|
import com.aryan.reader.SearchState
|
|
import com.aryan.reader.SearchTopBar
|
|
import com.aryan.reader.TooltipIconButton
|
|
import com.aryan.reader.epub.EpubChapter
|
|
import com.aryan.reader.loadNativeVoice
|
|
import com.aryan.reader.paginatedreader.BookPaginator
|
|
import com.aryan.reader.paginatedreader.IPaginator
|
|
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.withContext
|
|
import timber.log.Timber
|
|
import kotlin.math.roundToInt
|
|
|
|
enum class ReaderTool(val title: String, val category: String) {
|
|
DICTIONARY("External Apps", "Top Bar"),
|
|
THEME("Theme Settings", "Top Bar"),
|
|
SLIDER("Navigation Slider", "Bottom Bar"),
|
|
TOC("Sidebar", "Bottom Bar"),
|
|
FORMAT("Text Formatting", "Bottom Bar"),
|
|
SEARCH("Search", "Bottom Bar"),
|
|
AI_FEATURES("AI Features", "Bottom Bar"),
|
|
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
|
|
READING_MODE("Reading Mode", "Overflow Menu"),
|
|
BOOKMARK("Bookmark", "Overflow Menu"),
|
|
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
|
|
VOLUME_SCROLL("Volume Button Scrolling", "Overflow Menu"),
|
|
PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"),
|
|
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
|
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
|
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
|
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu")
|
|
}
|
|
|
|
@Composable
|
|
fun EpubReaderTopBar(
|
|
isVisible: Boolean,
|
|
searchState: SearchState,
|
|
bookTitle: String,
|
|
currentRenderMode: RenderMode,
|
|
isBookmarked: Boolean,
|
|
isTtsActive: Boolean,
|
|
tapToNavigateEnabled: Boolean,
|
|
volumeScrollEnabled: Boolean,
|
|
isPageTurnAnimationEnabled: Boolean,
|
|
onNavigateBack: () -> Unit,
|
|
isKeepScreenOn: Boolean,
|
|
onToggleKeepScreenOn: (Boolean) -> Unit,
|
|
onCloseSearch: () -> Unit,
|
|
onChangeRenderMode: (RenderMode) -> Unit,
|
|
onToggleBookmark: () -> Unit,
|
|
onToggleTapToNavigate: (Boolean) -> Unit,
|
|
onToggleVolumeScroll: (Boolean) -> Unit,
|
|
onTogglePageTurnAnimation: (Boolean) -> Unit,
|
|
onStartAutoScroll: () -> Unit,
|
|
onOpenTtsSettings: () -> Unit,
|
|
onOpenDeviceVoiceSettings: () -> Unit,
|
|
onOpenDictionarySettings: () -> Unit,
|
|
onOpenThemeSettings: () -> Unit,
|
|
onOpenVisualOptions: () -> Unit,
|
|
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
|
|
hiddenTools: Set<String>,
|
|
onCustomizeTools: () -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
onToggleReflow: (() -> Unit)? = null,
|
|
onDeleteReflow: (() -> Unit)? = null,
|
|
) {
|
|
AnimatedVisibility(
|
|
visible = isVisible,
|
|
enter = slideInVertically(animationSpec = tween(200)) { -it } + fadeIn(animationSpec = tween(200)),
|
|
exit = slideOutVertically(animationSpec = tween(200)) { -it } + fadeOut(animationSpec = tween(200)),
|
|
modifier = modifier
|
|
) {
|
|
Surface(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(55.dp),
|
|
color = MaterialTheme.colorScheme.surface,
|
|
tonalElevation = 4.dp
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
|
|
.padding(horizontal = 4.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
if (searchState.isSearchActive) {
|
|
SearchTopBar(
|
|
searchState = searchState,
|
|
focusRequester = searchFocusRequester,
|
|
onCloseSearch = onCloseSearch
|
|
)
|
|
} else {
|
|
TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_back),
|
|
description = stringResource(R.string.tooltip_back_desc),
|
|
onClick = onNavigateBack
|
|
) {
|
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
|
}
|
|
Spacer(Modifier.width(8.dp))
|
|
Text(
|
|
text = bookTitle.take(40) + if (bookTitle.length > 40) "..." else "",
|
|
style = MaterialTheme.typography.titleMedium,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
if (!hiddenTools.contains(ReaderTool.DICTIONARY.name)) {
|
|
TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_dictionary),
|
|
description = stringResource(R.string.tooltip_dictionary_desc),
|
|
onClick = onOpenDictionarySettings
|
|
) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.dictionary),
|
|
contentDescription = stringResource(R.string.content_desc_dictionary_settings)
|
|
)
|
|
}
|
|
}
|
|
if (!hiddenTools.contains(ReaderTool.THEME.name)) {
|
|
TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_theme),
|
|
description = stringResource(R.string.tooltip_theme_desc),
|
|
onClick = onOpenThemeSettings
|
|
) {
|
|
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
|
|
}
|
|
}
|
|
Box {
|
|
var showMoreMenu by remember { mutableStateOf(false) }
|
|
TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_more_options),
|
|
description = stringResource(R.string.tooltip_more_options_desc),
|
|
onClick = { showMoreMenu = true }
|
|
) {
|
|
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.content_desc_more_options))
|
|
}
|
|
|
|
DropdownMenu(
|
|
expanded = showMoreMenu,
|
|
onDismissRequest = { showMoreMenu = false }
|
|
) {
|
|
DropdownMenuItem(
|
|
text = { Text("Customize Toolbar") },
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onCustomizeTools()
|
|
},
|
|
leadingIcon = {
|
|
Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp))
|
|
}
|
|
)
|
|
HorizontalDivider()
|
|
|
|
if (onToggleReflow != null) {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_view_original_pdf)) },
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onToggleReflow()
|
|
},
|
|
leadingIcon = {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.picture_as_pdf),
|
|
contentDescription = null,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
}
|
|
)
|
|
HorizontalDivider()
|
|
}
|
|
|
|
onDeleteReflow?.let {
|
|
HorizontalDivider()
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_delete_text_view)) },
|
|
onClick = {
|
|
showMoreMenu = false
|
|
it()
|
|
},
|
|
leadingIcon = {
|
|
Icon(
|
|
imageVector = Icons.Default.Delete,
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colorScheme.error
|
|
)
|
|
},
|
|
colors = androidx.compose.material3.MenuDefaults.itemColors(
|
|
textColor = MaterialTheme.colorScheme.error
|
|
)
|
|
)
|
|
}
|
|
|
|
if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
|
enabled = !isTtsActive,
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
|
|
},
|
|
trailingIcon = {
|
|
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
|
|
Icons.Default.Check,
|
|
contentDescription = stringResource(R.string.content_desc_selected)
|
|
)
|
|
})
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
|
enabled = !isTtsActive,
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onChangeRenderMode(RenderMode.PAGINATED)
|
|
},
|
|
trailingIcon = {
|
|
if (currentRenderMode == RenderMode.PAGINATED) Icon(
|
|
Icons.Default.Check,
|
|
contentDescription = stringResource(R.string.content_desc_selected)
|
|
)
|
|
})
|
|
HorizontalDivider()
|
|
}
|
|
if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) {
|
|
DropdownMenuItem(text = {
|
|
Text(
|
|
if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(
|
|
R.string.menu_bookmark_this_page
|
|
)
|
|
)
|
|
}, onClick = {
|
|
showMoreMenu = false
|
|
onToggleBookmark()
|
|
})
|
|
HorizontalDivider()
|
|
}
|
|
if (!hiddenTools.contains(ReaderTool.TAP_TO_TURN.name)) {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
|
|
enabled = currentRenderMode == RenderMode.PAGINATED,
|
|
onClick = {
|
|
onToggleTapToNavigate(!tapToNavigateEnabled)
|
|
showMoreMenu = false
|
|
},
|
|
trailingIcon = {
|
|
if (tapToNavigateEnabled) Icon(
|
|
Icons.Default.Check,
|
|
contentDescription = stringResource(R.string.content_desc_enabled)
|
|
)
|
|
})
|
|
HorizontalDivider()
|
|
}
|
|
if (!hiddenTools.contains(ReaderTool.VOLUME_SCROLL.name)) {
|
|
DropdownMenuItem(
|
|
text = {
|
|
Text(
|
|
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) stringResource(
|
|
R.string.menu_volume_button_scrolling
|
|
)
|
|
else stringResource(R.string.menu_volume_button_page_turn)
|
|
)
|
|
},
|
|
enabled = true,
|
|
onClick = {
|
|
onToggleVolumeScroll(!volumeScrollEnabled)
|
|
showMoreMenu = false
|
|
},
|
|
trailingIcon = {
|
|
if (volumeScrollEnabled) Icon(
|
|
Icons.Default.Check,
|
|
contentDescription = stringResource(R.string.content_desc_enabled)
|
|
)
|
|
})
|
|
HorizontalDivider()
|
|
}
|
|
if (!hiddenTools.contains(ReaderTool.PAGE_TURN_ANIM.name)) {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_realistic_page_turns)) },
|
|
enabled = currentRenderMode == RenderMode.PAGINATED,
|
|
onClick = {
|
|
onTogglePageTurnAnimation(!isPageTurnAnimationEnabled)
|
|
showMoreMenu = false
|
|
},
|
|
trailingIcon = {
|
|
if (isPageTurnAnimationEnabled) Icon(
|
|
Icons.Default.Check,
|
|
contentDescription = stringResource(R.string.content_desc_enabled)
|
|
)
|
|
})
|
|
HorizontalDivider()
|
|
}
|
|
if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
|
|
onClick = {
|
|
onToggleKeepScreenOn(!isKeepScreenOn)
|
|
showMoreMenu = false
|
|
},
|
|
trailingIcon = {
|
|
if (isKeepScreenOn) Icon(
|
|
Icons.Default.Check,
|
|
contentDescription = stringResource(R.string.content_desc_enabled)
|
|
)
|
|
})
|
|
HorizontalDivider()
|
|
}
|
|
if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_visual_options)) },
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onOpenVisualOptions()
|
|
},
|
|
leadingIcon = {
|
|
Icon(
|
|
Icons.Default.Visibility,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
})
|
|
HorizontalDivider()
|
|
}
|
|
if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_auto_scroll)) },
|
|
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onStartAutoScroll()
|
|
})
|
|
HorizontalDivider()
|
|
}
|
|
if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onOpenDeviceVoiceSettings()
|
|
},
|
|
leadingIcon = {
|
|
Icon(
|
|
Icons.Default.GraphicEq,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
})
|
|
|
|
if (BuildConfig.DEBUG) {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_tts_settings_debug)) },
|
|
onClick = {
|
|
showMoreMenu = false
|
|
onOpenTtsSettings()
|
|
},
|
|
leadingIcon = {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.text_to_speech),
|
|
contentDescription = null,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@androidx.annotation.OptIn(UnstableApi::class)
|
|
@Composable
|
|
fun EpubReaderBottomBar(
|
|
isVisible: Boolean,
|
|
currentRenderMode: RenderMode,
|
|
isTtsSessionActive: Boolean,
|
|
ttsState: TtsState,
|
|
isProUser: Boolean,
|
|
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
|
|
onOpenTtsControls: () -> Unit,
|
|
onOpenSlider: () -> Unit,
|
|
onOpenDrawer: () -> Unit,
|
|
onToggleFormat: () -> Unit,
|
|
onToggleSearch: () -> Unit,
|
|
onSummarize: () -> Unit,
|
|
onRecap: () -> Unit,
|
|
onToggleTts: () -> Unit,
|
|
onPlayPauseTts: () -> Unit,
|
|
hiddenTools: Set<String>,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
AnimatedVisibility(
|
|
visible = isVisible,
|
|
enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)),
|
|
exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)),
|
|
modifier = modifier
|
|
) {
|
|
Surface(
|
|
modifier = Modifier.fillMaxWidth().height(45.dp),
|
|
color = MaterialTheme.colorScheme.surface,
|
|
tonalElevation = 4.dp
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
|
|
.padding(horizontal = 4.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.SpaceAround
|
|
) {
|
|
if (!hiddenTools.contains(ReaderTool.SLIDER.name)) {
|
|
TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_slider),
|
|
description = stringResource(R.string.tooltip_slider_desc),
|
|
onClick = onOpenSlider,
|
|
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
|
|
) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.slider),
|
|
contentDescription = stringResource(R.string.content_desc_navigate_slider)
|
|
)
|
|
}
|
|
}
|
|
if (!hiddenTools.contains(ReaderTool.TOC.name)) {
|
|
TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_toc),
|
|
description = stringResource(R.string.tooltip_toc_desc),
|
|
onClick = onOpenDrawer
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.Menu,
|
|
contentDescription = stringResource(R.string.content_desc_chapters_menu)
|
|
)
|
|
}
|
|
}
|
|
if (!hiddenTools.contains(ReaderTool.FORMAT.name)) {
|
|
TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_format),
|
|
description = stringResource(R.string.tooltip_format_desc),
|
|
onClick = onToggleFormat
|
|
) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.format_size),
|
|
contentDescription = stringResource(R.string.content_desc_text_formatting)
|
|
)
|
|
}
|
|
}
|
|
if (!hiddenTools.contains(ReaderTool.SEARCH.name)) {
|
|
TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_search),
|
|
description = stringResource(R.string.tooltip_search_desc),
|
|
onClick = onToggleSearch
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.Search,
|
|
contentDescription = stringResource(R.string.tooltip_search)
|
|
)
|
|
}
|
|
}
|
|
|
|
if (!hiddenTools.contains(ReaderTool.AI_FEATURES.name)) {
|
|
@Suppress(
|
|
"KotlinConstantConditions",
|
|
"SimplifyBooleanWithConstants"
|
|
) if (BuildConfig.FLAVOR != "oss") {
|
|
Box {
|
|
var showAiFeaturesMenu by remember { mutableStateOf(false) }
|
|
TooltipIconButton(
|
|
text = stringResource(R.string.tooltip_ai),
|
|
description = stringResource(R.string.tooltip_ai_desc),
|
|
onClick = { showAiFeaturesMenu = true }) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.ai),
|
|
contentDescription = "AI Features"
|
|
)
|
|
}
|
|
DropdownMenu(
|
|
expanded = showAiFeaturesMenu,
|
|
onDismissRequest = { showAiFeaturesMenu = false }) {
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_chapter_summarization)) },
|
|
onClick = {
|
|
showAiFeaturesMenu = false
|
|
onSummarize()
|
|
})
|
|
if (BuildConfig.DEBUG && isProUser) {
|
|
HorizontalDivider()
|
|
DropdownMenuItem(
|
|
text = { Text(stringResource(R.string.menu_recap_beta)) },
|
|
onClick = {
|
|
showAiFeaturesMenu = false
|
|
onRecap()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) {
|
|
Box {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
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
|
|
)
|
|
)
|
|
}
|
|
if (isTtsSessionActive) {
|
|
TooltipIconButton(
|
|
text = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause)
|
|
else stringResource(R.string.tooltip_tts_resume),
|
|
description = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause_desc)
|
|
else stringResource(R.string.tooltip_tts_resume_desc),
|
|
onClick = onPlayPauseTts,
|
|
enabled = !ttsState.isLoading
|
|
) {
|
|
Icon(
|
|
painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
|
|
contentDescription = if (ttsState.isPlaying) stringResource(
|
|
R.string.content_desc_pause_tts
|
|
) else stringResource(R.string.content_desc_resume_tts)
|
|
)
|
|
}
|
|
|
|
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.BASE) {
|
|
TooltipIconButton(
|
|
text = "Voice Adjustments",
|
|
description = "Adjust voice speed and pitch",
|
|
onClick = onOpenTtsControls
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.Tune,
|
|
contentDescription = "Voice Adjustments"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
|
@SuppressLint("UnusedBoxWithConstraintsScope")
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun EpubReaderPageSlider(
|
|
isVisible: Boolean,
|
|
currentRenderMode: RenderMode,
|
|
totalPages: Int,
|
|
sliderCurrentPage: Float,
|
|
sliderStartPage: Int,
|
|
startPageThumbnail: Bitmap?,
|
|
paginator: IPaginator?,
|
|
chapters: List<EpubChapter>,
|
|
onClose: () -> Unit,
|
|
onScrub: (Float) -> Unit,
|
|
onJumpToPage: (Int) -> Unit
|
|
) {
|
|
AnimatedVisibility(
|
|
visible = isVisible,
|
|
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
|
|
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200))
|
|
) {
|
|
Box(modifier = Modifier.fillMaxSize()) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.clickable(
|
|
interactionSource = remember { MutableInteractionSource() },
|
|
indication = null
|
|
) { onClose() }
|
|
)
|
|
|
|
IconButton(
|
|
onClick = onClose,
|
|
modifier = Modifier
|
|
.align(Alignment.TopStart)
|
|
.windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Top + WindowInsetsSides.Start))
|
|
.padding(8.dp)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
contentDescription = stringResource(R.string.content_desc_exit_slider)
|
|
)
|
|
}
|
|
|
|
// Bottom controls
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.align(Alignment.BottomCenter)
|
|
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp)
|
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 32.dp, vertical = 16.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
|
) {
|
|
BoxWithConstraints(
|
|
modifier = Modifier.weight(1f),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Slider(
|
|
value = sliderCurrentPage,
|
|
onValueChange = onScrub,
|
|
valueRange = 1f..(totalPages.toFloat().coerceAtLeast(1f)),
|
|
steps = if (totalPages > 2) totalPages - 2 else 0,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
thumb = {
|
|
Surface(
|
|
modifier = Modifier.size(20.dp),
|
|
shape = CircleShape,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
tonalElevation = 0.dp,
|
|
shadowElevation = 0.dp
|
|
) {}
|
|
},
|
|
track = { sliderState ->
|
|
val trackHeight = 2.dp
|
|
val trackShape = RoundedCornerShape(trackHeight)
|
|
val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
|
|
val fraction = if (range == 0f) 0f else {
|
|
((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
|
|
}
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(trackHeight)
|
|
.background(
|
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
|
|
shape = trackShape
|
|
)
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth(fraction)
|
|
.fillMaxHeight()
|
|
.background(
|
|
color = MaterialTheme.colorScheme.primary,
|
|
shape = trackShape
|
|
)
|
|
)
|
|
}
|
|
}
|
|
)
|
|
|
|
// Thumbnail Indicator
|
|
val startPageOffsetFraction = if (totalPages > 1) {
|
|
(sliderStartPage - 1).toFloat() / (totalPages - 1)
|
|
} else {
|
|
0f
|
|
}
|
|
val thumbWidth = 20.dp
|
|
val trackWidth = maxWidth - thumbWidth
|
|
val startPagePixelPosition = (trackWidth * startPageOffsetFraction) + (thumbWidth / 2)
|
|
val thumbnailModifier = Modifier
|
|
.graphicsLayer { clip = false }
|
|
.align(Alignment.TopStart)
|
|
.offset(
|
|
x = startPagePixelPosition - (45.dp / 2),
|
|
y = (-72).dp
|
|
)
|
|
|
|
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
|
|
startPageThumbnail?.let { thumbnail ->
|
|
ThumbnailWithIndicator(
|
|
modifier = thumbnailModifier,
|
|
onClick = { onJumpToPage(sliderStartPage) }
|
|
) {
|
|
Image(
|
|
bitmap = thumbnail.asImageBitmap(),
|
|
contentDescription = stringResource(R.string.content_desc_start_page_thumbnail),
|
|
contentScale = ContentScale.FillBounds,
|
|
modifier = Modifier.fillMaxSize()
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
val startPageChapterIndex = remember(sliderStartPage, paginator) {
|
|
(paginator as? BookPaginator)?.findChapterIndexForPage(sliderStartPage - 1)
|
|
}
|
|
val startPageChapterTitle = remember(startPageChapterIndex) {
|
|
startPageChapterIndex?.let { chapters.getOrNull(it)?.title }
|
|
}
|
|
ThumbnailWithIndicator(
|
|
modifier = thumbnailModifier,
|
|
onClick = { onJumpToPage(sliderStartPage) }
|
|
) {
|
|
PaginatedThumbnailContent(
|
|
pageNumber = sliderStartPage,
|
|
chapterTitle = startPageChapterTitle
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
Text(
|
|
text = "${sliderCurrentPage.roundToInt()} / $totalPages",
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = MaterialTheme.colorScheme.onSurface,
|
|
fontSize = 18.sp
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Helpers moved from Screen ---
|
|
|
|
@Composable
|
|
fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Column(
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
modifier = Modifier
|
|
.background(
|
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
|
|
shape = RoundedCornerShape(16.dp)
|
|
)
|
|
.padding(horizontal = 24.dp, vertical = 16.dp)
|
|
) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.slider),
|
|
contentDescription = null,
|
|
modifier = Modifier.size(48.dp),
|
|
tint = MaterialTheme.colorScheme.primary
|
|
)
|
|
Spacer(Modifier.height(12.dp))
|
|
Text(
|
|
text = "Page $currentPage of $totalPages",
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
color = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
internal fun ThumbnailWithIndicator(modifier: Modifier = Modifier, onClick: () -> Unit, content: @Composable () -> Unit) {
|
|
val borderColor = MaterialTheme.colorScheme.primary
|
|
Column(
|
|
modifier = modifier,
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
) {
|
|
Surface(
|
|
modifier = Modifier
|
|
.width(45.dp)
|
|
.height(64.dp)
|
|
.clickable(onClick = onClick),
|
|
shape = RoundedCornerShape(4.dp),
|
|
border = BorderStroke(2.dp, borderColor)
|
|
) {
|
|
content()
|
|
}
|
|
Box(
|
|
modifier = Modifier
|
|
.offset(y = (-4).dp)
|
|
.size(8.dp)
|
|
.rotate(45f)
|
|
.background(borderColor)
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun PaginatedThumbnailContent(pageNumber: Int, chapterTitle: String?) {
|
|
Surface(
|
|
modifier = Modifier.fillMaxSize(),
|
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
) {
|
|
Column(
|
|
modifier = Modifier.padding(4.dp),
|
|
verticalArrangement = Arrangement.Center,
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
) {
|
|
if (chapterTitle != null) {
|
|
Text(
|
|
text = chapterTitle,
|
|
style = MaterialTheme.typography.labelSmall,
|
|
maxLines = 3,
|
|
overflow = TextOverflow.Ellipsis,
|
|
textAlign = TextAlign.Center,
|
|
lineHeight = 10.sp
|
|
)
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
}
|
|
Text(
|
|
text = "$pageNumber",
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
suspend fun captureWebViewVisibleArea(webView: WebView): Bitmap? {
|
|
return withContext(Dispatchers.Main) {
|
|
if (webView.width <= 0 || webView.height <= 0) return@withContext null
|
|
try {
|
|
val thumbnailWidth = 180
|
|
val thumbnailHeight = 256
|
|
val bitmap = createBitmap(thumbnailWidth, thumbnailHeight)
|
|
val canvas = Canvas(bitmap)
|
|
val scale = thumbnailWidth.toFloat() / webView.width.toFloat()
|
|
canvas.scale(scale, scale)
|
|
canvas.translate(-webView.scrollX.toFloat(), -webView.scrollY.toFloat())
|
|
webView.draw(canvas)
|
|
bitmap
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "Failed to capture webview content")
|
|
null
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun SpeedDropdown(
|
|
label: String,
|
|
currentValue: Float,
|
|
options: List<Float>,
|
|
onValueChange: (Float) -> Unit
|
|
) {
|
|
var expanded by remember { mutableStateOf(false) }
|
|
Box {
|
|
Text(
|
|
text = "$label: ${currentValue}x",
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
modifier = Modifier
|
|
.clickable { expanded = true }
|
|
.padding(4.dp)
|
|
)
|
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
|
options.forEach { opt ->
|
|
DropdownMenuItem(
|
|
text = { Text("${opt}x") },
|
|
onClick = {
|
|
onValueChange(opt)
|
|
expanded = false
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun AutoScrollControls(
|
|
isPlaying: Boolean,
|
|
onPlayPauseToggle: () -> Unit,
|
|
speed: Float,
|
|
minSpeed: Float,
|
|
maxSpeed: Float,
|
|
onSpeedChange: (Float) -> Unit,
|
|
onMinSpeedChange: (Float) -> Unit,
|
|
onMaxSpeedChange: (Float) -> Unit,
|
|
onClose: () -> Unit,
|
|
isCollapsed: Boolean,
|
|
onCollapseChange: (Boolean) -> Unit,
|
|
isMusicianMode: Boolean,
|
|
onMusicianModeToggle: () -> Unit,
|
|
useSlider: Boolean,
|
|
onInputModeToggle: () -> Unit,
|
|
isLocalMode: Boolean,
|
|
onLocalModeToggle: (Boolean) -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
isTempPaused: Boolean = false,
|
|
onScrollToTop: (() -> Unit)? = null
|
|
) {
|
|
val backgroundAlpha = 0.6f
|
|
|
|
Surface(
|
|
shape = RoundedCornerShape(28.dp),
|
|
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = backgroundAlpha),
|
|
tonalElevation = 0.dp,
|
|
shadowElevation = 0.dp,
|
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
|
|
modifier = modifier
|
|
.widthIn(max = 400.dp)
|
|
.animateContentSize()
|
|
) {
|
|
AnimatedContent(
|
|
targetState = isCollapsed,
|
|
transitionSpec = {
|
|
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
|
|
},
|
|
label = "AutoScrollUnified"
|
|
) { collapsed ->
|
|
if (collapsed) {
|
|
Row(
|
|
modifier = Modifier
|
|
.padding(horizontal = 6.dp, vertical = 6.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
|
) {
|
|
IconButton(
|
|
onClick = { onCollapseChange(false) },
|
|
modifier = Modifier.size(36.dp)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.ChevronLeft,
|
|
contentDescription = stringResource(R.string.content_desc_expand),
|
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
|
|
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
|
|
FilledIconButton(
|
|
onClick = onPlayPauseToggle,
|
|
modifier = Modifier.size(36.dp),
|
|
colors = IconButtonDefaults.filledIconButtonColors(
|
|
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
|
contentColor = MaterialTheme.colorScheme.primary
|
|
)
|
|
) {
|
|
Icon(
|
|
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
|
contentDescription = if (isPlaying) stringResource(R.string.content_desc_pause_playback) else stringResource(R.string.content_desc_start_playback),
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
}
|
|
if (isTempPaused && isPlaying) {
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.size(36.dp),
|
|
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f),
|
|
strokeWidth = 2.dp
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Column(
|
|
modifier = Modifier.padding(16.dp)
|
|
) {
|
|
// Top Row: Label & Tools
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Box {
|
|
var showModeMenu by remember { mutableStateOf(false) }
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier
|
|
.clip(RoundedCornerShape(8.dp))
|
|
.clickable { showModeMenu = true }
|
|
.padding(4.dp)
|
|
) {
|
|
Text(
|
|
text = if (isLocalMode) stringResource(R.string.auto_scroll_local_speed) else stringResource(R.string.auto_scroll_global_speed),
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.primary
|
|
)
|
|
Icon(
|
|
imageVector = Icons.Default.ArrowDropDown,
|
|
contentDescription = stringResource(R.string.content_desc_select_mode),
|
|
tint = MaterialTheme.colorScheme.primary,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
}
|
|
|
|
DropdownMenu(
|
|
expanded = showModeMenu,
|
|
onDismissRequest = { showModeMenu = false }
|
|
) {
|
|
DropdownMenuItem(
|
|
text = {
|
|
Column {
|
|
Text(stringResource(R.string.auto_scroll_global_speed), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
|
Text(stringResource(R.string.auto_scroll_applies_all_files), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
},
|
|
onClick = {
|
|
onLocalModeToggle(false)
|
|
showModeMenu = false
|
|
},
|
|
trailingIcon = {
|
|
if (!isLocalMode) Icon(Icons.Default.Check, contentDescription = null)
|
|
}
|
|
)
|
|
HorizontalDivider()
|
|
DropdownMenuItem(
|
|
text = {
|
|
Column {
|
|
Text(stringResource(R.string.auto_scroll_local_speed), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
|
Text(stringResource(R.string.auto_scroll_saved_for_file), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
},
|
|
onClick = {
|
|
onLocalModeToggle(true)
|
|
showModeMenu = false
|
|
},
|
|
trailingIcon = {
|
|
if (isLocalMode) Icon(Icons.Default.Check, contentDescription = null)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
if (onScrollToTop != null) {
|
|
IconButton(
|
|
onClick = onScrollToTop,
|
|
modifier = Modifier.size(32.dp)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.ArrowUpward,
|
|
contentDescription = "Scroll to Top",
|
|
modifier = Modifier.size(18.dp),
|
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
}
|
|
IconButton(
|
|
onClick = onMusicianModeToggle,
|
|
modifier = Modifier.size(32.dp)
|
|
) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.music_note),
|
|
contentDescription = if (isMusicianMode) stringResource(R.string.content_desc_disable_musician_mode) else stringResource(R.string.content_desc_enable_musician_mode),
|
|
modifier = Modifier.size(18.dp),
|
|
tint = if (isMusicianMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
IconButton(
|
|
onClick = onInputModeToggle,
|
|
modifier = Modifier.size(32.dp)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.SwapHoriz,
|
|
contentDescription = stringResource(R.string.content_desc_swap_controls),
|
|
modifier = Modifier.size(18.dp),
|
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
IconButton(
|
|
onClick = { onCollapseChange(true) },
|
|
modifier = Modifier.size(32.dp)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.ChevronRight,
|
|
contentDescription = stringResource(R.string.content_desc_collapse),
|
|
modifier = Modifier.size(18.dp),
|
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
IconButton(
|
|
onClick = onClose,
|
|
modifier = Modifier.size(32.dp)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.Close,
|
|
contentDescription = stringResource(R.string.action_close),
|
|
tint = MaterialTheme.colorScheme.error,
|
|
modifier = Modifier.size(18.dp)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(Modifier.height(16.dp))
|
|
|
|
// Bottom Row: Controls
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
|
) {
|
|
// Play/Pause
|
|
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
|
FilledIconButton(
|
|
onClick = onPlayPauseToggle,
|
|
modifier = Modifier.size(48.dp),
|
|
colors = IconButtonDefaults.filledIconButtonColors(
|
|
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
|
contentColor = MaterialTheme.colorScheme.primary
|
|
)
|
|
) {
|
|
Icon(
|
|
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
|
contentDescription = if (isPlaying) stringResource(R.string.tooltip_tts_pause) else stringResource(R.string.content_desc_start_playback),
|
|
modifier = Modifier.size(24.dp)
|
|
)
|
|
}
|
|
if (isTempPaused && isPlaying) {
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.size(48.dp),
|
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
|
strokeWidth = 3.dp
|
|
)
|
|
}
|
|
}
|
|
|
|
// Speed Controls
|
|
Box(
|
|
modifier = Modifier.weight(1f),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
val speedOptions = listOf(0.1f, 0.5f, 1f, 1.5f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f)
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
SpeedDropdown(
|
|
label = stringResource(R.string.label_min),
|
|
currentValue = minSpeed,
|
|
options = speedOptions,
|
|
onValueChange = onMinSpeedChange
|
|
)
|
|
SpeedDropdown(
|
|
label = stringResource(R.string.label_max),
|
|
currentValue = maxSpeed,
|
|
options = speedOptions,
|
|
onValueChange = onMaxSpeedChange
|
|
)
|
|
}
|
|
Spacer(Modifier.height(4.dp))
|
|
|
|
val safeMax = maxSpeed.coerceAtLeast(minSpeed + 0.1f)
|
|
|
|
if (useSlider) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
Text(
|
|
text = "%.1fx".format(speed),
|
|
style = MaterialTheme.typography.titleMedium.copy(fontFeatureSettings = "tnum"),
|
|
modifier = Modifier.width(45.dp),
|
|
textAlign = TextAlign.End
|
|
)
|
|
val steps = ((safeMax - minSpeed) / 0.1f).roundToInt() - 1
|
|
|
|
Slider(
|
|
value = speed,
|
|
onValueChange = { onSpeedChange((it * 10f).roundToInt() / 10f) },
|
|
valueRange = minSpeed..safeMax,
|
|
steps = if (steps > 0) steps else 0,
|
|
modifier = Modifier.weight(1f),
|
|
thumb = {
|
|
Box(
|
|
modifier = Modifier
|
|
.size(20.dp)
|
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
|
)
|
|
},
|
|
track = { sliderState ->
|
|
val fraction = (sliderState.value - sliderState.valueRange.start) /
|
|
(sliderState.valueRange.endInclusive - sliderState.valueRange.start)
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(4.dp)
|
|
.background(
|
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f),
|
|
RoundedCornerShape(2.dp)
|
|
),
|
|
contentAlignment = Alignment.CenterStart
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth(fraction)
|
|
.height(4.dp)
|
|
.background(
|
|
MaterialTheme.colorScheme.primary,
|
|
RoundedCornerShape(2.dp)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
} else {
|
|
Surface(
|
|
shape = RoundedCornerShape(50),
|
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
|
modifier = Modifier.height(48.dp).fillMaxWidth()
|
|
) {
|
|
Row(
|
|
modifier = Modifier.fillMaxSize(),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
IconButton(
|
|
onClick = { onSpeedChange((speed - 0.1f).coerceAtLeast(minSpeed)) },
|
|
modifier = Modifier.size(48.dp)
|
|
) {
|
|
Icon(Icons.Default.Remove, stringResource(R.string.content_desc_slower))
|
|
}
|
|
Text(
|
|
text = "%.1fx".format(speed),
|
|
style = MaterialTheme.typography.titleMedium.copy(fontFeatureSettings = "tnum"),
|
|
textAlign = TextAlign.Center
|
|
)
|
|
IconButton(
|
|
onClick = { onSpeedChange((speed + 0.1f).coerceAtMost(safeMax)) },
|
|
modifier = Modifier.size(48.dp)
|
|
) {
|
|
Icon(Icons.Default.Add, stringResource(R.string.content_desc_faster))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun CustomizeToolsSheet(
|
|
hiddenTools: Set<String>,
|
|
onUpdate: (Set<String>) -> Unit,
|
|
onDismiss: () -> Unit
|
|
) {
|
|
ModalBottomSheet(
|
|
onDismissRequest = onDismiss,
|
|
contentWindowInsets = { WindowInsets.navigationBars }
|
|
) {
|
|
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
|
|
Text(
|
|
text = "Customize Toolbar",
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
fontWeight = FontWeight.Bold,
|
|
color = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
Text(
|
|
text = "Select the tools you want to keep visible. Unchecking a tool hides it from the UI to give you a distraction-free reading space.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
|
|
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
|
ReaderTool.entries.groupBy { it.category }.forEach { (category, tools) ->
|
|
item {
|
|
Text(
|
|
text = category,
|
|
style = MaterialTheme.typography.labelMedium,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
|
|
)
|
|
}
|
|
items(tools) { tool ->
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clip(RoundedCornerShape(8.dp))
|
|
.clickable {
|
|
val newSet = hiddenTools.toMutableSet()
|
|
if (newSet.contains(tool.name)) newSet.remove(tool.name)
|
|
else newSet.add(tool.name)
|
|
onUpdate(newSet)
|
|
}
|
|
.padding(vertical = 12.dp, horizontal = 8.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
Text(
|
|
text = tool.title,
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
Switch(
|
|
checked = !hiddenTools.contains(tool.name),
|
|
onCheckedChange = { isVisible ->
|
|
val newSet = hiddenTools.toMutableSet()
|
|
if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name)
|
|
onUpdate(newSet)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@androidx.annotation.OptIn(UnstableApi::class)
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun TtsControlsSheet(
|
|
onDismiss: () -> Unit,
|
|
onOpenDeviceVoiceSettings: () -> Unit,
|
|
ttsController: com.aryan.reader.tts.TtsController
|
|
) {
|
|
val context = androidx.compose.ui.platform.LocalContext.current
|
|
val ttsState by ttsController.ttsState.collectAsState()
|
|
|
|
// Local TTS for Sample Playback
|
|
var tts by remember { mutableStateOf<TextToSpeech?>(null) }
|
|
var isTtsReady by remember { mutableStateOf(false) }
|
|
|
|
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) }
|
|
|
|
// Initialize Local TTS for samples
|
|
DisposableEffect(Unit) {
|
|
val instance = TextToSpeech(context) { status ->
|
|
if (status == TextToSpeech.SUCCESS) {
|
|
isTtsReady = true
|
|
try {
|
|
val preferredVoiceName = loadNativeVoice(context)
|
|
if (preferredVoiceName != null) {
|
|
tts?.voices?.find { it.name == preferredVoiceName }?.let { targetVoice ->
|
|
tts?.voice = targetVoice
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "Failed to apply preferred voice in sample")
|
|
}
|
|
}
|
|
}
|
|
tts = instance
|
|
onDispose { instance.shutdown() }
|
|
}
|
|
|
|
val saveAndSlice = {
|
|
saveTtsSpeechRate(context, rate)
|
|
saveTtsPitch(context, pitch)
|
|
ttsController.sliceAndRetainPosition()
|
|
}
|
|
|
|
ModalBottomSheet(
|
|
onDismissRequest = onDismiss,
|
|
contentWindowInsets = { WindowInsets.navigationBars }
|
|
) {
|
|
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
|
|
Text("Voice Adjustments", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
|
Spacer(Modifier.height(16.dp))
|
|
|
|
// Rate Slider
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text("Speed (${"%.1f".format(rate)}x)", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
|
IconButton(onClick = {
|
|
rate = 1.0f
|
|
ttsController.pause()
|
|
saveAndSlice()
|
|
}) {
|
|
Icon(Icons.Default.Refresh, contentDescription = "Reset Speed")
|
|
}
|
|
}
|
|
Slider(
|
|
value = rate,
|
|
onValueChange = {
|
|
rate = it
|
|
// Pause playback immediately when user starts dragging
|
|
if (!isDraggingRate) {
|
|
isDraggingRate = true
|
|
ttsController.pause()
|
|
}
|
|
},
|
|
onValueChangeFinished = {
|
|
isDraggingRate = false
|
|
saveAndSlice()
|
|
},
|
|
valueRange = 0.5f..3.0f,
|
|
steps = 24 // Creates 0.1 increments
|
|
)
|
|
|
|
// Pitch Slider
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text("Pitch (${"%.1f".format(pitch)}x)", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
|
IconButton(onClick = {
|
|
pitch = 1.0f
|
|
ttsController.pause()
|
|
saveAndSlice()
|
|
}) {
|
|
Icon(Icons.Default.Refresh, contentDescription = "Reset Pitch")
|
|
}
|
|
}
|
|
Slider(
|
|
value = pitch,
|
|
onValueChange = {
|
|
pitch = it
|
|
if (!isDraggingPitch) {
|
|
isDraggingPitch = true
|
|
ttsController.pause()
|
|
}
|
|
},
|
|
onValueChangeFinished = {
|
|
isDraggingPitch = false
|
|
saveAndSlice()
|
|
},
|
|
valueRange = 0.5f..2.0f,
|
|
steps = 14 // Creates 0.1 increments
|
|
)
|
|
|
|
Spacer(Modifier.height(8.dp))
|
|
|
|
// Play Sample Button
|
|
Button(
|
|
onClick = {
|
|
if (ttsState.isPlaying) ttsController.pause()
|
|
tts?.setSpeechRate(rate)
|
|
tts?.setPitch(pitch)
|
|
tts?.speak("This is how your current voice settings sound.", TextToSpeech.QUEUE_FLUSH, null, null)
|
|
},
|
|
modifier = Modifier.fillMaxWidth(),
|
|
enabled = isTtsReady,
|
|
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
|
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
|
)
|
|
) {
|
|
Icon(Icons.Default.GraphicEq, contentDescription = null)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Play Sample")
|
|
}
|
|
|
|
Spacer(Modifier.height(24.dp))
|
|
|
|
// Central Play/Pause Control for the Book
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.Center,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
FilledIconButton(
|
|
onClick = {
|
|
tts?.stop()
|
|
if (ttsState.isPlaying) ttsController.pause() else ttsController.resume()
|
|
},
|
|
modifier = Modifier.size(64.dp),
|
|
colors = IconButtonDefaults.filledIconButtonColors(
|
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
|
)
|
|
) {
|
|
if (ttsState.isLoading) {
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.size(32.dp),
|
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
strokeWidth = 3.dp
|
|
)
|
|
} else {
|
|
Icon(
|
|
painter = painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
|
|
contentDescription = if (ttsState.isPlaying) "Pause Book" else "Play Book",
|
|
modifier = Modifier.size(32.dp)
|
|
)
|
|
}
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(
|
|
text = if (ttsState.isPlaying) "Pause Book" else "Resume Book",
|
|
style = MaterialTheme.typography.labelMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
}
|
|
|
|
Spacer(Modifier.height(24.dp))
|
|
OutlinedButton(
|
|
onClick = {
|
|
onDismiss()
|
|
onOpenDeviceVoiceSettings()
|
|
},
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Icon(Icons.Default.Settings, contentDescription = null)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("System Voice / Engine Settings")
|
|
}
|
|
}
|
|
}
|
|
} |