diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6890f138e0..8f8817b4b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,6 @@ plugins { kotlin("plugin.compose") id("com.mikepenz.aboutlibraries.plugin") id("com.mikepenz.aboutlibraries.plugin.android") - id("pt.jcosta.resourceplaceholders") } android { @@ -257,10 +256,6 @@ android { testOptions.unitTests.isIncludeAndroidResources = true } -resourcePlaceholders { - files.set(listOf("xml/shortcuts.xml")) -} - kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_21 @@ -287,7 +282,7 @@ tasks.withType { } aboutLibraries { - offlineMode = true + offlineMode = false collect { configPath = file("config") filterVariants.add("release") diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt index 36a0f5284e..75ae87cd94 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt @@ -115,6 +115,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.guava.future import kotlinx.coroutines.guava.await import org.akanework.gramophone.R +import org.akanework.gramophone.logic.car.CarLyricsConstants +import org.akanework.gramophone.logic.car.CarLyricsManager import org.akanework.gramophone.logic.ui.MeiZuLyricsMediaNotificationProvider import org.akanework.gramophone.logic.ui.isManualNotificationUpdate import org.akanework.gramophone.logic.utils.AfFormatInfo @@ -218,6 +220,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis private lateinit var lastPlayedManager: LastPlayedManager private lateinit var prefs: SharedPreferences private var lastSentHighlightedLyric: String? = null + private var carLyricsManager: CarLyricsManager? = null private lateinit var afFormatTracker: AfFormatTracker private lateinit var rgAp: ReplayGainAudioProcessor private var rgMode = 0 // 0 = disabled, 1 = track, 2 = album, 3 = smart @@ -452,6 +455,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis .build(), { lyrics }, queueBoard = qb, + getCarLyricsExtras = { carLyricsManager?.buildMetadataExtras() ?: Bundle() }, ) player.exoPlayer.addAnalyticsListener(EventLogger()) player.exoPlayer.addAnalyticsListener(afFormatTracker) @@ -571,6 +575,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis .build() addSession(mediaSession!!) controller = MediaBrowser.Builder(this, mediaSession!!.token).buildAsync().get() + carLyricsManager = CarLyricsManager(mediaSession!!) controller!!.addListener(this) if (controller!!.audioSessionId != C.AUDIO_SESSION_ID_UNSET) { onAudioSessionIdChanged(controller!!.audioSessionId) @@ -922,6 +927,10 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis val boostGain = prefs.getIntStrict("rg_boost_gain", 0) restart = !rgAp.setBoostGain(boostGain) || restart } + if (key == null || key == CarLyricsConstants.PREF_CAR_LYRICS_ENABLED) { + // vivo smart car lyrics switch toggled: (re)push both channels immediately. + pushCarLyrics() + } if (restart) { controller?.stop() controller?.prepare() @@ -1417,6 +1426,11 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis } val mediaItem = controller?.currentMediaItem + // Push the "loading" state to the car display while the lyrics for the new song are being + // loaded, so the head unit shows a loading indicator instead of "-1". + if (carLyricsManager?.setLoading() == true) { + endedWorkaroundPlayer?.updateCarLyrics() + } lyricsFetcher.launch { val trim = prefs.getBoolean("trim_lyrics", true) val options = LrcParserOptions( @@ -1789,6 +1803,8 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis LyricWidgetProvider.update(this) else LyricWidgetProvider.adapterUpdate(this) + // vivo smart car projected lyrics: push both channels on every lyric update. + pushCarLyrics() val isStatusBarLyricsEnabled = prefs.getBooleanStrict("status_bar_lyrics", false) val highlightedLyric = if (isStatusBarLyricsEnabled && controller?.playWhenReady == true) getCurrentLyricIndex(false)?.let { @@ -1814,6 +1830,29 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis } } + /** + * Pushes the vivo smart car projected lyrics through both MediaSession channels. + * Reads the master switch each time so that toggling it in settings takes effect promptly. + */ + private fun pushCarLyrics() { + val manager = carLyricsManager ?: return + val enabled = prefs.getBooleanStrict(CarLyricsConstants.PREF_CAR_LYRICS_ENABLED, false) + manager.enabled = enabled + val changed = if (!enabled) { + manager.push() + } else { + val currentLine = getCurrentLyricIndex(false)?.let { + syncedLyrics?.text?.get(it)?.text + } + manager.updateLyric(currentLine, lyrics) + } + // Refresh the metadata channel injected in EndedWorkaroundPlayer.getState() only when the + // pushed values actually changed, to avoid needless state broadcasts. + if (changed) { + endedWorkaroundPlayer?.updateCarLyrics() + } + } + fun getCurrentLyricIndex(withTranslation: Boolean): Int? { val lines = syncedLyrics?.text?.mapIndexed { i, it -> i to it }?.filter { it.second.start <= (controller?.currentPosition ?: 0).toULong() diff --git a/app/src/main/java/org/akanework/gramophone/logic/car/CarLyricsConstants.kt b/app/src/main/java/org/akanework/gramophone/logic/car/CarLyricsConstants.kt new file mode 100644 index 0000000000..c3da397528 --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/car/CarLyricsConstants.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2024 Akane Foundation + * + * Gramophone is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Gramophone is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.akanework.gramophone.logic.car + +/** + * Constants for the vivo smart car (JoviInCar / uCar) lyrics projection protocol. + * + * Two independent channels must be implemented simultaneously: + * - Channel A (Metadata): the car launcher reads `ucar.media.metadata.*` keys directly from + * the MediaSession metadata. + * - Channel B (Extras): the phone-side "smart car" app reads `music.media.extras.*` keys from + * the MediaSession extras and forwards them to the car head unit. + * + * See the vivo "车载投屏歌词适配开发指南" for details. + */ +object CarLyricsConstants { + // ---- Channel A: Metadata (car launcher direct) ---- + const val METADATA_KEY_LYRICS_LINE = "ucar.media.metadata.LYRICS_LINE" + const val METADATA_KEY_LYRICS_WHOLE = "ucar.media.metadata.LYRICS_WHOLE" + const val METADATA_KEY_LYRICS_STATUS = "ucar.media.metadata.LYRICS_STATUS" + + // ---- Channel B: Extras (phone-side smart car app forwarding) ---- + const val EXTRAS_KEY_LYRIC = "music.media.extras.LYRIC" + const val EXTRAS_KEY_LYRIC_ALLOWED = "music.media.extras.LYRIC_IS_ALLOWED" + const val EXTRAS_KEY_NOTICE_CAR = "music.media.extras.NOTICE_CAR" + + // ---- Lyrics status enum (MediaConstants$LyricsState) ---- + const val LYRICS_STATUS_SUCCESS = 0L // has lyrics + const val LYRICS_STATUS_NO_LYRICS = 1L // no lyrics + const val LYRICS_STATUS_LOADING = 2L // loading + const val LYRICS_STATUS_FAIL = 3L // load failed + + /** Preference key for the car lyrics master switch. */ + const val PREF_CAR_LYRICS_ENABLED = "car_lyrics_enabled" +} diff --git a/app/src/main/java/org/akanework/gramophone/logic/car/CarLyricsManager.kt b/app/src/main/java/org/akanework/gramophone/logic/car/CarLyricsManager.kt new file mode 100644 index 0000000000..8cbcc49c6a --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/car/CarLyricsManager.kt @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2024 Akane Foundation + * + * Gramophone is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Gramophone is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.akanework.gramophone.logic.car + +import android.os.Bundle +import androidx.media3.session.MediaSession +import org.akanework.gramophone.logic.utils.SemanticLyrics + +/** + * Pushes the current lyric line and the whole LRC to the vivo smart car system through the two + * independent MediaSession channels: + * + * - Channel A (Metadata): `ucar.media.metadata.LYRICS_*` keys are injected on-the-fly into the + * current [androidx.media3.common.MediaMetadata.extras] by + * `EndedWorkaroundPlayer.getState()` (see [buildMetadataExtras]). Media3's + * `LegacyConversions.convertToMediaMetadataCompat` already forwards String/Long entries of + * `MediaMetadata.extras` to the top-level `MediaMetadataCompat` keys, so the car launcher can + * read them directly. + * - Channel B (Extras): `music.media.extras.*` keys are set through + * [MediaSession.setSessionExtras] and read by the phone-side "smart car" app + * (`com.vivo.smartcar`). + * + * Both channels must be updated together to cover all projection scenarios. + * + * IMPORTANT: The metadata channel must NEVER be pushed by mutating the player timeline (e.g. via + * `MediaController.replaceMediaItem`). Replacing the current media item fires + * `onPositionDiscontinuity`, which triggers another lyric update, which replaces the item again, + * producing an infinite feedback loop that freezes the main thread and crashes the app. Instead, + * the metadata is injected in `EndedWorkaroundPlayer.getState()` and refreshed with + * `invalidateState()` only when the pushed values actually change. + */ +class CarLyricsManager( + private val mediaSession: MediaSession, +) { + /** Whether the car lyrics master switch is currently enabled. */ + var enabled: Boolean = false + + private var currentLine: String? = null + private var wholeLrc: String? = null + private var status: Long = CarLyricsConstants.LYRICS_STATUS_NO_LYRICS + + // Last values actually pushed, used to detect whether the metadata channel changed so the + // caller knows when to invalidate the player state (and avoid doing it on every tick). + private var lastPushedLine: String? = null + private var lastPushedWhole: String? = null + private var lastPushedStatus: Long = CarLyricsConstants.LYRICS_STATUS_NO_LYRICS + + /** + * Sets the loading state. Call this when starting to load lyrics for a new song so the car + * display shows a loading indicator instead of "-1". + * + * @return true if the metadata channel changed and the player state should be invalidated. + */ + fun setLoading(): Boolean { + if (status == CarLyricsConstants.LYRICS_STATUS_LOADING && currentLine == null) return false + currentLine = null + wholeLrc = null + status = CarLyricsConstants.LYRICS_STATUS_LOADING + return push() + } + + /** + * Updates the current lyric line (called frequently while playing, e.g. on every + * position/lyric change). + * + * @param currentLine the current lyric line text, or null/blank when there is none. + * @param lyrics the parsed lyrics of the current song, or null when there are none. + * @return true if the metadata channel changed and the player state should be invalidated. + */ + fun updateLyric( + currentLine: String?, + lyrics: SemanticLyrics?, + ): Boolean { + val line = currentLine?.takeIf { it.isNotBlank() } + val whole = lyrics?.toLrcString() + + this.currentLine = line + wholeLrc = whole + status = if (whole != null) { + CarLyricsConstants.LYRICS_STATUS_SUCCESS + } else { + CarLyricsConstants.LYRICS_STATUS_NO_LYRICS + } + return push() + } + + /** + * Pushes the current state to both channels. Called whenever the state changes, including + * when the master switch is toggled. + * + * @return true if the metadata channel changed and the player state should be invalidated. + */ + fun push(): Boolean { + // The values below must mirror exactly what buildMetadataExtras() injects, so that the + // "changed" detection matches the actual metadata channel content. + val lineToPush = if (enabled) currentLine else null + val wholeToPush = if (enabled) { + when (status) { + CarLyricsConstants.LYRICS_STATUS_SUCCESS -> wholeLrc ?: "-1" + CarLyricsConstants.LYRICS_STATUS_LOADING -> "" + else -> "-1" + } + } else null + val statusToPush = if (enabled) status else CarLyricsConstants.LYRICS_STATUS_NO_LYRICS + val changed = lineToPush != lastPushedLine + || wholeToPush != lastPushedWhole + || statusToPush != lastPushedStatus + lastPushedLine = lineToPush + lastPushedWhole = wholeToPush + lastPushedStatus = statusToPush + + if (!enabled) { + // When disabled, clear the extras channel. The metadata channel keys are removed + // automatically because EndedWorkaroundPlayer.getState() starts from the underlying + // player state and only injects when enabled. + mediaSession.setSessionExtras(Bundle()) + return changed + } + + // ---- Channel B: Extras (phone-side smart car app) ---- + val extras = Bundle().apply { + putBoolean(CarLyricsConstants.EXTRAS_KEY_LYRIC_ALLOWED, true) + if (!lineToPush.isNullOrEmpty()) { + putString(CarLyricsConstants.EXTRAS_KEY_LYRIC, lineToPush) + } + putBoolean(CarLyricsConstants.EXTRAS_KEY_NOTICE_CAR, true) + } + mediaSession.setSessionExtras(extras) + return changed + } + + /** + * Builds the Channel A metadata extras. Called by `EndedWorkaroundPlayer.getState()` on every + * state invalidation, so it must be cheap and side-effect free. Returns an empty bundle when + * disabled so the previously injected keys disappear. + */ + fun buildMetadataExtras(): Bundle { + val extras = Bundle() + if (!enabled) return extras + + // Current line: only set when non-empty. + val lineToPush = currentLine + if (!lineToPush.isNullOrEmpty()) { + extras.putString(CarLyricsConstants.METADATA_KEY_LYRICS_LINE, lineToPush) + } + // Whole LRC + status. "-1" for whole LRC means "no lyrics" per the protocol. + when (status) { + CarLyricsConstants.LYRICS_STATUS_SUCCESS -> { + extras.putString(CarLyricsConstants.METADATA_KEY_LYRICS_WHOLE, wholeLrc ?: "-1") + extras.putLong(CarLyricsConstants.METADATA_KEY_LYRICS_STATUS, status) + } + CarLyricsConstants.LYRICS_STATUS_LOADING -> { + extras.putString(CarLyricsConstants.METADATA_KEY_LYRICS_WHOLE, "") + extras.putLong(CarLyricsConstants.METADATA_KEY_LYRICS_STATUS, status) + } + else -> { + extras.putString(CarLyricsConstants.METADATA_KEY_LYRICS_WHOLE, "-1") + extras.putLong(CarLyricsConstants.METADATA_KEY_LYRICS_STATUS, status) + } + } + // CRITICAL: MediaMetadata.equals() deliberately ignores every extras key except the + // special "lyricInfo" key. Without this key, the very first injected state (e.g. "-1") + // is broadcast once, but subsequent lyric changes are NOT detected, so the car head unit + // keeps showing "-1" forever even after real lyrics are loaded. Mirroring the current + // lyric state into "lyricInfo" makes MediaMetadata.equals() return false on every real + // change, which triggers onMediaMetadataChanged and pushes the new lyrics to the car. + extras.putString( + "lyricInfo", + "$status|${wholeLrc?.hashCode()}|${lineToPush?.hashCode()}" + ) + return extras + } + + /** + * Converts [SemanticLyrics] to a standard LRC string suitable for the car head unit. + * + * We intentionally keep both original and translated lines (instead of filtering translations + * out) because: + * 1. The car head unit typically only displays one line at a time (the current line), and we + * push the current line separately via LYRICS_LINE. The whole LRC is mainly for the car to + * know "there are lyrics" and to have the full text for scrolling/display. + * 2. Filtering translated lines risks dropping ALL lines for songs where every line happens + * to have the same timestamp as its translation — we've seen this cause the whole LRC to + * be empty, showing "-1" on the car display. + * 3. Even if the car displays both, it's harmless — the user gets more info, not less. + */ + private fun SemanticLyrics.toLrcString(): String? { + return when (this) { + is SemanticLyrics.SyncedLyrics -> buildString { + for (line in text) { + if (line.text.isBlank()) continue + append('[') + append(formatTimestamp(line.start)) + append(']') + append(line.text) + append('\n') + } + }.takeIf { it.isNotBlank() } + + is SemanticLyrics.UnsyncedLyrics -> buildString { + for ((text, _) in unsyncedText) { + if (text.isNotBlank()) { + append(text) + append('\n') + } + } + }.takeIf { it.isNotBlank() } + } + } + + private fun formatTimestamp(ms: ULong): String { + val totalSeconds = ms.toLong() / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + val hundredths = (ms.toLong() % 1000) / 10 + return "%02d:%02d.%02d".format(minutes, seconds, hundredths) + } +} diff --git a/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/EndedWorkaroundPlayer.kt b/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/EndedWorkaroundPlayer.kt index 313980420a..c362edf5e0 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/EndedWorkaroundPlayer.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/EndedWorkaroundPlayer.kt @@ -52,7 +52,8 @@ class EndedWorkaroundPlayer( val context: Context, exoPlayer: ExoPlayer, private val getLyric: () -> SemanticLyrics?, - val queueBoard: QueueBoard + val queueBoard: QueueBoard, + private val getCarLyricsExtras: () -> Bundle = { Bundle() }, ) : ForwardingSimpleBasePlayer(exoPlayer), Player.Listener { @@ -102,6 +103,15 @@ class EndedWorkaroundPlayer( } } + /** + * Invalidates the player state so the vivo smart car lyrics metadata injected in [getState] + * is re-read and propagated to connected controllers. Must be called whenever the car lyrics + * values change (and only then, to avoid needless state broadcasts). + */ + fun updateCarLyrics() { + invalidateState() + } + override fun getState(): State { var superState = super.state if (superState.currentMetadata.artworkUri != null && @@ -119,6 +129,24 @@ class EndedWorkaroundPlayer( ) .build() } + // vivo smart car lyrics: inject the ucar.media.metadata.* keys on-the-fly without mutating + // the timeline. Mutating the timeline (e.g. via MediaController.replaceMediaItem) fired + // onPositionDiscontinuity, which triggered another lyric update, which replaced the item + // again, producing an infinite feedback loop that froze the main thread and crashed the + // app. getCarLyricsExtras() returns an empty bundle when disabled, so the keys disappear. + val carLyricsExtras = getCarLyricsExtras() + if (!carLyricsExtras.isEmpty && superState.currentMetadata != null) { + superState = superState.buildUpon() + .setPlaylist( + superState.timeline, superState.currentTracks, + superState.currentMetadata.buildUpon() + .setExtras(Bundle(superState.currentMetadata.extras ?: Bundle()).apply { + putAll(carLyricsExtras) + }) + .build() + ) + .build() + } if (context.packageName == "com.tencent.qqmusic") { // Oplus uses package name whitelist for their lockscreen lyric feature // (don't use BuildConfig in order to allow late patching of package name, after build) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index aeead4c25a..d6826b6393 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -155,6 +155,8 @@ 应用将在启动时显示第一个标签页。分隔线后的标签页将被隐藏。 状态栏歌词 启用魅族状态栏歌词(仅适用于部分设备) + 车载歌词(vivo) + 连接 vivo 智能车载(JoviInCar)时,将歌词投送到车机屏幕显示 启用新歌词界面 显示当前播放媒体歌词的小部件 滚动到专辑 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10ab605577..54a6fcd505 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -392,6 +392,10 @@ Status bar lyrics Enable MeiZu method of displaying lyrics in status bar (only available on some devices) + + Car lyrics (vivo) + + Project lyrics to the car screen when connected to a vivo smart car (JoviInCar) Widget that shows lyrics of currently playing media diff --git a/app/src/main/res/xml/settings_lyric.xml b/app/src/main/res/xml/settings_lyric.xml index db2fb01340..dd9bf914e7 100644 --- a/app/src/main/res/xml/settings_lyric.xml +++ b/app/src/main/res/xml/settings_lyric.xml @@ -27,6 +27,15 @@ android:widgetLayout="@layout/preference_switch_widget" app:iconSpaceReserved="false" /> + + @@ -16,7 +16,7 @@ app:queryPatterns="@array/shuffle_all_queries"> diff --git a/gradle.properties b/gradle.properties index e4624b30a4..0c2d18304a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,11 +6,11 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx4G -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+UseParallelGC # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -org.gradle.parallel=true +org.gradle.parallel=false # Suppress some warnings due to discrepancies between Gramophone JDK (21) and media3 JDK (8) android.javaCompile.suppressSourceTargetDeprecationWarning=true # Suppress experimental option warnings @@ -18,8 +18,8 @@ android.suppressUnsupportedOptionWarnings=android.enableAppCompileTimeRClass,and # Gradle org.gradle.caching=true org.gradle.configureondemand=true -org.gradle.configuration-cache=true -org.gradle.tooling.parallel=true +org.gradle.configuration-cache=false +org.gradle.tooling.parallel=false # Build BuildConfig as Bytecode android.enableBuildConfigAsBytecode=true diff --git a/hificore/build.gradle.kts b/hificore/build.gradle.kts index d4e72838c8..08fc1aae7f 100644 --- a/hificore/build.gradle.kts +++ b/hificore/build.gradle.kts @@ -8,6 +8,7 @@ plugins { android { namespace = "org.nift4.gramophone.hificore" compileSdk = 37 + ndkVersion = "27.2.12479018" defaultConfig { minSdk = 23