From 2f3d112aea1c76c4dc3f13ab86c36b8bca588d77 Mon Sep 17 00:00:00 2001 From: Selxo Date: Mon, 29 Jun 2026 07:22:03 +0300 Subject: [PATCH 1/2] Add QOL lookup improvements and warmups --- .../ScreenLookupOverlayController.kt | 19 +++++++++- .../ui/player/controls/PlayerControls.kt | 20 +++++++++++ .../controls/PlayerSubtitleLookupPopup.kt | 36 ++++++++++++------- .../player/controls/PlayerVideoOcrOverlay.kt | 14 +++++++- 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/dictionary/ScreenLookupOverlayController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/dictionary/ScreenLookupOverlayController.kt index 39974ba2ae..828269c30d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/dictionary/ScreenLookupOverlayController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/dictionary/ScreenLookupOverlayController.kt @@ -52,7 +52,11 @@ import eu.kanade.tachiyomi.ui.reader.viewer.fullText import eu.kanade.tachiyomi.ui.reader.viewer.isLookupStartChar import eu.kanade.tachiyomi.util.view.setComposeContent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import tachiyomi.core.common.i18n.stringResource as contextStringResource import tachiyomi.i18n.MR @@ -74,6 +78,8 @@ internal class ScreenLookupOverlayController( private var cachedProfile: chimahon.anki.AnkiProfile? = null private var overlayBackHandler: (() -> Boolean)? = null private var overlayBackCallback: Any? = null + private val lookupWarmupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var lookupWarmupJob: Job? = null val isShowing: Boolean get() = overlayView != null @@ -88,6 +94,10 @@ internal class ScreenLookupOverlayController( val webView = cachedWebView ?: prepareDictionaryWebViewShell(context, languageCode = profile.languageCode) .also { cachedWebView = it } + lookupWarmupJob?.cancel() + lookupWarmupJob = lookupWarmupScope.launch { + Injekt.get().warmUp(getDictionaryPaths(context, profile), profile.id) + } val owner = OverlayLifecycleOwner().also { it.performCreate() @@ -165,6 +175,8 @@ internal class ScreenLookupOverlayController( fun release() { dismiss(recycleScreenshot = true, notify = false) + lookupWarmupJob?.cancel() + lookupWarmupJob = null cachedWebView?.runCatching { destroy() } cachedWebView = null cachedProfile = null @@ -318,7 +330,12 @@ internal fun ScreenLookupOverlay( onBlockTapped = { tapped, tapX, tapY -> val charOffset = tapped.screenLookupCharOffset(tapX, tapY) val text = tapped.fullText - if (charOffset in text.indices && isLookupStartChar(text[charOffset])) { + if (selection?.block == tapped && selection?.sentenceOffset == charOffset) { + selection = null + showTapHint = false + matchedCharCount = 0 + matchOffset = 0 + } else if (charOffset in text.indices && isLookupStartChar(text[charOffset])) { val lookupString = extractOcrLookupString(text, charOffset) if (lookupString.isNotBlank()) { lookupNonce++ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt index bab95601d6..7cc71c2db2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt @@ -191,6 +191,11 @@ fun PlayerControls( label = "controls_transparent_overlay", ) val openSubtitleLookup: (SubtitleLookupSelection) -> Unit = openSubtitleLookup@{ subtitleLookup -> + if (subtitleLookupRequest?.matchesTap(subtitleLookup) == true) { + subtitleLookupRequest = null + viewModel.unpause() + return@openSubtitleLookup + } val currentPanel = viewModel.panelShown.value if ( viewModel.sheetShown.value != Sheets.None || @@ -1086,6 +1091,21 @@ private fun SubtitleLookupSelection.offsetBy(offset: Offset): SubtitleLookupSele ) } +private fun SubtitleLookupRequest.matchesTap(selection: SubtitleLookupSelection): Boolean { + if (fullText != selection.fullText || lineIndex != selection.lineIndex) return false + + val start = (charOffset + matchOffset).coerceIn(0, fullText.length) + val count = matchedCharCount.takeIf { it > 0 } ?: 1 + val end = (start + count).coerceIn(start + 1, fullText.length) + + return selection.tapCharOffset in start until end || + ( + selection.lookupString == lookupString && + selection.tapCharOffset == tapCharOffset && + selection.lineStartOffset == lineStartOffset + ) +} + private fun TextLayoutResult.lookupOffsetForPosition(text: String, position: Offset): Int? { if (text.isBlank()) return null val lineIndex = getLineForVerticalPosition(position.y).coerceIn(0, lineCount - 1) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSubtitleLookupPopup.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSubtitleLookupPopup.kt index d683f43e86..f62c73ac10 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSubtitleLookupPopup.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSubtitleLookupPopup.kt @@ -4,6 +4,7 @@ import android.webkit.WebView import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -13,8 +14,11 @@ import chimahon.DictionaryRepository import chimahon.MediaInfo import eu.kanade.tachiyomi.ui.dictionary.DictionaryPopupWebViewWarmup import eu.kanade.tachiyomi.ui.dictionary.DictionaryPreferences +import eu.kanade.tachiyomi.ui.dictionary.getDictionaryPaths import eu.kanade.tachiyomi.ui.player.PlayerViewModel import eu.kanade.tachiyomi.ui.reader.viewer.OcrLookupPopup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import tachiyomi.presentation.core.util.collectAsState import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -49,8 +53,6 @@ internal fun PlayerSubtitleLookupPopup( onTermMatched: (Int, Int) -> Unit, modifier: Modifier = Modifier, ) { - if (request == null) return - val context = LocalContext.current val dictionaryPreferences = remember { Injekt.get() } val repository = remember { Injekt.get() } @@ -67,26 +69,34 @@ internal fun PlayerSubtitleLookupPopup( DictionaryPopupWebViewWarmup.acquire(context, activeProfile.languageCode) } + LaunchedEffect(activeProfile) { + withContext(Dispatchers.IO) { + repository.warmUp(getDictionaryPaths(context, activeProfile), activeProfile.id) + } + } + DisposableEffect(webView) { onDispose { DictionaryPopupWebViewWarmup.recycle(context, webView) } } - BackHandler(onBack = onDismiss) + BackHandler(enabled = request != null, onBack = onDismiss) + + val visible = request != null OcrLookupPopup( - visible = true, - lookupString = request.lookupString, - fullText = request.fullText, - charOffset = request.charOffset, + visible = visible, + lookupString = request?.lookupString.orEmpty(), + fullText = request?.fullText.orEmpty(), + charOffset = request?.charOffset ?: 0, onDismiss = onDismiss, webView = webView, repository = repository, - anchorX = request.anchorX, - anchorY = request.anchorY, - anchorWidth = request.anchorWidth, - anchorHeight = request.anchorHeight, + anchorX = request?.anchorX ?: 0f, + anchorY = request?.anchorY ?: 0f, + anchorWidth = request?.anchorWidth ?: 0f, + anchorHeight = request?.anchorHeight ?: 0f, isVertical = false, activeProfile = activeProfile, type = "anime", @@ -99,8 +109,8 @@ internal fun PlayerSubtitleLookupPopup( }, onRequestSentenceAudio = { viewModel.captureSubtitleAudioForAnki( - startSeconds = request.cueStartSeconds, - endSeconds = request.cueEndSeconds, + startSeconds = request?.cueStartSeconds, + endSeconds = request?.cueEndSeconds, ) }, usePopup = false, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerVideoOcrOverlay.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerVideoOcrOverlay.kt index 3dcef3f5c9..7af2a8b4e1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerVideoOcrOverlay.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerVideoOcrOverlay.kt @@ -31,6 +31,7 @@ import eu.kanade.tachiyomi.ui.dictionary.OcrBlockCanvas import eu.kanade.tachiyomi.ui.dictionary.OcrSelection import eu.kanade.tachiyomi.ui.dictionary.OcrStatusOverlay import eu.kanade.tachiyomi.ui.dictionary.OcrTapHint +import eu.kanade.tachiyomi.ui.dictionary.getDictionaryPaths import eu.kanade.tachiyomi.ui.dictionary.screenLookupCharOffset import eu.kanade.tachiyomi.ui.dictionary.toScreenLookupBlocks import eu.kanade.tachiyomi.ui.player.PlayerViewModel @@ -79,6 +80,12 @@ internal fun PlayerVideoOcrOverlay( val boxScaleX = dictionaryPreferences.ocrBoxScaleX().get() val boxScaleY = dictionaryPreferences.ocrBoxScaleY().get() + LaunchedEffect(activeProfile) { + withContext(Dispatchers.IO) { + repository.warmUp(getDictionaryPaths(context, activeProfile), activeProfile.id) + } + } + DisposableEffect(webView) { onDispose { DictionaryPopupWebViewWarmup.recycle(context, webView) @@ -146,7 +153,12 @@ internal fun PlayerVideoOcrOverlay( val charOffset = tapped.screenLookupCharOffset(tapX, tapY) val orderedCharOffset = tapped.toOrderedOffset(charOffset) val text = tapped.orderedFullText - if (orderedCharOffset in text.indices && isLookupStartChar(text[orderedCharOffset])) { + if (selection?.block == tapped && selection?.sentenceOffset == orderedCharOffset) { + selection = null + showTapHint = false + matchedCharCount = 0 + matchOffset = 0 + } else if (orderedCharOffset in text.indices && isLookupStartChar(text[orderedCharOffset])) { val lookupString = extractOcrLookupString(text, orderedCharOffset) if (lookupString.isNotBlank()) { lookupNonce++ From 16c375be1d91c6faa9c0e2b398122aeb974c35a1 Mon Sep 17 00:00:00 2001 From: Selxo Date: Mon, 29 Jun 2026 07:39:11 +0300 Subject: [PATCH 2/2] Refine lookup result highlighting --- .../tachiyomi/ui/dictionary/OcrOverlayComponents.kt | 5 ++++- .../tachiyomi/ui/player/controls/PlayerControls.kt | 10 ++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/dictionary/OcrOverlayComponents.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/dictionary/OcrOverlayComponents.kt index 2cf1587d2f..1d462529ad 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/dictionary/OcrOverlayComponents.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/dictionary/OcrOverlayComponents.kt @@ -36,7 +36,6 @@ data class OcrSelection( ) private val borderColor = Color(0, 170, 255, 180) -private val highlightColor = Color(130, 150, 200, 100) @Composable fun OcrBlockCanvas( @@ -51,6 +50,8 @@ fun OcrBlockCanvas( onEmptyTap: () -> Unit, modifier: Modifier = Modifier, ) { + val highlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.45f) + Canvas( modifier = modifier .fillMaxSize() @@ -89,6 +90,7 @@ fun OcrBlockCanvas( selection = selection, boxScaleX = boxScaleX, boxScaleY = boxScaleY, + highlightColor = highlightColor, ) } } @@ -163,6 +165,7 @@ private fun DrawScope.drawMatchHighlight( selection: OcrSelection, boxScaleX: Float, boxScaleY: Float, + highlightColor: Color, ) { val orderedIndices = block.orderedLineIndices() val orderedSentence = orderedIndices.joinToString("") { block.lines[it] } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt index 7cc71c2db2..b1d5397c0b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt @@ -905,6 +905,7 @@ private fun PlayerSubtitleTextLayer( val fontSizeSp = (subtitleFontSize * subtitleScale * fontSizeFactor).coerceIn(minFontSize, maxFontSize) val resolvedBottomPadding = bottomPadding ?: (28f + (100 - subtitlePos).coerceIn(0, 100) * 2.2f).dp val outlineWidth = borderSize.coerceAtLeast(1) * 1.8f + val lookupHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.45f) val boxBackgroundColor = Color(backgroundColor).let { if (it.alpha == 0f && borderStyle != SubtitlesBorderStyle.OutlineAndShadow) { Color.Black.copy(alpha = 0.78f) @@ -959,16 +960,17 @@ private fun PlayerSubtitleTextLayer( } } - val activeRequest = request?.takeIf { it.fullText == subtitleText } ?: return@drawBehind + val activeRequest = request?.takeIf { + it.fullText == subtitleText && it.matchedCharCount > 0 + } ?: return@drawBehind val start = (activeRequest.charOffset + activeRequest.matchOffset) .coerceIn(0, subtitleText.length) if (start >= subtitleText.length) return@drawBehind - val fallbackCount = activeRequest.lookupString.length.coerceAtLeast(1) - val count = activeRequest.matchedCharCount.takeIf { it > 0 } ?: fallbackCount + val count = activeRequest.matchedCharCount val end = (start + count).coerceIn(start + 1, subtitleText.length) layout.highlightRects(subtitleText, start, end).forEach { rect -> drawRoundRect( - color = Color(130, 150, 200, 0x8A), + color = lookupHighlightColor, topLeft = Offset(rect.left, rect.top), size = Size(rect.width, rect.height), cornerRadius = CornerRadius(6f, 6f),