Add Android read aloud support
This commit is contained in:
parent
26343cb8a1
commit
11c8f7c6e4
@ -5,6 +5,9 @@
|
|||||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32"/>
|
android:maxSdkVersion="32"/>
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@ -52,6 +55,10 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/image_clipboard_paths"/>
|
android:resource="@xml/image_clipboard_paths"/>
|
||||||
</provider>
|
</provider>
|
||||||
|
<service
|
||||||
|
android:name=".ReadAloudService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback"/>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -52,6 +52,9 @@ fun initToreadPlatform(context: Context, chooser: AndroidLibraryDirectoryChooser
|
|||||||
directoryChooser = chooser
|
directoryChooser = chooser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun androidAppContext(): Context? =
|
||||||
|
if (::appContext.isInitialized) appContext else null
|
||||||
|
|
||||||
actual fun loadDefaultBookBytes(): ByteArray? = null
|
actual fun loadDefaultBookBytes(): ByteArray? = null
|
||||||
|
|
||||||
actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
|
actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
|
||||||
|
|||||||
@ -31,6 +31,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
|
|||||||
private lateinit var directoryLauncher: ActivityResultLauncher<Uri?>
|
private lateinit var directoryLauncher: ActivityResultLauncher<Uri?>
|
||||||
private lateinit var allFilesAccessLauncher: ActivityResultLauncher<Intent>
|
private lateinit var allFilesAccessLauncher: ActivityResultLauncher<Intent>
|
||||||
private lateinit var readStoragePermissionLauncher: ActivityResultLauncher<String>
|
private lateinit var readStoragePermissionLauncher: ActivityResultLauncher<String>
|
||||||
|
private lateinit var notificationPermissionLauncher: ActivityResultLauncher<String>
|
||||||
private var pendingDirectoryChoice: CompletableDeferred<String?>? = null
|
private var pendingDirectoryChoice: CompletableDeferred<String?>? = null
|
||||||
private var pendingExternalFileAccess: CompletableDeferred<Boolean>? = null
|
private var pendingExternalFileAccess: CompletableDeferred<Boolean>? = null
|
||||||
|
|
||||||
@ -52,6 +53,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
|
|||||||
pendingExternalFileAccess?.complete(granted)
|
pendingExternalFileAccess?.complete(granted)
|
||||||
pendingExternalFileAccess = null
|
pendingExternalFileAccess = null
|
||||||
}
|
}
|
||||||
|
notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||||
initToreadPlatform(this, this)
|
initToreadPlatform(this, this)
|
||||||
@ -62,6 +64,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
|
|||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
requestNotificationPermissionIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun chooseDirectory(): String? {
|
override suspend fun chooseDirectory(): String? {
|
||||||
@ -166,6 +169,12 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
|
|||||||
allFilesAccessLauncher.launch(settingsIntent)
|
allFilesAccessLauncher.launch(settingsIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
|
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
|
||||||
private fun downloadsPath(): String =
|
private fun downloadsPath(): String =
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
|
||||||
?: Environment.getExternalStorageDirectory().absolutePath
|
?: Environment.getExternalStorageDirectory().absolutePath
|
||||||
|
|||||||
@ -0,0 +1,305 @@
|
|||||||
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.speech.tts.TextToSpeech
|
||||||
|
import android.speech.tts.UtteranceProgressListener
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
actual object ReadAloudPlatform {
|
||||||
|
actual val isSupported: Boolean = true
|
||||||
|
actual val state: StateFlow<ReadAloudState> = AndroidReadAloudEngine.state
|
||||||
|
|
||||||
|
actual fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) {
|
||||||
|
val context = androidAppContext() ?: return
|
||||||
|
AndroidReadAloudEngine.prepare(context, bookTitle, sentences, startIndex)
|
||||||
|
ReadAloudService.start(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun play() {
|
||||||
|
val context = androidAppContext() ?: return
|
||||||
|
ReadAloudService.start(context)
|
||||||
|
AndroidReadAloudEngine.play(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun stop() {
|
||||||
|
AndroidReadAloudEngine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun skip(delta: Int) {
|
||||||
|
AndroidReadAloudEngine.skip(delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object AndroidReadAloudEngine {
|
||||||
|
private const val UtterancePrefix = "read-aloud-"
|
||||||
|
private const val BeforePausePrefix = "read-aloud-before-"
|
||||||
|
private const val SpeakPrefix = "read-aloud-speak-"
|
||||||
|
private const val AfterPausePrefix = "read-aloud-after-"
|
||||||
|
|
||||||
|
private val mutableState = MutableStateFlow(ReadAloudState())
|
||||||
|
val state: StateFlow<ReadAloudState> = mutableState
|
||||||
|
|
||||||
|
private var tts: TextToSpeech? = null
|
||||||
|
private var ttsReady = false
|
||||||
|
private var shouldSpeakWhenReady = false
|
||||||
|
private var bookTitle: String = ""
|
||||||
|
private var sentences: List<ReadAloudSentence> = emptyList()
|
||||||
|
private var currentIndex: Int = 0
|
||||||
|
|
||||||
|
fun prepare(context: Context, title: String, queue: List<ReadAloudSentence>, startIndex: Int) {
|
||||||
|
bookTitle = title
|
||||||
|
sentences = queue
|
||||||
|
currentIndex = startIndex.coerceIn(queue.indices.takeIf { queue.isNotEmpty() } ?: 0..0)
|
||||||
|
mutableState.value = ReadAloudState(
|
||||||
|
active = queue.isNotEmpty(),
|
||||||
|
playing = false,
|
||||||
|
sentenceIndex = queue.getOrNull(currentIndex)?.index,
|
||||||
|
)
|
||||||
|
ensureTts(context.applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play(context: Context) {
|
||||||
|
if (sentences.isEmpty()) return
|
||||||
|
ensureTts(context.applicationContext)
|
||||||
|
if (!ttsReady) {
|
||||||
|
shouldSpeakWhenReady = true
|
||||||
|
mutableState.value = mutableState.value.copy(active = true, playing = true, sentenceIndex = currentIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
speakCurrent()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
shouldSpeakWhenReady = false
|
||||||
|
tts?.stop()
|
||||||
|
mutableState.value = ReadAloudState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skip(delta: Int) {
|
||||||
|
if (sentences.isEmpty()) return
|
||||||
|
val wasPlaying = mutableState.value.playing
|
||||||
|
currentIndex = (currentIndex + delta).coerceIn(sentences.indices)
|
||||||
|
mutableState.value = mutableState.value.copy(
|
||||||
|
active = true,
|
||||||
|
playing = wasPlaying,
|
||||||
|
sentenceIndex = currentIndex,
|
||||||
|
)
|
||||||
|
if (wasPlaying && ttsReady) {
|
||||||
|
speakCurrent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureTts(context: Context) {
|
||||||
|
if (tts != null) return
|
||||||
|
tts = TextToSpeech(context) { status ->
|
||||||
|
ttsReady = status == TextToSpeech.SUCCESS
|
||||||
|
if (ttsReady) {
|
||||||
|
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
|
override fun onStart(utteranceId: String?) = Unit
|
||||||
|
|
||||||
|
override fun onDone(utteranceId: String?) {
|
||||||
|
handleUtteranceDone(utteranceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onError(utteranceId: String?) {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(utteranceId: String?, errorCode: Int) {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (shouldSpeakWhenReady) {
|
||||||
|
shouldSpeakWhenReady = false
|
||||||
|
speakCurrent()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun speakCurrent() {
|
||||||
|
val sentence = sentences.getOrNull(currentIndex) ?: run {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mutableState.value = ReadAloudState(active = true, playing = true, sentenceIndex = sentence.index)
|
||||||
|
val params = Bundle().apply {
|
||||||
|
putString(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, "$SpeakPrefix$currentIndex")
|
||||||
|
}
|
||||||
|
val queueMode = if (sentence.pauseBeforeMillis > 0) {
|
||||||
|
tts?.playSilentUtterance(sentence.pauseBeforeMillis, TextToSpeech.QUEUE_FLUSH, "$BeforePausePrefix$currentIndex")
|
||||||
|
TextToSpeech.QUEUE_ADD
|
||||||
|
} else {
|
||||||
|
TextToSpeech.QUEUE_FLUSH
|
||||||
|
}
|
||||||
|
tts?.speak(sentence.text, queueMode, params, "$SpeakPrefix$currentIndex")
|
||||||
|
if (sentence.pauseAfterMillis > 0) {
|
||||||
|
tts?.playSilentUtterance(sentence.pauseAfterMillis, TextToSpeech.QUEUE_ADD, "$AfterPausePrefix$currentIndex")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUtteranceDone(utteranceId: String?) {
|
||||||
|
if (!mutableState.value.playing) return
|
||||||
|
val speakIndex = utteranceId?.removePrefix(SpeakPrefix)?.takeIf { it != utteranceId }?.toIntOrNull()
|
||||||
|
val afterIndex = utteranceId?.removePrefix(AfterPausePrefix)?.takeIf { it != utteranceId }?.toIntOrNull()
|
||||||
|
val finishedIndex = afterIndex ?: speakIndex ?: return
|
||||||
|
if (finishedIndex != currentIndex) return
|
||||||
|
val currentSentence = sentences.getOrNull(currentIndex) ?: run {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (speakIndex != null && currentSentence.pauseAfterMillis > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentIndex < sentences.lastIndex) {
|
||||||
|
currentIndex += 1
|
||||||
|
speakCurrent()
|
||||||
|
} else {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReadAloudService : Service() {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
scope.launch {
|
||||||
|
AndroidReadAloudEngine.state.collect { state ->
|
||||||
|
if (state.active) {
|
||||||
|
updateNotification(state)
|
||||||
|
} else {
|
||||||
|
stopForegroundCompat()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
startForeground(NotificationId, buildNotification(AndroidReadAloudEngine.state.value))
|
||||||
|
when (intent?.action) {
|
||||||
|
ActionPlay -> AndroidReadAloudEngine.play(applicationContext)
|
||||||
|
ActionStop -> AndroidReadAloudEngine.stop()
|
||||||
|
ActionBack -> AndroidReadAloudEngine.skip(-1)
|
||||||
|
ActionForward -> AndroidReadAloudEngine.skip(1)
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(state: ReadAloudState): Notification {
|
||||||
|
val openIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
val contentIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
openIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val playStopAction = if (state.playing) ActionStop else ActionPlay
|
||||||
|
val playStopTitle = if (state.playing) "Stop" else "Play"
|
||||||
|
val playStopIcon = if (state.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, ChannelId)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_background)
|
||||||
|
.setContentTitle("Read aloud")
|
||||||
|
.setContentText("Reading in the background")
|
||||||
|
.setContentIntent(contentIntent)
|
||||||
|
.setOngoing(state.playing)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.addAction(android.R.drawable.ic_media_previous, "Previous", serviceIntent(ActionBack))
|
||||||
|
.addAction(playStopIcon, playStopTitle, serviceIntent(playStopAction))
|
||||||
|
.addAction(android.R.drawable.ic_media_next, "Next", serviceIntent(ActionForward))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serviceIntent(action: String): PendingIntent =
|
||||||
|
PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
action.hashCode(),
|
||||||
|
Intent(this, ReadAloudService::class.java).setAction(action),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun updateNotification(state: ReadAloudState) {
|
||||||
|
if (!hasNotificationPermission()) return
|
||||||
|
runCatching {
|
||||||
|
NotificationManagerCompat.from(this)
|
||||||
|
.notify(NotificationId, buildNotification(state))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
ChannelId,
|
||||||
|
"Read aloud",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
)
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasNotificationPermission(): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
private fun stopForegroundCompat() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
stopForeground(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ChannelId = "read_aloud"
|
||||||
|
private const val NotificationId = 2401
|
||||||
|
private const val ActionPlay = "net.sergeych.toread.READ_ALOUD_PLAY"
|
||||||
|
private const val ActionStop = "net.sergeych.toread.READ_ALOUD_STOP"
|
||||||
|
private const val ActionBack = "net.sergeych.toread.READ_ALOUD_BACK"
|
||||||
|
private const val ActionForward = "net.sergeych.toread.READ_ALOUD_FORWARD"
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
val intent = Intent(context, ReadAloudService::class.java)
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
data class ReadAloudSentence(
|
||||||
|
val index: Int,
|
||||||
|
val itemIndex: Int,
|
||||||
|
val start: Int,
|
||||||
|
val endExclusive: Int,
|
||||||
|
val text: String,
|
||||||
|
val pauseBeforeMillis: Long = 0,
|
||||||
|
val pauseAfterMillis: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReadAloudState(
|
||||||
|
val active: Boolean = false,
|
||||||
|
val playing: Boolean = false,
|
||||||
|
val sentenceIndex: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect object ReadAloudPlatform {
|
||||||
|
val isSupported: Boolean
|
||||||
|
val state: StateFlow<ReadAloudState>
|
||||||
|
|
||||||
|
fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int)
|
||||||
|
fun play()
|
||||||
|
fun stop()
|
||||||
|
fun skip(delta: Int)
|
||||||
|
}
|
||||||
@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
@ -85,6 +84,8 @@ internal fun ContinuousBookReader(
|
|||||||
stats: BookStats,
|
stats: BookStats,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
contentPlan: ReaderContentPlan = remember(book) { buildReaderContentPlan(book) },
|
||||||
|
highlightedSentence: ReadAloudSentence? = null,
|
||||||
onImageOpen: (ViewedBookImage) -> Unit = {},
|
onImageOpen: (ViewedBookImage) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val hyphenation = remember { HyphenationRegistry() }
|
val hyphenation = remember { HyphenationRegistry() }
|
||||||
@ -114,27 +115,78 @@ internal fun ContinuousBookReader(
|
|||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
itemsIndexed(contentPlan.elements) { itemIndex, element ->
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
val highlightedRange = highlightedSentence
|
||||||
|
?.takeIf { it.itemIndex == itemIndex }
|
||||||
|
?.let { ReaderSentenceRange(it.start, it.endExclusive) }
|
||||||
|
when (element) {
|
||||||
|
ReaderElement.Cover -> Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
CoverAndTitle(book, onImageOpen = onImageOpen)
|
CoverAndTitle(book, onImageOpen = onImageOpen)
|
||||||
MetadataCard(book)
|
MetadataCard(book)
|
||||||
StatsCard(stats)
|
StatsCard(stats)
|
||||||
}
|
}
|
||||||
}
|
is ReaderElement.FixedSpacer -> Spacer(Modifier.height(element.heightDp.dp))
|
||||||
item {
|
ReaderElement.SectionSeparator -> Spacer(
|
||||||
Spacer(Modifier.height(6.dp))
|
Modifier.height(2.dp)
|
||||||
}
|
.background(MaterialTheme.colorScheme.secondaryFixedDim)
|
||||||
book.sections.forEachIndexed { index, section ->
|
.fillMaxWidth().padding(vertical = 5.dp, horizontal = 4.dp)
|
||||||
sectionItems(
|
)
|
||||||
book = book,
|
is ReaderElement.SectionTitle -> {
|
||||||
section = section,
|
val titleModifier = Modifier
|
||||||
depth = 0,
|
.fillMaxWidth()
|
||||||
keyPrefix = "section-$index",
|
.then(
|
||||||
hyphenation = hyphenation,
|
if (highlightedRange != null) {
|
||||||
onImageOpen = onImageOpen,
|
Modifier.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.padding(
|
||||||
|
top = if (element.depth == 0) 22.dp else 14.dp,
|
||||||
|
start = (element.depth * 12).dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
element.title,
|
||||||
|
style = when (element.depth) {
|
||||||
|
0 -> MaterialTheme.typography.headlineMedium
|
||||||
|
1 -> MaterialTheme.typography.titleLarge
|
||||||
|
else -> MaterialTheme.typography.titleMedium
|
||||||
|
},
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
lineHeight = if (element.depth == 0) 36.sp else 28.sp,
|
||||||
|
modifier = titleModifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { Spacer(Modifier.height(22.dp)) }
|
is ReaderElement.BookImage -> BookImage(
|
||||||
|
book = book,
|
||||||
|
image = element.image,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
onOpen = onImageOpen,
|
||||||
|
)
|
||||||
|
is ReaderElement.Paragraph -> ReaderText(
|
||||||
|
text = element.text,
|
||||||
|
language = book.language,
|
||||||
|
hyphenation = hyphenation,
|
||||||
|
style = readerParagraphTextStyle(book.language),
|
||||||
|
highlightedRange = highlightedRange,
|
||||||
|
/* Justify adds extra padding to the end, which hardly can be removed */
|
||||||
|
textAlign = TextAlign.Justify,
|
||||||
|
// so we add 6.dp to make it look symmetric
|
||||||
|
modifier = Modifier.padding(start = (element.depth * 8).dp + 6.dp, end = 0.dp),
|
||||||
|
)
|
||||||
|
is ReaderElement.Subtitle -> ReaderText(
|
||||||
|
text = element.text,
|
||||||
|
language = book.language,
|
||||||
|
hyphenation = hyphenation,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
highlightedRange = highlightedRange,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,81 +218,6 @@ private fun LazyListState.pageScrollDistance(): Float {
|
|||||||
return viewportHeight.toFloat().coerceAtLeast(0f)
|
return viewportHeight.toFloat().coerceAtLeast(0f)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun LazyListScope.sectionItems(
|
|
||||||
book: Fb2Book,
|
|
||||||
section: Fb2Section,
|
|
||||||
depth: Int,
|
|
||||||
keyPrefix: String,
|
|
||||||
hyphenation: HyphenationRegistry,
|
|
||||||
onImageOpen: (ViewedBookImage) -> Unit,
|
|
||||||
) {
|
|
||||||
if( section.title.isNullOrBlank() ) {
|
|
||||||
item {
|
|
||||||
Spacer(Modifier.height(2.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.secondaryFixedDim)
|
|
||||||
.fillMaxWidth().padding(vertical = 5.dp, horizontal = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
item(key = "$keyPrefix-title") {
|
|
||||||
Text(
|
|
||||||
section.title!!,
|
|
||||||
style = when (depth) {
|
|
||||||
0 -> MaterialTheme.typography.headlineMedium
|
|
||||||
1 -> MaterialTheme.typography.titleLarge
|
|
||||||
else -> MaterialTheme.typography.titleMedium
|
|
||||||
},
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
lineHeight = if (depth == 0) 36.sp else 28.sp,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = if (depth == 0) 22.dp else 14.dp, start = (depth * 12).dp, bottom = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items(section.readableBlocks()) { block ->
|
|
||||||
when (block) {
|
|
||||||
Fb2Block.EmptyLine -> Spacer(Modifier.height(16.dp))
|
|
||||||
is Fb2Block.Image -> BookImage(
|
|
||||||
book = book,
|
|
||||||
image = block.image,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
onOpen = onImageOpen,
|
|
||||||
)
|
|
||||||
is Fb2Block.Paragraph -> ReaderText(
|
|
||||||
text = block.content,
|
|
||||||
language = book.language,
|
|
||||||
hyphenation = hyphenation,
|
|
||||||
style = readerParagraphTextStyle(book.language),
|
|
||||||
/* Justify adds extra padding to the end, which hardly can be removed */
|
|
||||||
textAlign = TextAlign.Justify,
|
|
||||||
// so we add 6.dp to make it look symmetric
|
|
||||||
modifier = Modifier.padding(start = (depth * 8).dp + 6.dp, end = 0.dp),
|
|
||||||
)
|
|
||||||
is Fb2Block.Subtitle -> ReaderText(
|
|
||||||
text = block.content,
|
|
||||||
language = book.language,
|
|
||||||
hyphenation = hyphenation,
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
section.sections.forEachIndexed { index, child ->
|
|
||||||
sectionItems(
|
|
||||||
book = book,
|
|
||||||
section = child,
|
|
||||||
depth = depth + 1,
|
|
||||||
keyPrefix = "$keyPrefix-$index",
|
|
||||||
hyphenation = hyphenation,
|
|
||||||
onImageOpen = onImageOpen,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DetailsPane(
|
private fun DetailsPane(
|
||||||
book: Fb2Book,
|
book: Fb2Book,
|
||||||
@ -410,8 +387,10 @@ private fun ReaderText(
|
|||||||
style: TextStyle,
|
style: TextStyle,
|
||||||
textAlign: TextAlign,
|
textAlign: TextAlign,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
highlightedRange: ReaderSentenceRange? = null,
|
||||||
) {
|
) {
|
||||||
val annotatedText = text.toAnnotatedString(language, hyphenation)
|
val highlightColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor)
|
||||||
val needsSoftHyphenPaintWorkaround = isDesktopPlatform()
|
val needsSoftHyphenPaintWorkaround = isDesktopPlatform()
|
||||||
var textLayout by remember(annotatedText) { mutableStateOf<TextLayoutResult?>(null) }
|
var textLayout by remember(annotatedText) { mutableStateOf<TextLayoutResult?>(null) }
|
||||||
val desktopHyphenColor = MaterialTheme.colorScheme.onSurface
|
val desktopHyphenColor = MaterialTheme.colorScheme.onSurface
|
||||||
@ -530,8 +509,14 @@ private fun BookImage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Fb2Text.toAnnotatedString(language: String?, hyphenation: HyphenationRegistry): AnnotatedString =
|
private fun Fb2Text.toAnnotatedString(
|
||||||
|
language: String?,
|
||||||
|
hyphenation: HyphenationRegistry,
|
||||||
|
highlightedRange: ReaderSentenceRange?,
|
||||||
|
highlightColor: Color,
|
||||||
|
): AnnotatedString =
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
|
var plainOffset = 0
|
||||||
spans.forEach { span ->
|
spans.forEach { span ->
|
||||||
val spanStyle = SpanStyle(
|
val spanStyle = SpanStyle(
|
||||||
fontStyle = if (Fb2TextStyle.Emphasis in span.styles) FontStyle.Italic else null,
|
fontStyle = if (Fb2TextStyle.Emphasis in span.styles) FontStyle.Italic else null,
|
||||||
@ -549,11 +534,182 @@ private fun Fb2Text.toAnnotatedString(language: String?, hyphenation: Hyphenatio
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
withStyle(spanStyle) {
|
withStyle(spanStyle) {
|
||||||
append(hyphenation.hyphenate(span.text, language))
|
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
|
||||||
|
}
|
||||||
|
plainOffset += span.text.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AnnotatedString.Builder.appendWithHighlight(
|
||||||
|
text: String,
|
||||||
|
plainOffset: Int,
|
||||||
|
highlightedRange: ReaderSentenceRange?,
|
||||||
|
highlightColor: Color,
|
||||||
|
language: String?,
|
||||||
|
hyphenation: HyphenationRegistry,
|
||||||
|
) {
|
||||||
|
if (highlightedRange == null) {
|
||||||
|
append(hyphenation.hyphenate(text, language))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var cursor = 0
|
||||||
|
while (cursor < text.length) {
|
||||||
|
val absolute = plainOffset + cursor
|
||||||
|
val inHighlight = absolute >= highlightedRange.start && absolute < highlightedRange.endExclusive
|
||||||
|
val nextBoundary = if (inHighlight) {
|
||||||
|
min(text.length, highlightedRange.endExclusive - plainOffset)
|
||||||
|
} else {
|
||||||
|
min(text.length, max(0, highlightedRange.start - plainOffset))
|
||||||
|
.takeIf { it > cursor } ?: text.length
|
||||||
|
}
|
||||||
|
val part = text.substring(cursor, nextBoundary)
|
||||||
|
if (inHighlight) {
|
||||||
|
withStyle(SpanStyle(background = highlightColor)) {
|
||||||
|
append(hyphenation.hyphenate(part, language))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
append(hyphenation.hyphenate(part, language))
|
||||||
|
}
|
||||||
|
cursor = nextBoundary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
||||||
|
val elements = mutableListOf<ReaderElement>()
|
||||||
|
val sentences = mutableListOf<ReadAloudSentence>()
|
||||||
|
|
||||||
|
fun addTextSentences(
|
||||||
|
itemIndex: Int,
|
||||||
|
text: Fb2Text,
|
||||||
|
pauseBeforeMillis: Long = 0,
|
||||||
|
pauseAfterMillis: Long = 0,
|
||||||
|
) {
|
||||||
|
text.plainText().sentenceRanges().forEach { range ->
|
||||||
|
val sentenceText = text.plainText().substring(range.start, range.endExclusive).trim()
|
||||||
|
if (sentenceText.isNotEmpty()) {
|
||||||
|
sentences += ReadAloudSentence(
|
||||||
|
index = sentences.size,
|
||||||
|
itemIndex = itemIndex,
|
||||||
|
start = range.start,
|
||||||
|
endExclusive = range.endExclusive,
|
||||||
|
text = sentenceText,
|
||||||
|
pauseBeforeMillis = pauseBeforeMillis,
|
||||||
|
pauseAfterMillis = pauseAfterMillis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addSection(section: Fb2Section, depth: Int) {
|
||||||
|
if (section.title.isNullOrBlank()) {
|
||||||
|
elements += ReaderElement.SectionSeparator
|
||||||
|
} else {
|
||||||
|
val itemIndex = elements.size
|
||||||
|
elements += ReaderElement.SectionTitle(section.title!!, depth)
|
||||||
|
sentences += ReadAloudSentence(
|
||||||
|
index = sentences.size,
|
||||||
|
itemIndex = itemIndex,
|
||||||
|
start = 0,
|
||||||
|
endExclusive = section.title!!.length,
|
||||||
|
text = section.title!!,
|
||||||
|
pauseBeforeMillis = HeadingPauseBeforeMillis,
|
||||||
|
pauseAfterMillis = HeadingPauseAfterMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
section.readableBlocks().forEach { block ->
|
||||||
|
val itemIndex = elements.size
|
||||||
|
when (block) {
|
||||||
|
Fb2Block.EmptyLine -> elements += ReaderElement.FixedSpacer(16)
|
||||||
|
is Fb2Block.Image -> elements += ReaderElement.BookImage(block.image)
|
||||||
|
is Fb2Block.Paragraph -> {
|
||||||
|
addTextSentences(itemIndex, block.content)
|
||||||
|
elements += ReaderElement.Paragraph(block.content, depth)
|
||||||
|
}
|
||||||
|
is Fb2Block.Subtitle -> {
|
||||||
|
addTextSentences(
|
||||||
|
itemIndex = itemIndex,
|
||||||
|
text = block.content,
|
||||||
|
pauseBeforeMillis = HeadingPauseBeforeMillis,
|
||||||
|
pauseAfterMillis = HeadingPauseAfterMillis,
|
||||||
|
)
|
||||||
|
elements += ReaderElement.Subtitle(block.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
section.sections.forEach { addSection(it, depth + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
elements += ReaderElement.Cover
|
||||||
|
elements += ReaderElement.FixedSpacer(6)
|
||||||
|
book.sections.forEach { addSection(it, 0) }
|
||||||
|
elements += ReaderElement.FixedSpacer(22)
|
||||||
|
|
||||||
|
return ReaderContentPlan(elements, sentences)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class ReaderContentPlan(
|
||||||
|
val elements: List<ReaderElement>,
|
||||||
|
val sentences: List<ReadAloudSentence>,
|
||||||
|
) {
|
||||||
|
fun sentenceIndexAtOrAfterItem(itemIndex: Int): Int =
|
||||||
|
sentences.firstOrNull { it.itemIndex >= itemIndex }?.index
|
||||||
|
?: sentences.lastOrNull()?.index
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed interface ReaderElement {
|
||||||
|
data object Cover : ReaderElement
|
||||||
|
data class FixedSpacer(val heightDp: Int) : ReaderElement
|
||||||
|
data object SectionSeparator : ReaderElement
|
||||||
|
data class SectionTitle(val title: String, val depth: Int) : ReaderElement
|
||||||
|
data class BookImage(val image: Fb2ImageRef) : ReaderElement
|
||||||
|
data class Paragraph(val text: Fb2Text, val depth: Int) : ReaderElement
|
||||||
|
data class Subtitle(val text: Fb2Text) : ReaderElement
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ReaderSentenceRange(
|
||||||
|
val start: Int,
|
||||||
|
val endExclusive: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Fb2Text.plainText(): String =
|
||||||
|
spans.joinToString(separator = "") { it.text }
|
||||||
|
|
||||||
|
private fun String.sentenceRanges(): List<ReaderSentenceRange> {
|
||||||
|
val ranges = mutableListOf<ReaderSentenceRange>()
|
||||||
|
var start = 0
|
||||||
|
|
||||||
|
fun skipLeadingWhitespace() {
|
||||||
|
while (start < length && this[start].isWhitespace()) start += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
skipLeadingWhitespace()
|
||||||
|
var index = start
|
||||||
|
while (index < length) {
|
||||||
|
if (this[index].isSentenceTerminator()) {
|
||||||
|
var end = index + 1
|
||||||
|
while (end < length && this[end] in "\"'»”’)]}") end += 1
|
||||||
|
ranges += ReaderSentenceRange(start, end)
|
||||||
|
start = end
|
||||||
|
skipLeadingWhitespace()
|
||||||
|
index = start
|
||||||
|
} else {
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start < length) {
|
||||||
|
ranges += ReaderSentenceRange(start, length)
|
||||||
|
}
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Char.isSentenceTerminator(): Boolean =
|
||||||
|
this == '.' || this == '!' || this == '?' || this == '…'
|
||||||
|
|
||||||
|
private const val HeadingPauseBeforeMillis = 1_000L
|
||||||
|
private const val HeadingPauseAfterMillis = 600L
|
||||||
|
|
||||||
private fun List<Fb2Section>.flattenSections(depth: Int = 0): List<ChapterEntry> =
|
private fun List<Fb2Section>.flattenSections(depth: Int = 0): List<ChapterEntry> =
|
||||||
flatMapIndexed { index, section ->
|
flatMapIndexed { index, section ->
|
||||||
val fallback = "Section ${index + 1}"
|
val fallback = "Section ${index + 1}"
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package net.sergeych.toread
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
@ -12,8 +14,13 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
|
import androidx.compose.material.icons.filled.FastForward
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Replay
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@ -25,9 +32,12 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -57,14 +67,21 @@ internal fun BookView(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val stats = remember(book) { BookStats.from(book) }
|
val stats = remember(book) { BookStats.from(book) }
|
||||||
|
val contentPlan = remember(book) { buildReaderContentPlan(book) }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var restored by remember(fileId) { mutableStateOf(false) }
|
var restored by remember(fileId) { mutableStateOf(false) }
|
||||||
var markedRead by remember(fileId) { mutableStateOf(false) }
|
var markedRead by remember(fileId) { mutableStateOf(false) }
|
||||||
|
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
|
||||||
|
val readAloudState by ReadAloudPlatform.state.collectAsState()
|
||||||
val platformName = getPlatform().name
|
val platformName = getPlatform().name
|
||||||
val showShareAction = platformName.startsWith("Android")
|
val showShareAction = platformName.startsWith("Android")
|
||||||
val showViewFileAction = platformName.startsWith("Java")
|
val showViewFileAction = platformName.startsWith("Java")
|
||||||
|
val showReadAloudAction = ReadAloudPlatform.isSupported && contentPlan.sentences.isNotEmpty()
|
||||||
|
val highlightedSentence = readAloudState.sentenceIndex
|
||||||
|
?.let { index -> contentPlan.sentences.getOrNull(index) }
|
||||||
|
?.takeIf { readAloudPanelVisible && readAloudState.active }
|
||||||
|
|
||||||
fun showMessage(message: String) {
|
fun showMessage(message: String) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -88,6 +105,12 @@ internal fun BookView(
|
|||||||
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
|
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(fileId) {
|
||||||
|
onDispose {
|
||||||
|
ReadAloudPlatform.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(fileId) {
|
LaunchedEffect(fileId) {
|
||||||
loadLibraryReadingPosition(fileId)?.let { position ->
|
loadLibraryReadingPosition(fileId)?.let { position ->
|
||||||
listState.scrollToItem(position.itemIndex, position.scrollOffset)
|
listState.scrollToItem(position.itemIndex, position.scrollOffset)
|
||||||
@ -118,6 +141,15 @@ internal fun BookView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(readAloudState.sentenceIndex) {
|
||||||
|
val itemIndex = highlightedSentence?.itemIndex ?: return@LaunchedEffect
|
||||||
|
saveLibraryReadingPosition(fileId, ReadingPosition(itemIndex, 0))
|
||||||
|
val visibleItems = listState.layoutInfo.visibleItemsInfo
|
||||||
|
if (visibleItems.none { it.index == itemIndex }) {
|
||||||
|
listState.animateScrollToItem(itemIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
@ -157,6 +189,13 @@ internal fun BookView(
|
|||||||
showMessage(if (opened) "Opened file location." else "Could not open file location.")
|
showMessage(if (opened) "Opened file location." else "Could not open file location.")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
showReadAloudAction = showReadAloudAction,
|
||||||
|
onReadAloud = {
|
||||||
|
val startIndex = contentPlan.sentenceIndexAtOrAfterItem(listState.firstVisibleItemIndex)
|
||||||
|
ReadAloudPlatform.prepare(book.title, contentPlan.sentences, startIndex)
|
||||||
|
readAloudPanelVisible = true
|
||||||
|
ReadAloudPlatform.play()
|
||||||
|
},
|
||||||
onDelete = {
|
onDelete = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val result = deleteLibraryBook(fileId, book.title)
|
val result = deleteLibraryBook(fileId, book.title)
|
||||||
@ -187,13 +226,32 @@ internal fun BookView(
|
|||||||
.padding(it)
|
.padding(it)
|
||||||
.background(readerBackground()),
|
.background(readerBackground()),
|
||||||
) {
|
) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
ContinuousBookReader(
|
ContinuousBookReader(
|
||||||
book = book,
|
book = book,
|
||||||
stats = stats,
|
stats = stats,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.weight(1f),
|
||||||
listState = listState,
|
listState = listState,
|
||||||
|
contentPlan = contentPlan,
|
||||||
|
highlightedSentence = highlightedSentence,
|
||||||
onImageOpen = onImageOpen,
|
onImageOpen = onImageOpen,
|
||||||
)
|
)
|
||||||
|
if (readAloudPanelVisible && readAloudState.active) {
|
||||||
|
ReadAloudPanel(
|
||||||
|
playing = readAloudState.playing,
|
||||||
|
onPlayStop = {
|
||||||
|
if (readAloudState.playing) {
|
||||||
|
ReadAloudPlatform.stop()
|
||||||
|
readAloudPanelVisible = false
|
||||||
|
} else {
|
||||||
|
ReadAloudPlatform.play()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBack = { ReadAloudPlatform.skip(-1) },
|
||||||
|
onForward = { ReadAloudPlatform.skip(1) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -210,6 +268,8 @@ private fun CompactReaderTopBar(
|
|||||||
onShare: () -> Unit,
|
onShare: () -> Unit,
|
||||||
showViewFileAction: Boolean,
|
showViewFileAction: Boolean,
|
||||||
onViewFile: () -> Unit,
|
onViewFile: () -> Unit,
|
||||||
|
showReadAloudAction: Boolean,
|
||||||
|
onReadAloud: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@ -232,9 +292,11 @@ private fun CompactReaderTopBar(
|
|||||||
IconButton(onClick = onThemeToggle) {
|
IconButton(onClick = onThemeToggle) {
|
||||||
Icon(Icons.Filled.Palette, contentDescription = "Theme")
|
Icon(Icons.Filled.Palette, contentDescription = "Theme")
|
||||||
}
|
}
|
||||||
IconButton(onClick = { }) {
|
if (showReadAloudAction) {
|
||||||
|
IconButton(onClick = onReadAloud) {
|
||||||
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
|
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Box {
|
Box {
|
||||||
IconButton(onClick = { menuOpen = true }) {
|
IconButton(onClick = { menuOpen = true }) {
|
||||||
Icon(Icons.Filled.MoreVert, contentDescription = "Book reader menu")
|
Icon(Icons.Filled.MoreVert, contentDescription = "Book reader menu")
|
||||||
@ -303,3 +365,40 @@ private fun CompactReaderTopBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReadAloudPanel(
|
||||||
|
playing: Boolean,
|
||||||
|
onPlayStop: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onForward: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
shadowElevation = 4.dp,
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Filled.Replay, contentDescription = "Previous sentence")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onPlayStop) {
|
||||||
|
Icon(
|
||||||
|
if (playing) Icons.Filled.Stop else Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = if (playing) "Stop reading" else "Start reading",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onForward) {
|
||||||
|
Icon(Icons.Filled.FastForward, contentDescription = "Next sentence")
|
||||||
|
}
|
||||||
|
IconButton(onClick = {}, enabled = false) {
|
||||||
|
Icon(Icons.Filled.Settings, contentDescription = "Read aloud settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
actual object ReadAloudPlatform {
|
||||||
|
actual val isSupported: Boolean = false
|
||||||
|
private val mutableState = MutableStateFlow(ReadAloudState())
|
||||||
|
actual val state: StateFlow<ReadAloudState> = mutableState
|
||||||
|
|
||||||
|
actual fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) = Unit
|
||||||
|
actual fun play() = Unit
|
||||||
|
actual fun stop() = Unit
|
||||||
|
actual fun skip(delta: Int) = Unit
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
actual object ReadAloudPlatform {
|
||||||
|
actual val isSupported: Boolean = false
|
||||||
|
private val mutableState = MutableStateFlow(ReadAloudState())
|
||||||
|
actual val state: StateFlow<ReadAloudState> = mutableState
|
||||||
|
|
||||||
|
actual fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) = Unit
|
||||||
|
actual fun play() = Unit
|
||||||
|
actual fun stop() = Unit
|
||||||
|
actual fun skip(delta: Int) = Unit
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user