diff --git a/app/src/main/kotlin/org/akanework/gramophone/logic/GramophoneExtensions.kt b/app/src/main/kotlin/org/akanework/gramophone/logic/GramophoneExtensions.kt index 0781ab054..0690cdd94 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/logic/GramophoneExtensions.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/logic/GramophoneExtensions.kt @@ -67,10 +67,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import org.akanework.gramophone.BuildConfig import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_GET_LYRICS +import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_GET_LYRICS_LEGACY import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_GET_SESSION import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QUERY_TIMER import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_SET_TIMER import org.akanework.gramophone.logic.utils.MediaStoreUtils +import org.akanework.gramophone.logic.utils.SemanticLyrics +import org.akanework.gramophone.logic.utils.SemanticLyrics.LyricLineHolder import org.jetbrains.annotations.Contract import java.io.File import java.util.Locale @@ -213,15 +216,24 @@ inline fun MutableList.replaceAllSupport(operator: (T) -> T) { } @Suppress("UNCHECKED_CAST") -fun MediaController.getLyrics(): MutableList? = +fun MediaController.getLyricsLegacy(): MutableList? = sendCustomCommand( - SessionCommand(SERVICE_GET_LYRICS, Bundle.EMPTY), + SessionCommand(SERVICE_GET_LYRICS_LEGACY, Bundle.EMPTY), Bundle.EMPTY ).get().extras.let { (BundleCompat.getParcelableArray(it, "lyrics", MediaStoreUtils.Lyric::class.java) as Array?)?.toMutableList() } +@Suppress("UNCHECKED_CAST") +fun MediaController.getLyrics(): SemanticLyrics? = + sendCustomCommand( + SessionCommand(SERVICE_GET_LYRICS, Bundle.EMPTY), + Bundle.EMPTY + ).get().extras.let { + BundleCompat.getParcelable(it, "lyrics", SemanticLyrics::class.java) + } + @OptIn(UnstableApi::class) @Suppress("UNCHECKED_CAST") fun MediaController.getSessionId(): Int? = diff --git a/app/src/main/kotlin/org/akanework/gramophone/logic/GramophonePlaybackService.kt b/app/src/main/kotlin/org/akanework/gramophone/logic/GramophonePlaybackService.kt index b0a2a2fa7..8284eb7d9 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/logic/GramophonePlaybackService.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/logic/GramophonePlaybackService.kt @@ -82,8 +82,11 @@ import org.akanework.gramophone.logic.utils.CircularShuffleOrder import org.akanework.gramophone.logic.utils.LastPlayedManager import org.akanework.gramophone.logic.utils.LrcUtils.LrcParserOptions import org.akanework.gramophone.logic.utils.LrcUtils.extractAndParseLyrics +import org.akanework.gramophone.logic.utils.LrcUtils.extractAndParseLyricsLegacy import org.akanework.gramophone.logic.utils.LrcUtils.loadAndParseLyricsFile +import org.akanework.gramophone.logic.utils.LrcUtils.loadAndParseLyricsFileLegacy import org.akanework.gramophone.logic.utils.MediaStoreUtils +import org.akanework.gramophone.logic.utils.SemanticLyrics import org.akanework.gramophone.logic.utils.exoplayer.EndedWorkaroundPlayer import org.akanework.gramophone.logic.utils.exoplayer.GramophoneMediaSourceFactory import org.akanework.gramophone.logic.utils.exoplayer.GramophoneRenderFactory @@ -113,6 +116,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis const val SERVICE_SET_TIMER = "set_timer" const val SERVICE_QUERY_TIMER = "query_timer" const val SERVICE_GET_LYRICS = "get_lyrics" + const val SERVICE_GET_LYRICS_LEGACY = "get_lyrics_legacy" const val SERVICE_GET_SESSION = "get_session" const val SERVICE_TIMER_CHANGED = "changed_timer" } @@ -120,7 +124,8 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis private var lastSessionId = 0 private var mediaSession: MediaLibrarySession? = null private var controller: MediaController? = null - private var lyrics: MutableList? = null + private var lyrics: SemanticLyrics? = null + private var lyricsLegacy: MutableList? = null private var shuffleFactory: ((Int) -> ((CircularShuffleOrder) -> Unit) -> CircularShuffleOrder)? = null private lateinit var customCommands: List @@ -411,6 +416,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis availableSessionCommands.add(SessionCommand(SERVICE_GET_SESSION, Bundle.EMPTY)) availableSessionCommands.add(SessionCommand(SERVICE_QUERY_TIMER, Bundle.EMPTY)) availableSessionCommands.add(SessionCommand(SERVICE_GET_LYRICS, Bundle.EMPTY)) + availableSessionCommands.add(SessionCommand(SERVICE_GET_LYRICS_LEGACY, Bundle.EMPTY)) handler.post { session.sendCustomCommand( controller, @@ -486,7 +492,13 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis SERVICE_GET_LYRICS -> { SessionResult(SessionResult.RESULT_SUCCESS).also { - it.extras.putParcelableArray("lyrics", lyrics?.toTypedArray()) + it.extras.putParcelable("lyrics", lyrics) + } + } + + SERVICE_GET_LYRICS_LEGACY -> { + SessionResult(SessionResult.RESULT_SUCCESS).also { + it.extras.putParcelableArray("lyrics", lyricsLegacy?.toTypedArray()) } } @@ -544,31 +556,56 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis val multiLine = prefs.getBoolean("lyric_multiline", false) val newParser = prefs.getBoolean("lyric_parser", false) val options = LrcParserOptions(trim = trim, multiLine = multiLine, - legacyParser = !newParser, errorText = getString( - androidx.media3.session.R.string.error_message_io)) - var lrc = loadAndParseLyricsFile(mediaItem?.getFile(), options) - if (lrc == null) { - loop@ for (i in tracks.groups) { - for (j in 0 until i.length) { - if (!i.isTrackSelected(j)) continue - // note: wav files can have null metadata - val trackMetadata = i.getTrackFormat(j).metadata ?: continue - lrc = extractAndParseLyrics(trackMetadata, options) ?: continue - // add empty element at the beginning - lrc.add(0, MediaStoreUtils.Lyric()) - break@loop + errorText = getString(androidx.media3.session.R.string.error_message_io)) + if (newParser) { + var lrc = loadAndParseLyricsFile(mediaItem?.getFile(), options) + if (lrc == null) { + loop@ for (i in tracks.groups) { + for (j in 0 until i.length) { + if (!i.isTrackSelected(j)) continue + // note: wav files can have null metadata + val trackMetadata = i.getTrackFormat(j).metadata ?: continue + lrc = extractAndParseLyrics(trackMetadata, options) ?: continue + break@loop + } } } - } - CoroutineScope(Dispatchers.Main).launch { - mediaSession?.let { - lyrics = lrc - it.broadcastCustomCommand( - SessionCommand(SERVICE_GET_LYRICS, Bundle.EMPTY), - Bundle.EMPTY - ) + CoroutineScope(Dispatchers.Main).launch { + mediaSession?.let { + lyrics = lrc + lyricsLegacy = null + it.broadcastCustomCommand( + SessionCommand(SERVICE_GET_LYRICS, Bundle.EMPTY), + Bundle.EMPTY + ) + } + }.join() + } else { + var lrc = loadAndParseLyricsFileLegacy(mediaItem?.getFile(), options) + if (lrc == null) { + loop@ for (i in tracks.groups) { + for (j in 0 until i.length) { + if (!i.isTrackSelected(j)) continue + // note: wav files can have null metadata + val trackMetadata = i.getTrackFormat(j).metadata ?: continue + lrc = extractAndParseLyricsLegacy(trackMetadata, options) ?: continue + // add empty element at the beginning + lrc.add(0, MediaStoreUtils.Lyric()) + break@loop + } + } } - }.join() + CoroutineScope(Dispatchers.Main).launch { + mediaSession?.let { + lyrics = null + lyricsLegacy = lrc + it.broadcastCustomCommand( + SessionCommand(SERVICE_GET_LYRICS, Bundle.EMPTY), + Bundle.EMPTY + ) + } + }.join() + } } } diff --git a/app/src/main/kotlin/org/akanework/gramophone/logic/utils/LrcUtils.kt b/app/src/main/kotlin/org/akanework/gramophone/logic/utils/LrcUtils.kt index ac0b92e64..688e84bd2 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/logic/utils/LrcUtils.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/logic/utils/LrcUtils.kt @@ -16,10 +16,10 @@ object LrcUtils { private const val TAG = "LrcUtils" - data class LrcParserOptions(val trim: Boolean, val multiLine: Boolean, val legacyParser: Boolean, val errorText: String) + data class LrcParserOptions(val trim: Boolean, val multiLine: Boolean, val errorText: String) @OptIn(UnstableApi::class) - fun extractAndParseLyrics(metadata: Metadata, parserOptions: LrcParserOptions): MutableList? { + fun extractAndParseLyrics(metadata: Metadata, parserOptions: LrcParserOptions): SemanticLyrics? { for (i in 0..? { + for (i in 0..? { + fun loadAndParseLyricsFile(musicFile: File?, parserOptions: LrcParserOptions): SemanticLyrics? { + val lrcFile = musicFile?.let { File(it.parentFile, it.nameWithoutExtension + ".lrc") } + return loadLrcFile(lrcFile)?.let { + try { + SemanticLyrics.parse(it, parserOptions.trim, parserOptions.multiLine) + } catch (e: Exception) { + Log.e(TAG, Log.getStackTraceString(e)) + SemanticLyrics.UnsyncedLyrics(listOf(parserOptions.errorText)) + } + } + } + + @OptIn(UnstableApi::class) + fun loadAndParseLyricsFileLegacy(musicFile: File?, parserOptions: LrcParserOptions): MutableList? { val lrcFile = musicFile?.let { File(it.parentFile, it.nameWithoutExtension + ".lrc") } return loadLrcFile(lrcFile)?.let { try { - parseLrcString(it, parserOptions) + parseLrcStringLegacy(it, parserOptions) } catch (e: Exception) { Log.e(TAG, Log.getStackTraceString(e)) null @@ -67,12 +105,10 @@ object LrcUtils { } @VisibleForTesting - fun parseLrcString( + fun parseLrcStringLegacy( lrcContent: String, parserOptions: LrcParserOptions ): MutableList? { - if (!parserOptions.legacyParser) - return SemanticLyrics.parseForLegacy(lrcContent, parserOptions.trim, parserOptions.multiLine) if (lrcContent.isBlank()) return null val timeMarksRegex = "\\[(\\d{2}:\\d{2})([.:]\\d+)?]".toRegex() diff --git a/app/src/main/kotlin/org/akanework/gramophone/logic/utils/SemanticLyrics.kt b/app/src/main/kotlin/org/akanework/gramophone/logic/utils/SemanticLyrics.kt index 5e0d6cb8d..34c611876 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/logic/utils/SemanticLyrics.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/logic/utils/SemanticLyrics.kt @@ -1,5 +1,10 @@ package org.akanework.gramophone.logic.utils +import android.os.Parcel +import android.os.Parcelable +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith import java.util.concurrent.atomic.AtomicReference import kotlin.collections.map @@ -27,7 +32,8 @@ import kotlin.collections.map * We completely ignore all ID3 tags from the header as MediaStore is our source of truth. */ -enum class SpeakerEntity(val isWakaloke: Boolean) { +@Parcelize +enum class SpeakerEntity(val isWakaloke: Boolean) : Parcelable { Male(true), // Wakaloke Female(true), // Wakaloke Duet(true), // Wakaloke @@ -314,18 +320,33 @@ private sealed class SyntacticLyrics { * - [offset:] tag in header (ref Wikipedia) * We completely ignore all ID3 tags from the header as MediaStore is our source of truth. */ -sealed class SemanticLyrics { +sealed class SemanticLyrics : Parcelable { abstract val unsyncedText: List + @Parcelize class UnsyncedLyrics(override val unsyncedText: List) : SemanticLyrics() - class SyncedLyrics(val text: List>) : SemanticLyrics() { + @Parcelize + class SyncedLyrics(val text: List) : SemanticLyrics() { override val unsyncedText: List - get() = text.map { it.first.text } + get() = text.map { it.lyric.text } } + @Parcelize data class LyricLine(val text: String, val start: ULong, val words: List?, - val speaker: SpeakerEntity?) - data class Word(val timeRange: ULongRange, val charRange: ULongRange) + val speaker: SpeakerEntity?) : Parcelable + @Parcelize + data class LyricLineHolder(val lyric: LyricLine, + val isTranslated: Boolean) : Parcelable + @Parcelize + data class Word(val timeRange: @WriteWith() ULongRange, val charRange: @WriteWith() ULongRange) : Parcelable + object ULongRangeParceler : Parceler { + override fun create(parcel: Parcel) = parcel.readLong().toULong()..parcel.readLong().toULong() + + override fun ULongRange.write(parcel: Parcel, flags: Int) { + parcel.writeLong(first.toLong()) + parcel.writeLong(last.toLong()) + } + } companion object { fun parse(lyricText: String, trimEnabled: Boolean, multiLineEnabled: Boolean): SemanticLyrics? { val lyricSyntax = SyntacticLyrics.parse(lyricText, multiLineEnabled) @@ -463,18 +484,17 @@ sealed class SemanticLyrics { it.copy(speaker = SpeakerEntity.Male) else it }.map { - Pair(it, it.start == previousTimestamp).also { - previousTimestamp = it.first.start + LyricLineHolder(it, it.start == previousTimestamp).also { + previousTimestamp = it.lyric.start } }) } - fun parseForLegacy(lyricText: String, trimEnabled: Boolean, multiLineEnabled: Boolean): - MutableList? { - val lyric = parse(lyricText, trimEnabled, multiLineEnabled) ?: return null + fun convertForLegacy(lyric: SemanticLyrics?): MutableList? { + if (lyric == null) return null if (lyric is SyncedLyrics) { return lyric.text.map { - MediaStoreUtils.Lyric(it.first.start.toLong(), it.first.text, it.second) + MediaStoreUtils.Lyric(it.lyric.start.toLong(), it.lyric.text, it.isTranslated) }.toMutableList() } return mutableListOf(MediaStoreUtils.Lyric(null, lyric.unsyncedText.joinToString("\n"), false)) diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/components/FullBottomSheet.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/components/FullBottomSheet.kt index b4aa495b6..a23e997c3 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/ui/components/FullBottomSheet.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/components/FullBottomSheet.kt @@ -74,6 +74,7 @@ import org.akanework.gramophone.logic.getBooleanStrict import org.akanework.gramophone.logic.getFile import org.akanework.gramophone.logic.getIntStrict import org.akanework.gramophone.logic.getLyrics +import org.akanework.gramophone.logic.getLyricsLegacy import org.akanework.gramophone.logic.getTimer import org.akanework.gramophone.logic.hasImagePermission import org.akanework.gramophone.logic.hasScopedStorageV1 @@ -88,7 +89,6 @@ import org.akanework.gramophone.logic.ui.placeholderScaleToFit import org.akanework.gramophone.logic.updateMargin import org.akanework.gramophone.logic.utils.CalculationUtils import org.akanework.gramophone.logic.utils.ColorUtils -import org.akanework.gramophone.logic.utils.MediaStoreUtils import org.akanework.gramophone.logic.utils.convertDurationToTimeStamp import org.akanework.gramophone.ui.MainActivity import org.akanework.gramophone.ui.fragments.ArtistSubFragment @@ -149,7 +149,7 @@ class FullBottomSheet if (mediaId != null) { if (seekBar != null) { instance?.seekTo((seekBar.progress.toLong())) - bottomSheetFullLyricView.updateLyric(seekBar.progress.toLong()) + bottomSheetFullLyricView.updateLyricPositionFromPlaybackPos() } } isUserTracking = false @@ -164,7 +164,7 @@ class FullBottomSheet val mediaId = instance?.currentMediaItem?.mediaId if (mediaId != null) { instance?.seekTo((slider.value.toLong())) - bottomSheetFullLyricView.updateLyric(slider.value.toLong()) + bottomSheetFullLyricView.updateLyricPositionFromPlaybackPos() } isUserTracking = false } @@ -259,8 +259,13 @@ class FullBottomSheet GramophonePlaybackService.SERVICE_TIMER_CHANGED -> updateTimer() GramophonePlaybackService.SERVICE_GET_LYRICS -> { - val parsedLyrics = instance?.getLyrics() - bottomSheetFullLyricView.updateLyrics(parsedLyrics) + if (prefs.getBoolean("lyric_parser", false)) { + val parsedLyrics = instance?.getLyrics() + bottomSheetFullLyricView.updateLyrics(parsedLyrics) + } else { + val parsedLyrics = instance?.getLyricsLegacy() + bottomSheetFullLyricView.updateLyricsLegacy(parsedLyrics) + } } else -> { @@ -917,7 +922,7 @@ class FullBottomSheet min(instance?.currentPosition?.toFloat() ?: 0f, bottomSheetFullSlider.valueTo) bottomSheetFullPosition.text = position } - bottomSheetFullLyricView.updateLyric(duration) + bottomSheetFullLyricView.updateLyricPositionFromPlaybackPos() } override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { @@ -1171,7 +1176,7 @@ class FullBottomSheet min(instance?.currentPosition?.toFloat() ?: 0f, bottomSheetFullSlider.valueTo) bottomSheetFullPosition.text = position } - bottomSheetFullLyricView.updateLyric(duration) + bottomSheetFullLyricView.updateLyricPositionFromPlaybackPos() if (instance?.isPlaying == true) { handler.postDelayed(this, SLIDER_UPDATE_INTERVAL) } else { diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/components/LegacyLyricsAdapter.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/components/LegacyLyricsAdapter.kt new file mode 100644 index 000000000..48547be1e --- /dev/null +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/components/LegacyLyricsAdapter.kt @@ -0,0 +1,385 @@ +package org.akanework.gramophone.ui.components + +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.util.TypedValue +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import android.view.animation.PathInterpolator +import android.widget.TextView +import androidx.core.animation.doOnEnd +import androidx.core.graphics.TypefaceCompat +import androidx.core.view.HapticFeedbackConstantsCompat +import androidx.core.view.ViewCompat +import androidx.core.view.doOnLayout +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.card.MaterialCardView +import org.akanework.gramophone.R +import org.akanework.gramophone.logic.dpToPx +import org.akanework.gramophone.logic.getBooleanStrict +import org.akanework.gramophone.logic.ui.CustomSmoothScroller +import org.akanework.gramophone.logic.ui.CustomSmoothScroller.SNAP_TO_START +import org.akanework.gramophone.logic.ui.MyRecyclerView +import org.akanework.gramophone.logic.utils.MediaStoreUtils +import org.akanework.gramophone.ui.MainActivity + +class LegacyLyricsAdapter( + private val context: Context, +): MyRecyclerView.Adapter() { + companion object { + const val LYRIC_REMOVE_HIGHLIGHT = 0 + const val LYRIC_SET_HIGHLIGHT = 1 + const val LYRIC_SCROLL_DURATION: Long = 650 + } + private val activity + get() = context as MainActivity + private val instance + get() = activity.getPlayer() + private var recyclerView: MyRecyclerView? = null + private val prefs = PreferenceManager.getDefaultSharedPreferences(context) + private var defaultTextColor = 0 + private var contrastTextColor = 0 + private var highlightTextColor = 0 + private val interpolator = PathInterpolator(0.4f, 0.2f, 0f, 1f) + private val lyricList = mutableListOf() + var currentFocusPos = -1 + private set + private var currentTranslationPos = -1 + private var isBoldEnabled = false + private var isLyricCentered = false + private var isLyricContrastEnhanced = false + private val sizeFactor = 1f + private val defaultSizeFactor = .97f + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): ViewHolder = + ViewHolder( + LayoutInflater + .from(parent.context) + .inflate(R.layout.lyrics, parent, false), + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + throw IllegalStateException() + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + payloads: MutableList + ) { + val lyric = lyricList[position] + val isHighlightPayload = + payloads.isNotEmpty() && (payloads[0] == LYRIC_SET_HIGHLIGHT || payloads[0] == LYRIC_REMOVE_HIGHLIGHT) + + with(holder.lyricCard) { + setOnClickListener { + lyric.timeStamp?.let { it1 -> + ViewCompat.performHapticFeedback( + it, + HapticFeedbackConstantsCompat.CONTEXT_CLICK + ) + val instance = activity.getPlayer() + if (instance?.isPlaying == false) { + instance.play() + } + instance?.seekTo(it1) + } + } + } + + with(holder.lyricTextView) { + visibility = if (lyric.content.isNotEmpty()) VISIBLE else GONE + text = lyric.content + gravity = if (isLyricCentered) Gravity.CENTER else Gravity.START + translationY = 0f + + val textSize = if (lyric.isTranslation) 20f else 34.25f + val paddingTop = if (lyric.isTranslation) 2 else 18 + val paddingBottom = + if (position + 1 < lyricList.size && lyricList[position + 1].isTranslation) 2 else 18 + + if (isBoldEnabled) { + this.typeface = TypefaceCompat.create(context, null, 700, false) + } else { + this.typeface = TypefaceCompat.create(context, null, 500, false) + } + + if (isLyricCentered) { + this.gravity = Gravity.CENTER + } else { + this.gravity = Gravity.START + } + + setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize) + setPadding( + (12.5f).dpToPx(context).toInt(), + paddingTop.dpToPx(context), + (12.5f).dpToPx(context).toInt(), + paddingBottom.dpToPx(context) + ) + + doOnLayout { + pivotX = 0f + pivotY = height / 2f + } + + when { + isHighlightPayload -> { + val targetScale = + if (payloads[0] == LYRIC_SET_HIGHLIGHT) sizeFactor else defaultSizeFactor + val targetColor = + if (payloads[0] == LYRIC_SET_HIGHLIGHT) + highlightTextColor + else + defaultTextColor + animateText(targetScale, targetColor) + } + + position == currentFocusPos || position == currentTranslationPos -> { + scaleText(sizeFactor) + setTextColor(highlightTextColor) + } + + else -> { + scaleText(defaultSizeFactor) + setTextColor(defaultTextColor) + } + } + } + } + + private fun TextView.animateText(targetScale: Float, targetColor: Int) { + val animator = ValueAnimator.ofFloat(scaleX, targetScale) + animator.addUpdateListener { animation -> + val animatedValue = animation.animatedValue as Float + scaleX = animatedValue + scaleY = animatedValue + } + animator.duration = LYRIC_SCROLL_DURATION + animator.interpolator = interpolator + animator.start() + + val colorAnimator = ValueAnimator.ofArgb(textColors.defaultColor, targetColor) + colorAnimator.addUpdateListener { animation -> + val animatedValue = animation.animatedValue as Int + setTextColor(animatedValue) + } + colorAnimator.duration = LYRIC_SCROLL_DURATION + colorAnimator.interpolator = interpolator + colorAnimator.start() + } + + private fun TextView.scaleText(scale: Float) { + scaleX = scale + scaleY = scale + } + + override fun onAttachedToRecyclerView(recyclerView: MyRecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + updateLyricStatus() + this.recyclerView = recyclerView + } + + override fun onDetachedFromRecyclerView(recyclerView: MyRecyclerView) { + super.onDetachedFromRecyclerView(recyclerView) + this.recyclerView = null + } + + fun updateLyricStatus() { + isBoldEnabled = prefs.getBooleanStrict("lyric_bold", false) + isLyricCentered = prefs.getBooleanStrict("lyric_center", false) + isLyricContrastEnhanced = prefs.getBooleanStrict("lyric_contrast", false) + } + + override fun getItemCount(): Int = lyricList.size + + inner class ViewHolder( + view: View + ) : RecyclerView.ViewHolder(view) { + val lyricTextView: TextView = view.findViewById(R.id.lyric) + val lyricCard: MaterialCardView = view.findViewById(R.id.cardview) + } + + fun updateHighlight(position: Int) { + if (currentFocusPos == position) return + if (position >= 0) { + currentFocusPos.let { + notifyItemChanged(it, LYRIC_REMOVE_HIGHLIGHT) + currentFocusPos = position + notifyItemChanged(currentFocusPos, LYRIC_SET_HIGHLIGHT) + } + + if (position + 1 < lyricList.size && + lyricList[position + 1].isTranslation + ) { + currentTranslationPos.let { + notifyItemChanged(it, LYRIC_REMOVE_HIGHLIGHT) + currentTranslationPos = position + 1 + notifyItemChanged(currentTranslationPos, LYRIC_SET_HIGHLIGHT) + } + } else if (currentTranslationPos != -1) { + notifyItemChanged(currentTranslationPos, LYRIC_REMOVE_HIGHLIGHT) + currentTranslationPos = -1 + } + } else { + currentFocusPos = -1 + currentTranslationPos = -1 + } + } + + private fun createSmoothScroller(noAnimation: Boolean): RecyclerView.SmoothScroller { + return object : CustomSmoothScroller(context) { + + override fun calculateDtToFit( + viewStart: Int, + viewEnd: Int, + boxStart: Int, + boxEnd: Int, + snapPreference: Int + ): Int { + return super.calculateDtToFit( + viewStart, + viewEnd, + boxStart, + boxEnd, + snapPreference + ) + 72.dpToPx(context) + } + + override fun getVerticalSnapPreference(): Int { + return SNAP_TO_START + } + + override fun calculateTimeForDeceleration(dx: Int): Int { + return LYRIC_SCROLL_DURATION.toInt() + } + + override fun afterTargetFound() { + if (targetPosition > 1) { + val firstVisibleItemPosition = targetPosition + 1 + val lastVisibleItemPosition = + (layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + 2 + for (i in firstVisibleItemPosition..lastVisibleItemPosition) { + val view: View? = layoutManager!!.findViewByPosition(i) + if (view != null) { + if (i == targetPosition + 1 && lyricList[i].isTranslation) { + continue + } + if (!noAnimation) { + val ii = i - firstVisibleItemPosition - + if (lyricList[i].isTranslation) 1 else 0 + val depth = 15.dpToPx(context).toFloat() + val duration = (LYRIC_SCROLL_DURATION * 0.278).toLong() + val durationReturn = (LYRIC_SCROLL_DURATION * 0.722).toLong() + val durationStep = (LYRIC_SCROLL_DURATION * 0.1).toLong() + val animator = ObjectAnimator.ofFloat( + view, + "translationY", + 0f, + depth, + ) + animator.setDuration(duration) + animator.interpolator = PathInterpolator(0.96f, 0.43f, 0.72f, 1f) + animator.doOnEnd { + val animator1 = ObjectAnimator.ofFloat( + view, + "translationY", + depth, + 0f + ) + animator1.setDuration(durationReturn + ii * durationStep) + animator1.interpolator = PathInterpolator(0.17f, 0f, -0.15f, 1f) + animator1.start() + } + animator.start() + } + } + } + } + } + } + } + + fun smoothScrollTo(position: Int, noAnimation: Boolean = false) { + val smoothScroller = createSmoothScroller(noAnimation) + smoothScroller.targetPosition = position + recyclerView!!.layoutManager!!.startSmoothScroll( + smoothScroller + ) + } + + fun onPrefsChanged() { + updateLyricStatus() + @Suppress("NotifyDataSetChanged") + notifyDataSetChanged() + } + + fun updateLyricPositionFromPlaybackPos() { + if (lyricList.isNotEmpty()) { + val newIndex = updateNewIndex() + + if (newIndex != -1 && + newIndex != currentFocusPos + ) { + if (lyricList[newIndex].content.isNotEmpty() || newIndex == 0) { + smoothScrollTo(newIndex, newIndex == 0) + } + + updateHighlight(newIndex) + } + } + } + + private fun updateNewIndex(): Int { + val filteredList = lyricList.filterIndexed { _, lyric -> + (lyric.timeStamp ?: 0) <= (instance?.currentPosition ?: 0) + } + + return if (filteredList.isNotEmpty()) { + filteredList.indices.maxBy { + filteredList[it].timeStamp ?: 0 + } + } else { + -1 + } + } + + fun updateLyrics(parsedLyrics: MutableList?) { + if (lyricList != parsedLyrics) { + lyricList.clear() + if (parsedLyrics?.isEmpty() != false) { + lyricList.add( + MediaStoreUtils.Lyric( + null, + context.getString(R.string.no_lyric_found) + ) + ) + } else { + lyricList.addAll(parsedLyrics) + } + @SuppressLint("NotifyDataSetChanged") + notifyDataSetChanged() + smoothScrollTo(0) + updateHighlight(0) + } + } + + fun updateTextColor(newColorContrast: Int, newColor: Int, newHighlightColor: Int) { + defaultTextColor = newColor + contrastTextColor = newColorContrast + highlightTextColor = newHighlightColor + @SuppressLint("NotifyDataSetChanged") + notifyDataSetChanged() + } +} diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/components/LyricsView.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/components/LyricsView.kt index e8caa5877..5dc3e412c 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/ui/components/LyricsView.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/components/LyricsView.kt @@ -1,69 +1,62 @@ package org.akanework.gramophone.ui.components -import android.animation.ObjectAnimator -import android.animation.ValueAnimator -import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import android.util.AttributeSet -import android.util.TypedValue -import android.view.Gravity -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.animation.PathInterpolator import android.widget.FrameLayout -import android.widget.TextView -import androidx.core.animation.doOnEnd -import androidx.core.graphics.TypefaceCompat -import androidx.core.view.HapticFeedbackConstantsCompat -import androidx.core.view.ViewCompat -import androidx.core.view.doOnLayout -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.card.MaterialCardView import org.akanework.gramophone.R -import org.akanework.gramophone.logic.dpToPx import org.akanework.gramophone.logic.getBooleanStrict -import org.akanework.gramophone.logic.ui.CustomSmoothScroller -import org.akanework.gramophone.logic.ui.CustomSmoothScroller.SNAP_TO_START import org.akanework.gramophone.logic.ui.MyRecyclerView import org.akanework.gramophone.logic.utils.MediaStoreUtils -import org.akanework.gramophone.ui.MainActivity +import org.akanework.gramophone.logic.utils.SemanticLyrics class LyricsView(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs), SharedPreferences.OnSharedPreferenceChangeListener { - companion object { - const val LYRIC_REMOVE_HIGHLIGHT = 0 - const val LYRIC_SET_HIGHLIGHT = 1 - const val LYRIC_SCROLL_DURATION: Long = 650 - } + private val prefs = PreferenceManager.getDefaultSharedPreferences(context) - private val activity - get() = context as MainActivity - private val instance - get() = activity.getPlayer() - private val recyclerView: MyRecyclerView - private val adapter = LyricAdapter() - private var animationLock = false + private var recyclerView: MyRecyclerView? = null + private var newView: NewLyricsView? = null + private val adapter + get() = recyclerView?.adapter as LegacyLyricsAdapter? + private var defaultTextColor = 0 + private var contrastTextColor = 0 + private var highlightTextColor = 0 + private var lyrics: SemanticLyrics? = null + private var lyricsLegacy: MutableList? = null init { - adapter.updateLyricStatus() - inflate(context, R.layout.lyric_view, this) - recyclerView = findViewById(R.id.recycler_view) - recyclerView.adapter = adapter - recyclerView.addItemDecoration(LyricPaddingDecoration(context)) + createView() + } + + private fun createView() { + removeAllViews() + recyclerView = null + newView = null + if (prefs.getBooleanStrict("lyric_ui", false)) { + inflate(context, R.layout.lyric_view_v2, this) + newView = findViewById(R.id.lyric_view) + newView?.updateTextColor(contrastTextColor, defaultTextColor, highlightTextColor) + newView?.updateLyrics(lyrics) + } else { + inflate(context, R.layout.lyric_view, this) + recyclerView = findViewById(R.id.recycler_view) + recyclerView!!.adapter = LegacyLyricsAdapter(context).also { + it.updateTextColor(contrastTextColor, defaultTextColor, highlightTextColor) + } + recyclerView!!.addItemDecoration(LyricPaddingDecoration(context)) + if (lyrics != null) + adapter?.updateLyrics(SemanticLyrics.convertForLegacy(lyrics)) + else + adapter?.updateLyrics(lyricsLegacy) + } } override fun onAttachedToWindow() { super.onAttachedToWindow() prefs.registerOnSharedPreferenceChangeListener(this) - adapter.updateLyricStatus() + adapter?.updateLyricStatus() } override fun onDetachedFromWindow() { @@ -73,348 +66,37 @@ class LyricsView(context: Context, attrs: AttributeSet?) : FrameLayout(context, override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { if (key == "lyric_center" || key == "lyric_bold" || key == "lyric_contrast") { - adapter.updateLyricStatus() - @Suppress("NotifyDataSetChanged") - adapter.notifyDataSetChanged() + adapter?.onPrefsChanged() + newView?.onPrefsChanged() + } else if (key == "lyric_ui") { + createView() } } - inner class LyricAdapter() : MyRecyclerView.Adapter() { - - private val interpolator = PathInterpolator(0.4f, 0.2f, 0f, 1f) - val lyricList = mutableListOf() - internal var defaultTextColor = 0 - internal var contrastTextColor = 0 - internal var highlightTextColor = 0 - internal var currentFocusPos = -1 - private set - private var currentTranslationPos = -1 - private var isBoldEnabled = false - private var isLyricCentered = false - private var isLyricContrastEnhanced = false - private val sizeFactor = 1f - private val defaultSizeFactor = .97f - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): ViewHolder = - ViewHolder( - LayoutInflater - .from(parent.context) - .inflate(R.layout.lyrics, parent, false), - ) - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - throw IllegalStateException() - } - - override fun onBindViewHolder( - holder: ViewHolder, - position: Int, - payloads: MutableList - ) { - val lyric = lyricList[position] - val isHighlightPayload = - payloads.isNotEmpty() && (payloads[0] == LYRIC_SET_HIGHLIGHT || payloads[0] == LYRIC_REMOVE_HIGHLIGHT) - - with(holder.lyricCard) { - setOnClickListener { - lyric.timeStamp?.let { it1 -> - ViewCompat.performHapticFeedback( - it, - HapticFeedbackConstantsCompat.CONTEXT_CLICK - ) - val instance = activity.getPlayer() - if (instance?.isPlaying == false) { - instance.play() - } - instance?.seekTo(it1) - } - } - } - - with(holder.lyricTextView) { - visibility = if (lyric.content.isNotEmpty()) VISIBLE else GONE - text = lyric.content - gravity = if (isLyricCentered) Gravity.CENTER else Gravity.START - translationY = 0f - - val textSize = if (lyric.isTranslation) 20f else 34.25f - val paddingTop = if (lyric.isTranslation) 2 else 18 - val paddingBottom = - if (position + 1 < lyricList.size && lyricList[position + 1].isTranslation) 2 else 18 - - if (isBoldEnabled) { - this.typeface = TypefaceCompat.create(context,null, 700, false) - } else { - this.typeface = TypefaceCompat.create(context, null, 500, false) - } - - if (isLyricCentered) { - this.gravity = Gravity.CENTER - } else { - this.gravity = Gravity.START - } - - setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize) - setPadding( - (12.5f).dpToPx(context).toInt(), - paddingTop.dpToPx(context), - (12.5f).dpToPx(context).toInt(), - paddingBottom.dpToPx(context) - ) - - doOnLayout { - pivotX = 0f - pivotY = height / 2f - } - - when { - isHighlightPayload -> { - val targetScale = - if (payloads[0] == LYRIC_SET_HIGHLIGHT) sizeFactor else defaultSizeFactor - val targetColor = - if (payloads[0] == LYRIC_SET_HIGHLIGHT) - highlightTextColor - else - defaultTextColor - animateText(targetScale, targetColor) - } - - position == currentFocusPos || position == currentTranslationPos -> { - scaleText(sizeFactor) - setTextColor(highlightTextColor) - } - - else -> { - scaleText(defaultSizeFactor) - setTextColor(defaultTextColor) - } - } - } - } - - private fun TextView.animateText(targetScale: Float, targetColor: Int) { - val animator = ValueAnimator.ofFloat(scaleX, targetScale) - animator.addUpdateListener { animation -> - val animatedValue = animation.animatedValue as Float - scaleX = animatedValue - scaleY = animatedValue - } - animator.duration = LYRIC_SCROLL_DURATION - animator.interpolator = interpolator - animator.start() - - val colorAnimator = ValueAnimator.ofArgb(textColors.defaultColor, targetColor) - colorAnimator.addUpdateListener { animation -> - val animatedValue = animation.animatedValue as Int - setTextColor(animatedValue) - } - colorAnimator.duration = LYRIC_SCROLL_DURATION - colorAnimator.interpolator = interpolator - colorAnimator.start() - } - - private fun TextView.scaleText(scale: Float) { - scaleX = scale - scaleY = scale - } - - override fun onAttachedToRecyclerView(recyclerView: MyRecyclerView) { - super.onAttachedToRecyclerView(recyclerView) - updateLyricStatus() - } - - fun updateLyricStatus() { - isBoldEnabled = prefs.getBooleanStrict("lyric_bold", false) - isLyricCentered = prefs.getBooleanStrict("lyric_center", false) - isLyricContrastEnhanced = prefs.getBooleanStrict("lyric_contrast", false) - } - - override fun getItemCount(): Int = lyricList.size - - inner class ViewHolder( - view: View - ) : RecyclerView.ViewHolder(view) { - val lyricTextView: TextView = view.findViewById(R.id.lyric) - val lyricCard: MaterialCardView = view.findViewById(R.id.cardview) - } - - internal fun updateHighlight(position: Int) { - if (currentFocusPos == position) return - if (position >= 0) { - currentFocusPos.let { - notifyItemChanged(it, LYRIC_REMOVE_HIGHLIGHT) - currentFocusPos = position - notifyItemChanged(currentFocusPos, LYRIC_SET_HIGHLIGHT) - } - - if (position + 1 < lyricList.size && - lyricList[position + 1].isTranslation - ) { - currentTranslationPos.let { - notifyItemChanged(it, LYRIC_REMOVE_HIGHLIGHT) - currentTranslationPos = position + 1 - notifyItemChanged(currentTranslationPos, LYRIC_SET_HIGHLIGHT) - } - } else if (currentTranslationPos != -1) { - notifyItemChanged(currentTranslationPos, LYRIC_REMOVE_HIGHLIGHT) - currentTranslationPos = -1 - } - } else { - currentFocusPos = -1 - currentTranslationPos = -1 - } - } - } - - private fun createSmoothScroller(noAnimation: Boolean = false): RecyclerView.SmoothScroller { - return object : CustomSmoothScroller(context) { - - override fun calculateDtToFit( - viewStart: Int, - viewEnd: Int, - boxStart: Int, - boxEnd: Int, - snapPreference: Int - ): Int { - return super.calculateDtToFit( - viewStart, - viewEnd, - boxStart, - boxEnd, - snapPreference - ) + 72.dpToPx(context) - } - - override fun getVerticalSnapPreference(): Int { - return SNAP_TO_START - } - - override fun calculateTimeForDeceleration(dx: Int): Int { - return LYRIC_SCROLL_DURATION.toInt() - } - - override fun afterTargetFound() { - if (targetPosition > 1) { - val firstVisibleItemPosition = targetPosition + 1 - val lastVisibleItemPosition = (layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + 2 - for (i in firstVisibleItemPosition..lastVisibleItemPosition) { - val view: View? = layoutManager!!.findViewByPosition(i) - if (view != null) { - if (i == targetPosition + 1 && adapter.lyricList[i].isTranslation) { - continue - } - if (!noAnimation) { - val ii = i - firstVisibleItemPosition - - if (adapter.lyricList[i].isTranslation) 1 else 0 - applyAnimation(view, ii) - } - } - } - } - } - } - } - - private fun applyAnimation(view: View, ii: Int) { - val depth = 15.dpToPx(context).toFloat() - val duration = (LYRIC_SCROLL_DURATION * 0.278).toLong() - val durationReturn = (LYRIC_SCROLL_DURATION * 0.722).toLong() - val durationStep = (LYRIC_SCROLL_DURATION * 0.1).toLong() - val animator = ObjectAnimator.ofFloat( - view, - "translationY", - 0f, - depth, - ) - animator.setDuration(duration) - animator.interpolator = PathInterpolator(0.96f, 0.43f, 0.72f, 1f) - animator.doOnEnd { - val animator1 = ObjectAnimator.ofFloat( - view, - "translationY", - depth, - 0f - ) - animator1.setDuration(durationReturn + ii * durationStep) - animator1.interpolator = PathInterpolator(0.17f, 0f, -0.15f, 1f) - animator1.start() - } - animator.start() - } - - fun resetToDefaultLyricPosition() { - val smoothScroller = createSmoothScroller() - smoothScroller.targetPosition = 0 - recyclerView.layoutManager!!.startSmoothScroll( - smoothScroller - ) - adapter.updateHighlight(0) - } - - - fun updateLyric(duration: Long?) { - if (adapter.lyricList.isNotEmpty()) { - val newIndex = updateNewIndex() - - if (newIndex != -1 && - duration != null && - newIndex != adapter.currentFocusPos - ) { - if (adapter.lyricList[newIndex].content.isNotEmpty()) { - val smoothScroller = createSmoothScroller(animationLock || newIndex == 0) - smoothScroller.targetPosition = newIndex - recyclerView.layoutManager!!.startSmoothScroll( - smoothScroller - ) - if (animationLock) animationLock = false - } - - adapter.updateHighlight(newIndex) - } - } + fun updateLyricPositionFromPlaybackPos() { + adapter?.updateLyricPositionFromPlaybackPos() + newView?.updateLyricPositionFromPlaybackPos() } - private fun updateNewIndex(): Int { - val filteredList = adapter.lyricList.filterIndexed { _, lyric -> - (lyric.timeStamp ?: 0) <= (instance?.currentPosition ?: 0) - } - - return if (filteredList.isNotEmpty()) { - filteredList.indices.maxBy { - filteredList[it].timeStamp ?: 0 - } - } else { - -1 - } + fun updateLyrics(parsedLyrics: SemanticLyrics?) { + lyrics = parsedLyrics + lyricsLegacy = null + adapter?.updateLyrics(SemanticLyrics.convertForLegacy(lyrics)) + newView?.updateLyrics(lyrics) } - fun updateLyrics(parsedLyrics: MutableList?) { - if (adapter.lyricList != parsedLyrics) { - adapter.lyricList.clear() - if (parsedLyrics?.isEmpty() != false) { - adapter.lyricList.add( - MediaStoreUtils.Lyric( - null, - context.getString(R.string.no_lyric_found) - ) - ) - } else { - adapter.lyricList.addAll(parsedLyrics) - } - @SuppressLint("NotifyDataSetChanged") - adapter.notifyDataSetChanged() - resetToDefaultLyricPosition() - } + fun updateLyricsLegacy(parsedLyrics: MutableList?) { + lyrics = null + lyricsLegacy = parsedLyrics + adapter?.updateLyrics(lyricsLegacy) + newView?.updateLyrics(null) } - @SuppressLint("NotifyDataSetChanged") fun updateTextColor(newColorContrast: Int, newColor: Int, newHighlightColor: Int) { - adapter.defaultTextColor = newColor - adapter.contrastTextColor = newColorContrast - adapter.highlightTextColor = newHighlightColor - adapter.notifyDataSetChanged() + defaultTextColor = newColor + contrastTextColor = newColorContrast + highlightTextColor = newHighlightColor + adapter?.updateTextColor(contrastTextColor, defaultTextColor, highlightTextColor) + newView?.updateTextColor(contrastTextColor, defaultTextColor, highlightTextColor) } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/components/NewLyricsView.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/components/NewLyricsView.kt new file mode 100644 index 000000000..2c0aff5f4 --- /dev/null +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/components/NewLyricsView.kt @@ -0,0 +1,39 @@ +package org.akanework.gramophone.ui.components + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import android.view.View +import org.akanework.gramophone.logic.utils.SemanticLyrics + +class NewLyricsView(context: Context, attrs: AttributeSet) : View(context, attrs) { + + private var defaultTextColor = 0 + private var contrastTextColor = 0 + private var highlightTextColor = 0 + private var lyrics: SemanticLyrics? = null + + fun updateTextColor(newColorContrast: Int, newColor: Int, newHighlightColor: Int) { + defaultTextColor = newColor + contrastTextColor = newColorContrast + highlightTextColor = newHighlightColor + } + + fun updateLyrics(parsedLyrics: SemanticLyrics?) { + lyrics = parsedLyrics + } + + fun updateLyricPositionFromPlaybackPos() { + } + + fun onPrefsChanged() { + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/lyric_view_v2.xml b/app/src/main/res/layout/lyric_view_v2.xml new file mode 100644 index 000000000..f4e20a558 --- /dev/null +++ b/app/src/main/res/layout/lyric_view_v2.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file