This commit is contained in:
HapeLee committed 2025-06-04 13:33:51 +08:00
1 parent b86d90021c
commit 6c0757c91b
972 files changed
+6885 -7321

No files matched your search

@@ -0,0 +1,339 @@
package io.legato.kazusa.ui.book.audio
import android.annotation.SuppressLint
import android.icu.text.SimpleDateFormat
import android.os.Build
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.material.slider.Slider
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.constant.Status
import io.legato.kazusa.constant.Theme
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.databinding.ActivityAudioPlayBinding
import io.legato.kazusa.help.book.isAudio
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.model.AudioPlay
import io.legato.kazusa.model.BookCover
import io.legato.kazusa.service.AudioPlayService
import io.legato.kazusa.ui.about.AppLogDialog
import io.legato.kazusa.ui.book.changesource.ChangeBookSourceDialog
import io.legato.kazusa.ui.book.source.edit.BookSourceEditActivity
import io.legato.kazusa.ui.book.toc.TocActivityResult
import io.legato.kazusa.ui.login.SourceLoginActivity
import io.legato.kazusa.utils.StartActivityContract
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.observeEvent
import io.legato.kazusa.utils.observeEventSticky
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.startActivityForBook
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.views.onLongClick
import java.util.Locale
/**
* 音频播放
*/
@SuppressLint("ObsoleteSdkInt")
class AudioPlayActivity :
VMBaseActivity<ActivityAudioPlayBinding, AudioPlayViewModel>(toolBarTheme = Theme.Dark),
ChangeBookSourceDialog.CallBack,
AudioPlay.CallBack {
override val binding by viewBinding(ActivityAudioPlayBinding::inflate)
override val viewModel by viewModels<AudioPlayViewModel>()
private val timerSliderPopup by lazy { TimerSliderPopup(this) }
private var adjustProgress = false
private val progressTimeFormat by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
SimpleDateFormat("mm:ss", Locale.getDefault())
} else {
java.text.SimpleDateFormat("mm:ss", Locale.getDefault())
}
}
private val tocActivityResult = registerForActivityResult(TocActivityResult()) {
it?.let {
if (it.first != AudioPlay.book?.durChapterIndex
|| it.second == 0
) {
AudioPlay.skipTo(it.first)
}
}
}
private val sourceEditResult =
registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
if (it.resultCode == RESULT_OK) {
viewModel.upSource()
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
binding.titleBar.setBackgroundResource(R.color.transparent)
AudioPlay.register(this)
viewModel.titleData.observe(this) {
binding.titleBar.title = it
}
viewModel.coverData.observe(this) {
upCover(it)
}
viewModel.initData(intent)
initView()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.audio_play, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_login)?.isVisible = !AudioPlay.bookSource?.loginUrl.isNullOrBlank()
menu.findItem(R.id.menu_wake_lock)?.isChecked = AppConfig.audioPlayUseWakeLock
return super.onMenuOpened(featureId, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_change_source -> AudioPlay.book?.let {
showDialogFragment(ChangeBookSourceDialog(it.name, it.author))
}
R.id.menu_login -> AudioPlay.bookSource?.let {
startActivity<SourceLoginActivity> {
putExtra("type", "bookSource")
putExtra("key", it.bookSourceUrl)
}
}
R.id.menu_wake_lock -> AppConfig.audioPlayUseWakeLock = !AppConfig.audioPlayUseWakeLock
R.id.menu_copy_audio_url -> sendToClip(AudioPlayService.url)
R.id.menu_edit_source -> AudioPlay.bookSource?.let {
sourceEditResult.launch {
putExtra("sourceUrl", it.bookSourceUrl)
}
}
R.id.menu_log -> showDialogFragment<AppLogDialog>()
}
return super.onCompatOptionsItemSelected(item)
}
private fun initView() {
binding.fabPlayStop.setOnClickListener {
playButton()
}
binding.fabPlayStop.onLongClick {
AudioPlay.stop()
}
binding.ivSkipNext.setOnClickListener {
AudioPlay.next()
}
binding.ivSkipPrevious.setOnClickListener {
AudioPlay.prev()
}
// binding.playerProgress.setOnSeekBarChangeListener(object : SeekBarChangeListener {
// override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
// binding.tvDurTime.text = progressTimeFormat.format(progress.toLong())
// }
//
// override fun onStartTrackingTouch(seekBar: SeekBar) {
// adjustProgress = true
// }
//
// override fun onStopTrackingTouch(seekBar: SeekBar) {
// adjustProgress = false
// AudioPlay.adjustProgress(seekBar.progress)
// }
// })
// 替换原来的 SeekBar 监听器
binding.playerProgress.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
binding.tvDurTime.text = progressTimeFormat.format(value.toLong())
}
}
binding.playerProgress.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
adjustProgress = true
}
override fun onStopTrackingTouch(slider: Slider) {
adjustProgress = false
AudioPlay.adjustProgress(slider.value.toInt())
}
})
binding.ivChapter.setOnClickListener {
AudioPlay.book?.let {
tocActivityResult.launch(it.bookUrl)
}
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
binding.ivFastRewind.invisible()
binding.ivFastForward.invisible()
}
binding.ivFastForward.setOnClickListener {
AudioPlay.adjustSpeed(0.1f)
}
binding.ivFastRewind.setOnClickListener {
AudioPlay.adjustSpeed(-0.1f)
}
binding.ivTimer.setOnClickListener {
timerSliderPopup.showAsDropDown(it, 0, (-100).dpToPx(), Gravity.TOP)
}
binding.llPlayMenu.applyNavigationBarPadding()
}
private fun upCover(path: String?) {
BookCover.load(this, path, sourceOrigin = AudioPlay.bookSource?.bookSourceUrl) {
BookCover.loadBlur(this, path, sourceOrigin = AudioPlay.bookSource?.bookSourceUrl)
.into(binding.ivBg)
}.into(binding.ivCover)
}
private fun playButton() {
when (AudioPlay.status) {
Status.PLAY -> AudioPlay.pause(this)
Status.PAUSE -> AudioPlay.resume(this)
else -> AudioPlay.loadOrUpPlayUrl()
}
}
override val oldBook: Book?
get() = AudioPlay.book
override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
if (book.isAudio) {
viewModel.changeTo(source, book, toc)
} else {
AudioPlay.stop()
lifecycleScope.launch {
withContext(IO) {
AudioPlay.book?.migrateTo(book, toc)
book.removeType(BookType.updateError)
AudioPlay.book?.delete()
appDb.bookDao.insert(book)
}
startActivityForBook(book)
finish()
}
}
}
override fun finish() {
val book = AudioPlay.book ?: return super.finish()
if (AudioPlay.inBookshelf) {
return super.finish()
}
if (!AppConfig.showAddToShelfAlert) {
viewModel.removeFromBookshelf { super.finish() }
} else {
alert(title = getString(R.string.add_to_bookshelf)) {
setMessage(getString(R.string.check_add_bookshelf, book.name))
okButton {
AudioPlay.book?.removeType(BookType.notShelf)
AudioPlay.book?.save()
AudioPlay.inBookshelf = true
setResult(RESULT_OK)
}
noButton { viewModel.removeFromBookshelf { super.finish() } }
}
}
}
override fun onDestroy() {
super.onDestroy()
if (AudioPlay.status != Status.PLAY) {
AudioPlay.stop()
}
AudioPlay.unregister(this)
}
@SuppressLint("SetTextI18n")
override fun observeLiveBus() {
observeEvent<Boolean>(EventBus.MEDIA_BUTTON) {
if (it) {
playButton()
}
}
observeEventSticky<Int>(EventBus.AUDIO_STATE) {
AudioPlay.status = it
if (it == Status.PLAY) {
binding.fabPlayStop.setImageResource(R.drawable.ic_pause_24dp)
} else {
binding.fabPlayStop.setImageResource(R.drawable.ic_play_24dp)
}
}
observeEventSticky<String>(EventBus.AUDIO_SUB_TITLE) {
binding.tvSubTitle.text = it
binding.ivSkipPrevious.isEnabled = AudioPlay.durChapterIndex > 0
binding.ivSkipNext.isEnabled =
AudioPlay.durChapterIndex < AudioPlay.simulatedChapterSize - 1
}
// observeEventSticky<Int>(EventBus.AUDIO_SIZE) {
// binding.playerProgress.max = it
// binding.tvAllTime.text = progressTimeFormat.format(it.toLong())
// }
// observeEventSticky<Int>(EventBus.AUDIO_PROGRESS) {
// if (!adjustProgress) binding.playerProgress.progress = it
// binding.tvDurTime.text = progressTimeFormat.format(it.toLong())
// }
// observeEventSticky<Int>(EventBus.AUDIO_BUFFER_PROGRESS) {
// binding.playerProgress.secondaryProgress = it
//
// }
observeEventSticky<Int>(EventBus.AUDIO_SIZE) {
binding.playerProgress.valueTo = it.toFloat()
binding.tvAllTime.text = progressTimeFormat.format(it.toLong())
}
observeEventSticky<Int>(EventBus.AUDIO_PROGRESS) {
if (!adjustProgress) binding.playerProgress.value = it.toFloat()
binding.tvDurTime.text = progressTimeFormat.format(it.toLong())
}
observeEventSticky<Int>(EventBus.AUDIO_BUFFER_PROGRESS) {
//updateBufferProgress(it)
}
observeEventSticky<Float>(EventBus.AUDIO_SPEED) {
binding.tvSpeed.text = String.format(Locale.ROOT, "%.1fX", it)
binding.tvSpeed.visible()
}
observeEventSticky<Int>(EventBus.AUDIO_DS) {
binding.tvTimer.text = "${it}m"
binding.tvTimer.visible(it > 0)
}
}
override fun upLoading(loading: Boolean) {
runOnUiThread {
binding.progressLoading.visible(loading)
}
}
}
@@ -0,0 +1,120 @@
package io.legato.kazusa.ui.book.audio
import android.app.Application
import android.content.Intent
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.help.book.getBookSource
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.book.simulatedTotalChapterNum
import io.legato.kazusa.model.AudioPlay
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.toastOnUi
class AudioPlayViewModel(application: Application) : BaseViewModel(application) {
val titleData = MutableLiveData<String>()
val coverData = MutableLiveData<String>()
fun initData(intent: Intent) = AudioPlay.apply {
execute {
val bookUrl = intent.getStringExtra("bookUrl") ?: return@execute
val book = appDb.bookDao.getBook(bookUrl) ?: return@execute
inBookshelf = intent.getBooleanExtra("inBookshelf", true)
initBook(book)
}.onFinally {
saveRead()
}
}
private suspend fun initBook(book: Book) {
val isSameBook = AudioPlay.book?.bookUrl == book.bookUrl
if (isSameBook) {
AudioPlay.upData(book)
} else {
AudioPlay.resetData(book)
}
titleData.postValue(book.name)
coverData.postValue(book.getDisplayCover())
if (book.tocUrl.isEmpty() && !loadBookInfo(book)) {
return
}
if (AudioPlay.chapterSize == 0 && !loadChapterList(book)) {
return
}
}
private suspend fun loadBookInfo(book: Book): Boolean {
val bookSource = AudioPlay.bookSource ?: return true
try {
WebBook.getBookInfoAwait(bookSource, book)
return true
} catch (e: Exception) {
AppLog.put("详情页出错: ${e.localizedMessage}", e, true)
return false
}
}
private suspend fun loadChapterList(book: Book): Boolean {
val bookSource = AudioPlay.bookSource ?: return true
try {
val oldBook = book.copy()
val cList = WebBook.getChapterListAwait(bookSource, book).getOrThrow()
if (oldBook.bookUrl == book.bookUrl) {
appDb.bookDao.update(book)
} else {
appDb.bookDao.insert(book)
}
appDb.bookChapterDao.delByBook(book.bookUrl)
appDb.bookChapterDao.insert(*cList.toTypedArray())
AudioPlay.chapterSize = cList.size
AudioPlay.simulatedChapterSize = book.simulatedTotalChapterNum()
AudioPlay.upDurChapter()
return true
} catch (e: Exception) {
context.toastOnUi(R.string.error_load_toc)
return false
}
}
fun upSource() {
execute {
val book = AudioPlay.book ?: return@execute
AudioPlay.bookSource = book.getBookSource()
}
}
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
execute {
AudioPlay.book?.migrateTo(book, toc)
book.removeType(BookType.updateError)
AudioPlay.book?.delete()
appDb.bookDao.insert(book)
AudioPlay.book = book
AudioPlay.bookSource = source
appDb.bookChapterDao.insert(*toc.toTypedArray())
AudioPlay.upDurChapter()
}.onFinally {
postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
}
}
fun removeFromBookshelf(success: (() -> Unit)?) {
execute {
AudioPlay.book?.let {
appDb.bookDao.delete(it)
}
}.onSuccess {
success?.invoke()
}
}
}
@@ -0,0 +1,53 @@
package io.legato.kazusa.ui.book.audio
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import io.legato.kazusa.R
import io.legato.kazusa.databinding.PopupSeekBarBinding
import io.legato.kazusa.model.AudioPlay
import io.legato.kazusa.service.AudioPlayService
class TimerSliderPopup(private val context: Context) :
PopupWindow(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) {
private val binding = PopupSeekBarBinding.inflate(LayoutInflater.from(context))
init {
contentView = binding.root
isTouchable = true
isOutsideTouchable = false
isFocusable = true
// 初始化 slider 值
binding.slider.valueFrom = 1f
binding.slider.valueTo = 180f
binding.slider.stepSize = 1f
binding.slider.value = AudioPlayService.timeMinute.toFloat()
setSliderTextValue(AudioPlayService.timeMinute)
binding.slider.addOnChangeListener { _, value, fromUser ->
setSliderTextValue(value.toInt())
if (fromUser) {
AudioPlay.setTimer(value.toInt())
}
}
}
override fun showAsDropDown(anchor: View?, xoff: Int, yoff: Int, gravity: Int) {
super.showAsDropDown(anchor, xoff, yoff, gravity)
binding.slider.value = AudioPlayService.timeMinute.toFloat()
}
override fun showAtLocation(parent: View?, gravity: Int, x: Int, y: Int) {
super.showAtLocation(parent, gravity, x, y)
binding.slider.value = AudioPlayService.timeMinute.toFloat()
}
private fun setSliderTextValue(value: Int) {
binding.tvSeekValue.text = context.getString(R.string.timer_m, value)
}
}
@@ -0,0 +1,82 @@
package io.legato.kazusa.ui.book.bookmark
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Bookmark
import io.legato.kazusa.databinding.ActivityAllBookmarkBinding
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
/**
* 所有书签
*/
class AllBookmarkActivity : VMBaseActivity<ActivityAllBookmarkBinding, AllBookmarkViewModel>(),
BookmarkAdapter.Callback {
override val viewModel by viewModels<AllBookmarkViewModel>()
override val binding by viewBinding(ActivityAllBookmarkBinding::inflate)
private val adapter by lazy {
BookmarkAdapter(this, this)
}
private val exportDir = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
when (it.requestCode) {
1 -> viewModel.exportBookmark(uri)
2 -> viewModel.exportBookmarkMd(uri)
}
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
initView()
lifecycleScope.launch {
appDb.bookmarkDao.flowAll().catch {
AppLog.put("所有书签界面获取数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).collect {
adapter.setItems(it)
}
}
}
private fun initView() {
binding.recyclerView.addItemDecoration(BookmarkDecoration(this,adapter))
binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.bookmark, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_export -> exportDir.launch {
requestCode = 1
}
R.id.menu_export_md -> exportDir.launch {
requestCode = 2
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onItemClick(bookmark: Bookmark, position: Int) {
showDialogFragment(BookmarkDialog(bookmark, position))
}
}
@@ -0,0 +1,67 @@
package io.legato.kazusa.ui.book.bookmark
import android.app.Application
import android.net.Uri
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.createFileIfNotExist
import io.legato.kazusa.utils.openOutputStream
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.writeToOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class AllBookmarkViewModel(application: Application) : BaseViewModel(application) {
/**
* 导出书签
*/
fun exportBookmark(treeUri: Uri) {
execute {
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
val fileName = "bookmark-${dateFormat.format(Date())}.json"
val dirDoc = FileDoc.fromUri(treeUri, true)
dirDoc.createFileIfNotExist(fileName).openOutputStream().getOrThrow().use {
GSON.writeToOutputStream(it, appDb.bookmarkDao.all)
}
}.onError {
AppLog.put("导出失败\n${it.localizedMessage}", it, true)
}.onSuccess {
context.toastOnUi("导出成功")
}
}
fun exportBookmarkMd(treeUri: Uri) {
execute {
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
val fileName = "bookmark-${dateFormat.format(Date())}.md"
val dirDoc = FileDoc.fromUri(treeUri, true)
val fileDoc = dirDoc.createFileIfNotExist(fileName).openOutputStream().getOrThrow()
fileDoc.use { outputStream ->
var name = ""
var author = ""
appDb.bookmarkDao.all.forEach {
if (it.bookName != name && it.bookAuthor != author) {
name = it.bookName
author = it.bookAuthor
outputStream.write("## ${it.bookName} ${it.bookAuthor}\n\n".toByteArray())
}
outputStream.write("#### ${it.chapterName}\n\n".toByteArray())
outputStream.write("###### 原文\n ${it.bookText}\n\n".toByteArray())
outputStream.write("###### 摘要\n ${it.content}\n\n".toByteArray())
}
}
}.onError {
AppLog.put("导出失败\n${it.localizedMessage}", it, true)
}.onSuccess {
context.toastOnUi("导出成功")
}
}
}
@@ -0,0 +1,60 @@
package io.legato.kazusa.ui.book.bookmark
import android.content.Context
import android.view.ViewGroup
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.entities.Bookmark
import io.legato.kazusa.databinding.ItemBookmarkBinding
import io.legato.kazusa.utils.gone
import splitties.views.onClick
class BookmarkAdapter(context: Context, val callback: Callback) :
RecyclerAdapter<Bookmark, ItemBookmarkBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemBookmarkBinding {
return ItemBookmarkBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBookmarkBinding,
item: Bookmark,
payloads: MutableList<Any>
) {
binding.tvChapterName.text = item.chapterName
binding.tvBookText.gone(item.bookText.isEmpty())
binding.tvBookText.text = item.bookText
binding.tvContent.gone(item.content.isEmpty())
binding.tvContent.text = item.content
}
override fun registerListener(holder: ItemViewHolder, binding: ItemBookmarkBinding) {
binding.root.onClick {
getItemByLayoutPosition(holder.layoutPosition)?.let {
callback.onItemClick(it, holder.layoutPosition)
}
}
}
fun getHeaderText(position: Int): String {
return with(getItem(position)) {
"${this?.bookName ?: ""}(${this?.bookAuthor ?: ""})"
}
}
fun isItemHeader(position: Int): Boolean {
if (position == 0) return true
val lastItem = getItem(position - 1)
val curItem = getItem(position)
return !(lastItem?.bookName == curItem?.bookName
&& lastItem?.bookAuthor == curItem?.bookAuthor)
}
interface Callback {
fun onItemClick(bookmark: Bookmark, position: Int)
}
}
@@ -0,0 +1,117 @@
package io.legato.kazusa.ui.book.bookmark
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.text.TextPaint
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.backgroundColor
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.spToPx
import kotlin.math.min
class BookmarkDecoration(
private val context: Context,
val adapter: BookmarkAdapter) : RecyclerView.ItemDecoration() {
private val headerLeft = 16f.dpToPx()
private val headerHeight = 32f.dpToPx()
private val headerPaint = Paint().apply {
color = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurfaceContainer, Color.GRAY)
}
private val textPaint = TextPaint().apply {
textSize = 16f.spToPx()
color = MaterialColors.getColor(context, com.google.android.material.R.attr.colorPrimary, Color.BLACK)
isAntiAlias = true
}
private val textRect = Rect()
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val count = parent.childCount
for (i in 0 until count) {
val view = parent.getChildAt(i)
val position = parent.getChildLayoutPosition(view)
val isHeader = adapter.isItemHeader(position)
if (isHeader) {
c.drawRect(
0f,
view.top - headerHeight,
parent.width.toFloat(),
view.top.toFloat(),
headerPaint
)
val headerText = adapter.getHeaderText(position)
textPaint.getTextBounds(headerText, 0, headerText.length, textRect)
c.drawText(
headerText,
headerLeft,
(view.top - headerHeight) + headerHeight / 2 + textRect.height() / 2,
textPaint
)
}
}
}
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val position = (parent.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
val view = parent.findViewHolderForAdapterPosition(position)?.itemView ?: return
val isHeader = adapter.isItemHeader(position + 1)
val headerText = adapter.getHeaderText(position)
if (isHeader) {
val bottom = min(headerHeight.toInt(), view.bottom)
c.drawRect(
0f,
view.top - headerHeight,
parent.width.toFloat(),
bottom.toFloat(),
headerPaint
)
textPaint.getTextBounds(headerText, 0, headerText.length, textRect)
c.drawText(
headerText,
headerLeft,
headerHeight / 2 + textRect.height() / 2 - (headerHeight - bottom),
textPaint
)
} else {
c.drawRect(
0f,
0f,
parent.width.toFloat(),
headerHeight,
headerPaint
)
textPaint.getTextBounds(headerText, 0, headerText.length, textRect)
c.drawText(
headerText,
headerLeft,
headerHeight / 2 + textRect.height() / 2,
textPaint
)
}
c.save()
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildLayoutPosition(view)
val isHeader = adapter.isItemHeader(position)
if (isHeader) {
outRect.top = headerHeight.toInt()
}
}
}
@@ -0,0 +1,83 @@
package io.legato.kazusa.ui.book.bookmark
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseBottomSheetDialogFragment
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Bookmark
import io.legato.kazusa.databinding.DialogBookmarkBinding
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class BookmarkDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_bookmark) {
constructor(bookmark: Bookmark, editPos: Int = -1) : this() {
arguments = Bundle().apply {
putInt("editPos", editPos)
putParcelable("bookmark", bookmark)
}
}
private val binding by viewBinding(DialogBookmarkBinding::bind)
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
val arguments = arguments ?: let {
dismiss()
return
}
//@Suppress("DEPRECATION")
val bookmark = arguments.getParcelable<Bookmark>("bookmark")
bookmark ?: let {
dismiss()
return
}
val editPos = arguments.getInt("editPos", -1)
binding.btnDelete.visible(editPos >= 0) // 替换 tvFooterLeft
binding.run {
tvChapterName.text = bookmark.chapterName
editBookText.setText(bookmark.bookText)
editContent.setText(bookmark.content)
btnCancel.setOnClickListener {
dismiss()
}
btnOk.setOnClickListener {
bookmark.bookText = editBookText.text?.toString() ?: ""
bookmark.content = editContent.text?.toString() ?: ""
lifecycleScope.launch {
withContext(IO) {
appDb.bookmarkDao.insert(bookmark)
}
dismiss()
}
}
btnDelete.setOnClickListener {
lifecycleScope.launch {
withContext(IO) {
appDb.bookmarkDao.delete(bookmark)
}
dismiss()
}
}
}
}
}
@@ -0,0 +1,568 @@
package io.legato.kazusa.ui.book.cache
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.textfield.TextInputLayout
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.AppConst.charsets
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.constant.IntentAction
import io.legato.kazusa.data.AppDatabase
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookGroup
import io.legato.kazusa.databinding.ActivityCacheBookBinding
import io.legato.kazusa.databinding.DialogEditTextBinding
import io.legato.kazusa.databinding.DialogSelectSectionExportBinding
import io.legato.kazusa.help.book.getExportFileName
import io.legato.kazusa.help.book.isAudio
import io.legato.kazusa.help.book.tryParesExportFileName
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.SelectItem
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.lib.dialogs.selector
import io.legato.kazusa.model.CacheBook
import io.legato.kazusa.service.ExportBookService
import io.legato.kazusa.ui.about.AppLogDialog
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.utils.ACache
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.applyOpenTint
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.checkWrite
import io.legato.kazusa.utils.cnCompare
import io.legato.kazusa.utils.enableCustomExport
import io.legato.kazusa.utils.flowWithLifecycleAndDatabaseChange
import io.legato.kazusa.utils.iconItemOnLongClick
import io.legato.kazusa.utils.isContentScheme
import io.legato.kazusa.utils.observeEvent
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.startService
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.verificationField
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.init.appCtx
import kotlin.math.max
/**
* cache/download 缓存界面
*/
class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>(),
PopupMenu.OnMenuItemClickListener,
CacheAdapter.CallBack {
override val binding by viewBinding(ActivityCacheBookBinding::inflate)
override val viewModel by viewModels<CacheViewModel>()
private val exportBookPathKey = "exportBookPath"
private val exportTypes = arrayListOf("txt", "epub")
private val layoutManager by lazy { LinearLayoutManager(this) }
private val adapter by lazy { CacheAdapter(this, this) }
private var booksFlowJob: Job? = null
private var menu: Menu? = null
private val groupList: ArrayList<BookGroup> = arrayListOf()
private var groupId: Long = -1
private val exportDir = registerForActivityResult(HandleFileContract()) { result ->
var isReadyPath = false
var dirPath = ""
result.uri?.let { uri ->
if (uri.isContentScheme()) {
ACache.get().put(exportBookPathKey, uri.toString())
dirPath = uri.toString()
isReadyPath = true
} else {
uri.path?.let { path ->
ACache.get().put(exportBookPathKey, path)
dirPath = path
isReadyPath = true
}
}
}
if (!isReadyPath) {
return@registerForActivityResult
}
if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub
configExportSection(dirPath, result.requestCode)
} else {
startExport(dirPath, result.requestCode)
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
groupId = intent.getLongExtra("groupId", -1)
lifecycleScope.launch {
binding.titleBar.subtitle = withContext(IO) {
appDb.bookGroupDao.getByID(groupId)?.groupName
?: getString(R.string.no_group)
}
}
initRecyclerView()
initGroupData()
initBookData()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_cache, menu)
menu.iconItemOnLongClick(R.id.menu_download) {
PopupMenu(this, it).apply {
inflate(R.menu.book_cache_download)
this.menu.applyOpenTint(this@CacheActivity)
setOnMenuItemClickListener(this@CacheActivity)
}.show()
}
return super.onCompatCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
this.menu = menu
upMenu()
return super.onPrepareOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_enable_replace)?.isChecked = AppConfig.exportUseReplace
// 菜单打开时读取状态[enableCustomExport]
menu.findItem(R.id.menu_enable_custom_export)?.isChecked = AppConfig.enableCustomExport
menu.findItem(R.id.menu_export_no_chapter_name)?.isChecked = AppConfig.exportNoChapterName
menu.findItem(R.id.menu_export_web_dav)?.isChecked = AppConfig.exportToWebDav
menu.findItem(R.id.menu_export_pics_file)?.isChecked = AppConfig.exportPictureFile
menu.findItem(R.id.menu_parallel_export)?.isChecked = AppConfig.parallelExportBook
menu.findItem(R.id.menu_export_type)?.title =
"${getString(R.string.export_type)}(${getTypeName()})"
menu.findItem(R.id.menu_export_charset)?.title =
"${getString(R.string.export_charset)}(${AppConfig.exportCharset})"
return super.onMenuOpened(featureId, menu)
}
private fun upMenu() {
menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu ->
subMenu.removeGroup(R.id.menu_group)
groupList.forEach { bookGroup ->
subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName)
}
}
}
/**
* 菜单按下回调
*/
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_download,
R.id.menu_download_after -> {
if (!CacheBook.isRun) {
adapter.getItems().forEach { book ->
CacheBook.start(
this@CacheActivity,
book,
book.durChapterIndex,
book.lastChapterIndex
)
}
} else {
CacheBook.stop(this@CacheActivity)
}
}
R.id.menu_download_all -> {
if (!CacheBook.isRun) {
adapter.getItems().forEach { book ->
CacheBook.start(
this@CacheActivity,
book,
0,
book.lastChapterIndex
)
}
} else {
CacheBook.stop(this@CacheActivity)
}
}
R.id.menu_export_all -> exportAll()
R.id.menu_enable_replace -> AppConfig.exportUseReplace = !item.isChecked
// 更改菜单状态[enableCustomExport]
R.id.menu_enable_custom_export -> AppConfig.enableCustomExport = !item.isChecked
R.id.menu_export_no_chapter_name -> AppConfig.exportNoChapterName = !item.isChecked
R.id.menu_export_web_dav -> AppConfig.exportToWebDav = !item.isChecked
R.id.menu_export_pics_file -> AppConfig.exportPictureFile = !item.isChecked
R.id.menu_parallel_export -> AppConfig.parallelExportBook = !item.isChecked
R.id.menu_export_folder -> {
selectExportFolder(-1)
}
R.id.menu_export_file_name -> alertExportFileName()
R.id.menu_export_type -> showExportTypeConfig()
R.id.menu_export_charset -> showCharsetConfig()
R.id.menu_log -> showDialogFragment<AppLogDialog>()
else -> if (item.groupId == R.id.menu_group) {
binding.titleBar.subtitle = item.title
groupId = appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0
initBookData()
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return onCompatOptionsItemSelected(item)
}
private fun initRecyclerView() {
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding()
}
private fun initBookData() {
booksFlowJob?.cancel()
booksFlowJob = lifecycleScope.launch {
appDb.bookDao.flowByGroup(groupId).map { books ->
val booksDownload = books.filter {
!it.isAudio
}
when (AppConfig.getBookSortByGroupId(groupId)) {
1 -> booksDownload.sortedByDescending { it.latestChapterTime }
2 -> booksDownload.sortedWith { o1, o2 ->
o1.name.cnCompare(o2.name)
}
3 -> booksDownload.sortedBy { it.order }
4 -> booksDownload.sortedByDescending {
max(it.latestChapterTime, it.durChapterTime)
}
else -> booksDownload.sortedByDescending { it.durChapterTime }
}
}.flowWithLifecycleAndDatabaseChange(
lifecycle, table = AppDatabase.BOOK_TABLE_NAME
).catch {
AppLog.put("缓存管理界面获取书籍列表失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect { books ->
adapter.setItems(books)
viewModel.loadCacheFiles(books)
}
}
}
@SuppressLint("NotifyDataSetChanged")
private fun initGroupData() {
lifecycleScope.launch {
appDb.bookGroupDao.flowAll().catch {
AppLog.put("缓存管理界面获取分组数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
groupList.clear()
groupList.addAll(it)
adapter.notifyDataSetChanged()
upMenu()
}
}
}
private fun notifyItemChanged(bookUrl: String) {
kotlin.runCatching {
adapter.getItems().forEachIndexed { index, book ->
if (bookUrl == book.bookUrl) {
adapter.notifyItemChanged(index, true)
return
}
}
}
}
override fun observeLiveBus() {
viewModel.upAdapterLiveData.observe(this) {
notifyItemChanged(it)
}
observeEvent<String>(EventBus.EXPORT_BOOK) {
notifyItemChanged(it)
}
observeEvent<String>(EventBus.UP_DOWNLOAD) {
if (!CacheBook.isRun) {
menu?.findItem(R.id.menu_download)?.let { item ->
item.setIcon(R.drawable.ic_play_24dp)
item.setTitle(R.string.download_start)
}
menu?.applyTint(this)
} else {
menu?.findItem(R.id.menu_download)?.let { item ->
item.setIcon(R.drawable.ic_stop_black_24dp)
item.setTitle(R.string.stop)
}
menu?.applyTint(this)
}
notifyItemChanged(it)
}
observeEvent<Pair<Book, BookChapter>>(EventBus.SAVE_CONTENT) { (book, chapter) ->
viewModel.cacheChapters[book.bookUrl]?.add(chapter.url)
notifyItemChanged(book.bookUrl)
}
}
override fun export(position: Int) {
val path = ACache.get().getAsString(exportBookPathKey)
lifecycleScope.launch {
if (path.isNullOrEmpty() ||
withContext(IO) { !FileDoc.fromDir(path).checkWrite() }
) {
selectExportFolder(position)
} else if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub
configExportSection(path, position)
} else {
startExport(path, position)
}
}
}
private fun exportAll() {
val path = ACache.get().getAsString(exportBookPathKey)
if (path.isNullOrEmpty()) {
selectExportFolder(-10)
} else {
startExport(path, -10)
}
}
/**
* 配置自定义导出对话框
*
* @param path 导出路径
* @param position book位置
* @author Discut
* @since 1.0.0
*/
private fun configExportSection(path: String, position: Int) {
val alertBinding = DialogSelectSectionExportBinding.inflate(layoutInflater)
.apply {
fun verifyExportFileNameJsStr(js: String): Boolean {
return tryParesExportFileName(js) && etEpubFilename.text.toString()
.isNotEmpty()
}
fun enableLyEtEpubFilenameIcon() {
lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_CUSTOM
lyEtEpubFilename.setEndIconOnClickListener {
adapter.getItem(position)?.run {
lyEtEpubFilename.helperText =
if (verifyExportFileNameJsStr(etEpubFilename.text.toString()))
"${resources.getString(R.string.result_analyzed)}: ${
getExportFileName(
"epub",
1,
etEpubFilename.text.toString()
)
}"
else "Error"
} ?: run {
lyEtEpubFilename.helperText = "Error"
AppLog.put("未找到书籍,position is $position")
}
}
}
etEpubSize.setText("1")
// lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE
etEpubFilename.text?.append(AppConfig.episodeExportFileName)
// 存储解析文件名的jsStr
etEpubFilename.let {
it.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus)
return@setOnFocusChangeListener
it.text?.run {
if (verifyExportFileNameJsStr(toString())) {
AppConfig.episodeExportFileName = toString()
}
}
}
}
tvAllExport.setOnClickListener {
cbAllExport.callOnClick()
}
tvSelectExport.setOnClickListener {
cbSelectExport.callOnClick()
}
cbSelectExport.onCheckedChangeListener = { _, isChecked ->
if (isChecked) {
etEpubSize.isEnabled = true
etInputScope.isEnabled = true
etEpubFilename.isEnabled = true
enableLyEtEpubFilenameIcon()
cbAllExport.isChecked = false
}
}
cbAllExport.onCheckedChangeListener = { _, isChecked ->
if (isChecked) {
etEpubSize.isEnabled = false
etInputScope.isEnabled = false
etEpubFilename.isEnabled = false
lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE
cbSelectExport.isChecked = false
}
}
etInputScope.onFocusChangeListener =
View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
etInputScope.hint = "1-5,8,10-18"
} else {
etInputScope.hint = ""
}
}
// 默认选择自定义导出
cbSelectExport.callOnClick()
}
val alertDialog = alert(titleResource = R.string.select_section_export) {
customView { alertBinding.root }
positiveButton(R.string.ok)
cancelButton()
}
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
alertBinding.apply {
if (cbAllExport.isChecked) {
startExport(path, position)
alertDialog.hide()
return@apply
}
val epubScope = etInputScope.text.toString()
if (!verificationField(epubScope)) {
etInputScope.error = appCtx.getString(R.string.error_scope_input)//"请输入正确的范围"
return@apply
}
etInputScope.error = null
val epubSize = etEpubSize.text.toString().toIntOrNull() ?: 1
adapter.getItem(position)?.let { book ->
startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", "epub")
putExtra("exportPath", path)
putExtra("epubSize", epubSize)
putExtra("epubScope", epubScope)
}
}
alertDialog.hide()
}
}
}
private fun selectExportFolder(exportPosition: Int) {
val default = arrayListOf<SelectItem<Int>>()
val path = ACache.get().getAsString(exportBookPathKey)
if (!path.isNullOrEmpty()) {
default.add(SelectItem(path, -1))
}
exportDir.launch {
otherActions = default
requestCode = exportPosition
}
}
private fun startExport(path: String, exportPosition: Int) {
val exportType = when (AppConfig.exportType) {
1 -> "epub"
else -> "txt"
}
if (exportPosition == -10) {
if (adapter.getItems().isNotEmpty()) {
adapter.getItems().forEach { book ->
startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", exportType)
putExtra("exportPath", path)
}
}
} else {
toastOnUi(R.string.no_book)
}
} else if (exportPosition >= 0) {
adapter.getItem(exportPosition)?.let { book ->
startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", exportType)
putExtra("exportPath", path)
}
}
}
}
@SuppressLint("SetTextI18n")
private fun alertExportFileName() {
alert(R.string.export_file_name) {
val message = "Variable: name, author."
setMessage(message)
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "file name js"
editView.setText(AppConfig.bookExportFileName)
}
customView { alertBinding.root }
okButton {
AppConfig.bookExportFileName = alertBinding.editView.text?.toString()
}
cancelButton()
}
}
private fun getTypeName(): String {
return exportTypes.getOrElse(AppConfig.exportType) {
exportTypes[0]
}
}
private fun showExportTypeConfig() {
selector(R.string.export_type, exportTypes) { _, i ->
AppConfig.exportType = i
}
}
private fun showCharsetConfig() {
alert(R.string.set_charset) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "charset name"
editView.setFilterValues(charsets)
editView.setText(AppConfig.exportCharset)
}
customView { alertBinding.root }
okButton {
AppConfig.exportCharset = alertBinding.editView.text?.toString() ?: "UTF-8"
}
cancelButton()
}
}
override val cacheChapters: HashMap<String, HashSet<String>>
get() = viewModel.cacheChapters
override fun exportProgress(bookUrl: String): Int? {
return ExportBookService.exportProgress[bookUrl]
}
override fun exportMsg(bookUrl: String): String? {
return ExportBookService.exportMsg[bookUrl]
}
}
@@ -0,0 +1,142 @@
package io.legato.kazusa.ui.book.cache
import android.content.Context
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil
import com.google.android.material.button.MaterialButton
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.DiffRecyclerAdapter
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.databinding.ItemDownloadBinding
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.model.CacheBook
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.visible
class CacheAdapter(context: Context, private val callBack: CallBack) :
DiffRecyclerAdapter<Book, ItemDownloadBinding>(context) {
override val diffItemCallback: DiffUtil.ItemCallback<Book>
get() = object : DiffUtil.ItemCallback<Book>() {
override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
return oldItem.bookUrl == newItem.bookUrl
}
override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
return oldItem.name == newItem.name
&& oldItem.author == newItem.author
}
}
override fun getViewBinding(parent: ViewGroup): ItemDownloadBinding {
return ItemDownloadBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemDownloadBinding,
item: Book,
payloads: MutableList<Any>
) {
binding.run {
if (payloads.isEmpty()) {
tvName.text = item.name
tvAuthor.text = context.getString(R.string.author_show, item.getRealAuthor())
if (item.isLocal) {
tvDownload.setText(R.string.local_book)
} else {
val cs = callBack.cacheChapters[item.bookUrl]
if (cs == null) {
tvDownload.setText(R.string.loading)
} else {
tvDownload.text =
context.getString(
R.string.download_count,
cs.size,
item.totalChapterNum
)
}
}
} else {
if (item.isLocal) {
tvDownload.setText(R.string.local_book)
} else {
val cacheSize = callBack.cacheChapters[item.bookUrl]?.size ?: 0
tvDownload.text =
context.getString(R.string.download_count, cacheSize, item.totalChapterNum)
}
}
upDownloadIv(ivDownload, item)
upExportInfo(tvMsg, progressExport, item)
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemDownloadBinding) {
binding.run {
ivDownload.setOnClickListener {
getItem(holder.layoutPosition)?.let { book ->
CacheBook.cacheBookMap[book.bookUrl]?.let {
if (!it.isStop()) {
CacheBook.remove(context, book.bookUrl)
} else {
CacheBook.start(context, book, 0, book.lastChapterIndex)
}
} ?: let {
CacheBook.start(context, book, 0, book.lastChapterIndex)
}
}
}
tvExport.setOnClickListener {
callBack.export(holder.layoutPosition)
}
}
}
private fun upDownloadIv(button: MaterialButton, book: Book) {
if (book.isLocal) {
button.gone()
} else {
button.visible()
CacheBook.cacheBookMap[book.bookUrl]?.let {
if (!it.isStop()) {
button.icon = ContextCompat.getDrawable(context, R.drawable.ic_stop_black_24dp)
} else {
button.icon = ContextCompat.getDrawable(context, R.drawable.ic_play_24dp)
}
} ?: run {
button.icon = ContextCompat.getDrawable(context, R.drawable.ic_play_24dp)
}
}
}
private fun upExportInfo(msgView: TextView, progressView: ProgressBar, book: Book) {
val msg = callBack.exportMsg(book.bookUrl)
if (msg != null) {
msgView.text = msg
msgView.visible()
progressView.gone()
return
}
msgView.gone()
val progress = callBack.exportProgress(book.bookUrl)
if (progress != null) {
progressView.max = book.totalChapterNum
progressView.progress = progress
progressView.visible()
return
}
progressView.gone()
}
interface CallBack {
val cacheChapters: HashMap<String, HashSet<String>>
fun export(position: Int)
fun exportProgress(bookUrl: String): Int?
fun exportMsg(bookUrl: String): String?
}
}
@@ -0,0 +1,46 @@
package io.legato.kazusa.ui.book.cache
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.utils.sendValue
import kotlinx.coroutines.ensureActive
import kotlin.collections.set
class CacheViewModel(application: Application) : BaseViewModel(application) {
val upAdapterLiveData = MutableLiveData<String>()
private var loadChapterCoroutine: Coroutine<Unit>? = null
val cacheChapters = hashMapOf<String, HashSet<String>>()
fun loadCacheFiles(books: List<Book>) {
loadChapterCoroutine?.cancel()
loadChapterCoroutine = execute {
books.forEach { book ->
if (!book.isLocal && !cacheChapters.contains(book.bookUrl)) {
val chapterCaches = hashSetOf<String>()
val cacheNames = BookHelp.getChapterFiles(book)
if (cacheNames.isNotEmpty()) {
appDb.bookChapterDao.getChapterList(book.bookUrl).also {
book.totalChapterNum = it.size
}.forEach { chapter ->
if (cacheNames.contains(chapter.getFileName()) || chapter.isVolume) {
chapterCaches.add(chapter.url)
}
}
}
cacheChapters[book.bookUrl] = chapterCaches
upAdapterLiveData.sendValue(book.bookUrl)
}
ensureActive()
}
}
}
}
@@ -0,0 +1,117 @@
package io.legato.kazusa.ui.book.changecover
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.databinding.DialogChangeCoverBinding
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
/**
* 换封面
*/
class ChangeCoverDialog() : BaseDialogFragment(R.layout.dialog_change_cover),
Toolbar.OnMenuItemClickListener,
CoverAdapter.CallBack {
constructor(name: String, author: String) : this() {
arguments = Bundle().apply {
putString("name", name)
putString("author", author)
}
}
private val binding by viewBinding(DialogChangeCoverBinding::bind)
private val callBack: CallBack? get() = activity as? CallBack
private val viewModel: ChangeCoverViewModel by viewModels()
private val adapter by lazy { CoverAdapter(requireContext(), this) }
private val startStopMenuItem: MenuItem?
get() = binding.toolBar.menu.findItem(R.id.menu_start_stop)
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.change_cover_source)
viewModel.initData(arguments)
initMenu()
initView()
initData()
}
private fun initMenu() {
binding.toolBar.inflateMenu(R.menu.change_cover)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
}
private fun initView() {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 3)
binding.recyclerView.adapter = adapter
}
private fun initData() {
lifecycleScope.launch {
repeatOnLifecycle(STARTED) {
viewModel.dataFlow.conflate().collect {
adapter.setItems(it)
delay(1000)
}
}
}
}
override fun observeLiveBus() {
super.observeLiveBus()
viewModel.searchStateData.observe(viewLifecycleOwner) {
binding.refreshProgressBar.isAutoLoading = it
if (it) {
startStopMenuItem?.let { item ->
item.setIcon(R.drawable.ic_stop_black_24dp)
item.setTitle(R.string.stop)
}
} else {
startStopMenuItem?.let { item ->
item.setIcon(R.drawable.ic_refresh)
item.setTitle(R.string.refresh)
}
}
binding.toolBar.menu.applyTint(requireContext())
}
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_start_stop -> viewModel.startOrStopSearch()
}
return false
}
override fun changeTo(coverUrl: String) {
callBack?.coverChangeTo(coverUrl)
dismissAllowingStateLoss()
}
interface CallBack {
fun coverChangeTo(coverUrl: String)
}
}
@@ -0,0 +1,170 @@
package io.legato.kazusa.ui.book.changecover
import android.app.Application
import android.os.Bundle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.BookSourcePart
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.mapParallelSafe
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.util.Collections
import java.util.concurrent.Executors
import kotlin.math.min
class ChangeCoverViewModel(application: Application) : BaseViewModel(application) {
private val threadCount = AppConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null
private var searchSuccess: ((SearchBook) -> Unit)? = null
private var upAdapter: (() -> Unit)? = null
private var bookSourceParts = arrayListOf<BookSourcePart>()
private val defaultCover by lazy {
listOf(
SearchBook(
originName = "默认封面",
name = name,
author = author,
coverUrl = "use_default_cover"
)
)
}
private var task: Job? = null
val searchStateData = MutableLiveData<Boolean>()
var name: String = ""
var author: String = ""
val searchBooks: MutableList<SearchBook> = Collections.synchronizedList(arrayListOf())
val dataFlow = callbackFlow {
searchSuccess = { searchBook ->
if (!searchBooks.contains(searchBook)) {
searchBooks.add(searchBook)
trySend(defaultCover + searchBooks.sortedBy { it.originOrder })
}
}
upAdapter = {
trySend(defaultCover + searchBooks.sortedBy { it.originOrder })
}
appDb.searchBookDao.getEnableHasCover(name, author).let {
searchBooks.addAll(it)
trySend(defaultCover + searchBooks.toList())
}
if (searchBooks.size <= 1) {
startSearch()
}
awaitClose {
searchBooks.clear()
searchSuccess = null
upAdapter = null
}
}.flowOn(IO)
fun initData(arguments: Bundle?) {
arguments?.let { bundle ->
bundle.getString("name")?.let {
name = it
}
bundle.getString("author")?.let {
author = it.replace(AppPattern.authorRegex, "")
}
}
}
private fun initSearchPool() {
searchPool = Executors
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
}
private fun startSearch() {
execute {
stopSearch()
searchBooks.clear()
upAdapter?.invoke()
bookSourceParts.clear()
bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart)
initSearchPool()
search()
}
}
private fun search() {
task = viewModelScope.launch(searchPool!!) {
flow {
for (bs in bookSourceParts) {
bs.getBookSource()?.let {
emit(it)
}
}
}.onStart {
searchStateData.postValue(true)
}.mapParallelSafe(threadCount) {
withTimeout(60000L) {
search(it)
}
}.onCompletion {
searchStateData.postValue(false)
}.catch {
AppLog.put("封面换源搜索出错\n${it.localizedMessage}", it)
}.collect()
}
}
private suspend fun search(source: BookSource) {
if (source.getSearchRule().coverUrl.isNullOrBlank()) {
return
}
val searchBook = WebBook.searchBookAwait(
source, name,
shouldBreak = { it > 0 }).firstOrNull() ?: return
if (searchBook.name == name && searchBook.author == author
&& !searchBook.coverUrl.isNullOrEmpty()
) {
appDb.searchBookDao.insert(searchBook)
searchSuccess?.invoke(searchBook)
}
}
fun startOrStopSearch() {
if (task == null || !task!!.isActive) {
startSearch()
} else {
stopSearch()
}
}
private fun stopSearch() {
task?.cancel()
searchPool?.close()
searchStateData.postValue(false)
}
override fun onCleared() {
super.onCleared()
searchPool?.close()
}
}
@@ -0,0 +1,55 @@
package io.legato.kazusa.ui.book.changecover
import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import io.legato.kazusa.base.adapter.DiffRecyclerAdapter
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.ItemCoverBinding
class CoverAdapter(context: Context, val callBack: CallBack) :
DiffRecyclerAdapter<SearchBook, ItemCoverBinding>(context) {
override val diffItemCallback: DiffUtil.ItemCallback<SearchBook>
get() = object : DiffUtil.ItemCallback<SearchBook>() {
override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean {
return oldItem.bookUrl == newItem.bookUrl
}
override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean {
return oldItem.originName == newItem.originName
&& oldItem.coverUrl == newItem.coverUrl
}
}
override fun getViewBinding(parent: ViewGroup): ItemCoverBinding {
return ItemCoverBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemCoverBinding,
item: SearchBook,
payloads: MutableList<Any>
) = binding.run {
ivCover.load(item.coverUrl, item.name, item.author, false, item.origin)
tvSource.text = item.originName
}
override fun registerListener(holder: ItemViewHolder, binding: ItemCoverBinding) {
holder.itemView.apply {
setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.changeTo(it.coverUrl ?: "")
}
}
}
}
interface CallBack {
fun changeTo(coverUrl: String)
}
}
@@ -0,0 +1,236 @@
package io.legato.kazusa.ui.book.changesource
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.DiffRecyclerAdapter
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.ItemChangeSourceBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.utils.getCompatColor
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.visible
import splitties.init.appCtx
import splitties.views.onLongClick
class ChangeBookSourceAdapter(
context: Context,
val viewModel: ChangeBookSourceViewModel,
val callBack: CallBack
) : DiffRecyclerAdapter<SearchBook, ItemChangeSourceBinding>(context) {
override val diffItemCallback = object : DiffUtil.ItemCallback<SearchBook>() {
override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean {
return oldItem.bookUrl == newItem.bookUrl
}
override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean {
return oldItem.originName == newItem.originName
&& oldItem.getDisplayLastChapterTitle() == newItem.getDisplayLastChapterTitle()
&& oldItem.chapterWordCountText == newItem.chapterWordCountText
&& oldItem.respondTime == newItem.respondTime
}
}
override fun getViewBinding(parent: ViewGroup): ItemChangeSourceBinding {
return ItemChangeSourceBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemChangeSourceBinding,
item: SearchBook,
payloads: MutableList<Any>
) {
binding.apply {
if (payloads.isEmpty()) {
tvOrigin.text = item.originName
tvAuthor.text = item.author
tvLast.text = item.getDisplayLastChapterTitle()
tvCurrentChapterWordCount.text = item.chapterWordCountText
tvRespondTime.text = context.getString(R.string.respondTime, item.respondTime)
if (callBack.oldBookUrl == item.bookUrl) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvOrigin.text = item.originName
"latest" -> tvLast.text = item.getDisplayLastChapterTitle()
"upCurSource" -> if (callBack.oldBookUrl == item.bookUrl) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
}
}
}
}
val score = callBack.getBookScore(item)
if (score > 0) {
binding.ivBad.gone()
binding.ivGood.visible()
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_A200)
)
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_100)
)
} else if (score < 0) {
binding.ivGood.gone()
binding.ivBad.visible()
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_100)
)
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_A200)
)
} else {
binding.ivGood.visible()
binding.ivBad.visible()
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_100)
)
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_100)
)
}
if (AppConfig.changeSourceLoadWordCount && !item.chapterWordCountText.isNullOrBlank()) {
tvCurrentChapterWordCount.visible()
} else {
tvCurrentChapterWordCount.gone()
}
if (AppConfig.changeSourceLoadWordCount && item.respondTime >= 0) {
tvRespondTime.visible()
} else {
tvRespondTime.gone()
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemChangeSourceBinding) {
binding.ivGood.setOnClickListener {
if (binding.ivBad.isVisible) {
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_A200)
)
binding.ivBad.gone()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, 1)
}
} else {
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_100)
)
binding.ivBad.visible()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, 0)
}
}
}
binding.ivBad.setOnClickListener {
if (binding.ivGood.isVisible) {
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_A200)
)
binding.ivGood.gone()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, -1)
}
} else {
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_100)
)
binding.ivGood.visible()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, 0)
}
}
}
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
if (it.bookUrl != callBack.oldBookUrl) {
callBack.changeTo(it)
}
}
}
holder.itemView.onLongClick {
showMenu(holder.itemView, getItem(holder.layoutPosition))
}
}
private fun showMenu(view: View, searchBook: SearchBook?) {
searchBook ?: return
val popupMenu = PopupMenu(context, view)
popupMenu.inflate(R.menu.change_source_item)
popupMenu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_top_source -> {
callBack.topSource(searchBook)
}
R.id.menu_bottom_source -> {
callBack.bottomSource(searchBook)
}
R.id.menu_edit_source -> {
callBack.editSource(searchBook)
}
R.id.menu_disable_source -> {
callBack.disableSource(searchBook)
}
R.id.menu_delete_source -> context.alert(R.string.draw) {
setMessage(context.getString(R.string.sure_del) + "\n" + searchBook.originName)
noButton()
yesButton {
callBack.deleteSource(searchBook)
updateItems(0, itemCount, listOf<Int>())
}
}
}
true
}
popupMenu.show()
}
interface CallBack {
val oldBookUrl: String?
fun changeTo(searchBook: SearchBook)
fun topSource(searchBook: SearchBook)
fun bottomSource(searchBook: SearchBook)
fun editSource(searchBook: SearchBook)
fun disableSource(searchBook: SearchBook)
fun deleteSource(searchBook: SearchBook)
fun setBookScore(searchBook: SearchBook, score: Int)
fun getBookScore(searchBook: SearchBook): Int
}
}
@@ -0,0 +1,460 @@
package io.legato.kazusa.ui.book.changesource
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.DialogBookChangeSourceBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.getPrimaryTextColor
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.ui.book.source.edit.BookSourceEditActivity
import io.legato.kazusa.ui.book.source.manage.BookSourceActivity
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.StartActivityContract
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.getCompatDrawable
import io.legato.kazusa.utils.observeEvent
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.transaction
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
/**
* 换源界面
*/
class ChangeBookSourceDialog() : BaseDialogFragment(R.layout.dialog_book_change_source),
Toolbar.OnMenuItemClickListener,
ChangeBookSourceAdapter.CallBack {
constructor(name: String, author: String) : this() {
arguments = Bundle().apply {
putString("name", name)
putString("author", author)
}
}
private val binding by viewBinding(DialogBookChangeSourceBinding::bind)
private val groups = linkedSetOf<String>()
private val callBack: CallBack? get() = activity as? CallBack
private val viewModel: ChangeBookSourceViewModel by viewModels()
private val waitDialog by lazy { WaitDialog(requireContext()) }
private val adapter by lazy { ChangeBookSourceAdapter(requireContext(), viewModel, this) }
private val editSourceResult =
registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
val origin = it.data?.getStringExtra("origin") ?: return@registerForActivityResult
viewModel.startSearch(origin)
}
private val searchFinishCallback: (isEmpty: Boolean) -> Unit = {
if (it) {
val searchGroup = AppConfig.searchGroup
if (searchGroup.isNotEmpty()) {
lifecycleScope.launch {
context?.alert("搜索结果为空") {
setMessage("${searchGroup}分组搜索结果为空,是否切换到全部分组")
cancelButton()
okButton {
AppConfig.searchGroup = ""
upGroupMenuName()
viewModel.startSearch()
}
}
}
}
}
}
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
viewModel.initData(arguments, callBack?.oldBook, activity is ReadBookActivity)
showTitle()
initMenu()
initRecyclerView()
initNavigationView()
initSearchView()
initBottomBar()
initLiveData()
viewModel.searchFinishCallback = searchFinishCallback
}
override fun onDestroy() {
super.onDestroy()
viewModel.searchFinishCallback = null
}
private fun showTitle() {
binding.toolBar.title = viewModel.name
binding.toolBar.subtitle = viewModel.author
binding.toolBar.navigationIcon =
getCompatDrawable(androidx.appcompat.R.drawable.abc_ic_ab_back_material)
//binding.toolBar.elevation = requireContext().elevation
}
private fun initMenu() {
binding.toolBar.inflateMenu(R.menu.change_source)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.menu.findItem(R.id.menu_check_author)
?.isChecked = AppConfig.changeSourceCheckAuthor
binding.toolBar.menu.findItem(R.id.menu_load_info)
?.isChecked = AppConfig.changeSourceLoadInfo
binding.toolBar.menu.findItem(R.id.menu_load_toc)
?.isChecked = AppConfig.changeSourceLoadToc
binding.toolBar.menu.findItem(R.id.menu_load_word_count)
?.isChecked = AppConfig.changeSourceLoadWordCount
}
private fun initRecyclerView() {
binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
binding.recyclerView.adapter = adapter
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0) {
binding.recyclerView.scrollToPosition(0)
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
if (toPosition == 0) {
binding.recyclerView.scrollToPosition(0)
}
}
})
}
private fun initSearchView() {
val searchView = binding.toolBar.menu.findItem(R.id.menu_screen).actionView as SearchView
searchView.setOnCloseListener {
showTitle()
false
}
searchView.setOnSearchClickListener {
binding.toolBar.title = ""
binding.toolBar.subtitle = ""
binding.toolBar.navigationIcon = null
}
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.screen(newText)
return false
}
})
}
private fun initNavigationView() {
binding.toolBar.navigationIcon =
getCompatDrawable(androidx.appcompat.R.drawable.abc_ic_ab_back_material)
binding.toolBar.setNavigationContentDescription(
androidx.appcompat.R.string.abc_action_bar_up_description
)
binding.toolBar.setNavigationOnClickListener {
dismissAllowingStateLoss()
}
kotlin.runCatching {
val mNavButtonViewField = Toolbar::class.java.getDeclaredField("mNavButtonView")
mNavButtonViewField.isAccessible = true
val navigationView = mNavButtonViewField.get(binding.toolBar) as ImageButton
// val isLight = ColorUtils.isColorLight(primaryColor)
// val textColor = requireContext().getPrimaryTextColor(isLight)
//navigationView.setColorFilter(textColor)
}
}
private fun initBottomBar() {
binding.tvDur.text = callBack?.oldBook?.originName
binding.tvDur.setOnClickListener {
scrollToDurSource()
}
binding.ivTop.setOnClickListener {
binding.recyclerView.scrollToPosition(0)
}
binding.ivBottom.setOnClickListener {
binding.recyclerView.scrollToPosition(adapter.itemCount - 1)
}
}
private fun initLiveData() {
viewModel.searchStateData.observe(viewLifecycleOwner) {
binding.refreshProgressBar.isAutoLoading = it
if (it) {
startStopMenuItem?.let { item ->
item.setIcon(R.drawable.ic_stop_black_24dp)
item.setTitle(R.string.stop)
}
} else {
startStopMenuItem?.let { item ->
item.setIcon(R.drawable.ic_refresh)
item.setTitle(R.string.refresh)
}
}
binding.toolBar.menu.applyTint(requireContext())
}
lifecycleScope.launch {
repeatOnLifecycle(STARTED) {
viewModel.searchDataFlow.conflate().collect {
adapter.setItems(it)
delay(1000)
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(STARTED) {
viewModel.changeSourceProgress
.drop(1)
.collect { (count, name) ->
binding.tvDur.text =
getString(
R.string.change_source_progress,
adapter.itemCount,
count,
viewModel.totalSourceCount,
name
)
delay(500)
}
}
}
lifecycleScope.launch {
appDb.bookSourceDao.flowEnabledGroups().conflate().collect {
groups.clear()
groups.addAll(it)
upGroupMenu()
}
}
}
private val startStopMenuItem: MenuItem?
get() = binding.toolBar.menu.findItem(R.id.menu_start_stop)
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_check_author -> {
AppConfig.changeSourceCheckAuthor = !item.isChecked
item.isChecked = !item.isChecked
viewModel.refresh()
}
R.id.menu_load_info -> {
AppConfig.changeSourceLoadInfo = !item.isChecked
item.isChecked = !item.isChecked
}
R.id.menu_load_toc -> {
AppConfig.changeSourceLoadToc = !item.isChecked
item.isChecked = !item.isChecked
}
R.id.menu_load_word_count -> {
AppConfig.changeSourceLoadWordCount = !item.isChecked
item.isChecked = !item.isChecked
viewModel.onLoadWordCountChecked(item.isChecked)
}
R.id.menu_start_stop -> viewModel.startOrStopSearch()
R.id.menu_source_manage -> startActivity<BookSourceActivity>()
R.id.menu_close -> dismissAllowingStateLoss()
R.id.menu_refresh_list -> viewModel.startRefreshList()
else -> if (item?.groupId == R.id.source_group && !item.isChecked) {
item.isChecked = true
if (item.title.toString() == getString(R.string.all_source)) {
AppConfig.searchGroup = ""
} else {
AppConfig.searchGroup = item.title.toString()
}
upGroupMenuName()
lifecycleScope.launch(IO) {
viewModel.stopSearch()
if (viewModel.refresh()) {
viewModel.startSearch()
}
}
}
}
return false
}
private fun scrollToDurSource() {
adapter.getItems().forEachIndexed { index, searchBook ->
if (searchBook.bookUrl == oldBookUrl) {
(binding.recyclerView.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(index, 60.dpToPx())
return
}
}
}
override fun changeTo(searchBook: SearchBook) {
val oldBookType = callBack?.oldBook?.type ?: 0
if (searchBook.sameBookTypeLocal(oldBookType)) {
changeSource(searchBook) {
dismissAllowingStateLoss()
}
} else {
alert(
titleResource = R.string.book_type_different,
messageResource = R.string.soure_change_source
) {
okButton {
changeSource(searchBook) {
dismissAllowingStateLoss()
}
}
cancelButton()
}
}
}
override val oldBookUrl: String?
get() = callBack?.oldBook?.bookUrl
override fun topSource(searchBook: SearchBook) {
viewModel.topSource(searchBook)
}
override fun bottomSource(searchBook: SearchBook) {
viewModel.bottomSource(searchBook)
}
override fun editSource(searchBook: SearchBook) {
editSourceResult.launch {
putExtra("sourceUrl", searchBook.origin)
}
}
override fun disableSource(searchBook: SearchBook) {
viewModel.disableSource(searchBook)
}
override fun deleteSource(searchBook: SearchBook) {
viewModel.del(searchBook)
if (oldBookUrl == searchBook.bookUrl) {
viewModel.autoChangeSource(callBack?.oldBook?.type) { book, toc, source ->
callBack?.changeTo(source, book, toc)
}
}
}
override fun setBookScore(searchBook: SearchBook, score: Int) {
viewModel.setBookScore(searchBook, score)
}
override fun getBookScore(searchBook: SearchBook): Int {
return viewModel.getBookScore(searchBook)
}
private fun changeSource(searchBook: SearchBook, onSuccess: (() -> Unit)? = null) {
waitDialog.setText(R.string.load_toc)
waitDialog.show()
val book = viewModel.bookMap[searchBook.primaryStr()] ?: searchBook.toBook()
val coroutine = viewModel.getToc(book, { toc, source ->
waitDialog.dismiss()
callBack?.changeTo(source, book, toc)
onSuccess?.invoke()
}, {
waitDialog.dismiss()
AppLog.put("换源获取目录出错\n$it", it, true)
})
waitDialog.setOnCancelListener {
coroutine.cancel()
}
}
/**
* 更新分组菜单
*/
private fun upGroupMenu() {
binding.toolBar.menu.findItem(R.id.menu_group)?.run {
subMenu?.transaction { menu ->
val selectedGroup = AppConfig.searchGroup
menu.removeGroup(R.id.source_group)
val allItem = menu.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source)
var hasSelectedGroup = false
groups.forEach { group ->
menu.add(R.id.source_group, Menu.NONE, Menu.NONE, group)?.let {
if (group == selectedGroup) {
it.isChecked = true
hasSelectedGroup = true
}
}
}
menu.setGroupCheckable(R.id.source_group, true, true)
if (hasSelectedGroup) {
title = getString(R.string.group) + "(" + AppConfig.searchGroup + ")"
} else {
allItem.isChecked = true
title = getString(R.string.group)
}
}
}
}
/**
* 更新分组菜单名
*/
private fun upGroupMenuName() {
val menuGroup = binding.toolBar.menu.findItem(R.id.menu_group)
val searchGroup = AppConfig.searchGroup
if (searchGroup.isEmpty()) {
menuGroup?.title = getString(R.string.group)
} else {
menuGroup?.title = getString(R.string.group) + "($searchGroup)"
}
}
override fun observeLiveBus() {
observeEvent<String>(EventBus.SOURCE_CHANGED) {
adapter.notifyItemRangeChanged(
0,
adapter.itemCount,
bundleOf(Pair("upCurSource", oldBookUrl))
)
}
}
interface CallBack {
val oldBook: Book?
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>)
}
}
@@ -0,0 +1,580 @@
package io.legato.kazusa.ui.book.changesource
import android.app.Application
import android.os.Bundle
import androidx.annotation.CallSuper
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.BookSourcePart
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.ContentProcessor
import io.legato.kazusa.help.book.primaryStr
import io.legato.kazusa.help.book.releaseHtmlData
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.SourceConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.help.source.SourceHelp
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.internString
import io.legato.kazusa.utils.mapParallel
import io.legato.kazusa.utils.mapParallelSafe
import io.legato.kazusa.utils.onEachIndexed
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate")
open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) {
private val threadCount = AppConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null
val searchStateData = MutableLiveData<Boolean>()
var searchFinishCallback: ((isEmpty: Boolean) -> Unit)? = null
var name: String = ""
var author: String = ""
private var fromReadBookActivity = false
private var oldBook: Book? = null
private var screenKey: String = ""
private var bookSourceParts = arrayListOf<BookSourcePart>()
val totalSourceCount: Int
get() = bookSourceParts.size
private var searchBookList = arrayListOf<SearchBook>()
private val searchBooks = Collections.synchronizedList(arrayListOf<SearchBook>())
private val tocMap = ConcurrentHashMap<String, List<BookChapter>>()
private val _changeSourceProgress = MutableStateFlow(0 to "")
val changeSourceProgress = _changeSourceProgress.asStateFlow()
private var tocMapChapterCount = 0
private val contentProcessor by lazy {
ContentProcessor.get(oldBook!!)
}
private var searchCallback: SourceCallback? = null
private val chapterNumRegex = "^\\[(\\d+)]".toRegex()
private val comparatorBase by lazy {
compareByDescending<SearchBook> { getBookScore(it) }
.thenByDescending { SourceConfig.getSourceScore(it.origin) }
}
private val defaultComparator by lazy {
comparatorBase.thenBy { it.originOrder }
}
private val wordCountComparator by lazy {
comparatorBase.thenByDescending { it.chapterWordCount > 1000 }
.thenByDescending { getChapterNum(it.chapterWordCountText) }
.thenByDescending { it.chapterWordCount }
.thenBy { it.originOrder }
}
private var task: Job? = null
val bookMap = ConcurrentHashMap<String, Book>()
val searchDataFlow = callbackFlow {
searchCallback = object : SourceCallback {
override fun searchSuccess(searchBook: SearchBook) {
searchBook.releaseHtmlData()
appDb.searchBookDao.insert(searchBook)
when {
screenKey.isEmpty() -> searchBooks.add(searchBook)
searchBook.name.contains(screenKey) -> searchBooks.add(searchBook)
else -> return
}
trySend(arrayOf(searchBooks))
}
override fun upAdapter() {
trySend(arrayOf(searchBooks))
}
}
getDbSearchBooks().let {
searchBooks.clear()
searchBooks.addAll(it)
trySend(arrayOf(searchBooks))
}
if (searchBooks.isEmpty()) {
startSearch()
}
awaitClose {
searchCallback = null
}
}.map {
kotlin.runCatching {
val comparator = if (AppConfig.changeSourceLoadWordCount) {
wordCountComparator
} else {
defaultComparator
}
searchBooks.sortedWith(comparator)
}.onFailure {
AppLog.put("换源排序出错\n${it.localizedMessage}", it)
}.getOrDefault(searchBooks)
}.flowOn(IO)
override fun onCleared() {
super.onCleared()
searchPool?.close()
}
@CallSuper
open fun initData(arguments: Bundle?, book: Book?, fromReadBookActivity: Boolean) {
arguments?.let { bundle ->
bundle.getString("name")?.let {
name = it
}
bundle.getString("author")?.let {
author = it.replace(AppPattern.authorRegex, "")
}
this.fromReadBookActivity = fromReadBookActivity
oldBook = book
}
}
private fun initSearchPool() {
searchPool = Executors
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
}
fun refresh(): Boolean {
getDbSearchBooks().let {
searchBooks.clear()
searchBooks.addAll(it)
searchCallback?.upAdapter()
}
return searchBooks.isEmpty()
}
/**
* 搜索书籍
*/
fun startSearch() {
execute {
stopSearch()
if (searchBooks.isNotEmpty()) {
appDb.searchBookDao.delete(*searchBooks.toTypedArray())
searchBooks.clear()
}
searchCallback?.upAdapter()
bookSourceParts.clear()
tocMap.clear()
bookMap.clear()
tocMapChapterCount = 0
_changeSourceProgress.value = 0 to ""
val searchGroup = AppConfig.searchGroup
if (searchGroup.isBlank()) {
bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart)
} else {
val sources = appDb.bookSourceDao.getEnabledPartByGroup(searchGroup)
if (sources.isEmpty()) {
AppConfig.searchGroup = ""
bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart)
} else {
bookSourceParts.addAll(sources)
}
}
initSearchPool()
search()
}
}
fun startSearch(origin: String) {
execute {
stopSearch()
bookSourceParts.clear()
tocMap.clear()
bookMap.clear()
tocMapChapterCount = 0
bookSourceParts.add(appDb.bookSourceDao.getBookSourcePart(origin)!!)
searchBooks.removeIf { it.origin == origin }
initSearchPool()
search()
}
}
private fun search() {
task = viewModelScope.launch(searchPool!!) {
flow {
for (bs in bookSourceParts) {
bs.getBookSource()?.let {
emit(it)
}
}
}.onStart {
searchStateData.postValue(true)
}.mapParallel(threadCount) {
try {
withTimeout(60000L) {
search(it)
}
} catch (_: Throwable) {
currentCoroutineContext().ensureActive()
}
it
}.onEachIndexed { index, value ->
_changeSourceProgress.update { _ ->
index + 1 to value.bookSourceName
}
}.onCompletion {
ensureActive()
searchStateData.postValue(false)
searchFinishCallback?.invoke(searchBooks.isEmpty())
}.catch {
AppLog.put("换源搜索出错\n${it.localizedMessage}", it)
}.collect()
}
}
private suspend fun search(source: BookSource) {
val checkAuthor = AppConfig.changeSourceCheckAuthor
val loadInfo = AppConfig.changeSourceLoadInfo
val loadToc = AppConfig.changeSourceLoadToc
val loadWordCount = AppConfig.changeSourceLoadWordCount
val resultBooks = WebBook.searchBookAwait(
source, name,
filter = { fName, fAuthor ->
fName == name && (!checkAuthor || fAuthor.contains(author))
})
resultBooks.forEach { searchBook ->
when {
loadInfo || loadToc || loadWordCount -> {
loadBookInfo(source, searchBook.toBook())
}
else -> {
searchCallback?.searchSuccess(searchBook)
}
}
}
}
private suspend fun loadBookInfo(source: BookSource, book: Book) {
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
}
if (AppConfig.changeSourceLoadToc || AppConfig.changeSourceLoadWordCount) {
loadBookToc(source, book)
} else {
//从详情页里获取最新章节
val searchBook = book.toSearchBook()
searchCallback?.searchSuccess(searchBook)
}
}
private suspend fun loadBookToc(source: BookSource, book: Book) {
val chapters = WebBook.getChapterListAwait(source, book).getOrThrow()
for (chapter in chapters) {
chapter.internString()
}
if (tocMapChapterCount < 30000) {
tocMapChapterCount += chapters.size
tocMap[book.primaryStr()] = chapters
}
bookMap[book.primaryStr()] = book
book.releaseHtmlData()
if (AppConfig.changeSourceLoadWordCount) {
loadBookWordCount(source, book, chapters)
} else {
val searchBook = book.toSearchBook()
searchCallback?.searchSuccess(searchBook)
}
}
private suspend fun loadBookWordCount(
source: BookSource,
book: Book,
chapters: List<BookChapter>
) = coroutineScope {
val chapterIndex = if (fromReadBookActivity) {
BookHelp.getDurChapter(oldBook!!, chapters)
} else {
chapters.lastIndex
}
val bookChapter = chapters[chapterIndex]
var title = bookChapter.title.trim()
if (title.length > 20) {
title = title.substring(0, 20) + "…"
}
val startTime = System.currentTimeMillis()
val pair = try {
val nextChapterUrl = chapters.getOrNull(chapterIndex + 1)?.url
var content = WebBook.getContentAwait(source, book, bookChapter, nextChapterUrl, false)
content = contentProcessor.getContent(oldBook!!, bookChapter, content, false).toString()
val len = content.length
len to "[${chapterIndex + 1}] ${title}\n字数:${len}"
} catch (t: Throwable) {
if (t is CancellationException) throw t
-1 to "[${chapterIndex + 1}] ${title}\n获取字数失败:${t.localizedMessage}"
}
val endTime = System.currentTimeMillis()
val searchBook = book.toSearchBook().apply {
chapterWordCountText = pair.second
chapterWordCount = pair.first
respondTime = (endTime - startTime).toInt()
}
searchCallback?.searchSuccess(searchBook)
}
fun onLoadWordCountChecked(isChecked: Boolean) {
if (isChecked) {
startRefreshList(true)
}
}
/**
* 刷新列表
*/
fun startRefreshList(onlyRefreshNoWordCountBook: Boolean = false) {
execute {
stopSearch()
searchBookList.clear()
if (onlyRefreshNoWordCountBook) {
searchBooks.filterTo(searchBookList) {
it.chapterWordCountText == null
}
searchBooks.removeIf { it.chapterWordCountText == null }
} else {
searchBookList.addAll(searchBooks)
searchBooks.clear()
}
searchCallback?.upAdapter()
initSearchPool()
refreshList()
}
}
private fun refreshList() {
task = viewModelScope.launch(searchPool!!) {
flow {
for (searchBook in searchBookList) {
emit(searchBook)
}
}.onStart {
searchStateData.postValue(true)
}.mapParallelSafe(threadCount) {
val source = appDb.bookSourceDao.getBookSource(it.origin)!!
withTimeout(60000L) {
loadBookInfo(source, it.toBook())
}
}.onCompletion {
searchStateData.postValue(false)
}.catch {
AppLog.put("换源刷新列表出错\n${it.localizedMessage}", it)
}.collect()
}
}
private fun getDbSearchBooks(): List<SearchBook> {
return if (screenKey.isEmpty()) {
if (AppConfig.changeSourceCheckAuthor) {
appDb.searchBookDao.changeSourceByGroup(
name, author, AppConfig.searchGroup
)
} else {
appDb.searchBookDao.changeSourceByGroup(
name, "", AppConfig.searchGroup
)
}
} else {
if (AppConfig.changeSourceCheckAuthor) {
appDb.searchBookDao.changeSourceSearch(
name, author, screenKey, AppConfig.searchGroup
)
} else {
appDb.searchBookDao.changeSourceSearch(
name, "", screenKey, AppConfig.searchGroup
)
}
}
}
/**
* 筛选
*/
fun screen(key: String?) {
screenKey = key?.trim() ?: ""
execute {
getDbSearchBooks().let {
searchBooks.clear()
searchBooks.addAll(it)
searchCallback?.upAdapter()
}
}
}
fun startOrStopSearch() {
if (task == null || !task!!.isActive) {
startSearch()
} else {
stopSearch()
}
}
fun stopSearch() {
task?.cancel()
searchPool?.close()
searchStateData.postValue(false)
}
fun getToc(
book: Book,
onSuccess: (toc: List<BookChapter>, source: BookSource) -> Unit,
onError: (e: Throwable) -> Unit
): Coroutine<Pair<List<BookChapter>, BookSource>> {
return execute {
val toc = tocMap[book.primaryStr()]
if (toc != null) {
val source = appDb.bookSourceDao.getBookSource(book.origin)
return@execute Pair(toc, source!!)
}
val result = getToc(book).getOrThrow()
tocMap[book.primaryStr()] = result.first
return@execute result
}.onSuccess {
onSuccess.invoke(it.first, it.second)
}.onError {
onError.invoke(it)
}
}
suspend fun getToc(book: Book): Result<Pair<List<BookChapter>, BookSource>> {
return kotlin.runCatching {
val source = appDb.bookSourceDao.getBookSource(book.origin)
?: throw NoStackTraceException("书源不存在")
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
}
val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
Pair(toc, source)
}
}
fun disableSource(searchBook: SearchBook) {
execute {
appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source ->
source.enabled = false
appDb.bookSourceDao.update(source)
}
searchBooks.remove(searchBook)
searchCallback?.upAdapter()
}
}
fun topSource(searchBook: SearchBook) {
execute {
appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source ->
val minOrder = appDb.bookSourceDao.minOrder - 1
source.customOrder = minOrder
searchBook.originOrder = source.customOrder
appDb.bookSourceDao.update(source)
updateSource(searchBook)
}
searchCallback?.upAdapter()
}
}
fun bottomSource(searchBook: SearchBook) {
execute {
appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source ->
val maxOrder = appDb.bookSourceDao.maxOrder + 1
source.customOrder = maxOrder
searchBook.originOrder = source.customOrder
appDb.bookSourceDao.update(source)
updateSource(searchBook)
}
searchCallback?.upAdapter()
}
}
fun updateSource(searchBook: SearchBook) {
appDb.searchBookDao.update(searchBook)
}
fun del(searchBook: SearchBook) {
execute {
SourceHelp.deleteBookSource(searchBook.origin)
appDb.searchBookDao.delete(searchBook)
}
searchBooks.remove(searchBook)
searchCallback?.upAdapter()
}
fun autoChangeSource(
bookType: Int?,
onSuccess: (book: Book, toc: List<BookChapter>, source: BookSource) -> Unit
) {
execute {
searchBooks.forEach {
if (it.type == bookType) {
val book = it.toBook()
val result = getToc(book).getOrNull()
if (result != null) {
return@execute Triple(book, result.first, result.second)
}
}
}
throw NoStackTraceException("没有有效源")
}.onSuccess {
onSuccess.invoke(it.first, it.second, it.third)
}.onError {
context.toastOnUi("自动换源失败\n${it.localizedMessage}")
}
}
fun setBookScore(searchBook: SearchBook, score: Int) {
execute {
SourceConfig.setBookScore(searchBook.origin, searchBook.name, searchBook.author, score)
searchCallback?.upAdapter()
}
}
fun getBookScore(searchBook: SearchBook): Int {
return SourceConfig.getBookScore(searchBook.origin, searchBook.name, searchBook.author)
}
private fun getChapterNum(wordCountText: String?): Int {
wordCountText ?: return -1
return chapterNumRegex.find(wordCountText)?.groupValues?.get(1)?.toIntOrNull() ?: -1
}
interface SourceCallback {
fun searchSuccess(searchBook: SearchBook)
fun upAdapter()
}
}
@@ -0,0 +1,205 @@
package io.legato.kazusa.ui.book.changesource
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.DiffRecyclerAdapter
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.ItemChangeSourceBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.utils.getCompatColor
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.visible
import splitties.init.appCtx
import splitties.views.onLongClick
class ChangeChapterSourceAdapter(
context: Context,
val viewModel: ChangeChapterSourceViewModel,
val callBack: CallBack
) : DiffRecyclerAdapter<SearchBook, ItemChangeSourceBinding>(context) {
override val diffItemCallback = object : DiffUtil.ItemCallback<SearchBook>() {
override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean {
return oldItem.bookUrl == newItem.bookUrl
}
override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean {
return oldItem.originName == newItem.originName
&& oldItem.getDisplayLastChapterTitle() == newItem.getDisplayLastChapterTitle()
}
}
override fun getViewBinding(parent: ViewGroup): ItemChangeSourceBinding {
return ItemChangeSourceBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemChangeSourceBinding,
item: SearchBook,
payloads: MutableList<Any>
) {
binding.apply {
if (payloads.isEmpty()) {
tvOrigin.text = item.originName
tvAuthor.text = item.author
tvLast.text = item.getDisplayLastChapterTitle()
tvCurrentChapterWordCount.text = item.chapterWordCountText
tvRespondTime.text = context.getString(R.string.respondTime, item.respondTime)
if (callBack.oldBookUrl == item.bookUrl) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvOrigin.text = item.originName
"latest" -> tvLast.text = item.getDisplayLastChapterTitle()
"upCurSource" -> if (callBack.oldBookUrl == item.bookUrl) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
}
}
}
}
val score = callBack.getBookScore(item)
if (score > 0) {
binding.ivBad.gone()
binding.ivGood.visible()
DrawableCompat.setTint(binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_A200))
DrawableCompat.setTint(binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_100))
} else if (score < 0) {
binding.ivGood.gone()
binding.ivBad.visible()
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_100)
)
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_A200)
)
} else {
binding.ivGood.visible()
binding.ivBad.visible()
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_100)
)
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_100)
)
}
if (AppConfig.changeSourceLoadWordCount && !item.chapterWordCountText.isNullOrBlank()) {
tvCurrentChapterWordCount.visible()
} else {
tvCurrentChapterWordCount.gone()
}
if (AppConfig.changeSourceLoadWordCount && item.respondTime >= 0) {
tvRespondTime.visible()
} else {
tvRespondTime.gone()
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemChangeSourceBinding) {
binding.ivGood.setOnClickListener {
if (binding.ivBad.isVisible) {
DrawableCompat.setTint(binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_A200))
binding.ivBad.gone()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, 1)
}
} else {
DrawableCompat.setTint(binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_100))
binding.ivBad.visible()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, 0)
}
}
}
binding.ivBad.setOnClickListener {
if (binding.ivGood.isVisible) {
DrawableCompat.setTint(binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_A200))
binding.ivGood.gone()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, -1)
}
} else {
DrawableCompat.setTint(binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_100))
binding.ivGood.visible()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, 0)
}
}
}
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.openToc(it)
}
}
holder.itemView.onLongClick {
showMenu(holder.itemView, getItem(holder.layoutPosition))
}
}
private fun showMenu(view: View, searchBook: SearchBook?) {
searchBook ?: return
val popupMenu = PopupMenu(context, view)
popupMenu.inflate(R.menu.change_source_item)
popupMenu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_top_source -> {
callBack.topSource(searchBook)
}
R.id.menu_bottom_source -> {
callBack.bottomSource(searchBook)
}
R.id.menu_edit_source -> {
callBack.editSource(searchBook)
}
R.id.menu_disable_source -> {
callBack.disableSource(searchBook)
}
R.id.menu_delete_source -> {
callBack.deleteSource(searchBook)
updateItems(0, itemCount, listOf<Int>())
}
}
true
}
popupMenu.show()
}
interface CallBack {
val oldBookUrl: String?
fun openToc(searchBook: SearchBook)
fun topSource(searchBook: SearchBook)
fun bottomSource(searchBook: SearchBook)
fun editSource(searchBook: SearchBook)
fun disableSource(searchBook: SearchBook)
fun deleteSource(searchBook: SearchBook)
fun setBookScore(searchBook: SearchBook, score: Int)
fun getBookScore(searchBook: SearchBook): Int
}
}
@@ -0,0 +1,414 @@
package io.legato.kazusa.ui.book.changesource
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.activity.addCallback
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.DialogChapterChangeSourceBinding
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.ui.book.source.edit.BookSourceEditActivity
import io.legato.kazusa.ui.book.source.manage.BookSourceActivity
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.StartActivityContract
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.observeEvent
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.transaction
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_chapter_change_source),
Toolbar.OnMenuItemClickListener,
ChangeChapterSourceAdapter.CallBack,
ChangeChapterTocAdapter.Callback {
constructor(name: String, author: String, chapterIndex: Int, chapterTitle: String) : this() {
arguments = Bundle().apply {
putString("name", name)
putString("author", author)
putInt("chapterIndex", chapterIndex)
putString("chapterTitle", chapterTitle)
}
}
private val binding by viewBinding(DialogChapterChangeSourceBinding::bind)
private val groups = linkedSetOf<String>()
private val callBack: CallBack? get() = activity as? CallBack
private val viewModel: ChangeChapterSourceViewModel by viewModels()
private val editSourceResult =
registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
viewModel.startSearch()
}
private val searchBookAdapter by lazy {
ChangeChapterSourceAdapter(requireContext(), viewModel, this)
}
private val tocAdapter by lazy {
ChangeChapterTocAdapter(requireContext(), this)
}
private val contentSuccess: (content: String) -> Unit = {
binding.loadingToc.gone()
callBack?.replaceContent(it)
dismissAllowingStateLoss()
}
private var searchBook: SearchBook? = null
private val searchFinishCallback: (isEmpty: Boolean) -> Unit = {
if (it) {
val searchGroup = AppConfig.searchGroup
if (searchGroup.isNotEmpty()) {
lifecycleScope.launch {
context?.alert("搜索结果为空") {
setMessage("${searchGroup}分组搜索结果为空,是否切换到全部分组")
noButton()
yesButton {
AppConfig.searchGroup = ""
upGroupMenu()
viewModel.startSearch()
}
}
}
}
}
}
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
viewModel.initData(arguments, callBack?.oldBook, activity is ReadBookActivity)
showTitle()
initMenu()
initView()
initRecyclerView()
initSearchView()
initBottomBar()
initLiveData()
viewModel.searchFinishCallback = searchFinishCallback
activity?.onBackPressedDispatcher?.addCallback(this) {
if (binding.clToc.isVisible) {
binding.clToc.gone()
return@addCallback
}
dismissAllowingStateLoss()
}
}
override fun onDestroy() {
super.onDestroy()
viewModel.searchFinishCallback = null
}
private fun showTitle() {
binding.toolBar.title = viewModel.chapterTitle
}
private fun initMenu() {
binding.toolBar.inflateMenu(R.menu.change_source)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.menu.findItem(R.id.menu_check_author)
?.isChecked = AppConfig.changeSourceCheckAuthor
binding.toolBar.menu.findItem(R.id.menu_load_info)
?.isChecked = AppConfig.changeSourceLoadInfo
binding.toolBar.menu.findItem(R.id.menu_load_toc)
?.isChecked = AppConfig.changeSourceLoadToc
binding.toolBar.menu.findItem(R.id.menu_load_word_count)
?.isChecked = AppConfig.changeSourceLoadWordCount
}
private fun initView() {
binding.ivHideToc.setOnClickListener {
binding.clToc.gone()
}
//binding.flHideToc.elevation = requireContext().elevation
}
private fun initRecyclerView() {
binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
binding.recyclerView.adapter = searchBookAdapter
searchBookAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0) {
binding.recyclerView.scrollToPosition(0)
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
if (toPosition == 0) {
binding.recyclerView.scrollToPosition(0)
}
}
})
binding.recyclerViewToc.adapter = tocAdapter
}
private fun initSearchView() {
val searchView = binding.toolBar.menu.findItem(R.id.menu_screen).actionView as SearchView
searchView.setOnCloseListener {
showTitle()
false
}
searchView.setOnSearchClickListener {
binding.toolBar.title = ""
binding.toolBar.subtitle = ""
}
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.screen(newText)
return false
}
})
}
private fun initBottomBar() {
binding.tvDur.text = callBack?.oldBook?.originName
binding.tvDur.setOnClickListener {
scrollToDurSource()
}
binding.ivTop.setOnClickListener {
binding.recyclerView.scrollToPosition(0)
}
binding.ivBottom.setOnClickListener {
binding.recyclerView.scrollToPosition(searchBookAdapter.itemCount - 1)
}
}
private fun initLiveData() {
viewModel.searchStateData.observe(viewLifecycleOwner) {
binding.refreshProgressBar.isAutoLoading = it
if (it) {
startStopMenuItem?.let { item ->
item.setIcon(R.drawable.ic_stop_black_24dp)
item.setTitle(R.string.stop)
}
} else {
startStopMenuItem?.let { item ->
item.setIcon(R.drawable.ic_refresh)
item.setTitle(R.string.refresh)
}
}
binding.toolBar.menu.applyTint(requireContext())
}
lifecycleScope.launch {
repeatOnLifecycle(STARTED) {
viewModel.searchDataFlow.conflate().collect {
searchBookAdapter.setItems(it)
delay(1000)
}
}
}
lifecycleScope.launch {
appDb.bookSourceDao.flowEnabledGroups().conflate().collect {
groups.clear()
groups.addAll(it)
upGroupMenu()
}
}
}
private val startStopMenuItem: MenuItem?
get() = binding.toolBar.menu.findItem(R.id.menu_start_stop)
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_check_author -> {
AppConfig.changeSourceCheckAuthor = !item.isChecked
item.isChecked = !item.isChecked
viewModel.refresh()
}
R.id.menu_load_info -> {
AppConfig.changeSourceLoadInfo = !item.isChecked
item.isChecked = !item.isChecked
}
R.id.menu_load_toc -> {
AppConfig.changeSourceLoadToc = !item.isChecked
item.isChecked = !item.isChecked
}
R.id.menu_load_word_count -> {
AppConfig.changeSourceLoadWordCount = !item.isChecked
item.isChecked = !item.isChecked
viewModel.onLoadWordCountChecked(item.isChecked)
}
R.id.menu_start_stop -> viewModel.startOrStopSearch()
R.id.menu_source_manage -> startActivity<BookSourceActivity>()
else -> if (item?.groupId == R.id.source_group && !item.isChecked) {
item.isChecked = true
if (item.title.toString() == getString(R.string.all_source)) {
AppConfig.searchGroup = ""
} else {
AppConfig.searchGroup = item.title.toString()
}
lifecycleScope.launch(IO) {
viewModel.stopSearch()
if (viewModel.refresh()) {
viewModel.startSearch()
}
}
}
}
return false
}
private fun scrollToDurSource() {
searchBookAdapter.getItems().forEachIndexed { index, searchBook ->
if (searchBook.bookUrl == oldBookUrl) {
(binding.recyclerView.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(index, 60.dpToPx())
return
}
}
}
override fun openToc(searchBook: SearchBook) {
this.searchBook = searchBook
tocAdapter.setItems(null)
binding.clToc.visible()
binding.loadingToc.visible()
val book = searchBook.toBook()
viewModel.getToc(book, { toc: List<BookChapter>, _: BookSource ->
tocAdapter.durChapterIndex =
BookHelp.getDurChapter(viewModel.chapterIndex, viewModel.chapterTitle, toc)
binding.loadingToc.gone()
tocAdapter.setItems(toc)
binding.recyclerViewToc.scrollToPosition(tocAdapter.durChapterIndex - 5)
}, {
binding.clToc.gone()
AppLog.put("单章换源获取目录出错\n$it", it, true)
})
}
override val oldBookUrl: String?
get() = callBack?.oldBook?.bookUrl
override fun topSource(searchBook: SearchBook) {
viewModel.topSource(searchBook)
}
override fun bottomSource(searchBook: SearchBook) {
viewModel.bottomSource(searchBook)
}
override fun editSource(searchBook: SearchBook) {
editSourceResult.launch {
putExtra("sourceUrl", searchBook.origin)
}
}
override fun disableSource(searchBook: SearchBook) {
viewModel.disableSource(searchBook)
}
override fun deleteSource(searchBook: SearchBook) {
viewModel.del(searchBook)
if (oldBookUrl == searchBook.bookUrl) {
viewModel.autoChangeSource(callBack?.oldBook?.type) { book, toc, source ->
callBack?.changeTo(source, book, toc)
}
}
}
override fun setBookScore(searchBook: SearchBook, score: Int) {
viewModel.setBookScore(searchBook, score)
}
override fun getBookScore(searchBook: SearchBook): Int {
return viewModel.getBookScore(searchBook)
}
override fun clickChapter(bookChapter: BookChapter, nextChapterUrl: String?) {
searchBook?.let {
binding.loadingToc.visible()
viewModel.getContent(it.toBook(), bookChapter, nextChapterUrl, contentSuccess) { msg ->
binding.loadingToc.gone()
binding.clToc.gone()
toastOnUi(msg)
}
}
}
/**
* 更新分组菜单
*/
private fun upGroupMenu() {
binding.toolBar.menu.findItem(R.id.menu_group)?.subMenu?.transaction { menu ->
val selectedGroup = AppConfig.searchGroup
menu.removeGroup(R.id.source_group)
val allItem = menu.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source)
var hasSelectedGroup = false
groups.forEach { group ->
menu.add(R.id.source_group, Menu.NONE, Menu.NONE, group)?.let {
if (group == selectedGroup) {
it.isChecked = true
hasSelectedGroup = true
}
}
}
menu.setGroupCheckable(R.id.source_group, true, true)
if (!hasSelectedGroup) {
allItem.isChecked = true
}
}
}
override fun observeLiveBus() {
observeEvent<String>(EventBus.SOURCE_CHANGED) {
searchBookAdapter.notifyItemRangeChanged(
0,
searchBookAdapter.itemCount,
bundleOf(Pair("upCurSource", oldBookUrl))
)
}
}
interface CallBack {
val oldBook: Book?
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>)
fun replaceContent(content: String)
}
}
@@ -0,0 +1,46 @@
package io.legato.kazusa.ui.book.changesource
import android.app.Application
import android.os.Bundle
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.model.webBook.WebBook
@Suppress("MemberVisibilityCanBePrivate")
class ChangeChapterSourceViewModel(application: Application) :
ChangeBookSourceViewModel(application) {
var chapterIndex: Int = 0
var chapterTitle: String = ""
override fun initData(arguments: Bundle?, book: Book?, fromReadBookActivity: Boolean) {
super.initData(arguments, book, fromReadBookActivity)
arguments?.let { bundle ->
bundle.getString("chapterTitle")?.let {
chapterTitle = it
}
chapterIndex = bundle.getInt("chapterIndex")
}
}
fun getContent(
book: Book,
chapter: BookChapter,
nextChapterUrl: String?,
success: (content: String) -> Unit,
error: (msg: String) -> Unit
) {
execute {
val bookSource = appDb.bookSourceDao.getBookSource(book.origin)
?: throw NoStackTraceException("书源不存在")
WebBook.getContentAwait(bookSource, book, chapter, nextChapterUrl, false)
}.onSuccess {
success.invoke(it)
}.onError {
error.invoke(it.localizedMessage ?: "获取正文出错")
}
}
}
@@ -0,0 +1,70 @@
package io.legato.kazusa.ui.book.changesource
import android.content.Context
import android.view.ViewGroup
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.databinding.ItemChapterListBinding
import io.legato.kazusa.lib.theme.ThemeUtils
//import io.legado.app.lib.theme.accentColor
import io.legato.kazusa.utils.getCompatColor
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.visible
class ChangeChapterTocAdapter(context: Context, val callback: Callback) :
RecyclerAdapter<BookChapter, ItemChapterListBinding>(context) {
var durChapterIndex = 0
override fun getViewBinding(parent: ViewGroup): ItemChapterListBinding {
return ItemChapterListBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemChapterListBinding,
item: BookChapter,
payloads: MutableList<Any>
) {
binding.run {
val isDur = durChapterIndex == item.index
if (isDur) {
//tvChapterName.setTextColor(context.accentColor)
} else {
tvChapterName.setTextColor(context.getCompatColor(R.color.primaryText))
}
tvChapterName.text = item.title
if (item.isVolume) {
//卷名,如第一卷 突出显示
tvChapterItem.setBackgroundColor(context.getCompatColor(R.color.btn_bg_press))
} else {
//普通章节 保持不变
tvChapterItem.background =
ThemeUtils.resolveDrawable(context, android.R.attr.selectableItemBackground)
}
if (!item.tag.isNullOrEmpty() && !item.isVolume) {
//卷名不显示tag(更新时间规则)
tvTag.text = item.tag
tvTag.visible()
} else {
tvTag.gone()
}
ivChecked.setImageResource(R.drawable.ic_check)
ivChecked.visible(isDur)
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemChapterListBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callback.clickChapter(it, getItem(holder.layoutPosition + 1)?.url)
}
}
}
interface Callback {
fun clickChapter(bookChapter: BookChapter, nextChapterUrl: String?)
}
}
@@ -0,0 +1,96 @@
package io.legato.kazusa.ui.book.explore
import android.os.Bundle
import androidx.activity.viewModels
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.ActivityExploreShowBinding
import io.legato.kazusa.databinding.ViewLoadMoreBinding
import io.legato.kazusa.ui.book.info.BookInfoActivity
import io.legato.kazusa.ui.widget.recycler.LoadMoreView
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
/**
* 发现列表
*/
class ExploreShowActivity : VMBaseActivity<ActivityExploreShowBinding, ExploreShowViewModel>(),
ExploreShowAdapter.CallBack {
override val binding by viewBinding(ActivityExploreShowBinding::inflate)
override val viewModel by viewModels<ExploreShowViewModel>()
private val adapter by lazy { ExploreShowAdapter(this, this) }
private val loadMoreView by lazy { LoadMoreView(this) }
override fun onActivityCreated(savedInstanceState: Bundle?) {
binding.titleBar.title = intent.getStringExtra("exploreName")
initRecyclerView()
viewModel.booksData.observe(this) { upData(it) }
viewModel.initData(intent)
viewModel.errorLiveData.observe(this) {
loadMoreView.error(it)
}
viewModel.upAdapterLiveData.observe(this) {
adapter.notifyItemRangeChanged(0, adapter.itemCount, bundleOf(it to null))
}
}
private fun initRecyclerView() {
binding.recyclerView.addItemDecoration(VerticalDivider(this))
binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding()
adapter.addFooterView {
ViewLoadMoreBinding.bind(loadMoreView)
}
loadMoreView.startLoad()
loadMoreView.setOnClickListener {
if (!loadMoreView.isLoading) {
scrollToBottom(true)
}
}
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (!recyclerView.canScrollVertically(1)) {
scrollToBottom()
}
}
})
}
private fun scrollToBottom(forceLoad: Boolean = false) {
if ((loadMoreView.hasMore && !loadMoreView.isLoading) || forceLoad) {
loadMoreView.hasMore()
viewModel.explore()
}
}
private fun upData(books: List<SearchBook>) {
loadMoreView.stopLoad()
if (books.isEmpty() && adapter.isEmpty()) {
loadMoreView.noMore(getString(R.string.empty))
} else if (adapter.getActualItemCount() == books.size) {
loadMoreView.noMore()
} else {
adapter.setItems(books)
}
}
override fun isInBookshelf(name: String, author: String): Boolean {
return viewModel.isInBookShelf(name, author)
}
override fun showBookInfo(book: Book) {
startActivity<BookInfoActivity> {
putExtra("name", book.name)
putExtra("author", book.author)
putExtra("bookUrl", book.bookUrl)
}
}
}
@@ -0,0 +1,98 @@
package io.legato.kazusa.ui.book.explore
import android.content.Context
import android.os.Bundle
import android.view.ViewGroup
import androidx.core.view.isVisible
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.ItemSearchBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.visible
class ExploreShowAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<SearchBook, ItemSearchBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemSearchBinding {
return ItemSearchBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemSearchBinding,
item: SearchBook,
payloads: MutableList<Any>
) {
if (payloads.isEmpty()) {
bind(binding, item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bindChange(binding, item, bundle)
}
}
}
private fun bind(binding: ItemSearchBinding, item: SearchBook) {
binding.run {
tvName.text = item.name
tvAuthor.text = context.getString(R.string.author_show, item.author)
ivInBookshelf.isVisible = callBack.isInBookshelf(item.name, item.author)
if (item.latestChapterTitle.isNullOrEmpty()) {
tvLasted.gone()
} else {
tvLasted.text = context.getString(R.string.lasted_show, item.latestChapterTitle)
tvLasted.visible()
}
tvIntroduce.text = item.trimIntro(context)
val kinds = item.getKindList()
if (kinds.isEmpty()) {
llKind.gone()
} else {
llKind.visible()
llKind.setLabels(kinds)
}
ivCover.load(
item.coverUrl,
item.name,
item.author,
AppConfig.loadCoverOnlyWifi,
item.origin
)
}
}
private fun bindChange(binding: ItemSearchBinding, item: SearchBook, bundle: Bundle) {
binding.run {
bundle.keySet().forEach {
when (it) {
"isInBookshelf" -> ivInBookshelf.isVisible =
callBack.isInBookshelf(item.name, item.author)
}
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemSearchBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.showBookInfo(it.toBook())
}
}
}
interface CallBack {
/**
* 是否已经加入书架
*/
fun isInBookshelf(name: String, author: String): Boolean
fun showBookInfo(book: Book)
}
}
@@ -0,0 +1,93 @@
package io.legato.kazusa.ui.book.explore
import android.app.Application
import android.content.Intent
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legato.kazusa.BuildConfig
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.help.book.isNotShelf
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.printOnDebug
import io.legato.kazusa.utils.stackTraceStr
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.mapLatest
import java.util.concurrent.ConcurrentHashMap
@OptIn(ExperimentalCoroutinesApi::class)
class ExploreShowViewModel(application: Application) : BaseViewModel(application) {
val bookshelf: MutableSet<String> = ConcurrentHashMap.newKeySet()
val upAdapterLiveData = MutableLiveData<String>()
val booksData = MutableLiveData<List<SearchBook>>()
val errorLiveData = MutableLiveData<String>()
private var bookSource: BookSource? = null
private var exploreUrl: String? = null
private var page = 1
private var books = linkedSetOf<SearchBook>()
init {
execute {
appDb.bookDao.flowAll().mapLatest { books ->
val keys = arrayListOf<String>()
books.filterNot { it.isNotShelf }
.forEach {
keys.add("${it.name}-${it.author}")
keys.add(it.name)
}
keys
}.catch {
AppLog.put("发现列表界面获取书籍数据失败\n${it.localizedMessage}", it)
}.collect {
bookshelf.clear()
bookshelf.addAll(it)
upAdapterLiveData.postValue("isInBookshelf")
}
}.onError {
AppLog.put("加载书架数据失败", it)
}
}
fun initData(intent: Intent) {
execute {
val sourceUrl = intent.getStringExtra("sourceUrl")
exploreUrl = intent.getStringExtra("exploreUrl")
if (bookSource == null && sourceUrl != null) {
bookSource = appDb.bookSourceDao.getBookSource(sourceUrl)
}
explore()
}
}
fun explore() {
val source = bookSource
val url = exploreUrl
if (source == null || url == null) return
WebBook.exploreBook(viewModelScope, source, url, page)
.timeout(if (BuildConfig.DEBUG) 0L else 30000L)
.onSuccess(IO) { searchBooks ->
books.addAll(searchBooks)
booksData.postValue(books.toList())
appDb.searchBookDao.insert(*searchBooks.toTypedArray())
page++
}.onError {
it.printOnDebug()
errorLiveData.postValue(it.stackTraceStr)
}
}
fun isInBookShelf(name: String, author: String): Boolean {
return if (author.isNotBlank()) {
bookshelf.contains("$name-$author")
} else {
bookshelf.contains(name)
}
}
}
@@ -0,0 +1,135 @@
package io.legato.kazusa.ui.book.group
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseBottomSheetDialogFragment
import io.legato.kazusa.data.entities.BookGroup
import io.legato.kazusa.databinding.DialogBookGroupEditBinding
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.MD5Utils
import io.legato.kazusa.utils.SelectImageContract
import io.legato.kazusa.utils.externalFiles
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.inputStream
import io.legato.kazusa.utils.launch
import io.legato.kazusa.utils.readUri
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import splitties.init.appCtx
import splitties.views.onClick
import java.io.FileOutputStream
class GroupEditDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_book_group_edit) {
constructor(bookGroup: BookGroup? = null) : this() {
arguments = Bundle().apply {
putParcelable("group", bookGroup?.copy())
}
}
private val binding by viewBinding(DialogBookGroupEditBinding::bind)
private val viewModel by viewModels<GroupViewModel>()
private var bookGroup: BookGroup? = null
private val selectImage = registerForActivityResult(SelectImageContract()) {
it.uri ?: return@registerForActivityResult
readUri(it.uri) { fileDoc, inputStream ->
try {
var file = requireContext().externalFiles
val suffix = fileDoc.name.substringAfterLast(".")
val fileName = it.uri.inputStream(requireContext()).getOrThrow().use { tmp ->
MD5Utils.md5Encode(tmp) + ".$suffix"
}
file = FileUtils.createFileIfNotExist(file, "covers", fileName)
FileOutputStream(file).use { outputStream ->
inputStream.copyTo(outputStream)
}
binding.ivCover.load(file.absolutePath)
} catch (e: Exception) {
appCtx.toastOnUi(e.localizedMessage)
}
}
}
override fun onStart() {
super.onStart()
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
@Suppress("DEPRECATION")
bookGroup = arguments?.getParcelable("group")
bookGroup?.let {
binding.btnDelete.visible(it.groupId > 0 || it.groupId == Long.MIN_VALUE)
binding.tieGroupName.setText(it.groupName)
binding.ivCover.load(it.cover)
binding.spSort.setSelection(it.bookSort + 1)
binding.cbEnableRefresh.isChecked = it.enableRefresh
} ?: let {
binding.toolBar.title = getString(R.string.add_group)
binding.btnDelete.gone()
binding.ivCover.load()
}
binding.run {
ivCover.onClick {
selectImage.launch()
}
btnCancel.onClick {
dismiss()
}
btnOk.onClick {
val groupName = tieGroupName.text?.toString()
if (groupName.isNullOrEmpty()) {
toastOnUi("分组名称不能为空")
} else {
val bookSort = binding.spSort.selectedItemPosition - 1
val coverPath = binding.ivCover.bitmapPath
val enableRefresh = binding.cbEnableRefresh.isChecked
bookGroup?.let {
it.groupName = groupName
it.cover = coverPath
it.bookSort = bookSort
it.enableRefresh = enableRefresh
viewModel.upGroup(it) {
dismiss()
}
} ?: let {
viewModel.addGroup(
groupName,
bookSort,
enableRefresh,
coverPath
) {
dismiss()
}
}
}
}
btnDelete.onClick {
deleteGroup {
bookGroup?.let {
viewModel.delGroup(it) {
dismiss()
}
}
}
}
}
}
private fun deleteGroup(ok: () -> Unit) {
alert(R.string.delete, R.string.sure_del) {
yesButton {
ok.invoke()
}
noButton()
}
}
}
@@ -0,0 +1,164 @@
package io.legato.kazusa.ui.book.group
import android.content.Context
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseBottomSheetDialogFragment
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookGroup
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemBookGroupManageBinding
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.backgroundColor
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.recycler.ItemTouchCallback
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
/**
* 书籍分组管理
*/
class GroupManageDialog : BaseBottomSheetDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener {
private val viewModel: GroupViewModel by viewModels()
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val adapter by lazy { GroupAdapter(requireContext()) }
override fun onStart() {
super.onStart()
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.group_manage)
initView()
initData()
initMenu()
}
private fun initView() {
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
binding.recyclerView.adapter = adapter
val itemTouchCallback = ItemTouchCallback(adapter)
itemTouchCallback.isCanDrag = true
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
//binding.tvOk.setTextColor(requireContext().accentColor)
binding.tvOk.visible()
binding.tvOk.setOnClickListener {
dismissAllowingStateLoss()
}
}
private fun initData() {
lifecycleScope.launch {
appDb.bookGroupDao.flowAll().catch {
AppLog.put("书籍分组管理界面获取分组数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
adapter.setItems(it)
}
}
}
private fun initMenu() {
binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.inflateMenu(R.menu.book_group_manage)
binding.toolBar.menu.applyTint(requireContext())
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_add -> {
if (appDb.bookGroupDao.canAddGroup) {
showDialogFragment(GroupEditDialog())
} else {
toastOnUi("分组已达上限(64个)")
}
}
}
return true
}
private inner class GroupAdapter(context: Context) :
RecyclerAdapter<BookGroup, ItemBookGroupManageBinding>(context),
ItemTouchCallback.Callback {
private var isMoved = false
override fun getViewBinding(parent: ViewGroup): ItemBookGroupManageBinding {
return ItemBookGroupManageBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBookGroupManageBinding,
item: BookGroup,
payloads: MutableList<Any>
) {
binding.run {
//root.setBackgroundColor(context.backgroundColor)
tvGroup.text = item.getManageName(context)
swShow.isChecked = item.show
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemBookGroupManageBinding) {
binding.run {
tvEdit.setOnClickListener {
getItem(holder.layoutPosition)?.let { bookGroup ->
showDialogFragment(
GroupEditDialog(bookGroup)
)
}
}
swShow.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
getItem(holder.layoutPosition)?.let {
viewModel.upGroup(it.copy(show = isChecked))
}
}
}
}
}
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
swapItem(srcPosition, targetPosition)
isMoved = true
return true
}
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (isMoved) {
for ((index, item) in getItems().withIndex()) {
item.order = index + 1
}
viewModel.upGroup(*getItems().toTypedArray())
}
isMoved = false
}
}
}
@@ -0,0 +1,168 @@
package io.legato.kazusa.ui.book.group
import android.content.Context
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseBottomSheetDialogFragment
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookGroup
import io.legato.kazusa.databinding.DialogBookGroupPickerBinding
import io.legato.kazusa.databinding.ItemGroupSelectBinding
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.backgroundColor
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.recycler.ItemTouchCallback
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
class GroupSelectDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_book_group_picker),
Toolbar.OnMenuItemClickListener {
constructor(groupId: Long, requestCode: Int = -1) : this() {
arguments = Bundle().apply {
putLong("groupId", groupId)
putInt("requestCode", requestCode)
}
}
private val binding by viewBinding(DialogBookGroupPickerBinding::bind)
private var requestCode: Int = -1
private val viewModel: GroupViewModel by viewModels()
private val adapter by lazy { GroupAdapter(requireContext()) }
private val callBack get() = (activity as? CallBack)
private var groupId: Long = 0
override fun onStart() {
super.onStart()
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
arguments?.let {
groupId = it.getLong("groupId")
requestCode = it.getInt("requestCode", -1)
}
initView()
initData()
}
private fun initView() {
binding.toolBar.title = getString(R.string.group_select)
binding.toolBar.inflateMenu(R.menu.book_group_manage)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
binding.recyclerView.adapter = adapter
val itemTouchCallback = ItemTouchCallback(adapter)
itemTouchCallback.isCanDrag = true
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
binding.tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
//binding.tvOk.setTextColor(requireContext().accentColor)
binding.tvOk.setOnClickListener {
callBack?.upGroup(requestCode, groupId)
dismissAllowingStateLoss()
}
}
private fun initData() {
lifecycleScope.launch {
appDb.bookGroupDao.flowSelect().conflate().collect {
adapter.setItems(it)
}
}
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_add -> showDialogFragment(
GroupEditDialog()
)
}
return true
}
private inner class GroupAdapter(context: Context) :
RecyclerAdapter<BookGroup, ItemGroupSelectBinding>(context),
ItemTouchCallback.Callback {
private var isMoved: Boolean = false
override fun getViewBinding(parent: ViewGroup): ItemGroupSelectBinding {
return ItemGroupSelectBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemGroupSelectBinding,
item: BookGroup,
payloads: MutableList<Any>
) {
binding.run {
//root.setBackgroundColor(context.backgroundColor)
cbGroup.text = item.groupName
cbGroup.isChecked = (groupId and item.groupId) > 0
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemGroupSelectBinding) {
binding.run {
cbGroup.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
getItem(holder.layoutPosition)?.let {
groupId = if (isChecked) {
groupId + it.groupId
} else {
groupId - it.groupId
}
}
}
}
tvEdit.setOnClickListener {
showDialogFragment(
GroupEditDialog(getItem(holder.layoutPosition))
)
}
}
}
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
swapItem(srcPosition, targetPosition)
isMoved = true
return true
}
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (isMoved) {
for ((index, item) in getItems().withIndex()) {
item.order = index + 1
}
viewModel.upGroup(*getItems().toTypedArray())
}
isMoved = false
}
}
interface CallBack {
fun upGroup(requestCode: Int, groupId: Long)
}
}
@@ -0,0 +1,52 @@
package io.legato.kazusa.ui.book.group
import android.app.Application
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookGroup
class GroupViewModel(application: Application) : BaseViewModel(application) {
fun upGroup(vararg bookGroup: BookGroup, finally: (() -> Unit)? = null) {
execute {
appDb.bookGroupDao.update(*bookGroup)
}.onFinally {
finally?.invoke()
}
}
fun addGroup(
groupName: String,
bookSort: Int,
enableRefresh: Boolean,
cover: String?,
finally: () -> Unit
) {
execute {
val groupId = appDb.bookGroupDao.getUnusedId()
val bookGroup = BookGroup(
groupId = groupId,
groupName = groupName,
cover = cover,
bookSort = bookSort,
enableRefresh = enableRefresh,
order = appDb.bookGroupDao.maxOrder.plus(1)
)
appDb.bookGroupDao.getByID(groupId) ?: appDb.bookDao.removeGroup(groupId)
appDb.bookGroupDao.insert(bookGroup)
}.onFinally {
finally()
}
}
fun delGroup(bookGroup: BookGroup, finally: () -> Unit) {
execute {
appDb.bookGroupDao.delete(bookGroup)
appDb.bookDao.removeGroup(bookGroup.groupId)
}.onFinally {
finally()
}
}
}
@@ -0,0 +1,177 @@
package io.legato.kazusa.ui.book.import
import android.os.Bundle
import android.view.MotionEvent
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.ViewModel
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.databinding.ActivityImportBookBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.lib.dialogs.selector
//import io.legado.app.lib.theme.primaryTextColor
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.utils.ArchiveUtils
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.hideSoftInput
import io.legato.kazusa.utils.shouldHideSoftInput
import io.legato.kazusa.utils.startActivityForBook
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
abstract class BaseImportBookActivity<VM : ViewModel> :
VMBaseActivity<ActivityImportBookBinding, VM>() {
final override val binding by viewBinding(ActivityImportBookBinding::inflate)
private var localBookTreeSelectListener: ((Boolean) -> Unit)? = null
protected val searchView: SearchView by lazy {
binding.titleBar.findViewById(R.id.search_view)
}
val localBookTreeSelect = registerForActivityResult(HandleFileContract()) {
it.uri?.let { treeUri ->
AppConfig.defaultBookTreeUri = treeUri.toString()
localBookTreeSelectListener?.invoke(true)
} ?: localBookTreeSelectListener?.invoke(false)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initSearchView()
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.action == MotionEvent.ACTION_DOWN) {
currentFocus?.let {
if (it.shouldHideSoftInput(ev)) {
it.post {
it.clearFocus()
it.hideSoftInput()
}
}
}
}
return super.dispatchTouchEvent(ev)
}
/**
* 设置书籍保存位置
*/
protected suspend fun setBookStorage() = suspendCoroutine { block ->
localBookTreeSelectListener = {
localBookTreeSelectListener = null
block.resume(it)
}
//测试书籍保存位置是否设置
if (!AppConfig.defaultBookTreeUri.isNullOrBlank()) {
localBookTreeSelectListener = null
block.resume(true)
return@suspendCoroutine
}
//测试读写??
val storageHelp = String(assets.open("storageHelp.md").readBytes())
val hint = getString(R.string.select_book_folder)
alert(hint, storageHelp) {
okButton {
localBookTreeSelect.launch {
title = hint
}
}
cancelButton {
localBookTreeSelectListener = null
block.resume(false)
}
onCancelled {
localBookTreeSelectListener = null
block.resume(false)
}
}
}
abstract fun onSearchTextChange(newText: String?)
protected fun startReadBook(book: Book) {
startActivityForBook(book)
}
protected fun onArchiveFileClick(fileDoc: FileDoc) {
val fileNames = ArchiveUtils.getArchiveFilesName(fileDoc) {
it.matches(AppPattern.bookFileRegex)
}
if (fileNames.size == 1) {
val name = fileNames[0]
appDb.bookDao.getBookByFileName(name)?.let {
startReadBook(it)
} ?: showImportAlert(fileDoc, name)
} else {
showSelectBookReadAlert(fileDoc, fileNames)
}
}
private fun showSelectBookReadAlert(fileDoc: FileDoc, fileNames: List<String>) {
if (fileNames.isEmpty()) {
toastOnUi(R.string.unsupport_archivefile_entry)
return
}
selector(
R.string.start_read,
fileNames
) { _, name, _ ->
appDb.bookDao.getBookByFileName(name)?.let {
startReadBook(it)
} ?: showImportAlert(fileDoc, name)
}
}
/* 添加压缩包内指定文件到书架 */
private inline fun addArchiveToBookShelf(
fileDoc: FileDoc,
fileName: String,
onSuccess: (Book) -> Unit
) {
LocalBook.importArchiveFile(fileDoc.uri, fileName) {
it.contains(fileName)
}.firstOrNull()?.run {
onSuccess.invoke(this)
}
}
/* 提示是否重新导入所点击的压缩文件 */
private fun showImportAlert(fileDoc: FileDoc, fileName: String) {
alert(
R.string.draw,
R.string.no_book_found_bookshelf
) {
okButton {
addArchiveToBookShelf(fileDoc, fileName) {
startReadBook(it)
}
}
noButton()
}
}
private fun initSearchView() {
//searchView.applyTint(primaryTextColor)
searchView.isSubmitButtonEnabled = true
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
onSearchTextChange(newText)
return false
}
})
}
}
@@ -0,0 +1,14 @@
package io.legato.kazusa.ui.book.import.local
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.utils.FileDoc
data class ImportBook(
val file: FileDoc,
var isOnBookShelf: Boolean = !file.isDir && LocalBook.isOnBookShelf(file.name)
) {
val name get() = file.name
val isDir get() = file.isDir
val size get() = file.size
val lastModified get() = file.lastModified
}
@@ -0,0 +1,319 @@
package io.legato.kazusa.ui.book.import.local
import android.annotation.SuppressLint
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.constant.PreferKey
import io.legato.kazusa.data.appDb
import io.legato.kazusa.databinding.DialogEditTextBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.lib.permission.Permissions
import io.legato.kazusa.lib.permission.PermissionsCompat
//import io.legado.app.lib.theme.backgroundColor
import io.legato.kazusa.ui.book.import.BaseImportBookActivity
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.ui.widget.SelectActionBar
import io.legato.kazusa.utils.ArchiveUtils
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.isContentScheme
import io.legato.kazusa.utils.isUri
import io.legato.kazusa.utils.launch
import io.legato.kazusa.utils.putPrefInt
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
/**
* 导入本地书籍界面
*/
class ImportBookActivity : BaseImportBookActivity<ImportBookViewModel>(),
PopupMenu.OnMenuItemClickListener,
ImportBookAdapter.CallBack,
SelectActionBar.CallBack {
override val viewModel by viewModels<ImportBookViewModel>()
private val adapter by lazy { ImportBookAdapter(this, this) }
private var scanDocJob: Job? = null
private val selectFolder = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
AppConfig.importBookPath = uri.toString()
initRootDoc(true)
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
searchView.queryHint = getString(R.string.screen) + " • " + getString(R.string.local_book)
onBackPressedDispatcher.addCallback(this) {
if (!goBackDir()) {
finish()
}
}
lifecycleScope.launch {
initView()
initEvent()
if (setBookStorage() && AppConfig.importBookPath.isNullOrBlank()) {
AppConfig.importBookPath = AppConfig.defaultBookTreeUri
}
initData()
}
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.import_book, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_sort_name)?.isChecked = viewModel.sort == 0
menu.findItem(R.id.menu_sort_size)?.isChecked = viewModel.sort == 1
menu.findItem(R.id.menu_sort_time)?.isChecked = viewModel.sort == 2
return super.onMenuOpened(featureId, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_select_folder -> selectFolder.launch()
R.id.menu_scan_folder -> scanFolder()
R.id.menu_import_file_name -> alertImportFileName()
R.id.menu_sort_name -> upSort(0)
R.id.menu_sort_size -> upSort(1)
R.id.menu_sort_time -> upSort(2)
}
return super.onCompatOptionsItemSelected(item)
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_del_selection -> viewModel.deleteDoc(adapter.selected) {
adapter.removeSelection()
}
}
return false
}
override fun selectAll(selectAll: Boolean) {
adapter.selectAll(selectAll)
}
override fun revertSelection() {
adapter.revertSelection()
}
@SuppressLint("NotifyDataSetChanged")
override fun onClickSelectBarMainAction() {
viewModel.addToBookshelf(adapter.selected) {
adapter.selected.forEach {
it.isOnBookShelf = true
}
adapter.selected.clear()
adapter.notifyDataSetChanged()
}
}
private fun initView() {
//binding.layTop.setBackgroundColor(backgroundColor)
binding.tvEmptyMsg.setText(R.string.empty_msg_import_book)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
binding.recyclerView.recycledViewPool.setMaxRecycledViews(0, 15)
binding.selectActionBar.setMainActionText(R.string.add_to_bookshelf)
binding.selectActionBar.inflateMenu(R.menu.import_book_sel)
binding.selectActionBar.setOnMenuItemClickListener(this)
binding.selectActionBar.setCallBack(this)
}
private fun initEvent() {
binding.tvGoBack.setOnClickListener {
goBackDir()
}
}
private fun initData() {
viewModel.dataFlowStart = {
initRootDoc()
}
lifecycleScope.launch {
viewModel.dataFlow.conflate().collect { docs ->
adapter.setItems(docs)
}
}
}
private fun initRootDoc(changedFolder: Boolean = false) {
if (viewModel.rootDoc != null && !changedFolder) {
upPath()
} else {
val lastPath = AppConfig.importBookPath
if (lastPath.isNullOrBlank()) {
binding.tvEmptyMsg.visible()
selectFolder.launch()
} else {
val rootUri = if (lastPath.isUri()) {
Uri.parse(lastPath)
} else {
Uri.fromFile(File(lastPath))
}
when {
rootUri.isContentScheme() -> {
kotlin.runCatching {
val doc = DocumentFile.fromTreeUri(this, rootUri)
if (doc == null || doc.name.isNullOrEmpty()) {
binding.tvEmptyMsg.visible()
selectFolder.launch()
} else {
viewModel.subDocs.clear()
viewModel.rootDoc = FileDoc.fromDocumentFile(doc)
upDocs(viewModel.rootDoc!!)
}
}.onFailure {
binding.tvEmptyMsg.visible()
selectFolder.launch()
}
}
else -> initRootPath(rootUri.path!!)
}
}
}
}
private fun initRootPath(path: String) {
binding.tvEmptyMsg.visible()
PermissionsCompat.Builder()
.addPermissions(*Permissions.Group.STORAGE)
.rationale(R.string.tip_perm_request_storage)
.onGranted {
kotlin.runCatching {
viewModel.rootDoc = FileDoc.fromFile(File(path))
viewModel.subDocs.clear()
upPath()
}.onFailure {
binding.tvEmptyMsg.visible()
selectFolder.launch()
}
}
.request()
}
private fun upSort(sort: Int) {
viewModel.sort = sort
putPrefInt(PreferKey.localBookImportSort, sort)
if (scanDocJob?.isActive != true) {
viewModel.dataCallback?.upAdapter()
}
}
@Synchronized
private fun upPath() {
binding.tvGoBack.isEnabled = viewModel.subDocs.isNotEmpty()
viewModel.rootDoc?.let {
scanDocJob?.cancel()
upDocs(it)
}
}
private fun upDocs(rootDoc: FileDoc) {
binding.tvEmptyMsg.gone()
var path = rootDoc.name + File.separator
var lastDoc = rootDoc
for (doc in viewModel.subDocs) {
lastDoc = doc
path = path + doc.name + File.separator
}
binding.tvPath.text = path
adapter.selected.clear()
adapter.clearItems()
viewModel.loadDoc(lastDoc)
}
/**
* 扫描当前文件夹及所有子文件夹
*/
private fun scanFolder() {
viewModel.rootDoc?.let { doc ->
adapter.clearItems()
val lastDoc = viewModel.subDocs.lastOrNull() ?: doc
binding.refreshProgressBar.isAutoLoading = true
scanDocJob?.cancel()
scanDocJob = lifecycleScope.launch(IO) {
viewModel.scanDoc(lastDoc)
withContext(Main) {
binding.refreshProgressBar.isAutoLoading = false
}
}
}
}
private fun alertImportFileName() {
alert(R.string.import_file_name) {
setMessage("""使用js处理文件名变量src,将书名作者分别赋值到变量name author""")
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "js"
editView.setText(AppConfig.bookImportFileName)
}
customView { alertBinding.root }
okButton {
AppConfig.bookImportFileName = alertBinding.editView.text?.toString()
}
cancelButton()
}
}
@Synchronized
override fun nextDoc(fileDoc: FileDoc) {
viewModel.subDocs.add(fileDoc)
upPath()
}
@Synchronized
private fun goBackDir(): Boolean {
return if (viewModel.subDocs.isNotEmpty()) {
viewModel.subDocs.removeAt(viewModel.subDocs.lastIndex)
upPath()
true
} else {
false
}
}
override fun onSearchTextChange(newText: String?) {
viewModel.updateCallBackFlow(newText)
}
override fun upCountView() {
binding.selectActionBar.upCountView(adapter.selected.size, adapter.checkableCount)
}
override fun startRead(fileDoc: FileDoc) {
if (!ArchiveUtils.isArchive(fileDoc.name)) {
appDb.bookDao.getBookByFileName(fileDoc.name)?.let {
val filePath = fileDoc.toString()
if (it.bookUrl != filePath) {
it.bookUrl = filePath
appDb.bookDao.insert(it)
}
startReadBook(it)
}
} else {
onArchiveFileClick(fileDoc)
}
}
}
@@ -0,0 +1,141 @@
package io.legato.kazusa.ui.book.import.local
import android.annotation.SuppressLint
import android.content.Context
import android.view.ViewGroup
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.databinding.ItemImportBookBinding
import io.legato.kazusa.utils.ConvertUtils
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.visible
class ImportBookAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<ImportBook, ItemImportBookBinding>(context) {
val selected = hashSetOf<ImportBook>()
var checkableCount = 0
override fun getViewBinding(parent: ViewGroup): ItemImportBookBinding {
return ItemImportBookBinding.inflate(inflater, parent, false)
}
override fun onCurrentListChanged() {
upCheckableCount()
}
override fun convert(
holder: ItemViewHolder,
binding: ItemImportBookBinding,
item: ImportBook,
payloads: MutableList<Any>
) {
binding.run {
if (payloads.isEmpty()) {
if (item.isDir) {
ivIcon.setImageResource(R.drawable.ic_folder)
ivIcon.visible()
cbSelect.invisible()
llBrief.gone()
cbSelect.isChecked = false
} else {
if (item.isOnBookShelf) {
ivIcon.setImageResource(R.drawable.ic_book_has)
ivIcon.visible()
cbSelect.invisible()
} else {
ivIcon.invisible()
cbSelect.visible()
}
llBrief.visible()
tvTag.text = item.name.substringAfterLast(".")
tvSize.text = ConvertUtils.formatFileSize(item.size)
tvDate.text = AppConst.dateFormat.format(item.lastModified)
cbSelect.isChecked = selected.contains(item)
}
tvName.text = item.name
} else {
cbSelect.isChecked = selected.contains(item)
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemImportBookBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
if (it.isDir) {
callBack.nextDoc(it.file)
} else if (!it.isOnBookShelf) {
if (!selected.contains(it)) {
selected.add(it)
} else {
selected.remove(it)
}
notifyItemChanged(holder.layoutPosition, true)
callBack.upCountView()
} else {
/* 点击开始阅读 */
callBack.startRead(it.file)
}
}
}
}
private fun upCheckableCount() {
checkableCount = 0
getItems().forEach {
if (!it.isDir && !it.isOnBookShelf) {
checkableCount++
}
}
callBack.upCountView()
}
@SuppressLint("NotifyDataSetChanged")
fun selectAll(selectAll: Boolean) {
if (selectAll) {
getItems().forEach {
if (!it.isDir && !it.isOnBookShelf) {
selected.add(it)
}
}
} else {
selected.clear()
}
notifyDataSetChanged()
callBack.upCountView()
}
fun revertSelection() {
getItems().forEach {
if (!it.isDir && !it.isOnBookShelf) {
if (selected.contains(it)) {
selected.remove(it)
} else {
selected.add(it)
}
}
}
notifyItemRangeChanged(0, itemCount, true)
callBack.upCountView()
}
fun removeSelection() {
for (i in getItems().lastIndex downTo 0) {
if (getItem(i) in selected) {
removeItem(i)
}
}
}
interface CallBack {
fun nextDoc(fileDoc: FileDoc)
fun upCountView()
fun startRead(fileDoc: FileDoc)
}
}
@@ -0,0 +1,181 @@
package io.legato.kazusa.ui.book.import.local
import android.app.Application
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern.archiveFileRegex
import io.legato.kazusa.constant.AppPattern.bookFileRegex
import io.legato.kazusa.constant.PreferKey
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.utils.AlphanumComparator
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.delete
import io.legato.kazusa.utils.getPrefInt
import io.legato.kazusa.utils.list
import io.legato.kazusa.utils.mapParallel
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.withContext
import java.util.Collections
class ImportBookViewModel(application: Application) : BaseViewModel(application) {
var rootDoc: FileDoc? = null
val subDocs = arrayListOf<FileDoc>()
var sort = context.getPrefInt(PreferKey.localBookImportSort)
var dataCallback: DataCallback? = null
var dataFlowStart: (() -> Unit)? = null
var filterKey: String? = null
val dataFlow = callbackFlow<List<ImportBook>> {
val list = Collections.synchronizedList(ArrayList<ImportBook>())
dataCallback = object : DataCallback {
override fun setItems(fileDocs: List<FileDoc>) {
list.clear()
fileDocs.mapTo(list) {
ImportBook(it)
}
trySend(list)
}
override fun addItems(fileDocs: List<FileDoc>) {
fileDocs.mapTo(list) {
ImportBook(it)
}
trySend(list)
}
override fun clear() {
list.clear()
trySend(emptyList())
}
override fun upAdapter() {
trySend(list)
}
}
withContext(Main) {
dataFlowStart?.invoke()
}
awaitClose {
dataCallback = null
}
}.map { docList ->
val docList = docList.toList()
val filterKey = filterKey
val skipFilter = filterKey.isNullOrBlank()
val comparator = when (sort) {
2 -> compareBy<ImportBook>({ !it.isDir }, { -it.lastModified })
1 -> compareBy({ !it.isDir }, { -it.size })
else -> compareBy { !it.isDir }
} then compareBy(AlphanumComparator) { it.name }
docList.asSequence().filter {
skipFilter || it.name.contains(filterKey)
}.sortedWith(comparator).toList()
}.flowOn(IO)
fun addToBookshelf(bookList: HashSet<ImportBook>, finally: () -> Unit) {
execute {
val fileUris = bookList.map {
it.file.uri
}
LocalBook.importFiles(fileUris)
}.onError {
context.toastOnUi("添加书架失败,请尝试重新选择文件夹")
AppLog.put("添加书架失败\n${it.localizedMessage}", it)
}.onSuccess {
context.toastOnUi("添加书架成功")
}.onFinally {
finally.invoke()
}
}
fun deleteDoc(bookList: HashSet<ImportBook>, finally: () -> Unit) {
execute {
bookList.forEach {
it.file.delete()
}
}.onFinally {
finally.invoke()
}
}
fun loadDoc(fileDoc: FileDoc) {
execute {
val docList = fileDoc.list { item ->
when {
item.name.startsWith(".") -> false
item.isDir -> true
else -> item.name.matches(bookFileRegex) || item.name.matches(archiveFileRegex)
}
}
dataCallback?.setItems(docList!!)
}.onError {
context.toastOnUi("获取文件列表出错\n${it.localizedMessage}")
}
}
suspend fun scanDoc(fileDoc: FileDoc) {
dataCallback?.clear()
val channel = Channel<FileDoc>(UNLIMITED)
var n = 1
channel.trySend(fileDoc)
val list = arrayListOf<FileDoc>()
channel.consumeAsFlow()
.mapParallel(16) { fileDoc ->
fileDoc.list()!!
}.onEach { fileDocs ->
n--
list.clear()
fileDocs.forEach {
if (it.isDir) {
n++
channel.trySend(it)
} else if (it.name.matches(bookFileRegex)
|| it.name.matches(archiveFileRegex)
) {
list.add(it)
}
}
dataCallback?.addItems(list)
}.takeWhile {
n > 0
}.catch {
context.toastOnUi("扫描文件夹出错\n${it.localizedMessage}")
}.collect()
}
fun updateCallBackFlow(filterKey: String?) {
this.filterKey = filterKey
dataCallback?.upAdapter()
}
interface DataCallback {
fun setItems(fileDocs: List<FileDoc>)
fun addItems(fileDocs: List<FileDoc>)
fun clear()
fun upAdapter()
}
}
@@ -0,0 +1,259 @@
package io.legato.kazusa.ui.book.import.remote
import android.annotation.SuppressLint
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.SubMenu
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.core.view.isGone
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.data.appDb
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.LocalConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.backgroundColor
import io.legato.kazusa.model.remote.RemoteBook
import io.legato.kazusa.ui.about.AppLogDialog
import io.legato.kazusa.ui.book.import.BaseImportBookActivity
import io.legato.kazusa.ui.widget.SelectActionBar
import io.legato.kazusa.utils.ArchiveUtils
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.find
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.showHelp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
import java.io.File
/**
* 展示远程书籍
*/
class RemoteBookActivity : BaseImportBookActivity<RemoteBookViewModel>(),
RemoteBookAdapter.CallBack,
SelectActionBar.CallBack,
ServersDialog.Callback {
override val viewModel by viewModels<RemoteBookViewModel>()
private val adapter by lazy { RemoteBookAdapter(this, this) }
private var groupMenu: SubMenu? = null
override fun onActivityCreated(savedInstanceState: Bundle?) {
searchView.queryHint = getString(R.string.screen) + " • " + getString(R.string.remote_book)
onBackPressedDispatcher.addCallback(this) {
if (!goBackDir()) {
finish()
}
}
lifecycleScope.launch {
if (!setBookStorage()) {
finish()
return@launch
}
initView()
initEvent()
launch {
viewModel.dataFlow.conflate().collect { sortedRemoteBooks ->
binding.refreshProgressBar.isAutoLoading = false
binding.tvEmptyMsg.isGone = sortedRemoteBooks.isNotEmpty()
adapter.setItems(sortedRemoteBooks)
delay(500)
}
}
viewModel.initData {
upPath()
}
}
}
override fun observeLiveBus() {
viewModel.permissionDenialLiveData.observe(this) {
localBookTreeSelect.launch {
title = getString(R.string.select_book_folder)
}
}
}
private fun initView() {
//binding.layTop.setBackgroundColor(backgroundColor)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
binding.selectActionBar.setMainActionText(R.string.add_to_bookshelf)
binding.selectActionBar.setCallBack(this)
if (!LocalConfig.webDavBookHelpVersionIsLast) {
showHelp("webDavBookHelp")
}
}
private fun sortCheck(sortKey: RemoteBookSort) {
if (viewModel.sortKey == sortKey) {
viewModel.sortAscending = !viewModel.sortAscending
} else {
viewModel.sortAscending = true
viewModel.sortKey = sortKey
}
}
private fun initEvent() {
binding.tvGoBack.setOnClickListener {
goBackDir()
}
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_remote, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_refresh -> upPath()
R.id.menu_server_config -> showDialogFragment<ServersDialog>()
R.id.menu_log -> showDialogFragment<AppLogDialog>()
R.id.menu_help -> showHelp("webDavBookHelp")
R.id.menu_sort_name -> {
item.isChecked = true
sortCheck(RemoteBookSort.Name)
upPath()
}
R.id.menu_sort_time -> {
item.isChecked = true
sortCheck(RemoteBookSort.Default)
upPath()
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
groupMenu = menu.findItem(R.id.menu_sort)?.subMenu
groupMenu?.setGroupCheckable(R.id.menu_group_sort, true, true)
groupMenu?.findItem(R.id.menu_sort_name)?.isChecked =
viewModel.sortKey == RemoteBookSort.Name
groupMenu?.findItem(R.id.menu_sort_time)?.isChecked =
viewModel.sortKey == RemoteBookSort.Default
return super.onPrepareOptionsMenu(menu)
}
override fun revertSelection() {
adapter.revertSelection()
}
override fun selectAll(selectAll: Boolean) {
adapter.selectAll(selectAll)
}
@SuppressLint("NotifyDataSetChanged")
override fun onClickSelectBarMainAction() {
binding.refreshProgressBar.isAutoLoading = true
viewModel.addToBookshelf(adapter.selected) {
adapter.selected.clear()
adapter.notifyDataSetChanged()
binding.refreshProgressBar.isAutoLoading = false
}
}
private fun goBackDir(): Boolean {
if (viewModel.dirList.isEmpty()) {
return false
}
viewModel.dirList.removeLastOrNull()
upPath()
return true
}
private fun upPath() {
binding.tvGoBack.isEnabled = viewModel.dirList.isNotEmpty()
var path = if (viewModel.isDefaultWebdav) {
"books" + File.separator
} else {
File.separator
}
viewModel.dirList.forEach {
path = path + it.filename + File.separator
}
binding.tvPath.text = path
viewModel.dataCallback?.clear()
adapter.selected.clear()
viewModel.loadRemoteBookList(
viewModel.dirList.lastOrNull()?.path
) {
binding.refreshProgressBar.isAutoLoading = it
}
}
override fun openDir(remoteBook: RemoteBook) {
viewModel.dirList.add(remoteBook)
upPath()
}
override fun upCountView() {
binding.selectActionBar.upCountView(adapter.selected.size, adapter.checkableCount)
}
override fun onDialogDismiss(tag: String) {
viewModel.initData {
upPath()
}
}
override fun onSearchTextChange(newText: String?) {
viewModel.updateCallBackFlow(newText)
}
private fun showRemoteBookDownloadAlert(
remoteBook: RemoteBook,
onDownloadFinish: (() -> Unit)? = null
) {
alert(
R.string.draw,
R.string.archive_not_found
) {
okButton {
viewModel.addToBookshelf(hashSetOf(remoteBook)) {
onDownloadFinish?.invoke()
}
}
noButton()
}
}
override fun startRead(remoteBook: RemoteBook) {
val downloadFileName = remoteBook.filename
if (!ArchiveUtils.isArchive(downloadFileName)) {
appDb.bookDao.getBookByFileName(downloadFileName)?.let {
startReadBook(it)
}
} else {
AppConfig.defaultBookTreeUri ?: return
val downloadArchiveFileDoc = FileDoc.fromUri(Uri.parse(AppConfig.defaultBookTreeUri), true)
.find(downloadFileName)
if (downloadArchiveFileDoc == null) {
showRemoteBookDownloadAlert(remoteBook) {
startRead(remoteBook)
}
} else {
onArchiveFileClick(downloadArchiveFileDoc)
}
}
}
override fun addToBookShelfAgain(remoteBook: RemoteBook) {
alert(getString(R.string.sure), "是否重新加入书架?") {
yesButton {
binding.refreshProgressBar.isAutoLoading = true
viewModel.addToBookshelf(hashSetOf(remoteBook)) {
binding.refreshProgressBar.isAutoLoading = false
}
}
noButton()
}
}
}
@@ -0,0 +1,157 @@
package io.legato.kazusa.ui.book.import.remote
import android.annotation.SuppressLint
import android.content.Context
import android.view.ViewGroup
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.databinding.ItemImportBookBinding
import io.legato.kazusa.model.remote.RemoteBook
import io.legato.kazusa.utils.ConvertUtils
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.visible
/**
* 适配器
* @author qianfanguojin
*/
class RemoteBookAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<RemoteBook, ItemImportBookBinding>(context) {
var selected = hashSetOf<RemoteBook>()
var checkableCount = 0
override fun getViewBinding(parent: ViewGroup): ItemImportBookBinding {
return ItemImportBookBinding.inflate(inflater, parent, false)
}
override fun onCurrentListChanged() {
upCheckableCount()
}
/**
* 绑定RecycleView 中每一个项的视图和数据
*/
override fun convert(
holder: ItemViewHolder,
binding: ItemImportBookBinding,
item: RemoteBook,
payloads: MutableList<Any>
) {
binding.run {
if (payloads.isEmpty()) {
if (item.isDir) {
ivIcon.setImageResource(R.drawable.ic_folder)
ivIcon.visible()
cbSelect.invisible()
llBrief.gone()
cbSelect.isChecked = false
} else {
if (item.isOnBookShelf) {
ivIcon.setImageResource(R.drawable.ic_book_has)
ivIcon.visible()
cbSelect.invisible()
} else {
ivIcon.invisible()
cbSelect.visible()
}
llBrief.visible()
tvTag.text = item.contentType
tvSize.text = ConvertUtils.formatFileSize(item.size)
tvDate.text = AppConst.dateFormat.format(item.lastModify)
cbSelect.isChecked = selected.contains(item)
}
tvName.text = item.filename
} else {
cbSelect.isChecked = selected.contains(item)
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemImportBookBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
if (it.isDir) {
callBack.openDir(it)
} else if (!it.isOnBookShelf) {
if (!selected.contains(it)) {
selected.add(it)
} else {
selected.remove(it)
}
notifyItemChanged(holder.layoutPosition, true)
callBack.upCountView()
} else {
/* 点击开始阅读 */
callBack.startRead(it)
}
}
}
holder.itemView.setOnLongClickListener {
getItem(holder.layoutPosition)?.let { remoteBook ->
if (remoteBook.isOnBookShelf) {
callBack.addToBookShelfAgain(remoteBook)
}
}
true
}
}
private fun upCheckableCount() {
checkableCount = 0
getItems().forEach {
if (!it.isDir && !it.isOnBookShelf) {
checkableCount++
}
}
callBack.upCountView()
}
@SuppressLint("NotifyDataSetChanged")
fun selectAll(selectAll: Boolean) {
if (selectAll) {
getItems().forEach {
if (!it.isDir && !it.isOnBookShelf) {
selected.add(it)
}
}
} else {
selected.clear()
}
notifyDataSetChanged()
callBack.upCountView()
}
fun revertSelection() {
getItems().forEach {
if (!it.isDir && !it.isOnBookShelf) {
if (selected.contains(it)) {
selected.remove(it)
} else {
selected.add(it)
}
}
}
notifyItemRangeChanged(0, itemCount, true)
callBack.upCountView()
}
fun removeSelection() {
for (i in getItems().lastIndex downTo 0) {
if (getItem(i) in selected) {
removeItem(i)
}
}
}
interface CallBack {
fun openDir(remoteBook: RemoteBook)
fun upCountView()
fun startRead(remoteBook: RemoteBook)
fun addToBookShelfAgain(remoteBook: RemoteBook)
}
}
@@ -0,0 +1,5 @@
package io.legato.kazusa.ui.book.import.remote
enum class RemoteBookSort {
Default, Name
}
@@ -0,0 +1,174 @@
package io.legato.kazusa.ui.book.import.remote
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.data.appDb
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.AppWebDav
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.webdav.Authorization
import io.legato.kazusa.model.analyzeRule.CustomUrl
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.model.remote.RemoteBook
import io.legato.kazusa.model.remote.RemoteBookWebDav
import io.legato.kazusa.utils.AlphanumComparator
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import java.util.Collections
class RemoteBookViewModel(application: Application) : BaseViewModel(application) {
var sortKey = RemoteBookSort.Default
var sortAscending = false
val dirList = arrayListOf<RemoteBook>()
val permissionDenialLiveData = MutableLiveData<Int>()
var dataCallback: DataCallback? = null
val dataFlow = callbackFlow<List<RemoteBook>> {
val list = Collections.synchronizedList(ArrayList<RemoteBook>())
dataCallback = object : DataCallback {
override fun setItems(remoteFiles: List<RemoteBook>) {
list.clear()
list.addAll(remoteFiles)
trySend(list)
}
override fun addItems(remoteFiles: List<RemoteBook>) {
list.addAll(remoteFiles)
trySend(list)
}
override fun clear() {
list.clear()
trySend(emptyList())
}
override fun screen(key: String?) {
if (key.isNullOrBlank()) {
trySend(list)
} else {
trySend(
list.filter { it.filename.contains(key) }
)
}
}
}
awaitClose {
dataCallback = null
}
}.map { list ->
if (sortAscending) when (sortKey) {
RemoteBookSort.Name -> list.sortedWith(compareBy<RemoteBook> { !it.isDir }
then compareBy(AlphanumComparator) { it.filename })
else -> list.sortedWith(compareBy({ !it.isDir }, { it.lastModify }))
} else when (sortKey) {
RemoteBookSort.Name -> list.sortedWith { o1, o2 ->
val compare = -compareValues(o1.isDir, o2.isDir)
if (compare == 0) {
return@sortedWith -AlphanumComparator.compare(o1.filename, o2.filename)
}
return@sortedWith compare
}
else -> list.sortedWith { o1, o2 ->
val compare = -compareValues(o1.isDir, o2.isDir)
if (compare == 0) {
return@sortedWith -compareValues(o1.lastModify, o2.lastModify)
}
return@sortedWith compare
}
}
}.flowOn(Dispatchers.IO)
private var remoteBookWebDav: RemoteBookWebDav? = null
var isDefaultWebdav = false
fun initData(onSuccess: () -> Unit) {
execute {
isDefaultWebdav = false
appDb.serverDao.get(AppConfig.remoteServerId)?.getWebDavConfig()?.let {
val authorization = Authorization(it)
remoteBookWebDav = RemoteBookWebDav(it.url, authorization, AppConfig.remoteServerId)
return@execute
}
isDefaultWebdav = true
remoteBookWebDav = AppWebDav.defaultBookWebDav
?: throw NoStackTraceException("webDav没有配置")
}.onError {
context.toastOnUi("初始化webDav出错:${it.localizedMessage}")
}.onSuccess {
onSuccess.invoke()
}
}
fun loadRemoteBookList(path: String?, loadCallback: (loading: Boolean) -> Unit) {
executeLazy {
val bookWebDav = remoteBookWebDav
?: throw NoStackTraceException("没有配置webDav")
dataCallback?.clear()
val url = path ?: bookWebDav.rootBookUrl
val bookList = bookWebDav.getRemoteBookList(url)
dataCallback?.setItems(bookList)
}.onError {
AppLog.put("获取webDav书籍出错\n${it.localizedMessage}", it)
context.toastOnUi("获取webDav书籍出错\n${it.localizedMessage}")
}.onStart {
loadCallback.invoke(true)
}.onFinally {
loadCallback.invoke(false)
}.start()
}
fun addToBookshelf(remoteBooks: HashSet<RemoteBook>, finally: () -> Unit) {
execute {
val bookWebDav = remoteBookWebDav
?: throw NoStackTraceException("没有配置webDav")
remoteBooks.forEach { remoteBook ->
val downloadBookUri = bookWebDav.downloadRemoteBook(remoteBook)
LocalBook.importFiles(downloadBookUri).forEach { book ->
book.origin = BookType.webDavTag + CustomUrl(remoteBook.path)
.putAttribute("serverID", bookWebDav.serverID)
.toString()
book.save()
}
remoteBook.isOnBookShelf = true
}
}.onError {
AppLog.put("导入出错\n${it.localizedMessage}", it)
context.toastOnUi("导入出错\n${it.localizedMessage}")
if (it is SecurityException) {
permissionDenialLiveData.postValue(1)
}
}.onFinally {
finally.invoke()
}
}
fun updateCallBackFlow(filterKey: String?) {
dataCallback?.screen(filterKey)
}
interface DataCallback {
fun setItems(remoteFiles: List<RemoteBook>)
fun addItems(remoteFiles: List<RemoteBook>)
fun clear()
fun screen(key: String?)
}
}
@@ -0,0 +1,131 @@
package io.legato.kazusa.ui.book.import.remote
import android.os.Bundle
import android.text.InputType
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.data.entities.Server
import io.legato.kazusa.data.entities.rule.RowUi
import io.legato.kazusa.databinding.DialogWebdavServerBinding
import io.legato.kazusa.databinding.ItemSourceEditBinding
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import org.json.JSONObject
class ServerConfigDialog() : BaseDialogFragment(R.layout.dialog_webdav_server, true),
Toolbar.OnMenuItemClickListener {
constructor(id: Long) : this() {
arguments = Bundle().apply {
putLong("id", id)
}
}
private val binding by viewBinding(DialogWebdavServerBinding::bind)
private val viewModel by viewModels<ServerConfigViewModel>()
private val webDavServerUi = listOf(
RowUi("url"),
RowUi("username"),
RowUi("password", RowUi.Type.password)
)
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//inding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.inflateMenu(R.menu.server_config)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
viewModel.init(arguments?.getLong("id")) {
upConfigView(viewModel.mServer)
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_save -> getServer().let {
viewModel.save(it) {
dismissAllowingStateLoss()
}
}
}
return true
}
private fun upConfigView(server: Server?) {
binding.etName.setText(server?.name)
binding.spType.setSelection(
when (server?.type) {
else -> 0
}
)
when (server?.type) {
else -> upWebDavServerUi(server?.getConfigJsonObject())
}
}
private fun upWebDavServerUi(config: JSONObject?) {
webDavServerUi.forEachIndexed { index, rowUi ->
when (rowUi.type) {
RowUi.Type.text -> ItemSourceEditBinding.inflate(
layoutInflater,
binding.root,
false
).let {
binding.flexbox.addView(it.root)
it.root.id = index + 1000
it.textInputLayout.hint = rowUi.name
it.editText.setText(config?.getString(rowUi.name))
}
RowUi.Type.password -> ItemSourceEditBinding.inflate(
layoutInflater,
binding.root,
false
).let {
binding.flexbox.addView(it.root)
it.root.id = index + 1000
it.textInputLayout.hint = rowUi.name
it.editText.inputType =
InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT
it.editText.setText(config?.getString(rowUi.name))
}
}
}
}
private fun getServer(): Server {
val server = viewModel.mServer?.copy() ?: Server()
server.name = binding.etName.text.toString()
server.type = when (binding.spType.selectedItemPosition) {
else -> Server.TYPE.WEBDAV
}
server.config = when (server.type) {
else -> GSON.toJson(getWebDavConfig())
}
return server
}
private fun getWebDavConfig(): HashMap<String, String> {
val data = hashMapOf<String, String>()
webDavServerUi.forEachIndexed { index, rowUi ->
val rowView = binding.root.findViewById<View>(index + 1000)
ItemSourceEditBinding.bind(rowView).editText.text?.let {
data[rowUi.name] = it.toString()
}
}
return data
}
}
@@ -0,0 +1,41 @@
package io.legato.kazusa.ui.book.import.remote
import android.app.Application
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Server
import io.legato.kazusa.utils.toastOnUi
class ServerConfigViewModel(application: Application): BaseViewModel(application) {
var mServer: Server? = null
fun init(id: Long?, onSuccess: () -> Unit) {
//mServer不为空可能是旋转屏幕界面重新创建,不用更新数据
if (mServer != null) return
execute {
mServer = if (id != null) {
appDb.serverDao.get(id)
} else {
Server()
}
}.onSuccess {
onSuccess.invoke()
}
}
fun save(server: Server, onSuccess: () -> Unit) {
execute {
mServer?.let {
appDb.serverDao.delete(it)
}
mServer = server
appDb.serverDao.insert(server)
}.onSuccess {
onSuccess.invoke()
}.onError {
context.toastOnUi("保存出错\n${it.localizedMessage}")
}
}
}
@@ -0,0 +1,167 @@
package io.legato.kazusa.ui.book.import.remote
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.constant.AppConst.DEFAULT_WEBDAV_ID
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Server
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemServerSelectBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.backgroundColor
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
/**
* 服务器配置
*/
class ServersDialog : BaseDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener {
val binding by viewBinding(DialogRecyclerViewBinding::bind)
val viewModel by viewModels<ServersViewModel>()
private val callback get() = (activity as? Callback)
private val adapter by lazy { ServersAdapter(requireContext()) }
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.server_config)
initView()
initData()
}
private fun initView() {
binding.toolBar.inflateMenu(R.menu.servers)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
binding.recyclerView.adapter = adapter
binding.tvFooterLeft.text = getString(R.string.text_default)
binding.tvFooterLeft.visible()
binding.tvFooterLeft.setOnClickListener {
AppConfig.remoteServerId = DEFAULT_WEBDAV_ID
dismissAllowingStateLoss()
}
binding.tvCancel.visible()
binding.tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
binding.tvOk.visible()
binding.tvOk.setOnClickListener {
AppConfig.remoteServerId = adapter.selectServerId
dismissAllowingStateLoss()
}
}
private fun initData() {
lifecycleScope.launch {
appDb.serverDao.observeAll().catch {
AppLog.put("服务器配置界面获取数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).collect {
adapter.setItems(it)
}
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_add -> showDialogFragment(ServerConfigDialog())
}
return true
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
callback?.onDialogDismiss("serversDialog")
}
inner class ServersAdapter(context: Context) :
RecyclerAdapter<Server, ItemServerSelectBinding>(context) {
var selectServerId: Long = AppConfig.remoteServerId
override fun getViewBinding(parent: ViewGroup): ItemServerSelectBinding {
return ItemServerSelectBinding.inflate(inflater, parent, false)
}
override fun registerListener(holder: ItemViewHolder, binding: ItemServerSelectBinding) {
binding.rbServer.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed && isChecked) {
selectServerId = getItemByLayoutPosition(holder.layoutPosition)!!.id
adapter.updateItems(0, itemCount - 1, "upSelect")
}
}
binding.ivEdit.setOnClickListener {
getItemByLayoutPosition(holder.layoutPosition)?.let { server ->
showDialogFragment(ServerConfigDialog(server.id))
}
}
binding.ivDelete.setOnClickListener {
alert {
setTitle(R.string.draw)
setMessage(R.string.sure_del)
yesButton {
getItemByLayoutPosition(holder.layoutPosition)?.let { server ->
viewModel.delete(server)
}
}
noButton()
}
}
}
override fun convert(
holder: ItemViewHolder,
binding: ItemServerSelectBinding,
item: Server,
payloads: MutableList<Any>
) {
if (payloads.isEmpty()) {
//binding.root.setBackgroundColor(context.backgroundColor)
binding.rbServer.text = item.name
binding.rbServer.isChecked = item.id == selectServerId
} else {
binding.rbServer.isChecked = item.id == selectServerId
}
}
}
interface Callback {
fun onDialogDismiss(tag: String)
}
}
@@ -0,0 +1,17 @@
package io.legato.kazusa.ui.book.import.remote
import android.app.Application
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Server
class ServersViewModel(application: Application): BaseViewModel(application) {
fun delete(server: Server) {
execute {
appDb.serverDao.delete(server)
}
}
}
@@ -0,0 +1,787 @@
package io.legato.kazusa.ui.book.info
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.RenderEffect
import android.graphics.Shader
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.CheckBox
import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.databinding.ActivityBookInfoBinding
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.AppWebDav
import io.legato.kazusa.help.book.addType
import io.legato.kazusa.help.book.getRemoteUrl
import io.legato.kazusa.help.book.isAudio
import io.legato.kazusa.help.book.isImage
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.book.isLocalTxt
import io.legato.kazusa.help.book.isWebFile
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.LocalConfig
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.lib.dialogs.selector
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.backgroundColor
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
import io.legato.kazusa.model.BookCover
import io.legato.kazusa.model.remote.RemoteBookWebDav
import io.legato.kazusa.ui.about.AppLogDialog
import io.legato.kazusa.ui.book.audio.AudioPlayActivity
import io.legato.kazusa.ui.book.changecover.ChangeCoverDialog
import io.legato.kazusa.ui.book.changesource.ChangeBookSourceDialog
import io.legato.kazusa.ui.book.group.GroupSelectDialog
import io.legato.kazusa.ui.book.info.edit.BookInfoEditActivity
import io.legato.kazusa.ui.book.manga.ReadMangaActivity
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED
import io.legato.kazusa.ui.book.search.SearchActivity
import io.legato.kazusa.ui.book.source.edit.BookSourceEditActivity
import io.legato.kazusa.ui.book.toc.TocActivityResult
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.ui.login.SourceLoginActivity
import io.legato.kazusa.ui.widget.dialog.PhotoDialog
import io.legato.kazusa.ui.widget.dialog.VariableDialog
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.utils.ConvertUtils
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.StartActivityContract
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.longToastOnUi
import io.legato.kazusa.utils.openFileUri
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.shareWithQr
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class BookInfoActivity :
VMBaseActivity<ActivityBookInfoBinding, BookInfoViewModel>(),
GroupSelectDialog.CallBack,
ChangeBookSourceDialog.CallBack,
ChangeCoverDialog.CallBack,
VariableDialog.Callback {
private val tocActivityResult = registerForActivityResult(TocActivityResult()) {
it?.let {
viewModel.getBook(false)?.let { book ->
lifecycleScope.launch {
withContext(IO) {
book.durChapterIndex = it.first
book.durChapterPos = it.second
chapterChanged = it.third
appDb.bookDao.update(book)
}
startReadActivity(book)
}
}
} ?: let {
if (!viewModel.inBookshelf) {
viewModel.delBook()
}
}
}
private val localBookTreeSelect = registerForActivityResult(HandleFileContract()) {
it.uri?.let { treeUri ->
AppConfig.defaultBookTreeUri = treeUri.toString()
}
}
private val readBookResult = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
viewModel.upBook(intent)
when (it.resultCode) {
RESULT_OK -> {
viewModel.inBookshelf = true
upTvBookshelf()
}
RESULT_DELETED -> {
setResult(RESULT_OK)
finish()
}
}
}
private val infoEditResult = registerForActivityResult(
StartActivityContract(BookInfoEditActivity::class.java)
) {
if (it.resultCode == RESULT_OK) {
viewModel.upEditBook()
}
}
private val editSourceResult = registerForActivityResult(
StartActivityContract(BookSourceEditActivity::class.java)
) {
if (it.resultCode == RESULT_CANCELED) {
return@registerForActivityResult
}
book?.let { book ->
viewModel.bookSource = appDb.bookSourceDao.getBookSource(book.origin)
viewModel.refreshBook(book)
}
}
private var chapterChanged = false
private val waitDialog by lazy { WaitDialog(this) }
private var editMenuItem: MenuItem? = null
private val book get() = viewModel.getBook(false)
override val binding by viewBinding(ActivityBookInfoBinding::inflate)
override val viewModel by viewModels<BookInfoViewModel>()
@SuppressLint("PrivateResource")
override fun onActivityCreated(savedInstanceState: Bundle?) {
setSupportActionBar(binding.topBar)
// binding.refreshLayout?.setColorSchemeColors(accentColor)
// binding.llInfo.setBackgroundColor(backgroundColor)
// binding.flAction.setBackgroundColor(bottomBackground)
//binding.flAction.applyNavigationBarPadding()
binding.btnShelf?.text = getString(R.string.remove_from_bookshelf)
binding.tvToc.text = getString(R.string.toc_s, getString(R.string.loading))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
binding.tvDetail?.let { it.revealOnFocusHint = false }
}
viewModel.bookData.observe(this) { showBook(it) }
viewModel.chapterListData.observe(this) { upLoading(false, it) }
viewModel.waitDialogData.observe(this) { upWaitDialogStatus(it) }
viewModel.initData(intent)
initViewEvent()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_info, menu)
editMenuItem = menu.findItem(R.id.menu_edit)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_can_update)?.isChecked =
viewModel.bookData.value?.canUpdate ?: true
menu.findItem(R.id.menu_split_long_chapter)?.isChecked =
viewModel.bookData.value?.getSplitLongChapter() ?: true
menu.findItem(R.id.menu_login)?.isVisible =
!viewModel.bookSource?.loginUrl.isNullOrBlank()
menu.findItem(R.id.menu_set_source_variable)?.isVisible =
viewModel.bookSource != null
menu.findItem(R.id.menu_set_book_variable)?.isVisible =
viewModel.bookSource != null
menu.findItem(R.id.menu_can_update)?.isVisible =
viewModel.bookSource != null
menu.findItem(R.id.menu_split_long_chapter)?.isVisible =
viewModel.bookData.value?.isLocalTxt ?: false
menu.findItem(R.id.menu_upload)?.isVisible =
viewModel.bookData.value?.isLocal ?: false
menu.findItem(R.id.menu_delete_alert)?.isChecked =
LocalConfig.bookInfoDeleteAlert
return super.onMenuOpened(featureId, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_edit -> {
viewModel.getBook()?.let {
infoEditResult.launch {
putExtra("bookUrl", it.bookUrl)
}
}
}
R.id.menu_share_it -> {
viewModel.getBook()?.let {
val bookJson = GSON.toJson(it)
val shareStr = "${it.bookUrl}#$bookJson"
shareWithQr(shareStr, it.name)
}
}
R.id.menu_refresh -> {
refreshBook()
}
R.id.menu_login -> viewModel.bookSource?.let {
startActivity<SourceLoginActivity> {
putExtra("type", "bookSource")
putExtra("key", it.bookSourceUrl)
}
}
R.id.menu_top -> viewModel.topBook()
R.id.menu_set_source_variable -> setSourceVariable()
R.id.menu_set_book_variable -> setBookVariable()
R.id.menu_copy_book_url -> viewModel.getBook()?.bookUrl?.let {
sendToClip(it)
}
R.id.menu_copy_toc_url -> viewModel.getBook()?.tocUrl?.let {
sendToClip(it)
}
R.id.menu_can_update -> {
viewModel.getBook()?.let {
it.canUpdate = !it.canUpdate
if (viewModel.inBookshelf) {
if (!it.canUpdate) {
it.removeType(BookType.updateError)
}
viewModel.saveBook(it)
}
}
}
R.id.menu_clear_cache -> viewModel.clearCache()
R.id.menu_log -> showDialogFragment<AppLogDialog>()
R.id.menu_split_long_chapter -> {
upLoading(true)
viewModel.getBook()?.let {
it.setSplitLongChapter(!item.isChecked)
viewModel.loadBookInfo(it, false)
}
item.isChecked = !item.isChecked
if (!item.isChecked) longToastOnUi(R.string.need_more_time_load_content)
}
R.id.menu_delete_alert -> LocalConfig.bookInfoDeleteAlert = !item.isChecked
R.id.menu_upload -> {
viewModel.getBook()?.let { book ->
book.getRemoteUrl()?.let {
alert(R.string.draw, R.string.sure_upload) {
okButton {
upLoadBook(book)
}
cancelButton()
}
} ?: upLoadBook(book)
}
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun observeLiveBus() {
viewModel.actionLive.observe(this) {
when (it) {
"selectBooksDir" -> localBookTreeSelect.launch {
title = getString(R.string.select_book_folder)
}
}
}
}
// override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
// if (ev.action == MotionEvent.ACTION_DOWN) {
// currentFocus?.let {
// if (it === binding.tvIntro && binding.tvIntro.hasSelection()) {
// it.clearFocus()
// }
// }
// }
// return super.dispatchTouchEvent(ev)
// }
private fun refreshBook() {
upLoading(true)
viewModel.getBook()?.let {
viewModel.refreshBook(it)
}
}
private fun upLoadBook(
book: Book,
bookWebDav: RemoteBookWebDav? = AppWebDav.defaultBookWebDav,
) {
lifecycleScope.launch {
waitDialog.setText("上传中.....")
waitDialog.show()
try {
bookWebDav
?.upload(book)
?: throw NoStackTraceException("未配置webDav")
//更新书籍最后更新时间,使之比远程书籍的时间新
book.lastCheckTime = System.currentTimeMillis()
viewModel.saveBook(book)
} catch (e: Exception) {
toastOnUi(e.localizedMessage)
} finally {
waitDialog.dismiss()
}
}
}
private fun showBook(book: Book) = binding.run {
showCover(book)
tvName.text = book.name
tvAuthor.text = getString(R.string.author_show, book.getRealAuthor())
tvOrigin.text = getString(R.string.origin_show, book.originName)
tvLasted.text = getString(R.string.lasted_show, book.latestChapterTitle)
tvDetail?.let { it.text = book.getDisplayIntro() }
llToc?.visible(!book.isWebFile)
upTvBookshelf()
upKinds(book)
upGroup(book.group)
}
private fun upKinds(book: Book) = binding.run {
lifecycleScope.launch {
var kinds = book.getKindList()
if (book.isLocal) {
withContext(IO) {
val size = FileDoc.fromFile(book.bookUrl).size
if (size > 0) {
kinds = kinds.toMutableList()
kinds.add(ConvertUtils.formatFileSize(size))
}
}
}
if (kinds.isEmpty()) {
lbKind.gone()
} else {
lbKind.visible()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
lbKind.setLabels(kinds)
}
}
}
}
private fun showCover(book: Book) {
binding.ivCover.load(book.getDisplayCover(), book.name, book.author, false, book.origin) {
if (!AppConfig.isEInkMode) {
//高版本使用RenderEffect
BookCover.load(this, book.getDisplayCover(), false, book.origin)
.into(binding.bgBook)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BookCover.load(this, book.getDisplayCover(), false, book.origin)
.into(binding.bgBook)
val blurEffect = RenderEffect.createBlurEffect(60f, 60f, Shader.TileMode.CLAMP)
binding.bgBook.setRenderEffect(blurEffect)
}else{
BookCover.loadBlur(this, book.getDisplayCover(), false, book.origin)
.into(binding.bgBook)
}
}
}
}
private fun upLoading(isLoading: Boolean, chapterList: List<BookChapter>? = null) {
when {
isLoading -> {
binding.tvToc.text = getString(R.string.toc_s, getString(R.string.loading))
}
chapterList.isNullOrEmpty() -> {
binding.tvToc.text = getString(
R.string.toc_s,
getString(R.string.error_load_toc)
)
}
else -> {
book?.let {
binding.tvToc.text = getString(R.string.toc_s, it.durChapterTitle)
binding.tvLasted.text = getString(R.string.lasted_show, it.latestChapterTitle)
}
}
}
}
private fun upTvBookshelf() {
binding.btnShelf?.apply {
if (viewModel.inBookshelf) {
text = getString(R.string.remove_from_bookshelf)
icon = ContextCompat.getDrawable(context, R.drawable.ic_clear_all)
} else {
text = getString(R.string.add_to_bookshelf)
icon = ContextCompat.getDrawable(context, R.drawable.ic_star)
}
}
editMenuItem?.isVisible = viewModel.inBookshelf
}
private fun upGroup(groupId: Long) {
viewModel.loadGroup(groupId) {
if (it.isNullOrEmpty()) {
binding.tvGroup.text = if (book?.isLocal == true) {
getString(R.string.group_s, getString(R.string.local_no_group))
} else {
getString(R.string.group_s, getString(R.string.no_group))
}
} else {
binding.tvGroup.text = getString(R.string.group_s, it)
}
}
}
private fun initViewEvent() = binding.run {
ivCover.setOnClickListener {
viewModel.getBook()?.let {
showDialogFragment(
ChangeCoverDialog(it.name, it.author)
)
}
}
ivCover.setOnLongClickListener {
viewModel.getBook()?.getDisplayCover()?.let { path ->
showDialogFragment(PhotoDialog(path))
}
true
}
btnRead?.setOnClickListener {
viewModel.getBook()?.let { book ->
if (book.isWebFile) {
showWebFileDownloadAlert {
readBook(it)
}
} else {
readBook(book)
}
}
}
btnShelf?.setOnClickListener {
viewModel.getBook()?.let { book ->
if (viewModel.inBookshelf) {
deleteBook()
} else {
if (book.isWebFile) {
showWebFileDownloadAlert()
} else {
viewModel.addToBookshelf {
upTvBookshelf()
}
}
}
}
}
tvOrigin.setOnClickListener {
viewModel.getBook()?.let { book ->
if (book.isLocal) return@let
if (!appDb.bookSourceDao.has(book.origin)) {
toastOnUi(R.string.error_no_source)
return@let
}
editSourceResult.launch {
putExtra("sourceUrl", book.origin)
}
}
}
btnChangeSource?.setOnClickListener {
viewModel.getBook()?.let { book ->
showDialogFragment(ChangeBookSourceDialog(book.name, book.author))
}
}
tvTocView.setOnClickListener {
if (viewModel.chapterListData.value.isNullOrEmpty()) {
toastOnUi(R.string.chapter_list_empty)
return@setOnClickListener
}
viewModel.getBook()?.let { book ->
if (!viewModel.inBookshelf) {
viewModel.saveBook(book) {
viewModel.saveChapterList {
openChapterList()
}
}
} else {
openChapterList()
}
}
}
tvChangeGroup.setOnClickListener {
viewModel.getBook()?.let {
showDialogFragment(
GroupSelectDialog(it.group)
)
}
}
tvAuthor.setOnClickListener {
viewModel.getBook(false)?.let { book ->
startActivity<SearchActivity> {
putExtra("key", book.author)
}
}
}
tvName.setOnClickListener {
viewModel.getBook(false)?.let { book ->
startActivity<SearchActivity> {
putExtra("key", book.name)
}
}
}
refreshLayout?.setOnRefreshListener {
refreshLayout.isRefreshing = false
refreshBook()
}
}
private fun setSourceVariable() {
lifecycleScope.launch {
val source = viewModel.bookSource
if (source == null) {
toastOnUi("书源不存在")
return@launch
}
val comment =
source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
val variable = withContext(IO) { source.getVariable() }
showDialogFragment(
VariableDialog(
getString(R.string.set_source_variable),
source.getKey(),
variable,
comment
)
)
}
}
private fun setBookVariable() {
lifecycleScope.launch {
val source = viewModel.bookSource
if (source == null) {
toastOnUi("书源不存在")
return@launch
}
val book = viewModel.getBook() ?: return@launch
val variable = withContext(IO) { book.getCustomVariable() }
val comment = source.getDisplayVariableComment(
"""书籍变量可在js中通过book.getVariable("custom")获取"""
)
showDialogFragment(
VariableDialog(
getString(R.string.set_book_variable),
book.bookUrl,
variable,
comment
)
)
}
}
override fun setVariable(key: String, variable: String?) {
when (key) {
viewModel.bookSource?.getKey() -> viewModel.bookSource?.setVariable(variable)
viewModel.bookData.value?.bookUrl -> viewModel.bookData.value?.let {
it.putCustomVariable(variable)
viewModel.saveBook(it)
}
}
}
@SuppressLint("InflateParams")
private fun deleteBook() {
viewModel.getBook()?.let {
if (LocalConfig.bookInfoDeleteAlert) {
alert(
titleResource = R.string.draw,
messageResource = R.string.sure_del
) {
var checkBox: CheckBox? = null
if (it.isLocal) {
checkBox = CheckBox(this@BookInfoActivity).apply {
setText(R.string.delete_book_file)
isChecked = LocalConfig.deleteBookOriginal
}
val view = LinearLayout(this@BookInfoActivity).apply {
setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
addView(checkBox)
}
customView { view }
}
yesButton {
if (checkBox != null) {
LocalConfig.deleteBookOriginal = checkBox.isChecked
}
viewModel.delBook(LocalConfig.deleteBookOriginal) {
setResult(RESULT_OK)
finish()
}
}
noButton()
}
} else {
viewModel.delBook(LocalConfig.deleteBookOriginal) {
setResult(RESULT_OK)
finish()
}
}
}
}
private fun openChapterList() {
viewModel.getBook()?.let {
tocActivityResult.launch(it.bookUrl)
}
}
private fun showWebFileDownloadAlert(
onClick: ((Book) -> Unit)? = null,
) {
val webFiles = viewModel.webFiles
if (webFiles.isEmpty()) {
toastOnUi("Unexpected webFileData")
return
}
selector(
R.string.download_and_import_file,
webFiles
) { _, webFile, _ ->
if (webFile.isSupported) {
/* import */
viewModel.importOrDownloadWebFile<Book>(webFile) {
onClick?.invoke(it)
}
} else if (webFile.isSupportDecompress) {
/* 解压筛选后再选择导入项 */
viewModel.importOrDownloadWebFile<Uri>(webFile) { uri ->
viewModel.getArchiveFilesName(uri) { fileNames ->
if (fileNames.size == 1) {
viewModel.importArchiveBook(uri, fileNames[0]) {
onClick?.invoke(it)
}
} else {
showDecompressFileImportAlert(uri, fileNames, onClick)
}
}
}
} else {
alert(
title = getString(R.string.draw),
message = getString(R.string.file_not_supported, webFile.name)
) {
neutralButton(R.string.open_fun) {
/* download only */
viewModel.importOrDownloadWebFile<Uri>(webFile) {
openFileUri(it, "*/*")
}
}
noButton()
}
}
}
}
private fun showDecompressFileImportAlert(
archiveFileUri: Uri,
fileNames: List<String>,
success: ((Book) -> Unit)? = null,
) {
if (fileNames.isEmpty()) {
toastOnUi(R.string.unsupport_archivefile_entry)
return
}
selector(
R.string.import_select_book,
fileNames
) { _, name, _ ->
viewModel.importArchiveBook(archiveFileUri, name) {
success?.invoke(it)
}
}
}
private fun readBook(book: Book) {
if (!viewModel.inBookshelf) {
book.addType(BookType.notShelf)
viewModel.saveBook(book) {
viewModel.saveChapterList {
startReadActivity(book)
}
}
} else {
viewModel.saveBook(book) {
startReadActivity(book)
}
}
}
private fun startReadActivity(book: Book) {
when {
book.isAudio -> readBookResult.launch(
Intent(this, AudioPlayActivity::class.java)
.putExtra("bookUrl", book.bookUrl)
.putExtra("inBookshelf", viewModel.inBookshelf)
)
else -> readBookResult.launch(
Intent(
this,
if (book.isImage && AppConfig.showMangaUi) ReadMangaActivity::class.java
else ReadBookActivity::class.java
)
.putExtra("bookUrl", book.bookUrl)
.putExtra("inBookshelf", viewModel.inBookshelf)
.putExtra("chapterChanged", chapterChanged)
)
}
}
override val oldBook: Book?
get() = viewModel.bookData.value
override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
viewModel.changeTo(source, book, toc)
}
override fun coverChangeTo(coverUrl: String) {
viewModel.bookData.value?.let { book ->
book.customCoverUrl = coverUrl
showCover(book)
if (viewModel.inBookshelf) {
viewModel.saveBook(book)
}
}
}
override fun upGroup(requestCode: Int, groupId: Long) {
upGroup(groupId)
viewModel.getBook()?.let { book ->
book.group = groupId
if (viewModel.inBookshelf) {
viewModel.saveBook(book)
} else if (groupId > 0) {
viewModel.addToBookshelf {
upTvBookshelf()
}
}
}
}
private fun upWaitDialogStatus(isShow: Boolean) {
val showText = "Loading....."
if (isShow) {
waitDialog.run {
setText(showText)
show()
}
} else {
waitDialog.dismiss()
}
}
}
@@ -0,0 +1,524 @@
package io.legato.kazusa.ui.book.info
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.exception.NoBooksDirException
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.AppWebDav
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.getExportFileName
import io.legato.kazusa.help.book.getRemoteUrl
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.book.isNotShelf
import io.legato.kazusa.help.book.isSameNameAuthor
import io.legato.kazusa.help.book.isWebFile
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.book.updateTo
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.lib.webdav.ObjectNotFoundException
import io.legato.kazusa.model.AudioPlay
import io.legato.kazusa.model.BookCover
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.model.ReadManga
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.ArchiveUtils
import io.legato.kazusa.utils.UrlUtil
import io.legato.kazusa.utils.isContentScheme
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
class BookInfoViewModel(application: Application) : BaseViewModel(application) {
val bookData = MutableLiveData<Book>()
val chapterListData = MutableLiveData<List<BookChapter>>()
val webFiles = mutableListOf<WebFile>()
var inBookshelf = false
var bookSource: BookSource? = null
private var changeSourceCoroutine: Coroutine<*>? = null
val waitDialogData = MutableLiveData<Boolean>()
val actionLive = MutableLiveData<String>()
fun initData(intent: Intent) {
execute {
val name = intent.getStringExtra("name") ?: ""
val author = intent.getStringExtra("author") ?: ""
val bookUrl = intent.getStringExtra("bookUrl") ?: ""
appDb.bookDao.getBook(name, author)?.let {
inBookshelf = !it.isNotShelf
upBook(it)
return@execute
}
if (bookUrl.isNotBlank()) {
appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let {
upBook(it)
return@execute
}
}
appDb.searchBookDao.getFirstByNameAuthor(name, author)?.toBook()?.let {
upBook(it)
return@execute
}
throw NoStackTraceException("未找到书籍")
}.onError {
AppLog.put(it.localizedMessage, it)
context.toastOnUi(it.localizedMessage)
}
}
fun upBook(intent: Intent) {
execute {
val name = intent.getStringExtra("name") ?: ""
val author = intent.getStringExtra("author") ?: ""
appDb.bookDao.getBook(name, author)?.let { book ->
upBook(book)
}
}
}
private fun upBook(book: Book) {
execute {
bookData.postValue(book)
upCoverByRule(book)
bookSource = if (book.isLocal) null else
appDb.bookSourceDao.getBookSource(book.origin)
if (book.tocUrl.isEmpty() && !book.isLocal) {
loadBookInfo(book, runPreUpdateJs = inBookshelf)
} else {
val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl)
if (chapterList.isNotEmpty()) {
chapterListData.postValue(chapterList)
} else {
loadChapter(book)
}
}
}
}
private fun upCoverByRule(book: Book) {
execute {
if (book.coverUrl.isNullOrBlank() && book.customCoverUrl.isNullOrBlank()) {
val coverUrl = BookCover.searchCover(book)
if (coverUrl.isNullOrBlank()) {
return@execute
}
book.customCoverUrl = coverUrl
bookData.postValue(book)
if (inBookshelf) {
saveBook(book)
}
}
}
}
fun refreshBook(book: Book) {
executeLazy(executeContext = IO) {
if (book.isLocal) {
book.tocUrl = ""
book.getRemoteUrl()?.let {
val bookWebDav = AppWebDav.defaultBookWebDav
?: throw NoStackTraceException("webDav没有配置")
val remoteBook = bookWebDav.getRemoteBook(it)
if (remoteBook == null) {
book.origin = BookType.localTag
} else if (remoteBook.lastModify > book.lastCheckTime) {
val uri = bookWebDav.downloadRemoteBook(remoteBook)
book.bookUrl = if (uri.isContentScheme()) uri.toString() else uri.path!!
book.lastCheckTime = remoteBook.lastModify
}
}
} else {
val bs = bookSource ?: return@executeLazy
if (book.originName != bs.bookSourceName) {
book.originName = bs.bookSourceName
}
}
}.onError {
when (it) {
is ObjectNotFoundException -> {
book.origin = BookType.localTag
}
else -> {
AppLog.put("下载远程书籍<${book.name}>失败", it)
}
}
}.onFinally {
loadBookInfo(book, false)
}.start()
}
fun loadBookInfo(
book: Book,
canReName: Boolean = true,
runPreUpdateJs: Boolean = true,
scope: CoroutineScope = viewModelScope
) {
if (book.isLocal) {
LocalBook.upBookInfo(book)
bookData.postValue(book)
loadChapter(book)
} else {
val bookSource = bookSource ?: let {
chapterListData.postValue(emptyList())
context.toastOnUi(R.string.error_no_source)
return
}
WebBook.getBookInfo(scope, bookSource, book, canReName = canReName)
.onSuccess(IO) {
val dbBook = appDb.bookDao.getBook(book.name, book.author)
if (!inBookshelf && dbBook != null && !dbBook.isNotShelf && dbBook.origin == book.origin) {
/**
* book 来自搜索时(inBookshelf == false),搜索的书名不存在于书架,但是加载详情后,书名更新,存在同名书籍
* 此时 book 的数据会与数据库中的不同,需要更新 #3652 #4619
* book 加载详情后虽然书名作者相同,但是又可能不是数据库中(书源不同)的那本书 #3149
*/
dbBook.updateTo(it)
inBookshelf = true
}
bookData.postValue(it)
if (inBookshelf) {
it.save()
}
if (it.isWebFile) {
loadWebFile(it)
} else {
loadChapter(it, runPreUpdateJs)
}
}.onError {
AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it)
context.toastOnUi(R.string.error_get_book_info)
}
}
}
private fun loadChapter(
book: Book,
runPreUpdateJs: Boolean = true,
scope: CoroutineScope = viewModelScope
) {
if (book.isLocal) {
execute(scope) {
LocalBook.getChapterList(book).let {
appDb.bookDao.update(book)
appDb.bookChapterDao.delByBook(book.bookUrl)
appDb.bookChapterDao.insert(*it.toTypedArray())
ReadBook.onChapterListUpdated(book)
bookData.postValue(book)
chapterListData.postValue(it)
}
}.onError {
context.toastOnUi("LoadTocError:${it.localizedMessage}")
}
} else {
val bookSource = bookSource ?: let {
chapterListData.postValue(emptyList())
context.toastOnUi(R.string.error_no_source)
return
}
val oldBook = book.copy()
WebBook.getChapterList(scope, bookSource, book, runPreUpdateJs)
.onSuccess(IO) {
if (inBookshelf) {
book.save()
/**
* runPreUpdateJs 有可能会修改 book 的 bookUrl
*/
if (oldBook.bookUrl != book.bookUrl) {
BookHelp.updateCacheFolder(oldBook, book)
}
appDb.bookChapterDao.delByBook(oldBook.bookUrl)
appDb.bookChapterDao.insert(*it.toTypedArray())
ReadBook.onChapterListUpdated(book)
}
bookData.postValue(book)
chapterListData.postValue(it)
}.onError {
chapterListData.postValue(emptyList())
AppLog.put("获取目录失败\n${it.localizedMessage}", it)
context.toastOnUi(R.string.error_get_chapter_list)
}
}
}
fun loadGroup(groupId: Long, success: ((groupNames: String?) -> Unit)) {
execute {
appDb.bookGroupDao.getGroupNames(groupId).joinToString(",")
}.onSuccess {
success.invoke(it)
}
}
private fun loadWebFile(book: Book) {
execute {
webFiles.clear()
val fileNameNoExtension = if (book.author.isBlank()) book.name
else "${book.name} 作者:${book.author}"
book.downloadUrls!!.map {
val analyzeUrl = AnalyzeUrl(
it, source = bookSource,
coroutineContext = coroutineContext
)
val mFileName = UrlUtil.getFileName(analyzeUrl)
?: "${fileNameNoExtension}.${analyzeUrl.type}"
WebFile(it, mFileName)
}
}.onError {
context.toastOnUi("LoadWebFileError\n${it.localizedMessage}")
}.onSuccess {
webFiles.addAll(it)
}
}
/* 导入或者下载在线文件 */
fun <T> importOrDownloadWebFile(webFile: WebFile, success: ((T) -> Unit)?) {
bookSource ?: return
execute {
waitDialogData.postValue(true)
if (webFile.isSupported) {
val book = LocalBook.importFileOnLine(
webFile.url,
bookData.value!!.getExportFileName(webFile.suffix),
bookSource
)
changeToLocalBook(book)
} else {
LocalBook.saveBookFile(
webFile.url,
bookData.value!!.getExportFileName(webFile.suffix),
bookSource
)
}
}.onSuccess {
@Suppress("unchecked_cast")
success?.invoke(it as T)
}.onError {
when (it) {
is NoBooksDirException -> actionLive.postValue("selectBooksDir")
else -> {
AppLog.put("ImportWebFileError\n${it.localizedMessage}", it)
context.toastOnUi("ImportWebFileError\n${it.localizedMessage}")
webFiles.remove(webFile)
}
}
}.onFinally {
waitDialogData.postValue(false)
}
}
fun getArchiveFilesName(archiveFileUri: Uri, onSuccess: (List<String>) -> Unit) {
execute {
ArchiveUtils.getArchiveFilesName(archiveFileUri) {
AppPattern.bookFileRegex.matches(it)
}
}.onError {
AppLog.put("getArchiveEntriesName Error:\n${it.localizedMessage}", it)
context.toastOnUi("getArchiveEntriesName Error:\n${it.localizedMessage}")
}.onSuccess {
onSuccess.invoke(it)
}
}
fun importArchiveBook(
archiveFileUri: Uri,
archiveEntryName: String,
success: ((Book) -> Unit)? = null
) {
execute {
val suffix = archiveEntryName.substringAfterLast(".")
LocalBook.importArchiveFile(
archiveFileUri,
bookData.value!!.getExportFileName(suffix)
) {
it.contains(archiveEntryName)
}.first()
}.onSuccess {
val book = changeToLocalBook(it)
success?.invoke(book)
}.onError {
AppLog.put("importArchiveBook Error:\n${it.localizedMessage}", it)
context.toastOnUi("importArchiveBook Error:\n${it.localizedMessage}")
}
}
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
changeSourceCoroutine?.cancel()
changeSourceCoroutine = execute {
bookSource = source
bookData.value?.migrateTo(book, toc)
if (inBookshelf) {
book.removeType(BookType.updateError)
bookData.value?.delete()
appDb.bookDao.insert(book)
appDb.bookChapterDao.insert(*toc.toTypedArray())
}
bookData.postValue(book)
chapterListData.postValue(toc)
}.onFinally {
postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
}
}
fun topBook() {
execute {
bookData.value?.let { book ->
val minOrder = appDb.bookDao.minOrder
book.order = minOrder - 1
book.durChapterTime = System.currentTimeMillis()
appDb.bookDao.update(book)
}
}
}
fun saveBook(book: Book?, success: (() -> Unit)? = null) {
book ?: return
execute {
if (book.order == 0) {
book.order = appDb.bookDao.minOrder - 1
}
appDb.bookDao.getBook(book.name, book.author)?.let {
book.durChapterIndex = it.durChapterIndex
book.durChapterPos = it.durChapterPos
book.durChapterTitle = it.durChapterTitle
}
book.save()
if (ReadBook.book?.isSameNameAuthor(book) == true) {
ReadBook.book = book
} else if (AudioPlay.book?.isSameNameAuthor(book) == true) {
AudioPlay.book = book
}
}.onSuccess {
success?.invoke()
}
}
fun saveChapterList(success: (() -> Unit)?) {
execute {
chapterListData.value?.let {
appDb.bookChapterDao.insert(*it.toTypedArray())
}
}.onSuccess {
success?.invoke()
}
}
fun addToBookshelf(success: (() -> Unit)?) {
execute {
bookData.value?.let { book ->
book.removeType(BookType.notShelf)
if (book.order == 0) {
book.order = appDb.bookDao.minOrder - 1
}
appDb.bookDao.getBook(book.name, book.author)?.let {
book.durChapterIndex = it.durChapterIndex
book.durChapterPos = it.durChapterPos
book.durChapterTitle = it.durChapterTitle
}
if (ReadBook.book?.isSameNameAuthor(book) == true) {
ReadBook.book = book
} else if (AudioPlay.book?.isSameNameAuthor(book) == true) {
AudioPlay.book = book
}
book.save()
}
chapterListData.value?.let {
appDb.bookChapterDao.insert(*it.toTypedArray())
}
inBookshelf = true
}.onSuccess {
success?.invoke()
}
}
fun getBook(toastNull: Boolean = true): Book? {
val book = bookData.value
if (toastNull && book == null) {
context.toastOnUi("book is null")
}
return book
}
fun delBook(deleteOriginal: Boolean = false, success: (() -> Unit)? = null) {
execute {
bookData.value?.let {
it.delete()
inBookshelf = false
if (it.isLocal) {
LocalBook.deleteBook(it, deleteOriginal)
}
}
}.onSuccess {
success?.invoke()
}
}
fun clearCache() {
execute {
BookHelp.clearCache(bookData.value!!)
if (ReadBook.book?.bookUrl == bookData.value!!.bookUrl) {
ReadBook.clearTextChapter()
}
if (ReadManga.book?.bookUrl == bookData.value!!.bookUrl) {
ReadManga.clearMangaChapter()
}
}.onSuccess {
context.toastOnUi(R.string.clear_cache_success)
}.onError {
context.toastOnUi("清理缓存出错\n${it.localizedMessage}")
}
}
fun upEditBook() {
bookData.value?.let {
appDb.bookDao.getBook(it.bookUrl)?.let { book ->
bookData.postValue(book)
}
}
}
private fun changeToLocalBook(localBook: Book): Book {
return LocalBook.mergeBook(localBook, bookData.value).let {
bookData.postValue(it)
loadChapter(it)
inBookshelf = true
it
}
}
data class WebFile(
val url: String,
val name: String,
) {
override fun toString(): String {
return name
}
// 后缀
val suffix: String = UrlUtil.getSuffix(name)
// txt epub umd pdf等文件
val isSupported: Boolean = AppPattern.bookFileRegex.matches(name)
// 压缩包形式的txt epub umd pdf文件
val isSupportDecompress: Boolean = AppPattern.archiveFileRegex.matches(name)
}
}
@@ -0,0 +1,170 @@
package io.legato.kazusa.ui.book.info.edit
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.core.view.WindowInsetsCompat
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.databinding.ActivityBookInfoEditBinding
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.addType
import io.legato.kazusa.help.book.isAudio
import io.legato.kazusa.help.book.isImage
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.ui.book.changecover.ChangeCoverDialog
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.MD5Utils
import io.legato.kazusa.utils.SelectImageContract
import io.legato.kazusa.utils.externalFiles
import io.legato.kazusa.utils.inputStream
import io.legato.kazusa.utils.launch
import io.legato.kazusa.utils.readUri
import io.legato.kazusa.utils.setOnApplyWindowInsetsListenerCompat
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import splitties.init.appCtx
import splitties.views.bottomPadding
import java.io.FileOutputStream
class BookInfoEditActivity :
VMBaseActivity<ActivityBookInfoEditBinding, BookInfoEditViewModel>(),
ChangeCoverDialog.CallBack {
private val selectCover = registerForActivityResult(SelectImageContract()) {
it.uri?.let { uri ->
coverChangeTo(uri)
}
}
override val binding by viewBinding(ActivityBookInfoEditBinding::inflate)
override val viewModel by viewModels<BookInfoEditViewModel>()
override fun onActivityCreated(savedInstanceState: Bundle?) {
viewModel.bookData.observe(this) { upView(it) }
if (viewModel.bookData.value == null) {
intent.getStringExtra("bookUrl")?.let {
viewModel.loadBook(it)
}
}
initView()
initEvent()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_info_edit, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_save -> saveData()
}
return super.onCompatOptionsItemSelected(item)
}
private fun initView() {
binding.root.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
val typeMask = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
val insets = windowInsets.getInsets(typeMask)
view.bottomPadding = insets.bottom
windowInsets
}
}
private fun initEvent() = binding.run {
tvChangeCover.setOnClickListener {
viewModel.bookData.value?.let {
showDialogFragment(
ChangeCoverDialog(it.name, it.author)
)
}
}
tvSelectCover.setOnClickListener {
selectCover.launch()
}
tvRefreshCover.setOnClickListener {
viewModel.book?.customCoverUrl = tieCoverUrl.text?.toString()
upCover()
}
}
private fun upView(book: Book) = binding.run {
tieBookName.setText(book.name)
tieBookAuthor.setText(book.author)
spType.setSelection(
when {
book.isImage -> 2
book.isAudio -> 1
else -> 0
}
)
tieCoverUrl.setText(book.getDisplayCover())
tieBookIntro.setText(book.getDisplayIntro())
upCover()
}
private fun upCover() {
viewModel.book?.let {
binding.ivCover.load(it.getDisplayCover(), it.name, it.author, false, it.origin)
}
}
private fun saveData() = binding.run {
val book = viewModel.book ?: return@run
val oldBook = book.copy()
book.name = tieBookName.text?.toString() ?: ""
book.author = tieBookAuthor.text?.toString() ?: ""
val local = if (book.isLocal) BookType.local else 0
val bookType = when (spType.selectedItemPosition) {
2 -> BookType.image or local
1 -> BookType.audio or local
else -> BookType.text or local
}
book.removeType(BookType.local, BookType.image, BookType.audio, BookType.text)
book.addType(bookType)
val customCoverUrl = tieCoverUrl.text?.toString()
book.customCoverUrl = if (customCoverUrl == book.coverUrl) null else customCoverUrl
val customIntro = tieBookIntro.text?.toString()
book.customIntro = if (customIntro == book.intro) null else customIntro
BookHelp.updateCacheFolder(oldBook, book)
viewModel.saveBook(book) {
setResult(RESULT_OK)
finish()
}
}
override fun coverChangeTo(coverUrl: String) {
viewModel.book?.customCoverUrl = coverUrl
binding.tieCoverUrl.setText(coverUrl)
upCover()
}
private fun coverChangeTo(uri: Uri) {
readUri(uri) { fileDoc, inputStream ->
runCatching {
inputStream.use {
var file = this.externalFiles
val suffix = fileDoc.name.substringAfterLast(".")
val fileName = uri.inputStream(this).getOrThrow().use {
MD5Utils.md5Encode(it) + ".$suffix"
}
file = FileUtils.createFileIfNotExist(file, "covers", fileName)
FileOutputStream(file).use { outputStream ->
inputStream.copyTo(outputStream)
}
coverChangeTo(file.absolutePath)
}
}.onFailure {
appCtx.toastOnUi(it.localizedMessage)
}
}
}
}
@@ -0,0 +1,33 @@
package io.legato.kazusa.ui.book.info.edit
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.model.ReadBook
class BookInfoEditViewModel(application: Application) : BaseViewModel(application) {
var book: Book? = null
val bookData = MutableLiveData<Book>()
fun loadBook(bookUrl: String) {
execute {
book = appDb.bookDao.getBook(bookUrl)
book?.let {
bookData.postValue(it)
}
}
}
fun saveBook(book: Book, success: (() -> Unit)?) {
execute {
if (ReadBook.book?.bookUrl == book.bookUrl) {
ReadBook.book = book
}
appDb.bookDao.update(book)
}.onSuccess {
success?.invoke()
}
}
}
@@ -0,0 +1,243 @@
package io.legato.kazusa.ui.book.manage
import android.annotation.SuppressLint
import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookGroup
import io.legato.kazusa.databinding.ItemArrangeBookBinding
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.config.AppConfig
//import io.legado.app.lib.theme.backgroundColor
import io.legato.kazusa.ui.widget.recycler.DragSelectTouchHelper
import io.legato.kazusa.ui.widget.recycler.ItemTouchCallback
import java.util.*
class BookAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<Book, ItemArrangeBookBinding>(context),
ItemTouchCallback.Callback {
val groupRequestCode = 12
private val selectedBooks: HashSet<Book> = hashSetOf()
var actionItem: Book? = null
val selection: List<Book>
get() {
return getItems().filter {
selectedBooks.contains(it)
}
}
override fun getViewBinding(parent: ViewGroup): ItemArrangeBookBinding {
return ItemArrangeBookBinding.inflate(inflater, parent, false)
}
override fun onCurrentListChanged() {
callBack.upSelectCount()
}
override fun convert(
holder: ItemViewHolder,
binding: ItemArrangeBookBinding,
item: Book,
payloads: MutableList<Any>
) {
binding.apply {
//root.setBackgroundColor(context.backgroundColor)
tvName.text = item.name
tvAuthor.text = item.author
tvAuthor.visibility = if (item.author.isEmpty()) View.GONE else View.VISIBLE
tvGroupS.text = getGroupName(item.group)
checkbox.isChecked = selectedBooks.contains(item)
if (item.isLocal) {
tvOrigin.setText(R.string.local_book)
} else {
tvOrigin.text = item.originName
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemArrangeBookBinding) {
binding.apply {
checkbox.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
getItem(holder.layoutPosition)?.let {
if (buttonView.isPressed) {
if (isChecked) {
selectedBooks.add(it)
} else {
selectedBooks.remove(it)
}
callBack.upSelectCount()
}
}
}
}
root.setOnClickListener {
getItem(holder.layoutPosition)?.let {
checkbox.isChecked = !checkbox.isChecked
if (checkbox.isChecked) {
selectedBooks.add(it)
} else {
selectedBooks.remove(it)
}
callBack.upSelectCount()
}
}
if (AppConfig.openBookInfoByClickTitle) {
tvName.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.openBook(it)
}
}
}
tvDelete.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.deleteBook(it)
}
}
tvGroup.setOnClickListener {
getItem(holder.layoutPosition)?.let {
actionItem = it
callBack.selectGroup(groupRequestCode, it.group)
}
}
}
}
@SuppressLint("NotifyDataSetChanged")
fun selectAll(selectAll: Boolean) {
if (selectAll) {
getItems().forEach {
selectedBooks.add(it)
}
} else {
selectedBooks.clear()
}
notifyDataSetChanged()
callBack.upSelectCount()
}
@SuppressLint("NotifyDataSetChanged")
fun revertSelection() {
getItems().forEach {
if (selectedBooks.contains(it)) {
selectedBooks.remove(it)
} else {
selectedBooks.add(it)
}
}
notifyDataSetChanged()
callBack.upSelectCount()
}
fun checkSelectedInterval() {
val selectedPosition = linkedSetOf<Int>()
getItems().forEachIndexed { index, it ->
if (selectedBooks.contains(it)) {
selectedPosition.add(index)
}
}
val minPosition = Collections.min(selectedPosition)
val maxPosition = Collections.max(selectedPosition)
val itemCount = maxPosition - minPosition + 1
for (i in minPosition..maxPosition) {
getItem(i)?.let {
selectedBooks.add(it)
}
}
notifyItemRangeChanged(minPosition, itemCount, bundleOf(Pair("selected", null)))
callBack.upSelectCount()
}
private fun getGroupList(groupId: Long): List<String> {
val groupNames = arrayListOf<String>()
callBack.groupList.forEach {
if (it.groupId > 0 && it.groupId and groupId > 0) {
groupNames.add(it.groupName)
}
}
return groupNames
}
private fun getGroupName(groupId: Long): String {
val groupNames = getGroupList(groupId)
if (groupNames.isEmpty()) {
return ""
}
return groupNames.joinToString(",")
}
private var isMoved = false
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
val srcItem = getItem(srcPosition)
val targetItem = getItem(targetPosition)
if (srcItem != null && targetItem != null) {
if (srcItem.order == targetItem.order) {
for ((index, item) in getItems().withIndex()) {
item.order = index + 1
}
} else {
val pos = srcItem.order
srcItem.order = targetItem.order
targetItem.order = pos
}
}
swapItem(srcPosition, targetPosition)
isMoved = true
return true
}
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (isMoved) {
callBack.updateBook(*getItems().toTypedArray())
}
isMoved = false
}
val dragSelectCallback: DragSelectTouchHelper.Callback =
object : DragSelectTouchHelper.AdvanceCallback<Book>(Mode.ToggleAndReverse) {
override fun currentSelectedId(): MutableSet<Book> {
return selectedBooks
}
override fun getItemId(position: Int): Book {
return getItem(position)!!
}
override fun updateSelectState(position: Int, isSelected: Boolean): Boolean {
getItem(position)?.let {
if (isSelected) {
selectedBooks.add(it)
} else {
selectedBooks.remove(it)
}
notifyItemChanged(position, bundleOf(Pair("selected", null)))
callBack.upSelectCount()
return true
}
return false
}
}
interface CallBack {
val groupList: List<BookGroup>
fun upSelectCount()
fun updateBook(vararg book: Book)
fun deleteBook(book: Book)
fun selectGroup(requestCode: Int, groupId: Long)
fun openBook(book: Book)
}
}
@@ -0,0 +1,433 @@
package io.legato.kazusa.ui.book.manage
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.widget.CheckBox
import android.widget.LinearLayout
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookGroup
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.databinding.ActivityArrangeBookBinding
import io.legato.kazusa.databinding.DialogEditTextBinding
import io.legato.kazusa.help.DirectLinkUpload
import io.legato.kazusa.help.book.contains
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.LocalConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
//import io.legado.app.lib.theme.primaryTextColor
import io.legato.kazusa.ui.book.group.GroupManageDialog
import io.legato.kazusa.ui.book.group.GroupSelectDialog
import io.legato.kazusa.ui.book.info.BookInfoActivity
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.ui.widget.SelectActionBar
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.ui.widget.recycler.DragSelectTouchHelper
import io.legato.kazusa.ui.widget.recycler.ItemTouchCallback
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.cnCompare
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.hideSoftInput
import io.legato.kazusa.utils.isAbsUrl
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.shouldHideSoftInput
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.max
/**
* 书架管理
*/
class BookshelfManageActivity :
VMBaseActivity<ActivityArrangeBookBinding, BookshelfManageViewModel>(),
PopupMenu.OnMenuItemClickListener,
SelectActionBar.CallBack,
BookAdapter.CallBack,
SourcePickerDialog.Callback,
GroupSelectDialog.CallBack {
override val binding by viewBinding(ActivityArrangeBookBinding::inflate)
override val viewModel by viewModels<BookshelfManageViewModel>()
override val groupList: ArrayList<BookGroup> = arrayListOf()
private val groupRequestCode = 22
private val addToGroupRequestCode = 34
private val adapter by lazy { BookAdapter(this, this) }
private val itemTouchCallback by lazy { ItemTouchCallback(adapter) }
private var booksFlowJob: Job? = null
private var menu: Menu? = null
private val searchView: SearchView by lazy {
binding.titleBar.findViewById(R.id.search_view)
}
private var books: List<Book>? = null
private val waitDialog by lazy { WaitDialog(this) }
private val exportDir = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
alert(R.string.export_success) {
if (uri.toString().isAbsUrl()) {
setMessage(DirectLinkUpload.getSummary())
}
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = getString(R.string.path)
editView.setText(uri.toString())
}
customView { alertBinding.root }
okButton {
sendToClip(uri.toString())
}
}
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
viewModel.groupId = intent.getLongExtra("groupId", -1)
lifecycleScope.launch {
viewModel.groupName = withContext(IO) {
appDb.bookGroupDao.getByID(viewModel.groupId)?.groupName
?: getString(R.string.no_group)
}
upTitle()
}
initSearchView()
initRecyclerView()
initOtherView()
initGroupData()
upBookDataByGroupId()
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.action == MotionEvent.ACTION_DOWN) {
currentFocus?.let {
if (it.shouldHideSoftInput(ev)) {
it.post {
it.clearFocus()
it.hideSoftInput()
}
}
}
}
return super.dispatchTouchEvent(ev)
}
override fun observeLiveBus() {
viewModel.batchChangeSourceState.observe(this) {
if (it) {
waitDialog.setText(R.string.change_source_batch)
waitDialog.show()
} else {
waitDialog.dismiss()
}
}
viewModel.batchChangeSourceProcessLiveData.observe(this) {
waitDialog.setText(it)
}
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.bookshelf_manage, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
this.menu = menu
menu.findItem(R.id.menu_open_book_info_by_click_title)?.isChecked =
AppConfig.openBookInfoByClickTitle
upMenu()
return super.onPrepareOptionsMenu(menu)
}
override fun selectAll(selectAll: Boolean) {
adapter.selectAll(selectAll)
}
override fun revertSelection() {
adapter.revertSelection()
}
override fun onClickSelectBarMainAction() {
selectGroup(groupRequestCode, 0)
}
private fun upTitle() {
searchView.queryHint = getString(R.string.screen) + " • " + viewModel.groupName
}
private fun initSearchView() {
//searchView.applyTint(primaryTextColor)
searchView.isSubmitButtonEnabled = true
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
upBookData()
return false
}
})
}
private fun initRecyclerView() {
//binding.recyclerView.setEdgeEffectColor(primaryColor)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.addItemDecoration(VerticalDivider(this))
binding.recyclerView.adapter = adapter
itemTouchCallback.isCanDrag = AppConfig.bookshelfSort == 3
val dragSelectTouchHelper: DragSelectTouchHelper =
DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
// When this page is opened, it is in selection mode
dragSelectTouchHelper.activeSlideSelect()
// Note: need judge selection first, so add ItemTouchHelper after it.
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
}
private fun initOtherView() {
binding.selectActionBar.setMainActionText(R.string.move_to_group)
binding.selectActionBar.inflateMenu(R.menu.bookshelf_menage_sel)
binding.selectActionBar.setOnMenuItemClickListener(this)
binding.selectActionBar.setCallBack(this)
waitDialog.setOnCancelListener {
viewModel.batchChangeSourceCoroutine?.cancel()
}
}
@SuppressLint("NotifyDataSetChanged")
private fun initGroupData() {
lifecycleScope.launch {
appDb.bookGroupDao.flowAll().catch {
AppLog.put("书架管理界面获取分组数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
groupList.clear()
groupList.addAll(it)
adapter.notifyDataSetChanged()
upMenu()
}
}
}
private fun upBookDataByGroupId() {
booksFlowJob?.cancel()
booksFlowJob = lifecycleScope.launch {
val bookSort = AppConfig.getBookSortByGroupId(viewModel.groupId)
appDb.bookDao.flowByGroup(viewModel.groupId).map { list ->
when (bookSort) {
1 -> list.sortedByDescending {
it.latestChapterTime
}
2 -> list.sortedWith { o1, o2 ->
o1.name.cnCompare(o2.name)
}
3 -> list.sortedBy {
it.order
}
4 -> list.sortedByDescending {
max(it.latestChapterTime, it.durChapterTime)
}
else -> list.sortedByDescending {
it.durChapterTime
}
}
}.catch {
AppLog.put("书架管理界面获取书籍列表失败\n${it.localizedMessage}", it)
}.flowOn(IO)
.conflate().collect {
books = it
upBookData()
itemTouchCallback.isCanDrag = bookSort == 3
}
}
}
private fun upBookData() {
books?.let { books ->
val searchKey = searchView.query
if (searchKey.isNullOrEmpty()) {
adapter.setItems(books)
} else {
books.filter {
it.contains(searchKey.toString())
}.let {
adapter.setItems(it)
}
}
}
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
R.id.menu_open_book_info_by_click_title -> {
AppConfig.openBookInfoByClickTitle = !item.isChecked
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
R.id.menu_export_all_use_book_source -> viewModel.saveAllUseBookSourceToFile { file ->
exportDir.launch {
mode = HandleFileContract.EXPORT
fileData = HandleFileContract.FileData(
"bookSource.json",
file,
"application/json"
)
}
}
else -> if (item.groupId == R.id.menu_group) {
viewModel.groupName = item.title.toString()
upTitle()
viewModel.groupId =
appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0
upBookDataByGroupId()
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_del_selection -> alertDelSelection()
R.id.menu_update_enable ->
viewModel.upCanUpdate(adapter.selection, true)
R.id.menu_update_disable ->
viewModel.upCanUpdate(adapter.selection, false)
R.id.menu_add_to_group -> selectGroup(addToGroupRequestCode, 0)
R.id.menu_change_source -> showDialogFragment<SourcePickerDialog>()
R.id.menu_clear_cache -> viewModel.clearCache(adapter.selection)
R.id.menu_check_selected_interval -> adapter.checkSelectedInterval()
}
return false
}
private fun upMenu() {
menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu ->
subMenu.removeGroup(R.id.menu_group)
groupList.forEach { bookGroup ->
subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName)
}
}
}
private fun alertDelSelection() {
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
val checkBox = CheckBox(this@BookshelfManageActivity).apply {
setText(R.string.delete_book_file)
isChecked = LocalConfig.deleteBookOriginal
}
val view = LinearLayout(this@BookshelfManageActivity).apply {
setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
addView(checkBox)
}
customView { view }
okButton {
LocalConfig.deleteBookOriginal = checkBox.isChecked
viewModel.deleteBook(adapter.selection, checkBox.isChecked)
}
noButton()
}
}
override fun selectGroup(requestCode: Int, groupId: Long) {
showDialogFragment(
GroupSelectDialog(groupId, requestCode)
)
}
override fun upGroup(requestCode: Int, groupId: Long) {
when (requestCode) {
groupRequestCode -> adapter.selection.let { books ->
val array = Array(books.size) {
books[it].copy(group = groupId)
}
viewModel.updateBook(*array)
}
adapter.groupRequestCode -> {
adapter.actionItem?.let {
viewModel.updateBook(it.copy(group = groupId))
}
}
addToGroupRequestCode -> adapter.selection.let { books ->
val array = Array(books.size) { index ->
val book = books[index]
book.copy(group = book.group or groupId)
}
viewModel.updateBook(*array)
}
}
}
override fun upSelectCount() {
binding.selectActionBar.upCountView(adapter.selection.size, adapter.getItems().size)
}
override fun updateBook(vararg book: Book) {
viewModel.updateBook(*book)
}
override fun deleteBook(book: Book) {
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
var checkBox: CheckBox? = null
if (book.isLocal) {
checkBox = CheckBox(this@BookshelfManageActivity).apply {
setText(R.string.delete_book_file)
isChecked = LocalConfig.deleteBookOriginal
}
val view = LinearLayout(this@BookshelfManageActivity).apply {
setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
addView(checkBox)
}
customView { view }
}
okButton {
if (checkBox != null) {
LocalConfig.deleteBookOriginal = checkBox.isChecked
}
viewModel.deleteBook(listOf(book), LocalConfig.deleteBookOriginal)
}
}
}
override fun openBook(book: Book) {
startActivity<BookInfoActivity> {
putExtra("name", book.name)
putExtra("author", book.author)
}
}
override fun sourceOnClick(source: BookSource) {
viewModel.changeSource(adapter.selection, source)
viewModel.batchChangeSourceState.value = true
}
}
@@ -0,0 +1,130 @@
package io.legato.kazusa.ui.book.manage
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.stackTraceStr
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.writeToOutputStream
import kotlinx.coroutines.delay
import java.io.File
class BookshelfManageViewModel(application: Application) : BaseViewModel(application) {
var groupId: Long = -1L
var groupName: String? = null
val batchChangeSourceState = MutableLiveData<Boolean>()
val batchChangeSourceProcessLiveData = MutableLiveData<String>()
var batchChangeSourceCoroutine: Coroutine<Unit>? = null
fun upCanUpdate(books: List<Book>, canUpdate: Boolean) {
execute {
val array = Array(books.size) {
books[it].copy(canUpdate = canUpdate).apply {
if (!canUpdate) {
removeType(BookType.updateError)
}
}
}
appDb.bookDao.update(*array)
}
}
fun updateBook(vararg book: Book) {
execute {
appDb.bookDao.update(*book)
}
}
fun deleteBook(books: List<Book>, deleteOriginal: Boolean = false) {
execute {
appDb.bookDao.delete(*books.toTypedArray())
books.forEach {
if (it.isLocal) {
LocalBook.deleteBook(it, deleteOriginal)
}
}
}
}
fun saveAllUseBookSourceToFile(success: (file: File) -> Unit) {
execute {
val path = "${context.filesDir}/shareBookSource.json"
FileUtils.delete(path)
val file = FileUtils.createFileWithReplace(path)
val sources = appDb.bookDao.getAllUseBookSource()
file.outputStream().buffered().use {
GSON.writeToOutputStream(it, sources)
}
file
}.onSuccess {
success.invoke(it)
}.onError {
context.toastOnUi(it.stackTraceStr)
}
}
fun changeSource(books: List<Book>, source: BookSource) {
batchChangeSourceCoroutine?.cancel()
batchChangeSourceCoroutine = execute {
val changeSourceDelay = AppConfig.batchChangeSourceDelay * 1000L
books.forEachIndexed { index, book ->
batchChangeSourceProcessLiveData.postValue("${index + 1} / ${books.size}")
if (book.isLocal) return@forEachIndexed
if (book.origin == source.bookSourceUrl) return@forEachIndexed
val newBook = WebBook.preciseSearchAwait(source, book.name, book.author)
.onFailure {
AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true)
}.getOrNull() ?: return@forEachIndexed
kotlin.runCatching {
if (newBook.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, newBook)
}
}.onFailure {
AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true)
return@forEachIndexed
}
WebBook.getChapterListAwait(source, newBook)
.onFailure {
AppLog.put("获取目录出错\n${it.localizedMessage}", it, true)
}.getOrNull()?.let { toc ->
book.migrateTo(newBook, toc)
book.removeType(BookType.updateError)
appDb.bookDao.insert(newBook)
appDb.bookChapterDao.insert(*toc.toTypedArray())
}
delay(changeSourceDelay)
}
}.onStart {
batchChangeSourceState.postValue(true)
}.onFinally {
batchChangeSourceState.postValue(false)
}
}
fun clearCache(books: List<Book>) {
execute {
books.forEach {
BookHelp.clearCache(it)
}
}.onSuccess {
context.toastOnUi(R.string.clear_cache_success)
}
}
}
@@ -0,0 +1,161 @@
package io.legato.kazusa.ui.book.manage
import android.content.Context
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.view.setPadding
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.BookSourcePart
import io.legato.kazusa.databinding.DialogSourcePickerBinding
import io.legato.kazusa.databinding.Item1lineTextBinding
import io.legato.kazusa.help.config.AppConfig
//import io.legado.app.lib.theme.primaryColor
//import io.legado.app.lib.theme.primaryTextColor
import io.legato.kazusa.ui.widget.number.NumberPickerDialog
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import splitties.views.onClick
/**
* 书源选择
*/
class SourcePickerDialog : BaseDialogFragment(R.layout.dialog_source_picker),
Toolbar.OnMenuItemClickListener {
private val binding by viewBinding(DialogSourcePickerBinding::bind)
private val searchView: SearchView by lazy {
binding.toolBar.findViewById(R.id.search_view)
}
private val toolBar: Toolbar by lazy {
binding.toolBar.toolbar
}
private val adapter by lazy {
SourceAdapter(requireContext())
}
private var sourceFlowJob: Job? = null
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initView()
initData()
initMenu()
}
private fun initView() {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.title = "选择书源"
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
//searchView.applyTint(primaryTextColor)
searchView.isSubmitButtonEnabled = true
searchView.queryHint = getString(R.string.search_book_source)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
initData(newText)
return false
}
})
}
private fun initData(searchKey: String? = null) {
sourceFlowJob?.cancel()
sourceFlowJob = lifecycleScope.launch {
when {
searchKey.isNullOrEmpty() -> appDb.bookSourceDao.flowEnabled()
else -> appDb.bookSourceDao.flowSearchEnabled(searchKey)
}.catch {
AppLog.put("书源选择界面获取书源数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).collect {
adapter.setItems(it)
}
}
}
private fun initMenu() {
toolBar.setOnMenuItemClickListener(this)
toolBar.inflateMenu(R.menu.source_picker)
toolBar.menu.applyTint(requireContext())
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_change_source_delay -> NumberPickerDialog(requireContext())
.setTitle(getString(R.string.change_source_delay))
.setMaxValue(9999)
.setMinValue(0)
.setValue(AppConfig.batchChangeSourceDelay)
.show {
AppConfig.batchChangeSourceDelay = it
}
}
return true
}
inner class SourceAdapter(context: Context) :
RecyclerAdapter<BookSourcePart, Item1lineTextBinding>(context) {
override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding {
return Item1lineTextBinding.inflate(inflater, parent, false).apply {
root.setPadding(16.dpToPx())
}
}
override fun convert(
holder: ItemViewHolder,
binding: Item1lineTextBinding,
item: BookSourcePart,
payloads: MutableList<Any>
) {
binding.textView.text = item.getDisPlayNameGroup()
}
override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) {
binding.root.onClick {
getItemByLayoutPosition(holder.layoutPosition)?.let {
it.getBookSource()?.let { source ->
callback?.sourceOnClick(source)
}
dismissAllowingStateLoss()
}
}
}
}
private val callback: Callback?
get() {
return (parentFragment as? Callback) ?: activity as? Callback
}
interface Callback {
fun sourceOnClick(source: BookSource)
}
}
@@ -0,0 +1,788 @@
package io.legato.kazusa.ui.book.manga
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Looper
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.WindowManager
import android.view.animation.LinearInterpolator
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import com.bumptech.glide.Glide
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
import com.bumptech.glide.util.FixedPreloadSizeProvider
import io.legato.kazusa.BuildConfig
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookProgress
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.databinding.ActivityMangaBinding
import io.legato.kazusa.databinding.ViewLoadMoreBinding
import io.legato.kazusa.help.book.isImage
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.storage.Backup
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.model.ReadManga
import io.legato.kazusa.receiver.NetworkChangedListener
import io.legato.kazusa.ui.book.changesource.ChangeBookSourceDialog
import io.legato.kazusa.ui.book.info.BookInfoActivity
import io.legato.kazusa.ui.book.manga.config.MangaColorFilterConfig
import io.legato.kazusa.ui.book.manga.config.MangaColorFilterDialog
import io.legato.kazusa.ui.book.manga.config.MangaFooterConfig
import io.legato.kazusa.ui.book.manga.config.MangaFooterSettingDialog
import io.legato.kazusa.ui.book.manga.entities.BaseMangaPage
import io.legato.kazusa.ui.book.manga.entities.MangaPage
import io.legato.kazusa.ui.book.manga.recyclerview.MangaAdapter
import io.legato.kazusa.ui.book.manga.recyclerview.MangaLayoutManager
import io.legato.kazusa.ui.book.manga.recyclerview.ScrollTimer
import io.legato.kazusa.ui.book.read.MangaMenu
import io.legato.kazusa.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED
import io.legato.kazusa.ui.book.toc.TocActivityResult
import io.legato.kazusa.ui.widget.number.NumberPickerDialog
import io.legato.kazusa.ui.widget.recycler.LoadMoreView
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.NetworkUtils
import io.legato.kazusa.utils.StartActivityContract
import io.legato.kazusa.utils.canScroll
import io.legato.kazusa.utils.fastBinarySearch
import io.legato.kazusa.utils.findCenterViewPosition
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.getCompatColor
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.observeEvent
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.toggleSystemBar
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.DecimalFormat
import kotlin.math.ceil
class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewModel>(),
ReadManga.Callback, ChangeBookSourceDialog.CallBack, MangaMenu.CallBack,
MangaColorFilterDialog.Callback, ScrollTimer.ScrollCallback {
private val mLayoutManager by lazy {
MangaLayoutManager(this)
}
private val mAdapter: MangaAdapter by lazy {
MangaAdapter(this)
}
private val mSizeProvider by lazy {
FixedPreloadSizeProvider<Any>(resources.displayMetrics.widthPixels, SIZE_ORIGINAL)
}
private val mPagerSnapHelper: PagerSnapHelper by lazy {
PagerSnapHelper()
}
private lateinit var mMangaFooterConfig: MangaFooterConfig
private val mLabelBuilder by lazy { StringBuilder() }
private var mMenu: Menu? = null
private var mRecyclerViewPreloader: RecyclerViewPreloader<Any>? = null
private val networkChangedListener by lazy {
NetworkChangedListener(this)
}
private var justInitData: Boolean = false
private var syncDialog: AlertDialog? = null
private val mScrollTimer by lazy {
ScrollTimer(this, binding.recyclerView, lifecycleScope).apply {
setSpeed(AppConfig.mangaAutoPageSpeed)
}
}
private var enableAutoScrollPage = false
private var enableAutoScroll = false
private val mLinearInterpolator by lazy {
LinearInterpolator()
}
private val loadMoreView by lazy {
LoadMoreView(this).apply {
setBackgroundColor(getCompatColor(R.color.book_ant_10))
setLoadingColor(R.color.white)
setLoadingTextColor(R.color.white)
}
}
//打开目录返回选择章节返回结果
private val tocActivity = registerForActivityResult(TocActivityResult()) {
it?.let {
viewModel.openChapter(it.first, it.second)
}
}
private val bookInfoActivity =
registerForActivityResult(StartActivityContract(BookInfoActivity::class.java)) {
if (it.resultCode == RESULT_OK) {
setResult(RESULT_DELETED)
super.finish()
} else {
ReadManga.loadOrUpContent()
}
}
override val binding by viewBinding(ActivityMangaBinding::inflate)
override val viewModel by viewModels<ReadMangaViewModel>()
private val loadingViewVisible get() = binding.flLoading.isVisible
private val df by lazy {
DecimalFormat("0.0%")
}
override fun onCreate(savedInstanceState: Bundle?) {
upLayoutInDisplayCutoutMode()
super.onCreate(savedInstanceState)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
ReadManga.register(this)
upSystemUiVisibility(false)
initRecyclerView()
binding.tvRetry.setOnClickListener {
binding.llLoading.isVisible = true
binding.llRetry.isGone = true
ReadManga.loadOrUpContent()
}
mAdapter.addFooterView {
ViewLoadMoreBinding.bind(loadMoreView)
}
loadMoreView.setOnClickListener {
if (!loadMoreView.isLoading && ReadManga.hasNextChapter) {
loadMoreView.startLoad()
ReadManga.loadOrUpContent()
}
}
loadMoreView.gone()
mMangaFooterConfig =
GSON.fromJsonObject<MangaFooterConfig>(AppConfig.mangaFooterConfig).getOrNull()
?: MangaFooterConfig()
}
override fun observeLiveBus() {
observeEvent<MangaFooterConfig>(EventBus.UP_MANGA_CONFIG) {
mMangaFooterConfig = it
val item = mAdapter.getItem(binding.recyclerView.findCenterViewPosition())
upInfoBar(item)
}
}
private fun initRecyclerView() {
val mangaColorFilter =
GSON.fromJsonObject<MangaColorFilterConfig>(AppConfig.mangaColorFilter).getOrNull()
?: MangaColorFilterConfig()
mAdapter.run {
setMangaImageColorFilter(mangaColorFilter)
}
setHorizontalScroll(AppConfig.enableMangaHorizontalScroll)
binding.recyclerView.run {
adapter = mAdapter
itemAnimator = null
layoutManager = mLayoutManager
setHasFixedSize(true)
setDisableClickScroll(AppConfig.disableClickScroll)
setDisableMangaScale(AppConfig.disableMangaScale)
setRecyclerViewPreloader(AppConfig.mangaPreDownloadNum)
setPreScrollListener { _, _, _, position ->
if (mAdapter.isNotEmpty()) {
val item = mAdapter.getItem(position)
if (item is BaseMangaPage) {
if (ReadManga.durChapterIndex < item.chapterIndex) {
ReadManga.moveToNextChapter()
} else if (ReadManga.durChapterIndex > item.chapterIndex) {
ReadManga.moveToPrevChapter()
} else {
ReadManga.durChapterPos = item.index
ReadManga.curPageChanged()
}
if (item is MangaPage) {
binding.mangaMenu.upSeekBar(item.index, item.imageCount)
upInfoBar(item)
}
}
}
}
}
binding.webtoonFrame.run {
onTouchMiddle {
if (!binding.mangaMenu.isVisible && !loadingViewVisible) {
binding.mangaMenu.runMenuIn()
}
}
onNextPage {
scrollToNext()
}
onPrevPage {
scrollToPrev()
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
viewModel.initData(intent)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
Looper.myQueue().addIdleHandler {
viewModel.initData(intent)
false
}
justInitData = true
}
override fun upContent() {
lifecycleScope.launch {
setTitle(ReadManga.book?.name)
val data = withContext(IO) { ReadManga.mangaContents }
val pos = data.pos
val list = data.items
val curFinish = data.curFinish
val nextFinish = data.nextFinish
mAdapter.submitList(list) {
if (loadingViewVisible && curFinish) {
binding.infobar.isVisible = true
upInfoBar(list[pos])
mLayoutManager.scrollToPositionWithOffset(pos, 0)
binding.flLoading.isGone = true
loadMoreView.visible()
binding.mangaMenu.upSeekBar(
ReadManga.durChapterPos, ReadManga.curMangaChapter!!.imageCount
)
}
if (curFinish) {
if (!ReadManga.hasNextChapter) {
loadMoreView.noMore("暂无章节了!")
} else if (nextFinish) {
loadMoreView.stopLoad()
} else {
loadMoreView.startLoad()
}
}
}
}
}
private fun upInfoBar(page: Any?) {
if (page !is MangaPage) {
return
}
val chapterIndex = page.chapterIndex
val chapterSize = page.chapterSize
val chapterPos = page.index
val imageCount = page.imageCount
val chapterName = page.mChapterName
mMangaFooterConfig.run {
mLabelBuilder.clear()
binding.infobar.isGone = hideFooter
binding.infobar.textInfoAlignment = footerOrientation
if (!hideChapterName) {
mLabelBuilder.append(chapterName).append(" ")
}
if (!hidePageNumber) {
if (!hidePageNumberLabel) {
mLabelBuilder.append(getString(R.string.manga_check_page_number))
}
mLabelBuilder.append("${chapterPos + 1}/${imageCount}").append(" ")
}
if (!hideChapter) {
if (!hideChapterLabel) {
mLabelBuilder.append(getString(R.string.manga_check_chapter))
}
mLabelBuilder.append("${chapterIndex + 1}/${chapterSize}").append(" ")
}
if (!hideProgressRatio) {
if (!hideProgressRatioLabel) {
mLabelBuilder.append(getString(R.string.manga_check_progress))
}
val percent = if (chapterSize == 0 || imageCount == 0 && chapterIndex == 0) {
"0.0%"
} else if (imageCount == 0) {
df.format((chapterIndex + 1.0f) / chapterSize.toDouble())
} else {
var percent =
df.format(
chapterIndex * 1.0f / chapterSize + 1.0f /
chapterSize * (chapterPos + 1) / imageCount.toDouble()
)
if (percent == "100.0%" && (chapterIndex + 1 != chapterSize || chapterPos + 1 != imageCount)) {
percent = "99.9%"
}
percent
}
mLabelBuilder.append(percent)
}
}
binding.infobar.update(
if (mLabelBuilder.isEmpty()) "" else mLabelBuilder.toString()
)
}
override fun onResume() {
super.onResume()
networkChangedListener.register()
networkChangedListener.onNetworkChanged = {
// 当网络是可用状态且无需初始化时同步进度(初始化中已有同步进度逻辑)
if (AppConfig.syncBookProgressPlus && NetworkUtils.isAvailable() && !justInitData) {
ReadManga.syncProgress({ progress -> sureNewProgress(progress) })
}
}
if (enableAutoScrollPage) {
mScrollTimer.isEnabledPage = true
}
if (enableAutoScroll) {
mScrollTimer.isEnabled = true
}
}
override fun onPause() {
super.onPause()
if (ReadManga.inBookshelf) {
ReadManga.saveRead()
if (!BuildConfig.DEBUG) {
if (AppConfig.syncBookProgressPlus) {
ReadManga.syncProgress()
} else {
ReadManga.uploadProgress()
}
Backup.autoBack(this)
}
}
ReadManga.cancelPreDownloadTask()
networkChangedListener.unRegister()
mScrollTimer.isEnabledPage = false
mScrollTimer.isEnabled = false
}
override fun loadFail(msg: String) {
lifecycleScope.launch {
if (loadingViewVisible) {
binding.llLoading.isGone = true
binding.llRetry.isVisible = true
binding.tvMsg.text = msg
} else {
loadMoreView.error(null, "加载失败,点击重试")
}
}
}
override fun onDestroy() {
ReadManga.unregister(this)
super.onDestroy()
}
override fun onLowMemory() {
super.onLowMemory()
Glide.get(this).clearMemory()
}
override fun sureNewProgress(progress: BookProgress) {
syncDialog?.dismiss()
syncDialog = alert(R.string.get_book_progress) {
setMessage(R.string.cloud_progress_exceeds_current)
okButton {
ReadManga.setProgress(progress)
}
noButton()
}
}
override fun showLoading() {
lifecycleScope.launch {
binding.flLoading.isVisible = true
}
}
override fun startLoad() {
lifecycleScope.launch {
loadMoreView.startLoad()
}
}
override fun scrollBy(distance: Int) {
if (!binding.recyclerView.canScroll(1)) {
return
}
val time = ceil(16f / distance * 10000).toInt()
binding.recyclerView.smoothScrollBy(10000, 10000, mLinearInterpolator, time)
}
override fun scrollPage() {
scrollToNext()
}
override val oldBook: Book?
get() = ReadManga.book
override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
if (book.isImage) {
binding.flLoading.isVisible = true
viewModel.changeTo(book, toc)
} else {
toastOnUi("所选择的源不是漫画源")
}
}
override fun updateColorFilter(config: MangaColorFilterConfig) {
mAdapter.setMangaImageColorFilter(config)
updateWindowBrightness(config.l)
}
@SuppressLint("StringFormatMatches")
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_manga, menu)
upMenu(menu)
return super.onCompatCreateOptionsMenu(menu)
}
/**
* 菜单
*/
@SuppressLint("StringFormatMatches", "NotifyDataSetChanged")
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_change_source -> {
binding.mangaMenu.runMenuOut()
ReadManga.book?.let {
showDialogFragment(ChangeBookSourceDialog(it.name, it.author))
}
}
R.id.menu_catalog -> {
ReadManga.book?.let {
tocActivity.launch(it.bookUrl)
}
}
R.id.menu_refresh -> {
binding.flLoading.isVisible = true
ReadManga.book?.let {
viewModel.refreshContentDur(it)
}
}
R.id.menu_pre_manga_number -> {
showNumberPickerDialog(
0,
getString(R.string.pre_download),
AppConfig.mangaPreDownloadNum
) {
AppConfig.mangaPreDownloadNum = it
item.title = getString(R.string.pre_download_m, it)
setRecyclerViewPreloader(it)
}
}
R.id.menu_disable_manga_scale -> {
item.isChecked = !item.isChecked
AppConfig.disableMangaScale = item.isChecked
setDisableMangaScale(item.isChecked)
}
R.id.menu_disable_click_scroll -> {
item.isChecked = !item.isChecked
AppConfig.disableClickScroll = item.isChecked
setDisableClickScroll(item.isChecked)
}
R.id.menu_enable_auto_page -> {
item.isChecked = !item.isChecked
val menuMangaAutoPageSpeed = mMenu?.findItem(R.id.menu_manga_auto_page_speed)
mScrollTimer.isEnabledPage = item.isChecked
menuMangaAutoPageSpeed?.isVisible = item.isChecked
enableAutoScrollPage = item.isChecked
enableAutoScroll = false
mScrollTimer.isEnabled = false
mMenu?.findItem(R.id.menu_enable_auto_scroll)?.isChecked = false
}
R.id.menu_manga_auto_page_speed -> {
showNumberPickerDialog(
1, getString(R.string.setting_manga_auto_page_speed),
AppConfig.mangaAutoPageSpeed
) {
AppConfig.mangaAutoPageSpeed = it
item.title = getString(R.string.manga_auto_page_speed, it)
mScrollTimer.setSpeed(it)
if (enableAutoScrollPage) {
mScrollTimer.isEnabledPage = true
}
}
}
R.id.menu_manga_footer_config -> {
showDialogFragment(MangaFooterSettingDialog())
}
R.id.menu_enable_horizontal_scroll -> {
item.isChecked = !item.isChecked
AppConfig.enableMangaHorizontalScroll = item.isChecked
setHorizontalScroll(item.isChecked)
mAdapter.notifyDataSetChanged()
}
R.id.menu_manga_color_filter -> {
binding.mangaMenu.runMenuOut()
showDialogFragment(MangaColorFilterDialog())
}
R.id.menu_enable_auto_scroll -> {
item.isChecked = !item.isChecked
mScrollTimer.isEnabled = item.isChecked
mMenu?.findItem(R.id.menu_enable_auto_page)?.isChecked = false
enableAutoScroll = item.isChecked
enableAutoScrollPage = false
mScrollTimer.isEnabledPage = false
mMenu?.findItem(R.id.menu_manga_auto_page_speed)?.isVisible = item.isChecked
if (enableAutoScroll) {
mPagerSnapHelper.attachToRecyclerView(null)
} else if (AppConfig.enableMangaHorizontalScroll) {
mPagerSnapHelper.attachToRecyclerView(binding.recyclerView)
}
}
R.id.menu_hide_manga_title -> {
item.isChecked = !item.isChecked
AppConfig.hideMangaTitle = item.isChecked
ReadManga.loadContent()
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun openBookInfoActivity() {
ReadManga.book?.let {
bookInfoActivity.launch {
putExtra("name", it.name)
putExtra("author", it.author)
}
}
}
override fun upSystemUiVisibility(menuIsVisible: Boolean) {
toggleSystemBar(menuIsVisible)
if (enableAutoScroll) {
mScrollTimer.isEnabled = !menuIsVisible
}
if (enableAutoScrollPage) {
mScrollTimer.isEnabledPage = !menuIsVisible
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val keyCode = event.keyCode
val action = event.action
val isDown = action == 0
if (keyCode == KeyEvent.KEYCODE_MENU) {
if (isDown && !binding.mangaMenu.canShowMenu) {
binding.mangaMenu.runMenuIn()
return true
}
if (!isDown && !binding.mangaMenu.canShowMenu) {
binding.mangaMenu.canShowMenu = true
return true
}
}
return super.dispatchKeyEvent(event)
}
private fun setRecyclerViewPreloader(maxPreload: Int) {
if (mRecyclerViewPreloader != null) {
binding.recyclerView.removeOnScrollListener(mRecyclerViewPreloader!!)
}
mRecyclerViewPreloader = RecyclerViewPreloader(
Glide.with(this), mAdapter, mSizeProvider, maxPreload
)
binding.recyclerView.addOnScrollListener(mRecyclerViewPreloader!!)
}
private fun setHorizontalScroll(enable: Boolean) {
mAdapter.isHorizontal = enable
if (enable) {
if (!enableAutoScroll) {
mPagerSnapHelper.attachToRecyclerView(binding.recyclerView)
}
mLayoutManager.orientation = LinearLayoutManager.HORIZONTAL
} else {
mPagerSnapHelper.attachToRecyclerView(null)
mLayoutManager.orientation = LinearLayoutManager.VERTICAL
}
}
@SuppressLint("StringFormatMatches")
private fun upMenu(menu: Menu) {
this.mMenu = menu
menu.findItem(R.id.menu_pre_manga_number).title =
getString(R.string.pre_download_m, AppConfig.mangaPreDownloadNum)
menu.findItem(R.id.menu_disable_manga_scale).isChecked = AppConfig.disableMangaScale
menu.findItem(R.id.menu_disable_click_scroll).isChecked = AppConfig.disableClickScroll
menu.findItem(R.id.menu_manga_auto_page_speed).title =
getString(R.string.manga_auto_page_speed, AppConfig.mangaAutoPageSpeed)
menu.findItem(R.id.menu_enable_horizontal_scroll).isChecked =
AppConfig.enableMangaHorizontalScroll
menu.findItem(R.id.menu_hide_manga_title).isChecked = AppConfig.hideMangaTitle
}
private fun setDisableMangaScale(disable: Boolean) {
binding.webtoonFrame.disableMangaScale = disable
binding.recyclerView.disableMangaScale = disable
if (disable) {
binding.recyclerView.resetZoom()
}
}
private fun setDisableClickScroll(disable: Boolean) {
binding.webtoonFrame.disabledClickScroll = disable
}
private fun upLayoutInDisplayCutoutMode() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
}
private fun scrollToNext() {
scrollPageTo(1)
}
private fun scrollToPrev() {
scrollPageTo(-1)
}
private fun scrollPageTo(direction: Int) {
if (!binding.recyclerView.canScroll(direction)) {
return
}
var dx = 0
var dy = 0
if (AppConfig.enableMangaHorizontalScroll) {
dx = binding.recyclerView.run {
width - paddingStart - paddingEnd
}
} else {
dy = binding.recyclerView.run {
height - paddingTop - paddingBottom
}
}
dx *= direction
dy *= direction
binding.recyclerView.smoothScrollBy(dx, dy)
}
private fun showNumberPickerDialog(
min: Int,
title: String,
initValue: Int,
callback: (Int) -> Unit,
) {
NumberPickerDialog(this)
.setTitle(title)
.setMaxValue(9999)
.setMinValue(min)
.setValue(initValue)
.show {
callback.invoke(it)
}
}
override fun finish() {
val book = ReadManga.book ?: return super.finish()
if (ReadManga.inBookshelf) {
return super.finish()
}
if (!AppConfig.showAddToShelfAlert) {
viewModel.removeFromBookshelf { super.finish() }
} else {
alert(title = getString(R.string.add_to_bookshelf)) {
setMessage(getString(R.string.check_add_bookshelf, book.name))
okButton {
ReadManga.book?.removeType(BookType.notShelf)
ReadManga.book?.save()
ReadManga.inBookshelf = true
setResult(RESULT_OK)
}
noButton { viewModel.removeFromBookshelf { super.finish() } }
}
}
}
fun updateWindowBrightness(brightness: Int) {
val layoutParams = window.attributes
val normalizedBrightness = brightness.toFloat() / 255.0f
layoutParams.screenBrightness = normalizedBrightness.coerceIn(0f, 1f)
window.attributes = layoutParams
// 强制刷新屏幕
window.decorView.postInvalidate()
}
override fun skipToPage(index: Int) {
val durChapterIndex = ReadManga.durChapterIndex
val itemPos = mAdapter.getItems().fastBinarySearch {
val chapterIndex: Int
val pageIndex: Int
if (it is BaseMangaPage) {
chapterIndex = it.chapterIndex
pageIndex = it.index
} else {
error("unknown item type")
}
val delta = chapterIndex - durChapterIndex
if (delta != 0) {
delta
} else {
pageIndex - index
}
}
if (itemPos > -1) {
mLayoutManager.scrollToPositionWithOffset(itemPos, 0)
upInfoBar(mAdapter.getItem(itemPos))
ReadManga.durChapterPos = index
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
scrollToPrev()
return true
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
scrollToNext()
return true
}
}
return super.onKeyDown(keyCode, event)
}
}
@@ -0,0 +1,290 @@
package io.legato.kazusa.ui.book.manga
import android.app.Application
import android.content.Intent
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookProgress
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.AppWebDav
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.book.isLocalModified
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.book.simulatedTotalChapterNum
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.model.ReadManga
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.mapParallelSafe
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onEmpty
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.take
import splitties.init.appCtx
class ReadMangaViewModel(application: Application) : BaseViewModel(application) {
private var changeSourceCoroutine: Coroutine<*>? = null
/**
* 初始化
*/
fun initData(intent: Intent, success: (() -> Unit)? = null) {
execute {
ReadManga.inBookshelf = intent.getBooleanExtra("inBookshelf", true)
ReadManga.chapterChanged = intent.getBooleanExtra("chapterChanged", false)
val bookUrl = intent.getStringExtra("bookUrl")
val book = when {
bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook
else -> appDb.bookDao.getBook(bookUrl)
} ?: ReadManga.book
when {
book != null -> initManga(book)
else -> context.getString(R.string.no_book)//没有找到书
}
}.onSuccess {
success?.invoke()
}.onError {
val msg = "初始化数据失败\n${it.localizedMessage}"
AppLog.put(msg, it)
}.onFinally {
ReadManga.saveRead()
}
}
private suspend fun initManga(book: Book) {
val isSameBook = ReadManga.book?.bookUrl == book.bookUrl
if (isSameBook) {
ReadManga.upData(book)
} else {
ReadManga.resetData(book)
}
if (!book.isLocal && book.tocUrl.isEmpty() && !loadBookInfo(book)) {
return
}
if (book.isLocal && !checkLocalBookFileExist(book)) {
return
}
if ((ReadManga.chapterSize == 0 || book.isLocalModified()) && !loadChapterListAwait(book)) {
return
}
//开始加载内容
if (!isSameBook) {
ReadManga.loadContent()
} else {
ReadManga.loadOrUpContent()
}
if (ReadManga.chapterChanged) {
// 有章节跳转不同步阅读进度
ReadManga.chapterChanged = false
} else if (!isSameBook) {
if (AppConfig.syncBookProgressPlus) {
ReadManga.syncProgress(
{ progress -> ReadManga.mCallback?.sureNewProgress(progress) })
} else {
syncBookProgress(book)
}
}
//自动换源
if (!book.isLocal && ReadManga.bookSource == null) {
autoChangeSource(book.name, book.author)
return
}
}
private suspend fun loadChapterListAwait(book: Book): Boolean {
ReadManga.bookSource?.let {
val oldBook = book.copy()
WebBook.getChapterListAwait(it, book, true).onSuccess { cList ->
if (oldBook.bookUrl == book.bookUrl) {
appDb.bookDao.update(book)
} else {
appDb.bookDao.insert(book)
BookHelp.updateCacheFolder(oldBook, book)
}
appDb.bookChapterDao.delByBook(oldBook.bookUrl)
appDb.bookChapterDao.insert(*cList.toTypedArray())
ReadManga.onChapterListUpdated(book)
return true
}.onFailure {
//加载章节出错
ReadManga.mCallback?.loadFail(appCtx.getString(R.string.error_load_toc))
return false
}
}
return true
}
/**
* 加载详情页
*/
private suspend fun loadBookInfo(book: Book): Boolean {
val source = ReadManga.bookSource ?: return true
try {
WebBook.getBookInfoAwait(source, book, canReName = false)
return true
} catch (e: Throwable) {
ReadManga.mCallback?.loadFail("详情页出错: ${e.localizedMessage}")
return false
}
}
/**
* 自动换源
*/
private fun autoChangeSource(name: String, author: String) {
if (!AppConfig.autoChangeSource) return
execute {
val sources = appDb.bookSourceDao.allTextEnabledPart
flow {
for (source in sources) {
source.getBookSource()?.let {
emit(it)
}
}
}.onStart {
// 自动换源
}.mapParallelSafe(AppConfig.threadCount) { source ->
val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow()
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
}
val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
val chapter = toc.getOrElse(book.durChapterIndex) {
toc.last()
}
val nextChapter = toc.getOrElse(chapter.index) {
toc.first()
}
WebBook.getContentAwait(
bookSource = source,
book = book,
bookChapter = chapter,
nextChapterUrl = nextChapter.url
)
book to toc
}.take(1).onEach { (book, toc) ->
changeTo(book, toc)
}.onEmpty {
throw NoStackTraceException("没有合适书源")
}.onCompletion {
// 换源完成
}.catch {
AppLog.put("自动换源失败\n${it.localizedMessage}", it)
context.toastOnUi("自动换源失败\n${it.localizedMessage}")
}.collect()
}
}
/**
* 同步进度
*/
fun syncBookProgress(
book: Book,
alertSync: ((progress: BookProgress) -> Unit)? = null
) {
if (!AppConfig.syncBookProgress) return
execute {
AppWebDav.getBookProgress(book)
}.onError {
AppLog.put("拉取阅读进度失败《${book.name}》\n${it.localizedMessage}", it)
}.onSuccess { progress ->
progress ?: return@onSuccess
if (progress.durChapterIndex < book.durChapterIndex ||
(progress.durChapterIndex == book.durChapterIndex
&& progress.durChapterPos < book.durChapterPos)
) {
alertSync?.invoke(progress)
} else if (progress.durChapterIndex < book.simulatedTotalChapterNum()) {
ReadManga.setProgress(progress)
AppLog.put("自动同步阅读进度成功《${book.name}》 ${progress.durChapterTitle}")
}
}
}
/**
* 换源
*/
fun changeTo(book: Book, toc: List<BookChapter>) {
changeSourceCoroutine?.cancel()
changeSourceCoroutine = execute {
//换源中
ReadManga.book?.migrateTo(book, toc)
book.removeType(BookType.updateError)
ReadManga.book?.delete()
appDb.bookDao.insert(book)
appDb.bookChapterDao.insert(*toc.toTypedArray())
ReadManga.resetData(book)
ReadManga.loadContent()
}.onError {
AppLog.put("换源失败\n$it", it, true)
}.onFinally {
postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
}
}
private fun checkLocalBookFileExist(book: Book): Boolean {
try {
LocalBook.getBookInputStream(book)
return true
} catch (e: Throwable) {
return false
}
}
fun openChapter(index: Int, durChapterPos: Int = 0) {
if (index < ReadManga.chapterSize) {
ReadManga.showLoading()
ReadManga.durChapterIndex = index
ReadManga.durChapterPos = durChapterPos
ReadManga.saveRead()
ReadManga.loadContent()
}
}
fun removeFromBookshelf(success: (() -> Unit)?) {
val book = ReadManga.book
Coroutine.async {
book?.delete()
}.onSuccess {
success?.invoke()
}
}
override fun onCleared() {
super.onCleared()
changeSourceCoroutine?.cancel()
}
fun refreshContentDur(book: Book) {
execute {
appDb.bookChapterDao.getChapter(book.bookUrl, ReadManga.durChapterIndex)
?.let { chapter ->
BookHelp.delContent(book, chapter)
openChapter(ReadManga.durChapterIndex, ReadManga.durChapterPos)
}
}
}
}
@@ -0,0 +1,18 @@
package io.legato.kazusa.ui.book.manga.config
import io.legato.kazusa.utils.GSON
data class MangaColorFilterConfig(
var r: Int = 0,
var g: Int = 0,
var b: Int = 0,
var a: Int = 0,
var l: Int = 0
) {
fun toJson(): String {
if (r == 0 && g == 0 && b == 0 && a == 0 && l == 0) {
return ""
}
return GSON.toJson(this)
}
}
@@ -0,0 +1,79 @@
package io.legato.kazusa.ui.book.manga.config
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.databinding.DialogMangaColorFilterBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class MangaColorFilterDialog : BaseDialogFragment(R.layout.dialog_manga_color_filter) {
private val binding by viewBinding(DialogMangaColorFilterBinding::bind)
private val mConfig =
GSON.fromJsonObject<MangaColorFilterConfig>(AppConfig.mangaColorFilter).getOrNull()
?: MangaColorFilterConfig()
private val callback get() = activity as? Callback
override fun onStart() {
super.onStart()
dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initData()
initView()
}
private fun initData() {
binding.run {
dsbBrightness.progress = mConfig.l
dsbR.progress = mConfig.r
dsbG.progress = mConfig.g
dsbB.progress = mConfig.b
dsbA.progress = mConfig.a
}
}
private fun initView() {
binding.run {
dsbBrightness.onChanged = {
mConfig.l = it
callback?.updateColorFilter(mConfig)
}
dsbR.onChanged = {
mConfig.r = it
callback?.updateColorFilter(mConfig)
}
dsbG.onChanged = {
mConfig.g = it
callback?.updateColorFilter(mConfig)
}
dsbB.onChanged = {
mConfig.b = it
callback?.updateColorFilter(mConfig)
}
dsbA.onChanged = {
mConfig.a = it
callback?.updateColorFilter(mConfig)
}
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
AppConfig.mangaColorFilter = mConfig.toJson()
}
interface Callback {
fun updateColorFilter(config: MangaColorFilterConfig)
}
}
@@ -0,0 +1,17 @@
package io.legato.kazusa.ui.book.manga.config
import androidx.annotation.Keep
import io.legato.kazusa.ui.widget.ReaderInfoBarView
@Keep
data class MangaFooterConfig(
var hideChapterLabel: Boolean = false,
var hideChapter: Boolean = false,
var hidePageNumberLabel: Boolean = false,
var hidePageNumber: Boolean = false,
var hideProgressRatioLabel: Boolean = false,
var hideProgressRatio: Boolean = false,
var footerOrientation: Int = ReaderInfoBarView.ALIGN_LEFT,//默认靠左
var hideFooter: Boolean = false,
var hideChapterName:Boolean=false,
)
@@ -0,0 +1,112 @@
package io.legato.kazusa.ui.book.manga.config
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.databinding.DialogMangaFooterSettingBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.ui.widget.ReaderInfoBarView
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class MangaFooterSettingDialog : BaseDialogFragment(R.layout.dialog_manga_footer_setting) {
val config = GSON.fromJsonObject<MangaFooterConfig>(AppConfig.mangaFooterConfig).getOrNull()
?: MangaFooterConfig()
private val binding by viewBinding(DialogMangaFooterSettingBinding::bind)
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
binding.cbChapterLabel.run {
isChecked = config.hideChapterLabel
setOnCheckedChangeListener { _, isChecked ->
config.hideChapterLabel = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.cbChapter.run {
isChecked = config.hideChapter
setOnCheckedChangeListener { _, isChecked ->
config.hideChapter = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.cbPageNumberLabel.run {
isChecked = config.hidePageNumberLabel
setOnCheckedChangeListener { _, isChecked ->
config.hidePageNumberLabel = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.cbPageNumber.run {
isChecked = config.hidePageNumber
setOnCheckedChangeListener { _, isChecked ->
config.hidePageNumber = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.cbProgressRatioLabel.run {
isChecked = config.hideProgressRatioLabel
setOnCheckedChangeListener { _, isChecked ->
config.hideProgressRatioLabel = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.cbProgressRatio.run {
isChecked = config.hideProgressRatio
setOnCheckedChangeListener { _, isChecked ->
config.hideProgressRatio = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.cbChapterName.run {
isChecked = config.hideChapterName
setOnCheckedChangeListener { _, isChecked ->
config.hideChapterName = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.rgFooterOrientation.check(if (config.footerOrientation == ReaderInfoBarView.ALIGN_CENTER) R.id.rb_center else R.id.rb_left)
binding.rgFooterOrientation.setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
R.id.rb_left -> {
config.footerOrientation = ReaderInfoBarView.ALIGN_LEFT
}
R.id.rb_center -> {
config.footerOrientation = ReaderInfoBarView.ALIGN_CENTER
}
}
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
binding.rgFooter.check(if (config.hideFooter) R.id.rb_hide else R.id.rb_show)
binding.rgFooter.setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
R.id.rb_show -> {
config.hideFooter = false
}
R.id.rb_hide -> {
config.hideFooter = true
}
}
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
AppConfig.mangaFooterConfig = GSON.toJson(config)
}
}
@@ -0,0 +1,6 @@
package io.legato.kazusa.ui.book.manga.entities
interface BaseMangaPage {
val chapterIndex: Int
val index: Int
}
@@ -0,0 +1,9 @@
package io.legato.kazusa.ui.book.manga.entities
import io.legato.kazusa.data.entities.BookChapter
data class MangaChapter(
val chapter: BookChapter,
val pages: List<BaseMangaPage>,
val imageCount: Int
)
@@ -0,0 +1,8 @@
package io.legato.kazusa.ui.book.manga.entities
data class MangaContent(
val pos: Int,
val items: List<Any>,
val curFinish: Boolean,
val nextFinish: Boolean
)
@@ -0,0 +1,10 @@
package io.legato.kazusa.ui.book.manga.entities
data class MangaPage(
override val chapterIndex: Int = 0,//总章节位置
val chapterSize: Int,//总章节数量
val mImageUrl: String = "",//当前URL
override val index: Int = 0,//当前章节位置
var imageCount: Int = 0,//当前章节内容总数
val mChapterName: String = "",//章节名称
) : BaseMangaPage
@@ -0,0 +1,9 @@
package io.legato.kazusa.ui.book.manga.entities
data class ReaderLoading(
override val chapterIndex: Int = 0,
override val index: Int = 0,
val mMessage: String? = null,
val isVolume: Boolean = false
) : BaseMangaPage
@@ -0,0 +1,64 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewConfiguration
import kotlin.math.abs
open class GestureDetectorWithLongTap(
context: Context,
listener: Listener,
) : GestureDetector(context, listener) {
private val handler = Handler(Looper.getMainLooper())
private val slop = ViewConfiguration.get(context).scaledTouchSlop
private val longTapTime = ViewConfiguration.getLongPressTimeout().toLong()
private val doubleTapTime = ViewConfiguration.getDoubleTapTimeout().toLong()
private var downX = 0f
private var downY = 0f
private var lastUp = 0L
private var lastDownEvent: MotionEvent? = null
private val longTapFn = Runnable { listener.onLongTapConfirmed(lastDownEvent!!) }
override fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastDownEvent?.recycle()
lastDownEvent = MotionEvent.obtain(ev)
if (ev.downTime - lastUp > doubleTapTime) {
downX = ev.rawX
downY = ev.rawY
handler.postDelayed(longTapFn, longTapTime)
}
}
MotionEvent.ACTION_MOVE -> {
if (abs(ev.rawX - downX) > slop || abs(ev.rawY - downY) > slop) {
handler.removeCallbacks(longTapFn)
}
}
MotionEvent.ACTION_UP -> {
lastUp = ev.eventTime
handler.removeCallbacks(longTapFn)
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(longTapFn)
}
}
return super.onTouchEvent(ev)
}
open class Listener : SimpleOnGestureListener() {
open fun onLongTapConfirmed(ev: MotionEvent) {
}
}
}
@@ -0,0 +1,247 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import android.content.Context
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.util.SparseArray
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import androidx.annotation.IntRange
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.bumptech.glide.Glide
import com.bumptech.glide.ListPreloader.PreloadModelProvider
import com.bumptech.glide.RequestBuilder
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter.Companion.TYPE_FOOTER_VIEW
import io.legato.kazusa.databinding.ItemBookMangaEdgeBinding
import io.legato.kazusa.databinding.ItemBookMangaPageBinding
import io.legato.kazusa.help.glide.progress.ProgressManager
import io.legato.kazusa.model.BookCover
import io.legato.kazusa.model.ReadManga
import io.legato.kazusa.ui.book.manga.config.MangaColorFilterConfig
import io.legato.kazusa.ui.book.manga.entities.MangaPage
import io.legato.kazusa.ui.book.manga.entities.ReaderLoading
import io.legato.kazusa.utils.dpToPx
class MangaAdapter(private val context: Context) :
RecyclerView.Adapter<RecyclerView.ViewHolder>(), PreloadModelProvider<Any> {
private val inflater: LayoutInflater = LayoutInflater.from(context)
private lateinit var mConfig: MangaColorFilterConfig
companion object {
private const val LOADING_VIEW = 0
private const val CONTENT_VIEW = 1
}
var isHorizontal = false
private val mDiffCallback: DiffUtil.ItemCallback<Any> = object : DiffUtil.ItemCallback<Any>() {
override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean {
return if (oldItem is ReaderLoading && newItem is ReaderLoading) {
newItem.mMessage == oldItem.mMessage
} else if (oldItem is MangaPage && newItem is MangaPage) {
oldItem.mImageUrl == newItem.mImageUrl
} else false
}
override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean {
return if (oldItem is ReaderLoading && newItem is ReaderLoading) {
oldItem == newItem
} else if (oldItem is MangaPage && newItem is MangaPage) {
oldItem == newItem
} else false
}
}
private val mDiffer = AsyncListDiffer(this, mDiffCallback)
fun getItem(@IntRange(from = 0) position: Int) = mDiffer.currentList.getOrNull(position)
fun getItems() = mDiffer.currentList
fun isEmpty() = mDiffer.currentList.isEmpty()
fun isNotEmpty() = !isEmpty()
//全部替换数据
fun submitList(contents: List<Any>, runnable: Runnable? = null) {
mDiffer.submitList(contents, runnable)
}
inner class PageViewHolder(binding: ItemBookMangaPageBinding) :
MangaVH<ItemBookMangaPageBinding>(binding, context) {
init {
initComponent(
binding.loading,
binding.image,
binding.progress,
binding.retry,
binding.flProgress
)
binding.retry.setOnClickListener {
val item = mDiffer.currentList[layoutPosition]
if (item is MangaPage) {
val isLastImage = item.imageCount > 0 && item.index == item.imageCount - 1
loadImageWithRetry(
item.mImageUrl, isHorizontal, isLastImage
)
}
}
}
fun onBind(item: MangaPage) {
setImageColorFilter()
val isLastImage = item.imageCount > 0 && item.index == item.imageCount - 1
loadImageWithRetry(item.mImageUrl, isHorizontal, isLastImage)
}
fun setImageColorFilter() {
require(
mConfig.r in 0..255 &&
mConfig.g in 0..255 &&
mConfig.b in 0..255 &&
mConfig.a in 0..255
) {
"ARGB values must be between 0-255"
}
val matrix = floatArrayOf(
(255 - mConfig.r) / 255f, 0f, 0f, 0f, 0f,
0f, (255 - mConfig.g) / 255f, 0f, 0f, 0f,
0f, 0f, (255 - mConfig.b) / 255f, 0f, 0f,
0f, 0f, 0f, (255 - mConfig.a) / 255f, 0f
)
binding.image.colorFilter = ColorMatrixColorFilter(ColorMatrix(matrix))
}
}
inner class PageMoreViewHolder(val binding: ItemBookMangaEdgeBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: ReaderLoading) {
val message = item.mMessage
binding.text.text = message
itemView.updateLayoutParams {
height = if (item.isVolume) {
MATCH_PARENT
} else {
96.dpToPx()
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when {
viewType >= TYPE_FOOTER_VIEW -> {
ItemViewHolder(footerItems.get(viewType).invoke(parent))
}
viewType == LOADING_VIEW -> {
PageMoreViewHolder(ItemBookMangaEdgeBinding.inflate(inflater, parent, false))
}
viewType == CONTENT_VIEW -> {
PageViewHolder(ItemBookMangaPageBinding.inflate(inflater, parent, false))
}
else -> error("Unknown view type!")
}
}
override fun getItemCount(): Int = getActualItemCount() + getFooterCount()
override fun getItemViewType(position: Int): Int {
return when {
isFooter(position) -> TYPE_FOOTER_VIEW + position - getActualItemCount()
getItem(position) is MangaPage -> CONTENT_VIEW
getItem(position) is ReaderLoading -> LOADING_VIEW
else -> error("Unknown view type!")
}
}
fun getFooterCount() = footerItems.size()
private fun isFooter(position: Int) = position >= getActualItemCount()
override fun onViewRecycled(vh: RecyclerView.ViewHolder) {
super.onViewRecycled(vh)
when (vh) {
is PageViewHolder -> {
vh.itemView.updateLayoutParams<ViewGroup.LayoutParams> {
height = MATCH_PARENT
}
Glide.with(context).clear(vh.binding.image)
if (vh.binding.image.tag is String) {
ProgressManager.removeListener(vh.binding.image.tag as String)
}
}
}
}
override fun onBindViewHolder(vh: RecyclerView.ViewHolder, position: Int) {
when (vh) {
is PageViewHolder -> vh.onBind(getItem(position) as MangaPage)
is PageMoreViewHolder -> vh.onBind(getItem(position) as ReaderLoading)
}
}
private val footerItems: SparseArray<(parent: ViewGroup) -> ViewBinding> by lazy { SparseArray() }
@Synchronized
fun addFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) {
kotlin.runCatching {
val index = getActualItemCount() + footerItems.size()
footerItems.put(TYPE_FOOTER_VIEW + footerItems.size(), footer)
notifyItemInserted(index)
}
}
/**
* 除去header和footer
*/
fun getActualItemCount() = getItems().size
@Synchronized
fun removeFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) {
kotlin.runCatching {
val index = footerItems.indexOfValue(footer)
if (index >= 0) {
footerItems.remove(index)
notifyItemRemoved(getActualItemCount() + index - 2)
}
}
}
override fun getPreloadItems(position: Int): List<Any> {
if (isEmpty() || position >= getItems().size) {
return emptyList()
}
return getItems().subList(position, position + 1)
}
override fun getPreloadRequestBuilder(item: Any): RequestBuilder<*>? {
if (item is MangaPage) {
return BookCover.preloadManga(
context,
item.mImageUrl,
sourceOrigin = ReadManga.book?.origin,
)
}
return null
}
fun setMangaImageColorFilter(config: MangaColorFilterConfig) {
mConfig = config
notifyItemRangeChanged(0, itemCount)
}
}
@@ -0,0 +1,17 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import android.content.Context
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
class MangaLayoutManager(context: Context) :
LinearLayoutManager(context) {
private val extraLayoutSpace = context.resources.displayMetrics.heightPixels * 3 / 4
@Deprecated("Deprecated in Java")
override fun getExtraLayoutSpace(state: RecyclerView.State?): Int {
return extraLayoutSpace
}
}
@@ -0,0 +1,133 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.Gravity
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import io.legato.kazusa.help.glide.progress.ProgressManager
import io.legato.kazusa.model.BookCover
import io.legato.kazusa.model.ReadManga
import io.legato.kazusa.utils.printOnDebug
open class MangaVH<VB : ViewBinding>(val binding: VB, private val context: Context) :
RecyclerView.ViewHolder(binding.root) {
protected lateinit var mLoading: ProgressBar
protected lateinit var mImage: AppCompatImageView
protected lateinit var mProgress: TextView
protected lateinit var mFlProgress: FrameLayout
protected var mRetry: Button? = null
private val minHeight = context.resources.displayMetrics.heightPixels * 2 / 3
fun initComponent(
loading: ProgressBar,
image: AppCompatImageView,
progress: TextView,
button: Button? = null,
flProgress: FrameLayout,
) {
mLoading = loading
mImage = image
mRetry = button
mProgress = progress
mFlProgress = flProgress
}
@SuppressLint("CheckResult")
fun loadImageWithRetry(imageUrl: String, isHorizontal: Boolean, isLastImage: Boolean) {
mFlProgress.isVisible = true
mLoading.isVisible = true
mRetry?.isGone = true
mProgress.isVisible = true
ProgressManager.removeListener(imageUrl)
ProgressManager.addListener(imageUrl) { _, percentage, _, _ ->
@SuppressLint("SetTextI18n")
mProgress.text = "$percentage%"
}
try {
mImage.tag = imageUrl
BookCover.loadManga(
context,
imageUrl,
sourceOrigin = ReadManga.book?.origin,
).addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean,
): Boolean {
mFlProgress.isVisible = true
mLoading.isGone = true
mRetry?.isVisible = true
mProgress.isGone = true
itemView.updateLayoutParams<ViewGroup.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource,
isFirstResource: Boolean,
): Boolean {
mFlProgress.isGone = true
if (!isHorizontal) {
itemView.updateLayoutParams<ViewGroup.LayoutParams> {
height = ViewGroup.LayoutParams.WRAP_CONTENT
}
mImage.updateLayoutParams<FrameLayout.LayoutParams> {
gravity = Gravity.NO_GRAVITY
}
if (isLastImage) {
mImage.updateLayoutParams<FrameLayout.LayoutParams> {
height = ViewGroup.LayoutParams.WRAP_CONTENT
}
itemView.minimumHeight = minHeight
} else {
mImage.updateLayoutParams<FrameLayout.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
itemView.minimumHeight = 0
}
mImage.scaleType = ImageView.ScaleType.FIT_XY
} else {
itemView.updateLayoutParams<ViewGroup.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
itemView.minimumHeight = 0
mImage.updateLayoutParams<FrameLayout.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
gravity = Gravity.CENTER
}
mImage.scaleType = ImageView.ScaleType.FIT_CENTER
}
return false
}
}).into(mImage)
} catch (e: Exception) {
e.printOnDebug()
}
}
}
@@ -0,0 +1,75 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class ScrollTimer(
private val callback: ScrollCallback,
private val recyclerView: RecyclerView,
private val coroutineScope: CoroutineScope,
) : RecyclerView.OnScrollListener() {
private var distance = 1
private var mScrollPageJob: Job? = null
var isEnabled: Boolean = false
set(value) {
if (field != value) {
field = value
if (value) {
recyclerView.addOnScrollListener(this)
startScroll()
} else {
recyclerView.removeOnScrollListener(this)
recyclerView.stopScroll()
}
}
}
var isEnabledPage: Boolean = false
set(value) {
if (field != value) {
field = value
if (value) {
mScrollPageJob?.cancel()
startScrollPage()
} else {
mScrollPageJob?.cancel()
}
}
}
override fun onScrollStateChanged(
recyclerView: RecyclerView,
newState: Int,
) {
if (newState == SCROLL_STATE_IDLE) {
startScroll()
}
}
fun setSpeed(distance: Int) {
this.distance = distance
}
private fun startScroll() {
callback.scrollBy(distance)
}
private fun startScrollPage() {
mScrollPageJob = coroutineScope.launch {
while (isActive) {
delay(distance.times(1000L))
callback.scrollPage()
}
}
}
interface ScrollCallback {
fun scrollBy(distance: Int)
fun scrollPage()
}
}
@@ -0,0 +1,126 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import android.content.Context
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.widget.FrameLayout
class WebtoonFrame : FrameLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(
context,
attrs,
defStyle
)
private val scaleDetector = ScaleGestureDetector(context, ScaleListener())
private val flingDetector = GestureDetector(context, FlingListener())
var doubleTapZoom = true
set(value) {
field = value
recycler?.doubleTapZoom = value
scaleDetector.isQuickScaleEnabled = value
}
var disableMangaScale = false
private val recycler: WebtoonRecyclerView?
get() = getChildAt(0) as? WebtoonRecyclerView
private val mcRect = RectF()
private val blRect = RectF()
private val brRect = RectF()
private var mTouchMiddle: (() -> Unit)? = null
fun onTouchMiddle(init: () -> Unit) = apply { this.mTouchMiddle = init }
private var mNextPage: (() -> Unit)? = null
fun onNextPage(init: () -> Unit) = apply { this.mNextPage = init }
private var mPrevPage: (() -> Unit)? = null
fun onPrevPage(init: () -> Unit) = apply { this.mPrevPage = init }
var disabledClickScroll = false
override fun onAttachedToWindow() {
super.onAttachedToWindow()
recycler?.tapListener = { ev ->
when {
mcRect.contains(ev.rawX, ev.rawY) -> {
mTouchMiddle?.invoke()
}
blRect.contains(ev.rawX, ev.rawY) && !disabledClickScroll -> {
mPrevPage?.invoke()
}
brRect.contains(ev.rawX, ev.rawY) && !disabledClickScroll -> {
mNextPage?.invoke()
}
}
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
mcRect.set(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f)
blRect.set(0f, height * 0.66f, width * 0.33f, height.toFloat())
brRect.set(width * 0.66f, height * 0.66f, width.toFloat(), height.toFloat())
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (!disableMangaScale) {
scaleDetector.onTouchEvent(ev)
flingDetector.onTouchEvent(ev)
val recyclerRect = Rect()
recycler?.getHitRect(recyclerRect) ?: return super.dispatchTouchEvent(ev)
recyclerRect.inset(1, 1)
if (recyclerRect.right < recyclerRect.left || recyclerRect.bottom < recyclerRect.top) {
return super.dispatchTouchEvent(ev)
}
ev.setLocation(
ev.x.coerceIn(recyclerRect.left.toFloat(), recyclerRect.right.toFloat()),
ev.y.coerceIn(recyclerRect.top.toFloat(), recyclerRect.bottom.toFloat()),
)
}
return super.dispatchTouchEvent(ev)
}
inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
recycler?.onScaleBegin()
return true
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
recycler?.onScale(detector.scaleFactor)
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
recycler?.onScaleEnd()
}
}
inner class FlingListener : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean {
return true
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean {
return recycler?.zoomFling(velocityX.toInt(), velocityY.toInt()) ?: false
}
}
}
@@ -0,0 +1,372 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.ViewConfiguration
import android.view.animation.DecelerateInterpolator
import androidx.core.animation.doOnEnd
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.utils.findCenterViewPosition
import kotlin.math.abs
class WebtoonRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : RecyclerView(context, attrs, defStyle) {
private var isZooming = false
private var atLastPosition = false
private var atFirstPosition = false
private var halfWidth = 0
private var halfHeight = 0
private var originalHeight = 0
private var heightSet = false
private var firstVisibleItemPosition = 0
private var lastVisibleItemPosition = 0
private var currentScale = DEFAULT_RATE
private var mLastCenterViewPosition = 0
private var mPreScrollListener: IComicPreScroll? = null
private var mNestedPreScrollListener: IComicPreScroll? = null
private val listener = GestureListener()
private val detector = Detector()
var doubleTapZoom = true
var tapListener: ((MotionEvent) -> Unit)? = null
var longTapListener: ((MotionEvent) -> Boolean)? = null
var disableMangaScale = false
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
halfWidth = MeasureSpec.getSize(widthSpec) / 2
halfHeight = MeasureSpec.getSize(heightSpec) / 2
if (!heightSet) {
originalHeight = MeasureSpec.getSize(heightSpec)
heightSet = true
}
super.onMeasure(widthSpec, heightSpec)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(e: MotionEvent): Boolean {
return detector.onTouchEvent(e) || super.onTouchEvent(e)
}
override fun onScrolled(dx: Int, dy: Int) {
super.onScrolled(dx, dy)
val layoutManager = layoutManager
lastVisibleItemPosition =
(layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
}
override fun onScrollStateChanged(state: Int) {
super.onScrollStateChanged(state)
val layoutManager = layoutManager
val visibleItemCount = layoutManager?.childCount ?: 0
val totalItemCount = layoutManager?.itemCount ?: 0
atLastPosition = visibleItemCount > 0 && lastVisibleItemPosition == totalItemCount - 1
atFirstPosition = firstVisibleItemPosition == 0
}
override fun dispatchNestedPreScroll(
dx: Int,
dy: Int,
consumed: IntArray?,
offsetInWindow: IntArray?,
type: Int
): Boolean {
val position = findCenterViewPosition()
if (position != NO_POSITION && position != mLastCenterViewPosition) {
mLastCenterViewPosition = position
mPreScrollListener?.onPreScrollListener(this, dx, dy, position)
}
mNestedPreScrollListener?.onPreScrollListener(this, dx, dy, position)
return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
}
private fun getPositionX(positionX: Float): Float {
if (currentScale < 1) {
return 0f
}
val maxPositionX = halfWidth * (currentScale - 1)
return positionX.coerceIn(-maxPositionX, maxPositionX)
}
private fun getPositionY(positionY: Float): Float {
if (currentScale < 1) {
return (originalHeight / 2 - halfHeight).toFloat()
}
val maxPositionY = halfHeight * (currentScale - 1)
return positionY.coerceIn(-maxPositionY, maxPositionY)
}
private fun zoom(
fromRate: Float,
toRate: Float,
fromX: Float,
toX: Float,
fromY: Float,
toY: Float,
) {
isZooming = true
val animatorSet = AnimatorSet()
val translationXAnimator = ValueAnimator.ofFloat(fromX, toX)
translationXAnimator.addUpdateListener { animation -> x = animation.animatedValue as Float }
val translationYAnimator = ValueAnimator.ofFloat(fromY, toY)
translationYAnimator.addUpdateListener { animation -> y = animation.animatedValue as Float }
val scaleAnimator = ValueAnimator.ofFloat(fromRate, toRate)
scaleAnimator.addUpdateListener { animation ->
currentScale = animation.animatedValue as Float
setScaleRate(currentScale)
}
animatorSet.playTogether(translationXAnimator, translationYAnimator, scaleAnimator)
animatorSet.duration = ANIMATOR_DURATION_TIME.toLong()
animatorSet.interpolator = DecelerateInterpolator()
animatorSet.start()
animatorSet.doOnEnd {
isZooming = false
currentScale = toRate
}
}
fun zoomFling(velocityX: Int, velocityY: Int): Boolean {
if (currentScale <= 1f) return false
val distanceTimeFactor = 0.4f
val animatorSet = AnimatorSet()
if (velocityX != 0) {
val dx = (distanceTimeFactor * velocityX / 2)
val newX = getPositionX(x + dx)
val translationXAnimator = ValueAnimator.ofFloat(x, newX)
translationXAnimator.addUpdateListener { animation ->
x = getPositionX(animation.animatedValue as Float)
}
animatorSet.play(translationXAnimator)
}
if (velocityY != 0 && (atFirstPosition || atLastPosition)) {
val dy = (distanceTimeFactor * velocityY / 2)
val newY = getPositionY(y + dy)
val translationYAnimator = ValueAnimator.ofFloat(y, newY)
translationYAnimator.addUpdateListener { animation ->
y = getPositionY(animation.animatedValue as Float)
}
animatorSet.play(translationYAnimator)
}
animatorSet.duration = 400
animatorSet.interpolator = DecelerateInterpolator()
animatorSet.start()
return true
}
fun resetZoom() {
zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
}
private fun zoomScrollBy(dx: Int, dy: Int) {
if (dx != 0) {
x = getPositionX(x + dx)
}
if (dy != 0) {
y = getPositionY(y + dy)
}
}
private fun setScaleRate(rate: Float) {
scaleX = rate
scaleY = rate
}
fun onScale(scaleFactor: Float) {
currentScale *= scaleFactor
currentScale = currentScale.coerceIn(
MIN_RATE,
MAX_SCALE_RATE,
)
setScaleRate(currentScale)
layoutParams.height = if (currentScale < 1) {
(originalHeight / currentScale).toInt()
} else {
originalHeight
}
halfHeight = layoutParams.height / 2
if (currentScale != DEFAULT_RATE) {
x = getPositionX(x)
y = getPositionY(y)
} else {
x = 0f
y = 0f
}
requestLayout()
}
fun onScaleBegin() {
if (detector.isDoubleTapping) {
detector.isQuickScaling = true
}
}
fun onScaleEnd() {
if (scaleX < MIN_RATE) {
zoom(currentScale, MIN_RATE, x, 0f, y, 0f)
}
}
inner class GestureListener : GestureDetectorWithLongTap.Listener() {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
tapListener?.invoke(ev)
return false
}
override fun onDoubleTap(ev: MotionEvent): Boolean {
detector.isDoubleTapping = true
return false
}
fun onDoubleTapConfirmed(ev: MotionEvent) {
if (!isZooming && doubleTapZoom) {
if (scaleX != DEFAULT_RATE) {
zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
} else {
val toScale = 2f
val toX = (halfWidth - ev.x) * (toScale - 1)
val toY = (halfHeight - ev.y) * (toScale - 1)
zoom(DEFAULT_RATE, toScale, 0f, toX, 0f, toY)
}
}
}
override fun onLongTapConfirmed(ev: MotionEvent) {
if (longTapListener?.invoke(ev) == true) {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
}
}
inner class Detector : GestureDetectorWithLongTap(context, listener) {
private var scrollPointerId = 0
private var downX = 0
private var downY = 0
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var isZoomDragging = false
var isDoubleTapping = false
var isQuickScaling = false
override fun onTouchEvent(ev: MotionEvent): Boolean {
val action = ev.actionMasked
val actionIndex = ev.actionIndex
when (action) {
MotionEvent.ACTION_DOWN -> {
scrollPointerId = ev.getPointerId(0)
downX = (ev.x + 0.5f).toInt()
downY = (ev.y + 0.5f).toInt()
}
MotionEvent.ACTION_POINTER_DOWN -> {
scrollPointerId = ev.getPointerId(actionIndex)
downX = (ev.getX(actionIndex) + 0.5f).toInt()
downY = (ev.getY(actionIndex) + 0.5f).toInt()
}
MotionEvent.ACTION_MOVE -> {
if (disableMangaScale) {
return super.onTouchEvent(ev)
}
if (isDoubleTapping && isQuickScaling) {
return true
}
val index = ev.findPointerIndex(scrollPointerId)
if (index < 0) {
return false
}
val x = (ev.getX(index) + 0.5f).toInt()
val y = (ev.getY(index) + 0.5f).toInt()
var dx = x - downX
var dy = if (atFirstPosition || atLastPosition) y - downY else 0
if (!isZoomDragging && currentScale > 1f) {
var startScroll = false
if (abs(dx) > touchSlop) {
if (dx < 0) {
dx += touchSlop
} else {
dx -= touchSlop
}
startScroll = true
}
if (abs(dy) > touchSlop) {
if (dy < 0) {
dy += touchSlop
} else {
dy -= touchSlop
}
startScroll = true
}
if (startScroll) {
isZoomDragging = true
}
}
if (isZoomDragging) {
zoomScrollBy(dx, dy)
}
}
MotionEvent.ACTION_UP -> {
if (isDoubleTapping && !isQuickScaling && !disableMangaScale) {
listener.onDoubleTapConfirmed(ev)
}
isZoomDragging = false
isDoubleTapping = false
isQuickScaling = false
}
MotionEvent.ACTION_CANCEL -> {
isZoomDragging = false
isDoubleTapping = false
isQuickScaling = false
}
}
return super.onTouchEvent(ev)
}
}
fun setPreScrollListener(iComicPreScroll: IComicPreScroll) {
mPreScrollListener = iComicPreScroll
}
fun setNestedPreScrollListener(iComicPreScroll: IComicPreScroll) {
mNestedPreScrollListener = iComicPreScroll
}
fun interface IComicPreScroll {
fun onPreScrollListener(recyclerView: RecyclerView, dx: Int, dy: Int, position: Int)
}
}
private const val ANIMATOR_DURATION_TIME = 200
private const val MIN_RATE = 0.5f
private const val DEFAULT_RATE = 1f
private const val MAX_SCALE_RATE = 3f
@@ -0,0 +1,389 @@
package io.legato.kazusa.ui.book.read
import android.annotation.SuppressLint
import android.app.DatePickerDialog
import android.content.pm.ActivityInfo
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.view.WindowInsets
import android.view.WindowManager
import androidx.activity.viewModels
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.AppConst.charsets
import io.legato.kazusa.constant.PreferKey
import io.legato.kazusa.databinding.ActivityBookReadBinding
import io.legato.kazusa.databinding.DialogDownloadChoiceBinding
import io.legato.kazusa.databinding.DialogEditTextBinding
import io.legato.kazusa.databinding.DialogSimulatedReadingBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.LocalConfig
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.lib.dialogs.selector
//import io.legado.app.lib.theme.bottomBackground
import io.legato.kazusa.model.CacheBook
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.ui.book.read.config.BgTextConfigDialog
import io.legato.kazusa.ui.book.read.config.ClickActionConfigDialog
import io.legato.kazusa.ui.book.read.config.PaddingConfigDialog
import io.legato.kazusa.ui.book.read.config.PageKeyDialog
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.find
import io.legato.kazusa.utils.getPrefString
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.isTv
import io.legato.kazusa.utils.setNavigationBarColorAuto
import io.legato.kazusa.utils.setOnApplyWindowInsetsListenerCompat
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import java.time.LocalDate
import java.time.format.DateTimeFormatter
/**
* 阅读界面
*/
abstract class BaseReadBookActivity :
VMBaseActivity<ActivityBookReadBinding, ReadBookViewModel>(imageBg = false) {
override val binding by viewBinding(ActivityBookReadBinding::inflate)
override val viewModel by viewModels<ReadBookViewModel>()
protected val menuLayoutIsVisible
get() = bottomDialog > 0 || binding.readMenu.isVisible || binding.searchMenu.bottomMenuVisible
var bottomDialog = 0
set(value) {
if (field != value) {
field = value
onBottomDialogChange()
}
}
private val selectBookFolderResult = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
ReadBook.book?.let { book ->
FileDoc.fromUri(uri, true).find(book.originName)?.let { doc ->
book.bookUrl = doc.uri.toString()
book.save()
viewModel.loadChapterList(book)
} ?: ReadBook.upMsg("找不到文件")
}
} ?: ReadBook.upMsg("没有权限访问")
}
override fun onCreate(savedInstanceState: Bundle?) {
ReadBook.msg = null
setOrientation()
upLayoutInDisplayCutoutMode()
super.onCreate(savedInstanceState)
binding.navigationBar.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
view.updateLayoutParams {
height = insets.bottom
}
windowInsets
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
//binding.navigationBar.setBackgroundColor(bottomBackground)
viewModel.permissionDenialLiveData.observe(this) {
selectBookFolderResult.launch {
mode = HandleFileContract.DIR_SYS
title = "选择书籍所在文件夹"
}
}
if (!LocalConfig.readHelpVersionIsLast) {
if (isTv) {
showCustomPageKeyConfig()
} else {
showClickRegionalConfig()
}
}
}
private fun onBottomDialogChange() {
when (bottomDialog) {
0 -> onMenuHide()
1 -> onMenuShow()
}
}
open fun onMenuShow() {
}
open fun onMenuHide() {
}
fun showPaddingConfig() {
showDialogFragment<PaddingConfigDialog>()
}
fun showBgTextConfig() {
showDialogFragment<BgTextConfigDialog>()
}
fun showClickRegionalConfig() {
showDialogFragment<ClickActionConfigDialog>()
}
private fun showCustomPageKeyConfig() {
PageKeyDialog(this).show()
}
/**
* 屏幕方向
*/
@SuppressLint("SourceLockedOrientationActivity")
fun setOrientation() {
when (AppConfig.screenOrientation) {
"0" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
"1" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
"2" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
"3" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR
"4" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
}
}
/**
* 更新状态栏,导航栏
*/
fun upSystemUiVisibility(
isInMultiWindow: Boolean,
toolBarHide: Boolean = true,
useBgMeanColor: Boolean = false
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.run {
if (toolBarHide && ReadBookConfig.hideNavigationBar) {
hide(WindowInsets.Type.navigationBars())
} else {
show(WindowInsets.Type.navigationBars())
}
if (toolBarHide && ReadBookConfig.hideStatusBar) {
hide(WindowInsets.Type.statusBars())
} else {
show(WindowInsets.Type.statusBars())
}
}
}
upSystemUiVisibilityO(isInMultiWindow, toolBarHide)
// if (toolBarHide) {
// setLightStatusBar(ReadBookConfig.durConfig.curStatusIconDark())
// } else {
// val statusBarColor =
// if (AppConfig.readBarStyleFollowPage
// && ReadBookConfig.durConfig.curBgType() == 0
// || useBgMeanColor
// ) {
// ReadBookConfig.bgMeanColor
// } else {
// ThemeStore.statusBarColor(this, AppConfig.isTransparentStatusBar)
// }
// setLightStatusBar(ColorUtils.isColorLight(statusBarColor))
// }
}
@Suppress("DEPRECATION")
private fun upSystemUiVisibilityO(
isInMultiWindow: Boolean,
toolBarHide: Boolean = true
) {
var flag = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_IMMERSIVE
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
if (!isInMultiWindow) {
flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
}
if (ReadBookConfig.hideNavigationBar) {
flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
if (toolBarHide) {
flag = flag or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
}
}
if (ReadBookConfig.hideStatusBar && toolBarHide) {
flag = flag or View.SYSTEM_UI_FLAG_FULLSCREEN
}
window.decorView.systemUiVisibility = flag
}
override fun upNavigationBarColor() {
upNavigationBar()
when {
binding.readMenu.isVisible -> super.upNavigationBarColor()
binding.searchMenu.bottomMenuVisible -> super.upNavigationBarColor()
bottomDialog > 0 -> super.upNavigationBarColor()
//!AppConfig.immNavigationBar -> super.upNavigationBarColor()
else -> setNavigationBarColorAuto(ReadBookConfig.bgMeanColor)
}
}
@SuppressLint("RtlHardcoded")
private fun upNavigationBar() {
binding.navigationBar.gone(!menuLayoutIsVisible)
}
/**
* 保持亮屏
*/
fun keepScreenOn(on: Boolean) {
val isScreenOn =
(window.attributes.flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0
if (on == isScreenOn) return
if (on) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
/**
* 适配刘海
*/
private fun upLayoutInDisplayCutoutMode() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode = if (ReadBookConfig.readBodyToLh) {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} else {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
}
}
}
}
@SuppressLint("InflateParams", "SetTextI18n")
fun showDownloadDialog() {
ReadBook.book?.let { book ->
alert(titleResource = R.string.offline_cache) {
val alertBinding = DialogDownloadChoiceBinding.inflate(layoutInflater).apply {
editStart.setText((book.durChapterIndex + 1).toString())
editEnd.setText(book.totalChapterNum.toString())
}
customView { alertBinding.root }
okButton {
alertBinding.run {
val start = editStart.text!!.toString().let {
if (it.isEmpty()) 0 else it.toInt()
}
val end = editEnd.text!!.toString().let {
if (it.isEmpty()) book.totalChapterNum else it.toInt()
}
CacheBook.start(this@BaseReadBookActivity, book, start - 1, end - 1)
}
}
cancelButton()
}
}
}
fun showSimulatedReading() {
val book = ReadBook.book ?: return
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val alertBinding = DialogSimulatedReadingBinding.inflate(layoutInflater).apply {
srEnabled.isChecked = book.getReadSimulating()
editStart.setText(book.getStartChapter().toString())
editNum.setText(book.getDailyChapters().toString())
startDate.setText(book.getStartDate()?.format(dateFormatter))
startDate.isFocusable = false // 设置为false,不允许获得焦点
startDate.isCursorVisible = false // 不显示光标
startDate.setOnClickListener {
// 获取当前日期
val localStartDate = LocalDate.parse(startDate.text)
// 创建 DatePickerDialog
val datePickerDialog = DatePickerDialog(
root.context,
{ _, yy, mm, dayOfMonth ->
// 使用Java 8的日期和时间API来格式化日期
val date = LocalDate.of(yy, mm + 1, dayOfMonth) // Java 8的LocalDate,月份从1开始
val formattedDate = date.format(dateFormatter)
startDate.setText(formattedDate)
}, localStartDate.year,
localStartDate.monthValue - 1,
localStartDate.dayOfMonth
)
datePickerDialog.show()
}
}
alert(titleResource = R.string.simulated_reading) {
customView { alertBinding.root }
okButton {
alertBinding.run {
val start = editStart.text!!.toString().let {
if (it.isEmpty()) 0 else it.toInt()
}
val num = editNum.text!!.toString().let {
if (it.isEmpty()) book.totalChapterNum else it.toInt()
}
val enabled = srEnabled.isChecked
val date = startDate.text!!.toString().let {
if (it.isEmpty()) LocalDate.now()
else LocalDate.parse(it, dateFormatter)
}
book.setStartDate(date)
book.setDailyChapters(num)
book.setStartChapter(start)
book.setReadSimulating(enabled)
book.save()
ReadBook.clearTextChapter()
viewModel.initData(intent)
}
}
cancelButton()
}
}
fun showCharsetConfig() {
alert(R.string.set_charset) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "charset"
editView.setFilterValues(charsets)
editView.setText(ReadBook.book?.charset)
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let {
ReadBook.setCharset(it)
}
}
cancelButton()
}
}
fun showPageAnimConfig(success: () -> Unit) {
val items = arrayListOf<String>()
items.add(getString(R.string.btn_default_s))
items.add(getString(R.string.page_anim_cover))
items.add(getString(R.string.page_anim_slide))
items.add(getString(R.string.page_anim_simulation))
items.add(getString(R.string.page_anim_scroll))
items.add(getString(R.string.page_anim_none))
selector(R.string.page_anim, items) { _, i ->
ReadBook.book?.setPageAnim(i - 1)
success()
}
}
fun isPrevKey(keyCode: Int): Boolean {
if (keyCode == KeyEvent.KEYCODE_UNKNOWN) {
return false
}
val prevKeysStr = getPrefString(PreferKey.prevKeys)
return prevKeysStr?.split(",")?.contains(keyCode.toString()) ?: false
}
fun isNextKey(keyCode: Int): Boolean {
if (keyCode == KeyEvent.KEYCODE_UNKNOWN) {
return false
}
val nextKeysStr = getPrefString(PreferKey.nextKeys)
return nextKeysStr?.split(",")?.contains(keyCode.toString()) ?: false
}
}
@@ -0,0 +1,170 @@
package io.legato.kazusa.ui.book.read
import android.app.Application
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.databinding.DialogContentEditBinding
import io.legato.kazusa.databinding.DialogEditTextBinding
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.ContentProcessor
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 内容编辑
*/
class ContentEditDialog : BaseDialogFragment(R.layout.dialog_content_edit) {
val binding by viewBinding(DialogContentEditBinding::bind)
val viewModel by viewModels<ContentEditViewModel>()
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.title = ReadBook.curTextChapter?.title
initMenu()
binding.toolBar.setOnClickListener {
lifecycleScope.launch {
val book = ReadBook.book ?: return@launch
val chapter = withContext(IO) {
appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
} ?: return@launch
editTitle(chapter)
}
}
viewModel.loadStateLiveData.observe(viewLifecycleOwner) {
if (it) {
binding.rlLoading.visible()
} else {
binding.rlLoading.gone()
}
}
viewModel.initContent {
binding.contentView.setText(it)
binding.contentView.post {
binding.contentView.apply {
val lineIndex = layout.getLineForOffset(ReadBook.durChapterPos)
val lineHeight = layout.getLineTop(lineIndex)
scrollTo(0, lineHeight)
}
}
}
}
private fun initMenu() {
binding.toolBar.inflateMenu(R.menu.content_edit)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_save -> {
save()
dismiss()
}
R.id.menu_reset -> viewModel.initContent(true) { content ->
binding.contentView.setText(content)
ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
}
R.id.menu_copy_all -> requireContext()
.sendToClip("${binding.toolBar.title}\n${binding.contentView.text}")
}
return@setOnMenuItemClickListener true
}
}
private fun editTitle(chapter: BookChapter) {
alert {
setTitle(R.string.edit)
val alertBinding = DialogEditTextBinding.inflate(layoutInflater)
alertBinding.editView.setText(chapter.title)
setCustomView(alertBinding.root)
okButton {
chapter.title = alertBinding.editView.text.toString()
lifecycleScope.launch {
withContext(IO) {
appDb.bookChapterDao.update(chapter)
}
binding.toolBar.title = chapter.getDisplayTitle()
ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
}
}
}
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
save()
}
private fun save() {
val content = binding.contentView.text?.toString() ?: return
Coroutine.async {
val book = ReadBook.book ?: return@async
val chapter = appDb.bookChapterDao
.getChapter(book.bookUrl, ReadBook.durChapterIndex)
?: return@async
BookHelp.saveText(book, chapter, content)
ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
}
}
class ContentEditViewModel(application: Application) : BaseViewModel(application) {
val loadStateLiveData = MutableLiveData<Boolean>()
var content: String? = null
fun initContent(reset: Boolean = false, success: (String) -> Unit) {
execute {
val book = ReadBook.book ?: return@execute null
val chapter = appDb.bookChapterDao
.getChapter(book.bookUrl, ReadBook.durChapterIndex)
?: return@execute null
if (reset) {
content = null
BookHelp.delContent(book, chapter)
if (!book.isLocal) ReadBook.bookSource?.let { bookSource ->
WebBook.getContentAwait(bookSource, book, chapter)
}
}
return@execute content ?: let {
val contentProcessor = ContentProcessor.get(book.name, book.origin)
val content = BookHelp.getContent(book, chapter) ?: return@let null
contentProcessor.getContent(book, chapter, content, includeTitle = false)
.toString()
}
}.onStart {
loadStateLiveData.postValue(true)
}.onSuccess {
content = it
success.invoke(it ?: "")
}.onFinally {
loadStateLiveData.postValue(false)
}
}
}
}
@@ -0,0 +1,114 @@
package io.legato.kazusa.ui.book.read
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.entities.ReplaceRule
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.Item1lineTextBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.ui.replace.edit.ReplaceEditActivity
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
/**
* 起效的替换规则
*/
class EffectiveReplacesDialog : BaseDialogFragment(R.layout.dialog_recycler_view) {
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel by activityViewModels<ReadBookViewModel>()
private val adapter by lazy { ReplaceAdapter(requireContext()) }
private val chineseConvert by lazy { ReplaceRule(0, "繁简转换") }
private var isEdit = false
private val editActivity =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == AppCompatActivity.RESULT_OK) {
isEdit = true
}
}
override fun onStart() {
super.onStart()
setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
binding.run {
//toolBar.setBackgroundColor(primaryColor)
toolBar.setTitle(R.string.effective_replaces)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
recyclerView.adapter = adapter
}
val effectiveReplaceRules = ReadBook.curTextChapter?.effectiveReplaceRules ?: emptyList()
if (AppConfig.chineseConverterType > 0) {
adapter.setItems(effectiveReplaceRules + chineseConvert)
} else {
adapter.setItems(effectiveReplaceRules)
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (isEdit) {
viewModel.replaceRuleChanged()
}
}
private fun showChineseConvertAlert() {
alert(titleResource = R.string.chinese_converter) {
items(resources.getStringArray(R.array.chinese_mode).toList()) { _, i ->
if (AppConfig.chineseConverterType != i) {
AppConfig.chineseConverterType = i
isEdit = true
}
}
}
}
private inner class ReplaceAdapter(context: Context) :
RecyclerAdapter<ReplaceRule, Item1lineTextBinding>(context) {
override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding {
return Item1lineTextBinding.inflate(inflater, parent, false)
}
override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) {
binding.root.setOnClickListener {
getItem(holder.layoutPosition)?.let { item ->
if (item == chineseConvert) {
showChineseConvertAlert()
return@let
}
editActivity.launch(ReplaceEditActivity.startIntent(requireContext(), item.id))
}
}
}
override fun convert(
holder: ItemViewHolder,
binding: Item1lineTextBinding,
item: ReplaceRule,
payloads: MutableList<Any>
) {
binding.textView.text = item.name
}
}
}
@@ -0,0 +1,244 @@
package io.legato.kazusa.ui.book.read
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.GradientDrawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.animation.Animation
import android.widget.FrameLayout
import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.google.android.material.slider.Slider
import io.legato.kazusa.R
import io.legato.kazusa.databinding.ViewMangaMenuBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.source.getSourceType
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.bottomBackground
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.model.ReadManga
import io.legato.kazusa.ui.browser.WebViewActivity
import io.legato.kazusa.utils.ConstraintModify
import io.legato.kazusa.utils.activity
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.loadAnimation
import io.legato.kazusa.utils.modifyBegin
import io.legato.kazusa.utils.openUrl
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.visible
class MangaMenu @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs) {
private val binding = ViewMangaMenuBinding.inflate(LayoutInflater.from(context), this, true)
private val callBack: CallBack get() = activity as CallBack
var canShowMenu: Boolean = false
private val menuTopIn: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_top_in)
}
private val menuTopOut: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_top_out)
}
private val menuBottomIn: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_bottom_in)
}
private val menuBottomOut: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_bottom_out)
}
private var isMenuOutAnimating = false
//private var bgColor = context.bottomBackground
private val menuOutListener = object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
isMenuOutAnimating = true
binding.vwMenuBg.setOnClickListener(null)
}
override fun onAnimationEnd(animation: Animation) {
this@MangaMenu.invisible()
binding.titleBar.invisible()
binding.bottomMenu.invisible()
isMenuOutAnimating = false
canShowMenu = false
callBack.upSystemUiVisibility(false)
}
override fun onAnimationRepeat(animation: Animation) = Unit
}
private val menuInListener = object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
binding.tvSourceAction.text =
ReadManga.bookSource?.bookSourceName ?: context.getString(R.string.book_source)
callBack.upSystemUiVisibility(true)
binding.tvSourceAction.isGone = false
}
@SuppressLint("RtlHardcoded")
override fun onAnimationEnd(animation: Animation) {
binding.run {
vwMenuBg.setOnClickListener { runMenuOut() }
}
}
override fun onAnimationRepeat(animation: Animation) = Unit
}
init {
initView()
bindEvent()
}
private fun initView() = binding.run {
initAnimation()
val brightnessBackground = GradientDrawable()
brightnessBackground.cornerRadius = 5F.dpToPx()
//brightnessBackground.setColor(ColorUtils.adjustAlpha(bgColor, 0.5f))
if (AppConfig.isEInkMode) {
titleBar.setBackgroundResource(R.drawable.bg_eink_border_bottom)
bottomMenu.setBackgroundResource(R.drawable.bg_eink_border_top)
} else {
//bottomMenu.setBackgroundColor(bgColor)
}
if (AppConfig.showReadTitleBarAddition) {
titleBarAddition.visible()
} else {
titleBarAddition.gone()
}
upBrightnessVwPos()
/**
* 确保视图不被导航栏遮挡
*/
bottomMenu.applyNavigationBarPadding()
}
private fun upBrightnessVwPos() {
if (AppConfig.brightnessVwPos) {
binding.root.modifyBegin()
.clear(R.id.ll_brightness, ConstraintModify.Anchor.LEFT)
.rightToRightOf(R.id.ll_brightness, R.id.vw_menu_root)
.commit()
} else {
binding.root.modifyBegin()
.clear(R.id.ll_brightness, ConstraintModify.Anchor.RIGHT)
.leftToLeftOf(R.id.ll_brightness, R.id.vw_menu_root)
.commit()
}
}
private fun initAnimation() {
menuTopIn.setAnimationListener(menuInListener)
menuTopOut.setAnimationListener(menuOutListener)
}
fun runMenuOut(anim: Boolean = !AppConfig.isEInkMode) {
if (isMenuOutAnimating) {
return
}
if (this.isVisible) {
if (anim) {
binding.titleBar.startAnimation(menuTopOut)
binding.bottomMenu.startAnimation(menuBottomOut)
} else {
menuOutListener.onAnimationStart(menuBottomOut)
menuOutListener.onAnimationEnd(menuBottomOut)
}
}
}
fun runMenuIn(anim: Boolean = !AppConfig.isEInkMode) {
this.visible()
binding.titleBar.visible()
binding.bottomMenu.visible()
if (anim) {
binding.titleBar.startAnimation(menuTopIn)
binding.bottomMenu.startAnimation(menuBottomIn)
} else {
menuInListener.onAnimationStart(menuBottomIn)
menuInListener.onAnimationEnd(menuBottomIn)
}
}
private fun bindEvent() = binding.run {
vwMenuBg.setOnClickListener { runMenuOut() }
titleBar.toolbar.setOnClickListener {
callBack.openBookInfoActivity()
}
val chapterViewClickListener = OnClickListener {
if (AppConfig.readUrlInBrowser) {
context.openUrl(tvChapterUrl.text.toString().substringBefore(",{"))
} else {
context.startActivity<WebViewActivity> {
val url = tvChapterUrl.text.toString()
val bookSource = ReadBook.bookSource
putExtra("title", tvChapterName.text)
putExtra("url", url)
putExtra("sourceOrigin", bookSource?.bookSourceUrl)
putExtra("sourceName", bookSource?.bookSourceName)
putExtra("sourceType", bookSource?.getSourceType())
}
}
}
val chapterViewLongClickListener = OnLongClickListener {
context.alert(R.string.open_fun) {
setMessage(R.string.use_browser_open)
okButton {
AppConfig.readUrlInBrowser = true
}
noButton {
AppConfig.readUrlInBrowser = false
}
}
true
}
tvChapterName.setOnClickListener(chapterViewClickListener)
tvChapterName.setOnLongClickListener(chapterViewLongClickListener)
tvChapterUrl.setOnClickListener(chapterViewClickListener)
tvChapterUrl.setOnLongClickListener(chapterViewLongClickListener)
tvNext.setOnClickListener {
ReadManga.moveToNextChapter(true)
}
tvPre.setOnClickListener {
ReadManga.moveToPrevChapter(true)
}
seekReadPage.addOnChangeListener { slider, value, fromUser ->
if (fromUser) {
callBack.skipToPage(value.toInt())
}
}
seekReadPage.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
vwMenuBg.setOnClickListener(null)
}
override fun onStopTrackingTouch(slider: Slider) {
vwMenuBg.setOnClickListener { runMenuOut() }
}
})
}
fun upSeekBar(value: Int, count: Int) {
binding.seekReadPage.apply {
valueFrom = 0f
valueTo = (count - 1).toFloat()
this.value = value.toFloat()
stepSize = 1f
}
}
interface CallBack {
fun openBookInfoActivity()
fun upSystemUiVisibility(menuIsVisible: Boolean)
fun skipToPage(index: Int)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,562 @@
package io.legato.kazusa.ui.book.read
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookProgress
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.AppWebDav
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.ContentProcessor
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.book.isLocalModified
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.book.simulatedTotalChapterNum
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.model.ImageProvider
import io.legato.kazusa.model.ReadAloud
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.service.BaseReadAloudService
import io.legato.kazusa.ui.book.read.page.entities.TextChapter
import io.legato.kazusa.ui.book.searchContent.SearchResult
import io.legato.kazusa.utils.DocumentUtils
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.isContentScheme
import io.legato.kazusa.utils.mapParallelSafe
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.toStringArray
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onEmpty
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.take
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.FileOutputStream
import kotlin.coroutines.coroutineContext
/**
* 阅读界面数据处理
*/
class ReadBookViewModel(application: Application) : BaseViewModel(application) {
val permissionDenialLiveData = MutableLiveData<Int>()
var isInitFinish = false
var searchContentQuery = ""
var searchResultList: List<SearchResult>? = null
var searchResultIndex: Int = 0
private var changeSourceCoroutine: Coroutine<*>? = null
init {
AppConfig.detectClickArea()
}
fun initReadBookConfig(intent: Intent) {
val bookUrl = intent.getStringExtra("bookUrl")
val book = when {
bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook
else -> appDb.bookDao.getBook(bookUrl)
} ?: return
ReadBook.upReadBookConfig(book)
}
/**
* 初始化
*/
fun initData(intent: Intent, success: (() -> Unit)? = null) {
execute {
ReadBook.inBookshelf = intent.getBooleanExtra("inBookshelf", true)
ReadBook.chapterChanged = intent.getBooleanExtra("chapterChanged", false)
val bookUrl = intent.getStringExtra("bookUrl")
val book = when {
bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook
else -> appDb.bookDao.getBook(bookUrl)
} ?: ReadBook.book
when {
book != null -> initBook(book)
else -> ReadBook.upMsg(context.getString(R.string.no_book))
}
}.onSuccess {
success?.invoke()
}.onError {
val msg = "初始化数据失败\n${it.localizedMessage}"
ReadBook.upMsg(msg)
AppLog.put(msg, it)
}.onFinally {
ReadBook.saveRead()
}
}
private suspend fun initBook(book: Book) {
val isSameBook = ReadBook.book?.bookUrl == book.bookUrl
if (isSameBook) {
ReadBook.upData(book)
} else {
ReadBook.resetData(book)
}
isInitFinish = true
if (!book.isLocal && book.tocUrl.isEmpty() && !loadBookInfo(book)) {
return
}
if (book.isLocal && !checkLocalBookFileExist(book)) {
return
}
if ((ReadBook.chapterSize == 0 || book.isLocalModified()) && !loadChapterListAwait(book)) {
return
}
ReadBook.upMsg(null)
if (!isSameBook) {
ReadBook.loadContent(resetPageOffset = true)
} else {
ReadBook.loadOrUpContent()
}
if (ReadBook.chapterChanged) {
// 有章节跳转不同步阅读进度
ReadBook.chapterChanged = false
} else if (!isSameBook || !BaseReadAloudService.isRun) {
if (AppConfig.syncBookProgressPlus) {
ReadBook.syncProgress({ progress -> ReadBook.callBack?.sureNewProgress(progress) })
} else {
syncBookProgress(book)
}
}
if (!book.isLocal && ReadBook.bookSource == null) {
autoChangeSource(book.name, book.author)
return
}
}
private fun checkLocalBookFileExist(book: Book): Boolean {
try {
LocalBook.getBookInputStream(book)
return true
} catch (e: Throwable) {
ReadBook.upMsg("打开本地书籍出错: ${e.localizedMessage}")
if (e is SecurityException || e is FileNotFoundException) {
permissionDenialLiveData.postValue(0)
}
return false
}
}
/**
* 加载详情页
*/
private suspend fun loadBookInfo(book: Book): Boolean {
val source = ReadBook.bookSource ?: return true
try {
WebBook.getBookInfoAwait(source, book, canReName = false)
return true
} catch (e: Throwable) {
coroutineContext.ensureActive()
ReadBook.upMsg("详情页出错: ${e.localizedMessage}")
return false
}
}
/**
* 加载目录
*/
fun loadChapterList(book: Book) {
execute {
if (loadChapterListAwait(book)) {
ReadBook.upMsg(null)
}
}
}
private suspend fun loadChapterListAwait(book: Book): Boolean {
if (book.isLocal) {
kotlin.runCatching {
LocalBook.getChapterList(book).let {
appDb.bookChapterDao.delByBook(book.bookUrl)
appDb.bookChapterDao.insert(*it.toTypedArray())
appDb.bookDao.update(book)
ReadBook.onChapterListUpdated(book)
}
return true
}.onFailure {
when (it) {
is SecurityException, is FileNotFoundException -> {
permissionDenialLiveData.postValue(1)
}
else -> {
AppLog.put("LoadTocError:${it.localizedMessage}", it)
ReadBook.upMsg("LoadTocError:${it.localizedMessage}")
}
}
return false
}
} else {
ReadBook.bookSource?.let {
val oldBook = book.copy()
WebBook.getChapterListAwait(it, book, true)
.onSuccess { cList ->
if (oldBook.bookUrl == book.bookUrl) {
appDb.bookDao.update(book)
} else {
appDb.bookDao.insert(book)
BookHelp.updateCacheFolder(oldBook, book)
}
appDb.bookChapterDao.delByBook(oldBook.bookUrl)
appDb.bookChapterDao.insert(*cList.toTypedArray())
ReadBook.onChapterListUpdated(book)
return true
}.onFailure {
coroutineContext.ensureActive()
ReadBook.upMsg(context.getString(R.string.error_load_toc))
return false
}
}
}
return true
}
/**
* 同步进度
*/
fun syncBookProgress(
book: Book,
alertSync: ((progress: BookProgress) -> Unit)? = null
) {
if (!AppConfig.syncBookProgress) return
execute {
AppWebDav.getBookProgress(book)
}.onError {
AppLog.put("拉取阅读进度失败《${book.name}》\n${it.localizedMessage}", it)
}.onSuccess { progress ->
progress ?: return@onSuccess
if (progress.durChapterIndex < book.durChapterIndex ||
(progress.durChapterIndex == book.durChapterIndex
&& progress.durChapterPos < book.durChapterPos)
) {
alertSync?.invoke(progress)
} else if (progress.durChapterIndex < book.simulatedTotalChapterNum()) {
ReadBook.setProgress(progress)
AppLog.put("自动同步阅读进度成功《${book.name}》 ${progress.durChapterTitle}")
}
}
}
/**
* 换源
*/
fun changeTo(book: Book, toc: List<BookChapter>) {
changeSourceCoroutine?.cancel()
changeSourceCoroutine = execute {
ReadBook.upMsg(context.getString(R.string.loading))
ReadBook.book?.migrateTo(book, toc)
book.removeType(BookType.updateError)
ReadBook.book?.delete()
appDb.bookDao.insert(book)
appDb.bookChapterDao.insert(*toc.toTypedArray())
ReadBook.resetData(book)
ReadBook.upMsg(null)
ReadBook.loadContent(resetPageOffset = true)
}.onError {
AppLog.put("换源失败\n$it", it, true)
ReadBook.upMsg(null)
}.onFinally {
postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
}
}
/**
* 自动换源
*/
private fun autoChangeSource(name: String, author: String) {
if (!AppConfig.autoChangeSource) return
execute {
val sources = appDb.bookSourceDao.allTextEnabledPart
flow {
for (source in sources) {
source.getBookSource()?.let {
emit(it)
}
}
}.onStart {
ReadBook.upMsg(context.getString(R.string.source_auto_changing))
}.mapParallelSafe(AppConfig.threadCount) { source ->
val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow()
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
}
val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
val chapter = toc.getOrElse(book.durChapterIndex) {
toc.last()
}
val nextChapter = toc.getOrElse(chapter.index) {
toc.first()
}
WebBook.getContentAwait(
bookSource = source,
book = book,
bookChapter = chapter,
nextChapterUrl = nextChapter.url
)
book to toc
}.take(1).onEach { (book, toc) ->
changeTo(book, toc)
}.onEmpty {
throw NoStackTraceException("没有合适书源")
}.onCompletion {
ReadBook.upMsg(null)
}.catch {
AppLog.put("自动换源失败\n${it.localizedMessage}", it)
context.toastOnUi("自动换源失败\n${it.localizedMessage}")
}.collect()
}
}
fun openChapter(index: Int, durChapterPos: Int = 0, success: (() -> Unit)? = null) {
ReadBook.openChapter(index, durChapterPos, success)
}
fun removeFromBookshelf(success: (() -> Unit)?) {
val book = ReadBook.book
Coroutine.async {
book?.delete()
}.onSuccess {
success?.invoke()
}
}
fun upBookSource(success: (() -> Unit)?) {
execute {
ReadBook.book?.let { book ->
ReadBook.bookSource = appDb.bookSourceDao.getBookSource(book.origin)
}
}.onSuccess {
success?.invoke()
}
}
fun refreshContentDur(book: Book) {
execute {
appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
?.let { chapter ->
BookHelp.delContent(book, chapter)
ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
}
}
}
fun refreshContentAfter(book: Book) {
execute {
appDb.bookChapterDao.getChapterList(
book.bookUrl,
ReadBook.durChapterIndex,
book.totalChapterNum
).forEach { chapter ->
BookHelp.delContent(book, chapter)
}
ReadBook.loadContent(false)
}
}
fun refreshContentAll(book: Book) {
execute {
BookHelp.clearCache(book)
ReadBook.loadContent(false)
}
}
/**
* 保存内容
*/
fun saveContent(book: Book, content: String) {
execute {
appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
?.let { chapter ->
BookHelp.saveText(book, chapter, content)
ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
}
}
}
/**
* 反转内容
*/
fun reverseContent(book: Book) {
execute {
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
?: return@execute
val content = BookHelp.getContent(book, chapter) ?: return@execute
val stringBuilder = StringBuilder()
content.toStringArray().forEach {
stringBuilder.insert(0, it)
}
BookHelp.saveText(book, chapter, stringBuilder.toString())
ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
}
}
/**
* 内容搜索跳转
*/
fun searchResultPositions(
textChapter: TextChapter,
searchResult: SearchResult
): Array<Int> {
// calculate search result's pageIndex
val pages = textChapter.pages
val content = textChapter.getContent()
val queryLength = searchContentQuery.length
var count = 0
var index = content.indexOf(searchContentQuery)
while (count != searchResult.resultCountWithinChapter) {
index = content.indexOf(searchContentQuery, index + queryLength)
count += 1
}
val contentPosition = index
var pageIndex = 0
var length = pages[pageIndex].text.length
while (length < contentPosition && pageIndex + 1 < pages.size) {
pageIndex += 1
length += pages[pageIndex].text.length
}
// calculate search result's lineIndex
val currentPage = pages[pageIndex]
val curTextLines = currentPage.lines
var lineIndex = 0
var curLine = curTextLines[lineIndex]
length = length - currentPage.text.length + curLine.text.length
if (curLine.isParagraphEnd) length++
while (length <= contentPosition && lineIndex + 1 < curTextLines.size) {
lineIndex += 1
curLine = curTextLines[lineIndex]
length += curLine.text.length
if (curLine.isParagraphEnd) length++
}
// charIndex
val currentLine = currentPage.lines[lineIndex]
var curLineLength = currentLine.text.length
if (currentLine.isParagraphEnd) curLineLength++
length -= curLineLength
val charIndex = contentPosition - length
var addLine = 0
var charIndex2 = 0
// change line
if ((charIndex + queryLength) > curLineLength) {
addLine = 1
charIndex2 = charIndex + queryLength - curLineLength - 1
}
// changePage
if ((lineIndex + addLine + 1) > currentPage.lines.size) {
addLine = -1
charIndex2 = charIndex + queryLength - curLineLength - 1
}
return arrayOf(pageIndex, lineIndex, charIndex, addLine, charIndex2)
}
/**
* 翻转删除重复标题
*/
fun reverseRemoveSameTitle() {
execute {
val book = ReadBook.book ?: return@execute
val textChapter = ReadBook.curTextChapter ?: return@execute
BookHelp.setRemoveSameTitle(
book, textChapter.chapter, !textChapter.sameTitleRemoved
)
ReadBook.loadContent(ReadBook.durChapterIndex)
}
}
/**
* 刷新图片
*/
fun refreshImage(src: String) {
execute {
ReadBook.book?.let { book ->
val vFile = BookHelp.getImage(book, src)
ImageProvider.bitmapLruCache.remove(vFile.absolutePath)
vFile.delete()
}
}.onFinally {
ReadBook.loadContent(false)
}
}
/**
* 保存图片
*/
fun saveImage(src: String?, uri: Uri) {
src ?: return
val book = ReadBook.book ?: return
execute {
val image = BookHelp.getImage(book, src)
FileInputStream(image).use { input ->
if (uri.isContentScheme()) {
DocumentFile.fromTreeUri(context, uri)?.let { doc ->
val imageDoc = DocumentUtils.createFileIfNotExist(doc, image.name)!!
context.contentResolver.openOutputStream(imageDoc.uri)!!.use { output ->
input.copyTo(output)
}
}
} else {
val dir = File(uri.path ?: uri.toString())
val file = FileUtils.createFileIfNotExist(dir, image.name)
FileOutputStream(file).use { output ->
input.copyTo(output)
}
}
}
}.onError {
AppLog.put("保存图片出错\n${it.localizedMessage}", it)
context.toastOnUi("保存图片出错\n${it.localizedMessage}")
}
}
/**
* 替换规则变化
*/
fun replaceRuleChanged() {
execute {
ReadBook.book?.let {
ContentProcessor.get(it.name, it.origin).upReplaceRules()
ReadBook.loadContent(resetPageOffset = false)
}
}
}
fun disableSource() {
execute {
ReadBook.bookSource?.let {
it.enabled = false
appDb.bookSourceDao.update(it)
}
}
}
override fun onCleared() {
super.onCleared()
if (BaseReadAloudService.isRun && BaseReadAloudService.pause) {
ReadAloud.stop(context)
}
}
}
@@ -0,0 +1,620 @@
package io.legato.kazusa.ui.book.read
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.GradientDrawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
import android.view.animation.Animation
import android.widget.FrameLayout
import android.widget.SeekBar
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.google.android.material.slider.Slider
import io.legato.kazusa.R
import io.legato.kazusa.constant.PreferKey
import io.legato.kazusa.databinding.ViewReadMenuBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.LocalConfig
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.help.config.ThemeConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.help.source.getSourceType
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.buttonDisabledColor
//import io.legado.app.lib.theme.getPrimaryTextColor
//import io.legado.app.lib.theme.primaryColor
//import io.legado.app.lib.theme.primaryTextColor
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.ui.browser.WebViewActivity
import io.legato.kazusa.ui.widget.seekbar.SeekBarChangeListener
import io.legato.kazusa.utils.ConstraintModify
import io.legato.kazusa.utils.activity
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.getPrefBoolean
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.loadAnimation
import io.legato.kazusa.utils.modifyBegin
import io.legato.kazusa.utils.openUrl
import io.legato.kazusa.utils.putPrefBoolean
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.visible
import splitties.views.onClick
import splitties.views.onLongClick
/**
* 阅读界面菜单
*/
class ReadMenu @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
var canShowMenu: Boolean = false
private val callBack: CallBack get() = activity as CallBack
private val binding = ViewReadMenuBinding.inflate(LayoutInflater.from(context), this, true)
private var confirmSkipToChapter: Boolean = false
private var isMenuOutAnimating = false
private val menuTopIn: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_top_in)
}
private val menuTopOut: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_top_out)
}
private val menuBottomIn: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_bottom_in)
}
private val menuBottomOut: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_bottom_out)
}
private val immersiveMenu: Boolean
get() = AppConfig.readBarStyleFollowPage && ReadBookConfig.durConfig.curBgType() == 0
// private var bgColor: Int = if (immersiveMenu) {
// kotlin.runCatching {
// ReadBookConfig.durConfig.curBgStr().toColorInt()
// }.getOrDefault(context.bottomBackground)
// } else {
// context.bottomBackground
// }
// private var textColor: Int = if (immersiveMenu) {
// ReadBookConfig.durConfig.curTextColor()
// } else {
// context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor))
// }
// private var bottomBackgroundList: ColorStateList = Selector.colorBuild()
// .setDefaultColor(bgColor)
// .setPressedColor(ColorUtils.darkenColor(bgColor))
// .create()
private var onMenuOutEnd: (() -> Unit)? = null
private val showBrightnessView
get() = context.getPrefBoolean(
PreferKey.showBrightnessView,
true
)
private val sourceMenu by lazy {
PopupMenu(context, binding.tvSourceAction).apply {
inflate(R.menu.book_read_source)
setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_login -> callBack.showLogin()
R.id.menu_chapter_pay -> callBack.payAction()
R.id.menu_edit_source -> callBack.openSourceEditActivity()
R.id.menu_disable_source -> callBack.disableSource()
}
true
}
}
}
private val menuInListener = object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
binding.tvSourceAction.text =
ReadBook.bookSource?.bookSourceName ?: context.getString(R.string.book_source)
binding.tvSourceAction.isGone = ReadBook.isLocalBook
callBack.upSystemUiVisibility()
binding.llBrightness.visible(showBrightnessView)
}
@SuppressLint("RtlHardcoded")
override fun onAnimationEnd(animation: Animation) {
binding.vwMenuBg.setOnClickListener { runMenuOut() }
callBack.upSystemUiVisibility()
if (!LocalConfig.readMenuHelpVersionIsLast) {
callBack.showHelp()
}
}
override fun onAnimationRepeat(animation: Animation) = Unit
}
private val menuOutListener = object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
isMenuOutAnimating = true
binding.vwMenuBg.setOnClickListener(null)
}
override fun onAnimationEnd(animation: Animation) {
this@ReadMenu.invisible()
binding.titleBar.invisible()
binding.bottomMenu.invisible()
canShowMenu = false
isMenuOutAnimating = false
onMenuOutEnd?.invoke()
callBack.upSystemUiVisibility()
}
override fun onAnimationRepeat(animation: Animation) = Unit
}
init {
initView()
upBrightnessState()
bindEvent()
}
private fun initView(reset: Boolean = false) = binding.run {
if (AppConfig.isNightTheme) {
fabNightTheme.setImageResource(R.drawable.ic_daytime)
} else {
fabNightTheme.setImageResource(R.drawable.ic_brightness)
}
initAnimation()
if (immersiveMenu) {
// val lightTextColor = ColorUtils.withAlpha(ColorUtils.lightenColor(textColor), 0.75f)
// titleBar.setTextColor(textColor)
// titleBar.setBackgroundColor(bgColor)
// titleBar.setColorFilter(textColor)
// tvChapterName.setTextColor(lightTextColor)
// tvChapterUrl.setTextColor(lightTextColor)
} else if (reset) {
// val bgColor = context.primaryColor
// val textColor = context.primaryTextColor
// titleBar.setTextColor(textColor)
// titleBar.setBackgroundColor(bgColor)
// titleBar.setColorFilter(textColor)
// tvChapterName.setTextColor(textColor)
// tvChapterUrl.setTextColor(textColor)
}
val brightnessBackground = GradientDrawable()
brightnessBackground.cornerRadius = 5F.dpToPx()
//brightnessBackground.setColor(ColorUtils.adjustAlpha(bgColor, 0.5f))
llBrightness.background = brightnessBackground
if (AppConfig.isEInkMode) {
titleBar.setBackgroundResource(R.drawable.bg_eink_border_bottom)
llBottomBg.setBackgroundResource(R.drawable.bg_eink_border_top)
} else {
//llBottomBg.setBackgroundColor(bgColor)
}
// fabSearch.backgroundTintList = bottomBackgroundList
// fabSearch.setColorFilter(textColor)
// fabAutoPage.backgroundTintList = bottomBackgroundList
// fabAutoPage.setColorFilter(textColor)
// fabReplaceRule.backgroundTintList = bottomBackgroundList
// fabReplaceRule.setColorFilter(textColor)
// fabNightTheme.backgroundTintList = bottomBackgroundList
// fabNightTheme.setColorFilter(textColor)
//tvPre.setTextColor(textColor)
//tvNext.setTextColor(textColor)
// ivCatalog.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// tvCatalog.setTextColor(textColor)
// ivReadAloud.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// tvReadAloud.setTextColor(textColor)
// ivFont.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// tvFont.setTextColor(textColor)
// ivSetting.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// tvSetting.setTextColor(textColor)
// vwBrightnessPosAdjust.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
llBrightness.setOnClickListener(null)
seekBrightness.post {
seekBrightness.progress = AppConfig.readBrightness
}
if (AppConfig.showReadTitleBarAddition) {
titleBarAddition.visible()
} else {
titleBarAddition.gone()
}
upBrightnessVwPos()
/**
* 确保视图不被导航栏遮挡
*/
applyNavigationBarPadding()
}
fun reset() {
upColorConfig()
initView(true)
}
fun refreshMenuColorFilter() {
if (immersiveMenu) {
//binding.titleBar.setColorFilter(textColor)
}
}
private fun upColorConfig() {
// bgColor = if (immersiveMenu) {
// kotlin.runCatching {
// Color.parseColor(ReadBookConfig.durConfig.curBgStr())
// }.getOrDefault(context.bottomBackground)
// } else {
// context.bottomBackground
// }
// textColor = if (immersiveMenu) {
// ReadBookConfig.durConfig.curTextColor()
// } else {
// context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor))
// }
// bottomBackgroundList = Selector.colorBuild()
// .setDefaultColor(bgColor)
// .setPressedColor(ColorUtils.darkenColor(bgColor))
// .create()
}
fun upBrightnessState() {
if (brightnessAuto()) {
//binding.ivBrightnessAuto.setColorFilter(context.accentColor)
binding.seekBrightness.isEnabled = false
} else {
//binding.ivBrightnessAuto.setColorFilter(context.buttonDisabledColor)
binding.seekBrightness.isEnabled = true
}
setScreenBrightness(AppConfig.readBrightness.toFloat())
}
/**
* 设置屏幕亮度
*/
fun setScreenBrightness(value: Float) {
activity?.run {
var brightness = BRIGHTNESS_OVERRIDE_NONE
if (!brightnessAuto() && value != BRIGHTNESS_OVERRIDE_NONE) {
brightness = value
if (brightness < 1f) brightness = 1f
brightness /= 255f
}
val params = window.attributes
params.screenBrightness = brightness
window.attributes = params
}
}
fun runMenuIn(anim: Boolean = !AppConfig.isEInkMode) {
callBack.onMenuShow()
this.visible()
binding.titleBar.visible()
binding.bottomMenu.visible()
if (anim) {
binding.titleBar.startAnimation(menuTopIn)
binding.bottomMenu.startAnimation(menuBottomIn)
} else {
menuInListener.onAnimationStart(menuBottomIn)
menuInListener.onAnimationEnd(menuBottomIn)
}
}
fun runMenuOut(anim: Boolean = !AppConfig.isEInkMode, onMenuOutEnd: (() -> Unit)? = null) {
if (isMenuOutAnimating) {
return
}
callBack.onMenuHide()
this.onMenuOutEnd = onMenuOutEnd
if (this.isVisible) {
if (anim) {
binding.titleBar.startAnimation(menuTopOut)
binding.bottomMenu.startAnimation(menuBottomOut)
} else {
menuOutListener.onAnimationStart(menuBottomOut)
menuOutListener.onAnimationEnd(menuBottomOut)
}
}
}
private fun brightnessAuto(): Boolean {
return context.getPrefBoolean("brightnessAuto", true) || !showBrightnessView
}
private fun bindEvent() = binding.run {
vwMenuBg.setOnClickListener { runMenuOut() }
titleBar.toolbar.setOnClickListener {
callBack.openBookInfoActivity()
}
val chapterViewClickListener = OnClickListener {
if (ReadBook.isLocalBook) {
return@OnClickListener
}
if (AppConfig.readUrlInBrowser) {
context.openUrl(tvChapterUrl.text.toString().substringBefore(",{"))
} else {
Coroutine.async {
context.startActivity<WebViewActivity> {
val url = tvChapterUrl.text.toString()
val bookSource = ReadBook.bookSource
putExtra("title", tvChapterName.text)
putExtra("url", url)
putExtra("sourceOrigin", bookSource?.bookSourceUrl)
putExtra("sourceName", bookSource?.bookSourceName)
putExtra("sourceType", bookSource?.getSourceType())
}
}
}
}
val chapterViewLongClickListener = OnLongClickListener {
if (ReadBook.isLocalBook) {
return@OnLongClickListener true
}
context.alert(R.string.open_fun) {
setMessage(R.string.use_browser_open)
okButton {
AppConfig.readUrlInBrowser = true
}
noButton {
AppConfig.readUrlInBrowser = false
}
}
true
}
tvChapterName.setOnClickListener(chapterViewClickListener)
tvChapterName.setOnLongClickListener(chapterViewLongClickListener)
tvChapterUrl.setOnClickListener(chapterViewClickListener)
tvChapterUrl.setOnLongClickListener(chapterViewLongClickListener)
//书源操作
tvSourceAction.onClick {
sourceMenu.menu.findItem(R.id.menu_login).isVisible =
!ReadBook.bookSource?.loginUrl.isNullOrEmpty()
sourceMenu.menu.findItem(R.id.menu_chapter_pay).isVisible =
!ReadBook.bookSource?.loginUrl.isNullOrEmpty()
&& ReadBook.curTextChapter?.isVip == true
&& ReadBook.curTextChapter?.isPay != true
sourceMenu.show()
}
//亮度跟随
ivBrightnessAuto.setOnClickListener {
context.putPrefBoolean("brightnessAuto", !brightnessAuto())
upBrightnessState()
}
//亮度调节
seekBrightness.setOnSeekBarChangeListener(object : SeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) {
setScreenBrightness(progress.toFloat())
}
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
AppConfig.readBrightness = seekBar.progress
}
})
vwBrightnessPosAdjust.setOnClickListener {
AppConfig.brightnessVwPos = !AppConfig.brightnessVwPos
upBrightnessVwPos()
}
//阅读进度
seekReadPage.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
vwMenuBg.setOnClickListener(null)
}
override fun onStopTrackingTouch(slider: Slider) {
vwMenuBg.setOnClickListener { runMenuOut() }
val progress = slider.value.toInt()
when (AppConfig.progressBarBehavior) {
"page" -> ReadBook.skipToPage(progress - 1)
"chapter" -> {
if (confirmSkipToChapter) {
callBack.skipToChapter(progress)
} else {
context.alert("章节跳转确认", "确定要跳转章节吗?") {
yesButton {
confirmSkipToChapter = true
callBack.skipToChapter(progress)
}
noButton {
upSeekBar()
}
onCancelled {
upSeekBar()
}
}
}
}
}
}
})
//搜索
fabSearch.setOnClickListener {
runMenuOut {
callBack.openSearchActivity(null)
}
}
//自动翻页
fabAutoPage.setOnClickListener {
runMenuOut {
callBack.autoPage()
}
}
//替换
fabReplaceRule.setOnClickListener { callBack.openReplaceRule() }
//夜间模式
fabNightTheme.setOnClickListener {
AppConfig.isNightTheme = !AppConfig.isNightTheme
ThemeConfig.applyDayNight(context)
}
//上一章
tvPre.setOnClickListener { ReadBook.moveToPrevChapter(upContent = true, toLast = false) }
//下一章
tvNext.setOnClickListener { ReadBook.moveToNextChapter(true) }
//目录
llCatalog.setOnClickListener {
runMenuOut {
callBack.openChapterList()
}
}
//朗读
llReadAloud.setOnClickListener {
runMenuOut {
callBack.onClickReadAloud()
}
}
llReadAloud.onLongClick {
runMenuOut {
callBack.showReadAloudDialog()
}
}
//界面
llFont.setOnClickListener {
runMenuOut {
callBack.showReadStyle()
}
}
//设置
llSetting.setOnClickListener {
runMenuOut {
callBack.showMoreSetting()
}
}
}
private fun initAnimation() {
menuTopIn.setAnimationListener(menuInListener)
menuTopOut.setAnimationListener(menuOutListener)
}
fun upBookView() {
binding.titleBar.title = ReadBook.book?.name
ReadBook.curTextChapter?.let {
binding.tvChapterName.text = it.title
binding.tvChapterName.visible()
if (!ReadBook.isLocalBook) {
binding.tvChapterUrl.text = it.chapter.getAbsoluteURL()
binding.tvChapterUrl.visible()
} else {
binding.tvChapterUrl.gone()
}
upSeekBar()
binding.tvPre.isEnabled = ReadBook.durChapterIndex != 0
binding.tvNext.isEnabled = ReadBook.durChapterIndex != ReadBook.simulatedChapterSize - 1
} ?: let {
binding.tvChapterName.gone()
binding.tvChapterUrl.gone()
}
}
fun upSeekBar() {
binding.seekReadPage.apply {
when (AppConfig.progressBarBehavior) {
"page" -> {
ReadBook.curTextChapter?.let { chapter ->
if (chapter.pageSize > 0) {
valueFrom = 1f
valueTo = chapter.pageSize.toFloat()
value = (ReadBook.durPageIndex).coerceIn(1, chapter.pageSize).toFloat()
stepSize = 1f
} else {
valueFrom = 1f
valueTo = 1f
value = 1f
}
} ?: run {
valueFrom = 1f
valueTo = 1f
value = 1f
}
}
"chapter" -> {
valueFrom = 1f
valueTo = ReadBook.simulatedChapterSize.toFloat()
value = ReadBook.durChapterIndex.toFloat()
stepSize = 1f
}
}
}
}
// fun upSeekBar() {
// binding.seekReadPage.apply {
// when (AppConfig.progressBarBehavior) {
// "page" -> {
// ReadBook.curTextChapter?.let {
// max = it.pageSize.minus(1)
// progress = ReadBook.durPageIndex
// }
// }
//
// "chapter" -> {
// max = ReadBook.simulatedChapterSize - 1
// progress = ReadBook.durChapterIndex
// }
// }
// }
// }
fun setSeekPage(seek: Int) {
binding.seekReadPage.value = seek.toFloat() + 1
}
fun setAutoPage(autoPage: Boolean) = binding.run {
if (autoPage) {
fabAutoPage.setImageResource(R.drawable.ic_auto_page_stop)
fabAutoPage.contentDescription = context.getString(R.string.auto_next_page_stop)
} else {
fabAutoPage.setImageResource(R.drawable.ic_auto_page)
fabAutoPage.contentDescription = context.getString(R.string.auto_next_page)
}
//fabAutoPage.setColorFilter(textColor)
}
private fun upBrightnessVwPos() {
if (AppConfig.brightnessVwPos) {
binding.root.modifyBegin()
.clear(R.id.ll_brightness, ConstraintModify.Anchor.LEFT)
.rightToRightOf(R.id.ll_brightness, R.id.vw_menu_root)
.commit()
} else {
binding.root.modifyBegin()
.clear(R.id.ll_brightness, ConstraintModify.Anchor.RIGHT)
.leftToLeftOf(R.id.ll_brightness, R.id.vw_menu_root)
.commit()
}
}
interface CallBack {
fun autoPage()
fun openReplaceRule()
fun openChapterList()
fun openSearchActivity(searchWord: String?)
fun openSourceEditActivity()
fun openBookInfoActivity()
fun showReadStyle()
fun showMoreSetting()
fun showReadAloudDialog()
fun upSystemUiVisibility()
fun onClickReadAloud()
fun showHelp()
fun showLogin()
fun payAction()
fun disableSource()
fun skipToChapter(index: Int)
fun onMenuShow()
fun onMenuHide()
}
}
@@ -0,0 +1,230 @@
package io.legato.kazusa.ui.book.read
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.animation.Animation
import android.widget.FrameLayout
import androidx.core.view.isVisible
import io.legato.kazusa.R
import io.legato.kazusa.databinding.ViewSearchMenuBinding
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.ui.book.searchContent.SearchResult
import io.legato.kazusa.utils.activity
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.loadAnimation
import io.legato.kazusa.utils.visible
/**
* 搜索界面菜单
*/
class SearchMenu @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val callBack: CallBack get() = activity as CallBack
private val binding = ViewSearchMenuBinding.inflate(LayoutInflater.from(context), this, true)
private val menuBottomIn: Animation = loadAnimation(context, R.anim.anim_readbook_bottom_in)
private val menuBottomOut: Animation = loadAnimation(context, R.anim.anim_readbook_bottom_out)
//private val bgColor: Int = context.bottomBackground
//private val textColor: Int = context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor))
// private val bottomBackgroundList: ColorStateList =
// Selector.colorBuild().setDefaultColor(bgColor)
// .setPressedColor(ColorUtils.darkenColor(bgColor)).create()
private var onMenuOutEnd: (() -> Unit)? = null
private var isMenuOutAnimating = false
private val searchResultList: MutableList<SearchResult> = mutableListOf()
private var currentSearchResultIndex: Int = -1
private var lastSearchResultIndex: Int = -1
private val hasSearchResult: Boolean
get() = searchResultList.isNotEmpty()
val selectedSearchResult: SearchResult?
get() = searchResultList.getOrNull(currentSearchResultIndex)
val previousSearchResult: SearchResult?
get() = searchResultList.getOrNull(lastSearchResultIndex)
val bottomMenuVisible get() = isVisible && binding.llBottomMenu.isVisible
init {
initAnimation()
initView()
bindEvent()
updateSearchInfo()
}
fun upSearchResultList(resultList: List<SearchResult>) {
searchResultList.clear()
searchResultList.addAll(resultList)
updateSearchInfo()
}
private fun initView() = binding.run {
// llSearchBaseInfo.setBackgroundColor(bgColor)
// tvCurrentSearchInfo.setTextColor(bottomBackgroundList)
// llBottomBg.setBackgroundColor(bgColor)
// fabLeft.backgroundTintList = bottomBackgroundList
// fabLeft.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// fabRight.backgroundTintList = bottomBackgroundList
// fabRight.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// tvMainMenu.setTextColor(textColor)
// tvSearchResults.setTextColor(textColor)
// tvSearchExit.setTextColor(textColor)
// ivMainMenu.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// ivSearchResults.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// ivSearchExit.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// ivSearchContentUp.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// ivSearchContentDown.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// tvCurrentSearchInfo.setTextColor(textColor)
applyNavigationBarPadding()
}
fun runMenuIn() {
this.visible()
binding.llBottomMenu.visible()
binding.vwMenuBg.visible()
binding.llBottomMenu.startAnimation(menuBottomIn)
}
fun runMenuOut(onMenuOutEnd: (() -> Unit)? = null) {
if (isMenuOutAnimating) {
return
}
this.onMenuOutEnd = onMenuOutEnd
if (this.isVisible) {
binding.llBottomMenu.startAnimation(menuBottomOut)
}
}
@SuppressLint("SetTextI18n")
fun updateSearchInfo() {
ReadBook.curTextChapter?.let {
binding.tvCurrentSearchInfo.text =
"""${context.getString(R.string.search_content_size)}: ${searchResultList.size} / 当前章节: ${it.title}"""
}
}
fun updateSearchResultIndex(updateIndex: Int) {
lastSearchResultIndex = currentSearchResultIndex
currentSearchResultIndex = when {
updateIndex < 0 -> 0
updateIndex >= searchResultList.size -> searchResultList.size - 1
else -> updateIndex
}
}
private fun bindEvent() = binding.run {
//搜索结果
llSearchResults.setOnClickListener {
runMenuOut {
callBack.openSearchActivity(selectedSearchResult?.query)
}
}
//主菜单
llMainMenu.setOnClickListener {
runMenuOut {
callBack.cancelSelect()
callBack.showMenuBar()
this@SearchMenu.invisible()
}
}
//退出
llSearchExit.setOnClickListener {
runMenuOut {
callBack.exitSearchMenu()
}
}
fabLeft.setOnClickListener {
updateSearchResultIndex(currentSearchResultIndex - 1)
callBack.navigateToSearch(
searchResultList[currentSearchResultIndex],
currentSearchResultIndex
)
}
ivSearchContentUp.setOnClickListener {
updateSearchResultIndex(currentSearchResultIndex - 1)
callBack.navigateToSearch(
searchResultList[currentSearchResultIndex],
currentSearchResultIndex
)
}
ivSearchContentDown.setOnClickListener {
updateSearchResultIndex(currentSearchResultIndex + 1)
callBack.navigateToSearch(
searchResultList[currentSearchResultIndex],
currentSearchResultIndex
)
}
fabRight.setOnClickListener {
updateSearchResultIndex(currentSearchResultIndex + 1)
callBack.navigateToSearch(
searchResultList[currentSearchResultIndex],
currentSearchResultIndex
)
}
}
private fun initAnimation() {
//显示菜单
menuBottomIn.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
callBack.upSystemUiVisibility()
binding.fabLeft.visible(hasSearchResult)
binding.fabRight.visible(hasSearchResult)
}
@SuppressLint("RtlHardcoded")
override fun onAnimationEnd(animation: Animation) {
binding.vwMenuBg.setOnClickListener { runMenuOut() }
callBack.upSystemUiVisibility()
}
override fun onAnimationRepeat(animation: Animation) = Unit
})
//隐藏菜单
menuBottomOut.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
isMenuOutAnimating = true
binding.vwMenuBg.setOnClickListener(null)
}
override fun onAnimationEnd(animation: Animation) {
isMenuOutAnimating = false
binding.llBottomMenu.invisible()
binding.vwMenuBg.invisible()
binding.vwMenuBg.setOnClickListener { runMenuOut() }
onMenuOutEnd?.invoke()
callBack.upSystemUiVisibility()
}
override fun onAnimationRepeat(animation: Animation) = Unit
})
}
interface CallBack {
var isShowingSearchResult: Boolean
fun openSearchActivity(searchWord: String?)
fun showSearchSetting()
fun upSystemUiVisibility()
fun exitSearchMenu()
fun showMenuBar()
fun navigateToSearch(searchResult: SearchResult, index: Int)
fun onMenuShow()
fun onMenuHide()
fun cancelSelect()
}
}
@@ -0,0 +1,290 @@
package io.legato.kazusa.ui.book.read
import android.annotation.SuppressLint
import android.app.SearchManager
import android.content.Context
import android.content.Intent
import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.Build
import android.view.Gravity
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import androidx.annotation.RequiresApi
import androidx.appcompat.view.SupportMenuInflater
import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.view.menu.MenuItemImpl
import androidx.core.view.isVisible
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.constant.PreferKey
import io.legato.kazusa.databinding.ItemTextBinding
import io.legato.kazusa.databinding.PopupActionMenuBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.utils.getPrefBoolean
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.isAbsUrl
import io.legato.kazusa.utils.printOnDebug
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.share
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.visible
@SuppressLint("RestrictedApi")
class TextActionMenu(private val context: Context, private val callBack: CallBack) :
PopupWindow(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) {
private val binding = PopupActionMenuBinding.inflate(LayoutInflater.from(context))
private val adapter = Adapter(context).apply {
setHasStableIds(true)
}
private val menuItems: List<MenuItemImpl>
private val visibleMenuItems = arrayListOf<MenuItemImpl>()
private val moreMenuItems = arrayListOf<MenuItemImpl>()
private val expandTextMenu get() = context.getPrefBoolean(PreferKey.expandTextMenu)
init {
@SuppressLint("InflateParams")
contentView = binding.root
isTouchable = true
isOutsideTouchable = false
isFocusable = false
val myMenu = MenuBuilder(context)
val otherMenu = MenuBuilder(context)
SupportMenuInflater(context).inflate(R.menu.content_select_action, myMenu)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
onInitializeMenu(otherMenu)
}
menuItems = myMenu.visibleItems + otherMenu.visibleItems
visibleMenuItems.addAll(menuItems.subList(0, 5))
moreMenuItems.addAll(menuItems.subList(5, menuItems.size))
binding.recyclerView.adapter = adapter
binding.recyclerViewMore.adapter = adapter
setOnDismissListener {
if (!context.getPrefBoolean(PreferKey.expandTextMenu)) {
binding.ivMenuMore.setImageResource(R.drawable.ic_more_vert)
binding.recyclerViewMore.gone()
adapter.setItems(visibleMenuItems)
binding.recyclerView.visible()
}
}
binding.ivMenuMore.setOnClickListener {
if (binding.recyclerView.isVisible) {
binding.ivMenuMore.setImageResource(R.drawable.ic_arrow_back)
adapter.setItems(moreMenuItems)
binding.recyclerView.gone()
binding.recyclerViewMore.visible()
} else {
binding.ivMenuMore.setImageResource(R.drawable.ic_more_vert)
binding.recyclerViewMore.gone()
adapter.setItems(visibleMenuItems)
binding.recyclerView.visible()
}
}
upMenu()
}
fun upMenu() {
if (expandTextMenu) {
adapter.setItems(menuItems)
binding.ivMenuMore.gone()
} else {
adapter.setItems(visibleMenuItems)
binding.ivMenuMore.visible()
}
}
fun show(
view: View,
windowHeight: Int,
startX: Int,
startTopY: Int,
startBottomY: Int,
endX: Int,
endBottomY: Int
) {
if (expandTextMenu) {
when {
startTopY > 500 -> {
showAtLocation(
view,
Gravity.BOTTOM or Gravity.START,
startX,
windowHeight - startTopY
)
}
endBottomY - startBottomY > 500 -> {
showAtLocation(view, Gravity.TOP or Gravity.START, startX, startBottomY)
}
else -> {
showAtLocation(view, Gravity.TOP or Gravity.START, endX, endBottomY)
}
}
} else {
contentView.measure(
View.MeasureSpec.UNSPECIFIED,
View.MeasureSpec.UNSPECIFIED,
)
val popupHeight = contentView.measuredHeight
when {
startBottomY > 500 -> {
showAtLocation(
view,
Gravity.TOP or Gravity.START,
startX,
startTopY - popupHeight
)
}
endBottomY - startBottomY > 500 -> {
showAtLocation(
view,
Gravity.TOP or Gravity.START,
startX,
startBottomY
)
}
else -> {
showAtLocation(
view,
Gravity.TOP or Gravity.START,
endX,
endBottomY
)
}
}
}
}
inner class Adapter(context: Context) :
RecyclerAdapter<MenuItemImpl, ItemTextBinding>(context) {
override fun getItemId(position: Int): Long {
return position.toLong()
}
override fun getViewBinding(parent: ViewGroup): ItemTextBinding {
return ItemTextBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemTextBinding,
item: MenuItemImpl,
payloads: MutableList<Any>
) {
with(binding) {
textView.text = item.title
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemTextBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
if (!callBack.onMenuItemSelected(it.itemId)) {
onMenuItemSelected(it)
}
}
callBack.onMenuActionFinally()
}
holder.itemView.setOnLongClickListener {
if (AppConfig.contentSelectSpeakMod == 0) {
AppConfig.contentSelectSpeakMod = 1
context.toastOnUi("切换为从选择的地方开始一直朗读")
} else {
AppConfig.contentSelectSpeakMod = 0
context.toastOnUi("切换为朗读选择内容")
}
true
}
}
}
private fun onMenuItemSelected(item: MenuItemImpl) {
when (item.itemId) {
R.id.menu_copy -> context.sendToClip(callBack.selectedText)
R.id.menu_share_str -> context.share(callBack.selectedText)
R.id.menu_browser -> {
kotlin.runCatching {
val intent = if (callBack.selectedText.isAbsUrl()) {
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(callBack.selectedText)
}
} else {
Intent(Intent.ACTION_WEB_SEARCH).apply {
putExtra(SearchManager.QUERY, callBack.selectedText)
}
}
context.startActivity(intent)
}.onFailure {
it.printOnDebug()
context.toastOnUi(it.localizedMessage ?: "ERROR")
}
}
else -> item.intent?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
it.putExtra(Intent.EXTRA_PROCESS_TEXT, callBack.selectedText)
context.startActivity(it)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun createProcessTextIntent(): Intent {
return Intent()
.setAction(Intent.ACTION_PROCESS_TEXT)
.setType("text/plain")
}
@RequiresApi(Build.VERSION_CODES.M)
private fun getSupportedActivities(): List<ResolveInfo> {
return context.packageManager
.queryIntentActivities(createProcessTextIntent(), 0)
}
@RequiresApi(Build.VERSION_CODES.M)
private fun createProcessTextIntentForResolveInfo(info: ResolveInfo): Intent {
return createProcessTextIntent()
.putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, false)
.setClassName(info.activityInfo.packageName, info.activityInfo.name)
}
/**
* Start with a menu Item order value that is high enough
* so that your "PROCESS_TEXT" menu items appear after the
* standard selection menu items like Cut, Copy, Paste.
*/
@RequiresApi(Build.VERSION_CODES.M)
private fun onInitializeMenu(menu: Menu) {
kotlin.runCatching {
var menuItemOrder = 100
for (resolveInfo in getSupportedActivities()) {
menu.add(
Menu.NONE, Menu.NONE,
menuItemOrder++, resolveInfo.loadLabel(context.packageManager)
).intent = createProcessTextIntentForResolveInfo(resolveInfo)
}
}.onFailure {
context.toastOnUi("获取文字操作菜单出错:${it.localizedMessage}")
}
}
interface CallBack {
val selectedText: String
fun onMenuItemSelected(itemId: Int): Boolean
fun onMenuActionFinally()
}
}
@@ -0,0 +1,128 @@
package io.legato.kazusa.ui.book.read.config
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import com.google.android.material.slider.Slider
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseBottomSheetDialogFragment
import io.legato.kazusa.databinding.DialogAutoReadBinding
import io.legato.kazusa.help.config.ReadBookConfig
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
import io.legato.kazusa.model.ReadAloud
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.service.BaseReadAloudService
import io.legato.kazusa.ui.book.read.BaseReadBookActivity
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import java.util.Locale
class AutoReadDialog : BaseBottomSheetDialogFragment(R.layout.dialog_auto_read) {
private val binding by viewBinding(DialogAutoReadBinding::bind)
private val callBack: CallBack? get() = activity as? CallBack
override fun onStart() {
super.onStart()
// dialog?.window?.run {
// clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
// setBackgroundDrawableResource(R.color.background)
// decorView.setPadding(0, 0, 0, 0)
// val attr = attributes
// attr.dimAmount = 0.0f
// attr.gravity = Gravity.BOTTOM
// attributes = attr
// setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
// }
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
(activity as ReadBookActivity).bottomDialog--
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run {
val bottomDialog = (activity as ReadBookActivity).bottomDialog++
if (bottomDialog > 0) {
dismiss()
return
}
//val bg = requireContext().bottomBackground
//val isLight = ColorUtils.isColorLight(bg)
//val textColor = requireContext().getPrimaryTextColor(isLight)
//root.setBackgroundColor(bg)
// tvReadSpeedTitle.setTextColor(textColor)
// tvReadSpeed.setTextColor(textColor)
// ivCatalog.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// tvCatalog.setTextColor(textColor)
// ivMainMenu.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// tvMainMenu.setTextColor(textColor)
// ivAutoPageStop.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// tvAutoPageStop.setTextColor(textColor)
// ivSetting.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
// tvSetting.setTextColor(textColor)
initOnChange()
initData()
initEvent()
}
private fun initData() {
val speed = if (ReadBookConfig.autoReadSpeed < 1) 1 else ReadBookConfig.autoReadSpeed
binding.tvReadSpeed.text = String.format(Locale.ROOT, "%ds", speed)
binding.seekAutoRead.value = speed.toFloat()
}
private fun initOnChange() {
binding.seekAutoRead.addOnChangeListener { slider, value, fromUser ->
val speed = if (value < 1) 1 else value.toInt()
binding.tvReadSpeed.text = String.format(Locale.ROOT, "%ds", speed)
}
binding.seekAutoRead.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
// 开始滑动时的操作
}
override fun onStopTrackingTouch(slider: Slider) {
ReadBookConfig.autoReadSpeed = if (slider.value < 1) 1 else slider.value.toInt()
upTtsSpeechRate()
}
})
}
private fun initEvent() {
binding.llMainMenu.setOnClickListener {
callBack?.showMenuBar()
dismissAllowingStateLoss()
}
binding.llSetting.setOnClickListener {
(activity as BaseReadBookActivity).showPageAnimConfig {
(activity as ReadBookActivity).upPageAnim()
ReadBook.loadContent(false)
}
}
binding.llCatalog.setOnClickListener { callBack?.openChapterList() }
binding.llAutoPageStop.setOnClickListener {
callBack?.autoPageStop()
binding.llAutoPageStop.post {
dismissAllowingStateLoss()
}
}
}
private fun upTtsSpeechRate() {
ReadAloud.upTtsSpeechRate(requireContext())
if (!BaseReadAloudService.pause) {
ReadAloud.pause(requireContext())
ReadAloud.resume(requireContext())
}
}
interface CallBack {
fun showMenuBar()
fun openChapterList()
fun autoPageStop()
}
}
@@ -0,0 +1,49 @@
package io.legato.kazusa.ui.book.read.config
import android.content.Context
import android.view.ViewGroup
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.databinding.ItemBgImageBinding
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.help.glide.ImageLoader
import io.legato.kazusa.utils.postEvent
import java.io.File
class BgAdapter(context: Context, val textColor: Int) :
RecyclerAdapter<String, ItemBgImageBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemBgImageBinding {
return ItemBgImageBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBgImageBinding,
item: String,
payloads: MutableList<Any>
) {
binding.run {
ImageLoader.load(
context,
context.assets.open("bg${File.separator}$item").readBytes()
)
.centerCrop()
.into(ivBg)
tvName.setTextColor(textColor)
tvName.text = item.substringBeforeLast(".")
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemBgImageBinding) {
holder.itemView.apply {
this.setOnClickListener {
getItemByLayoutPosition(holder.layoutPosition)?.let {
ReadBookConfig.durConfig.setCurBg(1, it)
postEvent(EventBus.UP_CONFIG, arrayListOf(1))
}
}
}
}
}
@@ -0,0 +1,414 @@
package io.legato.kazusa.ui.book.read.config
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.graphics.Color
import android.graphics.PorterDuff
import android.net.Uri
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.appcompat.widget.TooltipCompat
import androidx.documentfile.provider.DocumentFile
import com.google.android.material.slider.Slider
import com.jaredrummler.android.colorpicker.ColorPickerDialog
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.databinding.DialogEditTextBinding
import io.legato.kazusa.databinding.DialogReadBgTextBinding
import io.legato.kazusa.databinding.ItemBgImageBinding
import io.legato.kazusa.help.DefaultData
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.help.http.newCallResponseBody
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.lib.dialogs.SelectItem
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.lib.dialogs.selector
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
//import io.legado.app.lib.theme.getSecondaryTextColor
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.MD5Utils
import io.legato.kazusa.utils.SelectImageContract
import io.legato.kazusa.utils.compress.ZipUtils
import io.legato.kazusa.utils.createFileReplace
import io.legato.kazusa.utils.createFolderReplace
import io.legato.kazusa.utils.externalCache
import io.legato.kazusa.utils.externalFiles
import io.legato.kazusa.utils.getFile
import io.legato.kazusa.utils.inputStream
import io.legato.kazusa.utils.isContentScheme
import io.legato.kazusa.utils.launch
import io.legato.kazusa.utils.longToast
import io.legato.kazusa.utils.openOutputStream
import io.legato.kazusa.utils.outputStream
import io.legato.kazusa.utils.parseToUri
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.printOnDebug
import io.legato.kazusa.utils.readBytes
import io.legato.kazusa.utils.readUri
import io.legato.kazusa.utils.stackTraceStr
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import splitties.init.appCtx
import java.io.File
import java.io.FileOutputStream
class BgTextConfigDialog : BaseDialogFragment(R.layout.dialog_read_bg_text) {
companion object {
const val TEXT_COLOR = 121
const val BG_COLOR = 122
}
private val binding by viewBinding(DialogReadBgTextBinding::bind)
private val configFileName = "readConfig.zip"
private val adapter by lazy { BgAdapter(requireContext(), secondaryTextColor) }
private var primaryTextColor = 0
private var secondaryTextColor = 0
private val importFormNet = "网络导入"
private val selectBgImage = registerForActivityResult(SelectImageContract()) {
it.uri?.let { uri ->
setBgFromUri(uri)
}
}
private val selectExportDir = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
exportConfig(uri)
}
}
private val selectImportDoc = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
if (uri.toString() == importFormNet) {
importNetConfigAlert()
} else {
importConfig(uri)
}
}
}
override fun onStart() {
super.onStart()
dialog?.window?.run {
clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
//setBackgroundDrawableResource(R.color.background)
decorView.setPadding(0, 0, 0, 0)
val attr = attributes
attr.dimAmount = 0.0f
attr.gravity = Gravity.BOTTOM
attributes = attr
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
(activity as ReadBookActivity).bottomDialog++
initView()
initData()
initEvent()
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
ReadBookConfig.save()
(activity as ReadBookActivity).bottomDialog--
}
private fun initView() = binding.run {
// val bg = requireContext().bottomBackground
// val isLight = ColorUtils.isColorLight(bg)
// primaryTextColor = requireContext().getPrimaryTextColor(isLight)
// secondaryTextColor = requireContext().getSecondaryTextColor(isLight)
// rootView.setBackgroundColor(bg)
// tvNameTitle.setTextColor(primaryTextColor)
// tvName.setTextColor(secondaryTextColor)
// ivEdit.setColorFilter(secondaryTextColor, PorterDuff.Mode.SRC_IN)
// tvRestore.setTextColor(primaryTextColor)
// swDarkStatusIcon.setTextColor(primaryTextColor)
// swUnderline.setTextColor(primaryTextColor)
// ivImport.setColorFilter(primaryTextColor, PorterDuff.Mode.SRC_IN)
// ivExport.setColorFilter(primaryTextColor, PorterDuff.Mode.SRC_IN)
// ivDelete.setColorFilter(primaryTextColor, PorterDuff.Mode.SRC_IN)
// tvBgAlpha.setTextColor(primaryTextColor)
// tvBgImage.setTextColor(primaryTextColor)
recyclerView.adapter = adapter
adapter.addHeaderView {
ItemBgImageBinding.inflate(layoutInflater, it, false).apply {
tvName.setTextColor(secondaryTextColor)
tvName.text = getString(R.string.select_image)
ivBg.setImageResource(R.drawable.ic_image)
ivBg.setColorFilter(primaryTextColor, PorterDuff.Mode.SRC_IN)
root.setOnClickListener {
selectBgImage.launch()
}
}
}
requireContext().assets.list("bg")?.let {
adapter.setItems(it.toList())
}
}
@SuppressLint("InflateParams")
private fun initData() = with(ReadBookConfig.durConfig) {
binding.tvName.text = name.ifBlank { "文字" }
binding.swDarkStatusIcon.isChecked = curStatusIconDark()
binding.swUnderline.isChecked = underline
binding.sbBgAlpha.value = bgAlpha.toFloat()
}
@SuppressLint("InflateParams")
private fun initEvent() = with(ReadBookConfig.durConfig) {
binding.ivEdit.setOnClickListener {
alert(R.string.style_name) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "name"
editView.setText(ReadBookConfig.durConfig.name)
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let {
binding.tvName.text = it
ReadBookConfig.durConfig.name = it
}
}
cancelButton()
}
}
binding.tvRestore.setOnClickListener {
val defaultConfigs = DefaultData.readConfigs
val layoutNames = defaultConfigs.map { it.name }
context?.selector("选择预设布局", layoutNames) { _, i ->
if (i >= 0) {
ReadBookConfig.durConfig = defaultConfigs[i].copy()
initData()
postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
}
}
}
binding.swDarkStatusIcon.setOnCheckedChangeListener { _, isChecked ->
setCurStatusIconDark(isChecked)
(activity as? ReadBookActivity)?.upSystemUiVisibility()
}
binding.swUnderline.setOnCheckedChangeListener { _, isChecked ->
underline = isChecked
postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11))
}
binding.tvTextColor.setOnClickListener {
ColorPickerDialog.newBuilder()
.setColor(curTextColor())
.setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setDialogId(TEXT_COLOR)
.show(requireActivity())
}
binding.tvBgColor.setOnClickListener {
val bgColor =
if (curBgType() == 0) Color.parseColor(curBgStr())
else Color.parseColor("#015A86")
ColorPickerDialog.newBuilder()
.setColor(bgColor)
.setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setDialogId(BG_COLOR)
.show(requireActivity())
}
binding.tvBgColor.apply {
TooltipCompat.setTooltipText(this, text)
}
binding.ivImport.setOnClickListener {
selectImportDoc.launch {
mode = HandleFileContract.FILE
title = getString(R.string.import_str)
allowExtensions = arrayOf("zip")
otherActions = arrayListOf(SelectItem(importFormNet, -1))
}
}
binding.ivExport.setOnClickListener {
selectExportDir.launch {
title = getString(R.string.export_str)
}
}
binding.ivDelete.setOnClickListener {
if (ReadBookConfig.deleteDur()) {
postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
dismissAllowingStateLoss()
} else {
toastOnUi("数量已是最少,不能删除.")
}
}
binding.sbBgAlpha.addOnChangeListener { slider, value, fromUser ->
ReadBookConfig.bgAlpha = value.toInt()
postEvent(EventBus.UP_CONFIG, arrayListOf(3))
}
binding.sbBgAlpha.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
// 可留空
}
override fun onStopTrackingTouch(slider: Slider) {
postEvent(EventBus.UP_CONFIG, arrayListOf(3))
}
})
}
private fun exportConfig(uri: Uri) {
val exportFileName = if (ReadBookConfig.config.name.isBlank()) {
configFileName
} else {
"${ReadBookConfig.config.name}.zip"
}
execute {
val exportFiles = arrayListOf<File>()
val configDir = requireContext().externalCache.getFile("readConfig")
configDir.createFolderReplace()
val configFile = configDir.getFile("readConfig.json")
configFile.createFileReplace()
configFile.writeText(GSON.toJson(ReadBookConfig.getExportConfig()))
exportFiles.add(configFile)
val fontPath = ReadBookConfig.textFont
if (fontPath.isNotEmpty()) {
val fontName = FileUtils.getName(fontPath)
val fontInputStream =
fontPath.parseToUri().inputStream(requireContext()).getOrNull()
fontInputStream?.use {
val fontExportFile = FileUtils.createFileIfNotExist(configDir, fontName)
it.copyTo(fontExportFile.outputStream())
exportFiles.add(fontExportFile)
}
}
if (ReadBookConfig.durConfig.bgType == 2) {
val bgName = FileUtils.getName(ReadBookConfig.durConfig.bgStr)
val bgFile = File(ReadBookConfig.durConfig.bgStr)
if (bgFile.exists()) {
val bgExportFile = File(FileUtils.getPath(configDir, bgName))
if (!bgExportFile.exists()) {
bgFile.copyTo(bgExportFile)
exportFiles.add(bgExportFile)
}
}
}
if (ReadBookConfig.durConfig.bgTypeNight == 2) {
val bgName = FileUtils.getName(ReadBookConfig.durConfig.bgStrNight)
val bgFile = File(ReadBookConfig.durConfig.bgStrNight)
if (bgFile.exists()) {
val bgExportFile = File(FileUtils.getPath(configDir, bgName))
if (!bgExportFile.exists()) {
bgFile.copyTo(bgExportFile)
exportFiles.add(bgExportFile)
}
}
}
if (ReadBookConfig.durConfig.bgTypeEInk == 2) {
val bgName = FileUtils.getName(ReadBookConfig.durConfig.bgStrEInk)
val bgFile = File(ReadBookConfig.durConfig.bgStrEInk)
if (bgFile.exists()) {
val bgExportFile = File(FileUtils.getPath(configDir, bgName))
if (!bgExportFile.exists()) {
bgFile.copyTo(bgExportFile)
exportFiles.add(bgExportFile)
}
}
}
val configZipPath = FileUtils.getPath(requireContext().externalCache, configFileName)
if (ZipUtils.zipFiles(exportFiles, File(configZipPath))) {
if (uri.isContentScheme()) {
DocumentFile.fromTreeUri(requireContext(), uri)?.let { treeDoc ->
treeDoc.findFile(exportFileName)?.delete()
val out = treeDoc.createFile("", exportFileName)?.openOutputStream()
out?.use {
File(configZipPath).inputStream().use {
it.copyTo(out)
}
}
}
} else {
val exportPath = FileUtils.getPath(File(uri.path!!), exportFileName)
FileUtils.delete(exportPath)
File(configZipPath).copyTo(FileUtils.createFileIfNotExist(exportPath))
}
}
}.onSuccess {
toastOnUi("导出成功, 文件名为 $exportFileName")
}.onError {
it.printOnDebug()
AppLog.put("导出失败:${it.localizedMessage}", it)
longToast("导出失败:${it.localizedMessage}")
}
}
@SuppressLint("InflateParams")
private fun importNetConfigAlert() {
alert("输入地址") {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater)
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let { url ->
importNetConfig(url)
}
}
cancelButton()
}
}
private fun importNetConfig(url: String) {
execute {
okHttpClient.newCallResponseBody {
url(url)
}.bytes().let {
importConfig(it)
}
}.onError {
longToast(it.stackTraceStr)
}
}
private fun importConfig(uri: Uri) {
execute {
importConfig(uri.readBytes(requireContext()))
}.onError {
it.printOnDebug()
longToast("导入失败:${it.localizedMessage}")
}
}
private fun importConfig(byteArray: ByteArray) {
execute {
ReadBookConfig.import(byteArray).getOrThrow()
}.onSuccess {
ReadBookConfig.durConfig = it
postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
toastOnUi("导入成功")
}.onError {
it.printOnDebug()
longToast("导入失败:${it.localizedMessage}")
}
}
private fun setBgFromUri(uri: Uri) {
readUri(uri) { fileDoc, inputStream ->
kotlin.runCatching {
var file = requireContext().externalFiles
val suffix = fileDoc.name.substringAfterLast(".")
val fileName = uri.inputStream(requireContext()).getOrThrow().use {
MD5Utils.md5Encode(it) + ".$suffix"
}
file = FileUtils.createFileIfNotExist(file, "bg", fileName)
FileOutputStream(file).use { outputStream ->
inputStream.copyTo(outputStream)
}
ReadBookConfig.durConfig.setCurBg(2, fileName)
postEvent(EventBus.UP_CONFIG, arrayListOf(1))
}.onFailure {
appCtx.toastOnUi(it.localizedMessage)
}
}
}
}
@@ -0,0 +1,51 @@
package io.legato.kazusa.ui.book.read.config
import android.content.Context
import android.text.SpannableString
import android.util.AttributeSet
import io.legato.kazusa.R
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.accentColor
import io.legato.kazusa.ui.widget.text.StrokeTextView
class ChineseConverter(context: Context, attrs: AttributeSet?) : StrokeTextView(context, attrs) {
private val spannableString = SpannableString("简/繁")
// private var enabledSpan: ForegroundColorSpan = ForegroundColorSpan(context.accentColor)
private var onChanged: (() -> Unit)? = null
init {
text = spannableString
if (!isInEditMode) {
upUi(AppConfig.chineseConverterType)
}
setOnClickListener {
selectType()
}
}
private fun upUi(type: Int) {
//spannableString.removeSpan(enabledSpan)
// when (type) {
// 1 -> spannableString.setSpan(enabledSpan, 0, 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
// 2 -> spannableString.setSpan(enabledSpan, 2, 3, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
// }
text = spannableString
}
private fun selectType() {
context.alert(titleResource = R.string.chinese_converter) {
items(context.resources.getStringArray(R.array.chinese_mode).toList()) { _, i ->
AppConfig.chineseConverterType = i
upUi(i)
onChanged?.invoke()
}
}
}
fun onChanged(unit: () -> Unit) {
onChanged = unit
}
}
@@ -0,0 +1,149 @@
package io.legato.kazusa.ui.book.read.config
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.constant.PreferKey
import io.legato.kazusa.databinding.DialogClickActionConfigBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.selector
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.utils.getCompatColor
import io.legato.kazusa.utils.putPrefInt
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
/**
* 点击区域设置
*/
class ClickActionConfigDialog : BaseDialogFragment(R.layout.dialog_click_action_config) {
private val binding by viewBinding(DialogClickActionConfigBinding::bind)
private val actions by lazy {
linkedMapOf(
Pair(-1, getString(R.string.non_action)),
Pair(0, getString(R.string.menu)),
Pair(1, getString(R.string.next_page)),
Pair(2, getString(R.string.prev_page)),
Pair(3, getString(R.string.next_chapter)),
Pair(4, getString(R.string.previous_chapter)),
Pair(5, getString(R.string.read_aloud_prev_paragraph)),
Pair(6, getString(R.string.read_aloud_next_paragraph)),
Pair(7, getString(R.string.bookmark_add)),
Pair(8, getString(R.string.edit_content)),
Pair(9, getString(R.string.replace_state_change)),
Pair(10, getString(R.string.chapter_list)),
Pair(11, getString(R.string.search_content)),
Pair(12, getString(R.string.sync_book_progress_t))
)
}
override fun onStart() {
super.onStart()
dialog?.window?.run {
setBackgroundDrawableResource(R.color.transparent)
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
(activity as ReadBookActivity).bottomDialog--
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
(activity as ReadBookActivity).bottomDialog++
view.setBackgroundColor(getCompatColor(R.color.translucent))
initData()
initViewEvent()
}
private fun initData() = binding.run {
tvTopLeft.text = actions[AppConfig.clickActionTL]
tvTopCenter.text = actions[AppConfig.clickActionTC]
tvTopRight.text = actions[AppConfig.clickActionTR]
tvMiddleLeft.text = actions[AppConfig.clickActionML]
tvMiddleCenter.text = actions[AppConfig.clickActionMC]
tvMiddleRight.text = actions[AppConfig.clickActionMR]
tvBottomLeft.text = actions[AppConfig.clickActionBL]
tvBottomCenter.text = actions[AppConfig.clickActionBC]
tvBottomRight.text = actions[AppConfig.clickActionBR]
}
private fun initViewEvent() {
binding.ivClose.setOnClickListener {
dismissAllowingStateLoss()
}
binding.tvTopLeft.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionTL, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvTopCenter.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionTC, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvTopRight.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionTR, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvMiddleLeft.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionML, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvMiddleCenter.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionMC, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvMiddleRight.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionMR, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvBottomLeft.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionBL, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvBottomCenter.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionBC, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvBottomRight.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionBR, action)
(it as? TextView)?.text = actions[action]
}
}
}
private fun selectAction(success: (action: Int) -> Unit) {
context?.selector(
getString(R.string.select_action),
actions.values.toList()
) { _, index ->
success.invoke(actions.keys.toList()[index])
}
}
override fun onDestroy() {
super.onDestroy()
AppConfig.detectClickArea()
}
}
@@ -0,0 +1,139 @@
package io.legato.kazusa.ui.book.read.config
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.data.entities.HttpTTS
import io.legato.kazusa.databinding.DialogHttpTtsEditBinding
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.about.AppLogDialog
import io.legato.kazusa.ui.login.SourceLoginActivity
import io.legato.kazusa.ui.widget.code.addJsPattern
import io.legato.kazusa.ui.widget.code.addJsonPattern
import io.legato.kazusa.ui.widget.code.addLegadoPattern
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.showHelp
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class HttpTtsEditDialog() : BaseDialogFragment(R.layout.dialog_http_tts_edit, true),
Toolbar.OnMenuItemClickListener {
constructor(id: Long) : this() {
arguments = Bundle().apply {
putLong("id", id)
}
}
private val binding by viewBinding(DialogHttpTtsEditBinding::bind)
private val viewModel by viewModels<HttpTtsEditViewModel>()
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.tvUrl.run {
addLegadoPattern()
addJsonPattern()
addJsPattern()
}
binding.tvLoginUrl.run {
addLegadoPattern()
addJsonPattern()
addJsPattern()
}
binding.tvLoginUi.addJsonPattern()
binding.tvLoginCheckJs.addJsPattern()
binding.tvHeaders.run {
addLegadoPattern()
addJsonPattern()
addJsPattern()
}
viewModel.initData(arguments) {
initView(httpTTS = it)
}
initMenu()
}
fun initMenu() {
binding.toolBar.inflateMenu(R.menu.speak_engine_edit)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
}
fun initView(httpTTS: HttpTTS) {
binding.tvName.setText(httpTTS.name)
binding.tvUrl.setText(httpTTS.url)
binding.tvContentType.setText(httpTTS.contentType)
binding.tvConcurrentRate.setText(httpTTS.concurrentRate)
binding.tvLoginUrl.setText(httpTTS.loginUrl)
binding.tvLoginUi.setText(httpTTS.loginUi)
binding.tvLoginCheckJs.setText(httpTTS.loginCheckJs)
binding.tvHeaders.setText(httpTTS.header)
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_save -> viewModel.save(dataFromView()) {
toastOnUi("保存成功")
}
R.id.menu_login -> dataFromView().let { httpTts ->
if (httpTts.loginUrl.isNullOrBlank()) {
toastOnUi("登录url不能为空")
} else {
viewModel.save(httpTts) {
startActivity<SourceLoginActivity> {
putExtra("type", "httpTts")
putExtra("key", httpTts.id.toString())
}
}
}
}
R.id.menu_show_login_header -> alert {
setTitle(R.string.login_header)
dataFromView().getLoginHeader()?.let { loginHeader ->
setMessage(loginHeader)
}
}
R.id.menu_del_login_header -> dataFromView().removeLoginHeader()
R.id.menu_copy_source -> dataFromView().let {
context?.sendToClip(GSON.toJson(it))
}
R.id.menu_paste_source -> viewModel.importFromClip {
initView(it)
}
R.id.menu_log -> showDialogFragment<AppLogDialog>()
R.id.menu_help -> showHelp("httpTTSHelp")
}
return true
}
private fun dataFromView(): HttpTTS {
return HttpTTS(
id = viewModel.id ?: System.currentTimeMillis(),
name = binding.tvName.text.toString(),
url = binding.tvUrl.text.toString(),
contentType = binding.tvContentType.text?.toString(),
concurrentRate = binding.tvConcurrentRate.text?.toString(),
loginUrl = binding.tvLoginUrl.text?.toString(),
loginUi = binding.tvLoginUi.text?.toString(),
loginCheckJs = binding.tvLoginCheckJs.text?.toString(),
header = binding.tvHeaders.text?.toString()
)
}
}
@@ -0,0 +1,76 @@
package io.legato.kazusa.ui.book.read.config
import android.app.Application
import android.os.Bundle
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.HttpTTS
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.model.ReadAloud
import io.legato.kazusa.utils.getClipText
import io.legato.kazusa.utils.isJsonArray
import io.legato.kazusa.utils.isJsonObject
import io.legato.kazusa.utils.toastOnUi
class HttpTtsEditViewModel(app: Application) : BaseViewModel(app) {
var id: Long? = null
fun initData(arguments: Bundle?, success: (httpTTS: HttpTTS) -> Unit) {
execute {
if (id == null) {
val argumentId = arguments?.getLong("id")
if (argumentId != null && argumentId != 0L) {
id = argumentId
return@execute appDb.httpTTSDao.get(argumentId)
}
}
return@execute null
}.onSuccess {
it?.let {
success.invoke(it)
}
}
}
fun save(httpTTS: HttpTTS, success: (() -> Unit)? = null) {
id = httpTTS.id
execute {
appDb.httpTTSDao.insert(httpTTS)
if (ReadAloud.ttsEngine == httpTTS.id.toString()) ReadAloud.upReadAloudClass()
}.onSuccess {
success?.invoke()
}
}
fun importFromClip(onSuccess: (httpTTS: HttpTTS) -> Unit) {
val text = context.getClipText()
if (text.isNullOrBlank()) {
context.toastOnUi("剪贴板为空")
} else {
importSource(text, onSuccess)
}
}
fun importSource(text: String, onSuccess: (httpTTS: HttpTTS) -> Unit) {
val text1 = text.trim()
execute {
when {
text1.isJsonObject() -> {
HttpTTS.fromJson(text1).getOrThrow()
}
text1.isJsonArray() -> {
HttpTTS.fromJsonArray(text1).getOrThrow().first()
}
else -> {
throw NoStackTraceException("格式不对")
}
}
}.onSuccess {
onSuccess.invoke(it)
}.onError {
context.toastOnUi(it.localizedMessage)
}
}
}
@@ -0,0 +1,203 @@
package io.legato.kazusa.ui.book.read.config
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewConfiguration
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.preference.Preference
import io.legato.kazusa.R
import io.legato.kazusa.base.BasePrefDialogFragment
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.constant.PreferKey
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.lib.prefs.fragment.PreferenceFragment
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.ui.book.read.page.provider.ChapterProvider
import io.legato.kazusa.ui.widget.number.NumberPickerDialog
import io.legato.kazusa.utils.canvasrecorder.CanvasRecorderFactory
import io.legato.kazusa.utils.getPrefBoolean
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.removePref
class MoreConfigDialog : BasePrefDialogFragment() {
private val readPreferTag = "readPreferenceFragment"
override fun onStart() {
super.onStart()
// dialog?.window?.run {
// clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
// setBackgroundDrawableResource(R.color.background)
// decorView.setPadding(0, 0, 0, 0)
// val attr = attributes
// attr.dimAmount = 0.0f
// attr.gravity = Gravity.BOTTOM
// attributes = attr
// setLayout(ViewGroup.LayoutParams.MATCH_PARENT, 360.dpToPx())
// }
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
(activity as ReadBookActivity).bottomDialog++
val view = LinearLayout(context)
//view.setBackgroundColor(requireContext().bottomBackground)
view.id = R.id.tag1
container?.addView(view)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
var preferenceFragment = childFragmentManager.findFragmentByTag(readPreferTag)
if (preferenceFragment == null) preferenceFragment = ReadPreferenceFragment()
childFragmentManager.beginTransaction()
.replace(view.id, preferenceFragment, readPreferTag)
.commit()
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
(activity as ReadBookActivity).bottomDialog--
}
class ReadPreferenceFragment : PreferenceFragment(),
SharedPreferences.OnSharedPreferenceChangeListener {
private val slopSquare by lazy { ViewConfiguration.get(requireContext()).scaledTouchSlop }
@SuppressLint("RestrictedApi")
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_config_read)
upPreferenceSummary(PreferKey.pageTouchSlop, slopSquare.toString())
if (!CanvasRecorderFactory.isSupport) {
removePref(PreferKey.optimizeRender)
preferenceScreen.removePreferenceRecursively(PreferKey.optimizeRender)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
//listView.setEdgeEffectColor(primaryColor)
}
override fun onResume() {
super.onResume()
preferenceManager
.sharedPreferences
?.registerOnSharedPreferenceChangeListener(this)
}
override fun onPause() {
preferenceManager
.sharedPreferences
?.unregisterOnSharedPreferenceChangeListener(this)
super.onPause()
}
override fun onSharedPreferenceChanged(
sharedPreferences: SharedPreferences?,
key: String?
) {
when (key) {
PreferKey.readBodyToLh -> activity?.recreate()
PreferKey.hideStatusBar -> {
ReadBookConfig.hideStatusBar = getPrefBoolean(PreferKey.hideStatusBar)
postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2))
}
PreferKey.hideNavigationBar -> {
ReadBookConfig.hideNavigationBar = getPrefBoolean(PreferKey.hideNavigationBar)
postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2))
}
PreferKey.keepLight -> postEvent(key, true)
PreferKey.textSelectAble -> postEvent(key, getPrefBoolean(key))
PreferKey.screenOrientation -> {
(activity as? ReadBookActivity)?.setOrientation()
}
PreferKey.textFullJustify,
PreferKey.textBottomJustify,
PreferKey.useZhLayout -> {
postEvent(EventBus.UP_CONFIG, arrayListOf(5))
}
PreferKey.showBrightnessView -> {
postEvent(PreferKey.showBrightnessView, "")
}
PreferKey.expandTextMenu -> {
(activity as? ReadBookActivity)?.textActionMenu?.upMenu()
}
PreferKey.doublePageHorizontal -> {
ChapterProvider.upLayout()
ReadBook.loadContent(false)
}
PreferKey.showReadTitleAddition,
PreferKey.readBarStyleFollowPage -> {
postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
}
PreferKey.progressBarBehavior -> {
postEvent(EventBus.UP_SEEK_BAR, true)
}
PreferKey.noAnimScrollPage -> {
ReadBook.callBack?.upPageAnim()
}
PreferKey.optimizeRender -> {
ChapterProvider.upStyle()
ReadBook.callBack?.upPageAnim(true)
ReadBook.loadContent(false)
}
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
"customPageKey" -> PageKeyDialog(requireContext()).show()
"clickRegionalConfig" -> {
(activity as? ReadBookActivity)?.showClickRegionalConfig()
}
PreferKey.pageTouchSlop -> {
NumberPickerDialog(requireContext())
.setTitle(getString(R.string.page_touch_slop_dialog_title))
.setMaxValue(9999)
.setMinValue(0)
.setValue(AppConfig.pageTouchSlop)
.show {
AppConfig.pageTouchSlop = it
postEvent(EventBus.UP_CONFIG, arrayListOf(4))
}
}
}
return super.onPreferenceTreeClick(preference)
}
@Suppress("SameParameterValue")
private fun upPreferenceSummary(preferenceKey: String, value: String?) {
val preference = findPreference<Preference>(preferenceKey) ?: return
when (preferenceKey) {
PreferKey.pageTouchSlop -> preference.summary =
getString(R.string.page_touch_slop_summary, value)
}
}
}
}
@@ -0,0 +1,121 @@
package io.legato.kazusa.ui.book.read.config
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.databinding.DialogReadPaddingBinding
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class PaddingConfigDialog : BaseDialogFragment(R.layout.dialog_read_padding) {
private val binding by viewBinding(DialogReadPaddingBinding::bind)
override fun onStart() {
super.onStart()
// dialog?.window?.let {
// it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
// val attr = it.attributes
// attr.dimAmount = 0.0f
// it.attributes = attr
// }
// setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initData()
initView()
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
ReadBookConfig.save()
}
private fun initData() = binding.run {
//正文
dsbPaddingTop.progress = ReadBookConfig.paddingTop
dsbPaddingBottom.progress = ReadBookConfig.paddingBottom
dsbPaddingLeft.progress = ReadBookConfig.paddingLeft
dsbPaddingRight.progress = ReadBookConfig.paddingRight
//页眉
dsbHeaderPaddingTop.progress = ReadBookConfig.headerPaddingTop
dsbHeaderPaddingBottom.progress = ReadBookConfig.headerPaddingBottom
dsbHeaderPaddingLeft.progress = ReadBookConfig.headerPaddingLeft
dsbHeaderPaddingRight.progress = ReadBookConfig.headerPaddingRight
//页脚
dsbFooterPaddingTop.progress = ReadBookConfig.footerPaddingTop
dsbFooterPaddingBottom.progress = ReadBookConfig.footerPaddingBottom
dsbFooterPaddingLeft.progress = ReadBookConfig.footerPaddingLeft
dsbFooterPaddingRight.progress = ReadBookConfig.footerPaddingRight
cbShowTopLine.isChecked = ReadBookConfig.showHeaderLine
cbShowBottomLine.isChecked = ReadBookConfig.showFooterLine
}
private fun initView() = binding.run {
//正文
dsbPaddingTop.onChanged = {
ReadBookConfig.paddingTop = it
postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5))
}
dsbPaddingBottom.onChanged = {
ReadBookConfig.paddingBottom = it
postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5))
}
dsbPaddingLeft.onChanged = {
ReadBookConfig.paddingLeft = it
postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5))
}
dsbPaddingRight.onChanged = {
ReadBookConfig.paddingRight = it
postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5))
}
//页眉
dsbHeaderPaddingTop.onChanged = {
ReadBookConfig.headerPaddingTop = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
dsbHeaderPaddingBottom.onChanged = {
ReadBookConfig.headerPaddingBottom = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
dsbHeaderPaddingLeft.onChanged = {
ReadBookConfig.headerPaddingLeft = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
dsbHeaderPaddingRight.onChanged = {
ReadBookConfig.headerPaddingRight = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
//页脚
dsbFooterPaddingTop.onChanged = {
ReadBookConfig.footerPaddingTop = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
dsbFooterPaddingBottom.onChanged = {
ReadBookConfig.footerPaddingBottom = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
dsbFooterPaddingLeft.onChanged = {
ReadBookConfig.footerPaddingLeft = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
dsbFooterPaddingRight.onChanged = {
ReadBookConfig.footerPaddingRight = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
cbShowTopLine.setOnCheckedChangeListener { _, isChecked ->
ReadBookConfig.showHeaderLine = isChecked
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
cbShowBottomLine.setOnCheckedChangeListener { _, isChecked ->
ReadBookConfig.showFooterLine = isChecked
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
}
}
@@ -0,0 +1,72 @@
package io.legato.kazusa.ui.book.read.config
import android.app.Dialog
import android.content.Context
import android.view.KeyEvent
import android.view.ViewGroup
import io.legato.kazusa.constant.PreferKey
import io.legato.kazusa.databinding.DialogPageKeyBinding
//import io.legado.app.lib.theme.backgroundColor
import io.legato.kazusa.utils.getPrefString
import io.legato.kazusa.utils.hideSoftInput
import io.legato.kazusa.utils.putPrefString
import io.legato.kazusa.utils.setLayout
import splitties.views.onClick
class PageKeyDialog(context: Context) : Dialog(context) {
private val binding = DialogPageKeyBinding.inflate(layoutInflater)
override fun onStart() {
super.onStart()
setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
init {
setContentView(binding.root)
binding.run {
//contentView.setBackgroundColor(context.backgroundColor)
etPrev.setText(context.getPrefString(PreferKey.prevKeys))
etNext.setText(context.getPrefString(PreferKey.nextKeys))
tvReset.onClick {
etPrev.setText("")
etNext.setText("")
}
tvOk.setOnClickListener {
context.putPrefString(PreferKey.prevKeys, etPrev.text?.toString())
context.putPrefString(PreferKey.nextKeys, etNext.text?.toString())
dismiss()
}
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode != KeyEvent.KEYCODE_BACK && keyCode != KeyEvent.KEYCODE_DEL) {
if (binding.etPrev.hasFocus()) {
val editableText = binding.etPrev.editableText
if (editableText.isEmpty() or editableText.endsWith(",")) {
editableText.append(keyCode.toString())
} else {
editableText.append(",").append(keyCode.toString())
}
return true
} else if (binding.etNext.hasFocus()) {
val editableText = binding.etNext.editableText
if (editableText.isEmpty() or editableText.endsWith(",")) {
editableText.append(keyCode.toString())
} else {
editableText.append(",").append(keyCode.toString())
}
return true
}
}
return super.onKeyDown(keyCode, event)
}
override fun dismiss() {
super.dismiss()
currentFocus?.hideSoftInput()
}
}
@@ -0,0 +1,149 @@
package io.legato.kazusa.ui.book.read.config
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.preference.ListPreference
import androidx.preference.Preference
import io.legato.kazusa.R
import io.legato.kazusa.base.BasePrefDialogFragment
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.constant.PreferKey
import io.legato.kazusa.data.appDb
import io.legato.kazusa.help.IntentHelp
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.SelectItem
import io.legato.kazusa.lib.prefs.SwitchPreference
import io.legato.kazusa.lib.prefs.fragment.PreferenceFragment
//import io.legado.app.lib.theme.backgroundColor
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.model.ReadAloud
import io.legato.kazusa.service.BaseReadAloudService
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.StringUtils
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.showDialogFragment
class ReadAloudConfigDialog : BasePrefDialogFragment() {
private val readAloudPreferTag = "readAloudPreferTag"
override fun onStart() {
super.onStart()
dialog?.window?.run {
//setBackgroundDrawableResource(R.color.transparent)
//setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = LinearLayout(requireContext())
//view.setBackgroundColor(requireContext().backgroundColor)
view.id = R.id.tag1
container?.addView(view)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
var preferenceFragment = childFragmentManager.findFragmentByTag(readAloudPreferTag)
if (preferenceFragment == null) preferenceFragment = ReadAloudPreferenceFragment()
childFragmentManager.beginTransaction()
.replace(view.id, preferenceFragment, readAloudPreferTag)
.commit()
}
class ReadAloudPreferenceFragment : PreferenceFragment(),
SpeakEngineDialog.CallBack,
SharedPreferences.OnSharedPreferenceChangeListener {
private val speakEngineSummary: String
get() {
val ttsEngine = ReadAloud.ttsEngine
?: return getString(R.string.system_tts)
if (StringUtils.isNumeric(ttsEngine)) {
return appDb.httpTTSDao.getName(ttsEngine.toLong())
?: getString(R.string.system_tts)
}
return GSON.fromJsonObject<SelectItem<String>>(ttsEngine).getOrNull()?.title
?: getString(R.string.system_tts)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_config_aloud)
upSpeakEngineSummary()
findPreference<SwitchPreference>(PreferKey.pauseReadAloudWhilePhoneCalls)?.let {
it.isEnabled = AppConfig.ignoreAudioFocus
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
//listView.setEdgeEffectColor(primaryColor)
}
override fun onResume() {
super.onResume()
preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
}
override fun onPause() {
preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this)
super.onPause()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
PreferKey.ttsEngine -> showDialogFragment(SpeakEngineDialog())
"sysTtsConfig" -> IntentHelp.openTTSSetting()
}
return super.onPreferenceTreeClick(preference)
}
override fun onSharedPreferenceChanged(
sharedPreferences: SharedPreferences?,
key: String?
) {
when (key) {
PreferKey.readAloudByPage, PreferKey.streamReadAloudAudio -> {
if (BaseReadAloudService.isRun) {
postEvent(EventBus.MEDIA_BUTTON, false)
}
}
PreferKey.ignoreAudioFocus -> {
findPreference<SwitchPreference>(PreferKey.pauseReadAloudWhilePhoneCalls)?.let {
it.isEnabled = AppConfig.ignoreAudioFocus
}
}
}
}
private fun upPreferenceSummary(preference: Preference?, value: String) {
when (preference) {
is ListPreference -> {
val index = preference.findIndexOfValue(value)
preference.summary = if (index >= 0) preference.entries[index] else null
}
else -> {
preference?.summary = value
}
}
}
override fun upSpeakEngineSummary() {
upPreferenceSummary(
findPreference(PreferKey.ttsEngine),
speakEngineSummary
)
}
}
}
@@ -0,0 +1,254 @@
package io.legato.kazusa.ui.book.read.config
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import com.google.android.material.slider.Slider
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseBottomSheetDialogFragment
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.databinding.DialogReadAloudBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.selector
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
import io.legato.kazusa.model.ReadAloud
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.service.BaseReadAloudService
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.utils.*
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class ReadAloudDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_aloud) {
private val callBack: CallBack? get() = activity as? CallBack
private val binding by viewBinding(DialogReadAloudBinding::bind)
override fun onStart() {
super.onStart()
// dialog?.window?.run {
// clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
// setBackgroundDrawableResource(R.color.background)
// decorView.setPadding(0, 0, 0, 0)
// val attr = attributes
// attr.dimAmount = 0.0f
// attr.gravity = Gravity.BOTTOM
// attributes = attr
// setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
// }
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
(activity as ReadBookActivity).bottomDialog--
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
val bottomDialog = (activity as ReadBookActivity).bottomDialog++
if (bottomDialog > 0) {
dismiss()
return
}
//val bg = requireContext().bottomBackground
//val isLight = ColorUtils.isColorLight(bg)
//val textColor = requireContext().getPrimaryTextColor(isLight)
binding.run {
// rootView.setBackgroundColor(bg)
// tvPre.setTextColor(textColor)
// tvNext.setTextColor(textColor)
// ivPlayPrev.setColorFilter(textColor)
// ivPlayPause.setColorFilter(textColor)
// ivPlayNext.setColorFilter(textColor)
// ivStop.setColorFilter(textColor)
// ivTimer.setColorFilter(textColor)
// tvTimer.setTextColor(textColor)
// ivTtsSpeechReduce.setColorFilter(textColor)
// tvTtsSpeed.setTextColor(textColor)
// tvTtsSpeedValue.setTextColor(textColor)
// ivTtsSpeechAdd.setColorFilter(textColor)
// ivCatalog.setColorFilter(textColor)
// tvCatalog.setTextColor(textColor)
// ivMainMenu.setColorFilter(textColor)
// tvMainMenu.setTextColor(textColor)
// ivToBackstage.setColorFilter(textColor)
// tvToBackstage.setTextColor(textColor)
// ivSetting.setColorFilter(textColor)
// tvSetting.setTextColor(textColor)
// cbTtsFollowSys.setTextColor(textColor)
}
initData()
initEvent()
}
private fun initData() = binding.run {
upPlayState()
upTimerText(BaseReadAloudService.timeMinute)
cbTtsFollowSys.isChecked = requireContext().getPrefBoolean("ttsFollowSys", true)
upTtsSpeechRateEnabled(!cbTtsFollowSys.isChecked)
upSeekTimer()
}
private fun initEvent() = binding.run {
llMainMenu.setOnClickListener {
callBack?.showMenuBar()
dismissAllowingStateLoss()
}
llSetting.setOnClickListener {
ReadAloudConfigDialog().show(childFragmentManager, "readAloudConfigDialog")
}
tvPre.setOnClickListener { ReadBook.moveToPrevChapter(upContent = true, toLast = false) }
tvNext.setOnClickListener { ReadBook.moveToNextChapter(true) }
ivStop.setOnClickListener {
ReadAloud.stop(requireContext())
dismissAllowingStateLoss()
}
ivPlayPause.setOnClickListener { callBack?.onClickReadAloud() }
ivPlayPrev.setOnClickListener { ReadAloud.prevParagraph(requireContext()) }
ivPlayNext.setOnClickListener { ReadAloud.nextParagraph(requireContext()) }
llCatalog.setOnClickListener { callBack?.openChapterList() }
llToBackstage.setOnClickListener { callBack?.finish() }
cbTtsFollowSys.setOnCheckedChangeListener { _, isChecked ->
AppConfig.ttsFlowSys = isChecked
upTtsSpeechRateEnabled(!isChecked)
upTtsSpeechRate()
}
ivTimer.setOnClickListener {
AppConfig.ttsTimer = seekTimer.value.toInt()
toastOnUi("保存设定时间成功!")
}
// 设置初始值
seekTtsSpeechRate.value = AppConfig.ttsSpeechRate.toFloat()
seekTimer.value = if (BaseReadAloudService.timeMinute > 0)
BaseReadAloudService.timeMinute.toFloat()
else AppConfig.ttsTimer.toFloat()
// 减速按钮逻辑
ivTtsSpeechReduce.setOnClickListener {
val newValue = (seekTtsSpeechRate.value - 1).coerceAtLeast(seekTtsSpeechRate.valueFrom)
seekTtsSpeechRate.value = newValue
AppConfig.ttsSpeechRate = newValue.toInt()
upTtsSpeechRate()
}
// 加速按钮逻辑
ivTtsSpeechAdd.setOnClickListener {
val newValue = (seekTtsSpeechRate.value + 1).coerceAtMost(seekTtsSpeechRate.valueTo)
seekTtsSpeechRate.value = newValue
AppConfig.ttsSpeechRate = newValue.toInt()
upTtsSpeechRate()
}
btnTimer.setOnClickListener {
val times = intArrayOf(0, 5, 10, 15, 30, 60, 90, 180)
val timeKeys = times.map { "$it 分钟" }
context?.selector("设定时间", timeKeys) { _, index ->
ReadAloud.setTimer(requireContext(), times[index])
upTimerText(times[index])
}
}
//设置保存的默认值
seekTtsSpeechRate.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
upTtsSpeechRateText(value.toInt())
}
}
seekTtsSpeechRate.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {}
override fun onStopTrackingTouch(slider: Slider) {
AppConfig.ttsSpeechRate = slider.value.toInt()
upTtsSpeechRate()
}
})
seekTimer.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
upTimerText(value.toInt())
}
}
seekTimer.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {}
override fun onStopTrackingTouch(slider: Slider) {
ReadAloud.setTimer(requireContext(), slider.value.toInt())
}
})
}
private fun upTtsSpeechRateEnabled(enabled: Boolean) {
binding.run {
upTtsSpeechRateText(AppConfig.ttsSpeechRate)
tvTtsSpeedValue.visible(enabled)
seekTtsSpeechRate.isEnabled = enabled
ivTtsSpeechReduce.isEnabled = enabled
ivTtsSpeechAdd.isEnabled = enabled
}
}
private fun upPlayState() {
if (!BaseReadAloudService.pause) {
binding.ivPlayPause.icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_pause_24dp)
binding.ivPlayPause.contentDescription = getString(R.string.pause)
} else {
binding.ivPlayPause.icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_play_24dp)
binding.ivPlayPause.contentDescription = getString(R.string.audio_play)
}
// val bg = requireContext().bottomBackground
// val isLight = ColorUtils.isColorLight(bg)
// val textColor = requireContext().getPrimaryTextColor(isLight)
// binding.ivPlayPause.iconTint = ColorStateList.valueOf(textColor)
}
private fun upSeekTimer() {
binding.seekTimer.post {
binding.seekTimer.value = if (BaseReadAloudService.timeMinute > 0) {
BaseReadAloudService.timeMinute.toFloat()
} else {
AppConfig.ttsTimer.toFloat()
}
}
}
private fun upTimerText(timeMinute: Int) {
if (timeMinute < 0) {
binding.btnTimer.text = requireContext().getString(R.string.timer_m, 0)
} else {
binding.btnTimer.text = requireContext().getString(R.string.timer_m, timeMinute)
}
}
@SuppressLint("SetTextI18n")
private fun upTtsSpeechRateText(value: Int) {
binding.tvTtsSpeedValue.text = value.toString()
}
private fun upTtsSpeechRate() {
ReadAloud.upTtsSpeechRate(requireContext())
if (!BaseReadAloudService.pause) {
ReadAloud.pause(requireContext())
ReadAloud.resume(requireContext())
}
}
override fun observeLiveBus() {
observeEvent<Int>(EventBus.ALOUD_STATE) { upPlayState() }
observeEvent<Int>(EventBus.READ_ALOUD_DS) {
val value = it.coerceIn(binding.seekTimer.valueFrom.toInt(), binding.seekTimer.valueTo.toInt())
binding.seekTimer.value = value.toFloat()
}
}
interface CallBack {
fun showMenuBar()
fun openChapterList()
fun onClickReadAloud()
fun finish()
}
}
@@ -0,0 +1,264 @@
package io.legato.kazusa.ui.book.read.config
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.core.view.get
import com.github.liuyueyi.quick.transfer.constants.TransType
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseBottomSheetDialogFragment
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.databinding.DialogReadBookStyleBinding
import io.legato.kazusa.databinding.ItemReadStyleBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.lib.dialogs.selector
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.ui.font.FontSelectDialog
import io.legato.kazusa.utils.ChineseUtils
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import splitties.views.onLongClick
class ReadStyleDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_book_style),
FontSelectDialog.CallBack {
private val binding by viewBinding(DialogReadBookStyleBinding::bind)
private val callBack get() = activity as? ReadBookActivity
private lateinit var styleAdapter: StyleAdapter
override fun onStart() {
super.onStart()
// dialog?.window?.run {
// clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
// setBackgroundDrawableResource(R.color.background)
// decorView.setPadding(0, 0, 0, 0)
// val attr = attributes
// attr.dimAmount = 0.0f
// attr.gravity = Gravity.BOTTOM
// attributes = attr
// setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
// }
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
(activity as ReadBookActivity).bottomDialog++
initView()
initData()
initViewEvent()
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
ReadBookConfig.save()
(activity as ReadBookActivity).bottomDialog--
}
private fun initView() = binding.run {
//val bg = requireContext().bottomBackground
//val isLight = ColorUtils.isColorLight(bg)
//val textColor = requireContext().getPrimaryTextColor(isLight)
// rootView.setBackgroundColor(bg)
// tvPageAnim.setTextColor(textColor)
// tvBgTs.setTextColor(textColor)
// tvShareLayout.setTextColor(textColor)
dsbTextSize.valueFormat = {
(it + 5).toString()
}
dsbTextLetterSpacing.valueFormat = {
((it - 50) / 100f).toString()
}
dsbLineSize.valueFormat = { ((it - 10) / 10f).toString() }
dsbParagraphSpacing.valueFormat = { (it / 10f).toString() }
styleAdapter = StyleAdapter()
rvStyle.adapter = styleAdapter
styleAdapter.addFooterView {
ItemReadStyleBinding.inflate(layoutInflater, it, false).apply {
// ivStyle.setPadding(6.dpToPx(), 6.dpToPx(), 6.dpToPx(), 6.dpToPx())
// ivStyle.setText(null)
// ivStyle.setColorFilter(textColor)
// ivStyle.borderColor = textColor
ivStyle.setImageResource(R.drawable.ic_add)
root.setOnClickListener {
ReadBookConfig.configList.add(ReadBookConfig.Config())
showBgTextConfig(ReadBookConfig.configList.lastIndex)
}
}
}
}
private fun initData() {
binding.cbShareLayout.isChecked = ReadBookConfig.shareLayout
upView()
styleAdapter.setItems(ReadBookConfig.configList)
}
private fun initViewEvent() = binding.run {
chineseConverter.onChanged {
ChineseUtils.unLoad(*TransType.entries.toTypedArray())
postEvent(EventBus.UP_CONFIG, arrayListOf(5))
}
textFontWeightConverter.onChanged {
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 9, 6))
}
tvTextFont.setOnClickListener {
showDialogFragment<FontSelectDialog>()
}
tvTextIndent.setOnClickListener {
context?.selector(
title = getString(R.string.text_indent),
items = resources.getStringArray(R.array.indent).toList()
) { _, index ->
ReadBookConfig.paragraphIndent = " ".repeat(index)
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
}
tvPadding.setOnClickListener {
dismissAllowingStateLoss()
callBack?.showPaddingConfig()
}
tvTip.setOnClickListener {
TipConfigDialog().show(childFragmentManager, "tipConfigDialog")
}
// rgPageAnim.setOnCheckedChangeListener { _, checkedId ->
// ReadBook.book?.setPageAnim(-1)
// ReadBookConfig.pageAnim = binding.rgPageAnim.getIndexById(checkedId)
// callBack?.upPageAnim()
// ReadBook.loadContent(false)
// }
binding.rgPageAnim.setOnCheckedStateChangeListener { group, checkedIds ->
val checkedId = checkedIds.firstOrNull() ?: return@setOnCheckedStateChangeListener
ReadBook.book?.setPageAnim(-1)
ReadBookConfig.pageAnim = when (checkedId) {
R.id.rb_anim0 -> 0 // 覆盖动画
R.id.rb_anim1 -> 1 // 滑动动画
R.id.rb_simulation_anim -> 2 // 仿真翻页
R.id.rb_scroll_anim -> 3 // 滚动动画
R.id.rb_no_anim -> 4 // 无动画
else -> 0
}
callBack?.upPageAnim()
ReadBook.loadContent(false)
}
cbShareLayout.setOnCheckedChangeListener { _, isChecked ->
ReadBookConfig.shareLayout = isChecked
upView()
postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
}
dsbTextSize.onChanged = {
ReadBookConfig.textSize = it + 5
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
dsbTextLetterSpacing.onChanged = {
ReadBookConfig.letterSpacing = (it - 50) / 100f
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
dsbLineSize.onChanged = {
ReadBookConfig.lineSpacingExtra = it
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
dsbParagraphSpacing.onChanged = {
ReadBookConfig.paragraphSpacing = it
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
}
private fun changeBgTextConfig(index: Int) {
val oldIndex = ReadBookConfig.styleSelect
if (index != oldIndex) {
ReadBookConfig.styleSelect = index
upView()
styleAdapter.notifyItemChanged(oldIndex)
styleAdapter.notifyItemChanged(index)
postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
if (AppConfig.readBarStyleFollowPage) {
postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
}
}
}
private fun showBgTextConfig(index: Int): Boolean {
dismissAllowingStateLoss()
changeBgTextConfig(index)
callBack?.showBgTextConfig()
return true
}
private fun upView() = binding.run {
textFontWeightConverter.upUi(ReadBookConfig.textBold)
ReadBook.pageAnim().let {
if (it >= 0 && it < rgPageAnim.childCount) {
rgPageAnim.check(rgPageAnim[it].id)
}
}
ReadBookConfig.let {
dsbTextSize.progress = it.textSize - 5
dsbTextLetterSpacing.progress = (it.letterSpacing * 100).toInt() + 50
dsbLineSize.progress = it.lineSpacingExtra
dsbParagraphSpacing.progress = it.paragraphSpacing
}
}
override val curFontPath: String
get() = ReadBookConfig.textFont
override fun selectFont(path: String) {
if (path != ReadBookConfig.textFont || path.isEmpty()) {
ReadBookConfig.textFont = path
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 5))
}
}
inner class StyleAdapter :
RecyclerAdapter<ReadBookConfig.Config, ItemReadStyleBinding>(requireContext()) {
override fun getViewBinding(parent: ViewGroup): ItemReadStyleBinding {
return ItemReadStyleBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemReadStyleBinding,
item: ReadBookConfig.Config,
payloads: MutableList<Any>
) {
binding.apply {
ivStyle.setText(item.name.ifBlank { "文字" })
ivStyle.setTextColor(item.curTextColor())
//ivStyle.setImageDrawable(item.curBgDrawable(100, 150))
if (ReadBookConfig.styleSelect == holder.layoutPosition) {
//ivStyle.borderColor = accentColor
ivStyle.setTextBold(true)
} else {
ivStyle.borderColor = item.curTextColor()
ivStyle.setTextBold(false)
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemReadStyleBinding) {
binding.apply {
ivStyle.setOnClickListener {
if (ivStyle.isInView) {
changeBgTextConfig(holder.layoutPosition)
}
}
ivStyle.onLongClick(ivStyle.isInView) {
if (ivStyle.isInView) {
showBgTextConfig(holder.layoutPosition)
}
}
}
}
}
}
@@ -0,0 +1,289 @@
package io.legato.kazusa.ui.book.read.config
import android.content.Context
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.RadioButton
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.HttpTTS
import io.legato.kazusa.databinding.DialogEditTextBinding
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemHttpTtsBinding
import io.legato.kazusa.help.DirectLinkUpload
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.SelectItem
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.model.ReadAloud
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.ui.association.ImportHttpTtsDialog
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.ui.login.SourceLoginActivity
import io.legato.kazusa.utils.ACache
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.isAbsUrl
import io.legato.kazusa.utils.isJsonObject
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.splitNotBlank
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
/**
* tts引擎管理
*/
class SpeakEngineDialog() : BaseDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener {
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel: SpeakEngineViewModel by viewModels()
private val ttsUrlKey = "ttsUrlKey"
private val adapter by lazy { Adapter(requireContext()) }
private var ttsEngine: String? = ReadAloud.ttsEngine
private val sysTtsViews = arrayListOf<RadioButton>()
private val callBack: CallBack? get() = parentFragment as? CallBack
private val importDocResult = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
viewModel.importLocal(uri)
}
}
private val exportDirResult = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
alert(R.string.export_success) {
if (uri.toString().isAbsUrl()) {
setMessage(DirectLinkUpload.getSummary())
}
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = getString(R.string.path)
editView.setText(uri.toString())
}
customView { alertBinding.root }
okButton {
requireContext().sendToClip(uri.toString())
}
}
}
}
override fun onStart() {
super.onStart()
//setLayout(ViewGroup.LayoutParams.MATCH_PARENT, 0.9f)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initView()
initMenu()
initData()
}
private fun initView() = binding.run {
//toolBar.setBackgroundColor(primaryColor)
toolBar.setTitle(R.string.speak_engine)
//recyclerView.setEdgeEffectColor(primaryColor)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
recyclerView.adapter = adapter
adapter.addHeaderView {
ItemHttpTtsBinding.inflate(layoutInflater, recyclerView, false).apply {
sysTtsViews.add(cbName)
ivEdit.gone()
ivMenuDelete.gone()
labelSys.visible()
cbName.text = "系统默认"
cbName.tag = ""
cbName.isChecked = ttsEngine == null || ttsEngine!!.isJsonObject()
&& GSON.fromJsonObject<SelectItem<String>>(ttsEngine)
.getOrNull()?.value.isNullOrEmpty()
cbName.setOnClickListener {
upTts(GSON.toJson(SelectItem("系统默认", "")))
}
}
}
viewModel.sysEngines.forEach { engine ->
adapter.addHeaderView {
ItemHttpTtsBinding.inflate(layoutInflater, recyclerView, false).apply {
sysTtsViews.add(cbName)
ivEdit.gone()
ivMenuDelete.gone()
labelSys.visible()
cbName.text = engine.label
cbName.tag = engine.name
cbName.isChecked = GSON.fromJsonObject<SelectItem<String>>(ttsEngine)
.getOrNull()?.value == cbName.tag
cbName.setOnClickListener {
upTts(GSON.toJson(SelectItem(engine.label, engine.name)))
}
}
}
}
tvFooterLeft.setText(R.string.book)
tvFooterLeft.visible()
tvFooterLeft.setOnClickListener {
ReadBook.book?.setTtsEngine(ttsEngine)
callBack?.upSpeakEngineSummary()
ReadAloud.upReadAloudClass()
dismissAllowingStateLoss()
}
tvOk.setText(R.string.general)
tvOk.visible()
tvOk.setOnClickListener {
ReadBook.book?.setTtsEngine(null)
AppConfig.ttsEngine = ttsEngine
callBack?.upSpeakEngineSummary()
ReadAloud.upReadAloudClass()
dismissAllowingStateLoss()
}
tvCancel.visible()
tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
}
private fun initMenu() = binding.run {
toolBar.inflateMenu(R.menu.speak_engine)
toolBar.menu.applyTint(requireContext())
toolBar.setOnMenuItemClickListener(this@SpeakEngineDialog)
}
private fun initData() {
lifecycleScope.launch {
appDb.httpTTSDao.flowAll().catch {
AppLog.put("朗读引擎界面获取数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
adapter.setItems(it)
}
}
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_add -> showDialogFragment<HttpTtsEditDialog>()
R.id.menu_default -> viewModel.importDefault()
R.id.menu_import_local -> importDocResult.launch {
mode = HandleFileContract.FILE
allowExtensions = arrayOf("txt", "json")
}
R.id.menu_import_onLine -> importAlert()
R.id.menu_export -> exportDirResult.launch {
mode = HandleFileContract.EXPORT
fileData = HandleFileContract.FileData(
"httpTts.json",
GSON.toJson(adapter.getItems()).toByteArray(),
"application/json"
)
}
}
return true
}
private fun importAlert() {
val aCache = ACache.get(cacheDir = false)
val cacheUrls: MutableList<String> = aCache
.getAsString(ttsUrlKey)
?.splitNotBlank(",")
?.toMutableList() ?: mutableListOf()
alert(R.string.import_on_line) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "url"
editView.setFilterValues(cacheUrls)
editView.delCallBack = {
cacheUrls.remove(it)
aCache.put(ttsUrlKey, cacheUrls.joinToString(","))
}
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let { url ->
if (url.isAbsUrl() && !cacheUrls.contains(url)) {
cacheUrls.add(0, url)
aCache.put(ttsUrlKey, cacheUrls.joinToString(","))
}
showDialogFragment(ImportHttpTtsDialog(url))
}
}
}
}
private fun upTts(tts: String) {
ttsEngine = tts
sysTtsViews.forEach {
it.isChecked = GSON.fromJsonObject<SelectItem<String>>(ttsEngine)
.getOrNull()?.value == it.tag
}
adapter.notifyItemRangeChanged(adapter.getHeaderCount(), adapter.itemCount)
}
inner class Adapter(context: Context) :
RecyclerAdapter<HttpTTS, ItemHttpTtsBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemHttpTtsBinding {
return ItemHttpTtsBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemHttpTtsBinding,
item: HttpTTS,
payloads: MutableList<Any>
) {
binding.apply {
cbName.text = item.name
cbName.isChecked = item.id.toString() == ttsEngine
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemHttpTtsBinding) {
binding.run {
cbName.setOnClickListener {
getItemByLayoutPosition(holder.layoutPosition)?.let { httpTTS ->
val id = httpTTS.id.toString()
upTts(id)
if (!httpTTS.loginUrl.isNullOrBlank()
&& httpTTS.getLoginInfo().isNullOrBlank()
) {
startActivity<SourceLoginActivity> {
putExtra("type", "httpTts")
putExtra("key", id)
}
}
}
}
ivEdit.setOnClickListener {
val id = getItemByLayoutPosition(holder.layoutPosition)!!.id
showDialogFragment(HttpTtsEditDialog(id))
}
ivMenuDelete.setOnClickListener {
getItemByLayoutPosition(holder.layoutPosition)?.let { httpTTS ->
appDb.httpTTSDao.delete(httpTTS)
}
}
}
}
}
interface CallBack {
fun upSpeakEngineSummary()
}
}
@@ -0,0 +1,61 @@
package io.legato.kazusa.ui.book.read.config
import android.app.Application
import android.net.Uri
import android.speech.tts.TextToSpeech
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.HttpTTS
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.DefaultData
import io.legato.kazusa.utils.isJsonArray
import io.legato.kazusa.utils.isJsonObject
import io.legato.kazusa.utils.readText
import io.legato.kazusa.utils.toastOnUi
class SpeakEngineViewModel(application: Application) : BaseViewModel(application) {
val sysEngines: List<TextToSpeech.EngineInfo> by lazy {
val tts = TextToSpeech(context, null)
val engines = tts.engines
tts.shutdown()
engines
}
fun importDefault() {
execute {
DefaultData.importDefaultHttpTTS()
}
}
fun importLocal(uri: Uri) {
execute {
import(uri.readText(context))
}.onSuccess {
context.toastOnUi("导入成功")
}.onError {
context.toastOnUi("导入失败\n${it.localizedMessage}")
}
}
fun import(text: String) {
when {
text.isJsonArray() -> {
HttpTTS.fromJsonArray(text).getOrThrow().let {
appDb.httpTTSDao.insert(*it.toTypedArray())
}
}
text.isJsonObject() -> {
HttpTTS.fromJson(text).getOrThrow().let {
appDb.httpTTSDao.insert(it)
}
}
else -> {
throw NoStackTraceException("格式不对")
}
}
}
}
@@ -0,0 +1,54 @@
package io.legato.kazusa.ui.book.read.config
import android.content.Context
import android.text.SpannableString
import android.util.AttributeSet
import io.legato.kazusa.R
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.accentColor
import io.legato.kazusa.ui.widget.text.StrokeTextView
class TextFontWeightConverter(context: Context, attrs: AttributeSet?) :
StrokeTextView(context, attrs) {
private val spannableString = SpannableString(context.getString(R.string.font_weight_text))
//private var enabledSpan: ForegroundColorSpan = ForegroundColorSpan(context.accentColor)
private var onChanged: (() -> Unit)? = null
init {
text = spannableString
if (!isInEditMode) {
upUi(ReadBookConfig.textBold)
}
setOnClickListener {
selectType()
}
}
@Suppress("MemberVisibilityCanBePrivate")
fun upUi(type: Int) {
// spannableString.removeSpan(enabledSpan)
// when (type) {
// 0 -> spannableString.setSpan(enabledSpan, 0, 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
// 1 -> spannableString.setSpan(enabledSpan, 2, 3, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
// 2 -> spannableString.setSpan(enabledSpan, 4, 5, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
// }
text = spannableString
}
private fun selectType() {
context.alert(titleResource = R.string.text_font_weight_converter) {
items(context.resources.getStringArray(R.array.text_font_weight).toList()) { _, i ->
ReadBookConfig.textBold = i
upUi(i)
onChanged?.invoke()
}
}
}
fun onChanged(unit: () -> Unit) {
onChanged = unit
}
}
@@ -0,0 +1,248 @@
package io.legato.kazusa.ui.book.read.config
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import com.jaredrummler.android.colorpicker.ColorPickerDialog
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.databinding.DialogTipConfigBinding
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.help.config.ReadTipConfig
import io.legato.kazusa.lib.dialogs.selector
import io.legato.kazusa.utils.checkByIndex
import io.legato.kazusa.utils.getIndexById
import io.legato.kazusa.utils.hexString
import io.legato.kazusa.utils.observeEvent
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) {
companion object {
const val TIP_COLOR = 7897
const val TIP_DIVIDER_COLOR = 7898
}
private val binding by viewBinding(DialogTipConfigBinding::bind)
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initView()
initEvent()
observeEvent<String>(EventBus.TIP_COLOR) {
upTvTipColor()
upTvTipDividerColor()
}
}
private fun initView() {
binding.rgTitleMode.checkByIndex(ReadBookConfig.titleMode)
binding.dsbTitleSize.progress = ReadBookConfig.titleSize
binding.dsbTitleTop.progress = ReadBookConfig.titleTopSpacing
binding.dsbTitleBottom.progress = ReadBookConfig.titleBottomSpacing
binding.tvHeaderShow.text =
ReadTipConfig.getHeaderModes(requireContext())[ReadTipConfig.headerMode]
binding.tvFooterShow.text =
ReadTipConfig.getFooterModes(requireContext())[ReadTipConfig.footerMode]
ReadTipConfig.run {
tipNames.let { tipNames ->
binding.tvHeaderLeft.text =
tipNames.getOrElse(tipValues.indexOf(tipHeaderLeft)) { tipNames[none] }
binding.tvHeaderMiddle.text =
tipNames.getOrElse(tipValues.indexOf(tipHeaderMiddle)) { tipNames[none] }
binding.tvHeaderRight.text =
tipNames.getOrElse(tipValues.indexOf(tipHeaderRight)) { tipNames[none] }
binding.tvFooterLeft.text =
tipNames.getOrElse(tipValues.indexOf(tipFooterLeft)) { tipNames[none] }
binding.tvFooterMiddle.text =
tipNames.getOrElse(tipValues.indexOf(tipFooterMiddle)) { tipNames[none] }
binding.tvFooterRight.text =
tipNames.getOrElse(tipValues.indexOf(tipFooterRight)) { tipNames[none] }
}
}
upTvTipColor()
upTvTipDividerColor()
}
private fun upTvTipColor() {
val tipColorNames = ReadTipConfig.tipColorNames
val tipColor = ReadTipConfig.tipColor
binding.tvTipColor.text = if (tipColor == 0) {
tipColorNames.first()
} else {
"#${tipColor.hexString}"
}
}
private fun upTvTipDividerColor() {
val tipDividerColorNames = ReadTipConfig.tipDividerColorNames
val tipDividerColor = ReadTipConfig.tipDividerColor
binding.tvTipDividerColor.text = when (tipDividerColor) {
-1, 0 -> tipDividerColorNames[tipDividerColor + 1]
else -> "#${tipDividerColor.hexString}"
}
}
private fun initEvent() = binding.run {
rgTitleMode.setOnCheckedChangeListener { _, checkedId ->
ReadBookConfig.titleMode = rgTitleMode.getIndexById(checkedId)
postEvent(EventBus.UP_CONFIG, arrayListOf(5))
}
dsbTitleSize.onChanged = {
ReadBookConfig.titleSize = it
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
dsbTitleTop.onChanged = {
ReadBookConfig.titleTopSpacing = it
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
dsbTitleBottom.onChanged = {
ReadBookConfig.titleBottomSpacing = it
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
llHeaderShow.setOnClickListener {
val headerModes = ReadTipConfig.getHeaderModes(requireContext())
context?.selector(items = headerModes.values.toList()) { _, i ->
ReadTipConfig.headerMode = headerModes.keys.toList()[i]
tvHeaderShow.text = headerModes[ReadTipConfig.headerMode]
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
}
llFooterShow.setOnClickListener {
val footerModes = ReadTipConfig.getFooterModes(requireContext())
context?.selector(items = footerModes.values.toList()) { _, i ->
ReadTipConfig.footerMode = footerModes.keys.toList()[i]
tvFooterShow.text = footerModes[ReadTipConfig.footerMode]
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
}
llHeaderLeft.setOnClickListener {
context?.selector(items = ReadTipConfig.tipNames) { _, i ->
val tipValue = ReadTipConfig.tipValues[i]
clearRepeat(tipValue)
ReadTipConfig.tipHeaderLeft = tipValue
tvHeaderLeft.text = ReadTipConfig.tipNames[i]
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
}
}
llHeaderMiddle.setOnClickListener {
context?.selector(items = ReadTipConfig.tipNames) { _, i ->
val tipValue = ReadTipConfig.tipValues[i]
clearRepeat(tipValue)
ReadTipConfig.tipHeaderMiddle = tipValue
tvHeaderMiddle.text = ReadTipConfig.tipNames[i]
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
}
}
llHeaderRight.setOnClickListener {
context?.selector(items = ReadTipConfig.tipNames) { _, i ->
val tipValue = ReadTipConfig.tipValues[i]
clearRepeat(tipValue)
ReadTipConfig.tipHeaderRight = tipValue
tvHeaderRight.text = ReadTipConfig.tipNames[i]
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
}
}
llFooterLeft.setOnClickListener {
context?.selector(items = ReadTipConfig.tipNames) { _, i ->
val tipValue = ReadTipConfig.tipValues[i]
clearRepeat(tipValue)
ReadTipConfig.tipFooterLeft = tipValue
tvFooterLeft.text = ReadTipConfig.tipNames[i]
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
}
}
llFooterMiddle.setOnClickListener {
context?.selector(items = ReadTipConfig.tipNames) { _, i ->
val tipValue = ReadTipConfig.tipValues[i]
clearRepeat(tipValue)
ReadTipConfig.tipFooterMiddle = tipValue
tvFooterMiddle.text = ReadTipConfig.tipNames[i]
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
}
}
llFooterRight.setOnClickListener {
context?.selector(items = ReadTipConfig.tipNames) { _, i ->
val tipValue = ReadTipConfig.tipValues[i]
clearRepeat(tipValue)
ReadTipConfig.tipFooterRight = tipValue
tvFooterRight.text = ReadTipConfig.tipNames[i]
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
}
}
llTipColor.setOnClickListener {
context?.selector(items = ReadTipConfig.tipColorNames) { _, i ->
when (i) {
0 -> {
ReadTipConfig.tipColor = 0
upTvTipColor()
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
1 -> ColorPickerDialog.newBuilder()
.setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setDialogId(TIP_COLOR)
.show(requireActivity())
}
}
}
llTipDividerColor.setOnClickListener {
context?.selector(items = ReadTipConfig.tipDividerColorNames) { _, i ->
when (i) {
0, 1 -> {
ReadTipConfig.tipDividerColor = i - 1
upTvTipDividerColor()
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
2 -> ColorPickerDialog.newBuilder()
.setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setDialogId(TIP_DIVIDER_COLOR)
.show(requireActivity())
}
}
}
}
private fun clearRepeat(repeat: Int) = ReadTipConfig.apply {
if (repeat != none) {
if (tipHeaderLeft == repeat) {
tipHeaderLeft = none
binding.tvHeaderLeft.text = tipNames[none]
}
if (tipHeaderMiddle == repeat) {
tipHeaderMiddle = none
binding.tvHeaderMiddle.text = tipNames[none]
}
if (tipHeaderRight == repeat) {
tipHeaderRight = none
binding.tvHeaderRight.text = tipNames[none]
}
if (tipFooterLeft == repeat) {
tipFooterLeft = none
binding.tvFooterLeft.text = tipNames[none]
}
if (tipFooterMiddle == repeat) {
tipFooterMiddle = none
binding.tvFooterMiddle.text = tipNames[none]
}
if (tipFooterRight == repeat) {
tipFooterRight = none
binding.tvFooterRight.text = tipNames[none]
}
}
}
}
@@ -0,0 +1,134 @@
package io.legato.kazusa.ui.book.read.page
import android.graphics.Canvas
import android.graphics.Paint
import android.os.SystemClock
import androidx.core.graphics.withClip
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.lib.theme.ThemeStore
import io.legato.kazusa.ui.book.read.page.entities.PageDirection
import io.legato.kazusa.utils.canvasrecorder.CanvasRecorderFactory
import io.legato.kazusa.utils.canvasrecorder.recordIfNeeded
/**
* 自动翻页
*/
class AutoPager(private val readView: ReadView) {
private var progress = 0
var isRunning = false
private set
private var isPausing = false
private var scrollOffsetRemain = 0.0
private var scrollOffset = 0
private var lastTimeMillis = 0L
private var canvasRecorder = CanvasRecorderFactory.create()
private val paint by lazy { Paint() }
fun start() {
isRunning = true
paint.color = ThemeStore.accentColor
lastTimeMillis = SystemClock.uptimeMillis()
readView.curPage.upSelectAble(false)
readView.invalidate()
}
fun stop() {
if (!isRunning) {
return
}
isRunning = false
isPausing = false
readView.curPage.upSelectAble(AppConfig.textSelectAble)
readView.invalidate()
reset()
canvasRecorder.recycle()
}
fun pause() {
if (!isRunning) {
return
}
isPausing = true
}
fun resume() {
if (!isRunning) {
return
}
isPausing = false
lastTimeMillis = SystemClock.uptimeMillis()
readView.invalidate()
}
fun reset() {
progress = 0
scrollOffsetRemain = 0.0
scrollOffset = 0
canvasRecorder.invalidate()
}
fun upRecorder() {
canvasRecorder.recycle()
canvasRecorder = CanvasRecorderFactory.create()
}
fun onDraw(canvas: Canvas) {
if (!isRunning) {
return
}
if (readView.isScroll) {
if (!isPausing) readView.curPage.scroll(-scrollOffset)
} else {
val bottom = progress
val width = readView.width
canvasRecorder.recordIfNeeded(readView.nextPage)
canvas.withClip(0, 0, width, bottom) {
canvasRecorder.draw(this)
}
canvas.drawRect(
0f,
bottom.toFloat() - 1,
width.toFloat(),
bottom.toFloat(),
paint
)
if (!isPausing) readView.postInvalidate()
}
}
fun computeOffset() {
if (!isRunning || isPausing) {
return
}
val currentTime = SystemClock.uptimeMillis()
val elapsedTime = currentTime - lastTimeMillis
lastTimeMillis = currentTime
val readTime = ReadBookConfig.autoReadSpeed * 1000.0
val height = readView.height
scrollOffsetRemain += height / readTime * elapsedTime
if (scrollOffsetRemain < 1) {
return
}
scrollOffset = scrollOffsetRemain.toInt()
this.scrollOffsetRemain -= scrollOffset
if (!readView.isScroll) {
progress += scrollOffset
if (progress >= height) {
if (!readView.fillPage(PageDirection.NEXT)) {
stop()
} else {
reset()
}
}
}
}
}
@@ -0,0 +1,717 @@
package io.legato.kazusa.ui.book.read.page
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import io.legato.kazusa.R
import io.legato.kazusa.data.entities.Bookmark
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.ui.book.read.page.delegate.PageDelegate
import io.legato.kazusa.ui.book.read.page.entities.TextLine
import io.legato.kazusa.ui.book.read.page.entities.TextPage
import io.legato.kazusa.ui.book.read.page.entities.TextPos
import io.legato.kazusa.ui.book.read.page.entities.column.BaseColumn
import io.legato.kazusa.ui.book.read.page.entities.column.ButtonColumn
import io.legato.kazusa.ui.book.read.page.entities.column.ImageColumn
import io.legato.kazusa.ui.book.read.page.entities.column.ReviewColumn
import io.legato.kazusa.ui.book.read.page.entities.column.TextColumn
import io.legato.kazusa.ui.book.read.page.provider.ChapterProvider
import io.legato.kazusa.ui.book.read.page.provider.TextPageFactory
import io.legato.kazusa.ui.widget.dialog.PhotoDialog
import io.legato.kazusa.utils.activity
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.getCompatColor
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.toastOnUi
import java.util.concurrent.Executors
import kotlin.math.max
import kotlin.math.min
/**
* 阅读内容视图
*/
class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
var selectAble = AppConfig.textSelectAble
val selectedPaint by lazy {
Paint().apply {
color = context.getCompatColor(R.color.btn_bg_press_2)
style = Paint.Style.FILL
}
}
private var callBack: CallBack
private val visibleRect = ChapterProvider.visibleRect
val selectStart = TextPos(0, -1, -1)
private val selectEnd = TextPos(0, -1, -1)
var textPage: TextPage = TextPage()
private set
var isMainView = false
var longScreenshot = false
var reverseStartCursor = false
var reverseEndCursor = false
//滚动参数
private val pageFactory get() = callBack.pageFactory
private val pageDelegate get() = callBack.pageDelegate
private var pageOffset = 0
private var autoPager: AutoPager? = null
private var isScroll = false
private val renderRunnable by lazy { Runnable { preRenderPage() } }
//绘制图片的paint
val imagePaint by lazy {
Paint().apply {
isAntiAlias = AppConfig.useAntiAlias
}
}
init {
callBack = activity as CallBack
}
/**
* 设置内容
*/
fun setContent(textPage: TextPage) {
this.textPage = textPage
// 非滑动翻页动画需要同步重绘,不然翻页可能会出现闪烁
if (isScroll) {
postInvalidate()
} else {
invalidate()
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (!isMainView) return
ChapterProvider.upViewSize(w, h)
textPage.format()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
autoPager?.onDraw(canvas)
if (longScreenshot) {
canvas.translate(0f, scrollY.toFloat())
}
check(!visibleRect.isEmpty) { "visibleRect 为空" }
canvas.clipRect(visibleRect)
drawPage(canvas)
}
/**
* 绘制页面
*/
private fun drawPage(canvas: Canvas) {
var relativeOffset = relativeOffset(0)
textPage.draw(this, canvas, relativeOffset)
if (!callBack.isScroll) return
//滚动翻页
if (!pageFactory.hasNext()) return
val textPage1 = relativePage(1)
relativeOffset += textPage.height
textPage1.draw(this, canvas, relativeOffset)
if (!pageFactory.hasNextPlus()) return
relativeOffset += textPage1.height
if (relativeOffset < ChapterProvider.visibleHeight) {
val textPage2 = relativePage(2)
textPage2.draw(this, canvas, relativeOffset)
}
}
override fun computeScroll() {
pageDelegate?.computeScroll()
autoPager?.computeOffset()
}
/**
* 滚动事件
* pageOffset 向上滚动 减小 向下滚动 增大
* pageOffset 范围 0 ~ -textPage.height 大于0为上一页,小于-textPage.height为下一页
* 以内容显示区域顶端为界,pageOffset的绝对值为textPage上方的高度
* pageOffset + textPage.height 为 textPage 下方的高度
*/
fun scroll(mOffset: Int) {
pageOffset += mOffset
if (longScreenshot) {
scrollY += -mOffset
}
if (!pageFactory.hasPrev() && pageOffset > 0) {
pageOffset = 0
pageDelegate?.abortAnim()
} else if (!pageFactory.hasNext()
&& pageOffset < 0
&& pageOffset + textPage.height < ChapterProvider.visibleHeight
) {
val offset = (ChapterProvider.visibleHeight - textPage.height).toInt()
pageOffset = min(0, offset)
pageDelegate?.abortAnim()
} else if (pageOffset > 0) {
if (pageFactory.moveToPrev(true)) {
pageOffset -= textPage.height.toInt()
} else {
pageOffset = 0
pageDelegate?.abortAnim()
}
} else if (pageOffset < -textPage.height) {
val height = textPage.height
if (pageFactory.moveToNext(upContent = true)) {
pageOffset += height.toInt()
} else {
pageOffset = -height.toInt()
pageDelegate?.abortAnim()
}
}
postInvalidate()
}
fun submitRenderTask() {
renderThread.submit(renderRunnable)
}
private fun preRenderPage() {
val view = this
var invalidate = false
pageFactory.run {
if (hasPrev() && prevPage.render(view)) {
invalidate = true
}
if (curPage.render(view)) {
invalidate = true
}
if (hasNext() && nextPage.render(view) && callBack.isScroll) {
invalidate = true
}
if (hasNextPlus() && nextPlusPage.render(view) && callBack.isScroll
&& relativeOffset(2) < ChapterProvider.visibleHeight
) {
invalidate = true
}
if (invalidate) {
postInvalidate()
pageDelegate?.postInvalidate()
}
}
}
/**
* 重置滚动位置
*/
fun resetPageOffset() {
pageOffset = 0
}
/**
* 长按
*/
fun longPress(
x: Float,
y: Float,
select: (textPos: TextPos) -> Unit,
) {
touch(x, y) { _, textPos, _, _, column ->
when (column) {
is ImageColumn -> callBack.onImageLongPress(x, y, column.src)
is TextColumn -> {
if (!selectAble) return@touch
column.selected = true
select(textPos)
}
}
}
}
/**
* 单击
* @return true:已处理, false:未处理
*/
@Suppress("UNUSED_ANONYMOUS_PARAMETER")
fun click(x: Float, y: Float): Boolean {
var handled = false
touch(x, y) { _, textPos, textPage, textLine, column ->
when (column) {
is ButtonColumn -> {
context.toastOnUi("Button Pressed!")
handled = true
}
is ReviewColumn -> {
context.toastOnUi("Button Pressed!")
handled = true
}
is ImageColumn -> if (AppConfig.previewImageByClick) {
activity?.showDialogFragment(PhotoDialog(column.src))
handled = true
}
}
}
return handled
}
/**
* 选择文字
*/
fun selectText(
x: Float,
y: Float,
select: (textPos: TextPos) -> Unit,
) {
touchRough(x, y) { _, textPos, _, _, column ->
if (column is TextColumn) {
column.selected = true
select(textPos)
}
}
}
/**
* 开始选择符移动
*/
fun selectStartMove(x: Float, y: Float) {
touchRough(x, y) { _, textPos, _, _, _ ->
if (selectStart.compare(textPos) == 0) {
return@touchRough
}
if (textPos.compare(selectEnd) <= 0) {
selectStartMoveIndex(textPos)
} else {
touchRough(x - 2 * cursorWidth, y) { _, textPos, _, _, _ ->
if (textPos.compare(selectEnd) > 0) {
reverseStartCursor = true
reverseEndCursor = false
selectEnd.columnIndex++
selectStartMoveIndex(selectEnd)
selectEndMoveIndex(textPos)
}
}
}
}
}
/**
* 结束选择符移动
*/
fun selectEndMove(x: Float, y: Float) {
touchRough(x, y) { _, textPos, _, _, _ ->
if (textPos.compare(selectEnd) == 0) {
return@touchRough
}
if (textPos.compare(selectStart) >= 0) {
selectEndMoveIndex(textPos)
} else {
touchRough(x + 2 * cursorWidth, y) { _, textPos, _, _, _ ->
if (textPos.compare(selectStart) < 0) {
reverseEndCursor = true
reverseStartCursor = false
selectStart.columnIndex--
selectEndMoveIndex(selectStart)
selectStartMoveIndex(textPos)
}
}
}
}
}
/**
* 触碰位置信息
* @param touched 回调
*/
private fun touch(
x: Float,
y: Float,
touched: (
relativeOffset: Float,
textPos: TextPos,
textPage: TextPage,
textLine: TextLine,
column: BaseColumn
) -> Unit
) {
if (!visibleRect.contains(x, y)) return
var relativeOffset: Float
for (relativePos in 0..2) {
relativeOffset = relativeOffset(relativePos)
if (relativePos > 0) {
//滚动翻页
if (!callBack.isScroll) return
if (relativeOffset >= ChapterProvider.visibleHeight) return
}
val textPage = relativePage(relativePos)
for ((lineIndex, textLine) in textPage.lines.withIndex()) {
if (textLine.isTouch(x, y, relativeOffset)) {
for ((charIndex, textColumn) in textLine.columns.withIndex()) {
if (textColumn.isTouch(x)) {
touched.invoke(
relativeOffset,
TextPos(relativePos, lineIndex, charIndex),
textPage, textLine, textColumn
)
return
}
}
return
}
}
}
}
/**
* 触碰位置信息
* 文本选择专用
* @param touched 回调
*/
private fun touchRough(
x: Float,
y: Float,
touched: (
relativeOffset: Float,
textPos: TextPos,
textPage: TextPage,
textLine: TextLine,
column: BaseColumn
) -> Unit
) {
var relativeOffset: Float
for (relativePos in 0..2) {
relativeOffset = relativeOffset(relativePos)
if (relativePos > 0) {
//滚动翻页
if (!callBack.isScroll) return
if (relativeOffset >= ChapterProvider.visibleHeight) return
}
val textPage = relativePage(relativePos)
for (lineIndex in textPage.lines.indices) {
val textLine = textPage.getLine(lineIndex)
if (textLine.isTouchY(y, relativeOffset)) {
if (textPage.doublePage) {
val halfWidth = width / 2
if (textLine.isLeftLine && x > halfWidth) {
continue
}
if (!textLine.isLeftLine && x < halfWidth) {
continue
}
}
val columns = textLine.columns
for (charIndex in columns.indices) {
val textColumn = columns[charIndex]
if (textColumn.isTouch(x)) {
touched.invoke(
relativeOffset,
TextPos(relativePos, lineIndex, charIndex),
textPage, textLine, textColumn
)
return
}
}
val isLast = columns.first().start < x
val charIndex = if (isLast) columns.lastIndex + 1 else -1
val textColumn = if (isLast) columns.last() else columns.first()
touched.invoke(
relativeOffset,
TextPos(relativePos, lineIndex, charIndex),
textPage, textLine, textColumn
)
return
}
}
}
}
fun getCurVisiblePage(): TextPage {
val visiblePage = TextPage()
var relativeOffset: Float
for (relativePos in 0..2) {
relativeOffset = relativeOffset(relativePos)
if (relativePos > 0) {
//滚动翻页
if (!callBack.isScroll) break
if (relativeOffset >= ChapterProvider.visibleHeight) break
}
val textPage = relativePage(relativePos)
val lines = textPage.lines
for (i in lines.indices) {
val textLine = lines[i]
if (textLine.isVisible(relativeOffset)) {
val visibleLine = textLine.copy().apply {
lineTop += relativeOffset
lineBottom += relativeOffset
}
visiblePage.addLine(visibleLine)
}
}
}
return visiblePage
}
fun getCurVisibleFirstLine(): TextLine? {
var relativeOffset: Float
for (relativePos in 0..2) {
relativeOffset = relativeOffset(relativePos)
if (relativePos > 0) {
//滚动翻页
if (!callBack.isScroll) break
if (relativeOffset >= ChapterProvider.visibleHeight) break
}
val textPage = relativePage(relativePos)
val lines = textPage.lines
for (i in lines.indices) {
val textLine = lines[i]
if (textLine.isVisible(relativeOffset)) {
val visibleLine = textLine.copy().apply {
lineTop += relativeOffset
lineBottom += relativeOffset
}
return visibleLine
}
}
}
return null
}
/**
* 选择开始文字
*/
fun selectStartMoveIndex(
relativePagePos: Int,
lineIndex: Int,
charIndex: Int,
) {
selectStart.relativePagePos = relativePagePos
selectStart.lineIndex = lineIndex
selectStart.columnIndex = max(0, charIndex)
val textLine = relativePage(relativePagePos).getLine(lineIndex)
val textColumn = textLine.getColumn(charIndex)
upSelectedStart(
if (charIndex < textLine.columns.size) textColumn.start else textColumn.end,
textLine.lineBottom + relativeOffset(relativePagePos),
textLine.lineTop + relativeOffset(relativePagePos)
)
upSelectChars()
}
fun selectStartMoveIndex(textPos: TextPos) = textPos.run {
selectStartMoveIndex(relativePagePos, lineIndex, columnIndex)
}
/**
* 选择结束文字
*/
fun selectEndMoveIndex(
relativePage: Int,
lineIndex: Int,
charIndex: Int,
) {
selectEnd.relativePagePos = relativePage
selectEnd.lineIndex = lineIndex
val textLine = relativePage(relativePage).getLine(lineIndex)
selectEnd.columnIndex = min(charIndex, textLine.columns.lastIndex)
val textColumn = textLine.getColumn(charIndex)
upSelectedEnd(
if (charIndex > -1) textColumn.end else textColumn.start,
textLine.lineBottom + relativeOffset(relativePage)
)
upSelectChars()
}
fun selectEndMoveIndex(textPos: TextPos) = textPos.run {
selectEndMoveIndex(relativePagePos, lineIndex, columnIndex)
}
private fun upSelectChars() {
if (!selectStart.isSelected() && !selectEnd.isSelected()) {
return
}
val last = if (callBack.isScroll) 2 else 0
val textPos = TextPos(0, 0, 0)
for (relativePos in 0..last) {
textPos.relativePagePos = relativePos
val textPage = relativePage(relativePos)
for ((lineIndex, textLine) in textPage.lines.withIndex()) {
textPos.lineIndex = lineIndex
for ((charIndex, column) in textLine.columns.withIndex()) {
textPos.columnIndex = charIndex
if (column is TextColumn) {
val compareStart = textPos.compare(selectStart)
val compareEnd = textPos.compare(selectEnd)
column.selected = compareStart >= 0 && compareEnd <= 0
column.isSearchResult =
column.selected && callBack.isSelectingSearchResult
if (column.isSearchResult) {
textPage.searchResult.add(column)
}
}
}
}
}
postInvalidate()
}
private fun upSelectedStart(x: Float, y: Float, top: Float) {
callBack.run {
upSelectedStart(x, y + headerHeight, top + headerHeight)
}
}
private fun upSelectedEnd(x: Float, y: Float) {
callBack.run {
upSelectedEnd(x, y + headerHeight)
}
}
fun resetReverseCursor() {
reverseStartCursor = false
reverseEndCursor = false
}
fun cancelSelect(clearSearchResult: Boolean = false) {
val last = if (callBack.isScroll) 2 else 0
for (relativePos in 0..last) {
val textPage = relativePage(relativePos)
textPage.lines.forEach { textLine ->
textLine.columns.forEach {
if (it is TextColumn) {
it.selected = false
if (clearSearchResult) {
it.isSearchResult = false
textPage.searchResult.remove(it)
}
}
}
}
}
selectStart.reset()
selectEnd.reset()
postInvalidate()
callBack.onCancelSelect()
}
fun getSelectedText(): String {
val textPos = TextPos(0, 0, 0)
val builder = StringBuilder()
for (relativePos in selectStart.relativePagePos..selectEnd.relativePagePos) {
val textPage = relativePage(relativePos)
textPos.relativePagePos = relativePos
textPage.lines.forEachIndexed { lineIndex, textLine ->
textPos.lineIndex = lineIndex
textLine.columns.forEachIndexed { charIndex, column ->
textPos.columnIndex = charIndex
val compareStart = textPos.compare(selectStart)
val compareEnd = textPos.compare(selectEnd)
if (column is TextColumn) {
when {
compareStart == -1 -> if (
selectStart.columnIndex == textLine.columns.size
&& charIndex == textLine.columns.lastIndex
) {
builder.append("\n")
}
compareEnd == 1 -> if (selectEnd.columnIndex == -1 && charIndex == 0) {
builder.append("\n")
}
compareStart >= 0 && compareEnd <= 0 -> {
builder.append(column.charData)
if (
textLine.isParagraphEnd
&& charIndex == textLine.columns.lastIndex
&& compareEnd != 0
) {
builder.append("\n")
}
}
}
}
}
}
}
return builder.toString()
}
fun createBookmark(): Bookmark? {
val page = relativePage(selectStart.relativePagePos)
page.getTextChapter().let { chapter ->
ReadBook.book?.let { book ->
return book.createBookMark().apply {
chapterIndex = page.chapterIndex
chapterPos = chapter.getReadLength(page.index) +
page.getPosByLineColumn(selectStart.lineIndex, selectStart.columnIndex)
chapterName = chapter.title
bookText = getSelectedText()
}
}
}
return null
}
private fun relativeOffset(relativePos: Int): Float {
return when (relativePos) {
0 -> pageOffset.toFloat()
1 -> pageOffset + textPage.height
else -> pageOffset + textPage.height + pageFactory.nextPage.height
}
}
fun relativePage(relativePos: Int): TextPage {
return when (relativePos) {
0 -> textPage
1 -> pageFactory.nextPage
else -> pageFactory.nextPlusPage
}
}
fun setAutoPager(autoPager: AutoPager?) {
this.autoPager = autoPager
}
fun setIsScroll(value: Boolean) {
isScroll = value
}
override fun canScrollVertically(direction: Int): Boolean {
return callBack.isScroll && pageFactory.hasNext()
}
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
longScreenshot = true
scrollY = 0
}
MotionEvent.ACTION_UP -> {
longScreenshot = false
scrollY = 0
}
}
return callBack.onLongScreenshotTouchEvent(event)
}
companion object {
private val renderThread by lazy {
Executors.newSingleThreadExecutor {
Thread(it, "TextPageRender")
}
}
private val cursorWidth = 24.dpToPx()
}
interface CallBack {
val headerHeight: Int
val pageFactory: TextPageFactory
val pageDelegate: PageDelegate?
val isScroll: Boolean
var isSelectingSearchResult: Boolean
fun upSelectedStart(x: Float, y: Float, top: Float)
fun upSelectedEnd(x: Float, y: Float)
fun onImageLongPress(x: Float, y: Float, src: String)
fun onCancelSelect()
fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean
}
}
@@ -0,0 +1,473 @@
package io.legato.kazusa.ui.book.read.page
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import io.legato.kazusa.R
import io.legato.kazusa.constant.AppConst.timeFormat
import io.legato.kazusa.data.entities.Bookmark
import io.legato.kazusa.databinding.ViewBookPageBinding
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.help.config.ReadTipConfig
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.ui.book.read.page.entities.TextLine
import io.legato.kazusa.ui.book.read.page.entities.TextPage
import io.legato.kazusa.ui.book.read.page.entities.TextPos
import io.legato.kazusa.ui.book.read.page.provider.ChapterProvider
import io.legato.kazusa.ui.widget.BatteryView
import io.legato.kazusa.utils.activity
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.applyStatusBarPadding
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.setTextIfNotEqual
import splitties.views.backgroundColor
import java.util.Date
/**
* 页面视图
*/
class PageView(context: Context) : FrameLayout(context) {
private val binding = ViewBookPageBinding.inflate(LayoutInflater.from(context), this, true)
private val readBookActivity get() = activity as? ReadBookActivity
private var battery = 100
private var tvTitle: BatteryView? = null
private var tvTime: BatteryView? = null
private var tvBattery: BatteryView? = null
private var tvBatteryP: BatteryView? = null
private var tvPage: BatteryView? = null
private var tvTotalProgress: BatteryView? = null
private var tvTotalProgress1: BatteryView? = null
private var tvPageAndTotal: BatteryView? = null
private var tvBookName: BatteryView? = null
private var tvTimeBattery: BatteryView? = null
private var tvTimeBatteryP: BatteryView? = null
private var isMainView = false
var isScroll = false
val headerHeight: Int
get() {
val h1 = if (binding.vwStatusBar.isGone) 0 else binding.vwStatusBar.height
val h2 = if (binding.llHeader.isGone) 0 else binding.llHeader.height
return h1 + h2
}
init {
if (!isInEditMode) {
upStyle()
binding.vwStatusBar.applyStatusBarPadding()
binding.vwNavigationBar.applyNavigationBarPadding()
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
upBg()
}
fun upStyle() = binding.run {
upTipStyle()
ReadBookConfig.let {
val textColor = it.textColor
val tipColor = with(ReadTipConfig) {
if (tipColor == 0) textColor else tipColor
}
val tipDividerColor = with(ReadTipConfig) {
when (tipDividerColor) {
-1 -> ContextCompat.getColor(context, R.color.divider)
0 -> textColor
else -> tipDividerColor
}
}
tvHeaderLeft.setColor(tipColor)
tvHeaderMiddle.setColor(tipColor)
tvHeaderRight.setColor(tipColor)
tvFooterLeft.setColor(tipColor)
tvFooterMiddle.setColor(tipColor)
tvFooterRight.setColor(tipColor)
vwTopDivider.backgroundColor = tipDividerColor
vwBottomDivider.backgroundColor = tipDividerColor
upStatusBar()
upNavigationBar()
llHeader.setPadding(
it.headerPaddingLeft.dpToPx(),
it.headerPaddingTop.dpToPx(),
it.headerPaddingRight.dpToPx(),
it.headerPaddingBottom.dpToPx()
)
llFooter.setPadding(
it.footerPaddingLeft.dpToPx(),
it.footerPaddingTop.dpToPx(),
it.footerPaddingRight.dpToPx(),
it.footerPaddingBottom.dpToPx()
)
vwTopDivider.gone(llHeader.isGone || !it.showHeaderLine)
vwBottomDivider.gone(llFooter.isGone || !it.showFooterLine)
}
upTime()
upBattery(battery)
}
/**
* 显示状态栏时隐藏header
*/
fun upStatusBar() = with(binding.vwStatusBar) {
// setPadding(paddingLeft, context.statusBarHeight, paddingRight, paddingBottom)
isGone = ReadBookConfig.hideStatusBar || readBookActivity?.isInMultiWindow == true
}
fun upNavigationBar() {
binding.vwNavigationBar.isGone = ReadBookConfig.hideNavigationBar
}
/**
* 更新阅读信息
*/
private fun upTipStyle() = binding.run {
tvHeaderLeft.tag = null
tvHeaderMiddle.tag = null
tvHeaderRight.tag = null
tvFooterLeft.tag = null
tvFooterMiddle.tag = null
tvFooterRight.tag = null
llHeader.isGone = when (ReadTipConfig.headerMode) {
1 -> false
2 -> true
else -> !ReadBookConfig.hideStatusBar
}
llFooter.isGone = when (ReadTipConfig.footerMode) {
1 -> true
else -> false
}
ReadTipConfig.apply {
tvHeaderLeft.isGone = tipHeaderLeft == none
tvHeaderRight.isGone = tipHeaderRight == none
tvHeaderMiddle.isGone = tipHeaderMiddle == none
tvFooterLeft.isInvisible = tipFooterLeft == none
tvFooterRight.isGone = tipFooterRight == none
tvFooterMiddle.isGone = tipFooterMiddle == none
}
tvTitle = getTipView(ReadTipConfig.chapterTitle)?.apply {
tag = ReadTipConfig.chapterTitle
isBattery = false
typeface = ChapterProvider.typeface
textSize = 12f
}
tvTime = getTipView(ReadTipConfig.time)?.apply {
tag = ReadTipConfig.time
isBattery = false
typeface = ChapterProvider.typeface
textSize = 12f
}
tvBattery = getTipView(ReadTipConfig.battery)?.apply {
tag = ReadTipConfig.battery
isBattery = true
textSize = 11f
}
tvPage = getTipView(ReadTipConfig.page)?.apply {
tag = ReadTipConfig.page
isBattery = false
typeface = ChapterProvider.typeface
textSize = 12f
}
tvTotalProgress = getTipView(ReadTipConfig.totalProgress)?.apply {
tag = ReadTipConfig.totalProgress
isBattery = false
typeface = ChapterProvider.typeface
textSize = 12f
}
tvTotalProgress1 = getTipView(ReadTipConfig.totalProgress1)?.apply {
tag = ReadTipConfig.totalProgress1
isBattery = false
typeface = ChapterProvider.typeface
textSize = 12f
}
tvPageAndTotal = getTipView(ReadTipConfig.pageAndTotal)?.apply {
tag = ReadTipConfig.pageAndTotal
isBattery = false
typeface = ChapterProvider.typeface
textSize = 12f
}
tvBookName = getTipView(ReadTipConfig.bookName)?.apply {
tag = ReadTipConfig.bookName
isBattery = false
typeface = ChapterProvider.typeface
textSize = 12f
}
tvTimeBattery = getTipView(ReadTipConfig.timeBattery)?.apply {
tag = ReadTipConfig.timeBattery
isBattery = true
typeface = ChapterProvider.typeface
textSize = 11f
}
tvBatteryP = getTipView(ReadTipConfig.batteryPercentage)?.apply {
tag = ReadTipConfig.batteryPercentage
isBattery = false
typeface = ChapterProvider.typeface
textSize = 12f
}
tvTimeBatteryP = getTipView(ReadTipConfig.timeBatteryPercentage)?.apply {
tag = ReadTipConfig.timeBatteryPercentage
isBattery = false
typeface = ChapterProvider.typeface
textSize = 12f
}
}
/**
* 获取信息视图
* @param tip 信息类型
*/
private fun getTipView(tip: Int): BatteryView? = binding.run {
return when (tip) {
ReadTipConfig.tipHeaderLeft -> tvHeaderLeft
ReadTipConfig.tipHeaderMiddle -> tvHeaderMiddle
ReadTipConfig.tipHeaderRight -> tvHeaderRight
ReadTipConfig.tipFooterLeft -> tvFooterLeft
ReadTipConfig.tipFooterMiddle -> tvFooterMiddle
ReadTipConfig.tipFooterRight -> tvFooterRight
else -> null
}
}
/**
* 更新背景
*/
fun upBg() {
binding.vwRoot.backgroundColor = ReadBookConfig.bgMeanColor
binding.vwBg.background = ReadBookConfig.bg
upBgAlpha()
}
/**
* 更新背景透明度
*/
fun upBgAlpha() {
binding.vwBg.alpha = ReadBookConfig.bgAlpha / 100f
}
/**
* 更新时间信息
*/
fun upTime() {
tvTime?.text = timeFormat.format(Date(System.currentTimeMillis()))
upTimeBattery()
}
/**
* 更新电池信息
*/
@SuppressLint("SetTextI18n")
fun upBattery(battery: Int) {
this.battery = battery
tvBattery?.setBattery(battery)
tvBatteryP?.text = "$battery%"
upTimeBattery()
}
/**
* 更新电池信息
*/
@SuppressLint("SetTextI18n")
private fun upTimeBattery() {
val time = timeFormat.format(Date(System.currentTimeMillis()))
tvTimeBattery?.setBattery(battery, time)
tvTimeBatteryP?.text = "$time $battery%"
}
/**
* 设置内容
*/
fun setContent(textPage: TextPage, resetPageOffset: Boolean = true) {
if (isMainView && !isScroll) {
setProgress(textPage)
} else {
post {
setProgress(textPage)
}
}
if (resetPageOffset) {
resetPageOffset()
}
binding.contentTextView.setContent(textPage)
}
fun invalidateContentView() {
binding.contentTextView.invalidate()
}
/**
* 设置无障碍文本
*/
fun setContentDescription(content: String) {
binding.contentTextView.contentDescription = content
}
/**
* 重置滚动位置
*/
fun resetPageOffset() {
binding.contentTextView.resetPageOffset()
}
/**
* 设置进度
*/
@SuppressLint("SetTextI18n")
fun setProgress(textPage: TextPage) = textPage.apply {
tvBookName?.setTextIfNotEqual(ReadBook.book?.name)
tvTitle?.setTextIfNotEqual(textPage.title)
val readProgress = readProgress
tvTotalProgress?.setTextIfNotEqual(readProgress)
tvTotalProgress1?.setTextIfNotEqual("${chapterIndex.plus(1)}/${chapterSize}")
if (textChapter.isCompleted) {
tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize $readProgress")
tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize")
} else {
val pageSizeInt = pageSize
val pageSize = if (pageSizeInt <= 0) "-" else "~$pageSizeInt"
tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize $readProgress")
tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize")
}
}
fun setAutoPager(autoPager: AutoPager?) {
binding.contentTextView.setAutoPager(autoPager)
}
fun submitRenderTask() {
binding.contentTextView.submitRenderTask()
}
fun setIsScroll(value: Boolean) {
isScroll = value
binding.contentTextView.setIsScroll(value)
}
/**
* 滚动事件
*/
fun scroll(offset: Int) {
binding.contentTextView.scroll(offset)
}
/**
* 更新是否开启选择功能
*/
fun upSelectAble(selectAble: Boolean) {
binding.contentTextView.selectAble = selectAble
}
/**
* 优先处理页面内单击
* @return true:已处理, false:未处理
*/
fun onClick(x: Float, y: Float): Boolean {
return binding.contentTextView.click(x, y - headerHeight)
}
/**
* 长按事件
*/
fun longPress(
x: Float, y: Float,
select: (textPos: TextPos) -> Unit,
) {
return binding.contentTextView.longPress(x, y - headerHeight, select)
}
/**
* 选择文本
*/
fun selectText(
x: Float, y: Float,
select: (textPos: TextPos) -> Unit,
) {
return binding.contentTextView.selectText(x, y - headerHeight, select)
}
fun getCurVisiblePage(): TextPage {
return binding.contentTextView.getCurVisiblePage()
}
fun getCurVisibleFirstLine(): TextLine? {
return binding.contentTextView.getCurVisibleFirstLine()
}
fun markAsMainView() {
isMainView = true
binding.contentTextView.isMainView = true
}
fun selectStartMove(x: Float, y: Float) {
binding.contentTextView.selectStartMove(x, y - headerHeight)
}
fun selectStartMoveIndex(
relativePagePos: Int,
lineIndex: Int,
charIndex: Int
) {
binding.contentTextView.selectStartMoveIndex(relativePagePos, lineIndex, charIndex)
}
fun selectStartMoveIndex(textPos: TextPos) {
binding.contentTextView.selectStartMoveIndex(textPos)
}
fun selectEndMove(x: Float, y: Float) {
binding.contentTextView.selectEndMove(x, y - headerHeight)
}
fun selectEndMoveIndex(
relativePagePos: Int,
lineIndex: Int,
charIndex: Int
) {
binding.contentTextView.selectEndMoveIndex(relativePagePos, lineIndex, charIndex)
}
fun selectEndMoveIndex(textPos: TextPos) {
binding.contentTextView.selectEndMoveIndex(textPos)
}
fun getReverseStartCursor(): Boolean {
return binding.contentTextView.reverseStartCursor
}
fun getReverseEndCursor(): Boolean {
return binding.contentTextView.reverseEndCursor
}
fun isLongScreenShot(): Boolean {
return binding.contentTextView.longScreenshot
}
fun resetReverseCursor() {
binding.contentTextView.resetReverseCursor()
}
fun cancelSelect(clearSearchResult: Boolean = false) {
binding.contentTextView.cancelSelect(clearSearchResult)
}
fun createBookmark(): Bookmark? {
return binding.contentTextView.createBookmark()
}
fun relativePage(relativePagePos: Int): TextPage {
return binding.contentTextView.relativePage(relativePagePos)
}
val textPage get() = binding.contentTextView.textPage
val selectedText: String get() = binding.contentTextView.getSelectedText()
val selectStartPos get() = binding.contentTextView.selectStart
}
@@ -0,0 +1,746 @@
package io.legato.kazusa.ui.book.read.page
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.RectF
import android.os.Build
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.ViewConfiguration
import android.view.WindowInsets
import android.widget.FrameLayout
import io.legato.kazusa.R
import io.legato.kazusa.constant.PageAnim
import io.legato.kazusa.data.entities.BookProgress
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.model.ReadAloud
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.ui.book.read.ContentEditDialog
import io.legato.kazusa.ui.book.read.page.api.DataSource
import io.legato.kazusa.ui.book.read.page.delegate.CoverPageDelegate
import io.legato.kazusa.ui.book.read.page.delegate.HorizontalPageDelegate
import io.legato.kazusa.ui.book.read.page.delegate.NoAnimPageDelegate
import io.legato.kazusa.ui.book.read.page.delegate.PageDelegate
import io.legato.kazusa.ui.book.read.page.delegate.ScrollPageDelegate
import io.legato.kazusa.ui.book.read.page.delegate.SimulationPageDelegate
import io.legato.kazusa.ui.book.read.page.delegate.SlidePageDelegate
import io.legato.kazusa.ui.book.read.page.entities.PageDirection
import io.legato.kazusa.ui.book.read.page.entities.TextChapter
import io.legato.kazusa.ui.book.read.page.entities.TextPage
import io.legato.kazusa.ui.book.read.page.entities.TextPos
import io.legato.kazusa.ui.book.read.page.entities.column.TextColumn
import io.legato.kazusa.ui.book.read.page.provider.ChapterProvider
import io.legato.kazusa.ui.book.read.page.provider.LayoutProgressListener
import io.legato.kazusa.ui.book.read.page.provider.TextPageFactory
import io.legato.kazusa.utils.activity
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.longToastOnUi
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.throttle
import java.text.BreakIterator
import java.util.Locale
import kotlin.math.abs
/**
* 阅读视图
*/
class ReadView(context: Context, attrs: AttributeSet) :
FrameLayout(context, attrs),
DataSource, LayoutProgressListener {
val callBack: CallBack get() = activity as CallBack
var pageFactory: TextPageFactory = TextPageFactory(this)
var pageDelegate: PageDelegate? = null
private set(value) {
field?.onDestroy()
field = null
field = value
upContent()
}
override var isScroll = false
val prevPage by lazy { PageView(context) }
val curPage by lazy { PageView(context) }
val nextPage by lazy { PageView(context) }
val defaultAnimationSpeed = 300
private var pressDown = false
private var isMove = false
//起始点
var startX: Float = 0f
var startY: Float = 0f
//上一个触碰点
var lastX: Float = 0f
var lastY: Float = 0f
//触碰点
var touchX: Float = 0f
var touchY: Float = 0f
//是否停止动画动作
var isAbortAnim = false
//长按
private var longPressed = false
private val longPressTimeout = 600L
private val longPressRunnable = Runnable {
longPressed = true
onLongPress()
}
var isTextSelected = false
private var pressOnTextSelected = false
private val initialTextPos = TextPos(0, 0, 0)
private val slopSquare by lazy { ViewConfiguration.get(context).scaledTouchSlop }
private var pageSlopSquare: Int = slopSquare
var pageSlopSquare2: Int = pageSlopSquare * pageSlopSquare
private val tlRect = RectF()
private val tcRect = RectF()
private val trRect = RectF()
private val mlRect = RectF()
private val mcRect = RectF()
private val mrRect = RectF()
private val blRect = RectF()
private val bcRect = RectF()
private val brRect = RectF()
private val boundary by lazy { BreakIterator.getWordInstance(Locale.getDefault()) }
private val upProgressThrottle = throttle(200) { post { upProgress() } }
val autoPager = AutoPager(this)
val isAutoPage get() = autoPager.isRunning
init {
addView(nextPage)
addView(curPage)
addView(prevPage)
prevPage.invisible()
nextPage.invisible()
curPage.markAsMainView()
if (!isInEditMode) {
upBg()
setWillNotDraw(false)
upPageAnim()
upPageSlopSquare()
}
}
private fun setRect9x() {
tlRect.set(0f, 0f, width * 0.33f, height * 0.33f)
tcRect.set(width * 0.33f, 0f, width * 0.66f, height * 0.33f)
trRect.set(width * 0.36f, 0f, width.toFloat(), height * 0.33f)
mlRect.set(0f, height * 0.33f, width * 0.33f, height * 0.66f)
mcRect.set(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f)
mrRect.set(width * 0.66f, height * 0.33f, width.toFloat(), height * 0.66f)
blRect.set(0f, height * 0.66f, width * 0.33f, height.toFloat())
bcRect.set(width * 0.33f, height * 0.66f, width * 0.66f, height.toFloat())
brRect.set(width * 0.66f, height * 0.66f, width.toFloat(), height.toFloat())
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
setRect9x()
prevPage.x = -w.toFloat()
pageDelegate?.setViewSize(w, h)
if (w > 0 && h > 0) {
upBg()
callBack.upSystemUiVisibility()
}
}
override fun dispatchDraw(canvas: Canvas) {
super.dispatchDraw(canvas)
pageDelegate?.onDraw(canvas)
autoPager.onDraw(canvas)
}
override fun computeScroll() {
pageDelegate?.computeScroll()
autoPager.computeOffset()
}
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
return true
}
/**
* 触摸事件
*/
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val insets = this.rootWindowInsets.getInsetsIgnoringVisibility(
WindowInsets.Type.mandatorySystemGestures()
)
val height = activity?.windowManager?.currentWindowMetrics?.bounds?.height()
if (height != null) {
if (event.y > height.minus(insets.bottom)
&& event.action != MotionEvent.ACTION_UP
&& event.action != MotionEvent.ACTION_CANCEL
) {
return true
}
}
}
//在多点触控时,事件不走ACTION_DOWN分支而产生的特殊事件处理
if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN || event.actionMasked == MotionEvent.ACTION_POINTER_UP) {
pageDelegate?.onTouch(event)
}
when (event.action) {
MotionEvent.ACTION_DOWN -> {
callBack.screenOffTimerStart()
if (isTextSelected) {
curPage.cancelSelect()
isTextSelected = false
pressOnTextSelected = true
} else {
pressOnTextSelected = false
}
longPressed = false
postDelayed(longPressRunnable, longPressTimeout)
pressDown = true
isMove = false
pageDelegate?.onTouch(event)
pageDelegate?.onDown()
setStartPoint(event.x, event.y, false)
}
MotionEvent.ACTION_MOVE -> {
if (!pressDown) return true
val absX = abs(startX - event.x)
val absY = abs(startY - event.y)
if (!isMove) {
isMove = absX > slopSquare || absY > slopSquare
}
if (isMove) {
longPressed = false
removeCallbacks(longPressRunnable)
if (isTextSelected) {
selectText(event.x, event.y)
} else {
pageDelegate?.onTouch(event)
}
}
}
MotionEvent.ACTION_UP -> {
callBack.screenOffTimerStart()
removeCallbacks(longPressRunnable)
if (!pressDown) return true
pressDown = false
if (!pageDelegate!!.isMoved && !isMove) {
if (!longPressed && !pressOnTextSelected) {
if (!curPage.onClick(startX, startY)) {
onSingleTapUp()
}
return true
}
}
if (isTextSelected) {
callBack.showTextActionMenu()
} else if (pageDelegate!!.isMoved) {
pageDelegate?.onTouch(event)
}
pressOnTextSelected = false
}
MotionEvent.ACTION_CANCEL -> {
removeCallbacks(longPressRunnable)
if (!pressDown) return true
pressDown = false
if (isTextSelected) {
callBack.showTextActionMenu()
} else if (pageDelegate!!.isMoved) {
pageDelegate?.onTouch(event)
}
pressOnTextSelected = false
autoPager.resume()
}
}
return true
}
fun cancelSelect(clearSearchResult: Boolean = false) {
if (isTextSelected) {
curPage.cancelSelect(clearSearchResult)
isTextSelected = false
}
}
/**
* 更新状态栏
*/
fun upStatusBar() {
curPage.upStatusBar()
prevPage.upStatusBar()
nextPage.upStatusBar()
}
/**
* 保存开始位置
*/
fun setStartPoint(x: Float, y: Float, invalidate: Boolean = true) {
startX = x
startY = y
lastX = x
lastY = y
touchX = x
touchY = y
if (invalidate) {
invalidate()
}
}
/**
* 保存当前位置
*/
fun setTouchPoint(x: Float, y: Float, invalidate: Boolean = true) {
lastX = touchX
lastY = touchY
touchX = x
touchY = y
if (invalidate) {
invalidate()
}
pageDelegate?.onScroll()
}
/**
* 长按选择
*/
private fun onLongPress() {
kotlin.runCatching {
curPage.longPress(startX, startY) { textPos: TextPos ->
isTextSelected = true
pressOnTextSelected = true
initialTextPos.upData(textPos)
val startPos = textPos.copy()
val endPos = textPos.copy()
val page = curPage.relativePage(textPos.relativePagePos)
val stringBuilder = StringBuilder()
var cIndex = textPos.columnIndex
var lineStart = textPos.lineIndex
var lineEnd = textPos.lineIndex
for (index in textPos.lineIndex - 1 downTo 0) {
val textLine = page.getLine(index)
if (textLine.isParagraphEnd) {
break
} else {
stringBuilder.insert(0, textLine.text)
lineStart -= 1
cIndex += textLine.charSize
}
}
for (index in textPos.lineIndex until page.lineSize) {
val textLine = page.getLine(index)
stringBuilder.append(textLine.text)
lineEnd += 1
if (textLine.isParagraphEnd) {
break
}
}
var start: Int
var end: Int
boundary.setText(stringBuilder.toString())
start = boundary.first()
end = boundary.next()
while (end != BreakIterator.DONE) {
if (cIndex in start until end) {
break
}
start = end
end = boundary.next()
}
kotlin.run {
var ci = 0
for (index in lineStart..lineEnd) {
val textLine = page.getLine(index)
for (j in textLine.columns.indices) {
if (ci == start) {
startPos.lineIndex = index
startPos.columnIndex = j
} else if (ci == end - 1) {
endPos.lineIndex = index
endPos.columnIndex = j
return@run
}
val column = textLine.getColumn(j)
if (column is TextColumn) {
ci += column.charData.length
} else {
ci++
}
}
}
}
curPage.selectStartMoveIndex(startPos)
curPage.selectEndMoveIndex(endPos)
}
}
}
/**
* 单击
*/
private fun onSingleTapUp() {
when {
isTextSelected -> Unit
mcRect.contains(startX, startY) -> if (!isAbortAnim) {
click(AppConfig.clickActionMC)
}
bcRect.contains(startX, startY) -> {
click(AppConfig.clickActionBC)
}
blRect.contains(startX, startY) -> {
click(AppConfig.clickActionBL)
}
brRect.contains(startX, startY) -> {
click(AppConfig.clickActionBR)
}
mlRect.contains(startX, startY) -> {
click(AppConfig.clickActionML)
}
mrRect.contains(startX, startY) -> {
click(AppConfig.clickActionMR)
}
tlRect.contains(startX, startY) -> {
click(AppConfig.clickActionTL)
}
tcRect.contains(startX, startY) -> {
click(AppConfig.clickActionTC)
}
trRect.contains(startX, startY) -> {
click(AppConfig.clickActionTR)
}
}
}
/**
* 点击
*/
private fun click(action: Int) {
when (action) {
0 -> {
pageDelegate?.dismissSnackBar()
callBack.showActionMenu()
}
1 -> pageDelegate?.nextPageByAnim(defaultAnimationSpeed)
2 -> pageDelegate?.prevPageByAnim(defaultAnimationSpeed)
3 -> ReadBook.moveToNextChapter(true)
4 -> ReadBook.moveToPrevChapter(upContent = true, toLast = false)
5 -> ReadAloud.prevParagraph(context)
6 -> ReadAloud.nextParagraph(context)
7 -> callBack.addBookmark()
8 -> activity?.showDialogFragment(ContentEditDialog())
9 -> callBack.changeReplaceRuleState()
10 -> callBack.openChapterList()
11 -> callBack.openSearchActivity(null)
12 -> ReadBook.syncProgress(
{ progress -> callBack.sureNewProgress(progress) },
{ context.longToastOnUi(context.getString(R.string.upload_book_success)) },
{ context.longToastOnUi(context.getString(R.string.sync_book_progress_success)) })
}
}
/**
* 选择文本
*/
private fun selectText(x: Float, y: Float) {
curPage.selectText(x, y) { textPos ->
val compare = initialTextPos.compare(textPos)
when {
compare > 0 -> {
curPage.selectStartMoveIndex(textPos)
curPage.selectEndMoveIndex(
initialTextPos.relativePagePos,
initialTextPos.lineIndex,
initialTextPos.columnIndex - 1
)
}
else -> {
curPage.selectStartMoveIndex(initialTextPos)
curPage.selectEndMoveIndex(textPos)
}
}
}
}
/**
* 销毁事件
*/
fun onDestroy() {
pageDelegate?.onDestroy()
curPage.cancelSelect()
invalidateTextPage()
}
/**
* 翻页动画完成后事件
* @param direction 翻页方向
*/
fun fillPage(direction: PageDirection): Boolean {
return when (direction) {
PageDirection.PREV -> {
pageFactory.moveToPrev(true)
}
PageDirection.NEXT -> {
pageFactory.moveToNext(true)
}
else -> false
}
}
/**
* 更新翻页动画
*/
fun upPageAnim(upRecorder: Boolean = false) {
isScroll = ReadBook.pageAnim() == 3
ChapterProvider.upLayout()
when (ReadBook.pageAnim()) {
PageAnim.coverPageAnim -> if (pageDelegate !is CoverPageDelegate) {
pageDelegate = CoverPageDelegate(this)
}
PageAnim.slidePageAnim -> if (pageDelegate !is SlidePageDelegate) {
pageDelegate = SlidePageDelegate(this)
}
PageAnim.simulationPageAnim -> if (pageDelegate !is SimulationPageDelegate) {
pageDelegate = SimulationPageDelegate(this)
}
PageAnim.scrollPageAnim -> if (pageDelegate !is ScrollPageDelegate) {
pageDelegate = ScrollPageDelegate(this)
}
else -> if (pageDelegate !is NoAnimPageDelegate) {
pageDelegate = NoAnimPageDelegate(this)
}
}
(pageDelegate as? ScrollPageDelegate)?.noAnim = AppConfig.noAnimScrollPage
if (upRecorder) {
(pageDelegate as? HorizontalPageDelegate)?.upRecorder()
autoPager.upRecorder()
}
pageDelegate?.setViewSize(width, height)
if (isScroll) {
curPage.setAutoPager(autoPager)
} else {
curPage.setAutoPager(null)
}
curPage.setIsScroll(isScroll)
}
/**
* 更新阅读内容
* @param relativePosition 相对位置 -1 上一页 0 当前页 1 下一页
* @param resetPageOffset 滚动阅读是是否重置位置
*/
override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
post {
curPage.setContentDescription(pageFactory.curPage.text)
}
if (isScroll && !isAutoPage) {
if (relativePosition == 0) {
curPage.setContent(pageFactory.curPage, resetPageOffset)
} else {
curPage.invalidateContentView()
}
} else {
when (relativePosition) {
-1 -> prevPage.setContent(pageFactory.prevPage)
1 -> nextPage.setContent(pageFactory.nextPage)
else -> {
curPage.setContent(pageFactory.curPage, resetPageOffset)
nextPage.setContent(pageFactory.nextPage)
prevPage.setContent(pageFactory.prevPage)
}
}
}
callBack.screenOffTimerStart()
}
private fun upProgress() {
curPage.setProgress(pageFactory.curPage)
}
/**
* 更新滑动距离
*/
fun upPageSlopSquare() {
val pageTouchSlop = AppConfig.pageTouchSlop
this.pageSlopSquare = if (pageTouchSlop == 0) slopSquare else pageTouchSlop
pageSlopSquare2 = this.pageSlopSquare * this.pageSlopSquare
}
/**
* 更新样式
*/
fun upStyle() {
ChapterProvider.upStyle()
curPage.upStyle()
prevPage.upStyle()
nextPage.upStyle()
}
/**
* 更新背景
*/
fun upBg() {
ReadBookConfig.upBg(width, height)
curPage.upBg()
prevPage.upBg()
nextPage.upBg()
}
/**
* 更新背景透明度
*/
fun upBgAlpha() {
curPage.upBgAlpha()
prevPage.upBgAlpha()
nextPage.upBgAlpha()
}
/**
* 更新时间信息
*/
fun upTime() {
curPage.upTime()
prevPage.upTime()
nextPage.upTime()
}
/**
* 更新电量信息
*/
fun upBattery(battery: Int) {
curPage.upBattery(battery)
prevPage.upBattery(battery)
nextPage.upBattery(battery)
}
/**
* 从选择位置开始朗读
*/
suspend fun aloudStartSelect() {
val selectStartPos = curPage.selectStartPos
var pagePos = selectStartPos.relativePagePos
val line = selectStartPos.lineIndex
val column = selectStartPos.columnIndex
while (pagePos > 0) {
if (!ReadBook.moveToNextPage()) {
ReadBook.moveToNextChapterAwait(false)
}
pagePos--
}
val startPos = curPage.textPage.getPosByLineColumn(line, column)
ReadBook.readAloud(startPos = startPos)
}
/**
* @return 选择的文本
*/
fun getSelectText(): String {
return curPage.selectedText
}
fun getCurVisiblePage(): TextPage {
return curPage.getCurVisiblePage()
}
fun getCurPagePosition(): Int {
return curPage.getCurVisibleFirstLine()?.pagePosition ?: 0
}
fun invalidateTextPage() {
if (!AppConfig.optimizeRender) {
return
}
pageFactory.run {
prevPage.invalidateAll()
curPage.invalidateAll()
nextPage.invalidateAll()
nextPlusPage.invalidateAll()
}
}
fun onScrollAnimStart() {
autoPager.pause()
}
fun onScrollAnimStop() {
autoPager.resume()
}
fun onPageChange() {
autoPager.reset()
submitRenderTask()
}
fun submitRenderTask() {
if (!AppConfig.optimizeRender) {
return
}
curPage.submitRenderTask()
}
fun isLongScreenShot(): Boolean {
return curPage.isLongScreenShot()
}
override fun onLayoutPageCompleted(index: Int, page: TextPage) {
upProgressThrottle.invoke()
}
override val currentChapter: TextChapter?
get() {
return if (callBack.isInitFinish) ReadBook.textChapter(0) else null
}
override val nextChapter: TextChapter?
get() {
return if (callBack.isInitFinish) ReadBook.textChapter(1) else null
}
override val prevChapter: TextChapter?
get() {
return if (callBack.isInitFinish) ReadBook.textChapter(-1) else null
}
override fun hasNextChapter(): Boolean {
return ReadBook.durChapterIndex < ReadBook.simulatedChapterSize - 1
}
override fun hasPrevChapter(): Boolean {
return ReadBook.durChapterIndex > 0
}
interface CallBack {
val isInitFinish: Boolean
fun showActionMenu()
fun screenOffTimerStart()
fun showTextActionMenu()
fun autoPageStop()
fun openChapterList()
fun addBookmark()
fun changeReplaceRuleState()
fun openSearchActivity(searchWord: String?)
fun upSystemUiVisibility()
fun sureNewProgress(progress: BookProgress)
}
}
@@ -0,0 +1,24 @@
package io.legato.kazusa.ui.book.read.page.api
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.ui.book.read.page.entities.TextChapter
interface DataSource {
val pageIndex: Int get() = ReadBook.durPageIndex
val currentChapter: TextChapter?
val nextChapter: TextChapter?
val prevChapter: TextChapter?
val isScroll: Boolean
fun hasNextChapter(): Boolean
fun hasPrevChapter(): Boolean
fun upContent(relativePosition: Int = 0, resetPageOffset: Boolean = true)
}
@@ -0,0 +1,26 @@
package io.legato.kazusa.ui.book.read.page.api
abstract class PageFactory<DATA>(protected val dataSource: DataSource) {
abstract fun moveToFirst()
abstract fun moveToLast()
abstract fun moveToNext(upContent: Boolean): Boolean
abstract fun moveToPrev(upContent: Boolean): Boolean
abstract val nextPage: DATA
abstract val prevPage: DATA
abstract val curPage: DATA
abstract val nextPlusPage: DATA
abstract fun hasNext(): Boolean
abstract fun hasPrev(): Boolean
abstract fun hasNextPlus(): Boolean
}
@@ -0,0 +1,117 @@
package io.legato.kazusa.ui.book.read.page.delegate
import android.graphics.Canvas
import android.graphics.drawable.GradientDrawable
import androidx.core.graphics.withClip
import androidx.core.graphics.withTranslation
import io.legato.kazusa.ui.book.read.page.ReadView
import io.legato.kazusa.ui.book.read.page.entities.PageDirection
import io.legato.kazusa.utils.screenshot
class CoverPageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) {
private val shadowDrawableR: GradientDrawable
init {
val shadowColors = intArrayOf(0x66111111, 0x00000000)
shadowDrawableR = GradientDrawable(
GradientDrawable.Orientation.LEFT_RIGHT, shadowColors
)
shadowDrawableR.gradientType = GradientDrawable.LINEAR_GRADIENT
}
override fun onDraw(canvas: Canvas) {
if (!isRunning) return
val offsetX = touchX - startX
if ((mDirection == PageDirection.NEXT && offsetX > 0)
|| (mDirection == PageDirection.PREV && offsetX < 0)
) {
return
}
val distanceX = if (offsetX > 0) offsetX - viewWidth else offsetX + viewWidth
if (mDirection == PageDirection.PREV) {
if (offsetX <= viewWidth) {
canvas.withTranslation(distanceX) {
prevRecorder.draw(canvas)
}
addShadow(distanceX, canvas)
} else {
prevRecorder.draw(canvas)
}
} else if (mDirection == PageDirection.NEXT) {
val width = nextRecorder.width.toFloat()
val height = nextRecorder.height.toFloat()
canvas.withClip(width + offsetX, 0f, width, height) {
nextRecorder.draw(this)
}
canvas.withTranslation(distanceX - viewWidth) {
curRecorder.draw(this)
}
addShadow(distanceX, canvas)
}
}
override fun setBitmap() {
when (mDirection) {
PageDirection.PREV -> {
prevPage.screenshot(prevRecorder)
}
PageDirection.NEXT -> {
nextPage.screenshot(nextRecorder)
curPage.screenshot(curRecorder)
}
else -> Unit
}
}
private fun addShadow(left: Float, canvas: Canvas) {
if (left == 0f) return
val dx = if (left < 0) {
left + viewWidth
} else {
left
}
canvas.withTranslation(dx) {
shadowDrawableR.draw(canvas)
}
}
override fun setViewSize(width: Int, height: Int) {
super.setViewSize(width, height)
shadowDrawableR.setBounds(0, 0, 30, viewHeight)
}
override fun onAnimStop() {
if (!isCancel) {
readView.fillPage(mDirection)
}
}
override fun onAnimStart(animationSpeed: Int) {
val distanceX: Float
when (mDirection) {
PageDirection.NEXT -> distanceX =
if (isCancel) {
var dis = viewWidth - startX + touchX
if (dis > viewWidth) {
dis = viewWidth.toFloat()
}
viewWidth - dis
} else {
-(touchX + (viewWidth - startX))
}
else -> distanceX =
if (isCancel) {
-(touchX - startX)
} else {
viewWidth - (touchX - startX)
}
}
startScroll(touchX.toInt(), 0, distanceX.toInt(), 0, animationSpeed)
}
}
Loaded 100 of 972 files, more files were not shown because too many files have changed in this diff. Show more