From 77d0ac4fce2c7d10de0c599841002499ad14c7ee Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 14:26:11 +0700 Subject: [PATCH] fix: prioritize local embedded lyrics before online fetch --- lib/providers/playback_provider.dart | 169 +++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index d9c698c4..60731989 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -1727,6 +1727,16 @@ class PlaybackController extends Notifier { state = state.copyWith(lyricsLoading: true, clearLyrics: true); try { + final localLyrics = await _tryLoadLocalLyricsForItem(item); + if (generation != _lyricsGeneration) return; + if (localLyrics != null) { + _log.d( + 'Lyrics loaded from local source: ${localLyrics.source} (sync=${localLyrics.syncType}, lines=${localLyrics.lines.length}, wordSync=${localLyrics.isWordSynced})', + ); + state = state.copyWith(lyricsLoading: false, lyrics: localLyrics); + return; + } + final result = await PlatformBridge.fetchLyrics( item.id, item.title, @@ -1808,6 +1818,75 @@ class PlaybackController extends Notifier { await _fetchLyricsForItem(item); } + Future _tryLoadLocalLyricsForItem(PlaybackItem item) async { + final localPath = _resolveLocalLyricsLookupPath(item); + if (localPath == null) return null; + + try { + final result = await PlatformBridge.getLyricsLRCWithSource( + item.id, + item.title, + item.artist, + filePath: localPath, + durationMs: item.durationMs, + ); + return _lyricsDataFromLrcLookupResult(result); + } catch (e) { + _log.d('Local lyrics lookup skipped for ${item.id}: $e'); + return null; + } + } + + String? _resolveLocalLyricsLookupPath(PlaybackItem item) { + if (!item.isLocal) return null; + final sourceUri = item.sourceUri.trim(); + if (sourceUri.isEmpty) return null; + if (sourceUri.startsWith('content://')) return sourceUri; + if (sourceUri.startsWith('/')) return sourceUri; + + final uri = Uri.tryParse(sourceUri); + if (uri == null) return null; + if (uri.scheme == 'content') return sourceUri; + if (uri.scheme == 'file') { + try { + return uri.toFilePath(); + } catch (_) { + return uri.path.isNotEmpty ? uri.path : null; + } + } + return null; + } + + LyricsData? _lyricsDataFromLrcLookupResult(Map result) { + final rawLyrics = (result['lyrics'] as String?)?.trim() ?? ''; + final sourceRaw = (result['source'] as String?)?.trim() ?? ''; + final syncTypeRaw = (result['sync_type'] as String?)?.trim().toUpperCase(); + final instrumental = + result['instrumental'] == true || rawLyrics == '[instrumental:true]'; + final source = sourceRaw.isNotEmpty ? sourceRaw : 'Embedded'; + + if (instrumental) { + final syncType = syncTypeRaw == 'LINE_SYNCED' || syncTypeRaw == 'UNSYNCED' + ? syncTypeRaw! + : 'UNSYNCED'; + return LyricsData(instrumental: true, source: source, syncType: syncType); + } + if (rawLyrics.isEmpty) return null; + + final parsed = _parseLrcLyrics(rawLyrics); + if (parsed.lines.isEmpty) return null; + final effectiveSyncType = parsed.hasTimedLines ? 'LINE_SYNCED' : 'UNSYNCED'; + final syncType = syncTypeRaw == 'LINE_SYNCED' || syncTypeRaw == 'UNSYNCED' + ? syncTypeRaw! + : effectiveSyncType; + return LyricsData( + lines: parsed.lines, + syncType: syncType, + source: source, + isWordSynced: parsed.hasWordSync, + ); + } + /// Parse raw lines from Go backend into [LyricsLine] list. static ({List lines, bool hasWordSync}) _parseLyricsLines( List rawLines, @@ -1857,6 +1936,96 @@ class PlaybackController extends Notifier { return (lines: lines, hasWordSync: hasAnyWordSync); } + static final RegExp _lrcLineTimestampPattern = RegExp( + r'\[(\d{2}):(\d{2})\.(\d{2,3})\]', + ); + static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$'); + static final RegExp _lrcSpeakerPrefixPattern = RegExp( + r'^(v1|v2):\s*', + caseSensitive: false, + ); + + static ({List lines, bool hasWordSync, bool hasTimedLines}) + _parseLrcLyrics(String lrcText) { + final timed = []; + final unsyncedTexts = []; + var hasAnyWordSync = false; + + for (final rawLine in lrcText.split('\n')) { + final trimmed = rawLine.trim(); + if (trimmed.isEmpty || trimmed == '[instrumental:true]') continue; + + final timestamps = _lrcLineTimestampPattern.allMatches(trimmed).toList(); + if (timestamps.isEmpty) { + if (_lrcMetadataPattern.hasMatch(trimmed)) continue; + final unsynced = _stripInlineTimestamps( + trimmed.replaceFirst(_lrcSpeakerPrefixPattern, ''), + ); + if (unsynced.isNotEmpty) { + unsyncedTexts.add(unsynced); + } + continue; + } + + final timedText = trimmed + .replaceAll(_lrcLineTimestampPattern, '') + .replaceFirst(_lrcSpeakerPrefixPattern, '') + .trim(); + final displayText = _stripInlineTimestamps(timedText); + if (displayText.isEmpty) continue; + + for (final match in timestamps) { + final startMs = _lrcInlineToMs( + match.group(1)!, + match.group(2)!, + match.group(3)!, + ); + final words = _parseInlineWordTimestamps(timedText, startMs); + if (words.isNotEmpty) hasAnyWordSync = true; + timed.add( + LyricsLine( + startMs: startMs, + endMs: startMs + 5000, + text: displayText, + words: words, + ), + ); + } + } + + if (timed.isNotEmpty) { + timed.sort((a, b) => a.startMs.compareTo(b.startMs)); + final normalized = []; + for (var i = 0; i < timed.length; i++) { + final current = timed[i]; + final nextStart = i + 1 < timed.length + ? timed[i + 1].startMs + : current.startMs + 5000; + final endMs = nextStart > current.startMs + ? nextStart + : current.startMs + 5000; + normalized.add( + LyricsLine( + startMs: current.startMs, + endMs: endMs, + text: current.text, + words: current.words, + ), + ); + } + return ( + lines: normalized, + hasWordSync: hasAnyWordSync, + hasTimedLines: true, + ); + } + + final unsynced = unsyncedTexts + .map((text) => LyricsLine(startMs: 0, endMs: 0, text: text)) + .toList(growable: false); + return (lines: unsynced, hasWordSync: false, hasTimedLines: false); + } + /// Parse inline `` timestamps in enhanced LRC word-by-word format. static List _parseInlineWordTimestamps( String text,