feat(lyrics): show songwriter and provider credits after lyrics

This commit is contained in:
zarzet committed 2026-09-25 12:01:43 +07:00
1 parent abb8dc4a42
commit 8103c313d7
6 files changed
+238 -11

No files matched your search

+4
View File
@@ -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",
+2
View File
@@ -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",
+74 -9
View File
@@ -1693,6 +1693,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
children: [
_SyncedLyricsView(
lyrics: _lyrics,
credits: _lyricsCredits(),
colorScheme: colorScheme,
isActive: isActive,
showPronunciation: visibility.$1,
@@ -1709,17 +1710,36 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
}
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<LyricWord>, bool)> _lyricSupplements(
BuildContext context,
LyricLine line,
+67 -2
View File
@@ -37,12 +37,16 @@ class ParsedLyrics {
final bool wordSynced;
final List<LyricLine> 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<XmlElement>()
.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) {