Double pages (#699)

* Start of dual pages

* Fixes to dual pages + added button to shift

Next to add a button to get out of this nightmare

* Adding preference to truly support on and off

* Set double pages correctly for L2R reading mode

* Hide shift double page in single page mode

also fix shift into or out of double pages not going to right page

* Shift page back on loading a new chapter

* Fix back shift when reaching near end of chapter with shifted pages

* Fix chapters not being marked as read when last pages are doubled

* more fixes

* change menu icon and verbage

* Fixed going to previous/next chapter when turning on/off double shift sometimes

* Fix shift icon for double page in R2L

* Adding double page as option in reader sheet

* Adding saving/share/setting cover for second page

* Add back page indicator

* RTL language support for current page in double mode

* Changing to double page always moves the current page to first slot

Also fix bad snap back from single to double or vice versa

* Reset shift icon when going to a new chapter

* Add Double pages to reader settings

* Add padding to current/total pages text

since it gets pretty long with double digits in double pages mode

* Option for automatic single/double layout based on orientation

With option to change mid read

* More fixes

* Lotta reworking to make this work

Lotta reworking to make this work

Seriously a lot of reworking to make this work

* Cleanup + Some documentation

Because I won't "know my own code"

I genuinely do not know what I just coded

* Swapped around icons for double page in chapter sheet bar

Since it was confusing even me

* Fix shifting not updating the ui, nor shifting back to the right page

* Fix changing chapters

* Fixed Prev Chapter transition page jumping back 1

* Fixed progress spinner for single page loading

* Fix the case of a second page shifting for full width while double shift is on

It would unshift the pages in this case, requiring another shift back

If this sounds confusing or complex, it's because it is

Also clean up

* Update Initial dual page bg to match reader bg theme

* More cleanup

* More cleanup + docs + refactoring

* String update

* more cleanup

* Update toolbar ripple based if the menu item is visible

* Fix more issues with shifting + next chapter's first page being a double

* Cleanup ChapterTransision Checks

* Always run shifting logic

* Even more optimizing

which hopefully didnt break something (prolly did)

(spoilers it did I had to undo this local commit)
(more spoilers it still will break again)

* Fix Retry in double mode

* Set gifs as fullpages

* Fix going from automatic to single/double page layout

* More cleanup, more comments

* Fix changing page layout when on a transition page for a manga with a single chapter

The edge cases you can find....man

* Restore shifted pages on activity recrreation

* Adding a "beta" tag to page layout

IMO it's absolutely solid and release candidate ready, but you can get away with so much by calling something a "beta" or an "alpha"
This commit is contained in:
Jays2Kings 2021-04-05 14:55:22 -04:00 committed by GitHub
parent 4e4f9b339f
commit 6d6766a86a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 948 additions and 115 deletions

View File

@ -65,6 +65,8 @@ object PreferenceKeys {
const val webtoonNavInverted = "reader_tapping_inverted_webtoon"
const val pageLayout = "page_layout"
const val showNavigationOverlayNewUser = "reader_navigation_overlay_new_user"
const val showNavigationOverlayNewUserWebtoon = "reader_navigation_overlay_new_user_webtoon"

View File

@ -11,6 +11,7 @@ import com.tfcporciuncula.flow.FlowSharedPreferences
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PageLayout
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach
import java.io.File
@ -143,6 +144,8 @@ class PreferencesHelper(val context: Context) {
fun webtoonNavInverted() = flowPrefs.getEnum(Keys.webtoonNavInverted, ViewerNavigation.TappingInvertMode.NONE)
fun pageLayout() = flowPrefs.getInt(Keys.pageLayout, PageLayout.AUTOMATIC)
fun showNavigationOverlayNewUser() = flowPrefs.getBoolean(Keys.showNavigationOverlayNewUser, true)
fun showNavigationOverlayNewUserWebtoon() = flowPrefs.getBoolean(Keys.showNavigationOverlayNewUserWebtoon, true)

View File

@ -8,10 +8,12 @@ import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.LayerDrawable
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
@ -20,9 +22,10 @@ import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.widget.SeekBar
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.isVisible
import androidx.core.view.GestureDetectorCompat
import com.afollestad.materialdialogs.MaterialDialog
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.google.android.material.bottomsheet.BottomSheetBehavior
@ -46,6 +49,8 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PageLayout
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
@ -57,6 +62,7 @@ import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.hasSideNavBar
import eu.kanade.tachiyomi.util.system.isBottomTappable
import eu.kanade.tachiyomi.util.system.isLTR
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.system.openInBrowser
@ -160,6 +166,10 @@ class ReaderActivity :
var isLoading = false
var lastShiftDoubleState: Boolean? = null
var indexPageToShift: Int? = null
var indexChapterToShift: Long? = null
companion object {
@Suppress("unused")
const val LEFT_TO_RIGHT = 1
@ -168,6 +178,10 @@ class ReaderActivity :
const val WEBTOON = 4
const val VERTICAL_PLUS = 5
const val SHIFT_DOUBLE_PAGES = "shiftingDoublePages"
const val SHIFTED_PAGE_INDEX = "shiftedPageIndex"
const val SHIFTED_CHAP_INDEX = "shiftedChapterIndex"
fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent {
val intent = Intent(context, ReaderActivity::class.java)
intent.putExtra("manga", manga.id)
@ -217,6 +231,9 @@ class ReaderActivity :
if (savedInstanceState != null) {
menuVisible = savedInstanceState.getBoolean(::menuVisible.name)
lastShiftDoubleState = savedInstanceState.get(SHIFT_DOUBLE_PAGES) as? Boolean
indexPageToShift = savedInstanceState.get(SHIFTED_PAGE_INDEX) as? Int
indexChapterToShift = savedInstanceState.get(SHIFTED_CHAP_INDEX) as? Long
binding.readerNav.root.isVisible = menuVisible
} else {
binding.readerNav.root.gone()
@ -251,6 +268,16 @@ class ReaderActivity :
*/
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(::menuVisible.name, menuVisible)
(viewer as? PagerViewer)?.let { pViewer ->
val config = pViewer.config
outState.putBoolean(SHIFT_DOUBLE_PAGES, config.shiftDoublePage)
if (config.shiftDoublePage) {
pViewer.getShiftedPage()?.let {
outState.putInt(SHIFTED_PAGE_INDEX, it.index)
outState.putLong(SHIFTED_CHAP_INDEX, it.chapter.chapter.id ?: 0L)
}
}
}
if (!isChangingConfigurations) {
presenter.onSaveInstanceStateNonConfigurationChange()
}
@ -279,6 +306,65 @@ class ReaderActivity :
return true
}
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
val splitItem = menu?.findItem(R.id.action_shift_double_page)
splitItem?.isVisible = (viewer as? PagerViewer)?.config?.doublePages ?: false
(viewer as? PagerViewer)?.config?.let { config ->
splitItem?.icon = ContextCompat.getDrawable(
this,
if ((!config.shiftDoublePage).xor(viewer is R2LPagerViewer)) R.drawable.ic_page_previous_outline_24dp else R.drawable.ic_page_next_outline_24dp
)
}
setBottomNavButtons(preferences.pageLayout().get())
(binding.toolbar.background as? LayerDrawable)?.let { layerDrawable ->
val isDoublePage = splitItem?.isVisible ?: false
// Shout out to Google for not fixing setVisible https://issuetracker.google.com/issues/127538945
layerDrawable.findDrawableByLayerId(R.id.layer_full_width).alpha = if (!isDoublePage) 255 else 0
layerDrawable.findDrawableByLayerId(R.id.layer_one_item).alpha = if (isDoublePage) 255 else 0
}
return super.onPrepareOptionsMenu(menu)
}
fun setBottomNavButtons(pageLayout: Int) {
val isDoublePage = pageLayout == PageLayout.DOUBLE_PAGES ||
(pageLayout == PageLayout.AUTOMATIC && (viewer as? PagerViewer)?.config?.doublePages ?: false)
binding.chaptersSheet.doublePage.setImageDrawable(
ContextCompat.getDrawable(
this,
if (!isDoublePage) R.drawable.ic_single_page_24dp
else R.drawable.ic_book_open_variant_24dp
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
binding.chaptersSheet.doublePage.tooltipText =
getString(
if (isDoublePage) R.string.switch_to_single
else R.string.switch_to_double
)
}
}
/**
* Called when an item of the options menu was clicked. Used to handle clicks on our menu
* entries.
*/
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_shift_double_page -> {
(viewer as? PagerViewer)?.config?.let { config ->
config.shiftDoublePage = !config.shiftDoublePage
presenter.viewerChapters?.let {
(viewer as? PagerViewer)?.updateShifting()
(viewer as? PagerViewer)?.setChaptersDoubleShift(it)
invalidateOptionsMenu()
}
}
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
private fun popToMain() {
presenter.onBackPressed()
if (fromUrl) {
@ -358,6 +444,16 @@ class ReaderActivity :
}
}
binding.chaptersSheet.doublePage.setOnClickListener {
if (preferences.pageLayout().get() == PageLayout.AUTOMATIC) {
(viewer as? PagerViewer)?.config?.let { config ->
config.doublePages = !config.doublePages
reloadChapters(config.doublePages, true)
}
} else {
preferences.pageLayout().set(1 - preferences.pageLayout().get())
}
}
binding.readerNav.leftChapter.setOnClickListener {
if (isLoading) {
return@setOnClickListener
@ -579,6 +675,7 @@ class ReaderActivity :
binding.viewerContainer.removeAllViews()
}
viewer = newViewer
binding.chaptersSheet.doublePage.isVisible = viewer is PagerViewer
binding.viewerContainer.addView(newViewer.getView())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -591,6 +688,14 @@ class ReaderActivity :
}
}
if (newViewer is PagerViewer) {
if (preferences.pageLayout().get() == PageLayout.AUTOMATIC) {
setDoublePageMode(newViewer)
}
lastShiftDoubleState?.let { newViewer.config.shiftDoublePage = it }
lastShiftDoubleState = null
}
binding.navigationOverlay.isLTR = !(viewer is L2RPagerViewer)
binding.viewerContainer.setBackgroundColor(
if (viewer is WebtoonViewer) {
@ -606,6 +711,7 @@ class ReaderActivity :
binding.pleaseWait.visible()
binding.pleaseWait.startAnimation(AnimationUtils.loadAnimation(this, R.anim.fade_in_long))
invalidateOptionsMenu()
}
override fun onPause() {
@ -613,12 +719,44 @@ class ReaderActivity :
super.onPause()
}
fun reloadChapters(doublePages: Boolean, force: Boolean = false) {
val pViewer = viewer as? PagerViewer ?: return
pViewer.updateShifting()
if (!force && pViewer.config.autoDoublePages) {
setDoublePageMode(pViewer)
} else {
pViewer.config.doublePages = doublePages
}
val currentChapter = presenter.getCurrentChapter()
if (doublePages) {
// If we're moving from singe to double, we want the current page to be the first page
pViewer.config.shiftDoublePage = (
binding.readerNav.pageSeekbar.progress +
(
currentChapter?.pages?.subList(0, binding.readerNav.pageSeekbar.progress)
?.count { it.fullPage || it.isolatedPage } ?: 0
)
) % 2 != 0
}
presenter.viewerChapters?.let {
pViewer.setChaptersDoubleShift(it)
}
invalidateOptionsMenu()
}
/**
* Called from the presenter whenever a new [viewerChapters] have been set. It delegates the
* method to the current viewer, but also set the subtitle on the binding.toolbar.
*/
fun setChapters(viewerChapters: ViewerChapters) {
binding.pleaseWait.gone()
if (indexChapterToShift != null && indexPageToShift != null) {
viewerChapters.currChapter.pages?.find { it.index == indexPageToShift && it.chapter.chapter.id == indexChapterToShift }?.let {
(viewer as? PagerViewer)?.updateShifting(it)
}
indexChapterToShift = null
indexPageToShift = null
}
viewer?.setChapters(viewerChapters)
intentPageNumber?.let { moveToPageIndex(it) }
intentPageNumber = null
@ -687,37 +825,75 @@ class ReaderActivity :
* bottom menu and delegates the change to the presenter.
*/
@SuppressLint("SetTextI18n")
fun onPageSelected(page: ReaderPage) {
presenter.onPageSelected(page)
fun onPageSelected(page: ReaderPage, hasExtraPage: Boolean) {
presenter.onPageSelected(page, hasExtraPage)
val pages = page.chapter.pages ?: return
val currentPage = page.number
val totalPages = pages.size
// Set bottom page number
binding.pageNumber.text = "$currentPage/$totalPages"
if (viewer is R2LPagerViewer) {
binding.readerNav.rightPageText.text = currentPage.toString()
binding.readerNav.leftPageText.text = totalPages.toString()
val currentPage = if (hasExtraPage) {
if (resources.isLTR) "${page.number}-${page.number + 1}" else "${page.number + 1}-${page.number}"
} else {
binding.readerNav.leftPageText.text = currentPage.toString()
binding.readerNav.rightPageText.text = totalPages.toString()
"${page.number}"
}
val totalPages = pages.size.toString()
binding.pageNumber.text = if (resources.isLTR) "$currentPage/$totalPages" else "$totalPages/$currentPage"
if (viewer is R2LPagerViewer) {
binding.readerNav.rightPageText.text = currentPage
binding.readerNav.leftPageText.text = totalPages
} else {
binding.readerNav.leftPageText.text = currentPage
binding.readerNav.rightPageText.text = totalPages
}
if (binding.chaptersSheet.chaptersBottomSheet.selectedChapterId != page.chapter.chapter.id) {
binding.chaptersSheet.chaptersBottomSheet.refreshList()
}
// Set seekbar progress
binding.readerNav.pageSeekbar.max = pages.lastIndex
binding.readerNav.pageSeekbar.progress = page.index
val progress = page.index + if (hasExtraPage) 1 else 0
// For a double page, show the last 2 pages as if it was the final part of the seekbar
binding.readerNav.pageSeekbar.progress = if (progress == pages.lastIndex) progress else page.index
}
/**
* Called from the viewer whenever a [page] is long clicked. A bottom sheet with a list of
* actions to perform is shown.
*/
fun onPageLongTap(page: ReaderPage) {
val items = listOf(
fun onPageLongTap(page: ReaderPage, extraPage: ReaderPage? = null) {
val items = if (extraPage != null) {
listOf(
MaterialMenuSheet.MenuSheetItem(
3,
R.drawable.ic_outline_share_24dp,
R.string.share_second_page
),
MaterialMenuSheet.MenuSheetItem(
4,
R.drawable.ic_outline_save_24dp,
R.string.save_second_page
),
MaterialMenuSheet.MenuSheetItem(
5,
R.drawable.ic_outline_photo_24dp,
R.string.set_second_page_as_cover
),
MaterialMenuSheet.MenuSheetItem(
0,
R.drawable.ic_share_24dp,
R.string.share_first_page
),
MaterialMenuSheet.MenuSheetItem(
1,
R.drawable.ic_save_24dp,
R.string.save_first_page
),
MaterialMenuSheet.MenuSheetItem(
2,
R.drawable.ic_photo_24dp,
R.string.set_first_page_as_cover
)
)
} else {
listOf(
MaterialMenuSheet.MenuSheetItem(
0,
R.drawable.ic_share_24dp,
@ -734,11 +910,15 @@ class ReaderActivity :
R.string.set_as_cover
)
)
}
MaterialMenuSheet(this, items) { _, item ->
when (item) {
0 -> shareImage(page)
1 -> saveImage(page)
2 -> showSetCoverPrompt(page)
3 -> extraPage?.let { shareImage(it) }
4 -> extraPage?.let { saveImage(it) }
5 -> extraPage?.let { showSetCoverPrompt(it) }
}
true
}.show()
@ -911,6 +1091,11 @@ class ReaderActivity :
}
}
private fun setDoublePageMode(viewer: PagerViewer) {
val currentOrientation = resources.configuration.orientation
viewer.config.doublePages = (currentOrientation == Configuration.ORIENTATION_LANDSCAPE)
}
private fun handleIntentAction(intent: Intent): Boolean {
val uri = intent.data ?: return false
if (!presenter.canLoadUrl(uri)) {
@ -1000,6 +1185,12 @@ class ReaderActivity :
preferences.alwaysShowChapterTransition().asFlow()
.onEach { showNewChapter = it }
.launchIn(scope)
preferences.pageLayout().asFlow()
.onEach {
setBottomNavButtons(it)
}
.launchIn(scope)
}
/**

View File

@ -86,6 +86,9 @@ class ReaderPresenter(
*/
private val viewerChaptersRelay = BehaviorRelay.create<ViewerChapters>()
val viewerChapters: ViewerChapters?
get() = viewerChaptersRelay.value
/**
* Relay used when loading prev/next chapter needed to lock the UI (with a dialog).
*/
@ -398,8 +401,7 @@ class ReaderPresenter(
.doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) }
.subscribeFirst(
{ view, _ ->
val lastPage = if (chapter.pages_left <= 1) 0 else chapter.last_page_read
view.moveToPageIndex(lastPage)
view.moveToPageIndex(0)
view.refreshChapters()
},
{ _, _ ->
@ -458,7 +460,7 @@ class ReaderPresenter(
* read, update tracking services, enqueue downloaded chapter deletion, and updating the active chapter if this
* [page]'s chapter is different from the currently active.
*/
fun onPageSelected(page: ReaderPage) {
fun onPageSelected(page: ReaderPage, hasExtraPage: Boolean) {
val currentChapters = viewerChaptersRelay.value ?: return
val selectedChapter = page.chapter
@ -467,7 +469,10 @@ class ReaderPresenter(
selectedChapter.chapter.last_page_read = page.index
selectedChapter.chapter.pages_left =
(selectedChapter.pages?.size ?: page.index) - page.index
if (selectedChapter.pages?.lastIndex == page.index) {
// For double pages, check if the second to last page is doubled up
if (selectedChapter.pages?.lastIndex == page.index ||
(hasExtraPage && selectedChapter.pages?.lastIndex?.minus(1) == page.index)
) {
selectedChapter.chapter.read = true
updateTrackChapterRead(selectedChapter)
deleteChapterIfNeeded(selectedChapter)

View File

@ -17,8 +17,9 @@ sealed class ChapterTransition {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ChapterTransition) return false
if (from == other.from && to == other.to) return true
if (from == other.to && to == other.from) return true
if (from == other.from && to == other.to && to != null) return true
if (from == other.to && to == other.from && to != null) return true
if (to == other.to && to == null && from == other.from && other::class == this::class) return true
return false
}

View File

@ -10,8 +10,19 @@ class ReaderPage(
imageUrl: String? = null,
var stream: (() -> InputStream)? = null,
var bg: Drawable? = null,
var bgType: Int? = null
var bgType: Int? = null,
/** Value to check if this page is used to as if it was too wide */
var shiftedPage: Boolean = false,
/** Value to check if a page is can be doubled up, but can't because the next page is too wide */
var isolatedPage: Boolean = false
) : Page(index, url, imageUrl, null) {
lateinit var chapter: ReaderChapter
/** Value to check if a page is too wide to be doubled up */
var fullPage: Boolean = false
set(value) {
field = value
if (value) shiftedPage = false
}
}

View File

@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.ReaderPagedLayoutBinding
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.bindToPreference
import eu.kanade.tachiyomi.util.lang.addBetaTag
import eu.kanade.tachiyomi.util.view.visibleIf
import eu.kanade.tachiyomi.widget.BaseReaderSettingsView
@ -27,12 +28,18 @@ class ReaderPagedView @JvmOverloads constructor(context: Context, attrs: Attribu
binding.pagerNav.bindToPreference(preferences.navigationModePager())
binding.pagerInvert.bindToPreference(preferences.pagerNavInverted())
binding.extendPastCutout.bindToPreference(preferences.pagerCutoutBehavior())
binding.pageLayout.bindToPreference(preferences.pageLayout())
binding.pageLayout.title = binding.pageLayout.title.toString().addBetaTag(context)
val mangaViewer = (context as? ReaderActivity)?.presenter?.getMangaViewer() ?: 0
val isWebtoonView = mangaViewer == ReaderActivity.WEBTOON || mangaViewer == ReaderActivity.VERTICAL_PLUS
val hasMargins = mangaViewer == ReaderActivity.VERTICAL_PLUS
binding.cropBordersWebtoon.bindToPreference(if (hasMargins) preferences.cropBorders() else preferences.cropBordersWebtoon())
binding.webtoonSidePadding.bindToIntPreference(preferences.webtoonSidePadding(), R.array.webtoon_side_padding_values)
binding.webtoonSidePadding.bindToIntPreference(
preferences.webtoonSidePadding(),
R.array.webtoon_side_padding_values
)
binding.webtoonEnableZoomOut.bindToPreference(preferences.webtoonEnableZoomOut())
binding.webtoonNav.bindToPreference(preferences.navigationModeWebtoon())
binding.webtoonInvert.bindToPreference(preferences.webtoonNavInverted())
@ -49,8 +56,22 @@ class ReaderPagedView @JvmOverloads constructor(context: Context, attrs: Attribu
}
private fun updatePagedGroup(show: Boolean) {
listOf(binding.scaleType, binding.zoomStart, binding.cropBorders, binding.pageTransitions, binding.pagerNav, binding.pagerInvert).forEach { it.visibleIf(show) }
listOf(binding.cropBordersWebtoon, binding.webtoonSidePadding, binding.webtoonEnableZoomOut, binding.webtoonNav, binding.webtoonInvert).forEach { it.visibleIf(!show) }
listOf(
binding.scaleType,
binding.zoomStart,
binding.cropBorders,
binding.pageTransitions,
binding.pagerNav,
binding.pagerInvert,
binding.pageLayout
).forEach { it.visibleIf(show) }
listOf(
binding.cropBordersWebtoon,
binding.webtoonSidePadding,
binding.webtoonEnableZoomOut,
binding.webtoonNav,
binding.webtoonInvert
).forEach { it.visibleIf(!show) }
val isFullFit = when (preferences.imageScaleType().get()) {
SubsamplingScaleImageView.SCALE_TYPE_FIT_HEIGHT,
SubsamplingScaleImageView.SCALE_TYPE_SMART_FIT,

View File

@ -16,6 +16,7 @@ abstract class ViewerConfig(preferences: PreferencesHelper) {
protected val scope = CoroutineScope(Job() + Dispatchers.Main)
var imagePropertyChangedListener: (() -> Unit)? = null
var reloadChapterListener: ((Boolean) -> Unit)? = null
var navigationModeChangedListener: (() -> Unit)? = null
var navigationModeInvertedListener: (() -> Unit)? = null

View File

@ -38,6 +38,18 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe
var cutoutBehavior = 0
private set
var shiftDoublePage = false
var doublePages = preferences.pageLayout().get() == PageLayout.DOUBLE_PAGES
set(value) {
field = value
if (!value) {
shiftDoublePage = false
}
}
var autoDoublePages = preferences.pageLayout().get() == PageLayout.AUTOMATIC
init {
preferences.pageTransitions()
.register({ usePageTransitions = it })
@ -75,6 +87,25 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe
preferences.readerTheme()
.register({ readerTheme = it }, { imagePropertyChangedListener?.invoke() })
preferences.pageLayout()
.asFlow()
.drop(1)
.onEach {
autoDoublePages = it == PageLayout.AUTOMATIC
if (!autoDoublePages) {
doublePages = it == PageLayout.DOUBLE_PAGES
}
reloadChapterListener?.invoke(doublePages)
}
.launchIn(scope)
preferences.pageLayout()
.register({
autoDoublePages = it == PageLayout.AUTOMATIC
if (!autoDoublePages) {
doublePages = it == PageLayout.DOUBLE_PAGES
}
})
navigationOverlayForNewUser = preferences.showNavigationOverlayNewUser().get()
if (navigationOverlayForNewUser) {
preferences.showNavigationOverlayNewUser().set(false)
@ -141,3 +172,9 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe
const val CUTOUT_IGNORE = 2
}
}
object PageLayout {
const val SINGLE_PAGE = 0
const val DOUBLE_PAGES = 1
const val AUTOMATIC = 2
}

View File

@ -3,9 +3,12 @@ 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
@ -48,8 +51,11 @@ 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
/**
* View of the ViewPager that contains a page of a chapter.
@ -57,14 +63,15 @@ import java.util.concurrent.TimeUnit
@SuppressLint("ViewConstructor")
class PagerPageHolder(
val viewer: PagerViewer,
val page: ReaderPage
val page: ReaderPage,
private var extraPage: ReaderPage? = null
) : FrameLayout(viewer.activity), ViewPagerAdapter.PositionableView {
/**
* Item that identifies this view. Needed by the adapter to not recreate views.
*/
override val item
get() = page
get() = page to extraPage
/**
* Loading progress bar to indicate the current progress.
@ -101,12 +108,28 @@ class PagerPageHolder(
*/
private var progressSubscription: Subscription? = null
/**
* Subscription for status changes of the page.
*/
private var extraStatusSubscription: Subscription? = null
/**
* Subscription for progress changes of the page.
*/
private var extraProgressSubscription: Subscription? = null
/**
* Subscription used to read the header of the image. This is needed in order to instantiate
* the appropiate image view depending if the image is animated (GIF).
*/
private var readImageHeaderSubscription: Subscription? = null
var status: Int = 0
var extraStatus: Int = 0
var progress: Int = 0
var extraProgress: Int = 0
private var skipExtra = false
init {
addView(progressBar)
observeStatus()
@ -124,8 +147,10 @@ class PagerPageHolder(
@SuppressLint("ClickableViewAccessibility")
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
unsubscribeProgress()
unsubscribeStatus()
unsubscribeProgress(1)
unsubscribeStatus(1)
unsubscribeProgress(2)
unsubscribeStatus(2)
unsubscribeReadImageHeader()
subsamplingImageView?.setOnImageEventListener(null)
}
@ -141,7 +166,18 @@ class PagerPageHolder(
val loader = page.chapter.pageLoader ?: return
statusSubscription = loader.getPage(page)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processStatus(it) }
.subscribe {
status = it
processStatus(it)
}
val extraPage = extraPage ?: return
val loader2 = extraPage.chapter.pageLoader ?: return
extraStatusSubscription = loader2.getPage(extraPage)
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
extraStatus = it
processStatus2(it)
}
}
/**
@ -155,7 +191,28 @@ class PagerPageHolder(
.distinctUntilChanged()
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { value -> progressBar.setProgress(value) }
.subscribe { value ->
progress = value
if (extraPage == null) {
progressBar.setProgress(progress)
} else {
progressBar.setProgress((progress + extraProgress) / 2)
}
}
}
private fun observeProgress2() {
extraProgressSubscription?.unsubscribe()
val extraPage = extraPage ?: return
extraProgressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
.map { extraPage.progress }
.distinctUntilChanged()
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { value ->
extraProgress = value
progressBar.setProgress((progress + extraProgress) / 2)
}
}
/**
@ -172,12 +229,40 @@ class PagerPageHolder(
setDownloading()
}
Page.READY -> {
if (extraStatus == Page.READY || extraPage == null) {
setImage()
unsubscribeProgress()
}
unsubscribeProgress(1)
}
Page.ERROR -> {
setError()
unsubscribeProgress()
unsubscribeProgress(1)
}
}
}
/**
* Called when the status of the page changes.
*
* @param status the new status of the page.
*/
private fun processStatus2(status: Int) {
when (status) {
Page.QUEUE -> setQueued()
Page.LOAD_PAGE -> setLoading()
Page.DOWNLOAD_IMAGE -> {
observeProgress2()
setDownloading()
}
Page.READY -> {
if (this.status == Page.READY) {
setImage()
}
unsubscribeProgress(2)
}
Page.ERROR -> {
setError()
unsubscribeProgress(2)
}
}
}
@ -185,17 +270,19 @@ class PagerPageHolder(
/**
* Unsubscribes from the status subscription.
*/
private fun unsubscribeStatus() {
statusSubscription?.unsubscribe()
statusSubscription = null
private fun unsubscribeStatus(page: Int) {
val subscription = if (page == 1) statusSubscription else extraStatusSubscription
subscription?.unsubscribe()
if (page == 1) statusSubscription = null else extraStatusSubscription = null
}
/**
* Unsubscribes from the progress subscription.
*/
private fun unsubscribeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = null
private fun unsubscribeProgress(page: Int) {
val subscription = if (page == 1) progressSubscription else extraProgressSubscription
subscription?.unsubscribe()
if (page == 1) progressSubscription = null else extraProgressSubscription = null
}
/**
@ -244,23 +331,30 @@ class PagerPageHolder(
unsubscribeReadImageHeader()
val streamFn = page.stream ?: return
val streamFn2 = extraPage?.stream
var openStream: InputStream? = null
readImageHeaderSubscription = Observable
.fromCallable {
val stream = streamFn().buffered(16)
openStream = stream
ImageUtil.findImageType(stream) == ImageUtil.ImageType.GIF
val stream2 = if (extraPage != null) streamFn2?.invoke()?.buffered(16) else null
openStream = this@PagerPageHolder.mergePages(stream, stream2)
ImageUtil.findImageType(stream) == ImageUtil.ImageType.GIF ||
if (stream2 != null) ImageUtil.findImageType(stream2) == ImageUtil.ImageType.GIF else false
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { isAnimated ->
if (skipExtra) {
onPageSplit()
}
if (!isAnimated) {
if (viewer.config.readerTheme >= 2) {
val imageView = initSubsamplingImageView()
if (page.bg != null &&
page.bgType == getBGType(viewer.config.readerTheme, context)
page.bgType == getBGType(viewer.config.readerTheme, context) + item.hashCode()
) {
imageView.setImage(ImageSource.inputStream(openStream!!))
imageView.background = page.bg
@ -275,7 +369,7 @@ class PagerPageHolder(
launchUI {
imageView.background = setBG(bytesArray)
page.bg = imageView.background
page.bgType = getBGType(viewer.config.readerTheme, context)
page.bgType = getBGType(viewer.config.readerTheme, context) + item.hashCode()
}
}
} else {
@ -293,7 +387,9 @@ class PagerPageHolder(
// Keep the Rx stream alive to close the input stream only when unsubscribed
.flatMap { Observable.never<Unit>() }
.doOnUnsubscribe {
try { openStream?.close() } catch (e: Exception) {}
try {
openStream?.close()
} catch (e: Exception) {}
}
.subscribe({}, {})
}
@ -468,6 +564,9 @@ class PagerPageHolder(
setText(R.string.retry)
setOnClickListener {
page.chapter.pageLoader?.retryPage(page)
extraPage?.let {
it.chapter.pageLoader?.retryPage(it)
}
}
}
addView(retryButton)
@ -530,6 +629,83 @@ class PagerPageHolder(
return decodeLayout
}
private fun mergePages(imageStream: InputStream, imageStream2: InputStream?): InputStream {
imageStream2 ?: return imageStream
if (page.fullPage) return imageStream
if (ImageUtil.findImageType(imageStream) == ImageUtil.ImageType.GIF) {
page.fullPage = true
skipExtra = true
return imageStream
} else if (ImageUtil.findImageType(imageStream2) == ImageUtil.ImageType.GIF) {
page.isolatedPage = true
extraPage?.fullPage = true
skipExtra = true
return imageStream
}
val imageBytes = imageStream.readBytes()
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
val height = imageBitmap.height
val width = imageBitmap.width
if (height < width) {
imageStream2.close()
imageStream.close()
page.fullPage = true
skipExtra = true
return imageBytes.inputStream()
}
val imageBytes2 = imageStream2.readBytes()
val imageBitmap2 = BitmapFactory.decodeByteArray(imageBytes2, 0, imageBytes2.size)
val height2 = imageBitmap2.height
val width2 = imageBitmap2.width
if (height2 < width2) {
imageStream2.close()
imageStream.close()
extraPage?.fullPage = true
page.isolatedPage = true
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
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)
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)
val output = ByteArrayOutputStream()
result.compress(Bitmap.CompressFormat.JPEG, 100, output)
imageStream.close()
imageStream2.close()
return ByteArrayInputStream(output.toByteArray())
}
private fun onPageSplit() {
extraPage ?: return
viewer.onPageSplit(page)
if (extraPage?.fullPage == true) {
extraPage = null
}
}
/**
* Extension method to set a [stream] into this ImageView.
*/
@ -541,6 +717,9 @@ 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) {

View File

@ -60,29 +60,29 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
field = value
if (value) {
awaitingIdleViewerChapters?.let {
setChaptersInternal(it)
setChaptersDoubleShift(it)
awaitingIdleViewerChapters = null
}
}
}
private var pagerListener = object : ViewPager.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
onPageChange(position)
}
override fun onPageScrollStateChanged(state: Int) {
isIdle = state == ViewPager.SCROLL_STATE_IDLE
}
}
init {
pager.gone() // Don't layout the pager yet
pager.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
pager.offscreenPageLimit = 1
pager.id = R.id.reader_pager
pager.adapter = adapter
pager.addOnPageChangeListener(
object : ViewPager.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
onPageChange(position)
}
override fun onPageScrollStateChanged(state: Int) {
isIdle = state == ViewPager.SCROLL_STATE_IDLE
}
}
)
pager.addOnPageChangeListener(pagerListener)
pager.tapListener = f@{ event ->
if (!config.tappingEnabled) {
activity.toggleMenu()
@ -101,9 +101,11 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
}
pager.longTapListener = f@{
if (activity.menuVisible || config.longTapEnabled) {
val item = adapter.items.getOrNull(pager.currentItem)
if (item is ReaderPage) {
activity.onPageLongTap(item)
val item = adapter.joinedItems.getOrNull(pager.currentItem)
val firstPage = item?.first as? ReaderPage
val secondPage = item?.second as? ReaderPage
if (firstPage is ReaderPage) {
activity.onPageLongTap(firstPage, secondPage)
return@f true
}
}
@ -114,6 +116,10 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
refreshAdapter()
}
config.reloadChapterListener = {
activity.reloadChapters(it)
}
config.navigationModeChangedListener = {
val showOnStart = config.navigationOverlayForNewUser
activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart)
@ -136,14 +142,14 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
/**
* Called when a new page (either a [ReaderPage] or [ChapterTransition]) is marked as active
*/
private fun onPageChange(position: Int) {
val page = adapter.items.getOrNull(position)
fun onPageChange(position: Int) {
val page = adapter.joinedItems.getOrNull(position)
if (page != null && currentPage != page) {
val allowPreload = checkAllowPreload(page as? ReaderPage)
currentPage = page
when (page) {
is ReaderPage -> onReaderPageSelected(page, allowPreload)
is ChapterTransition -> onTransitionSelected(page)
val allowPreload = checkAllowPreload(page.first as? ReaderPage)
currentPage = page.first
when (val aPage = page.first) {
is ReaderPage -> onReaderPageSelected(aPage, allowPreload, page.second != null)
is ChapterTransition -> onTransitionSelected(aPage)
}
}
}
@ -171,11 +177,16 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
* Called when a [ReaderPage] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onReaderPageSelected(page: ReaderPage, allowPreload: Boolean) {
activity.onPageSelected(page)
private fun onReaderPageSelected(page: ReaderPage, allowPreload: Boolean, hasExtraPage: Boolean) {
activity.onPageSelected(page, hasExtraPage)
val offset = if (hasExtraPage) 1 else 0
val pages = page.chapter.pages ?: return
if (hasExtraPage) {
Timber.d("onReaderPageSelected: ${page.number}-${page.number + offset}/${pages.size}")
} else {
Timber.d("onReaderPageSelected: ${page.number}/${pages.size}")
}
// Preload next chapter once we're within the last 5 pages of the current chapter
val inPreloadRange = pages.size - page.number < 5
if (inPreloadRange && allowPreload && page.chapter == adapter.currentChapter) {
@ -202,13 +213,29 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
}
}
fun setChaptersDoubleShift(chapters: ViewerChapters) {
// Remove Listener since we're about to change the size of the items
// If we don't the size change could put us on a new chapter
pager.removeOnPageChangeListener(pagerListener)
setChaptersInternal(chapters)
pager.addOnPageChangeListener(pagerListener)
// Since we removed the listener while shifting, call page change to update the ui
onPageChange(pager.currentItem)
}
fun updateShifting(page: ReaderPage? = null) {
adapter.pageToShift = page ?: adapter.joinedItems[pager.currentItem].first as? ReaderPage
}
fun getShiftedPage(): ReaderPage? = adapter.pageToShift
/**
* Tells this viewer to set the given [chapters] as active. If the pager is currently idle,
* it sets the chapters immediately, otherwise they are saved and set when it becomes idle.
*/
override fun setChapters(chapters: ViewerChapters) {
if (isIdle) {
setChaptersInternal(chapters)
setChaptersDoubleShift(chapters)
} else {
awaitingIdleViewerChapters = chapters
}
@ -219,7 +246,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
*/
private fun setChaptersInternal(chapters: ViewerChapters) {
Timber.d("setChaptersInternal")
val forceTransition = config.alwaysShowChapterTransition || adapter.items.getOrNull(
val forceTransition = config.alwaysShowChapterTransition || adapter.joinedItems.getOrNull(
pager
.currentItem
) is ChapterTransition
@ -232,6 +259,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
moveToPage(pages[chapters.currChapter.requestedPage])
pager.visible()
}
activity.invalidateOptionsMenu()
}
/**
@ -239,13 +267,21 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
*/
override fun moveToPage(page: ReaderPage) {
Timber.d("moveToPage ${page.number}")
val position = adapter.items.indexOf(page)
val position = adapter.joinedItems.indexOfFirst { it.first == page || it.second == page }
if (position != -1) {
val currentPosition = pager.currentItem
pager.setCurrentItem(position, true)
// manually call onPageChange since ViewPager listener is not triggered in this case
if (currentPosition == position) {
onPageChange(position)
} else {
// Call this since with double shift onPageChange wont get called (it shouldn't)
// Instead just update the page count in ui
val joinedItem = adapter.joinedItems.firstOrNull { it.first == page || it.second == page }
activity.onPageSelected(
joinedItem?.first as? ReaderPage ?: page,
joinedItem?.second != null
)
}
} else {
Timber.d("Page $page not found in adapter")
@ -342,6 +378,10 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
return true
}
fun onPageSplit(currentPage: ReaderPage) {
adapter.onPageSplit(currentPage)
}
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.

View File

@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import timber.log.Timber
import kotlin.math.max
/**
* Pager adapter used by this [viewer] to where [ViewerChapters] updates are posted.
@ -15,14 +16,24 @@ import timber.log.Timber
class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
/**
* List of currently set items.
* Paired list of currently set items.
*/
var items: List<Any> = emptyList()
var joinedItems: MutableList<Pair<Any, Any?>> = mutableListOf()
private set
/** Single list of items */
private var subItems: MutableList<Any> = mutableListOf()
var nextTransition: ChapterTransition.Next? = null
private set
/** Page used to start the shifted pages */
var pageToShift: ReaderPage? = null
/** Varibles used to check if config of the pages have changed */
private var shifted = viewer.config.shiftDoublePage
private var doubledUp = viewer.config.doublePages
var currentChapter: ReaderChapter? = null
/**
@ -38,8 +49,15 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
// We only need to add the last few pages of the previous chapter, because it'll be
// selected as the current chapter when one of those pages is selected.
val prevPages = chapters.prevChapter.pages
// We will take an even number of pages if the page count if even
// however we should take account full pages when deciding
val numberOfFullPages =
(
chapters.prevChapter.pages?.count { it.fullPage || it.isolatedPage }
?: 0
)
if (prevPages != null) {
newItems.addAll(prevPages.takeLast(2))
newItems.addAll(prevPages.takeLast(if ((prevPages.size + numberOfFullPages) % 2 == 0) 2 else 3))
}
}
@ -75,27 +93,34 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
}
}
if (viewer is R2LPagerViewer) {
newItems.reverse()
}
subItems = newItems.toMutableList()
items = newItems
notifyDataSetChanged()
var useSecondPage = false
if (shifted != viewer.config.shiftDoublePage || (doubledUp != viewer.config.doublePages && doubledUp)) {
if (shifted && (doubledUp == viewer.config.doublePages)) {
useSecondPage = true
}
shifted = viewer.config.shiftDoublePage
}
doubledUp = viewer.config.doublePages
setJoinedItems(useSecondPage)
}
/**
* Returns the amount of items of the adapter.
*/
override fun getCount(): Int {
return items.size
return joinedItems.size
}
/**
* Creates a new view for the item at the given [position].
*/
override fun createView(container: ViewGroup, position: Int): View {
return when (val item = items[position]) {
is ReaderPage -> PagerPageHolder(viewer, item)
val item = joinedItems[position].first
val item2 = joinedItems[position].second
return when (item) {
is ReaderPage -> PagerPageHolder(viewer, item, item2 as? ReaderPage)
is ChapterTransition -> PagerTransitionHolder(viewer, item)
else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented")
}
@ -106,7 +131,9 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
*/
override fun getItemPosition(view: Any): Int {
if (view is PositionableView) {
val position = items.indexOf(view.item)
val position = joinedItems.indexOfFirst {
view.item == (it.first to it.second)
}
if (position != -1) {
return position
} else {
@ -115,4 +142,143 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
}
return POSITION_NONE
}
fun onPageSplit(current: ReaderPage) {
val oldCurrent = joinedItems.getOrNull(viewer.pager.currentItem)
setJoinedItems(
oldCurrent?.second == current ||
(current.index + 1) < (
(
oldCurrent?.second
?: oldCurrent?.first
) as? ReaderPage
)?.index ?: 0
)
// The listener may be removed when we split a page, so the ui may not have updated properly
// This case usually happens when we load a new chapter and the first 2 pages need to split og
viewer.pager.post {
viewer.onPageChange(viewer.pager.currentItem)
}
}
private fun setJoinedItems(useSecondPage: Boolean = false) {
val oldCurrent = joinedItems.getOrNull(viewer.pager.currentItem)
if (!viewer.config.doublePages) {
// If not in double mode, set up items like before
subItems.forEach {
(it as? ReaderPage)?.shiftedPage = false
}
this.joinedItems = subItems.map { Pair<Any, Any?>(it, null) }.toMutableList()
if (viewer is R2LPagerViewer) {
joinedItems.reverse()
}
} else {
val pagedItems = mutableListOf<MutableList<ReaderPage?>>()
val otherItems = mutableListOf<Any>()
pagedItems.add(mutableListOf())
// Step 1: segment the pages and transition pages
subItems.forEach {
if (it is ReaderPage) {
pagedItems.last().add(it)
} else {
otherItems.add(it)
pagedItems.add(mutableListOf())
}
}
var pagedIndex = 0
val subJoinedItems = mutableListOf<Pair<Any, Any?>>()
// Step 2: run through each set of pages
pagedItems.forEach { items ->
items.forEach {
it?.shiftedPage = false
}
// Step 3: If pages have been shifted,
if (viewer.config.shiftDoublePage) {
run loop@{
var index = items.indexOf(pageToShift)
if (pageToShift?.fullPage == true) {
index = max(0, index - 1)
}
// Go from the current page and work your way back to the first page,
// or the first page that's a full page.
// This is done in case user tries to shift a page after a full page
val fullPageBeforeIndex = max(
0,
(
if (index > -1) (
items.subList(0, index)
.indexOfFirst { it?.fullPage == true }
) else -1
)
)
// Add a shifted page to the first place there isnt a full page
(fullPageBeforeIndex until items.size).forEach {
if (items[it]?.fullPage == false) {
items[it]?.shiftedPage = true
return@loop
}
}
}
}
// Step 4: Add blanks for chunking
var itemIndex = 0
while (itemIndex < items.size) {
items[itemIndex]?.isolatedPage = false
if (items[itemIndex]?.fullPage == true || items[itemIndex]?.shiftedPage == true) {
// Add a 'blank' page after each full page. It will be used when chunked to solo a page
items.add(itemIndex + 1, null)
if (items[itemIndex]?.fullPage == true && itemIndex > 0 &&
items[itemIndex - 1] != null && (itemIndex - 1) % 2 == 0
) {
// If a page is a full page, check if the previous page needs to be isolated
// we should check if it's an even or odd page, since even pages need shifting
// For example if Page 1 is full, Page 0 needs to be isolated
// No need to take account shifted pages, because null additions should
// always have an odd index in the list
items[itemIndex - 1]?.isolatedPage = true
items.add(itemIndex, null)
itemIndex++
}
itemIndex++
}
itemIndex++
}
// Step 5: chunk em
if (items.isNotEmpty()) {
subJoinedItems.addAll(
items.chunked(2).map { Pair(it.first()!!, it.getOrNull(1)) }
)
}
otherItems.getOrNull(pagedIndex)?.let {
subJoinedItems.add(Pair(it, null))
pagedIndex++
}
}
if (viewer is R2LPagerViewer) {
subJoinedItems.reverse()
}
this.joinedItems = subJoinedItems
}
notifyDataSetChanged()
// Step 6: Move back to our previous page or transition page
// The listener is likely off around now, but either way when shifting or doubling,
// we need to set the page back correctly
// We will however shift to the first page of the new chapter if the last page we were are
// on is not in the new chapter that has loaded
val newPage =
when {
(oldCurrent?.first as? ReaderPage)?.chapter != currentChapter &&
(oldCurrent?.first as? ChapterTransition)?.from != currentChapter -> subItems.find { (it as? ReaderPage)?.chapter == currentChapter }
useSecondPage -> (oldCurrent?.second ?: oldCurrent?.first)
else -> oldCurrent?.first ?: return
}
val index = joinedItems.indexOfFirst { it.first == newPage || it.second == newPage }
viewer.pager.setCurrentItem(index, false)
}
}

View File

@ -187,7 +187,7 @@ class WebtoonViewer(val activity: ReaderActivity, val hasMargins: Boolean = fals
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onPageSelected(page: ReaderPage, allowPreload: Boolean) {
activity.onPageSelected(page)
activity.onPageSelected(page, false)
val pages = page.chapter.pages ?: return
Timber.d("onReaderPageSelected: ${page.number}/${pages.size}")

View File

@ -5,6 +5,8 @@ import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PageLayout
import eu.kanade.tachiyomi.util.lang.addBetaTag
import kotlinx.coroutines.flow.launchIn
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
@ -186,6 +188,21 @@ class SettingsReaderController : SettingsController() {
titleRes = R.string.crop_borders
defaultValue = false
}
intListPreference(activity) {
key = Keys.pageLayout
title = context.getString(R.string.page_layout).addBetaTag(context)
dialogTitleRes = R.string.page_layout
entriesRes = arrayOf(
R.string.single_page,
R.string.double_pages,
R.string.automatic_orientation
)
entryRange = 0..2
defaultValue = 2
}
infoPreference(R.string.automatic_can_still_switch).apply {
preferences.pageLayout().asImmediateFlow { isVisible = it == PageLayout.AUTOMATIC }.launchIn(viewScope)
}
}
preferenceCategory {
titleRes = R.string.webtoon

View File

@ -1,11 +1,19 @@
package eu.kanade.tachiyomi.util.lang
import android.content.Context
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.StyleSpan
import android.text.style.SuperscriptSpan
import androidx.annotation.ColorInt
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getResourceColor
import kotlin.math.floor
/**
@ -90,3 +98,13 @@ fun String.indexesOf(substr: String, ignoreCase: Boolean = true): List<Int> {
}
}
}
fun String.addBetaTag(context: Context): Spanned {
val betaText = context.getString(R.string.beta)
val betaSpan = SpannableStringBuilder(this + betaText)
betaSpan.setSpan(SuperscriptSpan(), length, length + betaText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
betaSpan.setSpan(RelativeSizeSpan(0.75f), length, length + betaText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
betaSpan.setSpan(StyleSpan(Typeface.BOLD), length, length + betaText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
betaSpan.setSpan(ForegroundColorSpan(context.getResourceColor(R.attr.colorAccent)), length, length + betaText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
return betaSpan
}

View File

@ -17,6 +17,9 @@ fun launchNow(block: suspend CoroutineScope.() -> Unit): Job =
fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job =
launch(Dispatchers.IO, block = block)
fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job =
launch(Dispatchers.Main, block = block)
suspend fun <T> withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
suspend fun <T> withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block)

View File

@ -29,6 +29,13 @@ class MaterialSpinnerView @JvmOverloads constructor(context: Context, attrs: Att
private var pref: Preference<Int>? = null
private var prefOffset = 0
private var popup: PopupMenu? = null
var title: CharSequence
get() {
return binding.titleView.text
}
set(value) {
binding.titleView.text = value
}
var onItemSelectedListener: ((Int) -> Unit)? = null
set(value) {
@ -49,7 +56,7 @@ class MaterialSpinnerView @JvmOverloads constructor(context: Context, attrs: Att
val a = context.obtainStyledAttributes(attrs, R.styleable.ReaderSpinnerView, 0, 0)
val str = a.getString(R.styleable.ReaderSpinnerView_title) ?: ""
binding.titleView.text = str
title = str
val entries = (a.getTextArray(R.styleable.ReaderSpinnerView_android_entries) ?: emptyArray()).map { it.toString() }
this.entries = entries

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.widget.preference
import android.app.Activity
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.StringRes
import androidx.preference.Preference
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.callbacks.onDismiss
@ -23,6 +24,8 @@ open class MatPreference @JvmOverloads constructor(
private var isShowing = false
var customSummary: String? = null
@StringRes var dialogTitleRes: Int? = null
override fun onClick() {
if (!isShowing) {
dialog().apply {
@ -38,7 +41,9 @@ open class MatPreference @JvmOverloads constructor(
open fun dialog(): MaterialDialog {
return MaterialDialog(activity ?: context).apply {
if (title != null) {
if (dialogTitleRes != null) {
title(res = dialogTitleRes)
} else if (title != null) {
title(text = title.toString())
}
negativeButton(android.R.string.cancel)

View File

@ -0,0 +1,9 @@
<!-- drawable/book_open_variant.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="M17.5 14.33C18.29 14.33 19.13 14.41 20 14.57V16.07C19.38 15.91 18.54 15.83 17.5 15.83C15.6 15.83 14.11 16.16 13 16.82V15.13C14.17 14.6 15.67 14.33 17.5 14.33M13 12.46C14.29 11.93 15.79 11.67 17.5 11.67C18.29 11.67 19.13 11.74 20 11.9V13.4C19.38 13.24 18.54 13.16 17.5 13.16C15.6 13.16 14.11 13.5 13 14.15M17.5 10.5C15.6 10.5 14.11 10.82 13 11.5V9.84C14.23 9.28 15.73 9 17.5 9C18.29 9 19.13 9.08 20 9.23V10.78C19.26 10.59 18.41 10.5 17.5 10.5M21 18.5V7C19.96 6.67 18.79 6.5 17.5 6.5C15.45 6.5 13.62 7 12 8V19.5C13.62 18.5 15.45 18 17.5 18C18.69 18 19.86 18.16 21 18.5M17.5 4.5C19.85 4.5 21.69 5 23 6V20.56C23 20.68 22.95 20.8 22.84 20.91C22.73 21 22.61 21.08 22.5 21.08C22.39 21.08 22.31 21.06 22.25 21.03C20.97 20.34 19.38 20 17.5 20C15.45 20 13.62 20.5 12 21.5C10.66 20.5 8.83 20 6.5 20C4.84 20 3.25 20.36 1.75 21.07C1.72 21.08 1.68 21.08 1.63 21.1C1.59 21.11 1.55 21.12 1.5 21.12C1.39 21.12 1.27 21.08 1.16 21C1.05 20.89 1 20.78 1 20.65V6C2.34 5 4.18 4.5 6.5 4.5C8.83 4.5 10.66 5 12 6C13.34 5 15.17 4.5 17.5 4.5Z" />
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/actionBarTintColor">
<path
android:fillColor="@android:color/white"
android:pathData="M19,5v14L5,19L5,5h14m0,-2L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM14.14,11.86l-3,3.87L9,13.14 6,17h12l-3.86,-5.14z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/actionBarTintColor">
<path
android:fillColor="@android:color/white"
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM19,19L5,19L5,5h11.17L19,7.83L19,19zM12,12c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3zM6,6h9v4L6,10z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/actionBarTintColor">
<path
android:fillColor="@android:color/white"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92s2.92,-1.31 2.92,-2.92c0,-1.61 -1.31,-2.92 -2.92,-2.92zM18,4c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1zM6,13c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1zM18,20.02c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1z"/>
</vector>

View File

@ -0,0 +1,9 @@
<!-- drawable/page_next_outline.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:tint="?attr/actionBarTintColor"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M22,3H5A2,2 0 0,0 3,5V9H5V5H22V19H5V15H3V19A2,2 0 0,0 5,21H22A2,2 0 0,0 24,19V5A2,2 0 0,0 22,3M7,15V13H0V11H7V9L11,12L7,15M20,13H13V11H20V13M20,9H13V7H20V9M17,17H13V15H17V17Z" />
</vector>

View File

@ -0,0 +1,9 @@
<!-- drawable/page_previous_outline.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:tint="?attr/actionBarTintColor"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M2,3H19A2,2 0 0,1 21,5V9H19V5H2V19H19V15H21V19A2,2 0 0,1 19,21H2A2,2 0 0,1 0,19V5A2,2 0 0,1 2,3M17,15V13H24V11H17V9L13,12L17,15M4,13H11V11H4V13M4,9H11V7H4V9M4,17H8V15H4V17Z" />
</vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/actionBarTintColor"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM14,17L7,17v-2h7v2zM17,13L7,13v-2h10v2zM17,9L7,9L7,7h10v2z"/>
</vector>

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:end="96dp" android:start="64dp" android:drawable="@drawable/square_ripple"/>
<item android:id="@+id/layer_full_width" android:end="0dp" android:start="64dp" android:drawable="@drawable/rect_ripple"/>
<item android:id="@+id/layer_one_item" android:end="48dp" android:start="64dp" android:drawable="@drawable/rect_ripple" />
</layer-list>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/fullRippleColor">
<item android:id="@android:id/mask">
<shape android:shape="rectangle">
<solid android:color="@color/fullRippleColor" />
</shape>
</item>
</ripple>

View File

@ -59,10 +59,26 @@
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/chapters_button"
app:layout_constraintEnd_toStartOf="@id/display_options"
app:layout_constraintEnd_toStartOf="@id/double_page"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_open_in_webview_24dp" />
<ImageButton
android:id="@+id/double_page"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/next_title"
android:padding="@dimen/material_layout_keylines_screen_edge_margin"
app:tint="?actionBarTintColor"
app:layout_constraintHorizontal_chainStyle="spread"
android:tooltipText="@string/double_pages"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/webview_button"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/display_options"
app:srcCompat="@drawable/ic_book_open_variant_24dp" />
<ImageButton
android:id="@+id/display_options"
android:layout_width="wrap_content"
@ -74,7 +90,7 @@
app:layout_constraintHorizontal_chainStyle="spread"
android:tooltipText="@string/display_options"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/webview_button"
app:layout_constraintStart_toEndOf="@id/double_page"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:srcCompat="@drawable/ic_tune_24dp" />

View File

@ -46,12 +46,12 @@
<TextView
android:id="@+id/left_page_text"
android:layout_width="32dp"
android:layout_width="48dp"
android:layout_height="match_parent"
android:gravity="center"
android:textColor="@color/textColorPrimary"
android:textSize="15sp"
tools:text="1" />
tools:text="12-14" />
<eu.kanade.tachiyomi.ui.reader.ReaderSeekBar
android:id="@+id/page_seekbar"
@ -63,7 +63,7 @@
<TextView
android:id="@+id/right_page_text"
android:layout_width="32dp"
android:layout_width="48dp"
android:layout_height="match_parent"
android:gravity="center"
android:textColor="@color/textColorPrimary"

View File

@ -40,6 +40,14 @@
app:title="@string/scale_type"
android:entries="@array/image_scale_type" />
<eu.kanade.tachiyomi.widget.MaterialSpinnerView
android:id="@+id/page_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
app:title="@string/page_layout"
android:entries="@array/page_layouts" />
<eu.kanade.tachiyomi.widget.MaterialSpinnerView
android:id="@+id/extend_past_cutout"
android:layout_width="match_parent"
@ -56,7 +64,6 @@
app:title="@string/zoom_start_position"
android:entries="@array/zoom_start" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/crop_borders"
android:layout_width="match_parent"

View File

@ -2,4 +2,9 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_shift_double_page"
android:icon="@drawable/ic_page_next_outline_24dp"
android:title="@string/shift_one_page_over"
app:showAsAction="ifRoom" />
</menu>

View File

@ -70,6 +70,12 @@
<item>@string/right_and_left_nav</item>
</string-array>
<string-array name="page_layouts">
<item>@string/single_page</item>
<item>@string/double_pages</item>
<item>@string/automatic</item>
</string-array>
<string-array name="invert_tapping_mode">
<item>@string/none</item>
<item>@string/horizontally</item>

View File

@ -165,6 +165,7 @@
<string name="hide_category_hopper">Hide category hopper</string>
<string name="hide_hopper_on_scroll">Hide category hopper on scroll</string>
<string name="more_library_settings">More library settings</string>
<string name="shift_one_page_over">Shift one page over</string>
<!-- Library update service notifications -->
<string name="new_chapters_found">New chapters found</string>
@ -279,9 +280,6 @@
<!-- Reader -->
<string name="custom_filter">Custom filter</string>
<string name="set_as_cover">Set as cover</string>
<string name="set_page_as_cover">Set page as cover</string>
<string name="share_page">Share page</string>
<string name="save_page">Save page</string>
<string name="_details">%1$s details</string>
<string name="reader_settings">Reader settings</string>
<string name="set_as_default_for_all">Set as default for all</string>
@ -306,6 +304,14 @@
<string name="next_chapter">Next chapter</string>
<string name="previous_chapter">Previous chapter</string>
<string name="set_first_page_as_cover">Set first page as cover</string>
<string name="share_first_page">Share first page</string>
<string name="save_first_page">Save first page</string>
<string name="set_second_page_as_cover">Set second page as cover</string>
<string name="share_second_page">Share second page</string>
<string name="save_second_page">Save second page</string>
<!-- Reader settings -->
<string name="fullscreen">Fullscreen</string>
<string name="animate_page_transitions">Animate page transitions</string>
@ -352,6 +358,10 @@
<string name="original_size">Original size</string>
<string name="smart_fit">Smart fit</string>
<string name="zoom_start_position">Zoom start position</string>
<string name="double_pages">Double pages</string>
<string name="single_page">Single page</string>
<string name="switch_to_double">Switch to double pages</string>
<string name="switch_to_single">Switch to single page</string>
<string name="force_portrait">Force portrait</string>
<string name="force_landscape">Force landscape</string>
<string name="red_initial">R</string>
@ -382,6 +392,9 @@
<string name="pad_cutout_areas">Pad cutout areas</string>
<string name="start_past_cutout">Start past cutout</string>
<string name="ignore_cutout_areas">Ignore cutout areas</string>
<string name="page_layout">Page layout</string>
<string name="automatic_can_still_switch">While using automatic page layout, you can still switch between layouts while reading without overriding this setting</string>
<string name="automatic_orientation">Automatic (based on orientation)</string>
<!-- Manga details -->
<string name="about_this_">About this %1$s</string>
@ -783,6 +796,7 @@
<string name="auto">Auto</string>
<string name="automatic">Automatic</string>
<string name="back">Back</string>
<string name="beta">BETA</string>
<string name="bottom">Bottom</string>
<string name="bug_report">Report a Bug</string>
<string name="cancel">Cancel</string>