Rename
This commit is contained in:
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()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+205
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
+46
@@ -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
|
||||
|
||||
+64
@@ -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
Reference in new issue
Block a user