import 'package:spotiflac_android/utils/lyrics_parser.dart'; /// Visual order is separate from the chronological timeline: a backing part /// stays directly under its lead, including when another singer starts first. class LyricDisplayLayout { final List lineOrder; final List rowForLine; final List leadForLine; final List focusForLine; LyricDisplayLayout(List lines) : lineOrder = [], rowForLine = List.filled(lines.length, 0), leadForLine = List.generate(lines.length, (index) => index), focusForLine = List.filled(lines.length, 0) { final leads = { for (var i = 0; i < lines.length; i++) if (!lines[i].isBackground && lines[i].vocalGroup != null) lines[i].vocalGroup!: i, }; final backing = >{}; int? previousLead; for (var i = 0; i < lines.length; i++) { final line = lines[i]; if (!line.isBackground) { if (line.text.isNotEmpty) previousLead = i; continue; } final lead = line.vocalGroup == null ? previousLead : leads[line.vocalGroup]; if (lead == null) continue; leadForLine[i] = lead; backing.putIfAbsent(lead, () => []).add(i); } for (var i = 0; i < lines.length; i++) { if (leadForLine[i] != i) continue; lineOrder.add(i); lineOrder.addAll(backing[i] ?? const []); } for (var row = 0; row < lineOrder.length; row++) { rowForLine[lineOrder[row]] = row; } var latestLead = 0; for (var i = 0; i < lines.length; i++) { if (!lines[i].isBackground) latestLead = i; // A delayed echo must not scroll backwards after the next lead starts. focusForLine[i] = leadForLine[i] > latestLead ? leadForLine[i] : latestLead; } } } /// Keep overlapping vocal parts lit until their explicit end. Untimed lines /// retain the normal single-line behavior instead of guessing a vocal length. Set activeLyricIndices( List lines, Duration position, int currentIndex, ) { final active = {}; for (var i = 0; i <= currentIndex; i++) { final line = lines[i]; final timedVoice = (i < currentIndex || line.voice != null || line.isBackground) && line.text.isNotEmpty && line.end != null; // Known ends also apply to the most recently started singer, so a short // reply does not stay lit over a longer lead vocal. if (timedVoice ? line.end! > position : i == currentIndex) active.add(i); } return active; } /// Inserts empty, bounded rows for instrumental countdowns in the player. /// Unmarked gaps in plain LRC cannot be distinguished from held vocals: only /// use explicit empty timestamps or known line/word ends, never an estimate. List lyricsTimelineWithGaps(List lines) { const minimumGap = Duration(seconds: 3); final timeline = []; Duration? gapStart; Duration? latestVocalEnd; for (final line in lines) { if (line.text.trim().isEmpty) { gapStart ??= line.time; continue; } var start = timeline.isEmpty ? Duration.zero : gapStart; // A second vocal line may overlap a previous, longer one. if (start != null && latestVocalEnd != null && start < latestVocalEnd) { start = latestVocalEnd; } if (start != null && line.time - start >= minimumGap) { timeline.add(LyricLine(time: start, end: line.time, text: '')); } timeline.add(line); final lastWord = line.words.lastOrNull; var end = line.end; if (lastWord != null) { if (lastWord.end != null && (end == null || lastWord.end! > end)) { end = lastWord.end; } if (end != null && end < lastWord.time) end = null; } if (end != null && end < line.time) end = null; gapStart = end; if (end != null && (latestVocalEnd == null || end > latestVocalEnd)) { latestVocalEnd = end; } } // No following vocal means no countdown: trailing blank timestamps are // intentionally omitted, and credits stay after the last sung line. return List.unmodifiable(timeline); }