mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 21:16:09 +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) }
|
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") {
|
Function("getArtworkDirPath") {
|
||||||
artworkDir().absolutePath
|
artworkDir().absolutePath
|
||||||
}
|
}
|
||||||
@@ -284,6 +299,90 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
return v / 256.0 + 5.0
|
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
|
// Directory walk
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -51,6 +51,17 @@ export interface ReplayGainTags {
|
|||||||
albumPeak: number | null;
|
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 {
|
export interface ScanProgressEvent {
|
||||||
phase: 'discovering';
|
phase: 'discovering';
|
||||||
found: number;
|
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).
|
* are null when the tag is absent; the whole call is cheap (metadata only).
|
||||||
*/
|
*/
|
||||||
readReplayGain(uri: string): Promise<ReplayGainTags>;
|
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;
|
getArtworkDirPath(): string;
|
||||||
getArtworkThumbDirPath(): string;
|
getArtworkThumbDirPath(): string;
|
||||||
ensureArtworkThumbnails(hashes: string[]): Promise<number>;
|
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