import 'dart:convert'; import 'package:xml/xml.dart'; class LyricWord { final Duration time; final Duration? end; final String text; const LyricWord({required this.time, this.end, required this.text}); } class LyricVoice { final String id; final int index; final bool isGroup; const LyricVoice({ required this.id, required this.index, this.isGroup = false, }); } class LyricLine { final Duration time; final Duration? end; final String text; final List words; final String? romanization; final List romanizationWords; final String? translation; final LyricVoice? voice; final bool isBackground; const LyricLine({ required this.time, this.end, required this.text, this.words = const [], this.romanization, this.romanizationWords = const [], this.translation, this.voice, this.isBackground = false, }); bool get hasWordTiming => words.isNotEmpty; } class ParsedLyrics { final bool synced; final bool wordSynced; final List lines; final String plainText; final String? writers; final String? provider; const ParsedLyrics({ required this.synced, required this.wordSynced, required this.lines, required this.plainText, this.writers, this.provider, }); bool get isEmpty => lines.isEmpty && plainText.trim().isEmpty; static const ParsedLyrics empty = ParsedLyrics( synced: false, wordSynced: false, lines: [], plainText: '', ); } class LyricsParser { LyricsParser._(); // [mm:ss.xx] or [mm:ss.xxx] or [mm:ss] static final RegExp _lineTimeTag = RegExp( r'\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]', ); // inline word timestamp (enhanced LRC). static final RegExp _wordTimeTag = RegExp( r'<(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?>', ); static final RegExp _voicePrefix = RegExp( r'^(\s*(?:<\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?>\s*)*)(v[1-9]\d*):[ \t]*', caseSensitive: false, ); static final RegExp _backgroundLine = RegExp( r'^\[bg:(.*)\]$', caseSensitive: false, ); static (String, LyricVoice?) _vocalText(String content) { final match = _voicePrefix.firstMatch(content); if (match == null) return (content, null); final id = match.group(2)!.toLowerCase(); final number = int.tryParse(id.substring(1)); return ( content.replaceRange( 0, match.end, match.group(1)!.replaceAll(RegExp(r'\s+'), ''), ), LyricVoice(id: id, index: (number ?? 1) - 1), ); } // ID tags such as [ti:..], [ar:..], [offset:..]. static final RegExp _idTag = RegExp( r'^\[(ti|ar|al|by|offset|length|re|ve|tool|au|la|encoder|instrumental|x-[a-z0-9_-]+):.*\]$', caseSensitive: false, ); static final RegExp _supplementTag = RegExp( r'^\[x-(romaji-words|romaji|translation):(\d+):([^\]]*)\]$', caseSensitive: false, ); static ParsedLyrics parse(String? raw) { final text = (raw ?? '').trim(); if (text.isEmpty) return ParsedLyrics.empty; if (_looksLikeTtml(text)) { final ttml = _parseTtml(text); if (ttml != null && ttml.lines.isNotEmpty) { return _withCredits(text, ttml); } } return _withCredits(text, _parseLrcOrPlain(text)); } static ParsedLyrics _withCredits(String raw, ParsedLyrics lyrics) { String? tag(String name) => RegExp( '^\\[$name:([^\\]\\r\\n]*)\\]\\s*\$', multiLine: true, caseSensitive: false, ).firstMatch(raw)?.group(1)?.trim(); var writers = tag('au'); var provider = tag('x-provider'); final credit = tag('by') ?? ''; provider ??= RegExp(r'\bvia\s+([^\(]+)', caseSensitive: false) .firstMatch(credit) ?.group(1) ?.trim() .replaceFirst(RegExp(r'\s+API$', caseSensitive: false), ''); provider ??= RegExp( r'\(source:\s*([^\)]+)\)', caseSensitive: false, ).firstMatch(credit)?.group(1)?.trim(); provider = provider?.replaceFirst( RegExp(r'^extension:', caseSensitive: false), '', ); if (_looksLikeTtml(raw)) { try { final doc = XmlDocument.parse(raw); final names = doc.descendants .whereType() .where((node) => node.name.local == 'songwriter') .map((node) => node.innerText.trim()) .where((name) => name.isNotEmpty) .toSet(); if (names.isNotEmpty) writers = names.join(', '); } on XmlParserException { // Malformed optional credits must not prevent lyric playback. } } var lines = lyrics.lines; if (lines.isNotEmpty) { final trailer = RegExp( r'^Written\s+by\s*:\s*(.+)$', caseSensitive: false, ).firstMatch(lines.last.text.trim()); if (trailer != null) { writers ??= trailer.group(1)?.trim(); lines = lines.sublist(0, lines.length - 1); } } return ParsedLyrics( synced: lyrics.synced, wordSynced: lyrics.wordSynced, lines: lines, plainText: identical(lines, lyrics.lines) ? lyrics.plainText : lines.map((line) => line.text).join('\n'), writers: writers?.isNotEmpty == true ? writers : null, provider: provider?.isNotEmpty == true ? provider : null, ); } static bool _looksLikeTtml(String text) { final head = text.trimLeft(); return head.startsWith('[]; final plainBuffer = []; final romanization = {}; final romanizationWords = >{}; final translation = {}; var sawTimestamp = false; var sawWordTiming = false; var offsetMs = 0; for (final rawLine in rawLines) { var line = rawLine.trimRight(); if (line.trim().isEmpty) continue; final background = _backgroundLine.firstMatch(line.trim()); if (background != null) line = background.group(1)!.trim(); final supplement = _supplementTag.firstMatch(line.trim()); if (supplement != null) { final time = int.tryParse(supplement.group(2)!); try { final value = utf8.decode(base64.decode(supplement.group(3)!)).trim(); if (time != null && value.isNotEmpty) { final kind = supplement.group(1)!.toLowerCase(); if (kind == 'romaji-words') { final words = _parseRomanizationWords(value); if (words.isNotEmpty) { romanizationWords.putIfAbsent(time, () => words); } } else { final target = kind == 'romaji' ? romanization : translation; target.putIfAbsent(time, () => value); } } } on FormatException { // Optional corrupt metadata must not hide the original lyrics. } continue; } // Capture [offset:] for timing correction, drop other ID tags. final idMatch = _idTag.firstMatch(line.trim()); if (idMatch != null) { final key = idMatch.group(1)!.toLowerCase(); if (key == 'offset') { final value = line .substring(line.indexOf(':') + 1) .replaceAll(']', '') .trim(); offsetMs = int.tryParse(value) ?? 0; } continue; } final timeMatches = _lineTimeTag.allMatches(line).toList(); if (timeMatches.isEmpty) { final (content, voice) = _vocalText(line.trim()); final clean = content.replaceAll(_wordTimeTag, '').trim(); plainBuffer.add(clean); if (background != null && parsed.isNotEmpty && clean.isNotEmpty) { final words = _parseWords(content); final previous = parsed.last; parsed.add( LyricLine( time: words.firstOrNull?.time ?? previous.time, end: words.lastOrNull?.end ?? previous.end, text: clean, words: words, voice: voice ?? previous.voice, isBackground: true, ), ); sawWordTiming |= words.isNotEmpty; } continue; } sawTimestamp = true; // Strip leading line timestamps to obtain the lyric content. final lastTag = timeMatches.last; final (content, voice) = _vocalText(line.substring(lastTag.end).trim()); // Enhanced LRC word timestamps inside the content. final words = _parseWords(content); if (words.isNotEmpty) sawWordTiming = true; final cleanContent = content.replaceAll(_wordTimeTag, '').trim(); plainBuffer.add(cleanContent); // A line can have multiple timestamps (repeated chorus). for (final tm in timeMatches) { final d = _toDuration(tm.group(1), tm.group(2), tm.group(3)); if (d == null) continue; parsed.add( LyricLine( time: d, end: words.lastOrNull?.end, text: cleanContent, words: words, voice: voice, isBackground: background != null, ), ); } } if (!sawTimestamp) { // Pure plain text. return ParsedLyrics( synced: false, wordSynced: false, lines: const [], plainText: plainBuffer.where((l) => l.isNotEmpty).join('\n'), ); } _sortLines(parsed); final alignedRomanization = _alignSupplements(parsed, romanization); final alignedRomanizationWords = _alignSupplements( parsed, romanizationWords, ); final alignedTranslation = _alignSupplements(parsed, translation); final adjusted = offsetMs == 0 && alignedRomanization.isEmpty && alignedTranslation.isEmpty ? parsed : parsed.map((l) { final romanization = alignedRomanization[l.time.inMilliseconds]; final words = alignedRomanizationWords[l.time.inMilliseconds] ?? const []; // Keep readable text when optional timings are incomplete or // belong to a different revision of the transliteration. final validWords = words.map((word) => word.text).join() == romanization ? words : const []; return LyricLine( time: _shift(l.time, offsetMs), end: l.end == null ? null : _shift(l.end!, offsetMs), text: l.text, voice: l.voice, isBackground: l.isBackground, romanization: romanization, romanizationWords: _shiftWords(validWords, offsetMs), translation: alignedTranslation[l.time.inMilliseconds], words: _shiftWords(l.words, offsetMs), ); }).toList(); return ParsedLyrics( synced: true, wordSynced: sawWordTiming, lines: adjusted, plainText: plainBuffer.where((l) => l.isNotEmpty).join('\n'), ); } /// Older files may have centisecond line times alongside millisecond /// supplement times. Resolve each supplement to one nearest line, keeping /// exact matches when multiple metadata entries compete for that line. static Map _alignSupplements( List lines, Map supplements, ) { final matched = {}; for (final entry in supplements.entries) { var lo = 0; var hi = lines.length; while (lo < hi) { final mid = (lo + hi) >> 1; if (lines[mid].time.inMilliseconds < entry.key) { lo = mid + 1; } else { hi = mid; } } int? nearest; var distance = 11; for (final index in [lo - 1, lo]) { if (index < 0 || index >= lines.length) continue; final time = lines[index].time.inMilliseconds; final delta = (time - entry.key).abs(); if (delta < distance) { nearest = time; distance = delta; } } if (nearest == null) continue; final previous = matched[nearest]; if (previous == null || distance < previous.$1) { matched[nearest] = (distance, entry.value); } } return matched.map((time, match) => MapEntry(time, match.$2)); } static List _parseRomanizationWords(String raw) { final value = jsonDecode(raw); if (value is! List) return const []; final words = []; for (final item in value) { if (item case { 'text': final String text, 'startTimeMs': final int start, 'endTimeMs': final int end, }) { if (text.trim().isEmpty || start < 0 || end < start || (words.isNotEmpty && words.last.time.inMilliseconds > start)) { return const []; } words.add( LyricWord( time: Duration(milliseconds: start), end: Duration(milliseconds: end), text: text, ), ); } else { return const []; } } return words; } static List _shiftWords(List words, int offsetMs) { if (offsetMs == 0 || words.isEmpty) return words; return words .map( (word) => LyricWord( time: _shift(word.time, offsetMs), end: word.end == null ? null : _shift(word.end!, offsetMs), text: word.text, ), ) .toList(growable: false); } static Duration _shift(Duration d, int offsetMs) { // LRC offset: positive value shifts lyrics earlier. final ms = d.inMilliseconds - offsetMs; return Duration(milliseconds: ms < 0 ? 0 : ms); } static List _parseWords(String content) { final matches = _wordTimeTag.allMatches(content).toList(); if (matches.isEmpty) return const []; final words = []; for (var i = 0; i < matches.length; i++) { final m = matches[i]; final d = _toDuration(m.group(1), m.group(2), m.group(3)); if (d == null) continue; final start = m.end; final end = i + 1 < matches.length ? matches[i + 1].start : content.length; final word = content.substring(start, end); if (word.trim().isEmpty) { final previous = words.lastOrNull; if (previous != null && previous.end == null && d >= previous.time) { words[words.length - 1] = LyricWord( time: previous.time, end: d, text: previous.text, ); } continue; } words.add(LyricWord(time: d, text: word)); } return words; } static ParsedLyrics? _parseTtml(String text) { try { final doc = XmlDocument.parse(text); const metadataNamespace = 'http://www.w3.org/ns/ttml#metadata'; final elements = doc.descendants.whereType().toList(); final paragraphs = elements.where((node) => node.name.local == 'p'); if (paragraphs.isEmpty) return null; final voices = {}; var individualIndex = 0; for (final agent in elements.where( (node) => node.name.local == 'agent' && node.name.namespaceUri == metadataNamespace, )) { final id = agent.getAttribute( 'id', namespaceUri: 'http://www.w3.org/XML/1998/namespace', ); if (id == null || id.isEmpty) continue; final group = agent.getAttribute('type') == 'group'; voices[id] = LyricVoice(id: id, index: individualIndex, isGroup: group); if (!group) individualIndex++; } LyricVoice? voiceOf(XmlElement element, LyricVoice? inherited) { final id = element.getAttribute( 'agent', namespaceUri: metadataNamespace, ); if (id == null || id.isEmpty) return inherited; return voices.putIfAbsent( id, () => LyricVoice(id: id, index: individualIndex++), ); } final lines = []; final plain = []; var sawWords = false; for (final p in paragraphs) { final begin = _parseClock(p.getAttribute('begin')); final end = _parseClock(p.getAttribute('end')); LyricVoice? inheritedVoice; for (final ancestor in p.ancestors.whereType().toList().reversed) { inheritedVoice = voiceOf(ancestor, inheritedVoice); } final runs = <_TtmlVocalRun>[]; void visit( XmlNode node, LyricVoice? voice, bool background, Duration? wordBegin, Duration? wordEnd, ) { if (node is XmlText) { if (node.value.trim().isEmpty && runs.isEmpty) return; var run = runs .where( (run) => run.voice?.id == voice?.id && run.background == background, ) .firstOrNull; if (run == null) { if (node.value.trim().isEmpty) return; run = _TtmlVocalRun(voice, background); runs.add(run); } run.add(node.value, wordBegin, wordEnd); } else if (node is XmlElement) { final nextVoice = voiceOf(node, voice); final nextBackground = background || node.getAttribute('role', namespaceUri: metadataNamespace) == 'x-bg'; final spanBegin = node == p ? null : _parseClock(node.getAttribute('begin')); final spanEnd = node == p ? null : _parseClock(node.getAttribute('end')); for (final child in node.children) { visit( child, nextVoice, nextBackground, spanBegin ?? wordBegin, spanEnd ?? wordEnd, ); } } } visit(p, inheritedVoice, false, null, null); for (final run in runs) { final lineText = run.text; if (lineText.isEmpty) continue; plain.add(lineText); final words = run.words; final lineBegin = runs.length == 1 ? begin : words.firstOrNull?.time ?? begin; if (lineBegin == null) continue; sawWords |= words.isNotEmpty; lines.add( LyricLine( time: lineBegin, end: runs.length == 1 ? end : words.lastOrNull?.end ?? end, text: lineText, words: words, voice: run.voice, isBackground: run.background, ), ); } } if (lines.isEmpty) { return ParsedLyrics( synced: false, wordSynced: false, lines: const [], plainText: plain.join('\n'), ); } _sortLines(lines); return ParsedLyrics( synced: true, wordSynced: sawWords, lines: lines, plainText: plain.where((l) => l.isNotEmpty).join('\n'), ); } catch (_) { return null; } } // TTML clock value: "mm:ss.fff", "hh:mm:ss.fff" or "12.5s". static Duration? _parseClock(String? value) { if (value == null || value.isEmpty) return null; final v = value.trim(); if (v.endsWith('s') && !v.contains(':')) { final seconds = double.tryParse(v.substring(0, v.length - 1)); if (seconds == null) return null; return Duration(milliseconds: (seconds * 1000).round()); } final parts = v.split(':'); try { if (parts.length == 3) { final h = int.parse(parts[0]); final m = int.parse(parts[1]); final s = double.parse(parts[2]); return Duration(hours: h, minutes: m, milliseconds: (s * 1000).round()); } else if (parts.length == 2) { final m = int.parse(parts[0]); final s = double.parse(parts[1]); return Duration(minutes: m, milliseconds: (s * 1000).round()); } } catch (_) { return null; } return null; } static int activeIndex(List lines, Duration position) { if (lines.isEmpty) return -1; var lo = 0; var hi = lines.length - 1; var result = -1; while (lo <= hi) { final mid = (lo + hi) >> 1; if (lines[mid].time <= position) { result = mid; lo = mid + 1; } else { hi = mid - 1; } } return result; } static void _sortLines(List lines) { // Simultaneous singers retain their document order. final order = {for (var i = 0; i < lines.length; i++) lines[i]: i}; lines.sort((a, b) { final timing = a.time.compareTo(b.time); return timing != 0 ? timing : order[a]!.compareTo(order[b]!); }); } } class _TtmlVocalRun { final LyricVoice? voice; final bool background; final List<(String, Duration?, Duration?)> _fragments = []; _TtmlVocalRun(this.voice, this.background); void add(String value, Duration? begin, Duration? end) { var text = value.replaceAll(RegExp(r'\s+'), ' '); if (_fragments.isEmpty || _fragments.last.$1.endsWith(' ')) { text = text.trimLeft(); } if (text.isEmpty) return; if (_fragments.isNotEmpty && (text.trim().isEmpty || (begin == _fragments.last.$2 && end == _fragments.last.$3))) { final previous = _fragments.removeLast(); _fragments.add(('${previous.$1}$text', previous.$2, previous.$3)); } else { _fragments.add((text, begin, end)); } } String get text => _fragments.map((fragment) => fragment.$1).join().trim(); List get words { // Never drop untimed text from a partially timed paragraph. if (_fragments.any((fragment) => fragment.$2 == null)) return const []; return [ for (var i = 0; i < _fragments.length; i++) LyricWord( time: _fragments[i].$2!, end: _fragments[i].$3 != null && _fragments[i].$3! >= _fragments[i].$2! ? _fragments[i].$3 : null, text: i == _fragments.length - 1 ? _fragments[i].$1.trimRight() : _fragments[i].$1, ), ]; } }