From c46830698f849aca7983e3af84fab3e6a01a3b78 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:54:24 -0400 Subject: [PATCH] read embedded and sidecar lyrics --- .../AstraLibraryScannerModule.kt | 99 ++++++++++++++++++ modules/astra-library-scanner/index.ts | 23 ++++ src/lyrics/local.ts | 38 +++++++ src/lyrics/lyrics.ts | Bin 7801 -> 8534 bytes 4 files changed, 160 insertions(+) create mode 100644 src/lyrics/local.ts diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt index 9d4a98b..86eaf33 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt @@ -125,6 +125,21 @@ class AstraLibraryScannerModule : Module() { withContext(Dispatchers.IO) { readReplayGain(uri) } } + // Sidecar lyrics: a `.xlrc` (preferred) or `.lrc` next to the track. + // Returns { text, format } or null. Read fresh on demand so files authored after + // a scan are picked up. + AsyncFunction("readSidecarLyrics") Coroutine { uri: String -> + withContext(Dispatchers.IO) { readSidecarLyrics(uri) } + } + + // Embedded lyrics from container tags (Vorbis LYRICS/UNSYNCEDLYRICS for + // FLAC/Ogg/Opus, plus TXXX/MP4 lyric atoms), metadata-only (no PCM decode). + // Returns { text } or null. ID3 USLT/SYLT are not decoded by ExoPlayer and are + // intentionally out of scope here. + AsyncFunction("readEmbeddedLyrics") Coroutine { uri: String -> + withContext(Dispatchers.IO) { readEmbeddedLyrics(uri) } + } + Function("getArtworkDirPath") { artworkDir().absolutePath } @@ -284,6 +299,90 @@ class AstraLibraryScannerModule : Module() { return v / 256.0 + 5.0 } + // --------------------------------------------------------------------------- + // Lyrics (v2 local sources) + // --------------------------------------------------------------------------- + + /** + * Look for a sibling lyrics file next to the track: `.xlrc` first, then + * `.lrc`. Builds the sibling document URI by swapping the audio file's + * extension (the same tree grant covers it), so no directory listing is needed for + * the common case. Returns { text, format } or null. + */ + private fun readSidecarLyrics(uriStr: String): Map? { + return try { + val uri = Uri.parse(uriStr) + val docId = DocumentsContract.getDocumentId(uri) ?: return null + val stem = docId.substringBeforeLast('.', "") + if (stem.isEmpty()) return null + for ((ext, format) in listOf("xlrc" to "xlrc", "lrc" to "lrc")) { + val siblingUri = DocumentsContract.buildDocumentUriUsingTree(uri, "$stem.$ext") + val text = readTextOrNull(siblingUri) ?: continue + if (text.isBlank()) continue + return mapOf("text" to text, "format" to format) + } + null + } catch (_: Throwable) { + null + } + } + + private fun readTextOrNull(uri: Uri): String? { + return try { + requireContext().contentResolver.openInputStream(uri)?.use { input -> + input.bufferedReader(Charsets.UTF_8).readText() + } + } catch (_: Throwable) { + null + } + } + + private val embeddedLyricKeys = setOf("lyrics", "unsyncedlyrics", "unsynced_lyrics", "syncedlyrics") + + /** + * Read embedded lyrics from container metadata via ExoPlayer's MetadataRetriever. + * Covers Vorbis LYRICS/UNSYNCEDLYRICS (FLAC/Ogg/Opus), TXXX:LYRICS (ID3), and MP4 + * freeform lyric atoms — metadata only, no PCM decode. ID3 USLT/SYLT arrive as + * undecoded BinaryFrames and are out of scope. Returns { text } or null. + */ + private fun readEmbeddedLyrics(uriStr: String): Map? { + return try { + val mediaItem = MediaItem.fromUri(Uri.parse(uriStr)) + val trackGroups = MetadataRetriever.retrieveMetadata(requireContext(), mediaItem) + .get(metadataTimeoutMs, TimeUnit.MILLISECONDS) + + // Keep the longest lyric candidate (a full body beats a stray short tag). + var best: String? = null + fun consider(rawKey: String?, rawValue: String?) { + if (rawKey == null || rawValue == null) return + val key = rawKey.trim().lowercase() + val isLyricKey = key in embeddedLyricKeys || key.contains("lyric") || key == "©lyr" + if (!isLyricKey) return + val value = rawValue.trim() + if (value.isEmpty()) return + if (best == null || value.length > best!!.length) best = value + } + + for (g in 0 until trackGroups.length) { + val group = trackGroups.get(g) + for (f in 0 until group.length) { + val metadata = group.getFormat(f).metadata ?: continue + for (i in 0 until metadata.length()) { + when (val entry = metadata.get(i)) { + is TextInformationFrame -> if (entry.id == "TXXX") consider(entry.description, entry.value) + is VorbisComment -> consider(entry.key, entry.value) + is InternalFrame -> consider(entry.description, entry.text) + } + } + } + } + + best?.let { mapOf("text" to it) } + } catch (_: Throwable) { + null + } + } + // --------------------------------------------------------------------------- // Directory walk // --------------------------------------------------------------------------- diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index 06a9166..1f9a764 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -51,6 +51,17 @@ export interface ReplayGainTags { albumPeak: number | null; } +/** A `.xlrc`/`.lrc` file found next to the track. */ +export interface SidecarLyrics { + text: string; + format: 'xlrc' | 'lrc'; +} + +/** Embedded lyrics text read from container tags. */ +export interface EmbeddedLyrics { + text: string; +} + export interface ScanProgressEvent { phase: 'discovering'; found: number; @@ -85,6 +96,18 @@ declare class AstraLibraryScannerModuleType extends NativeModule; + /** + * Look for a sibling lyrics file next to the track (`.xlrc` preferred, then + * `.lrc`) and return its text + format, or null. Read fresh on demand so + * files authored after a scan are picked up. + */ + readSidecarLyrics(uri: string): Promise; + /** + * Read embedded lyrics from container tags (Vorbis LYRICS/UNSYNCEDLYRICS for + * FLAC/Ogg/Opus, TXXX:LYRICS, MP4 lyric atoms) without decoding audio. Null when + * absent. ID3 USLT/SYLT are not covered (ExoPlayer leaves them undecoded). + */ + readEmbeddedLyrics(uri: string): Promise; getArtworkDirPath(): string; getArtworkThumbDirPath(): string; ensureArtworkThumbnails(hashes: string[]): Promise; diff --git a/src/lyrics/local.ts b/src/lyrics/local.ts new file mode 100644 index 0000000..2ca3fdf --- /dev/null +++ b/src/lyrics/local.ts @@ -0,0 +1,38 @@ +// Local lyrics sources (v2) — sidecar files + embedded tags, read through the +// native scanner module and parsed by the shared JS parser. Both are no-ops for +// non-local (remote / streaming) tracks. Sidecar wins over embedded, and both win +// over online lookup (see the orchestrator ordering in lyrics.ts). + +import { AstraLibraryScanner } from '../../modules/astra-library-scanner'; +import { parseLyricsText } from './parsing'; +import type { LyricsPayload } from './types'; + +function isLocalPath(path: string): boolean { + return path.startsWith('content://') || path.startsWith('file://'); +} + +/** Resolve a sibling `.xlrc`/`.lrc` next to a local track. */ +export async function resolveSidecarLyrics(trackPath: string): Promise { + if (!isLocalPath(trackPath)) return null; + try { + const sidecar = await AstraLibraryScanner.readSidecarLyrics(trackPath); + if (!sidecar?.text) return null; + const source = sidecar.format === 'xlrc' ? 'xlrc' : 'lrc'; + return parseLyricsText(sidecar.text, source, sidecar.format); + } catch { + return null; + } +} + +/** Resolve embedded lyrics (Vorbis LYRICS/etc.) from a local track's container. */ +export async function resolveEmbeddedLyrics(trackPath: string): Promise { + if (!isLocalPath(trackPath)) return null; + try { + const embedded = await AstraLibraryScanner.readEmbeddedLyrics(trackPath); + if (!embedded?.text) return null; + // Parse as LRC so timestamped tags become synced; plain text stays plain. + return parseLyricsText(embedded.text, 'embedded', 'lrc'); + } catch { + return null; + } +} diff --git a/src/lyrics/lyrics.ts b/src/lyrics/lyrics.ts index d1b59fb343231933c816f541b21b54096ee74396..9d02f7e12c3fe60cc305c13339f3add8023c2369 100644 GIT binary patch delta 663 zcmZva&uSDw5XR9UNG={kF$wA?2s;Do%*E5LxfpX0Vo=w}?y;x4W~WKd^x9n$cUi*v z4(o#md-oYUc^0oefj2vu85GH-p{lz6e7~yS&%QtZBDeOJaOAByqr);IrAQquoR+@d zxkp-&6t3~&9J#Y4dP!=m6lOEon%`b+18Iu(Bcfz(oJ^DOprSd0Ew~`C6Y@01&rhF2 zX`w>-5=9Yv*JE2ArllTcCG$}@bU2m25oh2|?|Mjz@hRWcJ?#KVxOb3|=k91^&8 z6_)TUxb_3AzwX`IZNvShwHhE7#uLtm+naB92iI2tPzM++rXJCI>oV4xqW`ak