From 10c5be4d94fa942053dab8579146768ecd85f4f3 Mon Sep 17 00:00:00 2001 From: Henry-ZHR Date: Fri, 20 Sep 2024 11:24:11 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=AD=8C=E8=AF=8D?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81=EF=BC=88=E9=83=A8=E5=88=86?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 2 + app/src/main/java/remix/myplayer/App.kt | 14 - .../remix/myplayer/bean/misc/LyricPriority.kt | 48 - .../remix/myplayer/helper/LyricsHelper.kt | 29 + .../myplayer/helper/MusicServiceRemote.kt | 5 - .../remix/myplayer/lyric/DefaultLrcParser.kt | 149 --- .../java/remix/myplayer/lyric/ILrcParser.kt | 16 - .../java/remix/myplayer/lyric/ILrcView.kt | 35 - .../main/java/remix/myplayer/lyric/LrcView.kt | 767 -------------- .../java/remix/myplayer/lyric/LyricFetcher.kt | 129 --- .../remix/myplayer/lyric/LyricSearcher.kt | 962 +++++++++--------- .../myplayer/lyric/UpdateLyricThread.java | 138 --- .../java/remix/myplayer/lyric/bean/LrcRow.kt | 168 --- .../myplayer/lyric/bean/LyricRowWrapper.kt | 25 - .../java/remix/myplayer/lyrics/LrcParser.kt | 129 +++ .../java/remix/myplayer/lyrics/LyricsLine.kt | 29 + .../remix/myplayer/lyrics/LyricsSearcher.kt | 200 ++++ .../myplayer/lyrics/PerWordLyricsLine.kt | 57 ++ .../remix/myplayer/lyrics/SimpleLyricsLine.kt | 14 + .../main/java/remix/myplayer/lyrics/Word.kt | 9 + .../lyrics/provider/EmbeddedProvider.kt | 28 + .../lyrics/provider/ILyricsProvider.kt | 16 + .../lyrics/provider/IgnoredProvider.kt | 17 + .../myplayer/lyrics/provider/StubProvider.kt | 18 + .../myplayer/lyrics/provider/UriProvider.kt | 37 + .../remix/myplayer/misc/cache/DiskCache.java | 34 - .../myplayer/misc/cache/DiskLruCache.java | 947 ----------------- .../myplayer/misc/menu/AudioPopupListener.kt | 99 +- .../java/remix/myplayer/service/Command.java | 2 +- .../remix/myplayer/service/MusicService.kt | 221 ++-- .../myplayer/theme/MaterialTintHelper.kt | 18 + .../java/remix/myplayer/theme/ThemeStore.kt | 18 - .../main/java/remix/myplayer/ui/ViewCommon.kt | 40 +- .../ui/activity/LockScreenActivity.kt | 58 +- .../myplayer/ui/activity/PlayerActivity.kt | 87 +- .../myplayer/ui/activity/SettingActivity.kt | 11 +- .../ui/adapter/DesktopLyricColorAdapter.kt | 102 -- .../ui/adapter/LyricPriorityAdapter.kt | 58 -- .../myplayer/ui/adapter/LyricsOrderAdapter.kt | 29 + .../myplayer/ui/dialog/LyricPriorityDialog.kt | 79 -- .../myplayer/ui/dialog/LyricsOrderDialog.kt | 49 + .../myplayer/ui/fragment/LyricFragment.kt | 223 ---- .../myplayer/ui/fragment/LyricsFragment.kt | 138 +++ .../ui/misc/PartialForegroundColorSpan.kt | 61 ++ .../remix/myplayer/ui/widget/LyricsView.kt | 248 +++++ .../ui/widget/ResponsiveScrollView.kt | 30 + .../ui/widget/SingleLineLyricsView.kt | 164 +++ .../widget/desktop/DesktopLyricTextView.java | 146 --- .../ui/widget/desktop/DesktopLyricView.kt | 520 ---------- .../ui/widget/desktop/DesktopLyricsView.kt | 399 ++++++++ .../main/java/remix/myplayer/util/SPUtil.java | 73 +- .../drawable-xxhdpi/icon_lyric_add_offset.png | Bin 670 -> 0 bytes .../icon_lyric_add_offset_second.png | Bin 1236 -> 0 bytes .../icon_lyric_reduce_offset.png | Bin 677 -> 0 bytes .../icon_lyric_reduce_offset_second.png | Bin 1165 -> 0 bytes .../res/drawable-xxhdpi/icon_lyric_reset.png | Bin 1507 -> 0 bytes .../drawable-xxhdpi/icon_lyric_timeline.png | Bin 1192 -> 0 bytes .../icon_lyric_add_offset.png | Bin 635 -> 0 bytes .../icon_lyric_add_offset_second.png | Bin 1506 -> 0 bytes .../icon_lyric_reduce_offset.png | Bin 590 -> 0 bytes .../icon_lyric_reduce_offset_second.png | Bin 1451 -> 0 bytes .../res/drawable-xxxhdpi/icon_lyric_reset.png | Bin 1702 -> 0 bytes .../main/res/drawable/ic_drag_handle_24dp.xml | 9 + app/src/main/res/drawable/ic_lock_24dp.xml | 10 + .../main/res/drawable/ic_looks_one_24dp.xml | 10 + .../main/res/drawable/ic_looks_two_24dp.xml | 10 + app/src/main/res/drawable/ic_palette_24dp.xml | 10 + app/src/main/res/drawable/ic_refresh_24dp.xml | 9 + .../main/res/drawable/ic_settings_24dp.xml | 10 + app/src/main/res/drawable/ic_stat_1_24dp.xml | 9 + .../res/drawable/ic_stat_minus_1_24dp.xml | 9 + .../res/drawable/ic_text_decrease_24dp.xml | 10 + .../res/drawable/ic_text_increase_24dp.xml | 10 + .../dialog_desktop_lyrics_color_settings.xml | 345 +++++++ app/src/main/res/layout/fragment_lrc.xml | 126 ++- .../main/res/layout/item_float_lrc_color.xml | 25 - .../main/res/layout/item_lyric_priority.xml | 19 - app/src/main/res/layout/item_lyrics_order.xml | 37 + .../main/res/layout/layout_desktop_lyric.xml | 236 ----- .../main/res/layout/layout_desktop_lyrics.xml | 210 ++++ .../layout/layout_desktop_lyrics_lines.xml | 39 + .../main/res/layout/layout_lyrics_view.xml | 68 ++ app/src/main/res/values-ja-rJP/strings.xml | 4 +- app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values-zh-rHK/strings.xml | 4 +- app/src/main/res/values-zh-rTW/strings.xml | 4 +- app/src/main/res/values/colors.xml | 6 +- app/src/main/res/values/dimens.xml | 27 +- app/src/main/res/values/strings.xml | 18 +- gradle/libs.versions.toml | 6 +- test-res/test.lrc | 20 + 91 files changed, 3385 insertions(+), 4814 deletions(-) delete mode 100644 app/src/main/java/remix/myplayer/bean/misc/LyricPriority.kt create mode 100644 app/src/main/java/remix/myplayer/helper/LyricsHelper.kt delete mode 100644 app/src/main/java/remix/myplayer/lyric/DefaultLrcParser.kt delete mode 100644 app/src/main/java/remix/myplayer/lyric/ILrcParser.kt delete mode 100644 app/src/main/java/remix/myplayer/lyric/ILrcView.kt delete mode 100644 app/src/main/java/remix/myplayer/lyric/LrcView.kt delete mode 100644 app/src/main/java/remix/myplayer/lyric/LyricFetcher.kt delete mode 100644 app/src/main/java/remix/myplayer/lyric/UpdateLyricThread.java delete mode 100644 app/src/main/java/remix/myplayer/lyric/bean/LrcRow.kt delete mode 100644 app/src/main/java/remix/myplayer/lyric/bean/LyricRowWrapper.kt create mode 100644 app/src/main/java/remix/myplayer/lyrics/LrcParser.kt create mode 100644 app/src/main/java/remix/myplayer/lyrics/LyricsLine.kt create mode 100644 app/src/main/java/remix/myplayer/lyrics/LyricsSearcher.kt create mode 100644 app/src/main/java/remix/myplayer/lyrics/PerWordLyricsLine.kt create mode 100644 app/src/main/java/remix/myplayer/lyrics/SimpleLyricsLine.kt create mode 100644 app/src/main/java/remix/myplayer/lyrics/Word.kt create mode 100644 app/src/main/java/remix/myplayer/lyrics/provider/EmbeddedProvider.kt create mode 100644 app/src/main/java/remix/myplayer/lyrics/provider/ILyricsProvider.kt create mode 100644 app/src/main/java/remix/myplayer/lyrics/provider/IgnoredProvider.kt create mode 100644 app/src/main/java/remix/myplayer/lyrics/provider/StubProvider.kt create mode 100644 app/src/main/java/remix/myplayer/lyrics/provider/UriProvider.kt delete mode 100644 app/src/main/java/remix/myplayer/misc/cache/DiskLruCache.java create mode 100644 app/src/main/java/remix/myplayer/theme/MaterialTintHelper.kt delete mode 100644 app/src/main/java/remix/myplayer/ui/adapter/DesktopLyricColorAdapter.kt delete mode 100644 app/src/main/java/remix/myplayer/ui/adapter/LyricPriorityAdapter.kt create mode 100644 app/src/main/java/remix/myplayer/ui/adapter/LyricsOrderAdapter.kt delete mode 100644 app/src/main/java/remix/myplayer/ui/dialog/LyricPriorityDialog.kt create mode 100644 app/src/main/java/remix/myplayer/ui/dialog/LyricsOrderDialog.kt delete mode 100644 app/src/main/java/remix/myplayer/ui/fragment/LyricFragment.kt create mode 100644 app/src/main/java/remix/myplayer/ui/fragment/LyricsFragment.kt create mode 100644 app/src/main/java/remix/myplayer/ui/misc/PartialForegroundColorSpan.kt create mode 100644 app/src/main/java/remix/myplayer/ui/widget/LyricsView.kt create mode 100644 app/src/main/java/remix/myplayer/ui/widget/ResponsiveScrollView.kt create mode 100644 app/src/main/java/remix/myplayer/ui/widget/SingleLineLyricsView.kt delete mode 100644 app/src/main/java/remix/myplayer/ui/widget/desktop/DesktopLyricTextView.java delete mode 100644 app/src/main/java/remix/myplayer/ui/widget/desktop/DesktopLyricView.kt create mode 100644 app/src/main/java/remix/myplayer/ui/widget/desktop/DesktopLyricsView.kt delete mode 100644 app/src/main/res/drawable-xxhdpi/icon_lyric_add_offset.png delete mode 100644 app/src/main/res/drawable-xxhdpi/icon_lyric_add_offset_second.png delete mode 100644 app/src/main/res/drawable-xxhdpi/icon_lyric_reduce_offset.png delete mode 100644 app/src/main/res/drawable-xxhdpi/icon_lyric_reduce_offset_second.png delete mode 100644 app/src/main/res/drawable-xxhdpi/icon_lyric_reset.png delete mode 100644 app/src/main/res/drawable-xxhdpi/icon_lyric_timeline.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/icon_lyric_add_offset.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/icon_lyric_add_offset_second.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/icon_lyric_reduce_offset.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/icon_lyric_reduce_offset_second.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/icon_lyric_reset.png create mode 100644 app/src/main/res/drawable/ic_drag_handle_24dp.xml create mode 100644 app/src/main/res/drawable/ic_lock_24dp.xml create mode 100644 app/src/main/res/drawable/ic_looks_one_24dp.xml create mode 100644 app/src/main/res/drawable/ic_looks_two_24dp.xml create mode 100644 app/src/main/res/drawable/ic_palette_24dp.xml create mode 100644 app/src/main/res/drawable/ic_refresh_24dp.xml create mode 100644 app/src/main/res/drawable/ic_settings_24dp.xml create mode 100644 app/src/main/res/drawable/ic_stat_1_24dp.xml create mode 100644 app/src/main/res/drawable/ic_stat_minus_1_24dp.xml create mode 100644 app/src/main/res/drawable/ic_text_decrease_24dp.xml create mode 100644 app/src/main/res/drawable/ic_text_increase_24dp.xml create mode 100644 app/src/main/res/layout/dialog_desktop_lyrics_color_settings.xml delete mode 100644 app/src/main/res/layout/item_float_lrc_color.xml delete mode 100644 app/src/main/res/layout/item_lyric_priority.xml create mode 100644 app/src/main/res/layout/item_lyrics_order.xml delete mode 100644 app/src/main/res/layout/layout_desktop_lyric.xml create mode 100644 app/src/main/res/layout/layout_desktop_lyrics.xml create mode 100644 app/src/main/res/layout/layout_desktop_lyrics_lines.xml create mode 100644 app/src/main/res/layout/layout_lyrics_view.xml create mode 100644 test-res/test.lrc diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f835f67a8..f367abc91 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.application) alias(libs.plugins.kotlin) alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.room) } @@ -214,6 +215,7 @@ androidComponents { dependencies { implementation(libs.kotlinx.coroutines) + implementation(libs.kotlinx.serialization.json) implementation(libs.appcompat) implementation(libs.cardview) diff --git a/app/src/main/java/remix/myplayer/App.kt b/app/src/main/java/remix/myplayer/App.kt index 65b2bfc0a..4ca43ab35 100644 --- a/app/src/main/java/remix/myplayer/App.kt +++ b/app/src/main/java/remix/myplayer/App.kt @@ -6,7 +6,6 @@ import android.os.Build import android.os.Process import androidx.multidex.MultiDex import androidx.multidex.MultiDexApplication -import com.hjq.permissions.XXPermissions import com.tencent.bugly.crashreport.CrashReport import com.tencent.bugly.crashreport.CrashReport.UserStrategy import io.reactivex.Completable @@ -60,17 +59,6 @@ class App : MultiDexApplication() { } private fun checkMigration() { - if (!SPUtil.getValue(context, SPUtil.LYRIC_KEY.NAME, SPUtil.LYRIC_KEY.LYRIC_RESET_ON_16000, false)) { - SPUtil.deleteFile(this, SPUtil.LYRIC_KEY.NAME) - SPUtil.putValue(context, SPUtil.LYRIC_KEY.NAME, SPUtil.LYRIC_KEY.LYRIC_RESET_ON_16000, true) - SPUtil.putValue(context, SPUtil.LYRIC_KEY.NAME, SPUtil.LYRIC_KEY.PRIORITY_LYRIC, SPUtil.LYRIC_KEY.DEFAULT_PRIORITY) -// try { -// DiskCache.getLrcDiskCache().delete() -// } catch (e: Exception) { -// Timber.v(e) -// } - } - val oldVersion = SPUtil.getValue(context, SETTING_KEY.NAME, SETTING_KEY.VERSION, 1) if (oldVersion < SETTING_KEY.NEWEST_VERSION) { if (oldVersion == 1) { @@ -85,8 +73,6 @@ class App : MultiDexApplication() { } private fun setUp() { - XXPermissions.setCheckMode(false) - DiskCache.init(this, "lyric") setApplicationLanguage(this) Completable .fromAction { diff --git a/app/src/main/java/remix/myplayer/bean/misc/LyricPriority.kt b/app/src/main/java/remix/myplayer/bean/misc/LyricPriority.kt deleted file mode 100644 index 3097b653f..000000000 --- a/app/src/main/java/remix/myplayer/bean/misc/LyricPriority.kt +++ /dev/null @@ -1,48 +0,0 @@ -package remix.myplayer.bean.misc - -import remix.myplayer.App -import remix.myplayer.R -import java.util.* - -enum class LyricPriority(val priority: Int, val desc: String) { - DEF(0, App.context.getString(R.string.default_lyric_priority)), - IGNORE(1, App.context.getString(R.string.ignore_lrc)), - EMBEDDED(2, App.context.getString(R.string.embedded_lyric)), - LOCAL(3, App.context.getString(R.string.local)), - KUGOU(4, App.context.getString(R.string.kugou)), - NETEASE(5, App.context.getString(R.string.netease)), - QQ(6, App.context.getString(R.string.qq)), - MANUAL(7, App.context.getString(R.string.select_lrc)); - - override fun toString(): String { - return desc - } - - companion object { - @JvmStatic - fun toLyricPrioritys(list: List): List { - val prioritys = ArrayList() - list.forEach { - prioritys.add(toLyricPriority(it)) - } - return prioritys - } - - @JvmStatic - fun toLyricPriority(desc: CharSequence): LyricPriority { - return when (desc) { - DEF.desc -> DEF - IGNORE.desc -> IGNORE - NETEASE.desc -> NETEASE - KUGOU.desc -> KUGOU - QQ.desc -> QQ - LOCAL.desc -> LOCAL - EMBEDDED.desc -> EMBEDDED - MANUAL.desc -> MANUAL - else -> DEF - } - } - } - - -} diff --git a/app/src/main/java/remix/myplayer/helper/LyricsHelper.kt b/app/src/main/java/remix/myplayer/helper/LyricsHelper.kt new file mode 100644 index 000000000..a092f3f69 --- /dev/null +++ b/app/src/main/java/remix/myplayer/helper/LyricsHelper.kt @@ -0,0 +1,29 @@ +package remix.myplayer.helper + +import remix.myplayer.lyrics.LyricsLine +import remix.myplayer.ui.widget.desktop.DesktopLyricsView + +object LyricsHelper { + fun getDesktopLyricsContent( + lyrics: List, offset: Int, progress: Int, duration: Int + ): DesktopLyricsView.Content { + if (lyrics.isEmpty()) { + return DesktopLyricsView.Content(LyricsLine.LYRICS_LINE_NO_LRC, null, 1, 1) + } + val progressWithOffset = progress + offset + val index = lyrics.binarySearchBy(progressWithOffset) { it.time }.let { + if (it < 0) -(it + 1) - 1 else it + } + if (index < 0) { + check(index == -1) + return DesktopLyricsView.Content(null, lyrics[0], 1, 1) + } + check(index < lyrics.size) + return DesktopLyricsView.Content( + lyrics[index], + lyrics.getOrNull(index + 1), + progressWithOffset, + lyrics.getOrNull(index + 1)?.time ?: (duration + offset) + ) + } +} diff --git a/app/src/main/java/remix/myplayer/helper/MusicServiceRemote.kt b/app/src/main/java/remix/myplayer/helper/MusicServiceRemote.kt index d233e232b..66ae6f44a 100644 --- a/app/src/main/java/remix/myplayer/helper/MusicServiceRemote.kt +++ b/app/src/main/java/remix/myplayer/helper/MusicServiceRemote.kt @@ -163,9 +163,4 @@ object MusicServiceRemote { fun getOperation(): Int { return service?.operation ?: Command.NEXT } - - @JvmStatic - fun setLyricOffset(offset: Int) { - service?.setLyricOffset(offset) - } } diff --git a/app/src/main/java/remix/myplayer/lyric/DefaultLrcParser.kt b/app/src/main/java/remix/myplayer/lyric/DefaultLrcParser.kt deleted file mode 100644 index b8c50e5a9..000000000 --- a/app/src/main/java/remix/myplayer/lyric/DefaultLrcParser.kt +++ /dev/null @@ -1,149 +0,0 @@ -package remix.myplayer.lyric - -import android.os.Environment -import android.text.TextUtils -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import remix.myplayer.App -import remix.myplayer.lyric.bean.LrcRow -import remix.myplayer.misc.cache.DiskCache -import java.io.BufferedReader -import java.io.File - -/** - * @ClassName - * @Description 解析歌词实现类 - * @Author Xiaoborui - * @Date 2016/10/28 09:50 - */ - -class DefaultLrcParser : ILrcParser { - override fun saveLrcRows(lrcRows: List?, cacheKey: String?, searchKey: String?) { - if (lrcRows == null || lrcRows.isEmpty()) - return - - //缓存 - if (!cacheKey.isNullOrEmpty()) { - DiskCache.getLrcDiskCache()?.apply { - edit(cacheKey)?.apply { - newOutputStream(0)?.use { outStream -> - outStream.write(Gson().toJson(lrcRows, object : TypeToken>() {}.type).toByteArray()) - } - }?.commit() - }?.flush() - } - - //保存歌词原始文件 - if (TextUtils.isEmpty(searchKey) || Environment.MEDIA_MOUNTED != Environment.getExternalStorageState()) - return - - if (!searchKey.isNullOrEmpty()) { - File(Environment.getExternalStorageDirectory(), "Android/data/" - + App.context.packageName + "/lyric") - .run { - //目录 - if (exists() || mkdirs()) - File(this, searchKey.replace("/".toRegex(), "") + ".lrc") - else null - }?.run { - //文件不存在或者成功重新创建 - if (!exists() || (delete() && createNewFile())) - this - else null - }?.run { - lrcRows.forEach { lrcRow -> - val strBuilder = StringBuilder(128) - strBuilder.append("[") - strBuilder.append(lrcRow.timeStr) - strBuilder.append("]") - strBuilder.append(lrcRow.content).append("\n") - if (lrcRow.hasTranslate()) { - strBuilder.append("[") - strBuilder.append(lrcRow.timeStr) - strBuilder.append("]") - strBuilder.append(lrcRow.translate).append("\n") - } - appendText(strBuilder.toString()) - } - } - } - } - - override fun getLrcRows(bufferedReader: BufferedReader?, needCache: Boolean, cacheKey: String?, searchKey: String?): List { - - //解析歌词 - val lrcRows = ArrayList() - val allLine = ArrayList() - var offset = 0 - if (bufferedReader == null) - return lrcRows - bufferedReader.useLines { allLines -> - allLines.forEach { eachLine -> - allLine.add(eachLine) - //读取offset标签 - if (eachLine.startsWith("[offset:") && eachLine.endsWith("]")) { - val offsetInString = eachLine.substring(eachLine.lastIndexOf(":") + 1, eachLine.length - 1) - if (offsetInString.isNotEmpty() && TextUtils.isDigitsOnly(offsetInString)) { - offset = Integer.valueOf(offsetInString) - } - } - } - } - - if (allLine.size == 0) - return lrcRows - - for (temp in allLine) { - //解析每一行歌词 - val rows = LrcRow.createRows(temp, offset) - if (rows != null && rows.size > 0) - lrcRows.addAll(rows) - } - - lrcRows.sort() - //合并翻译 - val combineLrcRows = ArrayList() - var index = 0 - while (index < lrcRows.size) { - // 判断下一句歌词和当前歌词的时间是否一致,一致则认为下一句是当前歌词的翻译 - val currentRow = lrcRows[index] - val nextRow = lrcRows.getOrNull(index + 1) - if (currentRow.time == nextRow?.time && !currentRow.content.isNullOrBlank()) { // 带翻译的歌词 - val tmp = LrcRow() - tmp.content = currentRow.content - tmp.time = currentRow.time - tmp.timeStr = currentRow.timeStr - tmp.translate = nextRow.content - combineLrcRows.add(tmp) - index++ - } else { // 普通歌词 - combineLrcRows.add(currentRow) - } - index++ - } - - lrcRows.clear() - lrcRows.addAll(combineLrcRows) - - - if (lrcRows.size == 0) - return lrcRows - lrcRows.sort() - //每行歌词的时间 - for (i in 0 until lrcRows.size - 1) { - lrcRows[i].setTotalTime(lrcRows[i + 1].time - lrcRows[i].time) - } - lrcRows[lrcRows.size - 1].setTotalTime(5000) - //缓存 - if (needCache) { - saveLrcRows(lrcRows, cacheKey, searchKey) - } - - return lrcRows - } - - companion object { - const val TAG = "DefaultLrcParser" - const val THRESHOLD_PROPORTION = 0.3 - } -} diff --git a/app/src/main/java/remix/myplayer/lyric/ILrcParser.kt b/app/src/main/java/remix/myplayer/lyric/ILrcParser.kt deleted file mode 100644 index e6c0cfe9b..000000000 --- a/app/src/main/java/remix/myplayer/lyric/ILrcParser.kt +++ /dev/null @@ -1,16 +0,0 @@ -package remix.myplayer.lyric - -import remix.myplayer.lyric.bean.LrcRow -import java.io.BufferedReader - -/** - * @ClassName - * @Description - * @Author Xiaoborui - * @Date 2016/10/28 09:48 - */ -interface ILrcParser { - fun saveLrcRows(lrcRows: List?, cacheKey: String?, searchKey: String?) - fun getLrcRows(bufferedReader: BufferedReader?, needCache: Boolean, cacheKey: String?, - searchKey: String?): List -} \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/lyric/ILrcView.kt b/app/src/main/java/remix/myplayer/lyric/ILrcView.kt deleted file mode 100644 index efaa0c1d4..000000000 --- a/app/src/main/java/remix/myplayer/lyric/ILrcView.kt +++ /dev/null @@ -1,35 +0,0 @@ -package remix.myplayer.lyric - -import remix.myplayer.lyric.bean.LrcRow - -interface ILrcView { - /** - * 初始化画笔,颜色,字体大小等设置 - */ - fun init() - - /*** - * 设置数据源 - * @param lrcRows - */ - fun setLrcRows(lrcRows: List?) - - /** - * 指定时间 - * - * @param progress 时间进度 - * @param fromSeekBarByUser 是否由用户触摸Seekbar触发 - */ - fun seekTo(progress: Int, fromSeekBar: Boolean, fromSeekBarByUser: Boolean) - - /*** - * 设置歌词文字的缩放比例 - * @param newFactor - */ - fun setLrcScalingFactor(newFactor: Float) - - /** - * 重置 - */ - fun reset() -} \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/lyric/LrcView.kt b/app/src/main/java/remix/myplayer/lyric/LrcView.kt deleted file mode 100644 index de6af4e60..000000000 --- a/app/src/main/java/remix/myplayer/lyric/LrcView.kt +++ /dev/null @@ -1,767 +0,0 @@ -package remix.myplayer.lyric - -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Rect -import android.os.Handler -import android.os.Looper -import android.text.Layout -import android.text.StaticLayout -import android.text.TextPaint -import android.util.AttributeSet -import android.util.TypedValue -import android.view.MotionEvent -import android.view.View -import android.view.ViewConfiguration -import android.view.animation.DecelerateInterpolator -import android.view.animation.Interpolator -import android.widget.Scroller -import androidx.annotation.ColorInt -import androidx.annotation.StringRes -import remix.myplayer.App -import remix.myplayer.R -import remix.myplayer.lyric.bean.LrcRow -import remix.myplayer.theme.Theme -import remix.myplayer.util.DensityUtil -import remix.myplayer.util.SPUtil -import timber.log.Timber -import kotlin.math.abs -import kotlin.math.max -import kotlin.math.min - -/** - * Created by Remix on 2018/1/3. - */ -class LrcView : View, ILrcView { - /** - * 所有的歌词 - */ - private var lrcRows: List? = null - - /** - * 所有歌词总计高度 - */ - private var totalHeight = 0 - - /** - * 画高亮歌词的画笔 - */ - private val highLightPaint by lazy { - TextPaint() - } - - /** - * 高亮歌词当前的字体颜色 - */ - private var highLightTextColor = DEFAULT_COLOR_FOR_HIGH_LIGHT_LRC - - /** - * 画其他歌词的画笔 - */ - private val normalPaint by lazy { - TextPaint() - } - /** - * 高亮歌词当前的字体颜色 - */ - private var normalTextColor = DEFAULT_COLOR_FOR_OTHER_LRC - - /** - * 画时间线的画笔 - */ - private val timeLinePaint: TextPaint by lazy { - TextPaint() - } - - /***时间线的颜色 */ - private var timeLineTextColor = Color.GRAY - - /** - * 时间文字大小 - */ - private var timeLineTextSize = 0f - - /** - * 是否画时间线 - */ - private var isDrawTimeLine = false - - /** - * 每一句歌词之间的行距 - */ - private var linePadding = DEFAULT_PADDING - /** - * 返回当前的歌词缩放比例 - */ - /** - * 歌词的当前缩放比例 - */ - var scalingFactor = - SPUtil.getValue(App.context, SPUtil.LYRIC_KEY.NAME, SPUtil.LYRIC_KEY.LYRIC_FONT_SIZE, "1f") - .toFloat() - private set - - /** - * 实现歌词竖直方向平滑滚动的辅助对象 - */ - private val scroller: Scroller by lazy { - Scroller(context, DEFAULT_INTERPOLATOR) - } - - /** - * 插值器 - */ - private val DEFAULT_INTERPOLATOR: Interpolator = DecelerateInterpolator() - - /** - * 控制文字缩放的因子 - */ - private var curFraction = 0f - private var touchSlop = 0 - - /** - * 错误提示文字 - */ - private var placeholder = App.context.getString(R.string.no_lrc) - - /** - * 当前纵坐标 - */ - private var rowY = 0f - - /** - * 时间线的图标 - */ - private val timelineDrawable = Theme - .getDrawable(App.context, R.drawable.icon_lyric_timeline) - - /** - * 初始状态时间线图标所在的位置 - */ - private val timelineRect by lazy { - Rect( - -timelineDrawable.intrinsicWidth / 2, - height / 2 - timelineDrawable.intrinsicHeight * 2, - timelineDrawable.intrinsicWidth * 2, - height / 2 + timelineDrawable.intrinsicHeight * 2 - ) - } - - constructor(context: Context?) : super(context) { - init() - } - - constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) { - init() - } - - /** - * 初始化画笔等 - */ - override fun init() { - highLightPaint.isAntiAlias = true - highLightPaint.color = highLightTextColor - - highLightPaint.textSize = DEFAULT_TEXT_SIZE * scalingFactor - highLightPaint.isFakeBoldText = true - - normalPaint.isAntiAlias = true - normalPaint.color = normalTextColor - normalPaint.textSize = DEFAULT_TEXT_SIZE * scalingFactor - - timeLinePaint.isAntiAlias = true - timeLineTextSize = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_SP, 11f, - context.resources.displayMetrics - ) - timeLinePaint.textSize = timeLineTextSize - timeLinePaint.color = timeLineTextColor - touchSlop = ViewConfiguration.get(context).scaledTouchSlop - - linePadding = DEFAULT_PADDING * scalingFactor - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) -// if (timelineRect == null) { //扩大点击区域 -// TIMELINE_DRAWABLE_RECT = Rect(-TIMELINE_DRAWABLE.intrinsicWidth / 2, -// height / 2 - TIMELINE_DRAWABLE.intrinsicHeight * 2, -// TIMELINE_DRAWABLE.intrinsicWidth * 2, -// height / 2 + TIMELINE_DRAWABLE.intrinsicHeight * 2) -// } - } - - @SuppressLint("DrawAllocation") - override fun onDraw(canvas: Canvas) { - if (lrcRows == null || lrcRows?.isEmpty() == true) { - //画默认的显示文字 - val textWidth = normalPaint.measureText(placeholder) - val textX = (width - textWidth) / 2 - normalPaint.alpha = 0xff - canvas.drawText(placeholder, textX, (height / 2).toFloat(), normalPaint) - return - } - val availableWidth = width - (paddingLeft + paddingRight) - rowY = (height / 2).toFloat() - lrcRows?.let { - for (i in it.indices) { - if (i == curRow) { //画高亮歌词 - drawLrcRow(canvas, highLightPaint, availableWidth, it[i]) - } else { //普通歌词 - drawLrcRow(canvas, normalPaint, availableWidth, it[i]) - } - } - } - - //画时间线和时间 - if (isDrawTimeLine) { -// final int timeLineOffsetY = -// mCurRow >= 0 && mLrcRows != null && mCurRow <= mLrcRows.size() - 1 ? -// mLrcRows.get(mCurRow).getTotalHeight() / 2 : -// 0; -// float y = getHeight() / 2 + getScrollY() + timeLineOffsetY ; - val y = height / 2 + scrollY + DEFAULT_SPACING_PADDING - val lrcRow = lrcRows?.get(curRow) - if (lrcRow != null) { - canvas.drawText( - lrcRow.timeStr, - width - timeLinePaint.measureText(lrcRow.timeStr) - 5, - y - 10, timeLinePaint - ) - } - - canvas.drawLine( - (timelineDrawable.intrinsicWidth + 10).toFloat(), - y, - width.toFloat(), - y, - timeLinePaint - ) - timelineDrawable.setBounds( - 0, - y.toInt() - timelineDrawable.intrinsicHeight / 2, - timelineDrawable.intrinsicWidth, - y.toInt() + timelineDrawable.intrinsicHeight / 2 - ) - timelineDrawable.draw(canvas) - } - } - - /** - * 分割绘制歌词 - */ - private fun drawLrcRow( - canvas: Canvas, - textPaint: TextPaint?, - availableWidth: Int, - lrcRow: LrcRow - ) { - drawText(canvas, textPaint, availableWidth, lrcRow.content) - if (lrcRow.hasTranslate()) { -// mRowY += DEFAULT_SPACING_PADDING; - drawText(canvas, textPaint, availableWidth, lrcRow.translate) - } - rowY += linePadding - } - - /** - * 分割绘制歌词 - */ - private fun drawText(canvas: Canvas, textPaint: TextPaint?, availableWidth: Int, text: String) { - val staticLayout = StaticLayout( - text, textPaint, availableWidth, - Layout.Alignment.ALIGN_CENTER, - DEFAULT_SPACING_MULTI, 0f, true - ) - val extra = if (staticLayout.lineCount > 1) DensityUtil.dip2px(context, 10f) else 0 - canvas.save() - canvas.translate(paddingLeft.toFloat(), rowY - staticLayout.height / 2 + extra) - staticLayout.draw(canvas) - canvas.restore() - rowY += staticLayout.height.toFloat() - } - - /** - * 是否可拖动歌词 - */ - private var canDrag = false - - /** - * 事件的第一次的y坐标 - */ - private var firstY = 0f - - /** - * 事件的上一次的y坐标 - */ - private var lastY = 0f - private var lastX = 0f - - /** - * 等待TimeLine - */ - private var timeLineWaiting = false - - /** - * 长按runnable - */ - private var longPressRunnable: Runnable? = LongPressRunnable() - private val timeLineDisableRunnable by lazy { - TimeLineRunnable() - } - - private val mHandler by lazy { - Handler(Looper.getMainLooper()) - } - - private inner class LongPressRunnable : Runnable { - override fun run() { - if (onLrcClickListener != null) { - onLrcClickListener?.onLongClick() - } - } - } - - private inner class TimeLineRunnable : Runnable { - override fun run() { - timeLineWaiting = false - isDrawTimeLine = false - invalidate() - } - } - - fun getLrcRows(): List? { - return lrcRows - } - - private fun hasLrc(): Boolean { - return lrcRows != null && lrcRows?.isNotEmpty() == true - } - - override fun onTouchEvent(event: MotionEvent): Boolean { - - when (event.action) { - MotionEvent.ACTION_DOWN -> { - //没有歌词 - if (hasLrc()) { - firstY = event.rawY - lastX = event.rawX - if (timeLineWaiting) { - //点击定位图标 - if (timelineRect.contains(event.x.toInt(), event.y.toInt()) - && onSeekToListener != null && curRow != -1 - ) { - mHandler.removeCallbacks(timeLineDisableRunnable) - mHandler.post(timeLineDisableRunnable) - onSeekToListener?.onSeekTo(lrcRows?.get(curRow)?.time ?: 0) - return false - } - } - } - longPressRunnable = LongPressRunnable() - mHandler.postDelayed(longPressRunnable!!, ViewConfiguration.getLongPressTimeout().toLong()) - } - MotionEvent.ACTION_MOVE -> if (hasLrc()) { - if (!canDrag) { - if (abs(x = event.rawY - firstY) > touchSlop - && abs(x = event.rawY - firstY) > abs(x = event.rawX - lastX) - ) { - canDrag = true - isDrawTimeLine = true - scroller.forceFinished(true) - curFraction = 1f - } - lastY = event.rawY - } - if (canDrag) { - timeLineWaiting = false - longPressRunnable?.let { mHandler.removeCallbacks(it) } - val offset = event.rawY - lastY //偏移量 - if (scrollY - offset < 0) { - if (offset > 0) { -// offset = offset / 3; - } - } else if (scrollY - offset > totalHeight) { - if (offset < 0) { -// offset = offset / 3; - } - } - scrollBy(scrollX, (-offset).toInt()) - lastY = event.rawY - //根据滚动后的距离 查找歌词 -// int currentRow = (int) (getScrollY() / (mSizeForOtherLrc + mLinePadding)); - var currentRow = rowByScrollY - lrcRows?.let { lrcRows -> - currentRow = min(currentRow, lrcRows.size - 1) - currentRow = max(currentRow, 0) - seekTo(lrcRows[currentRow].time, false, false) - } - return true - } - lastY = event.rawY - } else { - longPressRunnable?.let { mHandler.removeCallbacks(it) } - } - MotionEvent.ACTION_UP -> if (!canDrag) { - if (longPressRunnable == null && onLrcClickListener != null) { - onLrcClickListener?.onClick() - } - longPressRunnable?.let { mHandler.removeCallbacks(it) } - longPressRunnable = null - } else { - //显示三秒TimeLine - mHandler.removeCallbacks(timeLineDisableRunnable) - mHandler.postDelayed(timeLineDisableRunnable, DURATION_TIME_LINE.toLong()) - timeLineWaiting = true - if (scrollY < 0) { - smoothScrollTo(0, DURATION_FOR_ACTION_UP) - } else if (scrollY > getScrollYByRow(curRow)) { - smoothScrollTo(getScrollYByRow(curRow), DURATION_FOR_ACTION_UP) - } - canDrag = false - // mIsDrawTimeLine = false; - invalidate() - } - MotionEvent.ACTION_CANCEL -> { - longPressRunnable?.let { mHandler.removeCallbacks(it) } - longPressRunnable = null - } - } - return true - } - - /** - * 为LrcView设置歌词List集合数据 - */ - override fun setLrcRows(lrcRows: List?) { - reset() - - this.lrcRows = lrcRows - this.lrcRows?.let { lrcRows -> - //计算每一行歌词所占的高度 -// for(LrcRow lrcRow : mLrcRows){ -// int height = 0; -// String combine = lrcRow.getContent() + (!TextUtils.isEmpty(lrcRow.getTranslate()) ? "\t" + lrcRow.getTranslate() : ""); -// String[] multiText = combine.split("\t"); -// for (String text : multiText) { -// float textWidth = mPaintForOtherLrc.measureText(text); -// int lineNumber = (int) Math.ceil(textWidth / getWidth()); -// -// height += lineNumber * mPaintForOtherLrc.getTextSize() + DEFAULT_SPACING_PADDING; -// } -// lrcRow.setHeight(height); -// } - calculateLrcRowHeight(lrcRows) - } - invalidate() - } - - private fun calculateLrcRowHeight(lrcRows: List) { - totalHeight = 0 - for (lrcRow in lrcRows) { - lrcRow.contentHeight = getSingleLineHeight(lrcRow.content) - if (lrcRow.hasTranslate()) { - lrcRow.translateHeight = getSingleLineHeight(lrcRow.translate) - } - lrcRow.totalHeight = lrcRow.translateHeight + lrcRow.contentHeight - totalHeight += lrcRow.totalHeight - } - } - - /** - * 获得单句歌词的高度,可能有多行 - */ - private fun getSingleLineHeight(text: String): Int { - val staticLayout = StaticLayout( - text, normalPaint, - width - paddingLeft - paddingRight, Layout.Alignment.ALIGN_CENTER, - DEFAULT_SPACING_MULTI, DEFAULT_SPACING_PADDING, true - ) - return staticLayout.height - } - - /** - * 当前高亮歌词的行号 - */ - private var curRow = -1 - - /** - * 到第n行所滚动过的距离 - */ - private fun getScrollYByRow(row: Int): Int { - if (lrcRows == null) { - return 0 - } - var scrollY = 0 - var i = 0 - - lrcRows?.let { - while (i < it.size && i < row) { - scrollY += (it[i].totalHeight + linePadding).toInt() - i++ - } - } - - return scrollY - } - - /** - * 根据当前行数计算滑动距离 - */ - private val rowByScrollY: Int - get() { - lrcRows?.let { lrcRows -> - var totalY = 0 - var line = 0 - while (line < lrcRows.size) { - totalY += (linePadding + lrcRows[line].totalHeight).toInt() - if (totalY >= scrollY) { - return line - } - line++ - } - return line - 1 - } - - return 0 - } - - private var offset = 0 - fun setOffset(offset: Int) { - this.offset = offset - invalidate() - } - - override fun seekTo(p: Int, fromSeekBar: Boolean, fromSeekBarByUser: Boolean) { - var progress = p - if (progress != 0) { - progress += offset - } - - lrcRows?.let { lrcRows -> - if (lrcRows.isEmpty()) { - return - } - //如果是由seekbar的进度改变触发 并且这时候处于拖动状态,则返回 - if (fromSeekBar && canDrag) { - return - } - //滑动处于等待的状态 - if (timeLineWaiting) { - return - } - for (i in lrcRows.indices.reversed()) { - if (progress >= lrcRows[i].time) { - if (curRow != i) { - curRow = i - if (fromSeekBarByUser) { - if (!scroller.isFinished) { - scroller.forceFinished(true) - } - scrollTo(scrollX, getScrollYByRow(curRow)) - } else { - smoothScrollTo(getScrollYByRow(curRow), DURATION_FOR_LRC_SCROLL) - } - //如果高亮歌词的宽度大于View的宽,就需要开启属性动画,让它水平滚动 -// float textWidth = mPaintForHighLightLrc.measureText(mLrcRows.get(mCurRow).getContent()); -// log("textWidth="+textWidth+"getWidth()=" + getWidth()); -// if(textWidth > getWidth()){ -// if(fromSeekBarByUser){ -// mScroller.forceFinished(true); -// } -// log("开始水平滚动歌词:" + mLrcRows.get(mCurRow).getContent()); -// startScrollLrc(getWidth() - textWidth, (long) (mLrcRows.get(mCurRow).getTotalTime() * 0.6)); -// } - invalidate() - } - break - } - } - } - - } - - /** - * 设置歌词的缩放比例 - */ - override fun setLrcScalingFactor(newFactor: Float) { - if (scalingFactor == newFactor) { - return - } - SPUtil.putValue( - context, - SPUtil.LYRIC_KEY.NAME, - SPUtil.LYRIC_KEY.LYRIC_FONT_SIZE, - newFactor.toString() - ) - scalingFactor = newFactor - highLightPaint.textSize = DEFAULT_TEXT_SIZE * scalingFactor - - normalPaint.textSize = DEFAULT_TEXT_SIZE * scalingFactor - - linePadding = DEFAULT_PADDING * scalingFactor - - lrcRows?.let { - calculateLrcRowHeight(it) - scrollTo(scrollX, getScrollYByRow(curRow)) - scroller.forceFinished(true) - } - - invalidate() - } - - /** - * 重置 - */ - override fun reset() { - if (!scroller.isFinished) { - scroller.forceFinished(true) - } - curRow = 0 - lrcRows = null - longPressRunnable?.let { mHandler.removeCallbacks(it) } - mHandler.removeCallbacks(timeLineDisableRunnable) - mHandler.post(timeLineDisableRunnable) - scrollTo(scrollX, 0) - invalidate() - } - - /** - * 平滑的移动到某处 - */ - private fun smoothScrollTo(dstY: Int, duration: Int) { - val oldScrollY = scrollY - val offset = dstY - oldScrollY - scroller.startScroll(scrollX, oldScrollY, scrollX, offset, duration) - invalidate() - } - - override fun computeScroll() { - if (!scroller.isFinished) { - if (scroller.computeScrollOffset()) { - val oldY = scrollY - val y = scroller.currY - if (oldY != y && !canDrag) { - scrollTo(scrollX, y) - } - curFraction = scroller.timePassed() * 3f / DURATION_FOR_LRC_SCROLL - curFraction = min(curFraction, 1f) - invalidate() - } - } - } - - private var onSeekToListener: OnSeekToListener? = null - fun setOnSeekToListener(onSeekToListener: OnSeekToListener) { - this.onSeekToListener = onSeekToListener - } - - fun setText(text: String) { - this.placeholder = text - reset() - } - - fun setText(@StringRes res: Int) { - this.placeholder = resources.getString(res) - setText(placeholder) - reset() - } - - interface OnSeekToListener { - fun onSeekTo(progress: Int) - } - - private var onLrcClickListener: OnLrcClickListener? = null - fun setOnLrcClickListener(mOnLrcClickListener: OnLrcClickListener?) { - this.onLrcClickListener = mOnLrcClickListener - } - - interface OnLrcClickListener { - fun onClick() - fun onLongClick() - } - - fun log(o: Any?) { - Timber.v("%s", o) - } - - /** - * 设置高亮歌词颜色 - */ - fun setHighLightColor(@ColorInt color: Int) { - highLightTextColor = color - highLightPaint.color = highLightTextColor - } - - /** - * 设置非高亮歌词颜色 - */ - fun setOtherColor(@ColorInt color: Int) { - normalTextColor = color - normalPaint.color = normalTextColor - } - - /** - * 设置时间线颜色 - */ - fun setTimeLineColor(@ColorInt color: Int) { - if (timeLineTextColor != color) { - timeLineTextColor = color - Theme.tintDrawable(timelineDrawable, color) - timeLinePaint.color = color - } - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - mHandler.removeCallbacksAndMessages(null) - } - - companion object { - val DEFAULT_TEXT_SIZE = DensityUtil.dip2px(App.context, 15f).toFloat() - - /** - * 歌词间默认的行距 - */ - val DEFAULT_PADDING = DensityUtil.dip2px(App.context, 10f).toFloat() - - /** - * 跨行歌词之间额外的行距 - */ - const val DEFAULT_SPACING_PADDING = 0f - /** DensityUtil.dip2px(App.getContext(),5) */ - /** - * 跨行歌词之间行距倍数 - */ - const val DEFAULT_SPACING_MULTI = 1f - - /** - * 高亮歌词的默认字体颜色 - */ - private const val DEFAULT_COLOR_FOR_HIGH_LIGHT_LRC = Color.BLACK - - /** - * 其他歌词的默认字体颜色 - */ - private const val DEFAULT_COLOR_FOR_OTHER_LRC = Color.GRAY - - /** - * 时间线默认大小 - */ - private const val DEFAULT_SIZE_FOR_TIMELINE = 35f - - /***移动一句歌词的持续时间 */ - private const val DURATION_FOR_LRC_SCROLL = 800 - - /***停止触摸时 如果View需要滚动 时的持续时间 */ - private const val DURATION_FOR_ACTION_UP = 400 - - /** - * 滑动后TimeLine显示的时间 - */ - private const val DURATION_TIME_LINE = 3000 - - } -} \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/lyric/LyricFetcher.kt b/app/src/main/java/remix/myplayer/lyric/LyricFetcher.kt deleted file mode 100644 index ac2e49523..000000000 --- a/app/src/main/java/remix/myplayer/lyric/LyricFetcher.kt +++ /dev/null @@ -1,129 +0,0 @@ -package remix.myplayer.lyric - -import io.reactivex.disposables.Disposable -import remix.myplayer.App -import remix.myplayer.bean.mp3.Song -import remix.myplayer.lyric.bean.LrcRow -import remix.myplayer.lyric.bean.LrcRow.Companion.LYRIC_EMPTY_ROW -import remix.myplayer.lyric.bean.LyricRowWrapper -import remix.myplayer.lyric.bean.LyricRowWrapper.Companion.LYRIC_WRAPPER_NO -import remix.myplayer.lyric.bean.LyricRowWrapper.Companion.LYRIC_WRAPPER_SEARCHING -import remix.myplayer.service.MusicService -import remix.myplayer.util.SPUtil -import timber.log.Timber -import java.lang.ref.WeakReference -import java.util.concurrent.CopyOnWriteArrayList - -/** - * Created by remix on 2019/2/6 - */ -class LyricFetcher(service: MusicService) { - - private val lrcRows = CopyOnWriteArrayList() - private val reference: WeakReference = WeakReference(service) - private var disposable: Disposable? = null - private var status = Status.SEARCHING - var song: Song = Song.EMPTY_SONG - var offset = 0 - private val lyricSearcher = LyricSearcher() - - - fun findCurrentLyric(): LyricRowWrapper { - val wrapper = LyricRowWrapper() - wrapper.status = status - val service = reference.get() - - when { - service == null || status == Status.NO -> { - return LYRIC_WRAPPER_NO - } - status == Status.SEARCHING -> { - return LYRIC_WRAPPER_SEARCHING - } - status == Status.NORMAL -> { - val song = service.currentSong - if (song == Song.EMPTY_SONG) { - Timber.v("歌曲异常") - return wrapper - } - val progress = service.progress + offset - - for (i in lrcRows.indices.reversed()) { - val lrcRow = lrcRows[i] - val interval = progress - lrcRow.time - if (i == 0 && interval < 0) { - //未开始歌唱前显示歌曲信息 - wrapper.lineOne = LrcRow("", 0, song.title) - wrapper.lineTwo = LrcRow("", 0, song.artist + " - " + song.album) - return wrapper - } else if (progress >= lrcRow.time) { - if (lrcRow.hasTranslate()) { - wrapper.lineOne = LrcRow(lrcRow) - wrapper.lineOne.content = lrcRow.content - wrapper.lineTwo = LrcRow(lrcRow) - wrapper.lineTwo.content = lrcRow.translate - } else { - wrapper.lineOne = lrcRow - wrapper.lineTwo = LrcRow(if (i + 1 < lrcRows.size) lrcRows[i + 1] else LYRIC_EMPTY_ROW) - } - return wrapper - } - } - return wrapper - } - else -> { - return LYRIC_WRAPPER_NO - } - } - } - - fun updateLyricRows(song: Song) { - this.song = song - - if (song == Song.EMPTY_SONG) { - status = Status.NO - lrcRows.clear() - return - } - - val id = song.id - - disposable?.dispose() - disposable = lyricSearcher.setSong(song) - .getLyricObservable() - .doOnSubscribe { - status = Status.SEARCHING - } - .subscribe({ -// Timber.v("updateLyricRows, lrc: $it") - if (id == song.id) { - status = Status.NORMAL - offset = SPUtil.getValue(App.context, SPUtil.LYRIC_OFFSET_KEY.NAME, id.toString(), 0) - lrcRows.clear() - lrcRows.addAll(it) - } else { - lrcRows.clear() - status = Status.NO - } - }, { throwable -> - Timber.v(throwable) - if (id == song.id) { - status = Status.NO - lrcRows.clear() - } - }) - } - - fun dispose() { - disposable?.dispose() - } - - enum class Status { - NO, SEARCHING, NORMAL - } - - companion object { - const val LYRIC_FIND_INTERVAL = 400L - } - -} \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/lyric/LyricSearcher.kt b/app/src/main/java/remix/myplayer/lyric/LyricSearcher.kt index f5390b543..c5ba92333 100644 --- a/app/src/main/java/remix/myplayer/lyric/LyricSearcher.kt +++ b/app/src/main/java/remix/myplayer/lyric/LyricSearcher.kt @@ -1,481 +1,481 @@ -package remix.myplayer.lyric - -import android.net.Uri -import android.provider.MediaStore -import android.text.TextUtils -import android.util.Base64 -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import io.reactivex.Observable -import io.reactivex.Single -import io.reactivex.functions.Function -import org.jaudiotagger.audio.AudioFileIO -import org.jaudiotagger.tag.FieldKey -import remix.myplayer.App -import remix.myplayer.bean.misc.LyricPriority -import remix.myplayer.bean.mp3.Song -import remix.myplayer.lyric.bean.LrcRow -import remix.myplayer.misc.cache.DiskCache -import remix.myplayer.request.network.HttpClient -import remix.myplayer.util.* -import timber.log.Timber -import java.io.* -import java.nio.charset.Charset -import java.util.* - -/** - * Created by Remix on 2015/12/7. - */ - -/** - * 根据歌曲名和歌手名 搜索歌词并解析成固定格式 - */ -class LyricSearcher { - private var song: Song = Song.EMPTY_SONG - private val lrcParser: ILrcParser - private var displayName: String? = null - private var cacheKey: String? = null - private var searchKey: String? = null - - init { - lrcParser = DefaultLrcParser() - } - - private fun parse() { - try { - if (!TextUtils.isEmpty(song.displayName)) { - val temp = song.displayName - displayName = if (temp.indexOf('.') > 0) temp.substring(0, temp.lastIndexOf('.')) else temp - } - searchKey = getLyricSearchKey(song) - } catch (e: Exception) { - Timber.v(e) - displayName = song.title - } - } - - fun setSong(song: Song): LyricSearcher { - this.song = song - parse() - return this - } - - /** - * 发送请求并解析歌词 - * - * @return 歌词 - */ - fun getLyricObservable(uri: Uri, clearCache: Boolean): Observable> { - if (song == Song.EMPTY_SONG) { - return Observable.error(Throwable("empty song")) - } - - val type = SPUtil.getValue(App.context, SPUtil.LYRIC_KEY.NAME, song.id, SPUtil.LYRIC_KEY.LYRIC_DEFAULT) - - val observable = when (type) { - SPUtil.LYRIC_KEY.LYRIC_IGNORE -> { - Timber.v("ignore lyric") - return Observable.error(Throwable("ignore lyric")) - } - SPUtil.LYRIC_KEY.LYRIC_EMBEDDED -> { - getEmbeddedObservable() - } - SPUtil.LYRIC_KEY.LYRIC_LOCAL -> { - getLocalObservable() - } - SPUtil.LYRIC_KEY.LYRIC_KUGOU -> { - getKuGouObservable() - } - SPUtil.LYRIC_KEY.LYRIC_NETEASE -> { - getNeteaseObservable() - } - SPUtil.LYRIC_KEY.LYRIC_QQ -> { - getQQObservable() - } - SPUtil.LYRIC_KEY.LYRIC_MANUAL -> { - getManualObservable(uri) - } - SPUtil.LYRIC_KEY.LYRIC_DEFAULT -> { - //默认优先级排序 本地-内嵌-酷狗-网易-QQ-忽略 - - val priority = Gson().fromJson>(SPUtil.getValue(App.context, SPUtil.LYRIC_KEY.NAME, SPUtil.LYRIC_KEY.PRIORITY_LYRIC, SPUtil.LYRIC_KEY.DEFAULT_PRIORITY), - object : TypeToken>() {}.type) - if (priority.firstOrNull() == LyricPriority.IGNORE) { - return Observable.error(Throwable("ignore lyric")) - } - - val observables = mutableListOf>>() - priority.forEach { - when (it.priority) { - LyricPriority.KUGOU.priority -> observables.add(getKuGouObservable()) - LyricPriority.NETEASE.priority -> observables.add(getNeteaseObservable()) - LyricPriority.QQ.priority -> observables.add(getQQObservable()) - LyricPriority.LOCAL.priority -> observables.add(getLocalObservable()) - LyricPriority.EMBEDDED.priority -> observables.add(getEmbeddedObservable()) - LyricPriority.IGNORE.priority -> observables.add(Observable.create { emitter -> - emitter.onError(Throwable("ignore lyric")) - }) - } - } - Observable.concat(observables).firstOrError().toObservable() - } - else -> { - Observable.error(Throwable("unknown type")) - } - } - - return if (isTypeAvailable(type)) Observable.concat(getCacheObservable(), observable) - .firstOrError() - .toObservable() - .doOnError { - Timber.w(it) - } - .doOnSubscribe { - cacheKey = Util.hashKeyForDisk(song.id.toString() + "-" + - (if (!TextUtils.isEmpty(song.artist)) song.artist else "") + "-" + - if (!TextUtils.isEmpty(song.title)) song.title else "") - Timber.v("CacheKey: $cacheKey SearchKey: $searchKey") - if (clearCache) { - Timber.v("clearCache") - DiskCache.getLrcDiskCache().remove(cacheKey) - } - }.compose(RxUtil.applyScheduler()) - else - observable - } - - private fun isTypeAvailable(type: Int): Boolean { - return type != SPUtil.LYRIC_KEY.LYRIC_IGNORE - } - - /** - * 根据歌词id,发送请求并解析歌词 - * - * @return 歌词 - */ - fun getLyricObservable(): Observable> { - return getLyricObservable(Uri.EMPTY, false) - } - - /** - * 内嵌歌词 - * - * @return - */ - private fun getEmbeddedObservable(): Observable> { - return Observable.create { e -> - val audioFile = if (song.isLocal()) AudioFileIO.read(File(song.data)) else null - val lyric = try { - audioFile?.tag?.getFirst(FieldKey.LYRICS) - } catch (e: Exception) { - "" - } - if (!lyric.isNullOrEmpty()) { - e.onNext(lrcParser.getLrcRows(getBufferReader(lyric.toByteArray(UTF_8)), - true, cacheKey, searchKey)) - Timber.v("EmbeddedLyric") - } - e.onComplete() - } - } - - /** - * 缓存 - */ - private fun getCacheObservable(): Observable> { - return Observable.create { e -> - DiskCache.getLrcDiskCache().get(cacheKey)?.run { - BufferedReader(InputStreamReader(getInputStream(0))).use { - it.readLine().run { - e.onNext(Gson().fromJson(this, object : TypeToken>() {}.type)) - Timber.v("CacheLyric") - } - } - } - e.onComplete() - } - } - - private val isCN: Boolean - get() = "zh".equals(Locale.getDefault().language, ignoreCase = true) - - /** - * 搜索本地所有歌词文件 - */ - private fun getLocalLyricPath(): String? { - var path = "" - //没有设置歌词路径 搜索所有可能的歌词文件 - App.context.contentResolver.query(MediaStore.Files.getContentUri("external"), null, - MediaStore.Files.FileColumns.DATA + " like ? or " + - MediaStore.Files.FileColumns.DATA + " like ? or " + - MediaStore.Files.FileColumns.DATA + " like ? or " + - MediaStore.Files.FileColumns.DATA + " like ? or " + - MediaStore.Files.FileColumns.DATA + " like ? or " + - MediaStore.Files.FileColumns.DATA + " like ?", - getLocalSearchKey(), - null) - .use { filesCursor -> - if (filesCursor == null) { - return "" - } - while (filesCursor.moveToNext()) { - val file = File(filesCursor.getString(filesCursor.getColumnIndex(MediaStore.Files.FileColumns.DATA))) - Timber.v("file: %s", file.absolutePath) - if (file.exists() && file.isFile && file.canRead()) { - path = file.absolutePath - break - } - } - return path - } - } - - /** - * @param searchPath 设置的本地歌词搜索路径 - * 本地歌词搜索的关键字 - * artist-displayName.lrc - * displayName.lrc - * title.lrc - * title-artist.lrc - * displayname-artist.lrc - * artist-title.lrc - */ - private fun getLocalSearchKey(searchPath: String? = null): Array { - return arrayOf("%${song.artist}%$displayName$SUFFIX_LYRIC", - "%$displayName$SUFFIX_LYRIC", - "%${song.title}$SUFFIX_LYRIC", - "%${song.title}%${song.artist}$SUFFIX_LYRIC", - "%${song.displayName}%${song.artist}$SUFFIX_LYRIC", - "%${song.artist}%${song.title}$SUFFIX_LYRIC") - } - - /** - * 网络或者本地歌词 - * - * @param type - * @return - */ - @Deprecated("") - private fun getContentObservable(type: Int): Observable> { - val networkObservable = getNetworkObservable(type) - val localObservable = getLocalObservable() - val onlineFirst = SPUtil.getValue(App.context, SPUtil.SETTING_KEY.NAME, SPUtil.SETTING_KEY.ONLINE_LYRIC_FIRST, false) - return Observable.concat(if (onlineFirst) networkObservable else localObservable, if (onlineFirst) localObservable else networkObservable).firstOrError().toObservable() - } - - /** - * 手动设置歌词 - */ - private fun getManualObservable(uri: Uri): Observable> { - return Observable.create { e -> - //手动设置的歌词 - Timber.v("ManualLyric") - e.onNext( - lrcParser.getLrcRows( - BufferedReader( - InputStreamReader( - App.context.contentResolver.openInputStream( - uri - ) - ) - ), true, cacheKey, searchKey - ) - ) - e.onComplete() - } - } - - - /** - * 本地歌词 - * - * @param type - * @return - */ - private fun getLocalObservable(): Observable> { - return Observable - .create { emitter -> - val path = getLocalLyricPath() - if (path != null && path.isNotEmpty()) { - Timber.v("LocalLyric") - emitter.onNext(lrcParser.getLrcRows(getBufferReader(path), true, cacheKey, searchKey)) - } - emitter.onComplete() - } - } - - /** - * 网易歌词 - */ - private fun getNeteaseObservable(): Observable> { - return HttpClient.searchNeteaseSong(searchKey, 0, 1) - .flatMap { - HttpClient.searchNeteaseLyric(it.result?.songs?.get(0)?.id ?: 0) - } - .map { lrcResponse -> - val combine = lrcParser.getLrcRows(getBufferReader(lrcResponse.lrc?.lyric?.toByteArray() - ?: "".toByteArray()), false, cacheKey, searchKey) - if (isCN && lrcResponse.tlyric != null && !lrcResponse.tlyric.lyric.isNullOrEmpty()) { - val translate = lrcParser.getLrcRows(getBufferReader(lrcResponse.tlyric.lyric.toByteArray()), false, cacheKey, searchKey) - if (translate.isNotEmpty()) { - for (i in translate.indices) { - for (j in combine.indices) { - if (translate[i].time == combine[j].time) { - combine[j].translate = translate[i].content - break - } - } - } - } - } - Timber.v("NeteaseLyric") - lrcParser.saveLrcRows(combine, cacheKey, searchKey) - combine - } - .toObservable() - .onErrorResumeNext(Function { - Timber.w("search netease lyric failed: ${it.message}") - Observable.empty() - }) - } - - /** - * 酷狗歌词 - */ - private fun getKuGouObservable(): Observable> { - //酷狗歌词 - return HttpClient.searchKuGou(searchKey, song.duration) - .flatMap { searchResponse -> - if (searchResponse.candidates.isNotEmpty() && song.title.equals(searchResponse.candidates[0].song, true)) { - HttpClient.searchKuGouLyric( - searchResponse.candidates[0].id, - searchResponse.candidates[0].accesskey) - .map { lrcResponse -> - Timber.v("KugouLyric") - val rows = lrcParser.getLrcRows(getBufferReader(Base64.decode(lrcResponse.content, Base64.DEFAULT)), true, cacheKey, searchKey) - rows.forEach { - it.content = Util.htmlToText(it.content) - } - rows - } - } else { - Single.error(Throwable("no kugou lyric")) - } - } - .toObservable() - .onErrorResumeNext(Function { - Timber.w("search kugou lyric failed: ${it.message}") - Observable.empty() - }) - } - - /** - * QQ歌词 - */ - private fun getQQObservable(): Observable> { - return HttpClient.searchQQ(searchKey) - .flatMap { searchResponse -> - if (song.title.equals(searchResponse.data.song.list[0].songname, true)) { - HttpClient.searchQQLyric(searchResponse.data.song.list[0].songmid) - .map { lrcResponse -> - val combine = lrcParser.getLrcRows(getBufferReader(lrcResponse.lyric.toByteArray()), false, cacheKey, searchKey) - combine.forEach { - it.content = Util.htmlToText(it.content) - } - if (lrcResponse.trans.isNotEmpty()) { - val translate = lrcParser.getLrcRows(getBufferReader(lrcResponse.trans.toByteArray()), false, cacheKey, searchKey) - if (isCN && translate.isNotEmpty()) { - translate.forEach { - if (it.content.isNotEmpty() && it.content != "//") { - for (i in combine.indices) { - if (it.time == combine[i].time) { - combine[i].translate = Util.htmlToText(it.content) - break - } - } - } - } - } - } - Timber.v("QQLyric") - lrcParser.saveLrcRows(combine, cacheKey, searchKey) - combine - } - } else { - Single.error(Throwable("no qq lyric")) - } - } - .toObservable() - .onErrorResumeNext(Function { - Timber.w("search qq lyric failed: ${it.message}") - Observable.empty() - }) - } - - /** - * 在线歌词 - * - * @param type - * @return - */ - @Deprecated("") - private fun getNetworkObservable(type: Int): Observable> { - var newType = type - - if (TextUtils.isEmpty(searchKey)) { - return Observable.error(Throwable("no available key")) - } - if (newType == SPUtil.LYRIC_KEY.LYRIC_DEFAULT) - newType = SPUtil.LYRIC_KEY.LYRIC_NETEASE - return if (newType == SPUtil.LYRIC_KEY.LYRIC_KUGOU) { - //酷狗歌词 - getKuGouObservable() - } else { - //网易歌词 - getNeteaseObservable() - } - } - - private fun isTranslateCanUse(translate: String): Boolean { - return !TextUtils.isEmpty(translate) && !translate.startsWith("词") && !translate.startsWith("曲") - } - - /** - * 获得搜索歌词的关键字 - * - * @param song - * @return - */ - private fun getLyricSearchKey(song: Song?): String { - if (song == null) - return "" - val isTitleAvailable = !ImageUriUtil.isSongNameUnknownOrEmpty(song.title) - val isAlbumAvailable = !ImageUriUtil.isAlbumNameUnknownOrEmpty(song.album) - val isArtistAvailable = !ImageUriUtil.isArtistNameUnknownOrEmpty(song.artist) - - //歌曲名合法 - return if (isTitleAvailable) { - when { - isArtistAvailable -> song.artist + "-" + song.title //艺术家合法 - isAlbumAvailable -> //专辑名合法 - song.album + "-" + song.title - else -> song.title - } - } else "" - } - - @Throws(FileNotFoundException::class, UnsupportedEncodingException::class) - private fun getBufferReader(path: String): BufferedReader { - return BufferedReader(InputStreamReader(FileInputStream(path), LyricUtil.getCharset(path))) - } - - private fun getBufferReader(bytes: ByteArray): BufferedReader { - return BufferedReader(InputStreamReader(ByteArrayInputStream(bytes), UTF_8)) - } - - companion object { - private const val TAG = "LyricSearcher" - private const val SUFFIX_LYRIC = ".lrc" - private val UTF_8 = Charset.forName("UTF-8") - } -} +//package remix.myplayer.lyric +// +//import android.net.Uri +//import android.provider.MediaStore +//import android.text.TextUtils +//import android.util.Base64 +//import com.google.gson.Gson +//import com.google.gson.reflect.TypeToken +//import io.reactivex.Observable +//import io.reactivex.Single +//import io.reactivex.functions.Function +//import org.jaudiotagger.audio.AudioFileIO +//import org.jaudiotagger.tag.FieldKey +//import remix.myplayer.App +//import remix.myplayer.bean.misc.LyricPriority +//import remix.myplayer.bean.mp3.Song +//import remix.myplayer.lyric.bean.LrcRow +//import remix.myplayer.misc.cache.DiskCache +//import remix.myplayer.request.network.HttpClient +//import remix.myplayer.util.* +//import timber.log.Timber +//import java.io.* +//import java.nio.charset.Charset +//import java.util.* +// +///** +// * Created by Remix on 2015/12/7. +// */ +// +///** +// * 根据歌曲名和歌手名 搜索歌词并解析成固定格式 +// */ +//class LyricSearcher { +// private var song: Song = Song.EMPTY_SONG +// private val lrcParser: ILrcParser +// private var displayName: String? = null +// private var cacheKey: String? = null +// private var searchKey: String? = null +// +// init { +// lrcParser = DefaultLrcParser() +// } +// +// private fun parse() { +// try { +// if (!TextUtils.isEmpty(song.displayName)) { +// val temp = song.displayName +// displayName = if (temp.indexOf('.') > 0) temp.substring(0, temp.lastIndexOf('.')) else temp +// } +// searchKey = getLyricSearchKey(song) +// } catch (e: Exception) { +// Timber.v(e) +// displayName = song.title +// } +// } +// +// fun setSong(song: Song): LyricSearcher { +// this.song = song +// parse() +// return this +// } +// +// /** +// * 发送请求并解析歌词 +// * +// * @return 歌词 +// */ +// fun getLyricObservable(uri: Uri, clearCache: Boolean): Observable> { +// if (song == Song.EMPTY_SONG) { +// return Observable.error(Throwable("empty song")) +// } +// +// val type = SPUtil.getValue(App.context, SPUtil.LYRIC_KEY.NAME, song.id, SPUtil.LYRIC_KEY.LYRIC_DEFAULT) +// +// val observable = when (type) { +// SPUtil.LYRIC_KEY.LYRIC_IGNORE -> { +// Timber.v("ignore lyric") +// return Observable.error(Throwable("ignore lyric")) +// } +// SPUtil.LYRIC_KEY.LYRIC_EMBEDDED -> { +// getEmbeddedObservable() +// } +// SPUtil.LYRIC_KEY.LYRIC_LOCAL -> { +// getLocalObservable() +// } +// SPUtil.LYRIC_KEY.LYRIC_KUGOU -> { +// getKuGouObservable() +// } +// SPUtil.LYRIC_KEY.LYRIC_NETEASE -> { +// getNeteaseObservable() +// } +// SPUtil.LYRIC_KEY.LYRIC_QQ -> { +// getQQObservable() +// } +// SPUtil.LYRIC_KEY.LYRIC_MANUAL -> { +// getManualObservable(uri) +// } +// SPUtil.LYRIC_KEY.LYRIC_DEFAULT -> { +// //默认优先级排序 本地-内嵌-酷狗-网易-QQ-忽略 +// +// val priority = Gson().fromJson>(SPUtil.getValue(App.context, SPUtil.LYRIC_KEY.NAME, SPUtil.LYRIC_KEY.PRIORITY_LYRIC, SPUtil.LYRIC_KEY.DEFAULT_PRIORITY), +// object : TypeToken>() {}.type) +// if (priority.firstOrNull() == LyricPriority.IGNORE) { +// return Observable.error(Throwable("ignore lyric")) +// } +// +// val observables = mutableListOf>>() +// priority.forEach { +// when (it.priority) { +// LyricPriority.KUGOU.priority -> observables.add(getKuGouObservable()) +// LyricPriority.NETEASE.priority -> observables.add(getNeteaseObservable()) +// LyricPriority.QQ.priority -> observables.add(getQQObservable()) +// LyricPriority.LOCAL.priority -> observables.add(getLocalObservable()) +// LyricPriority.EMBEDDED.priority -> observables.add(getEmbeddedObservable()) +// LyricPriority.IGNORE.priority -> observables.add(Observable.create { emitter -> +// emitter.onError(Throwable("ignore lyric")) +// }) +// } +// } +// Observable.concat(observables).firstOrError().toObservable() +// } +// else -> { +// Observable.error(Throwable("unknown type")) +// } +// } +// +// return if (isTypeAvailable(type)) Observable.concat(getCacheObservable(), observable) +// .firstOrError() +// .toObservable() +// .doOnError { +// Timber.w(it) +// } +// .doOnSubscribe { +// cacheKey = Util.hashKeyForDisk(song.id.toString() + "-" + +// (if (!TextUtils.isEmpty(song.artist)) song.artist else "") + "-" + +// if (!TextUtils.isEmpty(song.title)) song.title else "") +// Timber.v("CacheKey: $cacheKey SearchKey: $searchKey") +// if (clearCache) { +// Timber.v("clearCache") +// DiskCache.getLrcDiskCache().remove(cacheKey) +// } +// }.compose(RxUtil.applyScheduler()) +// else +// observable +// } +// +// private fun isTypeAvailable(type: Int): Boolean { +// return type != SPUtil.LYRIC_KEY.LYRIC_IGNORE +// } +// +// /** +// * 根据歌词id,发送请求并解析歌词 +// * +// * @return 歌词 +// */ +// fun getLyricObservable(): Observable> { +// return getLyricObservable(Uri.EMPTY, false) +// } +// +// /** +// * 内嵌歌词 +// * +// * @return +// */ +// private fun getEmbeddedObservable(): Observable> { +// return Observable.create { e -> +// val audioFile = if (song.isLocal()) AudioFileIO.read(File(song.data)) else null +// val lyric = try { +// audioFile?.tag?.getFirst(FieldKey.LYRICS) +// } catch (e: Exception) { +// "" +// } +// if (!lyric.isNullOrEmpty()) { +// e.onNext(lrcParser.getLrcRows(getBufferReader(lyric.toByteArray(UTF_8)), +// true, cacheKey, searchKey)) +// Timber.v("EmbeddedLyric") +// } +// e.onComplete() +// } +// } +// +// /** +// * 缓存 +// */ +// private fun getCacheObservable(): Observable> { +// return Observable.create { e -> +// DiskCache.getLrcDiskCache().get(cacheKey)?.run { +// BufferedReader(InputStreamReader(getInputStream(0))).use { +// it.readLine().run { +// e.onNext(Gson().fromJson(this, object : TypeToken>() {}.type)) +// Timber.v("CacheLyric") +// } +// } +// } +// e.onComplete() +// } +// } +// +// private val isCN: Boolean +// get() = "zh".equals(Locale.getDefault().language, ignoreCase = true) +// +// /** +// * 搜索本地所有歌词文件 +// */ +// private fun getLocalLyricPath(): String? { +// var path = "" +// //没有设置歌词路径 搜索所有可能的歌词文件 +// App.context.contentResolver.query(MediaStore.Files.getContentUri("external"), null, +// MediaStore.Files.FileColumns.DATA + " like ? or " + +// MediaStore.Files.FileColumns.DATA + " like ? or " + +// MediaStore.Files.FileColumns.DATA + " like ? or " + +// MediaStore.Files.FileColumns.DATA + " like ? or " + +// MediaStore.Files.FileColumns.DATA + " like ? or " + +// MediaStore.Files.FileColumns.DATA + " like ?", +// getLocalSearchKey(), +// null) +// .use { filesCursor -> +// if (filesCursor == null) { +// return "" +// } +// while (filesCursor.moveToNext()) { +// val file = File(filesCursor.getString(filesCursor.getColumnIndex(MediaStore.Files.FileColumns.DATA))) +// Timber.v("file: %s", file.absolutePath) +// if (file.exists() && file.isFile && file.canRead()) { +// path = file.absolutePath +// break +// } +// } +// return path +// } +// } +// +// /** +// * @param searchPath 设置的本地歌词搜索路径 +// * 本地歌词搜索的关键字 +// * artist-displayName.lrc +// * displayName.lrc +// * title.lrc +// * title-artist.lrc +// * displayname-artist.lrc +// * artist-title.lrc +// */ +// private fun getLocalSearchKey(searchPath: String? = null): Array { +// return arrayOf("%${song.artist}%$displayName$SUFFIX_LYRIC", +// "%$displayName$SUFFIX_LYRIC", +// "%${song.title}$SUFFIX_LYRIC", +// "%${song.title}%${song.artist}$SUFFIX_LYRIC", +// "%${song.displayName}%${song.artist}$SUFFIX_LYRIC", +// "%${song.artist}%${song.title}$SUFFIX_LYRIC") +// } +// +// /** +// * 网络或者本地歌词 +// * +// * @param type +// * @return +// */ +// @Deprecated("") +// private fun getContentObservable(type: Int): Observable> { +// val networkObservable = getNetworkObservable(type) +// val localObservable = getLocalObservable() +// val onlineFirst = SPUtil.getValue(App.context, SPUtil.SETTING_KEY.NAME, SPUtil.SETTING_KEY.ONLINE_LYRIC_FIRST, false) +// return Observable.concat(if (onlineFirst) networkObservable else localObservable, if (onlineFirst) localObservable else networkObservable).firstOrError().toObservable() +// } +// +// /** +// * 手动设置歌词 +// */ +// private fun getManualObservable(uri: Uri): Observable> { +// return Observable.create { e -> +// //手动设置的歌词 +// Timber.v("ManualLyric") +// e.onNext( +// lrcParser.getLrcRows( +// BufferedReader( +// InputStreamReader( +// App.context.contentResolver.openInputStream( +// uri +// ) +// ) +// ), true, cacheKey, searchKey +// ) +// ) +// e.onComplete() +// } +// } +// +// +// /** +// * 本地歌词 +// * +// * @param type +// * @return +// */ +// private fun getLocalObservable(): Observable> { +// return Observable +// .create { emitter -> +// val path = getLocalLyricPath() +// if (path != null && path.isNotEmpty()) { +// Timber.v("LocalLyric") +// emitter.onNext(lrcParser.getLrcRows(getBufferReader(path), true, cacheKey, searchKey)) +// } +// emitter.onComplete() +// } +// } +// +// /** +// * 网易歌词 +// */ +// private fun getNeteaseObservable(): Observable> { +// return HttpClient.searchNeteaseSong(searchKey, 0, 1) +// .flatMap { +// HttpClient.searchNeteaseLyric(it.result?.songs?.get(0)?.id ?: 0) +// } +// .map { lrcResponse -> +// val combine = lrcParser.getLrcRows(getBufferReader(lrcResponse.lrc?.lyric?.toByteArray() +// ?: "".toByteArray()), false, cacheKey, searchKey) +// if (isCN && lrcResponse.tlyric != null && !lrcResponse.tlyric.lyric.isNullOrEmpty()) { +// val translate = lrcParser.getLrcRows(getBufferReader(lrcResponse.tlyric.lyric.toByteArray()), false, cacheKey, searchKey) +// if (translate.isNotEmpty()) { +// for (i in translate.indices) { +// for (j in combine.indices) { +// if (translate[i].time == combine[j].time) { +// combine[j].translate = translate[i].content +// break +// } +// } +// } +// } +// } +// Timber.v("NeteaseLyric") +// lrcParser.saveLrcRows(combine, cacheKey, searchKey) +// combine +// } +// .toObservable() +// .onErrorResumeNext(Function { +// Timber.w("search netease lyric failed: ${it.message}") +// Observable.empty() +// }) +// } +// +// /** +// * 酷狗歌词 +// */ +// private fun getKuGouObservable(): Observable> { +// //酷狗歌词 +// return HttpClient.searchKuGou(searchKey, song.duration) +// .flatMap { searchResponse -> +// if (searchResponse.candidates.isNotEmpty() && song.title.equals(searchResponse.candidates[0].song, true)) { +// HttpClient.searchKuGouLyric( +// searchResponse.candidates[0].id, +// searchResponse.candidates[0].accesskey) +// .map { lrcResponse -> +// Timber.v("KugouLyric") +// val rows = lrcParser.getLrcRows(getBufferReader(Base64.decode(lrcResponse.content, Base64.DEFAULT)), true, cacheKey, searchKey) +// rows.forEach { +// it.content = Util.htmlToText(it.content) +// } +// rows +// } +// } else { +// Single.error(Throwable("no kugou lyric")) +// } +// } +// .toObservable() +// .onErrorResumeNext(Function { +// Timber.w("search kugou lyric failed: ${it.message}") +// Observable.empty() +// }) +// } +// +// /** +// * QQ歌词 +// */ +// private fun getQQObservable(): Observable> { +// return HttpClient.searchQQ(searchKey) +// .flatMap { searchResponse -> +// if (song.title.equals(searchResponse.data.song.list[0].songname, true)) { +// HttpClient.searchQQLyric(searchResponse.data.song.list[0].songmid) +// .map { lrcResponse -> +// val combine = lrcParser.getLrcRows(getBufferReader(lrcResponse.lyric.toByteArray()), false, cacheKey, searchKey) +// combine.forEach { +// it.content = Util.htmlToText(it.content) +// } +// if (lrcResponse.trans.isNotEmpty()) { +// val translate = lrcParser.getLrcRows(getBufferReader(lrcResponse.trans.toByteArray()), false, cacheKey, searchKey) +// if (isCN && translate.isNotEmpty()) { +// translate.forEach { +// if (it.content.isNotEmpty() && it.content != "//") { +// for (i in combine.indices) { +// if (it.time == combine[i].time) { +// combine[i].translate = Util.htmlToText(it.content) +// break +// } +// } +// } +// } +// } +// } +// Timber.v("QQLyric") +// lrcParser.saveLrcRows(combine, cacheKey, searchKey) +// combine +// } +// } else { +// Single.error(Throwable("no qq lyric")) +// } +// } +// .toObservable() +// .onErrorResumeNext(Function { +// Timber.w("search qq lyric failed: ${it.message}") +// Observable.empty() +// }) +// } +// +// /** +// * 在线歌词 +// * +// * @param type +// * @return +// */ +// @Deprecated("") +// private fun getNetworkObservable(type: Int): Observable> { +// var newType = type +// +// if (TextUtils.isEmpty(searchKey)) { +// return Observable.error(Throwable("no available key")) +// } +// if (newType == SPUtil.LYRIC_KEY.LYRIC_DEFAULT) +// newType = SPUtil.LYRIC_KEY.LYRIC_NETEASE +// return if (newType == SPUtil.LYRIC_KEY.LYRIC_KUGOU) { +// //酷狗歌词 +// getKuGouObservable() +// } else { +// //网易歌词 +// getNeteaseObservable() +// } +// } +// +// private fun isTranslateCanUse(translate: String): Boolean { +// return !TextUtils.isEmpty(translate) && !translate.startsWith("词") && !translate.startsWith("曲") +// } +// +// /** +// * 获得搜索歌词的关键字 +// * +// * @param song +// * @return +// */ +// private fun getLyricSearchKey(song: Song?): String { +// if (song == null) +// return "" +// val isTitleAvailable = !ImageUriUtil.isSongNameUnknownOrEmpty(song.title) +// val isAlbumAvailable = !ImageUriUtil.isAlbumNameUnknownOrEmpty(song.album) +// val isArtistAvailable = !ImageUriUtil.isArtistNameUnknownOrEmpty(song.artist) +// +// //歌曲名合法 +// return if (isTitleAvailable) { +// when { +// isArtistAvailable -> song.artist + "-" + song.title //艺术家合法 +// isAlbumAvailable -> //专辑名合法 +// song.album + "-" + song.title +// else -> song.title +// } +// } else "" +// } +// +// @Throws(FileNotFoundException::class, UnsupportedEncodingException::class) +// private fun getBufferReader(path: String): BufferedReader { +// return BufferedReader(InputStreamReader(FileInputStream(path), LyricUtil.getCharset(path))) +// } +// +// private fun getBufferReader(bytes: ByteArray): BufferedReader { +// return BufferedReader(InputStreamReader(ByteArrayInputStream(bytes), UTF_8)) +// } +// +// companion object { +// private const val TAG = "LyricSearcher" +// private const val SUFFIX_LYRIC = ".lrc" +// private val UTF_8 = Charset.forName("UTF-8") +// } +//} diff --git a/app/src/main/java/remix/myplayer/lyric/UpdateLyricThread.java b/app/src/main/java/remix/myplayer/lyric/UpdateLyricThread.java deleted file mode 100644 index 1dae2815a..000000000 --- a/app/src/main/java/remix/myplayer/lyric/UpdateLyricThread.java +++ /dev/null @@ -1,138 +0,0 @@ -//package remix.myplayer.lyric; -// -//import io.reactivex.disposables.Disposable; -//import java.lang.ref.WeakReference; -//import java.util.List; -//import remix.myplayer.App; -//import remix.myplayer.R; -//import remix.myplayer.bean.mp3.Song; -//import remix.myplayer.lyric.bean.LrcRow; -//import remix.myplayer.lyric.bean.LyricRowWrapper; -//import remix.myplayer.service.MusicService; -//import remix.myplayer.util.SPUtil; -//import timber.log.Timber; -// -//public abstract class UpdateLyricThread extends Thread { -// -// public static final String TAG = UpdateLyricThread.class.getSimpleName(); -// public static final LrcRow LYRIC_EMPTY_ROW = new LrcRow("", 0, ""); -// public static final LrcRow LYRIC_NO_ROW = new LrcRow("", 0, -// App.getContext().getString(R.string.no_lrc)); -// public static final LrcRow LYRIC_SEARCHING_ROW = new LrcRow("", 0, -// App.getContext().getString(R.string.searching)); -// public static final int LRC_INTERVAL = 400; -// -// private volatile List mLrcRows; -// private Disposable mDisposable; -// private WeakReference mReference; -// private Song mSong; -// private Status mStatus = Status.SEARCHING; -// private int mOffset = 0; -// private LyricSearcher mLyricSearcher = new LyricSearcher(); -// -// public UpdateLyricThread(MusicService service) { -// mReference = new WeakReference<>(service); -// setSongAndGetLyricRows(mReference.get().getCurrentSong()); -// } -// -// private void updateLrcRows() { -// if (mSong == null) { -// mStatus = Status.NO; -// mLrcRows = null; -// return; -// } -// final int id = mSong.getId(); -// if (mDisposable != null && !mDisposable.isDisposed()) { -// mDisposable.dispose(); -// } -// mDisposable = mLyricSearcher.setSong(mSong) -// .getLyricObservable() -// .doOnSubscribe(disposable -> mStatus = Status.SEARCHING) -// .subscribe(lrcRows -> { -// if (id == mSong.getId()) { -// mStatus = Status.NORMAL; -// mOffset = SPUtil.getValue(App.getContext(), SPUtil.LYRIC_OFFSET_KEY.NAME, id + "", 0); -// mLrcRows = lrcRows; -// } -// }, throwable -> { -// Timber.tag(TAG).v(throwable); -// if (id == mSong.getId()) { -// mStatus = Status.ERROR; -// mLrcRows = null; -// } -// }); -// } -// -// public void setSongAndGetLyricRows(Song song) { -// mSong = song; -// updateLrcRows(); -// } -// -// public Status getStatus() { -// return mStatus; -// } -// -// public void quitImmediately() { -// interrupt(); -// } -// -// @Override -// public void interrupt() { -// super.interrupt(); -// Timber.tag(TAG).v("interrupt"); -// if (mDisposable != null && !mDisposable.isDisposed()) { -// mDisposable.dispose(); -// } -// mReference = null; -// } -// -// protected LyricRowWrapper findCurrentLyric() { -// final MusicService service = mReference != null ? mReference.get() : null; -// if (service == null) { -// return null; -// } -// LyricRowWrapper wrapper = new LyricRowWrapper(); -// wrapper.setStatus(mStatus); -// -// if (mStatus == Status.SEARCHING) { -// return wrapper; -// } -// -// if (mStatus == Status.ERROR || mStatus == Status.NO) { -// Timber.tag(TAG).v("当前没有歌词"); -// return wrapper; -// } -// final Song song = service.getCurrentSong(); -// final int progress = service.getProgress() + mOffset; -// if (mLrcRows == null || mLrcRows.isEmpty()) { -// return wrapper; -// } -// for (int i = mLrcRows.size() - 1; i >= 0; i--) { -// LrcRow lrcRow = mLrcRows.get(i); -// int interval = progress - lrcRow.getTime(); -// if (i == 0 && interval < 0) { -// //未开始歌唱前显示歌曲信息 -// wrapper.setLineOne(new LrcRow("", 0, song.getTitle())); -// wrapper.setLineTwo(new LrcRow("", 0, song.getArtist() + " - " + song.getAlbum())); -// return wrapper; -// } else if (progress >= lrcRow.getTime()) { -// if (lrcRow.hasTranslate()) { -// wrapper.setLineOne(new LrcRow(lrcRow)); -// wrapper.getLineOne().setContent(lrcRow.getContent()); -// wrapper.setLineTwo(new LrcRow(lrcRow)); -// wrapper.getLineTwo().setContent(lrcRow.getTranslate()); -// } else { -// wrapper.setLineOne(lrcRow); -// wrapper.setLineTwo(new LrcRow(i + 1 < mLrcRows.size() ? mLrcRows.get(i + 1) : LYRIC_EMPTY_ROW)); -// } -// return wrapper; -// } -// } -// return wrapper; -// } -// -// -// public enum Status { -// NO, SEARCHING, ERROR, NORMAL -// } -//} diff --git a/app/src/main/java/remix/myplayer/lyric/bean/LrcRow.kt b/app/src/main/java/remix/myplayer/lyric/bean/LrcRow.kt deleted file mode 100644 index 7ebc11db1..000000000 --- a/app/src/main/java/remix/myplayer/lyric/bean/LrcRow.kt +++ /dev/null @@ -1,168 +0,0 @@ -package remix.myplayer.lyric.bean - -import android.text.TextUtils -import com.google.gson.annotations.SerializedName -import remix.myplayer.App -import remix.myplayer.R -import java.util.* - -/** - * 每行歌词的实体类,实现了Comparable接口,方便List的sort排序 - * - * @author Ligang 2014/8/19 - */ -class LrcRow : Comparable { - @SerializedName("mTimeStr") - var timeStr: String = "" - - /** - * 开始时间 毫秒数 00:10:00 为10000 - */ - @SerializedName("mTime") - var time = 0 - - /** - * 歌词内容 - */ - @SerializedName("mContent") - var content: String = "" - - /** - * 歌词内容翻译 - */ - @SerializedName("mTranslate") - var translate: String = "" - - /** - * 该行歌词显示的总时间 - */ - @SerializedName("mTotalTime") - var totalTime: Long = 0 - private set - - /** - * 该行歌词内容所占的高度 - */ - @SerializedName("mContentHeight") - var contentHeight = 0 - - /** - * 该行歌词翻译所占的高度 - */ - @SerializedName("mTranslateHeight") - var translateHeight = 0 - - /** - * 该句歌词所占的总共高度 - */ - @SerializedName("mTotalHeight") - var totalHeight = 0 - - fun setTotalTime(totalTime: Int) { - this.totalTime = totalTime.toLong() - } - - fun hasTranslate(): Boolean { - return !TextUtils.isEmpty(translate) - } - - constructor() {} - constructor(lrcRow: LrcRow) { - timeStr = lrcRow.timeStr - time = lrcRow.time - totalTime = lrcRow.totalTime - content = lrcRow.content - translate = lrcRow.translate - } - - constructor(timeStr: String, time: Int, content: String) : super() { - this.timeStr = timeStr - this.time = time - if (TextUtils.isEmpty(content)) { - this.content = "" - translate = "" - return - } - val mulitiContent = content.split("\t".toRegex()).toTypedArray() - this.content = mulitiContent[0] - if (mulitiContent.size > 1) { - translate = mulitiContent[1] - } - } - - override fun compareTo(row: LrcRow): Int { - return time - row.time - } - - // @Override - // public String toString() { - // return "LrcRow [mTimeStr=" + mTimeStr + ", mTime=" + mTime + ", mTotalTime=" + mTotalTime +", mContent=" - // + mContent + "]"; - // } - override fun toString(): String { - return "[$timeStr] $content" - } - - companion object { - /** - * 将歌词文件中的某一行 解析成一个List 因为一行中可能包含了多个LrcRow对象 比如 [03:33.02][00:36.37]当鸽子不再象征和平 ,就包含了2个对象 - */ - fun createRows(lrcLine: String, offset: Int): List? { - if (!lrcLine.startsWith("[") || !lrcLine.contains("]")) { - return null - } - //最后一个"]" - val lastIndexOfRightBracket = lrcLine.lastIndexOf("]") - //歌词内容 - val content = lrcLine.substring(lastIndexOfRightBracket + 1, lrcLine.length) - //截取出歌词时间,并将"[" 和"]" 替换为"-" [offset:0] - - // -03:33.02--00:36.37- - val times = lrcLine.substring(0, lastIndexOfRightBracket + 1).replace("[", "-") - .replace("]", "-") - val timesArray = times.split("-".toRegex()).toTypedArray() - val lrcRows: MutableList = ArrayList() - for (tem in timesArray) { - //保留空白行 - if (TextUtils.isEmpty(tem.trim { it <= ' ' }) - /**|| TextUtils.isEmpty(content) */ - ) { - continue - } - try { - val lrcRow = LrcRow(tem, formatTime(tem) - offset, content) - lrcRows.add(lrcRow) - } catch (e: Exception) { - } - } - return lrcRows - } - - /**** - * 把歌词时间转换为毫秒值 如 将00:10.00 转为10000 - * @param str - * @return - */ - private fun formatTime(str: String): Int { - val timeStr = str.replace('.', ':') - val times = timeStr.split(":".toRegex()).toTypedArray() - return if (times.size > 2) { - times[0].toInt() * 60 * 1000 + times[1].toInt() * 1000 + times[2].toInt() - } else times[0].toInt() * 60 * 1000 + times[1].toInt() * 1000 - } - - val offset: Int - get() = 0 - - @JvmField - var LYRIC_EMPTY_ROW = LrcRow("", 0, "") - - @JvmField - var LYRIC_NO_ROW = LrcRow("", 0, - App.context.getString(R.string.no_lrc)) - - @JvmField - var LYRIC_SEARCHING_ROW = LrcRow("", 0, - App.context.getString(R.string.searching)) - } -} \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/lyric/bean/LyricRowWrapper.kt b/app/src/main/java/remix/myplayer/lyric/bean/LyricRowWrapper.kt deleted file mode 100644 index f8cd67a9a..000000000 --- a/app/src/main/java/remix/myplayer/lyric/bean/LyricRowWrapper.kt +++ /dev/null @@ -1,25 +0,0 @@ -package remix.myplayer.lyric.bean - -import remix.myplayer.lyric.LyricFetcher - -/** - * @ClassName - * @Description - * @Author Xiaoborui - * @Date 2017/5/10 13:37 - */ -data class LyricRowWrapper(var lineOne: LrcRow = LrcRow.LYRIC_EMPTY_ROW, - var lineTwo: LrcRow = LrcRow.LYRIC_EMPTY_ROW, - var status: LyricFetcher.Status = LyricFetcher.Status.NO) { - override fun toString(): String { - return "LyricRowWrapper{" + - "LineOne=" + lineOne + - ", LineTwo=" + lineTwo + - '}' - } - - companion object { - val LYRIC_WRAPPER_NO = LyricRowWrapper(LrcRow.LYRIC_NO_ROW, LrcRow.LYRIC_EMPTY_ROW) - val LYRIC_WRAPPER_SEARCHING = LyricRowWrapper(LrcRow.LYRIC_SEARCHING_ROW, LrcRow.LYRIC_EMPTY_ROW) - } -} \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/lyrics/LrcParser.kt b/app/src/main/java/remix/myplayer/lyrics/LrcParser.kt new file mode 100644 index 000000000..d4ea54738 --- /dev/null +++ b/app/src/main/java/remix/myplayer/lyrics/LrcParser.kt @@ -0,0 +1,129 @@ +package remix.myplayer.lyrics + +import timber.log.Timber +import kotlin.math.roundToInt + +object LrcParser { + private const val TAG = "LrcParser" + + private val WORD_TIME_TAG_REGEX = """<(\d+:){1,2}\d+(\.\d*)?>""".toRegex() + + /** + * 一般格式: `mm:ss.xx`, `mm:ss.xxx` + * + * @param offset 正数表示更早,负数表示更晚 + * @return 以毫秒为单位;失败返回 null + */ + private fun parseTime(timeStr: String, offset: Int): Int? { + try { + val parts = timeStr.split(':') + val minutes = when (parts.size) { + 2 -> parts[0].toInt() + 3 -> parts[0].toInt() * 60 + parts[1].toInt() + else -> throw Exception("Unknown time format") + } + val seconds = minutes * 60 + parts.last().toDouble() + return (seconds * 1000).roundToInt() - offset + } catch (t: Throwable) { + Timber.tag(TAG).w(t, "Failed to parse time: $timeStr") + } + return null + } + + /** + * 解析精确到字的歌词 + * + * @param time 整行的开始时间 + */ + private fun parseWords(time: Int, offset: Int, content: String): LyricsLine { + val words = ArrayList() + var currentTime = time + var lastStart = 0 + var match = WORD_TIME_TAG_REGEX.find(content) ?: return SimpleLyricsLine(time, content) + while (lastStart < content.length) { + words.add(Word(currentTime, content.substring(lastStart, match.range.first))) + parseTime(match.value.substring(1, match.value.lastIndex), offset)?.let { + // 确保同一 LyricsLine 内 time 单调不减 + if (it > currentTime) { + currentTime = it + } + } + lastStart = match.range.last + 1 + match = match.next() ?: break + } + if (lastStart < content.length) { + words.add(Word(currentTime, content.substring(lastStart))) + } + return PerWordLyricsLine(time, words) + } + + fun parse(data: String): ArrayList { + val lines = ArrayList() + var offset = 0 + + data.lines().forEach { + if (it.isBlank()) { + return@forEach + } + if (!it.startsWith('[')) { + Timber.tag(TAG).w("Ignored unknown line: $it") + return@forEach + } + + // [xxx] + if (it.endsWith(']')) { + val tag = it.substring(1, it.lastIndex) + println(tag) + // [offset:+/-xxx] + if (tag.startsWith("offset:")) { + try { + offset = Integer.parseInt(tag.substring(7).trim()) + return@forEach + } catch (_: Throwable) { + } + } + Timber.tag(TAG).v("Ignored unknown tag: $tag") + return@forEach + } + + var index = 0 + + // 解析时间戳 + val times = ArrayList() + while (it.startsWith("[", index)) { + val closing = it.indexOf(']', index) + if (closing == -1) break + parseTime(it.substring(index + 1, closing - 1), offset)?.let { time -> + times.add(time) + } + index = closing + 1 + } + + if (times.size == 1) { + lines.add(parseWords(times[0], offset, it.substring(index))) + } else { + times.forEach { time -> + lines.add(SimpleLyricsLine(time, it.substring(index))) + } + } + } + + // 合并翻译 + // 相同时间戳的两行,认为第二行是第一行的翻译 + lines.sortBy { it.time } + val combinedLines = ArrayList() + for (line in lines) { + val lastLine = combinedLines.lastOrNull() + combinedLines.add( + if (lastLine?.time == line.time && lastLine.translation == null) { + combinedLines.removeLast() + lastLine.withTranslation(line.content) + } else { + line + } + ) + } + + return combinedLines + } +} \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/lyrics/LyricsLine.kt b/app/src/main/java/remix/myplayer/lyrics/LyricsLine.kt new file mode 100644 index 000000000..fbd1210d0 --- /dev/null +++ b/app/src/main/java/remix/myplayer/lyrics/LyricsLine.kt @@ -0,0 +1,29 @@ +package remix.myplayer.lyrics + +import remix.myplayer.App +import remix.myplayer.R + +abstract class LyricsLine { + /** + * 这行歌词的开始时间 + */ + abstract val time: Int + + /** + * 整行歌词的内容,仅文本 + */ + abstract val content: String + + abstract val translation: String? + + abstract fun withTranslation(newTranslation: String?): LyricsLine + + companion object { + val LYRICS_LINE_SEARCHING by lazy { + SimpleLyricsLine(0, App.context.getString(R.string.searching)) + } + val LYRICS_LINE_NO_LRC by lazy { + SimpleLyricsLine(0, App.context.getString(R.string.no_lrc)) + } + } +} diff --git a/app/src/main/java/remix/myplayer/lyrics/LyricsSearcher.kt b/app/src/main/java/remix/myplayer/lyrics/LyricsSearcher.kt new file mode 100644 index 000000000..274f89b4f --- /dev/null +++ b/app/src/main/java/remix/myplayer/lyrics/LyricsSearcher.kt @@ -0,0 +1,200 @@ +package remix.myplayer.lyrics + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import remix.myplayer.App +import remix.myplayer.bean.mp3.Song +import remix.myplayer.lyrics.provider.EmbeddedProvider +import remix.myplayer.lyrics.provider.ILyricsProvider +import remix.myplayer.lyrics.provider.IgnoredProvider +import remix.myplayer.lyrics.provider.StubProvider +import remix.myplayer.util.SPUtil +import timber.log.Timber +import java.io.File +import java.security.MessageDigest + +@OptIn(ExperimentalSerializationApi::class) +object LyricsSearcher { + private const val TAG = "LyricsSearcher" + + private const val CACHE_DIRECTORY_NAME = "lyrics" + + // 同时作为默认顺序 + private val PROVIDERS = listOf( + EmbeddedProvider, + IgnoredProvider, + )/* + old: + DEF(0, App.context.getString(R.string.default_lyric_priority)), + IGNORE(1, App.context.getString(R.string.ignore_lrc)), + EMBEDDED(2, App.context.getString(R.string.embedded_lyric)), + LOCAL(3, App.context.getString(R.string.local)), + KUGOU(4, App.context.getString(R.string.kugou)), + NETEASE(5, App.context.getString(R.string.netease)), + QQ(6, App.context.getString(R.string.qq)), + MANUAL(7, App.context.getString(R.string.select_lrc)); + */ + + private val ID_TO_PROVIDER: Map = run { + val map = HashMap() + PROVIDERS.forEach { + map[it.id] = it + } + map + } + + var order: List + get() { + val providers = ArrayList() + try { + Json.decodeFromString>( + SPUtil.getValue( + App.context, SPUtil.LYRICS_KEY.NAME, SPUtil.LYRICS_KEY.ORDER, "" + ) + ).forEach { id -> + ID_TO_PROVIDER[id]?.let { + if (!providers.contains(it)) { + providers.add(it) + } + } + } + } catch (t: Throwable) { + Timber.tag(TAG).w(t, "Failed to get search order from preference") + } + PROVIDERS.forEach { + if (!providers.contains(it)) { + providers.add(it) + } + } + return providers + } + set(value) { + SPUtil.putValue( + App.context, + SPUtil.LYRICS_KEY.NAME, + SPUtil.LYRICS_KEY.ORDER, + Json.encodeToString(value.map { it.id }) + ) + } + + private fun getHashKey(song: Song): String { + require(song != Song.EMPTY_SONG) + val rawKey = Json.encodeToString( + listOf( + when (song) { + is Song.Local -> "local" + is Song.Remote -> "remote" + }, if (song is Song.Local) song.id else song.data, song.title, song.artist, song.album + ) + ) + // 要作为文件名,安全起见保证输出长度不超过 127 字节,SHA-384 输出 96 字节 + val digest = MessageDigest.getInstance("SHA-384").digest(rawKey.toByteArray()) + return digest.fold("") { str, it -> str + "%02x".format(it) } + } + + private fun getCacheFile(hashKey: String, persistent: Boolean): File { + val baseDir: File = App.context.run { + if (persistent) getExternalFilesDir(null) ?: filesDir + else externalCacheDir ?: cacheDir + } + val dir = File(baseDir, CACHE_DIRECTORY_NAME) + dir.mkdirs() + return File(dir, hashKey) + } + + private fun getCachedOrNull(song: Song): Pair, Int>? { + val hashKey = getHashKey(song) + listOf(true, false).map { getCacheFile(hashKey, it) }.forEach { + try { + return Pair( + Json.decodeFromStream>(it.inputStream()), SPUtil.getValue( + App.context, SPUtil.LYRICS_KEY.NAME, SPUtil.LYRICS_KEY.OFFSET_PREFIX + hashKey, 0 + ) + ) + } catch (t: Throwable) { + Timber.tag(TAG).i(t, "Failed to get lyrics from cache $it") + } + } + return null + } + + private fun clearCache(song: Song) { + val hashKey = getHashKey(song) + listOf(true, false).map { getCacheFile(hashKey, it) }.forEach { + it.delete() + } + } + + private fun saveLyrics(song: Song, lyrics: List, persistent: Boolean) { + if (song == Song.EMPTY_SONG) { + Timber.tag(TAG).e("Trying to save lyrics for empty song") + return + } + Timber.tag(TAG).v("Saving lyrics to cache, song: $song") + val hashKey = getHashKey(song) + SPUtil.deleteValue( + App.context, SPUtil.LYRICS_KEY.NAME, SPUtil.LYRICS_KEY.OFFSET_PREFIX + hashKey + ) + if (!persistent) { + getCacheFile(hashKey, true).delete() + } + try { + val cacheFile = getCacheFile(hashKey, persistent) + cacheFile.delete() + cacheFile.createNewFile() + Json.encodeToStream(lyrics, cacheFile.outputStream()) + } catch (t: Throwable) { + Timber.tag(TAG).e(t, "Failed to save lyrics to cache") + } + } + + fun saveOffset(song: Song, offset: Int) { + if (song == Song.EMPTY_SONG) { + Timber.tag(TAG).e("Trying to save offset for empty song") + return + } + Timber.tag(TAG).v("Saving offset, song: $song") + val hashKey = getHashKey(song) + SPUtil.putValue( + App.context, SPUtil.LYRICS_KEY.NAME, SPUtil.LYRICS_KEY.OFFSET_PREFIX + hashKey, offset + ) + } + + /** + * @param provider 由用户指定的歌词源 + */ + fun getLyricsAndOffset( + song: Song, provider: ILyricsProvider? = null + ): Pair, Int> { + if (song == Song.EMPTY_SONG) { + return Pair(listOf(), 0) + } + if (provider == null) { + getCachedOrNull(song)?.let { + Timber.tag(TAG).v("Got lyrics from cache, song: $song") + return it + } + } + Timber.tag(TAG).v("Searching lyrics for song: $song") + listOfNotNull(provider).ifEmpty { order }.forEach { + Timber.tag(TAG).v("Trying provider: ${it.id}") + try { + return Pair(it.getLyrics(song).let { lyrics -> + if (provider != null || it != IgnoredProvider) { + // Fallback 到 ignored 可能是因为网络等问题,如果缓存将会导致以后需要手动点击才能获取到歌词 + saveLyrics(song, lyrics, provider != null && provider !is StubProvider) + } + lyrics + }, 0) + } catch (t: Throwable) { + Timber.tag(TAG).i(t, "Failed to get lyrics from provider `${it.id}`") + } + } + clearCache(song) + Timber.tag(TAG).i("Failed to get lyrics from any provider, returning empty list") + return Pair(listOf(), 0) + } +} diff --git a/app/src/main/java/remix/myplayer/lyrics/PerWordLyricsLine.kt b/app/src/main/java/remix/myplayer/lyrics/PerWordLyricsLine.kt new file mode 100644 index 000000000..ec6dded95 --- /dev/null +++ b/app/src/main/java/remix/myplayer/lyrics/PerWordLyricsLine.kt @@ -0,0 +1,57 @@ +package remix.myplayer.lyrics + +import android.text.SpannedString +import androidx.annotation.ColorInt +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import kotlinx.serialization.Serializable +import remix.myplayer.ui.misc.PartialForegroundColorSpan + +@Serializable +data class PerWordLyricsLine( + override val time: Int, val words: List, override val translation: String? = null +) : LyricsLine() { + init { + require(words.isNotEmpty() && time <= words[0].time) + } + + override val content = words.joinToString("") { it.content } + + override fun withTranslation(newTranslation: String?): PerWordLyricsLine { + return PerWordLyricsLine(time, words, newTranslation) + } + + /** + * @return 0 到 `words.size` 之间的值 + */ + fun getProgress(time: Int, endTime: Int): Float { + require(time >= this.time && time <= endTime) + var index = words.binarySearchBy(time) { it.time } + // TODO: check + if (index >= 0) { + return index.toFloat() + } + index = -(index + 1) + check(index >= 0 && index <= words.size) + return if (index == 0) { + 0f + } else { + index - 1 + (time - words[index - 1].time).toFloat() / ((if (index == words.size) endTime else words[index].time) - words[index - 1].time) + } + } + + /** + * @param progress 0 到 `words.size` 之间的值,可通过 `getProgress` 获得 + * @param color 高亮部分的颜色 + */ + fun getSpannedString(progress: Float, @ColorInt color: Int): SpannedString { + require(progress >= 0 && progress <= words.size) + return buildSpannedString { + words.forEachIndexed { index, word -> + inSpans(PartialForegroundColorSpan((progress - index).coerceIn(0f, 1f), color)) { + append(word.content) + } + } + } + } +} diff --git a/app/src/main/java/remix/myplayer/lyrics/SimpleLyricsLine.kt b/app/src/main/java/remix/myplayer/lyrics/SimpleLyricsLine.kt new file mode 100644 index 000000000..d407e1e6e --- /dev/null +++ b/app/src/main/java/remix/myplayer/lyrics/SimpleLyricsLine.kt @@ -0,0 +1,14 @@ +package remix.myplayer.lyrics + +import kotlinx.serialization.Serializable + +@Serializable +data class SimpleLyricsLine( + override val time: Int, + override val content: String, + override val translation: String? = null +) : LyricsLine() { + override fun withTranslation(newTranslation: String?): SimpleLyricsLine { + return SimpleLyricsLine(time, content, newTranslation) + } +} \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/lyrics/Word.kt b/app/src/main/java/remix/myplayer/lyrics/Word.kt new file mode 100644 index 000000000..b61ae29bf --- /dev/null +++ b/app/src/main/java/remix/myplayer/lyrics/Word.kt @@ -0,0 +1,9 @@ +package remix.myplayer.lyrics + +import kotlinx.serialization.Serializable + +@Serializable +data class Word( + val time: Int, // in ms + val content: String +) \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/lyrics/provider/EmbeddedProvider.kt b/app/src/main/java/remix/myplayer/lyrics/provider/EmbeddedProvider.kt new file mode 100644 index 000000000..aebf2a724 --- /dev/null +++ b/app/src/main/java/remix/myplayer/lyrics/provider/EmbeddedProvider.kt @@ -0,0 +1,28 @@ +package remix.myplayer.lyrics.provider + +import org.jaudiotagger.audio.AudioFileIO +import org.jaudiotagger.tag.FieldKey +import remix.myplayer.App +import remix.myplayer.R +import remix.myplayer.bean.mp3.Song +import remix.myplayer.lyrics.LrcParser +import remix.myplayer.lyrics.LyricsLine +import java.io.File + +object EmbeddedProvider : ILyricsProvider { + override val id = "embedded" + override val displayName by lazy { + App.context.getString(R.string.embedded_lyric) + } + + override fun getLyrics(song: Song): List { + if (song is Song.Local) { + val lrc = AudioFileIO.read(File(song.data)).tag.getFirst(FieldKey.LYRICS) + if (lrc.isNullOrEmpty()) { + throw Exception("Field `LYRICS` doesn't exist or is empty") + } + return LrcParser.parse(lrc) + } + TODO("Reading embedded lyrics of song type ${song.javaClass.simpleName} is not supported yet") + } +} diff --git a/app/src/main/java/remix/myplayer/lyrics/provider/ILyricsProvider.kt b/app/src/main/java/remix/myplayer/lyrics/provider/ILyricsProvider.kt new file mode 100644 index 000000000..c93d31b35 --- /dev/null +++ b/app/src/main/java/remix/myplayer/lyrics/provider/ILyricsProvider.kt @@ -0,0 +1,16 @@ +package remix.myplayer.lyrics.provider + +import remix.myplayer.bean.mp3.Song +import remix.myplayer.lyrics.LyricsLine + +interface ILyricsProvider { + val id: String + val displayName: String + + /** + * 返回的 List 为空不视为失败,仅抛出异常视为失败 + * + * @throws Throwable + */ + fun getLyrics(song: Song): List +} diff --git a/app/src/main/java/remix/myplayer/lyrics/provider/IgnoredProvider.kt b/app/src/main/java/remix/myplayer/lyrics/provider/IgnoredProvider.kt new file mode 100644 index 000000000..ae795dcab --- /dev/null +++ b/app/src/main/java/remix/myplayer/lyrics/provider/IgnoredProvider.kt @@ -0,0 +1,17 @@ +package remix.myplayer.lyrics.provider + +import remix.myplayer.App +import remix.myplayer.R +import remix.myplayer.bean.mp3.Song +import remix.myplayer.lyrics.LyricsLine + +object IgnoredProvider : ILyricsProvider { + override val id = "ignored" + override val displayName by lazy { + App.context.getString(R.string.ignore_lrc) + } + + override fun getLyrics(song: Song): List { + return listOf() + } +} \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/lyrics/provider/StubProvider.kt b/app/src/main/java/remix/myplayer/lyrics/provider/StubProvider.kt new file mode 100644 index 000000000..6111dadff --- /dev/null +++ b/app/src/main/java/remix/myplayer/lyrics/provider/StubProvider.kt @@ -0,0 +1,18 @@ +package remix.myplayer.lyrics.provider + +import remix.myplayer.bean.mp3.Song +import remix.myplayer.lyrics.LyricsLine + +/** + * 用于恢复默认 + */ +object StubProvider : ILyricsProvider { + override val id: String + get() = throw RuntimeException() + override val displayName: String + get() = throw RuntimeException() + + override fun getLyrics(song: Song): List { + throw RuntimeException() + } +} diff --git a/app/src/main/java/remix/myplayer/lyrics/provider/UriProvider.kt b/app/src/main/java/remix/myplayer/lyrics/provider/UriProvider.kt new file mode 100644 index 000000000..14d61d877 --- /dev/null +++ b/app/src/main/java/remix/myplayer/lyrics/provider/UriProvider.kt @@ -0,0 +1,37 @@ +package remix.myplayer.lyrics.provider + +import android.net.Uri +import remix.myplayer.App +import remix.myplayer.bean.mp3.Song +import remix.myplayer.lyrics.LrcParser +import remix.myplayer.lyrics.LyricsLine +import timber.log.Timber + +class UriProvider(private val uri: Uri) : ILyricsProvider { + companion object { + private const val TAG = "UriProvider" + } + + // 不应该用到 + override val id: String + get() = throw RuntimeException() + override val displayName: String + get() = throw RuntimeException() + + override fun getLyrics(song: Song): List { + return try { + App.context.contentResolver.openInputStream(uri)!!.run { + try { + LrcParser.parse(readBytes().decodeToString()) + } catch (t: Throwable) { + throw t + } finally { + close() + } + } + } catch (t: Throwable) { + Timber.tag(TAG).w(t, "Failed to get lyrics from URI: $uri") + emptyList() + } + } +} diff --git a/app/src/main/java/remix/myplayer/misc/cache/DiskCache.java b/app/src/main/java/remix/myplayer/misc/cache/DiskCache.java index 88636d4d8..0714a8a92 100644 --- a/app/src/main/java/remix/myplayer/misc/cache/DiskCache.java +++ b/app/src/main/java/remix/myplayer/misc/cache/DiskCache.java @@ -1,39 +1,15 @@ package remix.myplayer.misc.cache; -import static remix.myplayer.util.Constants.MB; - import android.content.Context; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; import android.os.Environment; import android.text.TextUtils; import java.io.File; -import timber.log.Timber; - /** * Created by Remix on 2016/6/14. */ public class DiskCache { - private static DiskLruCache mLrcCache; - - public static void init(Context context, String name) { - try { - File lrcCacheDir = getDiskCacheDir(context, name); - if (!lrcCacheDir.exists()) { - lrcCacheDir.mkdir(); - } - mLrcCache = DiskLruCache.open(lrcCacheDir, getAppVersion(context), 1, 200 * MB); - } catch (Exception e) { - Timber.e(e); - } - } - - public static DiskLruCache getLrcDiskCache() { - return mLrcCache; - } - public static File getDiskCacheDir(Context context, String uniqueName) { String cachePath = ""; if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) @@ -59,14 +35,4 @@ public static File getDiskCacheDir(Context context, String uniqueName) { return new File(cachePath + File.separator + uniqueName); } - public static int getAppVersion(Context context) { - try { - PackageInfo info = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); - return info.versionCode; - } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); - } - return 1; - } - } diff --git a/app/src/main/java/remix/myplayer/misc/cache/DiskLruCache.java b/app/src/main/java/remix/myplayer/misc/cache/DiskLruCache.java deleted file mode 100644 index d12603bcd..000000000 --- a/app/src/main/java/remix/myplayer/misc/cache/DiskLruCache.java +++ /dev/null @@ -1,947 +0,0 @@ -/* - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package remix.myplayer.misc.cache; - -import java.io.BufferedInputStream; -import java.io.BufferedWriter; -import java.io.Closeable; -import java.io.EOFException; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.FileWriter; -import java.io.FilterOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.Reader; -import java.io.StringWriter; -import java.io.Writer; -import java.lang.reflect.Array; -import java.nio.charset.Charset; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import timber.log.Timber; - -/** - * ***************************************************************************** Taken from the JB source code, can be - * found in: libcore/luni/src/main/java/libcore/io/DiskLruCache.java or direct link: - * https://android.googlesource.com/platform/libcore/+/android-4.1.1_r1/luni/src/main/java/libcore/io/DiskLruCache.java - * ***************************************************************************** - *

- * A cache that uses a bounded amount of space on a filesystem. Each cache entry has a string key and a fixed number of - * values. Values are byte sequences, accessible as streams or files. Each value must be between {@code 0} and {@code - * Integer.MAX_VALUE} bytes in length. - * - *

The cache stores its data in a directory on the filesystem. This - * directory must be exclusive to the cache; the cache may deleteSongs or overwrite files from its directory. It is an - * error for multiple processes to use the same cache directory at the same time. - * - *

This cache limits the number of bytes that it will store on the - * filesystem. When the number of stored bytes exceeds the limit, the cache will remove entries in the background until - * the limit is satisfied. The limit is not strict: the cache may temporarily exceed it while waiting for files to be - * deleted. The limit does not include filesystem overhead or the cache journal so space-sensitive applications should - * set a conservative limit. - * - *

Clients call {@link #edit} to create or update the values of an entry. An - * entry may have only one editor at one time; if a value is not available to be edited then {@link #edit} will return - * null. - *

    - *
  • When an entry is being created it is necessary to - * supply a full set of values; the empty value should be used as a placeholder if necessary. - *
  • When an entry is being edited, it is not necessary - * to supply data for every value; values default to their previous value. - *
- * Every {@link #edit} call must be matched by a call to {@link Editor#commit} or {@link Editor#abort}. Committing is - * atomic: a read observes the full set of values as they were before or after the commit, but never a mix of values. - * - *

Clients call {@link #get} to read a snapshot of an entry. The read will - * observe the value at the time that {@link #get} was called. Updates and removals after the call do not impact ongoing - * reads. - * - *

This class is tolerant of some I/O errors. If files are missing from the - * filesystem, the corresponding entries will be dropped from the cache. If an error occurs while writing a cache value, - * the edit will fail silently. Callers should handle other problems by catching {@code IOException} and responding - * appropriately. - */ -public final class DiskLruCache implements Closeable { - - static final String JOURNAL_FILE = "journal"; - static final String JOURNAL_FILE_TMP = "journal.tmp"; - static final String MAGIC = "libcore.io.DiskLruCache"; - static final String VERSION_1 = "1"; - static final long ANY_SEQUENCE_NUMBER = -1; - private static final String CLEAN = "CLEAN"; - private static final String DIRTY = "DIRTY"; - private static final String REMOVE = "REMOVE"; - private static final String READ = "READ"; - - private static final Charset UTF_8 = Charset.forName("UTF-8"); - private static final int IO_BUFFER_SIZE = 8 * 1024; - - /* - * This cache uses a journal file named "journal". A typical journal file - * looks like this: - * libcore.io.DiskLruCache - * 1 - * 100 - * 2 - * - * CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054 - * DIRTY 335c4c6028171cfddfbaae1a9c313c52 - * CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342 - * REMOVE 335c4c6028171cfddfbaae1a9c313c52 - * DIRTY 1ab96a171faeeee38496d8b330771a7a - * CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234 - * READ 335c4c6028171cfddfbaae1a9c313c52 - * READ 3400330d1dfc7f3f7f4b8d4d803dfcf6 - * - * The first five lines of the journal form its header. They are the - * constant string "libcore.io.DiskLruCache", the disk cache's version, - * the application's version, the value count, and a blank line. - * - * Each of the subsequent lines in the file is a record of the state of a - * cache entry. Each line contains space-separated values: a state, a key, - * and optional state-specific values. - * o DIRTY lines track that an entry is actively being created or updated. - * Every successful DIRTY action should be followed by a CLEAN or REMOVE - * action. DIRTY lines without a matching CLEAN or REMOVE indicate that - * temporary files may need to be deleted. - * o CLEAN lines track a cache entry that has been successfully published - * and may be read. A publish line is followed by the lengths of each of - * its values. - * o READ lines track accesses for LRU. - * o REMOVE lines track entries that have been deleted. - * - * The journal file is appended to as cache operations occur. The journal may - * occasionally be compacted by dropping redundant lines. A temporary file named - * "journal.tmp" will be used during compaction; that file should be deleted if - * it exists when the cache is opened. - */ - - private final File directory; - private final File journalFile; - private final File journalFileTmp; - private final int appVersion; - private final long maxSize; - private final int valueCount; - private long size = 0; - private Writer journalWriter; - private final LinkedHashMap lruEntries - = new LinkedHashMap(0, 0.75f, true); - private int redundantOpCount; - - /** - * To differentiate between old and current snapshots, each entry is given a sequence number each time an edit is - * committed. A snapshot is stale if its sequence number is not equal to its entry's sequence number. - */ - private long nextSequenceNumber = 0; - - /* From java.util.Arrays */ - @SuppressWarnings("unchecked") - private static T[] copyOfRange(T[] original, int start, int end) { - final int originalLength = original.length; // For exception priority compatibility. - if (start > end) { - throw new IllegalArgumentException(); - } - if (start < 0 || start > originalLength) { - throw new ArrayIndexOutOfBoundsException(); - } - final int resultLength = end - start; - final int copyLength = Math.min(resultLength, originalLength - start); - final T[] result = (T[]) Array - .newInstance(original.getClass().getComponentType(), resultLength); - System.arraycopy(original, start, result, 0, copyLength); - return result; - } - - /** - * Returns the remainder of 'reader' as a string, closing it when done. - */ - public static String readFully(Reader reader) throws IOException { - try { - StringWriter writer = new StringWriter(); - char[] buffer = new char[1024]; - int count; - while ((count = reader.read(buffer)) != -1) { - writer.write(buffer, 0, count); - } - return writer.toString(); - } finally { - reader.close(); - } - } - - /** - * Returns the ASCII characters up to but not including the next "\r\n", or "\n". - * - * @throws java.io.EOFException if the stream is exhausted before the next newline character. - */ - public static String readAsciiLine(InputStream in) throws IOException { - // TODO: support UTF-8 here instead - - StringBuilder result = new StringBuilder(80); - while (true) { - int c = in.read(); - if (c == -1) { - throw new EOFException(); - } else if (c == '\n') { - break; - } - - result.append((char) c); - } - int length = result.length(); - if (length > 0 && result.charAt(length - 1) == '\r') { - result.setLength(length - 1); - } - return result.toString(); - } - - /** - * Closes 'closeable', ignoring any checked exceptions. Does nothing if 'closeable' is null. - */ - public static void closeQuietly(Closeable closeable) { - if (closeable != null) { - try { - closeable.close(); - } catch (RuntimeException rethrown) { - throw rethrown; - } catch (Exception ignored) { - } - } - } - - /** - * Recursively deleteSongs everything in {@code dir}. - */ - // TODO: this should specify paths as Strings rather than as Files - public static void deleteContents(File dir) throws IOException { - File[] files = dir.listFiles(); - if (files == null) { - throw new IllegalArgumentException("not a directory: " + dir); - } - for (File file : files) { - if (file.isDirectory()) { - deleteContents(file); - } - if (!file.delete()) { - throw new IOException("failed to deleteSongs file: " + file); - } - } - } - - /** - * This cache uses a single background thread to evict entries. - */ - private final ExecutorService executorService = new ThreadPoolExecutor(0, 1, - 60L, TimeUnit.SECONDS, new LinkedBlockingQueue()); - private final Callable cleanupCallable = new Callable() { - @Override - public Void call() throws Exception { - synchronized (DiskLruCache.this) { - if (journalWriter == null) { - return null; // closed - } - trimToSize(); - if (journalRebuildRequired()) { - rebuildJournal(); - redundantOpCount = 0; - } - } - return null; - } - }; - - private DiskLruCache(File directory, int appVersion, int valueCount, long maxSize) { - this.directory = directory; - this.appVersion = appVersion; - this.journalFile = new File(directory, JOURNAL_FILE); - this.journalFileTmp = new File(directory, JOURNAL_FILE_TMP); - this.valueCount = valueCount; - this.maxSize = maxSize; - } - - /** - * Opens the cache in {@code directory}, creating a cache if none exists there. - * - * @param directory a writable directory - * @param valueCount the number of values per cache entry. Must be positive. - * @param maxSize the maximum number of bytes this cache should use to store - * @throws java.io.IOException if reading or writing the cache directory fails - */ - public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize) - throws IOException { - if (maxSize <= 0) { - throw new IllegalArgumentException("maxSize <= 0"); - } - if (valueCount <= 0) { - throw new IllegalArgumentException("valueCount <= 0"); - } - - // prefer to pick up where we left off - DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); - if (cache.journalFile.exists()) { - try { - cache.readJournal(); - cache.processJournal(); - cache.journalWriter = new BufferedWriter(new FileWriter(cache.journalFile, true), - IO_BUFFER_SIZE); - return cache; - } catch (IOException journalIsCorrupt) { - Timber.v("DiskLruCache " + directory + " is corrupt: " - + journalIsCorrupt.getMessage() + ", removing"); - cache.delete(); - } - } - - // create a new empty cache - directory.mkdirs(); - cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); - cache.rebuildJournal(); - return cache; - } - - private void readJournal() throws IOException { - InputStream in = new BufferedInputStream(new FileInputStream(journalFile), IO_BUFFER_SIZE); - try { - String magic = readAsciiLine(in); - String version = readAsciiLine(in); - String appVersionString = readAsciiLine(in); - String valueCountString = readAsciiLine(in); - String blank = readAsciiLine(in); - if (!MAGIC.equals(magic) - || !VERSION_1.equals(version) - || !Integer.toString(appVersion).equals(appVersionString) - || !Integer.toString(valueCount).equals(valueCountString) - || !"".equals(blank)) { - throw new IOException("unexpected journal header: [" - + magic + ", " + version + ", " + valueCountString + ", " + blank + "]"); - } - - while (true) { - try { - readJournalLine(readAsciiLine(in)); - } catch (EOFException endOfJournal) { - break; - } - } - } finally { - closeQuietly(in); - } - } - - private void readJournalLine(String line) throws IOException { - String[] parts = line.split(" "); - if (parts.length < 2) { - throw new IOException("unexpected journal line: " + line); - } - - String key = parts[1]; - if (parts[0].equals(REMOVE) && parts.length == 2) { - lruEntries.remove(key); - return; - } - - Entry entry = lruEntries.get(key); - if (entry == null) { - entry = new Entry(key); - lruEntries.put(key, entry); - } - - if (parts[0].equals(CLEAN) && parts.length == 2 + valueCount) { - entry.readable = true; - entry.currentEditor = null; - entry.setLengths(copyOfRange(parts, 2, parts.length)); - } else if (parts[0].equals(DIRTY) && parts.length == 2) { - entry.currentEditor = new Editor(entry); - } else if (parts[0].equals(READ) && parts.length == 2) { - // this work was already done by calling lruEntries.get() - } else { - throw new IOException("unexpected journal line: " + line); - } - } - - /** - * Computes the initial size and collects garbage as a part of opening the cache. Dirty entries are assumed to be - * inconsistent and will be deleted. - */ - private void processJournal() throws IOException { - deleteIfExists(journalFileTmp); - for (Iterator i = lruEntries.values().iterator(); i.hasNext(); ) { - Entry entry = i.next(); - if (entry.currentEditor == null) { - for (int t = 0; t < valueCount; t++) { - size += entry.lengths[t]; - } - } else { - entry.currentEditor = null; - for (int t = 0; t < valueCount; t++) { - deleteIfExists(entry.getCleanFile(t)); - deleteIfExists(entry.getDirtyFile(t)); - } - i.remove(); - } - } - } - - /** - * Creates a new journal that omits redundant information. This replaces the current journal if it exists. - */ - private synchronized void rebuildJournal() throws IOException { - if (journalWriter != null) { - journalWriter.close(); - } - - Writer writer = new BufferedWriter(new FileWriter(journalFileTmp), IO_BUFFER_SIZE); - writer.write(MAGIC); - writer.write("\n"); - writer.write(VERSION_1); - writer.write("\n"); - writer.write(Integer.toString(appVersion)); - writer.write("\n"); - writer.write(Integer.toString(valueCount)); - writer.write("\n"); - writer.write("\n"); - - for (Entry entry : lruEntries.values()) { - if (entry.currentEditor != null) { - writer.write(DIRTY + ' ' + entry.key + '\n'); - } else { - writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); - } - } - - writer.close(); - journalFileTmp.renameTo(journalFile); - journalWriter = new BufferedWriter(new FileWriter(journalFile, true), IO_BUFFER_SIZE); - } - - private static void deleteIfExists(File file) throws IOException { -// try { -// Libcore.os.remove(file.getPath()); -// } catch (ErrnoException errnoException) { -// if (errnoException.errno != OsConstants.ENOENT) { -// throw errnoException.rethrowAsIOException(); -// } -// } - if (file.exists() && !file.delete()) { - throw new IOException(); - } - } - - /** - * Returns a snapshot of the entry named {@code key}, or null if it doesn't exist is not currently readable. If a - * value is returned, it is moved to the head of the LRU queue. - */ - public synchronized Snapshot get(String key) throws IOException { - checkNotClosed(); - validateKey(key); - Entry entry = lruEntries.get(key); - if (entry == null) { - return null; - } - - if (!entry.readable) { - return null; - } - - /* - * Open all streams eagerly to guarantee that we see a single published - * snapshot. If we opened streams lazily then the streams could come - * from different edits. - */ - InputStream[] ins = new InputStream[valueCount]; - try { - for (int i = 0; i < valueCount; i++) { - ins[i] = new FileInputStream(entry.getCleanFile(i)); - } - } catch (FileNotFoundException e) { - // a file must have been deleted manually! - return null; - } - - redundantOpCount++; - journalWriter.append(READ + ' ' + key + '\n'); - if (journalRebuildRequired()) { - executorService.submit(cleanupCallable); - } - - return new Snapshot(key, entry.sequenceNumber, ins); - } - - /** - * Returns an editor for the entry named {@code key}, or null if another edit is in progress. - */ - public Editor edit(String key) throws IOException { - return edit(key, ANY_SEQUENCE_NUMBER); - } - - private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException { - checkNotClosed(); - validateKey(key); - Entry entry = lruEntries.get(key); - if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER - && (entry == null || entry.sequenceNumber != expectedSequenceNumber)) { - return null; // snapshot is stale - } - if (entry == null) { - entry = new Entry(key); - lruEntries.put(key, entry); - } else if (entry.currentEditor != null) { - return null; // another edit is in progress - } - - Editor editor = new Editor(entry); - entry.currentEditor = editor; - - // flush the journal before creating files to prevent file leaks - journalWriter.write(DIRTY + ' ' + key + '\n'); - journalWriter.flush(); - return editor; - } - - /** - * Returns the directory where this cache stores its data. - */ - public File getDirectory() { - return directory; - } - - /** - * Returns the maximum number of bytes that this cache should use to store its data. - */ - public long maxSize() { - return maxSize; - } - - /** - * Returns the number of bytes currently being used to store the values in this cache. This may be greater than the - * max size if a background deletion is pending. - */ - public synchronized long size() { - return size; - } - - private synchronized void completeEdit(Editor editor, boolean success) throws IOException { - Entry entry = editor.entry; - if (entry.currentEditor != editor) { - throw new IllegalStateException(); - } - - // if this edit is creating the entry for the first time, every index must have a value - if (success && !entry.readable) { - for (int i = 0; i < valueCount; i++) { - if (!entry.getDirtyFile(i).exists()) { - editor.abort(); - throw new IllegalStateException("edit didn't create file " + i); - } - } - } - - for (int i = 0; i < valueCount; i++) { - File dirty = entry.getDirtyFile(i); - if (success) { - if (dirty.exists()) { - File clean = entry.getCleanFile(i); - dirty.renameTo(clean); - long oldLength = entry.lengths[i]; - long newLength = clean.length(); - entry.lengths[i] = newLength; - size = size - oldLength + newLength; - } - } else { - deleteIfExists(dirty); - } - } - - redundantOpCount++; - entry.currentEditor = null; - if (entry.readable | success) { - entry.readable = true; - journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); - if (success) { - entry.sequenceNumber = nextSequenceNumber++; - } - } else { - lruEntries.remove(entry.key); - journalWriter.write(REMOVE + ' ' + entry.key + '\n'); - } - - if (size > maxSize || journalRebuildRequired()) { - executorService.submit(cleanupCallable); - } - } - - /** - * We only rebuild the journal when it will halve the size of the journal and eliminate at least 2000 ops. - */ - private boolean journalRebuildRequired() { - final int REDUNDANT_OP_COMPACT_THRESHOLD = 2000; - return redundantOpCount >= REDUNDANT_OP_COMPACT_THRESHOLD - && redundantOpCount >= lruEntries.size(); - } - - /** - * Drops the entry for {@code key} if it exists and can be removed. Entries actively being edited cannot be removed. - * - * @return true if an entry was removed. - */ - public synchronized boolean remove(String key) throws IOException { - checkNotClosed(); - validateKey(key); - Entry entry = lruEntries.get(key); - if (entry == null || entry.currentEditor != null) { - return false; - } - - for (int i = 0; i < valueCount; i++) { - File file = entry.getCleanFile(i); - if (!file.delete()) { - throw new IOException("failed to delete " + file); - } - size -= entry.lengths[i]; - entry.lengths[i] = 0; - } - - redundantOpCount++; - journalWriter.append(REMOVE + ' ' + key + '\n'); - lruEntries.remove(key); - - if (journalRebuildRequired()) { - executorService.submit(cleanupCallable); - } - - return true; - } - - /** - * Returns true if this cache has been closed. - */ - public boolean isClosed() { - return journalWriter == null; - } - - private void checkNotClosed() { - if (journalWriter == null) { - throw new IllegalStateException("cache is closed"); - } - } - - /** - * Force buffered operations to the filesystem. - */ - public synchronized void flush() throws IOException { - checkNotClosed(); - trimToSize(); - journalWriter.flush(); - } - - /** - * Closes this cache. Stored values will remain on the filesystem. - */ - public synchronized void close() throws IOException { - if (journalWriter == null) { - return; // already closed - } - for (Entry entry : new ArrayList(lruEntries.values())) { - if (entry.currentEditor != null) { - entry.currentEditor.abort(); - } - } - trimToSize(); - journalWriter.close(); - journalWriter = null; - } - - private void trimToSize() throws IOException { - while (size > maxSize) { -// Map.Entry toEvict = lruEntries.eldest(); - final Map.Entry toEvict = lruEntries.entrySet().iterator().next(); - remove(toEvict.getKey()); - } - } - - /** - * Closes the cache and deletes all of its stored values. This will deleteSongs all files in the cache directory - * including files that weren't created by the cache. - */ - public void delete() throws IOException { - close(); - deleteContents(directory); - } - - private void validateKey(String key) { - if (key.contains(" ") || key.contains("\n") || key.contains("\r")) { - throw new IllegalArgumentException( - "keys must not contain spaces or newlines: \"" + key + "\""); - } - } - - private static String inputStreamToString(InputStream in) throws IOException { - return readFully(new InputStreamReader(in, UTF_8)); - } - - /** - * A snapshot of the values for an entry. - */ - public final class Snapshot implements Closeable { - - private final String key; - private final long sequenceNumber; - private final InputStream[] ins; - - private Snapshot(String key, long sequenceNumber, InputStream[] ins) { - this.key = key; - this.sequenceNumber = sequenceNumber; - this.ins = ins; - } - - /** - * Returns an editor for this snapshot's entry, or null if either the entry has changed since this snapshot was - * created or if another edit is in progress. - */ - public Editor edit() throws IOException { - return DiskLruCache.this.edit(key, sequenceNumber); - } - - /** - * Returns the unbuffered stream with the value for {@code index}. - */ - public InputStream getInputStream(int index) { - return ins[index]; - } - - /** - * Returns the string value for {@code index}. - */ - public String getString(int index) throws IOException { - return inputStreamToString(getInputStream(index)); - } - - @Override - public void close() { - for (InputStream in : ins) { - closeQuietly(in); - } - } - } - - /** - * Edits the values for an entry. - */ - public final class Editor { - - private final Entry entry; - private boolean hasErrors; - - private Editor(Entry entry) { - this.entry = entry; - } - - /** - * Returns an unbuffered input stream to read the last committed value, or null if no value has been committed. - */ - public InputStream newInputStream(int index) throws IOException { - synchronized (DiskLruCache.this) { - if (entry.currentEditor != this) { - throw new IllegalStateException(); - } - if (!entry.readable) { - return null; - } - return new FileInputStream(entry.getCleanFile(index)); - } - } - - /** - * Returns the last committed value as a string, or null if no value has been committed. - */ - public String getString(int index) throws IOException { - InputStream in = newInputStream(index); - return in != null ? inputStreamToString(in) : null; - } - - /** - * Returns a new unbuffered output stream to write the value at {@code index}. If the underlying output stream - * encounters errors when writing to the filesystem, this edit will be aborted when {@link #commit} is called. The - * returned output stream does not throw IOExceptions. - */ - public OutputStream newOutputStream(int index) throws IOException { - synchronized (DiskLruCache.this) { - if (entry.currentEditor != this) { - throw new IllegalStateException(); - } - return new FaultHidingOutputStream(new FileOutputStream(entry.getDirtyFile(index))); - } - } - - /** - * Sets the value at {@code index} to {@code value}. - */ - public void set(int index, String value) throws IOException { - Writer writer = null; - try { - writer = new OutputStreamWriter(newOutputStream(index), UTF_8); - writer.write(value); - } finally { - closeQuietly(writer); - } - } - - /** - * Commits this edit so it is visible to readers. This releases the edit lock so another edit may be started on the - * same key. - */ - public void commit() throws IOException { - if (hasErrors) { - completeEdit(this, false); - remove(entry.key); // the previous entry is stale - } else { - completeEdit(this, true); - } - } - - /** - * Aborts this edit. This releases the edit lock so another edit may be started on the same key. - */ - public void abort() throws IOException { - completeEdit(this, false); - } - - private class FaultHidingOutputStream extends FilterOutputStream { - - private FaultHidingOutputStream(OutputStream out) { - super(out); - } - - @Override - public void write(int oneByte) { - try { - out.write(oneByte); - } catch (IOException e) { - hasErrors = true; - } - } - - @Override - public void write(byte[] buffer, int offset, int length) { - try { - out.write(buffer, offset, length); - } catch (IOException e) { - hasErrors = true; - } - } - - @Override - public void close() { - try { - out.close(); - } catch (IOException e) { - hasErrors = true; - } - } - - @Override - public void flush() { - try { - out.flush(); - } catch (IOException e) { - hasErrors = true; - } - } - } - } - - private final class Entry { - - private final String key; - - /** - * Lengths of this entry's files. - */ - private final long[] lengths; - - /** - * True if this entry has ever been published - */ - private boolean readable; - - /** - * The ongoing edit or null if this entry is not being edited. - */ - private Editor currentEditor; - - /** - * The sequence number of the most recently committed edit to this entry. - */ - private long sequenceNumber; - - private Entry(String key) { - this.key = key; - this.lengths = new long[valueCount]; - } - - public String getLengths() throws IOException { - StringBuilder result = new StringBuilder(); - for (long size : lengths) { - result.append(' ').append(size); - } - return result.toString(); - } - - /** - * Set lengths using decimal numbers like "10123". - */ - private void setLengths(String[] strings) throws IOException { - if (strings.length != valueCount) { - throw invalidLengths(strings); - } - - try { - for (int i = 0; i < strings.length; i++) { - lengths[i] = Long.parseLong(strings[i]); - } - } catch (NumberFormatException e) { - throw invalidLengths(strings); - } - } - - private IOException invalidLengths(String[] strings) throws IOException { - throw new IOException("unexpected journal line: " + Arrays.toString(strings)); - } - - public File getCleanFile(int i) { - return new File(directory, key + "." + i); - } - - public File getDirtyFile(int i) { - return new File(directory, key + "." + i + ".tmp"); - } - } -} diff --git a/app/src/main/java/remix/myplayer/misc/menu/AudioPopupListener.kt b/app/src/main/java/remix/myplayer/misc/menu/AudioPopupListener.kt index ba0daff4e..e5ec28ad9 100644 --- a/app/src/main/java/remix/myplayer/misc/menu/AudioPopupListener.kt +++ b/app/src/main/java/remix/myplayer/misc/menu/AudioPopupListener.kt @@ -1,7 +1,7 @@ package remix.myplayer.misc.menu +import android.content.ActivityNotFoundException import android.content.ContextWrapper -import android.content.Intent import android.view.MenuItem import android.webkit.MimeTypeMap import android.widget.CompoundButton @@ -13,7 +13,11 @@ import remix.myplayer.bean.mp3.Song import remix.myplayer.db.room.DatabaseRepository import remix.myplayer.helper.DeleteHelper import remix.myplayer.helper.EQHelper +import remix.myplayer.helper.MusicServiceRemote import remix.myplayer.helper.MusicServiceRemote.getCurrentSong +import remix.myplayer.lyrics.provider.EmbeddedProvider +import remix.myplayer.lyrics.provider.IgnoredProvider +import remix.myplayer.lyrics.provider.StubProvider import remix.myplayer.service.Command import remix.myplayer.theme.Theme.getBaseDialog import remix.myplayer.ui.ViewCommon @@ -21,12 +25,10 @@ import remix.myplayer.ui.activity.PlayerActivity import remix.myplayer.ui.dialog.AddtoPlayListDialog import remix.myplayer.ui.dialog.TimerDialog import remix.myplayer.ui.misc.AudioTag -import remix.myplayer.util.MusicUtil import remix.myplayer.util.RxUtil.applySingleScheduler import remix.myplayer.util.SPUtil import remix.myplayer.util.ToastUtil import remix.myplayer.util.Util -import remix.myplayer.util.Util.sendLocalBroadcast import java.lang.ref.WeakReference /** @@ -141,86 +143,39 @@ class AudioPopupListener(activity: PlayerActivity, private val song: Song) : } return true } - + private fun onClickLyric(activity: PlayerActivity) { - val alreadyIgnore = (SPUtil - .getValue( - ref.get(), SPUtil.LYRIC_KEY.NAME, song.id.toString(), - SPUtil.LYRIC_KEY.LYRIC_DEFAULT - ) == SPUtil.LYRIC_KEY.LYRIC_IGNORE) - - val lyricFragment = ref.get()?.lyricFragment ?: return - getBaseDialog(ref.get()) - .items( + getBaseDialog(activity).items( + getString(R.string.default_lyrics), getString(R.string.embedded_lyric), getString(R.string.local), getString(R.string.kugou), getString(R.string.netease), getString(R.string.qq), getString(R.string.select_lrc), - getString(if (!alreadyIgnore) R.string.ignore_lrc else R.string.cancel_ignore_lrc), + getString(R.string.ignore_lrc), getString(R.string.lyric_adjust_font_size), getString(R.string.change_offset) - ) - .itemsCallback { dialog, itemView, position, text -> + ).itemsCallback { _, _, position, _ -> when (position) { - 0, 1, 2, 3, 4 -> { //0内嵌 1本地 2酷狗 3网易 4qq - SPUtil.putValue(ref.get(), SPUtil.LYRIC_KEY.NAME, song.id.toString(), position + 2) - lyricFragment.updateLrc(song, true) - sendLocalBroadcast(MusicUtil.makeCmdIntent(Command.CHANGE_LYRIC)) - } - - 5 -> { //手动选择歌词 - val intent = Intent(Intent.ACTION_GET_CONTENT).apply { - type = MimeTypeMap.getSingleton().getMimeTypeFromExtension("lrc") - addCategory(Intent.CATEGORY_OPENABLE) - } - Util.startActivityForResultSafely( - activity, - intent, - PlayerActivity.REQUEST_SELECT_LYRIC - ) - } - - 6 -> { //忽略或者取消忽略 - getBaseDialog(activity) - .title(if (!alreadyIgnore) R.string.confirm_ignore_lrc else R.string.confirm_cancel_ignore_lrc) - .negativeText(R.string.cancel) - .positiveText(R.string.confirm) - .onPositive { dialog1, which -> - if (!alreadyIgnore) {//忽略 - SPUtil.putValue( - activity, SPUtil.LYRIC_KEY.NAME, song.id.toString(), - SPUtil.LYRIC_KEY.LYRIC_IGNORE - ) - lyricFragment.updateLrc(song) - } else {//取消忽略 - SPUtil.putValue( - activity, SPUtil.LYRIC_KEY.NAME, song.id.toString(), - SPUtil.LYRIC_KEY.LYRIC_DEFAULT - ) - lyricFragment.updateLrc(song) - } - sendLocalBroadcast(MusicUtil.makeCmdIntent(Command.CHANGE_LYRIC)) - } - .show() - } - - 7 -> { //字体大小调整 - getBaseDialog(ref.get()) - .items(R.array.lyric_font_size) - .itemsCallback { dialog, itemView, position, text -> - lyricFragment.setLyricScalingFactor(position) - } - .show() - } - - 8 -> { //歌词时间轴调整 - activity.showLyricOffsetView() + 0 -> MusicServiceRemote.service?.updateLyrics(StubProvider) // 恢复默认 + 1 -> MusicServiceRemote.service?.updateLyrics(EmbeddedProvider) // 内嵌 + 2, 3, 4, 5 -> TODO() // 本地 酷狗 网易 QQ + 6 -> try { + activity.getContent.launch(lrcMimeType) // 手动选择 + } catch (e: ActivityNotFoundException) { + ToastUtil.show(activity, R.string.activity_not_found_tip) } + + 7 -> MusicServiceRemote.service?.updateLyrics(IgnoredProvider) // 忽略 + 8 -> TODO() // 调整字体大小 + 9 -> activity.showLyricOffsetView()// 调整时间轴 } - - } - .show() + }.show() + } + + companion object { + private val lrcMimeType: String + get() = MimeTypeMap.getSingleton().getMimeTypeFromExtension("lrc") ?: "*/*" } } diff --git a/app/src/main/java/remix/myplayer/service/Command.java b/app/src/main/java/remix/myplayer/service/Command.java index 9a1a5107c..15ce03c4e 100644 --- a/app/src/main/java/remix/myplayer/service/Command.java +++ b/app/src/main/java/remix/myplayer/service/Command.java @@ -17,7 +17,7 @@ public interface Command { int UNLOCK_DESKTOP_LYRIC = 11; int CLOSE_NOTIFY = 12; int ADD_TO_NEXT_SONG = 13; - int CHANGE_LYRIC = 14; +// int CHANGE_LYRIC = 14; int PLAY_AT_BREAKPOINT = 15; int TOGGLE_TIMER = 16; int TOGGLE_DESKTOP_LYRIC = 17; diff --git a/app/src/main/java/remix/myplayer/service/MusicService.kt b/app/src/main/java/remix/myplayer/service/MusicService.kt index 4fc1b6966..d672bbdda 100644 --- a/app/src/main/java/remix/myplayer/service/MusicService.kt +++ b/app/src/main/java/remix/myplayer/service/MusicService.kt @@ -24,7 +24,6 @@ import android.view.Gravity import android.view.ViewGroup import android.view.WindowManager import androidx.annotation.WorkerThread -import androidx.core.app.ServiceCompat import androidx.media.AudioAttributesCompat import androidx.media.AudioFocusRequestCompat import androidx.media.AudioManagerCompat @@ -33,6 +32,7 @@ import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import kotlinx.coroutines.* import remix.myplayer.App +import remix.myplayer.BuildConfig import remix.myplayer.R import remix.myplayer.appwidgets.BaseAppwidget import remix.myplayer.appwidgets.big.AppWidgetBig @@ -45,9 +45,9 @@ import remix.myplayer.bean.mp3.Song.Companion.EMPTY_SONG import remix.myplayer.db.room.DatabaseRepository import remix.myplayer.db.room.model.PlayQueue import remix.myplayer.helper.* -import remix.myplayer.lyric.LyricFetcher -import remix.myplayer.lyric.LyricFetcher.Companion.LYRIC_FIND_INTERVAL -import remix.myplayer.lyric.bean.LyricRowWrapper +import remix.myplayer.lyrics.LyricsLine +import remix.myplayer.lyrics.LyricsSearcher +import remix.myplayer.lyrics.provider.ILyricsProvider import remix.myplayer.misc.floatpermission.FloatWindowManager import remix.myplayer.misc.getPendingIntentFlag import remix.myplayer.misc.log.LogObserver @@ -66,10 +66,11 @@ import remix.myplayer.ui.activity.LockScreenActivity import remix.myplayer.ui.activity.base.BaseMusicActivity import remix.myplayer.ui.activity.base.BaseMusicActivity.Companion.EXTRA_PERMISSION import remix.myplayer.ui.activity.base.BaseMusicActivity.Companion.EXTRA_PLAYLIST -import remix.myplayer.ui.widget.desktop.DesktopLyricView +import remix.myplayer.ui.widget.desktop.DesktopLyricsView import remix.myplayer.util.* import remix.myplayer.util.Constants.* import remix.myplayer.util.RxUtil.applySingleScheduler +import remix.myplayer.util.SPUtil.DESKTOP_LYRICS_KEY import remix.myplayer.util.SPUtil.SETTING_KEY import remix.myplayer.util.Util.isAppOnForeground import remix.myplayer.util.Util.registerLocalReceiver @@ -78,6 +79,7 @@ import remix.myplayer.util.Util.unregisterLocalReceiver import timber.log.Timber import java.lang.ref.WeakReference import java.util.* +import kotlin.time.Duration.Companion.milliseconds /** * Created by Remix on 2015/12/1. @@ -256,6 +258,41 @@ class MusicService : BaseService(), Playback, MusicEventCallback, getSystemService(Context.WINDOW_SERVICE) as WindowManager } + /** + * 当前歌曲对应的歌词及偏移 + */ + var lyrics: Deferred> = CompletableDeferred(listOf()) + private var _lyricsOffset: Int? = null + var lyricsOffset: Int + get() = + // 正常情况下只有在歌词加载完后才应该访问 offset,此时 offset 一定不为 null + _lyricsOffset ?: if (BuildConfig.DEBUG) { + throw IllegalStateException() + } else { + Timber.e("Trying to get lyricsOffset when _lyricsOffset is null") + 0 + } + set(value) { + if (_lyricsOffset == null) { + if (BuildConfig.DEBUG) { + throw IllegalStateException() + } else { + Timber.e("Trying to set lyricsOffset when _lyricsOffset is null") + return + } + } + // 仅 LyricsFragment 会修改 + _lyricsOffset = value + launch(Dispatchers.IO) { + LyricsSearcher.saveOffset(currentSong, value) + } + } + + /** + * 用于更新桌面歌词和状态栏歌词的 Job + */ + private var updateLyricsJob: Job? = null + /** * 是否显示状态栏歌词 */ @@ -273,7 +310,7 @@ class MusicService : BaseService(), Playback, MusicEventCallback, /** * 桌面歌词控件 */ - private var desktopLyricView: DesktopLyricView? = null + private var desktopLyricView: DesktopLyricsView? = null /** * service是否停止运行 @@ -371,7 +408,6 @@ class MusicService : BaseService(), Playback, MusicEventCallback, */ private var timer: Timer = Timer() private var desktopWidgetTask: WidgetTask? = null - private var lyricTask: LyricTask? = null /** * 创建桌面歌词悬浮窗 @@ -388,10 +424,7 @@ class MusicService : BaseService(), Playback, MusicEventCallback, * 桌面歌词是否锁定 */ val isDesktopLyricLocked: Boolean - get() = if (desktopLyricView == null) - SPUtil.getValue(service, SETTING_KEY.NAME, SETTING_KEY.DESKTOP_LYRIC_LOCK, false) - else - desktopLyricView?.isLocked == true + get() = SPUtil.getValue(service, DESKTOP_LYRICS_KEY.NAME, DESKTOP_LYRICS_KEY.LOCKED, false) /** * 锁屏 @@ -758,7 +791,9 @@ class MusicService : BaseService(), Playback, MusicEventCallback, uiHandler.removeCallbacksAndMessages(null) showDesktopLyric = false - lyricTask?.cancel() + uiHandler.sendEmptyMessage(UPDATE_NOTIFICATION) + updateLyricsJob?.cancel() + updateLyricsJob = null AudioManagerCompat.abandonAudioFocusRequest(audioManager, focusRequest) @@ -1167,11 +1202,6 @@ class MusicService : BaseService(), Playback, MusicEventCallback, updateAppwidget() updateNotification() updateMediaSession(operation) - //桌面歌词与状态栏歌词 - if (lyricTask == null) { - lyricTask = LyricTask() - timer.schedule(lyricTask, LYRIC_FIND_INTERVAL, LYRIC_FIND_INTERVAL) - } // 是否需要保存进度 if (playAtBreakPoint) { startSaveProgress() @@ -1193,7 +1223,7 @@ class MusicService : BaseService(), Playback, MusicEventCallback, return } //更新桌面歌词播放按钮 - desktopLyricView?.setPlayIcon(isPlaying) + desktopLyricView?.isPlaying = isPlaying sendLocalBroadcast(Intent(PLAY_STATE_CHANGE)) } @@ -1291,8 +1321,8 @@ class MusicService : BaseService(), Playback, MusicEventCallback, } //桌面歌词 Command.TOGGLE_DESKTOP_LYRIC -> { - val open: Boolean = if (intent.hasExtra(EXTRA_DESKTOP_LYRIC)) { - intent.getBooleanExtra(EXTRA_DESKTOP_LYRIC, false) + val open: Boolean = if (intent.hasExtra(EXTRA_DESKTOP_LYRICS)) { + intent.getBooleanExtra(EXTRA_DESKTOP_LYRICS, false) } else { !SPUtil.getValue(service, SETTING_KEY.NAME, @@ -1328,11 +1358,10 @@ class MusicService : BaseService(), Playback, MusicEventCallback, } //解锁桌面歌词 Command.UNLOCK_DESKTOP_LYRIC -> { - if (desktopLyricView != null) { - desktopLyricView?.saveLock(false, true) - } else { - SPUtil.putValue(service, SETTING_KEY.NAME, SETTING_KEY.DESKTOP_LYRIC_LOCK, false) - } + desktopLyricView?.run { + isLocked = false + } ?: SPUtil.putValue(service, DESKTOP_LYRICS_KEY.NAME, DESKTOP_LYRICS_KEY.LOCKED, false) + ToastUtil.show(service, R.string.desktop_lyric__unlock) //更新通知栏 updateNotification() } @@ -1349,14 +1378,6 @@ class MusicService : BaseService(), Playback, MusicEventCallback, playQueue.addNextSong(nextSong) ToastUtil.show(service, R.string.already_add_to_next_song) } - //改变歌词源 - Command.CHANGE_LYRIC -> { - lyricTask?.force = true -// if (showDesktopLyric) { -// updateDesktopLyric(true) -// } -// updateStatusBarLyric(true) - } //切换定时器 Command.TOGGLE_TIMER -> { val hasDefault = SPUtil.getValue(service, SETTING_KEY.NAME, SETTING_KEY.TIMER_DEFAULT, false) @@ -1473,6 +1494,8 @@ class MusicService : BaseService(), Playback, MusicEventCallback, * @param song 播放歌曲的路径 */ private fun prepare(song: Song, requestFocus: Boolean = true) { + // 需要保证 updateLyrics 在别的地方使用 lyrics 前执行 + updateLyrics(null) tryLaunch( block = { Timber.v("prepare start: %s", song) @@ -1734,68 +1757,62 @@ class MusicService : BaseService(), Playback, MusicEventCallback, } } - fun setLyricOffset(offset: Int) { - lyricTask?.lyricFetcher?.offset = offset - } - - private inner class LyricTask : TimerTask() { - private var prev: CharSequence = "" - val lyricFetcher = LyricFetcher(this@MusicService) - var force = false - - override fun run() { - if (!showDesktopLyric) { - if (isDesktopLyricShowing) { - uiHandler.sendEmptyMessage(REMOVE_DESKTOP_LRC) - } - if (!showStatusBarLyric || stop) { - return - } + fun updateLyrics(provider: ILyricsProvider?) { + lyrics.cancel() + _lyricsOffset = null + lyrics = async(Dispatchers.IO) { + LyricsSearcher.getLyricsAndOffset(currentSong, provider).run { + ensureActive() + _lyricsOffset = second + first } + } + // TODO: 仅在需要时运行,不需要时(暂停、熄屏?)停止 + // TODO: 重构 + updateLyricsJob?.cancel() + updateLyricsJob = launch(Dispatchers.IO) { + var prev = "" // 最近一次更新的状态栏歌词 + while (true) { + delay(UPDATE_LYRICS_INTERVAL) - val currentSong = playQueue.song - if (lyricFetcher.song != currentSong || force) { - Timber.tag(TAG_DESKTOP_LYRIC).v("重新获取歌词内容, id: ${currentSong.id}") - force = false - lyricFetcher.updateLyricRows(currentSong) - return - } + runCatching { + if (!showDesktopLyric) { + if (isDesktopLyricShowing) { + uiHandler.sendEmptyMessage(REMOVE_DESKTOP_LRC) + } + if (!showStatusBarLyric || stop) { + return@runCatching + } + } - // 桌面歌词 - val wrapper = lyricFetcher.findCurrentLyric() - Timber.tag(TAG_DESKTOP_LYRIC).v("findCurrentLyric: $wrapper") - if (!showDesktopLyric || !screenOn || checkNoPermission() || isAppOnForeground) { - if (isDesktopLyricShowing) { - Timber.tag(TAG_DESKTOP_LYRIC).v("remove desktop lyric") - uiHandler.sendEmptyMessage(REMOVE_DESKTOP_LRC) - } - } else { - if (!isDesktopLyricShowing) { - Timber.tag(TAG_DESKTOP_LYRIC).v("create desktop lyric") - uiHandler.removeMessages(CREATE_DESKTOP_LRC) - uiHandler.sendEmptyMessageDelayed(CREATE_DESKTOP_LRC, 50) - } else { - Timber.tag(TAG_DESKTOP_LYRIC).v("update desktop lyric") - uiHandler.obtainMessage(UPDATE_DESKTOP_LRC_CONTENT, wrapper).sendToTarget() - } - } + val lyrics = lyrics.await() + val content = + LyricsHelper.getDesktopLyricsContent(lyrics, lyricsOffset, progress, duration) - // 状态栏歌词 - if (showStatusBarLyric) { - if (TextUtils.equals(prev, wrapper.lineOne.content)) { - return + // 桌面歌词 + if (!showDesktopLyric || !screenOn || checkNoPermission() || isAppOnForeground) { + if (isDesktopLyricShowing) { + Timber.tag(TAG_LYRICS_TASK).v("remove desktop lyric") + uiHandler.sendEmptyMessage(REMOVE_DESKTOP_LRC) + } + } else { + if (!isDesktopLyricShowing) { + Timber.tag(TAG_LYRICS_TASK).v("create desktop lyric") + uiHandler.removeMessages(CREATE_DESKTOP_LRC) + uiHandler.sendEmptyMessageDelayed(CREATE_DESKTOP_LRC, 50) + } else { + uiHandler.obtainMessage(UPDATE_DESKTOP_LRC_CONTENT, content).sendToTarget() + } + } + + // 状态栏歌词 + if (showStatusBarLyric && !TextUtils.equals(prev, content.currentLine?.content ?: "")) { + prev = content.currentLine?.content ?: "" + uiHandler.obtainMessage(UPDATE_STATUS_BAR_LRC, prev).sendToTarget() + } } - prev = wrapper.lineOne.content - uiHandler.obtainMessage(UPDATE_STATUS_BAR_LRC, wrapper).sendToTarget() } } - - override fun cancel(): Boolean { - Timber.tag(TAG_DESKTOP_LYRIC).v("cancel task") - lyricFetcher.dispose() - uiHandler.sendEmptyMessage(UPDATE_NOTIFICATION) - return super.cancel() - } } private fun createDesktopLyric() { @@ -1816,20 +1833,20 @@ class MusicService : BaseService(), Playback, MusicEventCallback, param.format = PixelFormat.RGBA_8888 param.gravity = Gravity.TOP - param.width = resources.displayMetrics.widthPixels + param.width = ViewGroup.LayoutParams.MATCH_PARENT param.height = ViewGroup.LayoutParams.WRAP_CONTENT param.x = 0 - param.y = SPUtil.getValue(this, SETTING_KEY.NAME, SETTING_KEY.DESKTOP_LYRIC_Y, 0) + param.y = 0 if (desktopLyricView != null) { windowManager.removeView(desktopLyricView) desktopLyricView = null } - desktopLyricView = DesktopLyricView(service) + desktopLyricView = DesktopLyricsView(service) windowManager.addView(desktopLyricView, param) + desktopLyricView!!.restoreWindowPosition() isDesktopLyricInitializing = false - Timber.tag(TAG_DESKTOP_LYRIC).v("创建桌面歌词") } /** @@ -1837,7 +1854,7 @@ class MusicService : BaseService(), Playback, MusicEventCallback, */ private fun removeDesktopLyric() { if (desktopLyricView != null) { - Timber.tag(TAG_DESKTOP_LYRIC).v("移除桌面歌词") + Timber.v("移除桌面歌词") // desktopLyricView.cancelNotify(); windowManager.removeView(desktopLyricView) desktopLyricView = null @@ -1859,7 +1876,7 @@ class MusicService : BaseService(), Playback, MusicEventCallback, return } progressTask = ProgressTask() - timer.schedule(progressTask, 1000, LYRIC_FIND_INTERVAL) + timer.schedule(progressTask, 1000, SAVE_PROGRESS_INTERVAL) } private fun stopSaveProgress() { @@ -1945,10 +1962,7 @@ class MusicService : BaseService(), Playback, MusicEventCallback, musicService.handleMetaChange() } UPDATE_DESKTOP_LRC_CONTENT -> { - if (msg.obj is LyricRowWrapper) { - val wrapper = msg.obj as LyricRowWrapper - musicService.desktopLyricView?.setText(wrapper.lineOne, wrapper.lineTwo) - } + musicService.desktopLyricView?.setContent(msg.obj as DesktopLyricsView.Content) } REMOVE_DESKTOP_LRC -> { musicService.removeDesktopLyric() @@ -1957,10 +1971,7 @@ class MusicService : BaseService(), Playback, MusicEventCallback, musicService.createDesktopLyric() } UPDATE_STATUS_BAR_LRC -> { - if (msg.obj is LyricRowWrapper) { - val wrapper = msg.obj as LyricRowWrapper - musicService.updateNotificationWithLrc(wrapper.lineOne.content); - } + musicService.updateNotificationWithLrc(msg.obj as String) } UPDATE_NOTIFICATION -> { musicService.updateNotification() @@ -1996,9 +2007,9 @@ class MusicService : BaseService(), Playback, MusicEventCallback, } companion object { - const val TAG_DESKTOP_LYRIC = "LyricTask" + private const val TAG_LYRICS_TASK = "LyricsTask" const val TAG_LIFECYCLE = "ServiceLifeCycle" - const val EXTRA_DESKTOP_LYRIC = "DesktopLyric" + const val EXTRA_DESKTOP_LYRICS = "DesktopLyrics" const val EXTRA_SONG = "Song" const val EXTRA_POSITION = "Position" @@ -2074,6 +2085,8 @@ class MusicService : BaseService(), Playback, MusicEventCallback, private const val APPWIDGET_SMALL_TRANSPARENT = "AppWidgetSmallTransparent" private const val INTERVAL_APPWIDGET = 1000L + private const val SAVE_PROGRESS_INTERVAL = 500L + private val UPDATE_LYRICS_INTERVAL = 50.milliseconds // TODO: 是否合适? /** diff --git a/app/src/main/java/remix/myplayer/theme/MaterialTintHelper.kt b/app/src/main/java/remix/myplayer/theme/MaterialTintHelper.kt new file mode 100644 index 000000000..281aa3582 --- /dev/null +++ b/app/src/main/java/remix/myplayer/theme/MaterialTintHelper.kt @@ -0,0 +1,18 @@ +package remix.myplayer.theme + +import android.content.res.ColorStateList +import com.google.android.material.slider.Slider +import com.google.android.material.textfield.TextInputEditText + +/** + * Helper for theming Material Components + */ +object MaterialTintHelper { + fun setTint(slider: Slider) { + TODO() + } + + fun setTint(editText: TextInputEditText) { + TODO() + } +} diff --git a/app/src/main/java/remix/myplayer/theme/ThemeStore.kt b/app/src/main/java/remix/myplayer/theme/ThemeStore.kt index 843a17071..01141c22b 100644 --- a/app/src/main/java/remix/myplayer/theme/ThemeStore.kt +++ b/app/src/main/java/remix/myplayer/theme/ThemeStore.kt @@ -20,7 +20,6 @@ object ThemeStore { const val FOLLOW_SYSTEM = "follow_system" private const val KEY_PRIMARY_COLOR = "primary_color" private const val KEY_ACCENT_COLOR = "accent_color" - private const val KEY_FLOAT_LYRIC_TEXT_COLOR = "float_lyric_text_color" const val STATUS_BAR_ALPHA = 150 @JvmField @@ -282,23 +281,6 @@ object ThemeStore { } ) - @JvmStatic - @get:ColorInt - var floatLyricTextColor: Int - get() { - val temp = SPUtil.getValue( - App.context, KEY_NAME, KEY_FLOAT_LYRIC_TEXT_COLOR, materialPrimaryColor - ) - return if (ColorUtil.isColorCloseToWhite(temp)) { - Color.parseColor("#F9F9F9") - } else { - temp - } - } - set(value) { - SPUtil.putValue(App.context, KEY_NAME, KEY_FLOAT_LYRIC_TEXT_COLOR, value) - } - @get:ColorInt val colorOnPrimary: Int get() { diff --git a/app/src/main/java/remix/myplayer/ui/ViewCommon.kt b/app/src/main/java/remix/myplayer/ui/ViewCommon.kt index 59aab6f8f..a9a76a678 100644 --- a/app/src/main/java/remix/myplayer/ui/ViewCommon.kt +++ b/app/src/main/java/remix/myplayer/ui/ViewCommon.kt @@ -7,25 +7,25 @@ import remix.myplayer.util.SPUtil object ViewCommon { fun showLocalLyricTip(context: Context, action: () -> Unit) { - if (!SPUtil.getValue( - context, - SPUtil.LYRIC_KEY.NAME, - SPUtil.LYRIC_KEY.LYRIC_LOCAL_TIP_SHOWN, - false - ) - ) { - SPUtil.putValue(context, SPUtil.LYRIC_KEY.NAME, SPUtil.LYRIC_KEY.LYRIC_LOCAL_TIP_SHOWN, true) - Theme.getBaseDialog(context) - .negativeText(R.string.cancel) - .positiveText(R.string.confirm) - .onPositive { dialog, which -> - action.invoke() - } - .content(R.string.local_lyric_tip) - .show() - } else { - action.invoke() - } +// if (!SPUtil.getValue( +// context, +// SPUtil.LYRIC_KEY.NAME, +// SPUtil.LYRIC_KEY.LYRIC_LOCAL_TIP_SHOWN, +// false +// ) +// ) { +// SPUtil.putValue(context, SPUtil.LYRIC_KEY.NAME, SPUtil.LYRIC_KEY.LYRIC_LOCAL_TIP_SHOWN, true) +// Theme.getBaseDialog(context) +// .negativeText(R.string.cancel) +// .positiveText(R.string.confirm) +// .onPositive { dialog, which -> +// action.invoke() +// } +// .content(R.string.local_lyric_tip) +// .show() +// } else { +// action.invoke() +// } + TODO() } - } \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/ui/activity/LockScreenActivity.kt b/app/src/main/java/remix/myplayer/ui/activity/LockScreenActivity.kt index 27528bfdc..c977c1c61 100644 --- a/app/src/main/java/remix/myplayer/ui/activity/LockScreenActivity.kt +++ b/app/src/main/java/remix/myplayer/ui/activity/LockScreenActivity.kt @@ -19,15 +19,11 @@ import com.bumptech.glide.request.target.Target import io.reactivex.Single import io.reactivex.disposables.Disposable import io.reactivex.functions.Consumer +import kotlinx.coroutines.ExperimentalCoroutinesApi import remix.myplayer.R -import remix.myplayer.bean.mp3.Song import remix.myplayer.databinding.ActivityLockscreenBinding +import remix.myplayer.helper.LyricsHelper import remix.myplayer.helper.MusicServiceRemote -import remix.myplayer.lyric.LyricFetcher -import remix.myplayer.lyric.LyricFetcher.Companion.LYRIC_FIND_INTERVAL -import remix.myplayer.lyric.bean.LyricRowWrapper -import remix.myplayer.lyric.bean.LyricRowWrapper.Companion.LYRIC_WRAPPER_NO -import remix.myplayer.lyric.bean.LyricRowWrapper.Companion.LYRIC_WRAPPER_SEARCHING import remix.myplayer.misc.menu.CtrlButtonListener import remix.myplayer.service.MusicService import remix.myplayer.ui.activity.base.BaseMusicActivity @@ -58,8 +54,6 @@ class LockScreenActivity : BaseMusicActivity() { private var disposable: Disposable? = null - @Volatile - private var curLyric: LyricRowWrapper? = null private var updateLyricThread: UpdateLockScreenLyricThread? = null //前后两次触摸的X @@ -254,33 +248,16 @@ class LockScreenActivity : BaseMusicActivity() { return Palette.from(rawBitMap ?: return null).generate() } - private fun setCurrentLyric(wrapper: LyricRowWrapper) { + private fun setLyrics(lyrics: String) { runOnUiThread { - curLyric = wrapper - if (curLyric == null || curLyric === LYRIC_WRAPPER_NO) { - binding.lockscreenLyric.setTextWithAnimation(R.string.no_lrc) - } else if (curLyric === LYRIC_WRAPPER_SEARCHING) { - binding.lockscreenLyric.text = "" - } else { - binding.lockscreenLyric.setTextWithAnimation( - String.format("%s\n%s", curLyric?.lineOne?.content, - curLyric?.lineTwo?.content)) - } - + binding.lockscreenLyric.setTextWithAnimation(lyrics) } } private class UpdateLockScreenLyricThread constructor(activity: LockScreenActivity, service: MusicService) : Thread() { - private val ref: WeakReference = WeakReference(activity) - private val lyricFetcher: LyricFetcher = LyricFetcher(service) - private var songInThread: Song = Song.EMPTY_SONG - - override fun interrupt() { - super.interrupt() - lyricFetcher.dispose() - } + @OptIn(ExperimentalCoroutinesApi::class) override fun run() { while (true) { try { @@ -289,17 +266,26 @@ class LockScreenActivity : BaseMusicActivity() { return } - val song = MusicServiceRemote.getCurrentSong() - if (songInThread !== song) { - songInThread = song - lyricFetcher.updateLyricRows(songInThread) - continue + val service = MusicServiceRemote.service ?: continue + val activity = ref.get() ?: continue + try { + val lyrics = service.lyrics.getCompleted() + if (lyrics.isEmpty()) { + activity.setLyrics(activity.getString(R.string.no_lrc)) + } else { + val content = LyricsHelper.getDesktopLyricsContent( + lyrics, service.lyricsOffset, service.progress, service.duration + ) + activity.setLyrics("${content.currentLine?.content ?: ""}\n${content.nextLine ?: ""}") + } + } catch (_: IllegalStateException) { + activity.setLyrics(activity.getString(R.string.searching)) } - - val activity = ref.get() - activity?.setCurrentLyric(lyricFetcher.findCurrentLyric()) } } } + companion object { + const val LYRIC_FIND_INTERVAL = 400L + } } diff --git a/app/src/main/java/remix/myplayer/ui/activity/PlayerActivity.kt b/app/src/main/java/remix/myplayer/ui/activity/PlayerActivity.kt index 4a97b5339..c40782982 100644 --- a/app/src/main/java/remix/myplayer/ui/activity/PlayerActivity.kt +++ b/app/src/main/java/remix/myplayer/ui/activity/PlayerActivity.kt @@ -3,7 +3,6 @@ package remix.myplayer.ui.activity import android.animation.ArgbEvaluator import android.animation.ValueAnimator import android.annotation.SuppressLint -import android.app.Activity import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -26,6 +25,7 @@ import android.view.animation.Animation.AnimationListener import android.widget.ImageView import android.widget.LinearLayout import android.widget.SeekBar +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.FragmentManager @@ -49,8 +49,7 @@ import remix.myplayer.helper.MusicServiceRemote.getPlayModel import remix.myplayer.helper.MusicServiceRemote.getProgress import remix.myplayer.helper.MusicServiceRemote.isPlaying import remix.myplayer.helper.MusicServiceRemote.setPlayModel -import remix.myplayer.lyric.LrcView -import remix.myplayer.lyric.LrcView.OnLrcClickListener +import remix.myplayer.lyrics.provider.UriProvider import remix.myplayer.misc.cache.DiskCache import remix.myplayer.misc.handler.MsgHandler import remix.myplayer.misc.handler.OnHandleMessage @@ -68,10 +67,11 @@ import remix.myplayer.ui.adapter.PagerAdapter import remix.myplayer.ui.blur.StackBlurManager import remix.myplayer.ui.dialog.PlayQueueDialog import remix.myplayer.ui.dialog.PlayQueueDialog.Companion.newInstance -import remix.myplayer.ui.fragment.LyricFragment +import remix.myplayer.ui.fragment.LyricsFragment import remix.myplayer.ui.fragment.RecordFragment import remix.myplayer.ui.fragment.player.CoverFragment import remix.myplayer.ui.fragment.player.RoundCoverFragment +import remix.myplayer.ui.widget.LyricsView import remix.myplayer.util.* import remix.myplayer.util.SPUtil.SETTING_KEY import timber.log.Timber @@ -98,9 +98,6 @@ class PlayerActivity : BaseMusicActivity() { //是否正在拖动进度条 var isDragSeekBarFromUser = false - //歌词控件 - private var lrcView: LrcView? = null - //高亮与非高亮指示器 private lateinit var highLightIndicator: GradientDrawable private lateinit var normalIndicator: GradientDrawable @@ -119,7 +116,7 @@ class PlayerActivity : BaseMusicActivity() { private var duration = 0 //Fragment - lateinit var lyricFragment: LyricFragment + lateinit var lyricsFragment: LyricsFragment private set private lateinit var coverFragment: RoundCoverFragment @@ -165,6 +162,13 @@ class PlayerActivity : BaseMusicActivity() { SPUtil.getValue(this, SETTING_KEY.NAME, SETTING_KEY.PLAYER_BACKGROUND, BACKGROUND_ADAPTIVE_COLOR) } + val getContent = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { + MusicServiceRemote.service?.updateLyrics(UriProvider(it)) + lyricsFragment.updateLyrics() + } + } + override fun setUpTheme() { // if (ThemeStore.isLightTheme()) { // super.setUpTheme(); @@ -448,10 +452,10 @@ class PlayerActivity : BaseMusicActivity() { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { if (fromUser) { updateProgressText(progress) + lyricsFragment.updateProgress() } handler.sendEmptyMessage(UPDATE_TIME_ONLY) currentTime = progress - lrcView?.seekTo(progress, true, fromUser) } override fun onStartTrackingTouch(seekBar: SeekBar) { @@ -477,8 +481,7 @@ class PlayerActivity : BaseMusicActivity() { val current = audioManager.getStreamVolume(STREAM_MUSIC) runOnUiThread { - if (min != 0) { - @RequiresApi(Build.VERSION_CODES.O) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { volumeSeekbar.min = min } volumeSeekbar.max = max @@ -563,7 +566,7 @@ class PlayerActivity : BaseMusicActivity() { val fragments = fragmentManager.fragments for (fragment in fragments) { - if (fragment is LyricFragment || + if (fragment is LyricsFragment || fragment is CoverFragment<*> || fragment is RecordFragment) { fragmentManager.beginTransaction().remove(fragment).commitNow() @@ -571,8 +574,8 @@ class PlayerActivity : BaseMusicActivity() { } coverFragment = RoundCoverFragment() setUpCoverFragment() - lyricFragment = LyricFragment() - setUpLyricFragment() + lyricsFragment = LyricsFragment() + setUpLyricsFragment() if (this.isPortraitOrientation()) { @@ -580,7 +583,7 @@ class PlayerActivity : BaseMusicActivity() { val adapter = PagerAdapter(supportFragmentManager) // adapter.addFragment(mRecordFragment); adapter.addFragment(coverFragment) - adapter.addFragment(lyricFragment) + adapter.addFragment(lyricsFragment) binding.viewPager.adapter = adapter binding.viewPager.offscreenPageLimit = adapter.count - 1 binding.viewPager.currentItem = 0 @@ -604,7 +607,7 @@ class PlayerActivity : BaseMusicActivity() { fragmentManager .beginTransaction() .replace(R.id.container_cover, coverFragment) - .replace(R.id.container_lyric, lyricFragment) + .replace(R.id.container_lyric, lyricsFragment) .commit() } @@ -653,27 +656,13 @@ class PlayerActivity : BaseMusicActivity() { } - private fun setUpLyricFragment() { - lyricFragment.setOnInflateFinishListener { view: View? -> - lrcView = view as LrcView - lrcView?.setOnLrcClickListener(object : OnLrcClickListener { - override fun onClick() {} - override fun onLongClick() {} - }) - - lrcView?.setOnSeekToListener(object : LrcView.OnSeekToListener { - override fun onSeekTo(progress: Int) { - if (progress > 0 && progress < getDuration()) { - MusicServiceRemote.setProgress(progress) - currentTime = progress - handler.sendEmptyMessage(UPDATE_TIME_ALL) - } - } - - }) - lrcView?.setHighLightColor(ThemeStore.textColorPrimary) - lrcView?.setOtherColor(ThemeStore.textColorSecondary) - lrcView?.setTimeLineColor(ThemeStore.textColorSecondary) + private fun setUpLyricsFragment() { + lyricsFragment.onSeekToListener = LyricsView.OnSeekToListener { progress -> + if (progress in 0..getDuration()) { + MusicServiceRemote.setProgress(progress) + currentTime = progress + handler.sendEmptyMessage(UPDATE_TIME_ALL) + } } } @@ -691,7 +680,7 @@ class PlayerActivity : BaseMusicActivity() { super.onMediaStoreChanged() val newSong = getCurrentSong() updateTopStatus(newSong) - lyricFragment.updateLrc(newSong) + lyricsFragment.updateLyrics() // TODO song = newSong coverFragment.setImage(song, false, true) } @@ -705,7 +694,7 @@ class PlayerActivity : BaseMusicActivity() { //更新顶部信息 updateTopStatus(song) //更新歌词 - handler.postDelayed({ lyricFragment.updateLrc(song) }, 50) + lyricsFragment.updateLyrics() // TODO: is it needed? //更新进度条 val temp = getProgress() currentTime = if (temp in 1 until duration) temp else 0 @@ -967,22 +956,6 @@ class PlayerActivity : BaseMusicActivity() { Util.unregisterLocalReceiver(receiver) } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == REQUEST_SELECT_LYRIC && resultCode == Activity.RESULT_OK) { - data?.data?.let { uri -> - SPUtil.putValue( - this, - SPUtil.LYRIC_KEY.NAME, - song.id.toString(), - SPUtil.LYRIC_KEY.LYRIC_MANUAL - ) - lyricFragment.updateLrc(uri) - Util.sendLocalBroadcast(MusicUtil.makeCmdIntent(Command.CHANGE_LYRIC)) - } - } - } - private fun updateProgressText(current: Int) { if (current > 0 && duration - current > 0) { binding.textHasplay.text = Util.getTime(current.toLong()) @@ -1026,7 +999,7 @@ class PlayerActivity : BaseMusicActivity() { if (binding.viewPager.currentItem != 2) { binding.viewPager.setCurrentItem(2, true) } - lyricFragment.showLyricOffsetView() + lyricsFragment.showOffsetPanel() } private inner class Receiver : BroadcastReceiver() { @@ -1055,7 +1028,5 @@ class PlayerActivity : BaseMusicActivity() { private const val DELAY_SHOW_NEXT_SONG: Long = 3000 const val ACTION_UPDATE_NEXT = "remix.myplayer.update.next_song" - - const val REQUEST_SELECT_LYRIC = 0x104 } } \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/ui/activity/SettingActivity.kt b/app/src/main/java/remix/myplayer/ui/activity/SettingActivity.kt index 3118c7046..7b826166e 100644 --- a/app/src/main/java/remix/myplayer/ui/activity/SettingActivity.kt +++ b/app/src/main/java/remix/myplayer/ui/activity/SettingActivity.kt @@ -66,7 +66,7 @@ import remix.myplayer.misc.zipFrom import remix.myplayer.misc.zipOutputStream import remix.myplayer.service.Command import remix.myplayer.service.MusicService -import remix.myplayer.service.MusicService.Companion.EXTRA_DESKTOP_LYRIC +import remix.myplayer.service.MusicService.Companion.EXTRA_DESKTOP_LYRICS import remix.myplayer.theme.Theme import remix.myplayer.theme.Theme.getBaseDialog import remix.myplayer.theme.ThemeStore @@ -79,7 +79,7 @@ import remix.myplayer.ui.activity.MainActivity.Companion.EXTRA_REFRESH_LIBRARY import remix.myplayer.ui.activity.PlayerActivity.Companion.BACKGROUND_ADAPTIVE_COLOR import remix.myplayer.ui.activity.PlayerActivity.Companion.BACKGROUND_CUSTOM_IMAGE import remix.myplayer.ui.activity.PlayerActivity.Companion.BACKGROUND_THEME -import remix.myplayer.ui.dialog.LyricPriorityDialog +import remix.myplayer.ui.dialog.LyricsOrderDialog import remix.myplayer.ui.dialog.color.ColorChooserDialog import remix.myplayer.ui.misc.FolderChooser import remix.myplayer.util.* @@ -221,7 +221,7 @@ class SettingActivity : ToolbarActivity(), ColorChooserDialog.ColorCallback, binding.settingLrcFloatTip.setText(if (isChecked) R.string.opened_desktop_lrc else R.string.closed_desktop_lrc) val intent = MusicUtil.makeCmdIntent(Command.TOGGLE_DESKTOP_LYRIC) intent.putExtra( - EXTRA_DESKTOP_LYRIC, binding.settingLrcFloatSwitch.isChecked + EXTRA_DESKTOP_LYRICS, binding.settingLrcFloatSwitch.isChecked ) sendLocalBroadcast(intent) } @@ -915,9 +915,7 @@ class SettingActivity : ToolbarActivity(), ColorChooserDialog.ColorCallback, */ private fun configLyricPriority() { ViewCommon.showLocalLyricTip(this) { - LyricPriorityDialog.newInstance().show( - supportFragmentManager, "configLyricPriority" - ) + LyricsOrderDialog().show(supportFragmentManager, "configLyricPriority") } } @@ -1031,7 +1029,6 @@ class SettingActivity : ToolbarActivity(), ColorChooserDialog.ColorCallback, //清除配置文件、数据库等缓存 Util.deleteFilesByDirectory(cacheDir) // Util.deleteFilesByDirectory(externalCacheDir) - DiskCache.init(this@SettingActivity, "lyric") //清除glide缓存 Glide.get(this@SettingActivity).clearDiskCache() UriFetcher.clearAllCache() diff --git a/app/src/main/java/remix/myplayer/ui/adapter/DesktopLyricColorAdapter.kt b/app/src/main/java/remix/myplayer/ui/adapter/DesktopLyricColorAdapter.kt deleted file mode 100644 index dccaab150..000000000 --- a/app/src/main/java/remix/myplayer/ui/adapter/DesktopLyricColorAdapter.kt +++ /dev/null @@ -1,102 +0,0 @@ -package remix.myplayer.ui.adapter - -import android.content.Context -import android.graphics.Color -import android.graphics.drawable.GradientDrawable -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.RelativeLayout -import androidx.recyclerview.widget.RecyclerView -import remix.myplayer.R -import remix.myplayer.databinding.ItemFloatLrcColorBinding -import remix.myplayer.theme.GradientDrawableMaker -import remix.myplayer.theme.ThemeStore.floatLyricTextColor -import remix.myplayer.ui.adapter.DesktopLyricColorAdapter.FloatColorHolder -import remix.myplayer.ui.adapter.holder.BaseViewHolder -import remix.myplayer.util.ColorUtil -import remix.myplayer.util.DensityUtil - -/** - * Created by Remix on 2017/8/15. - */ -class DesktopLyricColorAdapter(context: Context?, layoutId: Int, width: Int) : BaseAdapter(layoutId) { - //当前桌面歌词的字体颜色 默认为当前主题颜色 - private var currentColor: Int - private var itemWidth: Int - - /** - * 判断是否是选中的颜色 - */ - private fun isColorChoose(context: Context, colorRes: Int): Boolean { - return context.resources.getColor(colorRes) == currentColor - } - - fun setCurrentColor(color: Int) { - currentColor = color - floatLyricTextColor = color - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FloatColorHolder { - val context = parent.context - val holder = FloatColorHolder( - LayoutInflater.from(context).inflate(R.layout.item_float_lrc_color, parent, false)) - val imgLayoutParam = RelativeLayout.LayoutParams( - DensityUtil.dip2px(context, 18f), DensityUtil.dip2px(context, 18f)) - imgLayoutParam.addRule(RelativeLayout.CENTER_IN_PARENT) - holder.binding.itemColor.layoutParams = imgLayoutParam - val rootLayoutParam = RecyclerView.LayoutParams(itemWidth, - ViewGroup.LayoutParams.MATCH_PARENT) - holder.binding.root.layoutParams = rootLayoutParam - return holder - } - - override fun convert(holder: FloatColorHolder, colorRes: Int?, position: Int) { - if (colorRes == null) { - return - } - val color = if (colorRes != R.color.md_white_primary) ColorUtil.getColor(colorRes) else Color.parseColor("#F9F9F9") - if (isColorChoose(holder.itemView.context, colorRes)) { - holder.binding.itemColor.background = GradientDrawableMaker() - .shape(GradientDrawable.OVAL) - .color(color) - .strokeSize(DensityUtil.dip2px(1f)) - .strokeColor(Color.BLACK) - .width(SIZE) - .height(SIZE) - .make() - } else { - holder.binding.itemColor.background = GradientDrawableMaker() - .shape(GradientDrawable.OVAL) - .color(color) - .width(SIZE) - .height(SIZE) - .make() - } - holder.binding.root.setOnClickListener { v: View? -> onItemClickListener?.onItemClick(v, position) } - } - - class FloatColorHolder(view: View) : BaseViewHolder(view) { - val binding: ItemFloatLrcColorBinding = ItemFloatLrcColorBinding.bind(view) - } - - companion object { - private val SIZE = DensityUtil.dip2px(18f) - val COLORS = listOf( - R.color.md_red_primary, R.color.md_brown_primary, R.color.md_navy_primary, - R.color.md_green_primary, R.color.md_yellow_primary, R.color.md_purple_primary, - R.color.md_indigo_primary, R.color.md_plum_primary, R.color.md_blue_primary, - R.color.md_white_primary, R.color.md_pink_primary - ) - } - - init { - setDataList(COLORS) - itemWidth = width / COLORS.size - //宽度太小 - if (itemWidth < DensityUtil.dip2px(context, 20f)) { - itemWidth = DensityUtil.dip2px(context, 20f) - } - currentColor = floatLyricTextColor - } -} \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/ui/adapter/LyricPriorityAdapter.kt b/app/src/main/java/remix/myplayer/ui/adapter/LyricPriorityAdapter.kt deleted file mode 100644 index 0d606edb7..000000000 --- a/app/src/main/java/remix/myplayer/ui/adapter/LyricPriorityAdapter.kt +++ /dev/null @@ -1,58 +0,0 @@ -package remix.myplayer.ui.adapter - -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import remix.myplayer.R -import remix.myplayer.bean.misc.LyricPriority -import remix.myplayer.ui.adapter.holder.BaseViewHolder -import remix.myplayer.util.SPUtil - - -class LyricPriorityAdapter(context: Context?, layoutId: Int) : BaseAdapter(layoutId) { - - init { - val temp: ArrayList = Gson().fromJson(SPUtil.getValue(context, SPUtil.LYRIC_KEY.NAME, SPUtil.LYRIC_KEY.PRIORITY_LYRIC, SPUtil.LYRIC_KEY.DEFAULT_PRIORITY), - object : TypeToken>() {}.type) - - val all = listOf( - LyricPriority.EMBEDDED, - LyricPriority.LOCAL, - LyricPriority.KUGOU, - LyricPriority.NETEASE, - LyricPriority.QQ, - LyricPriority.IGNORE) - if (temp.size < all.size) { - if (!temp.contains(LyricPriority.QQ)) { - temp.add(2,LyricPriority.QQ) - } - if (!temp.contains(LyricPriority.IGNORE)) { - temp.add(temp.size, LyricPriority.IGNORE) - } - - } - - setDataList(temp) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LyricPriorityHolder { - return LyricPriorityHolder(LayoutInflater.from(parent.context).inflate(layoutId, parent, false)) - } - - override fun convert(holder: LyricPriorityHolder, lyricPriority: LyricPriority?, position: Int) { - if(lyricPriority == null){ - return - } - - holder.view.findViewById(R.id.item_title)?.text = lyricPriority.desc - holder.view.setOnClickListener { - - } - } - - class LyricPriorityHolder(val view: View) : BaseViewHolder(view) -} diff --git a/app/src/main/java/remix/myplayer/ui/adapter/LyricsOrderAdapter.kt b/app/src/main/java/remix/myplayer/ui/adapter/LyricsOrderAdapter.kt new file mode 100644 index 000000000..e9a3c6e5c --- /dev/null +++ b/app/src/main/java/remix/myplayer/ui/adapter/LyricsOrderAdapter.kt @@ -0,0 +1,29 @@ +package remix.myplayer.ui.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import remix.myplayer.R +import remix.myplayer.lyrics.provider.ILyricsProvider + +// TODO: implements BaseAdapter? +class LyricsOrderAdapter(val order: List) : + RecyclerView.Adapter() { + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val title: TextView = view.findViewById(R.id.item_title) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + LayoutInflater.from(parent.context).inflate(R.layout.item_lyrics_order, parent, false).let { + return ViewHolder(it) + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.title.text = order[position].displayName + } + + override fun getItemCount() = order.size +} diff --git a/app/src/main/java/remix/myplayer/ui/dialog/LyricPriorityDialog.kt b/app/src/main/java/remix/myplayer/ui/dialog/LyricPriorityDialog.kt deleted file mode 100644 index 2b264ce9a..000000000 --- a/app/src/main/java/remix/myplayer/ui/dialog/LyricPriorityDialog.kt +++ /dev/null @@ -1,79 +0,0 @@ -package remix.myplayer.ui.dialog - -import android.app.Dialog -import android.os.Bundle -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.ItemTouchHelper -import com.google.gson.Gson -import remix.myplayer.App -import remix.myplayer.R -import remix.myplayer.misc.cache.DiskCache -import remix.myplayer.theme.Theme -import remix.myplayer.ui.adapter.LyricPriorityAdapter -import remix.myplayer.ui.dialog.base.BaseMusicDialog -import remix.myplayer.util.SPUtil -import remix.myplayer.util.ToastUtil -import java.util.* - -class LyricPriorityDialog : BaseMusicDialog() { - companion object { - @JvmStatic - fun newInstance(): LyricPriorityDialog { - return LyricPriorityDialog() - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val view = requireActivity().layoutInflater.inflate(R.layout.dialog_lyric_priority, null) - - val adapter = LyricPriorityAdapter(context, R.layout.item_lyric_priority) - val recyclerView = view.findViewById(R.id.recycler_view) - recyclerView.layoutManager = LinearLayoutManager(activity) - ItemTouchHelper(object : ItemTouchHelper.Callback() { - override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { - val dragFlag = ItemTouchHelper.LEFT or ItemTouchHelper.DOWN or ItemTouchHelper.UP or ItemTouchHelper.RIGHT - return makeMovementFlags(dragFlag, 0) - } - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - Collections.swap(adapter.dataList, if (viewHolder.adapterPosition >= 0) viewHolder.adapterPosition else 0, - if (target.adapterPosition >= 0) target.adapterPosition else 0) - adapter.notifyItemMoved(viewHolder.adapterPosition, target.adapterPosition) - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - - } - }).attachToRecyclerView(recyclerView) - - recyclerView.adapter = adapter - - return Theme.getBaseDialog(activity) - .title(R.string.lrc_priority) - .customView(view, false) - .positiveText(R.string.confirm) - .negativeText(R.string.cancel) - .onPositive { dialog, which -> - try { - DiskCache.getLrcDiskCache().delete() - DiskCache.init(App.context, "lyric") - SPUtil.deleteFile(App.context, SPUtil.LYRIC_KEY.NAME) - SPUtil.putValue(App.context, SPUtil.LYRIC_KEY.NAME, SPUtil.LYRIC_KEY.LYRIC_RESET_ON_16000, true) - SPUtil.putValue(activity, SPUtil.LYRIC_KEY.NAME, SPUtil.LYRIC_KEY.PRIORITY_LYRIC, - Gson().toJson(adapter.dataList)) - ToastUtil.show(context, R.string.save_success) - } catch (e: Exception) { - ToastUtil.show(context, R.string.save_error_arg, e.message) - } - - } - .onNegative { dialog, which -> - dismiss() - } - .build() - } - - -} \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/ui/dialog/LyricsOrderDialog.kt b/app/src/main/java/remix/myplayer/ui/dialog/LyricsOrderDialog.kt new file mode 100644 index 000000000..a1b58ac13 --- /dev/null +++ b/app/src/main/java/remix/myplayer/ui/dialog/LyricsOrderDialog.kt @@ -0,0 +1,49 @@ +package remix.myplayer.ui.dialog + +import android.app.Dialog +import android.os.Bundle +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import remix.myplayer.R +import remix.myplayer.lyrics.LyricsSearcher +import remix.myplayer.theme.Theme +import remix.myplayer.ui.adapter.LyricsOrderAdapter +import remix.myplayer.ui.dialog.base.BaseDialog +import java.util.Collections + +class LyricsOrderDialog : BaseDialog() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + val recyclerView = RecyclerView(context) + val adapter = LyricsOrderAdapter(LyricsSearcher.order) + recyclerView.layoutManager = LinearLayoutManager(context) + recyclerView.adapter = adapter + + ItemTouchHelper(object : + ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val p1 = viewHolder.adapterPosition.takeIf { it in adapter.order.indices } ?: return false + val p2 = target.adapterPosition.takeIf { it in adapter.order.indices } ?: return false + Collections.swap(adapter.order, p1, p2) + adapter.notifyItemMoved(p1, p2) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + }).attachToRecyclerView(recyclerView) + + return Theme.getBaseDialog(context) + .title(R.string.lrc_priority) + .customView(recyclerView, false) + .positiveText(R.string.confirm) + .negativeText(R.string.cancel) + .onPositive { _, _ -> LyricsSearcher.order = adapter.order } + .onNegative { _, _ -> dismiss() } + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/ui/fragment/LyricFragment.kt b/app/src/main/java/remix/myplayer/ui/fragment/LyricFragment.kt deleted file mode 100644 index db7c31385..000000000 --- a/app/src/main/java/remix/myplayer/ui/fragment/LyricFragment.kt +++ /dev/null @@ -1,223 +0,0 @@ -package remix.myplayer.ui.fragment - -import android.graphics.Color -import android.net.Uri -import android.os.Bundle -import android.os.Message -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import io.reactivex.disposables.Disposable -import io.reactivex.functions.Consumer -import remix.myplayer.App -import remix.myplayer.R -import remix.myplayer.bean.mp3.Song -import remix.myplayer.databinding.FragmentLrcBinding -import remix.myplayer.helper.MusicServiceRemote -import remix.myplayer.lyric.LyricSearcher -import remix.myplayer.misc.handler.MsgHandler -import remix.myplayer.misc.handler.OnHandleMessage -import remix.myplayer.misc.interfaces.OnInflateFinishListener -import remix.myplayer.theme.ThemeStore -import remix.myplayer.ui.fragment.base.BaseMusicFragment -import remix.myplayer.util.SPUtil -import remix.myplayer.util.ToastUtil -import timber.log.Timber -import java.util.* -import kotlin.math.abs - -/** - * Created by Remix on 2015/12/2. - */ - -/** - * 歌词界面Fragment - */ -class LyricFragment : BaseMusicFragment(), View.OnClickListener { - override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentLrcBinding - get() = FragmentLrcBinding::inflate - - private var onFindListener: OnInflateFinishListener? = null - private var song: Song? = null - - private var disposable: Disposable? = null - private val msgHandler = MsgHandler(this) - - private val lyricSearcher = LyricSearcher() - - fun setOnInflateFinishListener(l: OnInflateFinishListener) { - onFindListener = l - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - pageName = LyricFragment::class.java.simpleName - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.offsetReduce.setOnClickListener(this) - binding.offsetAdd.setOnClickListener(this) - binding.offsetReset.setOnClickListener(this) - onFindListener?.onViewInflateFinish(binding.lrcView) - //黑色主题着色按钮 - val themeRes = ThemeStore.themeRes - if (themeRes == R.style.Theme_APlayer_Black || themeRes == R.style.Theme_APlayer_Dark) { - binding.ivOffsetReduceArrow.setColorFilter(Color.WHITE) - binding.ivOffsetReduceSecond.setColorFilter(Color.WHITE) - binding.offsetReset.setColorFilter(Color.WHITE) - binding.ivOffsetAddArrow.setColorFilter(Color.WHITE) - binding.ivOffsetAddSecond.setColorFilter(Color.WHITE) - } - } - - override fun onDestroyView() { - msgHandler.remove() - disposable?.dispose() - onFindListener = null - super.onDestroyView() - } - - @JvmOverloads - fun updateLrc(song: Song, clearCache: Boolean = false) { - this.song = song - getLrc(Uri.EMPTY, clearCache) - } - - fun updateLrc(uri: Uri) { - getLrc(uri, true) - } - - private fun getLrc(uri: Uri, clearCache: Boolean) { - if (!isVisible) - return - if (song == null) { - binding.lrcView.setText(getStringSafely(R.string.no_lrc)) - return - } - if (clearCache) { - //清除offset - SPUtil.putValue(App.context, SPUtil.LYRIC_OFFSET_KEY.NAME, song?.id.toString() + "", 0) - binding.lrcView.setOffset(0) - } - val id = song?.id - - disposable?.dispose() - binding.lrcView.setText(getStringSafely(R.string.searching)) - disposable = lyricSearcher.setSong(song ?: return) - .getLyricObservable(uri, clearCache) - .subscribe(Consumer { - Timber.v("setLrcRows") - if (id == song?.id) { - if (it == null || it.isEmpty()) { - binding.lrcView.setText(getStringSafely(R.string.no_lrc)) - return@Consumer - } - binding.lrcView.setOffset( - SPUtil.getValue( - requireContext(), - SPUtil.LYRIC_OFFSET_KEY.NAME, - song?.id.toString() + "", - 0 - ) - ) - binding.lrcView.setLrcRows(it) - } - }, Consumer { - Timber.v(it) - if (id == song?.id) { - binding.lrcView.setLrcRows(null) - binding.lrcView.setText(getStringSafely(R.string.no_lrc)) - } - }) - } - - override fun onClick(view: View) { - msgHandler.removeMessages(MESSAGE_HIDE) - msgHandler.sendEmptyMessageDelayed(MESSAGE_HIDE, DELAY_HIDE) - - val originalOffset = - SPUtil.getValue(requireContext(), SPUtil.LYRIC_OFFSET_KEY.NAME, song?.id.toString() + "", 0) - var newOffset = originalOffset - when (view.id) { - R.id.offsetReset -> { - newOffset = 0 - ToastUtil.show(requireContext(), R.string.lyric_offset_reset) - } - R.id.offsetAdd -> newOffset += 500 - R.id.offsetReduce -> newOffset -= 500 - } - if (originalOffset != newOffset) { - SPUtil.putValue( - requireContext(), - SPUtil.LYRIC_OFFSET_KEY.NAME, - song?.id.toString() + "", - newOffset - ) - val toastMsg = msgHandler.obtainMessage(MESSAGE_SHOW_TOAST) - toastMsg.arg1 = newOffset - msgHandler.removeMessages(MESSAGE_SHOW_TOAST) - msgHandler.sendMessageDelayed(toastMsg, DELAY_SHOW_TOAST) - binding.lrcView.setOffset(newOffset) - MusicServiceRemote.setLyricOffset(newOffset) - } - - } - - fun showLyricOffsetView() { - if (binding.lrcView.getLrcRows().isNullOrEmpty()) { - ToastUtil.show(requireContext(), R.string.no_lrc) - return - } - binding.offsetContainer.visibility = View.VISIBLE - msgHandler.sendEmptyMessageDelayed(MESSAGE_HIDE, DELAY_HIDE) - } - - fun setLyricScalingFactor(choose: Int) { - val factor = when (choose) { - 0 -> { - 1f - } - 1 -> { - 1.5f - } - 2 -> { - 2f - } - else -> { - 1f - } - } - binding.lrcView.setLrcScalingFactor(factor) - } - - @OnHandleMessage - fun handleInternal(msg: Message) { - when (msg.what) { - MESSAGE_HIDE -> { - binding.offsetContainer.visibility = View.GONE - } - MESSAGE_SHOW_TOAST -> { - val newOffset = msg.arg1 - if (newOffset != 0 && abs(newOffset) <= 60000) {//最大偏移60s - ToastUtil.show( - requireContext(), - if (newOffset > 0) R.string.lyric_advance_x_second else R.string.lyric_delay_x_second, - String.format(Locale.getDefault(), "%.1f", newOffset / 1000f) - ) - } - } - } - } - - - companion object { - private const val DELAY_HIDE = 5000L - private const val DELAY_SHOW_TOAST = 100L - - private const val MESSAGE_HIDE = 1 - private const val MESSAGE_SHOW_TOAST = 2 - } - -} diff --git a/app/src/main/java/remix/myplayer/ui/fragment/LyricsFragment.kt b/app/src/main/java/remix/myplayer/ui/fragment/LyricsFragment.kt new file mode 100644 index 000000000..a371c275f --- /dev/null +++ b/app/src/main/java/remix/myplayer/ui/fragment/LyricsFragment.kt @@ -0,0 +1,138 @@ +package remix.myplayer.ui.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import remix.myplayer.R +import remix.myplayer.databinding.FragmentLrcBinding +import remix.myplayer.helper.MusicServiceRemote +import remix.myplayer.ui.fragment.base.BaseMusicFragment +import remix.myplayer.ui.widget.LyricsView +import remix.myplayer.util.ToastUtil +import kotlin.math.sign +import kotlin.time.Duration.Companion.milliseconds + +class LyricsFragment : BaseMusicFragment(), View.OnClickListener { + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentLrcBinding + get() = FragmentLrcBinding::inflate + + companion object { + private val TAG = LyricsFragment::class.java.simpleName + + private val UPDATE_PROGRESS_INTERVAL = 50.milliseconds + private val HIDE_PANEL_DELAY = 5000.milliseconds + } + + init { + pageName = TAG + } + + var onSeekToListener: LyricsView.OnSeekToListener? = null + + private var loadLyricsJob: Job? = null + private var updateProgressJob: Job? = null + + @UiThread + fun updateLyrics() { + view ?: return // TODO: Better check? + + loadLyricsJob?.cancel() + updateProgressJob?.cancel() + + binding.lyrics.visibility = View.GONE + binding.lyricsNoLrc.visibility = View.GONE + binding.lyricsSearching.visibility = View.VISIBLE + + MusicServiceRemote.service?.let { service -> + loadLyricsJob = lifecycleScope.launch { + val lyrics = service.lyrics.await() + binding.lyricsSearching.visibility = View.GONE + if (lyrics.isEmpty()) { + binding.lyricsNoLrc.visibility = View.VISIBLE + } else { + binding.lyrics.lyrics = lyrics + binding.lyrics.offset = service.lyricsOffset + binding.lyrics.visibility = View.VISIBLE + updateProgressJob = lifecycleScope.launch { + while (true) { + updateProgress() + delay(UPDATE_PROGRESS_INTERVAL) + } + } + } + } + } + } + + @UiThread + fun updateProgress() { + MusicServiceRemote.service?.run { + binding.lyrics.updateProgress(progress, duration) + } + } + + private val hideOffsetPanelRunnable = Runnable { + binding.offsetPanel.visibility = View.GONE + } + + override fun onClick(v: View) { + MusicServiceRemote.service?.run { + val oldOffset = lyricsOffset + when (v.id) { + R.id.offset_inc -> lyricsOffset += 500 + R.id.offset_dec -> lyricsOffset -= 500 + R.id.offset_reset -> lyricsOffset = 0 + else -> return@onClick + } + if (lyricsOffset != oldOffset) { + val seconds = lyricsOffset / 1000f + when (seconds.sign) { + +1f -> ToastUtil.show(context, R.string.lyric_advance_x_second, seconds) + -1f -> ToastUtil.show(context, R.string.lyric_delay_x_second, -seconds) + 0f -> ToastUtil.show(context, R.string.lyric_offset_reset) + } + binding.lyrics.offset = lyricsOffset + } + } + binding.root.handler.removeCallbacks(hideOffsetPanelRunnable) + binding.root.handler.postDelayed(hideOffsetPanelRunnable, HIDE_PANEL_DELAY.inWholeMilliseconds) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.offsetInc.setOnClickListener(this) + binding.offsetDec.setOnClickListener(this) + binding.offsetReset.setOnClickListener(this) + binding.lyrics.onSeekToListener = LyricsView.OnSeekToListener { + onSeekToListener?.onSeekTo(it) + } + } + + override fun onMediaStoreChanged() { + super.onMediaStoreChanged() + updateLyrics() + TODO("when is it called?") + } + + override fun onMetaChanged() { + super.onMetaChanged() + updateLyrics() + TODO("when is it called?") + } + + @UiThread + fun showOffsetPanel() { + binding.offsetPanel.visibility = View.VISIBLE + binding.root.handler.run { + removeCallbacks(hideOffsetPanelRunnable) + postDelayed(hideOffsetPanelRunnable, HIDE_PANEL_DELAY.inWholeMilliseconds) + } + } +} diff --git a/app/src/main/java/remix/myplayer/ui/misc/PartialForegroundColorSpan.kt b/app/src/main/java/remix/myplayer/ui/misc/PartialForegroundColorSpan.kt new file mode 100644 index 000000000..6ec25c167 --- /dev/null +++ b/app/src/main/java/remix/myplayer/ui/misc/PartialForegroundColorSpan.kt @@ -0,0 +1,61 @@ +package remix.myplayer.ui.misc + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.style.ReplacementSpan +import androidx.annotation.ColorInt +import kotlin.math.roundToInt + +/** + * 从左边开始对文字的一部分使用特定颜色的 Span + * + * @param proportion 0 到 1 之间的值,表示特定颜色部分的宽度占比 + * + * TODO: RTL? + */ +class PartialForegroundColorSpan( + private val proportion: Float, + @ColorInt private val color: Int +) : ReplacementSpan() { + init { + require(proportion in 0f..1f) + } + + override fun getSize( + paint: Paint, + text: CharSequence, start: Int, end: Int, + fm: Paint.FontMetricsInt? + ): Int { + return paint.measureText(text, start, end).roundToInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence, start: Int, end: Int, + x: Float, top: Int, y: Int, bottom: Int, + paint: Paint + ) { + val width = paint.measureText(text, start, end) + + if (proportion == 0f) { + canvas.drawText(text, start, end, x, y.toFloat(), paint) + return + } + if (proportion == 1f) { + paint.color = color + canvas.drawText(text, start, end, x, y.toFloat(), paint) + return + } + + canvas.save() + canvas.clipRect(x, top.toFloat(), x + width * proportion, bottom.toFloat()) + canvas.drawText(text, start, end, x, y.toFloat(), paint) + canvas.restore() + + paint.color = color + canvas.save() + canvas.clipRect(x + width * proportion, top.toFloat(), x + width, bottom.toFloat()) + canvas.drawText(text, start, end, x, y.toFloat(), paint) + canvas.restore() + } +} diff --git a/app/src/main/java/remix/myplayer/ui/widget/LyricsView.kt b/app/src/main/java/remix/myplayer/ui/widget/LyricsView.kt new file mode 100644 index 000000000..d27b65a96 --- /dev/null +++ b/app/src/main/java/remix/myplayer/ui/widget/LyricsView.kt @@ -0,0 +1,248 @@ +package remix.myplayer.ui.widget + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.UiThread +import remix.myplayer.R +import remix.myplayer.databinding.LayoutLyricsViewBinding +import remix.myplayer.lyrics.LyricsLine +import remix.myplayer.lyrics.PerWordLyricsLine +import remix.myplayer.theme.ThemeStore +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds + +class LyricsView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : FrameLayout(context, attrs), View.OnTouchListener { + companion object { + private val DEACTIVATE_DELAY = 5000.milliseconds + + private val normalTextColor + @ColorInt get() = ThemeStore.textColorSecondary + private val highlightTextColor + @ColorInt get() = ThemeStore.textColorPrimary + } + + fun interface OnSeekToListener { + fun onSeekTo(progress: Int) + } + + private val binding = LayoutLyricsViewBinding.inflate(LayoutInflater.from(context), this, true) + + var onSeekToListener: OnSeekToListener? = null + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + if (h != oldh) { + // 给 container 上下加空白,确保第一行和最后一行歌词可以滚动到 view 中间 + binding.innerContainer.setPadding(0, h / 2, 0, h / 2) + } + } + + private fun newLayoutForLine(line: LyricsLine): LinearLayout { + val params = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + val padding = resources.getDimensionPixelSize(R.dimen.lyrics_view_lrc_block_vertical_padding) + val textColor = normalTextColor + + val layout = LinearLayout(context) + layout.layoutParams = params + layout.setPadding(0, padding, 0, padding) + layout.orientation = LinearLayout.VERTICAL + if (line.content.isNotBlank()) { + val view = TextView(context) + view.layoutParams = params + view.text = if (line is PerWordLyricsLine) { + line.getSpannedString(0f, textColor) + } else { + line.content + } + view.setTextColor(textColor) + layout.addView(view) + } + if (line.translation?.isNotBlank() == true) { + val view = TextView(context) + view.layoutParams = params + view.text = line.translation + view.setTextColor(textColor) + layout.addView(view) + } + return layout + } + + /** + * 修改完后应立刻设置 offset + */ + var lyrics: List = emptyList() + @UiThread set(value) { + if (value == field) { + return + } + field = value + binding.innerContainer.removeAllViews() + isClickable = lyrics.isNotEmpty() + value.forEach { + binding.innerContainer.addView(newLayoutForLine(it)) + } + rawProgressAndDuration = null + lastHighlightLine = null + } + + private var rawProgressAndDuration: Pair? = null + + /** + * 修改时自动更新 UI + */ + var offset: Int = 0 + @UiThread set(value) { + if (value == field) { + return + } + field = value + if (isActive) { + showTimeIndicator() + } + rawProgressAndDuration?.run { + updateProgress(first, second) + } + } + + private fun getTextViewOfLine(index: Int): TextView { + check(lyrics[index].content.isNotBlank()) + val layout = binding.innerContainer.getChildAt(index) as LinearLayout + return layout.getChildAt(0) as TextView + } + + private fun setProgressOfLine(index: Int, progress: Float, @ColorInt color: Int) { + val view = getTextViewOfLine(index) + val line = lyrics[index] + if (line.content.isBlank()) { + return + } + if (line is PerWordLyricsLine) { + view.text = line.getSpannedString(progress, color) + } else { + view.setTextColor(color) + } + } + + private var lastHighlightLine: Int? = null + + @UiThread + fun updateProgress(rawProgress: Int, rawDuration: Int) { + check(lyrics.isNotEmpty()) + rawProgressAndDuration = Pair(rawProgress, rawDuration) + val progress = rawProgress + offset + val duration = rawDuration + offset + check(progress <= duration) + val index = lyrics.binarySearchBy(progress) { it.time }.let { + if (it < 0) -(it + 1) - 1 else it + } + check(index >= -1 && index < lyrics.size) + if (index != lastHighlightLine) { + lastHighlightLine?.let { + setProgressOfLine(it, 0f, normalTextColor) + lastHighlightLine = null + } + if (index >= 0) { + val line = lyrics[index] + setProgressOfLine( + index, if (line is PerWordLyricsLine) { + line.getProgress( + progress, lyrics.getOrNull(index + 1)?.time ?: duration + ) + } else 0f, highlightTextColor + ) + lastHighlightLine = index + } + if (!isActive) { + scrollToLine(index) + } + } + } + + private fun getNearestLine(): Int { + val y = binding.outerContainer.scrollY + binding.outerContainer.height / 2f + var line: Int = -1 + var minDistance = Float.POSITIVE_INFINITY + for (i in lyrics.indices) { + val view = binding.innerContainer.getChildAt(i) + if (y >= view.top && y <= view.bottom) { + return i + } + val distance = if (y < view.top) (view.top - y) else (y - view.bottom) + if (distance < minDistance) { + line = i + minDistance = distance + } + } + check(line != -1) + return line + } + + private fun scrollToLine(line: Int?) { + val y = if (line == null || line < 0) { + 0 + } else { + val view = binding.innerContainer.getChildAt(line) + ((view.top + view.bottom - binding.outerContainer.height) / 2f).roundToInt() + } + binding.outerContainer.smoothScrollTo(0, y) + } + + private var isActive: Boolean = false + set(value) { + field = value + if (value) { + showTimeIndicator() + handler.removeCallbacks(deactivateRunnable) + handler.postDelayed(deactivateRunnable, DEACTIVATE_DELAY.inWholeMilliseconds) + } else { + binding.timeIndicator.visibility = View.GONE + rawProgressAndDuration?.run { + updateProgress(first, second) + } + } + } + + private val deactivateRunnable = Runnable { + isActive = false + } + + @SuppressLint("SetTextI18n") + private fun showTimeIndicator() { + (lyrics[getNearestLine()].time - offset).coerceAtLeast(0).let { + binding.time.text = + "%02d:%02d.%02d".format(it / 1000 / 60, it / 1000 % 60, (it % 1000 / 10f).roundToInt()) + binding.playButton.setOnClickListener { _ -> + onSeekToListener?.onSeekTo(it) + } + } + binding.timeIndicator.visibility = View.VISIBLE + } + + override fun onTouch(v: View, event: MotionEvent): Boolean { + isActive = true + return false + } + + // 在单独函数以忽略警告 + @SuppressLint("ClickableViewAccessibility") + private fun setupOnTouchListener() { + binding.outerContainer.setOnTouchListener(this) + } + + init { + binding.outerContainer.onFlingEndListener = ResponsiveScrollView.OnFlingEndListener { + scrollToLine(getNearestLine()) + } + setupOnTouchListener() + } +} diff --git a/app/src/main/java/remix/myplayer/ui/widget/ResponsiveScrollView.kt b/app/src/main/java/remix/myplayer/ui/widget/ResponsiveScrollView.kt new file mode 100644 index 000000000..2bce0e2a1 --- /dev/null +++ b/app/src/main/java/remix/myplayer/ui/widget/ResponsiveScrollView.kt @@ -0,0 +1,30 @@ +package remix.myplayer.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.widget.ScrollView +import kotlin.math.abs + +class ResponsiveScrollView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : ScrollView(context, attrs) { + fun interface OnFlingEndListener { + fun onFlingEnd(v: ResponsiveScrollView) + } + + var onFlingEndListener: OnFlingEndListener? = null + private var isBeingFlung: Boolean = false + + override fun fling(velocityY: Int) { + isBeingFlung = true + super.fling(velocityY) + } + + override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) { + if (isBeingFlung && (abs(t - oldt) <= 1 || t >= measuredHeight || t <= 0)) { + isBeingFlung = false + onFlingEndListener?.onFlingEnd(this) + } + super.onScrollChanged(l, t, oldl, oldt) + } +} diff --git a/app/src/main/java/remix/myplayer/ui/widget/SingleLineLyricsView.kt b/app/src/main/java/remix/myplayer/ui/widget/SingleLineLyricsView.kt new file mode 100644 index 000000000..a035b6350 --- /dev/null +++ b/app/src/main/java/remix/myplayer/ui/widget/SingleLineLyricsView.kt @@ -0,0 +1,164 @@ +package remix.myplayer.ui.widget + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.appcompat.widget.AppCompatTextView +import remix.myplayer.lyrics.LyricsLine +import remix.myplayer.lyrics.PerWordLyricsLine +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * 显示单行歌词(不换行)的 View + * + * + * TODO: RTL支持? + */ +class SingleLineLyricsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = android.R.attr.textViewStyle +) : AppCompatTextView(context, attrs, defStyleAttr) { + companion object { + private const val ELLIPSIS = Typography.ellipsis.toString() + } + + @ColorInt + var sungColor: Int = currentTextColor + set(@ColorInt value) { + if (value != field) { + field = value + invalidate() + } + } + + @ColorInt + var unsungColor: Int = currentTextColor + set(@ColorInt value) { + if (value != field) { + field = value + invalidate() + } + } + + /** + * 整行的全部文字 + */ + private var content = "" + var lyricsLine: LyricsLine? = null + set(value) { + if (value == field) { + return + } + field = value + content = lyricsLine?.content ?: "" + contentDescription = content + progress = null + invalidate() + requestLayout() + } + + /** + * 当前进度 + * + * - `lyricsLine` is `PerWordLyricsLine`:[0, lyricsLine.words.size] + * - 否则:[0, 1] + */ + private var progress: Float? = null + + fun setProgress(time: Int, endTime: Int) { + lyricsLine?.let { + require(time >= it.time && time <= endTime) + val newProgress = if (it is PerWordLyricsLine) { + it.getProgress(time, endTime) + } else { + (time - it.time).toFloat() / (endTime - it.time) + } + if (newProgress != progress) { + progress = newProgress + invalidate() + } + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + + val width = if (widthMode == MeasureSpec.EXACTLY) { + widthSize + } else { + val desired = paint.measureText(content.ifBlank { ELLIPSIS }).roundToInt() + if (widthMode == MeasureSpec.AT_MOST) min(desired, widthSize) else desired + } + val height = if (heightMode == MeasureSpec.EXACTLY) { + heightSize + } else { + val fm = paint.fontMetrics + val desired = (fm.bottom - fm.top).roundToInt() + if (heightMode == MeasureSpec.AT_MOST) min(desired, heightSize) else desired + } + setMeasuredDimension(width, height) + } + + override fun onDraw(canvas: Canvas) { + // Make compiler happy + val content = content + val lyricsLine = lyricsLine + val offset = progress + + val textWidth = paint.measureText(content.ifBlank { ELLIPSIS }) + val fm = paint.fontMetrics + paint.color = sungColor + if (content.isBlank() || lyricsLine == null || offset == null) { + // 进度未设置时居中显示 + // 无可用内容时显示省略号 + canvas.drawText(content.ifBlank { ELLIPSIS }, (width - textWidth) / 2, -fm.top, paint) + } else { + if (lyricsLine is PerWordLyricsLine) { + val index = offset.toInt() + if (index < lyricsLine.words.size) { + val l = + paint.measureText(lyricsLine.words.subList(0, index).joinToString("") { it.content }) + val r = paint.measureText( + lyricsLine.words.subList(0, index + 1).joinToString("") { it.content }) + val highlightWidth = l + (r - l) * (offset - index) + val left = if (width >= textWidth) { + (width - textWidth) / 2 + } else { + (width / 2f - highlightWidth).coerceIn(width - textWidth, 0f) + } + canvas.save() + canvas.clipRect(left, 0f, left + highlightWidth, height.toFloat()) + canvas.drawText(content, left, -fm.top, paint) + canvas.restore() + paint.color = unsungColor + canvas.save() + canvas.clipRect(left + highlightWidth, 0f, left + textWidth, height.toFloat()) + canvas.drawText(content, left, -fm.top, paint) + canvas.restore() + } else { + check(index == lyricsLine.words.size) + val left = if (width >= textWidth) { + (width - textWidth) / 2 + } else { + width - textWidth + } + canvas.drawText(content, left, -fm.top, paint) + } + } else { + val left = if (width >= textWidth) { + (width - textWidth) / 2 + } else { + (width - textWidth) * offset + } + canvas.drawText(content, left, -fm.top, paint) + } + } + } +} diff --git a/app/src/main/java/remix/myplayer/ui/widget/desktop/DesktopLyricTextView.java b/app/src/main/java/remix/myplayer/ui/widget/desktop/DesktopLyricTextView.java deleted file mode 100644 index b61f0e302..000000000 --- a/app/src/main/java/remix/myplayer/ui/widget/desktop/DesktopLyricTextView.java +++ /dev/null @@ -1,146 +0,0 @@ -package remix.myplayer.ui.widget.desktop; - -import android.animation.ValueAnimator; -import android.content.Context; -import android.content.res.ColorStateList; -import android.graphics.Canvas; -import android.graphics.Rect; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.text.TextPaint; -import android.util.AttributeSet; -import remix.myplayer.lyric.bean.LrcRow; - -/** - * @ClassName - * @Description - * @Author Xiaoborui - * @Date 2017/5/11 14:22 - */ - -public class DesktopLyricTextView extends androidx.appcompat.widget.AppCompatTextView { - - private static final int DELAY_MAX = 100; - - /** - * 当前x坐标 - */ - private float mCurTextXForHighLightLrc; - /** - * 当前的歌词 - */ - private LrcRow mCurLrcRow; - /** - * 当前歌词的字符串所占的控件 - */ - private Rect mTextRect = new Rect(); - /** - * 垂直便宜 - */ - private int mOffsetY; - /*** - * 监听属性动画的数值值的改变 - */ - ValueAnimator.AnimatorUpdateListener mUpdateListener = new ValueAnimator.AnimatorUpdateListener() { - - @Override - public void onAnimationUpdate(ValueAnimator animation) { - mCurTextXForHighLightLrc = (Float) animation.getAnimatedValue(); - invalidate(); - } - }; - - public DesktopLyricTextView(Context context) { - this(context, null); - } - - public DesktopLyricTextView(Context context, @Nullable AttributeSet attrs) { - this(context, attrs, 0); - } - - public DesktopLyricTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - init(); - } - - private void init() { - } - - /** - * 控制歌词水平滚动的属性动画 - ***/ - private ValueAnimator mAnimator; - - /** - * 开始水平滚动歌词 - * - * @param endX 歌词第一个字的最终的x坐标 - * @param duration 滚动的持续时间 - */ - private void startScrollLrc(float endX, long duration) { - if (mAnimator == null) { - mAnimator = ValueAnimator.ofFloat(0, endX); - mAnimator.addUpdateListener(mUpdateListener); - } else { - mCurTextXForHighLightLrc = 0; - mAnimator.cancel(); - mAnimator.setFloatValues(0, endX); - } - mAnimator.setDuration(duration); - long delay = (long) (duration * 0.1); - mAnimator.setStartDelay(delay > DELAY_MAX ? DELAY_MAX : delay); //延迟执行属性动画 - mAnimator.start(); - } - - @Override - public void setTextColor(ColorStateList colors) { - super.setTextColor(colors); - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - if (mCurLrcRow == null) { - return; - } - canvas.drawText(mCurLrcRow.getContent(), - mCurTextXForHighLightLrc, - (getHeight() - getPaint().getFontMetrics().top - getPaint().getFontMetrics().bottom) / 2, - getPaint()); - } - - public void setLrcRow(@NonNull LrcRow lrcRow) { - if (lrcRow.getTime() != 0 && mCurLrcRow != null && mCurLrcRow.getTime() == lrcRow.getTime()) { - return; - } - mCurLrcRow = lrcRow; - stopAnimation(); -// setText(mCurLrcRow.getContent()); - - TextPaint paint = getPaint(); - if (paint == null) { - return; - } - String text = mCurLrcRow.getContent(); - paint.getTextBounds(text, 0, text.length(), mTextRect); - float textWidth = mTextRect.width(); - mOffsetY = (int) ((mTextRect.bottom + mTextRect.top - paint.getFontMetrics().bottom - paint - .getFontMetrics().top) / 2); - if (textWidth > getWidth()) { - //如果歌词宽度大于view的宽,则需要动态设置歌词的起始x坐标,以实现水平滚动 - startScrollLrc(getWidth() - textWidth, (long) (mCurLrcRow.getTotalTime() * 0.85)); - } else { - //如果歌词宽度小于view的宽,则让歌词居中显示 - mCurTextXForHighLightLrc = (getWidth() - textWidth) / 2; - invalidate(); - } - - } - - public void stopAnimation() { - if (mAnimator != null && mAnimator.isRunning()) { - mAnimator.cancel(); - } - invalidate(); - } -} diff --git a/app/src/main/java/remix/myplayer/ui/widget/desktop/DesktopLyricView.kt b/app/src/main/java/remix/myplayer/ui/widget/desktop/DesktopLyricView.kt deleted file mode 100644 index 697c00ef8..000000000 --- a/app/src/main/java/remix/myplayer/ui/widget/desktop/DesktopLyricView.kt +++ /dev/null @@ -1,520 +0,0 @@ -package remix.myplayer.ui.widget.desktop - -import android.app.Service -import android.content.Context -import android.graphics.Color -import android.graphics.PointF -import android.hardware.input.InputManager -import android.os.Build -import android.os.Message -import android.text.TextUtils -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.ViewTreeObserver -import android.view.WindowManager -import android.widget.RelativeLayout -import android.widget.SeekBar -import android.widget.SeekBar.OnSeekBarChangeListener -import androidx.recyclerview.widget.LinearLayoutManager -import com.afollestad.materialdialogs.internal.MDTintHelper -import remix.myplayer.R -import remix.myplayer.databinding.LayoutDesktopLyricBinding -import remix.myplayer.lyric.bean.LrcRow -import remix.myplayer.misc.handler.MsgHandler -import remix.myplayer.misc.handler.OnHandleMessage -import remix.myplayer.misc.interfaces.OnItemClickListener -import remix.myplayer.service.Command -import remix.myplayer.service.MusicService -import remix.myplayer.service.MusicService.Companion.EXTRA_DESKTOP_LYRIC -import remix.myplayer.theme.ThemeStore -import remix.myplayer.ui.adapter.DesktopLyricColorAdapter -import remix.myplayer.ui.adapter.DesktopLyricColorAdapter.Companion.COLORS -import remix.myplayer.util.* -import remix.myplayer.util.MusicUtil.makeCmdIntent -import remix.myplayer.util.SPUtil.SETTING_KEY -import remix.myplayer.util.Util.sendLocalBroadcast -import timber.log.Timber -import kotlin.math.abs - - -/** - * @ClassName - * @Description - * @Author Xiaoborui - * @Date 2017/3/22 15:47 - */ - -class DesktopLyricView(private val service: MusicService) : RelativeLayout(service), View.OnClickListener { - private val binding: LayoutDesktopLyricBinding = - LayoutDesktopLyricBinding.inflate(LayoutInflater.from(service), this, true) - - private val windowManager: WindowManager by lazy { - service.getSystemService(Context.WINDOW_SERVICE) as WindowManager - } - private val lastPoint = PointF() - var isLocked = false - private set - private val handler = MsgHandler(this) - private val colorAdapter: DesktopLyricColorAdapter by lazy { - DesktopLyricColorAdapter( - service, - R.layout.item_float_lrc_color, - binding.widgetColorRecyclerview.measuredWidth - ) - } - - private var textSizeType = MEDIUM - private val hideRunnable = Runnable { - binding.widgetPanel.visibility = View.GONE - binding.widgetLrcContainer.visibility = View.GONE - } - - private val onSeekBarChangeListener = object : OnSeekBarChangeListener { - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - val temp = Color.rgb( - binding.widgetSeekbarR.progress, - binding.widgetSeekbarG.progress, - binding.widgetSeekbarB.progress - ) - val color = if (ColorUtil.isColorCloseToWhite(temp)) Color.parseColor("#F9F9F9") else temp - binding.widgetLine1.setTextColor(color) - MDTintHelper.setTint(binding.widgetSeekbarR, color) - MDTintHelper.setTint(binding.widgetSeekbarG, color) - MDTintHelper.setTint(binding.widgetSeekbarB, color) - binding.widgetTextR.setTextColor(color) - binding.widgetTextG.setTextColor(color) - binding.widgetTextB.setTextColor(color) - resetHide() - - handler.removeMessages(MESSAGE_SAVE_COLOR) - handler.sendMessageDelayed(Message.obtain(handler, MESSAGE_SAVE_COLOR, color, 0), 100) - } - - override fun onStartTrackingTouch(seekBar: SeekBar) { - - } - - override fun onStopTrackingTouch(seekBar: SeekBar) { - - } - } - - /** - * 当前是否正在拖动 - */ - private var isDragging = false - - init { - setUpView() - } - - private fun setUpColor() { - binding.widgetColorRecyclerview.viewTreeObserver - .addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { - override fun onPreDraw(): Boolean { - binding.widgetColorRecyclerview.viewTreeObserver.removeOnPreDrawListener(this) - colorAdapter.onItemClickListener = object : OnItemClickListener { - override fun onItemClick(view: View, position: Int) { - val color = ColorUtil.getColor(COLORS[position]) - binding.widgetLine1.setTextColor(color) - colorAdapter.setCurrentColor(color) - colorAdapter.notifyDataSetChanged() - resetHide() - } - - override fun onItemLongClick(view: View, position: Int) { - - } - } - binding.widgetColorRecyclerview.layoutManager = LinearLayoutManager( - service, - LinearLayoutManager.HORIZONTAL, - false - ) - binding.widgetColorRecyclerview.overScrollMode = View.OVER_SCROLL_NEVER - binding.widgetColorRecyclerview.adapter = colorAdapter - return true - } - }) - } - - private fun setUpView() { - val temp = ThemeStore.floatLyricTextColor - val color = if (ColorUtil.isColorCloseToWhite(temp)) Color.parseColor("#F9F9F9") else temp - val red = color and 0xff0000 shr 16 - val green = color and 0x00ff00 shr 8 - val blue = color and 0x0000ff - binding.widgetSeekbarR.max = 255 - binding.widgetSeekbarR.progress = red - binding.widgetSeekbarG.max = 255 - binding.widgetSeekbarG.progress = green - binding.widgetSeekbarB.max = 255 - binding.widgetSeekbarB.progress = blue - binding.widgetTextR.setTextColor(color) - binding.widgetTextG.setTextColor(color) - binding.widgetTextB.setTextColor(color) - binding.widgetSeekbarR.setOnSeekBarChangeListener(onSeekBarChangeListener) - binding.widgetSeekbarG.setOnSeekBarChangeListener(onSeekBarChangeListener) - binding.widgetSeekbarB.setOnSeekBarChangeListener(onSeekBarChangeListener) - MDTintHelper.setTint(binding.widgetSeekbarR, color) - MDTintHelper.setTint(binding.widgetSeekbarG, color) - MDTintHelper.setTint(binding.widgetSeekbarB, color) - - textSizeType = SPUtil.getValue(service, SETTING_KEY.NAME, SETTING_KEY.DESKTOP_LYRIC_TEXT_SIZE, MEDIUM) - binding.widgetLine1.setTextColor(color) - binding.widgetLine1.textSize = getTextSize(TYPE_TEXT_SIZE_FIRST_LINE) - binding.widgetLine2.textSize = getTextSize(TYPE_TEXT_SIZE_SECOND_LINE) - isLocked = SPUtil.getValue(service, SETTING_KEY.NAME, SETTING_KEY.DESKTOP_LYRIC_LOCK, false) - - setPlayIcon(service.isPlaying) - - viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { - override fun onPreDraw(): Boolean { - viewTreeObserver.removeOnPreDrawListener(this) - saveLock(isLocked, false) - return true - } - }) - - arrayOf( - binding.widgetClose, - binding.widgetLock, - binding.widgetNext, - binding.widgetPlay, - binding.widgetPrev, - binding.widgetLrcBigger, - binding.widgetLrcSmaller, - binding.widgetSetting - ).forEach { - it.setOnClickListener(this) - } - } - - /** - * @param type 0: 第一行 1:第二行 - */ - private fun getTextSize(type: Int): Float { - return when (type) { - TYPE_TEXT_SIZE_FIRST_LINE -> { - (when (textSizeType) { - TINY -> FIRST_LINE_TINY - SMALL -> FIRST_LINE_SMALL - MEDIUM -> FIRST_LINE_MEDIUM - BIG -> FIRST_LINE_BIG - else -> FIRST_LINE_HUGE - }).toFloat() - } - TYPE_TEXT_SIZE_SECOND_LINE -> { - (when (textSizeType) { - TINY -> SECOND_LINE_TINY - SMALL -> SECOND_LINE_SMALL - MEDIUM -> SECOND_LINE_MEDIUM - BIG -> SECOND_LINE_BIG - else -> SECOND_LINE_HUGE - }).toFloat() - } - else -> throw IllegalArgumentException("unknown textSize type") - } - } - - fun setText(lrc1: LrcRow?, lrc2: LrcRow?) { - if (lrc1 != null) { - if (TextUtils.isEmpty(lrc1.content)) { - lrc1.content = "......" - } - binding.widgetLine1.setLrcRow(lrc1) - } - if (lrc2 != null) { - if (TextUtils.isEmpty(lrc2.content)) { - lrc2.content = "....." - } - binding.widgetLine2.text = lrc2.content - } - } - - override fun onTouchEvent(event: MotionEvent): Boolean { - when (event.action) { - MotionEvent.ACTION_DOWN -> if (!isLocked) { - isDragging = false - lastPoint.set(event.rawX, event.rawY) - handler.removeCallbacks(hideRunnable) - } else { -// mUIHandler.postDelayed(mLongClickRunnable, LONGCLICK_THRESHOLD); - } - MotionEvent.ACTION_MOVE -> if (!isLocked) { - val params = layoutParams as WindowManager.LayoutParams - - if (abs(event.rawY - lastPoint.y) > DISTANCE_THRESHOLD) { - params.y += (event.rawY - lastPoint.y).toInt() - isDragging = true - if (isAttachedToWindow) { - windowManager.updateViewLayout(this, params) - } - } - lastPoint.set(event.rawX, event.rawY) - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> if (!isLocked) { - if (!isDragging) { - //点击后隐藏或者显示操作栏 - if (binding.widgetPanel.isShown) { - binding.widgetPanel.visibility = View.INVISIBLE - } else { - binding.widgetPanel.visibility = View.VISIBLE - handler.postDelayed(hideRunnable, DISMISS_THRESHOLD.toLong()) - } - } else { - //滑动 - if (binding.widgetPanel.isShown) { - handler.postDelayed(hideRunnable, DISMISS_THRESHOLD.toLong()) - } - isDragging = false - } - //保存y坐标 - val params = layoutParams as WindowManager.LayoutParams - SPUtil.putValue(service, SETTING_KEY.NAME, SETTING_KEY.DESKTOP_LYRIC_Y, params.y) - } else { -// mUIHandler.removeCallbacks(mLongClickRunnable) - } - } - return true - } - - fun setPlayIcon(play: Boolean) { - binding.widgetPlay.setImageResource( - if (play) R.drawable.widget_btn_stop_normal - else R.drawable.widget_btn_play_normal - ) - } - - - override fun onClick(view: View) { - when (view.id) { - //关闭桌面歌词 - R.id.widget_close -> { - SPUtil.putValue(service, SETTING_KEY.NAME, SETTING_KEY.DESKTOP_LYRIC_SHOW, - false) - sendLocalBroadcast( - makeCmdIntent(Command.TOGGLE_DESKTOP_LYRIC).putExtra(EXTRA_DESKTOP_LYRIC, false)) - } - //锁定 - R.id.widget_lock -> { - saveLock(lock = true, toast = true) - handler.postDelayed(hideRunnable, 0) - Util.sendCMDLocalBroadcast(Command.LOCK_DESKTOP_LYRIC) - } - //歌词字体、大小设置 - R.id.widget_setting -> { - binding.widgetLrcContainer.visibility = if (binding.widgetLrcContainer.isShown) View.GONE else View.VISIBLE - setUpColor() - //操作后重置消息的时间 - resetHide() - } - R.id.widget_next, R.id.widget_play, R.id.widget_prev -> { - sendLocalBroadcast(makeCmdIntent( - when (view.id) { - R.id.widget_next -> Command.NEXT - R.id.widget_prev -> Command.PREV - else -> Command.TOGGLE - } - )) - handler.postDelayed({ - binding.widgetPlay.setImageResource( - if (service.isPlaying) - R.drawable.widget_btn_stop_normal - else - R.drawable.widget_btn_play_normal) - }, 100) - //操作后重置消息的时间 - resetHide() - } - //字体放大、缩小 - R.id.widget_lrc_bigger, R.id.widget_lrc_smaller -> { - var needRefresh = false - if (view.id == R.id.widget_lrc_bigger) { - //当前已经是最大字体 - if (textSizeType == HUGE) { - return - } - textSizeType++ - needRefresh = true - } - if (view.id == R.id.widget_lrc_smaller) { - //当前已经是最小字体 - if (textSizeType == TINY) { - return - } - textSizeType-- - needRefresh = true - } - if (needRefresh) { - binding.widgetLine1.textSize = getTextSize(TYPE_TEXT_SIZE_FIRST_LINE) - binding.widgetLine2.textSize = getTextSize(TYPE_TEXT_SIZE_SECOND_LINE) - SPUtil.putValue(service, SETTING_KEY.NAME, SETTING_KEY.DESKTOP_LYRIC_TEXT_SIZE, textSizeType) - //操作后重置消息的时间 - resetHide() - } - } - } - } - - fun saveLock(lock: Boolean, toast: Boolean) { - isLocked = lock - SPUtil.putValue(service, SETTING_KEY.NAME, SETTING_KEY.DESKTOP_LYRIC_LOCK, isLocked) - if (toast) { - ToastUtil.show(service, if (!isLocked) R.string.desktop_lyric__unlock else R.string.desktop_lyric_lock) - } - val params = layoutParams as WindowManager.LayoutParams? - if (params != null) { - if (lock) { - //锁定后点击通知栏解锁 - // mNotify.notifyToUnlock(); - params.flags = (WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL - or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val inputManager = context.getSystemService(Service.INPUT_SERVICE) as InputManager - params.alpha = inputManager.maximumObscuringOpacityForTouch - } - } else { - // mNotify.cancel(); - params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - } - if (isAttachedToWindow) { - windowManager.updateViewLayout(this, params) - } - } - } - - /** - * 应用退出后清除通知 - */ - // public void cancelNotify() { - // Timber.v("取消解锁通知"); - // if (mNotify != null) { - // mNotify.cancel(); - // } - // } - - /** - * 操作后重置消失的时间 - */ - private fun resetHide() { - handler.removeCallbacks(hideRunnable) - handler.postDelayed(hideRunnable, DISMISS_THRESHOLD.toLong()) - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - handler.removeCallbacksAndMessages(null) - binding.widgetSeekbarR.setOnSeekBarChangeListener(null) - binding.widgetSeekbarG.setOnSeekBarChangeListener(null) - binding.widgetSeekbarB.setOnSeekBarChangeListener(null) - Timber.v("onDetachedFromWindow") - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - Timber.v("onAttachedToWindow") - } - - @OnHandleMessage - fun handleMsg(msg: Message) { - when (msg.what) { - MESSAGE_SAVE_COLOR -> ThemeStore.floatLyricTextColor = msg.arg1 - } - } - - companion object { - - // private UnLockNotify mNotify; - - //当前字体大小 - private const val TINY = 0 - private const val SMALL = 1 - private const val MEDIUM = 2 - private const val BIG = 3 - private const val HUGE = 4 - - //第一行歌词字体大小 - private const val FIRST_LINE_HUGE = 23 - private const val FIRST_LINE_BIG = 20 - private const val FIRST_LINE_MEDIUM = 18 - private const val FIRST_LINE_SMALL = 17 - private const val FIRST_LINE_TINY = 16 - - //第二行歌词字体大小 - private const val SECOND_LINE_HUGE = 20 - private const val SECOND_LINE_BIG = 18 - private const val SECOND_LINE_MEDIUM = 16 - private const val SECOND_LINE_SMALL = 15 - private const val SECOND_LINE_TINY = 14 - - private const val TYPE_TEXT_SIZE_FIRST_LINE = 0 - private const val TYPE_TEXT_SIZE_SECOND_LINE = 1 - - private const val DISTANCE_THRESHOLD = 10 - private const val DISMISS_THRESHOLD = 4500 - private const val LONG_CLICK_THRESHOLD = 1000 - - - private const val MESSAGE_SAVE_COLOR = 1 - } - - // private static class UnLockNotify { - // - // private static final String UNLOCK_NOTIFICATION_CHANNEL_ID = "unlock_notification"; - // private static final int UNLOCK_NOTIFICATION_ID = 2; - // private Context mContext; - // private NotificationManager mNotificationManager; - // - // UnLockNotify() { - // mContext = App.getContext(); - // mNotificationManager = (NotificationManager) mContext - // .getSystemService(Context.NOTIFICATION_SERVICE); - // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // initNotificationChanel(); - // } - // } - // - // @RequiresApi(api = Build.VERSION_CODES.O) - // private void initNotificationChanel() { - // NotificationChannel notificationChannel = new NotificationChannel( - // UNLOCK_NOTIFICATION_CHANNEL_ID, mContext.getString(R.string.unlock_notification), - // NotificationManager.IMPORTANCE_LOW); - // notificationChannel.setShowBadge(false); - // notificationChannel.enableLights(false); - // notificationChannel.enableVibration(false); - // notificationChannel - // .setDescription(mContext.getString(R.string.unlock_notification_description)); - // mNotificationManager.createNotificationChannel(notificationChannel); - // } - // - // void notifyToUnlock() { - // Notification notification = new NotificationCompat.Builder(mContext, - // UNLOCK_NOTIFICATION_CHANNEL_ID) - // .setContentText(mContext.getString(R.string.desktop_lyric_lock)) - // .setContentTitle(mContext.getString(R.string.click_to_unlock)) - // .setShowWhen(false) - // .setPriority(NotificationCompat.PRIORITY_DEFAULT) - // .setOngoing(true) - // .setTicker(mContext.getString(R.string.desktop_lyric__lock_ticker)) - // .setContentIntent(buildPendingIntent()) - // .setSmallIcon(R.drawable.icon_notifbar) - // .build(); - // mNotificationManager.notify(UNLOCK_NOTIFICATION_ID, notification); - // } - // - // void cancel() { - // mNotificationManager.cancel(UNLOCK_NOTIFICATION_ID); - // } - // - // PendingIntent buildPendingIntent() { - // Intent intent = new Intent(MusicService.ACTION_CMD); - // intent.putExtra("Control", Command.UNLOCK_DESKTOP_LYRIC); - // intent.setComponent(new ComponentName(mContext, MusicService.class)); - // return PendingIntent.getService(mContext, Command.UNLOCK_DESKTOP_LYRIC, intent, - // PendingIntent.FLAG_UPDATE_CURRENT); - // } - // } -} diff --git a/app/src/main/java/remix/myplayer/ui/widget/desktop/DesktopLyricsView.kt b/app/src/main/java/remix/myplayer/ui/widget/desktop/DesktopLyricsView.kt new file mode 100644 index 000000000..996a9acc4 --- /dev/null +++ b/app/src/main/java/remix/myplayer/ui/widget/desktop/DesktopLyricsView.kt @@ -0,0 +1,399 @@ +package remix.myplayer.ui.widget.desktop + +import android.app.Service +import android.content.Context +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.os.Build +import android.util.AttributeSet +import android.util.TypedValue.COMPLEX_UNIT_SP +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.view.WindowManager +import android.widget.FrameLayout +import androidx.annotation.ColorInt +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.children +import androidx.core.widget.ImageViewCompat +import remix.myplayer.R +import remix.myplayer.databinding.LayoutDesktopLyricsBinding +import remix.myplayer.lyrics.LyricsLine +import remix.myplayer.service.Command +import remix.myplayer.service.MusicService.Companion.EXTRA_DESKTOP_LYRICS +import remix.myplayer.theme.MaterialTintHelper +import remix.myplayer.util.MusicUtil.makeCmdIntent +import remix.myplayer.util.SPUtil +import remix.myplayer.util.SPUtil.DESKTOP_LYRICS_KEY +import remix.myplayer.util.SPUtil.SETTING_KEY +import remix.myplayer.util.ToastUtil +import remix.myplayer.util.Util.sendLocalBroadcast +import timber.log.Timber +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds + +class DesktopLyricsView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + companion object { + private const val TAG = "DesktopLyricsView" + + private const val ELLIPSIS = Typography.ellipsis.toString() + + private val HIDE_PANEL_DELAY = 5000.milliseconds + + private const val DEFAULT_FIRST_LINE_SIZE = 18f + private const val DEFAULT_SECOND_LINE_SIZE = 16f + + @ColorInt + private const val DEFAULT_SUNG_COLOR = 0xff698cf6.toInt() + + @ColorInt + private const val DEFAULT_UNSUNG_COLOR = 0xffd4d4d4.toInt() + + @ColorInt + private const val DEFAULT_TRANSLATION_COLOR = 0xffd4d4d4.toInt() + } + + data class Content( + val currentLine: LyricsLine?, + val nextLine: LyricsLine?, + val progress: Int, + val currentLineEndTime: Int + ) + + private val binding = LayoutDesktopLyricsBinding.inflate(LayoutInflater.from(context), this, true) + + private val windowManager by lazy { + context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + } + private val inputManager by lazy { + context.getSystemService(Service.INPUT_SERVICE) as InputManager + } + + private var secondLineIsTranslation: Boolean = false + + private var yPosition: Int + get() = SPUtil.getValue( + context, + DESKTOP_LYRICS_KEY.NAME, + DESKTOP_LYRICS_KEY.Y_POSITION_PREFIX + resources.configuration.orientation, + 0 + ) + set(value) = SPUtil.putValue( + context, + DESKTOP_LYRICS_KEY.NAME, + DESKTOP_LYRICS_KEY.Y_POSITION_PREFIX + resources.configuration.orientation, + value + ) + private var firstLineSize: Float + get() = SPUtil.getValue( + context, DESKTOP_LYRICS_KEY.NAME, DESKTOP_LYRICS_KEY.FIRST_LINE_SIZE, DEFAULT_FIRST_LINE_SIZE + ) + set(v) { + binding.linesContainer.firstLine.setTextSize(COMPLEX_UNIT_SP, v) + SPUtil.putValue(context, DESKTOP_LYRICS_KEY.NAME, DESKTOP_LYRICS_KEY.FIRST_LINE_SIZE, v) + // TODO("fix window position?") + } + private var secondLineSize: Float + get() = SPUtil.getValue( + context, + DESKTOP_LYRICS_KEY.NAME, + DESKTOP_LYRICS_KEY.SECOND_LINE_SIZE, + DEFAULT_SECOND_LINE_SIZE + ) + set(v) { + binding.linesContainer.secondLine.setTextSize(COMPLEX_UNIT_SP, v) + SPUtil.putValue(context, DESKTOP_LYRICS_KEY.NAME, DESKTOP_LYRICS_KEY.SECOND_LINE_SIZE, v) + // TODO("fix window position?") + } + + var sungColor: Int + @ColorInt get() = SPUtil.getValue( + context, DESKTOP_LYRICS_KEY.NAME, DESKTOP_LYRICS_KEY.SUNG_COLOR, DEFAULT_SUNG_COLOR + ) + set(@ColorInt v) { + SPUtil.putValue(context, DESKTOP_LYRICS_KEY.NAME, DESKTOP_LYRICS_KEY.SUNG_COLOR, v) + binding.linesContainer.firstLine.sungColor = v + } + var unsungColor: Int + @ColorInt get() = SPUtil.getValue( + context, DESKTOP_LYRICS_KEY.NAME, DESKTOP_LYRICS_KEY.UNSUNG_COLOR, DEFAULT_UNSUNG_COLOR + ) + set(@ColorInt v) { + SPUtil.putValue(context, DESKTOP_LYRICS_KEY.NAME, DESKTOP_LYRICS_KEY.UNSUNG_COLOR, v) + binding.linesContainer.firstLine.unsungColor = v + if (!secondLineIsTranslation) { + binding.linesContainer.secondLine.setTextColor(v) + } + } + var translationColor: Int + @ColorInt get() = SPUtil.getValue( + context, + DESKTOP_LYRICS_KEY.NAME, + DESKTOP_LYRICS_KEY.TRANSLATION_COLOR, + DEFAULT_TRANSLATION_COLOR + ) + set(@ColorInt v) { + SPUtil.putValue(context, DESKTOP_LYRICS_KEY.NAME, DESKTOP_LYRICS_KEY.TRANSLATION_COLOR, v) + if (secondLineIsTranslation) { + binding.linesContainer.secondLine.setTextColor(v) + } + } + + var isPlaying: Boolean = true // 初值与 xml 对应;由 service 负责修改 + set(value) { + if (value != field) { + field = value + binding.playPause.setImageResource( + if (value) R.drawable.ic_pause_black_24dp + else R.drawable.ic_play_arrow_black_24dp + ) + ImageViewCompat.setImageTintList( + binding.playPause, + AppCompatResources.getColorStateList(context, R.color.desktop_lyrics_control_color) + ) + } + } + + fun setContent(content: Content) { + binding.linesContainer.firstLine.lyricsLine = content.currentLine + if (!content.currentLine?.translation.isNullOrBlank()) { + setTranslation(content.currentLine?.translation!!) + } else { + // 翻译和下一行歌词都没有时显示省略号,统一用显示下一行歌词的颜色 + setNextLine(content.nextLine?.content?.ifBlank { ELLIPSIS } ?: ELLIPSIS) + } + binding.linesContainer.firstLine.setProgress(content.progress, content.currentLineEndTime) + } + + private fun setTranslation(translation: String) { + if (!secondLineIsTranslation) { + secondLineIsTranslation = true + binding.linesContainer.secondLine.setTextColor(translationColor) + } + if (translation != binding.linesContainer.secondLine.text) { + binding.linesContainer.secondLine.text = translation + } + } + + private fun setNextLine(content: String) { + if (secondLineIsTranslation) { + secondLineIsTranslation = false + binding.linesContainer.secondLine.setTextColor(unsungColor) + } + if (content != binding.linesContainer.secondLine.text) { + binding.linesContainer.secondLine.text = content + } + } + + private var isPanelVisible: Boolean = true // 初值与 xml 对应 + set(value) { + if (value != field) { + Timber.tag(TAG).v("set isPanelVisible: $value") + field = value + binding.root.setBackgroundColor( + ResourcesCompat.getColor( + resources, + if (value) R.color.desktop_lyrics_window_background else R.color.transparent, + null + ) + ) + binding.root.children.forEach { + if (it.id != R.id.lines_container) { + it.visibility = if (value) View.VISIBLE else View.GONE + } + } + if (value) { + // 控制组件由隐藏转为显示时,字体颜色和大小设置默认隐藏 + isSettingsVisible = false + } + restoreWindowPosition() + } + if (value) { + handler.run { + removeCallbacks(hidePanelRunnable) + postDelayed(hidePanelRunnable, HIDE_PANEL_DELAY.inWholeMilliseconds) + } + } + } + + private val hidePanelRunnable = Runnable { + isPanelVisible = false + } + + private var isSettingsVisible: Boolean = true // 初值与 xml 对应 + set(value) { + arrayOf(binding.divider, binding.settingsContainer).forEach { + it.visibility = if (value) View.VISIBLE else View.GONE + } + field = value + } + + var isLocked: Boolean + get() = SPUtil.getValue(context, DESKTOP_LYRICS_KEY.NAME, DESKTOP_LYRICS_KEY.LOCKED, false) + set(value) { + (layoutParams as WindowManager.LayoutParams?)?.run { + flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + if (value) { + flags = flags or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + alpha = inputManager.maximumObscuringOpacityForTouch + } + } + windowManager.updateViewLayout(this@DesktopLyricsView, this) + } + SPUtil.putValue(context, DESKTOP_LYRICS_KEY.NAME, DESKTOP_LYRICS_KEY.LOCKED, value) + } + + init { + binding.linesContainer.firstLine.setTextSize(COMPLEX_UNIT_SP, firstLineSize) + binding.linesContainer.secondLine.setTextSize(COMPLEX_UNIT_SP, secondLineSize) + binding.linesContainer.firstLine.sungColor = sungColor + binding.linesContainer.firstLine.unsungColor = unsungColor + + setContent(Content(null, null, 0, 0)) + + isPanelVisible = false + + binding.close.setOnClickListener { + SPUtil.putValue( + context, SETTING_KEY.NAME, SETTING_KEY.DESKTOP_LYRIC_SHOW, false + ) + sendLocalBroadcast( + makeCmdIntent(Command.TOGGLE_DESKTOP_LYRIC).putExtra(EXTRA_DESKTOP_LYRICS, false) + ) + } + binding.prev.setOnClickListener { + sendLocalBroadcast(makeCmdIntent(Command.PREV)) + isPanelVisible = true + } + binding.playPause.setOnClickListener { + sendLocalBroadcast(makeCmdIntent(Command.TOGGLE)) + isPanelVisible = true + } + binding.next.setOnClickListener { + sendLocalBroadcast(makeCmdIntent(Command.NEXT)) + isPanelVisible = true + } + binding.lock.setOnClickListener { + isLocked = true + ToastUtil.show(context, R.string.desktop_lyric_lock) + } + binding.settings.setOnClickListener { + isSettingsVisible = !isSettingsVisible + isPanelVisible = true + } + binding.colorSettings.setOnClickListener { + handler?.removeCallbacks(hidePanelRunnable) + // TODO: Set colors + isPanelVisible = true + } + MaterialTintHelper.setTint(binding.firstLineSizeSlider) + MaterialTintHelper.setTint(binding.secondLineSizeSlider) + binding.firstLineSizeSlider.setLabelFormatter { "${it}sp" } + binding.secondLineSizeSlider.setLabelFormatter { "${it}sp" } + // TODO: Change on stop? onStopTrackingTouch + binding.firstLineSizeSlider.addOnChangeListener { _, value, fromUser -> + if (!fromUser) { + Timber.tag(TAG).w("firstLineSizeSlider's change is not from user") + } + firstLineSize = value + } + binding.secondLineSizeSlider.addOnChangeListener { _, value, fromUser -> + if (!fromUser) { + Timber.tag(TAG).w("secondLineSizeSlider's change is not from user") + } + secondLineSize = value + } + + setOnClickListener { + isPanelVisible = true + } + } + + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private var isDragging = false + private var lastPointerY: Float? = null + private var lastWindowY: Int? = null + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (isLocked) { + Timber.tag(TAG).w("isLocked is true but received touch event: $event") + return false + } + val params = layoutParams as WindowManager.LayoutParams + return when (event.action) { + MotionEvent.ACTION_DOWN -> { + handler?.removeCallbacks(hidePanelRunnable) + isDragging = false + lastPointerY = event.rawY + lastWindowY = params.y + true + } + + MotionEvent.ACTION_MOVE -> { + if (abs(event.rawY - lastPointerY!!) >= touchSlop) { + isDragging = true + } + if (isDragging) { + params.y = lastWindowY!! + (event.rawY - lastPointerY!!).roundToInt() + windowManager.updateViewLayout(this, params) + } + true + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (isDragging) { + params.y = lastWindowY!! + (event.rawY - lastPointerY!!).roundToInt() + windowManager.updateViewLayout(this, params) + isPanelVisible = isPanelVisible // 触发自动隐藏 + isDragging = false + lastPointerY = null + lastWindowY = null + saveWindowLocation() + } else { + if (event.action == MotionEvent.ACTION_UP) { + performClick() + } + } + true + } + + else -> false + } + } + + override fun performClick(): Boolean { + super.performClick() + isPanelVisible = true + return true + } + + private fun saveWindowLocation() { + val location = IntArray(2) + binding.linesContainer.root.getLocationOnScreen(location) + yPosition = location[1] + } + + fun restoreWindowPosition() { + val location = IntArray(2) + binding.linesContainer.root.getLocationOnScreen(location) + + if (location[1] != yPosition) { + val params = layoutParams as WindowManager.LayoutParams + params.y += yPosition - location[1] + windowManager.updateViewLayout(this, params) + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + // TODO: Does it work? + Timber.tag(TAG).v("onConfigurationChanged, new orientation: ${newConfig.orientation}") + restoreWindowPosition() + } +} diff --git a/app/src/main/java/remix/myplayer/util/SPUtil.java b/app/src/main/java/remix/myplayer/util/SPUtil.java index e06e83807..0d4fd9236 100644 --- a/app/src/main/java/remix/myplayer/util/SPUtil.java +++ b/app/src/main/java/remix/myplayer/util/SPUtil.java @@ -2,13 +2,8 @@ import android.content.Context; import android.content.SharedPreferences; -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; -import java.util.Arrays; import java.util.HashSet; -import java.util.List; import java.util.Set; -import remix.myplayer.bean.misc.LyricPriority; /** * Created by taeja on 16-1-15. @@ -54,6 +49,12 @@ public static void putValue(Context context, String name, String key, long value editor.putLong(key, value).apply(); } + public static void putValue(Context context, String name, String key, float value) { + SharedPreferences.Editor editor = context.getSharedPreferences(name, Context.MODE_PRIVATE) + .edit(); + editor.putFloat(key, value).apply(); + } + public static void putValue(Context context, String name, String key, String value) { SharedPreferences.Editor editor = context.getSharedPreferences(name, Context.MODE_PRIVATE) .edit(); @@ -79,6 +80,10 @@ public static long getValue(Context context, String name, Object key, long dft) return context.getSharedPreferences(name, Context.MODE_PRIVATE).getLong(key.toString(), dft); } + public static float getValue(Context context, String name, Object key, float dft) { + return context.getSharedPreferences(name, Context.MODE_PRIVATE).getFloat(key.toString(), dft); + } + public static String getValue(Context context, String name, Object key, String dft) { return context.getSharedPreferences(name, Context.MODE_PRIVATE).getString(key.toString(), dft); } @@ -102,34 +107,11 @@ public interface UPDATE_KEY { String IGNORE_FOREVER = "ignore_forever"; } - public interface LYRIC_KEY { - - String NAME = "Lyric"; - //歌词搜索优先级 - String PRIORITY_LYRIC = "priority_lyric"; - String DEFAULT_PRIORITY = new Gson().toJson(Arrays - .asList( - LyricPriority.EMBEDDED, - LyricPriority.LOCAL, - LyricPriority.KUGOU, - LyricPriority.NETEASE, - LyricPriority.QQ, - LyricPriority.IGNORE), - new TypeToken>() { - }.getType()); - - int LYRIC_DEFAULT = LyricPriority.DEF.getPriority(); - int LYRIC_IGNORE = LyricPriority.IGNORE.getPriority(); - int LYRIC_NETEASE = LyricPriority.NETEASE.getPriority(); - int LYRIC_KUGOU = LyricPriority.KUGOU.getPriority(); - int LYRIC_QQ = LyricPriority.QQ.getPriority(); - int LYRIC_LOCAL = LyricPriority.LOCAL.getPriority(); - int LYRIC_EMBEDDED = LyricPriority.EMBEDDED.getPriority(); - int LYRIC_MANUAL = LyricPriority.MANUAL.getPriority(); - - String LYRIC_FONT_SIZE = "lyric_font_size"; - String LYRIC_RESET_ON_16000 = "lyric_reset_on_16000"; - String LYRIC_LOCAL_TIP_SHOWN = "lyric_local_tip_shown"; + public interface LYRICS_KEY { + String NAME = "Lyrics"; + + String ORDER = "order"; + String OFFSET_PREFIX = "offset_"; // offset_$hashKey } public interface COVER_KEY { @@ -137,17 +119,23 @@ public interface COVER_KEY { String NAME = "Cover"; } + public interface DESKTOP_LYRICS_KEY { + String NAME = "DesktopLyrics"; + + String LOCKED = "locked"; + String Y_POSITION_PREFIX = "y_position_"; // y_position_$orientation + String FIRST_LINE_SIZE = "first_line_size"; + String SECOND_LINE_SIZE = "second_line_size"; + String SUNG_COLOR = "sung_color"; + String UNSUNG_COLOR = "unsung_color"; + String TRANSLATION_COLOR = "translation_color"; + } + public interface SETTING_KEY { String NAME = "Setting"; //第一次读取数据 String FIRST_LOAD = "first_load"; - //桌面歌词是否可移动 - String DESKTOP_LYRIC_LOCK = "desktop_lyric_lock"; - //桌面歌词字体大小 - String DESKTOP_LYRIC_TEXT_SIZE = "desktop_lyric_text_size"; - //桌面歌词y坐标 - String DESKTOP_LYRIC_Y = "desktop_lyric_y"; //是否开启屏幕常亮 String SCREEN_ALWAYS_ON = "key_screen_always_on"; //通知栏是否启用经典样式 @@ -164,8 +152,6 @@ public interface SETTING_KEY { String COLOR_NAVIGATION = "color_Navigation"; //摇一摇 String SHAKE = "shake"; - //优先搜索在线歌词 - String ONLINE_LYRIC_FIRST = "online_lyric_first"; //是否开启桌面歌词 String DESKTOP_LYRIC_SHOW = "desktop_lyric_show"; //是否开启状态栏歌词 @@ -278,11 +264,6 @@ public interface SETTING_KEY { int NEWEST_VERSION = 3; } - public interface LYRIC_OFFSET_KEY { - - String NAME = "LyricOffset"; - } - public interface OTHER_KEY { String NAME = "Other"; diff --git a/app/src/main/res/drawable-xxhdpi/icon_lyric_add_offset.png b/app/src/main/res/drawable-xxhdpi/icon_lyric_add_offset.png deleted file mode 100644 index 420af6ef4a01697c12e713420fd58d4f605488ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 670 zcmV;P0%84$P)JN+&G5zTnFykjEI7a4SU=Ra}7%+jLY&j+6wfgos)!lc- zdhD*Q-s|+L&{O>9ZK@&vYT(lnV_O4!9L-b$*a(~f9su{32*eTKC~zHkk!KnO5an55 zC-5D(0XzhLE)t0H?XgTNEc^`cF6GGqHUTGrZCO}pbQhRiB!Gj!G_W>%|8pzsD_u(f z(&jv{xe)X#aNCrcB*$SZs473&-T+thMFfy*(-crT$-hSv&`Ftg1LuHJ_>@s|VMd)# z*aT^-u5`L%5N&&4KxR720a_rHMQ`rUsBsg6N~|Gi^Ktd=1m@}QWn>MQ)m2cIi79gtAd(-KM zowu|ov^AGDT>xry($A2m@?%$sM5oT$3$E)mfO~edXrJ1h*!v!BcFWQaxc8w*zo7vlS*rT}0=`O-m##2eJOBUy07*qoM6N<$ Ef|c4c7XSbN diff --git a/app/src/main/res/drawable-xxhdpi/icon_lyric_add_offset_second.png b/app/src/main/res/drawable-xxhdpi/icon_lyric_add_offset_second.png deleted file mode 100644 index a006dbcdf351ab05de0038c3f6691f3dd4c74cca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1236 zcmV;_1S|WAP)oi%rU-_n zr43pdVPyj;O_o}zXmgsT7SCmE*&g(VBKL2iY@^4YI!^yyPKv$q?fc*|w1+1^~j5C2@W!~8cEU$9kn&q_tSg_9kw*sf9 z_B;ga0-gY#t-`tPz*}W}dmgyIian1m%?tPhU@-6$a2Qwu%mo?&_W(Bl`+&QEl@&TT z1b7Zu4g6H(oi)I`3imr^Szf?r0j~iafr-Eb;O{u02{0!>F9Ze!_;P+)4X*7Bd|YmE z#&db(6yT4ncS`}j0%!<)0Q^tfzW^Tr)6?gc0=xle4_pa+UgY4Zz$jn=&@W?+0ow!A1G)k4RRG|}C}o2s z2b>h|9Ei5b+QMpB1e^=}3Ak3Z@ciAt-M~84BQIcsauqPV1TlRAw2MA|sSMyVB0vVl zwJm{WA#>ZqX}Lf^iJ47X=N4cx@FuVWu%-F{a>CwFku7-vD?QDDJAs#rfRTKz1|9&O zE(5p|@P4$AT5g6C^SfngCWodpUK#ulXaLA5>0tPMZM2)LJ})m|%g2N4&o_#I(ZSn; z&>kxTxPP#`PQMP!58@Kk$T&wr8Usv<5HL#=;HG0k2PLyhV(3^}1dN%I&F_uu zmBO4P!0b4<6q;P%gk?5Igx{{YFtR=%5-32G<7Z$K@MHw12Czdy7GUdNz_MKWaCLTE z0Cse=ob4%xg)CE&Bp-(m*XJ?E&Ud5o#)lv2{q)(1PXcQj@?^`|`~H8efc=~r;L@U+6np$t`*waadtx!K! zqP?4#;3dJwV=ywQL-UfBQIu7!fXjey3Ow`@V1G$y2E-C8W@gU{koBF|vKYO05Sct8 zLA@RO8hA%q-z&5SfoXtTGB7V-BdZ+QqelJ?;8(!$&Y{I;vZ#*uNtm-&JpUmaNhUdG zaZ67f9tbFV@`uD~R%ZlR*~d2r8C@RhB|0-|P(4R%GP(h`w|#eHUcg=+WL3+>lJwf9 zv;J)fw+VT<4{(A??1cbib=YrCRGMn>VpNM#<6b^9`(1zzK|WqglJ>A2J^!{~!c%`5-}67XOrmq1ArQ=92MSriUD>%gQQ-o-N}y$`YH3i63+@- zr*%l4S-0Hf7iG*!GpI_LWOpR*Z7JO^-ixmfnUWQ~_$vuX8JB3^%tvP1wlKR}B6|{3 yDRb^uCR>EMdN2jma1Y9kk>xNB7Rf;X0000a1Fkw1mY5vJV6L?CznbtqJry<5fS;N2PWh6 zOw~zsh<$S|PS5oJSASL2(?k5{W2mtHW55pJU8!|S8aM*n28MzAz}pfgN*UM(+yS%$ z8Xtgrz{hA4B@9UPRp4kt?0aB78mDLj6*h-6k^U5oQ>1|=HaS{Caf&jK!zO1U45tVK zec0qogy6JU16FK`;j|e8UTlisG^l|=u?fd%5Ca};ek8@a3=M3c>f|-x!R8C_82ANT z0S+2WxT}-LfCn2z{sHhNi9VUmMI#0iuIgkp;K61+b=%hsv1fo=KqyWY10HO?CTvzR zkv;`n1`Zf@!h}AH@>XRpJrD#;Kk-?N?60Xy;CNDX~2zBWx$J#BCmwaRhwSLjS12r6H06sPI_<*n5lh$ zcP*|`sf1njDm2bN4_x!$Bm-(JQ-%e;1HwiLTXLvl3n%4c-&m3XP1M$%=$QR8byy{A z6gFMeNhYLecZa4mV7AJEL%>}?zs(`>iu_VvID-GbkgjLis?%qQ2UhDsBlu`m9WpY0GlqH&Za&goc>}{zu{}^#KK6| zf~qu&)=(9mcQF;3Ibl;@D|Z$e1%%Ib8hZhqyEcrz#e5y_{*KRZ4lW%ch)wb6fEcdCVMF+w zezYyH8E^50#aVE~d zFXd@LTvOm~#K8q}P&}z_&Tfbo2OaM5JUpKr-}idAEwC+d55B`vJp$hWcj66fh4)$n znrf_!&G1%>^UaaPa01rF-1r(V;KI&(+X6cu$KVF++ariM@dakaCU~kvpdVKYCMn_Q zto6>6YWH3)Qr%myD1JcBMxAs2=hz2NM6KHbdm0PH2Ed%Asz8WGFkCopfIICRV>$anD0mAjMZGk0Ik@;{a znM~_ORI=8rQO}}UxiCMCQf*hy!`EDxwvJ`jOv_}BjYY=EZ^fHb>9P`y@NB0Nh44M+ zBRHv0l9zjeAqtwrhI@H;d|O}{1yQk-Ijxk>iCmK3(|RkF03C#E6pdY7fv4KxLpF@q zD!9I?9|@1IJWDksfcpuUMlbRZN>^5zlx?=OlR<5fPoK z!vJfl5Eb2~i*?wM|>3#qUqbnXn+kH f_A;sV615!1?&xI z)>jV!Ba>qTgeo4xMBDPJsb7JQfyaR_GGH3h+BHnrBfY__2OJ;1F*b;t0=@_C4WAOE z)HW^sv7u@(nw2JX7_d>LJsDko2-WMDf>*1))#hgnE4B0t@Ivrtn^ybzDlppPp}-*_ zNS6SO&A$bF716~Awmf8pZTn&j)ms940NYlWs(jy$`?qhIR&OwS0jCrg$V@?vMy~@; zg}v9j@#f6hp>d<38XIc93u?wlb!vx3OoA_k34GfJL2_0AYHH6g=QRiL>I7r{I0M+Y zz*wJnByz*hgqO0`1a}5b3@!D+R0N|<9|!E4QM*y)JK(O!;C(KvJ*yv`4)oDb5zN7n zkbF~Z63Ax(WE!(t2$a5~pQ@!=%7R%3xU7)X^^tpkcWQ4Ka4we+YuL;Kqa&IWt#wi| z#6Af;QNwxQnNmWmVK)zq&VF{3?hee`#E5YV49c_EOq)9Lo1rv*ffo46~Pz=F9Oz2q}A!~j?#Q=Ak&&w5zMB* zC7H5C6T80Y6-GoehG`YS><65j5%%RM!$-tXEaRuSV~)=VL_jIoPY7sCvYjy4SdouK zw>Isz`Q@Bwg;VC5SgzZfMu=&uq+pJY z!9{xeqrlU%7isv7f^nc`o1D^nz%9dB&;CilSd(2EL$&Yu&LkXlJuIMW%X<`o4O&xQn4Mqn$ zKgOl$IVYDga7wcnEp+=nWpZw2YqCZLj4MP_#-v(q2Y~kZ&BI1>Ydy`({h?F&#U%a2 zsir9-4M?`~x0NcB^sv7lRG4ZCKovg*JhuoD&Zz2&@wCDq(yfCL%yf~Rap%?u_UQVK zlldHJX6_#a&va51>H_2c>~pLRwpoZ#T>5#`a%#=Q*3?Z3@5kZ1`ya=_x$ZW-Mw4FP34}IiLz#6Qi={ewQ87qXl8@J9S2dz+E z)e$vn_)lmyZDSWfmaL(A=aHUmjm82bq)TZX-s@!jB^iIxwz%+80mGvb}&(ZD} zWM3i(H8L-=*IqCLOq;jgWKvUt)4`i>Gj&Bj!@629o2!Xu#~L}Y5!9lAplB1-qG?gIFo+i6 zrh$k8sc5F4frx0Jfrth(8`M|-1OHPW*Y}=pxc9z$h2P4B_viWk=X_`IczXUMMSyF6 zv_Kz_9E3h@7S&=YARVNFG(jqVml8zQHFE012ZI%$0eC?s=?gN1yiwFOqcN{{Tm-8?4HyarIk9ZDU^+)&R0M(=H4(iBCqWbQo5OtS zvQ4;XJc63v;3+r?X0mJ@lg}hEgDSzw7z8z{Z0~^`TtD+zwh7Ir6!gX^sIu7wK7uRE zXJw-E8BFkQjDk{Bub(QP3xGdS`7{bvnII@dRX(4A-CzbN;<92}KCKiqPDIziMley7 zt>yW&OHhxf>Tg?^&k9ktmgUn?pLIziAV`W74pjbXNB5$DrMg2stRZOgWSX)N1N z+p@6vbgH22Z!dBEtc{}WbB&P=UN>lzMJ{M>1A!&G7}q(f-GTwpN1g*~z*PR-a+YJEp&a8n3Aam7 zuV-z%pxMfs;u_}6&z$)vX4+kbU4k7v!aoG&fw3ZI#mrf*6}QD!3+h9p7DLYDVx8B^ zePVJvFRZK*?B;o=YOgl&V7DrUY2R^2_bO{Yc*nShM}O7q9sQj4eTHe@(l<9|3aU|i z!JI7tb<9~=n4H;ueHQHnsGjy!IiBY6!IWOwt5DOvB`+Ccy~5UFZEUa9y~1>sqwP5} z`w>Eb+ZeU0-picTF=wW{;1pO4#u#!IZF^<;Q<6vpySa_I0M_$7vMNZ< z9QzO`+*2-fdezGIszI!0mbF)QJ;qbz_?Ryw8pZz1FLGu{du6tu@j2xUzHvDM=2Fbh zQq*FWwpUgN>Y22JGh-7=F`pAhK1(sz^Z!At*W>U9v3~)|k?L~l;Tb*v00000)+jEP)`ee;Bw23aZfCV#Bqy;r^Kd zunqiQIiyh!V*vJmbKn)YUMYkNUyPY`?LKp(jH@`** zee=P?Hwb+IG4r7VZ6kHEWGQUrBc?A9x&WrkhYp{ByM+=s1NIZ<_6Z>XNHxD?2&v}3 zgo6O&nhza30{02DTQoTZei#P26@b`*k<#A-@GGTZ#Wp9vfwBLW5CUt3P-6Z)zv&)= zB@q4szm2P4{p=VxG=BV<-1V=)5(s|){aVPyye2x72rV7L5ul$%-_rV6DqI3Vp`ehc zA#HiBzD0vI%~&y10zo%}ZU|4gn~#M+chXCNplePizD1+e?9_XJ1}uS~L`ex#iIm_d z(XnWIaLenokJ0}ieAiMmQQ zpSuG*Am~*|uS;WUX5I>*^h_9opeHQtGK;cS-Wmd4XM!GCHQ}!Gc&SHBi%jNAc7R7X zTmk<+I-1422Y}LW5CrMcvh2AB-q0Aiw?J&3CAWjod){du8^jAh=}yRnV2=6zK%M|v zL2xtgbq4gI)(i(%^R)of+6nIFYXb0wP{n+00A3KPnQsB06hc+=tpHddR5#xeKrVzP z=34_uh0x6W8~~;uG&MgLfEa}4=H~>^hcJuzxd8+Ssz+w~7n*l5lv~x!-hSf&yaAl> Ve5$Uv0i6H<002ovPDHLkV1hJa6>9(h diff --git a/app/src/main/res/drawable-xxxhdpi/icon_lyric_add_offset_second.png b/app/src/main/res/drawable-xxxhdpi/icon_lyric_add_offset_second.png deleted file mode 100644 index 5d66f56366d9d345a787e6e354493b193a261469..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1506 zcmV<81s(c{P)3OzIV^sYw!J^75`dq1j8~A0wV@m-F)$Mz=FWMz>mNOz?;pN|9>^x z25?s3NMKK3vH}dR0jC024hYZWz%M}4yW9k9HQ=7z2TvX0+=rR|mb3xj#K3#N zTydnefQNb}->fIopzz)y7>6rR5i%ma)D_8;QfTwO0d2biS7 z#@oQwz)MM|4FFB(CU(W}yuRjAuLFyOjvnyV5J0e4Su}hBf+vXP(#3;!egwP<2y#`G zUjeWHO$v+!?gn(6FM#=hEy9UBnt6IY;5+|l1Hk8jr2~XDfJbToc@$VRbo{hRHWdL+ zg#38Gl(0m}Vhh-Qz@0q|-w13O&s_gXiQbvOLGk_3z;SH>C}m09Z%N?g8Z^v-UR;sj z%F~+4Mirox*|-kaA<{+uJf(qeff;*jZZ7vl=S^wrbPFA?5@=sX^mXV3*J_ z51ro>JwX0=R;Dk{yg3s7&%k290f3Q^X}KnkM>xmZxl$@c;GL`U^6g#@5sU^w(EhR!;%SPqz8hSkt2SS51HjYGj|_;kOA zs97f?(9@I$gFEI+?A>kge3@3`GVyHcbop=_0Oo+K8FZlPZgQ$rIi(DCF>Q$XFjdzo zg74^U{qR=#Z_z6aY1>dEr%PjO@Xc3c;<(9P)?B8Cl!@V;Le(PRbVWG=IHdv&6S8*g zVI|bF06p8L0a5iOB?9~uuwp}d0=OYyDw9JEfHKAbT-z|QZo-7c*3vh=Zmx!cYa%}-pJo~e40B9>DEs#%H z@!UvqT_(A=+9FA4o65q`qpEM%8~#;dr?E;Xi)ZE#3z4-0DBJEdb3MF9ME)6ls8Ab5}I}hP9N)9H{IBTvc*#Da6Y0eHj5Z zFI||eb0}+axBD@d+G7izWID+?}wffZGI6`_cf1q#m#+R$nW0>JUm)IwZEr1JfPMpL%GT>J)7`hO!4vbIP*4988#c zEKa?|9DP_!cRD&v0Iaar=#d_0!d*7%_XMa^2x{owVAS@_f1CGl4k@qzf z>5VoVS($&$v+o84>dpa^vHt?VrU#!O^@af)@U;yCI3R%k0IvoyEG*#b;Q#;t07*qo IM6N<$f=yS*-T(jq diff --git a/app/src/main/res/drawable-xxxhdpi/icon_lyric_reduce_offset.png b/app/src/main/res/drawable-xxxhdpi/icon_lyric_reduce_offset.png deleted file mode 100644 index 9ff56133a4097c4674beeb07113c79f343efdaa8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 590 zcmV-U0Q1q{I#kzgsn2i{zQm4Fa0K9V6830}}wevc31=J2N(0moR8e(coLR)|ao=cuf-?EZO5CKzz z7t!1qxI@I$W+hMn!v{l1OmUmzc2}}A64n$Do4|O$;X$W{;%Skm#?V^ID-bH7CU}8n zZeV`!txY&G=VJ z-B$h+0Ww>j19V=oq%H^U1EvL52etqviud~f!`q&)OK*MP=6Lo9@N12ffly8&=hyuSxn zqu^xs0;>Z4wsZ%Pd4QvV6@Y2NkO1%+@HlX2pzzQhB>~zWI58}o16Xdo#4q#}Fc$DT zFa_{KpC^15m<>1s*sIUAxw=_+IxtR+jW>Z!fftfaNq}saHcrLzxw^sWSAqFL$1nIs zABPkSY{~}M;20!(*@D5lJ^)?@3}PKhE(O@&#|1_JcK{Cpp8<0M8v!eZU-rp4fN%W2 zBtXvsi-xh4fQK3m{4lV5==fWdX)6Xa5zeCkOLn1(#R9~hz->JYUk7X)?_B>B<^n*7R}9p}eFVs!vq$J# z0s=%;uQ>=fF7yP0Wiq7oaZWevY5H(U=+rX<I@v%rAbaME7lsW{x5klAqLx413E^ zSg+5Lk%>5pg7~pSvntUyb@aI-DL`9AJ@<0xG~n}inICxN zfXljr$SD%?2&uC0e?rXigidt?)~lH{Bin@b?yR!p9FxraBAB+SS+UPbYDbo!nB|gE(BFwPR{7%)n@Y-i zq3v2at(xP7D5kiNti4XWJ-8wiM9v-KZ{d5c_}c(>2w;UsN+pjrctzizE(wrSUD%XQ zh!`4@JXT4bZBlp%TU(hoW+e43OUtuPWXhtklGBL|fg9twmwU(77?B`K6O`4wz9|Wi zi*xw(UdSN**0r(pUE*d#w14>(yXPwkD06BuJ74JpQ5v2jLZl4FdGEjs`)B5%pEG;`n1ACCNEpS^OU{Y%bf8Hj$|>(X23OFd*uhWTai+a5LBvsVX6F1EeDpXAU^1I$D@*B3lx{ z#D?Dq;2;5Vy)fw|m=1J{=q17>hFhW1A!&6Plzg;*nPKTxpxc($6wYAjo~mju2VYx* z&A*q}BM*$*fsW3g0ZJ)qLt8xhg5r*!8lCjYdptg?vFZIR*sNu7X2~b0T{sJV$6D%DxgMI)2002ovPDHLk FV1kYisjL71 diff --git a/app/src/main/res/drawable-xxxhdpi/icon_lyric_reset.png b/app/src/main/res/drawable-xxxhdpi/icon_lyric_reset.png deleted file mode 100644 index 3277d74d8b825b665de9b450f41d248bef1c2f72..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1702 zcmV;X23h%uP)|Jh8chzsItE#K3?kpD26a;21z+)-`eFCx^uo|!=uo$pt zc>V@{49^ckKmXP72U`XB8u+7Fr%OOq1U3WK3P1=z9r_b^7kCSJ8~C}-%Nb(@<9OgQ z;McAISqj(^*c@=dw&)+=g8jwd3>*q9Qj^Qyf~$NzFzEktXj#BD zHv!fL>c&fYq9=hJLl78Sv$mI66*#%37x^+U^ajv0+SHS69o%}|;4TOJT7byFsWnvi zd*ER}jfh$N97c!77(^NC_|@Btb!q`}R8ZR-0SvS69x+(TnZFpb46r}2QJt3}W3vI- z37DI?sBwUiW7}F$wY)ZEkDxJ`ELIN70%T3##EdxO7vQdN2y4pNDDUh34{o@Hl7MK$ zPRocuzXh%bv>W53ZGeLsFpvb~@NjI8t)b_*4)~-2BW=8z6KAB>i2|}RaC&M6;Qnyp z%oy6)kT$@OxhNnW);3Lu@CtBGXJY0}+!h#7KytL>3*ed>$L&1v>Wyj>j3^)nguF(K zy(<7g85gw;MkOE~u+B-u{jY|=YkmTw5|9mm;}Viy4tPcx2X&ztg&d_u)!@bhR297g z+&m5>LbBTIxM!Izs1%U1Lr$VBzzi6nmK`o zH4m75(X)WprvH(7g`-3MvWkH#fzSHlZrtk#hO0r7O}Ii`xv zAA^U<7NG5c0&->|iF_VlT7M!)K+KML)lfwj0;UQlf&|2{c-@G!7L<77HW4HssjCL_ zA0JNy8o}+Fpw;(R(S5+nlLScW{^dE~!ASyS9pHqBz#jqEP7)wOJwiszUqQ~=@~uC}C#X5zLv1z0^IlLdQ^Q~~0PP7tFT zfcIyJisZ;snj;>p%sC6Na;J&}Lr`V{S?v_7gEOu&zWN^x0O?9#k)`I-d;}v+Wm3mSP(4Vc0rNYsb3K zvYmmw6BAp{dVAHFVL)=U#JO}=4|O3?gE`j=mu<0PVzuMySO&1H+);!@Iz@K?RvAtQ za*LdlQ8sGX*JCk;%K*`aIC;#6JEtYoRt=Ba6&p}`^iaEF%>_h; zWYqj?s!B1%ycv9R5tdgJ;CPp7L5zv`>JwZcekt)nb#-ejMKOs7B__4l^2 z-rD3IX%s+YRyEfD+W|ElNNqFO^J3F@XjDK1K`;&jOx2B(Jgk}GbQ?CNCV;rO@7u|3 z?{1SmxlshM>A(_mgTmIRDIf(S#w!Idv8-Dgj2nJiGpkE4Ye{XiMq7XsOgNwS#P#x1Y)dAHmpAZ1@sJ7eTq w{Ou6`EdbCc3c^V7Lt84-Hy~~C-Pt_<0ux0sR7fEyegFUf07*qoM6N<$f-%+t;s5{u diff --git a/app/src/main/res/drawable/ic_drag_handle_24dp.xml b/app/src/main/res/drawable/ic_drag_handle_24dp.xml new file mode 100644 index 000000000..c1280dad6 --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_handle_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_lock_24dp.xml b/app/src/main/res/drawable/ic_lock_24dp.xml new file mode 100644 index 000000000..88f09039d --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_looks_one_24dp.xml b/app/src/main/res/drawable/ic_looks_one_24dp.xml new file mode 100644 index 000000000..2297a90dd --- /dev/null +++ b/app/src/main/res/drawable/ic_looks_one_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_looks_two_24dp.xml b/app/src/main/res/drawable/ic_looks_two_24dp.xml new file mode 100644 index 000000000..6d820bb82 --- /dev/null +++ b/app/src/main/res/drawable/ic_looks_two_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_palette_24dp.xml b/app/src/main/res/drawable/ic_palette_24dp.xml new file mode 100644 index 000000000..0de8857eb --- /dev/null +++ b/app/src/main/res/drawable/ic_palette_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_24dp.xml b/app/src/main/res/drawable/ic_refresh_24dp.xml new file mode 100644 index 000000000..a1e4f05de --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_24dp.xml b/app/src/main/res/drawable/ic_settings_24dp.xml new file mode 100644 index 000000000..2b1946bfb --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_stat_1_24dp.xml b/app/src/main/res/drawable/ic_stat_1_24dp.xml new file mode 100644 index 000000000..73a6c7a6a --- /dev/null +++ b/app/src/main/res/drawable/ic_stat_1_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stat_minus_1_24dp.xml b/app/src/main/res/drawable/ic_stat_minus_1_24dp.xml new file mode 100644 index 000000000..379401170 --- /dev/null +++ b/app/src/main/res/drawable/ic_stat_minus_1_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_text_decrease_24dp.xml b/app/src/main/res/drawable/ic_text_decrease_24dp.xml new file mode 100644 index 000000000..f800f35f7 --- /dev/null +++ b/app/src/main/res/drawable/ic_text_decrease_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_text_increase_24dp.xml b/app/src/main/res/drawable/ic_text_increase_24dp.xml new file mode 100644 index 000000000..3ad11f16f --- /dev/null +++ b/app/src/main/res/drawable/ic_text_increase_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/dialog_desktop_lyrics_color_settings.xml b/app/src/main/res/layout/dialog_desktop_lyrics_color_settings.xml new file mode 100644 index 000000000..9c3dd4613 --- /dev/null +++ b/app/src/main/res/layout/dialog_desktop_lyrics_color_settings.xml @@ -0,0 +1,345 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_lrc.xml b/app/src/main/res/layout/fragment_lrc.xml index 8570987f1..1161d7964 100644 --- a/app/src/main/res/layout/fragment_lrc.xml +++ b/app/src/main/res/layout/fragment_lrc.xml @@ -1,83 +1,79 @@ + android:layout_margin="16dp" + android:orientation="vertical"> - + android:visibility="gone" /> + + + + + - - - + android:visibility="gone" + tools:visibility="visible"> - - +