diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 88b9ac12..d9b8e5dd 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -6448,6 +6448,10 @@ "description": "Tab label for the lyrics view" }, "nowPlayingNoLyrics": "No lyrics in this file", + "nowPlayingInstrumental": "Instrumental", + "@nowPlayingInstrumental": { + "description": "Accessibility label for the three-dot countdown during an instrumental break before lyrics resume" + }, "nowPlayingLyricsLanguageOptions": "Lyrics language options", "@nowPlayingLyricsLanguageOptions": { "description": "Tooltip for the player menu that shows or hides existing lyric pronunciation and translation" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 75f8c16f..97d26aac 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -5699,6 +5699,7 @@ "setupStorageGranted": "Izin Penyimpanan Diberikan!", "downloadAllowLocalNetwork": "Allow Local Network Access", "nowPlayingNoLyrics": "No lyrics in this file", + "nowPlayingInstrumental": "Jeda instrumental", "nowPlayingLyricsLanguageOptions": "Opsi bahasa lirik", "nowPlayingHidePronunciation": "Sembunyikan Pelafalan", "nowPlayingShowPronunciation": "Tampilkan Pelafalan", diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index ab01a66b..38439d04 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -26,6 +26,7 @@ import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/isrc_utils.dart'; import 'package:spotiflac_android/utils/lyrics_parser.dart'; +import 'package:spotiflac_android/utils/lyrics_timeline.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart'; @@ -33,6 +34,7 @@ import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/aligned_lyric_pronunciation.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/audio_output_button.dart'; +import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/overflow_marquee.dart'; import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; @@ -2814,6 +2816,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { ProviderSubscription? _loadingSubscription; Timer? _lineBoundaryTimer; Timer? _userScrollIdleTimer; + late List _lines; late List _lineKeys; int _active = -1; Duration _activeTransitionPosition = Duration.zero; @@ -2861,8 +2864,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _hasStarted = false; _lineExtents = null; _lineLayoutKey = null; + _lines = lyricsTimelineWithGaps(widget.lyrics.lines); _lineKeys = List.generate( - widget.lyrics.lines.length, + _lines.length, (index) => GlobalKey(debugLabel: 'lyric-line-$index'), growable: false, ); @@ -2930,7 +2934,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _hasStarted || playback?.playing == true || position > Duration.zero; if (!_hasStarted) return -1; } - return LyricsParser.activeIndex(widget.lyrics.lines, position); + return LyricsParser.activeIndex(_lines, position); } void _setActiveLine(int active, {required Duration position}) { @@ -2948,7 +2952,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _lineBoundaryTimer?.cancel(); if (!widget.isActive || !_playing || _loading) return; - final lines = widget.lyrics.lines; + final lines = _lines; final dueIndex = syncedLyricsDueLineIndex( lineStarts: lines.map((line) => line.time).toList(growable: false), currentIndex: _active, @@ -2996,11 +3000,12 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { locale: locale, ); final measurements = <(double, double, double)>[]; - for (final line in widget.lyrics.lines) { - painter.text = TextSpan( - text: line.text.trim().isEmpty ? '\u00b7\u00b7\u00b7' : line.text, - style: style, - ); + for (final line in _lines) { + if (line.text.isEmpty) { + measurements.add((56, 0, 0)); + continue; + } + painter.text = TextSpan(text: line.text, style: style); painter.layout(maxWidth: width); var height = painter.height + 32; var pronunciationHeight = 0.0; @@ -3118,8 +3123,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { ); Widget _buildLyrics(BuildContext context, Offset visibility) { - final lines = widget.lyrics.lines; + final lines = _lines; final active = _active; + final loading = ref.watch(playbackLoadingProvider); final mornye = context.isMornye; final highContrast = MediaQuery.highContrastOf(context); final blurLyrics = @@ -3210,9 +3216,30 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { ? widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.5) : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.8); - final text = line.text.trim().isEmpty - ? '\u00b7\u00b7\u00b7' - : line.text; + if (line.text.isEmpty) { + return Padding( + key: _lineKeys[index], + padding: EdgeInsets.symmetric( + vertical: context.tokens.lyricsLinePaddingV, + ), + child: SizedBox( + height: 24, + child: Align( + alignment: mornye + ? Alignment.centerLeft + : Alignment.center, + child: isActive && widget.isActive && !loading + ? LyricGapIndicator( + key: ValueKey(line.time), + start: line.time, + end: line.end!, + color: color, + ) + : null, + ), + ), + ); + } final timed = isActive && @@ -3228,7 +3255,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { ); } else { content = Text( - text, + line.text, textAlign: mornye ? TextAlign.start : TextAlign.center, style: (mornye || isActive diff --git a/lib/utils/lyrics_timeline.dart b/lib/utils/lyrics_timeline.dart new file mode 100644 index 00000000..8aa4f0be --- /dev/null +++ b/lib/utils/lyrics_timeline.dart @@ -0,0 +1,45 @@ +import 'package:spotiflac_android/utils/lyrics_parser.dart'; + +/// 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); +} diff --git a/lib/widgets/lyric_gap_indicator.dart b/lib/widgets/lyric_gap_indicator.dart new file mode 100644 index 00000000..28461bb0 --- /dev/null +++ b/lib/widgets/lyric_gap_indicator.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart'; + +/// A playback-driven countdown, rather than a repeating loading animation. +class LyricGapIndicator extends ConsumerWidget { + const LyricGapIndicator({ + super.key, + required this.start, + required this.end, + required this.color, + }); + + final Duration start; + final Duration end; + final Color color; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final progress = syncedLyricSegmentProgress( + position: ref.watch(playbackPositionProvider), + start: start, + end: end, + ); + final animate = + ref.watch(playbackPlayingProvider) && + !ref.watch(playbackLoadingProvider) && + !MediaQuery.disableAnimationsOf(context); + return Semantics( + label: context.l10n.nowPlayingInstrumental, + value: '${(progress * 100).round()}%', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < 3; index++) ...[ + if (index > 0) const SizedBox(width: 8), + AnimatedContainer( + key: ValueKey('lyric-gap-dot-$index'), + duration: animate + ? const Duration(milliseconds: 120) + : Duration.zero, + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Color.lerp( + color.withValues(alpha: 0.25), + color, + (progress * 3 - index).clamp(0.0, 1.0), + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/test/lyrics_timeline_test.dart b/test/lyrics_timeline_test.dart new file mode 100644 index 00000000..e1e702c1 --- /dev/null +++ b/test/lyrics_timeline_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/lyrics_parser.dart'; +import 'package:spotiflac_android/utils/lyrics_timeline.dart'; + +void main() { + List<(int, int)> gaps(String text) => + lyricsTimelineWithGaps(LyricsParser.parse(text).lines) + .where((line) => line.text.isEmpty) + .map((line) => (line.time.inMilliseconds, line.end!.inMilliseconds)) + .toList(); + + test('intro and explicit LRC breaks count down, never the outro', () { + const text = ''' +[00:00.00] +[00:09.00]First +[00:12.00] +[00:14.00] +[00:21.00]Second +[00:25.00] +[00:30.00] +'''; + expect(gaps(text), [(0, 9000), (12000, 21000)]); + expect( + lyricsTimelineWithGaps(LyricsParser.parse(text).lines).last.text, + 'Second', + ); + }); + + test('TTML line ends identify breaks without empty timestamps', () { + expect( + gaps(''' +
+

First

+

Second

+

Third

+
+'''), + [(0, 9000), (12000, 21000)], + ); + }); + + test('enhanced LRC word ends retain held vocals before a break', () { + expect( + gaps(''' +[00:00.00]<00:00.00>Held<00:16.00> +[00:20.00]<00:20.00>Last<00:24.00> +'''), + [(16000, 20000)], + ); + }); + + test('overlapping lines cannot start a countdown during a held vocal', () { + expect( + gaps(''' +
+

Held

+

Backing vocal

+

Last

+
+'''), + [(16000, 20000)], + ); + }); + + test('plain LRC does not invent an end time from distance between lines', () { + expect(gaps('[00:00]Held\n[00:30]Last'), isEmpty); + expect(gaps('[00:00]\n[00:30]'), isEmpty); + expect(gaps('Unsynced text'), isEmpty); + }); + + test('short rests and immediate vocals have no flashing countdown', () { + expect(gaps('[00:02]First\n[00:04]\n[00:06]Last'), isEmpty); + expect(gaps('[00:00]First\n[00:04]\n[00:07]Last'), [(4000, 7000)]); + }); + + test('offset correction also moves the countdown boundaries', () { + expect(gaps('[offset:500]\n[00:09]First\n[00:12]\n[00:21]Last'), [ + (0, 8500), + (11500, 20500), + ]); + }); + + test('word ends protect against an early or invalid paragraph end', () { + expect( + gaps(''' +
+

Held

+

Last

+
+'''), + [(8000, 12000)], + ); + expect( + gaps(''' +
+

Unknown end

+

Last

+
+'''), + isEmpty, + ); + }); +} diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 61113464..cd31840c 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -22,6 +22,7 @@ import 'package:video_player/video_player.dart'; import 'package:spotiflac_android/screens/now_playing_screen.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; +import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart'; import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; import 'package:spotiflac_android/widgets/mornye_playback_time.dart'; @@ -948,6 +949,106 @@ void main() { }, ); + for (final layout in ['material', 'portrait', 'landscape']) { + testWidgets('instrumental dots follow intro, break and seeks ($layout)', ( + tester, + ) async { + metadataOverrides['lyrics'] = ''' +[00:09.00]First vocal +[00:12.00] +[00:21.00]Last vocal +[00:25.00] +'''; + final playback = StreamController.broadcast(); + addTearDown(playback.close); + await pumpNowPlaying( + tester, + theme: layout == 'material' ? null : MornyeTheme.build(Brightness.dark), + size: layout == 'landscape' + ? const Size(852, 393) + : const Size(393, 852), + playbackEvents: playback.stream, + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + if (layout == 'material') { + await tester.drag(find.byType(PageView), const Offset(-350, 0)); + } else { + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + } + await tester.pumpAndSettle(); + + Future positionAt( + int seconds, { + bool playing = false, + AudioProcessingState state = AudioProcessingState.ready, + }) async { + playback.add( + PlaybackState( + processingState: state, + playing: playing, + updatePosition: Duration(seconds: seconds), + ), + ); + if (state == AudioProcessingState.ready) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + } + + List dotAlphas() => List.generate(3, (index) { + final dot = tester.widget( + find.byKey(ValueKey('lyric-gap-dot-$index')), + ); + return (dot.decoration! as BoxDecoration).color!.a; + }); + + for (final (seconds, expected) in [ + (3, [1.0, 0.25, 0.25]), + (6, [1.0, 1.0, 0.25]), + (15, [1.0, 0.25, 0.25]), + (18, [1.0, 1.0, 0.25]), + (6, [1.0, 1.0, 0.25]), + ]) { + await positionAt(seconds); + expect(find.byType(LyricGapIndicator).hitTestable(), findsOneWidget); + expect(dotAlphas(), expected); + if (layout != 'material') { + final filter = tester.widget( + find + .ancestor( + of: find.text(seconds >= 12 ? 'Last vocal' : 'First vocal'), + matching: find.byType(ImageFiltered), + ) + .first, + ); + expect(filter.enabled, isTrue); + } + } + + await positionAt(6, playing: true, state: AudioProcessingState.buffering); + expect(find.byType(LyricGapIndicator), findsNothing); + await positionAt(6); + expect(dotAlphas(), [1.0, 1.0, 0.25]); + await tester.pump(const Duration(seconds: 2)); + expect(dotAlphas(), [1.0, 1.0, 0.25]); + + for (final seconds in [9, 21, 25, 40]) { + await positionAt(seconds); + expect(find.byType(LyricGapIndicator), findsNothing); + } + await positionAt(6); + expect(find.byType(LyricGapIndicator), findsOneWidget); + metadataOverrides.clear(); + mediaItems.add(item('second')); + await tester.pumpAndSettle(); + expect(find.byType(LyricGapIndicator), findsNothing); + expect(tester.takeException(), isNull); + }); + } + testWidgets('Mornye lyrics stay blurred until playback starts', ( tester, ) async {