Added URL detection in reader, improved platform URL handling, library item auto-rescan, and file association support.
This commit is contained in:
1 parent
2e9a52f4af
commit
cde4d89eed
15 files changed
+367
-48
No files matched your search
@@ -24,6 +24,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import net.sergeych.toread.fb2.Fb2Format
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -180,6 +184,9 @@ private fun BookReaderApp(
|
||||
var nextLibraryItemRefreshId by remember { mutableStateOf(0L) }
|
||||
var libraryItemRefreshRequest by remember { mutableStateOf<LibraryItemRefreshRequest?>(null) }
|
||||
var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) }
|
||||
var resumeGeneration by remember { mutableStateOf(0L) }
|
||||
var downloadsRescanRequestGeneration by remember { mutableStateOf(0L) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun refreshLibraryItem(fileId: String) {
|
||||
@@ -191,6 +198,60 @@ private fun BookReaderApp(
|
||||
state = loadStartupState()
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
resumeGeneration += 1
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(500)
|
||||
val requestResult = runCatching { loadPlatformOpenBookRequest() }
|
||||
if (requestResult.isFailure) {
|
||||
val scanPath = when (val current = state) {
|
||||
is AppState.Library -> current.scanPath
|
||||
is AppState.Scan -> current.scanPath
|
||||
is AppState.Reader -> current.scanPath
|
||||
is AppState.BookInfo -> current.scanPath
|
||||
is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty()
|
||||
}
|
||||
state = AppState.Library(emptyList(), scanPath, requestResult.exceptionOrNull()?.message ?: strings.couldNotOpenLibrary)
|
||||
continue
|
||||
}
|
||||
val request = requestResult.getOrNull() ?: continue
|
||||
val scanPath = when (val current = state) {
|
||||
is AppState.Library -> current.scanPath
|
||||
is AppState.Scan -> current.scanPath
|
||||
is AppState.Reader -> current.scanPath
|
||||
is AppState.BookInfo -> current.scanPath
|
||||
is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty()
|
||||
}
|
||||
val nextState = runCatching {
|
||||
AppState.Reader(
|
||||
fileId = request.id,
|
||||
book = Fb2Format.parse(request.bytes, request.displayName),
|
||||
libraryItems = emptyList(),
|
||||
scanPath = scanPath,
|
||||
)
|
||||
}.getOrElse {
|
||||
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName))
|
||||
}
|
||||
libraryBackState = when (val current = state) {
|
||||
is AppState.Library -> current
|
||||
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
|
||||
is AppState.Reader, is AppState.BookInfo -> libraryBackState
|
||||
is AppState.Error, AppState.LoadingStartup -> null
|
||||
}
|
||||
state = nextState
|
||||
refreshLibraryItem(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
val activeBookState = when (val current = state) {
|
||||
is AppState.Reader -> current.fileId to current.libraryItems.isEmpty()
|
||||
is AppState.BookInfo -> current.fileId to current.libraryItems.isEmpty()
|
||||
@@ -296,6 +357,7 @@ private fun BookReaderApp(
|
||||
val backState = libraryBackState?.copy(message = current.message)
|
||||
?: AppState.Library(current.libraryItems, current.scanPath, current.message)
|
||||
libraryBackState = null
|
||||
downloadsRescanRequestGeneration += 1
|
||||
backState
|
||||
}
|
||||
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
|
||||
@@ -357,6 +419,14 @@ private fun BookReaderApp(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(resumeGeneration, downloadsRescanRequestGeneration, state !is AppState.LoadingStartup) {
|
||||
if (state is AppState.LoadingStartup || activeScan != null || scanJob?.isActive == true) return@LaunchedEffect
|
||||
if (!loadScanDownloadsAutomatically() || !loadDownloadsWasScanned()) return@LaunchedEffect
|
||||
val path = downloadsScanPath() ?: return@LaunchedEffect
|
||||
state = state.withMessage(strings.scanningDownloads)
|
||||
startScan(path)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
val currentLibraryState = when (val current = state) {
|
||||
is AppState.Library -> current
|
||||
@@ -380,7 +450,6 @@ private fun BookReaderApp(
|
||||
libraryBackState = null
|
||||
state = AppState.Scan(libraryState.items, libraryState.scanPath, libraryState.message)
|
||||
},
|
||||
onStartScan = ::startScan,
|
||||
onDeleteRequested = ::requestDelete,
|
||||
)
|
||||
}
|
||||
@@ -447,3 +516,12 @@ internal data class ViewedBookImage(
|
||||
|
||||
private fun LibraryScanReport.hasLibraryChanges(): Boolean =
|
||||
importedFiles > 0
|
||||
|
||||
private fun AppState.withMessage(message: String): AppState =
|
||||
when (this) {
|
||||
is AppState.Library -> copy(message = message)
|
||||
is AppState.Scan -> copy(message = message)
|
||||
is AppState.Reader -> copy(message = message)
|
||||
is AppState.BookInfo -> copy(message = message)
|
||||
is AppState.Error, AppState.LoadingStartup -> this
|
||||
}
|
||||
@@ -42,7 +42,10 @@ internal suspend fun loadStartupState(): AppState {
|
||||
} catch (t: Throwable) {
|
||||
return AppState.Error(t.message ?: strings.couldNotOpenLibrary)
|
||||
}
|
||||
loadPlatformOpenBookRequest()?.let { request ->
|
||||
val platformRequest = runCatching { loadPlatformOpenBookRequest() }.getOrElse {
|
||||
return AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpenLibrary)
|
||||
}
|
||||
platformRequest?.let { request ->
|
||||
return runCatching {
|
||||
AppState.Reader(
|
||||
fileId = request.id,
|
||||
|
||||
@@ -142,6 +142,8 @@ expect suspend fun shareLibraryBookFile(fileId: String): Boolean
|
||||
|
||||
expect suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean
|
||||
|
||||
expect fun openPlatformUrl(url: String): Boolean
|
||||
|
||||
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
|
||||
|
||||
expect suspend fun loadActiveReadingFileId(): String?
|
||||
|
||||
@@ -98,7 +98,6 @@ internal fun LibraryScreen(
|
||||
hiddenFileIds: Set<String>,
|
||||
onStateChange: (AppState) -> Unit,
|
||||
onNavigateToScan: () -> Unit,
|
||||
onStartScan: (String) -> Unit,
|
||||
onDeleteRequested: (
|
||||
request: LibraryDeleteRequest,
|
||||
remove: () -> Unit,
|
||||
@@ -116,8 +115,6 @@ internal fun LibraryScreen(
|
||||
var settingsMenuOpen by remember { mutableStateOf(false) }
|
||||
var localeMenuOpen by remember { mutableStateOf(false) }
|
||||
var autoScanDownloads by remember { mutableStateOf(true) }
|
||||
var autoScanSettingLoaded by remember { mutableStateOf(false) }
|
||||
var backgroundDownloadsRescanStarted by remember { mutableStateOf(false) }
|
||||
var searchText by remember { mutableStateOf("") }
|
||||
var searchFocused by remember { mutableStateOf(false) }
|
||||
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
|
||||
@@ -314,25 +311,6 @@ internal fun LibraryScreen(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
autoScanDownloads = loadScanDownloadsAutomatically()
|
||||
autoScanSettingLoaded = true
|
||||
}
|
||||
|
||||
LaunchedEffect(autoScanSettingLoaded, autoScanDownloads, activeScan == null) {
|
||||
if (
|
||||
!autoScanSettingLoaded ||
|
||||
!autoScanDownloads ||
|
||||
backgroundDownloadsRescanStarted ||
|
||||
activeScan != null
|
||||
) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (loadDownloadsWasScanned()) {
|
||||
downloadsScanPath()?.let { path ->
|
||||
backgroundDownloadsRescanStarted = true
|
||||
message = strings.scanningDownloads
|
||||
onStartScan(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(activeScan != null) {
|
||||
|
||||
@@ -1293,14 +1293,100 @@ private fun Fb2Text.toAnnotatedString(
|
||||
span.href?.takeIf { it.isNotBlank() }?.let {
|
||||
pushStringAnnotation(ReaderLinkAnnotationTag, it)
|
||||
}
|
||||
withStyle(spanStyle) {
|
||||
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
|
||||
if (isLink) {
|
||||
withStyle(spanStyle) {
|
||||
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
|
||||
}
|
||||
} else {
|
||||
appendWithDetectedWebLinks(
|
||||
text = span.text,
|
||||
spanStyle = spanStyle,
|
||||
plainOffset = plainOffset,
|
||||
highlightedRange = highlightedRange,
|
||||
highlightColor = highlightColor,
|
||||
language = language,
|
||||
hyphenation = hyphenation,
|
||||
)
|
||||
}
|
||||
if (isLink) pop()
|
||||
plainOffset += span.text.length
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.appendWithDetectedWebLinks(
|
||||
text: String,
|
||||
spanStyle: SpanStyle,
|
||||
plainOffset: Int,
|
||||
highlightedRange: ReaderSentenceRange?,
|
||||
highlightColor: Color,
|
||||
language: String?,
|
||||
hyphenation: HyphenationRegistry,
|
||||
) {
|
||||
var cursor = 0
|
||||
text.webUrlRanges().forEach { range ->
|
||||
if (cursor < range.start) {
|
||||
withStyle(spanStyle) {
|
||||
appendWithHighlight(
|
||||
text = text.substring(cursor, range.start),
|
||||
plainOffset = plainOffset + cursor,
|
||||
highlightedRange = highlightedRange,
|
||||
highlightColor = highlightColor,
|
||||
language = language,
|
||||
hyphenation = hyphenation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val url = text.substring(range)
|
||||
pushStringAnnotation(ReaderLinkAnnotationTag, url)
|
||||
withStyle(spanStyle.readerLinkStyle()) {
|
||||
appendWithHighlight(
|
||||
text = url,
|
||||
plainOffset = plainOffset + range.start,
|
||||
highlightedRange = highlightedRange,
|
||||
highlightColor = highlightColor,
|
||||
language = null,
|
||||
hyphenation = hyphenation,
|
||||
)
|
||||
}
|
||||
pop()
|
||||
cursor = range.endInclusive + 1
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
withStyle(spanStyle) {
|
||||
appendWithHighlight(
|
||||
text = text.substring(cursor),
|
||||
plainOffset = plainOffset + cursor,
|
||||
highlightedRange = highlightedRange,
|
||||
highlightColor = highlightColor,
|
||||
language = language,
|
||||
hyphenation = hyphenation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SpanStyle.readerLinkStyle(): SpanStyle =
|
||||
copy(
|
||||
color = LinkTextColor,
|
||||
textDecoration = when (textDecoration) {
|
||||
TextDecoration.LineThrough ->
|
||||
TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough))
|
||||
else -> TextDecoration.Underline
|
||||
},
|
||||
)
|
||||
|
||||
internal fun String.webUrlRanges(): List<IntRange> =
|
||||
WebUrlRegex.findAll(this).mapNotNull { match ->
|
||||
val trimmed = match.value.trimEnd(*WebUrlTrailingPunctuation)
|
||||
if (trimmed.substringAfter("://").isEmpty()) return@mapNotNull null
|
||||
match.range.first..<(match.range.first + trimmed.length)
|
||||
}.toList()
|
||||
|
||||
internal fun String.isWebUrl(): Boolean =
|
||||
startsWith("https://", ignoreCase = true) || startsWith("http://", ignoreCase = true)
|
||||
|
||||
private fun AnnotatedString.Builder.appendWithHighlight(
|
||||
text: String,
|
||||
plainOffset: Int,
|
||||
@@ -1678,6 +1764,8 @@ private const val EllipsisPauseAfterMillis = 350L
|
||||
private const val CombiningAcuteAccent = '\u0301'
|
||||
private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY"
|
||||
private const val ReaderLinkAnnotationTag = "fb2-link"
|
||||
private val WebUrlRegex = Regex("""https?://\S+""", RegexOption.IGNORE_CASE)
|
||||
private val WebUrlTrailingPunctuation = charArrayOf('.', ',', ';', ':', '!', '?', ')', ']', '}', '>', '"', '\'', '»', '”', '’')
|
||||
private val LinkTextColor = Color(0xFF0B57D0)
|
||||
private val ReaderLinkMinimumTouchWidth = 44.dp
|
||||
private val ReaderLinkMinimumTouchHeight = 40.dp
|
||||
|
||||
@@ -130,6 +130,14 @@ internal fun BookView(
|
||||
}
|
||||
}
|
||||
|
||||
fun openReaderLink(href: String) {
|
||||
if (href.isWebUrl()) {
|
||||
openPlatformUrl(href)
|
||||
} else {
|
||||
selectedNoteId = href.removePrefix("#")
|
||||
}
|
||||
}
|
||||
|
||||
fun updateReaderFontSettings(transform: (ReaderFontSettings) -> ReaderFontSettings) {
|
||||
val next = transform(readerFontSettings).coerced()
|
||||
if (next == readerFontSettings) return
|
||||
@@ -353,7 +361,7 @@ internal fun BookView(
|
||||
},
|
||||
onUserScroll = { userScrollGeneration += 1 },
|
||||
onImageOpen = onImageOpen,
|
||||
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") },
|
||||
onNoteOpen = ::openReaderLink,
|
||||
)
|
||||
if (readerSettingsPanelVisible) {
|
||||
ReaderFontSettingsPanel(
|
||||
@@ -420,7 +428,7 @@ internal fun BookView(
|
||||
noteId = noteId,
|
||||
onDismiss = { selectedNoteId = null },
|
||||
onImageOpen = onImageOpen,
|
||||
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") },
|
||||
onNoteOpen = ::openReaderLink,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user