Compare commits
No commits in common. "6422f814d18e1fa19202573438e30189d1c33138" and "26343cb8a1f5aeaacda60682442656243d2db255" have entirely different histories.
6422f814d1
...
26343cb8a1
@ -80,7 +80,6 @@ android {
|
|||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("release") {
|
getByName("release") {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 16 KiB |
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -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}"
|
||||||
|
|||||||
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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 |
@ -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 |
@ -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 |
@ -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 & 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 & 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 |
@ -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 |
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||