Эх сурвалжийг харах

Long Strip Split for Webtoon (#5759)

* Long Strip Split for Webtoon

* Review Changes

* Review Changes 2 + Rebase
AntsyLich 2 жил өмнө
parent
commit
88b56121a3

+ 4 - 2
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt

@@ -83,12 +83,14 @@ class PreferencesHelper(val context: Context) {
 
     fun dualPageSplitPaged() = flowPrefs.getBoolean("pref_dual_page_split", false)
 
-    fun dualPageSplitWebtoon() = flowPrefs.getBoolean("pref_dual_page_split_webtoon", false)
-
     fun dualPageInvertPaged() = flowPrefs.getBoolean("pref_dual_page_invert", false)
 
+    fun dualPageSplitWebtoon() = flowPrefs.getBoolean("pref_dual_page_split_webtoon", false)
+
     fun dualPageInvertWebtoon() = flowPrefs.getBoolean("pref_dual_page_invert_webtoon", false)
 
+    fun longStripSplitWebtoon() = flowPrefs.getBoolean("pref_long_strip_split_webtoon", true)
+
     fun showReadingMode() = prefs.getBoolean(Keys.showReadingMode, true)
 
     fun trueColor() = flowPrefs.getBoolean("pref_true_color_key", false)

+ 15 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/StencilPage.kt

@@ -0,0 +1,15 @@
+package eu.kanade.tachiyomi.ui.reader.model
+
+import eu.kanade.tachiyomi.util.system.ImageUtil
+
+class StencilPage(
+    parent: ReaderPage,
+    val splitData: ImageUtil.SplitData,
+) : ReaderPage(parent.index, parent.url, parent.imageUrl) {
+
+    override var chapter: ReaderChapter = parent.chapter
+
+    init {
+        stream = parent.stream
+    }
+}

+ 3 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt

@@ -86,8 +86,8 @@ class ReaderReadingModeSettings @JvmOverloads constructor(context: Context, attr
         binding.pagerPrefsGroup.cropBorders.bindToPreference(preferences.cropBorders())
         binding.pagerPrefsGroup.navigatePan.bindToPreference(preferences.navigateToPan())
 
-        // Makes so that dual page invert gets hidden away when turning of dual page split
         binding.pagerPrefsGroup.dualPageSplit.bindToPreference(preferences.dualPageSplitPaged())
+        // Makes it so that dual page invert gets hidden away when dual page split is turned off
         preferences.dualPageSplitPaged()
             .asHotFlow { binding.pagerPrefsGroup.dualPageInvert.isVisible = it }
             .launchIn((context as ReaderActivity).lifecycleScope)
@@ -110,11 +110,12 @@ class ReaderReadingModeSettings @JvmOverloads constructor(context: Context, attr
         binding.webtoonPrefsGroup.cropBordersWebtoon.bindToPreference(preferences.cropBordersWebtoon())
         binding.webtoonPrefsGroup.webtoonSidePadding.bindToIntPreference(preferences.webtoonSidePadding(), R.array.webtoon_side_padding_values)
 
-        // Makes so that dual page invert gets hidden away when turning of dual page split
         binding.webtoonPrefsGroup.dualPageSplit.bindToPreference(preferences.dualPageSplitWebtoon())
+        // Makes it so that dual page invert gets hidden away when dual page split is turned off
         preferences.dualPageSplitWebtoon()
             .asHotFlow { binding.webtoonPrefsGroup.dualPageInvert.isVisible = it }
             .launchIn((context as ReaderActivity).lifecycleScope)
         binding.webtoonPrefsGroup.dualPageInvert.bindToPreference(preferences.dualPageInvertWebtoon())
+        binding.webtoonPrefsGroup.longStripSplit.bindToPreference(preferences.longStripSplitWebtoon())
     }
 }

+ 26 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt

@@ -7,10 +7,12 @@ import androidx.recyclerview.widget.RecyclerView
 import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
 import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
 import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
+import eu.kanade.tachiyomi.ui.reader.model.StencilPage
 import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
 import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
 import eu.kanade.tachiyomi.ui.reader.viewer.hasMissingChapters
 import eu.kanade.tachiyomi.util.system.createReaderThemeContext
+import eu.kanade.tachiyomi.util.system.logcat
 
 /**
  * RecyclerView Adapter used by this [viewer] to where [ViewerChapters] updates are posted.
@@ -25,6 +27,26 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
 
     var currentChapter: ReaderChapter? = null
 
+    fun onLongStripSplit(currentStrip: Any?, newStrips: List<StencilPage>) {
+        if (currentStrip is StencilPage) return
+
+        val placeAtIndex = items.indexOf(currentStrip) + 1
+        // Stop constantly adding split images
+        if (items[placeAtIndex] is StencilPage) return
+
+        val updatedItems = items.toMutableList()
+        updatedItems.addAll(placeAtIndex, newStrips)
+        updateItems(updatedItems)
+        logcat { "New adapter item count is $itemCount" }
+    }
+
+    fun cleanupSplitStrips() {
+        if (items.any { it is StencilPage }) {
+            val updatedItems = items.filterNot { it is StencilPage }
+            updateItems(updatedItems)
+        }
+    }
+
     /**
      * Context that has been wrapped to use the correct theme values based on the
      * current app theme and reader background color
@@ -79,6 +101,10 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
             }
         }
 
+        updateItems(newItems)
+    }
+
+    private fun updateItems(newItems: List<Any>) {
         val result = DiffUtil.calculateDiff(Callback(items, newItems))
         items = newItems
         result.dispatchUpdatesTo(this)

+ 14 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt

@@ -32,6 +32,11 @@ class WebtoonConfig(
     var sidePadding = 0
         private set
 
+    var longStripSplit = false
+        private set
+
+    var longStripSplitChangedListener: ((Boolean) -> Unit)? = null
+
     val theme = preferences.readerTheme().get()
 
     init {
@@ -57,6 +62,15 @@ class WebtoonConfig(
         preferences.dualPageInvertWebtoon()
             .register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() })
 
+        preferences.longStripSplitWebtoon()
+            .register(
+                { longStripSplit = it },
+                {
+                    imagePropertyChangedListener?.invoke()
+                    longStripSplitChangedListener?.invoke(it)
+                },
+            )
+
         preferences.readerTheme().asFlow()
             .drop(1)
             .distinctUntilChanged()

+ 29 - 7
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt

@@ -14,10 +14,12 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
 import eu.kanade.tachiyomi.databinding.ReaderErrorBinding
 import eu.kanade.tachiyomi.source.model.Page
 import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
+import eu.kanade.tachiyomi.ui.reader.model.StencilPage
 import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
 import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
 import eu.kanade.tachiyomi.ui.webview.WebViewActivity
 import eu.kanade.tachiyomi.util.system.ImageUtil
+import eu.kanade.tachiyomi.util.system.ImageUtil.SplitData
 import eu.kanade.tachiyomi.util.system.dpToPx
 import rx.Observable
 import rx.Subscription
@@ -274,17 +276,37 @@ class WebtoonPageHolder(
     }
 
     private fun process(imageStream: BufferedInputStream): InputStream {
-        if (!viewer.config.dualPageSplit) {
-            return imageStream
+        if (viewer.config.dualPageSplit) {
+            val isDoublePage = ImageUtil.isWideImage(imageStream)
+            if (isDoublePage) {
+                val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
+                return ImageUtil.splitAndMerge(imageStream, upperSide)
+            }
         }
 
-        val isDoublePage = ImageUtil.isWideImage(imageStream)
-        if (!isDoublePage) {
-            return imageStream
+        if (viewer.config.longStripSplit) {
+            if (page is StencilPage) {
+                val splitData = (page as StencilPage).splitData
+                return ImageUtil.splitStrip(imageStream, splitData)
+            }
+
+            val isStripSplitNeeded = ImageUtil.isStripSplitNeeded(imageStream)
+            if (isStripSplitNeeded) {
+                val splitData = onStripSplit(imageStream)
+                splitData?.let { return ImageUtil.splitStrip(imageStream, it) }
+            }
         }
 
-        val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
-        return ImageUtil.splitAndMerge(imageStream, upperSide)
+        return imageStream
+    }
+
+    private fun onStripSplit(imageStream: BufferedInputStream): SplitData? {
+        val page = page ?: return null
+        val splitData = ImageUtil.getSplitDataForStream(imageStream).toMutableList()
+        val toReturn = splitData.removeFirstOrNull()
+        val newPages = splitData.map { StencilPage(page, it) }
+        viewer.onLongStripSplit(page, newPages)
+        return toReturn
     }
 
     /**

+ 18 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt

@@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.ui.reader.ReaderActivity
 import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
 import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
+import eu.kanade.tachiyomi.ui.reader.model.StencilPage
 import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
 import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
 import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
@@ -154,6 +155,12 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
             activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart)
         }
 
+        config.longStripSplitChangedListener = { enabled ->
+            if (!enabled) {
+                cleanupSplitStrips()
+            }
+        }
+
         frame.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
         frame.addView(recycler)
     }
@@ -354,4 +361,15 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
             min(position + 3, adapter.itemCount - 1),
         )
     }
+
+    fun onLongStripSplit(currentStrip: Any?, newStrips: List<StencilPage>) {
+        activity.runOnUiThread {
+            // Need to insert on UI thread else images will go blank
+            adapter.onLongStripSplit(currentStrip, newStrips)
+        }
+    }
+
+    private fun cleanupSplitStrips() {
+        adapter.cleanupSplitStrips()
+    }
 }

+ 5 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt

@@ -289,6 +289,11 @@ class SettingsReaderController : SettingsController() {
                 summaryRes = R.string.pref_dual_page_invert_summary
                 visibleIf(preferences.dualPageSplitWebtoon()) { it }
             }
+            switchPreference {
+                bindTo(preferences.longStripSplitWebtoon())
+                titleRes = R.string.pref_long_strip_split
+                summaryRes = R.string.pref_long_strip_split_summary
+            }
         }
 
         preferenceCategory {

+ 93 - 33
app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt

@@ -206,35 +206,6 @@ object ImageUtil {
             return true
         }
 
-        val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply { inJustDecodeBounds = false }
-        // Values are stored as they get modified during split loop
-        val imageHeight = options.outHeight
-        val imageWidth = options.outWidth
-
-        val splitHeight = (getDisplayMaxHeightInPx * 1.5).toInt()
-        // -1 so it doesn't try to split when imageHeight = getDisplayHeightInPx
-        val partCount = (imageHeight - 1) / splitHeight + 1
-
-        val optimalSplitHeight = imageHeight / partCount
-
-        val splitDataList = (0 until partCount).fold(mutableListOf<SplitData>()) { list, index ->
-            list.apply {
-                // Only continue if the list is empty or there is image remaining
-                if (isEmpty() || imageHeight > last().bottomOffset) {
-                    val topOffset = index * optimalSplitHeight
-                    var outputImageHeight = min(optimalSplitHeight, imageHeight - topOffset)
-
-                    val remainingHeight = imageHeight - (topOffset + outputImageHeight)
-                    // If remaining height is smaller or equal to 1/3th of
-                    // optimal split height then include it in current page
-                    if (remainingHeight <= (optimalSplitHeight / 3)) {
-                        outputImageHeight += remainingHeight
-                    }
-                    add(SplitData(index, topOffset, outputImageHeight))
-                }
-            }
-        }
-
         val bitmapRegionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
             BitmapRegionDecoder.newInstance(imageFile.openInputStream())
         } else {
@@ -247,10 +218,12 @@ object ImageUtil {
             return false
         }
 
-        logcat {
-            "Splitting image with height of $imageHeight into $partCount part " +
-                "with estimated ${optimalSplitHeight}px height per split"
-        }
+        val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply { inJustDecodeBounds = false }
+
+        // Values are stored as they get modified during split loop
+        val imageWidth = options.outWidth
+
+        val splitDataList = getSplitDataForOptions(options)
 
         return try {
             splitDataList.forEach { splitData ->
@@ -285,6 +258,93 @@ object ImageUtil {
     private fun splitImagePath(imageFilePath: String, index: Int) =
         imageFilePath.substringBeforeLast(".") + "__${"%03d".format(index + 1)}.jpg"
 
+    /**
+     * Check whether the image is a long Strip that needs splitting
+     * @return true if the image is not animated and it's height is greater than image width and screen height
+     */
+    fun isStripSplitNeeded(imageStream: BufferedInputStream): Boolean {
+        if (isAnimatedAndSupported(imageStream)) return false
+        val options = extractImageOptions(imageStream)
+
+        val imageHeightIsBiggerThanWidth = options.outHeight > options.outWidth
+        val imageHeightBiggerThanScreenHeight = options.outHeight > getDisplayMaxHeightInPx
+        return imageHeightIsBiggerThanWidth && imageHeightBiggerThanScreenHeight
+    }
+
+    /**
+     * Split the imageStream according to the provided splitData
+     */
+    fun splitStrip(imageStream: InputStream, splitData: SplitData): InputStream {
+        val bitmapRegionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            BitmapRegionDecoder.newInstance(imageStream)
+        } else {
+            @Suppress("DEPRECATION")
+            BitmapRegionDecoder.newInstance(imageStream, false)
+        }
+
+        if (bitmapRegionDecoder == null) {
+            throw Exception("Failed to create new instance of BitmapRegionDecoder")
+        }
+
+        logcat {
+            "WebtoonSplit #${splitData.index} with topOffset=${splitData.topOffset} " +
+                "outputImageHeight=${splitData.outputImageHeight} bottomOffset=${splitData.bottomOffset}"
+        }
+
+        val options = extractImageOptions(imageStream).apply { inJustDecodeBounds = false }
+
+        val region = Rect(0, splitData.topOffset, splitData.outputImageHeight, splitData.bottomOffset)
+
+        try {
+            val splitBitmap = bitmapRegionDecoder.decodeRegion(region, options)
+            val outputStream = ByteArrayOutputStream()
+            splitBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
+            return ByteArrayInputStream(outputStream.toByteArray())
+        } catch (e: Throwable) {
+            throw e
+        } finally {
+            bitmapRegionDecoder.recycle()
+        }
+    }
+
+    fun getSplitDataForStream(imageStream: InputStream): List<SplitData> {
+        val options = extractImageOptions(imageStream)
+        return getSplitDataForOptions(options)
+    }
+
+    private fun getSplitDataForOptions(options: BitmapFactory.Options): List<SplitData> {
+        val imageHeight = options.outHeight
+
+        val splitHeight = (getDisplayMaxHeightInPx * 1.5).toInt()
+        // -1 so it doesn't try to split when imageHeight = splitHeight
+        val partCount = (imageHeight - 1) / splitHeight + 1
+
+        val optimalSplitHeight = imageHeight / partCount
+
+        logcat {
+            "Generating SplitData for image with height of $imageHeight. " +
+                "Estimated $partCount part and ${optimalSplitHeight}px height per part"
+        }
+
+        return mutableListOf<SplitData>().apply {
+            for (index in (0 until partCount)) {
+                // Only continue if the list is empty or there is image remaining
+                if (isNotEmpty() && imageHeight <= last().bottomOffset) break
+
+                val topOffset = index * optimalSplitHeight
+                var outputImageHeight = min(optimalSplitHeight, imageHeight - topOffset)
+
+                val remainingHeight = imageHeight - (topOffset + outputImageHeight)
+                // If remaining height is smaller or equal to 1/10th of
+                // optimal split height then include it in current page
+                if (remainingHeight <= (optimalSplitHeight / 10)) {
+                    outputImageHeight += remainingHeight
+                }
+                add(SplitData(index, topOffset, outputImageHeight))
+            }
+        }
+    }
+
     data class SplitData(
         val index: Int,
         val topOffset: Int,

+ 9 - 0
app/src/main/res/layout/reader_webtoon_settings.xml

@@ -66,6 +66,15 @@
         android:visibility="gone"
         tools:visibility="visible" />
 
+    <com.google.android.material.switchmaterial.SwitchMaterial
+        android:id="@+id/long_strip_split"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:paddingStart="16dp"
+        android:paddingEnd="16dp"
+        android:text="@string/pref_long_strip_split"
+        android:textColor="?android:attr/textColorSecondary" />
+
     <androidx.constraintlayout.widget.Group
         android:id="@+id/tapping_prefs_group"
         android:layout_width="wrap_content"

+ 2 - 0
app/src/main/res/values/strings.xml

@@ -297,6 +297,8 @@
     <string name="pref_dual_page_split">Dual page split</string>
     <string name="pref_dual_page_invert">Invert dual page split placement</string>
     <string name="pref_dual_page_invert_summary">If the placement of the dual page split doesn\'t match reading direction</string>
+    <string name="pref_long_strip_split">Split tall images (Alpha)</string>
+    <string name="pref_long_strip_split_summary">Improves reader performance</string>
     <string name="pref_cutout_short">Show content in cutout area</string>
     <string name="pref_page_transitions">Animate page transitions</string>
     <string name="pref_double_tap_anim_speed">Double tap animation speed</string>