mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat(lyrics): show songwriter and provider credits after lyrics
This commit is contained in:
1 parent
abb8dc4a42
commit
8103c313d7
6 files changed
+238
-11
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user