feat: add SongLink region setting and fix track metadata lookup with name+artist fallback

- Add configurable SongLink region (userCountry) setting with picker UI
- Pass songLinkRegion through download request payload to Go backend
- Go backend: thread-safe global SongLink region with per-request override
- Fix downloaded track not recognized in collection tap: add findByTrackAndArtist
  fallback in download history lookup chain (Spotify ID → ISRC → name+artist)
- Apply same name+artist fallback to isDownloaded check in track options sheet
- Add missing library_database.dart import for LocalLibraryItem
This commit is contained in:
zarzet
2026-02-19 19:16:55 +07:00
parent ab72a10578
commit 882afd938b
9 changed files with 446 additions and 19 deletions
+62 -17
View File
@@ -8,6 +8,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -992,9 +994,12 @@ class _CollectionTrackTile extends ConsumerWidget {
void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) {
final track = entry.track;
final colorScheme = Theme.of(context).colorScheme;
final isDownloaded = ref.read(
downloadHistoryProvider.select((state) => state.isDownloaded(track.id)),
);
final historyState = ref.read(downloadHistoryProvider);
final isDownloaded = historyState.isDownloaded(track.id) ||
(track.isrc != null &&
track.isrc!.isNotEmpty &&
historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) != null;
// Wishlist: only show "Add to Playlist" if track is already downloaded
final showAddToPlaylist =
mode != LibraryTracksFolderMode.wishlist || isDownloaded;
@@ -1168,22 +1173,62 @@ class _CollectionTrackTile extends ConsumerWidget {
Future<void> _navigateToMetadata(BuildContext context, WidgetRef ref) async {
final track = entry.track;
final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
final historyState = ref.read(downloadHistoryProvider);
if (historyItem == null) return;
// 1. Download history by Spotify ID
var historyItem = historyState.getBySpotifyId(track.id);
await Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: historyItem),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
// 2. Download history by ISRC
if (historyItem == null &&
track.isrc != null &&
track.isrc!.isNotEmpty) {
historyItem = historyState.getByIsrc(track.isrc!);
}
// 3. Download history by track name + artist (handles ID/ISRC mismatch)
historyItem ??=
historyState.findByTrackAndArtist(track.name, track.artistName);
if (historyItem != null) {
await Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: historyItem),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
return;
}
// 4. Local library by ISRC
final localState = ref.read(localLibraryProvider);
LocalLibraryItem? localItem;
if (track.isrc != null && track.isrc!.isNotEmpty) {
localItem = localState.getByIsrc(track.isrc!);
}
// 5. Local library by track name + artist
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
if (localItem != null) {
await Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(localItem: localItem),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
return;
}
// 6. Not found anywhere — offer to download
_downloadTrack(context, ref);
}
}
@@ -24,6 +24,206 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
static const _songLinkRegions = [
'AD',
'AE',
'AG',
'AL',
'AM',
'AO',
'AR',
'AT',
'AU',
'AZ',
'BA',
'BB',
'BD',
'BE',
'BF',
'BG',
'BH',
'BI',
'BJ',
'BN',
'BO',
'BR',
'BS',
'BT',
'BW',
'BZ',
'CA',
'CD',
'CG',
'CH',
'CI',
'CL',
'CM',
'CO',
'CR',
'CV',
'CW',
'CY',
'CZ',
'DE',
'DJ',
'DK',
'DM',
'DO',
'DZ',
'EC',
'EE',
'EG',
'ES',
'ET',
'FI',
'FJ',
'FM',
'FR',
'GA',
'GB',
'GD',
'GE',
'GH',
'GM',
'GN',
'GQ',
'GR',
'GT',
'GW',
'GY',
'HK',
'HN',
'HR',
'HT',
'HU',
'ID',
'IE',
'IL',
'IN',
'IQ',
'IS',
'IT',
'JM',
'JO',
'JP',
'KE',
'KG',
'KH',
'KI',
'KM',
'KN',
'KR',
'KW',
'KZ',
'LA',
'LB',
'LC',
'LI',
'LK',
'LR',
'LS',
'LT',
'LU',
'LV',
'LY',
'MA',
'MC',
'MD',
'ME',
'MG',
'MH',
'MK',
'ML',
'MN',
'MO',
'MR',
'MT',
'MU',
'MV',
'MW',
'MX',
'MY',
'MZ',
'NA',
'NE',
'NG',
'NI',
'NL',
'NO',
'NP',
'NR',
'NZ',
'OM',
'PA',
'PE',
'PG',
'PH',
'PK',
'PL',
'PS',
'PT',
'PW',
'PY',
'QA',
'RO',
'RS',
'RW',
'SA',
'SB',
'SC',
'SE',
'SG',
'SI',
'SK',
'SL',
'SM',
'SN',
'SR',
'ST',
'SV',
'SZ',
'TD',
'TG',
'TH',
'TJ',
'TL',
'TN',
'TO',
'TR',
'TT',
'TV',
'TW',
'TZ',
'UA',
'UG',
'US',
'UY',
'UZ',
'VC',
'VE',
'VN',
'VU',
'WS',
'XK',
'ZA',
'ZM',
'ZW',
];
static const _songLinkRegionNames = <String, String>{
'US': 'United States',
'GB': 'United Kingdom',
'FR': 'France',
'DE': 'Germany',
'JP': 'Japan',
'KR': 'South Korea',
'IN': 'India',
'ID': 'Indonesia',
'BR': 'Brazil',
'MX': 'Mexico',
'AU': 'Australia',
'CA': 'Canada',
'XK': 'Kosovo',
};
int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false;
bool _artistFolderFiltersExpanded = false;
@@ -536,6 +736,16 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
settings.downloadNetworkMode,
),
),
SettingsItem(
icon: Icons.public,
title: 'SongLink Region',
subtitle: _getSongLinkRegionLabel(settings.songLinkRegion),
onTap: () => _showSongLinkRegionPicker(
context,
ref,
settings.songLinkRegion,
),
),
SettingsSwitchItem(
icon: Icons.security_outlined,
title: 'Network compatibility mode',
@@ -1225,6 +1435,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
}
}
String _getSongLinkRegionLabel(String code) {
final normalized = code.trim().toUpperCase();
final effective = normalized.isEmpty ? 'US' : normalized;
final name = _songLinkRegionNames[effective];
if (name == null) return effective;
return '$effective - $name';
}
void _showLyricsModePicker(
BuildContext context,
WidgetRef ref,
@@ -1630,6 +1848,74 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
);
}
void _showSongLinkRegionPicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
final normalizedCurrent = current.trim().toUpperCase();
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'SongLink Region',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Used as userCountry for SongLink API lookup.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: ListView.builder(
itemCount: _songLinkRegions.length,
itemBuilder: (context, index) {
final code = _songLinkRegions[index];
final isSelected = code == normalizedCurrent;
final displayName = _songLinkRegionNames[code];
return ListTile(
title: Text(code),
subtitle: displayName != null ? Text(displayName) : null,
trailing: isSelected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setSongLinkRegion(code);
Navigator.pop(context);
},
);
},
),
),
],
),
),
),
);
}
void _showFolderOrganizationPicker(
BuildContext context,
WidgetRef ref,