diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index b4c6e9e1..afbdc261 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -6282,6 +6282,10 @@ }, "nowPlayingUpNext": "Up next", "nowPlayingAudioOutput": "Audio Output", + "nowPlayingWrittenBy": "Written by: {writers}", + "@nowPlayingWrittenBy": {"placeholders": {"writers": {"type": "String"}}}, + "nowPlayingLyricsProvider": "Lyrics: {provider}", + "@nowPlayingLyricsProvider": {"placeholders": {"provider": {"type": "String"}}}, "concertGetTickets": "Get Tickets", "concertSetList": "Concert Set List", "concertSetListButton": "Set List", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 30ab7dee..f00e54aa 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -4578,6 +4578,8 @@ }, "nowPlayingUpNext": "Up next", "nowPlayingAudioOutput": "Output Audio", + "nowPlayingWrittenBy": "Ditulis oleh: {writers}", + "nowPlayingLyricsProvider": "Lirik: {provider}", "concertGetTickets": "Beli Tiket", "concertSetList": "Set List Konser", "concertSetListButton": "Set List", diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index c5842361..9ead75ae 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -1693,6 +1693,7 @@ class _NowPlayingScreenState extends ConsumerState { children: [ _SyncedLyricsView( lyrics: _lyrics, + credits: _lyricsCredits(), colorScheme: colorScheme, isActive: isActive, showPronunciation: visibility.$1, @@ -1709,17 +1710,36 @@ class _NowPlayingScreenState extends ConsumerState { } return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), - child: Text( - _lyrics.plainText, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - height: 1.6, - color: colorScheme.onSurface, - ), - textAlign: TextAlign.center, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + _lyrics.plainText, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + height: 1.6, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + ?_lyricsCredits(), + ], ), ); } + _LyricsCredits? _lyricsCredits() { + String? metadata(String key) { + final value = _metadata?[key]; + return value is String && value.trim().isNotEmpty ? value.trim() : null; + } + + final writers = + _lyrics.writers ?? metadata('lyricist') ?? metadata('composer'); + final provider = _lyrics.provider ?? metadata('lyrics_provider'); + if (writers == null && provider == null) return null; + return _LyricsCredits(writers: writers, provider: provider); + } + Widget? _lyricsOptionsButton(ColorScheme colorScheme) { if (_loadingMeta || !_lyrics.synced) return null; final pronunciation = _lyrics.lines.any( @@ -2680,6 +2700,7 @@ class _PlaybackControls extends ConsumerWidget { class _SyncedLyricsView extends ConsumerStatefulWidget { final ParsedLyrics lyrics; + final _LyricsCredits? credits; final ColorScheme colorScheme; final bool isActive; final bool showPronunciation; @@ -2687,6 +2708,7 @@ class _SyncedLyricsView extends ConsumerStatefulWidget { const _SyncedLyricsView({ required this.lyrics, + this.credits, required this.colorScheme, required this.isActive, required this.showPronunciation, @@ -3065,11 +3087,17 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { return ListView.builder( controller: _scroll, itemExtentBuilder: mornye - ? (index, _) => _lineExtents![index] + ? (index, _) => index < lines.length + ? _lineExtents![index] + : widget.credits!.heightFor( + context, + constraints.maxWidth - 48, + ) : null, padding: EdgeInsets.fromLTRB(24, centerPadding, 24, bottomPadding), - itemCount: lines.length, + itemCount: lines.length + (widget.credits == null ? 0 : 1), itemBuilder: (context, index) { + if (index == lines.length) return widget.credits!; final line = lines[index]; final isActive = index == active; final isPast = index < active; @@ -3191,6 +3219,43 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } } +class _LyricsCredits extends StatelessWidget { + const _LyricsCredits({this.writers, this.provider}); + + final String? writers; + final String? provider; + + String _text(BuildContext context) => [ + if (writers != null) context.l10n.nowPlayingWrittenBy(writers!), + if (provider != null) context.l10n.nowPlayingLyricsProvider(provider!), + ].join('\n'); + + TextStyle _style(BuildContext context) => + Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: context.isMornye ? 17 : 14, + fontWeight: FontWeight.w600, + height: 1.4, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.48), + ); + + double heightFor(BuildContext context, double width) { + final painter = TextPainter( + text: TextSpan(text: _text(context), style: _style(context)), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(maxWidth: width.clamp(0, double.infinity)); + final height = painter.height + 32; + painter.dispose(); + return height; + } + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text(_text(context), style: _style(context)), + ); +} + Iterable<(String, TextStyle, List, bool)> _lyricSupplements( BuildContext context, LyricLine line, diff --git a/lib/utils/lyrics_parser.dart b/lib/utils/lyrics_parser.dart index 41d74930..e2761934 100644 --- a/lib/utils/lyrics_parser.dart +++ b/lib/utils/lyrics_parser.dart @@ -37,12 +37,16 @@ class ParsedLyrics { 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; @@ -84,10 +88,71 @@ class LyricsParser { if (_looksLikeTtml(text)) { final ttml = _parseTtml(text); - if (ttml != null && ttml.lines.isNotEmpty) return ttml; + if (ttml != null && ttml.lines.isNotEmpty) { + return _withCredits(text, ttml); + } } - return _parseLrcOrPlain(text); + 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) { diff --git a/test/lyrics_parser_test.dart b/test/lyrics_parser_test.dart index f3291b10..aee32404 100644 --- a/test/lyrics_parser_test.dart +++ b/test/lyrics_parser_test.dart @@ -7,6 +7,65 @@ String _tag(String kind, int time, String text) => '[x-$kind:$time:${base64.encode(utf8.encode(text))}]'; void main() { + test( + 'writer tags and stored provider attribution remain separate from lyric rows', + () { + final lyrics = LyricsParser.parse(''' +[au:Example Writer, Another Writer] +[by:SpotiFLAC-Mobile via Example Lyrics API (source: upstream)] +[00:01.00]First line +[00:04.00]Last line +'''); + expect(lyrics.writers, 'Example Writer, Another Writer'); + expect(lyrics.provider, 'Example Lyrics'); + expect(lyrics.lines, hasLength(2)); + expect(lyrics.lines.last.time, const Duration(seconds: 4)); + }, + ); + + test( + 'an LRC uploader or performing artist is not invented as the songwriter or provider', + () { + final lyrics = LyricsParser.parse( + '[ar:Performer]\n[by:Uploader]\n[00:01.00]Line', + ); + expect(lyrics.writers, isNull); + expect(lyrics.provider, isNull); + expect( + LyricsParser.parse( + '[by:SpotiFLAC-Mobile (source: extension:example.lyrics)]\n[00:01]Line', + ).provider, + 'example.lyrics', + ); + }, + ); + + test( + 'explicit ending credits become a footer rather than a seekable lyric', + () { + final lyrics = LyricsParser.parse( + '[00:01]Last lyric\n[00:04]Written By: Example Writer', + ); + expect(lyrics.lines.single.text, 'Last lyric'); + expect(lyrics.writers, 'Example Writer'); + expect(lyrics.plainText, 'Last lyric'); + }, + ); + + test( + 'TTML songwriter metadata is retained without treating performers as writers', + () { + final lyrics = LyricsParser.parse(''' + +Example Writer +Second Writer +

Lyric

+'''); + expect(lyrics.writers, 'Example Writer, Second Writer'); + expect(lyrics.lines.single.text, 'Lyric'); + }, + ); + test('retains all three texts, word ends and millisecond timing', () { final lyrics = LyricsParser.parse(''' ${_tag('romaji', 1009, 'Romanized')} diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 29e14b21..26cfa076 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -1641,6 +1641,38 @@ void main() { }, ); + testWidgets( + 'lyrics end with writer and provider credits and clear them on track change', + (tester) async { + metadataOverrides['composer'] = 'Example Composer'; + metadataOverrides['lyrics'] = + '[by:SpotiFLAC-Mobile via Example Lyrics API (source: upstream)]\n[00:01]Last lyric'; + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(390, 844), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(); + final credit = find.text( + 'Written by: Example Composer\nLyrics: Example Lyrics', + ); + expect(credit, findsOneWidget); + expect( + tester.getTopLeft(credit).dy, + greaterThan(tester.getBottomLeft(find.text('Last lyric')).dy), + ); + metadataOverrides.clear(); + mediaItems.add(item('second')); + await tester.pumpAndSettle(); + expect(credit, findsNothing); + expect(find.text('Second lyric'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + for (final supplement in ['none', 'pronunciation', 'translation']) { testWidgets('language menu only offers available $supplement', ( tester,