diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2f946c..47478ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,26 @@ # Changelog -## [1.5.6] - 2026-01-02 +## [1.5.7] - 2026-01-02 + +### Added +- **Manual Quality Selection**: New option to choose audio quality before each download + - Toggle "Ask Before Download" in Download Settings + - When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading + - Works for both single track and batch downloads +- **Live Search**: Search results appear as you type with 400ms debounce + - Animated search bar moves from center to top when typing + - Keyboard stays open during transition + - Back button navigates through search history (album → artist → idle) + - Clear button to reset search + - URLs still require manual submit +- **Search Tab Header**: Added collapsing app bar to centered search view for consistent UI across all tabs +- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen ### Fixed - **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`) - Users on hotfix versions now properly receive update notifications - Handles `-hotfix`, `-beta`, `-rc` suffixes correctly - -### Added -- **Search Tab Header**: Added collapsing app bar to centered search view for consistent UI across all tabs +- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners ### Changed - **Settings UI Redesign**: New Android-style grouped settings with connected cards diff --git a/README.md b/README.md index 91333ad3..a5c027c3 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, ma ## Disclaimer +> **📱 iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them! + This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement. **SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service. diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 402bbff5..02ced4f4 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 = '1.5.6'; - static const String buildNumber = '23'; + static const String version = '1.5.7'; + static const String buildNumber = '24'; static const String fullVersion = '$version+$buildNumber'; static const String appName = 'SpotiFLAC'; diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index 6ccf2e49..3273e8ea 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -22,6 +22,7 @@ class DownloadItem { final String? filePath; final String? error; final DateTime createdAt; + final String? qualityOverride; // Override quality for this specific download const DownloadItem({ required this.id, @@ -32,6 +33,7 @@ class DownloadItem { this.filePath, this.error, required this.createdAt, + this.qualityOverride, }); DownloadItem copyWith({ @@ -43,6 +45,7 @@ class DownloadItem { String? filePath, String? error, DateTime? createdAt, + String? qualityOverride, }) { return DownloadItem( id: id ?? this.id, @@ -53,6 +56,7 @@ class DownloadItem { filePath: filePath ?? this.filePath, error: error ?? this.error, createdAt: createdAt ?? this.createdAt, + qualityOverride: qualityOverride ?? this.qualityOverride, ); } diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart index 0f6ad94b..2dbdec62 100644 --- a/lib/models/download_item.g.dart +++ b/lib/models/download_item.g.dart @@ -17,6 +17,7 @@ DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem( filePath: json['filePath'] as String?, error: json['error'] as String?, createdAt: DateTime.parse(json['createdAt'] as String), + qualityOverride: json['qualityOverride'] as String?, ); Map _$DownloadItemToJson(DownloadItem instance) => @@ -29,6 +30,7 @@ Map _$DownloadItemToJson(DownloadItem instance) => 'filePath': instance.filePath, 'error': instance.error, 'createdAt': instance.createdAt.toIso8601String(), + 'qualityOverride': instance.qualityOverride, }; const _$DownloadStatusEnumMap = { diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 6c8d8c66..a3a8e150 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -18,6 +18,7 @@ class AppSettings { final String folderOrganization; // none, artist, album, artist_album final bool convertLyricsToRomaji; // Convert Japanese lyrics to romaji final String historyViewMode; // list, grid + final bool askQualityBeforeDownload; // Show quality picker before each download const AppSettings({ this.defaultService = 'tidal', @@ -34,6 +35,7 @@ class AppSettings { this.folderOrganization = 'none', // Default: no folder organization this.convertLyricsToRomaji = false, // Default: keep original Japanese this.historyViewMode = 'grid', // Default: grid view + this.askQualityBeforeDownload = false, // Default: use preset quality }); AppSettings copyWith({ @@ -51,6 +53,7 @@ class AppSettings { String? folderOrganization, bool? convertLyricsToRomaji, String? historyViewMode, + bool? askQualityBeforeDownload, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -67,6 +70,7 @@ class AppSettings { folderOrganization: folderOrganization ?? this.folderOrganization, convertLyricsToRomaji: convertLyricsToRomaji ?? this.convertLyricsToRomaji, historyViewMode: historyViewMode ?? this.historyViewMode, + askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 7821fbf0..35d474cc 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -20,7 +20,8 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, folderOrganization: json['folderOrganization'] as String? ?? 'none', convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false, - historyViewMode: json['historyViewMode'] as String? ?? 'list', + historyViewMode: json['historyViewMode'] as String? ?? 'grid', + askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? false, ); Map _$AppSettingsToJson(AppSettings instance) => @@ -39,4 +40,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'folderOrganization': instance.folderOrganization, 'convertLyricsToRomaji': instance.convertLyricsToRomaji, 'historyViewMode': instance.historyViewMode, + 'askQualityBeforeDownload': instance.askQualityBeforeDownload, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 1fd5cf9f..71601121 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -446,7 +446,7 @@ class DownloadQueueNotifier extends Notifier { ); } - String addToQueue(Track track, String service) { + String addToQueue(Track track, String service, {String? qualityOverride}) { // Sync settings before adding to queue final settings = ref.read(settingsProvider); updateSettings(settings); @@ -457,6 +457,7 @@ class DownloadQueueNotifier extends Notifier { track: track, service: service, createdAt: DateTime.now(), + qualityOverride: qualityOverride, ); state = state.copyWith(items: [...state.items, item]); @@ -469,7 +470,7 @@ class DownloadQueueNotifier extends Notifier { return id; } - void addMultipleToQueue(List tracks, String service) { + void addMultipleToQueue(List tracks, String service, {String? qualityOverride}) { // Sync settings before adding to queue final settings = ref.read(settingsProvider); updateSettings(settings); @@ -481,6 +482,7 @@ class DownloadQueueNotifier extends Notifier { track: track, service: service, createdAt: DateTime.now(), + qualityOverride: qualityOverride, ); }).toList(); @@ -814,11 +816,14 @@ class DownloadQueueNotifier extends Notifier { final settings = ref.read(settingsProvider); final outputDir = await _buildOutputDir(item.track, settings.folderOrganization); + // Use quality override if set, otherwise use default from settings + final quality = item.qualityOverride ?? state.audioQuality; + Map result; if (state.autoFallback) { _log.d('Using auto-fallback mode'); - _log.d('Quality: ${state.audioQuality}'); + _log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}'); _log.d('Output dir: $outputDir'); result = await PlatformBridge.downloadWithFallback( isrc: item.track.isrc ?? '', @@ -830,7 +835,7 @@ class DownloadQueueNotifier extends Notifier { coverUrl: item.track.coverUrl, outputDir: outputDir, filenameFormat: state.filenameFormat, - quality: state.audioQuality, + quality: quality, trackNumber: item.track.trackNumber ?? 1, discNumber: item.track.discNumber ?? 1, releaseDate: item.track.releaseDate, @@ -850,7 +855,7 @@ class DownloadQueueNotifier extends Notifier { coverUrl: item.track.coverUrl, outputDir: outputDir, filenameFormat: state.filenameFormat, - quality: state.audioQuality, + quality: quality, trackNumber: item.track.trackNumber ?? 1, discNumber: item.track.discNumber ?? 1, releaseDate: item.track.releaseDate, @@ -927,7 +932,7 @@ class DownloadQueueNotifier extends Notifier { discNumber: item.track.discNumber, duration: item.track.duration, releaseDate: item.track.releaseDate, - quality: state.audioQuality, + quality: quality, ), ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 31e3adc7..cb54f9ea 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -98,6 +98,11 @@ class SettingsNotifier extends Notifier { state = state.copyWith(historyViewMode: mode); _saveSettings(); } + + void setAskQualityBeforeDownload(bool enabled) { + state = state.copyWith(askQualityBeforeDownload: enabled); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 36a64519..e49bb20a 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -219,7 +219,7 @@ class _HomeScreenState extends ConsumerState { width: 80, height: 80, fit: BoxFit.cover, - placeholder: (_, __) => Container( + placeholder: (_, _) => Container( width: 80, height: 80, color: colorScheme.surfaceContainerHighest, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index b5423b6f..645b0554 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -15,21 +16,108 @@ class HomeTab extends ConsumerStatefulWidget { class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { final _urlController = TextEditingController(); + Timer? _debounce; + bool _isTyping = false; + final FocusNode _searchFocusNode = FocusNode(); @override bool get wantKeepAlive => true; + @override - void dispose() { _urlController.dispose(); super.dispose(); } + void initState() { + super.initState(); + _urlController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _debounce?.cancel(); + _urlController.removeListener(_onSearchChanged); + _urlController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + /// Handle back button - returns true if handled, false to let system handle + bool _handleBack() { + final trackState = ref.read(trackProvider); + + // If we have previous state, go back to it + if (trackState.canGoBack) { + ref.read(trackProvider.notifier).goBack(); + return true; + } + + // If we're in results view but no previous state, clear and go to idle + if (_isTyping || trackState.hasContent) { + _clearAndRefresh(); + return true; + } + + // Let system handle (exit app) + return false; + } + + void _onSearchChanged() { + final text = _urlController.text.trim(); + final wasFocused = _searchFocusNode.hasFocus; + + // Update typing state immediately for UI transition + if (text.isNotEmpty && !_isTyping) { + setState(() => _isTyping = true); + } else if (text.isEmpty && _isTyping) { + setState(() => _isTyping = false); + ref.read(trackProvider.notifier).clear(); + return; + } + + // Re-request focus after rebuild if it was focused + if (wasFocused) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _searchFocusNode.requestFocus(); + } + }); + } + + // Don't live search for URLs - wait for submit + if (text.startsWith('http') || text.startsWith('spotify:')) { + _debounce?.cancel(); + return; + } + + // Debounce search queries + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 400), () { + if (text.length >= 2) { + _performSearch(text); + } + }); + } + + Future _performSearch(String query) async { + await ref.read(trackProvider.notifier).search(query); + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + } Future _pasteFromClipboard() async { final data = await Clipboard.getData(Clipboard.kTextPlain); - if (data?.text != null) _urlController.text = data!.text!; + if (data?.text != null) { + _urlController.text = data!.text!; + // For URLs, trigger fetch immediately after paste + final text = data.text!.trim(); + if (text.startsWith('http') || text.startsWith('spotify:')) { + _fetchMetadata(); + } + } } Future _clearAndRefresh() async { + _debounce?.cancel(); _urlController.clear(); + _searchFocusNode.unfocus(); + setState(() => _isTyping = false); ref.read(trackProvider.notifier).clear(); - await Future.delayed(const Duration(milliseconds: 300)); } Future _fetchMetadata() async { @@ -48,8 +136,16 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (index >= 0 && index < trackState.tracks.length) { final track = trackState.tracks[index]; final settings = ref.read(settingsProvider); - ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); + + if (settings.askQualityBeforeDownload) { + _showQualityPicker(context, (quality) { + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); + }); + } else { + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); + } } } @@ -57,13 +153,59 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final trackState = ref.read(trackProvider); if (trackState.tracks.isEmpty) return; final settings = ref.read(settingsProvider); - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue'))); + + if (settings.askQualityBeforeDownload) { + _showQualityPicker(context, (quality) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue'))); + }); + } else { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue'))); + } + } + + void _showQualityPicker(BuildContext context, void Function(String quality) onSelect) { + final colorScheme = Theme.of(context).colorScheme; + 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: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + ), + _QualityPickerOption( + title: 'FLAC Lossless', + subtitle: '16-bit / 44.1kHz', + onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }, + ), + _QualityPickerOption( + title: 'Hi-Res FLAC', + subtitle: '24-bit / up to 96kHz', + onTap: () { Navigator.pop(context); onSelect('HI_RES'); }, + ), + _QualityPickerOption( + title: 'Hi-Res FLAC Max', + subtitle: '24-bit / up to 192kHz', + onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); } bool get _hasResults { final trackState = ref.watch(trackProvider); - return trackState.tracks.isNotEmpty || trackState.artistAlbums != null || trackState.isLoading; + // Show results view when typing, loading, or has results + return _isTyping || trackState.tracks.isNotEmpty || trackState.artistAlbums != null || trackState.isLoading; } @override @@ -72,101 +214,124 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final trackState = ref.watch(trackProvider); final colorScheme = Theme.of(context).colorScheme; final hasResults = _hasResults; - - return Scaffold( - body: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: hasResults - ? _buildResultsView(trackState, colorScheme) - : _buildCenteredSearch(colorScheme), - ), - ); - } - - // Centered search view when no results - Widget _buildCenteredSearch(ColorScheme colorScheme) { + final screenHeight = MediaQuery.of(context).size.height; final historyItems = ref.watch(downloadHistoryProvider).items; - - return CustomScrollView( - key: const ValueKey('centered'), - slivers: [ - // Collapsing App Bar - same style as other tabs - SliverAppBar( - expandedHeight: 130, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - automaticallyImplyLeading: false, - flexibleSpace: FlexibleSpaceBar( - expandedTitleScale: 1.3, - titlePadding: const EdgeInsets.only(left: 24, bottom: 16), - title: Text( - 'Search', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (!_handleBack()) { + Navigator.of(context).maybePop(); + } + }, + child: Scaffold( + body: CustomScrollView( + slivers: [ + // App Bar - always present + SliverAppBar( + expandedHeight: 130, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + automaticallyImplyLeading: false, + flexibleSpace: FlexibleSpaceBar( + expandedTitleScale: 1.3, + titlePadding: const EdgeInsets.only(left: 24, bottom: 16), + title: Text( + 'Search', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), ), ), - ), - ), - - // Content - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 24), - // App icon/logo - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.3), - shape: BoxShape.circle, - ), - child: Icon(Icons.music_note, size: 48, color: colorScheme.primary), - ), - const SizedBox(height: 24), - Text( - 'Search Music', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Paste a Spotify link or search by name', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 32), - // Search bar - _buildSearchBar(colorScheme), - const SizedBox(height: 12), - // Helper text - if (!ref.watch(settingsProvider).hasSearchedBefore) - Text( - 'Supports: Track, Album, Playlist, Artist URLs', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - // Recent downloads - compact horizontal scroll - if (historyItems.isNotEmpty) ...[ - const SizedBox(height: 32), - _buildRecentDownloads(historyItems, colorScheme), - ], - ], + + // Idle content (logo, title) - always in tree, animated size + SliverToBoxAdapter( + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + child: hasResults + ? const SizedBox.shrink() + : Column( + children: [ + SizedBox(height: screenHeight * 0.06), + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: Icon(Icons.music_note, size: 48, color: colorScheme.primary), + ), + const SizedBox(height: 16), + Text( + 'Search Music', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Paste a Spotify link or search by name', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), ), - ), + + // Search bar - always present at same position in tree + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(16, hasResults ? 8 : 32, 16, hasResults ? 8 : 16), + child: _buildSearchBar(colorScheme), + ), + ), + + // Idle content below search bar - always in tree + SliverToBoxAdapter( + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + child: hasResults + ? const SizedBox.shrink() + : Column( + children: [ + if (!ref.watch(settingsProvider).hasSearchedBefore) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Supports: Track, Album, Playlist, Artist URLs', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + if (historyItems.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 24), + child: _buildRecentDownloads(historyItems, colorScheme), + ), + ], + ), + ), + ), + + // Results content - always in tree + ..._buildResultsContent(trackState, colorScheme, hasResults), + ], ), - ], + ), ); } @@ -247,93 +412,63 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } - // Results view with search bar at top - Widget _buildResultsView(TrackState trackState, ColorScheme colorScheme) { - return RefreshIndicator( - key: const ValueKey('results'), - onRefresh: _clearAndRefresh, - displacement: 100, - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - // Collapsing App Bar - SliverAppBar( - expandedHeight: 130, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - automaticallyImplyLeading: false, - flexibleSpace: FlexibleSpaceBar( - expandedTitleScale: 1.3, - titlePadding: const EdgeInsets.only(left: 24, bottom: 16), - title: Text( - 'Search', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ), - ), + // Results content slivers (without app bar and search bar) + List _buildResultsContent(TrackState trackState, ColorScheme colorScheme, bool hasResults) { + // Return empty slivers when no results to keep tree structure stable + if (!hasResults) { + return [const SliverToBoxAdapter(child: SizedBox.shrink())]; + } + + return [ + // Error message + if (trackState.error != null) + SliverToBoxAdapter(child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)), + )), - // Search bar at top - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: _buildSearchBar(colorScheme), - ), - ), + // Loading indicator + if (trackState.isLoading) + const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())), - // Error message - if (trackState.error != null) - SliverToBoxAdapter(child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)), - )), + // Album/Playlist header + if (trackState.albumName != null || trackState.playlistName != null) + SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)), - // Loading indicator - if (trackState.isLoading) - const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())), + // Artist header and discography + if (trackState.artistName != null && trackState.artistAlbums != null) + SliverToBoxAdapter(child: _buildArtistHeader(trackState, colorScheme)), - // Album/Playlist header - if (trackState.albumName != null || trackState.playlistName != null) - SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)), + if (trackState.artistAlbums != null && trackState.artistAlbums!.isNotEmpty) + SliverToBoxAdapter(child: _buildArtistDiscography(trackState, colorScheme)), - // Artist header and discography - if (trackState.artistName != null && trackState.artistAlbums != null) - SliverToBoxAdapter(child: _buildArtistHeader(trackState, colorScheme)), + // Download All button + if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null && trackState.artistAlbums == null) + SliverToBoxAdapter(child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download), + label: Text('Download All (${trackState.tracks.length})'), + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))), + )), - if (trackState.artistAlbums != null && trackState.artistAlbums!.isNotEmpty) - SliverToBoxAdapter(child: _buildArtistDiscography(trackState, colorScheme)), + // Track list + SliverList(delegate: SliverChildBuilderDelegate( + (context, index) => _buildTrackTile(index, colorScheme), + childCount: trackState.tracks.length, + )), - // Download All button - if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null && trackState.artistAlbums == null) - SliverToBoxAdapter(child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download), - label: Text('Download All (${trackState.tracks.length})'), - style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))), - )), - - // Track list - SliverList(delegate: SliverChildBuilderDelegate( - (context, index) => _buildTrackTile(index, colorScheme), - childCount: trackState.tracks.length, - )), - - // Bottom padding - const SliverToBoxAdapter(child: SizedBox(height: 16)), - ], - ), - ); + // Bottom padding + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ]; } Widget _buildSearchBar(ColorScheme colorScheme) { + final hasText = _urlController.text.isNotEmpty; + return TextField( controller: _urlController, + focusNode: _searchFocusNode, + autofocus: false, decoration: InputDecoration( hintText: 'Paste Spotify URL or search...', filled: true, @@ -350,30 +485,22 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient borderRadius: BorderRadius.circular(28), borderSide: BorderSide(color: colorScheme.primary, width: 2), ), - prefixIcon: const Icon(Icons.link), + prefixIcon: const Icon(Icons.search), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: const Icon(Icons.paste), - onPressed: _pasteFromClipboard, - tooltip: 'Paste', - ), - Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton( - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.primary, - borderRadius: BorderRadius.circular(12), - ), - child: Icon(Icons.search, color: colorScheme.onPrimary, size: 20), - ), - onPressed: _fetchMetadata, - tooltip: 'Search', + if (hasText) + IconButton( + icon: const Icon(Icons.clear), + onPressed: _clearAndRefresh, + tooltip: 'Clear', + ) + else + IconButton( + icon: const Icon(Icons.paste), + onPressed: _pasteFromClipboard, + tooltip: 'Paste', ), - ), ], ), contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), @@ -582,3 +709,22 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } } + +class _QualityPickerOption extends StatelessWidget { + final String title; + final String subtitle; + final VoidCallback onTap; + const _QualityPickerOption({required this.title, required this.subtitle, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Icon(Icons.music_note, color: colorScheme.primary), + title: Text(title), + subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)), + onTap: onTap, + ); + } +} diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index c642ca6f..1c233ff0 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -73,25 +73,34 @@ class DownloadSettingsPage extends ConsumerWidget { SliverToBoxAdapter( child: SettingsGroup( children: [ - _QualityOption( - title: 'FLAC Lossless', - subtitle: '16-bit / 44.1kHz', - isSelected: settings.audioQuality == 'LOSSLESS', - onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS'), - ), - _QualityOption( - title: 'Hi-Res FLAC', - subtitle: '24-bit / up to 96kHz', - isSelected: settings.audioQuality == 'HI_RES', - onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES'), - ), - _QualityOption( - title: 'Hi-Res FLAC Max', - subtitle: '24-bit / up to 192kHz', - isSelected: settings.audioQuality == 'HI_RES_LOSSLESS', - onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS'), - showDivider: false, + SettingsSwitchItem( + icon: Icons.tune, + title: 'Ask Before Download', + subtitle: 'Choose quality for each download', + value: settings.askQualityBeforeDownload, + onChanged: (value) => ref.read(settingsProvider.notifier).setAskQualityBeforeDownload(value), ), + if (!settings.askQualityBeforeDownload) ...[ + _QualityOption( + title: 'FLAC Lossless', + subtitle: '16-bit / 44.1kHz', + isSelected: settings.audioQuality == 'LOSSLESS', + onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS'), + ), + _QualityOption( + title: 'Hi-Res FLAC', + subtitle: '24-bit / up to 96kHz', + isSelected: settings.audioQuality == 'HI_RES', + onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES'), + ), + _QualityOption( + title: 'Hi-Res FLAC Max', + subtitle: '24-bit / up to 192kHz', + isSelected: settings.audioQuality == 'HI_RES_LOSSLESS', + onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS'), + showDivider: false, + ), + ], ], ), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index b8f237e2..6bd8f184 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -194,7 +194,7 @@ class SettingsScreen extends ConsumerWidget { builder: (context) => AlertDialog( title: Row( children: [ - Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)), + Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)), const SizedBox(width: 12), Text(AppInfo.appName), ], diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 8134049a..5fdec025 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:open_filex/open_filex.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -854,7 +855,7 @@ class _TrackMetadataScreenState extends ConsumerState { title: const Text('Share'), onTap: () { Navigator.pop(context); - // TODO: Implement share + _shareFile(context); }, ), ListTile( @@ -926,6 +927,23 @@ class _TrackMetadataScreenState extends ConsumerState { ); } + Future _shareFile(BuildContext context) async { + final file = File(item.filePath); + if (!await file.exists()) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('File not found')), + ); + } + return; + } + + await Share.shareXFiles( + [XFile(item.filePath)], + text: '${item.trackName} - ${item.artistName}', + ); + } + String _formatFullDate(DateTime date) { final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 31bc2583..fa617c41 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -8,7 +8,7 @@ final log = Logger( errorMethodCount: 5, lineLength: 80, colors: true, - printEmojis: true, + printEmojis: false, dateTimeFormat: DateTimeFormat.none, ), level: Level.debug, diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index 16cf587b..4782c1b9 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -31,9 +31,12 @@ class SettingsGroup extends StatelessWidget { borderRadius: BorderRadius.circular(20), ), clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - children: children, + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: children, + ), ), ); } @@ -67,6 +70,8 @@ class SettingsItem extends StatelessWidget { children: [ InkWell( onTap: onTap, + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: colorScheme.primary.withValues(alpha: 0.08), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( @@ -147,6 +152,8 @@ class SettingsSwitchItem extends StatelessWidget { children: [ InkWell( onTap: onChanged != null ? () => onChanged!(!value) : null, + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: colorScheme.primary.withValues(alpha: 0.08), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row( diff --git a/pubspec.yaml b/pubspec.yaml index 94dd3d9a..23fbaad7 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: 1.5.6+23 +version: 1.5.7+24 environment: sdk: ^3.10.0