mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 13:09:46 +02:00
read embedded and sidecar lyrics
This commit is contained in:
+99
@@ -125,6 +125,21 @@ class AstraLibraryScannerModule : Module() {
|
||||
withContext(Dispatchers.IO) { readReplayGain(uri) }
|
||||
}
|
||||
|
||||
// Sidecar lyrics: a `<name>.xlrc` (preferred) or `<name>.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: `<name>.xlrc` first, then
|
||||
* `<name>.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<String, Any?>? {
|
||||
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<String, Any?>? {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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<AstraLibrarySca
|
||||
* are null when the tag is absent; the whole call is cheap (metadata only).
|
||||
*/
|
||||
readReplayGain(uri: string): Promise<ReplayGainTags>;
|
||||
/**
|
||||
* Look for a sibling lyrics file next to the track (`<name>.xlrc` preferred, then
|
||||
* `<name>.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<SidecarLyrics | null>;
|
||||
/**
|
||||
* 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<EmbeddedLyrics | null>;
|
||||
getArtworkDirPath(): string;
|
||||
getArtworkThumbDirPath(): string;
|
||||
ensureArtworkThumbnails(hashes: string[]): Promise<number>;
|
||||
|
||||
@@ -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 `<name>.xlrc`/`.lrc` next to a local track. */
|
||||
export async function resolveSidecarLyrics(trackPath: string): Promise<LyricsPayload | null> {
|
||||
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<LyricsPayload | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user