mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
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
This commit is contained in:
parent
30973a8e78
commit
f4934dcb28
12 changed files with 803 additions and 200 deletions
|
|
@ -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)
|
- **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)
|
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
|
||||||
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
|
- **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)
|
- **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)
|
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1582,6 +1582,32 @@ class MainActivity: FlutterFragmentActivity() {
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"getLyricsLRCWithSource" -> {
|
||||||
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val durationMs = call.argument<Int>("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" -> {
|
"embedLyricsToFile" -> {
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
val lyrics = call.argument<String>("lyrics") ?: ""
|
val lyrics = call.argument<String>("lyrics") ?: ""
|
||||||
|
|
|
||||||
|
|
@ -1008,6 +1008,64 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
|
||||||
return lrcContent, nil
|
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) {
|
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||||
err := EmbedLyrics(filePath, lyrics)
|
err := EmbedLyrics(filePath, lyrics)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -608,6 +608,13 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
|
||||||
continue
|
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)
|
matches := lrcPattern.FindStringSubmatch(line)
|
||||||
if len(matches) == 5 {
|
if len(matches) == 5 {
|
||||||
startMs := lrcTimestampToMs(matches[1], matches[2], matches[3])
|
startMs := lrcTimestampToMs(matches[1], matches[2], matches[3])
|
||||||
|
|
|
||||||
|
|
@ -289,7 +289,7 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
|
||||||
|
|
||||||
timestamp := msToLRCTimestamp(int64(line.Timestamp))
|
timestamp := msToLRCTimestamp(int64(line.Timestamp))
|
||||||
|
|
||||||
if lyricsType == "Syllable" {
|
if strings.EqualFold(lyricsType, "Syllable") {
|
||||||
sb.WriteString(timestamp)
|
sb.WriteString(timestamp)
|
||||||
if multiPersonWordByWord {
|
if multiPersonWordByWord {
|
||||||
if line.OppositeTurn {
|
if line.OppositeTurn {
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,17 @@ import Gobackend // Import Go framework
|
||||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "embedLyricsToFile":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,14 @@ class AboutPage extends StatelessWidget {
|
||||||
title: context.l10n.aboutSpotiSaver,
|
title: context.l10n.aboutSpotiSaver,
|
||||||
subtitle: context.l10n.aboutSpotiSaverDesc,
|
subtitle: context.l10n.aboutSpotiSaverDesc,
|
||||||
onTap: () => _launchUrl('https://spotisaver.net'),
|
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,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.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';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class DownloadSettingsPage extends ConsumerStatefulWidget {
|
class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||||
|
|
@ -284,10 +285,11 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
icon: Icons.source_outlined,
|
icon: Icons.source_outlined,
|
||||||
title: 'Lyrics Providers',
|
title: 'Lyrics Providers',
|
||||||
subtitle: _getLyricsProvidersSubtitle(settings.lyricsProviders),
|
subtitle: _getLyricsProvidersSubtitle(settings.lyricsProviders),
|
||||||
onTap: () => _showLyricsProvidersPicker(
|
onTap: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
ref,
|
MaterialPageRoute(
|
||||||
settings.lyricsProviders,
|
builder: (_) => const LyricsProviderPriorityPage(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
|
|
@ -1246,14 +1248,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
'qqmusic': 'QQ Music',
|
'qqmusic': 'QQ Music',
|
||||||
};
|
};
|
||||||
|
|
||||||
static const _providerDescriptions = <String, String>{
|
|
||||||
'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<String> providers) {
|
String _getLyricsProvidersSubtitle(List<String> providers) {
|
||||||
if (providers.isEmpty) return 'None enabled';
|
if (providers.isEmpty) return 'None enabled';
|
||||||
return providers
|
return providers
|
||||||
|
|
@ -1261,165 +1255,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
.join(' > ');
|
.join(' > ');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showLyricsProvidersPicker(
|
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref,
|
|
||||||
List<String> currentProviders,
|
|
||||||
) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final allProviders = ['lrclib', 'netease', 'musixmatch', 'apple_music', 'qqmusic'];
|
|
||||||
|
|
||||||
// Work with a mutable copy
|
|
||||||
final selectedProviders = List<String>.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<String>.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<String>.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<String>.from(selectedProviders),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _normalizeMusixmatchLanguage(String value) {
|
String _normalizeMusixmatchLanguage(String value) {
|
||||||
final normalized = value.trim().toLowerCase();
|
final normalized = value.trim().toLowerCase();
|
||||||
return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), '');
|
return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), '');
|
||||||
|
|
|
||||||
572
lib/screens/settings/lyrics_provider_priority_page.dart
Normal file
572
lib/screens/settings/lyrics_provider_priority_page.dart
Normal file
|
|
@ -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<LyricsProviderPriorityPage> createState() =>
|
||||||
|
_LyricsProviderPriorityPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LyricsProviderPriorityPageState
|
||||||
|
extends ConsumerState<LyricsProviderPriorityPage> {
|
||||||
|
static const _allProviderIds = [
|
||||||
|
'lrclib',
|
||||||
|
'netease',
|
||||||
|
'musixmatch',
|
||||||
|
'apple_music',
|
||||||
|
'qqmusic',
|
||||||
|
];
|
||||||
|
|
||||||
|
late List<String> _enabledProviders;
|
||||||
|
late List<String> _initialProviders;
|
||||||
|
bool _hasChanges = false;
|
||||||
|
|
||||||
|
List<String> 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<void> _saveChanges() async {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setLyricsProviders(List<String>.from(_enabledProviders));
|
||||||
|
setState(() {
|
||||||
|
_initialProviders = List.from(_enabledProviders);
|
||||||
|
_hasChanges = false;
|
||||||
|
});
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Lyrics provider priority saved')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||||
String? _rawLyrics; // Raw LRC with timestamps for embedding
|
String? _rawLyrics; // Raw LRC with timestamps for embedding
|
||||||
bool _lyricsLoading = false;
|
bool _lyricsLoading = false;
|
||||||
String? _lyricsError;
|
String? _lyricsError;
|
||||||
|
String? _lyricsSource;
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
|
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
|
||||||
bool _isEmbedding = false; // Track embed operation in progress
|
bool _isEmbedding = false; // Track embed operation in progress
|
||||||
|
|
@ -69,6 +70,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||||
r'^\[\d{2}:\d{2}\.\d{2,3}\]',
|
r'^\[\d{2}:\d{2}\.\d{2,3}\]',
|
||||||
);
|
);
|
||||||
static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$');
|
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<String> _months = [
|
static const List<String> _months = [
|
||||||
'Jan',
|
'Jan',
|
||||||
'Feb',
|
'Feb',
|
||||||
|
|
@ -1339,6 +1345,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
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),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
if (_lyricsLoading)
|
if (_lyricsLoading)
|
||||||
|
|
@ -1460,6 +1476,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||||
_lyricsLoading = true;
|
_lyricsLoading = true;
|
||||||
_lyricsError = null;
|
_lyricsError = null;
|
||||||
_isInstrumental = false;
|
_isInstrumental = false;
|
||||||
|
_lyricsSource = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -1468,20 +1485,31 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||||
|
|
||||||
// First, check if lyrics are embedded in the file
|
// First, check if lyrics are embedded in the file
|
||||||
if (_fileExists) {
|
if (_fileExists) {
|
||||||
final embeddedResult = await PlatformBridge.getLyricsLRC(
|
final embeddedResult =
|
||||||
'',
|
await PlatformBridge.getLyricsLRCWithSource(
|
||||||
trackName,
|
'',
|
||||||
artistName,
|
trackName,
|
||||||
filePath: cleanFilePath,
|
artistName,
|
||||||
durationMs: 0,
|
filePath: cleanFilePath,
|
||||||
).timeout(const Duration(seconds: 5), onTimeout: () => '');
|
durationMs: 0,
|
||||||
|
).timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () => <String, dynamic>{'lyrics': '', 'source': ''},
|
||||||
|
);
|
||||||
|
|
||||||
if (embeddedResult.isNotEmpty) {
|
final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? '';
|
||||||
|
final embeddedSource = embeddedResult['source']?.toString() ?? '';
|
||||||
|
|
||||||
|
if (embeddedLyrics.isNotEmpty) {
|
||||||
// Lyrics found in file
|
// Lyrics found in file
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final cleanLyrics = _cleanLrcForDisplay(embeddedResult);
|
final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics);
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyrics = cleanLyrics;
|
_lyrics = cleanLyrics;
|
||||||
|
_rawLyrics = embeddedLyrics;
|
||||||
|
_lyricsSource = embeddedSource.isNotEmpty
|
||||||
|
? embeddedSource
|
||||||
|
: 'Embedded';
|
||||||
_lyricsEmbedded = true;
|
_lyricsEmbedded = true;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
|
|
@ -1491,31 +1519,43 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// No embedded lyrics, fetch from online
|
// No embedded lyrics, fetch from online
|
||||||
final result = await PlatformBridge.getLyricsLRC(
|
final result =
|
||||||
_spotifyId ?? '',
|
await PlatformBridge.getLyricsLRCWithSource(
|
||||||
trackName,
|
_spotifyId ?? '',
|
||||||
artistName,
|
trackName,
|
||||||
filePath: null, // Don't check file again
|
artistName,
|
||||||
durationMs: durationMs,
|
filePath: null, // Don't check file again
|
||||||
).timeout(const Duration(seconds: 20), onTimeout: () => '');
|
durationMs: durationMs,
|
||||||
|
).timeout(
|
||||||
|
const Duration(seconds: 20),
|
||||||
|
onTimeout: () => <String, dynamic>{'lyrics': '', 'source': ''},
|
||||||
|
);
|
||||||
|
|
||||||
|
final lrcText = result['lyrics']?.toString() ?? '';
|
||||||
|
final source = result['source']?.toString() ?? '';
|
||||||
|
final instrumental =
|
||||||
|
(result['instrumental'] as bool? ?? false) ||
|
||||||
|
lrcText == '[instrumental:true]';
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// Check for instrumental marker
|
// Check for instrumental marker
|
||||||
if (result == '[instrumental:true]') {
|
if (instrumental) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isInstrumental = true;
|
_isInstrumental = true;
|
||||||
|
_lyricsSource = source.isNotEmpty ? source : null;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
} else if (result.isEmpty) {
|
} else if (lrcText.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
final cleanLyrics = _cleanLrcForDisplay(result);
|
final cleanLyrics = _cleanLrcForDisplay(lrcText);
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyrics = cleanLyrics;
|
_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
|
_lyricsEmbedded = false; // Lyrics from online, not embedded
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
|
|
@ -2213,17 +2253,28 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||||
final cleanLines = <String>[];
|
final cleanLines = <String>[];
|
||||||
|
|
||||||
for (final line in lines) {
|
for (final line in lines) {
|
||||||
final trimmedLine = line.trim();
|
var cleaned = line.trim();
|
||||||
|
|
||||||
// Skip metadata tags
|
// Skip metadata tags
|
||||||
if (_lrcMetadataPattern.hasMatch(trimmedLine)) {
|
if (_lrcMetadataPattern.hasMatch(cleaned) &&
|
||||||
|
!_lrcBackgroundLinePattern.hasMatch(cleaned)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove timestamp and clean up
|
// Convert [bg:...] wrapper to a plain secondary vocal line.
|
||||||
final cleanLine = trimmedLine.replaceAll(_lrcTimestampPattern, '').trim();
|
final bgMatch = _lrcBackgroundLinePattern.firstMatch(cleaned);
|
||||||
if (cleanLine.isNotEmpty) {
|
if (bgMatch != null) {
|
||||||
cleanLines.add(cleanLine);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,23 @@ class PlatformBridge {
|
||||||
return result as String;
|
return result as String;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> 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<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> embedLyricsToFile(
|
static Future<Map<String, dynamic>> embedLyricsToFile(
|
||||||
String filePath,
|
String filePath,
|
||||||
String lyrics,
|
String lyrics,
|
||||||
|
|
@ -350,14 +367,17 @@ class PlatformBridge {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns metadata about all available lyrics providers.
|
/// Returns metadata about all available lyrics providers.
|
||||||
static Future<List<Map<String, dynamic>>> getAvailableLyricsProviders() async {
|
static Future<List<Map<String, dynamic>>>
|
||||||
|
getAvailableLyricsProviders() async {
|
||||||
final result = await _channel.invokeMethod('getAvailableLyricsProviders');
|
final result = await _channel.invokeMethod('getAvailableLyricsProviders');
|
||||||
final List<dynamic> decoded = jsonDecode(result as String) as List<dynamic>;
|
final List<dynamic> decoded = jsonDecode(result as String) as List<dynamic>;
|
||||||
return decoded.cast<Map<String, dynamic>>();
|
return decoded.cast<Map<String, dynamic>>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets advanced lyrics fetch options used by provider-specific integrations.
|
/// Sets advanced lyrics fetch options used by provider-specific integrations.
|
||||||
static Future<void> setLyricsFetchOptions(Map<String, dynamic> options) async {
|
static Future<void> setLyricsFetchOptions(
|
||||||
|
Map<String, dynamic> options,
|
||||||
|
) async {
|
||||||
final optionsJSON = jsonEncode(options);
|
final optionsJSON = jsonEncode(options);
|
||||||
await _channel.invokeMethod('setLyricsFetchOptions', {
|
await _channel.invokeMethod('setLyricsFetchOptions', {
|
||||||
'options_json': optionsJSON,
|
'options_json': optionsJSON,
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,21 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Paxsenix (lyrics proxy) -->
|
||||||
|
<div class="infra-card">
|
||||||
|
<div class="infra-icon" style="background: rgba(59,130,246,.1); color: #3b82f6;">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 2c2.4 0 4.6 1.1 6 3 1.4 1.9 1.8 4.3 1.2 6.6-.7 2.2-2.3 4-4.4 5v2.4h-6V17c-2.1-1-3.7-2.8-4.4-5C3.8 9.3 4.2 6.9 5.6 5 7 3.1 9.2 2 11.6 2H12zm-1 18h2v2h-2v-2zm-.2-5h2.4c1.9-.7 3.3-2.2 3.9-4.1.5-1.7.2-3.5-.8-4.9-1-1.4-2.6-2.2-4.3-2.2H12c-1.7 0-3.3.8-4.3 2.2-1 1.4-1.3 3.2-.8 4.9.6 1.9 2 3.4 3.9 4.1z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="infra-info">
|
||||||
|
<div class="infra-name">Paxsenix</div>
|
||||||
|
<div class="infra-desc">Lyrics proxy partner used for Apple Music and QQ Music lyric retrieval, including word-by-word synced formats consumed by SpotiFLAC.</div>
|
||||||
|
<a class="infra-link" href="https://lyrics.paxsenix.org" target="_blank">
|
||||||
|
lyrics.paxsenix.org
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- === TIDAL STREAM APIs === -->
|
<!-- === TIDAL STREAM APIs === -->
|
||||||
|
|
||||||
<!-- hifi-api / Binimum (GitHub) -->
|
<!-- hifi-api / Binimum (GitHub) -->
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue