mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-28 10:31:27 +02:00
Release 2.1.0
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'concurrentDownloads': instance.concurrentDownloads,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
'updateChannel': instance.updateChannel,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -686,20 +686,37 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<DownloadQueueState> {
|
||||
|
||||
_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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
);
|
||||
|
||||
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
|
||||
|
||||
@@ -96,6 +96,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUpdateChannel(String channel) {
|
||||
state = state.copyWith(updateChannel: channel);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHasSearchedBefore() {
|
||||
if (!state.hasSearchedBefore) {
|
||||
state = state.copyWith(hasSearchedBefore: true);
|
||||
|
||||
@@ -24,11 +24,13 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
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<ThemeSettings> {
|
||||
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<ThemeSettings> {
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
/// Enable or disable AMOLED mode (pure black background)
|
||||
Future<void> 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;
|
||||
|
||||
@@ -302,8 +302,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
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<AlbumScreen> {
|
||||
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<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
+107
-48
@@ -170,8 +170,8 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> 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;
|
||||
|
||||
@@ -85,7 +85,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<String> 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))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,17 +759,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
});
|
||||
|
||||
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<TrackMetadataScreen> {
|
||||
}
|
||||
} 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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<String> _getDeviceArch() async {
|
||||
if (!Platform.isAndroid) return 'unknown';
|
||||
@@ -55,30 +58,59 @@ class UpdateChecker {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<UpdateInfo?> checkForUpdate() async {
|
||||
/// Check for updates based on channel preference
|
||||
/// [channel] can be 'stable' or 'preview'
|
||||
static Future<UpdateInfo?> 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<String, dynamic>? 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<dynamic>;
|
||||
if (releases.isEmpty) {
|
||||
_log.i('No releases found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// First release is the latest (including prereleases)
|
||||
releaseData = releases.first as Map<String, dynamic>;
|
||||
} 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<String, dynamic>;
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
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<dynamic>? ?? [];
|
||||
final assets = releaseData['assets'] as List<dynamic>? ?? [];
|
||||
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');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user