chore: rebuild dev history without streaming-era commits

This commit is contained in:
zarzet
2026-02-27 13:48:44 +07:00
parent c89600591c
commit ab26d84632
111 changed files with 18282 additions and 3459 deletions
+28 -2
View File
@@ -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,
+2 -2
View File
@@ -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';
+456
View File
@@ -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
+259
View File
@@ -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';
}
}
+258
View File
@@ -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';
}
}
+307
View File
@@ -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';
}
+259
View File
@@ -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';
}
}
+259
View File
@@ -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';
}
}
+284 -14
View File
@@ -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';
}
}
+251
View File
@@ -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';
}
}
+251
View File
@@ -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';
}
}
+259
View File
@@ -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';
}
}
+307
View File
@@ -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';
}
+259
View File
@@ -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';
}
}
+258
View File
@@ -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';
}
}
+328
View File
@@ -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
View File
@@ -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"
}
+166
View File
@@ -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
View File
@@ -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"
}
+14 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
+14 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "自动发现并将相似曲目添加到您的队列中"
}
+14 -1
View File
@@ -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
View File
@@ -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": "自動探索並將相似曲目新增到您的佇列中"
}
+91
View File
@@ -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://');
}
}
+24
View File
@@ -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,
);
}
+17 -5
View File
@@ -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,
};
+4
View File
@@ -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,
+4
View File
@@ -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,
+401 -211
View File
@@ -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 &&
+230 -83
View File
@@ -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();
}
}
+166 -41
View File
@@ -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
+29 -1
View File
@@ -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);
+42 -8
View File
@@ -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? ?? '',
+36 -72
View File
@@ -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,
),
),
),
);
+43 -25
View File
@@ -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 -3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+24 -6
View File
@@ -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) {
+276 -51
View File
@@ -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)),
),
);
}
+14 -4
View File
@@ -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
View File
@@ -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,
);
}
}
+179 -27
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+32 -14
View File
@@ -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)),
+103 -2
View File
@@ -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,
),
],
),
),
),
+286 -252
View File
@@ -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
View File
@@ -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
View File
@@ -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,
),
+14 -1
View File
@@ -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(
+2
View File
@@ -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,
+463 -4
View File
@@ -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,
});
}
+52
View File
@@ -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;
}
}
+2 -45
View File
@@ -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');
+15
View File
@@ -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);
}
+577
View File
@@ -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,
),
);
}
}
+2 -1
View File
@@ -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
+170 -213
View File
@@ -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,
+226 -158
View File
@@ -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),
],
),
),
);
}