diff --git a/CHANGELOG.md b/CHANGELOG.md index d46a6012..10b8016a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## [2.1.0] - 2026-01-06 + +### Added +- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality + - Service selector chips appear above quality options + - Defaults to your preferred service from settings + - Change service on-the-fly without going to settings + - Available in Home, Album, and Playlist screens +- **AMOLED Dark Theme**: Pure black background for OLED screens + - Toggle in Settings > Appearance > Theme + - Saves battery on OLED/AMOLED displays + - All surface colors adjusted for true black background +- **Update Channel Setting**: Choose between Stable and Preview release channels + - Stable: Only receive stable release notifications + - Preview: Get notified about preview/beta releases too + - Configure in Settings > Options > App + +### Changed +- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs + - arm64 APK: 46.6 MB (previously 51 MB) + - arm32 APK: 59 MB (previously 64 MB) + - Only includes FLAC, MP3 (LAME), and AAC codecs + - Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only + - Native MethodChannel bridge for FFmpeg operations + - Separate iOS build configuration with ffmpeg_kit_flutter plugin + +### Fixed +- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing + - Now properly handles retry when queue processing has finished + - Also allows retrying skipped (cancelled) downloads +- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching + - Shows "Lyrics not available" instead of loading forever +- **iOS Directory Picker**: Fixed unable to select download folder on iOS + - iOS limitation: Empty folders cannot be selected via document picker + - Added "App Documents Folder" option as recommended default + - Files saved to app Documents folder are accessible via iOS Files app + +### Performance +- **Download Speed Optimizations**: Significant improvements to download initialization and throughput + - Token caching for Tidal (eliminates redundant auth requests) + - Singleton pattern for all downloaders (HTTP connection reuse) + - ISRC search first strategy (faster than SongLink API) + - Track ID cache with 30 minute TTL for album/playlist downloads + - Pre-warm cache when viewing album/playlist + - Parallel cover art and lyrics fetching during audio download + - 64KB HTTP read/write buffers + - 256KB buffered file writer for all downloaders + - Progress updates every 64KB (reduced lock contention) +- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader + +## [2.1.0-preview2] - 2026-01-06 + +### Added +- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality + - Service selector chips appear above quality options + - Defaults to your preferred service from settings + - Change service on-the-fly without going to settings + - Available in Home, Album, and Playlist screens +- **AMOLED Dark Theme**: Pure black background for OLED screens + - Toggle in Settings > Appearance > Theme + - Saves battery on OLED/AMOLED displays + - All surface colors adjusted for true black background +- **Update Channel Setting**: Choose between Stable and Preview release channels + - Stable: Only receive stable release notifications + - Preview: Get notified about preview/beta releases too + - Configure in Settings > Options > App + +### Fixed +- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing + - Now properly handles retry when queue processing has finished + - Also allows retrying skipped (cancelled) downloads + - Added logging for better debugging +- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching + - Shows "Lyrics not available" instead of loading forever + - Better error messages for timeout and not found cases + ## [2.1.0-preview] - 2026-01-06 ### Performance diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index a371e298..5d353551 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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 = '2.1.0-preview'; - static const String buildNumber = '39'; + static const String version = '2.1.0'; + static const String buildNumber = '41'; static const String fullVersion = '$version+$buildNumber'; @@ -15,4 +15,6 @@ class AppInfo { static const String githubRepo = 'zarzet/SpotiFLAC-Mobile'; static const String githubUrl = 'https://github.com/$githubRepo'; static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC'; + + static const String kofiUrl = 'https://ko-fi.com/zarzet'; } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 0c6ad037..4f5d9aed 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -14,6 +14,7 @@ class AppSettings { final bool isFirstLaunch; final int concurrentDownloads; // 1 = sequential (default), max 3 final bool checkForUpdates; // Check for updates on app start + final String updateChannel; // stable, preview final bool hasSearchedBefore; // Hide helper text after first search final String folderOrganization; // none, artist, album, artist_album final String historyViewMode; // list, grid @@ -33,6 +34,7 @@ class AppSettings { this.isFirstLaunch = true, this.concurrentDownloads = 1, // Default: sequential (off) this.checkForUpdates = true, // Default: enabled + this.updateChannel = 'stable', // Default: stable releases only this.hasSearchedBefore = false, // Default: show helper text this.folderOrganization = 'none', // Default: no folder organization this.historyViewMode = 'grid', // Default: grid view @@ -53,6 +55,7 @@ class AppSettings { bool? isFirstLaunch, int? concurrentDownloads, bool? checkForUpdates, + String? updateChannel, bool? hasSearchedBefore, String? folderOrganization, String? historyViewMode, @@ -72,6 +75,7 @@ class AppSettings { isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads, checkForUpdates: checkForUpdates ?? this.checkForUpdates, + updateChannel: updateChannel ?? this.updateChannel, hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore, folderOrganization: folderOrganization ?? this.folderOrganization, historyViewMode: historyViewMode ?? this.historyViewMode, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index fda0fd5c..5d458693 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -17,6 +17,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1, checkForUpdates: json['checkForUpdates'] as bool? ?? true, + updateChannel: json['updateChannel'] as String? ?? 'stable', hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, folderOrganization: json['folderOrganization'] as String? ?? 'none', historyViewMode: json['historyViewMode'] as String? ?? 'grid', @@ -39,6 +40,7 @@ Map _$AppSettingsToJson(AppSettings instance) => 'isFirstLaunch': instance.isFirstLaunch, 'concurrentDownloads': instance.concurrentDownloads, 'checkForUpdates': instance.checkForUpdates, + 'updateChannel': instance.updateChannel, 'hasSearchedBefore': instance.hasSearchedBefore, 'folderOrganization': instance.folderOrganization, 'historyViewMode': instance.historyViewMode, diff --git a/lib/models/theme_settings.dart b/lib/models/theme_settings.dart index 6b6aaa15..55381fab 100644 --- a/lib/models/theme_settings.dart +++ b/lib/models/theme_settings.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; const String kThemeModeKey = 'theme_mode'; const String kUseDynamicColorKey = 'use_dynamic_color'; const String kSeedColorKey = 'seed_color'; +const String kUseAmoledKey = 'use_amoled'; /// Default Spotify green color for fallback const int kDefaultSeedColor = 0xFF1DB954; @@ -13,11 +14,13 @@ class ThemeSettings { final ThemeMode themeMode; final bool useDynamicColor; final int seedColorValue; + final bool useAmoled; // Pure black background for OLED screens const ThemeSettings({ this.themeMode = ThemeMode.system, this.useDynamicColor = true, this.seedColorValue = kDefaultSeedColor, + this.useAmoled = false, }); /// Get seed color as Color object @@ -28,11 +31,13 @@ class ThemeSettings { ThemeMode? themeMode, bool? useDynamicColor, int? seedColorValue, + bool? useAmoled, }) { return ThemeSettings( themeMode: themeMode ?? this.themeMode, useDynamicColor: useDynamicColor ?? this.useDynamicColor, seedColorValue: seedColorValue ?? this.seedColorValue, + useAmoled: useAmoled ?? this.useAmoled, ); } @@ -41,6 +46,7 @@ class ThemeSettings { kThemeModeKey: themeMode.name, kUseDynamicColorKey: useDynamicColor, kSeedColorKey: seedColorValue, + kUseAmoledKey: useAmoled, }; /// Create from JSON map @@ -49,6 +55,7 @@ class ThemeSettings { themeMode: _themeModeFromString(json[kThemeModeKey] as String?), useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true, seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor, + useAmoled: json[kUseAmoledKey] as bool? ?? false, ); } @@ -58,12 +65,13 @@ class ThemeSettings { return other is ThemeSettings && other.themeMode == themeMode && other.useDynamicColor == useDynamicColor && - other.seedColorValue == seedColorValue; + other.seedColorValue == seedColorValue && + other.useAmoled == useAmoled; } @override int get hashCode => - themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode; + themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode; } /// Helper to convert string to ThemeMode diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index acfa16a3..0d5dac29 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -686,20 +686,37 @@ class DownloadQueueNotifier extends Notifier { } } - /// Retry a failed download + /// Retry a failed or skipped download void retryItem(String id) { - final items = state.items.map((item) { - if (item.id == id && item.status == DownloadStatus.failed) { - return item.copyWith(status: DownloadStatus.queued, progress: 0, error: null); + final item = state.items.where((i) => i.id == id).firstOrNull; + if (item == null) { + _log.w('retryItem: Item not found: $id'); + return; + } + + // Only retry if status is failed or skipped + if (item.status != DownloadStatus.failed && item.status != DownloadStatus.skipped) { + _log.w('retryItem: Item status is ${item.status}, not retrying'); + return; + } + + _log.i('Retrying item: ${item.track.name} (id: $id)'); + + final items = state.items.map((i) { + if (i.id == id) { + return i.copyWith(status: DownloadStatus.queued, progress: 0, error: null); } - return item; + return i; }).toList(); state = state.copyWith(items: items); _saveQueueToStorage(); // Persist queue - // Start processing if not already + // Start processing if not already running if (!state.isProcessing) { + _log.d('Starting queue processing for retry'); Future.microtask(() => _processQueue()); + } else { + _log.d('Queue already processing, item will be picked up'); } } @@ -851,6 +868,13 @@ class DownloadQueueNotifier extends Notifier { _log.i('Queue processing finished'); state = state.copyWith(isProcessing: false, currentDownload: null); + + // Check if there are new queued items (e.g., from retry) and restart if needed + final hasQueuedItems = state.items.any((item) => item.status == DownloadStatus.queued); + if (hasQueuedItems) { + _log.i('Found queued items after processing finished, restarting queue...'); + Future.microtask(() => _processQueue()); + } } /// Sequential download processing (uses multi-progress system with single item) @@ -866,7 +890,9 @@ class DownloadQueueNotifier extends Notifier { continue; } - final nextItem = state.items.firstWhere( + // Re-read state to get latest items (important for retry) + final currentItems = state.items; + final nextItem = currentItems.firstWhere( (item) => item.status == DownloadStatus.queued, orElse: () => DownloadItem( id: '', @@ -877,10 +903,11 @@ class DownloadQueueNotifier extends Notifier { ); if (nextItem.id.isEmpty) { - _log.d('No more items to process'); + _log.d('No more items to process (checked ${currentItems.length} items)'); break; } + _log.d('Processing next item: ${nextItem.track.name} (id: ${nextItem.id})'); await _downloadSingleItem(nextItem); // Clear item progress after download completes diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 86252e75..17944142 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -96,6 +96,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setUpdateChannel(String channel) { + state = state.copyWith(updateChannel: channel); + _saveSettings(); + } + void setHasSearchedBefore() { if (!state.hasSearchedBefore) { state = state.copyWith(hasSearchedBefore: true); diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 76a62189..ca40c032 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -24,11 +24,13 @@ class ThemeNotifier extends Notifier { final modeString = prefs.getString(kThemeModeKey); final useDynamic = prefs.getBool(kUseDynamicColorKey); final seedColor = prefs.getInt(kSeedColorKey); + final useAmoled = prefs.getBool(kUseAmoledKey); state = ThemeSettings( themeMode: _themeModeFromString(modeString), useDynamicColor: useDynamic ?? true, seedColorValue: seedColor ?? kDefaultSeedColor, + useAmoled: useAmoled ?? false, ); } catch (e) { debugPrint('Error loading theme settings: $e'); @@ -43,6 +45,7 @@ class ThemeNotifier extends Notifier { await prefs.setString(kThemeModeKey, state.themeMode.name); await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor); await prefs.setInt(kSeedColorKey, state.seedColorValue); + await prefs.setBool(kUseAmoledKey, state.useAmoled); } catch (e) { debugPrint('Error saving theme settings: $e'); } @@ -72,6 +75,12 @@ class ThemeNotifier extends Notifier { await _saveToStorage(); } + /// Enable or disable AMOLED mode (pure black background) + Future setUseAmoled(bool value) async { + state = state.copyWith(useAmoled: value); + await _saveToStorage(); + } + /// Helper to convert string to ThemeMode ThemeMode _themeModeFromString(String? value) { if (value == null) return ThemeMode.system; diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 452d8ba5..6c8be651 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -302,8 +302,8 @@ class _AlbumScreenState extends ConsumerState { void _downloadTrack(BuildContext context, Track track) { final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, (quality) { - ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality); + _showQualityPicker(context, (quality, service) { + ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); }, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl); } else { @@ -317,8 +317,8 @@ class _AlbumScreenState extends ConsumerState { if (tracks == null || tracks.isEmpty) return; final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, (quality) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality); + _showQualityPicker(context, (quality, service) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); }, trackName: '${tracks.length} tracks', artistName: widget.albumName); } else { @@ -327,44 +327,69 @@ class _AlbumScreenState extends ConsumerState { } } - void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) { + void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) { final colorScheme = Theme.of(context).colorScheme; + final settings = ref.read(settingsProvider); + String selectedService = settings.defaultService; + showModalBottomSheet( context: context, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (trackName != null) ...[ - _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), - Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ] else ...[ - const SizedBox(height: 8), - Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), - ], - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), - ), - // Disclaimer - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: Text( - 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, + isScrollControlled: true, + builder: (context) => StatefulBuilder( + builder: (context, setModalState) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (trackName != null) ...[ + _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), + Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ] else ...[ + const SizedBox(height: 8), + Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), + ], + // Service selector + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), ), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + _ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')), + const SizedBox(width: 8), + _ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')), + const SizedBox(width: 8), + _ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + ), + // Disclaimer + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Text( + 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }), + _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }), + _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }), + const SizedBox(height: 16), + ], ), - _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }), - _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }), - _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }), - const SizedBox(height: 16), - ], + ), ), ), ); @@ -455,6 +480,40 @@ class _QualityOption extends StatelessWidget { } } +class _ServiceChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + const _ServiceChip({required this.label, required this.isSelected, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ); + } +} + class _TrackInfoHeader extends StatefulWidget { final String trackName; final String? artistName; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 6a2054a3..e33aef78 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -170,8 +170,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, (quality) { - ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality); + _showQualityPicker(context, (quality, service) { + ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); }, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl); } else { @@ -181,59 +181,84 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) { + void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) { final colorScheme = Theme.of(context).colorScheme; + final settings = ref.read(settingsProvider); + String selectedService = settings.defaultService; + showModalBottomSheet( context: context, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (trackName != null) ...[ - _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), - Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ] else ...[ - const SizedBox(height: 8), - Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), - ], - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), - ), - // Disclaimer - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: Text( - 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, + isScrollControlled: true, + builder: (context) => StatefulBuilder( + builder: (context, setModalState) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (trackName != null) ...[ + _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), + Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ] else ...[ + const SizedBox(height: 8), + Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), + ], + // Service selector + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), ), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + _ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')), + const SizedBox(width: 8), + _ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')), + const SizedBox(width: 8), + _ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + ), + // Disclaimer + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Text( + 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + _QualityPickerOption( + title: 'FLAC Lossless', + subtitle: '16-bit / 44.1kHz', + icon: Icons.music_note, + onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }, + ), + _QualityPickerOption( + title: 'Hi-Res FLAC', + subtitle: '24-bit / up to 96kHz', + icon: Icons.high_quality, + onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }, + ), + _QualityPickerOption( + title: 'Hi-Res FLAC Max', + subtitle: '24-bit / up to 192kHz', + icon: Icons.four_k, + onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }, + ), + const SizedBox(height: 16), + ], ), - _QualityPickerOption( - title: 'FLAC Lossless', - subtitle: '16-bit / 44.1kHz', - icon: Icons.music_note, - onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }, - ), - _QualityPickerOption( - title: 'Hi-Res FLAC', - subtitle: '24-bit / up to 96kHz', - icon: Icons.high_quality, - onTap: () { Navigator.pop(context); onSelect('HI_RES'); }, - ), - _QualityPickerOption( - title: 'Hi-Res FLAC Max', - subtitle: '24-bit / up to 192kHz', - icon: Icons.four_k, - onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }, - ), - const SizedBox(height: 16), - ], + ), ), ), ); @@ -764,6 +789,40 @@ class _QualityPickerOption extends StatelessWidget { } } +class _ServiceChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + const _ServiceChip({required this.label, required this.isSelected, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ); + } +} + class _TrackInfoHeader extends StatefulWidget { final String trackName; final String? artistName; diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index f3c160b8..3398e20c 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -85,7 +85,7 @@ class _MainShellState extends ConsumerState { final settings = ref.read(settingsProvider); if (!settings.checkForUpdates) return; - final updateInfo = await UpdateChecker.checkForUpdate(); + final updateInfo = await UpdateChecker.checkForUpdate(channel: settings.updateChannel); if (updateInfo != null && mounted) { showUpdateDialog( context, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index c9ab581f..cc3baec7 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -168,8 +168,8 @@ class PlaylistScreen extends ConsumerWidget { void _downloadTrack(BuildContext context, WidgetRef ref, Track track) { final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, (quality) { - ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality); + _showQualityPicker(context, ref, (quality, service) { + ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); }, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl); } else { @@ -182,8 +182,8 @@ class PlaylistScreen extends ConsumerWidget { if (tracks.isEmpty) return; final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, (quality) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality); + _showQualityPicker(context, ref, (quality, service) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); }, trackName: '${tracks.length} tracks', artistName: playlistName); } else { @@ -192,41 +192,66 @@ class PlaylistScreen extends ConsumerWidget { } } - void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) { + void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) { final colorScheme = Theme.of(context).colorScheme; + final settings = ref.read(settingsProvider); + String selectedService = settings.defaultService; + showModalBottomSheet( context: context, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (trackName != null) ...[ - _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), - Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ] else ...[ - const SizedBox(height: 8), - Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), - ], - Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))), - // Disclaimer - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: Text( - 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, + isScrollControlled: true, + builder: (context) => StatefulBuilder( + builder: (context, setModalState) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (trackName != null) ...[ + _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), + Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ] else ...[ + const SizedBox(height: 8), + Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), + ], + // Service selector + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), ), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + _ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')), + const SizedBox(width: 8), + _ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')), + const SizedBox(width: 8), + _ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')), + ], + ), + ), + Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))), + // Disclaimer + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Text( + 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }), + _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }), + _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }), + const SizedBox(height: 16), + ], ), - _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }), - _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }), - _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }), - const SizedBox(height: 16), - ], + ), ), ), ); @@ -254,6 +279,40 @@ class _QualityOption extends StatelessWidget { } } +class _ServiceChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + const _ServiceChip({required this.label, required this.isSelected, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ); + } +} + class _TrackInfoHeader extends StatefulWidget { final String trackName; final String? artistName; diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index ca0c815f..01caa4d9 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -123,6 +123,24 @@ class AboutPage extends StatelessWidget { ), ), + // Support section + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Support'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsItem( + icon: Icons.coffee_outlined, + title: 'Buy me a coffee', + subtitle: 'Support development on Ko-fi', + onTap: () => _launchUrl(AppInfo.kofiUrl), + showDivider: false, + ), + ], + ), + ), + // App info section const SliverToBoxAdapter( child: SettingsSectionHeader(title: 'App'), diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index cef052ba..107be1b2 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -40,6 +40,14 @@ class AppearanceSettingsPage extends ConsumerWidget { currentMode: themeSettings.themeMode, onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode), ), + SettingsSwitchItem( + icon: Icons.brightness_2, + title: 'AMOLED Dark', + subtitle: 'Pure black background for OLED screens', + value: themeSettings.useAmoled, + onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value), + showDivider: false, + ), ], ), ), diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 4a634c9f..56da180d 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -105,7 +105,10 @@ class OptionsSettingsPage extends ConsumerWidget { subtitle: 'Notify when new version is available', value: settings.checkForUpdates, onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v), - showDivider: false, + ), + _UpdateChannelSelector( + currentChannel: settings.updateChannel, + onChanged: (v) => ref.read(settingsProvider.notifier).setUpdateChannel(v), ), ], ), @@ -393,3 +396,76 @@ class _ConcurrentChip extends StatelessWidget { ); } } + +class _UpdateChannelSelector extends StatelessWidget { + final String currentChannel; + final ValueChanged onChanged; + const _UpdateChannelSelector({required this.currentChannel, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.all(20), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(Icons.new_releases, color: colorScheme.onSurfaceVariant, size: 24), + const SizedBox(width: 16), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Update Channel', style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 2), + Text(currentChannel == 'preview' ? 'Get preview releases' : 'Stable releases only', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ])), + ]), + const SizedBox(height: 16), + Row(children: [ + _ChannelChip(label: 'Stable', isSelected: currentChannel == 'stable', onTap: () => onChanged('stable')), + const SizedBox(width: 8), + _ChannelChip(label: 'Preview', isSelected: currentChannel == 'preview', onTap: () => onChanged('preview')), + ]), + const SizedBox(height: 12), + Row(children: [ + Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded(child: Text('Preview may contain bugs or incomplete features', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))), + ]), + ]), + ); + } +} + +class _ChannelChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + const _ChannelChip({required this.label, required this.isSelected, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final unselectedColor = isDark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) + : colorScheme.surfaceContainerHigh; + + return Expanded( + child: Material( + color: isSelected ? colorScheme.primaryContainer : unselectedColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center(child: Text(label, style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))), + ), + ), + ), + ); + } +} diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index bfeb61dd..ef051169 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -759,17 +759,21 @@ class _TrackMetadataScreenState extends ConsumerState { }); try { + // Add timeout to prevent infinite loading final result = await PlatformBridge.getLyricsLRC( item.spotifyId ?? '', item.trackName, item.artistName, filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first + ).timeout( + const Duration(seconds: 20), + onTimeout: () => '', // Return empty string on timeout ); if (mounted) { if (result.isEmpty) { setState(() { - _lyricsError = 'Lyrics not found'; + _lyricsError = 'Lyrics not available for this track'; _lyricsLoading = false; }); } else { @@ -783,8 +787,11 @@ class _TrackMetadataScreenState extends ConsumerState { } } catch (e) { if (mounted) { + final errorMsg = e.toString().contains('TimeoutException') + ? 'Request timed out. Try again later.' + : 'Failed to load lyrics'; setState(() { - _lyricsError = 'Failed to load lyrics'; + _lyricsError = errorMsg; _lyricsLoading = false; }); } diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index 72130c12..4673e0a8 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -12,6 +12,7 @@ class UpdateInfo { final String downloadUrl; final String? apkDownloadUrl; final DateTime publishedAt; + final bool isPrerelease; const UpdateInfo({ required this.version, @@ -19,11 +20,13 @@ class UpdateInfo { required this.downloadUrl, this.apkDownloadUrl, required this.publishedAt, + this.isPrerelease = false, }); } class UpdateChecker { - static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; + 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 _getDeviceArch() async { if (!Platform.isAndroid) return 'unknown'; @@ -55,30 +58,59 @@ class UpdateChecker { } } - static Future checkForUpdate() async { + /// Check for updates based on channel preference + /// [channel] can be 'stable' or 'preview' + static Future checkForUpdate({String channel = 'stable'}) async { try { - final response = await http.get( - Uri.parse(_apiUrl), - headers: {'Accept': 'application/vnd.github.v3+json'}, - ).timeout(const Duration(seconds: 10)); + Map? releaseData; + + if (channel == 'preview') { + // For preview channel, get all releases and find the latest (including prereleases) + final response = await http.get( + Uri.parse('$_allReleasesApiUrl?per_page=10'), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ).timeout(const Duration(seconds: 10)); - if (response.statusCode != 200) { - _log.w('GitHub API returned ${response.statusCode}'); - return null; + if (response.statusCode != 200) { + _log.w('GitHub API returned ${response.statusCode}'); + return null; + } + + final releases = jsonDecode(response.body) as List; + if (releases.isEmpty) { + _log.i('No releases found'); + return null; + } + + // First release is the latest (including prereleases) + releaseData = releases.first as Map; + } else { + // For stable channel, use /latest endpoint (excludes prereleases) + final response = await http.get( + Uri.parse(_latestApiUrl), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ).timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + _log.w('GitHub API returned ${response.statusCode}'); + return null; + } + + releaseData = jsonDecode(response.body) as Map; } - final data = jsonDecode(response.body) as Map; - final tagName = data['tag_name'] as String? ?? ''; + final tagName = releaseData['tag_name'] as String? ?? ''; final latestVersion = tagName.replaceFirst('v', ''); + final isPrerelease = releaseData['prerelease'] as bool? ?? false; if (!_isNewerVersion(latestVersion, AppInfo.version)) { - _log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion)'); + _log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)'); return null; } - final body = data['body'] as String? ?? 'No changelog available'; - final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; - final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now(); + final body = releaseData['body'] as String? ?? 'No changelog available'; + 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'); @@ -87,7 +119,7 @@ class UpdateChecker { String? arm32Url; String? universalUrl; - final assets = data['assets'] as List? ?? []; + final assets = releaseData['assets'] as List? ?? []; for (final asset in assets) { final name = (asset['name'] as String? ?? '').toLowerCase(); if (name.endsWith('.apk')) { @@ -117,7 +149,7 @@ class UpdateChecker { apkUrl = universalUrl ?? arm64Url ?? arm32Url; } - _log.i('Update available: $latestVersion, APK URL: $apkUrl'); + _log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl'); return UpdateInfo( version: latestVersion, @@ -125,6 +157,7 @@ class UpdateChecker { downloadUrl: htmlUrl, apkDownloadUrl: apkUrl, publishedAt: publishedAt, + isPrerelease: isPrerelease, ); } catch (e) { _log.e('Error checking for updates: $e'); diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 3dff5566..9d08b720 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -43,6 +43,7 @@ class AppTheme { static ThemeData dark({ ColorScheme? dynamicScheme, Color? seedColor, + bool isAmoled = false, }) { final scheme = dynamicScheme ?? ColorScheme.fromSeed( @@ -53,7 +54,8 @@ class AppTheme { return ThemeData( useMaterial3: true, colorScheme: scheme, - appBarTheme: _appBarTheme(scheme), + scaffoldBackgroundColor: isAmoled ? Colors.black : null, + appBarTheme: _appBarTheme(scheme, isAmoled: isAmoled), cardTheme: _cardTheme(scheme), elevatedButtonTheme: _elevatedButtonTheme(scheme), filledButtonTheme: _filledButtonTheme(scheme), @@ -63,7 +65,7 @@ class AppTheme { inputDecorationTheme: _inputDecorationTheme(scheme), listTileTheme: _listTileTheme(scheme), dialogTheme: _dialogTheme(scheme), - navigationBarTheme: _navigationBarTheme(scheme), + navigationBarTheme: _navigationBarTheme(scheme, isAmoled: isAmoled), snackBarTheme: _snackBarTheme(scheme), progressIndicatorTheme: _progressIndicatorTheme(scheme), switchTheme: _switchTheme(scheme), @@ -73,12 +75,12 @@ class AppTheme { } /// AppBar theme - static AppBarTheme _appBarTheme(ColorScheme scheme) => AppBarTheme( + static AppBarTheme _appBarTheme(ColorScheme scheme, {bool isAmoled = false}) => AppBarTheme( elevation: 0, - scrolledUnderElevation: 3, - backgroundColor: scheme.surface, + scrolledUnderElevation: isAmoled ? 0 : 3, + backgroundColor: isAmoled ? Colors.black : scheme.surface, foregroundColor: scheme.onSurface, - surfaceTintColor: scheme.surfaceTint, + surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint, centerTitle: true, titleTextStyle: TextStyle( color: scheme.onSurface, @@ -180,12 +182,12 @@ class AppTheme { ); /// Navigation bar theme - static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme) => + static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme, {bool isAmoled = false}) => NavigationBarThemeData( elevation: 0, - backgroundColor: scheme.surfaceContainer, + backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer, indicatorColor: scheme.secondaryContainer, - surfaceTintColor: scheme.surfaceTint, + surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint, labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, ); diff --git a/lib/theme/dynamic_color_wrapper.dart b/lib/theme/dynamic_color_wrapper.dart index 43c829af..b90569b0 100644 --- a/lib/theme/dynamic_color_wrapper.dart +++ b/lib/theme/dynamic_color_wrapper.dart @@ -40,12 +40,32 @@ class DynamicColorWrapper extends ConsumerWidget { ); } + // Apply AMOLED mode if enabled (pure black background) + if (themeSettings.useAmoled) { + darkScheme = _applyAmoledColors(darkScheme); + } + // Build themes final lightTheme = AppTheme.light(dynamicScheme: lightScheme); - final darkTheme = AppTheme.dark(dynamicScheme: darkScheme); + final darkTheme = AppTheme.dark(dynamicScheme: darkScheme, isAmoled: themeSettings.useAmoled); return builder(lightTheme, darkTheme, themeSettings.themeMode); }, ); } + + /// Apply AMOLED colors - pure black background with adjusted surface colors + ColorScheme _applyAmoledColors(ColorScheme scheme) { + return scheme.copyWith( + surface: Colors.black, + onSurface: Colors.white, + surfaceContainerLowest: Colors.black, + surfaceContainerLow: const Color(0xFF0A0A0A), + surfaceContainer: const Color(0xFF121212), + surfaceContainerHigh: const Color(0xFF1A1A1A), + surfaceContainerHighest: const Color(0xFF222222), + inverseSurface: Colors.white, + onInverseSurface: Colors.black, + ); + } } diff --git a/pubspec.yaml b/pubspec.yaml index c8062a57..e371a96b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 2.1.0-preview+39 +version: 2.1.0-preview2+40 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index aaac68d7..6d6715e7 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 2.1.0-preview+39 +version: 2.1.0-preview2+40 environment: sdk: ^3.10.0