mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-22 01:20:47 +02:00
chore: rebuild dev history without streaming-era commits
This commit is contained in:
+28
-2
@@ -37,6 +37,9 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (context, state) => const TutorialScreen(),
|
||||
),
|
||||
],
|
||||
// Safety net: if a deep link URL (e.g. Spotify/Deezer) somehow reaches
|
||||
// GoRouter, redirect to home instead of showing "Page Not Found".
|
||||
errorBuilder: (context, state) => const MainShell(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -54,10 +57,14 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
: null;
|
||||
|
||||
Locale? locale;
|
||||
if (localeString != 'system') {
|
||||
if (localeString != 'system' && localeString.isNotEmpty) {
|
||||
if (localeString.contains('_')) {
|
||||
final parts = localeString.split('_');
|
||||
locale = Locale(parts[0], parts[1]);
|
||||
if (parts.length == 2) {
|
||||
locale = Locale(parts[0], parts[1]);
|
||||
} else {
|
||||
locale = Locale(parts[0]);
|
||||
}
|
||||
} else {
|
||||
locale = Locale(localeString);
|
||||
}
|
||||
@@ -76,6 +83,25 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
themeAnimationCurve: Curves.easeInOut,
|
||||
routerConfig: router,
|
||||
locale: locale,
|
||||
localeResolutionCallback: (deviceLocale, supportedLocales) {
|
||||
if (locale != null) return locale;
|
||||
if (deviceLocale == null) return supportedLocales.first;
|
||||
|
||||
for (var supportedLocale in supportedLocales) {
|
||||
if (supportedLocale.languageCode == deviceLocale.languageCode &&
|
||||
supportedLocale.countryCode == deviceLocale.countryCode) {
|
||||
return supportedLocale;
|
||||
}
|
||||
}
|
||||
|
||||
for (var supportedLocale in supportedLocales) {
|
||||
if (supportedLocale.languageCode == deviceLocale.languageCode) {
|
||||
return supportedLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return supportedLocales.first;
|
||||
},
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.7.0';
|
||||
static const String buildNumber = '83';
|
||||
static const String version = '4.0.1';
|
||||
static const String buildNumber = '102';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -544,6 +544,54 @@ abstract class AppLocalizations {
|
||||
/// **'Try other services if download fails'**
|
||||
String get optionsAutoFallbackSubtitle;
|
||||
|
||||
/// Toggle to skip to the next queue track when current track stream resolution fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto Skip Unavailable Tracks'**
|
||||
String get optionsAutoSkipUnavailableTracks;
|
||||
|
||||
/// Subtitle when auto skip on resolve failure is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Automatically skip to the next queue track when a stream cannot be resolved.'**
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn;
|
||||
|
||||
/// Subtitle when auto skip on resolve failure is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stop on failed track resolution and show an error.'**
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff;
|
||||
|
||||
/// Tap behavior mode for track lists
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Interaction Mode'**
|
||||
String get optionsInteractionMode;
|
||||
|
||||
/// Interaction mode where taps queue downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloader Mode'**
|
||||
String get modeDownloader;
|
||||
|
||||
/// Subtitle for downloader interaction mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap tracks to add them to download queue'**
|
||||
String get modeDownloaderSubtitle;
|
||||
|
||||
/// Interaction mode where taps start playback
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Streaming Mode'**
|
||||
String get modeStreaming;
|
||||
|
||||
/// Subtitle for streaming interaction mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap tracks to play instantly'**
|
||||
String get modeStreamingSubtitle;
|
||||
|
||||
/// Enable extension download providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1906,6 +1954,12 @@ abstract class AppLocalizations {
|
||||
/// **'No tracks found'**
|
||||
String get errorNoTracksFound;
|
||||
|
||||
/// Error - seek disabled for live decrypted stream
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Seeking is not supported for this live stream'**
|
||||
String get errorSeekNotSupported;
|
||||
|
||||
/// Error - extension source not available
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2842,6 +2896,12 @@ abstract class AppLocalizations {
|
||||
/// **'Download All ({count})'**
|
||||
String downloadAllCount(int count);
|
||||
|
||||
/// Play all button with count
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play All ({count})'**
|
||||
String playAllCount(int count);
|
||||
|
||||
/// Track count display
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4048,12 +4108,24 @@ abstract class AppLocalizations {
|
||||
/// **'Download Discography'**
|
||||
String get discographyDownload;
|
||||
|
||||
/// Button - play artist discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play Discography'**
|
||||
String get discographyPlay;
|
||||
|
||||
/// Option - download entire discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download All'**
|
||||
String get discographyDownloadAll;
|
||||
|
||||
/// Option - play entire discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play All'**
|
||||
String get discographyPlayAll;
|
||||
|
||||
/// Subtitle showing total tracks and albums
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4120,6 +4192,12 @@ abstract class AppLocalizations {
|
||||
/// **'Download Selected'**
|
||||
String get discographyDownloadSelected;
|
||||
|
||||
/// Button - play selected albums
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play Selected'**
|
||||
String get discographyPlaySelected;
|
||||
|
||||
/// Snackbar - tracks added from discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -5555,6 +5633,384 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Converted {success} of {total} tracks to {format}'**
|
||||
String selectionBatchConvertSuccess(int success, int total, String format);
|
||||
|
||||
/// Title for mode selection step in setup wizard
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose Your Mode'**
|
||||
String get setupModeSelectionTitle;
|
||||
|
||||
/// Description for mode selection step
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How would you like to use SpotiFLAC? You can always change this later in Settings.'**
|
||||
String get setupModeSelectionDescription;
|
||||
|
||||
/// Title for downloader mode option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloader'**
|
||||
String get setupModeDownloaderTitle;
|
||||
|
||||
/// Downloader mode feature 1
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download tracks in lossless FLAC quality'**
|
||||
String get setupModeDownloaderFeature1;
|
||||
|
||||
/// Downloader mode feature 2
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save music to your device for offline listening'**
|
||||
String get setupModeDownloaderFeature2;
|
||||
|
||||
/// Downloader mode feature 3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Manage your local music library'**
|
||||
String get setupModeDownloaderFeature3;
|
||||
|
||||
/// Title for streaming mode option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Streaming'**
|
||||
String get setupModeStreamingTitle;
|
||||
|
||||
/// Streaming mode feature 1
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stream tracks instantly without downloading'**
|
||||
String get setupModeStreamingFeature1;
|
||||
|
||||
/// Streaming mode feature 2
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Smart Queue auto-discovers new music for you'**
|
||||
String get setupModeStreamingFeature2;
|
||||
|
||||
/// Streaming mode feature 3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play any track on demand with playback controls'**
|
||||
String get setupModeStreamingFeature3;
|
||||
|
||||
/// Hint that mode can be changed later
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You can switch between modes anytime in Settings.'**
|
||||
String get setupModeChangeableLater;
|
||||
|
||||
/// Title for Smart Queue toggle in settings
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Smart Queue'**
|
||||
String get settingsSmartQueueTitle;
|
||||
|
||||
/// Subtitle for Smart Queue toggle in settings
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Automatically discover and add similar tracks to your queue'**
|
||||
String get settingsSmartQueueSubtitle;
|
||||
|
||||
/// Title for the What's New screen
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'What\'s New in 4.0'**
|
||||
String get whatsNewTitle;
|
||||
|
||||
/// Subtitle for the What's New screen
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SpotiFLAC has evolved — here\'s what changed since 3.x'**
|
||||
String get whatsNewSubtitle;
|
||||
|
||||
/// Welcome page title in What's New screen
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SpotiFLAC Mobile 4.0'**
|
||||
String get whatsNewWelcomeTitle;
|
||||
|
||||
/// Welcome page description in What's New screen
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'**
|
||||
String get whatsNewWelcomeDesc;
|
||||
|
||||
/// Welcome page tip 1
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New streaming mode with instant playback'**
|
||||
String get whatsNewWelcomeTip1;
|
||||
|
||||
/// Welcome page tip 2
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Redesigned library and full-screen player'**
|
||||
String get whatsNewWelcomeTip2;
|
||||
|
||||
/// Welcome page tip 3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Batch tools, performance boosts, and more'**
|
||||
String get whatsNewWelcomeTip3;
|
||||
|
||||
/// What's New feature: Streaming Mode title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Streaming Mode'**
|
||||
String get whatsNewStreamingTitle;
|
||||
|
||||
/// What's New feature: Streaming Mode description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'**
|
||||
String get whatsNewStreamingDesc;
|
||||
|
||||
/// What's New feature: Smart Queue title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Smart Queue'**
|
||||
String get whatsNewSmartQueueTitle;
|
||||
|
||||
/// What's New feature: Smart Queue description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'**
|
||||
String get whatsNewSmartQueueDesc;
|
||||
|
||||
/// What's New feature: Dual Mode title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Dual Mode'**
|
||||
String get whatsNewDualModeTitle;
|
||||
|
||||
/// What's New feature: Dual Mode description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'**
|
||||
String get whatsNewDualModeDesc;
|
||||
|
||||
/// What's New feature: Library redesign title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Redesigned Library'**
|
||||
String get whatsNewLibraryTitle;
|
||||
|
||||
/// What's New feature: Library redesign description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'**
|
||||
String get whatsNewLibraryDesc;
|
||||
|
||||
/// What's New feature: Full-Screen Player title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Full-Screen Player'**
|
||||
String get whatsNewPlayerTitle;
|
||||
|
||||
/// What's New feature: Full-Screen Player description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'**
|
||||
String get whatsNewPlayerDesc;
|
||||
|
||||
/// What's New feature: Context Menus title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Long-Press Menus'**
|
||||
String get whatsNewContextMenuTitle;
|
||||
|
||||
/// What's New feature: Context Menus description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'**
|
||||
String get whatsNewContextMenuDesc;
|
||||
|
||||
/// What's New feature: Performance title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Performance'**
|
||||
String get whatsNewPerformanceTitle;
|
||||
|
||||
/// What's New feature: Performance description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'**
|
||||
String get whatsNewPerformanceDesc;
|
||||
|
||||
/// What's New feature: Batch Tools title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Batch Tools'**
|
||||
String get whatsNewBatchToolsTitle;
|
||||
|
||||
/// What's New feature: Batch Tools description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'**
|
||||
String get whatsNewBatchToolsDesc;
|
||||
|
||||
/// What's New tip: streaming instant play
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap any track to start playing instantly'**
|
||||
String get whatsNewStreamingTip1;
|
||||
|
||||
/// What's New tip: streaming synced lyrics
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Synced lyrics in the full-screen player'**
|
||||
String get whatsNewStreamingTip2;
|
||||
|
||||
/// What's New tip: streaming download from player
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download tracks directly from the player'**
|
||||
String get whatsNewStreamingTip3;
|
||||
|
||||
/// What's New tip: smart queue auto-fill
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Queue auto-fills with related tracks'**
|
||||
String get whatsNewSmartQueueTip1;
|
||||
|
||||
/// What's New tip: smart queue artist discovery
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Discover new artists as you listen'**
|
||||
String get whatsNewSmartQueueTip2;
|
||||
|
||||
/// What's New tip: smart queue endless
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Never run out of music to play'**
|
||||
String get whatsNewSmartQueueTip3;
|
||||
|
||||
/// What's New tip: dual mode switch
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Switch modes anytime in Settings'**
|
||||
String get whatsNewDualModeTip1;
|
||||
|
||||
/// What's New tip: dual mode adaptive UI
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'UI buttons adapt to your current mode'**
|
||||
String get whatsNewDualModeTip2;
|
||||
|
||||
/// What's New tip: dual mode use cases
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download for offline, stream for instant play'**
|
||||
String get whatsNewDualModeTip3;
|
||||
|
||||
/// What's New tip: library drag and drop
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Drag and drop to organize playlists'**
|
||||
String get whatsNewLibraryTip1;
|
||||
|
||||
/// What's New tip: library custom covers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Set custom cover images for playlists'**
|
||||
String get whatsNewLibraryTip2;
|
||||
|
||||
/// What's New tip: library multi-select
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Multi-select tracks for batch actions'**
|
||||
String get whatsNewLibraryTip3;
|
||||
|
||||
/// What's New tip: player parallax
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cover art with parallax scrolling effect'**
|
||||
String get whatsNewPlayerTip1;
|
||||
|
||||
/// What's New tip: player persistence
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playback persists across app restarts'**
|
||||
String get whatsNewPlayerTip2;
|
||||
|
||||
/// What's New tip: player lyrics
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Synced lyrics while you listen'**
|
||||
String get whatsNewPlayerTip3;
|
||||
|
||||
/// What's New tip: context menu add to playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add tracks to any playlist instantly'**
|
||||
String get whatsNewContextMenuTip1;
|
||||
|
||||
/// What's New tip: context menu share/convert
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Share or convert with one tap'**
|
||||
String get whatsNewContextMenuTip2;
|
||||
|
||||
/// What's New tip: context menu re-enrich
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Re-enrich metadata when needed'**
|
||||
String get whatsNewContextMenuTip3;
|
||||
|
||||
/// What's New tip: batch share
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Share multiple tracks at once'**
|
||||
String get whatsNewBatchToolsTip1;
|
||||
|
||||
/// What's New tip: batch convert
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Batch convert to MP3 or Opus format'**
|
||||
String get whatsNewBatchToolsTip2;
|
||||
|
||||
/// What's New tip: batch re-enrich
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Re-enrich metadata across your library'**
|
||||
String get whatsNewBatchToolsTip3;
|
||||
|
||||
/// What's New tip: performance startup
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Faster app startup time'**
|
||||
String get whatsNewPerformanceTip1;
|
||||
|
||||
/// What's New tip: performance memory
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reduced memory usage during playback'**
|
||||
String get whatsNewPerformanceTip2;
|
||||
|
||||
/// What's New tip: performance SQLite
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SQLite-backed storage for reliability'**
|
||||
String get whatsNewPerformanceTip3;
|
||||
|
||||
/// Ready card message on last What's New page
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You\'re all set — enjoy the new SpotiFLAC!'**
|
||||
String get whatsNewReadyMessage;
|
||||
|
||||
/// Button text to dismiss What's New screen
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Let\'s Go'**
|
||||
String get whatsNewGetStarted;
|
||||
|
||||
/// Page indicator text in What's New screen
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{current} of {total}'**
|
||||
String whatsNewPageIndicator(int current, int total);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -251,6 +251,33 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Andere Dienste versuchen, wenn Download fehlschlägt';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Erweiterungs-Anbieter verwenden';
|
||||
|
||||
@@ -1057,6 +1084,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Keine Titel gefunden';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Kann $item nicht lade wegen fehlender Erweiterungsquelle';
|
||||
@@ -1578,6 +1609,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2256,9 +2292,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2303,6 +2345,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -3202,4 +3247,218 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Wähle deinen Modus';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Wie möchtest du SpotiFLAC nutzen? Du kannst dies später jederzeit in den Einstellungen ändern.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Downloader';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Lade Titel in verlustfreier FLAC-Qualität herunter';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Speichere Musik auf deinem Gerät zum Offline-Hören';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Verwalte deine lokale Musikbibliothek';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Streame Titel sofort ohne Herunterladen';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue entdeckt automatisch neue Musik für dich';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Spiele jeden Titel auf Abruf mit Wiedergabesteuerung';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln.';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle =>
|
||||
'Automatisch ähnliche Titel entdecken und zu deiner Warteschlange hinzufügen';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'What\'s New in 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC has evolved — here\'s what changed since 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Dual Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Redesigned Library';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Full-Screen Player';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Long-Press Menus';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performance';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Batch Tools';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Tap any track to start playing instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 =>
|
||||
'Download tracks directly from the player';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Download for offline, stream for instant play';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Faster app startup time';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage =>
|
||||
'You\'re all set — enjoy the new SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Let\'s Go';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1041,6 +1068,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1557,6 +1588,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2235,9 +2271,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2282,6 +2324,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -3181,4 +3226,217 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Choose Your Mode';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'How would you like to use SpotiFLAC? You can always change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Downloader';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Download tracks in lossless FLAC quality';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Save music to your device for offline listening';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => 'Manage your local music library';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Stream tracks instantly without downloading';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue auto-discovers new music for you';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Play any track on demand with playback controls';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'You can switch between modes anytime in Settings.';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle =>
|
||||
'Automatically discover and add similar tracks to your queue';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'What\'s New in 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC has evolved — here\'s what changed since 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Dual Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Redesigned Library';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Full-Screen Player';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Long-Press Menus';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performance';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Batch Tools';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Tap any track to start playing instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 =>
|
||||
'Download tracks directly from the player';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Download for offline, stream for instant play';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Faster app startup time';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage =>
|
||||
'You\'re all set — enjoy the new SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Let\'s Go';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1041,6 +1068,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1557,6 +1588,11 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2235,9 +2271,15 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2282,6 +2324,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -3181,6 +3226,220 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Elige tu modo';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Descargador';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Descarga pistas en calidad FLAC sin pérdida';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Guarda música en tu dispositivo para escuchar sin conexión';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Gestiona tu biblioteca de música local';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Transmite pistas al instante sin descargar';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue descubre automáticamente nueva música para ti';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Reproduce cualquier pista bajo demanda con controles de reproducción';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Puedes cambiar entre modos en cualquier momento en Ajustes.';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle =>
|
||||
'Descubre y añade automáticamente pistas similares a tu cola de reproducción';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'What\'s New in 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC has evolved — here\'s what changed since 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Dual Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Redesigned Library';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Full-Screen Player';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Long-Press Menus';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performance';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Batch Tools';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Tap any track to start playing instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 =>
|
||||
'Download tracks directly from the player';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Download for offline, stream for instant play';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Faster app startup time';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage =>
|
||||
'You\'re all set — enjoy the new SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Let\'s Go';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||
@@ -6147,4 +6406,52 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Elige tu modo';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Descargador';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Descarga pistas en calidad FLAC sin pérdida';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Guarda música en tu dispositivo para escuchar sin conexión';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Gestiona tu biblioteca de música local';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Transmite pistas al instante sin descargar';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue descubre automáticamente nueva música para ti';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Reproduce cualquier pista bajo demanda con controles de reproducción';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Puedes cambiar entre modos en cualquier momento en Ajustes.';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle =>
|
||||
'Descubre y añade automáticamente pistas similares a tu cola de reproducción';
|
||||
}
|
||||
|
||||
@@ -253,6 +253,33 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Essayez d\'autres services si le téléchargement échoue';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders =>
|
||||
'Utiliser des fournisseurs d\'extension';
|
||||
@@ -1047,6 +1074,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1563,6 +1594,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2241,9 +2277,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2288,6 +2330,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -3187,4 +3232,218 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Choisissez votre mode';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Comment souhaitez-vous utiliser SpotiFLAC ? Vous pouvez toujours changer cela plus tard dans les Paramètres.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Téléchargeur';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Téléchargez des pistes en qualité FLAC sans perte';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Enregistrez de la musique sur votre appareil pour une écoute hors ligne';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Gérez votre bibliothèque musicale locale';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Diffusez des pistes instantanément sans télécharger';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue découvre automatiquement de nouvelle musique pour vous';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Écoutez n\'importe quelle piste à la demande avec les contrôles de lecture';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Vous pouvez changer de mode à tout moment dans les Paramètres.';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle =>
|
||||
'Découvrir et ajouter automatiquement des pistes similaires à votre file d\'attente';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'What\'s New in 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC has evolved — here\'s what changed since 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Dual Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Redesigned Library';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Full-Screen Player';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Long-Press Menus';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performance';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Batch Tools';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Tap any track to start playing instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 =>
|
||||
'Download tracks directly from the player';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Download for offline, stream for instant play';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Faster app startup time';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage =>
|
||||
'You\'re all set — enjoy the new SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Let\'s Go';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1041,6 +1068,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1557,6 +1588,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2235,9 +2271,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2282,6 +2324,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -3181,4 +3226,218 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'अपना मोड चुनें';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'आप SpotiFLAC का उपयोग कैसे करना चाहेंगे? आप इसे बाद में सेटिंग्स में कभी भी बदल सकते हैं।';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'डाउनलोडर';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'लॉसलेस FLAC गुणवत्ता में ट्रैक डाउनलोड करें';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'ऑफ़लाइन सुनने के लिए संगीत अपने डिवाइस में सहेजें';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'अपनी स्थानीय संगीत लाइब्रेरी प्रबंधित करें';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'स्ट्रीमिंग';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'आप सेटिंग्स में कभी भी मोड बदल सकते हैं।';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle =>
|
||||
'स्वचालित रूप से समान ट्रैक खोजें और अपनी कतार में जोड़ें';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'What\'s New in 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC has evolved — here\'s what changed since 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Dual Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Redesigned Library';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Full-Screen Player';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Long-Press Menus';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performance';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Batch Tools';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Tap any track to start playing instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 =>
|
||||
'Download tracks directly from the player';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Download for offline, stream for instant play';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Faster app startup time';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage =>
|
||||
'You\'re all set — enjoy the new SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Let\'s Go';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +251,34 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Coba layanan lain jika unduhan gagal';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks =>
|
||||
'Lewati Otomatis Lagu yang Tidak Tersedia';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Otomatis lanjut ke lagu berikutnya di antrean jika stream lagu tidak bisa ditemukan.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Berhenti di lagu yang gagal dan tampilkan pesan error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Mode Interaksi';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Mode Downloader';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Ketuk lagu untuk menambah ke antrean unduhan';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Mode Streaming';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Ketuk lagu untuk langsung memutar';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Gunakan Provider Ekstensi';
|
||||
|
||||
@@ -1047,6 +1075,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Menggeser posisi lagu tidak didukung untuk live stream ini';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Tidak dapat memuat $item: sumber ekstensi tidak ada';
|
||||
@@ -1567,6 +1599,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Unduh Semua ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Putar Semua ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2248,9 +2285,15 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Putar Diskografi';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Unduh Semua';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Putar Semua';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2295,6 +2338,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Putar Terpilih';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -3144,31 +3190,32 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
other: 'trek',
|
||||
one: 'trek',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
return 'Bagikan $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
other: 'trek',
|
||||
one: 'trek',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
return 'Konversi $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
String get selectionConvertNoConvertible =>
|
||||
'Tidak ada trek yang dapat dikonversi dipilih';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
String get selectionBatchConvertConfirmTitle => 'Konversi Massal';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
@@ -3179,19 +3226,242 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
other: 'trek',
|
||||
one: 'trek',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
return 'Mengonversi $current dari $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
return 'Berhasil mengonversi $success dari $total trek ke $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Pilih Mode Anda';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Bagaimana Anda ingin menggunakan SpotiFLAC? Anda dapat mengubahnya nanti di Pengaturan.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Pengunduh';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Unduh trek dalam kualitas FLAC lossless';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Simpan musik ke perangkat Anda untuk mendengarkan offline';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Kelola perpustakaan musik lokal Anda';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Streaming trek secara instan tanpa mengunduh';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue secara otomatis menemukan musik baru untuk Anda';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Putar trek apa pun sesuai permintaan dengan kontrol pemutaran';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Anda dapat beralih antar mode kapan saja di Pengaturan.';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle =>
|
||||
'Secara otomatis temukan dan tambahkan trek serupa ke antrean Anda';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'Yang Baru di 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC telah berevolusi — inilah yang berubah sejak 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Selamat datang kembali! Ini pembaruan besar dengan banyak fitur baru. Geser untuk melihat apa yang berubah.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 =>
|
||||
'Mode streaming baru dengan pemutaran instan';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 =>
|
||||
'Perpustakaan dan pemutar layar penuh yang didesain ulang';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 =>
|
||||
'Alat massal, peningkatan performa, dan lainnya';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Mode Streaming';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Ketuk trek apa pun untuk langsung diputar — tanpa perlu mengunduh. Pemutar layar penuh dengan lirik tersinkron dan kontrol media.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Antrean Anda otomatis mengkurasi trek terkait dan penemuan artis. Tak pernah kehabisan musik.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Mode Ganda';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Beralih antara mode Pengunduh dan Streaming kapan saja. Semua tombol menyesuaikan secara otomatis.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Perpustakaan Baru';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Tata letak berbasis playlist dengan kategorisasi seret-dan-lepas, sampul kustom, dan aksi massal multi-pilih.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Pemutar Layar Penuh';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Paralaks seni sampul, lirik tersinkron, pemutaran tetap tersimpan saat restart, dan tombol unduh di pemutar.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Menu Tekan Lama';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Tekan lama trek apa pun untuk aksi cepat — tambah ke playlist, bagikan, konversi, atau perbarui metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performa';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Startup lebih cepat, penggunaan memori berkurang, penyimpanan berbasis SQLite, dan pembaruan UI yang lebih efisien.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Alat Massal';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Berbagi multi-pilih, konversi massal ke MP3/Opus, dan perbarui metadata secara massal di seluruh perpustakaan.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Ketuk trek apa pun untuk langsung memutar';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Lirik tersinkron di pemutar layar penuh';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 => 'Unduh trek langsung dari pemutar';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 =>
|
||||
'Antrean terisi otomatis dengan trek terkait';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Temukan artis baru saat mendengarkan';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 =>
|
||||
'Tak pernah kehabisan musik untuk diputar';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Beralih mode kapan saja di Pengaturan';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'Tombol UI menyesuaikan dengan mode Anda';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Unduh untuk offline, streaming untuk putar langsung';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Seret dan lepas untuk mengatur playlist';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Atur gambar sampul kustom untuk playlist';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Pilih banyak trek untuk aksi massal';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Seni sampul dengan efek paralaks';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Pemutaran tetap tersimpan saat restart';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Lirik tersinkron saat mendengarkan';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 =>
|
||||
'Tambahkan trek ke playlist mana pun langsung';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 =>
|
||||
'Bagikan atau konversi dengan satu ketukan';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Perbarui metadata saat diperlukan';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Bagikan banyak trek sekaligus';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 =>
|
||||
'Konversi massal ke format MP3 atau Opus';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 =>
|
||||
'Perbarui metadata di seluruh perpustakaan';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Waktu startup aplikasi lebih cepat';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 =>
|
||||
'Penggunaan memori berkurang saat pemutaran';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 =>
|
||||
'Penyimpanan berbasis SQLite untuk keandalan';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage => 'Siap — nikmati SpotiFLAC yang baru!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Ayo Mulai';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current dari $total';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する';
|
||||
|
||||
@@ -1035,6 +1062,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'トラックがありません';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return '$item を読み込めません: 拡張ソースがありません';
|
||||
@@ -1550,6 +1581,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return 'すべてダウンロード ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2221,9 +2257,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'ディスコグラフィをダウンロード';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'すべてダウンロード';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$albumCount 個のリリースから $count 個のトラック';
|
||||
@@ -2268,6 +2310,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => '選択済みをダウンロード';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -3167,4 +3212,210 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'モードを選択';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'SpotiFLACをどのように使いますか?この設定は後からいつでも変更できます。';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'ダウンローダー';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 => 'ロスレスFLAC品質でトラックをダウンロード';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 => 'オフライン再生用に音楽をデバイスに保存';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => 'ローカル音楽ライブラリを管理';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'ストリーミング';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 => 'ダウンロードせずにトラックを即座にストリーミング';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 => 'Smart Queueが自動的に新しい音楽を見つけます';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 => '再生コントロールで任意のトラックをオンデマンド再生';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater => '設定からいつでもモードを切り替えられます。';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle => '類似トラックを自動的に検出してキューに追加';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'What\'s New in 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC has evolved — here\'s what changed since 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Dual Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Redesigned Library';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Full-Screen Player';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Long-Press Menus';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performance';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Batch Tools';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Tap any track to start playing instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 =>
|
||||
'Download tracks directly from the player';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Download for offline, stream for instant play';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Faster app startup time';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage =>
|
||||
'You\'re all set — enjoy the new SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Let\'s Go';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,33 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1040,6 +1067,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1556,6 +1587,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2234,9 +2270,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2281,6 +2323,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -3180,4 +3225,210 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => '모드 선택';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'SpotiFLAC을 어떻게 사용하시겠습니까? 나중에 설정에서 언제든지 변경할 수 있습니다.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => '다운로더';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 => '무손실 FLAC 품질로 트랙 다운로드';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 => '오프라인 감상을 위해 기기에 음악 저장';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => '로컬 음악 라이브러리 관리';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => '스트리밍';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 => '다운로드 없이 트랙을 즉시 스트리밍';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 => 'Smart Queue가 자동으로 새로운 음악을 발견합니다';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 => '재생 컨트롤로 원하는 트랙을 온디맨드 재생';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater => '설정에서 언제든지 모드를 전환할 수 있습니다.';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle => '유사한 트랙을 자동으로 검색하여 대기열에 추가';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'What\'s New in 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC has evolved — here\'s what changed since 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Dual Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Redesigned Library';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Full-Screen Player';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Long-Press Menus';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performance';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Batch Tools';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Tap any track to start playing instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 =>
|
||||
'Download tracks directly from the player';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Download for offline, stream for instant play';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Faster app startup time';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage =>
|
||||
'You\'re all set — enjoy the new SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Let\'s Go';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1041,6 +1068,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1557,6 +1588,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2235,9 +2271,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2282,6 +2324,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -3181,4 +3226,218 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Kies je modus';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Hoe wil je SpotiFLAC gebruiken? Je kunt dit later altijd wijzigen in Instellingen.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Downloader';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Download nummers in lossless FLAC-kwaliteit';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Sla muziek op je apparaat op om offline te luisteren';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Beheer je lokale muziekbibliotheek';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Stream nummers direct zonder te downloaden';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue ontdekt automatisch nieuwe muziek voor je';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Speel elk nummer op aanvraag af met afspeelbediening';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Je kunt op elk moment wisselen tussen modi in Instellingen.';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle =>
|
||||
'Ontdek automatisch vergelijkbare nummers en voeg ze toe aan je wachtrij';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'What\'s New in 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC has evolved — here\'s what changed since 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Dual Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Redesigned Library';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Full-Screen Player';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Long-Press Menus';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performance';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Batch Tools';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Tap any track to start playing instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 =>
|
||||
'Download tracks directly from the player';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Download for offline, stream for instant play';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Faster app startup time';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage =>
|
||||
'You\'re all set — enjoy the new SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Let\'s Go';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1041,6 +1068,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1557,6 +1588,11 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2235,9 +2271,15 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2282,6 +2324,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -3181,6 +3226,220 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Escolha seu modo';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Como você gostaria de usar o SpotiFLAC? Você pode alterar isso depois nas Configurações.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Downloader';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Baixe faixas em qualidade FLAC lossless';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Salve músicas no seu dispositivo para ouvir offline';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Gerencie sua biblioteca de músicas local';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Transmita faixas instantaneamente sem baixar';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue descobre automaticamente novas músicas para você';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Reproduza qualquer faixa sob demanda com controles de reprodução';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Você pode alternar entre os modos a qualquer momento nas Configurações.';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle =>
|
||||
'Descubra e adicione automaticamente faixas semelhantes à sua fila';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'What\'s New in 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC has evolved — here\'s what changed since 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Dual Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Redesigned Library';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Full-Screen Player';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Long-Press Menus';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performance';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Batch Tools';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Tap any track to start playing instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 =>
|
||||
'Download tracks directly from the player';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Download for offline, stream for instant play';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Faster app startup time';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage =>
|
||||
'You\'re all set — enjoy the new SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Let\'s Go';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||
@@ -6141,4 +6400,52 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Escolha o seu modo';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Como gostaria de utilizar o SpotiFLAC? Pode alterar isto mais tarde nas Definições.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Transferência';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Transfira faixas em qualidade FLAC sem perdas';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Guarde música no seu dispositivo para ouvir offline';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Faça a gestão da sua biblioteca de música local';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Streaming';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Transmita faixas instantaneamente sem transferir';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue descobre automaticamente novas músicas para si';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Reproduza qualquer faixa a pedido com controlos de reprodução';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Pode alternar entre modos a qualquer momento nas Definições.';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle =>
|
||||
'Descubra e adicione automaticamente faixas semelhantes à sua fila';
|
||||
}
|
||||
|
||||
@@ -255,6 +255,33 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Попробовать другие сервисы при сбое загрузки';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders =>
|
||||
'Использовать провайдера расширений';
|
||||
@@ -1066,6 +1093,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Треки не найдены';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Невозможно загрузить $item: отсутствует источник расширения';
|
||||
@@ -1587,6 +1618,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Скачать все ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2283,9 +2319,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Скачать дискографию';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Скачать всё';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count треков из $albumCount релизов';
|
||||
@@ -2330,6 +2372,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Скачать выбранное';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Добавлено $count треков в очередь';
|
||||
@@ -3279,4 +3324,218 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Выберите режим';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'Как вы хотите использовать SpotiFLAC? Вы всегда можете изменить это позже в Настройках.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'Загрузчик';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Скачивайте треки в качестве FLAC без потерь';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Сохраняйте музыку на устройство для прослушивания офлайн';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 =>
|
||||
'Управляйте своей локальной музыкальной библиотекой';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Стриминг';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'Слушайте треки мгновенно без скачивания';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue автоматически подбирает новую музыку для вас';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'Воспроизводите любой трек по запросу с элементами управления';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Вы можете переключаться между режимами в любое время в Настройках.';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle =>
|
||||
'Автоматически находите и добавляйте похожие треки в очередь воспроизведения';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'What\'s New in 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC has evolved — here\'s what changed since 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Dual Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Redesigned Library';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Full-Screen Player';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Long-Press Menus';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performance';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Batch Tools';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Tap any track to start playing instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 =>
|
||||
'Download tracks directly from the player';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Download for offline, stream for instant play';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Faster app startup time';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage =>
|
||||
'You\'re all set — enjoy the new SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Let\'s Go';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +252,33 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'İndirme başarısız olursa diğer hizmetleri dene';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Eklenti sağlayıcılarını kullan';
|
||||
|
||||
@@ -1048,6 +1075,10 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Parça bulunamadı';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return '$item yüklenemedi: Eksik eklenti kaynağı';
|
||||
@@ -1570,6 +1601,11 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
return 'Tümünü İndir ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2250,9 +2286,15 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2297,6 +2339,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return '$count şarkı kuyruğa eklendi';
|
||||
@@ -3196,4 +3241,217 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => 'Modunuzu Seçin';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription =>
|
||||
'SpotiFLAC\'ı nasıl kullanmak istersiniz? Bunu daha sonra Ayarlar\'dan değiştirebilirsiniz.';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => 'İndirici';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 =>
|
||||
'Kayıpsız FLAC kalitesinde parça indirin';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 =>
|
||||
'Çevrimdışı dinlemek için müziği cihazınıza kaydedin';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => 'Yerel müzik kütüphanenizi yönetin';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => 'Yayın Akışı';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 =>
|
||||
'İndirmeden parçaları anında yayınlayın';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 =>
|
||||
'Smart Queue sizin için otomatik olarak yeni müzik keşfeder';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 =>
|
||||
'İstediğiniz parçayı oynatma kontrolleriyle çalın';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater =>
|
||||
'Ayarlar\'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz.';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle =>
|
||||
'Sıranıza otomatik olarak benzer parçalar keşfedin ve ekleyin';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'What\'s New in 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC has evolved — here\'s what changed since 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Dual Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Redesigned Library';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Full-Screen Player';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Long-Press Menus';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performance';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Batch Tools';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Tap any track to start playing instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 =>
|
||||
'Download tracks directly from the player';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Download for offline, stream for instant play';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Faster app startup time';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage =>
|
||||
'You\'re all set — enjoy the new SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Let\'s Go';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,33 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
|
||||
'Automatically skip to the next queue track when a stream cannot be resolved.';
|
||||
|
||||
@override
|
||||
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
|
||||
'Stop on failed track resolution and show an error.';
|
||||
|
||||
@override
|
||||
String get optionsInteractionMode => 'Interaction Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloader => 'Downloader Mode';
|
||||
|
||||
@override
|
||||
String get modeDownloaderSubtitle =>
|
||||
'Tap tracks to add them to download queue';
|
||||
|
||||
@override
|
||||
String get modeStreaming => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@@ -1041,6 +1068,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorSeekNotSupported =>
|
||||
'Seeking is not supported for this live stream';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -1557,6 +1588,11 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String playAllCount(int count) {
|
||||
return 'Play All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String tracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
@@ -2235,9 +2271,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyPlay => 'Play Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String get discographyPlayAll => 'Play All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
@@ -2282,6 +2324,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String get discographyPlaySelected => 'Play Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
@@ -3181,6 +3226,211 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => '选择您的模式';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => '下载器';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 => '以无损 FLAC 品质下载曲目';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 => '将音乐保存到设备以供离线收听';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => '管理您的本地音乐库';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => '流媒体';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 => '无需下载即可即时播放曲目';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 => 'Smart Queue 自动为您发现新音乐';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 => '通过播放控件随时点播任意曲目';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater => '您可以随时在设置中切换模式。';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle => '自动发现并将相似曲目添加到您的队列中';
|
||||
|
||||
@override
|
||||
String get whatsNewTitle => 'What\'s New in 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewSubtitle =>
|
||||
'SpotiFLAC has evolved — here\'s what changed since 3.x';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeDesc =>
|
||||
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTitle => 'Streaming Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingDesc =>
|
||||
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueDesc =>
|
||||
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTitle => 'Dual Mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeDesc =>
|
||||
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTitle => 'Redesigned Library';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryDesc =>
|
||||
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTitle => 'Full-Screen Player';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerDesc =>
|
||||
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTitle => 'Long-Press Menus';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuDesc =>
|
||||
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTitle => 'Performance';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceDesc =>
|
||||
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTitle => 'Batch Tools';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsDesc =>
|
||||
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip1 =>
|
||||
'Tap any track to start playing instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
|
||||
|
||||
@override
|
||||
String get whatsNewStreamingTip3 =>
|
||||
'Download tracks directly from the player';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
|
||||
|
||||
@override
|
||||
String get whatsNewDualModeTip3 =>
|
||||
'Download for offline, stream for instant play';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
|
||||
|
||||
@override
|
||||
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
|
||||
|
||||
@override
|
||||
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
|
||||
|
||||
@override
|
||||
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
|
||||
|
||||
@override
|
||||
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip1 => 'Faster app startup time';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
|
||||
|
||||
@override
|
||||
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
|
||||
|
||||
@override
|
||||
String get whatsNewReadyMessage =>
|
||||
'You\'re all set — enjoy the new SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get whatsNewGetStarted => 'Let\'s Go';
|
||||
|
||||
@override
|
||||
String whatsNewPageIndicator(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||
@@ -6114,6 +6364,45 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => '选择您的模式';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => '下载器';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 => '以无损 FLAC 品质下载曲目';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 => '将音乐保存到设备以供离线收听';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => '管理您的本地音乐库';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => '流媒体';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 => '无需下载即可即时播放曲目';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 => 'Smart Queue 自动为您发现新音乐';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 => '通过播放控件随时点播任意曲目';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater => '您可以随时在设置中切换模式。';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle => '自动发现并将相似曲目添加到您的队列中';
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
|
||||
@@ -9047,4 +9336,43 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionTitle => '選擇您的模式';
|
||||
|
||||
@override
|
||||
String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍後在設定中隨時變更。';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderTitle => '下載器';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature1 => '以無損 FLAC 品質下載曲目';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature2 => '將音樂儲存到裝置以供離線收聽';
|
||||
|
||||
@override
|
||||
String get setupModeDownloaderFeature3 => '管理您的本機音樂庫';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingTitle => '串流';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature1 => '無需下載即可即時串流曲目';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature2 => 'Smart Queue 自動為您探索新音樂';
|
||||
|
||||
@override
|
||||
String get setupModeStreamingFeature3 => '透過播放控制項隨時點播任意曲目';
|
||||
|
||||
@override
|
||||
String get setupModeChangeableLater => '您可以隨時在設定中切換模式。';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueTitle => 'Smart Queue';
|
||||
|
||||
@override
|
||||
String get settingsSmartQueueSubtitle => '自動探索並將相似曲目新增到您的佇列中';
|
||||
}
|
||||
|
||||
+14
-1
@@ -3868,5 +3868,18 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "Wähle deinen Modus",
|
||||
"setupModeSelectionDescription": "Wie möchtest du SpotiFLAC nutzen? Du kannst dies später jederzeit in den Einstellungen ändern.",
|
||||
"setupModeDownloaderTitle": "Downloader",
|
||||
"setupModeDownloaderFeature1": "Lade Titel in verlustfreier FLAC-Qualität herunter",
|
||||
"setupModeDownloaderFeature2": "Speichere Musik auf deinem Gerät zum Offline-Hören",
|
||||
"setupModeDownloaderFeature3": "Verwalte deine lokale Musikbibliothek",
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"setupModeStreamingFeature1": "Streame Titel sofort ohne Herunterladen",
|
||||
"setupModeStreamingFeature2": "Smart Queue entdeckt automatisch neue Musik für dich",
|
||||
"setupModeStreamingFeature3": "Spiele jeden Titel auf Abruf mit Wiedergabesteuerung",
|
||||
"setupModeChangeableLater": "Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln.",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "Automatisch ähnliche Titel entdecken und zu deiner Warteschlange hinzufügen"
|
||||
}
|
||||
@@ -175,6 +175,22 @@
|
||||
"@optionsAutoFallback": {"description": "Auto-retry with other services"},
|
||||
"optionsAutoFallbackSubtitle": "Try other services if download fails",
|
||||
"@optionsAutoFallbackSubtitle": {"description": "Subtitle for auto fallback"},
|
||||
"optionsAutoSkipUnavailableTracks": "Auto Skip Unavailable Tracks",
|
||||
"@optionsAutoSkipUnavailableTracks": {"description": "Toggle to skip to the next queue track when current track stream resolution fails"},
|
||||
"optionsAutoSkipUnavailableTracksSubtitleOn": "Automatically skip to the next queue track when a stream cannot be resolved.",
|
||||
"@optionsAutoSkipUnavailableTracksSubtitleOn": {"description": "Subtitle when auto skip on resolve failure is enabled"},
|
||||
"optionsAutoSkipUnavailableTracksSubtitleOff": "Stop on failed track resolution and show an error.",
|
||||
"@optionsAutoSkipUnavailableTracksSubtitleOff": {"description": "Subtitle when auto skip on resolve failure is disabled"},
|
||||
"optionsInteractionMode": "Interaction Mode",
|
||||
"@optionsInteractionMode": {"description": "Tap behavior mode for track lists"},
|
||||
"modeDownloader": "Downloader Mode",
|
||||
"@modeDownloader": {"description": "Interaction mode where taps queue downloads"},
|
||||
"modeDownloaderSubtitle": "Tap tracks to add them to download queue",
|
||||
"@modeDownloaderSubtitle": {"description": "Subtitle for downloader interaction mode"},
|
||||
"modeStreaming": "Streaming Mode",
|
||||
"@modeStreaming": {"description": "Interaction mode where taps start playback"},
|
||||
"modeStreamingSubtitle": "Tap tracks to play instantly",
|
||||
"@modeStreamingSubtitle": {"description": "Subtitle for streaming interaction mode"},
|
||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||
"@optionsUseExtensionProviders": {"description": "Enable extension download providers"},
|
||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
||||
@@ -759,6 +775,8 @@
|
||||
},
|
||||
"errorNoTracksFound": "No tracks found",
|
||||
"@errorNoTracksFound": {"description": "Error - search returned no results"},
|
||||
"errorSeekNotSupported": "Seeking is not supported for this live stream",
|
||||
"@errorSeekNotSupported": {"description": "Error - seek disabled for live decrypted stream"},
|
||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -1151,6 +1169,13 @@
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"playAllCount": "Play All ({count})",
|
||||
"@playAllCount": {
|
||||
"description": "Play all button with count",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||
"@tracksCount": {
|
||||
"description": "Track count display",
|
||||
@@ -1669,8 +1694,12 @@
|
||||
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {"description": "Button - download artist discography"},
|
||||
"discographyPlay": "Play Discography",
|
||||
"@discographyPlay": {"description": "Button - play artist discography"},
|
||||
"discographyDownloadAll": "Download All",
|
||||
"@discographyDownloadAll": {"description": "Option - download entire discography"},
|
||||
"discographyPlayAll": "Play All",
|
||||
"@discographyPlayAll": {"description": "Option - play entire discography"},
|
||||
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
@@ -1722,6 +1751,8 @@
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {"description": "Button - download selected albums"},
|
||||
"discographyPlaySelected": "Play Selected",
|
||||
"@discographyPlaySelected": {"description": "Button - play selected albums"},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
@@ -2441,5 +2472,140 @@
|
||||
"total": {"type": "int"},
|
||||
"format": {"type": "String"}
|
||||
}
|
||||
},
|
||||
|
||||
"setupModeSelectionTitle": "Choose Your Mode",
|
||||
"@setupModeSelectionTitle": {"description": "Title for mode selection step in setup wizard"},
|
||||
"setupModeSelectionDescription": "How would you like to use SpotiFLAC? You can always change this later in Settings.",
|
||||
"@setupModeSelectionDescription": {"description": "Description for mode selection step"},
|
||||
"setupModeDownloaderTitle": "Downloader",
|
||||
"@setupModeDownloaderTitle": {"description": "Title for downloader mode option"},
|
||||
"setupModeDownloaderFeature1": "Download tracks in lossless FLAC quality",
|
||||
"@setupModeDownloaderFeature1": {"description": "Downloader mode feature 1"},
|
||||
"setupModeDownloaderFeature2": "Save music to your device for offline listening",
|
||||
"@setupModeDownloaderFeature2": {"description": "Downloader mode feature 2"},
|
||||
"setupModeDownloaderFeature3": "Manage your local music library",
|
||||
"@setupModeDownloaderFeature3": {"description": "Downloader mode feature 3"},
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"@setupModeStreamingTitle": {"description": "Title for streaming mode option"},
|
||||
"setupModeStreamingFeature1": "Stream tracks instantly without downloading",
|
||||
"@setupModeStreamingFeature1": {"description": "Streaming mode feature 1"},
|
||||
"setupModeStreamingFeature2": "Smart Queue auto-discovers new music for you",
|
||||
"@setupModeStreamingFeature2": {"description": "Streaming mode feature 2"},
|
||||
"setupModeStreamingFeature3": "Play any track on demand with playback controls",
|
||||
"@setupModeStreamingFeature3": {"description": "Streaming mode feature 3"},
|
||||
"setupModeChangeableLater": "You can switch between modes anytime in Settings.",
|
||||
"@setupModeChangeableLater": {"description": "Hint that mode can be changed later"},
|
||||
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"@settingsSmartQueueTitle": {"description": "Title for Smart Queue toggle in settings"},
|
||||
"settingsSmartQueueSubtitle": "Automatically discover and add similar tracks to your queue",
|
||||
"@settingsSmartQueueSubtitle": {"description": "Subtitle for Smart Queue toggle in settings"},
|
||||
|
||||
"whatsNewTitle": "What's New in 4.0",
|
||||
"@whatsNewTitle": {"description": "Title for the What's New screen"},
|
||||
"whatsNewSubtitle": "SpotiFLAC has evolved — here's what changed since 3.x",
|
||||
"@whatsNewSubtitle": {"description": "Subtitle for the What's New screen"},
|
||||
"whatsNewWelcomeTitle": "SpotiFLAC Mobile 4.0",
|
||||
"@whatsNewWelcomeTitle": {"description": "Welcome page title in What's New screen"},
|
||||
"whatsNewWelcomeDesc": "Welcome back! This is a major update packed with new features. Swipe through to see what's changed.",
|
||||
"@whatsNewWelcomeDesc": {"description": "Welcome page description in What's New screen"},
|
||||
"whatsNewWelcomeTip1": "New streaming mode with instant playback",
|
||||
"@whatsNewWelcomeTip1": {"description": "Welcome page tip 1"},
|
||||
"whatsNewWelcomeTip2": "Redesigned library and full-screen player",
|
||||
"@whatsNewWelcomeTip2": {"description": "Welcome page tip 2"},
|
||||
"whatsNewWelcomeTip3": "Batch tools, performance boosts, and more",
|
||||
"@whatsNewWelcomeTip3": {"description": "Welcome page tip 3"},
|
||||
"whatsNewStreamingTitle": "Streaming Mode",
|
||||
"@whatsNewStreamingTitle": {"description": "What's New feature: Streaming Mode title"},
|
||||
"whatsNewStreamingDesc": "Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.",
|
||||
"@whatsNewStreamingDesc": {"description": "What's New feature: Streaming Mode description"},
|
||||
"whatsNewSmartQueueTitle": "Smart Queue",
|
||||
"@whatsNewSmartQueueTitle": {"description": "What's New feature: Smart Queue title"},
|
||||
"whatsNewSmartQueueDesc": "Your queue auto-curates with related tracks and artist discovery. Never run out of music.",
|
||||
"@whatsNewSmartQueueDesc": {"description": "What's New feature: Smart Queue description"},
|
||||
"whatsNewDualModeTitle": "Dual Mode",
|
||||
"@whatsNewDualModeTitle": {"description": "What's New feature: Dual Mode title"},
|
||||
"whatsNewDualModeDesc": "Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.",
|
||||
"@whatsNewDualModeDesc": {"description": "What's New feature: Dual Mode description"},
|
||||
"whatsNewLibraryTitle": "Redesigned Library",
|
||||
"@whatsNewLibraryTitle": {"description": "What's New feature: Library redesign title"},
|
||||
"whatsNewLibraryDesc": "Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.",
|
||||
"@whatsNewLibraryDesc": {"description": "What's New feature: Library redesign description"},
|
||||
"whatsNewPlayerTitle": "Full-Screen Player",
|
||||
"@whatsNewPlayerTitle": {"description": "What's New feature: Full-Screen Player title"},
|
||||
"whatsNewPlayerDesc": "Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.",
|
||||
"@whatsNewPlayerDesc": {"description": "What's New feature: Full-Screen Player description"},
|
||||
"whatsNewContextMenuTitle": "Long-Press Menus",
|
||||
"@whatsNewContextMenuTitle": {"description": "What's New feature: Context Menus title"},
|
||||
"whatsNewContextMenuDesc": "Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.",
|
||||
"@whatsNewContextMenuDesc": {"description": "What's New feature: Context Menus description"},
|
||||
"whatsNewPerformanceTitle": "Performance",
|
||||
"@whatsNewPerformanceTitle": {"description": "What's New feature: Performance title"},
|
||||
"whatsNewPerformanceDesc": "Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.",
|
||||
"@whatsNewPerformanceDesc": {"description": "What's New feature: Performance description"},
|
||||
"whatsNewBatchToolsTitle": "Batch Tools",
|
||||
"@whatsNewBatchToolsTitle": {"description": "What's New feature: Batch Tools title"},
|
||||
"whatsNewBatchToolsDesc": "Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.",
|
||||
"@whatsNewBatchToolsDesc": {"description": "What's New feature: Batch Tools description"},
|
||||
"whatsNewStreamingTip1": "Tap any track to start playing instantly",
|
||||
"@whatsNewStreamingTip1": {"description": "What's New tip: streaming instant play"},
|
||||
"whatsNewStreamingTip2": "Synced lyrics in the full-screen player",
|
||||
"@whatsNewStreamingTip2": {"description": "What's New tip: streaming synced lyrics"},
|
||||
"whatsNewStreamingTip3": "Download tracks directly from the player",
|
||||
"@whatsNewStreamingTip3": {"description": "What's New tip: streaming download from player"},
|
||||
"whatsNewSmartQueueTip1": "Queue auto-fills with related tracks",
|
||||
"@whatsNewSmartQueueTip1": {"description": "What's New tip: smart queue auto-fill"},
|
||||
"whatsNewSmartQueueTip2": "Discover new artists as you listen",
|
||||
"@whatsNewSmartQueueTip2": {"description": "What's New tip: smart queue artist discovery"},
|
||||
"whatsNewSmartQueueTip3": "Never run out of music to play",
|
||||
"@whatsNewSmartQueueTip3": {"description": "What's New tip: smart queue endless"},
|
||||
"whatsNewDualModeTip1": "Switch modes anytime in Settings",
|
||||
"@whatsNewDualModeTip1": {"description": "What's New tip: dual mode switch"},
|
||||
"whatsNewDualModeTip2": "UI buttons adapt to your current mode",
|
||||
"@whatsNewDualModeTip2": {"description": "What's New tip: dual mode adaptive UI"},
|
||||
"whatsNewDualModeTip3": "Download for offline, stream for instant play",
|
||||
"@whatsNewDualModeTip3": {"description": "What's New tip: dual mode use cases"},
|
||||
"whatsNewLibraryTip1": "Drag and drop to organize playlists",
|
||||
"@whatsNewLibraryTip1": {"description": "What's New tip: library drag and drop"},
|
||||
"whatsNewLibraryTip2": "Set custom cover images for playlists",
|
||||
"@whatsNewLibraryTip2": {"description": "What's New tip: library custom covers"},
|
||||
"whatsNewLibraryTip3": "Multi-select tracks for batch actions",
|
||||
"@whatsNewLibraryTip3": {"description": "What's New tip: library multi-select"},
|
||||
"whatsNewPlayerTip1": "Cover art with parallax scrolling effect",
|
||||
"@whatsNewPlayerTip1": {"description": "What's New tip: player parallax"},
|
||||
"whatsNewPlayerTip2": "Playback persists across app restarts",
|
||||
"@whatsNewPlayerTip2": {"description": "What's New tip: player persistence"},
|
||||
"whatsNewPlayerTip3": "Synced lyrics while you listen",
|
||||
"@whatsNewPlayerTip3": {"description": "What's New tip: player lyrics"},
|
||||
"whatsNewContextMenuTip1": "Add tracks to any playlist instantly",
|
||||
"@whatsNewContextMenuTip1": {"description": "What's New tip: context menu add to playlist"},
|
||||
"whatsNewContextMenuTip2": "Share or convert with one tap",
|
||||
"@whatsNewContextMenuTip2": {"description": "What's New tip: context menu share/convert"},
|
||||
"whatsNewContextMenuTip3": "Re-enrich metadata when needed",
|
||||
"@whatsNewContextMenuTip3": {"description": "What's New tip: context menu re-enrich"},
|
||||
"whatsNewBatchToolsTip1": "Share multiple tracks at once",
|
||||
"@whatsNewBatchToolsTip1": {"description": "What's New tip: batch share"},
|
||||
"whatsNewBatchToolsTip2": "Batch convert to MP3 or Opus format",
|
||||
"@whatsNewBatchToolsTip2": {"description": "What's New tip: batch convert"},
|
||||
"whatsNewBatchToolsTip3": "Re-enrich metadata across your library",
|
||||
"@whatsNewBatchToolsTip3": {"description": "What's New tip: batch re-enrich"},
|
||||
"whatsNewPerformanceTip1": "Faster app startup time",
|
||||
"@whatsNewPerformanceTip1": {"description": "What's New tip: performance startup"},
|
||||
"whatsNewPerformanceTip2": "Reduced memory usage during playback",
|
||||
"@whatsNewPerformanceTip2": {"description": "What's New tip: performance memory"},
|
||||
"whatsNewPerformanceTip3": "SQLite-backed storage for reliability",
|
||||
"@whatsNewPerformanceTip3": {"description": "What's New tip: performance SQLite"},
|
||||
"whatsNewReadyMessage": "You're all set — enjoy the new SpotiFLAC!",
|
||||
"@whatsNewReadyMessage": {"description": "Ready card message on last What's New page"},
|
||||
"whatsNewGetStarted": "Let's Go",
|
||||
"@whatsNewGetStarted": {"description": "Button text to dismiss What's New screen"},
|
||||
"whatsNewPageIndicator": "{current} of {total}",
|
||||
"@whatsNewPageIndicator": {
|
||||
"description": "Page indicator text in What's New screen",
|
||||
"placeholders": {
|
||||
"current": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -2565,5 +2565,18 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "Elige tu modo",
|
||||
"setupModeSelectionDescription": "¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.",
|
||||
"setupModeDownloaderTitle": "Descargador",
|
||||
"setupModeDownloaderFeature1": "Descarga pistas en calidad FLAC sin pérdida",
|
||||
"setupModeDownloaderFeature2": "Guarda música en tu dispositivo para escuchar sin conexión",
|
||||
"setupModeDownloaderFeature3": "Gestiona tu biblioteca de música local",
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"setupModeStreamingFeature1": "Transmite pistas al instante sin descargar",
|
||||
"setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti",
|
||||
"setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción",
|
||||
"setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes.",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "Descubre y añade automáticamente pistas similares a tu cola de reproducción"
|
||||
}
|
||||
@@ -3868,5 +3868,18 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "Elige tu modo",
|
||||
"setupModeSelectionDescription": "¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.",
|
||||
"setupModeDownloaderTitle": "Descargador",
|
||||
"setupModeDownloaderFeature1": "Descarga pistas en calidad FLAC sin pérdida",
|
||||
"setupModeDownloaderFeature2": "Guarda música en tu dispositivo para escuchar sin conexión",
|
||||
"setupModeDownloaderFeature3": "Gestiona tu biblioteca de música local",
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"setupModeStreamingFeature1": "Transmite pistas al instante sin descargar",
|
||||
"setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti",
|
||||
"setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción",
|
||||
"setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes.",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "Descubre y añade automáticamente pistas similares a tu cola de reproducción"
|
||||
}
|
||||
+14
-1
@@ -3868,5 +3868,18 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "Choisissez votre mode",
|
||||
"setupModeSelectionDescription": "Comment souhaitez-vous utiliser SpotiFLAC ? Vous pouvez toujours changer cela plus tard dans les Paramètres.",
|
||||
"setupModeDownloaderTitle": "Téléchargeur",
|
||||
"setupModeDownloaderFeature1": "Téléchargez des pistes en qualité FLAC sans perte",
|
||||
"setupModeDownloaderFeature2": "Enregistrez de la musique sur votre appareil pour une écoute hors ligne",
|
||||
"setupModeDownloaderFeature3": "Gérez votre bibliothèque musicale locale",
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"setupModeStreamingFeature1": "Diffusez des pistes instantanément sans télécharger",
|
||||
"setupModeStreamingFeature2": "Smart Queue découvre automatiquement de nouvelle musique pour vous",
|
||||
"setupModeStreamingFeature3": "Écoutez n'importe quelle piste à la demande avec les contrôles de lecture",
|
||||
"setupModeChangeableLater": "Vous pouvez changer de mode à tout moment dans les Paramètres.",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "Découvrir et ajouter automatiquement des pistes similaires à votre file d'attente"
|
||||
}
|
||||
+14
-1
@@ -3868,5 +3868,18 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "अपना मोड चुनें",
|
||||
"setupModeSelectionDescription": "आप SpotiFLAC का उपयोग कैसे करना चाहेंगे? आप इसे बाद में सेटिंग्स में कभी भी बदल सकते हैं।",
|
||||
"setupModeDownloaderTitle": "डाउनलोडर",
|
||||
"setupModeDownloaderFeature1": "लॉसलेस FLAC गुणवत्ता में ट्रैक डाउनलोड करें",
|
||||
"setupModeDownloaderFeature2": "ऑफ़लाइन सुनने के लिए संगीत अपने डिवाइस में सहेजें",
|
||||
"setupModeDownloaderFeature3": "अपनी स्थानीय संगीत लाइब्रेरी प्रबंधित करें",
|
||||
"setupModeStreamingTitle": "स्ट्रीमिंग",
|
||||
"setupModeStreamingFeature1": "बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें",
|
||||
"setupModeStreamingFeature2": "Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है",
|
||||
"setupModeStreamingFeature3": "प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं",
|
||||
"setupModeChangeableLater": "आप सेटिंग्स में कभी भी मोड बदल सकते हैं।",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "स्वचालित रूप से समान ट्रैक खोजें और अपनी कतार में जोड़ें"
|
||||
}
|
||||
+261
-37
@@ -300,15 +300,47 @@
|
||||
"@optionsSwitchBack": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
},
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"@optionsAutoFallback": {
|
||||
"description": "Auto-retry with other services"
|
||||
},
|
||||
"optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal",
|
||||
"@optionsAutoFallbackSubtitle": {
|
||||
"description": "Subtitle for auto fallback"
|
||||
},
|
||||
"optionsUseExtensionProviders": "Gunakan Provider Ekstensi",
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"@optionsAutoFallback": {
|
||||
"description": "Auto-retry with other services"
|
||||
},
|
||||
"optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal",
|
||||
"@optionsAutoFallbackSubtitle": {
|
||||
"description": "Subtitle for auto fallback"
|
||||
},
|
||||
"optionsAutoSkipUnavailableTracks": "Lewati Otomatis Lagu yang Tidak Tersedia",
|
||||
"@optionsAutoSkipUnavailableTracks": {
|
||||
"description": "Toggle to skip to the next queue track when current track stream resolution fails"
|
||||
},
|
||||
"optionsAutoSkipUnavailableTracksSubtitleOn": "Otomatis lanjut ke lagu berikutnya di antrean jika stream lagu tidak bisa ditemukan.",
|
||||
"@optionsAutoSkipUnavailableTracksSubtitleOn": {
|
||||
"description": "Subtitle when auto skip on resolve failure is enabled"
|
||||
},
|
||||
"optionsAutoSkipUnavailableTracksSubtitleOff": "Berhenti di lagu yang gagal dan tampilkan pesan error.",
|
||||
"@optionsAutoSkipUnavailableTracksSubtitleOff": {
|
||||
"description": "Subtitle when auto skip on resolve failure is disabled"
|
||||
},
|
||||
"optionsInteractionMode": "Mode Interaksi",
|
||||
"@optionsInteractionMode": {
|
||||
"description": "Tap behavior mode for track lists"
|
||||
},
|
||||
"modeDownloader": "Mode Downloader",
|
||||
"@modeDownloader": {
|
||||
"description": "Interaction mode where taps queue downloads"
|
||||
},
|
||||
"modeDownloaderSubtitle": "Ketuk lagu untuk menambah ke antrean unduhan",
|
||||
"@modeDownloaderSubtitle": {
|
||||
"description": "Subtitle for downloader interaction mode"
|
||||
},
|
||||
"modeStreaming": "Mode Streaming",
|
||||
"@modeStreaming": {
|
||||
"description": "Interaction mode where taps start playback"
|
||||
},
|
||||
"modeStreamingSubtitle": "Ketuk lagu untuk langsung memutar",
|
||||
"@modeStreamingSubtitle": {
|
||||
"description": "Subtitle for streaming interaction mode"
|
||||
},
|
||||
"optionsUseExtensionProviders": "Gunakan Provider Ekstensi",
|
||||
"@optionsUseExtensionProviders": {
|
||||
"description": "Enable extension download providers"
|
||||
},
|
||||
@@ -1336,11 +1368,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorNoTracksFound": "Tidak ada lagu ditemukan",
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
|
||||
"errorNoTracksFound": "Tidak ada lagu ditemukan",
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorSeekNotSupported": "Menggeser posisi lagu tidak didukung untuk live stream ini",
|
||||
"@errorSeekNotSupported": {
|
||||
"description": "Error - seek disabled for live decrypted stream"
|
||||
},
|
||||
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
"placeholders": {
|
||||
@@ -2013,16 +2049,25 @@
|
||||
"@tracksHeader": {
|
||||
"description": "Section header for track list"
|
||||
},
|
||||
"downloadAllCount": "Unduh Semua ({count})",
|
||||
"@downloadAllCount": {
|
||||
"description": "Download all button with count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
|
||||
"downloadAllCount": "Unduh Semua ({count})",
|
||||
"@downloadAllCount": {
|
||||
"description": "Download all button with count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playAllCount": "Putar Semua ({count})",
|
||||
"@playAllCount": {
|
||||
"description": "Play all button with count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
|
||||
"@tracksCount": {
|
||||
"description": "Track count display",
|
||||
"placeholders": {
|
||||
@@ -2927,14 +2972,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {
|
||||
"description": "Button - download artist discography"
|
||||
},
|
||||
"discographyDownloadAll": "Unduh Semua",
|
||||
"@discographyDownloadAll": {
|
||||
"description": "Option - download entire discography"
|
||||
},
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {
|
||||
"description": "Button - download artist discography"
|
||||
},
|
||||
"discographyPlay": "Putar Diskografi",
|
||||
"@discographyPlay": {
|
||||
"description": "Button - play artist discography"
|
||||
},
|
||||
"discographyDownloadAll": "Unduh Semua",
|
||||
"@discographyDownloadAll": {
|
||||
"description": "Option - download entire discography"
|
||||
},
|
||||
"discographyPlayAll": "Putar Semua",
|
||||
"@discographyPlayAll": {
|
||||
"description": "Option - play entire discography"
|
||||
},
|
||||
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
@@ -3012,10 +3065,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {
|
||||
"description": "Button - download selected albums"
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {
|
||||
"description": "Button - download selected albums"
|
||||
},
|
||||
"discographyPlaySelected": "Putar Terpilih",
|
||||
"@discographyPlaySelected": {
|
||||
"description": "Button - play selected albums"
|
||||
},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
@@ -4132,5 +4189,172 @@
|
||||
"collectionPlaylistChangeCover": "Ubah gambar sampul",
|
||||
"@collectionPlaylistChangeCover": {"description": "Bottom sheet action to pick a custom cover image for a playlist"},
|
||||
"collectionPlaylistRemoveCover": "Hapus gambar sampul",
|
||||
"@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"}
|
||||
"@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"},
|
||||
"setupModeSelectionTitle": "Pilih Mode Anda",
|
||||
"setupModeSelectionDescription": "Bagaimana Anda ingin menggunakan SpotiFLAC? Anda dapat mengubahnya nanti di Pengaturan.",
|
||||
"setupModeDownloaderTitle": "Pengunduh",
|
||||
"setupModeDownloaderFeature1": "Unduh trek dalam kualitas FLAC lossless",
|
||||
"setupModeDownloaderFeature2": "Simpan musik ke perangkat Anda untuk mendengarkan offline",
|
||||
"setupModeDownloaderFeature3": "Kelola perpustakaan musik lokal Anda",
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"setupModeStreamingFeature1": "Streaming trek secara instan tanpa mengunduh",
|
||||
"setupModeStreamingFeature2": "Smart Queue secara otomatis menemukan musik baru untuk Anda",
|
||||
"setupModeStreamingFeature3": "Putar trek apa pun sesuai permintaan dengan kontrol pemutaran",
|
||||
"setupModeChangeableLater": "Anda dapat beralih antar mode kapan saja di Pengaturan.",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "Secara otomatis temukan dan tambahkan trek serupa ke antrean Anda",
|
||||
|
||||
"selectionShareCount": "Bagikan {count} {count, plural, =1{trek} other{trek}}",
|
||||
"@selectionShareCount": {
|
||||
"description": "Share button text with count in selection mode",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"selectionShareNoFiles": "Tidak ada file yang dapat dibagikan",
|
||||
"@selectionShareNoFiles": {"description": "Snackbar when no selected files exist on disk"},
|
||||
"selectionConvertCount": "Konversi {count} {count, plural, =1{trek} other{trek}}",
|
||||
"@selectionConvertCount": {
|
||||
"description": "Convert button text with count in selection mode",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"selectionConvertNoConvertible": "Tidak ada trek yang dapat dikonversi dipilih",
|
||||
"@selectionConvertNoConvertible": {"description": "Snackbar when no selected tracks support conversion"},
|
||||
"selectionBatchConvertConfirmTitle": "Konversi Massal",
|
||||
"@selectionBatchConvertConfirmTitle": {"description": "Confirmation dialog title for batch conversion"},
|
||||
"selectionBatchConvertConfirmMessage": "Konversi {count} {count, plural, =1{trek} other{trek}} ke {format} pada {bitrate}?\n\nFile asli akan dihapus setelah konversi.",
|
||||
"@selectionBatchConvertConfirmMessage": {
|
||||
"description": "Confirmation dialog message for batch conversion",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"format": {"type": "String"},
|
||||
"bitrate": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertProgress": "Mengonversi {current} dari {total}...",
|
||||
"@selectionBatchConvertProgress": {
|
||||
"description": "Snackbar during batch conversion progress",
|
||||
"placeholders": {
|
||||
"current": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertSuccess": "Berhasil mengonversi {success} dari {total} trek ke {format}",
|
||||
"@selectionBatchConvertSuccess": {
|
||||
"description": "Snackbar after batch conversion completes",
|
||||
"placeholders": {
|
||||
"success": {"type": "int"},
|
||||
"total": {"type": "int"},
|
||||
"format": {"type": "String"}
|
||||
}
|
||||
},
|
||||
|
||||
"whatsNewTitle": "Yang Baru di 4.0",
|
||||
"@whatsNewTitle": {"description": "Title for the What's New screen"},
|
||||
"whatsNewSubtitle": "SpotiFLAC telah berevolusi — inilah yang berubah sejak 3.x",
|
||||
"@whatsNewSubtitle": {"description": "Subtitle for the What's New screen"},
|
||||
"whatsNewWelcomeTitle": "SpotiFLAC Mobile 4.0",
|
||||
"@whatsNewWelcomeTitle": {"description": "Welcome page title in What's New screen"},
|
||||
"whatsNewWelcomeDesc": "Selamat datang kembali! Ini pembaruan besar dengan banyak fitur baru. Geser untuk melihat apa yang berubah.",
|
||||
"@whatsNewWelcomeDesc": {"description": "Welcome page description in What's New screen"},
|
||||
"whatsNewWelcomeTip1": "Mode streaming baru dengan pemutaran instan",
|
||||
"@whatsNewWelcomeTip1": {"description": "Welcome page tip 1"},
|
||||
"whatsNewWelcomeTip2": "Perpustakaan dan pemutar layar penuh yang didesain ulang",
|
||||
"@whatsNewWelcomeTip2": {"description": "Welcome page tip 2"},
|
||||
"whatsNewWelcomeTip3": "Alat massal, peningkatan performa, dan lainnya",
|
||||
"@whatsNewWelcomeTip3": {"description": "Welcome page tip 3"},
|
||||
"whatsNewStreamingTitle": "Mode Streaming",
|
||||
"@whatsNewStreamingTitle": {"description": "What's New feature: Streaming Mode title"},
|
||||
"whatsNewStreamingDesc": "Ketuk trek apa pun untuk langsung diputar — tanpa perlu mengunduh. Pemutar layar penuh dengan lirik tersinkron dan kontrol media.",
|
||||
"@whatsNewStreamingDesc": {"description": "What's New feature: Streaming Mode description"},
|
||||
"whatsNewSmartQueueTitle": "Smart Queue",
|
||||
"@whatsNewSmartQueueTitle": {"description": "What's New feature: Smart Queue title"},
|
||||
"whatsNewSmartQueueDesc": "Antrean Anda otomatis mengkurasi trek terkait dan penemuan artis. Tak pernah kehabisan musik.",
|
||||
"@whatsNewSmartQueueDesc": {"description": "What's New feature: Smart Queue description"},
|
||||
"whatsNewDualModeTitle": "Mode Ganda",
|
||||
"@whatsNewDualModeTitle": {"description": "What's New feature: Dual Mode title"},
|
||||
"whatsNewDualModeDesc": "Beralih antara mode Pengunduh dan Streaming kapan saja. Semua tombol menyesuaikan secara otomatis.",
|
||||
"@whatsNewDualModeDesc": {"description": "What's New feature: Dual Mode description"},
|
||||
"whatsNewLibraryTitle": "Perpustakaan Baru",
|
||||
"@whatsNewLibraryTitle": {"description": "What's New feature: Library redesign title"},
|
||||
"whatsNewLibraryDesc": "Tata letak berbasis playlist dengan kategorisasi seret-dan-lepas, sampul kustom, dan aksi massal multi-pilih.",
|
||||
"@whatsNewLibraryDesc": {"description": "What's New feature: Library redesign description"},
|
||||
"whatsNewPlayerTitle": "Pemutar Layar Penuh",
|
||||
"@whatsNewPlayerTitle": {"description": "What's New feature: Full-Screen Player title"},
|
||||
"whatsNewPlayerDesc": "Paralaks seni sampul, lirik tersinkron, pemutaran tetap tersimpan saat restart, dan tombol unduh di pemutar.",
|
||||
"@whatsNewPlayerDesc": {"description": "What's New feature: Full-Screen Player description"},
|
||||
"whatsNewContextMenuTitle": "Menu Tekan Lama",
|
||||
"@whatsNewContextMenuTitle": {"description": "What's New feature: Context Menus title"},
|
||||
"whatsNewContextMenuDesc": "Tekan lama trek apa pun untuk aksi cepat — tambah ke playlist, bagikan, konversi, atau perbarui metadata.",
|
||||
"@whatsNewContextMenuDesc": {"description": "What's New feature: Context Menus description"},
|
||||
"whatsNewPerformanceTitle": "Performa",
|
||||
"@whatsNewPerformanceTitle": {"description": "What's New feature: Performance title"},
|
||||
"whatsNewPerformanceDesc": "Startup lebih cepat, penggunaan memori berkurang, penyimpanan berbasis SQLite, dan pembaruan UI yang lebih efisien.",
|
||||
"@whatsNewPerformanceDesc": {"description": "What's New feature: Performance description"},
|
||||
"whatsNewBatchToolsTitle": "Alat Massal",
|
||||
"@whatsNewBatchToolsTitle": {"description": "What's New feature: Batch Tools title"},
|
||||
"whatsNewBatchToolsDesc": "Berbagi multi-pilih, konversi massal ke MP3/Opus, dan perbarui metadata secara massal di seluruh perpustakaan.",
|
||||
"@whatsNewBatchToolsDesc": {"description": "What's New feature: Batch Tools description"},
|
||||
"whatsNewStreamingTip1": "Ketuk trek apa pun untuk langsung memutar",
|
||||
"@whatsNewStreamingTip1": {"description": "What's New tip: streaming instant play"},
|
||||
"whatsNewStreamingTip2": "Lirik tersinkron di pemutar layar penuh",
|
||||
"@whatsNewStreamingTip2": {"description": "What's New tip: streaming synced lyrics"},
|
||||
"whatsNewStreamingTip3": "Unduh trek langsung dari pemutar",
|
||||
"@whatsNewStreamingTip3": {"description": "What's New tip: streaming download from player"},
|
||||
"whatsNewSmartQueueTip1": "Antrean terisi otomatis dengan trek terkait",
|
||||
"@whatsNewSmartQueueTip1": {"description": "What's New tip: smart queue auto-fill"},
|
||||
"whatsNewSmartQueueTip2": "Temukan artis baru saat mendengarkan",
|
||||
"@whatsNewSmartQueueTip2": {"description": "What's New tip: smart queue artist discovery"},
|
||||
"whatsNewSmartQueueTip3": "Tak pernah kehabisan musik untuk diputar",
|
||||
"@whatsNewSmartQueueTip3": {"description": "What's New tip: smart queue endless"},
|
||||
"whatsNewDualModeTip1": "Beralih mode kapan saja di Pengaturan",
|
||||
"@whatsNewDualModeTip1": {"description": "What's New tip: dual mode switch"},
|
||||
"whatsNewDualModeTip2": "Tombol UI menyesuaikan dengan mode Anda",
|
||||
"@whatsNewDualModeTip2": {"description": "What's New tip: dual mode adaptive UI"},
|
||||
"whatsNewDualModeTip3": "Unduh untuk offline, streaming untuk putar langsung",
|
||||
"@whatsNewDualModeTip3": {"description": "What's New tip: dual mode use cases"},
|
||||
"whatsNewLibraryTip1": "Seret dan lepas untuk mengatur playlist",
|
||||
"@whatsNewLibraryTip1": {"description": "What's New tip: library drag and drop"},
|
||||
"whatsNewLibraryTip2": "Atur gambar sampul kustom untuk playlist",
|
||||
"@whatsNewLibraryTip2": {"description": "What's New tip: library custom covers"},
|
||||
"whatsNewLibraryTip3": "Pilih banyak trek untuk aksi massal",
|
||||
"@whatsNewLibraryTip3": {"description": "What's New tip: library multi-select"},
|
||||
"whatsNewPlayerTip1": "Seni sampul dengan efek paralaks",
|
||||
"@whatsNewPlayerTip1": {"description": "What's New tip: player parallax"},
|
||||
"whatsNewPlayerTip2": "Pemutaran tetap tersimpan saat restart",
|
||||
"@whatsNewPlayerTip2": {"description": "What's New tip: player persistence"},
|
||||
"whatsNewPlayerTip3": "Lirik tersinkron saat mendengarkan",
|
||||
"@whatsNewPlayerTip3": {"description": "What's New tip: player lyrics"},
|
||||
"whatsNewContextMenuTip1": "Tambahkan trek ke playlist mana pun langsung",
|
||||
"@whatsNewContextMenuTip1": {"description": "What's New tip: context menu add to playlist"},
|
||||
"whatsNewContextMenuTip2": "Bagikan atau konversi dengan satu ketukan",
|
||||
"@whatsNewContextMenuTip2": {"description": "What's New tip: context menu share/convert"},
|
||||
"whatsNewContextMenuTip3": "Perbarui metadata saat diperlukan",
|
||||
"@whatsNewContextMenuTip3": {"description": "What's New tip: context menu re-enrich"},
|
||||
"whatsNewBatchToolsTip1": "Bagikan banyak trek sekaligus",
|
||||
"@whatsNewBatchToolsTip1": {"description": "What's New tip: batch share"},
|
||||
"whatsNewBatchToolsTip2": "Konversi massal ke format MP3 atau Opus",
|
||||
"@whatsNewBatchToolsTip2": {"description": "What's New tip: batch convert"},
|
||||
"whatsNewBatchToolsTip3": "Perbarui metadata di seluruh perpustakaan",
|
||||
"@whatsNewBatchToolsTip3": {"description": "What's New tip: batch re-enrich"},
|
||||
"whatsNewPerformanceTip1": "Waktu startup aplikasi lebih cepat",
|
||||
"@whatsNewPerformanceTip1": {"description": "What's New tip: performance startup"},
|
||||
"whatsNewPerformanceTip2": "Penggunaan memori berkurang saat pemutaran",
|
||||
"@whatsNewPerformanceTip2": {"description": "What's New tip: performance memory"},
|
||||
"whatsNewPerformanceTip3": "Penyimpanan berbasis SQLite untuk keandalan",
|
||||
"@whatsNewPerformanceTip3": {"description": "What's New tip: performance SQLite"},
|
||||
"whatsNewReadyMessage": "Siap — nikmati SpotiFLAC yang baru!",
|
||||
"@whatsNewReadyMessage": {"description": "Ready card message on last What's New page"},
|
||||
"whatsNewGetStarted": "Ayo Mulai",
|
||||
"@whatsNewGetStarted": {"description": "Button text to dismiss What's New screen"},
|
||||
"whatsNewPageIndicator": "{current} dari {total}",
|
||||
"@whatsNewPageIndicator": {
|
||||
"description": "Page indicator text in What's New screen",
|
||||
"placeholders": {
|
||||
"current": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -3868,5 +3868,18 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "モードを選択",
|
||||
"setupModeSelectionDescription": "SpotiFLACをどのように使いますか?この設定は後からいつでも変更できます。",
|
||||
"setupModeDownloaderTitle": "ダウンローダー",
|
||||
"setupModeDownloaderFeature1": "ロスレスFLAC品質でトラックをダウンロード",
|
||||
"setupModeDownloaderFeature2": "オフライン再生用に音楽をデバイスに保存",
|
||||
"setupModeDownloaderFeature3": "ローカル音楽ライブラリを管理",
|
||||
"setupModeStreamingTitle": "ストリーミング",
|
||||
"setupModeStreamingFeature1": "ダウンロードせずにトラックを即座にストリーミング",
|
||||
"setupModeStreamingFeature2": "Smart Queueが自動的に新しい音楽を見つけます",
|
||||
"setupModeStreamingFeature3": "再生コントロールで任意のトラックをオンデマンド再生",
|
||||
"setupModeChangeableLater": "設定からいつでもモードを切り替えられます。",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "類似トラックを自動的に検出してキューに追加"
|
||||
}
|
||||
+14
-1
@@ -3868,5 +3868,18 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "모드 선택",
|
||||
"setupModeSelectionDescription": "SpotiFLAC을 어떻게 사용하시겠습니까? 나중에 설정에서 언제든지 변경할 수 있습니다.",
|
||||
"setupModeDownloaderTitle": "다운로더",
|
||||
"setupModeDownloaderFeature1": "무손실 FLAC 품질로 트랙 다운로드",
|
||||
"setupModeDownloaderFeature2": "오프라인 감상을 위해 기기에 음악 저장",
|
||||
"setupModeDownloaderFeature3": "로컬 음악 라이브러리 관리",
|
||||
"setupModeStreamingTitle": "스트리밍",
|
||||
"setupModeStreamingFeature1": "다운로드 없이 트랙을 즉시 스트리밍",
|
||||
"setupModeStreamingFeature2": "Smart Queue가 자동으로 새로운 음악을 발견합니다",
|
||||
"setupModeStreamingFeature3": "재생 컨트롤로 원하는 트랙을 온디맨드 재생",
|
||||
"setupModeChangeableLater": "설정에서 언제든지 모드를 전환할 수 있습니다.",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "유사한 트랙을 자동으로 검색하여 대기열에 추가"
|
||||
}
|
||||
+14
-1
@@ -3868,5 +3868,18 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "Kies je modus",
|
||||
"setupModeSelectionDescription": "Hoe wil je SpotiFLAC gebruiken? Je kunt dit later altijd wijzigen in Instellingen.",
|
||||
"setupModeDownloaderTitle": "Downloader",
|
||||
"setupModeDownloaderFeature1": "Download nummers in lossless FLAC-kwaliteit",
|
||||
"setupModeDownloaderFeature2": "Sla muziek op je apparaat op om offline te luisteren",
|
||||
"setupModeDownloaderFeature3": "Beheer je lokale muziekbibliotheek",
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"setupModeStreamingFeature1": "Stream nummers direct zonder te downloaden",
|
||||
"setupModeStreamingFeature2": "Smart Queue ontdekt automatisch nieuwe muziek voor je",
|
||||
"setupModeStreamingFeature3": "Speel elk nummer op aanvraag af met afspeelbediening",
|
||||
"setupModeChangeableLater": "Je kunt op elk moment wisselen tussen modi in Instellingen.",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "Ontdek automatisch vergelijkbare nummers en voeg ze toe aan je wachtrij"
|
||||
}
|
||||
+14
-1
@@ -2565,5 +2565,18 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "Escolha seu modo",
|
||||
"setupModeSelectionDescription": "Como você gostaria de usar o SpotiFLAC? Você pode alterar isso depois nas Configurações.",
|
||||
"setupModeDownloaderTitle": "Downloader",
|
||||
"setupModeDownloaderFeature1": "Baixe faixas em qualidade FLAC lossless",
|
||||
"setupModeDownloaderFeature2": "Salve músicas no seu dispositivo para ouvir offline",
|
||||
"setupModeDownloaderFeature3": "Gerencie sua biblioteca de músicas local",
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"setupModeStreamingFeature1": "Transmita faixas instantaneamente sem baixar",
|
||||
"setupModeStreamingFeature2": "Smart Queue descobre automaticamente novas músicas para você",
|
||||
"setupModeStreamingFeature3": "Reproduza qualquer faixa sob demanda com controles de reprodução",
|
||||
"setupModeChangeableLater": "Você pode alternar entre os modos a qualquer momento nas Configurações.",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "Descubra e adicione automaticamente faixas semelhantes à sua fila"
|
||||
}
|
||||
@@ -3868,5 +3868,18 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "Escolha o seu modo",
|
||||
"setupModeSelectionDescription": "Como gostaria de utilizar o SpotiFLAC? Pode alterar isto mais tarde nas Definições.",
|
||||
"setupModeDownloaderTitle": "Transferência",
|
||||
"setupModeDownloaderFeature1": "Transfira faixas em qualidade FLAC sem perdas",
|
||||
"setupModeDownloaderFeature2": "Guarde música no seu dispositivo para ouvir offline",
|
||||
"setupModeDownloaderFeature3": "Faça a gestão da sua biblioteca de música local",
|
||||
"setupModeStreamingTitle": "Streaming",
|
||||
"setupModeStreamingFeature1": "Transmita faixas instantaneamente sem transferir",
|
||||
"setupModeStreamingFeature2": "Smart Queue descobre automaticamente novas músicas para si",
|
||||
"setupModeStreamingFeature3": "Reproduza qualquer faixa a pedido com controlos de reprodução",
|
||||
"setupModeChangeableLater": "Pode alternar entre modos a qualquer momento nas Definições.",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "Descubra e adicione automaticamente faixas semelhantes à sua fila"
|
||||
}
|
||||
+14
-1
@@ -3868,5 +3868,18 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "Выберите режим",
|
||||
"setupModeSelectionDescription": "Как вы хотите использовать SpotiFLAC? Вы всегда можете изменить это позже в Настройках.",
|
||||
"setupModeDownloaderTitle": "Загрузчик",
|
||||
"setupModeDownloaderFeature1": "Скачивайте треки в качестве FLAC без потерь",
|
||||
"setupModeDownloaderFeature2": "Сохраняйте музыку на устройство для прослушивания офлайн",
|
||||
"setupModeDownloaderFeature3": "Управляйте своей локальной музыкальной библиотекой",
|
||||
"setupModeStreamingTitle": "Стриминг",
|
||||
"setupModeStreamingFeature1": "Слушайте треки мгновенно без скачивания",
|
||||
"setupModeStreamingFeature2": "Smart Queue автоматически подбирает новую музыку для вас",
|
||||
"setupModeStreamingFeature3": "Воспроизводите любой трек по запросу с элементами управления",
|
||||
"setupModeChangeableLater": "Вы можете переключаться между режимами в любое время в Настройках.",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "Автоматически находите и добавляйте похожие треки в очередь воспроизведения"
|
||||
}
|
||||
+14
-1
@@ -3868,5 +3868,18 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "Modunuzu Seçin",
|
||||
"setupModeSelectionDescription": "SpotiFLAC'ı nasıl kullanmak istersiniz? Bunu daha sonra Ayarlar'dan değiştirebilirsiniz.",
|
||||
"setupModeDownloaderTitle": "İndirici",
|
||||
"setupModeDownloaderFeature1": "Kayıpsız FLAC kalitesinde parça indirin",
|
||||
"setupModeDownloaderFeature2": "Çevrimdışı dinlemek için müziği cihazınıza kaydedin",
|
||||
"setupModeDownloaderFeature3": "Yerel müzik kütüphanenizi yönetin",
|
||||
"setupModeStreamingTitle": "Yayın Akışı",
|
||||
"setupModeStreamingFeature1": "İndirmeden parçaları anında yayınlayın",
|
||||
"setupModeStreamingFeature2": "Smart Queue sizin için otomatik olarak yeni müzik keşfeder",
|
||||
"setupModeStreamingFeature3": "İstediğiniz parçayı oynatma kontrolleriyle çalın",
|
||||
"setupModeChangeableLater": "Ayarlar'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz.",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "Sıranıza otomatik olarak benzer parçalar keşfedin ve ekleyin"
|
||||
}
|
||||
+14
-1
@@ -2565,5 +2565,18 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "选择您的模式",
|
||||
"setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。",
|
||||
"setupModeDownloaderTitle": "下载器",
|
||||
"setupModeDownloaderFeature1": "以无损 FLAC 品质下载曲目",
|
||||
"setupModeDownloaderFeature2": "将音乐保存到设备以供离线收听",
|
||||
"setupModeDownloaderFeature3": "管理您的本地音乐库",
|
||||
"setupModeStreamingTitle": "流媒体",
|
||||
"setupModeStreamingFeature1": "无需下载即可即时播放曲目",
|
||||
"setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐",
|
||||
"setupModeStreamingFeature3": "通过播放控件随时点播任意曲目",
|
||||
"setupModeChangeableLater": "您可以随时在设置中切换模式。",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "自动发现并将相似曲目添加到您的队列中"
|
||||
}
|
||||
@@ -3868,5 +3868,18 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "选择您的模式",
|
||||
"setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。",
|
||||
"setupModeDownloaderTitle": "下载器",
|
||||
"setupModeDownloaderFeature1": "以无损 FLAC 品质下载曲目",
|
||||
"setupModeDownloaderFeature2": "将音乐保存到设备以供离线收听",
|
||||
"setupModeDownloaderFeature3": "管理您的本地音乐库",
|
||||
"setupModeStreamingTitle": "流媒体",
|
||||
"setupModeStreamingFeature1": "无需下载即可即时播放曲目",
|
||||
"setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐",
|
||||
"setupModeStreamingFeature3": "通过播放控件随时点播任意曲目",
|
||||
"setupModeChangeableLater": "您可以随时在设置中切换模式。",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "自动发现并将相似曲目添加到您的队列中"
|
||||
}
|
||||
@@ -3868,5 +3868,18 @@
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
},
|
||||
"setupModeSelectionTitle": "選擇您的模式",
|
||||
"setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍後在設定中隨時變更。",
|
||||
"setupModeDownloaderTitle": "下載器",
|
||||
"setupModeDownloaderFeature1": "以無損 FLAC 品質下載曲目",
|
||||
"setupModeDownloaderFeature2": "將音樂儲存到裝置以供離線收聽",
|
||||
"setupModeDownloaderFeature3": "管理您的本機音樂庫",
|
||||
"setupModeStreamingTitle": "串流",
|
||||
"setupModeStreamingFeature1": "無需下載即可即時串流曲目",
|
||||
"setupModeStreamingFeature2": "Smart Queue 自動為您探索新音樂",
|
||||
"setupModeStreamingFeature3": "透過播放控制項隨時點播任意曲目",
|
||||
"setupModeChangeableLater": "您可以隨時在設定中切換模式。",
|
||||
"settingsSmartQueueTitle": "Smart Queue",
|
||||
"settingsSmartQueueSubtitle": "自動探索並將相似曲目新增到您的佇列中"
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
|
||||
class PlaybackItem {
|
||||
final String id;
|
||||
final String title;
|
||||
final String artist;
|
||||
final String album;
|
||||
final String coverUrl;
|
||||
final String sourceUri;
|
||||
final bool isLocal;
|
||||
final String service;
|
||||
final int durationMs;
|
||||
|
||||
// Stream quality metadata
|
||||
final String format;
|
||||
final int bitDepth;
|
||||
final int sampleRate;
|
||||
final int bitrate;
|
||||
|
||||
// Original track reference for queue operations
|
||||
final Track? track;
|
||||
|
||||
const PlaybackItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
this.album = '',
|
||||
this.coverUrl = '',
|
||||
required this.sourceUri,
|
||||
this.isLocal = false,
|
||||
this.service = '',
|
||||
this.durationMs = 0,
|
||||
this.format = '',
|
||||
this.bitDepth = 0,
|
||||
this.sampleRate = 0,
|
||||
this.bitrate = 0,
|
||||
this.track,
|
||||
});
|
||||
|
||||
PlaybackItem copyWith({
|
||||
String? sourceUri,
|
||||
String? service,
|
||||
String? format,
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
int? bitrate,
|
||||
}) {
|
||||
return PlaybackItem(
|
||||
id: id,
|
||||
title: title,
|
||||
artist: artist,
|
||||
album: album,
|
||||
coverUrl: coverUrl,
|
||||
sourceUri: sourceUri ?? this.sourceUri,
|
||||
isLocal: isLocal,
|
||||
service: service ?? this.service,
|
||||
durationMs: durationMs,
|
||||
format: format ?? this.format,
|
||||
bitDepth: bitDepth ?? this.bitDepth,
|
||||
sampleRate: sampleRate ?? this.sampleRate,
|
||||
bitrate: bitrate ?? this.bitrate,
|
||||
track: track,
|
||||
);
|
||||
}
|
||||
|
||||
/// Human-readable quality label for UI display
|
||||
String get qualityLabel {
|
||||
final parts = <String>[];
|
||||
|
||||
if (format.isNotEmpty) {
|
||||
parts.add(format.toUpperCase());
|
||||
}
|
||||
|
||||
if (bitDepth > 0 && sampleRate > 0) {
|
||||
final srKhz = sampleRate >= 1000
|
||||
? '${(sampleRate / 1000).toStringAsFixed(sampleRate % 1000 == 0 ? 0 : 1)}kHz'
|
||||
: '${sampleRate}Hz';
|
||||
parts.add('$bitDepth-bit / $srKhz');
|
||||
} else if (bitrate > 0) {
|
||||
parts.add('${bitrate}kbps');
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/// Whether this item has cover art that is a local file path
|
||||
bool get hasLocalCover {
|
||||
if (coverUrl.isEmpty) return false;
|
||||
return !coverUrl.startsWith('http://') && !coverUrl.startsWith('https://');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ class AppSettings {
|
||||
final String storageMode; // 'app' or 'saf'
|
||||
final String downloadTreeUri; // SAF persistable tree URI
|
||||
final bool autoFallback;
|
||||
final bool autoSkipUnavailableTracks;
|
||||
final bool smartQueueEnabled; // Enable smart curated autoplay queue
|
||||
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
|
||||
final bool embedLyrics;
|
||||
final bool maxQualityCover;
|
||||
final bool isFirstLaunch;
|
||||
@@ -76,6 +79,10 @@ class AppSettings {
|
||||
final String
|
||||
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
|
||||
|
||||
// Version upgrade tracking
|
||||
final String
|
||||
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
@@ -84,6 +91,9 @@ class AppSettings {
|
||||
this.storageMode = 'app',
|
||||
this.downloadTreeUri = '',
|
||||
this.autoFallback = true,
|
||||
this.autoSkipUnavailableTracks = true,
|
||||
this.smartQueueEnabled = true,
|
||||
this.embedMetadata = true,
|
||||
this.embedLyrics = true,
|
||||
this.maxQualityCover = true,
|
||||
this.isFirstLaunch = true,
|
||||
@@ -127,6 +137,7 @@ class AppSettings {
|
||||
// Lyrics providers default order
|
||||
this.lyricsProviders = const [
|
||||
'lrclib',
|
||||
'spotify_api',
|
||||
'musixmatch',
|
||||
'netease',
|
||||
'apple_music',
|
||||
@@ -136,6 +147,8 @@ class AppSettings {
|
||||
this.lyricsIncludeRomanizationNetease = false,
|
||||
this.lyricsMultiPersonWordByWord = false,
|
||||
this.musixmatchLanguage = '',
|
||||
// Version upgrade tracking
|
||||
this.lastSeenVersion = '',
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -146,6 +159,9 @@ class AppSettings {
|
||||
String? storageMode,
|
||||
String? downloadTreeUri,
|
||||
bool? autoFallback,
|
||||
bool? autoSkipUnavailableTracks,
|
||||
bool? smartQueueEnabled,
|
||||
bool? embedMetadata,
|
||||
bool? embedLyrics,
|
||||
bool? maxQualityCover,
|
||||
bool? isFirstLaunch,
|
||||
@@ -193,6 +209,8 @@ class AppSettings {
|
||||
bool? lyricsIncludeRomanizationNetease,
|
||||
bool? lyricsMultiPersonWordByWord,
|
||||
String? musixmatchLanguage,
|
||||
// Version upgrade tracking
|
||||
String? lastSeenVersion,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -202,6 +220,10 @@ class AppSettings {
|
||||
storageMode: storageMode ?? this.storageMode,
|
||||
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
||||
autoFallback: autoFallback ?? this.autoFallback,
|
||||
autoSkipUnavailableTracks:
|
||||
autoSkipUnavailableTracks ?? this.autoSkipUnavailableTracks,
|
||||
smartQueueEnabled: smartQueueEnabled ?? this.smartQueueEnabled,
|
||||
embedMetadata: embedMetadata ?? this.embedMetadata,
|
||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
@@ -264,6 +286,8 @@ class AppSettings {
|
||||
lyricsMultiPersonWordByWord:
|
||||
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
|
||||
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
|
||||
// Version upgrade tracking
|
||||
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
storageMode: json['storageMode'] as String? ?? 'app',
|
||||
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
|
||||
autoFallback: json['autoFallback'] as bool? ?? true,
|
||||
autoSkipUnavailableTracks: json['autoSkipUnavailableTracks'] as bool? ?? true,
|
||||
smartQueueEnabled: json['smartQueueEnabled'] as bool? ?? true,
|
||||
embedMetadata: json['embedMetadata'] as bool? ?? true,
|
||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
@@ -50,10 +53,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
autoExportFailedDownloads:
|
||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
|
||||
networkCompatibilityMode:
|
||||
json['networkCompatibilityMode'] as bool? ??
|
||||
json['songLinkCompatibilityMode'] as bool? ??
|
||||
false,
|
||||
networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false,
|
||||
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
|
||||
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
|
||||
localLibraryPath: json['localLibraryPath'] as String? ?? '',
|
||||
@@ -64,7 +64,14 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
(json['lyricsProviders'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'],
|
||||
const [
|
||||
'lrclib',
|
||||
'spotify_api',
|
||||
'musixmatch',
|
||||
'netease',
|
||||
'apple_music',
|
||||
'qqmusic',
|
||||
],
|
||||
lyricsIncludeTranslationNetease:
|
||||
json['lyricsIncludeTranslationNetease'] as bool? ?? false,
|
||||
lyricsIncludeRomanizationNetease:
|
||||
@@ -72,6 +79,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
lyricsMultiPersonWordByWord:
|
||||
json['lyricsMultiPersonWordByWord'] as bool? ?? false,
|
||||
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
|
||||
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(
|
||||
@@ -84,6 +92,9 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'storageMode': instance.storageMode,
|
||||
'downloadTreeUri': instance.downloadTreeUri,
|
||||
'autoFallback': instance.autoFallback,
|
||||
'autoSkipUnavailableTracks': instance.autoSkipUnavailableTracks,
|
||||
'smartQueueEnabled': instance.smartQueueEnabled,
|
||||
'embedMetadata': instance.embedMetadata,
|
||||
'embedLyrics': instance.embedLyrics,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
@@ -128,4 +139,5 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'lyricsIncludeRomanizationNetease': instance.lyricsIncludeRomanizationNetease,
|
||||
'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord,
|
||||
'musixmatchLanguage': instance.musixmatchLanguage,
|
||||
'lastSeenVersion': instance.lastSeenVersion,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ class Track {
|
||||
final String artistName;
|
||||
final String albumName;
|
||||
final String? albumArtist;
|
||||
final String? artistId;
|
||||
final String? albumId;
|
||||
final String? coverUrl;
|
||||
final String? isrc;
|
||||
final int duration;
|
||||
@@ -27,6 +29,8 @@ class Track {
|
||||
required this.artistName,
|
||||
required this.albumName,
|
||||
this.albumArtist,
|
||||
this.artistId,
|
||||
this.albumId,
|
||||
this.coverUrl,
|
||||
this.isrc,
|
||||
required this.duration,
|
||||
|
||||
@@ -12,6 +12,8 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
artistName: json['artistName'] as String,
|
||||
albumName: json['albumName'] as String,
|
||||
albumArtist: json['albumArtist'] as String?,
|
||||
artistId: json['artistId'] as String?,
|
||||
albumId: json['albumId'] as String?,
|
||||
coverUrl: json['coverUrl'] as String?,
|
||||
isrc: json['isrc'] as String?,
|
||||
duration: (json['duration'] as num).toInt(),
|
||||
@@ -35,6 +37,8 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'artistName': instance.artistName,
|
||||
'albumName': instance.albumName,
|
||||
'albumArtist': instance.albumArtist,
|
||||
'artistId': instance.artistId,
|
||||
'albumId': instance.albumId,
|
||||
'coverUrl': instance.coverUrl,
|
||||
'isrc': instance.isrc,
|
||||
'duration': instance.duration,
|
||||
|
||||
@@ -702,10 +702,13 @@ class _ProgressUpdate {
|
||||
|
||||
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
Timer? _progressTimer;
|
||||
Timer? _progressStreamBootstrapTimer;
|
||||
Timer? _queuePersistDebounce;
|
||||
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
|
||||
int _downloadCount = 0;
|
||||
static const _cleanupInterval = 50;
|
||||
static const _progressPollingInterval = Duration(milliseconds: 800);
|
||||
static const _idleProgressPollEveryTicks = 3;
|
||||
static const _queueSchedulingInterval = Duration(milliseconds: 250);
|
||||
static const _queuePersistDebounceDuration = Duration(milliseconds: 350);
|
||||
static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI.
|
||||
@@ -718,6 +721,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final Set<String> _ensuredDirs = {};
|
||||
int _progressPollingErrorCount = 0;
|
||||
bool _isProgressPollingInFlight = false;
|
||||
int _idleProgressPollTick = 0;
|
||||
bool _hasReceivedProgressStreamEvent = false;
|
||||
bool _usingProgressStream = false;
|
||||
String? _lastServiceTrackName;
|
||||
String? _lastServiceArtistName;
|
||||
int _lastServicePercent = -1;
|
||||
@@ -788,7 +794,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
ref.onDispose(() {
|
||||
_progressTimer?.cancel();
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamSub?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_progressStreamSub = null;
|
||||
if (_queuePersistDebounce?.isActive == true) {
|
||||
_queuePersistDebounce?.cancel();
|
||||
unawaited(_flushQueueToStorage());
|
||||
@@ -894,213 +904,105 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
void _startMultiProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_hasReceivedProgressStreamEvent = false;
|
||||
_usingProgressStream = false;
|
||||
_idleProgressPollTick = 0;
|
||||
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
_attachDownloadProgressStream();
|
||||
return;
|
||||
}
|
||||
|
||||
_startMultiProgressPollingTimer();
|
||||
}
|
||||
|
||||
void _attachDownloadProgressStream() {
|
||||
_progressStreamSub = PlatformBridge.downloadProgressStream().listen(
|
||||
(allProgress) {
|
||||
_hasReceivedProgressStreamEvent = true;
|
||||
_usingProgressStream = true;
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamBootstrapTimer = null;
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
_processAllDownloadProgress(allProgress);
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Progress stream processing failed: $e');
|
||||
}
|
||||
} finally {
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (_usingProgressStream) {
|
||||
_log.w(
|
||||
'Download progress stream failed, fallback to polling: $error',
|
||||
);
|
||||
}
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_usingProgressStream = false;
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_startMultiProgressPollingTimer();
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
_progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (_hasReceivedProgressStreamEvent) {
|
||||
return;
|
||||
}
|
||||
_log.w('Download progress stream timeout, fallback to polling');
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_usingProgressStream = false;
|
||||
_startMultiProgressPollingTimer();
|
||||
});
|
||||
}
|
||||
|
||||
void _startMultiProgressPollingTimer() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(_progressPollingInterval, (timer) async {
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||
final currentItems = state.items;
|
||||
final itemsById = <String, DownloadItem>{};
|
||||
final itemIndexById = <String, int>{};
|
||||
int queuedCount = 0;
|
||||
int downloadingCount = 0;
|
||||
DownloadItem? firstDownloading;
|
||||
for (int i = 0; i < currentItems.length; i++) {
|
||||
final item = currentItems[i];
|
||||
itemsById[item.id] = item;
|
||||
itemIndexById[item.id] = i;
|
||||
if (item.status == DownloadStatus.downloading) {
|
||||
downloadingCount++;
|
||||
firstDownloading ??= item;
|
||||
}
|
||||
if (item.status == DownloadStatus.queued ||
|
||||
item.status == DownloadStatus.downloading) {
|
||||
queuedCount++;
|
||||
}
|
||||
}
|
||||
final progressUpdates = <String, _ProgressUpdate>{};
|
||||
final hasQueuedItems = currentItems.any(
|
||||
(item) => item.status == DownloadStatus.queued,
|
||||
);
|
||||
final hasActiveItems = currentItems.any(
|
||||
(item) =>
|
||||
item.status == DownloadStatus.downloading ||
|
||||
item.status == DownloadStatus.finalizing,
|
||||
);
|
||||
|
||||
bool hasFinalizingItem = false;
|
||||
String? finalizingTrackName;
|
||||
String? finalizingArtistName;
|
||||
|
||||
for (final entry in items.entries) {
|
||||
final itemId = entry.key;
|
||||
final localItem = itemsById[itemId];
|
||||
if (localItem == null) {
|
||||
continue;
|
||||
}
|
||||
if (localItem.status == DownloadStatus.skipped) {
|
||||
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
|
||||
continue;
|
||||
}
|
||||
if (localItem.status == DownloadStatus.completed ||
|
||||
localItem.status == DownloadStatus.failed) {
|
||||
continue;
|
||||
}
|
||||
final itemProgress = entry.value as Map<String, dynamic>;
|
||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
||||
final speedMBps =
|
||||
(itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
|
||||
final isDownloading =
|
||||
itemProgress['is_downloading'] as bool? ?? false;
|
||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||
|
||||
if (status == 'finalizing' && bytesTotal > 0) {
|
||||
progressUpdates[itemId] = const _ProgressUpdate(
|
||||
status: DownloadStatus.finalizing,
|
||||
progress: 1.0,
|
||||
);
|
||||
hasFinalizingItem = true;
|
||||
finalizingTrackName = localItem.track.name;
|
||||
finalizingArtistName = localItem.track.artistName;
|
||||
continue;
|
||||
if (!hasActiveItems) {
|
||||
if (state.isPaused || !hasQueuedItems) {
|
||||
_idleProgressPollTick = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
final progressFromBackend =
|
||||
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||
|
||||
if (isDownloading) {
|
||||
double percentage = 0.0;
|
||||
if (bytesTotal > 0) {
|
||||
percentage = bytesReceived / bytesTotal;
|
||||
} else {
|
||||
percentage = progressFromBackend;
|
||||
}
|
||||
final normalizedProgress = _normalizeProgressForUi(percentage);
|
||||
final normalizedSpeed = _normalizeSpeedForUi(speedMBps);
|
||||
final normalizedBytes = _normalizeBytesForUi(bytesReceived);
|
||||
|
||||
progressUpdates[itemId] = _ProgressUpdate(
|
||||
status: DownloadStatus.downloading,
|
||||
progress: normalizedProgress,
|
||||
speedMBps: normalizedSpeed,
|
||||
bytesReceived: normalizedBytes,
|
||||
);
|
||||
|
||||
if (LogBuffer.loggingEnabled) {
|
||||
final mbReceived = bytesReceived / (1024 * 1024);
|
||||
final mbTotal = bytesTotal / (1024 * 1024);
|
||||
if (bytesTotal > 0) {
|
||||
_log.d(
|
||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||
);
|
||||
} else {
|
||||
_log.d(
|
||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||
);
|
||||
}
|
||||
}
|
||||
_idleProgressPollTick =
|
||||
(_idleProgressPollTick + 1) % _idleProgressPollEveryTicks;
|
||||
if (_idleProgressPollTick != 0) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
_idleProgressPollTick = 0;
|
||||
}
|
||||
|
||||
if (progressUpdates.isNotEmpty) {
|
||||
var updatedItems = currentItems;
|
||||
bool changed = false;
|
||||
|
||||
for (final entry in progressUpdates.entries) {
|
||||
final index = itemIndexById[entry.key];
|
||||
if (index == null) continue;
|
||||
final current = updatedItems[index];
|
||||
if (current.status == DownloadStatus.skipped ||
|
||||
current.status == DownloadStatus.completed ||
|
||||
current.status == DownloadStatus.failed) {
|
||||
continue;
|
||||
}
|
||||
final update = entry.value;
|
||||
final next = current.copyWith(
|
||||
status: update.status,
|
||||
progress: update.progress,
|
||||
speedMBps: update.speedMBps ?? current.speedMBps,
|
||||
bytesReceived: update.bytesReceived ?? current.bytesReceived,
|
||||
);
|
||||
if (current.status != next.status ||
|
||||
current.progress != next.progress ||
|
||||
current.speedMBps != next.speedMBps ||
|
||||
current.bytesReceived != next.bytesReceived) {
|
||||
if (!changed) {
|
||||
updatedItems = List<DownloadItem>.from(updatedItems);
|
||||
changed = true;
|
||||
}
|
||||
updatedItems[index] = next;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
state = state.copyWith(items: updatedItems);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFinalizingItem && finalizingTrackName != null) {
|
||||
final safeArtistName = finalizingArtistName ?? '';
|
||||
if (finalizingTrackName != _lastFinalizingTrackName ||
|
||||
safeArtistName != _lastFinalizingArtistName) {
|
||||
_notificationService.showDownloadFinalizing(
|
||||
trackName: finalizingTrackName,
|
||||
artistName: safeArtistName,
|
||||
);
|
||||
_lastFinalizingTrackName = finalizingTrackName;
|
||||
_lastFinalizingArtistName = safeArtistName;
|
||||
}
|
||||
return;
|
||||
}
|
||||
_lastFinalizingTrackName = null;
|
||||
_lastFinalizingArtistName = null;
|
||||
|
||||
if (items.isNotEmpty) {
|
||||
final firstEntry = items.entries.first;
|
||||
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
||||
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||
|
||||
if (downloadingCount > 0 && firstDownloading != null) {
|
||||
final trackName = downloadingCount == 1
|
||||
? firstDownloading.track.name
|
||||
: '$downloadingCount downloads';
|
||||
final artistName = downloadingCount == 1
|
||||
? firstDownloading.track.artistName
|
||||
: 'Downloading...';
|
||||
|
||||
int notifProgress = bytesReceived;
|
||||
int notifTotal = bytesTotal;
|
||||
|
||||
if (bytesTotal <= 0) {
|
||||
final progressPercent =
|
||||
(firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||
notifProgress = (progressPercent * 100).toInt();
|
||||
notifTotal = 100;
|
||||
}
|
||||
|
||||
final safeNotifTotal = notifTotal > 0 ? notifTotal : 1;
|
||||
if (_shouldUpdateProgressNotification(
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
progress: notifProgress,
|
||||
total: safeNotifTotal,
|
||||
queueCount: queuedCount,
|
||||
)) {
|
||||
_notificationService.showDownloadProgress(
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
progress: notifProgress,
|
||||
total: safeNotifTotal,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
_maybeUpdateAndroidDownloadService(
|
||||
trackName: firstDownloading.track.name,
|
||||
artistName: firstDownloading.track.artistName,
|
||||
progress: notifProgress,
|
||||
total: safeNotifTotal,
|
||||
queueCount: queuedCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||
_processAllDownloadProgress(allProgress);
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
@@ -1113,6 +1015,221 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
});
|
||||
}
|
||||
|
||||
void _processAllDownloadProgress(Map<String, dynamic> allProgress) {
|
||||
final rawItems = allProgress['items'];
|
||||
final items = rawItems is Map
|
||||
? rawItems.map((key, value) => MapEntry(key.toString(), value))
|
||||
: const <String, dynamic>{};
|
||||
final currentItems = state.items;
|
||||
final itemsById = <String, DownloadItem>{};
|
||||
final itemIndexById = <String, int>{};
|
||||
int queuedCount = 0;
|
||||
int downloadingCount = 0;
|
||||
DownloadItem? firstDownloading;
|
||||
for (int i = 0; i < currentItems.length; i++) {
|
||||
final item = currentItems[i];
|
||||
itemsById[item.id] = item;
|
||||
itemIndexById[item.id] = i;
|
||||
if (item.status == DownloadStatus.downloading) {
|
||||
downloadingCount++;
|
||||
firstDownloading ??= item;
|
||||
}
|
||||
if (item.status == DownloadStatus.queued ||
|
||||
item.status == DownloadStatus.downloading) {
|
||||
queuedCount++;
|
||||
}
|
||||
}
|
||||
final progressUpdates = <String, _ProgressUpdate>{};
|
||||
|
||||
bool hasFinalizingItem = false;
|
||||
String? finalizingTrackName;
|
||||
String? finalizingArtistName;
|
||||
|
||||
for (final entry in items.entries) {
|
||||
final itemId = entry.key;
|
||||
final localItem = itemsById[itemId];
|
||||
if (localItem == null) {
|
||||
continue;
|
||||
}
|
||||
if (localItem.status == DownloadStatus.skipped) {
|
||||
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
|
||||
continue;
|
||||
}
|
||||
if (localItem.status == DownloadStatus.completed ||
|
||||
localItem.status == DownloadStatus.failed) {
|
||||
continue;
|
||||
}
|
||||
final rawItemProgress = entry.value;
|
||||
if (rawItemProgress is! Map) {
|
||||
continue;
|
||||
}
|
||||
final itemProgress = Map<String, dynamic>.from(rawItemProgress);
|
||||
final bytesReceived =
|
||||
(itemProgress['bytes_received'] as num?)?.toInt() ?? 0;
|
||||
final bytesTotal = (itemProgress['bytes_total'] as num?)?.toInt() ?? 0;
|
||||
final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
|
||||
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||
|
||||
if (status == 'finalizing' && bytesTotal > 0) {
|
||||
progressUpdates[itemId] = const _ProgressUpdate(
|
||||
status: DownloadStatus.finalizing,
|
||||
progress: 1.0,
|
||||
);
|
||||
hasFinalizingItem = true;
|
||||
finalizingTrackName = localItem.track.name;
|
||||
finalizingArtistName = localItem.track.artistName;
|
||||
continue;
|
||||
}
|
||||
|
||||
final progressFromBackend =
|
||||
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||
|
||||
if (isDownloading) {
|
||||
double percentage = 0.0;
|
||||
if (bytesTotal > 0) {
|
||||
percentage = bytesReceived / bytesTotal;
|
||||
} else {
|
||||
percentage = progressFromBackend;
|
||||
}
|
||||
final normalizedProgress = _normalizeProgressForUi(percentage);
|
||||
final normalizedSpeed = _normalizeSpeedForUi(speedMBps);
|
||||
final normalizedBytes = _normalizeBytesForUi(bytesReceived);
|
||||
|
||||
progressUpdates[itemId] = _ProgressUpdate(
|
||||
status: DownloadStatus.downloading,
|
||||
progress: normalizedProgress,
|
||||
speedMBps: normalizedSpeed,
|
||||
bytesReceived: normalizedBytes,
|
||||
);
|
||||
|
||||
if (LogBuffer.loggingEnabled) {
|
||||
final mbReceived = bytesReceived / (1024 * 1024);
|
||||
final mbTotal = bytesTotal / (1024 * 1024);
|
||||
if (bytesTotal > 0) {
|
||||
_log.d(
|
||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||
);
|
||||
} else {
|
||||
_log.d(
|
||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (progressUpdates.isNotEmpty) {
|
||||
var updatedItems = currentItems;
|
||||
bool changed = false;
|
||||
|
||||
for (final entry in progressUpdates.entries) {
|
||||
final index = itemIndexById[entry.key];
|
||||
if (index == null) continue;
|
||||
final current = updatedItems[index];
|
||||
if (current.status == DownloadStatus.skipped ||
|
||||
current.status == DownloadStatus.completed ||
|
||||
current.status == DownloadStatus.failed) {
|
||||
continue;
|
||||
}
|
||||
final update = entry.value;
|
||||
final next = current.copyWith(
|
||||
status: update.status,
|
||||
progress: update.progress,
|
||||
speedMBps: update.speedMBps ?? current.speedMBps,
|
||||
bytesReceived: update.bytesReceived ?? current.bytesReceived,
|
||||
);
|
||||
if (current.status != next.status ||
|
||||
current.progress != next.progress ||
|
||||
current.speedMBps != next.speedMBps ||
|
||||
current.bytesReceived != next.bytesReceived) {
|
||||
if (!changed) {
|
||||
updatedItems = List<DownloadItem>.from(updatedItems);
|
||||
changed = true;
|
||||
}
|
||||
updatedItems[index] = next;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
state = state.copyWith(items: updatedItems);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFinalizingItem && finalizingTrackName != null) {
|
||||
final safeArtistName = finalizingArtistName ?? '';
|
||||
if (finalizingTrackName != _lastFinalizingTrackName ||
|
||||
safeArtistName != _lastFinalizingArtistName) {
|
||||
_notificationService.showDownloadFinalizing(
|
||||
trackName: finalizingTrackName,
|
||||
artistName: safeArtistName,
|
||||
);
|
||||
_lastFinalizingTrackName = finalizingTrackName;
|
||||
_lastFinalizingArtistName = safeArtistName;
|
||||
}
|
||||
return;
|
||||
}
|
||||
_lastFinalizingTrackName = null;
|
||||
_lastFinalizingArtistName = null;
|
||||
|
||||
if (items.isNotEmpty) {
|
||||
final firstEntry = items.entries.first;
|
||||
final rawFirstProgress = firstEntry.value;
|
||||
if (rawFirstProgress is! Map) {
|
||||
return;
|
||||
}
|
||||
final firstProgress = Map<String, dynamic>.from(rawFirstProgress);
|
||||
final bytesReceived =
|
||||
(firstProgress['bytes_received'] as num?)?.toInt() ?? 0;
|
||||
final bytesTotal = (firstProgress['bytes_total'] as num?)?.toInt() ?? 0;
|
||||
|
||||
if (downloadingCount > 0 && firstDownloading != null) {
|
||||
final trackName = downloadingCount == 1
|
||||
? firstDownloading.track.name
|
||||
: '$downloadingCount downloads';
|
||||
final artistName = downloadingCount == 1
|
||||
? firstDownloading.track.artistName
|
||||
: 'Downloading...';
|
||||
|
||||
int notifProgress = bytesReceived;
|
||||
int notifTotal = bytesTotal;
|
||||
|
||||
if (bytesTotal <= 0) {
|
||||
final progressPercent =
|
||||
(firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||
notifProgress = (progressPercent * 100).toInt();
|
||||
notifTotal = 100;
|
||||
}
|
||||
|
||||
final safeNotifTotal = notifTotal > 0 ? notifTotal : 1;
|
||||
if (_shouldUpdateProgressNotification(
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
progress: notifProgress,
|
||||
total: safeNotifTotal,
|
||||
queueCount: queuedCount,
|
||||
)) {
|
||||
_notificationService.showDownloadProgress(
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
progress: notifProgress,
|
||||
total: safeNotifTotal,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
_maybeUpdateAndroidDownloadService(
|
||||
trackName: firstDownloading.track.name,
|
||||
artistName: firstDownloading.track.artistName,
|
||||
progress: notifProgress,
|
||||
total: safeNotifTotal,
|
||||
queueCount: queuedCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeUpdateAndroidDownloadService({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
@@ -1156,9 +1273,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
void _stopProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamSub?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_progressStreamSub = null;
|
||||
_progressPollingErrorCount = 0;
|
||||
_isProgressPollingInFlight = false;
|
||||
_idleProgressPollTick = 0;
|
||||
_hasReceivedProgressStreamEvent = false;
|
||||
_usingProgressStream = false;
|
||||
_lastServiceTrackName = null;
|
||||
_lastServiceArtistName = null;
|
||||
_lastServicePercent = -1;
|
||||
@@ -1926,6 +2050,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
artistName: baseTrack.artistName,
|
||||
albumName: backendAlbum ?? baseTrack.albumName,
|
||||
albumArtist: resolvedAlbumArtist,
|
||||
artistId: baseTrack.artistId,
|
||||
albumId: baseTrack.albumId,
|
||||
coverUrl: baseTrack.coverUrl,
|
||||
duration: baseTrack.duration,
|
||||
isrc: baseTrack.isrc,
|
||||
@@ -1945,8 +2071,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String? genre,
|
||||
String? label,
|
||||
String? copyright,
|
||||
bool writeExternalLrc = true,
|
||||
}) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (!settings.embedMetadata) {
|
||||
_log.d('Metadata embedding disabled, skipping FLAC metadata/cover embed');
|
||||
return;
|
||||
}
|
||||
|
||||
String? coverPath;
|
||||
var coverUrl = track.coverUrl;
|
||||
@@ -2030,12 +2161,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final shouldEmbedLyrics =
|
||||
settings.embedLyrics &&
|
||||
(lyricsMode == 'embed' || lyricsMode == 'both');
|
||||
final shouldSaveExternalLyrics =
|
||||
settings.embedLyrics &&
|
||||
(lyricsMode == 'external' || lyricsMode == 'both');
|
||||
final shouldFetchLyrics = shouldEmbedLyrics || shouldSaveExternalLyrics;
|
||||
String? lrcContent;
|
||||
|
||||
if (shouldEmbedLyrics) {
|
||||
if (shouldFetchLyrics) {
|
||||
try {
|
||||
final durationMs = track.duration * 1000;
|
||||
|
||||
final lrcContent = await PlatformBridge.getLyricsLRC(
|
||||
final fetchedLrc = await PlatformBridge.getLyricsLRC(
|
||||
track.id,
|
||||
track.name,
|
||||
track.artistName,
|
||||
@@ -2043,20 +2179,46 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
durationMs: durationMs,
|
||||
);
|
||||
|
||||
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
|
||||
} else if (lrcContent == '[instrumental:true]') {
|
||||
_log.d('Track is instrumental, skipping lyrics embedding');
|
||||
if (fetchedLrc.isNotEmpty && fetchedLrc != '[instrumental:true]') {
|
||||
lrcContent = fetchedLrc;
|
||||
_log.d('Lyrics fetched for FLAC (${fetchedLrc.length} chars)');
|
||||
} else if (fetchedLrc == '[instrumental:true]') {
|
||||
_log.d('Track is instrumental, skipping lyrics handling');
|
||||
} else {
|
||||
_log.d('No lyrics returned for FLAC download');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to fetch lyrics for embedding: $e');
|
||||
_log.w('Failed to fetch lyrics for FLAC: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldEmbedLyrics) {
|
||||
if (lrcContent != null) {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||
_log.d('Lyrics added to FLAC metadata');
|
||||
} else {
|
||||
_log.d('No lyrics available for FLAC embedding');
|
||||
}
|
||||
} else {
|
||||
metadata['LYRICS'] = '';
|
||||
metadata['UNSYNCEDLYRICS'] = '';
|
||||
_log.d('Lyrics embedding disabled by settings, skipping lyric fetch');
|
||||
_log.d(
|
||||
'Lyrics embedding disabled by settings, skipping lyric embedding',
|
||||
);
|
||||
}
|
||||
|
||||
if (writeExternalLrc && shouldSaveExternalLyrics && lrcContent != null) {
|
||||
try {
|
||||
final replacedPath = flacPath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc');
|
||||
final lrcPath = replacedPath == flacPath
|
||||
? '$flacPath.lrc'
|
||||
: replacedPath;
|
||||
await File(lrcPath).writeAsString(lrcContent);
|
||||
_log.d('External LRC file saved: $lrcPath');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save external LRC file for FLAC: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_log.d('Generating tags for FLAC: $metadata');
|
||||
@@ -2098,6 +2260,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String? copyright,
|
||||
}) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (!settings.embedMetadata) {
|
||||
_log.d('Metadata embedding disabled, skipping MP3 metadata/cover embed');
|
||||
return;
|
||||
}
|
||||
|
||||
String? coverPath;
|
||||
var coverUrl = track.coverUrl;
|
||||
@@ -2262,6 +2428,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String? copyright,
|
||||
}) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (!settings.embedMetadata) {
|
||||
_log.d('Metadata embedding disabled, skipping Opus metadata/cover embed');
|
||||
return;
|
||||
}
|
||||
|
||||
String? coverPath;
|
||||
var coverUrl = track.coverUrl;
|
||||
@@ -2743,6 +2913,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
try {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final metadataEmbeddingEnabled = settings.embedMetadata;
|
||||
|
||||
Track trackToDownload = item.track;
|
||||
final needsEnrichment =
|
||||
@@ -2785,6 +2956,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
(data['album_name'] as String?) ??
|
||||
trackToDownload.albumName,
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||
trackToDownload.artistId,
|
||||
albumId:
|
||||
data['album_id']?.toString() ?? trackToDownload.albumId,
|
||||
coverUrl: data['images'] as String?,
|
||||
duration:
|
||||
((data['duration_ms'] as int?) ??
|
||||
@@ -2991,6 +3167,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
artistId: trackToDownload.artistId,
|
||||
albumId: trackToDownload.albumId,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
duration: trackToDownload.duration,
|
||||
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
|
||||
@@ -3101,12 +3279,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: resolvedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl ?? '',
|
||||
coverUrl: metadataEmbeddingEnabled
|
||||
? (trackToDownload.coverUrl ?? '')
|
||||
: '',
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: quality,
|
||||
embedLyrics: settings.embedLyrics,
|
||||
embedMaxQualityCover: settings.maxQualityCover,
|
||||
embedMetadata: metadataEmbeddingEnabled,
|
||||
embedLyrics: metadataEmbeddingEnabled && settings.embedLyrics,
|
||||
embedMaxQualityCover:
|
||||
metadataEmbeddingEnabled && settings.maxQualityCover,
|
||||
trackNumber: normalizedTrackNumber,
|
||||
discNumber: normalizedDiscNumber,
|
||||
releaseDate: trackToDownload.releaseDate ?? '',
|
||||
@@ -3501,6 +3683,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
writeExternalLrc: false,
|
||||
);
|
||||
|
||||
final newFileName = '${safBaseName ?? 'track'}.flac';
|
||||
@@ -3692,7 +3875,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isContentUriPath &&
|
||||
} else if (metadataEmbeddingEnabled &&
|
||||
isContentUriPath &&
|
||||
effectiveSafMode &&
|
||||
isFlacFile &&
|
||||
!wasExisting) {
|
||||
@@ -3724,6 +3908,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
writeExternalLrc: false,
|
||||
);
|
||||
|
||||
final newFileName = '${safBaseName ?? 'track'}.flac';
|
||||
@@ -3753,7 +3938,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} else if (!isContentUriPath &&
|
||||
} else if (metadataEmbeddingEnabled &&
|
||||
!isContentUriPath &&
|
||||
!effectiveSafMode &&
|
||||
isFlacFile &&
|
||||
!wasExisting &&
|
||||
@@ -3792,7 +3978,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt
|
||||
if (!wasExisting && item.service == 'youtube' && filePath != null) {
|
||||
if (metadataEmbeddingEnabled &&
|
||||
!wasExisting &&
|
||||
item.service == 'youtube' &&
|
||||
filePath != null) {
|
||||
final isOpusFile = filePath.endsWith('.opus');
|
||||
final isMp3File = filePath.endsWith('.mp3');
|
||||
|
||||
@@ -3957,6 +4146,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
final lyricsMode = settings.lyricsMode;
|
||||
final shouldSaveExternalLrc =
|
||||
metadataEmbeddingEnabled &&
|
||||
settings.embedLyrics &&
|
||||
(lyricsMode == 'external' || lyricsMode == 'both');
|
||||
if (shouldSaveExternalLrc &&
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -9,6 +13,7 @@ final _log = AppLogger('ExtensionProvider');
|
||||
|
||||
const _metadataProviderPriorityKey = 'metadata_provider_priority';
|
||||
const _providerPriorityKey = 'provider_priority';
|
||||
const _spotifyWebExtensionId = 'spotify-web';
|
||||
|
||||
class Extension {
|
||||
final String id;
|
||||
@@ -27,12 +32,14 @@ class Extension {
|
||||
final bool hasMetadataProvider;
|
||||
final bool hasDownloadProvider;
|
||||
final bool hasLyricsProvider;
|
||||
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final bool
|
||||
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final SearchBehavior? searchBehavior;
|
||||
final URLHandler? urlHandler;
|
||||
final TrackMatching? trackMatching;
|
||||
final PostProcessing? postProcessing;
|
||||
final Map<String, dynamic> capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||
final Map<String, dynamic>
|
||||
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||
|
||||
const Extension({
|
||||
required this.id,
|
||||
@@ -63,7 +70,8 @@ class Extension {
|
||||
return Extension(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
displayName:
|
||||
json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
author: json['author'] as String? ?? 'Unknown',
|
||||
description: json['description'] as String? ?? '',
|
||||
@@ -71,28 +79,40 @@ class Extension {
|
||||
status: json['status'] as String? ?? 'loaded',
|
||||
errorMessage: json['error_message'] as String?,
|
||||
iconPath: json['icon_path'] as String?,
|
||||
permissions: (json['permissions'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
settings: (json['settings'] as List<dynamic>?)
|
||||
?.map((s) => ExtensionSetting.fromJson(s as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
qualityOptions: (json['quality_options'] as List<dynamic>?)
|
||||
?.map((q) => QualityOption.fromJson(q as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
permissions:
|
||||
(json['permissions'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
settings:
|
||||
(json['settings'] as List<dynamic>?)
|
||||
?.map((s) => ExtensionSetting.fromJson(s as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
qualityOptions:
|
||||
(json['quality_options'] as List<dynamic>?)
|
||||
?.map((q) => QualityOption.fromJson(q as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false,
|
||||
hasDownloadProvider: json['has_download_provider'] as bool? ?? false,
|
||||
hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false,
|
||||
skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false,
|
||||
searchBehavior: json['search_behavior'] != null
|
||||
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
|
||||
skipMetadataEnrichment:
|
||||
json['skip_metadata_enrichment'] as bool? ?? false,
|
||||
searchBehavior: json['search_behavior'] != null
|
||||
? SearchBehavior.fromJson(
|
||||
json['search_behavior'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
urlHandler: json['url_handler'] != null
|
||||
? URLHandler.fromJson(json['url_handler'] as Map<String, dynamic>)
|
||||
: null,
|
||||
trackMatching: json['track_matching'] != null
|
||||
? TrackMatching.fromJson(json['track_matching'] as Map<String, dynamic>)
|
||||
? TrackMatching.fromJson(
|
||||
json['track_matching'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
postProcessing: json['post_processing'] != null
|
||||
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
|
||||
? PostProcessing.fromJson(
|
||||
json['post_processing'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
|
||||
);
|
||||
@@ -139,7 +159,8 @@ class Extension {
|
||||
hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider,
|
||||
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
|
||||
hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider,
|
||||
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
||||
skipMetadataEnrichment:
|
||||
skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
||||
searchBehavior: searchBehavior ?? this.searchBehavior,
|
||||
urlHandler: urlHandler ?? this.urlHandler,
|
||||
trackMatching: trackMatching ?? this.trackMatching,
|
||||
@@ -161,11 +182,7 @@ class SearchFilter {
|
||||
final String? label;
|
||||
final String? icon;
|
||||
|
||||
const SearchFilter({
|
||||
required this.id,
|
||||
this.label,
|
||||
this.icon,
|
||||
});
|
||||
const SearchFilter({required this.id, this.label, this.icon});
|
||||
|
||||
factory SearchFilter.fromJson(Map<String, dynamic> json) {
|
||||
return SearchFilter(
|
||||
@@ -181,10 +198,12 @@ class SearchBehavior {
|
||||
final String? placeholder;
|
||||
final bool primary;
|
||||
final String? icon;
|
||||
final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
final String?
|
||||
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
final int? thumbnailWidth;
|
||||
final int? thumbnailHeight;
|
||||
final List<SearchFilter> filters; // Available search filters (e.g., track, album, artist, playlist)
|
||||
final List<SearchFilter>
|
||||
filters; // Available search filters (e.g., track, album, artist, playlist)
|
||||
|
||||
const SearchBehavior({
|
||||
required this.enabled,
|
||||
@@ -206,9 +225,11 @@ class SearchBehavior {
|
||||
thumbnailRatio: json['thumbnailRatio'] as String?,
|
||||
thumbnailWidth: json['thumbnailWidth'] as int?,
|
||||
thumbnailHeight: json['thumbnailHeight'] as int?,
|
||||
filters: (json['filters'] as List<dynamic>?)
|
||||
?.map((f) => SearchFilter.fromJson(f as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
filters:
|
||||
(json['filters'] as List<dynamic>?)
|
||||
?.map((f) => SearchFilter.fromJson(f as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,7 +237,7 @@ class SearchBehavior {
|
||||
if (thumbnailWidth != null && thumbnailHeight != null) {
|
||||
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
||||
}
|
||||
|
||||
|
||||
switch (thumbnailRatio) {
|
||||
case 'wide': // 16:9 - YouTube style
|
||||
return (defaultSize * 16 / 9, defaultSize);
|
||||
@@ -253,17 +274,18 @@ class PostProcessing {
|
||||
final bool enabled;
|
||||
final List<PostProcessingHook> hooks;
|
||||
|
||||
const PostProcessing({
|
||||
required this.enabled,
|
||||
this.hooks = const [],
|
||||
});
|
||||
const PostProcessing({required this.enabled, this.hooks = const []});
|
||||
|
||||
factory PostProcessing.fromJson(Map<String, dynamic> json) {
|
||||
return PostProcessing(
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
hooks: (json['hooks'] as List<dynamic>?)
|
||||
?.map((h) => PostProcessingHook.fromJson(h as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
hooks:
|
||||
(json['hooks'] as List<dynamic>?)
|
||||
?.map(
|
||||
(h) => PostProcessingHook.fromJson(h as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -273,10 +295,7 @@ class URLHandler {
|
||||
final bool enabled;
|
||||
final List<String> patterns;
|
||||
|
||||
const URLHandler({
|
||||
required this.enabled,
|
||||
this.patterns = const [],
|
||||
});
|
||||
const URLHandler({required this.enabled, this.patterns = const []});
|
||||
|
||||
factory URLHandler.fromJson(Map<String, dynamic> json) {
|
||||
return URLHandler(
|
||||
@@ -319,7 +338,8 @@ class PostProcessingHook {
|
||||
name: json['name'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
defaultEnabled: json['defaultEnabled'] as bool? ?? false,
|
||||
supportedFormats: (json['supportedFormats'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
supportedFormats:
|
||||
(json['supportedFormats'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -342,9 +362,14 @@ class QualityOption {
|
||||
id: json['id'] as String? ?? '',
|
||||
label: json['label'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
settings: (json['settings'] as List<dynamic>?)
|
||||
?.map((s) => QualitySpecificSetting.fromJson(s as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
settings:
|
||||
(json['settings'] as List<dynamic>?)
|
||||
?.map(
|
||||
(s) =>
|
||||
QualitySpecificSetting.fromJson(s as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -447,7 +472,8 @@ class ExtensionState {
|
||||
return ExtensionState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
providerPriority: providerPriority ?? this.providerPriority,
|
||||
metadataProviderPriority: metadataProviderPriority ?? this.metadataProviderPriority,
|
||||
metadataProviderPriority:
|
||||
metadataProviderPriority ?? this.metadataProviderPriority,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
@@ -455,18 +481,44 @@ class ExtensionState {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
AppLifecycleListener? _appLifecycleListener;
|
||||
bool _cleanupInFlight = false;
|
||||
|
||||
@override
|
||||
ExtensionState build() {
|
||||
_appLifecycleListener ??= AppLifecycleListener(
|
||||
onDetach: _scheduleLifecycleCleanup,
|
||||
);
|
||||
ref.onDispose(() {
|
||||
_appLifecycleListener?.dispose();
|
||||
_appLifecycleListener = null;
|
||||
});
|
||||
return const ExtensionState();
|
||||
}
|
||||
|
||||
void _scheduleLifecycleCleanup() {
|
||||
if (_cleanupInFlight) return;
|
||||
_cleanupInFlight = true;
|
||||
unawaited(_cleanupExtensions(reason: 'lifecycle detach'));
|
||||
}
|
||||
|
||||
Future<void> _cleanupExtensions({required String reason}) async {
|
||||
try {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
_log.d('Extensions cleaned up ($reason)');
|
||||
} catch (e) {
|
||||
_log.w('Extension cleanup failed ($reason): $e');
|
||||
} finally {
|
||||
_cleanupInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initialize(String extensionsDir, String dataDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
|
||||
try {
|
||||
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
|
||||
await loadExtensions(extensionsDir);
|
||||
@@ -482,7 +534,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<void> loadExtensions(String dirPath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.loadExtensionsFromDir(dirPath);
|
||||
_log.d('Load extensions result: $result');
|
||||
@@ -500,10 +552,12 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
final extensions = list.map((e) => Extension.fromJson(e)).toList();
|
||||
state = state.copyWith(extensions: extensions);
|
||||
_log.d('Loaded ${extensions.length} extensions');
|
||||
|
||||
|
||||
for (final ext in extensions) {
|
||||
if (ext.searchBehavior != null) {
|
||||
_log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}');
|
||||
_log.d(
|
||||
'Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -512,14 +566,13 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
|
||||
Future<bool> installExtension(String filePath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.loadExtensionFromPath(filePath);
|
||||
_log.i('Installed extension: ${result['name']}');
|
||||
@@ -544,10 +597,12 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<bool> upgradeExtension(String filePath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.upgradeExtension(filePath);
|
||||
_log.i('Upgraded extension: ${result['display_name']} to v${result['version']}');
|
||||
_log.i(
|
||||
'Upgraded extension: ${result['display_name']} to v${result['version']}',
|
||||
);
|
||||
await refreshExtensions();
|
||||
state = state.copyWith(isLoading: false);
|
||||
return true;
|
||||
@@ -560,7 +615,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<bool> removeExtension(String extensionId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
|
||||
try {
|
||||
await PlatformBridge.removeExtension(extensionId);
|
||||
_log.i('Removed extension: $extensionId');
|
||||
@@ -574,35 +629,40 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||
_log.d('Set extension $extensionId enabled: $enabled');
|
||||
|
||||
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||
|
||||
|
||||
final ext = state.extensions
|
||||
.where((e) => e.id == extensionId)
|
||||
.firstOrNull;
|
||||
|
||||
final extensions = state.extensions.map((e) {
|
||||
if (e.id == extensionId) {
|
||||
return e.copyWith(enabled: enabled);
|
||||
}
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
|
||||
state = state.copyWith(extensions: extensions);
|
||||
|
||||
|
||||
if (!enabled && ext != null) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
|
||||
if (settings.searchProvider == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
|
||||
_log.d(
|
||||
'Cleared search provider and reset to Deezer because extension $extensionId was disabled',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
||||
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
||||
_log.d(
|
||||
'Reset default service to Tidal because extension $extensionId was disabled',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -611,6 +671,68 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> ensureSpotifyWebExtensionReady({
|
||||
bool setAsSearchProvider = true,
|
||||
}) async {
|
||||
try {
|
||||
await refreshExtensions();
|
||||
|
||||
var ext = state.extensions
|
||||
.where((e) => e.id == _spotifyWebExtensionId)
|
||||
.firstOrNull;
|
||||
|
||||
if (ext == null) {
|
||||
final cacheDir = await getTemporaryDirectory();
|
||||
await PlatformBridge.initExtensionStore(cacheDir.path);
|
||||
|
||||
final tempRoot = await getTemporaryDirectory();
|
||||
final installDir = await Directory(
|
||||
'${tempRoot.path}/spotiflac_bootstrap_spotify_web',
|
||||
).create(recursive: true);
|
||||
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(
|
||||
_spotifyWebExtensionId,
|
||||
installDir.path,
|
||||
);
|
||||
|
||||
final installed = await installExtension(downloadPath);
|
||||
if (!installed) {
|
||||
_log.w('Failed to install spotify-web extension from store');
|
||||
return false;
|
||||
}
|
||||
|
||||
await refreshExtensions();
|
||||
ext = state.extensions
|
||||
.where((e) => e.id == _spotifyWebExtensionId)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
if (ext == null) {
|
||||
_log.w('spotify-web extension is still not available after install');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ext.enabled) {
|
||||
await setExtensionEnabled(_spotifyWebExtensionId, true);
|
||||
}
|
||||
|
||||
if (setAsSearchProvider) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.searchProvider != _spotifyWebExtensionId) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider(_spotifyWebExtensionId);
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('spotify-web extension is ready');
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.w('Failed to ensure spotify-web extension is ready: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
||||
try {
|
||||
return await PlatformBridge.getExtensionSettings(extensionId);
|
||||
@@ -620,7 +742,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
||||
Future<void> setExtensionSettings(
|
||||
String extensionId,
|
||||
Map<String, dynamic> settings,
|
||||
) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionSettings(extensionId, settings);
|
||||
_log.d('Updated settings for extension: $extensionId');
|
||||
@@ -635,49 +760,72 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
// Load from SharedPreferences first (persisted)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedJson = prefs.getString(_providerPriorityKey);
|
||||
|
||||
|
||||
List<String> priority;
|
||||
if (savedJson != null) {
|
||||
final saved = jsonDecode(savedJson) as List<dynamic>;
|
||||
priority = saved.map((e) => e as String).toList();
|
||||
priority = _sanitizeDownloadProviderPriority(priority);
|
||||
_log.d('Loaded provider priority from prefs: $priority');
|
||||
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
} else {
|
||||
// Fallback to Go backend default
|
||||
priority = await PlatformBridge.getProviderPriority();
|
||||
priority = _sanitizeDownloadProviderPriority(priority);
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
_log.d('Using default provider priority: $priority');
|
||||
}
|
||||
|
||||
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load provider priority: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> setProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
final sanitized = _sanitizeDownloadProviderPriority(priority);
|
||||
// Save to SharedPreferences for persistence
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
|
||||
|
||||
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
|
||||
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
_log.d('Saved provider priority: $priority');
|
||||
await PlatformBridge.setProviderPriority(sanitized);
|
||||
state = state.copyWith(providerPriority: sanitized);
|
||||
_log.d('Saved provider priority: $sanitized');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set provider priority: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _sanitizeDownloadProviderPriority(List<String> input) {
|
||||
final allowed = getAllDownloadProviders().toSet();
|
||||
final result = <String>[];
|
||||
|
||||
for (final provider in input) {
|
||||
if (allowed.contains(provider) && !result.contains(provider)) {
|
||||
result.add(provider);
|
||||
}
|
||||
}
|
||||
|
||||
for (final provider in const ['tidal', 'qobuz', 'amazon']) {
|
||||
if (!result.contains(provider)) {
|
||||
result.add(provider);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> loadMetadataProviderPriority() async {
|
||||
try {
|
||||
// Load from SharedPreferences first (persisted)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedJson = prefs.getString(_metadataProviderPriorityKey);
|
||||
|
||||
|
||||
List<String> priority;
|
||||
if (savedJson != null) {
|
||||
final saved = jsonDecode(savedJson) as List<dynamic>;
|
||||
@@ -690,7 +838,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
priority = await PlatformBridge.getMetadataProviderPriority();
|
||||
_log.d('Using default metadata provider priority: $priority');
|
||||
}
|
||||
|
||||
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load metadata provider priority: $e');
|
||||
@@ -702,7 +850,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
// Save to SharedPreferences for persistence
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
|
||||
|
||||
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
@@ -714,12 +862,9 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
Future<void> cleanup() async {
|
||||
try {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
_log.d('Extensions cleaned up');
|
||||
} catch (e) {
|
||||
_log.e('Failed to cleanup extensions: $e');
|
||||
}
|
||||
if (_cleanupInFlight) return;
|
||||
_cleanupInFlight = true;
|
||||
await _cleanupExtensions(reason: 'manual');
|
||||
}
|
||||
|
||||
Extension? getExtension(String extensionId) {
|
||||
@@ -755,7 +900,9 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<Extension> get searchProviders {
|
||||
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
||||
return state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,15 +121,25 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
static const _progressPollingInterval = Duration(milliseconds: 800);
|
||||
Timer? _progressTimer;
|
||||
Timer? _progressStreamBootstrapTimer;
|
||||
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
|
||||
bool _isLoaded = false;
|
||||
bool _scanCancelRequested = false;
|
||||
int _progressPollingErrorCount = 0;
|
||||
bool _isProgressPollingInFlight = false;
|
||||
bool _hasReceivedProgressStreamEvent = false;
|
||||
bool _usingProgressStream = false;
|
||||
static const _scanNotificationHeartbeat = Duration(seconds: 4);
|
||||
int _lastScanNotificationPercent = -1;
|
||||
int _lastScanNotificationTotalFiles = -1;
|
||||
DateTime _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
LocalLibraryState build() {
|
||||
ref.onDispose(() {
|
||||
_progressTimer?.cancel();
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamSub?.cancel();
|
||||
});
|
||||
|
||||
Future.microtask(() async {
|
||||
@@ -257,12 +267,19 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
scanErrorCount: 0,
|
||||
scanWasCancelled: false,
|
||||
);
|
||||
await _showScanProgressNotification(
|
||||
_resetScanNotificationTracking();
|
||||
if (_shouldShowScanProgressNotification(
|
||||
progress: 0,
|
||||
scannedFiles: 0,
|
||||
totalFiles: 0,
|
||||
currentFile: null,
|
||||
);
|
||||
isComplete: false,
|
||||
)) {
|
||||
await _showScanProgressNotification(
|
||||
progress: 0,
|
||||
scannedFiles: 0,
|
||||
totalFiles: 0,
|
||||
currentFile: null,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
@@ -499,49 +516,75 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
void _startProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_hasReceivedProgressStreamEvent = false;
|
||||
_usingProgressStream = false;
|
||||
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
_progressStreamSub = PlatformBridge.libraryScanProgressStream().listen(
|
||||
(progress) async {
|
||||
_hasReceivedProgressStreamEvent = true;
|
||||
_usingProgressStream = true;
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamBootstrapTimer = null;
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
await _handleLibraryScanProgress(progress);
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Library scan progress stream processing failed: $e');
|
||||
}
|
||||
} finally {
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (_usingProgressStream) {
|
||||
_log.w(
|
||||
'Library scan progress stream failed, fallback to polling: $error',
|
||||
);
|
||||
}
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_usingProgressStream = false;
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_startProgressPollingTimer();
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
_progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (_hasReceivedProgressStreamEvent) {
|
||||
return;
|
||||
}
|
||||
_log.w('Library scan progress stream timeout, fallback to polling');
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_usingProgressStream = false;
|
||||
_startProgressPollingTimer();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_startProgressPollingTimer();
|
||||
}
|
||||
|
||||
void _startProgressPollingTimer() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||
final nextProgress =
|
||||
(progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||
0.0,
|
||||
100.0,
|
||||
);
|
||||
final currentFile = progress['current_file'] as String?;
|
||||
final totalFiles = progress['total_files'] as int? ?? 0;
|
||||
final scannedFiles = progress['scanned_files'] as int? ?? 0;
|
||||
final errorCount = progress['error_count'] as int? ?? 0;
|
||||
|
||||
final shouldUpdateState =
|
||||
state.scanProgress != normalizedProgress ||
|
||||
state.scanCurrentFile != currentFile ||
|
||||
state.scanTotalFiles != totalFiles ||
|
||||
state.scannedFiles != scannedFiles ||
|
||||
state.scanErrorCount != errorCount;
|
||||
|
||||
if (shouldUpdateState) {
|
||||
state = state.copyWith(
|
||||
scanProgress: normalizedProgress,
|
||||
scanCurrentFile: currentFile,
|
||||
scanTotalFiles: totalFiles,
|
||||
scannedFiles: scannedFiles,
|
||||
scanErrorCount: errorCount,
|
||||
);
|
||||
await _showScanProgressNotification(
|
||||
progress: normalizedProgress,
|
||||
scannedFiles: scannedFiles,
|
||||
totalFiles: totalFiles,
|
||||
currentFile: currentFile,
|
||||
);
|
||||
}
|
||||
|
||||
if (progress['is_complete'] == true) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
await _handleLibraryScanProgress(progress);
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
@@ -554,11 +597,93 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async {
|
||||
final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||
0.0,
|
||||
100.0,
|
||||
);
|
||||
final currentFile = progress['current_file'] as String?;
|
||||
final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0;
|
||||
final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0;
|
||||
final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0;
|
||||
final isComplete = progress['is_complete'] == true;
|
||||
|
||||
final shouldUpdateState =
|
||||
state.scanProgress != normalizedProgress ||
|
||||
state.scanCurrentFile != currentFile ||
|
||||
state.scanTotalFiles != totalFiles ||
|
||||
state.scannedFiles != scannedFiles ||
|
||||
state.scanErrorCount != errorCount;
|
||||
|
||||
if (shouldUpdateState) {
|
||||
state = state.copyWith(
|
||||
scanProgress: normalizedProgress,
|
||||
scanCurrentFile: currentFile,
|
||||
scanTotalFiles: totalFiles,
|
||||
scannedFiles: scannedFiles,
|
||||
scanErrorCount: errorCount,
|
||||
);
|
||||
}
|
||||
|
||||
if (_shouldShowScanProgressNotification(
|
||||
progress: normalizedProgress,
|
||||
totalFiles: totalFiles,
|
||||
isComplete: isComplete,
|
||||
)) {
|
||||
await _showScanProgressNotification(
|
||||
progress: normalizedProgress,
|
||||
scannedFiles: scannedFiles,
|
||||
totalFiles: totalFiles,
|
||||
currentFile: currentFile,
|
||||
);
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
}
|
||||
|
||||
void _stopProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamSub?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_progressStreamSub = null;
|
||||
_progressPollingErrorCount = 0;
|
||||
_isProgressPollingInFlight = false;
|
||||
_hasReceivedProgressStreamEvent = false;
|
||||
_usingProgressStream = false;
|
||||
_resetScanNotificationTracking();
|
||||
}
|
||||
|
||||
void _resetScanNotificationTracking() {
|
||||
_lastScanNotificationPercent = -1;
|
||||
_lastScanNotificationTotalFiles = -1;
|
||||
_lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
|
||||
bool _shouldShowScanProgressNotification({
|
||||
required double progress,
|
||||
required int totalFiles,
|
||||
required bool isComplete,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final percent = progress.round().clamp(0, 100);
|
||||
final percentChanged = percent != _lastScanNotificationPercent;
|
||||
final totalFilesChanged = totalFiles != _lastScanNotificationTotalFiles;
|
||||
final heartbeatDue =
|
||||
now.difference(_lastScanNotificationAt) >= _scanNotificationHeartbeat;
|
||||
|
||||
if (!percentChanged && !totalFilesChanged && !isComplete && !heartbeatDue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastScanNotificationPercent = percent;
|
||||
_lastScanNotificationTotalFiles = totalFiles;
|
||||
_lastScanNotificationAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> cancelScan() async {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 2;
|
||||
const _currentMigrationVersion = 4;
|
||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
final _log = AppLogger('SettingsProvider');
|
||||
|
||||
@@ -93,6 +94,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
if (!state.isFirstLaunch && !state.hasCompletedTutorial) {
|
||||
state = state.copyWith(hasCompletedTutorial: true);
|
||||
}
|
||||
// Migration 4: include Spotify Lyrics API in provider order for existing users
|
||||
if (!state.lyricsProviders.contains('spotify_api')) {
|
||||
final updatedProviders = List<String>.from(state.lyricsProviders);
|
||||
final lrclibIndex = updatedProviders.indexOf('lrclib');
|
||||
if (lrclibIndex >= 0) {
|
||||
updatedProviders.insert(lrclibIndex + 1, 'spotify_api');
|
||||
} else {
|
||||
updatedProviders.add('spotify_api');
|
||||
}
|
||||
state = state.copyWith(lyricsProviders: updatedProviders);
|
||||
}
|
||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||
await _saveSettings();
|
||||
}
|
||||
@@ -266,11 +279,26 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoSkipUnavailableTracks(bool enabled) {
|
||||
state = state.copyWith(autoSkipUnavailableTracks: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSmartQueueEnabled(bool enabled) {
|
||||
state = state.copyWith(smartQueueEnabled: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEmbedLyrics(bool enabled) {
|
||||
state = state.copyWith(embedLyrics: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEmbedMetadata(bool enabled) {
|
||||
state = state.copyWith(embedMetadata: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLyricsMode(String mode) {
|
||||
if (mode == 'embed' || mode == 'external' || mode == 'both') {
|
||||
state = state.copyWith(lyricsMode: mode);
|
||||
|
||||
@@ -551,6 +551,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
|
||||
@@ -713,6 +714,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
searchPlaylists: playlists,
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter, // Preserve filter in results
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
@@ -722,6 +724,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
}
|
||||
@@ -737,6 +740,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve filter during loading
|
||||
);
|
||||
@@ -776,6 +780,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
searchArtists: [],
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve selected filter
|
||||
@@ -787,6 +792,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -808,6 +814,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
artistName: track.artistName,
|
||||
albumName: track.albumName,
|
||||
albumArtist: track.albumArtist,
|
||||
artistId: track.artistId,
|
||||
albumId: track.albumId,
|
||||
coverUrl: track.coverUrl,
|
||||
isrc: track.isrc,
|
||||
duration: track.duration,
|
||||
@@ -876,19 +884,23 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
playlistName: playlistName,
|
||||
coverUrl: coverUrl,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
);
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
return Track(
|
||||
id: data['spotify_id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
artistName: data['artists'] as String? ?? '',
|
||||
albumName: data['album_name'] as String? ?? '',
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: data['images'] as String?,
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
@@ -896,13 +908,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
|
||||
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
durationMs = durationValue;
|
||||
} else if (durationValue is double) {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
final durationMs = _extractDurationMs(data);
|
||||
|
||||
final itemType = data['item_type']?.toString();
|
||||
|
||||
@@ -912,6 +918,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
@@ -927,6 +935,32 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
int _extractDurationMs(Map<String, dynamic> data) {
|
||||
final durationMsRaw = data['duration_ms'];
|
||||
if (durationMsRaw is num && durationMsRaw > 0) {
|
||||
return durationMsRaw.toInt();
|
||||
}
|
||||
if (durationMsRaw is String) {
|
||||
final parsed = num.tryParse(durationMsRaw.trim());
|
||||
if (parsed != null && parsed > 0) {
|
||||
return parsed.toInt();
|
||||
}
|
||||
}
|
||||
|
||||
final durationSecRaw = data['duration'];
|
||||
if (durationSecRaw is num && durationSecRaw > 0) {
|
||||
return (durationSecRaw * 1000).toInt();
|
||||
}
|
||||
if (durationSecRaw is String) {
|
||||
final parsed = num.tryParse(durationSecRaw.trim());
|
||||
if (parsed != null && parsed > 0) {
|
||||
return (parsed * 1000).toInt();
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||
return ArtistAlbum(
|
||||
id: data['id'] as String? ?? '',
|
||||
|
||||
@@ -14,9 +14,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
show ExtensionArtistScreen;
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
class _AlbumCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
@@ -187,7 +185,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
.toList();
|
||||
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||
final artistId = albumInfo?['artist_id'] as String?;
|
||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||
?.toString();
|
||||
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
|
||||
@@ -215,6 +214,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
artistName: data['artists'] as String? ?? '',
|
||||
albumName: data['album_name'] as String? ?? '',
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
||||
albumId: data['album_id']?.toString() ?? widget.albumId,
|
||||
coverUrl: data['images'] as String?,
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||
@@ -368,19 +370,19 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
),
|
||||
if (artistName != null && artistName.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToArtist(context, artistName),
|
||||
child: Text(
|
||||
artistName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
ClickableArtistName(
|
||||
artistName: artistName,
|
||||
artistId: _artistId,
|
||||
coverUrl: widget.coverUrl,
|
||||
extensionId: widget.extensionId,
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (tracks.isNotEmpty) ...[
|
||||
@@ -459,7 +461,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
icon: Icon(Icons.download, size: 18),
|
||||
label: Text(
|
||||
context.l10n.downloadAllCount(tracks.length),
|
||||
),
|
||||
@@ -608,8 +610,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed:
|
||||
tracks == null || tracks.isEmpty ? null : () => _loveAll(tracks),
|
||||
onPressed: tracks == null || tracks.isEmpty
|
||||
? null
|
||||
: () => _loveAll(tracks),
|
||||
icon: Icon(
|
||||
allLoved ? Icons.favorite : Icons.favorite_border,
|
||||
size: 22,
|
||||
@@ -634,10 +637,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed:
|
||||
_tracks == null || _tracks!.isEmpty
|
||||
? null
|
||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
|
||||
onPressed: _tracks == null || _tracks!.isEmpty
|
||||
? null
|
||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
|
||||
icon: const Icon(Icons.add, size: 22, color: Colors.white),
|
||||
tooltip: 'Add to Playlist',
|
||||
padding: EdgeInsets.zero,
|
||||
@@ -657,9 +659,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Removed ${tracks.length} tracks from Loved'),
|
||||
),
|
||||
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -672,55 +672,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Added $addedCount tracks to Loved'),
|
||||
),
|
||||
SnackBar(content: Text('Added $addedCount tracks to Loved')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToArtist(BuildContext context, String artistName) {
|
||||
final artistId =
|
||||
_artistId ??
|
||||
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
|
||||
|
||||
if (artistId == 'unknown' ||
|
||||
artistId == 'deezer:unknown' ||
|
||||
artistId.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Artist information not available')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.extensionId != null) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ExtensionArtistScreen(
|
||||
extensionId: widget.extensionId!,
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ArtistScreen(
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit =
|
||||
error.contains('429') ||
|
||||
@@ -860,8 +817,10 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
track.artistName,
|
||||
child: ClickableArtistName(
|
||||
artistName: track.artistName,
|
||||
artistId: track.artistId,
|
||||
coverUrl: track.coverUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
@@ -909,6 +868,11 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
),
|
||||
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
|
||||
context,
|
||||
ref,
|
||||
track,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
show ExtensionAlbumScreen;
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
/// Simple in-memory cache for artist data
|
||||
class _ArtistCache {
|
||||
@@ -309,6 +310,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||
widget.artistId,
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
@@ -675,6 +680,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -772,7 +778,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
List<ArtistAlbum> albums,
|
||||
) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
@@ -990,6 +995,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
.toString(),
|
||||
albumName: album.name,
|
||||
albumArtist: widget.artistName,
|
||||
artistId: widget.artistId,
|
||||
albumId: album.id.isNotEmpty ? album.id : null,
|
||||
coverUrl: album.coverUrl,
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
@@ -1110,17 +1117,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
children: [
|
||||
Text(
|
||||
widget.artistName,
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 4,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
style: Theme.of(context).textTheme.headlineLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 4,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -1128,16 +1136,19 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
listenersText,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -1263,6 +1274,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
),
|
||||
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
|
||||
context,
|
||||
ref,
|
||||
track,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
@@ -1329,8 +1345,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (track.albumName.isNotEmpty)
|
||||
Text(
|
||||
track.albumName,
|
||||
ClickableAlbumName(
|
||||
albumName: track.albumName,
|
||||
albumId: track.albumId,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
extensionId: widget.extensionId,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
@@ -1339,9 +1359,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
TrackCollectionQuickActions(
|
||||
track: track,
|
||||
),
|
||||
TrackCollectionQuickActions(track: track),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
@@ -267,9 +268,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
Future<void> _openFile(DownloadHistoryItem track) async {
|
||||
try {
|
||||
await openFile(filePath);
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playLocalPath(
|
||||
path: track.filePath,
|
||||
title: track.trackName,
|
||||
artist: track.artistName,
|
||||
album: track.albumName,
|
||||
coverUrl: track.coverUrl ?? '',
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -849,7 +858,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
trailing: _isSelectionMode
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () => _openFile(track.filePath),
|
||||
onPressed: () => _openFile(track),
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(
|
||||
@@ -915,6 +924,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
|
||||
+593
-396
File diff suppressed because it is too large
Load Diff
@@ -149,6 +149,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -264,6 +265,9 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
const double size = 48;
|
||||
final borderRadius = BorderRadius.circular(8);
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final cacheWidth = (size * dpr).round().clamp(64, 512);
|
||||
final placeholder = _playlistIconFallback(colorScheme, size);
|
||||
|
||||
// Priority: custom cover > first track cover URL > icon fallback
|
||||
final customCoverPath = playlist.coverImagePath;
|
||||
@@ -275,7 +279,14 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size),
|
||||
cacheWidth: cacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return placeholder;
|
||||
},
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -302,7 +313,14 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size),
|
||||
cacheWidth: cacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return placeholder;
|
||||
},
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -314,15 +332,15 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (size * 2).toInt(),
|
||||
memCacheWidth: cacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => _playlistIconFallback(colorScheme, size),
|
||||
errorWidget: (_, _, _) => _playlistIconFallback(colorScheme, size),
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _playlistIconFallback(colorScheme, size);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
Widget _playlistIconFallback(ColorScheme colorScheme, double size) {
|
||||
|
||||
@@ -6,9 +6,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.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/providers/playback_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';
|
||||
@@ -75,11 +77,47 @@ class _LibraryTracksFolderScreenState
|
||||
};
|
||||
}
|
||||
|
||||
String? _resolveEntryCoverUrl(
|
||||
CollectionTrackEntry entry,
|
||||
LocalLibraryState localState,
|
||||
) {
|
||||
final rawCover = entry.track.coverUrl?.trim();
|
||||
if (rawCover != null &&
|
||||
rawCover.isNotEmpty &&
|
||||
!rawCover.startsWith('content://')) {
|
||||
return rawCover;
|
||||
}
|
||||
|
||||
final isrc = entry.track.isrc?.trim();
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = localState.getByIsrc(isrc);
|
||||
final localCover = byIsrc?.coverPath?.trim();
|
||||
if (localCover != null && localCover.isNotEmpty) {
|
||||
return localCover;
|
||||
}
|
||||
}
|
||||
|
||||
final byTrack = localState.findByTrackAndArtist(
|
||||
entry.track.name,
|
||||
entry.track.artistName,
|
||||
);
|
||||
final localCover = byTrack?.coverPath?.trim();
|
||||
if (localCover != null && localCover.isNotEmpty) {
|
||||
return localCover;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Find the first available cover URL from entries.
|
||||
String? _firstCoverUrl(List<CollectionTrackEntry> entries) {
|
||||
String? _firstCoverUrl(
|
||||
List<CollectionTrackEntry> entries,
|
||||
LocalLibraryState localState,
|
||||
) {
|
||||
for (final entry in entries) {
|
||||
if (entry.track.coverUrl != null && entry.track.coverUrl!.isNotEmpty) {
|
||||
return entry.track.coverUrl;
|
||||
final cover = _resolveEntryCoverUrl(entry, localState);
|
||||
if (cover != null && cover.isNotEmpty) {
|
||||
return cover;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -173,11 +211,7 @@ class _LibraryTracksFolderScreenState
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.selectionSelected(count),
|
||||
),
|
||||
),
|
||||
SnackBar(content: Text(context.l10n.selectionSelected(count))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,11 +230,7 @@ class _LibraryTracksFolderScreenState
|
||||
|
||||
if (!mounted || count == 0) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.selectionSelected(count),
|
||||
),
|
||||
),
|
||||
SnackBar(content: Text(context.l10n.selectionSelected(count))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,6 +247,8 @@ class _LibraryTracksFolderScreenState
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
ref.watch(localLibraryProvider.select((s) => s.items));
|
||||
final localState = ref.read(localLibraryProvider);
|
||||
final UserPlaylistCollection? playlist;
|
||||
final List<CollectionTrackEntry> entries;
|
||||
|
||||
@@ -280,6 +312,9 @@ class _LibraryTracksFolderScreenState
|
||||
LibraryTracksFolderMode.playlist =>
|
||||
context.l10n.collectionPlaylistEmptySubtitle,
|
||||
};
|
||||
final folderTracks = entries
|
||||
.map((entry) => entry.track)
|
||||
.toList(growable: false);
|
||||
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
@@ -296,7 +331,14 @@ class _LibraryTracksFolderScreenState
|
||||
CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme, title, entries, playlist),
|
||||
_buildAppBar(
|
||||
context,
|
||||
colorScheme,
|
||||
title,
|
||||
entries,
|
||||
playlist,
|
||||
localState,
|
||||
),
|
||||
if (entries.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
@@ -316,6 +358,8 @@ class _LibraryTracksFolderScreenState
|
||||
entry: entry,
|
||||
mode: widget.mode,
|
||||
playlistId: widget.playlistId,
|
||||
localLibraryState: localState,
|
||||
folderTracks: folderTracks,
|
||||
isSelectionMode: _isSelectionMode,
|
||||
isSelected: isSelected,
|
||||
onTap: _isSelectionMode
|
||||
@@ -494,8 +538,8 @@ class _LibraryTracksFolderScreenState
|
||||
selectedCount > 0
|
||||
? '${widget.mode == LibraryTracksFolderMode.playlist ? context.l10n.collectionRemoveFromPlaylist : context.l10n.collectionRemoveFromFolder} ($selectedCount)'
|
||||
: widget.mode == LibraryTracksFolderMode.playlist
|
||||
? context.l10n.collectionRemoveFromPlaylist
|
||||
: context.l10n.collectionRemoveFromFolder,
|
||||
? context.l10n.collectionRemoveFromPlaylist
|
||||
: context.l10n.collectionRemoveFromFolder,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: selectedCount > 0
|
||||
@@ -551,13 +595,14 @@ class _LibraryTracksFolderScreenState
|
||||
String title,
|
||||
List<CollectionTrackEntry> entries,
|
||||
UserPlaylistCollection? playlist,
|
||||
LocalLibraryState localState,
|
||||
) {
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final customCoverPath = playlist?.coverImagePath;
|
||||
final isLovedMode = widget.mode == LibraryTracksFolderMode.loved;
|
||||
final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist;
|
||||
// Loved always shows the heart icon (like Spotify's Liked Songs)
|
||||
final coverUrl = isLovedMode ? null : _firstCoverUrl(entries);
|
||||
final coverUrl = isLovedMode ? null : _firstCoverUrl(entries, localState);
|
||||
final hasCustomCover =
|
||||
customCoverPath != null && customCoverPath.isNotEmpty;
|
||||
final hasCoverUrl = coverUrl != null;
|
||||
@@ -608,6 +653,18 @@ class _LibraryTracksFolderScreenState
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final cacheWidth = (MediaQuery.sizeOf(context).width * dpr)
|
||||
.round()
|
||||
.clamp(320, 2048);
|
||||
final coverFallback = Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
_modeIcon(),
|
||||
size: 80,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
@@ -619,26 +676,37 @@ class _LibraryTracksFolderScreenState
|
||||
Image.file(
|
||||
File(customCoverPath),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
_modeIcon(),
|
||||
size: 80,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
cacheWidth: cacheWidth,
|
||||
filterQuality: FilterQuality.low,
|
||||
gaplessPlayback: true,
|
||||
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return coverFallback;
|
||||
},
|
||||
errorBuilder: (_, _, _) => coverFallback,
|
||||
)
|
||||
else if (hasCoverUrl)
|
||||
_isCoverLocalPath(coverUrl)
|
||||
? Image.file(
|
||||
File(coverUrl),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
filterQuality: FilterQuality.low,
|
||||
gaplessPlayback: true,
|
||||
frameBuilder:
|
||||
(_, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) {
|
||||
return child;
|
||||
}
|
||||
return Container(color: colorScheme.surface);
|
||||
},
|
||||
errorBuilder: (_, _, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
@@ -646,14 +714,7 @@ class _LibraryTracksFolderScreenState
|
||||
Container(color: colorScheme.surface),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
_modeIcon(),
|
||||
size: 80,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
coverFallback,
|
||||
// Bottom gradient for readability
|
||||
Positioned(
|
||||
left: 0,
|
||||
@@ -728,6 +789,18 @@ class _LibraryTracksFolderScreenState
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (widget.mode !=
|
||||
LibraryTracksFolderMode.wishlist) ...[
|
||||
_buildShuffleButton(entries),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
_buildDownloadAllCenterButton(context, entries),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -758,11 +831,127 @@ class _LibraryTracksFolderScreenState
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shuffle / Download buttons ──
|
||||
|
||||
Widget _buildShuffleButton(List<CollectionTrackEntry> entries) {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: entries.isEmpty ? null : () => _shufflePlay(entries),
|
||||
icon: const Icon(Icons.shuffle_rounded, size: 22, color: Colors.white),
|
||||
tooltip: 'Shuffle Play',
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadAllCenterButton(
|
||||
BuildContext context,
|
||||
List<CollectionTrackEntry> entries,
|
||||
) {
|
||||
final tracks = entries.map((e) => e.track).toList(growable: false);
|
||||
return FilledButton.icon(
|
||||
onPressed: tracks.isEmpty ? null : () => _downloadAll(context, tracks),
|
||||
icon: const Icon(Icons.download_rounded, size: 18),
|
||||
label: Text(context.l10n.downloadAllCount(tracks.length)),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _shufflePlay(List<CollectionTrackEntry> entries) {
|
||||
final tracks = entries.map((e) => e.track).toList(growable: false);
|
||||
if (tracks.isEmpty) return;
|
||||
final shuffled = [...tracks]..shuffle();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
ref.read(playbackProvider.notifier).playTrackList(shuffled).catchError((e) {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Cannot shuffle play local tracks: $e')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _downloadAll(BuildContext context, List<Track> tracks) {
|
||||
if (tracks.isEmpty) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
final colorScheme = Theme.of(dialogContext).colorScheme;
|
||||
return AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: const Text('Download All'),
|
||||
content: Text('Download ${tracks.length} tracks?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
_executeDownloadAll(context, tracks);
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _executeDownloadAll(BuildContext context, List<Track> tracks) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: '',
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -840,6 +1029,8 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
final CollectionTrackEntry entry;
|
||||
final LibraryTracksFolderMode mode;
|
||||
final String? playlistId;
|
||||
final LocalLibraryState localLibraryState;
|
||||
final List<Track> folderTracks;
|
||||
final bool isSelectionMode;
|
||||
final bool isSelected;
|
||||
final VoidCallback? onTap;
|
||||
@@ -849,6 +1040,8 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
required this.entry,
|
||||
required this.mode,
|
||||
required this.playlistId,
|
||||
required this.localLibraryState,
|
||||
required this.folderTracks,
|
||||
this.isSelectionMode = false,
|
||||
this.isSelected = false,
|
||||
this.onTap,
|
||||
@@ -859,6 +1052,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final track = entry.track;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final effectiveCoverUrl = _resolveCoverUrl(track);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
@@ -903,8 +1097,8 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
],
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: track.coverUrl != null && track.coverUrl!.isNotEmpty
|
||||
? _buildTrackCover(context, track.coverUrl!, 52)
|
||||
child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty
|
||||
? _buildTrackCover(context, effectiveCoverUrl, 52)
|
||||
: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
@@ -917,8 +1111,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
title:
|
||||
Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
@@ -936,15 +1129,45 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
),
|
||||
onTap: isSelectionMode
|
||||
? onTap
|
||||
: mode == LibraryTracksFolderMode.wishlist
|
||||
? () => _downloadTrack(context, ref)
|
||||
: () => _navigateToMetadata(context, ref),
|
||||
: () {
|
||||
if (mode == LibraryTracksFolderMode.wishlist) {
|
||||
_downloadTrack(context, ref);
|
||||
return;
|
||||
}
|
||||
|
||||
_navigateToMetadata(context, ref);
|
||||
},
|
||||
onLongPress: isSelectionMode ? onTap : onLongPress,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _resolveCoverUrl(Track track) {
|
||||
final rawCover = track.coverUrl?.trim();
|
||||
if (rawCover != null &&
|
||||
rawCover.isNotEmpty &&
|
||||
!rawCover.startsWith('content://')) {
|
||||
return rawCover;
|
||||
}
|
||||
|
||||
final isrc = track.isrc?.trim();
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = localLibraryState.getByIsrc(isrc);
|
||||
final localCover = byIsrc?.coverPath?.trim();
|
||||
if (localCover != null && localCover.isNotEmpty) return localCover;
|
||||
}
|
||||
|
||||
final byTrack = localLibraryState.findByTrackAndArtist(
|
||||
track.name,
|
||||
track.artistName,
|
||||
);
|
||||
final localCover = byTrack?.coverPath?.trim();
|
||||
if (localCover != null && localCover.isNotEmpty) return localCover;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Builds a cover image widget that handles both network URLs and local file paths.
|
||||
Widget _buildTrackCover(BuildContext context, String coverUrl, double size) {
|
||||
final isLocal =
|
||||
@@ -984,9 +1207,11 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
|
||||
void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) {
|
||||
final track = entry.track;
|
||||
final effectiveCoverUrl = _resolveCoverUrl(track);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final isDownloaded = historyState.isDownloaded(track.id) ||
|
||||
final isDownloaded =
|
||||
historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null &&
|
||||
track.isrc!.isNotEmpty &&
|
||||
historyState.getByIsrc(track.isrc!) != null) ||
|
||||
@@ -997,6 +1222,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1024,8 +1250,9 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child:
|
||||
track.coverUrl != null && track.coverUrl!.isNotEmpty
|
||||
? _buildTrackCover(context, track.coverUrl!, 56)
|
||||
effectiveCoverUrl != null &&
|
||||
effectiveCoverUrl.isNotEmpty
|
||||
? _buildTrackCover(context, effectiveCoverUrl, 56)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
@@ -1170,15 +1397,15 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
var historyItem = historyState.getBySpotifyId(track.id);
|
||||
|
||||
// 2. Download history by ISRC
|
||||
if (historyItem == null &&
|
||||
track.isrc != null &&
|
||||
track.isrc!.isNotEmpty) {
|
||||
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);
|
||||
historyItem ??= historyState.findByTrackAndArtist(
|
||||
track.name,
|
||||
track.artistName,
|
||||
);
|
||||
|
||||
if (historyItem != null) {
|
||||
await Navigator.of(context).push(
|
||||
@@ -1287,9 +1514,7 @@ class _SelectionActionButton extends StatelessWidget {
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
|
||||
/// Screen to display tracks from a local library album
|
||||
class LocalAlbumScreen extends ConsumerStatefulWidget {
|
||||
@@ -204,9 +205,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
Future<void> _openFile(LocalLibraryItem track) async {
|
||||
try {
|
||||
await openFile(filePath);
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playLocalPath(
|
||||
path: track.filePath,
|
||||
title: track.trackName,
|
||||
artist: track.artistName,
|
||||
album: track.albumName,
|
||||
coverUrl: track.coverPath ?? '',
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -639,7 +648,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
),
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(track.id)
|
||||
: () => _openFile(track.filePath),
|
||||
: () => _openFile(track),
|
||||
onLongPress: _isSelectionMode
|
||||
? null
|
||||
: () => _enterSelectionMode(track.id),
|
||||
@@ -724,7 +733,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
trailing: _isSelectionMode
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () => _openFile(track.filePath),
|
||||
onPressed: () => _openFile(track),
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(
|
||||
@@ -989,6 +998,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
|
||||
+181
-47
@@ -16,9 +16,11 @@ import 'package:spotiflac_android/screens/store_tab.dart';
|
||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/update_checker.dart';
|
||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/mini_player_bar.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('MainShell');
|
||||
@@ -36,11 +38,21 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
bool _hasCheckedUpdate = false;
|
||||
StreamSubscription<String>? _shareSubscription;
|
||||
DateTime? _lastBackPress;
|
||||
final GlobalKey<NavigatorState> _homeTabNavigatorKey =
|
||||
ShellNavigationService.homeTabNavigatorKey;
|
||||
final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
|
||||
ShellNavigationService.libraryTabNavigatorKey;
|
||||
final GlobalKey<NavigatorState> _storeTabNavigatorKey =
|
||||
ShellNavigationService.storeTabNavigatorKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(initialPage: _currentIndex);
|
||||
ShellNavigationService.syncState(
|
||||
currentTabIndex: _currentIndex,
|
||||
showStoreTab: false,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkForUpdates();
|
||||
_setupShareListener();
|
||||
@@ -86,6 +98,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
_homeTabNavigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
|
||||
if (_currentIndex != 0) {
|
||||
_onNavTap(0);
|
||||
@@ -213,10 +226,34 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _resetHomeToMain() {
|
||||
final showStore = ref.read(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
final homeNavigator = _navigatorForTab(0, showStore);
|
||||
homeNavigator?.popUntil((route) => route.isFirst);
|
||||
// Unfocus BEFORE clear so _onTrackStateChanged can properly
|
||||
// clear _urlController (it checks !_searchFocusNode.hasFocus)
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
}
|
||||
|
||||
void _onNavTap(int index) {
|
||||
if (index == 0 && _currentIndex == 0) {
|
||||
_resetHomeToMain();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex != index) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _currentIndex = index);
|
||||
final showStore = ref.read(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
ShellNavigationService.syncState(
|
||||
currentTabIndex: _currentIndex,
|
||||
showStoreTab: showStore,
|
||||
);
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
@@ -226,48 +263,121 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
|
||||
void _onPageChanged(int index) {
|
||||
final previousIndex = _currentIndex;
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
final showStore = ref.read(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
ShellNavigationService.syncState(
|
||||
currentTabIndex: _currentIndex,
|
||||
showStoreTab: showStore,
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (index == 0 && previousIndex != 0) {
|
||||
_resetHomeToMain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleBackPress() {
|
||||
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
||||
if (rootNavigator.canPop()) {
|
||||
_log.i('Back: step 1 - root navigator pop');
|
||||
rootNavigator.pop();
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final showStore = ref.read(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
final currentNavigator = _navigatorForTab(_currentIndex, showStore);
|
||||
if (currentNavigator != null && currentNavigator.canPop()) {
|
||||
_log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)');
|
||||
currentNavigator.pop();
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final trackState = ref.read(trackProvider);
|
||||
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
if (isKeyboardVisible) {
|
||||
|
||||
_log.d(
|
||||
'Back: state check - tab=$_currentIndex, '
|
||||
'isShowingRecentAccess=${trackState.isShowingRecentAccess}, '
|
||||
'hasSearchText=${trackState.hasSearchText}, '
|
||||
'hasContent=${trackState.hasContent}, '
|
||||
'isLoading=${trackState.isLoading}, '
|
||||
'isKeyboardVisible=$isKeyboardVisible',
|
||||
);
|
||||
|
||||
if (_currentIndex == 0 &&
|
||||
trackState.isShowingRecentAccess &&
|
||||
!trackState.isLoading &&
|
||||
(trackState.hasSearchText || trackState.hasContent)) {
|
||||
// Has recent access AND search content — clear everything at once
|
||||
_log.i(
|
||||
'Back: step 3a - dismiss recent access + clear search/content '
|
||||
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
|
||||
// Recent access overlay only (no search content) — just dismiss it
|
||||
_log.i('Back: step 3b - dismiss recent access only');
|
||||
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex == 0 &&
|
||||
!trackState.isLoading &&
|
||||
(trackState.hasSearchText || trackState.hasContent)) {
|
||||
_log.i(
|
||||
'Back: step 4 - clear search/content '
|
||||
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
|
||||
);
|
||||
// Unfocus BEFORE clear so _onTrackStateChanged can properly
|
||||
// clear _urlController (it checks !_searchFocusNode.hasFocus)
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex == 0 && isKeyboardVisible) {
|
||||
_log.i('Back: step 5 - dismiss keyboard');
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex != 0) {
|
||||
_log.i('Back: step 6 - switch to home tab from tab=$_currentIndex');
|
||||
_onNavTap(0);
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (trackState.isLoading) {
|
||||
_log.i('Back: blocked - loading in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastBackPress != null &&
|
||||
now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
|
||||
_log.i('Back: step 8 - double-tap exit');
|
||||
SystemNavigator.pop();
|
||||
} else {
|
||||
_log.i('Back: step 7 - first tap, showing exit snackbar');
|
||||
_lastBackPress = now;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -279,46 +389,46 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
}
|
||||
|
||||
NavigatorState? _navigatorForTab(int index, bool showStore) {
|
||||
if (index == 0) return _homeTabNavigatorKey.currentState;
|
||||
if (index == 1) return _libraryTabNavigatorKey.currentState;
|
||||
if (showStore && index == 2) return _storeTabNavigatorKey.currentState;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queueState = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.queuedCount),
|
||||
);
|
||||
final trackHasSearchText = ref.watch(
|
||||
trackProvider.select((s) => s.hasSearchText),
|
||||
);
|
||||
final trackHasContent = ref.watch(
|
||||
trackProvider.select((s) => s.hasContent),
|
||||
);
|
||||
final trackIsLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||
final trackIsShowingRecentAccess = ref.watch(
|
||||
trackProvider.select((s) => s.isShowingRecentAccess),
|
||||
);
|
||||
final showStore = ref.watch(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
ShellNavigationService.syncState(
|
||||
currentTabIndex: _currentIndex,
|
||||
showStoreTab: showStore,
|
||||
);
|
||||
final storeUpdatesCount = ref.watch(
|
||||
storeProvider.select((s) => s.updatesAvailableCount),
|
||||
);
|
||||
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
|
||||
final canPop =
|
||||
_currentIndex == 0 &&
|
||||
!trackHasSearchText &&
|
||||
!trackHasContent &&
|
||||
!trackIsLoading &&
|
||||
!trackIsShowingRecentAccess &&
|
||||
!isKeyboardVisible;
|
||||
|
||||
final tabs = <Widget>[
|
||||
const HomeTab(),
|
||||
QueueTab(
|
||||
parentPageController: _pageController,
|
||||
parentPageIndex: 1,
|
||||
nextPageIndex: showStore ? 2 : 3,
|
||||
_TabNavigator(
|
||||
key: const ValueKey('tab-home'),
|
||||
navigatorKey: _homeTabNavigatorKey,
|
||||
child: const HomeTab(),
|
||||
),
|
||||
if (showStore) const StoreTab(),
|
||||
_TabNavigator(
|
||||
key: const ValueKey('tab-library'),
|
||||
navigatorKey: _libraryTabNavigatorKey,
|
||||
child: _LibraryTabRoot(parentPageController: _pageController),
|
||||
),
|
||||
if (showStore)
|
||||
_TabNavigator(
|
||||
key: const ValueKey('tab-store'),
|
||||
navigatorKey: _storeTabNavigatorKey,
|
||||
child: const StoreTab(),
|
||||
),
|
||||
const SettingsTab(),
|
||||
];
|
||||
|
||||
@@ -378,7 +488,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: canPop,
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) {
|
||||
return;
|
||||
@@ -387,13 +497,18 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
_handleBackPress();
|
||||
},
|
||||
child: Scaffold(
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: (_currentIndex == 0 && trackIsShowingRecentAccess)
|
||||
? const _NoSwipeRightPhysics()
|
||||
: const ClampingScrollPhysics(),
|
||||
children: tabs,
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: tabs,
|
||||
),
|
||||
),
|
||||
const MiniPlayerBar(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
@@ -415,23 +530,42 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom physics that blocks swiping to the right (next page) while
|
||||
/// still allowing vertical scrolling inside the page content.
|
||||
class _NoSwipeRightPhysics extends ScrollPhysics {
|
||||
const _NoSwipeRightPhysics({super.parent});
|
||||
class _TabNavigator extends StatelessWidget {
|
||||
final GlobalKey<NavigatorState> navigatorKey;
|
||||
final Widget child;
|
||||
|
||||
const _TabNavigator({
|
||||
super.key,
|
||||
required this.navigatorKey,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
_NoSwipeRightPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return _NoSwipeRightPhysics(parent: buildParent(ancestor));
|
||||
Widget build(BuildContext context) {
|
||||
return Navigator(
|
||||
key: navigatorKey,
|
||||
onGenerateInitialRoutes: (_, _) => [
|
||||
MaterialPageRoute<void>(builder: (_) => child),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LibraryTabRoot extends ConsumerWidget {
|
||||
final PageController parentPageController;
|
||||
|
||||
const _LibraryTabRoot({required this.parentPageController});
|
||||
|
||||
@override
|
||||
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
|
||||
// In a horizontal PageView, a negative offset means the user is
|
||||
// dragging left (i.e. trying to go to the next page / right).
|
||||
// Block that direction only.
|
||||
if (offset < 0) return 0.0;
|
||||
return super.applyPhysicsToUserOffset(position, offset);
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final showStore = ref.watch(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
return QueueTab(
|
||||
parentPageController: parentPageController,
|
||||
parentPageIndex: 1,
|
||||
nextPageIndex: showStore ? 2 : 3,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
|
||||
@@ -108,6 +110,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
@@ -297,22 +301,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(
|
||||
context.l10n.downloadAllCount(_tracks.length),
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildLoveAllButton(),
|
||||
const SizedBox(width: 12),
|
||||
_buildDownloadAllCenterButton(context),
|
||||
const SizedBox(width: 12),
|
||||
_buildShufflePlayButton(),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -410,6 +407,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
|
||||
void _downloadTrack(BuildContext context, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
@@ -437,22 +435,175 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadAll(BuildContext context) {
|
||||
// ── Shuffle / Love / Download buttons ──
|
||||
|
||||
Widget _buildCircleButton({
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
required VoidCallback? onPressed,
|
||||
}) {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 22, color: Colors.white),
|
||||
tooltip: tooltip,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoveAllButton() {
|
||||
final collectionsState = ref.watch(libraryCollectionsProvider);
|
||||
final allLoved =
|
||||
_tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t));
|
||||
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
|
||||
icon: Icon(
|
||||
allLoved ? Icons.favorite : Icons.favorite_border,
|
||||
size: 22,
|
||||
color: allLoved ? Colors.redAccent : Colors.white,
|
||||
),
|
||||
tooltip: allLoved ? 'Remove from Loved' : 'Love All',
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadAllCenterButton(BuildContext context) {
|
||||
return FilledButton.icon(
|
||||
onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context),
|
||||
icon: const Icon(Icons.download_rounded, size: 18),
|
||||
label: Text(context.l10n.downloadAllCount(_tracks.length)),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShufflePlayButton() {
|
||||
return _buildCircleButton(
|
||||
icon: Icons.shuffle_rounded,
|
||||
tooltip: 'Shuffle Play',
|
||||
onPressed: _tracks.isEmpty ? null : _shufflePlayLocal,
|
||||
);
|
||||
}
|
||||
|
||||
void _shufflePlayLocal() {
|
||||
if (_tracks.isEmpty) return;
|
||||
final shuffled = [..._tracks]..shuffle();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
ref.read(playbackProvider.notifier).playTrackList(shuffled).catchError((e) {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Cannot shuffle play local tracks: $e')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _confirmDownloadAll(BuildContext context) {
|
||||
if (_tracks.isEmpty) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
final colorScheme = Theme.of(dialogContext).colorScheme;
|
||||
return AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: const Text('Download All'),
|
||||
content: Text('Download ${_tracks.length} tracks?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
_downloadAll(context);
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loveAll(List<Track> tracks) async {
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
final state = ref.read(libraryCollectionsProvider);
|
||||
final allLoved = tracks.every((t) => state.isLoved(t));
|
||||
|
||||
if (allLoved) {
|
||||
for (final track in tracks) {
|
||||
final key = trackCollectionKey(track);
|
||||
await notifier.removeFromLoved(key);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
int addedCount = 0;
|
||||
for (final track in tracks) {
|
||||
if (!state.isLoved(track)) {
|
||||
await notifier.toggleLoved(track);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added $addedCount tracks to Loved')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadAll(BuildContext context) {
|
||||
_downloadTracks(context, _tracks);
|
||||
}
|
||||
|
||||
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
||||
if (tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${_tracks.length} tracks',
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: widget.playlistName,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(_tracks, service, qualityOverride: quality);
|
||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -461,12 +612,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(_tracks, settings.defaultService);
|
||||
.addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
|
||||
),
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -602,9 +751,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: TrackCollectionQuickActions(
|
||||
track: track,
|
||||
),
|
||||
trailing: TrackCollectionQuickActions(track: track),
|
||||
onTap: () => _handleTap(
|
||||
context,
|
||||
ref,
|
||||
@@ -612,6 +759,11 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
),
|
||||
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
|
||||
context,
|
||||
ref,
|
||||
track,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+656
-433
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
final String query;
|
||||
@@ -61,9 +63,10 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
||||
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final tracks = trackState.tracks;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -86,15 +89,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (trackState.isLoading)
|
||||
LinearProgressIndicator(color: colorScheme.primary),
|
||||
if (trackState.error != null)
|
||||
if (isLoading) LinearProgressIndicator(color: colorScheme.primary),
|
||||
if (error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
trackState.error!,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
Expanded(
|
||||
child: tracks.isEmpty
|
||||
@@ -159,14 +158,19 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.artistName,
|
||||
ClickableArtistName(
|
||||
artistName: track.artistName,
|
||||
artistId: track.artistId,
|
||||
coverUrl: track.coverUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
Text(
|
||||
track.albumName,
|
||||
ClickableAlbumName(
|
||||
albumName: track.albumName,
|
||||
albumId: track.albumId,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -175,7 +179,21 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: null,
|
||||
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
|
||||
context,
|
||||
ref,
|
||||
track,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
tooltip: 'Download',
|
||||
onPressed: () => _downloadTrack(track),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _downloadTrack(track),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -763,6 +763,7 @@ class _LanguageSelector extends StatelessWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
@@ -166,6 +167,9 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
const donorNames = [
|
||||
'NinoBrown',
|
||||
'@nino_sandzak',
|
||||
'IMJ',
|
||||
'J',
|
||||
'Julian',
|
||||
'matt_3050',
|
||||
@@ -282,6 +286,19 @@ class _DonateLinksCard extends StatelessWidget {
|
||||
url: AppInfo.githubSponsorsUrl,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 74,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
_CryptoWalletItem(
|
||||
title: 'USDT (TRC20)',
|
||||
walletAddress: 'TL7iAqjq9M8BwVMi9AtHvuAGHtdwEvsDta',
|
||||
color: const Color(0xFF26A17B),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -357,13 +374,97 @@ class _DonateCardItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _CryptoWalletItem extends StatelessWidget {
|
||||
final String title;
|
||||
final String walletAddress;
|
||||
final Color color;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _CryptoWalletItem({
|
||||
required this.title,
|
||||
required this.walletAddress,
|
||||
required this.color,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: walletAddress));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$title address copied to clipboard'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'\$',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
walletAddress,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.copy_rounded,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int _cr(String v) {
|
||||
int r = 0x1F;
|
||||
for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; }
|
||||
return r;
|
||||
}
|
||||
// Highlighted supporters (hashes of names): Julian, J.
|
||||
const _cv = {1825257268, 1035};
|
||||
// Highlighted supporters (hashes of names): Julian, J, NinoBrown, @nino_sandzak, IMJ.
|
||||
const _cv = {1825257268, 1035, 1497948283, 398058782, 996135};
|
||||
|
||||
class _SupporterChip extends StatelessWidget {
|
||||
final String name;
|
||||
|
||||
@@ -501,14 +501,17 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.subtitles_outlined,
|
||||
title: context.l10n.optionsEmbedLyrics,
|
||||
subtitle: context.l10n.optionsEmbedLyricsSubtitle,
|
||||
subtitle: settings.embedMetadata
|
||||
? context.l10n.optionsEmbedLyricsSubtitle
|
||||
: 'Disabled while Embed Metadata is turned off',
|
||||
value: settings.embedLyrics,
|
||||
enabled: settings.embedMetadata,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setEmbedLyrics(value),
|
||||
showDivider: settings.embedLyrics,
|
||||
showDivider: settings.embedMetadata && settings.embedLyrics,
|
||||
),
|
||||
if (settings.embedLyrics) ...[
|
||||
if (settings.embedMetadata && settings.embedLyrics) ...[
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
@@ -858,6 +861,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -992,6 +996,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -1209,6 +1214,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1288,6 +1294,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1451,6 +1458,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1516,6 +1524,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
}
|
||||
|
||||
static const _providerDisplayNames = <String, String>{
|
||||
'spotify_api': 'Spotify Lyrics API',
|
||||
'lrclib': 'LRCLIB',
|
||||
'netease': 'Netease',
|
||||
'musixmatch': 'Musixmatch',
|
||||
@@ -1544,6 +1553,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1604,6 +1614,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1702,6 +1713,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1786,6 +1798,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1857,6 +1870,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final normalizedCurrent = current.trim().toUpperCase();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -1924,6 +1938,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -2024,16 +2039,13 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'youtube'];
|
||||
|
||||
final extensionProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||
.toList();
|
||||
|
||||
final isExtensionService = ![
|
||||
'tidal',
|
||||
'qobuz',
|
||||
'amazon',
|
||||
].contains(currentService);
|
||||
final isExtensionService = !builtInServiceIds.contains(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
? extensionProviders.any((e) => e.id == currentService)
|
||||
: true;
|
||||
@@ -2046,47 +2058,56 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_ServiceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Tidal',
|
||||
isSelected: effectiveService == 'tidal',
|
||||
onTap: () => onChanged('tidal'),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Tidal',
|
||||
isSelected: effectiveService == 'tidal',
|
||||
onTap: () => onChanged('tidal'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: effectiveService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: effectiveService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.shopping_bag_outlined,
|
||||
label: 'Amazon',
|
||||
isSelected: effectiveService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.shopping_bag_outlined,
|
||||
label: 'Amazon',
|
||||
isSelected: effectiveService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.smart_display,
|
||||
label: 'YouTube',
|
||||
isSelected: effectiveService == 'youtube',
|
||||
onTap: () => onChanged('youtube'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (extensionProviders.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (int i = 0; i < extensionProviders.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.extension,
|
||||
label: extensionProviders[i].displayName,
|
||||
isSelected: effectiveService == extensionProviders[i].id,
|
||||
onTap: () => onChanged(extensionProviders[i].id),
|
||||
),
|
||||
for (final extension in extensionProviders)
|
||||
_ServiceChip(
|
||||
icon: Icons.extension,
|
||||
label: extension.displayName,
|
||||
isSelected: effectiveService == extension.id,
|
||||
onTap: () => onChanged(extension.id),
|
||||
),
|
||||
],
|
||||
for (int i = extensionProviders.length; i < 3; i++) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -2120,38 +2141,35 @@ class _ServiceChip extends StatelessWidget {
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
return Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,12 +20,15 @@ class ExtensionsPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
static final RegExp _platformExceptionPattern =
|
||||
RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),');
|
||||
static final RegExp _platformExceptionSimplePattern =
|
||||
RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null');
|
||||
static final RegExp _trailingNullsPattern =
|
||||
RegExp(r',\s*null\s*,\s*null\)?$');
|
||||
static final RegExp _platformExceptionPattern = RegExp(
|
||||
r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),',
|
||||
);
|
||||
static final RegExp _platformExceptionSimplePattern = RegExp(
|
||||
r'PlatformException\([^,]+,\s*(.+?),\s*null',
|
||||
);
|
||||
static final RegExp _trailingNullsPattern = RegExp(
|
||||
r',\s*null\s*,\s*null\)?$',
|
||||
);
|
||||
static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*');
|
||||
|
||||
@override
|
||||
@@ -40,11 +43,13 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.initialize(extensionsDir, dataDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,67 +64,205 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
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: () => Navigator.pop(context),
|
||||
),
|
||||
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(
|
||||
context.l10n.extensionsTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
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: () => Navigator.pop(context),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
title: Text(
|
||||
context.l10n.extensionsTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.error != null)
|
||||
if (extState.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.error != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
extState.error!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.extensionsProviderPrioritySection,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_DownloadPriorityItem(),
|
||||
_MetadataPriorityItem(),
|
||||
_SearchProviderSelector(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.extensionsInstalledSection,
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.extensions.isEmpty && !extState.isLoading)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.extension_outlined,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
context.l10n.extensionsNoExtensions,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.extensionsNoExtensionsSubtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.extensions.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: extState.extensions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider: index < extState.extensions.length - 1,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
ExtensionDetailPage(extensionId: ext.id),
|
||||
),
|
||||
),
|
||||
onToggle: (enabled) => ref
|
||||
.read(extensionProvider.notifier)
|
||||
.setExtensionEnabled(ext.id, enabled),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _installExtension,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(context.l10n.extensionsInstallButton),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
extState.error!,
|
||||
style: TextStyle(color: colorScheme.onErrorContainer),
|
||||
context.l10n.extensionsInfoTip,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -127,131 +270,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_DownloadPriorityItem(),
|
||||
_MetadataPriorityItem(),
|
||||
_SearchProviderSelector(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
|
||||
),
|
||||
|
||||
if (extState.extensions.isEmpty && !extState.isLoading)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.extension_outlined,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
context.l10n.extensionsNoExtensions,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.extensionsNoExtensionsSubtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.extensions.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: extState.extensions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider: index < extState.extensions.length - 1,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ExtensionDetailPage(extensionId: ext.id),
|
||||
),
|
||||
),
|
||||
onToggle: (enabled) => ref
|
||||
.read(extensionProvider.notifier)
|
||||
.setExtensionEnabled(ext.id, enabled),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _installExtension,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(context.l10n.extensionsInstallButton),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
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(
|
||||
context.l10n.extensionsInfoTip,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,9 +288,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
if (!file.path!.endsWith('.spotiflac-ext')) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarSelectExtFile),
|
||||
),
|
||||
SnackBar(content: Text(context.l10n.snackbarSelectExtFile)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -287,12 +306,12 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
} else {
|
||||
message = _getFriendlyErrorMessage(extState.error);
|
||||
}
|
||||
|
||||
|
||||
ref.read(extensionProvider.notifier).clearError();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,9 +320,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
/// Parse error message to be more user-friendly
|
||||
String _getFriendlyErrorMessage(String? error) {
|
||||
if (error == null) return 'Failed to install extension';
|
||||
|
||||
|
||||
String message = error;
|
||||
|
||||
|
||||
if (message.contains('PlatformException')) {
|
||||
final match = _platformExceptionPattern.firstMatch(message);
|
||||
if (match != null) {
|
||||
@@ -315,10 +334,10 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
message = message.replaceAll(_trailingNullsPattern, '');
|
||||
message = message.replaceAll(_leadingCommaPattern, '');
|
||||
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -359,7 +378,9 @@ class _ExtensionItem extends StatelessWidget {
|
||||
: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: extension.iconPath != null && extension.iconPath!.isNotEmpty
|
||||
child:
|
||||
extension.iconPath != null &&
|
||||
extension.iconPath!.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(
|
||||
@@ -396,7 +417,8 @@ class _ExtensionItem extends StatelessWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasError
|
||||
? extension.errorMessage ?? context.l10n.extensionsErrorLoading
|
||||
? extension.errorMessage ??
|
||||
context.l10n.extensionsErrorLoading
|
||||
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: hasError
|
||||
@@ -435,17 +457,16 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final hasDownloadExtensions = extState.extensions
|
||||
.any((e) => e.enabled && e.hasDownloadProvider);
|
||||
|
||||
|
||||
final hasDownloadExtensions = extState.extensions.any(
|
||||
(e) => e.enabled && e.hasDownloadProvider,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: hasDownloadExtensions
|
||||
onTap: hasDownloadExtensions
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ProviderPriorityPage(),
|
||||
),
|
||||
MaterialPageRoute(builder: (_) => const ProviderPriorityPage()),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
@@ -454,8 +475,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Icons.download,
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -466,14 +487,12 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
Text(
|
||||
context.l10n.extensionsDownloadPriority,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasDownloadExtensions
|
||||
? null
|
||||
: colorScheme.outline,
|
||||
color: hasDownloadExtensions ? null : colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasDownloadExtensions
|
||||
hasDownloadExtensions
|
||||
? context.l10n.extensionsDownloadPrioritySubtitle
|
||||
: context.l10n.extensionsNoDownloadProvider,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -485,8 +504,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
],
|
||||
@@ -503,12 +522,13 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final hasMetadataExtensions = extState.extensions
|
||||
.any((e) => e.enabled && e.hasMetadataProvider);
|
||||
|
||||
|
||||
final hasMetadataExtensions = extState.extensions.any(
|
||||
(e) => e.enabled && e.hasMetadataProvider,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: hasMetadataExtensions
|
||||
onTap: hasMetadataExtensions
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -522,8 +542,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -534,14 +554,12 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
Text(
|
||||
context.l10n.extensionsMetadataPriority,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasMetadataExtensions
|
||||
? null
|
||||
: colorScheme.outline,
|
||||
color: hasMetadataExtensions ? null : colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasMetadataExtensions
|
||||
hasMetadataExtensions
|
||||
? context.l10n.extensionsMetadataPrioritySubtitle
|
||||
: context.l10n.extensionsNoMetadataProvider,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -553,8 +571,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
],
|
||||
@@ -572,32 +590,40 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
|
||||
final searchProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasCustomSearch)
|
||||
.toList();
|
||||
|
||||
|
||||
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
|
||||
if (settings.searchProvider != null &&
|
||||
settings.searchProvider!.isNotEmpty) {
|
||||
final ext = searchProviders
|
||||
.where((e) => e.id == settings.searchProvider)
|
||||
.firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? settings.searchProvider!;
|
||||
}
|
||||
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: searchProviders.isEmpty
|
||||
? null
|
||||
: () => _showSearchProviderPicker(context, ref, settings, searchProviders),
|
||||
onTap: searchProviders.isEmpty
|
||||
? null
|
||||
: () => _showSearchProviderPicker(
|
||||
context,
|
||||
ref,
|
||||
settings,
|
||||
searchProviders,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.manage_search,
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -608,14 +634,14 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
Text(
|
||||
context.l10n.extensionsSearchProvider,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
searchProviders.isEmpty
|
||||
searchProviders.isEmpty
|
||||
? context.l10n.extensionsNoCustomSearch
|
||||
: currentProviderName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -627,8 +653,8 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
@@ -646,9 +672,10 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
List<Extension> searchProviders,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -662,9 +689,9 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
ctx.l10n.extensionsSearchProvider,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
@@ -680,7 +707,9 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||
title: Text(ctx.l10n.extensionDefaultProvider),
|
||||
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
|
||||
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
|
||||
trailing:
|
||||
(settings.searchProvider == null ||
|
||||
settings.searchProvider!.isEmpty)
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
@@ -688,18 +717,23 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
...searchProviders.map((ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
title: Text(ext.displayName),
|
||||
subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch),
|
||||
trailing: settings.searchProvider == ext.id
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(ext.id);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
)),
|
||||
...searchProviders.map(
|
||||
(ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
title: Text(ext.displayName),
|
||||
subtitle: Text(
|
||||
ext.searchBehavior?.placeholder ??
|
||||
ctx.l10n.extensionsCustomSearch,
|
||||
),
|
||||
trailing: settings.searchProvider == ext.id
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(ext.id);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -16,6 +16,7 @@ class _LyricsProviderPriorityPageState
|
||||
extends ConsumerState<LyricsProviderPriorityPage> {
|
||||
static const _allProviderIds = [
|
||||
'lrclib',
|
||||
'spotify_api',
|
||||
'netease',
|
||||
'musixmatch',
|
||||
'apple_music',
|
||||
@@ -183,6 +184,12 @@ class _LyricsProviderPriorityPageState
|
||||
|
||||
static _LyricsProviderInfo _getLyricsProviderInfo(String id) {
|
||||
switch (id) {
|
||||
case 'spotify_api':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Spotify Lyrics API',
|
||||
description: 'Spotify-sourced synced lyrics via community API',
|
||||
icon: Icons.music_note_outlined,
|
||||
);
|
||||
case 'lrclib':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'LRCLIB',
|
||||
|
||||
@@ -152,6 +152,30 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setAutoFallback(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.skip_next_rounded,
|
||||
title: context.l10n.optionsAutoSkipUnavailableTracks,
|
||||
subtitle: settings.autoSkipUnavailableTracks
|
||||
? context
|
||||
.l10n
|
||||
.optionsAutoSkipUnavailableTracksSubtitleOn
|
||||
: context
|
||||
.l10n
|
||||
.optionsAutoSkipUnavailableTracksSubtitleOff,
|
||||
value: settings.autoSkipUnavailableTracks,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAutoSkipUnavailableTracks(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.queue_music_rounded,
|
||||
title: context.l10n.settingsSmartQueueTitle,
|
||||
subtitle: context.l10n.settingsSmartQueueSubtitle,
|
||||
value: settings.smartQueueEnabled,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSmartQueueEnabled(v),
|
||||
),
|
||||
if (hasExtensions)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.extension,
|
||||
@@ -164,11 +188,24 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
.read(settingsProvider.notifier)
|
||||
.setUseExtensionProviders(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.sell_outlined,
|
||||
title: 'Embed Metadata',
|
||||
subtitle: settings.embedMetadata
|
||||
? 'Write metadata, cover art, and embedded lyrics to files'
|
||||
: 'Disabled (advanced): skip all metadata embedding',
|
||||
value: settings.embedMetadata,
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setEmbedMetadata(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.image,
|
||||
title: context.l10n.optionsMaxQualityCover,
|
||||
subtitle: context.l10n.optionsMaxQualityCoverSubtitle,
|
||||
subtitle: settings.embedMetadata
|
||||
? context.l10n.optionsMaxQualityCoverSubtitle
|
||||
: 'Disabled when metadata embedding is off',
|
||||
value: settings.maxQualityCover,
|
||||
enabled: settings.embedMetadata,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setMaxQualityCover(v),
|
||||
@@ -375,6 +412,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -972,9 +1010,9 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.optionsSpotifyDeprecationWarning,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -8,7 +8,8 @@ class ProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const ProviderPriorityPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProviderPriorityPage> createState() => _ProviderPriorityPageState();
|
||||
ConsumerState<ProviderPriorityPage> createState() =>
|
||||
_ProviderPriorityPageState();
|
||||
}
|
||||
|
||||
class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
@@ -23,8 +24,10 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
|
||||
void _loadProviders() {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
|
||||
|
||||
final allProviders = ref
|
||||
.read(extensionProvider.notifier)
|
||||
.getAllDownloadProviders();
|
||||
|
||||
if (extState.providerPriority.isNotEmpty) {
|
||||
_providers = List.from(extState.providerPriority);
|
||||
for (final provider in allProviders) {
|
||||
@@ -86,13 +89,17 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
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),
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.providerPriorityTitle,
|
||||
style: TextStyle(
|
||||
@@ -156,14 +163,19 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.providerPriorityInfo,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -292,7 +304,9 @@ class _ProviderItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension,
|
||||
info.isBuiltIn
|
||||
? context.l10n.providerBuiltIn
|
||||
: context.l10n.providerExtension,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -300,10 +314,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -321,17 +332,19 @@ class _ProviderItem extends StatelessWidget {
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'qobuz':
|
||||
return _ProviderInfo(
|
||||
name: 'Qobuz',
|
||||
icon: Icons.album,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true);
|
||||
case 'amazon':
|
||||
return _ProviderInfo(
|
||||
name: 'Amazon Music',
|
||||
icon: Icons.shopping_bag,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'youtube':
|
||||
return _ProviderInfo(
|
||||
name: 'YouTube',
|
||||
icon: Icons.play_circle_outline,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
default:
|
||||
return _ProviderInfo(
|
||||
name: provider,
|
||||
|
||||
+162
-126
@@ -6,6 +6,7 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -30,11 +31,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
bool _isLoading = false;
|
||||
int _androidSdkVersion = 0;
|
||||
|
||||
// Spotify form
|
||||
final _clientIdController = TextEditingController();
|
||||
final _clientSecretController = TextEditingController();
|
||||
bool _useSpotifyApi = false;
|
||||
bool _showClientSecret = false;
|
||||
// Mode selection
|
||||
String _selectedMode = 'downloader';
|
||||
|
||||
// We add 1 for the Welcome step
|
||||
int get _totalSteps => (_androidSdkVersion >= 33 ? 4 : 3) + 1;
|
||||
@@ -48,8 +46,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_clientIdController.dispose();
|
||||
_clientSecretController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -291,6 +287,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -339,8 +336,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(validation.errorReason ?? 'Invalid folder selected'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
content: Text(
|
||||
validation.errorReason ??
|
||||
'Invalid folder selected',
|
||||
),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
@@ -402,20 +404,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
if (_useSpotifyApi &&
|
||||
_clientIdController.text.trim().isNotEmpty &&
|
||||
_clientSecretController.text.trim().isNotEmpty) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSpotifyCredentials(
|
||||
_clientIdController.text.trim(),
|
||||
_clientSecretController.text.trim(),
|
||||
);
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
||||
} else {
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
}
|
||||
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.ensureSpotifyWebExtensionReady();
|
||||
ref.read(settingsProvider.notifier).setFirstLaunchComplete();
|
||||
|
||||
if (mounted) context.go('/tutorial');
|
||||
@@ -475,7 +467,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
case 2:
|
||||
return _selectedDirectory != null;
|
||||
case 3:
|
||||
return false; // Spotify is last/submit
|
||||
return true; // Mode selection always has a default
|
||||
}
|
||||
} else {
|
||||
switch (logicStep) {
|
||||
@@ -484,7 +476,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
case 1:
|
||||
return _selectedDirectory != null;
|
||||
case 2:
|
||||
return false; // Spotify
|
||||
return true; // Mode selection always has a default
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -561,7 +553,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
if (_androidSdkVersion >= 33)
|
||||
_buildNotificationStep(colorScheme),
|
||||
_buildDirectoryStep(colorScheme),
|
||||
_buildSpotifyStep(colorScheme),
|
||||
_buildModeSelectionStep(colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -581,12 +573,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
icon: const SizedBox.shrink(), // Custom layout
|
||||
)
|
||||
: FloatingActionButton.extended(
|
||||
onPressed:
|
||||
(!_useSpotifyApi ||
|
||||
(_clientIdController.text.isNotEmpty &&
|
||||
_clientSecretController.text.isNotEmpty))
|
||||
? _completeSetup
|
||||
: null,
|
||||
onPressed: _isLoading ? null : _completeSetup,
|
||||
label: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
@@ -761,106 +748,32 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpotifyStep(ColorScheme colorScheme) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
Widget _buildModeSelectionStep(ColorScheme colorScheme) {
|
||||
return _StepLayout(
|
||||
title: context.l10n.setupModeSelectionTitle,
|
||||
description: context.l10n.setupModeSelectionDescription,
|
||||
icon: Icons.tune,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.api, size: 48, color: colorScheme.primary),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.setupSpotifyApiOptional,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
_ModeCard(
|
||||
icon: Icons.download,
|
||||
title: context.l10n.setupModeDownloaderTitle,
|
||||
features: [
|
||||
context.l10n.setupModeDownloaderFeature1,
|
||||
context.l10n.setupModeDownloaderFeature2,
|
||||
context.l10n.setupModeDownloaderFeature3,
|
||||
],
|
||||
isSelected: _selectedMode == 'downloader',
|
||||
onTap: () => setState(() => _selectedMode = 'downloader'),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.setupSpotifyApiDescription,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
context.l10n.setupModeChangeableLater,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: _useSpotifyApi,
|
||||
onChanged: (v) => setState(() => _useSpotifyApi = v),
|
||||
title: Text(context.l10n.setupUseSpotifyApi),
|
||||
subtitle: Text(
|
||||
_useSpotifyApi
|
||||
? context.l10n.setupEnterCredentialsBelow
|
||||
: "Using bundled metadata",
|
||||
),
|
||||
),
|
||||
if (_useSpotifyApi) ...[
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _clientIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.credentialsClientId,
|
||||
prefixIcon: const Icon(Icons.key),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outline,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _clientSecretController,
|
||||
obscureText: !_showClientSecret,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.credentialsClientSecret,
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outline,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_showClientSecret
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _showClientSecret = !_showClientSecret,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -975,3 +888,126 @@ class _SuccessCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModeCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final List<String> features;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _ModeCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.features,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outlineVariant,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Icon(
|
||||
isSelected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
size: 22,
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 22,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...features.map(
|
||||
(feature) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'\u2022 ',
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
feature,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+103
-72
@@ -43,7 +43,30 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(storeProvider);
|
||||
final storeFilterState = ref.watch(
|
||||
storeProvider.select(
|
||||
(s) => (s.extensions, s.selectedCategory, s.searchQuery),
|
||||
),
|
||||
);
|
||||
final extensions = storeFilterState.$1;
|
||||
final selectedCategory = storeFilterState.$2;
|
||||
final searchQuery = storeFilterState.$3;
|
||||
final isLoading = ref.watch(storeProvider.select((s) => s.isLoading));
|
||||
final error = ref.watch(storeProvider.select((s) => s.error));
|
||||
final downloadingId = ref.watch(
|
||||
storeProvider.select((s) => s.downloadingId),
|
||||
);
|
||||
final filteredExtensions = StoreState(
|
||||
extensions: extensions,
|
||||
selectedCategory: selectedCategory,
|
||||
searchQuery: searchQuery,
|
||||
).filteredExtensions;
|
||||
if (_searchController.text != searchQuery) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: searchQuery,
|
||||
selection: TextSelection.collapsed(offset: searchQuery.length),
|
||||
);
|
||||
}
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
@@ -89,41 +112,46 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.storeSearch,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
ref
|
||||
.read(storeProvider.notifier)
|
||||
.setSearchQuery('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(storeProvider.notifier).setSearchQuery(value);
|
||||
setState(() {});
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _searchController,
|
||||
builder: (context, value, _) {
|
||||
return TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.storeSearch,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: value.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
ref
|
||||
.read(storeProvider.notifier)
|
||||
.setSearchQuery('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor:
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(storeProvider.notifier).setSearchQuery(value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -141,7 +169,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterAll,
|
||||
icon: Icons.apps,
|
||||
isSelected: state.selectedCategory == null,
|
||||
isSelected: selectedCategory == null,
|
||||
onTap: () =>
|
||||
ref.read(storeProvider.notifier).setCategory(null),
|
||||
),
|
||||
@@ -149,8 +177,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterMetadata,
|
||||
icon: Icons.label_outline,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.metadata,
|
||||
isSelected: selectedCategory == StoreCategory.metadata,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.metadata),
|
||||
@@ -159,8 +186,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterDownload,
|
||||
icon: Icons.download_outlined,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.download,
|
||||
isSelected: selectedCategory == StoreCategory.download,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.download),
|
||||
@@ -169,8 +195,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterUtility,
|
||||
icon: Icons.build_outlined,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.utility,
|
||||
isSelected: selectedCategory == StoreCategory.utility,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.utility),
|
||||
@@ -179,8 +204,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterLyrics,
|
||||
icon: Icons.lyrics_outlined,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.lyrics,
|
||||
isSelected: selectedCategory == StoreCategory.lyrics,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.lyrics),
|
||||
@@ -189,8 +213,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterIntegration,
|
||||
icon: Icons.link,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.integration,
|
||||
isSelected: selectedCategory == StoreCategory.integration,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.integration),
|
||||
@@ -200,22 +223,26 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
),
|
||||
),
|
||||
|
||||
if (state.isLoading && state.extensions.isEmpty)
|
||||
if (isLoading && extensions.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (state.error != null && state.extensions.isEmpty)
|
||||
else if (error != null && extensions.isEmpty)
|
||||
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
|
||||
else if (filteredExtensions.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: _buildErrorState(state.error!, colorScheme),
|
||||
child: _buildEmptyState(
|
||||
hasFilters:
|
||||
searchQuery.isNotEmpty || selectedCategory != null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
)
|
||||
else if (state.filteredExtensions.isEmpty)
|
||||
SliverFillRemaining(child: _buildEmptyState(state, colorScheme))
|
||||
else ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'${state.filteredExtensions.length} ${state.filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
|
||||
'${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -227,16 +254,13 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SettingsGroup(
|
||||
children: state.filteredExtensions.asMap().entries.map((
|
||||
entry,
|
||||
) {
|
||||
children: filteredExtensions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider:
|
||||
index < state.filteredExtensions.length - 1,
|
||||
isDownloading: state.downloadingId == ext.id,
|
||||
showDivider: index < filteredExtensions.length - 1,
|
||||
isDownloading: downloadingId == ext.id,
|
||||
onInstall: () => _installExtension(ext),
|
||||
onUpdate: () => _updateExtension(ext),
|
||||
onTap: () => _showExtensionDetails(ext),
|
||||
@@ -288,10 +312,10 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) {
|
||||
final hasFilters =
|
||||
state.searchQuery.isNotEmpty || state.selectedCategory != null;
|
||||
|
||||
Widget _buildEmptyState({
|
||||
required bool hasFilters,
|
||||
required ColorScheme colorScheme,
|
||||
}) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -541,7 +565,10 @@ class _ExtensionItem extends StatelessWidget {
|
||||
if (extension.requiresNewerApp) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -549,14 +576,19 @@ class _ExtensionItem extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 12, color: colorScheme.onErrorContainer),
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 12,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Requires v${extension.minAppVersion}+',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -565,9 +597,8 @@ class _ExtensionItem extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
extension.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
@@ -2336,6 +2337,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
@@ -2566,6 +2568,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
@@ -3003,6 +3006,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
final saved = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -3039,6 +3043,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.trackDeleteConfirmTitle),
|
||||
content: Text(context.l10n.trackDeleteConfirmMessage),
|
||||
@@ -3088,7 +3093,15 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
||||
try {
|
||||
await openFile(filePath);
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playLocalPath(
|
||||
path: filePath,
|
||||
title: trackName,
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
coverUrl: _coverUrl ?? '',
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -106,6 +106,8 @@ class CsvImportService {
|
||||
artistName: trackData['artists'] as String? ?? track.artistName,
|
||||
albumName: trackData['album_name'] as String? ?? track.albumName,
|
||||
albumArtist: trackData['album_artist'] as String?,
|
||||
artistId: trackData['artist_id']?.toString(),
|
||||
albumId: trackData['album_id']?.toString(),
|
||||
coverUrl: coverUrl ?? track.coverUrl,
|
||||
isrc: trackData['isrc'] as String? ?? track.isrc,
|
||||
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
|
||||
|
||||
@@ -10,6 +10,7 @@ class DownloadRequestPayload {
|
||||
final String outputDir;
|
||||
final String filenameFormat;
|
||||
final String quality;
|
||||
final bool embedMetadata;
|
||||
final bool embedLyrics;
|
||||
final bool embedMaxQualityCover;
|
||||
final int trackNumber;
|
||||
@@ -47,6 +48,7 @@ class DownloadRequestPayload {
|
||||
required this.outputDir,
|
||||
required this.filenameFormat,
|
||||
this.quality = 'LOSSLESS',
|
||||
this.embedMetadata = true,
|
||||
this.embedLyrics = true,
|
||||
this.embedMaxQualityCover = true,
|
||||
this.trackNumber = 1,
|
||||
@@ -86,6 +88,7 @@ class DownloadRequestPayload {
|
||||
'output_dir': outputDir,
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'embed_metadata': embedMetadata,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'track_number': trackNumber,
|
||||
@@ -129,6 +132,7 @@ class DownloadRequestPayload {
|
||||
outputDir: outputDir,
|
||||
filenameFormat: filenameFormat,
|
||||
quality: quality,
|
||||
embedMetadata: embedMetadata,
|
||||
embedLyrics: embedLyrics,
|
||||
embedMaxQualityCover: embedMaxQualityCover,
|
||||
trackNumber: trackNumber,
|
||||
|
||||
@@ -25,6 +25,7 @@ class DownloadedEmbeddedCoverResolver {
|
||||
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
|
||||
static final Set<String> _pendingExtract = <String>{};
|
||||
static final Set<String> _pendingRefresh = <String>{};
|
||||
static final Set<String> _pendingPreviewValidation = <String>{};
|
||||
static final Set<String> _failedExtract = <String>{};
|
||||
|
||||
static String cleanFilePath(String? filePath) {
|
||||
@@ -66,12 +67,9 @@ class DownloadedEmbeddedCoverResolver {
|
||||
|
||||
final cached = _cache[cleanPath];
|
||||
if (cached != null) {
|
||||
if (File(cached.previewPath).existsSync()) {
|
||||
_touch(cleanPath, cached);
|
||||
return cached.previewPath;
|
||||
}
|
||||
_cache.remove(cleanPath);
|
||||
_cleanupTempCoverPathSync(cached.previewPath);
|
||||
_touch(cleanPath, cached);
|
||||
_validateCachedPreviewAsync(cleanPath, cached, onChanged: onChanged);
|
||||
return cached.previewPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -106,6 +104,7 @@ class DownloadedEmbeddedCoverResolver {
|
||||
final cached = _cache.remove(cleanPath);
|
||||
_pendingExtract.remove(cleanPath);
|
||||
_pendingRefresh.remove(cleanPath);
|
||||
_pendingPreviewValidation.remove(cleanPath);
|
||||
_failedExtract.remove(cleanPath);
|
||||
if (cached != null) {
|
||||
_cleanupTempCoverPathSync(cached.previewPath);
|
||||
@@ -144,10 +143,36 @@ class DownloadedEmbeddedCoverResolver {
|
||||
}
|
||||
_pendingExtract.remove(oldestKey);
|
||||
_pendingRefresh.remove(oldestKey);
|
||||
_pendingPreviewValidation.remove(oldestKey);
|
||||
_failedExtract.remove(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
static void _validateCachedPreviewAsync(
|
||||
String cleanPath,
|
||||
_EmbeddedCoverCacheEntry entry, {
|
||||
VoidCallback? onChanged,
|
||||
}) {
|
||||
if (_pendingPreviewValidation.contains(cleanPath)) return;
|
||||
_pendingPreviewValidation.add(cleanPath);
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
final exists = await fileExists(entry.previewPath);
|
||||
if (!exists) {
|
||||
final latest = _cache[cleanPath];
|
||||
if (latest != null && latest.previewPath == entry.previewPath) {
|
||||
_cache.remove(cleanPath);
|
||||
_failedExtract.remove(cleanPath);
|
||||
onChanged?.call();
|
||||
}
|
||||
_cleanupTempCoverPathSync(entry.previewPath);
|
||||
}
|
||||
} finally {
|
||||
_pendingPreviewValidation.remove(cleanPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static void _ensureCover(
|
||||
String cleanPath, {
|
||||
bool forceRefresh = false,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
@@ -11,7 +13,20 @@ final _log = AppLogger('FFmpeg');
|
||||
|
||||
class FFmpegService {
|
||||
static const int _commandLogPreviewLength = 300;
|
||||
static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8);
|
||||
static const Duration _liveTunnelStartupPollInterval = Duration(
|
||||
milliseconds: 200,
|
||||
);
|
||||
static const Duration _liveTunnelStabilizationDelay = Duration(
|
||||
milliseconds: 900,
|
||||
);
|
||||
static int _tempEmbedCounter = 0;
|
||||
static FFmpegSession? _activeLiveDecryptSession;
|
||||
static String? _activeLiveDecryptUrl;
|
||||
static String? _activeLiveTempInputPath;
|
||||
static String? _activeNativeDashManifestPath;
|
||||
static String? _activeNativeDashManifestUrl;
|
||||
static final Set<String> _preparedNativeDashManifestPaths = <String>{};
|
||||
|
||||
static String _buildOutputPath(String inputPath, String extension) {
|
||||
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
|
||||
@@ -305,6 +320,433 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool isActiveLiveDecryptedUrl(String url) {
|
||||
final active = _activeLiveDecryptUrl;
|
||||
if (active == null || active.isEmpty) return false;
|
||||
return active == url.trim();
|
||||
}
|
||||
|
||||
static bool isActiveNativeDashManifestUrl(String url) {
|
||||
final activeUrl = _activeNativeDashManifestUrl;
|
||||
if (activeUrl == null || activeUrl.isEmpty) return false;
|
||||
|
||||
final normalized = url.trim();
|
||||
if (activeUrl == normalized) return true;
|
||||
|
||||
try {
|
||||
final activePath = Uri.parse(activeUrl).toFilePath();
|
||||
final incomingPath = Uri.parse(normalized).toFilePath();
|
||||
return activePath == incomingPath;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> prepareTidalDashManifestForNativePlayback({
|
||||
required String manifestPayload,
|
||||
bool registerAsActive = true,
|
||||
}) async {
|
||||
final rawPayload = manifestPayload.trim();
|
||||
if (rawPayload.isEmpty) return null;
|
||||
|
||||
final payload = rawPayload.startsWith('MANIFEST:')
|
||||
? rawPayload.substring('MANIFEST:'.length)
|
||||
: rawPayload;
|
||||
|
||||
final manifestPath = await _writeTempManifestFile(payload);
|
||||
if (manifestPath == null) {
|
||||
_log.e('Failed to prepare Tidal DASH manifest for native playback');
|
||||
return null;
|
||||
}
|
||||
|
||||
final manifestUrl = Uri.file(manifestPath).toString();
|
||||
_preparedNativeDashManifestPaths.add(manifestPath);
|
||||
if (registerAsActive) {
|
||||
await activatePreparedNativeDashManifest(manifestUrl);
|
||||
}
|
||||
return manifestUrl;
|
||||
}
|
||||
|
||||
static Future<void> activatePreparedNativeDashManifest(String url) async {
|
||||
final normalized = url.trim();
|
||||
if (normalized.isEmpty) return;
|
||||
|
||||
final manifestPath = _nativeDashManifestPathFromUrl(normalized);
|
||||
if (manifestPath == null ||
|
||||
!_preparedNativeDashManifestPaths.contains(manifestPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final previousPath = _activeNativeDashManifestPath;
|
||||
_activeNativeDashManifestPath = manifestPath;
|
||||
_activeNativeDashManifestUrl = Uri.file(manifestPath).toString();
|
||||
|
||||
if (previousPath != null &&
|
||||
previousPath.isNotEmpty &&
|
||||
previousPath != manifestPath) {
|
||||
_preparedNativeDashManifestPaths.remove(previousPath);
|
||||
await _deleteNativeDashManifestFile(previousPath);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> stopNativeDashManifestPlayback() async {
|
||||
final manifestPath = _activeNativeDashManifestPath;
|
||||
_activeNativeDashManifestPath = null;
|
||||
_activeNativeDashManifestUrl = null;
|
||||
|
||||
if (manifestPath == null || manifestPath.isEmpty) return;
|
||||
_preparedNativeDashManifestPaths.remove(manifestPath);
|
||||
await _deleteNativeDashManifestFile(manifestPath);
|
||||
}
|
||||
|
||||
static Future<void> cleanupInactivePreparedNativeDashManifests() async {
|
||||
final activePath = _activeNativeDashManifestPath;
|
||||
final stalePaths = _preparedNativeDashManifestPaths
|
||||
.where((path) => path != activePath)
|
||||
.toList(growable: false);
|
||||
|
||||
for (final path in stalePaths) {
|
||||
_preparedNativeDashManifestPaths.remove(path);
|
||||
await _deleteNativeDashManifestFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
static String? _nativeDashManifestPathFromUrl(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (uri.scheme.toLowerCase() != 'file') {
|
||||
return null;
|
||||
}
|
||||
final path = uri.toFilePath();
|
||||
return path.trim().isEmpty ? null : path;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _deleteNativeDashManifestFile(String path) async {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> stopLiveDecryptedStream() async {
|
||||
final session = _activeLiveDecryptSession;
|
||||
final tempInputPath = _activeLiveTempInputPath;
|
||||
_activeLiveDecryptSession = null;
|
||||
_activeLiveDecryptUrl = null;
|
||||
_activeLiveTempInputPath = null;
|
||||
|
||||
if (session != null) {
|
||||
try {
|
||||
await session.cancel();
|
||||
} catch (e) {
|
||||
final sessionId = session.getSessionId();
|
||||
if (sessionId != null) {
|
||||
try {
|
||||
await FFmpegKit.cancel(sessionId);
|
||||
} catch (_) {}
|
||||
}
|
||||
_log.w('Failed to stop live decrypt session cleanly: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (tempInputPath != null && tempInputPath.isNotEmpty) {
|
||||
try {
|
||||
final file = File(tempInputPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> startTidalDashLiveStream({
|
||||
required String manifestPayload,
|
||||
String preferredFormat = 'm4a',
|
||||
}) async {
|
||||
final rawPayload = manifestPayload.trim();
|
||||
if (rawPayload.isEmpty) return null;
|
||||
|
||||
final payload = rawPayload.startsWith('MANIFEST:')
|
||||
? rawPayload.substring('MANIFEST:'.length)
|
||||
: rawPayload;
|
||||
|
||||
final manifestPath = await _writeTempManifestFile(payload);
|
||||
if (manifestPath == null) {
|
||||
_log.e('Failed to prepare Tidal DASH manifest for live stream');
|
||||
return null;
|
||||
}
|
||||
|
||||
await stopLiveDecryptedStream();
|
||||
await stopNativeDashManifestPlayback();
|
||||
|
||||
final attempts = _buildLiveDashFormatAttempts(preferredFormat);
|
||||
for (final format in attempts) {
|
||||
final stream = await _tryStartLiveDashAttempt(
|
||||
manifestPath: manifestPath,
|
||||
format: format,
|
||||
);
|
||||
if (stream != null) {
|
||||
_activeLiveDecryptSession = stream.session;
|
||||
_activeLiveDecryptUrl = stream.localUrl;
|
||||
_activeLiveTempInputPath = manifestPath;
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final file = File(manifestPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> _writeTempManifestFile(String payload) async {
|
||||
if (payload.trim().isEmpty) return null;
|
||||
|
||||
Uint8List bytes;
|
||||
try {
|
||||
bytes = base64Decode(payload);
|
||||
} catch (_) {
|
||||
bytes = Uint8List.fromList(utf8.encode(payload));
|
||||
}
|
||||
|
||||
final manifestText = utf8.decode(bytes, allowMalformed: true).trim();
|
||||
if (manifestText.isEmpty) return null;
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final manifestPath =
|
||||
'${tempDir.path}${Platform.pathSeparator}tidal_dash_${DateTime.now().microsecondsSinceEpoch}.mpd';
|
||||
await File(manifestPath).writeAsString(manifestText, flush: true);
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
static List<_LiveDecryptFormat> _buildLiveDashFormatAttempts(
|
||||
String preferredFormat,
|
||||
) {
|
||||
final normalized = preferredFormat.trim().toLowerCase();
|
||||
if (normalized == 'flac') {
|
||||
return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a];
|
||||
}
|
||||
return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac];
|
||||
}
|
||||
|
||||
static Future<bool> _awaitLiveTunnelReady(FFmpegSession session) async {
|
||||
final deadline = DateTime.now().add(_liveTunnelStartupTimeout);
|
||||
var seenRunning = false;
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
final state = await session.getState();
|
||||
if (state == SessionState.running) {
|
||||
seenRunning = true;
|
||||
break;
|
||||
}
|
||||
if (state != SessionState.created) {
|
||||
return false;
|
||||
}
|
||||
await Future<void>.delayed(_liveTunnelStartupPollInterval);
|
||||
}
|
||||
|
||||
if (!seenRunning) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await Future<void>.delayed(_liveTunnelStabilizationDelay);
|
||||
return (await session.getState()) == SessionState.running;
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> _tryStartLiveDashAttempt({
|
||||
required String manifestPath,
|
||||
required _LiveDecryptFormat format,
|
||||
}) async {
|
||||
final port = await _allocateLoopbackPort();
|
||||
final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a';
|
||||
final mimeType = format == _LiveDecryptFormat.flac
|
||||
? 'audio/flac'
|
||||
: 'audio/mp4';
|
||||
final localUrl = 'http://localhost:$port/stream.$ext';
|
||||
|
||||
final commandArguments = <String>[
|
||||
'-nostdin',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-protocol_whitelist',
|
||||
'file,http,https,tcp,tls,crypto,data',
|
||||
'-i',
|
||||
manifestPath,
|
||||
'-map',
|
||||
'0:a:0',
|
||||
'-c:a',
|
||||
'copy',
|
||||
if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'],
|
||||
if (format == _LiveDecryptFormat.m4a) ...[
|
||||
'-movflags',
|
||||
'+frag_keyframe+empty_moov+default_base_moof',
|
||||
'-f',
|
||||
'mp4',
|
||||
],
|
||||
'-content_type',
|
||||
mimeType,
|
||||
'-listen',
|
||||
'1',
|
||||
localUrl,
|
||||
];
|
||||
|
||||
_log.d(
|
||||
'Starting Tidal DASH tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
|
||||
);
|
||||
|
||||
final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments);
|
||||
final isReady = await _awaitLiveTunnelReady(session);
|
||||
if (isReady) {
|
||||
return LiveDecryptedStreamResult(
|
||||
localUrl: localUrl,
|
||||
format: ext,
|
||||
session: session,
|
||||
);
|
||||
}
|
||||
|
||||
final state = await session.getState();
|
||||
final output = (await session.getOutput() ?? '').trim();
|
||||
if (output.isNotEmpty) {
|
||||
_log.w('Tidal DASH tunnel failed ($ext): $output');
|
||||
} else {
|
||||
_log.w('Tidal DASH tunnel failed ($ext) with session state: $state');
|
||||
}
|
||||
|
||||
try {
|
||||
await session.cancel();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> startAmazonLiveDecryptedStream({
|
||||
required String encryptedStreamUrl,
|
||||
required String decryptionKey,
|
||||
String preferredFormat = 'flac',
|
||||
}) async {
|
||||
final inputUrl = encryptedStreamUrl.trim();
|
||||
if (inputUrl.isEmpty) return null;
|
||||
|
||||
final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey);
|
||||
if (keyCandidates.isEmpty) {
|
||||
_log.e('No usable decryption key candidates for live stream');
|
||||
return null;
|
||||
}
|
||||
|
||||
await stopLiveDecryptedStream();
|
||||
|
||||
final attempts = _buildLiveDecryptFormatAttempts(preferredFormat);
|
||||
for (final format in attempts) {
|
||||
for (final keyCandidate in keyCandidates) {
|
||||
final stream = await _tryStartLiveDecryptAttempt(
|
||||
inputUrl: inputUrl,
|
||||
decryptionKey: keyCandidate,
|
||||
format: format,
|
||||
);
|
||||
if (stream != null) {
|
||||
_activeLiveDecryptSession = stream.session;
|
||||
_activeLiveDecryptUrl = stream.localUrl;
|
||||
_activeLiveTempInputPath = null;
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<_LiveDecryptFormat> _buildLiveDecryptFormatAttempts(
|
||||
String preferredFormat,
|
||||
) {
|
||||
final normalized = preferredFormat.trim().toLowerCase();
|
||||
if (normalized == 'm4a' || normalized == 'mp4' || normalized == 'aac') {
|
||||
return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac];
|
||||
}
|
||||
return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a];
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> _tryStartLiveDecryptAttempt({
|
||||
required String inputUrl,
|
||||
required String decryptionKey,
|
||||
required _LiveDecryptFormat format,
|
||||
}) async {
|
||||
final port = await _allocateLoopbackPort();
|
||||
final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a';
|
||||
final mimeType = format == _LiveDecryptFormat.flac
|
||||
? 'audio/flac'
|
||||
: 'audio/mp4';
|
||||
final localUrl = 'http://localhost:$port/stream.$ext';
|
||||
|
||||
final commandArguments = <String>[
|
||||
'-nostdin',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-decryption_key',
|
||||
decryptionKey,
|
||||
'-i',
|
||||
inputUrl,
|
||||
'-map',
|
||||
'0:a:0',
|
||||
'-c:a',
|
||||
'copy',
|
||||
if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'],
|
||||
if (format == _LiveDecryptFormat.m4a) ...[
|
||||
'-movflags',
|
||||
'+frag_keyframe+empty_moov+default_base_moof',
|
||||
'-f',
|
||||
'mp4',
|
||||
],
|
||||
'-content_type',
|
||||
mimeType,
|
||||
'-listen',
|
||||
'1',
|
||||
localUrl,
|
||||
];
|
||||
|
||||
_log.d(
|
||||
'Starting live decrypt tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
|
||||
);
|
||||
|
||||
final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments);
|
||||
final isReady = await _awaitLiveTunnelReady(session);
|
||||
if (isReady) {
|
||||
return LiveDecryptedStreamResult(
|
||||
localUrl: localUrl,
|
||||
format: ext,
|
||||
session: session,
|
||||
);
|
||||
}
|
||||
|
||||
final state = await session.getState();
|
||||
final output = (await session.getOutput() ?? '').trim();
|
||||
if (output.isNotEmpty) {
|
||||
_log.w('Live decrypt attempt failed ($ext): $output');
|
||||
} else {
|
||||
_log.w('Live decrypt attempt failed ($ext) with session state: $state');
|
||||
}
|
||||
|
||||
try {
|
||||
await session.cancel();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<int> _allocateLoopbackPort() async {
|
||||
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final port = socket.port;
|
||||
await socket.close();
|
||||
return port;
|
||||
}
|
||||
|
||||
static Future<String?> convertFlacToOpus(
|
||||
String inputPath, {
|
||||
String bitrate = '128k',
|
||||
@@ -861,9 +1303,10 @@ class FFmpegService {
|
||||
|
||||
for (final entry in vorbisMetadata.entries) {
|
||||
final key = entry.key.toUpperCase();
|
||||
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final value = entry.value;
|
||||
|
||||
switch (key) {
|
||||
switch (normalizedKey) {
|
||||
case 'TITLE':
|
||||
id3Map['title'] = value;
|
||||
break;
|
||||
@@ -878,10 +1321,12 @@ class FFmpegService {
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
id3Map['track'] = value;
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
id3Map['disc'] = value;
|
||||
break;
|
||||
case 'DATE':
|
||||
@@ -921,3 +1366,17 @@ class FFmpegResult {
|
||||
required this.output,
|
||||
});
|
||||
}
|
||||
|
||||
enum _LiveDecryptFormat { flac, m4a }
|
||||
|
||||
class LiveDecryptedStreamResult {
|
||||
final String localUrl;
|
||||
final String format;
|
||||
final FFmpegSession session;
|
||||
|
||||
LiveDecryptedStreamResult({
|
||||
required this.localUrl,
|
||||
required this.format,
|
||||
required this.session,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ final _log = AppLogger('PlatformBridge');
|
||||
|
||||
class PlatformBridge {
|
||||
static const _channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
static const _downloadProgressEvents = EventChannel(
|
||||
'com.zarz.spotiflac/download_progress_stream',
|
||||
);
|
||||
static const _libraryScanProgressEvents = EventChannel(
|
||||
'com.zarz.spotiflac/library_scan_progress_stream',
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
||||
_log.d('parseSpotifyUrl: $url');
|
||||
@@ -48,6 +54,17 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getSpotifyRelatedArtists(
|
||||
String artistId, {
|
||||
int limit = 12,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('getSpotifyRelatedArtists', {
|
||||
'artist_id': artistId,
|
||||
'limit': limit,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> checkAvailability(
|
||||
String spotifyId,
|
||||
String isrc,
|
||||
@@ -113,6 +130,18 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Stream<Map<String, dynamic>> downloadProgressStream() {
|
||||
return _downloadProgressEvents.receiveBroadcastStream().map((event) {
|
||||
if (event is String) {
|
||||
return jsonDecode(event) as Map<String, dynamic>;
|
||||
}
|
||||
if (event is Map) {
|
||||
return Map<String, dynamic>.from(event);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> initItemProgress(String itemId) async {
|
||||
await _channel.invokeMethod('initItemProgress', {'item_id': itemId});
|
||||
}
|
||||
@@ -532,6 +561,17 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getDeezerRelatedArtists(
|
||||
String artistId, {
|
||||
int limit = 12,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('getDeezerRelatedArtists', {
|
||||
'artist_id': artistId,
|
||||
'limit': limit,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getDeezerMetadata(
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
@@ -1098,6 +1138,18 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Stream<Map<String, dynamic>> libraryScanProgressStream() {
|
||||
return _libraryScanProgressEvents.receiveBroadcastStream().map((event) {
|
||||
if (event is String) {
|
||||
return jsonDecode(event) as Map<String, dynamic>;
|
||||
}
|
||||
if (event is Map) {
|
||||
return Map<String, dynamic>.from(event);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
});
|
||||
}
|
||||
|
||||
/// Cancel ongoing library scan
|
||||
static Future<void> cancelLibraryScan() async {
|
||||
await _channel.invokeMethod('cancelLibraryScan');
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ShellNavigationService {
|
||||
static final GlobalKey<NavigatorState> homeTabNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
static final GlobalKey<NavigatorState> libraryTabNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
static final GlobalKey<NavigatorState> storeTabNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
|
||||
static int _currentTabIndex = 0;
|
||||
static bool _showStoreTab = false;
|
||||
|
||||
static void syncState({
|
||||
required int currentTabIndex,
|
||||
required bool showStoreTab,
|
||||
}) {
|
||||
_currentTabIndex = currentTabIndex;
|
||||
_showStoreTab = showStoreTab;
|
||||
}
|
||||
|
||||
static NavigatorState? activeTabNavigator() {
|
||||
if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState;
|
||||
if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState;
|
||||
if (_showStoreTab && _currentTabIndex == 2) {
|
||||
return storeTabNavigatorKey.currentState;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -28,36 +27,6 @@ class UpdateChecker {
|
||||
static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||
static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
|
||||
|
||||
static Future<String> _getDeviceArch() async {
|
||||
if (!Platform.isAndroid) return 'unknown';
|
||||
|
||||
try {
|
||||
final cpuInfo = await File('/proc/cpuinfo').readAsString();
|
||||
|
||||
if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) {
|
||||
return 'arm64';
|
||||
}
|
||||
|
||||
final result = await Process.run('uname', ['-m']);
|
||||
final arch = result.stdout.toString().trim().toLowerCase();
|
||||
|
||||
if (arch.contains('aarch64') || arch.contains('arm64')) {
|
||||
return 'arm64';
|
||||
} else if (arch.contains('armv7') || arch.contains('arm')) {
|
||||
return 'arm32';
|
||||
} else if (arch.contains('x86_64')) {
|
||||
return 'x86_64';
|
||||
} else if (arch.contains('x86') || arch.contains('i686')) {
|
||||
return 'x86';
|
||||
}
|
||||
|
||||
return 'arm64';
|
||||
} catch (e) {
|
||||
_log.e('Error detecting arch: $e');
|
||||
return 'arm64';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for updates based on channel preference
|
||||
/// [channel] can be 'stable' or 'preview'
|
||||
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
|
||||
@@ -109,11 +78,7 @@ class UpdateChecker {
|
||||
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
||||
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now();
|
||||
|
||||
final deviceArch = await _getDeviceArch();
|
||||
_log.d('Device architecture: $deviceArch');
|
||||
|
||||
String? arm64Url;
|
||||
String? arm32Url;
|
||||
String? universalUrl;
|
||||
|
||||
final assets = releaseData['assets'] as List<dynamic>? ?? [];
|
||||
@@ -128,22 +93,14 @@ class UpdateChecker {
|
||||
}
|
||||
if (name.contains('arm64') || name.contains('v8a')) {
|
||||
arm64Url = downloadUrl;
|
||||
} else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) {
|
||||
arm32Url = downloadUrl;
|
||||
} else if (name.contains('universal')) {
|
||||
universalUrl = downloadUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? apkUrl;
|
||||
if (deviceArch == 'arm64') {
|
||||
apkUrl = arm64Url ?? universalUrl ?? arm32Url;
|
||||
} else if (deviceArch == 'arm32') {
|
||||
apkUrl = arm32Url ?? universalUrl;
|
||||
} else {
|
||||
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
|
||||
}
|
||||
// Only arm64 is supported; fall back to universal if available
|
||||
final apkUrl = arm64Url ?? universalUrl;
|
||||
|
||||
_log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl');
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
final RegExp _artistNameSplitPattern = RegExp(
|
||||
r'\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?(?=\s|$)\s*',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
List<String> splitArtistNames(String rawArtists) {
|
||||
final raw = rawArtists.trim();
|
||||
if (raw.isEmpty) return const [];
|
||||
|
||||
return raw
|
||||
.split(_artistNameSplitPattern)
|
||||
.map((part) => part.trim())
|
||||
.where((part) => part.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
show ExtensionArtistScreen, ExtensionAlbumScreen;
|
||||
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('ClickableMetadata');
|
||||
|
||||
/// Navigate to an artist screen by searching Deezer for the artist ID.
|
||||
///
|
||||
/// If [artistId] is provided and valid, navigates directly.
|
||||
/// Otherwise, searches Deezer by [artistName] to resolve the ID first.
|
||||
/// For extension-based content, pass [extensionId] to use ExtensionArtistScreen.
|
||||
Future<void> navigateToArtist(
|
||||
BuildContext context, {
|
||||
required String artistName,
|
||||
String? artistId,
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) async {
|
||||
if (artistName.isEmpty) return;
|
||||
|
||||
final normalizedArtistId = _normalizeArtistId(artistId);
|
||||
|
||||
// If we have a valid artist ID already, navigate directly
|
||||
if (normalizedArtistId != null &&
|
||||
_canNavigateArtistDirectly(
|
||||
artistId: normalizedArtistId,
|
||||
extensionId: extensionId,
|
||||
)) {
|
||||
_pushArtistScreen(
|
||||
context,
|
||||
artistId: normalizedArtistId,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
extensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Search Deezer to resolve the artist ID
|
||||
_showLoadingSnackBar(context, 'Looking up artist...');
|
||||
try {
|
||||
final results = await PlatformBridge.searchDeezerAll(
|
||||
artistName,
|
||||
trackLimit: 0,
|
||||
artistLimit: 3,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
|
||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
if (artistList.isEmpty) {
|
||||
_showUnavailable(context, 'Artist');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find best match - prefer exact name match (case-insensitive)
|
||||
Map<String, dynamic>? bestMatch;
|
||||
final lowerName = artistName.toLowerCase().trim();
|
||||
for (final a in artistList) {
|
||||
if (a is Map<String, dynamic>) {
|
||||
final name = (a['name'] as String? ?? '').toLowerCase().trim();
|
||||
if (name == lowerName) {
|
||||
bestMatch = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bestMatch ??= artistList.first as Map<String, dynamic>;
|
||||
|
||||
final resolvedId = bestMatch['id'] as String? ?? '';
|
||||
final resolvedName = bestMatch['name'] as String? ?? artistName;
|
||||
final resolvedImage = bestMatch['images'] as String?;
|
||||
|
||||
if (resolvedId.isEmpty) {
|
||||
_showUnavailable(context, 'Artist');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
_pushArtistScreen(
|
||||
context,
|
||||
artistId: resolvedId,
|
||||
artistName: resolvedName,
|
||||
coverUrl: resolvedImage ?? coverUrl,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('Failed to look up artist "$artistName": $e', e);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
_showUnavailable(context, 'Artist');
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to an album screen by searching Deezer for the album ID.
|
||||
///
|
||||
/// If [albumId] is provided and valid, navigates directly.
|
||||
/// Otherwise, searches Deezer by [albumName] (optionally with [artistName]) to resolve the ID.
|
||||
/// For extension-based content, pass [extensionId] to use ExtensionAlbumScreen.
|
||||
Future<void> navigateToAlbum(
|
||||
BuildContext context, {
|
||||
required String albumName,
|
||||
String? albumId,
|
||||
String? artistName,
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) async {
|
||||
if (albumName.isEmpty) return;
|
||||
|
||||
// If we have a valid album ID already, navigate directly
|
||||
if (albumId != null &&
|
||||
albumId.isNotEmpty &&
|
||||
albumId != 'unknown' &&
|
||||
albumId != 'deezer:unknown') {
|
||||
_pushAlbumScreen(
|
||||
context,
|
||||
albumId: albumId,
|
||||
albumName: albumName,
|
||||
coverUrl: coverUrl,
|
||||
extensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's extension-based content without an ID, can't search Deezer for it
|
||||
if (extensionId != null) {
|
||||
_showUnavailable(context, 'Album');
|
||||
return;
|
||||
}
|
||||
|
||||
// Search Deezer to resolve the album ID
|
||||
_showLoadingSnackBar(context, 'Looking up album...');
|
||||
try {
|
||||
// Build search query: "albumName artistName" for better accuracy
|
||||
final query = artistName != null && artistName.isNotEmpty
|
||||
? '$albumName $artistName'
|
||||
: albumName;
|
||||
|
||||
final results = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 0,
|
||||
artistLimit: 0,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
|
||||
final albumList = results['albums'] as List<dynamic>? ?? [];
|
||||
if (albumList.isEmpty) {
|
||||
_showUnavailable(context, 'Album');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find best match - prefer exact name match (case-insensitive)
|
||||
Map<String, dynamic>? bestMatch;
|
||||
final lowerName = albumName.toLowerCase().trim();
|
||||
for (final a in albumList) {
|
||||
if (a is Map<String, dynamic>) {
|
||||
final name = (a['name'] as String? ?? '').toLowerCase().trim();
|
||||
if (name == lowerName) {
|
||||
bestMatch = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bestMatch ??= albumList.first as Map<String, dynamic>;
|
||||
|
||||
final resolvedId = bestMatch['id'] as String? ?? '';
|
||||
final resolvedName = bestMatch['name'] as String? ?? albumName;
|
||||
final resolvedImage = bestMatch['images'] as String?;
|
||||
|
||||
if (resolvedId.isEmpty) {
|
||||
_showUnavailable(context, 'Album');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
_pushAlbumScreen(
|
||||
context,
|
||||
albumId: resolvedId,
|
||||
albumName: resolvedName,
|
||||
coverUrl: resolvedImage ?? coverUrl,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('Failed to look up album "$albumName": $e', e);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
_showUnavailable(context, 'Album');
|
||||
}
|
||||
}
|
||||
|
||||
void _pushArtistScreen(
|
||||
BuildContext context, {
|
||||
required String artistId,
|
||||
required String artistName,
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) {
|
||||
_pushViaPreferredNavigator(
|
||||
context,
|
||||
(context) => extensionId != null
|
||||
? ExtensionArtistScreen(
|
||||
extensionId: extensionId,
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
)
|
||||
: ArtistScreen(
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pushAlbumScreen(
|
||||
BuildContext context, {
|
||||
required String albumId,
|
||||
required String albumName,
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) {
|
||||
_pushViaPreferredNavigator(
|
||||
context,
|
||||
(context) => extensionId != null
|
||||
? ExtensionAlbumScreen(
|
||||
extensionId: extensionId,
|
||||
albumId: albumId,
|
||||
albumName: albumName,
|
||||
coverUrl: coverUrl,
|
||||
)
|
||||
: AlbumScreen(
|
||||
albumId: albumId,
|
||||
albumName: albumName,
|
||||
coverUrl: coverUrl,
|
||||
tracks: const [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) {
|
||||
final currentNavigator = Navigator.of(context);
|
||||
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
||||
final activeTabNavigator = ShellNavigationService.activeTabNavigator();
|
||||
|
||||
final shouldRouteToTabNavigator =
|
||||
identical(currentNavigator, rootNavigator) && activeTabNavigator != null;
|
||||
|
||||
if (!shouldRouteToTabNavigator) {
|
||||
currentNavigator.push(MaterialPageRoute(builder: builder));
|
||||
return;
|
||||
}
|
||||
|
||||
final currentRoute = ModalRoute.of(context);
|
||||
final shouldPopCurrentRoute =
|
||||
currentRoute != null && currentRoute.isFirst == false;
|
||||
|
||||
if (shouldPopCurrentRoute && currentNavigator.canPop()) {
|
||||
currentNavigator.pop();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!activeTabNavigator.mounted) return;
|
||||
activeTabNavigator.push(MaterialPageRoute(builder: builder));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabNavigator.push(MaterialPageRoute(builder: builder));
|
||||
}
|
||||
|
||||
void _showLoadingSnackBar(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(message),
|
||||
],
|
||||
),
|
||||
duration: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showUnavailable(BuildContext context, String type) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('$type information not available')));
|
||||
}
|
||||
|
||||
/// A reusable widget that makes text tappable to navigate to an artist screen.
|
||||
///
|
||||
/// Wraps the text in a GestureDetector that, when tapped, looks up the artist
|
||||
/// via Deezer search and navigates to the ArtistScreen.
|
||||
class ClickableArtistName extends StatefulWidget {
|
||||
final String artistName;
|
||||
final String? artistId;
|
||||
final String? coverUrl;
|
||||
final String? extensionId;
|
||||
final TextStyle? style;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
final TextAlign? textAlign;
|
||||
|
||||
const ClickableArtistName({
|
||||
super.key,
|
||||
required this.artistName,
|
||||
this.artistId,
|
||||
this.coverUrl,
|
||||
this.extensionId,
|
||||
this.style,
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
this.textAlign,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ClickableArtistName> createState() => _ClickableArtistNameState();
|
||||
}
|
||||
|
||||
class _ClickableArtistNameState extends State<ClickableArtistName> {
|
||||
List<_ArtistTapTarget> _artistTargets = const [];
|
||||
final List<TapGestureRecognizer> _recognizers = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_rebuildArtistTargets();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ClickableArtistName oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.artistName != widget.artistName ||
|
||||
oldWidget.artistId != widget.artistId ||
|
||||
oldWidget.coverUrl != widget.coverUrl ||
|
||||
oldWidget.extensionId != widget.extensionId) {
|
||||
_rebuildArtistTargets();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeRecognizers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeRecognizers() {
|
||||
for (final recognizer in _recognizers) {
|
||||
recognizer.dispose();
|
||||
}
|
||||
_recognizers.clear();
|
||||
}
|
||||
|
||||
void _rebuildArtistTargets() {
|
||||
_disposeRecognizers();
|
||||
_artistTargets = _buildArtistTapTargets(widget.artistName, widget.artistId);
|
||||
if (_artistTargets.length <= 1) return;
|
||||
|
||||
for (final target in _artistTargets) {
|
||||
final recognizer = TapGestureRecognizer()
|
||||
..onTap = () => navigateToArtist(
|
||||
context,
|
||||
artistName: target.name,
|
||||
artistId: target.artistId,
|
||||
coverUrl: widget.coverUrl,
|
||||
extensionId: _extensionIdForTarget(target),
|
||||
);
|
||||
_recognizers.add(recognizer);
|
||||
}
|
||||
}
|
||||
|
||||
String? _extensionIdForTarget(_ArtistTapTarget target) {
|
||||
if (widget.extensionId == null) return null;
|
||||
if (_artistTargets.length == 1) return widget.extensionId;
|
||||
return target.artistId != null ? widget.extensionId : null;
|
||||
}
|
||||
|
||||
List<InlineSpan> _buildMultiArtistSpans() {
|
||||
final spans = <InlineSpan>[];
|
||||
for (var i = 0; i < _artistTargets.length; i++) {
|
||||
final target = _artistTargets[i];
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: target.name,
|
||||
style: widget.style,
|
||||
recognizer: _recognizers[i],
|
||||
),
|
||||
);
|
||||
if (i < _artistTargets.length - 1) {
|
||||
spans.add(TextSpan(text: ', ', style: widget.style));
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_artistTargets.isEmpty) {
|
||||
return Text(
|
||||
widget.artistName,
|
||||
style: widget.style,
|
||||
maxLines: widget.maxLines,
|
||||
overflow: widget.overflow,
|
||||
textAlign: widget.textAlign,
|
||||
);
|
||||
}
|
||||
|
||||
if (_artistTargets.length == 1) {
|
||||
final target = _artistTargets.first;
|
||||
return GestureDetector(
|
||||
onTap: () => navigateToArtist(
|
||||
context,
|
||||
artistName: target.name,
|
||||
artistId: target.artistId,
|
||||
coverUrl: widget.coverUrl,
|
||||
extensionId: _extensionIdForTarget(target),
|
||||
),
|
||||
child: Text(
|
||||
target.name,
|
||||
style: widget.style,
|
||||
maxLines: widget.maxLines,
|
||||
overflow: widget.overflow,
|
||||
textAlign: widget.textAlign,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Text.rich(
|
||||
TextSpan(style: widget.style, children: _buildMultiArtistSpans()),
|
||||
maxLines: widget.maxLines,
|
||||
overflow: widget.overflow ?? TextOverflow.clip,
|
||||
textAlign: widget.textAlign ?? TextAlign.start,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistTapTarget {
|
||||
final String name;
|
||||
final String? artistId;
|
||||
|
||||
const _ArtistTapTarget({required this.name, this.artistId});
|
||||
}
|
||||
|
||||
List<_ArtistTapTarget> _buildArtistTapTargets(
|
||||
String rawArtistNames,
|
||||
String? rawArtistIds,
|
||||
) {
|
||||
final parsedNames = splitArtistNames(rawArtistNames);
|
||||
if (parsedNames.isEmpty) return const [];
|
||||
|
||||
final uniqueNames = <String>[];
|
||||
final seen = <String>{};
|
||||
for (final parsed in parsedNames) {
|
||||
final key = parsed.toLowerCase().replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
if (key.isEmpty || !seen.add(key)) continue;
|
||||
uniqueNames.add(parsed);
|
||||
}
|
||||
if (uniqueNames.isEmpty) return const [];
|
||||
|
||||
if (uniqueNames.length == 1) {
|
||||
return [
|
||||
_ArtistTapTarget(
|
||||
name: uniqueNames.first,
|
||||
artistId: _normalizeArtistId(rawArtistIds),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
final parsedIds = _parseArtistIds(rawArtistIds);
|
||||
if (parsedIds.length == uniqueNames.length) {
|
||||
return List<_ArtistTapTarget>.generate(
|
||||
uniqueNames.length,
|
||||
(index) => _ArtistTapTarget(
|
||||
name: uniqueNames[index],
|
||||
artistId: parsedIds[index],
|
||||
),
|
||||
growable: false,
|
||||
);
|
||||
}
|
||||
|
||||
return uniqueNames
|
||||
.map((name) => _ArtistTapTarget(name: name))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _parseArtistIds(String? rawArtistIds) {
|
||||
final raw = rawArtistIds?.trim();
|
||||
if (raw == null || raw.isEmpty) return const [];
|
||||
|
||||
final parsed = <String>[];
|
||||
for (final part in raw.split(RegExp(r'\s*,\s*'))) {
|
||||
final normalized = _normalizeArtistId(part);
|
||||
if (normalized != null) {
|
||||
parsed.add(normalized);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
String? _normalizeArtistId(String? artistId) {
|
||||
final id = artistId?.trim();
|
||||
if (id == null || id.isEmpty || id == 'unknown' || id == 'deezer:unknown') {
|
||||
return null;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
bool _canNavigateArtistDirectly({
|
||||
required String artistId,
|
||||
required String? extensionId,
|
||||
}) {
|
||||
if (extensionId != null) return true;
|
||||
if (artistId.startsWith('deezer:')) return true;
|
||||
return _spotifyArtistIdPattern.hasMatch(artistId);
|
||||
}
|
||||
|
||||
final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
|
||||
|
||||
/// A reusable widget that makes text tappable to navigate to an album screen.
|
||||
///
|
||||
/// Wraps the text in a GestureDetector that, when tapped, looks up the album
|
||||
/// via Deezer search and navigates to the AlbumScreen.
|
||||
class ClickableAlbumName extends StatelessWidget {
|
||||
final String albumName;
|
||||
final String? albumId;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
final String? extensionId;
|
||||
final TextStyle? style;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
final TextAlign? textAlign;
|
||||
|
||||
const ClickableAlbumName({
|
||||
super.key,
|
||||
required this.albumName,
|
||||
this.albumId,
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
this.extensionId,
|
||||
this.style,
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
this.textAlign,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => navigateToAlbum(
|
||||
context,
|
||||
albumName: albumName,
|
||||
albumId: albumId,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
extensionId: extensionId,
|
||||
),
|
||||
child: Text(
|
||||
albumName,
|
||||
style: style,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow,
|
||||
textAlign: textAlign,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class BuiltInService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Default quality options for built-in services (Tidal, Qobuz, Amazon, YouTube)
|
||||
/// Default quality options for built-in services
|
||||
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
|
||||
const _builtInServices = [
|
||||
BuiltInService(
|
||||
@@ -129,6 +129,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,134 +13,9 @@ Future<void> showAddTrackToPlaylistSheet(
|
||||
WidgetRef ref,
|
||||
Track track,
|
||||
) async {
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
final state = ref.read(libraryCollectionsProvider);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final playlists = ref.watch(
|
||||
libraryCollectionsProvider.select((state) => state.playlists),
|
||||
);
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.playlist_add),
|
||||
title: Text(sheetContext.l10n.collectionAddToPlaylist),
|
||||
subtitle: Text('${track.name} • ${track.artistName}'),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_circle_outline),
|
||||
title: Text(sheetContext.l10n.collectionCreatePlaylist),
|
||||
onTap: () async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
final name = await _promptPlaylistName(context);
|
||||
if (name == null || name.trim().isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
final playlistId = await notifier.createPlaylist(name.trim());
|
||||
final added = await notifier.addTrackToPlaylist(
|
||||
playlistId,
|
||||
track,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
added
|
||||
? context.l10n.collectionAddedToPlaylist(name.trim())
|
||||
: context.l10n.collectionAlreadyInPlaylist(
|
||||
name.trim(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (playlists.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Text(
|
||||
sheetContext.l10n.collectionNoPlaylistsYet,
|
||||
style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(sheetContext).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: playlists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = playlists[index];
|
||||
final alreadyInPlaylist = playlist.containsTrack(track);
|
||||
return ListTile(
|
||||
leading: _PlaylistPickerThumbnail(
|
||||
playlist: playlist,
|
||||
isSelected: alreadyInPlaylist,
|
||||
),
|
||||
title: Text(playlist.name),
|
||||
subtitle: Text(
|
||||
context.l10n.collectionPlaylistTracks(
|
||||
playlist.tracks.length,
|
||||
),
|
||||
),
|
||||
enabled: !alreadyInPlaylist,
|
||||
onTap: !alreadyInPlaylist
|
||||
? () async {
|
||||
final added = await notifier.addTrackToPlaylist(
|
||||
playlist.id,
|
||||
track,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(sheetContext).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
added
|
||||
? context.l10n
|
||||
.collectionAddedToPlaylist(
|
||||
playlist.name,
|
||||
)
|
||||
: context.l10n
|
||||
.collectionAlreadyInPlaylist(
|
||||
playlist.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
final afterState = ref.read(libraryCollectionsProvider);
|
||||
if (afterState.playlists.length != state.playlists.length) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.collectionPlaylistCreated)),
|
||||
);
|
||||
}
|
||||
return showAddTracksToPlaylistSheet(context, ref, [track]);
|
||||
}
|
||||
|
||||
/// Batch version: add multiple tracks to a chosen playlist.
|
||||
Future<void> showAddTracksToPlaylistSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -148,79 +23,157 @@ Future<void> showAddTracksToPlaylistSheet(
|
||||
) async {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) {
|
||||
final playlists = ref.watch(
|
||||
libraryCollectionsProvider.select((state) => state.playlists),
|
||||
return _PlaylistPickerSheetContent(tracks: tracks);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _PlaylistPickerSheetContent extends ConsumerStatefulWidget {
|
||||
final List<Track> tracks;
|
||||
|
||||
const _PlaylistPickerSheetContent({required this.tracks});
|
||||
|
||||
@override
|
||||
ConsumerState<_PlaylistPickerSheetContent> createState() =>
|
||||
_PlaylistPickerSheetContentState();
|
||||
}
|
||||
|
||||
class _PlaylistPickerSheetContentState
|
||||
extends ConsumerState<_PlaylistPickerSheetContent> {
|
||||
final Set<String> _selectedPlaylistIds = {};
|
||||
final Set<String> _initialDisabledIds = {};
|
||||
bool _initialized = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_initialized) {
|
||||
final playlists = ref.read(libraryCollectionsProvider).playlists;
|
||||
for (final playlist in playlists) {
|
||||
final alreadyInPlaylist =
|
||||
widget.tracks.every((t) => playlist.containsTrack(t));
|
||||
if (alreadyInPlaylist) {
|
||||
_initialDisabledIds.add(playlist.id);
|
||||
_selectedPlaylistIds.add(playlist.id);
|
||||
}
|
||||
}
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDone() async {
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds);
|
||||
final addedNames = <String>[];
|
||||
|
||||
for (final playlistId in idsToAdd) {
|
||||
final playlist =
|
||||
ref.read(libraryCollectionsProvider).playlistById(playlistId);
|
||||
if (playlist != null) {
|
||||
addedNames.add(playlist.name);
|
||||
}
|
||||
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
|
||||
if (addedNames.isNotEmpty) {
|
||||
final name =
|
||||
addedNames.length == 1 ? addedNames.first : addedNames.join(', ');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.collectionAddedToPlaylist(name)),
|
||||
),
|
||||
);
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.playlist_add),
|
||||
title: Text(sheetContext.l10n.collectionAddToPlaylist),
|
||||
subtitle: Text(
|
||||
'${tracks.length} ${tracks.length == 1 ? 'track' : 'tracks'}',
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_circle_outline),
|
||||
title: Text(sheetContext.l10n.collectionCreatePlaylist),
|
||||
onTap: () async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
final name = await _promptPlaylistName(context);
|
||||
if (name == null || name.trim().isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
final playlistId = await notifier.createPlaylist(name.trim());
|
||||
final result = await notifier.addTracksToPlaylist(
|
||||
playlistId,
|
||||
tracks,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.addedCount > 0
|
||||
? context.l10n.collectionAddedToPlaylist(name.trim())
|
||||
: context.l10n.collectionAlreadyInPlaylist(
|
||||
name.trim(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (playlists.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Text(
|
||||
sheetContext.l10n.collectionNoPlaylistsYet,
|
||||
style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(sheetContext).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final playlists = ref.watch(
|
||||
libraryCollectionsProvider.select((state) => state.playlists),
|
||||
);
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
|
||||
final String subtitle;
|
||||
if (widget.tracks.length == 1) {
|
||||
final track = widget.tracks.first;
|
||||
subtitle = '${track.name} • ${track.artistName}';
|
||||
} else {
|
||||
subtitle =
|
||||
'${widget.tracks.length} ${widget.tracks.length == 1 ? 'track' : 'tracks'}';
|
||||
}
|
||||
|
||||
final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds);
|
||||
final hasNewSelections = idsToAdd.isNotEmpty;
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.playlist_add),
|
||||
title: Text(context.l10n.collectionAddToPlaylist),
|
||||
subtitle: Text(subtitle),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_circle_outline),
|
||||
title: Text(context.l10n.collectionCreatePlaylist),
|
||||
onTap: () async {
|
||||
final name = await _promptPlaylistName(context);
|
||||
if (name == null || name.trim().isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
final playlistId = await notifier.createPlaylist(name.trim());
|
||||
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
|
||||
setState(() {
|
||||
_initialDisabledIds.add(playlistId);
|
||||
_selectedPlaylistIds.add(playlistId);
|
||||
});
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.collectionAddedToPlaylist(name.trim())),
|
||||
),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
);
|
||||
},
|
||||
),
|
||||
if (playlists.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Text(
|
||||
context.l10n.collectionNoPlaylistsYet,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: playlists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = playlists[index];
|
||||
final isAlreadyIn = _initialDisabledIds.contains(playlist.id);
|
||||
final isSelected = _selectedPlaylistIds.contains(playlist.id);
|
||||
|
||||
return ListTile(
|
||||
leading: _PlaylistPickerThumbnail(
|
||||
playlist: playlist,
|
||||
isSelected: false,
|
||||
isSelected: isSelected,
|
||||
),
|
||||
title: Text(playlist.name),
|
||||
subtitle: Text(
|
||||
@@ -228,37 +181,44 @@ Future<void> showAddTracksToPlaylistSheet(
|
||||
playlist.tracks.length,
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await notifier.addTracksToPlaylist(
|
||||
playlist.id,
|
||||
tracks,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(sheetContext).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.addedCount > 0
|
||||
? context.l10n.collectionAddedToPlaylist(
|
||||
playlist.name,
|
||||
)
|
||||
: context.l10n.collectionAlreadyInPlaylist(
|
||||
playlist.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
enabled: !isAlreadyIn,
|
||||
onTap: !isAlreadyIn
|
||||
? () {
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selectedPlaylistIds.remove(playlist.id);
|
||||
} else {
|
||||
_selectedPlaylistIds.add(playlist.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
if (hasNewSelections) {
|
||||
_handleDone();
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Text(context.l10n.dialogDone),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _promptPlaylistName(BuildContext context) async {
|
||||
@@ -352,10 +312,7 @@ class _PlaylistPickerThumbnail extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: colorScheme.primary,
|
||||
width: 1.5,
|
||||
),
|
||||
border: Border.all(color: colorScheme.primary, width: 1.5),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
|
||||
@@ -3,17 +3,36 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
class TrackCollectionQuickActions extends ConsumerWidget {
|
||||
final Track track;
|
||||
|
||||
const TrackCollectionQuickActions({
|
||||
super.key,
|
||||
required this.track,
|
||||
});
|
||||
const TrackCollectionQuickActions({super.key, required this.track});
|
||||
|
||||
static void showTrackOptionsSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Track track,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (sheetContext) => _TrackOptionsSheet(track: track),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -25,24 +44,11 @@ class TrackCollectionQuickActions extends ConsumerWidget {
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => _showTrackOptionsSheet(context, ref),
|
||||
onPressed: () => showTrackOptionsSheet(context, ref, track),
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
);
|
||||
}
|
||||
|
||||
void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (sheetContext) => _TrackOptionsSheet(track: track),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackOptionsSheet extends ConsumerWidget {
|
||||
@@ -53,6 +59,9 @@ class _TrackOptionsSheet extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final rootContext = Navigator.of(context, rootNavigator: true).context;
|
||||
final container = ProviderScope.containerOf(rootContext, listen: false);
|
||||
|
||||
final isLoved = ref.watch(
|
||||
libraryCollectionsProvider.select((state) => state.isLoved(track)),
|
||||
@@ -62,155 +71,214 @@ class _TrackOptionsSheet extends ConsumerWidget {
|
||||
);
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header with drag handle + track info (matches _TrackInfoHeader)
|
||||
Column(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.82,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: track.coverUrl != null && track.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
// Header with drag handle + track info (matches _TrackInfoHeader)
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
track.artistName,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child:
|
||||
track.coverUrl != null &&
|
||||
track.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
errorWidget: (context, url, error) =>
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.name,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ClickableArtistName(
|
||||
artistName: track.artistName,
|
||||
artistId: track.artistId,
|
||||
coverUrl: track.coverUrl,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
// Action items (matches _QualityOption style)
|
||||
_OptionTile(
|
||||
icon: Icons.download_rounded,
|
||||
title: context.l10n.downloadTitle,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
rootContext,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
container
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(
|
||||
track,
|
||||
service,
|
||||
qualityOverride: quality,
|
||||
);
|
||||
ScaffoldMessenger.of(rootContext).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
rootContext.l10n.snackbarAddedToQueue(track.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
container
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, settings.defaultService);
|
||||
if (!rootContext.mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(rootContext).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
rootContext.l10n.snackbarAddedToQueue(track.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
_OptionTile(
|
||||
icon: isLoved ? Icons.favorite : Icons.favorite_border,
|
||||
iconColor: isLoved ? colorScheme.error : null,
|
||||
title: isLoved
|
||||
? context.l10n.trackOptionRemoveFromLoved
|
||||
: context.l10n.trackOptionAddToLoved,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final added = await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.toggleLoved(track);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
added
|
||||
? context.l10n.collectionAddedToLoved(track.name)
|
||||
: context.l10n.collectionRemovedFromLoved(
|
||||
track.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_OptionTile(
|
||||
icon: isInWishlist
|
||||
? Icons.playlist_add_check_circle
|
||||
: Icons.add_circle_outline,
|
||||
iconColor: isInWishlist ? colorScheme.primary : null,
|
||||
title: isInWishlist
|
||||
? context.l10n.trackOptionRemoveFromWishlist
|
||||
: context.l10n.trackOptionAddToWishlist,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final added = await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.toggleWishlist(track);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
added
|
||||
? context.l10n.collectionAddedToWishlist(track.name)
|
||||
: context.l10n.collectionRemovedFromWishlist(
|
||||
track.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_OptionTile(
|
||||
icon: Icons.playlist_add,
|
||||
title: context.l10n.collectionAddToPlaylist,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showAddTrackToPlaylistSheet(context, ref, track);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
// Action items (matches _QualityOption style)
|
||||
_OptionTile(
|
||||
icon: isLoved ? Icons.favorite : Icons.favorite_border,
|
||||
iconColor: isLoved ? colorScheme.error : null,
|
||||
title: isLoved
|
||||
? context.l10n.trackOptionRemoveFromLoved
|
||||
: context.l10n.trackOptionAddToLoved,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final added = await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.toggleLoved(track);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
added
|
||||
? context.l10n.collectionAddedToLoved(track.name)
|
||||
: context.l10n.collectionRemovedFromLoved(track.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_OptionTile(
|
||||
icon: isInWishlist
|
||||
? Icons.playlist_add_check_circle
|
||||
: Icons.add_circle_outline,
|
||||
iconColor: isInWishlist ? colorScheme.primary : null,
|
||||
title: isInWishlist
|
||||
? context.l10n.trackOptionRemoveFromWishlist
|
||||
: context.l10n.trackOptionAddToWishlist,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final added = await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.toggleWishlist(track);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
added
|
||||
? context.l10n.collectionAddedToWishlist(track.name)
|
||||
: context.l10n.collectionRemovedFromWishlist(
|
||||
track.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_OptionTile(
|
||||
icon: Icons.playlist_add,
|
||||
title: context.l10n.collectionAddToPlaylist,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showAddTrackToPlaylistSheet(context, ref, track);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user