Compare commits

..

No commits in common. "6422f814d18e1fa19202573438e30189d1c33138" and "26343cb8a1f5aeaacda60682442656243d2db255" have entirely different histories.

35 changed files with 339 additions and 1275 deletions

View File

@ -80,7 +80,6 @@ android {
buildTypes { buildTypes {
getByName("release") { getByName("release") {
isMinifyEnabled = false isMinifyEnabled = false
signingConfig = signingConfigs.getByName("debug")
} }
} }
compileOptions { compileOptions {

View File

@ -5,9 +5,6 @@
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"
@ -55,10 +52,6 @@
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>

View File

@ -52,9 +52,6 @@ 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? =
@ -307,24 +304,9 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
} }
} }
actual suspend fun clearLibraryReadingPosition(fileId: String) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary
val clusterId = file.bodyClusterId ?: return@useLibrary
db.readingStates.deleteForBodyCluster(clusterId)
}
Unit
}
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) { actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.transaction { db.files.updateReadingStatus(fileId, status)
if (status == BookReadingStatus.NEW) {
val file = files.get(fileId) ?: return@transaction false
file.bodyClusterId?.let { readingStates.deleteForBodyCluster(it) }
}
files.updateReadingStatus(fileId, status)
}
} }
} }

View File

@ -31,7 +31,6 @@ 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
@ -53,7 +52,6 @@ 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)
@ -64,7 +62,6 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
App() App()
} }
} }
requestNotificationPermissionIfNeeded()
} }
override suspend fun chooseDirectory(): String? { override suspend fun chooseDirectory(): String? {
@ -169,12 +166,6 @@ 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

View File

@ -1,305 +0,0 @@
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)
}
}
}

View File

@ -1,65 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Adaptive icon foreground: 108dp x 108dp canvas.
Safe zone: inner 72dp circle (18dp..90dp). Book (200x260) scaled 0.2769x:
translateX=26.31, translateY=18. Star sub-group at book coords (67,59) scale 0.888x.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<group <aapt:attr name="android:fillColor">
android:translateX="26.31" <gradient
android:translateY="18" android:endX="85.84757"
android:scaleX="0.2769" android:endY="92.4963"
android:scaleY="0.2769"> android:startX="42.9492"
android:startY="49.59793"
<!-- Pages stack --> android:type="linear">
<path android:fillColor="#e8e4d8" <item
android:pathData="M36,18 H178 V248 H36 Z"/> android:color="#44000000"
<path android:fillColor="#ece9df" android:offset="0.0"/>
android:pathData="M34,16 H176 V246 H34 Z"/> <item
<path android:fillColor="#f0ede3" android:color="#00000000"
android:pathData="M32,14 H174 V244 H32 Z"/> android:offset="1.0"/>
</gradient>
<!-- Spine --> </aapt:attr>
<path android:fillColor="#1a3d52" </path>
android:pathData="M18,14 H36 V244 H18 Z"/> <path
<path android:fillColor="#00000000" android:fillColor="#FFFFFF"
android:strokeColor="#30000000" android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1" android:strokeWidth="1"
android:pathData="M35,14 V244"/> android:strokeColor="#00000000"/>
<!-- Cover -->
<path android:fillColor="#2c5f7a"
android:pathData="M32,14 H174 V244 H32 Z"/>
<!-- Cover border -->
<path android:fillColor="#00000000"
android:strokeColor="#30ffffff"
android:strokeWidth="1.5"
android:pathData="M40,22 H166 V236 H40 Z"/>
<!-- Star arms -->
<group
android:translateX="67"
android:translateY="59"
android:scaleX="0.888"
android:scaleY="0.888">
<path android:fillColor="#6197b4"
android:pathData="m 32.120065,25.957965 -15.0296,-9.2912 c -0.3066,-0.187 -0.6532,0.1603 -0.4666,0.4676 l 8.9112,14.4745 c 0.2399,0.3874 0.5665,0.7013 0.9597,0.9284 l 14.0032,8.0555 V 0.34209525 c 0,-0.394094 -0.5532,-0.474248 -0.6665,-0.1002 z"/>
<path android:fillColor="#bdccd6"
android:pathData="m 25.901665,48.988865 -9.271,15.0556 c -0.1866,0.3073 0.16,0.6546 0.4665,0.4676 l 14.4431,-8.9305 c 0.3866,-0.2405 0.6999,-0.5678 0.9265,-0.9619 l 8.038,-14.0336 H 0.34134532 c -0.393231,0 -0.473212,0.5544 -0.09997,0.6679 z"/>
<path android:fillColor="#94bfcf"
android:pathData="m 55.101065,32.189965 9.2711,-15.0623 c 0.1866,-0.3072 -0.16,-0.6546 -0.4666,-0.4675 l -14.4431,8.9371 c -0.3865,0.2405 -0.6998,0.5678 -0.9264,0.9619 l -8.038,14.027 h 40.1634 c 0.3932,0 0.4732,-0.5544 0.1,-0.668 z"/>
<path android:fillColor="#eee9d9"
android:pathData="m 54.507965,48.641565 -14.0099,-8.0555 v 40.2507 c 0,0.3941 0.5532,0.4742 0.6665,0.1002 l 7.7181,-25.7094 15.0296,9.2912 c 0.3066,0.187 0.6532,-0.1603 0.4665,-0.4676 l -8.9177,-14.4812 c -0.2333,-0.3807 -0.5666,-0.7013 -0.9531,-0.9284 z"/>
</group>
<!-- Bottom spine accent -->
<path android:fillColor="#0e2030"
android:pathData="M18,228 H36 V244 H18 Z"/>
</group>
</vector> </vector>

View File

@ -5,6 +5,166 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path <path
android:fillColor="#0d2535" android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/> android:pathData="M0,0h108v108h-108z"/>
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
</vector> </vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -118,7 +118,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
) )
is AppState.Reader -> { is AppState.Reader -> {
scope.launch { saveActiveReadingFileId(null) } scope.launch { saveActiveReadingFileId(null) }
AppState.Library(emptyList(), current.scanPath, current.message) AppState.Library(current.libraryItems, current.scanPath, current.message)
} }
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message) is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
is AppState.Error -> AppState.LoadingLibrary is AppState.Error -> AppState.LoadingLibrary

View File

@ -116,8 +116,6 @@ expect suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition?
expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition)
expect suspend fun clearLibraryReadingPosition(fileId: String)
expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean
expect suspend fun shareLibraryBookFile(fileId: String): Boolean expect suspend fun shareLibraryBookFile(fileId: String): Boolean

View File

@ -146,28 +146,6 @@ internal fun LibraryScreen(
} }
} }
suspend fun applyReadingStatus(
item: LibraryItem,
status: BookReadingStatus,
successMessage: String,
) {
if (markLibraryReadingStatus(item.fileId, status)) {
val updatedItem = loadLibraryItem(item.fileId) ?: item.copy(readingStatus = status)
items = items.replaceLibraryItem(updatedItem)
searchResults = searchResults.replaceLibraryItem(updatedItem)
message = successMessage
if (searchActive) {
searching = true
searchResults = searchLibraryItems(searchText, SearchResultLimit)
searching = false
} else {
loadPage(reset = true)
}
} else {
message = "Could not update ${item.title}."
}
}
fun rescanAllLibrary() { fun rescanAllLibrary() {
settingsMenuOpen = false settingsMenuOpen = false
scope.launch { scope.launch {
@ -342,13 +320,7 @@ internal fun LibraryScreen(
readerLibraryItems = readerLibraryItems.replaceLibraryItem(updatedItem) readerLibraryItems = readerLibraryItems.replaceLibraryItem(updatedItem)
coverCache[updatedItem.fileId] = loadLibraryItemCover(updatedItem.fileId) coverCache[updatedItem.fileId] = loadLibraryItemCover(updatedItem.fileId)
} }
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)) { markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
val readingItem = loadLibraryItem(item.fileId)
?: item.copy(readingStatus = BookReadingStatus.READING)
items = items.replaceLibraryItem(readingItem)
searchResults = searchResults.replaceLibraryItem(readingItem)
readerLibraryItems = readerLibraryItems.replaceLibraryItem(readingItem)
}
saveActiveReadingFileId(item.fileId) saveActiveReadingFileId(item.fileId)
AppState.Reader( AppState.Reader(
fileId = item.fileId, fileId = item.fileId,
@ -370,7 +342,12 @@ internal fun LibraryScreen(
scope.launch { scope.launch {
busy = true busy = true
try { try {
applyReadingStatus(item, BookReadingStatus.READ, "Marked ${item.title} as read.") if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READ)) {
message = "Marked ${item.title} as read."
refresh()
} else {
message = "Could not update ${item.title}."
}
} finally { } finally {
busy = false busy = false
} }
@ -380,7 +357,12 @@ internal fun LibraryScreen(
scope.launch { scope.launch {
busy = true busy = true
try { try {
applyReadingStatus(item, BookReadingStatus.NEW, "Marked ${item.title} as unread.") if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
message = "Marked ${item.title} as unread."
refresh()
} else {
message = "Could not update ${item.title}."
}
} finally { } finally {
busy = false busy = false
} }
@ -390,7 +372,12 @@ internal fun LibraryScreen(
scope.launch { scope.launch {
busy = true busy = true
try { try {
applyReadingStatus(item, BookReadingStatus.NEW, "Removed marks from ${item.title}.") if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
message = "Removed marks from ${item.title}."
refresh()
} else {
message = "Could not update ${item.title}."
}
} finally { } finally {
busy = false busy = false
} }
@ -400,7 +387,12 @@ internal fun LibraryScreen(
scope.launch { scope.launch {
busy = true busy = true
try { try {
applyReadingStatus(item, BookReadingStatus.NOT_INTERESTED, "Marked ${item.title} as not interested.") if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
message = "Marked ${item.title} as not interested."
refresh()
} else {
message = "Could not update ${item.title}."
}
} finally { } finally {
busy = false busy = false
} }
@ -446,8 +438,7 @@ internal fun LibraryScreen(
librarySection( librarySection(
key = "reading", key = "reading",
title = "reading now", title = "reading now",
itemCount = readingNow.size, count = readingNow.size,
displayCount = readingNow.size.takeIf { endReached },
collapsed = readingNowCollapsed, collapsed = readingNowCollapsed,
onCollapsedChange = { readingNowCollapsed = it }, onCollapsedChange = { readingNowCollapsed = it },
) { ) {
@ -456,8 +447,7 @@ internal fun LibraryScreen(
librarySection( librarySection(
key = "library", key = "library",
title = "my library", title = "my library",
itemCount = myLibrary.size, count = myLibrary.size,
displayCount = myLibrary.size.takeIf { endReached },
collapsed = myLibraryCollapsed, collapsed = myLibraryCollapsed,
onCollapsedChange = { myLibraryCollapsed = it }, onCollapsedChange = { myLibraryCollapsed = it },
) { ) {
@ -466,8 +456,7 @@ internal fun LibraryScreen(
librarySection( librarySection(
key = "not-interested", key = "not-interested",
title = "not interested", title = "not interested",
itemCount = notInterested.size, count = notInterested.size,
displayCount = notInterested.size.takeIf { endReached },
collapsed = notInterestedCollapsed, collapsed = notInterestedCollapsed,
onCollapsedChange = { notInterestedCollapsed = it }, onCollapsedChange = { notInterestedCollapsed = it },
) { ) {
@ -584,17 +573,16 @@ private fun EmptySearchPane(modifier: Modifier = Modifier) {
private fun LazyListScope.librarySection( private fun LazyListScope.librarySection(
key: String, key: String,
title: String, title: String,
itemCount: Int, count: Int,
displayCount: Int?,
collapsed: Boolean, collapsed: Boolean,
onCollapsedChange: (Boolean) -> Unit, onCollapsedChange: (Boolean) -> Unit,
content: LazyListScope.() -> Unit, content: LazyListScope.() -> Unit,
) { ) {
if (itemCount == 0) return if (count == 0) return
item(key = "section-$key") { item(key = "section-$key") {
LibrarySectionHeader( LibrarySectionHeader(
text = title, text = title,
count = displayCount, count = count,
collapsed = collapsed, collapsed = collapsed,
onToggle = { onCollapsedChange(!collapsed) }, onToggle = { onCollapsedChange(!collapsed) },
) )
@ -607,7 +595,7 @@ private fun LazyListScope.librarySection(
@Composable @Composable
private fun LibrarySectionHeader( private fun LibrarySectionHeader(
text: String, text: String,
count: Int?, count: Int,
collapsed: Boolean, collapsed: Boolean,
onToggle: () -> Unit, onToggle: () -> Unit,
) { ) {
@ -626,7 +614,7 @@ private fun LibrarySectionHeader(
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
Text( Text(
if (count == null) text else "$text ($count)", "$text ($count)",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,

View File

@ -1,29 +0,0 @@
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)
}

View File

@ -21,6 +21,7 @@ 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
@ -84,8 +85,6 @@ 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() }
@ -115,78 +114,27 @@ internal fun ContinuousBookReader(
contentPadding = contentPadding, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
itemsIndexed(contentPlan.elements) { itemIndex, element -> item {
val highlightedRange = highlightedSentence Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
?.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))
ReaderElement.SectionSeparator -> Spacer(
Modifier.height(2.dp)
.background(MaterialTheme.colorScheme.secondaryFixedDim)
.fillMaxWidth().padding(vertical = 5.dp, horizontal = 4.dp)
)
is ReaderElement.SectionTitle -> {
val titleModifier = Modifier
.fillMaxWidth()
.then(
if (highlightedRange != null) {
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,
)
} }
is ReaderElement.BookImage -> BookImage( item {
Spacer(Modifier.height(6.dp))
}
book.sections.forEachIndexed { index, section ->
sectionItems(
book = book, book = book,
image = element.image, section = section,
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), depth = 0,
contentScale = ContentScale.Fit, keyPrefix = "section-$index",
onOpen = onImageOpen,
)
is ReaderElement.Paragraph -> ReaderText(
text = element.text,
language = book.language,
hyphenation = hyphenation, hyphenation = hyphenation,
style = readerParagraphTextStyle(book.language), onImageOpen = onImageOpen,
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),
) )
} }
} item { Spacer(Modifier.height(22.dp)) }
} }
} }
@ -218,6 +166,81 @@ 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,
@ -387,10 +410,8 @@ private fun ReaderText(
style: TextStyle, style: TextStyle,
textAlign: TextAlign, textAlign: TextAlign,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
highlightedRange: ReaderSentenceRange? = null,
) { ) {
val highlightColor = MaterialTheme.colorScheme.secondaryContainer val annotatedText = text.toAnnotatedString(language, hyphenation)
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
@ -509,14 +530,8 @@ private fun BookImage(
} }
} }
private fun Fb2Text.toAnnotatedString( private fun Fb2Text.toAnnotatedString(language: String?, hyphenation: HyphenationRegistry): AnnotatedString =
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,
@ -534,182 +549,11 @@ private fun Fb2Text.toAnnotatedString(
}, },
) )
withStyle(spanStyle) { withStyle(spanStyle) {
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation) append(hyphenation.hyphenate(span.text, language))
}
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}"

View File

@ -2,8 +2,6 @@ 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
@ -14,13 +12,8 @@ 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
@ -32,12 +25,9 @@ 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
@ -67,21 +57,14 @@ 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 {
@ -105,12 +88,6 @@ 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)
@ -141,15 +118,6 @@ 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) },
@ -189,13 +157,6 @@ 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)
@ -226,32 +187,13 @@ 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.weight(1f), modifier = Modifier.fillMaxSize(),
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) },
)
}
}
} }
} }
} }
@ -268,8 +210,6 @@ 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,
) { ) {
@ -292,11 +232,9 @@ private fun CompactReaderTopBar(
IconButton(onClick = onThemeToggle) { IconButton(onClick = onThemeToggle) {
Icon(Icons.Filled.Palette, contentDescription = "Theme") Icon(Icons.Filled.Palette, contentDescription = "Theme")
} }
if (showReadAloudAction) { IconButton(onClick = { }) {
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")
@ -365,40 +303,3 @@ 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")
}
}
}
}

View File

@ -255,24 +255,9 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
} }
} }
actual suspend fun clearLibraryReadingPosition(fileId: String) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary
val clusterId = file.bodyClusterId ?: return@useLibrary
db.readingStates.deleteForBodyCluster(clusterId)
}
Unit
}
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) { actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.transaction { db.files.updateReadingStatus(fileId, status)
if (status == BookReadingStatus.NEW) {
val file = files.get(fileId) ?: return@transaction false
file.bodyClusterId?.let { readingStates.deleteForBodyCluster(it) }
}
files.updateReadingStatus(fileId, status)
}
} }
} }

View File

@ -1,15 +0,0 @@
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
}

View File

@ -49,8 +49,6 @@ actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition?
actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = Unit actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = Unit
actual suspend fun clearLibraryReadingPosition(fileId: String) = Unit
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false

View File

@ -1,15 +0,0 @@
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
}

View File

@ -1,83 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="200"
height="260"
viewBox="0 0 200 260"
fill="none"
version="1.1"
xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Drop shadow filter -->
<filter id="shadow" x="-10%" y="-5%" width="130%" height="120%">
<feDropShadow dx="4" dy="4" stdDeviation="5" flood-color="#00000044"/>
</filter>
<!-- Page texture gradient -->
<linearGradient id="pageGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f0ede3"/>
<stop offset="100%" stop-color="#e8e4d8"/>
</linearGradient>
<!-- Cover gradient -->
<linearGradient id="coverGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#2c5f7a"/>
<stop offset="100%" stop-color="#1a3d52"/>
</linearGradient>
<!-- Spine gradient -->
<linearGradient id="spineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#1a3d52"/>
<stop offset="100%" stop-color="#2c5f7a"/>
</linearGradient>
<!-- Highlight on cover -->
<linearGradient id="coverShine" x1="0%" y1="0%" x2="30%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.12"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Book pages (stacked, slightly offset) -->
<rect x="36" y="18" width="142" height="230" rx="2" fill="url(#pageGrad)" filter="url(#shadow)"/>
<rect x="34" y="16" width="142" height="230" rx="2" fill="#ece9df"/>
<rect x="32" y="14" width="142" height="230" rx="2" fill="#f0ede3"/>
<!-- Book spine -->
<rect x="18" y="14" width="18" height="230" rx="2" fill="url(#spineGrad)"/>
<!-- Spine inner shadow line -->
<line x1="35" y1="14" x2="35" y2="244" stroke="#00000030" stroke-width="1"/>
<!-- Book cover (front) -->
<rect x="32" y="14" width="142" height="230" rx="2" fill="url(#coverGrad)"/>
<!-- Cover shine overlay -->
<rect x="32" y="14" width="142" height="230" rx="2" fill="url(#coverShine)"/>
<!-- Cover border decoration -->
<rect x="40" y="22" width="126" height="214" rx="1" fill="none" stroke="#ffffff30" stroke-width="1.5"/>
<!-- Star graphic centered on cover (scaled & translated star-colored.svg paths) -->
<!-- Original star viewBox: 0 0 81 81, scaled to ~72x72, centered at (103, 95) -->
<g transform="translate(67, 59) scale(0.888)">
<!-- path9: blue-gray arm -->
<path
d="m 32.120065,25.957965 -15.0296,-9.2912 c -0.3066,-0.187 -0.6532,0.1603 -0.4666,0.4676 l 8.9112,14.4745 c 0.2399,0.3874 0.5665,0.7013 0.9597,0.9284 l 14.0032,8.0555 V 0.34209525 c 0,-0.394094 -0.5532,-0.474248 -0.6665,-0.1002 z"
fill="#6197b4"/>
<!-- path11: light arm -->
<path
d="m 25.901665,48.988865 -9.271,15.0556 c -0.1866,0.3073 0.16,0.6546 0.4665,0.4676 l 14.4431,-8.9305 c 0.3866,-0.2405 0.6999,-0.5678 0.9265,-0.9619 l 8.038,-14.0336 H 0.34134532 c -0.393231,0 -0.473212,0.5544 -0.09997,0.6679 z"
fill="#bdccd6"/>
<!-- path13: medium arm -->
<path
d="m 55.101065,32.189965 9.2711,-15.0623 c 0.1866,-0.3072 -0.16,-0.6546 -0.4666,-0.4675 l -14.4431,8.9371 c -0.3865,0.2405 -0.6998,0.5678 -0.9264,0.9619 l -8.038,14.027 h 40.1634 c 0.3932,0 0.4732,-0.5544 0.1,-0.668 z"
fill="#94bfcf"/>
<!-- path15: cream arm -->
<path
d="m 54.507965,48.641565 -14.0099,-8.0555 v 40.2507 c 0,0.3941 0.5532,0.4742 0.6665,0.1002 l 7.7181,-25.7094 15.0296,9.2912 c 0.3066,0.187 0.6532,-0.1603 0.4665,-0.4676 l -8.9177,-14.4812 c -0.2333,-0.3807 -0.5666,-0.7013 -0.9531,-0.9284 z"
fill="#eee9d9"/>
</g>
<!-- Title text -->
<text x="103" y="180" text-anchor="middle" font-family="Georgia, serif" font-size="15" font-weight="bold" fill="#ffffff" letter-spacing="1">TOREAD</text>
<!-- Subtitle / decorative line -->
<line x1="55" y1="188" x2="151" y2="188" stroke="#ffffff50" stroke-width="0.8"/>
<text x="103" y="202" text-anchor="middle" font-family="Georgia, serif" font-size="9" fill="#aaccdd" letter-spacing="2">E-READER</text>
<!-- Bottom spine detail -->
<rect x="18" y="228" width="18" height="16" rx="0 0 2 2" fill="#163348"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- 32x32 web favicon — star symbol on teal rounded background -->
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="fg_bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3a82a6"/>
<stop offset="100%" stop-color="#1a3d52"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="32" height="32" rx="6" fill="url(#fg_bg)"/>
<!-- Star from star-colored.svg, scaled to 24x24, centered (4px padding) -->
<!-- Original viewBox 81x81 → scale=24/81=0.2963, translate(4,4) -->
<g transform="translate(4, 4) scale(0.2963)">
<path d="m 32.120065,25.957965 -15.0296,-9.2912 c -0.3066,-0.187 -0.6532,0.1603 -0.4666,0.4676 l 8.9112,14.4745 c 0.2399,0.3874 0.5665,0.7013 0.9597,0.9284 l 14.0032,8.0555 V 0.34209525 c 0,-0.394094 -0.5532,-0.474248 -0.6665,-0.1002 z" fill="#6197b4"/>
<path d="m 25.901665,48.988865 -9.271,15.0556 c -0.1866,0.3073 0.16,0.6546 0.4665,0.4676 l 14.4431,-8.9305 c 0.3866,-0.2405 0.6999,-0.5678 0.9265,-0.9619 l 8.038,-14.0336 H 0.34134532 c -0.393231,0 -0.473212,0.5544 -0.09997,0.6679 z" fill="#bdccd6"/>
<path d="m 55.101065,32.189965 9.2711,-15.0623 c 0.1866,-0.3072 -0.16,-0.6546 -0.4666,-0.4675 l -14.4431,8.9371 c -0.3865,0.2405 -0.6998,0.5678 -0.9264,0.9619 l -8.038,14.027 h 40.1634 c 0.3932,0 0.4732,-0.5544 0.1,-0.668 z" fill="#94bfcf"/>
<path d="m 54.507965,48.641565 -14.0099,-8.0555 v 40.2507 c 0,0.3941 0.5532,0.4742 0.6665,0.1002 l 7.7181,-25.7094 15.0296,9.2912 c 0.3066,0.187 0.6532,-0.1603 0.4665,-0.4676 l -8.9177,-14.4812 c -0.2333,-0.3807 -0.5666,-0.7013 -0.9531,-0.9284 z" fill="#eee9d9"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1,65 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- 512x512 app icon — desktop launcher & Android (keep content in inner ~340px safe zone) -->
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="bgGrad" cx="45%" cy="35%" r="75%">
<stop offset="0%" stop-color="#2e6b8a"/>
<stop offset="100%" stop-color="#0d2535"/>
</radialGradient>
<filter id="shadow" x="-10%" y="-5%" width="130%" height="120%">
<feDropShadow dx="5" dy="7" stdDeviation="8" flood-color="#00000066"/>
</filter>
<linearGradient id="pageGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f0ede3"/>
<stop offset="100%" stop-color="#e8e4d8"/>
</linearGradient>
<linearGradient id="coverGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3a82a6"/>
<stop offset="100%" stop-color="#1a3d52"/>
</linearGradient>
<linearGradient id="spineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#122c3c"/>
<stop offset="100%" stop-color="#2e6b8a"/>
</linearGradient>
<linearGradient id="coverShine" x1="0%" y1="0%" x2="30%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Full-bleed background -->
<rect width="512" height="512" fill="url(#bgGrad)"/>
<!-- Subtle radial glow behind book -->
<ellipse cx="268" cy="256" rx="200" ry="220" fill="#2e6b8a" opacity="0.25"/>
<!-- Book, scaled 1.84x and centered (book canvas 200x260 → 368x478, centered in 512x512) -->
<!-- x_offset=(512-368)/2=72, y_offset=(512-478)/2=17 -->
<g transform="translate(72, 17) scale(1.84)">
<!-- Pages -->
<rect x="36" y="18" width="142" height="230" rx="2" fill="url(#pageGrad)" filter="url(#shadow)"/>
<rect x="34" y="16" width="142" height="230" rx="2" fill="#ece9df"/>
<rect x="32" y="14" width="142" height="230" rx="2" fill="#f0ede3"/>
<!-- Spine -->
<rect x="18" y="14" width="18" height="230" rx="2" fill="url(#spineGrad)"/>
<line x1="35" y1="14" x2="35" y2="244" stroke="#00000030" stroke-width="1"/>
<!-- Cover -->
<rect x="32" y="14" width="142" height="230" rx="2" fill="url(#coverGrad)"/>
<rect x="32" y="14" width="142" height="230" rx="2" fill="url(#coverShine)"/>
<!-- Cover border -->
<rect x="40" y="22" width="126" height="214" rx="1" fill="none" stroke="#ffffff30" stroke-width="1.5"/>
<!-- Star (scale 0.888 within book, translate 67,59) -->
<g transform="translate(67, 59) scale(0.888)">
<path d="m 32.120065,25.957965 -15.0296,-9.2912 c -0.3066,-0.187 -0.6532,0.1603 -0.4666,0.4676 l 8.9112,14.4745 c 0.2399,0.3874 0.5665,0.7013 0.9597,0.9284 l 14.0032,8.0555 V 0.34209525 c 0,-0.394094 -0.5532,-0.474248 -0.6665,-0.1002 z" fill="#6197b4"/>
<path d="m 25.901665,48.988865 -9.271,15.0556 c -0.1866,0.3073 0.16,0.6546 0.4665,0.4676 l 14.4431,-8.9305 c 0.3866,-0.2405 0.6999,-0.5678 0.9265,-0.9619 l 8.038,-14.0336 H 0.34134532 c -0.393231,0 -0.473212,0.5544 -0.09997,0.6679 z" fill="#bdccd6"/>
<path d="m 55.101065,32.189965 9.2711,-15.0623 c 0.1866,-0.3072 -0.16,-0.6546 -0.4666,-0.4675 l -14.4431,8.9371 c -0.3865,0.2405 -0.6998,0.5678 -0.9264,0.9619 l -8.038,14.027 h 40.1634 c 0.3932,0 0.4732,-0.5544 0.1,-0.668 z" fill="#94bfcf"/>
<path d="m 54.507965,48.641565 -14.0099,-8.0555 v 40.2507 c 0,0.3941 0.5532,0.4742 0.6665,0.1002 l 7.7181,-25.7094 15.0296,9.2912 c 0.3066,0.187 0.6532,-0.1603 0.4665,-0.4676 l -8.9177,-14.4812 c -0.2333,-0.3807 -0.5666,-0.7013 -0.9531,-0.9284 z" fill="#eee9d9"/>
</g>
<!-- Title -->
<text x="103" y="180" text-anchor="middle" font-family="Georgia, serif" font-size="15" font-weight="bold" fill="#ffffff" letter-spacing="1">TOREAD</text>
<line x1="55" y1="188" x2="151" y2="188" stroke="#ffffff50" stroke-width="0.8"/>
<text x="103" y="202" text-anchor="middle" font-family="Georgia, serif" font-size="9" fill="#aaccdd" letter-spacing="2">E-READER</text>
<!-- Bottom spine detail -->
<rect x="18" y="228" width="18" height="16" fill="#0e2030"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,108 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Play Store feature graphic: 1024x500 -->
<svg width="1024" height="500" viewBox="0 0 1024 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Background gradient -->
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0d2535"/>
<stop offset="60%" stop-color="#1a4a66"/>
<stop offset="100%" stop-color="#0a1e2c"/>
</linearGradient>
<!-- Accent glow -->
<radialGradient id="glow" cx="72%" cy="50%" r="40%">
<stop offset="0%" stop-color="#3a82a6" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#3a82a6" stop-opacity="0"/>
</radialGradient>
<!-- Book defs -->
<filter id="bookshadow" x="-15%" y="-10%" width="140%" height="130%">
<feDropShadow dx="8" dy="12" stdDeviation="14" flood-color="#00000088"/>
</filter>
<linearGradient id="pageGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f0ede3"/>
<stop offset="100%" stop-color="#e8e4d8"/>
</linearGradient>
<linearGradient id="coverGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3a82a6"/>
<stop offset="100%" stop-color="#1a3d52"/>
</linearGradient>
<linearGradient id="spineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#122c3c"/>
<stop offset="100%" stop-color="#2e6b8a"/>
</linearGradient>
<linearGradient id="coverShine" x1="0%" y1="0%" x2="30%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<!-- Subtle divider gradient -->
<linearGradient id="divider" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#aaccdd" stop-opacity="0"/>
<stop offset="50%" stop-color="#aaccdd" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#aaccdd" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="1024" height="500" fill="url(#bg)"/>
<!-- Radial glow on right where book sits -->
<rect width="1024" height="500" fill="url(#glow)"/>
<!-- Decorative circles (faint) -->
<circle cx="780" cy="250" r="280" fill="none" stroke="#3a82a6" stroke-width="0.6" opacity="0.15"/>
<circle cx="780" cy="250" r="210" fill="none" stroke="#3a82a6" stroke-width="0.6" opacity="0.15"/>
<circle cx="780" cy="250" r="140" fill="none" stroke="#3a82a6" stroke-width="0.6" opacity="0.15"/>
<!-- Left panel: branding -->
<!-- Large star symbol, centered left area -->
<!-- star viewBox 81x81, scale to 110x110 at position (70, 100) -->
<!-- scale=110/81=1.358, translate(70,100) -->
<g transform="translate(70, 120) scale(1.358)" opacity="0.85">
<path d="m 32.120065,25.957965 -15.0296,-9.2912 c -0.3066,-0.187 -0.6532,0.1603 -0.4666,0.4676 l 8.9112,14.4745 c 0.2399,0.3874 0.5665,0.7013 0.9597,0.9284 l 14.0032,8.0555 V 0.34209525 c 0,-0.394094 -0.5532,-0.474248 -0.6665,-0.1002 z" fill="#6197b4"/>
<path d="m 25.901665,48.988865 -9.271,15.0556 c -0.1866,0.3073 0.16,0.6546 0.4665,0.4676 l 14.4431,-8.9305 c 0.3866,-0.2405 0.6999,-0.5678 0.9265,-0.9619 l 8.038,-14.0336 H 0.34134532 c -0.393231,0 -0.473212,0.5544 -0.09997,0.6679 z" fill="#bdccd6"/>
<path d="m 55.101065,32.189965 9.2711,-15.0623 c 0.1866,-0.3072 -0.16,-0.6546 -0.4666,-0.4675 l -14.4431,8.9371 c -0.3865,0.2405 -0.6998,0.5678 -0.9264,0.9619 l -8.038,14.027 h 40.1634 c 0.3932,0 0.4732,-0.5544 0.1,-0.668 z" fill="#94bfcf"/>
<path d="m 54.507965,48.641565 -14.0099,-8.0555 v 40.2507 c 0,0.3941 0.5532,0.4742 0.6665,0.1002 l 7.7181,-25.7094 15.0296,9.2912 c 0.3066,0.187 0.6532,-0.1603 0.4665,-0.4676 l -8.9177,-14.4812 c -0.2333,-0.3807 -0.5666,-0.7013 -0.9531,-0.9284 z" fill="#eee9d9"/>
</g>
<!-- App name -->
<text x="215" y="175" font-family="Georgia, serif" font-size="88" font-weight="bold" fill="#ffffff" letter-spacing="4">TOREAD</text>
<!-- Tagline -->
<text x="218" y="224" font-family="Georgia, serif" font-size="26" fill="#aaccdd" letter-spacing="3">Your e-reading companion</text>
<!-- Feature bullets -->
<text x="218" y="282" font-family="Georgia, serif" font-size="18" fill="#7baabf">· EPUB &amp; FB2 support</text>
<text x="218" y="312" font-family="Georgia, serif" font-size="18" fill="#7baabf">· Clean reader, smart library</text>
<text x="218" y="342" font-family="Georgia, serif" font-size="18" fill="#7baabf">· Read aloud &amp; offline</text>
<!-- Vertical divider line -->
<line x1="600" y1="40" x2="600" y2="460" stroke="url(#divider)" stroke-width="1"/>
<!-- Book on the right, scale to fit 420px height -->
<!-- scale=420/260=1.615, width=200*1.615=323 -->
<!-- x: 600 + (424-323)/2 = 600+50 = 650, y: (500-420)/2=40 -->
<g transform="translate(652, 38) scale(1.615)">
<!-- Pages -->
<rect x="36" y="18" width="142" height="230" rx="2" fill="url(#pageGrad)" filter="url(#bookshadow)"/>
<rect x="34" y="16" width="142" height="230" rx="2" fill="#ece9df"/>
<rect x="32" y="14" width="142" height="230" rx="2" fill="#f0ede3"/>
<!-- Spine -->
<rect x="18" y="14" width="18" height="230" rx="2" fill="url(#spineGrad)"/>
<line x1="35" y1="14" x2="35" y2="244" stroke="#00000030" stroke-width="1"/>
<!-- Cover -->
<rect x="32" y="14" width="142" height="230" rx="2" fill="url(#coverGrad)"/>
<rect x="32" y="14" width="142" height="230" rx="2" fill="url(#coverShine)"/>
<!-- Cover border -->
<rect x="40" y="22" width="126" height="214" rx="1" fill="none" stroke="#ffffff30" stroke-width="1.5"/>
<!-- Star on cover -->
<g transform="translate(67, 59) scale(0.888)">
<path d="m 32.120065,25.957965 -15.0296,-9.2912 c -0.3066,-0.187 -0.6532,0.1603 -0.4666,0.4676 l 8.9112,14.4745 c 0.2399,0.3874 0.5665,0.7013 0.9597,0.9284 l 14.0032,8.0555 V 0.34209525 c 0,-0.394094 -0.5532,-0.474248 -0.6665,-0.1002 z" fill="#6197b4"/>
<path d="m 25.901665,48.988865 -9.271,15.0556 c -0.1866,0.3073 0.16,0.6546 0.4665,0.4676 l 14.4431,-8.9305 c 0.3866,-0.2405 0.6999,-0.5678 0.9265,-0.9619 l 8.038,-14.0336 H 0.34134532 c -0.393231,0 -0.473212,0.5544 -0.09997,0.6679 z" fill="#bdccd6"/>
<path d="m 55.101065,32.189965 9.2711,-15.0623 c 0.1866,-0.3072 -0.16,-0.6546 -0.4666,-0.4675 l -14.4431,8.9371 c -0.3865,0.2405 -0.6998,0.5678 -0.9264,0.9619 l -8.038,14.027 h 40.1634 c 0.3932,0 0.4732,-0.5544 0.1,-0.668 z" fill="#94bfcf"/>
<path d="m 54.507965,48.641565 -14.0099,-8.0555 v 40.2507 c 0,0.3941 0.5532,0.4742 0.6665,0.1002 l 7.7181,-25.7094 15.0296,9.2912 c 0.3066,0.187 0.6532,-0.1603 0.4665,-0.4676 l -8.9177,-14.4812 c -0.2333,-0.3807 -0.5666,-0.7013 -0.9531,-0.9284 z" fill="#eee9d9"/>
</g>
<!-- Title -->
<text x="103" y="180" text-anchor="middle" font-family="Georgia, serif" font-size="15" font-weight="bold" fill="#ffffff" letter-spacing="1">TOREAD</text>
<line x1="55" y1="188" x2="151" y2="188" stroke="#ffffff50" stroke-width="0.8"/>
<text x="103" y="202" text-anchor="middle" font-family="Georgia, serif" font-size="9" fill="#aaccdd" letter-spacing="2">E-READER</text>
<rect x="18" y="228" width="18" height="16" fill="#0e2030"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="81.0028"
height="81.178841"
viewBox="0 0 81.0028 81.178841"
fill="none"
version="1.1"
id="svg17"
sodipodi:docname="star-colored.svg"
inkscape:version="1.1.1 (c3084ef, 2021-09-22)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs21" />
<sodipodi:namedview
id="namedview19"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="6.7100498"
inkscape:cx="56.780502"
inkscape:cy="58.047259"
inkscape:window-width="1312"
inkscape:window-height="1067"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="svg17" />
<path
d="m 32.120065,25.957965 -15.0296,-9.2912 c -0.3066,-0.187 -0.6532,0.1603 -0.4666,0.4676 l 8.9112,14.4745 c 0.2399,0.3874 0.5665,0.7013 0.9597,0.9284 l 14.0032,8.0555 V 0.34209525 c 0,-0.394094 -0.5532,-0.474248 -0.6665,-0.1002 z"
fill="#6197b4"
id="path9" />
<path
d="m 25.901665,48.988865 -9.271,15.0556 c -0.1866,0.3073 0.16,0.6546 0.4665,0.4676 l 14.4431,-8.9305 c 0.3866,-0.2405 0.6999,-0.5678 0.9265,-0.9619 l 8.038,-14.0336 H 0.34134532 c -0.393231,0 -0.473212,0.5544 -0.09997,0.6679 z"
fill="#bdccd6"
id="path11" />
<path
d="m 55.101065,32.189965 9.2711,-15.0623 c 0.1866,-0.3072 -0.16,-0.6546 -0.4666,-0.4675 l -14.4431,8.9371 c -0.3865,0.2405 -0.6998,0.5678 -0.9264,0.9619 l -8.038,14.027 h 40.1634 c 0.3932,0 0.4732,-0.5544 0.1,-0.668 z"
fill="#94bfcf"
id="path13" />
<path
d="m 54.507965,48.641565 -14.0099,-8.0555 v 40.2507 c 0,0.3941 0.5532,0.4742 0.6665,0.1002 l 7.7181,-25.7094 15.0296,9.2912 c 0.3066,0.187 0.6532,-0.1603 0.4665,-0.4676 l -8.9177,-14.4812 c -0.2333,-0.3807 -0.5666,-0.7013 -0.9531,-0.9284 z"
fill="#eee9d9"
id="path15" />
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -217,8 +217,6 @@ interface ReadingStateRepository {
fun upsert(state: ReadingStateRecord) fun upsert(state: ReadingStateRecord)
fun get(id: String): ReadingStateRecord? fun get(id: String): ReadingStateRecord?
fun getForBodyCluster(bodyClusterId: String): ReadingStateRecord? fun getForBodyCluster(bodyClusterId: String): ReadingStateRecord?
fun deleteForBodyCluster(bodyClusterId: String): Int
fun delete(id: String): Boolean
} }
interface BookmarkRepository { interface BookmarkRepository {

View File

@ -862,15 +862,6 @@ private class JdbcReadingStateRepository(private val connection: Connection) : R
it.toReadingStateRecord() it.toReadingStateRecord()
} }
} }
override fun deleteForBodyCluster(bodyClusterId: String): Int {
return connection.prepareStatement("DELETE FROM reading_states WHERE body_cluster_id = ?").use { statement ->
statement.setString(1, bodyClusterId)
statement.executeUpdate()
}
}
override fun delete(id: String): Boolean = connection.deleteById("reading_states", id)
} }
private class JdbcBookmarkRepository(private val connection: Connection) : BookmarkRepository { private class JdbcBookmarkRepository(private val connection: Connection) : BookmarkRepository {

View File

@ -376,42 +376,6 @@ class H2LibraryDatabaseTest {
H2LibraryDatabase.openFile(path).close() H2LibraryDatabase.openFile(path).close()
} }
@Test
fun persistsReadingStatusAcrossDatabaseRestart() {
val path = Files.createTempDirectory("toread-h2-reading-status-").resolve("library").toString()
val now = 1_700_000_000_000L
val initialDb = H2LibraryDatabase.openFile(path)
try {
val db = initialDb
db.transaction {
books.upsert(BookRecord(id = "book-1", title = "Persistent", createdAt = now, updatedAt = now))
files.upsert(
BookFileRecord(
id = "file-1",
bookId = "book-1",
rawSha256 = "sha-1",
storageKind = BookFileStorageKind.EXTERNAL_URI,
createdAt = now,
updatedAt = now,
)
)
}
assertEquals(true, db.files.updateReadingStatus("file-1", BookReadingStatus.NOT_INTERESTED))
} finally {
initialDb.close()
}
val reopenedDb = H2LibraryDatabase.openFile(path)
try {
val db = reopenedDb
assertEquals(BookReadingStatus.NOT_INTERESTED, db.files.get("file-1")?.readingStatus)
assertEquals(BookReadingStatus.NOT_INTERESTED, db.files.getLibraryFile("file-1")?.readingStatus)
} finally {
reopenedDb.close()
}
}
@Test @Test
fun migratesLegacyBookFilesWithImportTime() { fun migratesLegacyBookFilesWithImportTime() {
val path = Files.createTempDirectory("toread-h2-imported-at-").resolve("library").toString() val path = Files.createTempDirectory("toread-h2-imported-at-").resolve("library").toString()