From 4c257f7ad91c245096f2fa3abe89007562aafc78 Mon Sep 17 00:00:00 2001 From: meowarex Date: Sat, 18 Jul 2026 15:02:07 +1000 Subject: [PATCH] Word by Word Lyrics <3 --- .../app/src/main/assets/patches-manifest.json | 22 +- .../ui/screens/patchopts/PatchOptions.kt | 2 + .../ui/screens/patchopts/PatchSpec.kt | 12 +- .../components/PatchSelectionAccordion.kt | 27 +- .../components/PatchVariantSelector.kt | 3 +- patches/data.json | 2 +- patches/extension/radiant/IsrcCache.smali | 61 + .../extension/radiant/RLAPILyricsHook.smali | 16 +- .../extension/radiant/RLAPILyricsWorker.smali | 271 +++- patches/extension/radiant/WordLyrics.smali | 1123 +++++++++++++++++ .../extension/radiant/WordLyricsTick.smali | 21 + patches/lyrics-rl-api-isrc.patch | 26 + patches/lyrics-rl-api-observer.patch | 17 +- patches/lyrics-rl-api-word.patch | 56 + patches/manifest.json | 20 +- 15 files changed, 1645 insertions(+), 34 deletions(-) create mode 100644 patches/extension/radiant/IsrcCache.smali create mode 100644 patches/extension/radiant/WordLyrics.smali create mode 100644 patches/extension/radiant/WordLyricsTick.smali create mode 100644 patches/lyrics-rl-api-isrc.patch create mode 100644 patches/lyrics-rl-api-word.patch diff --git a/Manager/app/src/main/assets/patches-manifest.json b/Manager/app/src/main/assets/patches-manifest.json index b3188c7..80429d6 100644 --- a/Manager/app/src/main/assets/patches-manifest.json +++ b/Manager/app/src/main/assets/patches-manifest.json @@ -32,11 +32,25 @@ "order": 20, "fileNames": [ "lyrics-rl-api.patch", - "lyrics-rl-api-observer.patch" + "lyrics-rl-api-observer.patch", + "lyrics-rl-api-isrc.patch" ], "title": "Radiant Lyrics API - WIP", - "description": "Use Radiant Lyrics API to fetch Lyrics (Higher quality & More providers)", - "default": false + "description": "Use Radiant Lyrics API to fetch Lyrics (Higher quality & More providers). Pick how the highlight moves: whole Line or word-by-word (Word).", + "default": false, + "defaultVariantIndex": 0, + "variants": [ + { + "title": "Line", + "fileNames": [] + }, + { + "title": "Word", + "fileNames": [ + "lyrics-rl-api-word.patch" + ] + } + ] }, { "id": "PlayerBackdrop", @@ -144,7 +158,7 @@ "type": "toggle", "key": "cover_everywhere", "title": "Cover Everywhere - WIP", - "description": "Applies TIDAL's player backdrop to every Compose based page (Home & Collection)", + "description": "Applies TIDAL's native blurred album-art backdrop to every Compose based page (Home & Collection)", "default": false, "inline": true, "fileNames": [ diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt index 243a9d0..90d6bfd 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt @@ -79,6 +79,8 @@ data class PatchOptions( if (spec.variants.isEmpty()) { if (!enabled) addAll(spec.fileNames) } else { + // base fileNames apply for every variant while enabled; disable them when off + if (!enabled) addAll(spec.fileNames) val selected = variantIndex(spec) spec.variants.forEachIndexed { index, variant -> if (!enabled || index != selected) addAll(variant.fileNames) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt index 57532a9..206b1f2 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt @@ -48,6 +48,7 @@ data class VariantSpec( val title: String, val fileNames: List = emptyList(), val extensionFiles: List = emptyList(), + val enabled: Boolean = true, ) /** A single advanced option. Discriminated by `"type"` in JSON. */ @@ -173,7 +174,7 @@ fun builtinPatchSpecs(context: Context, json: Json): List = try { } @Immutable -data class EffectiveVariant(val originalIndex: Int, val title: String) +data class EffectiveVariant(val originalIndex: Int, val title: String, val enabled: Boolean = true) fun PatchSpec.effectiveVariants(isOptionOn: (OptionSpec.Toggle) -> Boolean): List { if (variants.isEmpty()) return emptyList() @@ -186,19 +187,22 @@ fun PatchSpec.effectiveVariants(isOptionOn: (OptionSpec.Toggle) -> Boolean): Lis } } return variants.mapIndexedNotNull { index, variant -> - if (index in hidden) null else EffectiveVariant(index, relabel[index] ?: variant.title) + if (index in hidden) null + else EffectiveVariant(index, relabel[index] ?: variant.title, variant.enabled) } } fun PatchSpec.resolveVariantIndex(stored: Int, isOptionOn: (OptionSpec.Toggle) -> Boolean): Int { val visible = effectiveVariants(isOptionOn) - if (visible.isEmpty() || visible.any { it.originalIndex == stored }) return stored + // never resolve onto a greyed/disabled variant + if (visible.isEmpty() || visible.any { it.originalIndex == stored && it.enabled }) return stored val relabelKeys = buildSet { for (option in advancedOptions) { if (option is OptionSpec.Toggle && isOptionOn(option)) addAll(option.relabelVariants.keys) } } - return visible.firstOrNull { it.originalIndex in relabelKeys }?.originalIndex + return visible.firstOrNull { it.originalIndex in relabelKeys && it.enabled }?.originalIndex + ?: visible.firstOrNull { it.enabled }?.originalIndex ?: visible.first().originalIndex } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchSelectionAccordion.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchSelectionAccordion.kt index 70ab32c..92d9132 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchSelectionAccordion.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchSelectionAccordion.kt @@ -169,15 +169,18 @@ fun PatchSelectionAccordion( .fillMaxWidth() .padding(bottom = 8.dp), ) { + val resolvedVariant = + if (patch.variants.isNotEmpty()) + patch.resolveVariantIndex(variantIndex(patch)) { + optionState.toggle(patch, it) + } + else -1 if (patch.variants.isNotEmpty()) { val effective = patch.effectiveVariants { optionState.toggle(patch, it) } - val resolved = patch.resolveVariantIndex(variantIndex(patch)) { - optionState.toggle(patch, it) - } PatchVariantSelector( variants = effective, selectedIndex = effective - .indexOfFirst { it.originalIndex == resolved } + .indexOfFirst { it.originalIndex == resolvedVariant } .coerceAtLeast(0), onSelect = { pos -> effective.getOrNull(pos)?.let { onSelectVariant(patch, it.originalIndex) } @@ -186,12 +189,16 @@ fun PatchSelectionAccordion( } for (option in inlineToggles) key(option.key) { - InlineToggleRow( - title = option.title, - description = option.description, - checked = optionState.toggle(patch, option), - onCheckedChange = { optionState.setToggle(patch, option, it) }, - ) + val show = option.requiresVariant == null || + option.requiresVariant == resolvedVariant + if (show) { + InlineToggleRow( + title = option.title, + description = option.description, + checked = optionState.toggle(patch, option), + onCheckedChange = { optionState.setToggle(patch, option, it) }, + ) + } } if (hasSheetOptions) { diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchVariantSelector.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchVariantSelector.kt index a22d37b..735203e 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchVariantSelector.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchVariantSelector.kt @@ -22,7 +22,8 @@ fun PatchVariantSelector( variants.forEachIndexed { index, variant -> SegmentedButton( selected = index == selectedIndex, - onClick = { onSelect(index) }, + enabled = variant.enabled, + onClick = { if (variant.enabled) onSelect(index) }, shape = SegmentedButtonDefaults.itemShape( index = index, count = variants.size, diff --git a/patches/data.json b/patches/data.json index fece944..4f32f05 100644 --- a/patches/data.json +++ b/patches/data.json @@ -1,5 +1,5 @@ { "tidalVersionCode": 10004, "tidalApkUrl": "https://github.com/meowarex/rl-mobile/releases/download/latest/tidal-stock.apk", - "patchesVersion": "0.11.1" + "patchesVersion": "0.12.0" } diff --git a/patches/extension/radiant/IsrcCache.smali b/patches/extension/radiant/IsrcCache.smali new file mode 100644 index 0000000..2d4a083 --- /dev/null +++ b/patches/extension/radiant/IsrcCache.smali @@ -0,0 +1,61 @@ +.class public final Lradiant/IsrcCache; +.super Ljava/lang/Object; + +# Caches trackId(String) -> ISRC(String), captured from TIDAL's own track mapper + +.field public static final map:Ljava/util/concurrent/ConcurrentHashMap; + + +.method static constructor ()V + .locals 1 + + new-instance v0, Ljava/util/concurrent/ConcurrentHashMap; + + invoke-direct {v0}, Ljava/util/concurrent/ConcurrentHashMap;->()V + + sput-object v0, Lradiant/IsrcCache;->map:Ljava/util/concurrent/ConcurrentHashMap; + + return-void +.end method + +.method private constructor ()V + .locals 0 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + return-void +.end method + +.method public static put(Ljava/lang/String;Ljava/lang/String;)V + .locals 1 + + if-eqz p0, :ret + + if-eqz p1, :ret + + sget-object v0, Lradiant/IsrcCache;->map:Ljava/util/concurrent/ConcurrentHashMap; + + invoke-virtual {v0, p0, p1}, Ljava/util/concurrent/ConcurrentHashMap;->put(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + + :ret + return-void +.end method + +.method public static get(Ljava/lang/String;)Ljava/lang/String; + .locals 2 + + const/4 v0, 0x0 + + if-eqz p0, :none + + sget-object v1, Lradiant/IsrcCache;->map:Ljava/util/concurrent/ConcurrentHashMap; + + invoke-virtual {v1, p0}, Ljava/util/concurrent/ConcurrentHashMap;->get(Ljava/lang/Object;)Ljava/lang/Object; + + move-result-object v0 + + check-cast v0, Ljava/lang/String; + + :none + return-object v0 +.end method diff --git a/patches/extension/radiant/RLAPILyricsHook.smali b/patches/extension/radiant/RLAPILyricsHook.smali index af398b6..0b7eb70 100644 --- a/patches/extension/radiant/RLAPILyricsHook.smali +++ b/patches/extension/radiant/RLAPILyricsHook.smali @@ -156,13 +156,27 @@ goto :invalid_track :title_ok + # match desktop: primary artist name (artist?.name), fall back to the joined list + invoke-virtual {p1}, Lcom/aspiro/wamp/model/MediaItem;->getArtist()Lcom/aspiro/wamp/model/Artist; + + move-result-object v3 + + if-eqz v3, :use_artist_names + + invoke-virtual {v3}, Lcom/aspiro/wamp/model/Artist;->getName()Ljava/lang/String; + + move-result-object v2 + + if-nez v2, :artist_present + + :use_artist_names invoke-virtual {p1}, Lcom/aspiro/wamp/model/MediaItem;->getArtistNames()Ljava/lang/String; move-result-object v2 if-nez v2, :artist_present - const-string v3, "bail: getArtistNames() returned null" + const-string v3, "bail: artist name null" invoke-static {v3}, Lradiant/RLAPILyricsHook;->dlog(Ljava/lang/String;)V diff --git a/patches/extension/radiant/RLAPILyricsWorker.smali b/patches/extension/radiant/RLAPILyricsWorker.smali index 38a633b..d8de64c 100644 --- a/patches/extension/radiant/RLAPILyricsWorker.smali +++ b/patches/extension/radiant/RLAPILyricsWorker.smali @@ -14,6 +14,8 @@ .field public final lyricsId:Ljava/lang/String; +.field public static volatile clientIp:Ljava/lang/String; + # direct methods .method public constructor (Lcom/tidal/android/feature/playerscreen/ui/PlayerViewModel;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V @@ -43,6 +45,8 @@ if-eqz v0, :done + invoke-static {}, Lradiant/WordLyrics;->clear()V + const/4 v0, 0x0 sput-object v0, Lradiant/RLAPILyricsHook;->currentKey:Ljava/lang/String; @@ -114,6 +118,99 @@ return v3 .end method +# Public client IP (needed for debugging with ratelimits) +.method static clientIp()Ljava/lang/String; + .locals 6 + + sget-object v0, Lradiant/RLAPILyricsWorker;->clientIp:Ljava/lang/String; + + if-eqz v0, :fetch + + return-object v0 + + :fetch + :try_start + new-instance v0, Ljava/net/URL; + + const-string v1, "https://api.ipify.org?format=text" + + invoke-direct {v0, v1}, Ljava/net/URL;->(Ljava/lang/String;)V + + invoke-virtual {v0}, Ljava/net/URL;->openConnection()Ljava/net/URLConnection; + + move-result-object v0 + + check-cast v0, Ljava/net/HttpURLConnection; + + const/16 v1, 0x5dc + + invoke-virtual {v0, v1}, Ljava/net/URLConnection;->setConnectTimeout(I)V + + invoke-virtual {v0, v1}, Ljava/net/URLConnection;->setReadTimeout(I)V + + invoke-virtual {v0}, Ljava/net/HttpURLConnection;->getResponseCode()I + + move-result v1 + + const/16 v2, 0xc8 + + if-eq v1, v2, :ok + + invoke-virtual {v0}, Ljava/net/HttpURLConnection;->disconnect()V + + goto :fail + + :ok + invoke-virtual {v0}, Ljava/net/HttpURLConnection;->getInputStream()Ljava/io/InputStream; + + move-result-object v1 + + new-instance v2, Ljava/io/InputStreamReader; + + const-string v3, "UTF-8" + + invoke-direct {v2, v1, v3}, Ljava/io/InputStreamReader;->(Ljava/io/InputStream;Ljava/lang/String;)V + + new-instance v3, Ljava/io/BufferedReader; + + invoke-direct {v3, v2}, Ljava/io/BufferedReader;->(Ljava/io/Reader;)V + + invoke-virtual {v3}, Ljava/io/BufferedReader;->readLine()Ljava/lang/String; + + move-result-object v4 + + invoke-virtual {v3}, Ljava/io/BufferedReader;->close()V + + invoke-virtual {v0}, Ljava/net/HttpURLConnection;->disconnect()V + + if-eqz v4, :fail + + invoke-virtual {v4}, Ljava/lang/String;->trim()Ljava/lang/String; + + move-result-object v4 + + invoke-virtual {v4}, Ljava/lang/String;->isEmpty()Z + + move-result v5 + + if-nez v5, :fail + + sput-object v4, Lradiant/RLAPILyricsWorker;->clientIp:Ljava/lang/String; + + return-object v4 + + :try_end + .catchall {:try_start .. :try_end} :catch_all + + :catch_all + move-exception v0 + + :fail + const-string v0, "null" + + return-object v0 +.end method + .method public static fetch(Ljava/lang/String;Z)Ljava/lang/String; .locals 7 @@ -136,6 +233,8 @@ invoke-virtual {v0, v1}, Ljava/net/URLConnection;->setConnectTimeout(I)V + const v1, 0xafc8 + invoke-virtual {v0, v1}, Ljava/net/URLConnection;->setReadTimeout(I)V if-eqz p1, :no_auth @@ -154,7 +253,9 @@ const-string v1, "x-client-ip" - const-string v2, "null" + invoke-static {}, Lradiant/RLAPILyricsWorker;->clientIp()Ljava/lang/String; + + move-result-object v2 invoke-virtual {v0, v1, v2}, Ljava/net/URLConnection;->setRequestProperty(Ljava/lang/String;Ljava/lang/String;)V @@ -393,20 +494,168 @@ return-object v1 .end method +# Returns "&isrc=" for the current track +.method private isrcParam()Ljava/lang/String; + .locals 6 + + iget-object v0, p0, Lradiant/RLAPILyricsWorker;->key:Ljava/lang/String; + + const/4 v1, 0x0 + + :loop + invoke-static {v0}, Lradiant/IsrcCache;->get(Ljava/lang/String;)Ljava/lang/String; + + move-result-object v2 + + if-eqz v2, :retry + + invoke-virtual {v2}, Ljava/lang/String;->trim()Ljava/lang/String; + + move-result-object v2 + + invoke-virtual {v2}, Ljava/lang/String;->isEmpty()Z + + move-result v3 + + if-nez v3, :retry + + :try_enc_start + invoke-static {v2}, Lradiant/RLAPILyricsWorker;->uric(Ljava/lang/String;)Ljava/lang/String; + + move-result-object v2 + + :try_enc_end + .catchall {:try_enc_start .. :try_enc_end} :enc_fail + + :enc_done + new-instance v3, Ljava/lang/StringBuilder; + + const-string v4, "&isrc=" + + invoke-direct {v3, v4}, Ljava/lang/StringBuilder;->(Ljava/lang/String;)V + + invoke-virtual {v3, v2}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder; + + invoke-virtual {v3}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String; + + move-result-object v2 + + return-object v2 + + :enc_fail + move-exception v3 + + goto :enc_done + + :retry + # keep waiting for TIDAL's mapper to cache the ISRC while this track is still current + invoke-direct {p0}, Lradiant/RLAPILyricsWorker;->isCurrent()Z + + move-result v3 + + if-eqz v3, :giveup + + const/16 v3, 0x28 + + if-ge v1, v3, :giveup + + :try_sleep_start + const-wide/16 v3, 0x64 + + invoke-static {v3, v4}, Ljava/lang/Thread;->sleep(J)V + + :try_sleep_end + .catchall {:try_sleep_start .. :try_sleep_end} :sleep_catch + + :sleep_done + add-int/lit8 v1, v1, 0x1 + + goto :loop + + :sleep_catch + move-exception v3 + + goto :sleep_done + + :giveup + const-string v0, "" + + return-object v0 +.end method + +# URL-encode like JS encodeURIComponent (so requests are byte identical to the desktop plugin) +.method static uric(Ljava/lang/String;)Ljava/lang/String; + .locals 3 + + const-string v0, "UTF-8" + + invoke-static {p0, v0}, Ljava/net/URLEncoder;->encode(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + + move-result-object v0 + + const-string v1, "+" + + const-string v2, "%20" + + invoke-virtual {v0, v1, v2}, Ljava/lang/String;->replace(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String; + + move-result-object v0 + + const-string v1, "%21" + + const-string v2, "!" + + invoke-virtual {v0, v1, v2}, Ljava/lang/String;->replace(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String; + + move-result-object v0 + + const-string v1, "%27" + + const-string v2, "\'" + + invoke-virtual {v0, v1, v2}, Ljava/lang/String;->replace(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String; + + move-result-object v0 + + const-string v1, "%28" + + const-string v2, "(" + + invoke-virtual {v0, v1, v2}, Ljava/lang/String;->replace(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String; + + move-result-object v0 + + const-string v1, "%29" + + const-string v2, ")" + + invoke-virtual {v0, v1, v2}, Ljava/lang/String;->replace(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String; + + move-result-object v0 + + const-string v1, "%7E" + + const-string v2, "~" + + invoke-virtual {v0, v1, v2}, Ljava/lang/String;->replace(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String; + + move-result-object v0 + + return-object v0 +.end method + .method private runImpl()V - .locals 11 + .locals 13 iget-object v0, p0, Lradiant/RLAPILyricsWorker;->title:Ljava/lang/String; - const-string v1, "UTF-8" - - invoke-static {v0, v1}, Ljava/net/URLEncoder;->encode(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + invoke-static {v0}, Lradiant/RLAPILyricsWorker;->uric(Ljava/lang/String;)Ljava/lang/String; move-result-object v0 iget-object v2, p0, Lradiant/RLAPILyricsWorker;->artist:Ljava/lang/String; - invoke-static {v2, v1}, Ljava/net/URLEncoder;->encode(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + invoke-static {v2}, Lradiant/RLAPILyricsWorker;->uric(Ljava/lang/String;)Ljava/lang/String; move-result-object v1 @@ -424,6 +673,12 @@ invoke-virtual {v2, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder; + invoke-direct {p0}, Lradiant/RLAPILyricsWorker;->isrcParam()Ljava/lang/String; + + move-result-object v0 + + invoke-virtual {v2, v0}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder; + const-string v0, "&platform=rl-mobile" invoke-virtual {v2, v0}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder; @@ -479,6 +734,8 @@ goto :request_failed :got_body + move-object v11, v3 + invoke-static {v3}, Lradiant/RLAPILyricsWorker;->parseLines(Ljava/lang/String;)Ljava/util/ArrayList; move-result-object v3 @@ -536,6 +793,8 @@ return-void :key_ok + invoke-static {v11}, Lradiant/WordLyrics;->ingest(Ljava/lang/String;)V + iget-object v4, p0, Lradiant/RLAPILyricsWorker;->vm:Lcom/tidal/android/feature/playerscreen/ui/PlayerViewModel; iget-object v5, v4, Lcom/tidal/android/feature/playerscreen/ui/PlayerViewModel;->O:Lkotlinx/coroutines/flow/MutableStateFlow; diff --git a/patches/extension/radiant/WordLyrics.smali b/patches/extension/radiant/WordLyrics.smali new file mode 100644 index 0000000..5f894a3 --- /dev/null +++ b/patches/extension/radiant/WordLyrics.smali @@ -0,0 +1,1123 @@ +.class public final Lradiant/WordLyrics; +.super Ljava/lang/Object; + +# Word-level lyrics highlighting support for the RL-API lyrics injection. +# Parses the API "Word" payload (per-line syllabus[]) into per-line word arrays, +# runs a 50ms main-thread clock that publishes an interpolated playhead into a Compose +# MutableState, and builds a two-tone AnnotatedString (sung / unsung) that the patched +# PlayerScreen line composable (f2) renders for whatever line Compose marks active. +# Line-level data (scroll, line index) still comes from TIDAL's own g$c pipeline. +# +# ROBUSTNESS: render() gates on Compose's OWN active-line index (f2.b) — not a clock +# guess — and the active line ALWAYS reads the playhead state, so it can never lose its +# recomposition subscription and get stranded on line-highlighting. + + +# static state + +.field public static lineStarts:[J + +.field public static lineWordText:[[Ljava/lang/String; + +.field public static lineWordStart:[[J + +.field public static volatile playheadState:Landroidx/compose/runtime/MutableState; + +# active RL line index (from clock over lineStarts) +.field public static volatile activeLineState:Landroidx/compose/runtime/MutableState; + +.field public static volatile baseMs:J + +.field public static volatile baseAnchor:J + +.field public static volatile ticking:Z + +# per-render scratch handed to styleSpot +.field public static tmpStarts:[J + +.field public static tmpOffs:[I + +.field public static tmpN:I + +.field public static tmpNow:J + +.field public static tmpSung:J + +.field public static tmpUnsung:J + +.field public static handler:Landroid/os/Handler; + +.field public static tickRunnable:Ljava/lang/Runnable; + +.field public static sungColor:J + +.field public static sungSpan:Landroidx/compose/ui/text/SpanStyle; + +.field public static unsungColor:J + +.field public static unsungSpan:Landroidx/compose/ui/text/SpanStyle; + + +.method private constructor ()V + .locals 0 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + return-void +.end method + + +# snapshot state (interpolated playhead ms) + +.method public static state()Landroidx/compose/runtime/MutableState; + .locals 2 + + sget-object v0, Lradiant/WordLyrics;->playheadState:Landroidx/compose/runtime/MutableState; + + if-nez v0, :ret + + const/4 v0, 0x0 + + invoke-static {v0}, Ljava/lang/Integer;->valueOf(I)Ljava/lang/Integer; + + move-result-object v0 + + invoke-static {}, Landroidx/compose/runtime/SnapshotStateKt;->structuralEqualityPolicy()Landroidx/compose/runtime/SnapshotMutationPolicy; + + move-result-object v1 + + invoke-static {v0, v1}, Landroidx/compose/runtime/SnapshotStateKt;->mutableStateOf(Ljava/lang/Object;Landroidx/compose/runtime/SnapshotMutationPolicy;)Landroidx/compose/runtime/MutableState; + + move-result-object v0 + + sput-object v0, Lradiant/WordLyrics;->playheadState:Landroidx/compose/runtime/MutableState; + + :ret + return-object v0 +.end method + +# active-line snapshot state (init -1) +.method public static lineState()Landroidx/compose/runtime/MutableState; + .locals 2 + + sget-object v0, Lradiant/WordLyrics;->activeLineState:Landroidx/compose/runtime/MutableState; + + if-nez v0, :ret + + const/4 v0, -0x1 + + invoke-static {v0}, Ljava/lang/Integer;->valueOf(I)Ljava/lang/Integer; + + move-result-object v0 + + invoke-static {}, Landroidx/compose/runtime/SnapshotStateKt;->structuralEqualityPolicy()Landroidx/compose/runtime/SnapshotMutationPolicy; + + move-result-object v1 + + invoke-static {v0, v1}, Landroidx/compose/runtime/SnapshotStateKt;->mutableStateOf(Ljava/lang/Object;Landroidx/compose/runtime/SnapshotMutationPolicy;)Landroidx/compose/runtime/MutableState; + + move-result-object v0 + + sput-object v0, Lradiant/WordLyrics;->activeLineState:Landroidx/compose/runtime/MutableState; + + :ret + return-object v0 +.end method + +.method public static setData([J[[Ljava/lang/String;[[J)V + .locals 0 + + sput-object p0, Lradiant/WordLyrics;->lineStarts:[J + + sput-object p1, Lradiant/WordLyrics;->lineWordText:[[Ljava/lang/String; + + sput-object p2, Lradiant/WordLyrics;->lineWordStart:[[J + + invoke-static {}, Lradiant/WordLyrics;->state()Landroidx/compose/runtime/MutableState; + + invoke-static {}, Lradiant/WordLyrics;->lineState()Landroidx/compose/runtime/MutableState; + + invoke-static {}, Lradiant/WordLyrics;->startClock()V + + return-void +.end method + +.method public static clear()V + .locals 1 + + const/4 v0, 0x0 + + sput-object v0, Lradiant/WordLyrics;->lineStarts:[J + + sput-object v0, Lradiant/WordLyrics;->lineWordText:[[Ljava/lang/String; + + sput-object v0, Lradiant/WordLyrics;->lineWordStart:[[J + + invoke-static {}, Lradiant/WordLyrics;->stopClock()V + + return-void +.end method + +.method public static onProgress(J)V + .locals 4 + + sput-wide p0, Lradiant/WordLyrics;->baseMs:J + + invoke-static {}, Landroid/os/SystemClock;->elapsedRealtime()J + + move-result-wide v0 + + sput-wide v0, Lradiant/WordLyrics;->baseAnchor:J + + # self-heal: restart the clock if data is present but it stopped + sget-object v2, Lradiant/WordLyrics;->lineWordText:[[Ljava/lang/String; + + if-eqz v2, :done_op + + sget-boolean v2, Lradiant/WordLyrics;->ticking:Z + + if-nez v2, :done_op + + invoke-static {}, Lradiant/WordLyrics;->startClock()V + + :done_op + return-void +.end method + + +# Clock + +.method public static startClock()V + .locals 2 + + sget-boolean v0, Lradiant/WordLyrics;->ticking:Z + + if-nez v0, :done + + const/4 v0, 0x1 + + sput-boolean v0, Lradiant/WordLyrics;->ticking:Z + + sget-object v0, Lradiant/WordLyrics;->handler:Landroid/os/Handler; + + if-nez v0, :have_h + + new-instance v0, Landroid/os/Handler; + + invoke-static {}, Landroid/os/Looper;->getMainLooper()Landroid/os/Looper; + + move-result-object v1 + + invoke-direct {v0, v1}, Landroid/os/Handler;->(Landroid/os/Looper;)V + + sput-object v0, Lradiant/WordLyrics;->handler:Landroid/os/Handler; + + :have_h + sget-object v0, Lradiant/WordLyrics;->tickRunnable:Ljava/lang/Runnable; + + if-nez v0, :have_r + + new-instance v0, Lradiant/WordLyricsTick; + + invoke-direct {v0}, Lradiant/WordLyricsTick;->()V + + sput-object v0, Lradiant/WordLyrics;->tickRunnable:Ljava/lang/Runnable; + + :have_r + sget-object v0, Lradiant/WordLyrics;->handler:Landroid/os/Handler; + + sget-object v1, Lradiant/WordLyrics;->tickRunnable:Ljava/lang/Runnable; + + invoke-virtual {v0, v1}, Landroid/os/Handler;->post(Ljava/lang/Runnable;)Z + + :done + return-void +.end method + +.method public static stopClock()V + .locals 2 + + const/4 v0, 0x0 + + sput-boolean v0, Lradiant/WordLyrics;->ticking:Z + + sget-object v0, Lradiant/WordLyrics;->handler:Landroid/os/Handler; + + if-eqz v0, :ret + + sget-object v1, Lradiant/WordLyrics;->tickRunnable:Ljava/lang/Runnable; + + if-eqz v1, :ret + + invoke-virtual {v0, v1}, Landroid/os/Handler;->removeCallbacks(Ljava/lang/Runnable;)V + + :ret + return-void +.end method + +.method static computeNow()J + .locals 7 + + sget-wide v0, Lradiant/WordLyrics;->baseMs:J + + sget-wide v2, Lradiant/WordLyrics;->baseAnchor:J + + invoke-static {}, Landroid/os/SystemClock;->elapsedRealtime()J + + move-result-wide v4 + + sub-long/2addr v4, v2 + + const-wide/16 v2, 0x0 + + cmp-long v6, v4, v2 + + if-gez v6, :chk_hi + + move-wide v4, v2 + + goto :add + + :chk_hi + const-wide/16 v2, 0x190 + + cmp-long v6, v4, v2 + + if-lez v6, :add + + move-wide v4, v2 + + :add + add-long/2addr v0, v4 + + return-wide v0 +.end method + +# largest index i with arr[i] <= v, else -1 (arr ascending) +.method static search([JJ)I + .locals 5 + + const/4 v0, -0x1 + + if-eqz p0, :done + + array-length v1, p0 + + const/4 v2, 0x0 + + :loop + if-ge v2, v1, :done + + aget-wide v3, p0, v2 + + cmp-long v4, v3, p1 + + if-gtz v4, :done + + move v0, v2 + + add-int/lit8 v2, v2, 0x1 + + goto :loop + + :done + return v0 +.end method + +# 33ms tick: publish the interpolated playhead + the active RL line into snapshot states +.method public static tick()V + .locals 6 + + sget-object v0, Lradiant/WordLyrics;->lineWordText:[[Ljava/lang/String; + + if-eqz v0, :stop + + invoke-static {}, Lradiant/WordLyrics;->computeNow()J + + move-result-wide v0 + + # active RL line index (search over lineStarts) + sget-object v2, Lradiant/WordLyrics;->lineStarts:[J + + invoke-static {v2, v0, v1}, Lradiant/WordLyrics;->search([JJ)I + + move-result v2 + + invoke-static {}, Lradiant/WordLyrics;->lineState()Landroidx/compose/runtime/MutableState; + + move-result-object v3 + + invoke-interface {v3}, Landroidx/compose/runtime/MutableState;->getValue()Ljava/lang/Object; + + move-result-object v4 + + if-eqz v4, :set_al + + check-cast v4, Ljava/lang/Integer; + + invoke-virtual {v4}, Ljava/lang/Integer;->intValue()I + + move-result v5 + + if-eq v5, v2, :al_done + + :set_al + invoke-static {v2}, Ljava/lang/Integer;->valueOf(I)Ljava/lang/Integer; + + move-result-object v4 + + invoke-interface {v3, v4}, Landroidx/compose/runtime/MutableState;->setValue(Ljava/lang/Object;)V + + :al_done + # playhead tick (every frame) -> playheadState (drives active line fade) + long-to-int v0, v0 + + invoke-static {v0}, Ljava/lang/Integer;->valueOf(I)Ljava/lang/Integer; + + move-result-object v0 + + invoke-static {}, Lradiant/WordLyrics;->state()Landroidx/compose/runtime/MutableState; + + move-result-object v1 + + invoke-interface {v1, v0}, Landroidx/compose/runtime/MutableState;->setValue(Ljava/lang/Object;)V + + sget-boolean v0, Lradiant/WordLyrics;->ticking:Z + + if-eqz v0, :done + + sget-object v0, Lradiant/WordLyrics;->handler:Landroid/os/Handler; + + if-eqz v0, :done + + sget-object v1, Lradiant/WordLyrics;->tickRunnable:Ljava/lang/Runnable; + + if-eqz v1, :done + + const-wide/16 v2, 0x21 + + invoke-virtual {v0, v1, v2, v3}, Landroid/os/Handler;->postDelayed(Ljava/lang/Runnable;J)Z + + :done + return-void + + :stop + const/4 v0, 0x0 + + sput-boolean v0, Lradiant/WordLyrics;->ticking:Z + + return-void +.end method + + +# render + +.method public static render(IILjava/lang/String;JJ)Landroidx/compose/ui/text/AnnotatedString; + .locals 18 + + move/from16 v15, p0 + + move-object/from16 v10, p2 + + move-wide/from16 v11, p3 + + move-wide/from16 v13, p5 + + # only style lines that have RL word data + sget-boolean v0, Lradiant/RLAPILyricsHook;->isRlState:Z + + if-eqz v0, :plain + + sget-object v0, Lradiant/WordLyrics;->lineWordText:[[Ljava/lang/String; + + if-eqz v0, :plain + + if-ltz v15, :plain + + array-length v1, v0 + + if-ge v15, v1, :plain + + aget-object v1, v0, v15 + + if-eqz v1, :plain + + array-length v2, v1 + + if-eqz v2, :plain + + sget-object v3, Lradiant/WordLyrics;->lineWordStart:[[J + + if-eqz v3, :plain + + aget-object v3, v3, v15 + + if-eqz v3, :plain + + # build line text + per word char offsets (offsets[n] = total length) + new-instance v6, Landroidx/compose/ui/text/AnnotatedString$Builder; + + invoke-direct {v6}, Landroidx/compose/ui/text/AnnotatedString$Builder;->()V + + add-int/lit8 v8, v2, 0x1 + + new-array v7, v8, [I + + const/4 v8, 0x0 + + const/4 v9, 0x0 + + :aloop + if-ge v8, v2, :adone + + aput v9, v7, v8 + + aget-object v0, v1, v8 + + invoke-virtual {v6, v0}, Landroidx/compose/ui/text/AnnotatedString$Builder;->append(Ljava/lang/String;)V + + invoke-virtual {v0}, Ljava/lang/String;->length()I + + move-result v0 + + add-int/2addr v9, v0 + + add-int/lit8 v8, v8, 0x1 + + goto :aloop + + :adone + aput v9, v7, v2 + + # base grey span over the whole line (stops flicker) + const/4 v0, 0x0 + + invoke-static {v13, v14, v0}, Lradiant/WordLyrics;->span(JZ)Landroidx/compose/ui/text/SpanStyle; + + move-result-object v0 + + const/4 v8, 0x0 + + invoke-virtual {v6, v0, v8, v9}, Landroidx/compose/ui/text/AnnotatedString$Builder;->addStyle(Landroidx/compose/ui/text/SpanStyle;II)V + + # active line = clocks line + invoke-static {}, Lradiant/WordLyrics;->lineState()Landroidx/compose/runtime/MutableState; + + move-result-object v0 + + invoke-interface {v0}, Landroidx/compose/runtime/MutableState;->getValue()Ljava/lang/Object; + + move-result-object v0 + + const/4 v4, -0x1 + + if-eqz v0, :have_al + + check-cast v0, Ljava/lang/Integer; + + invoke-virtual {v0}, Ljava/lang/Integer;->intValue()I + + move-result v4 + + :have_al + if-ne v15, v4, :emit + + # active line subscribes to the playhead tick (fade) & create/use a fresh playhead + invoke-static {}, Lradiant/WordLyrics;->state()Landroidx/compose/runtime/MutableState; + + move-result-object v0 + + invoke-interface {v0}, Landroidx/compose/runtime/MutableState;->getValue()Ljava/lang/Object; + + invoke-static {}, Lradiant/WordLyrics;->computeNow()J + + move-result-wide v4 + + invoke-static {v3, v4, v5}, Lradiant/WordLyrics;->search([JJ)I + + move-result v8 + + if-ltz v8, :emit + + # spotlight (only the active word is highlighted) + + sput-object v3, Lradiant/WordLyrics;->tmpStarts:[J + + sput-object v7, Lradiant/WordLyrics;->tmpOffs:[I + + sput v2, Lradiant/WordLyrics;->tmpN:I + + sput-wide v4, Lradiant/WordLyrics;->tmpNow:J + + sput-wide v11, Lradiant/WordLyrics;->tmpSung:J + + sput-wide v13, Lradiant/WordLyrics;->tmpUnsung:J + + invoke-static {v6, v8}, Lradiant/WordLyrics;->styleSpot(Landroidx/compose/ui/text/AnnotatedString$Builder;I)V + + :emit + invoke-virtual {v6}, Landroidx/compose/ui/text/AnnotatedString$Builder;->toAnnotatedString()Landroidx/compose/ui/text/AnnotatedString; + + move-result-object v0 + + return-object v0 + + :plain + new-instance v0, Landroidx/compose/ui/text/AnnotatedString$Builder; + + invoke-direct {v0, v10}, Landroidx/compose/ui/text/AnnotatedString$Builder;->(Ljava/lang/String;)V + + invoke-virtual {v0}, Landroidx/compose/ui/text/AnnotatedString$Builder;->toAnnotatedString()Landroidx/compose/ui/text/AnnotatedString; + + move-result-object v0 + + return-object v0 +.end method + +# Colour the single active word j (faded grey<->white by its bump curve) +.method static styleSpot(Landroidx/compose/ui/text/AnnotatedString$Builder;I)V + .locals 12 + + sget v0, Lradiant/WordLyrics;->tmpN:I + + if-ltz p1, :done + + if-ge p1, v0, :done + + sget-object v1, Lradiant/WordLyrics;->tmpStarts:[J + + aget-wide v2, v1, p1 + + add-int/lit8 v4, p1, 0x1 + + if-ge v4, v0, :last_w + + aget-wide v4, v1, v4 + + goto :have_we + + :last_w + const-wide/16 v4, 0x320 + + add-long/2addr v4, v2 + + :have_we + sget-wide v6, Lradiant/WordLyrics;->tmpNow:J + + invoke-static/range {v2 .. v7}, Lradiant/WordLyrics;->bump(JJJ)F + + move-result v8 + + const v9, 0x3c23d70a + + cmpg-float v10, v8, v9 + + if-lez v10, :done + + sget-wide v0, Lradiant/WordLyrics;->tmpUnsung:J + + sget-wide v2, Lradiant/WordLyrics;->tmpSung:J + + invoke-static {v0, v1, v2, v3, v8}, Landroidx/compose/ui/graphics/ColorKt;->lerp-jxsXWHM(JJF)J + + move-result-wide v0 + + invoke-static {v0, v1}, Lradiant/WordLyrics;->makeSpan(J)Landroidx/compose/ui/text/SpanStyle; + + move-result-object v0 + + sget-object v1, Lradiant/WordLyrics;->tmpOffs:[I + + aget v2, v1, p1 + + add-int/lit8 v3, p1, 0x1 + + aget v3, v1, v3 + + invoke-virtual {p0, v0, v2, v3}, Landroidx/compose/ui/text/AnnotatedString$Builder;->addStyle(Landroidx/compose/ui/text/SpanStyle;II)V + + :done + return-void +.end method + +# Fade Curve (tech blabble below) +# Fade curve for a word active over [ws, we) ramps 0 -> 1 over the first FADE ms & 1 -> 0 over the last FADE ms (min of the two ramps) 0 outside the window +.method static bump(JJJ)F + .locals 6 + + cmp-long v0, p4, p0 + + if-ltz v0, :zero + + cmp-long v0, p4, p2 + + if-gez v0, :zero + + sub-long v0, p4, p0 + + long-to-float v0, v0 + + const v1, 0x42f00000 + + div-float v0, v0, v1 + + sub-long v2, p2, p4 + + long-to-float v2, v2 + + div-float v2, v2, v1 + + cmpg-float v3, v0, v2 + + if-lez v3, :have_min + + move v0, v2 + + :have_min + const v1, 0x3f800000 + + cmpg-float v3, v0, v1 + + if-lez v3, :ret + + move v0, v1 + + :ret + return v0 + + :zero + const/4 v0, 0x0 + + return v0 +.end method + +# cached SpanStyle per color (p2=isSung slot) +.method static span(JZ)Landroidx/compose/ui/text/SpanStyle; + .locals 3 + + if-eqz p2, :unsung + + sget-object v0, Lradiant/WordLyrics;->sungSpan:Landroidx/compose/ui/text/SpanStyle; + + if-eqz v0, :build_s + + sget-wide v1, Lradiant/WordLyrics;->sungColor:J + + cmp-long v2, v1, p0 + + if-nez v2, :build_s + + return-object v0 + + :build_s + invoke-static {p0, p1}, Lradiant/WordLyrics;->makeSpan(J)Landroidx/compose/ui/text/SpanStyle; + + move-result-object v0 + + sput-object v0, Lradiant/WordLyrics;->sungSpan:Landroidx/compose/ui/text/SpanStyle; + + sput-wide p0, Lradiant/WordLyrics;->sungColor:J + + return-object v0 + + :unsung + sget-object v0, Lradiant/WordLyrics;->unsungSpan:Landroidx/compose/ui/text/SpanStyle; + + if-eqz v0, :build_u + + sget-wide v1, Lradiant/WordLyrics;->unsungColor:J + + cmp-long v2, v1, p0 + + if-nez v2, :build_u + + return-object v0 + + :build_u + invoke-static {p0, p1}, Lradiant/WordLyrics;->makeSpan(J)Landroidx/compose/ui/text/SpanStyle; + + move-result-object v0 + + sput-object v0, Lradiant/WordLyrics;->unsungSpan:Landroidx/compose/ui/text/SpanStyle; + + sput-wide p0, Lradiant/WordLyrics;->unsungColor:J + + return-object v0 +.end method + +# SpanStyle(color = ) with every other property defaulted (mask 0xFFFE, bit0 clear = provide color) +.method static makeSpan(J)Landroidx/compose/ui/text/SpanStyle; + .locals 23 + + new-instance v0, Landroidx/compose/ui/text/SpanStyle; + + move-wide/from16 v1, p0 + + const-wide/16 v3, 0x0 + + const/4 v5, 0x0 + + const/4 v6, 0x0 + + const/4 v7, 0x0 + + const/4 v8, 0x0 + + const/4 v9, 0x0 + + const-wide/16 v10, 0x0 + + const/4 v12, 0x0 + + const/4 v13, 0x0 + + const/4 v14, 0x0 + + const-wide/16 v15, 0x0 + + const/16 v17, 0x0 + + const/16 v18, 0x0 + + const/16 v19, 0x0 + + const/16 v20, 0x0 + + const v21, 0xfffe + + const/16 v22, 0x0 + + invoke-direct/range {v0 .. v22}, Landroidx/compose/ui/text/SpanStyle;->(JJLandroidx/compose/ui/text/font/FontWeight;Landroidx/compose/ui/text/font/FontStyle;Landroidx/compose/ui/text/font/FontSynthesis;Landroidx/compose/ui/text/font/FontFamily;Ljava/lang/String;JLandroidx/compose/ui/text/style/BaselineShift;Landroidx/compose/ui/text/style/TextGeometricTransform;Landroidx/compose/ui/text/intl/LocaleList;JLandroidx/compose/ui/text/style/TextDecoration;Landroidx/compose/ui/graphics/Shadow;Landroidx/compose/ui/text/PlatformSpanStyle;Landroidx/compose/ui/graphics/drawscope/DrawStyle;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + + return-object v0 +.end method + + +# ingest API ("Word" payload into per line word arrays) + +.method public static ingest(Ljava/lang/String;)V + .locals 20 + + :try_start + move-object/from16 v9, p0 + + new-instance v6, Lorg/json/JSONObject; + + invoke-direct {v6, v9}, Lorg/json/JSONObject;->(Ljava/lang/String;)V + + const-string v7, "type" + + const-string v8, "" + + invoke-virtual {v6, v7, v8}, Lorg/json/JSONObject;->optString(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + + move-result-object v7 + + const-string v8, "Word" + + invoke-virtual {v8, v7}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z + + move-result v7 + + if-nez v7, :is_word + + const-string v7, "WordLyrics ingest: not Word type -> cleared" + + invoke-static {v7}, Lradiant/RLAPILyricsHook;->dlog(Ljava/lang/String;)V + + invoke-static {}, Lradiant/WordLyrics;->clear()V + + return-void + + :is_word + const-string v7, "data" + + invoke-virtual {v6, v7}, Lorg/json/JSONObject;->optJSONArray(Ljava/lang/String;)Lorg/json/JSONArray; + + move-result-object v0 + + if-nez v0, :have_data + + invoke-static {}, Lradiant/WordLyrics;->clear()V + + return-void + + :have_data + invoke-virtual {v0}, Lorg/json/JSONArray;->length()I + + move-result v1 + + if-nez v1, :nonempty + + invoke-static {}, Lradiant/WordLyrics;->clear()V + + return-void + + :nonempty + new-array v2, v1, [J + + new-array v3, v1, [[Ljava/lang/String; + + new-array v4, v1, [[J + + const/4 v5, 0x0 + + :line_loop + if-ge v5, v1, :lines_done + + invoke-virtual {v0, v5}, Lorg/json/JSONArray;->getJSONObject(I)Lorg/json/JSONObject; + + move-result-object v6 + + const-string v7, "startTime" + + const-wide/16 v8, 0x0 + + invoke-virtual {v6, v7, v8, v9}, Lorg/json/JSONObject;->optDouble(Ljava/lang/String;D)D + + move-result-wide v7 + + const-wide v9, 0x408f400000000000L + + mul-double/2addr v7, v9 + + double-to-long v7, v7 + + aput-wide v7, v2, v5 + + const-string v7, "syllabus" + + invoke-virtual {v6, v7}, Lorg/json/JSONObject;->optJSONArray(Ljava/lang/String;)Lorg/json/JSONArray; + + move-result-object v7 + + if-eqz v7, :single + + invoke-virtual {v7}, Lorg/json/JSONArray;->length()I + + move-result v8 + + if-nez v8, :have_syl + + :single + const/4 v8, 0x1 + + new-array v9, v8, [Ljava/lang/String; + + const-string v10, "text" + + const-string v11, "" + + invoke-virtual {v6, v10, v11}, Lorg/json/JSONObject;->optString(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + + move-result-object v10 + + const/4 v11, 0x0 + + aput-object v10, v9, v11 + + aput-object v9, v3, v5 + + new-array v9, v8, [J + + aget-wide v7, v2, v5 + + aput-wide v7, v9, v11 + + aput-object v9, v4, v5 + + goto :line_next + + :have_syl + const/4 v9, 0x0 + + const/4 v10, 0x0 + + :count_loop + if-ge v10, v8, :count_done + + invoke-virtual {v7, v10}, Lorg/json/JSONArray;->getJSONObject(I)Lorg/json/JSONObject; + + move-result-object v11 + + const-string v12, "text" + + const-string v13, "" + + invoke-virtual {v11, v12, v13}, Lorg/json/JSONObject;->optString(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + + move-result-object v11 + + invoke-static {v11, v10, v8}, Lradiant/WordLyrics;->isWordEnd(Ljava/lang/String;II)Z + + move-result v11 + + if-eqz v11, :count_next + + add-int/lit8 v9, v9, 0x1 + + :count_next + add-int/lit8 v10, v10, 0x1 + + goto :count_loop + + :count_done + if-nez v9, :count_ok + + const/4 v9, 0x1 + + :count_ok + new-array v10, v9, [Ljava/lang/String; + + new-array v11, v9, [J + + new-instance v12, Ljava/lang/StringBuilder; + + invoke-direct {v12}, Ljava/lang/StringBuilder;->()V + + const/4 v13, 0x0 + + const/4 v14, 0x0 + + const-wide/16 v15, 0x0 + + const/16 v17, 0x0 + + :fill_loop + if-ge v13, v8, :fill_done + + invoke-virtual {v7, v13}, Lorg/json/JSONArray;->getJSONObject(I)Lorg/json/JSONObject; + + move-result-object v6 + + if-nez v17, :grp_open + + const-string v9, "time" + + invoke-virtual {v6, v9}, Lorg/json/JSONObject;->optLong(Ljava/lang/String;)J + + move-result-wide v15 + + const/16 v17, 0x1 + + :grp_open + const-string v9, "text" + + invoke-virtual {v6, v9}, Lorg/json/JSONObject;->optString(Ljava/lang/String;)Ljava/lang/String; + + move-result-object v6 + + invoke-virtual {v12, v6}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder; + + invoke-static {v6, v13, v8}, Lradiant/WordLyrics;->isWordEnd(Ljava/lang/String;II)Z + + move-result v6 + + if-eqz v6, :fill_next + + invoke-virtual {v12}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String; + + move-result-object v6 + + aput-object v6, v10, v14 + + aput-wide v15, v11, v14 + + add-int/lit8 v14, v14, 0x1 + + new-instance v12, Ljava/lang/StringBuilder; + + invoke-direct {v12}, Ljava/lang/StringBuilder;->()V + + const/16 v17, 0x0 + + :fill_next + add-int/lit8 v13, v13, 0x1 + + goto :fill_loop + + :fill_done + aput-object v10, v3, v5 + + aput-object v11, v4, v5 + + :line_next + # use the first WORD's start as the line's start so the active-line clock and the word times come from the same source (API line startTime can precede its words btw) + aget-object v6, v4, v5 + + const/4 v7, 0x0 + + aget-wide v8, v6, v7 + + aput-wide v8, v2, v5 + + add-int/lit8 v5, v5, 0x1 + + goto :line_loop + + :lines_done + new-instance v6, Ljava/lang/StringBuilder; + + const-string v7, "WordLyrics ingest: Word, lines=" + + invoke-direct {v6, v7}, Ljava/lang/StringBuilder;->(Ljava/lang/String;)V + + invoke-virtual {v6, v1}, Ljava/lang/StringBuilder;->append(I)Ljava/lang/StringBuilder; + + invoke-virtual {v6}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String; + + move-result-object v6 + + invoke-static {v6}, Lradiant/RLAPILyricsHook;->dlog(Ljava/lang/String;)V + + invoke-static {v2, v3, v4}, Lradiant/WordLyrics;->setData([J[[Ljava/lang/String;[[J)V + + return-void + + :try_end + .catchall {:try_start .. :try_end} :catch + + :catch + move-exception v0 + + invoke-static {}, Lradiant/WordLyrics;->clear()V + + return-void +.end method + +# boundary if last char is whitespace or last syllable in the line +.method static isWordEnd(Ljava/lang/String;II)Z + .locals 3 + + add-int/lit8 v0, p2, -0x1 + + if-ne p1, v0, :not_last + + const/4 v0, 0x1 + + return v0 + + :not_last + invoke-virtual {p0}, Ljava/lang/String;->length()I + + move-result v0 + + if-nez v0, :has_len + + const/4 v0, 0x0 + + return v0 + + :has_len + add-int/lit8 v1, v0, -0x1 + + invoke-virtual {p0, v1}, Ljava/lang/String;->charAt(I)C + + move-result v1 + + invoke-static {v1}, Ljava/lang/Character;->isWhitespace(C)Z + + move-result v1 + + return v1 +.end method diff --git a/patches/extension/radiant/WordLyricsTick.smali b/patches/extension/radiant/WordLyricsTick.smali new file mode 100644 index 0000000..2dca060 --- /dev/null +++ b/patches/extension/radiant/WordLyricsTick.smali @@ -0,0 +1,21 @@ +.class public final Lradiant/WordLyricsTick; +.super Ljava/lang/Object; +.implements Ljava/lang/Runnable; + +# 50ms clock tick driver for word level lyrics highlighting + +.method public constructor ()V + .locals 0 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + return-void +.end method + +.method public run()V + .locals 0 + + invoke-static {}, Lradiant/WordLyrics;->tick()V + + return-void +.end method diff --git a/patches/lyrics-rl-api-isrc.patch b/patches/lyrics-rl-api-isrc.patch new file mode 100644 index 0000000..0d53720 --- /dev/null +++ b/patches/lyrics-rl-api-isrc.patch @@ -0,0 +1,26 @@ +--- a/cg0/h.smali ++++ b/cg0/h.smali +@@ -363,6 +363,23 @@ + .method public static final d(Lcom/tidal/sdk/tidalapi/generated/models/TracksResourceObject;Ljava/util/ArrayList;Ljava/util/ArrayList;Ljava/util/ArrayList;Ljava/util/ArrayList;Ljava/util/ArrayList;Ljava/util/ArrayList;Ljava/util/Map;)Lcom/tidal/android/tidalapi/domain/model/o; + .locals 23 + ++ invoke-virtual/range {p0 .. p0}, Lcom/tidal/sdk/tidalapi/generated/models/TracksResourceObject;->getAttributes()Lcom/tidal/sdk/tidalapi/generated/models/TracksAttributes; ++ ++ move-result-object v1 ++ ++ if-eqz v1, :rl_isrc_skip ++ ++ invoke-virtual/range {p0 .. p0}, Lcom/tidal/sdk/tidalapi/generated/models/TracksResourceObject;->getId()Ljava/lang/String; ++ ++ move-result-object v0 ++ ++ invoke-virtual {v1}, Lcom/tidal/sdk/tidalapi/generated/models/TracksAttributes;->getIsrc()Ljava/lang/String; ++ ++ move-result-object v1 ++ ++ invoke-static {v0, v1}, Lradiant/IsrcCache;->put(Ljava/lang/String;Ljava/lang/String;)V ++ ++ :rl_isrc_skip + move-object/from16 v0, p4 + + move-object/from16 v1, p7 diff --git a/patches/lyrics-rl-api-observer.patch b/patches/lyrics-rl-api-observer.patch index 883b24b..5985e39 100644 --- a/patches/lyrics-rl-api-observer.patch +++ b/patches/lyrics-rl-api-observer.patch @@ -1,14 +1,23 @@ --- a/com/tidal/android/feature/playerscreen/ui/PlayerViewModel$observeLyricsProgress$1$a.smali +++ b/com/tidal/android/feature/playerscreen/ui/PlayerViewModel$observeLyricsProgress$1$a.smali -@@ -191,6 +191,13 @@ +@@ -58,6 +58,8 @@ + .line 5 + int-to-long p1, p1 + ++ invoke-static {p1, p2}, Lradiant/WordLyrics;->onProgress(J)V ++ + .line 6 + iget-object v0, p0, Lcom/tidal/android/feature/playerscreen/ui/PlayerViewModel$observeLyricsProgress$1$a;->a:Lcom/tidal/android/feature/playerscreen/ui/PlayerViewModel; + +@@ -191,6 +193,13 @@ .line 67 :cond_3 -+ sget-boolean v6, Lradiant/RLAPILyricsHook;->isRlState:Z # read RL flag ++ sget-boolean v6, Lradiant/RLAPILyricsHook;->isRlState:Z + -+ if-eqz v6, :radiant_skip # skip if not RL ++ if-eqz v6, :radiant_skip + -+ const/4 v5, -0x1 # RL no-match default = -1 ++ const/4 v5, -0x1 + + :radiant_skip if-gez v4, :cond_4 diff --git a/patches/lyrics-rl-api-word.patch b/patches/lyrics-rl-api-word.patch new file mode 100644 index 0000000..9c7c459 --- /dev/null +++ b/patches/lyrics-rl-api-word.patch @@ -0,0 +1,56 @@ +--- a/com/tidal/android/feature/playerscreen/ui/composables/f2.smali ++++ b/com/tidal/android/feature/playerscreen/ui/composables/f2.smali +@@ -87,7 +87,7 @@ + + # virtual methods + .method public final invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +- .locals 28 ++ .locals 40 + + .line 1 + move-object/from16 v0, p0 +@@ -313,6 +313,8 @@ + .line 100 + check-cast v1, Lcom/tidal/android/feature/playerscreen/ui/f; + ++ move/16 v33, v2 ++ + .line 101 + .line 102 + const v3, 0x6b82e849 +@@ -801,6 +803,26 @@ + .line 331 + iget-object v3, v1, Lcom/tidal/android/feature/playerscreen/ui/f;->a:Ljava/lang/String; + ++ iget v4, v0, Lcom/tidal/android/feature/playerscreen/ui/composables/f2;->b:I ++ ++ move/16 v34, v4 ++ ++ iget-object v4, v1, Lcom/tidal/android/feature/playerscreen/ui/f;->a:Ljava/lang/String; ++ ++ move-object/16 v35, v4 ++ ++ iget-wide v4, v0, Lcom/tidal/android/feature/playerscreen/ui/composables/f2;->d:J ++ ++ move-wide/16 v36, v4 ++ ++ iget-wide v4, v0, Lcom/tidal/android/feature/playerscreen/ui/composables/f2;->e:J ++ ++ move-wide/16 v38, v4 ++ ++ invoke-static/range {v33 .. v39}, Lradiant/WordLyrics;->render(IILjava/lang/String;JJ)Landroidx/compose/ui/text/AnnotatedString; ++ ++ move-result-object v3 ++ + .line 332 + .line 333 + invoke-interface {v2}, Landroidx/compose/runtime/State;->getValue()Ljava/lang/Object; +@@ -898,7 +920,7 @@ + + .line 377 + .line 378 +- invoke-static/range {v3 .. v27}, Landroidx/compose/material3/TextKt;->Text--4IGK_g(Ljava/lang/String;Landroidx/compose/ui/Modifier;JJLandroidx/compose/ui/text/font/FontStyle;Landroidx/compose/ui/text/font/FontWeight;Landroidx/compose/ui/text/font/FontFamily;JLandroidx/compose/ui/text/style/TextDecoration;Landroidx/compose/ui/text/style/TextAlign;JIZIILam0/l;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/runtime/Composer;III)V ++ invoke-static/range {v3 .. v27}, Landroidx/compose/material3/TextKt;->Text--4IGK_g(Landroidx/compose/ui/text/AnnotatedString;Landroidx/compose/ui/Modifier;JJLandroidx/compose/ui/text/font/FontStyle;Landroidx/compose/ui/text/font/FontWeight;Landroidx/compose/ui/text/font/FontFamily;JLandroidx/compose/ui/text/style/TextDecoration;Landroidx/compose/ui/text/style/TextAlign;JIZILjava/util/Map;Lam0/l;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/runtime/Composer;III)V + + .line 379 + .line 380 diff --git a/patches/manifest.json b/patches/manifest.json index f1baae2..80429d6 100644 --- a/patches/manifest.json +++ b/patches/manifest.json @@ -32,11 +32,25 @@ "order": 20, "fileNames": [ "lyrics-rl-api.patch", - "lyrics-rl-api-observer.patch" + "lyrics-rl-api-observer.patch", + "lyrics-rl-api-isrc.patch" ], "title": "Radiant Lyrics API - WIP", - "description": "Use Radiant Lyrics API to fetch Lyrics (Higher quality & More providers)", - "default": false + "description": "Use Radiant Lyrics API to fetch Lyrics (Higher quality & More providers). Pick how the highlight moves: whole Line or word-by-word (Word).", + "default": false, + "defaultVariantIndex": 0, + "variants": [ + { + "title": "Line", + "fileNames": [] + }, + { + "title": "Word", + "fileNames": [ + "lyrics-rl-api-word.patch" + ] + } + ] }, { "id": "PlayerBackdrop",