From f4934dcb28e108e00a1f99d70afff290a96b9314 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 14 Feb 2026 02:15:36 +0700 Subject: [PATCH] feat: add lyrics source tracking, Paxsenix partner, and dedicated lyrics provider settings page - Add getLyricsLRCWithSource to return lyrics with source metadata - Display lyrics source in track metadata screen - Improve LRC parsing to preserve background vocal tags - Add dedicated LyricsProviderPriorityPage for provider configuration - Add Paxsenix as lyrics proxy partner for Apple Music/QQ Music - Handle inline timestamps and speaker prefixes in LRC display --- README.md | 2 +- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 26 + go_backend/exports.go | 58 ++ go_backend/lyrics.go | 7 + go_backend/lyrics_apple.go | 2 +- ios/Runner/AppDelegate.swift | 11 + lib/screens/settings/about_page.dart | 8 + .../settings/download_settings_page.dart | 175 +----- .../lyrics_provider_priority_page.dart | 572 ++++++++++++++++++ lib/screens/track_metadata_screen.dart | 103 +++- lib/services/platform_bridge.dart | 24 +- site/partners.html | 15 + 12 files changed, 803 insertions(+), 200 deletions(-) create mode 100644 lib/screens/settings/lyrics_provider_priority_page.dart diff --git a/README.md b/README.md index 5b7b9da..d18e8b5 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ The software is provided "as is", without warranty of any kind. The author assum - **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net) - **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev) - **Amazon**: [AfkarXYZ](https://github.com/afkarxyz) -- **Lyrics**: [LRCLib](https://lrclib.net) +- **Lyrics**: [LRCLib](https://lrclib.net), [Paxsenix](https://lyrics.paxsenix.org) (Apple Music/QQ Music lyrics proxy) - **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com) - **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 5e8d2c6..16e7ffb 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1582,6 +1582,32 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "getLyricsLRCWithSource" -> { + val spotifyId = call.argument("spotify_id") ?: "" + val trackName = call.argument("track_name") ?: "" + val artistName = call.argument("artist_name") ?: "" + val filePath = call.argument("file_path") ?: "" + val durationMs = call.argument("duration_ms")?.toLong() ?: 0L + val response = withContext(Dispatchers.IO) { + if (filePath.startsWith("content://")) { + val tempPath = copyUriToTemp(Uri.parse(filePath)) + if (tempPath == null) { + """{"lyrics":"","source":"","sync_type":"","instrumental":false}""" + } else { + try { + Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, tempPath, durationMs) + } finally { + try { + File(tempPath).delete() + } catch (_: Exception) {} + } + } + } else { + Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs) + } + } + result.success(response) + } "embedLyricsToFile" -> { val filePath = call.argument("file_path") ?: "" val lyrics = call.argument("lyrics") ?: "" diff --git a/go_backend/exports.go b/go_backend/exports.go index 88f0909..3adfcb9 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1008,6 +1008,64 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura return lrcContent, nil } +func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { + if filePath != "" { + lyrics, err := ExtractLyrics(filePath) + if err == nil && lyrics != "" { + result := map[string]interface{}{ + "lyrics": lyrics, + "source": "Embedded", + "sync_type": "EMBEDDED", + "instrumental": false, + } + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } + + result := map[string]interface{}{ + "lyrics": "", + "source": "", + "sync_type": "", + "instrumental": false, + } + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } + + client := NewLyricsClient() + durationSec := float64(durationMs) / 1000.0 + lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) + if err != nil { + return "", err + } + + lrcContent := "" + if lyricsData.Instrumental { + lrcContent = "[instrumental:true]" + } else { + lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName) + } + + result := map[string]interface{}{ + "lyrics": lrcContent, + "source": lyricsData.Source, + "sync_type": lyricsData.SyncType, + "instrumental": lyricsData.Instrumental, + } + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + func EmbedLyricsToFile(filePath, lyrics string) (string, error) { err := EmbedLyrics(filePath, lyrics) if err != nil { diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 3fe5c3e..ccda290 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -608,6 +608,13 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine { continue } + // Preserve Apple/QQ background vocal tags by attaching them to + // the previous timed line. This keeps [bg:...] in final exported LRC. + if strings.HasPrefix(line, "[bg:") && len(lines) > 0 { + lines[len(lines)-1].Words = strings.TrimSpace(lines[len(lines)-1].Words + "\n" + line) + continue + } + matches := lrcPattern.FindStringSubmatch(line) if len(matches) == 5 { startMs := lrcTimestampToMs(matches[1], matches[2], matches[3]) diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index 7b95fa2..d3fef0b 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -289,7 +289,7 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW timestamp := msToLRCTimestamp(int64(line.Timestamp)) - if lyricsType == "Syllable" { + if strings.EqualFold(lyricsType, "Syllable") { sb.WriteString(timestamp) if multiPersonWordByWord { if line.OppositeTurn { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c596849..6c6a9ab 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -191,6 +191,17 @@ import Gobackend // Import Go framework let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error) if let error = error { throw error } return response + + case "getLyricsLRCWithSource": + let args = call.arguments as! [String: Any] + let spotifyId = args["spotify_id"] as! String + let trackName = args["track_name"] as! String + let artistName = args["artist_name"] as! String + let filePath = args["file_path"] as? String ?? "" + let durationMs = args["duration_ms"] as? Int64 ?? 0 + let response = GobackendGetLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs, &error) + if let error = error { throw error } + return response case "embedLyricsToFile": let args = call.arguments as! [String: Any] diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 4131282..ba8d7bc 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -141,6 +141,14 @@ class AboutPage extends StatelessWidget { title: context.l10n.aboutSpotiSaver, subtitle: context.l10n.aboutSpotiSaverDesc, onTap: () => _launchUrl('https://spotisaver.net'), + showDivider: true, + ), + _AboutSettingsItem( + icon: Icons.lyrics_outlined, + title: 'Paxsenix', + subtitle: + 'Partner lyrics proxy for Apple Music and QQ Music sources', + onTap: () => _launchUrl('https://lyrics.paxsenix.org'), showDivider: false, ), ], diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 456246a..a6a7b03 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class DownloadSettingsPage extends ConsumerStatefulWidget { @@ -284,10 +285,11 @@ class _DownloadSettingsPageState extends ConsumerState { icon: Icons.source_outlined, title: 'Lyrics Providers', subtitle: _getLyricsProvidersSubtitle(settings.lyricsProviders), - onTap: () => _showLyricsProvidersPicker( + onTap: () => Navigator.push( context, - ref, - settings.lyricsProviders, + MaterialPageRoute( + builder: (_) => const LyricsProviderPriorityPage(), + ), ), ), SettingsSwitchItem( @@ -1246,14 +1248,6 @@ class _DownloadSettingsPageState extends ConsumerState { 'qqmusic': 'QQ Music', }; - static const _providerDescriptions = { - 'lrclib': 'Open-source synced lyrics database', - 'netease': 'NetEase Cloud Music (good for Asian songs)', - 'musixmatch': 'Largest lyrics database (multi-language)', - 'apple_music': 'Word-by-word synced lyrics (via proxy)', - 'qqmusic': 'QQ Music (good for Chinese songs, via proxy)', - }; - String _getLyricsProvidersSubtitle(List providers) { if (providers.isEmpty) return 'None enabled'; return providers @@ -1261,165 +1255,6 @@ class _DownloadSettingsPageState extends ConsumerState { .join(' > '); } - void _showLyricsProvidersPicker( - BuildContext context, - WidgetRef ref, - List currentProviders, - ) { - final colorScheme = Theme.of(context).colorScheme; - final allProviders = ['lrclib', 'netease', 'musixmatch', 'apple_music', 'qqmusic']; - - // Work with a mutable copy - final selectedProviders = List.from(currentProviders); - - showModalBottomSheet( - context: context, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - isScrollControlled: true, - builder: (context) => StatefulBuilder( - builder: (context, setLocalState) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - 'Lyrics Providers', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), - child: Text( - 'Enable/disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - // Reorderable list of providers - ...allProviders.map((providerId) { - final isEnabled = selectedProviders.contains(providerId); - final displayName = _providerDisplayNames[providerId] ?? providerId; - final description = _providerDescriptions[providerId] ?? ''; - final orderIndex = selectedProviders.indexOf(providerId); - - return CheckboxListTile( - title: Row( - children: [ - if (isEnabled) - Padding( - padding: const EdgeInsets.only(right: 8), - child: CircleAvatar( - radius: 12, - backgroundColor: colorScheme.primaryContainer, - child: Text( - '${orderIndex + 1}', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: colorScheme.onPrimaryContainer, - ), - ), - ), - ), - Text(displayName), - ], - ), - subtitle: Text(description), - value: isEnabled, - onChanged: (bool? value) { - setLocalState(() { - if (value == true) { - selectedProviders.add(providerId); - } else { - selectedProviders.remove(providerId); - } - }); - ref.read(settingsProvider.notifier).setLyricsProviders( - List.from(selectedProviders), - ); - }, - ); - }), - // Move up/down hint - Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 4), - child: Text( - 'Priority order (tap to move):', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - // Show enabled providers with move controls - ...selectedProviders.asMap().entries.map((entry) { - final index = entry.key; - final providerId = entry.value; - final displayName = _providerDisplayNames[providerId] ?? providerId; - - return ListTile( - dense: true, - leading: CircleAvatar( - radius: 14, - backgroundColor: colorScheme.primary, - child: Text( - '${index + 1}', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: colorScheme.onPrimary, - ), - ), - ), - title: Text(displayName), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (index > 0) - IconButton( - icon: const Icon(Icons.arrow_upward, size: 20), - onPressed: () { - setLocalState(() { - selectedProviders.removeAt(index); - selectedProviders.insert(index - 1, providerId); - }); - ref.read(settingsProvider.notifier).setLyricsProviders( - List.from(selectedProviders), - ); - }, - ), - if (index < selectedProviders.length - 1) - IconButton( - icon: const Icon(Icons.arrow_downward, size: 20), - onPressed: () { - setLocalState(() { - selectedProviders.removeAt(index); - selectedProviders.insert(index + 1, providerId); - }); - ref.read(settingsProvider.notifier).setLyricsProviders( - List.from(selectedProviders), - ); - }, - ), - ], - ), - ); - }), - const SizedBox(height: 16), - ], - ), - ), - ), - ); - } - String _normalizeMusixmatchLanguage(String value) { final normalized = value.trim().toLowerCase(); return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart new file mode 100644 index 0000000..58abaea --- /dev/null +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -0,0 +1,572 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class LyricsProviderPriorityPage extends ConsumerStatefulWidget { + const LyricsProviderPriorityPage({super.key}); + + @override + ConsumerState createState() => + _LyricsProviderPriorityPageState(); +} + +class _LyricsProviderPriorityPageState + extends ConsumerState { + static const _allProviderIds = [ + 'lrclib', + 'netease', + 'musixmatch', + 'apple_music', + 'qqmusic', + ]; + + late List _enabledProviders; + late List _initialProviders; + bool _hasChanges = false; + + List get _disabledProviders => _allProviderIds + .where((id) => !_enabledProviders.contains(id)) + .toList(); + + @override + void initState() { + super.initState(); + final settings = ref.read(settingsProvider); + _enabledProviders = List.from(settings.lyricsProviders); + _initialProviders = List.from(settings.lyricsProviders); + } + + void _markChanged() { + final changed = _enabledProviders.length != _initialProviders.length || + !_enabledProviders + .asMap() + .entries + .every((e) => + e.key < _initialProviders.length && + _initialProviders[e.key] == e.value); + setState(() => _hasChanges = changed); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + final disabled = _disabledProviders; + + return PopScope( + canPop: !_hasChanges, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _confirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + }, + child: Scaffold( + body: CustomScrollView( + slivers: [ + // ── Collapsing App Bar ── + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { + if (_hasChanges) { + final shouldPop = await _confirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + } else { + Navigator.pop(context); + } + }, + ), + actions: [ + if (_hasChanges) + TextButton( + onPressed: _saveChanges, + child: const Text('Save'), + ), + ], + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: + EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + 'Lyrics Providers', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // ── Description ── + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Text( + 'Enable, disable and reorder lyrics sources. ' + 'Providers are tried top-to-bottom until lyrics are found.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + + // ── Enabled section header ── + if (_enabledProviders.isNotEmpty) + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: 'Enabled (${_enabledProviders.length})', + ), + ), + + // ── Reorderable enabled list ── + if (_enabledProviders.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverReorderableList( + itemCount: _enabledProviders.length, + itemBuilder: (context, index) { + final id = _enabledProviders[index]; + final info = _getLyricsProviderInfo(id); + return _EnabledProviderItem( + key: ValueKey(id), + providerId: id, + info: info, + index: index, + isFirst: index == 0, + onToggle: () => _disableProvider(id), + ); + }, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) newIndex -= 1; + final item = _enabledProviders.removeAt(oldIndex); + _enabledProviders.insert(newIndex, item); + }); + _markChanged(); + }, + ), + ), + + // ── Disabled section header ── + if (disabled.isNotEmpty) + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: 'Disabled (${disabled.length})', + ), + ), + + // ── Disabled list ── + if (disabled.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final id = disabled[index]; + final info = _getLyricsProviderInfo(id); + return _DisabledProviderItem( + key: ValueKey(id), + providerId: id, + info: info, + onToggle: () => _enableProvider(id), + ); + }, + childCount: disabled.length, + ), + ), + ), + + // ── Info banner ── + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline, + size: 20, color: colorScheme.tertiary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Extension lyrics providers always run before ' + 'built-in providers. At least one provider must ' + 'remain enabled.', + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } + + // ── State mutations ── + + void _enableProvider(String id) { + setState(() => _enabledProviders.add(id)); + _markChanged(); + } + + void _disableProvider(String id) { + if (_enabledProviders.length <= 1) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('At least one provider must remain enabled'), + ), + ); + return; + } + setState(() => _enabledProviders.remove(id)); + _markChanged(); + } + + // ── Save / Discard ── + + Future _saveChanges() async { + ref + .read(settingsProvider.notifier) + .setLyricsProviders(List.from(_enabledProviders)); + setState(() { + _initialProviders = List.from(_enabledProviders); + _hasChanges = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Lyrics provider priority saved')), + ); + } + } + + Future _confirmDiscard(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Discard changes?'), + content: + const Text('You have unsaved changes that will be lost.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Discard'), + ), + ], + ), + ); + return result ?? false; + } + + // ── Provider metadata ── + + static _LyricsProviderInfo _getLyricsProviderInfo(String id) { + switch (id) { + case 'lrclib': + return _LyricsProviderInfo( + name: 'LRCLIB', + description: 'Open-source synced lyrics database', + icon: Icons.subtitles_outlined, + ); + case 'netease': + return _LyricsProviderInfo( + name: 'Netease', + description: 'NetEase Cloud Music (good for Asian songs)', + icon: Icons.cloud_outlined, + ); + case 'musixmatch': + return _LyricsProviderInfo( + name: 'Musixmatch', + description: 'Largest lyrics database (multi-language)', + icon: Icons.translate, + ); + case 'apple_music': + return _LyricsProviderInfo( + name: 'Apple Music', + description: 'Word-by-word synced lyrics (via proxy)', + icon: Icons.music_note, + ); + case 'qqmusic': + return _LyricsProviderInfo( + name: 'QQ Music', + description: 'QQ Music (good for Chinese songs, via proxy)', + icon: Icons.queue_music, + ); + default: + return _LyricsProviderInfo( + name: id, + description: 'Extension provider', + icon: Icons.extension, + ); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Enabled provider card (reorderable) +// ═══════════════════════════════════════════════════════════════════════════ + +class _EnabledProviderItem extends StatelessWidget { + final String providerId; + final _LyricsProviderInfo info; + final int index; + final bool isFirst; + final VoidCallback onToggle; + + const _EnabledProviderItem({ + super.key, + required this.providerId, + required this.info, + required this.index, + required this.isFirst, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHigh; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + child: ReorderableDragStartListener( + index: index, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Numbered badge + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isFirst + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${index + 1}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isFirst + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(width: 16), + // Icon + Icon(info.icon, color: colorScheme.primary), + const SizedBox(width: 12), + // Name + description + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.name, + style: + Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + info.description, + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Enable/disable switch + SizedBox( + height: 32, + child: FittedBox( + child: Switch( + value: true, + onChanged: (_) => onToggle(), + ), + ), + ), + const SizedBox(width: 4), + // Drag handle + Icon( + Icons.drag_handle, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Disabled provider card +// ═══════════════════════════════════════════════════════════════════════════ + +class _DisabledProviderItem extends StatelessWidget { + final String providerId; + final _LyricsProviderInfo info; + final VoidCallback onToggle; + + const _DisabledProviderItem({ + super.key, + required this.providerId, + required this.info, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.03), + colorScheme.surface, + ) + : colorScheme.surfaceContainerLow; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Opacity( + opacity: 0.6, + child: Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onToggle, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Empty space aligned with numbered badge + const SizedBox(width: 28), + const SizedBox(width: 16), + // Icon (muted) + Icon(info.icon, color: colorScheme.outline), + const SizedBox(width: 12), + // Name + description + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.name, + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith( + fontWeight: FontWeight.w500, + color: colorScheme.onSurfaceVariant, + ), + ), + Text( + info.description, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: colorScheme.outline, + ), + ), + ], + ), + ), + // Switch + SizedBox( + height: 32, + child: FittedBox( + child: Switch( + value: false, + onChanged: (_) => onToggle(), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Provider info model +// ═══════════════════════════════════════════════════════════════════════════ + +class _LyricsProviderInfo { + final String name; + final String description; + final IconData icon; + + const _LyricsProviderInfo({ + required this.name, + required this.description, + required this.icon, + }); +} diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index c75bcfe..ecb2e5b 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -56,6 +56,7 @@ class _TrackMetadataScreenState extends ConsumerState { String? _rawLyrics; // Raw LRC with timestamps for embedding bool _lyricsLoading = false; String? _lyricsError; + String? _lyricsSource; bool _showTitleInAppBar = false; bool _lyricsEmbedded = false; // Track if lyrics are embedded in file bool _isEmbedding = false; // Track embed operation in progress @@ -69,6 +70,11 @@ class _TrackMetadataScreenState extends ConsumerState { r'^\[\d{2}:\d{2}\.\d{2,3}\]', ); static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$'); + static final RegExp _lrcInlineTimestampPattern = RegExp( + r'<\d{2}:\d{2}\.\d{2,3}>', + ); + static final RegExp _lrcSpeakerPrefixPattern = RegExp(r'^(v1|v2):\s*'); + static final RegExp _lrcBackgroundLinePattern = RegExp(r'^\[bg:(.*)\]$'); static const List _months = [ 'Jan', 'Feb', @@ -1339,6 +1345,16 @@ class _TrackMetadataScreenState extends ConsumerState { ), ], ), + if (_lyricsSource != null && _lyricsSource!.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Source: ${_lyricsSource!}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), const SizedBox(height: 12), if (_lyricsLoading) @@ -1460,6 +1476,7 @@ class _TrackMetadataScreenState extends ConsumerState { _lyricsLoading = true; _lyricsError = null; _isInstrumental = false; + _lyricsSource = null; }); try { @@ -1468,20 +1485,31 @@ class _TrackMetadataScreenState extends ConsumerState { // First, check if lyrics are embedded in the file if (_fileExists) { - final embeddedResult = await PlatformBridge.getLyricsLRC( - '', - trackName, - artistName, - filePath: cleanFilePath, - durationMs: 0, - ).timeout(const Duration(seconds: 5), onTimeout: () => ''); + final embeddedResult = + await PlatformBridge.getLyricsLRCWithSource( + '', + trackName, + artistName, + filePath: cleanFilePath, + durationMs: 0, + ).timeout( + const Duration(seconds: 5), + onTimeout: () => {'lyrics': '', 'source': ''}, + ); - if (embeddedResult.isNotEmpty) { + final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? ''; + final embeddedSource = embeddedResult['source']?.toString() ?? ''; + + if (embeddedLyrics.isNotEmpty) { // Lyrics found in file if (mounted) { - final cleanLyrics = _cleanLrcForDisplay(embeddedResult); + final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics); setState(() { _lyrics = cleanLyrics; + _rawLyrics = embeddedLyrics; + _lyricsSource = embeddedSource.isNotEmpty + ? embeddedSource + : 'Embedded'; _lyricsEmbedded = true; _lyricsLoading = false; }); @@ -1491,31 +1519,43 @@ class _TrackMetadataScreenState extends ConsumerState { } // No embedded lyrics, fetch from online - final result = await PlatformBridge.getLyricsLRC( - _spotifyId ?? '', - trackName, - artistName, - filePath: null, // Don't check file again - durationMs: durationMs, - ).timeout(const Duration(seconds: 20), onTimeout: () => ''); + final result = + await PlatformBridge.getLyricsLRCWithSource( + _spotifyId ?? '', + trackName, + artistName, + filePath: null, // Don't check file again + durationMs: durationMs, + ).timeout( + const Duration(seconds: 20), + onTimeout: () => {'lyrics': '', 'source': ''}, + ); + + final lrcText = result['lyrics']?.toString() ?? ''; + final source = result['source']?.toString() ?? ''; + final instrumental = + (result['instrumental'] as bool? ?? false) || + lrcText == '[instrumental:true]'; if (mounted) { // Check for instrumental marker - if (result == '[instrumental:true]') { + if (instrumental) { setState(() { _isInstrumental = true; + _lyricsSource = source.isNotEmpty ? source : null; _lyricsLoading = false; }); - } else if (result.isEmpty) { + } else if (lrcText.isEmpty) { setState(() { _lyricsError = context.l10n.trackLyricsNotAvailable; _lyricsLoading = false; }); } else { - final cleanLyrics = _cleanLrcForDisplay(result); + final cleanLyrics = _cleanLrcForDisplay(lrcText); setState(() { _lyrics = cleanLyrics; - _rawLyrics = result; // Keep raw LRC with timestamps for embedding + _rawLyrics = lrcText; // Keep raw LRC with timestamps for embedding + _lyricsSource = source.isNotEmpty ? source : null; _lyricsEmbedded = false; // Lyrics from online, not embedded _lyricsLoading = false; }); @@ -2213,17 +2253,28 @@ class _TrackMetadataScreenState extends ConsumerState { final cleanLines = []; for (final line in lines) { - final trimmedLine = line.trim(); + var cleaned = line.trim(); // Skip metadata tags - if (_lrcMetadataPattern.hasMatch(trimmedLine)) { + if (_lrcMetadataPattern.hasMatch(cleaned) && + !_lrcBackgroundLinePattern.hasMatch(cleaned)) { continue; } - // Remove timestamp and clean up - final cleanLine = trimmedLine.replaceAll(_lrcTimestampPattern, '').trim(); - if (cleanLine.isNotEmpty) { - cleanLines.add(cleanLine); + // Convert [bg:...] wrapper to a plain secondary vocal line. + final bgMatch = _lrcBackgroundLinePattern.firstMatch(cleaned); + if (bgMatch != null) { + cleaned = bgMatch.group(1)?.trim() ?? ''; + } + + // Remove line timestamp, inline word-by-word timestamps, and speaker prefix. + cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim(); + cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, ''); + cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, ''); + cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim(); + + if (cleaned.isNotEmpty) { + cleanLines.add(cleaned); } } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 8284c4f..f2351ae 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -276,6 +276,23 @@ class PlatformBridge { return result as String; } + static Future> getLyricsLRCWithSource( + String spotifyId, + String trackName, + String artistName, { + String? filePath, + int durationMs = 0, + }) async { + final result = await _channel.invokeMethod('getLyricsLRCWithSource', { + 'spotify_id': spotifyId, + 'track_name': trackName, + 'artist_name': artistName, + 'file_path': filePath ?? '', + 'duration_ms': durationMs, + }); + return jsonDecode(result as String) as Map; + } + static Future> embedLyricsToFile( String filePath, String lyrics, @@ -350,14 +367,17 @@ class PlatformBridge { } /// Returns metadata about all available lyrics providers. - static Future>> getAvailableLyricsProviders() async { + static Future>> + getAvailableLyricsProviders() async { final result = await _channel.invokeMethod('getAvailableLyricsProviders'); final List decoded = jsonDecode(result as String) as List; return decoded.cast>(); } /// Sets advanced lyrics fetch options used by provider-specific integrations. - static Future setLyricsFetchOptions(Map options) async { + static Future setLyricsFetchOptions( + Map options, + ) async { final optionsJSON = jsonEncode(options); await _channel.invokeMethod('setLyricsFetchOptions', { 'options_json': optionsJSON, diff --git a/site/partners.html b/site/partners.html index 79d1704..58af382 100644 --- a/site/partners.html +++ b/site/partners.html @@ -414,6 +414,21 @@ + +
+
+ +
+
+
Paxsenix
+
Lyrics proxy partner used for Apple Music and QQ Music lyric retrieval, including word-by-word synced formats consumed by SpotiFLAC.
+ + lyrics.paxsenix.org + + +
+
+