mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-12-23 16:01:48 +01:00
Option to save/share combined double pages
This commit is contained in:
parent
443887c89a
commit
2ec4db3c10
@ -12,6 +12,7 @@ import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
@ -52,9 +53,19 @@ open class MaterialMenuSheet(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.isInNightMode() && !activity.window.decorView.rootWindowInsets.hasSideNavBar()) {
|
||||
window?.decorView?.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
|
||||
}
|
||||
maxHeight?.let {
|
||||
binding.menuScrollView.maxHeight = it + activity.window.decorView.rootWindowInsets.systemWindowInsetBottom
|
||||
if (maxHeight != null) {
|
||||
binding.menuScrollView.maxHeight = maxHeight + activity.window.decorView.rootWindowInsets.systemWindowInsetBottom
|
||||
binding.menuScrollView.requestLayout()
|
||||
} else {
|
||||
binding.titleLayout.viewTreeObserver.addOnGlobalLayoutListener {
|
||||
binding.menuScrollView.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||
val fullHeight = activity.window.decorView.height
|
||||
val insets = activity.window.decorView.rootWindowInsets
|
||||
matchConstraintMaxHeight =
|
||||
fullHeight - (insets?.systemWindowInsetTop ?: 0) -
|
||||
binding.titleLayout.height - 26.dpToPx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.divider.visibleIf(showDivider)
|
||||
|
@ -9,11 +9,14 @@ class MaxHeightScrollView @JvmOverloads constructor(context: Context, attrs: Att
|
||||
var maxHeight = -1
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val heightS = if (maxHeight > 0) {
|
||||
var heightS = if (maxHeight > 0) {
|
||||
MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST)
|
||||
} else {
|
||||
heightMeasureSpec
|
||||
}
|
||||
if (maxHeight < height + (rootWindowInsets?.systemWindowInsetBottom ?: 0)) {
|
||||
heightS = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST)
|
||||
}
|
||||
super.onMeasure(widthMeasureSpec, heightS)
|
||||
}
|
||||
}
|
||||
|
@ -968,6 +968,16 @@ class ReaderActivity :
|
||||
2,
|
||||
R.drawable.ic_photo_24dp,
|
||||
R.string.set_first_page_as_cover
|
||||
),
|
||||
MaterialMenuSheet.MenuSheetItem(
|
||||
6,
|
||||
R.drawable.ic_share_all_outline_24dp,
|
||||
R.string.share_combined_pages
|
||||
),
|
||||
MaterialMenuSheet.MenuSheetItem(
|
||||
7,
|
||||
R.drawable.ic_save_all_outline_24dp,
|
||||
R.string.save_combined_pages
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@ -997,6 +1007,22 @@ class ReaderActivity :
|
||||
3 -> extraPage?.let { shareImage(it) }
|
||||
4 -> extraPage?.let { saveImage(it) }
|
||||
5 -> extraPage?.let { showSetCoverPrompt(it) }
|
||||
6, 7 -> extraPage?.let { secondPage ->
|
||||
(viewer as? PagerViewer)?.let { viewer ->
|
||||
val isLTR = (viewer !is R2LPagerViewer).xor(viewer.config.invertDoublePages)
|
||||
val bg =
|
||||
if (viewer.config.readerTheme >= 2 || viewer.config.readerTheme == 0) {
|
||||
Color.WHITE
|
||||
} else {
|
||||
Color.BLACK
|
||||
}
|
||||
if (item == 6) {
|
||||
presenter.shareImages(page, secondPage, isLTR, bg)
|
||||
} else {
|
||||
presenter.saveImages(page, secondPage, isLTR, bg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}.show()
|
||||
@ -1051,17 +1077,22 @@ class ReaderActivity :
|
||||
* Called from the presenter when a page is ready to be shared. It shows Android's default
|
||||
* sharing tool.
|
||||
*/
|
||||
fun onShareImageResult(file: File, page: ReaderPage) {
|
||||
fun onShareImageResult(file: File, page: ReaderPage, secondPage: ReaderPage? = null) {
|
||||
val manga = presenter.manga ?: return
|
||||
val chapter = page.chapter.chapter
|
||||
|
||||
val decimalFormat =
|
||||
DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' })
|
||||
|
||||
val pageNumber = if (secondPage != null) {
|
||||
getString(R.string.pages_, if (resources.isLTR) "${page.number}-${page.number + 1}" else "${page.number + 1}-${page.number}")
|
||||
} else {
|
||||
getString(R.string.page_, page.number)
|
||||
}
|
||||
val text = "${manga.title}: ${getString(
|
||||
R.string.chapter_,
|
||||
decimalFormat.format(chapter.chapter_number)
|
||||
)}, ${getString(R.string.page_, page.number)}"
|
||||
)}, $pageNumber"
|
||||
|
||||
val stream = file.getUriCompat(this)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
|
@ -1,9 +1,11 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import androidx.annotation.ColorInt
|
||||
import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
@ -32,8 +34,11 @@ import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.system.ImageUtil
|
||||
import eu.kanade.tachiyomi.util.system.executeOnIO
|
||||
import eu.kanade.tachiyomi.util.system.withUIContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import rx.Completable
|
||||
@ -117,6 +122,8 @@ class ReaderPresenter(
|
||||
|
||||
var chapterItems = emptyList<ReaderChapterItem>()
|
||||
|
||||
private var scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
|
||||
/**
|
||||
* Called when the presenter is created. It retrieves the saved active chapter if the process
|
||||
* was restored.
|
||||
@ -610,6 +617,40 @@ class ReaderPresenter(
|
||||
return destFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the image of this [page] in the given [directory] and returns the file location.
|
||||
*/
|
||||
private fun saveImages(page1: ReaderPage, page2: ReaderPage, isLTR: Boolean, @ColorInt bg: Int, directory: File, manga: Manga): File {
|
||||
val stream1 = page1.stream!!
|
||||
ImageUtil.findImageType(stream1) ?: throw Exception("Not an image")
|
||||
val stream2 = page2.stream!!
|
||||
ImageUtil.findImageType(stream2) ?: throw Exception("Not an image")
|
||||
val imageBytes = stream1().readBytes()
|
||||
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
|
||||
val imageBytes2 = stream2().readBytes()
|
||||
val imageBitmap2 = BitmapFactory.decodeByteArray(imageBytes2, 0, imageBytes2.size)
|
||||
|
||||
val stream = ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, bg)
|
||||
directory.mkdirs()
|
||||
|
||||
val chapter = page1.chapter.chapter
|
||||
|
||||
// Build destination file.
|
||||
val filename = DiskUtil.buildValidFilename(
|
||||
"${manga.title} - ${chapter.name}".take(225)
|
||||
) + " - ${page1.number}-${page2.number}.jpg"
|
||||
|
||||
val destFile = File(directory, filename)
|
||||
stream.use { input ->
|
||||
destFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
stream.close()
|
||||
return destFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the image of this [page] on the pictures directory and notifies the UI of the result.
|
||||
* There's also a notification to allow sharing the image somewhere else or deleting it.
|
||||
@ -644,6 +685,34 @@ class ReaderPresenter(
|
||||
)
|
||||
}
|
||||
|
||||
fun saveImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) {
|
||||
scope.launch {
|
||||
if (firstPage.status != Page.READY) return@launch
|
||||
if (secondPage.status != Page.READY) return@launch
|
||||
val manga = manga ?: return@launch
|
||||
val context = Injekt.get<Application>()
|
||||
|
||||
val notifier = SaveImageNotifier(context)
|
||||
notifier.onClear()
|
||||
|
||||
// Pictures directory.
|
||||
val destDir = File(
|
||||
Environment.getExternalStorageDirectory().absolutePath +
|
||||
File.separator + Environment.DIRECTORY_PICTURES +
|
||||
File.separator + "Tachiyomi"
|
||||
)
|
||||
|
||||
try {
|
||||
val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga)
|
||||
DiskUtil.scanMedia(context, file)
|
||||
notifier.onComplete(file)
|
||||
withUIContext { view?.onSaveImageResult(SaveImageResult.Success(file)) }
|
||||
} catch (e: Exception) {
|
||||
withUIContext { view?.onSaveImageResult(SaveImageResult.Error(e)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares the image of this [page] and notifies the UI with the path of the file to share.
|
||||
* The image must be first copied to the internal partition because there are many possible
|
||||
@ -668,6 +737,25 @@ class ReaderPresenter(
|
||||
)
|
||||
}
|
||||
|
||||
fun shareImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) {
|
||||
scope.launch {
|
||||
if (firstPage.status != Page.READY) return@launch
|
||||
if (secondPage.status != Page.READY) return@launch
|
||||
val manga = manga ?: return@launch
|
||||
val context = Injekt.get<Application>()
|
||||
|
||||
val destDir = File(context.cacheDir, "shared_image")
|
||||
destDir.deleteRecursively()
|
||||
try {
|
||||
val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga)
|
||||
withUIContext {
|
||||
view?.onShareImageResult(file, firstPage, secondPage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the image of this [page] as cover and notifies the UI of the result.
|
||||
*/
|
||||
|
@ -3,12 +3,9 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.PointF
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.GestureDetector
|
||||
import android.view.Gravity
|
||||
@ -54,11 +51,8 @@ import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@ -683,36 +677,24 @@ class PagerPageHolder(
|
||||
skipExtra = true
|
||||
return imageBytes.inputStream()
|
||||
}
|
||||
|
||||
val maxHeight = max(height, height2)
|
||||
|
||||
val result = Bitmap.createBitmap(width + width2, max(height, height2), Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(result)
|
||||
canvas.drawColor(if (viewer.config.readerTheme >= 2 || viewer.config.readerTheme == 0) Color.WHITE else Color.BLACK)
|
||||
val isLTR = (viewer !is R2LPagerViewer).xor(viewer.config.invertDoublePages)
|
||||
val upperPart = Rect(
|
||||
if (isLTR) 0 else width2,
|
||||
(maxHeight - imageBitmap.height) / 2,
|
||||
(if (isLTR) 0 else width2) + imageBitmap.width,
|
||||
imageBitmap.height + (maxHeight - imageBitmap.height) / 2
|
||||
)
|
||||
canvas.drawBitmap(imageBitmap, imageBitmap.rect, upperPart, null)
|
||||
scope?.launchUI { progressBar.setProgress(98) }
|
||||
val bottomPart = Rect(
|
||||
if (!isLTR) 0 else width,
|
||||
(maxHeight - imageBitmap2.height) / 2,
|
||||
(if (!isLTR) 0 else width) + imageBitmap2.width,
|
||||
imageBitmap2.height + (maxHeight - imageBitmap2.height) / 2
|
||||
)
|
||||
canvas.drawBitmap(imageBitmap2, imageBitmap2.rect, bottomPart, null)
|
||||
scope?.launchUI { progressBar.setProgress(99) }
|
||||
val bg = if (viewer.config.readerTheme >= 2 || viewer.config.readerTheme == 0) {
|
||||
Color.WHITE
|
||||
} else {
|
||||
Color.BLACK
|
||||
}
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
result.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||
imageStream.close()
|
||||
imageStream2.close()
|
||||
scope?.launchUI { progressBar.completeAndFadeOut() }
|
||||
return ByteArrayInputStream(output.toByteArray())
|
||||
return ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, bg) {
|
||||
scope?.launchUI {
|
||||
if (it == 100) {
|
||||
progressBar.completeAndFadeOut()
|
||||
} else {
|
||||
progressBar.setProgress(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun splitDoublePages() {
|
||||
@ -734,9 +716,6 @@ class PagerPageHolder(
|
||||
}
|
||||
}
|
||||
|
||||
private val Bitmap.rect: Rect
|
||||
get() = Rect(0, 0, width, height)
|
||||
|
||||
companion object {
|
||||
fun getBGType(readerTheme: Int, context: Context): Int {
|
||||
return if (readerTheme == 3) {
|
||||
|
@ -3,14 +3,20 @@ package eu.kanade.tachiyomi.util.system
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import androidx.annotation.ColorInt
|
||||
import eu.kanade.tachiyomi.R
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.net.URLConnection
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
object ImageUtil {
|
||||
|
||||
@ -244,6 +250,47 @@ object ImageUtil {
|
||||
return ColorDrawable(backgroundColor)
|
||||
}
|
||||
|
||||
fun mergeBitmaps(
|
||||
imageBitmap: Bitmap,
|
||||
imageBitmap2: Bitmap,
|
||||
isLTR: Boolean,
|
||||
@ColorInt background: Int = Color.WHITE,
|
||||
progressCallback: ((Int) -> Unit)? = null
|
||||
): ByteArrayInputStream {
|
||||
val height = imageBitmap.height
|
||||
val width = imageBitmap.width
|
||||
val height2 = imageBitmap2.height
|
||||
val width2 = imageBitmap2.width
|
||||
val maxHeight = max(height, height2)
|
||||
val result = Bitmap.createBitmap(width + width2, max(height, height2), Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(result)
|
||||
canvas.drawColor(background)
|
||||
val upperPart = Rect(
|
||||
if (isLTR) 0 else width2,
|
||||
(maxHeight - imageBitmap.height) / 2,
|
||||
(if (isLTR) 0 else width2) + imageBitmap.width,
|
||||
imageBitmap.height + (maxHeight - imageBitmap.height) / 2
|
||||
)
|
||||
canvas.drawBitmap(imageBitmap, imageBitmap.rect, upperPart, null)
|
||||
progressCallback?.invoke(98)
|
||||
val bottomPart = Rect(
|
||||
if (!isLTR) 0 else width,
|
||||
(maxHeight - imageBitmap2.height) / 2,
|
||||
(if (!isLTR) 0 else width) + imageBitmap2.width,
|
||||
imageBitmap2.height + (maxHeight - imageBitmap2.height) / 2
|
||||
)
|
||||
canvas.drawBitmap(imageBitmap2, imageBitmap2.rect, bottomPart, null)
|
||||
progressCallback?.invoke(99)
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
result.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||
progressCallback?.invoke(100)
|
||||
return ByteArrayInputStream(output.toByteArray())
|
||||
}
|
||||
|
||||
private val Bitmap.rect: Rect
|
||||
get() = Rect(0, 0, width, height)
|
||||
|
||||
fun Boolean.toInt() = if (this) 1 else 0
|
||||
private fun isDark(color: Int): Boolean {
|
||||
return Color.red(color) < 40 && Color.blue(color) < 40 && Color.green(color) < 40 &&
|
||||
|
9
app/src/main/res/drawable/ic_save_all_outline_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_save_all_outline_24dp.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<!-- drawable/content_save_all_outline.xml -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:height="24dp"
|
||||
android:width="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/actionBarTintColor">
|
||||
<path android:fillColor="#000" android:pathData="M1 7H3V21H17V23H3C1.9 23 1 22.11 1 21V7M19 1H7C5.89 1 5 1.9 5 3V17C5 18.1 5.89 19 7 19H21C22.1 19 23 18.1 23 17V5L19 1M21 17H7V3H18.17L21 5.83V17M14 10C12.34 10 11 11.34 11 13S12.34 16 14 16 17 14.66 17 13 15.66 10 14 10M8 4H17V8H8V4Z" />
|
||||
</vector>
|
9
app/src/main/res/drawable/ic_share_all_outline_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_share_all_outline_24dp.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<!-- drawable/share_all_outline.xml -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:height="24dp"
|
||||
android:width="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/actionBarTintColor">
|
||||
<path android:fillColor="#000" android:pathData="M13 9.8V10.7L11.3 10.9C8.7 11.3 6.8 12.3 5.4 13.6C7.1 13.1 8.9 12.8 11 12.8H13V14.1L15.2 12L13 9.8M11 5L18 12L11 19V14.9C6 14.9 2.5 16.5 0 20C1 15 4 10 11 9M17 8V5L24 12L17 19V16L21 12" />
|
||||
</vector>
|
@ -284,6 +284,7 @@
|
||||
<string name="set_as_default_for_all">Set as default for all</string>
|
||||
<string name="cover_updated">Cover updated</string>
|
||||
<string name="page_">Page %1$d</string>
|
||||
<string name="pages_">Pages %1$s</string>
|
||||
<string name="next_chapter_not_found">Next chapter not found</string>
|
||||
<string name="decode_image_error">The image could not be decoded</string>
|
||||
<string name="use_image_as_cover">Use this image as cover art?</string>
|
||||
@ -311,6 +312,9 @@
|
||||
<string name="share_second_page">Share second page</string>
|
||||
<string name="save_second_page">Save second page</string>
|
||||
|
||||
<string name="share_combined_pages">Share combined pages</string>
|
||||
<string name="save_combined_pages">Save combined pages</string>
|
||||
|
||||
<!-- Reader settings -->
|
||||
<string name="fullscreen">Fullscreen</string>
|
||||
<string name="animate_page_transitions">Animate page transitions</string>
|
||||
|
Loading…
Reference in New Issue
Block a user