diff --git a/app/src/main/java/player/phonograph/mediastore/LyricsLoader.kt b/app/src/main/java/player/phonograph/mediastore/LyricsLoader.kt index 5b73f4ea4..e72c01683 100644 --- a/app/src/main/java/player/phonograph/mediastore/LyricsLoader.kt +++ b/app/src/main/java/player/phonograph/mediastore/LyricsLoader.kt @@ -12,7 +12,7 @@ import player.phonograph.App import player.phonograph.model.Song import player.phonograph.model.lyrics.AbsLyrics import player.phonograph.model.lyrics.LrcLyrics -import player.phonograph.model.lyrics.LyricsList +import player.phonograph.model.lyrics.LyricsInfo import player.phonograph.model.lyrics.LyricsSource import player.phonograph.model.lyrics.TextLyrics import player.phonograph.settings.Setting @@ -20,6 +20,8 @@ import player.phonograph.util.FileUtil import player.phonograph.util.debug import player.phonograph.util.permissions.hasStorageReadPermission import player.phonograph.util.reportError +import android.content.Context +import android.net.Uri import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -31,19 +33,19 @@ object LyricsLoader { private val backgroundCoroutine: CoroutineScope by lazy { CoroutineScope(Dispatchers.IO) } - suspend fun loadLyrics(songFile: File, song: Song): LyricsList { + suspend fun loadLyrics(songFile: File, song: Song): LyricsInfo { if (!Setting.instance.enableLyrics) { debug { Log.v(TAG, "Lyrics is off for ${song.title}") } - return LyricsList() + return LyricsInfo.EMPTY } if (!hasStorageReadPermission(App.instance)) { debug { Log.v(TAG, "No storage read permission to fetch lyrics for ${song.title}") } - return LyricsList() + return LyricsInfo.EMPTY } // embedded @@ -73,8 +75,10 @@ object LyricsLoader { addAll(vagueLyrics) } + val activated: Int = resultList.indexOfFirst { it is LrcLyrics } + // end of fetching - return LyricsList(resultList) + return LyricsInfo(song, resultList, activated) } private fun parseEmbedded( @@ -185,5 +189,15 @@ object LyricsLoader { } } + + fun parseFromUri(context: Context, uri: Uri): AbsLyrics? { + return context.contentResolver.openInputStream(uri)?.use { inputStream -> + inputStream.reader().use { + parse(it.readText(), LyricsSource.ManuallyLoaded()) + } + } + } + + private const val TAG = "LyricsLoader" } diff --git a/app/src/main/java/player/phonograph/misc/LyricsUpdater.kt b/app/src/main/java/player/phonograph/misc/LyricsUpdater.kt index 944256116..dc255a020 100644 --- a/app/src/main/java/player/phonograph/misc/LyricsUpdater.kt +++ b/app/src/main/java/player/phonograph/misc/LyricsUpdater.kt @@ -33,7 +33,7 @@ class LyricsUpdater(song: Song?) { if (!file.exists()) return@also runBlocking(exceptionHandler) { LyricsLoader.loadLyrics(file, song).let { - fetcher = LyricsFetcher(it.getLrcLyrics()) + fetcher = LyricsFetcher(it.firstLrcLyrics()) } } } diff --git a/app/src/main/java/player/phonograph/model/lyrics/LyricsCursor.kt b/app/src/main/java/player/phonograph/model/lyrics/LyricsCursor.kt deleted file mode 100644 index e5611ebff..000000000 --- a/app/src/main/java/player/phonograph/model/lyrics/LyricsCursor.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) 2022 chr_56 - */ - -package player.phonograph.model.lyrics - -class LyricsCursor(val l: LrcLyrics) { - - private var index: Int = 0 - fun setIndex(i: Int) { - index = i - } - - fun locate(index: Int): String { - return l.rawLyrics.valueAt(index) as String - } - - fun next(): String { - index++ - return l.rawLyrics.valueAt(index) as String - } - - fun previous(): String { - index-- - return l.rawLyrics.valueAt(index) as String - } - - fun first(): String { - return l.rawLyrics[0] as String - } - - fun moveToFirst() { - index = 0 - } - - fun last(): String { - return l.rawLyrics[l.rawLyrics.size()] as String - } - - fun moveToLast() { - index = l.rawLyrics.size() - } -} \ No newline at end of file diff --git a/app/src/main/java/player/phonograph/model/lyrics/LyricsInfo.kt b/app/src/main/java/player/phonograph/model/lyrics/LyricsInfo.kt new file mode 100644 index 000000000..6a9df62b6 --- /dev/null +++ b/app/src/main/java/player/phonograph/model/lyrics/LyricsInfo.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022~2023 chr_56 + */ + +package player.phonograph.model.lyrics + +import player.phonograph.model.Song +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + + +@Parcelize +data class LyricsInfo( + val linkedSong: Song, + private val lyricsList: ArrayList, + private val activatedLyricsNumber: Int, +) : Parcelable, List by lyricsList { + + + fun firstLrcLyrics(): LrcLyrics? = findFirst() + fun firstTextLyrics(): TextLyrics? = findFirst() + fun allLyricsFrom(source: LyricsSource): List = lyricsList.filter { it.source == source } + fun firstLyricsFrom(source: LyricsSource): AbsLyrics? = allLyricsFrom(source).firstOrNull() + fun availableSources(): Set = lyricsList.map { it.source }.toSet() + val activatedLyrics: AbsLyrics? get() = lyricsList.getOrNull(activatedLyricsNumber) + + fun isActive(index: Int) = index == activatedLyricsNumber + + private inline fun findFirst(): L? { + for (lyric in lyricsList) { + if (lyric is L) return lyric + } + return null + } + + fun createAmended(absLyrics: AbsLyrics): LyricsInfo = insect(this, absLyrics) + + fun replaceActivated(index: Int): LyricsInfo = setActive(this, index) + fun replaceActivated(lyrics: AbsLyrics): LyricsInfo? = setActive(this, lyrics) + + companion object { + val EMPTY = LyricsInfo(Song.EMPTY_SONG, ArrayList(), -1) + + private fun insect(old: LyricsInfo, newLyrics: AbsLyrics): LyricsInfo = + LyricsInfo( + old.linkedSong, + ArrayList(old.lyricsList).also { it.add(newLyrics) }, + old.activatedLyricsNumber + ) + + private fun setActive(old: LyricsInfo, index: Int): LyricsInfo = + LyricsInfo( + old.linkedSong, + old.lyricsList, + index + ) + + private fun setActive(old: LyricsInfo, lyrics: AbsLyrics): LyricsInfo? { + var index = -1 + for ((i, l) in old.lyricsList.withIndex()) { + if (l === lyrics) { + index = i + } + } + return if (index > -1) setActive(old, index) else null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/player/phonograph/model/lyrics/LyricsList.kt b/app/src/main/java/player/phonograph/model/lyrics/LyricsList.kt deleted file mode 100644 index 9f3e566fc..000000000 --- a/app/src/main/java/player/phonograph/model/lyrics/LyricsList.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (c) 2022 chr_56 - */ - -package player.phonograph.model.lyrics - -import android.os.Parcel -import android.os.Parcelable -import player.phonograph.model.Song -import player.phonograph.model.lyrics.LyricsSource.Companion.EMBEDDED -import player.phonograph.model.lyrics.LyricsSource.Companion.EXTERNAL_DECORATED -import player.phonograph.model.lyrics.LyricsSource.Companion.EXTERNAL_PRECISE - -data class LyricsList(val list: ArrayList = ArrayList(), val song: Song = Song.EMPTY_SONG) : Parcelable { - - fun isEmpty(): Boolean = list.isEmpty() - - fun getAvailableLyrics(): AbsLyrics? { - if (list.isNotEmpty()) return list[0] - return null - } - - fun getLrcLyrics(): LrcLyrics? { - if (!list.isNullOrEmpty()) { - for (l in list) { - if (l is LrcLyrics) return l - } - } - return null - } - fun getByType(type: LyricsSource): AbsLyrics? { - return when (type.type) { - EMBEDDED -> { - return list.firstOrNull { - it.source.type == EMBEDDED - } - } - EXTERNAL_PRECISE -> { - return list.firstOrNull { - it.source.type == EXTERNAL_PRECISE - } - } - EXTERNAL_DECORATED -> { - return list.firstOrNull { - it.source.type == EXTERNAL_DECORATED - } - } - else -> null - } - } - - fun getAvailableTypes(): Set? { - val all = list.map { it.source } - return if (all.isEmpty()) null else all.toSet() - } - - companion object { - @JvmField - val CREATOR = object : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): LyricsList { - return LyricsList(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } - } - - constructor(parcel: Parcel) : this( - parcel.createTypedArrayList(AbsLyrics.CREATOR) as ArrayList, - parcel.readParcelable(Song::class.java.classLoader)!! - ) - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeTypedList(list) - parcel.writeParcelable(song, flags) - } - override fun describeContents(): Int = 0 -} diff --git a/app/src/main/java/player/phonograph/model/lyrics/LyricsSource.kt b/app/src/main/java/player/phonograph/model/lyrics/LyricsSource.kt index f3d789c02..52ef98a81 100644 --- a/app/src/main/java/player/phonograph/model/lyrics/LyricsSource.kt +++ b/app/src/main/java/player/phonograph/model/lyrics/LyricsSource.kt @@ -4,6 +4,9 @@ package player.phonograph.model.lyrics +import player.phonograph.R +import android.content.Context + @JvmInline value class LyricsSource(val type: Int = UNKNOWN_SOURCE) { @Suppress("FunctionName") @@ -11,12 +14,21 @@ value class LyricsSource(val type: Int = UNKNOWN_SOURCE) { fun Embedded() = LyricsSource(EMBEDDED) fun ExternalPrecise() = LyricsSource(EXTERNAL_PRECISE) fun ExternalDecorated() = LyricsSource(EXTERNAL_DECORATED) + fun ManuallyLoaded() = LyricsSource(MANUALLY_LOADED) const val EMBEDDED = 0 const val EXTERNAL_PRECISE = 1 const val EXTERNAL_DECORATED = 2 + const val MANUALLY_LOADED = 4 fun Unknown() = LyricsSource(UNKNOWN_SOURCE) const val UNKNOWN_SOURCE = -1 } + + fun name(context: Context): String = when (type) { + EMBEDDED -> context.getString(R.string.embedded_lyrics) + EXTERNAL_DECORATED, EXTERNAL_PRECISE -> context.getString(R.string.external_lyrics) + MANUALLY_LOADED -> context.getString(R.string.loaded) + else -> "unknown" + } } \ No newline at end of file diff --git a/app/src/main/java/player/phonograph/ui/dialogs/LyricsDialog.kt b/app/src/main/java/player/phonograph/ui/dialogs/LyricsDialog.kt index d4ad5a1bf..d190fa7c4 100644 --- a/app/src/main/java/player/phonograph/ui/dialogs/LyricsDialog.kt +++ b/app/src/main/java/player/phonograph/ui/dialogs/LyricsDialog.kt @@ -4,18 +4,10 @@ package player.phonograph.ui.dialogs -import android.content.res.ColorStateList -import android.graphics.drawable.GradientDrawable -import android.os.Bundle -import android.os.Message -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.CompoundButton -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.google.android.material.chip.Chip import lib.phonograph.dialog.LargeDialog +import lib.phonograph.misc.IOpenFileStorageAccess +import lib.phonograph.misc.OpenDocumentContract import mt.pref.ThemeColor import mt.util.color.lightenColor import mt.util.color.primaryTextColor @@ -25,43 +17,46 @@ import player.phonograph.R import player.phonograph.adapter.LyricsAdapter import player.phonograph.databinding.DialogLyricsBinding import player.phonograph.misc.MusicProgressViewUpdateHelper -import player.phonograph.model.Song -import player.phonograph.model.lyrics.AbsLyrics import player.phonograph.model.lyrics.DEFAULT_TITLE import player.phonograph.model.lyrics.LrcLyrics -import player.phonograph.model.lyrics.LyricsList -import player.phonograph.model.lyrics.LyricsSource -import player.phonograph.ui.activities.base.AbsSlidingMusicPanelActivity -import player.phonograph.ui.fragments.player.AbsPlayerFragment +import player.phonograph.model.lyrics.LyricsInfo +import player.phonograph.ui.fragments.player.LyricsViewModel +import player.phonograph.util.reportError +import player.phonograph.util.theme.getTintedDrawable import player.phonograph.util.theme.nightMode +import player.phonograph.util.warning +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CompoundButton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** - * @author Karim Abou Zeid (kabouzeid) + * Large Dialog to show Lyrics. + * + * **MUST** be created from a view-model owner possessing [LyricsViewModel] */ class LyricsDialog : LargeDialog(), MusicProgressViewUpdateHelper.Callback { private var _viewBinding: DialogLyricsBinding? = null val binding: DialogLyricsBinding get() = _viewBinding!! - private lateinit var song: Song - private lateinit var lyricsList: LyricsList - private lateinit var lyricsDisplay: AbsLyrics - private val availableLyricTypes: MutableSet = HashSet(1) - private lateinit var lyricsAdapter: LyricsAdapter - private lateinit var linearLayoutManager: LinearLayoutManager - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + private val viewModel: LyricsViewModel by viewModels({ requireParentFragment() }) - requireArguments().let { - song = it.getParcelable(SONG)!! - lyricsList = it.getParcelable(LYRICS_PACK)!! - lyricsDisplay = it.getParcelable(CURRENT_LYRICS)!! - } - if (lyricsList.list.isEmpty()) { - throw IllegalStateException("No lyrics?!") - } - } + //region Fragment LifeCycle override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _viewBinding = DialogLyricsBinding.inflate(layoutInflater) @@ -69,196 +64,270 @@ class LyricsDialog : LargeDialog(), MusicProgressViewUpdateHelper.Callback { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - availableLyricTypes.addAll(lyricsList.getAvailableTypes().orEmpty()) + val lyricsInfo: LyricsInfo = viewModel.lyricsInfo.value - binding.title.text = if (lyricsDisplay.getTitle() != DEFAULT_TITLE) lyricsDisplay.getTitle() else song.title - initChip() - initRecycleView(lyricsDisplay) + updateChips(lyricsInfo) + updateTitle(lyricsInfo) + setupRecycleView(lyricsInfo) // corner requireDialog().window!!.setBackgroundDrawable( GradientDrawable().apply { this.cornerRadius = 0f - setColor(requireContext().theme.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.colorBackgroundFloating)).getColor(0, 0)) + setColor( + requireContext().theme.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.colorBackgroundFloating)) + .getColor(0, 0) + ) } ) binding.ok.setOnClickListener { requireDialog().dismiss() } binding.viewStub.setOnClickListener { requireDialog().dismiss() } - setupFollowing() + setupFollowing(lyricsInfo) // scrollingOffset = binding.root.height / 4 + observe() } - private fun initRecycleView(lyrics: AbsLyrics) { - linearLayoutManager = LinearLayoutManager(requireActivity(), RecyclerView.VERTICAL, false) - lyricsAdapter = LyricsAdapter(requireActivity(), lyrics.getLyricsTimeArray(), lyrics.getLyricsLineArray(), dialog) - binding.recyclerViewLyrics - .apply { - layoutManager = this@LyricsDialog.linearLayoutManager - adapter = this@LyricsDialog.lyricsAdapter - } + override fun onDestroyView() { + super.onDestroyView() + _viewBinding = null } - private fun createChip(text: String, index: Int, checked: Boolean = false, callback: (Chip, Int) -> Unit): Chip { - val chip = Chip(requireContext(), null, com.google.android.material.R.style.Widget_MaterialComponents_Chip_Choice) - chip.text = text - chip.isChecked = checked - chip.setTextColor(getChipTextColor(checked)) - chip.chipBackgroundColor = getChipBackgroundColor(checked) - chip.setOnClickListener { - callback(it as Chip, index) - } - return chip + override fun onDestroy() { + super.onDestroy() + _progressViewUpdateHelper?.destroy() + _progressViewUpdateHelper = null } + //endregion - private fun getChipBackgroundColor(checked: Boolean): ColorStateList { - return ColorStateList.valueOf( - if (checked) lightenColor(primaryColor) - else resources.getColor(R.color.defaultFooterColor, requireContext().theme) - ) - } - private fun getChipTextColor(checked: Boolean): ColorStateList { - return ColorStateList.valueOf( - if (checked) requireContext().secondaryTextColor(primaryColor) - else textColor - ) + private fun observe() { + lifecycleScope.launch { + viewModel.lyricsInfo.collect { info -> + withContext(Dispatchers.Main) { + updateTitle(info) + updateChips(info) + updateRecycleView(info) + updateFollowing(info, viewModel.requireLyricsFollowing.value) + } + } + } + lifecycleScope.launch { + viewModel.requireLyricsFollowing.collect { newValue -> + updateFollowing(viewModel.lyricsInfo.value, newValue) + } + } } + + //region Chip & Title + + private var chipSelected: Chip? = null - private fun initChip() { + private fun updateChips(info: LyricsInfo) { + binding.types.removeAllViews() binding.types.isSingleSelection = true - lyricsList.list.forEachIndexed { index, lyrics -> + for ((index, lyrics) in info.withIndex()) { + val requireCheck = info.isActive(index) val chip = createChip( - getLocalizedTypeName(lyrics.source), index, lyricsDisplay == lyrics, this::onChipClicked + lyrics.source.name(requireContext()), index, requireCheck, null, this::onChipClicked ) binding.types.addView(chip) - if (lyricsDisplay == lyrics) chipSelected = chip + if (requireCheck) chipSelected = chip } + binding.types.addView( + createChip( + getString(R.string.action_load), + -1, + false, + requireContext().getTintedDrawable(R.drawable.ic_add_white_24dp, Color.BLACK) + ) { _, _ -> manualLoadLyrics() } + ) // binding.types.isSelectionRequired = true } + + private fun createChip( + label: String, + index: Int, + checked: Boolean = false, + icon: Drawable? = null, + callback: (Chip, Int) -> Unit, + ) = + Chip(requireContext(), null, com.google.android.material.R.style.Widget_MaterialComponents_Chip_Choice) + .apply { + text = label + isChecked = checked + setTextColor(correctChipTextColor(checked)) + chipBackgroundColor = correctChipBackgroundColor(checked) + setOnClickListener { + callback(it as Chip, index) + } + if (icon != null) chipIcon = icon + } + private fun onChipClicked(chip: Chip, index: Int) { - if (lyricsList.list[index] == lyricsDisplay) return // do not change - switchLyrics(index) + val lyricsInfo = viewModel.lyricsInfo.value + if (lyricsInfo.isActive(index)) return // do not change + viewModel.forceReplaceLyrics(lyricsInfo[index]) chip.isChecked = true - chip.chipBackgroundColor = getChipBackgroundColor(true) - chip.setTextColor(getChipTextColor(true)) + chip.chipBackgroundColor = correctChipBackgroundColor(true) + chip.setTextColor(correctChipTextColor(true)) chipSelected?.isChecked = false - chipSelected?.chipBackgroundColor = getChipBackgroundColor(false) - chipSelected?.setTextColor(getChipTextColor(false)) + chipSelected?.chipBackgroundColor = correctChipBackgroundColor(false) + chipSelected?.setTextColor(correctChipTextColor(false)) chipSelected = chip } - private fun switchLyrics(index: Int) { - val lyrics = lyricsList.list[index] - lyricsDisplay = lyrics - lyricsAdapter.update(lyrics.getLyricsTimeArray(), lyrics.getLyricsLineArray()) - val fragment = activity?.supportFragmentManager?.findFragmentByTag(AbsSlidingMusicPanelActivity.NOW_PLAYING_FRAGMENT) - if (fragment != null && fragment is AbsPlayerFragment) { - fragment.handler.sendMessage( - Message.obtain(fragment.handler, AbsPlayerFragment.UPDATE_LYRICS).apply { - what = AbsPlayerFragment.UPDATE_LYRICS - data = Bundle().apply { putParcelable(AbsPlayerFragment.LYRICS, lyrics) } - } - ) + private fun updateTitle(info: LyricsInfo) { + val activated = info.activatedLyrics + binding.title.text = if (activated != null && activated.getTitle() != DEFAULT_TITLE) { + activated.getTitle() + } else { + info.linkedSong.title } } - private fun getLocalizedTypeName(t: LyricsSource): String = - when (t.type) { - LyricsSource.EMBEDDED -> getString(R.string.embedded_lyrics) - LyricsSource.EXTERNAL_DECORATED, LyricsSource.EXTERNAL_PRECISE -> getString(R.string.external_lyrics) - else -> "unknown" - } + //endregion - private val accentColor by lazy { ThemeColor.accentColor(App.instance) } - private val primaryColor by lazy { ThemeColor.primaryColor(App.instance) } - private val textColor by lazy { App.instance.primaryTextColor(App.instance.nightMode) } - private val backgroundCsl: ColorStateList by lazy { - ColorStateList( - arrayOf( - intArrayOf(android.R.attr.state_selected), - intArrayOf(android.R.attr.state_checked), - intArrayOf(), - ), - intArrayOf(lightenColor(primaryColor), - lightenColor(primaryColor), - resources.getColor(R.color.defaultFooterColor, requireContext().theme)) - ) + //region Manual Load + private fun manualLoadLyrics() { + val accessor = requireActivity() as? IOpenFileStorageAccess + if (accessor != null) { + accessor.openFileStorageAccessTool.launch( + OpenDocumentContract.Config(arrayOf("*/*")) + ) { uri -> viewModel.insert(requireContext(), uri) } + } else { + warning("Lyrics", "Can not open file from $activity") + } } - private val textColorCsl: ColorStateList by lazy { - ColorStateList( - arrayOf( - intArrayOf(android.R.attr.state_checked), - intArrayOf(), - ), - intArrayOf(requireContext().primaryTextColor(primaryColor), textColor) - ) + //endregion + + //region RecycleView + private lateinit var lyricsAdapter: LyricsAdapter + private lateinit var linearLayoutManager: LinearLayoutManager + private fun setupRecycleView(lyricsInfo: LyricsInfo) { + val lyrics = lyricsInfo.activatedLyrics ?: lyricsInfo.first() + linearLayoutManager = LinearLayoutManager(requireActivity(), RecyclerView.VERTICAL, false) + lyricsAdapter = + LyricsAdapter(requireActivity(), lyrics.getLyricsTimeArray(), lyrics.getLyricsLineArray(), dialog) + binding.recyclerViewLyrics + .apply { + layoutManager = this@LyricsDialog.linearLayoutManager + adapter = this@LyricsDialog.lyricsAdapter + } } - private fun setupFollowing() { + private fun updateRecycleView(info: LyricsInfo) { + val activated = info.activatedLyrics ?: info.first() + lyricsAdapter.update(activated.getLyricsTimeArray(), activated.getLyricsLineArray()) + } + //endregion + + + //region Scroll + + private var _progressViewUpdateHelper: MusicProgressViewUpdateHelper? = null + private val progressViewUpdateHelper: MusicProgressViewUpdateHelper get() = _progressViewUpdateHelper!! + + private fun setupFollowing(info: LyricsInfo) { binding.lyricsFollowing.apply { buttonTintList = backgroundCsl - setOnCheckedChangeListener { button: CompoundButton, b: Boolean -> - if (lyricsDisplay is LrcLyrics) { - if (_progressViewUpdateHelper == null) { - // init - _progressViewUpdateHelper = MusicProgressViewUpdateHelper(this@LyricsDialog, 500, 1000) + setOnCheckedChangeListener { button: CompoundButton, newValue: Boolean -> + viewModel.requireLyricsFollowing.update { + if (info.activatedLyrics is LrcLyrics) { + newValue + } else { + // text lyrics can not follow + button.isChecked = false + false } - if (b) progressViewUpdateHelper.start() else progressViewUpdateHelper.stop() - } else { - button.isChecked = false } } } } - override fun onDestroyView() { - super.onDestroyView() - _viewBinding = null - } + private fun updateFollowing(info: LyricsInfo, newValue: Boolean) { + if (info.activatedLyrics is LrcLyrics) { + // dispose ProgressViewUpdateHelper + _progressViewUpdateHelper?.destroy() + _progressViewUpdateHelper = null + _progressViewUpdateHelper = MusicProgressViewUpdateHelper(this@LyricsDialog, 500, 1000) - override fun onDestroy() { - super.onDestroy() - _progressViewUpdateHelper?.destroy() - _progressViewUpdateHelper = null + progressViewUpdateHelper.run { + if (newValue) start() else stop() + } + } } - private var _progressViewUpdateHelper: MusicProgressViewUpdateHelper? = null - private val progressViewUpdateHelper: MusicProgressViewUpdateHelper get() = _progressViewUpdateHelper!! private var scrollingOffset: Int = 0 override fun onUpdateProgressViews(progress: Int, total: Int) { - if (_viewBinding != null) { + if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { scrollingTo(progress) - } else { - _progressViewUpdateHelper?.destroy() - _progressViewUpdateHelper = null } } private fun scrollingTo(timeStamp: Int) { - if (lyricsDisplay is LrcLyrics) { - val line = (lyricsDisplay as LrcLyrics).getPosition(timeStamp) - linearLayoutManager.smoothScrollToPosition(binding.recyclerViewLyrics, null, line) -// linearLayoutManager.scrollToPositionWithOffset(line, scrollingOffset) + val activated = viewModel.lyricsInfo.value.activatedLyrics + val lrc = activated as? LrcLyrics + if (lrc != null) { + val line = lrc.getPosition(timeStamp) + if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) && line >= 0) { + try { + linearLayoutManager.smoothScrollToPosition(binding.recyclerViewLyrics, null, line) + //linearLayoutManager.scrollToPositionWithOffset(line, scrollingOffset) + } catch (e: Exception) { + reportError(e, "LyricsScroll", "Failed to scroll to $line") + } + } } } + //endregion - companion object { - private const val SONG = "song" - private const val LYRICS_PACK = "lyrics_pack" - private const val CURRENT_LYRICS = "current_lyrics" - - fun create(lyricsList: LyricsList, song: Song, currentLyrics: AbsLyrics): LyricsDialog = - LyricsDialog() - .apply { - arguments = Bundle().apply { - putParcelable(SONG, song) - putParcelable(LYRICS_PACK, lyricsList) - putParcelable(CURRENT_LYRICS, currentLyrics) - } - } + + //region Theme& Color + + private val accentColor by lazy { ThemeColor.accentColor(App.instance) } + private val primaryColor by lazy { ThemeColor.primaryColor(App.instance) } + private val textColor by lazy { App.instance.primaryTextColor(App.instance.nightMode) } + + + private fun correctChipBackgroundColor(checked: Boolean) = + ColorStateList.valueOf( + if (checked) lightenColor(primaryColor) + else resources.getColor(R.color.defaultFooterColor, requireContext().theme) + ) + + private fun correctChipTextColor(checked: Boolean) = + ColorStateList.valueOf( + if (checked) requireContext().secondaryTextColor(primaryColor) + else textColor + ) + + private val backgroundCsl: ColorStateList by lazy { + ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_selected), + intArrayOf(android.R.attr.state_checked), + intArrayOf(), + ), + intArrayOf( + lightenColor(primaryColor), + lightenColor(primaryColor), + resources.getColor(R.color.defaultFooterColor, requireContext().theme) + ) + ) } + private val textColorCsl: ColorStateList by lazy { + ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(), + ), + intArrayOf(requireContext().primaryTextColor(primaryColor), textColor) + ) + } + //endregion + } diff --git a/app/src/main/java/player/phonograph/ui/fragments/player/AbsPlayerFragment.kt b/app/src/main/java/player/phonograph/ui/fragments/player/AbsPlayerFragment.kt index 21fd79dac..e8555f366 100644 --- a/app/src/main/java/player/phonograph/ui/fragments/player/AbsPlayerFragment.kt +++ b/app/src/main/java/player/phonograph/ui/fragments/player/AbsPlayerFragment.kt @@ -4,6 +4,8 @@ import com.github.chr56.android.menu_dsl.attach import com.github.chr56.android.menu_dsl.menuItem import com.h6ah4i.android.widget.advrecyclerview.draggable.RecyclerViewDragDropManager import com.h6ah4i.android.widget.advrecyclerview.utils.WrapperAdapterUtils +import lib.phonograph.misc.IOpenFileStorageAccess +import lib.phonograph.misc.OpenDocumentContract import mt.pref.primaryColor import mt.tint.viewtint.setMenuColor import mt.util.color.toolbarIconColor @@ -25,6 +27,7 @@ import player.phonograph.ui.fragments.player.PlayerAlbumCoverFragment.Companion. import player.phonograph.mechanism.Favorite.toggleFavorite import player.phonograph.util.theme.getTintedDrawable import player.phonograph.util.NavigationUtil +import player.phonograph.util.warning import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar @@ -53,7 +56,7 @@ abstract class AbsPlayerFragment : protected lateinit var playerAlbumCoverFragment: PlayerAlbumCoverFragment protected lateinit var playbackControlsFragment: AbsPlayerControllerFragment protected val viewModel: PlayerFragmentViewModel by viewModels() - lateinit var handler: Handler + protected val lyricsViewModel: LyricsViewModel by viewModels() // recycle view protected lateinit var layoutManager: LinearLayoutManager @@ -67,26 +70,6 @@ abstract class AbsPlayerFragment : internal lateinit var impl: Impl - override fun onAttach(context: Context) { - super.onAttach(context) - handler = Handler(Looper.getMainLooper(), handlerCallbacks) - } - - private val handlerCallbacks = Handler.Callback { msg -> - if (msg.what == UPDATE_LYRICS) { - val lyrics = msg.data.get(LYRICS) as AbsLyrics - viewModel.forceReplaceLyrics(lyrics) - if (lyrics is LrcLyrics) { - playerAlbumCoverFragment.setLyrics(lyrics) - MusicPlayerRemote.musicService?.replaceLyrics(lyrics) - } else { - playerAlbumCoverFragment.clearLyrics() - MusicPlayerRemote.musicService?.replaceLyrics(null) - } - } - false - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initRecyclerView() @@ -123,7 +106,7 @@ abstract class AbsPlayerFragment : override fun onDestroyView() { favoriteMenuItem = null - viewModel.lyricsMenuItem = null + lyricsMenuItem = null super.onDestroyView() _recyclerViewDragDropManager?.let { recyclerViewDragDropManager.release() @@ -137,14 +120,17 @@ abstract class AbsPlayerFragment : private fun addLyricsObserver() { lifecycleScope.launch(viewModel.exceptionHandler) { - viewModel.currentLyrics.collect { lyrics -> + lyricsViewModel.lyricsInfo.collect { lyricsList -> withContext(Dispatchers.Main) { - if (lyrics != null && lyrics is LrcLyrics) { - playerAlbumCoverFragment.setLyrics(lyrics) + val activated = lyricsList.activatedLyrics + if (lyricsList.isNotEmpty() && activated is LrcLyrics) { + playerAlbumCoverFragment.setLyrics(activated) + MusicPlayerRemote.musicService?.replaceLyrics(activated) } else { playerAlbumCoverFragment.clearLyrics() + MusicPlayerRemote.musicService?.replaceLyrics(null) } - viewModel.lyricsMenuItem?.isVisible = (lyrics != null) + lyricsMenuItem?.isVisible = lyricsList.isNotEmpty() } } } @@ -153,6 +139,9 @@ abstract class AbsPlayerFragment : // // Toolbar // + + private var lyricsMenuItem: MenuItem? = null + private fun initToolbar() { playerToolbar = getImplToolbar() playerToolbar.setNavigationIcon(R.drawable.ic_close_white_24dp) @@ -167,18 +156,14 @@ abstract class AbsPlayerFragment : visible = false itemId = R.id.action_show_lyrics onClick { - val lyricsPack = viewModel.lyricsList.value - if (!lyricsPack.isEmpty()) { - LyricsDialog.create( - lyricsPack, - viewModel.currentSong.value, - viewModel.currentLyrics.value ?: lyricsPack.getAvailableLyrics()!! - ).show(childFragmentManager, "LYRICS") + val lyricsList = lyricsViewModel.lyricsInfo.value + if (lyricsList.isNotEmpty()) { + LyricsDialog().show(childFragmentManager, "LYRICS") } true } - }.apply { - viewModel.lyricsMenuItem = this + }.also { + lyricsMenuItem = it } menuItem(getString(R.string.action_add_to_favorites)) { @@ -219,6 +204,21 @@ abstract class AbsPlayerFragment : true } } + menuItem { + title = getString(R.string.action_choose_lyrics) + showAsActionFlag = MenuItem.SHOW_AS_ACTION_NEVER + onClick { + val accessor = requireActivity() as? IOpenFileStorageAccess + if (accessor != null) { + accessor.openFileStorageAccessTool.launch( + OpenDocumentContract.Config(arrayOf("*/*")) + ) { uri -> lyricsViewModel.insert(requireContext(), uri) } + } else { + warning("Lyrics", "Can not open file from $activity") + } + true + } + } menuItem { title = getString(R.string.action_sleep_timer) showAsActionFlag = MenuItem.SHOW_AS_ACTION_NEVER @@ -351,14 +351,14 @@ abstract class AbsPlayerFragment : updateQueue() updateCurrentSong() viewModel.updateFavoriteState(MusicPlayerRemote.currentSong, context) - viewModel.loadLyrics(MusicPlayerRemote.currentSong) + lyricsViewModel.loadLyrics(MusicPlayerRemote.currentSong) } override fun onPlayingMetaChanged() { updateCurrentSong() updateQueuePosition() viewModel.updateFavoriteState(MusicPlayerRemote.currentSong, context) - viewModel.loadLyrics(MusicPlayerRemote.currentSong) + lyricsViewModel.loadLyrics(MusicPlayerRemote.currentSong) } override fun onQueueChanged() { @@ -404,11 +404,6 @@ abstract class AbsPlayerFragment : abstract override fun onToolbarToggled() - companion object { - const val UPDATE_LYRICS = 1001 - const val LYRICS = "lyrics" - } - internal interface Impl { fun init() fun setUpPanelAndAlbumCoverHeight() diff --git a/app/src/main/java/player/phonograph/ui/fragments/player/LyricsViewModel.kt b/app/src/main/java/player/phonograph/ui/fragments/player/LyricsViewModel.kt new file mode 100644 index 000000000..ba31a6ca1 --- /dev/null +++ b/app/src/main/java/player/phonograph/ui/fragments/player/LyricsViewModel.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2022~2023 chr_56 + */ + +package player.phonograph.ui.fragments.player + +import player.phonograph.mediastore.LyricsLoader +import player.phonograph.model.Song +import player.phonograph.model.lyrics.AbsLyrics +import player.phonograph.model.lyrics.LyricsInfo +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import android.content.Context +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File + +class LyricsViewModel : ViewModel() { + + private var _lyricsInfo: MutableStateFlow = MutableStateFlow(LyricsInfo.EMPTY) + val lyricsInfo get() = _lyricsInfo.asStateFlow() + + fun forceReplaceLyrics(lyrics: AbsLyrics) { + viewModelScope.launch { + val new = _lyricsInfo.value.replaceActivated(lyrics) + if (new != null) _lyricsInfo.emit(new) + } + } + + private var loadLyricsJob: Job? = null + fun loadLyrics(song: Song) { + // cancel old song's lyrics after switching + loadLyricsJob?.cancel() + // load new lyrics + loadLyricsJob = viewModelScope.launch { + if (song == Song.EMPTY_SONG) { + _lyricsInfo.emit(LyricsInfo.EMPTY) + } else { + val newLyrics = LyricsLoader.loadLyrics(File(song.data), song) + _lyricsInfo.emit(newLyrics) + } + } + } + + fun insert(context: Context, uri: Uri?) { + if (uri != null) { + viewModelScope.launch(Dispatchers.IO) { + val lyrics = LyricsLoader.parseFromUri(context, uri) + if (lyrics != null) { + val info = _lyricsInfo.value.createAmended(lyrics).replaceActivated(lyrics)!! + _lyricsInfo.emit(info) + } + } + } + } + + // for LyricsDialog + val requireLyricsFollowing = MutableStateFlow(false) +} \ No newline at end of file diff --git a/app/src/main/java/player/phonograph/ui/fragments/player/PlayerFragmentViewModel.kt b/app/src/main/java/player/phonograph/ui/fragments/player/PlayerFragmentViewModel.kt index 02a32c510..e55902795 100644 --- a/app/src/main/java/player/phonograph/ui/fragments/player/PlayerFragmentViewModel.kt +++ b/app/src/main/java/player/phonograph/ui/fragments/player/PlayerFragmentViewModel.kt @@ -5,23 +5,18 @@ package player.phonograph.ui.fragments.player import player.phonograph.App -import player.phonograph.mediastore.LyricsLoader -import player.phonograph.model.Song -import player.phonograph.model.lyrics.AbsLyrics -import player.phonograph.model.lyrics.LyricsList import player.phonograph.mechanism.Favorite.isFavorite +import player.phonograph.model.Song import player.phonograph.util.reportError import androidx.annotation.ColorInt import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import android.content.Context -import android.view.MenuItem import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.io.File class PlayerFragmentViewModel : ViewModel() { @@ -35,41 +30,10 @@ class PlayerFragmentViewModel : ViewModel() { fun updateCurrentSong(song: Song, context: Context?) { viewModelScope.launch { _currentSong.emit(song) - loadLyrics(song) updateFavoriteState(song, context) } } - var lyricsMenuItem: MenuItem? = null - - private var _lyricsList: MutableStateFlow = MutableStateFlow(LyricsList()) - val lyricsList get() = _lyricsList.asStateFlow() - - private var _currentLyrics: MutableStateFlow = MutableStateFlow(null) - val currentLyrics get() = _currentLyrics.asStateFlow() - - fun forceReplaceLyrics(lyrics: AbsLyrics) { - viewModelScope.launch { - _currentLyrics.emit(lyrics) - } - } - - private var loadLyricsJob: Job? = null - fun loadLyrics(song: Song) { - // cancel old song's lyrics after switching - loadLyricsJob?.cancel() - _currentLyrics.value = null - _lyricsList.value = LyricsList() - lyricsMenuItem?.isVisible = false - // load new lyrics - loadLyricsJob = viewModelScope.launch { - if (song == Song.EMPTY_SONG) return@launch - val newLyrics = LyricsLoader.loadLyrics(File(song.data), song) - _lyricsList.emit(newLyrics) - _currentLyrics.emit(newLyrics.getAvailableLyrics()) - } - } - private var _favoriteState: MutableStateFlow> = MutableStateFlow(Song.EMPTY_SONG to false) val favoriteState = _favoriteState.asStateFlow() diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4f7406a88..cb4809242 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -400,4 +400,7 @@ 导出 %1$s 重启应用 加载中… + 选择歌词… + 加载的 + 加载 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 5e40215e0..7bdee8a1b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -400,4 +400,7 @@ 匯出 %1$s 重慶程式 加載中… + 選擇歌詞… + 加載的 + 加載 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b73e1e4f..b9fe40f64 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -410,4 +410,7 @@ Export %1$s Reboot App Process… + Choose Lyrics… + loaded + Load