mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-24 20:02:39 +02:00
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:
@@ -177,6 +177,7 @@ type DownloadRequest struct {
|
|||||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||||
UseExtensions bool `json:"use_extensions,omitempty"`
|
UseExtensions bool `json:"use_extensions,omitempty"`
|
||||||
UseFallback bool `json:"use_fallback,omitempty"`
|
UseFallback bool `json:"use_fallback,omitempty"`
|
||||||
|
SongLinkRegion string `json:"songlink_region,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResponse struct {
|
type DownloadResponse struct {
|
||||||
@@ -378,11 +379,19 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applySongLinkRegionFromRequest(req *DownloadRequest) {
|
||||||
|
if req == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SetSongLinkRegion(req.SongLinkRegion)
|
||||||
|
}
|
||||||
|
|
||||||
func DownloadTrack(requestJSON string) (string, error) {
|
func DownloadTrack(requestJSON string) (string, error) {
|
||||||
var req DownloadRequest
|
var req DownloadRequest
|
||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
return errorResponse("Invalid request: " + err.Error())
|
return errorResponse("Invalid request: " + err.Error())
|
||||||
}
|
}
|
||||||
|
applySongLinkRegionFromRequest(&req)
|
||||||
defer closeOwnedOutputFD(req.OutputFD)
|
defer closeOwnedOutputFD(req.OutputFD)
|
||||||
|
|
||||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
@@ -566,6 +575,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
return errorResponse("Invalid request: " + err.Error())
|
return errorResponse("Invalid request: " + err.Error())
|
||||||
}
|
}
|
||||||
|
applySongLinkRegionFromRequest(&req)
|
||||||
defer closeOwnedOutputFD(req.OutputFD)
|
defer closeOwnedOutputFD(req.OutputFD)
|
||||||
|
|
||||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
@@ -1533,6 +1543,7 @@ func DownloadFromYouTube(requestJSON string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
return errorResponse("Invalid request: " + err.Error())
|
return errorResponse("Invalid request: " + err.Error())
|
||||||
}
|
}
|
||||||
|
applySongLinkRegionFromRequest(&req)
|
||||||
defer closeOwnedOutputFD(req.OutputFD)
|
defer closeOwnedOutputFD(req.OutputFD)
|
||||||
|
|
||||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
@@ -2251,6 +2262,7 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
return "", fmt.Errorf("invalid request: %w", err)
|
return "", fmt.Errorf("invalid request: %w", err)
|
||||||
}
|
}
|
||||||
|
applySongLinkRegionFromRequest(&req)
|
||||||
defer closeOwnedOutputFD(req.OutputFD)
|
defer closeOwnedOutputFD(req.OutputFD)
|
||||||
|
|
||||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
|
|||||||
+37
-2
@@ -34,6 +34,8 @@ type TrackAvailability struct {
|
|||||||
var (
|
var (
|
||||||
globalSongLinkClient *SongLinkClient
|
globalSongLinkClient *SongLinkClient
|
||||||
songLinkClientOnce sync.Once
|
songLinkClientOnce sync.Once
|
||||||
|
songLinkRegion = "US"
|
||||||
|
songLinkRegionMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
@@ -45,6 +47,33 @@ func NewSongLinkClient() *SongLinkClient {
|
|||||||
return globalSongLinkClient
|
return globalSongLinkClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeSongLinkRegion(region string) string {
|
||||||
|
normalized := strings.ToUpper(strings.TrimSpace(region))
|
||||||
|
if len(normalized) != 2 {
|
||||||
|
return "US"
|
||||||
|
}
|
||||||
|
for _, ch := range normalized {
|
||||||
|
if ch < 'A' || ch > 'Z' {
|
||||||
|
return "US"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetSongLinkRegion(region string) {
|
||||||
|
normalized := normalizeSongLinkRegion(region)
|
||||||
|
songLinkRegionMu.Lock()
|
||||||
|
songLinkRegion = normalized
|
||||||
|
songLinkRegionMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSongLinkRegion() string {
|
||||||
|
songLinkRegionMu.RLock()
|
||||||
|
region := songLinkRegion
|
||||||
|
songLinkRegionMu.RUnlock()
|
||||||
|
return region
|
||||||
|
}
|
||||||
|
|
||||||
func songLinkBaseURL() string {
|
func songLinkBaseURL() string {
|
||||||
opts := GetNetworkCompatibilityOptions()
|
opts := GetNetworkCompatibilityOptions()
|
||||||
if opts.AllowHTTP {
|
if opts.AllowHTTP {
|
||||||
@@ -54,6 +83,9 @@ func songLinkBaseURL() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildSongLinkURLFromTarget(targetURL string, userCountry string) string {
|
func buildSongLinkURLFromTarget(targetURL string, userCountry string) string {
|
||||||
|
if userCountry == "" {
|
||||||
|
userCountry = GetSongLinkRegion()
|
||||||
|
}
|
||||||
apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL))
|
apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL))
|
||||||
if userCountry != "" {
|
if userCountry != "" {
|
||||||
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
||||||
@@ -62,6 +94,9 @@ func buildSongLinkURLFromTarget(targetURL string, userCountry string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string {
|
func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string {
|
||||||
|
if userCountry == "" {
|
||||||
|
userCountry = GetSongLinkRegion()
|
||||||
|
}
|
||||||
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s",
|
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s",
|
||||||
songLinkBaseURL(),
|
songLinkBaseURL(),
|
||||||
url.QueryEscape(platform),
|
url.QueryEscape(platform),
|
||||||
@@ -448,7 +483,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
|||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
apiURL := buildSongLinkURLFromTarget(deezerURL, "US")
|
apiURL := buildSongLinkURLFromTarget(deezerURL, "")
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -552,7 +587,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "US")
|
apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "")
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class AppSettings {
|
|||||||
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
||||||
final bool
|
final bool
|
||||||
networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests
|
networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests
|
||||||
|
final String
|
||||||
|
songLinkRegion; // SongLink userCountry region code used for platform lookup
|
||||||
|
|
||||||
// Local Library Settings
|
// Local Library Settings
|
||||||
final bool localLibraryEnabled; // Enable local library scanning
|
final bool localLibraryEnabled; // Enable local library scanning
|
||||||
@@ -115,6 +117,7 @@ class AppSettings {
|
|||||||
this.autoExportFailedDownloads = false,
|
this.autoExportFailedDownloads = false,
|
||||||
this.downloadNetworkMode = 'any',
|
this.downloadNetworkMode = 'any',
|
||||||
this.networkCompatibilityMode = false,
|
this.networkCompatibilityMode = false,
|
||||||
|
this.songLinkRegion = 'US',
|
||||||
// Local Library defaults
|
// Local Library defaults
|
||||||
this.localLibraryEnabled = false,
|
this.localLibraryEnabled = false,
|
||||||
this.localLibraryPath = '',
|
this.localLibraryPath = '',
|
||||||
@@ -177,6 +180,7 @@ class AppSettings {
|
|||||||
bool? autoExportFailedDownloads,
|
bool? autoExportFailedDownloads,
|
||||||
String? downloadNetworkMode,
|
String? downloadNetworkMode,
|
||||||
bool? networkCompatibilityMode,
|
bool? networkCompatibilityMode,
|
||||||
|
String? songLinkRegion,
|
||||||
// Local Library
|
// Local Library
|
||||||
bool? localLibraryEnabled,
|
bool? localLibraryEnabled,
|
||||||
String? localLibraryPath,
|
String? localLibraryPath,
|
||||||
@@ -241,6 +245,7 @@ class AppSettings {
|
|||||||
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
||||||
networkCompatibilityMode:
|
networkCompatibilityMode:
|
||||||
networkCompatibilityMode ?? this.networkCompatibilityMode,
|
networkCompatibilityMode ?? this.networkCompatibilityMode,
|
||||||
|
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
|
||||||
// Local Library
|
// Local Library
|
||||||
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
||||||
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
json['networkCompatibilityMode'] as bool? ??
|
json['networkCompatibilityMode'] as bool? ??
|
||||||
json['songLinkCompatibilityMode'] as bool? ??
|
json['songLinkCompatibilityMode'] as bool? ??
|
||||||
false,
|
false,
|
||||||
|
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
|
||||||
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
|
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
|
||||||
localLibraryPath: json['localLibraryPath'] as String? ?? '',
|
localLibraryPath: json['localLibraryPath'] as String? ?? '',
|
||||||
localLibraryShowDuplicates:
|
localLibraryShowDuplicates:
|
||||||
@@ -117,6 +118,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||||
'networkCompatibilityMode': instance.networkCompatibilityMode,
|
'networkCompatibilityMode': instance.networkCompatibilityMode,
|
||||||
|
'songLinkRegion': instance.songLinkRegion,
|
||||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||||
'localLibraryPath': instance.localLibraryPath,
|
'localLibraryPath': instance.localLibraryPath,
|
||||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||||
|
|||||||
@@ -232,6 +232,22 @@ class DownloadHistoryState {
|
|||||||
|
|
||||||
DownloadHistoryItem? getByIsrc(String isrc) => _byIsrc[isrc];
|
DownloadHistoryItem? getByIsrc(String isrc) => _byIsrc[isrc];
|
||||||
|
|
||||||
|
DownloadHistoryItem? findByTrackAndArtist(
|
||||||
|
String trackName,
|
||||||
|
String artistName,
|
||||||
|
) {
|
||||||
|
final normalizedTrack = trackName.trim().toLowerCase();
|
||||||
|
final normalizedArtist = artistName.trim().toLowerCase();
|
||||||
|
if (normalizedTrack.isEmpty) return null;
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.trackName.trim().toLowerCase() == normalizedTrack &&
|
||||||
|
item.artistName.trim().toLowerCase() == normalizedArtist) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
||||||
return DownloadHistoryState(items: items ?? this.items);
|
return DownloadHistoryState(items: items ?? this.items);
|
||||||
}
|
}
|
||||||
@@ -3111,6 +3127,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
safRelativeDir: relativeDir,
|
safRelativeDir: relativeDir,
|
||||||
safFileName: fileName,
|
safFileName: fileName,
|
||||||
safOutputExt: outputExt,
|
safOutputExt: outputExt,
|
||||||
|
songLinkRegion: settings.songLinkRegion,
|
||||||
);
|
);
|
||||||
|
|
||||||
return PlatformBridge.downloadByStrategy(
|
return PlatformBridge.downloadByStrategy(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ final _log = AppLogger('SettingsProvider');
|
|||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
|
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
|
||||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||||
|
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
||||||
|
|
||||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||||
@@ -36,6 +37,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
await _normalizeYouTubeBitratesIfNeeded();
|
await _normalizeYouTubeBitratesIfNeeded();
|
||||||
|
await _normalizeSongLinkRegionIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _loadSpotifyClientSecret(prefs);
|
await _loadSpotifyClientSecret(prefs);
|
||||||
@@ -165,6 +167,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _normalizeSongLinkRegion(String region) {
|
||||||
|
final normalized = region.trim().toUpperCase();
|
||||||
|
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
|
||||||
|
return 'US';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _normalizeSongLinkRegionIfNeeded() async {
|
||||||
|
final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
|
||||||
|
if (normalized == state.songLinkRegion) return;
|
||||||
|
state = state.copyWith(songLinkRegion: normalized);
|
||||||
|
await _saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
||||||
final storedSecret = await _secureStorage.read(
|
final storedSecret = await _secureStorage.read(
|
||||||
key: _spotifyClientSecretKey,
|
key: _spotifyClientSecretKey,
|
||||||
@@ -483,6 +498,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_syncNetworkCompatibilitySettingsToBackend();
|
_syncNetworkCompatibilitySettingsToBackend();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setSongLinkRegion(String region) {
|
||||||
|
final normalized = _normalizeSongLinkRegion(region);
|
||||||
|
state = state.copyWith(songLinkRegion: normalized);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setLocalLibraryEnabled(bool enabled) {
|
void setLocalLibraryEnabled(bool enabled) {
|
||||||
state = state.copyWith(localLibraryEnabled: enabled);
|
state = state.copyWith(localLibraryEnabled: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/library_collections_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/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
@@ -992,9 +994,12 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) {
|
void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) {
|
||||||
final track = entry.track;
|
final track = entry.track;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDownloaded = ref.read(
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
downloadHistoryProvider.select((state) => state.isDownloaded(track.id)),
|
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
|
// Wishlist: only show "Add to Playlist" if track is already downloaded
|
||||||
final showAddToPlaylist =
|
final showAddToPlaylist =
|
||||||
mode != LibraryTracksFolderMode.wishlist || isDownloaded;
|
mode != LibraryTracksFolderMode.wishlist || isDownloaded;
|
||||||
@@ -1168,22 +1173,62 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
|
|
||||||
Future<void> _navigateToMetadata(BuildContext context, WidgetRef ref) async {
|
Future<void> _navigateToMetadata(BuildContext context, WidgetRef ref) async {
|
||||||
final track = entry.track;
|
final track = entry.track;
|
||||||
final historyItem = ref
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
.read(downloadHistoryProvider.notifier)
|
|
||||||
.getBySpotifyId(track.id);
|
|
||||||
|
|
||||||
if (historyItem == null) return;
|
// 1. Download history by Spotify ID
|
||||||
|
var historyItem = historyState.getBySpotifyId(track.id);
|
||||||
|
|
||||||
await Navigator.of(context).push(
|
// 2. Download history by ISRC
|
||||||
PageRouteBuilder(
|
if (historyItem == null &&
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
track.isrc != null &&
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
track.isrc!.isNotEmpty) {
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
historyItem = historyState.getByIsrc(track.isrc!);
|
||||||
TrackMetadataScreen(item: historyItem),
|
}
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
|
||||||
FadeTransition(opacity: animation, child: child),
|
// 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> {
|
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
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;
|
int _androidSdkVersion = 0;
|
||||||
bool _hasAllFilesAccess = false;
|
bool _hasAllFilesAccess = false;
|
||||||
bool _artistFolderFiltersExpanded = false;
|
bool _artistFolderFiltersExpanded = false;
|
||||||
@@ -536,6 +736,16 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
settings.downloadNetworkMode,
|
settings.downloadNetworkMode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.public,
|
||||||
|
title: 'SongLink Region',
|
||||||
|
subtitle: _getSongLinkRegionLabel(settings.songLinkRegion),
|
||||||
|
onTap: () => _showSongLinkRegionPicker(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
settings.songLinkRegion,
|
||||||
|
),
|
||||||
|
),
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.security_outlined,
|
icon: Icons.security_outlined,
|
||||||
title: 'Network compatibility mode',
|
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(
|
void _showLyricsModePicker(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
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(
|
void _showFolderOrganizationPicker(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class DownloadRequestPayload {
|
|||||||
final String safRelativeDir;
|
final String safRelativeDir;
|
||||||
final String safFileName;
|
final String safFileName;
|
||||||
final String safOutputExt;
|
final String safOutputExt;
|
||||||
|
final String songLinkRegion;
|
||||||
|
|
||||||
const DownloadRequestPayload({
|
const DownloadRequestPayload({
|
||||||
this.isrc = '',
|
this.isrc = '',
|
||||||
@@ -69,6 +70,7 @@ class DownloadRequestPayload {
|
|||||||
this.safRelativeDir = '',
|
this.safRelativeDir = '',
|
||||||
this.safFileName = '',
|
this.safFileName = '',
|
||||||
this.safOutputExt = '',
|
this.safOutputExt = '',
|
||||||
|
this.songLinkRegion = 'US',
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
@@ -107,6 +109,7 @@ class DownloadRequestPayload {
|
|||||||
'saf_relative_dir': safRelativeDir,
|
'saf_relative_dir': safRelativeDir,
|
||||||
'saf_file_name': safFileName,
|
'saf_file_name': safFileName,
|
||||||
'saf_output_ext': safOutputExt,
|
'saf_output_ext': safOutputExt,
|
||||||
|
'songlink_region': songLinkRegion,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +152,7 @@ class DownloadRequestPayload {
|
|||||||
safRelativeDir: safRelativeDir,
|
safRelativeDir: safRelativeDir,
|
||||||
safFileName: safFileName,
|
safFileName: safFileName,
|
||||||
safOutputExt: safOutputExt,
|
safOutputExt: safOutputExt,
|
||||||
|
songLinkRegion: songLinkRegion,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user