diff --git a/.gitignore b/.gitignore index 8a5e5209..72bdf220 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,9 @@ android/app/libs/gobackend-sources.jar # Extension folder extension/ + +# Agent instructions AGENTS.md + +# Temp/misc nul -/extension diff --git a/CHANGELOG.md b/CHANGELOG.md index 9999deda..a2cbf055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,13 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int - One-tap install, update, and uninstall - Offline cache for browsing without internet +#### Spotify Web Extension + +- Available in Extension Store - install and enable in Settings > Extensions +- Metadata provider using Spotify's internal web player API +- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists +- Useful when official Spotify API is rate-limited or unavailable + #### Extension Capabilities - **Custom Search Providers** @@ -128,8 +135,31 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int - Qobuz: Both APIs requested simultaneously, first success wins - Significantly reduces download URL fetch time +### UI/UX Improvements + +- **Swipeable History Filters**: History tab now supports swipe gestures between All, Albums, and Singles filters + + - Swipe left/right to switch between filter tabs + - Filter chips sync with swipe position + - Smooth edge-to-edge transition: swipe past Singles to go to Store, swipe past All to go to Home + - Natural gesture feel - drag connects to parent navigation + + - **Improved File Open Intent**: Play button in History now correctly opens music players only + - Added proper MIME type (`audio/flac`, `audio/mpeg`, etc.) when opening downloaded files + - Prevents system from showing unrelated apps in the "Open with" dialog + ### Fixed +- **Fixed Tab Edge Overscroll**: Home and Settings tabs now stop at edges instead of bouncing into empty space + +- **Fixed Extension Duplicate Load Error**: Extension loading now silently skips already-loaded extensions instead of throwing error + +- **Fixed Settings Item Highlight on Swipe**: Settings items no longer highlight when swiping at page edge + +- **Fixed Keyboard Appearing on Tab Switch**: Keyboard now auto-dismisses when swiping between tabs + +- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from Deezer/Spotify selector in Options + - **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings - Added `PopScope` with `canPop: true` to all settings pages diff --git a/README.md b/README.md index a77e2ffd..f702b75a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) -[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/) +[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
@@ -23,59 +23,58 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account

-## Metadata Source +## Search Source -SpotiFLAC supports two metadata sources for searching tracks: +SpotiFLAC supports two search sources: -| Source | Pros | Cons | -|--------|------|------| -| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog | -| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting | +| Source | Setup | +|--------|-------| +| **Deezer** (Default) | No setup required | +| **Spotify** | Install **Spotify Web** extension from the Store, or use your own [Spotify Developer](https://developer.spotify.com) Client ID & Secret in Settings | -### Using Spotify -To use Spotify as your search source without hitting rate limits: -1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com) -2. Create an app to get your Client ID and Client Secret -3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials** -4. Enter your Client ID and Secret -5. Change **Search Source** to Spotify +## Extensions -## Extensions (Alpha) - -> **Alpha Feature**: Extensions are now available in alpha. Some features may be unstable or change in future releases. - -SpotiFLAC supports extensions to add custom metadata and download providers. Extensions are written in JavaScript and run in a secure sandbox. - -### Features -- **Metadata Providers**: Add new sources for track/album/artist search -- **Download Providers**: Add new sources for audio downloads -- **Custom Settings**: Extensions can have user-configurable settings -- **Provider Priority**: Set the order in which providers are tried +Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently. ### Installing Extensions -1. Download a `.spotiflac-ext` file -2. Go to **Settings > Extensions** -3. Tap **Install Extension** and select the file +1. Go to **Store** tab in the app +2. Browse and install extensions with one tap +3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions** 4. Configure extension settings if needed 5. Set provider priority in **Settings > Extensions > Provider Priority** ### Developing Extensions -Want to create your own extension? Check out the [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md) for complete documentation. - -### Example Extensions -Sample extensions are available in the [docs/extensions_example](docs/extensions_example) folder: +Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation. ## Other project ### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC) Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux +## FAQ + +**Q: Why is my download failing with "Song not found"?** +A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store. + +**Q: Why are some tracks downloading in lower quality?** +A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality. + +**Q: Can I download my Spotify playlists?** +A: Yes! Just paste the Spotify playlist URL in the search bar. The app will fetch all tracks and queue them for download. + +**Q: Why do I need to grant storage permission?** +A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions. + +**Q: How do I download Daily Mix or Discover Weekly?** +A: Install the **Spotify Web** extension from the Store. This extension can access personalized playlists that aren't available through the public API. + +**Q: Is this app safe?** +A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README). + [![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20Me-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/zarzet) ## 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/assets/images/1.jpg b/assets/images/1.jpg index b5d15588..6413c6ff 100644 Binary files a/assets/images/1.jpg and b/assets/images/1.jpg differ diff --git a/assets/images/2.jpg b/assets/images/2.jpg index 156af83b..e5b487ff 100644 Binary files a/assets/images/2.jpg and b/assets/images/2.jpg differ diff --git a/assets/images/3.jpg b/assets/images/3.jpg index 6c5e3ee8..2de6e443 100644 Binary files a/assets/images/3.jpg and b/assets/images/3.jpg differ diff --git a/assets/images/4.jpg b/assets/images/4.jpg index 62755089..2b5b5888 100644 Binary files a/assets/images/4.jpg and b/assets/images/4.jpg differ diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 826569ad..df64b8dc 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -456,9 +456,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx return nil, fmt.Errorf("Extension is missing index.js file") } - // Check if extension already loaded - skip if already exists (for directory loading on startup) - if _, exists := m.extensions[manifest.Name]; exists { - return nil, fmt.Errorf("Extension '%s' is already loaded", manifest.DisplayName) + // Check if extension already loaded - skip silently (for directory loading on startup) + if existing, exists := m.extensions[manifest.Name]; exists { + GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName) + return existing, nil } // Create data directory for extension diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index cb01b712..43ed4e34 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -547,6 +547,47 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + // Extension Store + case "initExtensionStore": + let args = call.arguments as! [String: Any] + let cacheDir = args["cache_dir"] as! String + GobackendInitExtensionStoreJSON(cacheDir, &error) + if let error = error { throw error } + return nil + + case "getStoreExtensions": + let args = call.arguments as! [String: Any] + let forceRefresh = args["force_refresh"] as? Bool ?? false + let response = GobackendGetStoreExtensionsJSON(forceRefresh, &error) + if let error = error { throw error } + return response + + case "searchStoreExtensions": + let args = call.arguments as! [String: Any] + let query = args["query"] as? String ?? "" + let category = args["category"] as? String ?? "" + let response = GobackendSearchStoreExtensionsJSON(query, category, &error) + if let error = error { throw error } + return response + + case "getStoreCategories": + let response = GobackendGetStoreCategoriesJSON(&error) + if let error = error { throw error } + return response + + case "downloadStoreExtension": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let destDir = args["dest_dir"] as! String + let response = GobackendDownloadStoreExtensionJSON(extensionId, destDir, &error) + if let error = error { throw error } + return response + + case "clearStoreCache": + GobackendClearStoreCacheJSON(&error) + if let error = error { throw error } + return nil + default: throw NSError( domain: "SpotiFLAC", diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index d2b927da..9b7932ed 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -122,6 +122,8 @@ class _MainShellState extends ConsumerState { void _onPageChanged(int index) { if (_currentIndex != index) { setState(() => _currentIndex = index); + // Unfocus any text field when switching tabs to prevent keyboard from appearing + FocusScope.of(context).unfocus(); } } @@ -190,7 +192,11 @@ class _MainShellState extends ConsumerState { // Build tabs and destinations based on settings final tabs = [ const HomeTab(), - const QueueTab(), + QueueTab( + parentPageController: _pageController, + parentPageIndex: 1, + nextPageIndex: showStore ? 2 : 3, + ), if (showStore) const StoreTab(), const SettingsTab(), ]; @@ -254,7 +260,7 @@ class _MainShellState extends ConsumerState { body: PageView( controller: _pageController, onPageChanged: _onPageChanged, - physics: const BouncingScrollPhysics(), + physics: const ClampingScrollPhysics(), children: tabs, ), bottomNavigationBar: NavigationBar( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index c69fc1d1..666fb18d 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -31,7 +31,17 @@ class _GroupedAlbum { } class QueueTab extends ConsumerStatefulWidget { - const QueueTab({super.key}); + final PageController? parentPageController; + final int parentPageIndex; + final int? nextPageIndex; + + const QueueTab({ + super.key, + this.parentPageController, + this.parentPageIndex = 1, + this.nextPageIndex, + }); + @override ConsumerState createState() => _QueueTabState(); } @@ -45,6 +55,46 @@ class _QueueTabState extends ConsumerState { bool _isSelectionMode = false; final Set _selectedIds = {}; + // Filter page controller for swipe between All/Albums/Singles + PageController? _filterPageController; + final List _filterModes = ['all', 'albums', 'singles']; + bool _isPageControllerInitialized = false; + + + + @override + void initState() { + super.initState(); + // Will be initialized in build when we have access to ref + } + + void _initializePageController() { + if (_isPageControllerInitialized) return; + _isPageControllerInitialized = true; + final currentFilter = ref.read(settingsProvider).historyFilterMode; + final initialPage = _filterModes.indexOf(currentFilter).clamp(0, 2); + _filterPageController = PageController(initialPage: initialPage); + } + + @override + void dispose() { + _filterPageController?.dispose(); + super.dispose(); + } + + void _onFilterPageChanged(int index) { + final filterMode = _filterModes[index]; + ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode); + } + + void _animateToFilterPage(int index) { + _filterPageController?.animateToPage( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } + /// Enter selection mode with initial item void _enterSelectionMode(String itemId) { HapticFeedback.mediumImpact(); @@ -90,7 +140,9 @@ class _QueueTabState extends ConsumerState { context: context, builder: (ctx) => AlertDialog( title: const Text('Delete Selected'), - content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from history?\n\nThis will also delete the files from storage.'), + content: Text( + 'Delete $count ${count == 1 ? 'track' : 'tracks'} from history?\n\nThis will also delete the files from storage.', + ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), @@ -110,7 +162,7 @@ class _QueueTabState extends ConsumerState { if (confirmed == true && mounted) { final historyNotifier = ref.read(downloadHistoryProvider.notifier); final items = ref.read(downloadHistoryProvider).items; - + int deletedCount = 0; for (final id in _selectedIds) { final item = items.where((e) => e.id == id).firstOrNull; @@ -126,12 +178,16 @@ class _QueueTabState extends ConsumerState { deletedCount++; } } - + _exitSelectionMode(); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')), + SnackBar( + content: Text( + 'Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}', + ), + ), ); } } @@ -177,69 +233,87 @@ class _QueueTabState extends ConsumerState { await OpenFilex.open(cleanPath, type: mimeType); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Cannot open file: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Cannot open file: $e'))); } } } void _navigateToMetadataScreen(DownloadItem item) { - final historyItem = ref.read(downloadHistoryProvider).items.firstWhere( - (h) => h.filePath == item.filePath, - orElse: () => DownloadHistoryItem( - id: item.id, - trackName: item.track.name, - artistName: item.track.artistName, - albumName: item.track.albumName, - coverUrl: item.track.coverUrl, - filePath: item.filePath ?? '', - downloadedAt: DateTime.now(), - service: item.service, + final historyItem = ref + .read(downloadHistoryProvider) + .items + .firstWhere( + (h) => h.filePath == item.filePath, + orElse: () => DownloadHistoryItem( + id: item.id, + trackName: item.track.name, + artistName: item.track.artistName, + albumName: item.track.albumName, + coverUrl: item.track.coverUrl, + filePath: item.filePath ?? '', + downloadedAt: DateTime.now(), + service: item.service, + ), + ); + + Navigator.push( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(item: historyItem), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), ), ); - - Navigator.push(context, PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: historyItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), - )); } void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) { - Navigator.push(context, PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), - )); + Navigator.push( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(item: item), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ), + ); } /// Filter history items based on current filter mode /// Album = track yang albumnya punya >1 track di history /// Single = track yang albumnya cuma 1 track di history - List _filterHistoryItems(List items, String filterMode) { + List _filterHistoryItems( + List items, + String filterMode, + ) { if (filterMode == 'all') return items; - + // Count tracks per album final albumCounts = {}; for (final item in items) { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; albumCounts[key] = (albumCounts[key] ?? 0) + 1; } - + switch (filterMode) { case 'albums': // Album = more than 1 track from same album in history return items.where((item) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + final key = + '${item.albumName}|${item.albumArtist ?? item.artistName}'; return (albumCounts[key] ?? 0) > 1; }).toList(); case 'singles': // Single = only 1 track from that album in history return items.where((item) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + final key = + '${item.albumName}|${item.albumArtist ?? item.artistName}'; return (albumCounts[key] ?? 0) == 1; }).toList(); default: @@ -255,10 +329,10 @@ class _QueueTabState extends ConsumerState { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; albumCounts[key] = (albumCounts[key] ?? 0) + 1; } - + int albumTracks = 0; int singleTracks = 0; - + for (final item in items) { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; if ((albumCounts[key] ?? 0) > 1) { @@ -267,44 +341,45 @@ class _QueueTabState extends ConsumerState { singleTracks++; } } - + return {'albums': albumTracks, 'singles': singleTracks}; } /// Group history items by album (for Albums filter view) List<_GroupedAlbum> _groupByAlbum(List items) { final albumMap = >{}; - + for (final item in items) { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; albumMap.putIfAbsent(key, () => []).add(item); } - + // Only include albums with more than 1 track - final groupedAlbums = albumMap.entries - .where((e) => e.value.length > 1) - .map((e) { - final tracks = e.value; - // Sort tracks by track number - tracks.sort((a, b) { - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - return aNum.compareTo(bNum); - }); - - return _GroupedAlbum( - albumName: tracks.first.albumName, - artistName: tracks.first.albumArtist ?? tracks.first.artistName, - coverUrl: tracks.first.coverUrl, - tracks: tracks, - latestDownload: tracks.map((t) => t.downloadedAt).reduce((a, b) => a.isAfter(b) ? a : b), - ); - }) - .toList(); - + final groupedAlbums = albumMap.entries.where((e) => e.value.length > 1).map( + (e) { + final tracks = e.value; + // Sort tracks by track number + tracks.sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + return aNum.compareTo(bNum); + }); + + return _GroupedAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverUrl: tracks.first.coverUrl, + tracks: tracks, + latestDownload: tracks + .map((t) => t.downloadedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + ); + }, + ).toList(); + // Sort by latest download groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); - + return groupedAlbums; } @@ -315,51 +390,72 @@ class _QueueTabState extends ConsumerState { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; albumKeys.add(key); } - + // Count albums with more than 1 track int count = 0; for (final key in albumKeys) { - final trackCount = items.where((i) => '${i.albumName}|${i.albumArtist ?? i.artistName}' == key).length; + final trackCount = items + .where( + (i) => '${i.albumName}|${i.albumArtist ?? i.artistName}' == key, + ) + .length; if (trackCount > 1) count++; } return count; } void _navigateToDownloadedAlbum(_GroupedAlbum album) { - Navigator.push(context, PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => DownloadedAlbumScreen( - albumName: album.albumName, - artistName: album.artistName, - coverUrl: album.coverUrl, + Navigator.push( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + DownloadedAlbumScreen( + albumName: album.albumName, + artistName: album.artistName, + coverUrl: album.coverUrl, + ), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), ), - transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), - )); + ); } @override Widget build(BuildContext context) { + // Initialize page controller on first build + _initializePageController(); + final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); - final isProcessing = ref.watch(downloadQueueProvider.select((s) => s.isProcessing)); + final isProcessing = ref.watch( + downloadQueueProvider.select((s) => s.isProcessing), + ); final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused)); - final queuedCount = ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); - final completedCount = ref.watch(downloadQueueProvider.select((s) => s.completedCount)); - final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); - final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode)); - final historyFilterMode = ref.watch(settingsProvider.select((s) => s.historyFilterMode)); + final queuedCount = ref.watch( + downloadQueueProvider.select((s) => s.queuedCount), + ); + final completedCount = ref.watch( + downloadQueueProvider.select((s) => s.completedCount), + ); + final allHistoryItems = ref.watch( + downloadHistoryProvider.select((s) => s.items), + ); + final historyViewMode = ref.watch( + settingsProvider.select((s) => s.historyViewMode), + ); + final historyFilterMode = ref.watch( + settingsProvider.select((s) => s.historyFilterMode), + ); final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - // Filter history items - final historyItems = _filterHistoryItems(allHistoryItems, historyFilterMode); - // Group albums for Albums filter view final groupedAlbums = _groupByAlbum(allHistoryItems); - + // Count for filter chips final counts = _countAlbumsAndSingles(allHistoryItems); - final albumCount = _countUniqueAlbums(allHistoryItems); // Show unique album count + final albumCount = _countUniqueAlbums(allHistoryItems); final singleCount = counts['singles'] ?? 0; final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -373,8 +469,8 @@ class _QueueTabState extends ConsumerState { }, child: Stack( children: [ - CustomScrollView( - slivers: [ + NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ // App Bar - always normal style SliverAppBar( expandedHeight: 120 + topPadding, @@ -388,8 +484,11 @@ class _QueueTabState extends ConsumerState { builder: (context, constraints) { final maxHeight = 120 + topPadding; final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); - + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + return FlexibleSpaceBar( expandedTitleScale: 1.0, titlePadding: const EdgeInsets.only(left: 24, bottom: 16), @@ -406,234 +505,420 @@ class _QueueTabState extends ConsumerState { ), ), - // Pause/Resume controls - if ((isProcessing || queuedCount > 0) && (queueItems.length > 1 || isPaused)) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Card( + // Pause/Resume controls + if ((isProcessing || queuedCount > 0) && + (queueItems.length > 1 || isPaused)) + SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: isPaused ? colorScheme.errorContainer : colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - isPaused ? Icons.pause : Icons.downloading, - color: isPaused ? colorScheme.onErrorContainer : colorScheme.onPrimaryContainer, - ), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isPaused + ? colorScheme.errorContainer + : colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + isPaused ? Icons.pause : Icons.downloading, + color: isPaused + ? colorScheme.onErrorContainer + : colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + isPaused + ? 'Paused' + : '$completedCount/${queueItems.length}', + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + FilledButton.tonal( + onPressed: () => ref + .read(downloadQueueProvider.notifier) + .togglePause(), + child: Text(isPaused ? 'Resume' : 'Pause'), + ), + ], ), - const SizedBox(width: 12), - Expanded( - child: Text( - isPaused ? 'Paused' : '$completedCount/${queueItems.length}', - style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), - ), - ), - FilledButton.tonal( - onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(), - child: Text(isPaused ? 'Resume' : 'Pause'), - ), - ], + ), ), ), ), - ), - ), - // Queue header - if (queueItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text('Downloading (${queueItems.length})', - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - ), - - // Queue list - if (queueItems.isNotEmpty) - SliverList(delegate: SliverChildBuilderDelegate( - (context, index) { - final item = queueItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), - child: _buildQueueItem(context, item, colorScheme), - ); - }, - childCount: queueItems.length, - )), - - // Filter chips (only show when history has items) - if (allHistoryItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _FilterChip( - label: 'All', - count: allHistoryItems.length, - isSelected: historyFilterMode == 'all', - onTap: () => ref.read(settingsProvider.notifier).setHistoryFilterMode('all'), + // Queue header + if (queueItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + 'Downloading (${queueItems.length})', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, ), - const SizedBox(width: 8), - _FilterChip( - label: 'Albums', - count: albumCount, - isSelected: historyFilterMode == 'albums', - onTap: () => ref.read(settingsProvider.notifier).setHistoryFilterMode('albums'), - ), - const SizedBox(width: 8), - _FilterChip( - label: 'Singles', - count: singleCount, - isSelected: historyFilterMode == 'singles', - onTap: () => ref.read(settingsProvider.notifier).setHistoryFilterMode('singles'), - ), - ], + ), ), ), - ), - ), - // History section header - if (historyItems.isNotEmpty && queueItems.isEmpty && historyFilterMode != 'albums') - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Row( - children: [ - Text('${historyItems.length} ${historyItems.length == 1 ? 'track' : 'tracks'}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const Spacer(), - if (!_isSelectionMode) - TextButton.icon( - onPressed: historyItems.isNotEmpty ? () => _enterSelectionMode(historyItems.first.id) : null, - icon: const Icon(Icons.checklist, size: 18), - label: const Text('Select'), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ], - ), - ), - ), - - // Albums section header (when Albums filter is selected) - if (groupedAlbums.isNotEmpty && queueItems.isEmpty && historyFilterMode == 'albums') - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text('${groupedAlbums.length} ${groupedAlbums.length == 1 ? 'album' : 'albums'}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ), - ), - - // History section header when queue has items - if (historyItems.isNotEmpty && queueItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Downloaded', - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - ), - - // Albums Grid (when Albums filter is selected) - if (historyFilterMode == 'albums' && groupedAlbums.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 0.75, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final album = groupedAlbums[index]; + // Queue list + if (queueItems.isNotEmpty) + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = queueItems[index]; return KeyedSubtree( - key: ValueKey(album.key), - child: _buildAlbumGridItem(context, album, colorScheme), + key: ValueKey(item.id), + child: _buildQueueItem(context, item, colorScheme), ); - }, - childCount: groupedAlbums.length, + }, childCount: queueItems.length), + ), + + // Filter chips (only show when history has items) + if (allHistoryItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChip( + label: 'All', + count: allHistoryItems.length, + isSelected: historyFilterMode == 'all', + onTap: () { + _animateToFilterPage(0); + }, + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Albums', + count: albumCount, + isSelected: historyFilterMode == 'albums', + onTap: () { + _animateToFilterPage(1); + }, + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Singles', + count: singleCount, + isSelected: historyFilterMode == 'singles', + onTap: () { + _animateToFilterPage(2); + }, + ), + ], + ), + ), + ), + ), + ], + body: NotificationListener( + onNotification: (notification) { + final parentController = widget.parentPageController; + if (parentController == null || !parentController.hasClients) { + return false; + } + + final page = _filterPageController!.page?.round() ?? 0; + + if (notification is OverscrollNotification) { + final overscroll = notification.overscroll; + + // At first page and overscrolling to the left -> push parent toward Home + if (page == 0 && overscroll < 0) { + final currentOffset = parentController.offset; + final targetOffset = (currentOffset + overscroll).clamp( + 0.0, + parentController.position.maxScrollExtent, + ); + parentController.jumpTo(targetOffset); + return true; + } + + // At last page and overscrolling to the right -> push parent toward next tab + if (page == 2 && overscroll > 0) { + final currentOffset = parentController.offset; + final targetOffset = (currentOffset + overscroll).clamp( + 0.0, + parentController.position.maxScrollExtent, + ); + parentController.jumpTo(targetOffset); + return true; + } + } + + // Snap parent to nearest page when scroll ends + if (notification is ScrollEndNotification) { + if (page == 0 || page == 2) { + final currentPage = parentController.page ?? widget.parentPageIndex.toDouble(); + final historyPage = widget.parentPageIndex.toDouble(); + final offset = currentPage - historyPage; + + // Only snap if we've moved the parent + if (offset.abs() > 0.01) { + // Use 0.3 threshold (30%) + if (offset < -0.3) { + // Swiped enough toward Home - animate to Home + parentController.animateToPage( + widget.parentPageIndex - 1, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } else if (offset > 0.3) { + // Swiped enough toward next tab - animate to next + parentController.animateToPage( + widget.nextPageIndex ?? (widget.parentPageIndex + 1), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } else { + // Not enough - instant jump back (no animation) + parentController.jumpToPage(widget.parentPageIndex); + } + } + } + } + + return false; + }, + child: PageView( + controller: _filterPageController!, + physics: const ClampingScrollPhysics(), + onPageChanged: _onFilterPageChanged, + children: [ + // All tab + _buildFilterContent( + context: context, + colorScheme: colorScheme, + filterMode: 'all', + allHistoryItems: allHistoryItems, + historyViewMode: historyViewMode, + queueItems: queueItems, + groupedAlbums: groupedAlbums, + ), + // Albums tab + _buildFilterContent( + context: context, + colorScheme: colorScheme, + filterMode: 'albums', + allHistoryItems: allHistoryItems, + historyViewMode: historyViewMode, + queueItems: queueItems, + groupedAlbums: groupedAlbums, + ), + // Singles tab + _buildFilterContent( + context: context, + colorScheme: colorScheme, + filterMode: 'singles', + allHistoryItems: allHistoryItems, + historyViewMode: historyViewMode, + queueItems: queueItems, + groupedAlbums: groupedAlbums, + ), + ], + ), + ), + ), + + // Bottom Selection Action Bar + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), + child: _buildSelectionBottomBar( + context, + colorScheme, + _filterHistoryItems(allHistoryItems, historyFilterMode), + bottomPadding, + ), + ), + ], + ), + ); + } + + /// Build content for each filter tab + Widget _buildFilterContent({ + required BuildContext context, + required ColorScheme colorScheme, + required String filterMode, + required List allHistoryItems, + required String historyViewMode, + required List queueItems, + required List<_GroupedAlbum> groupedAlbums, + }) { + final historyItems = _filterHistoryItems(allHistoryItems, filterMode); + + return CustomScrollView( + slivers: [ + // History section header + if (historyItems.isNotEmpty && + queueItems.isEmpty && + filterMode != 'albums') + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + Text( + '${historyItems.length} ${historyItems.length == 1 ? 'track' : 'tracks'}', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const Spacer(), + if (!_isSelectionMode) + TextButton.icon( + onPressed: historyItems.isNotEmpty + ? () => _enterSelectionMode(historyItems.first.id) + : null, + icon: const Icon(Icons.checklist, size: 18), + label: const Text('Select'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ), + ), + + // Albums section header (when Albums filter is selected) + if (groupedAlbums.isNotEmpty && + queueItems.isEmpty && + filterMode == 'albums') + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + '${groupedAlbums.length} ${groupedAlbums.length == 1 ? 'album' : 'albums'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, ), ), ), + ), - // History - Grid or List (for All and Singles filter) - if (historyItems.isNotEmpty && historyFilterMode != 'albums') - historyViewMode == 'grid' - ? SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 0.75, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final item = historyItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), - child: _buildHistoryGridItem(context, item, colorScheme), - ); - }, - childCount: historyItems.length, - ), - ), - ) - : SliverList(delegate: SliverChildBuilderDelegate( - (context, index) { + // History section header when queue has items + if (historyItems.isNotEmpty && queueItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Downloaded', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + + // Albums Grid (when Albums filter is selected) + if (filterMode == 'albums' && groupedAlbums.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final album = groupedAlbums[index]; + return KeyedSubtree( + key: ValueKey(album.key), + child: _buildAlbumGridItem(context, album, colorScheme), + ); + }, childCount: groupedAlbums.length), + ), + ), + + // History - Grid or List (for All and Singles filter) + if (historyItems.isNotEmpty && filterMode != 'albums') + historyViewMode == 'grid' + ? SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { final item = historyItems[index]; return KeyedSubtree( key: ValueKey(item.id), - child: _buildHistoryItem(context, item, colorScheme), + child: _buildHistoryGridItem( + context, + item, + colorScheme, + ), ); - }, - childCount: historyItems.length, - )), + }, childCount: historyItems.length), + ), + ) + : SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = historyItems[index]; + return KeyedSubtree( + key: ValueKey(item.id), + child: _buildHistoryItem( + context, + item, + colorScheme, + ), + ); + }, childCount: historyItems.length), + ), - // Empty state - if (queueItems.isEmpty && historyItems.isEmpty && (historyFilterMode != 'albums' || groupedAlbums.isEmpty)) - SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme, historyFilterMode)) - else - // Add bottom padding when selection mode is active to avoid overlap with bottom bar - SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 100 : 16)), - ], - ), - - // Bottom Selection Action Bar - AnimatedPositioned( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - left: 0, - right: 0, - bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), - child: _buildSelectionBottomBar(context, colorScheme, historyItems, bottomPadding), - ), - ], - ), -); + // Empty state + if (queueItems.isEmpty && + historyItems.isEmpty && + (filterMode != 'albums' || groupedAlbums.isEmpty)) + SliverFillRemaining( + hasScrollBody: false, + child: _buildEmptyState( + context, + colorScheme, + filterMode, + ), + ) + else + // Add bottom padding when selection mode is active to avoid overlap with bottom bar + SliverToBoxAdapter( + child: SizedBox(height: _isSelectionMode ? 100 : 16), + ), + ], + ); } - Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme, String filterMode) { + Widget _buildEmptyState( + BuildContext context, + ColorScheme colorScheme, + String filterMode, + ) { String message; String subtitle; IconData icon; - + switch (filterMode) { case 'albums': message = 'No album downloads'; @@ -650,20 +935,37 @@ class _QueueTabState extends ConsumerState { subtitle = 'Downloaded tracks will appear here'; icon = Icons.history; } - + return Center( - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 64, color: colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text(message, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: 8), - Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7))), - ]), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 64, color: colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text( + message, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), ); } /// Build album grid item for grouped albums view - Widget _buildAlbumGridItem(BuildContext context, _GroupedAlbum album, ColorScheme colorScheme) { + Widget _buildAlbumGridItem( + BuildContext context, + _GroupedAlbum album, + ColorScheme colorScheme, + ) { return GestureDetector( onTap: () => _navigateToDownloadedAlbum(album), child: Column( @@ -686,7 +988,13 @@ class _QueueTabState extends ConsumerState { ) : Container( color: colorScheme.surfaceContainerHighest, - child: Center(child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 48)), + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, + ), + ), ), ), // Track count badge @@ -694,7 +1002,10 @@ class _QueueTabState extends ConsumerState { right: 8, bottom: 8, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), decoration: BoxDecoration( color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), @@ -702,7 +1013,11 @@ class _QueueTabState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.music_note, size: 12, color: colorScheme.onPrimaryContainer), + Icon( + Icons.music_note, + size: 12, + color: colorScheme.onPrimaryContainer, + ), const SizedBox(width: 4), Text( '${album.tracks.length}', @@ -725,14 +1040,18 @@ class _QueueTabState extends ConsumerState { album.albumName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), // Artist name Text( album.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -740,10 +1059,16 @@ class _QueueTabState extends ConsumerState { } /// Bottom action bar for selection mode (Material Design 3 style) - Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List historyItems, double bottomPadding) { + Widget _buildSelectionBottomBar( + BuildContext context, + ColorScheme colorScheme, + List historyItems, + double bottomPadding, + ) { final selectedCount = _selectedIds.length; - final allSelected = selectedCount == historyItems.length && historyItems.isNotEmpty; - + final allSelected = + selectedCount == historyItems.length && historyItems.isNotEmpty; + return Container( decoration: BoxDecoration( color: colorScheme.surfaceContainerHigh, @@ -773,7 +1098,7 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(2), ), ), - + // Selection info row Row( children: [ @@ -786,7 +1111,7 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(width: 12), - + // Selection count Expanded( child: Column( @@ -794,20 +1119,20 @@ class _QueueTabState extends ConsumerState { children: [ Text( '$selectedCount selected', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), ), Text( - allSelected ? 'All tracks selected' : 'Tap tracks to select', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + allSelected + ? 'All tracks selected' + : 'Tap tracks to select', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ], ), ), - + // Select all toggle TextButton.icon( onPressed: () { @@ -828,9 +1153,9 @@ class _QueueTabState extends ConsumerState { ), ], ), - + const SizedBox(height: 16), - + // Delete button SizedBox( width: double.infinity, @@ -838,13 +1163,17 @@ class _QueueTabState extends ConsumerState { onPressed: selectedCount > 0 ? _deleteSelected : null, icon: const Icon(Icons.delete_outline), label: Text( - selectedCount > 0 + selectedCount > 0 ? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}' : 'Select tracks to delete', ), style: FilledButton.styleFrom( - backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest, - foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant, + backgroundColor: selectedCount > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -859,9 +1188,13 @@ class _QueueTabState extends ConsumerState { ); } - Widget _buildQueueItem(BuildContext context, DownloadItem item, ColorScheme colorScheme) { + Widget _buildQueueItem( + BuildContext context, + DownloadItem item, + ColorScheme colorScheme, + ) { final isCompleted = item.status == DownloadStatus.completed; - + return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: InkWell( @@ -872,7 +1205,10 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ isCompleted - ? Hero(tag: 'cover_${item.id}', child: _buildCoverArt(item, colorScheme)) + ? Hero( + tag: 'cover_${item.id}', + child: _buildCoverArt(item, colorScheme), + ) : _buildCoverArt(item, colorScheme), const SizedBox(width: 12), Expanded( @@ -883,14 +1219,18 @@ class _QueueTabState extends ConsumerState { item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 2), Text( item.track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), if (item.status == DownloadStatus.downloading) ...[ const SizedBox(height: 8), @@ -901,7 +1241,8 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: item.progress > 0 ? item.progress : null, - backgroundColor: colorScheme.surfaceContainerHighest, + backgroundColor: + colorScheme.surfaceContainerHighest, color: colorScheme.primary, minHeight: 6, ), @@ -909,13 +1250,14 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(width: 8), Text( - item.speedMBps > 0 + item.speedMBps > 0 ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' : '${(item.progress * 100).toStringAsFixed(0)}%', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), ), ], ), @@ -926,7 +1268,9 @@ class _QueueTabState extends ConsumerState { item.errorMessage, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.error), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.error, + ), ), ], ], @@ -965,21 +1309,31 @@ class _QueueTabState extends ConsumerState { ); } - Widget _buildActionButtons(BuildContext context, DownloadItem item, ColorScheme colorScheme) { + Widget _buildActionButtons( + BuildContext context, + DownloadItem item, + ColorScheme colorScheme, + ) { switch (item.status) { case DownloadStatus.queued: return IconButton( - onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id), + onPressed: () => + ref.read(downloadQueueProvider.notifier).cancelItem(item.id), icon: Icon(Icons.close, color: colorScheme.error), tooltip: 'Cancel', - style: IconButton.styleFrom(backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3)), + style: IconButton.styleFrom( + backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), + ), ); case DownloadStatus.downloading: return IconButton( - onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id), + onPressed: () => + ref.read(downloadQueueProvider.notifier).cancelItem(item.id), icon: Icon(Icons.stop, color: colorScheme.error), tooltip: 'Stop', - style: IconButton.styleFrom(backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3)), + style: IconButton.styleFrom( + backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), + ), ); case DownloadStatus.finalizing: return SizedBox( @@ -988,7 +1342,10 @@ class _QueueTabState extends ConsumerState { child: Stack( alignment: Alignment.center, children: [ - CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary), + CircularProgressIndicator( + strokeWidth: 3, + color: colorScheme.tertiary, + ), Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), ], ), @@ -1003,15 +1360,26 @@ class _QueueTabState extends ConsumerState { onPressed: () => _openFile(item.filePath!), icon: Icon(Icons.play_arrow, color: colorScheme.primary), tooltip: 'Play', - style: IconButton.styleFrom(backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3)), + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + ), ) else Icon(Icons.error_outline, color: colorScheme.error, size: 20), const SizedBox(width: 4), Container( padding: const EdgeInsets.all(8), - decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), - child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: 20), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + color: colorScheme.onPrimaryContainer, + size: 20, + ), ), ], ); @@ -1021,18 +1389,33 @@ class _QueueTabState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ IconButton( - onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id), + onPressed: () => + ref.read(downloadQueueProvider.notifier).retryItem(item.id), icon: Icon(Icons.refresh, color: colorScheme.primary), tooltip: 'Retry', - style: IconButton.styleFrom(backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3)), + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + ), ), const SizedBox(width: 4), IconButton( - onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id), - icon: Icon(Icons.close, color: item.status == DownloadStatus.failed ? colorScheme.error : colorScheme.onSurfaceVariant), + onPressed: () => + ref.read(downloadQueueProvider.notifier).removeItem(item.id), + icon: Icon( + Icons.close, + color: item.status == DownloadStatus.failed + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), tooltip: 'Remove', - style: item.status == DownloadStatus.failed - ? IconButton.styleFrom(backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3)) + style: item.status == DownloadStatus.failed + ? IconButton.styleFrom( + backgroundColor: colorScheme.errorContainer.withValues( + alpha: 0.3, + ), + ) : null, ), ], @@ -1040,12 +1423,16 @@ class _QueueTabState extends ConsumerState { } } - Widget _buildHistoryGridItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) { + Widget _buildHistoryGridItem( + BuildContext context, + DownloadHistoryItem item, + ColorScheme colorScheme, + ) { final fileExists = _checkFileExists(item.filePath); final isSelected = _selectedIds.contains(item.id); - + return GestureDetector( - onTap: _isSelectionMode + onTap: _isSelectionMode ? () => _toggleSelection(item.id) : () => _navigateToHistoryMetadataScreen(item), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), @@ -1069,7 +1456,11 @@ class _QueueTabState extends ConsumerState { ) : Container( color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32), + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + size: 32, + ), ), ), ), @@ -1079,18 +1470,26 @@ class _QueueTabState extends ConsumerState { left: 4, top: 4, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), decoration: BoxDecoration( - color: item.quality!.startsWith('24') ? colorScheme.tertiary : colorScheme.surfaceContainerHighest, + color: item.quality!.startsWith('24') + ? colorScheme.tertiary + : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( item.quality!.split('/').first, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: item.quality!.startsWith('24') ? colorScheme.onTertiary : colorScheme.onSurfaceVariant, - fontSize: 9, - fontWeight: FontWeight.w600, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: item.quality!.startsWith('24') + ? colorScheme.onTertiary + : colorScheme.onSurfaceVariant, + fontSize: 9, + fontWeight: FontWeight.w600, + ), ), ), ), @@ -1103,8 +1502,15 @@ class _QueueTabState extends ConsumerState { onTap: () => _openFile(item.filePath), child: Container( padding: const EdgeInsets.all(6), - decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle), - child: Icon(Icons.play_arrow, color: colorScheme.onPrimary, size: 16), + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + child: Icon( + Icons.play_arrow, + color: colorScheme.onPrimary, + size: 16, + ), ), ), ), @@ -1115,8 +1521,15 @@ class _QueueTabState extends ConsumerState { bottom: 4, child: Container( padding: const EdgeInsets.all(4), - decoration: BoxDecoration(color: colorScheme.errorContainer, shape: BoxShape.circle), - child: Icon(Icons.error_outline, color: colorScheme.error, size: 14), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.error_outline, + color: colorScheme.error, + size: 14, + ), ), ), // Selection overlay @@ -1124,7 +1537,9 @@ class _QueueTabState extends ConsumerState { Positioned.fill( child: Container( decoration: BoxDecoration( - color: isSelected ? colorScheme.primary.withValues(alpha: 0.3) : Colors.transparent, + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, borderRadius: BorderRadius.circular(8), ), ), @@ -1136,13 +1551,17 @@ class _QueueTabState extends ConsumerState { item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), ), Text( item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -1155,9 +1574,14 @@ class _QueueTabState extends ConsumerState { decoration: BoxDecoration( color: isSelected ? colorScheme.primary : colorScheme.surface, shape: BoxShape.circle, - border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2), + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), ), - child: isSelected + child: isSelected ? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) : const SizedBox(width: 16, height: 16), ), @@ -1167,22 +1591,43 @@ class _QueueTabState extends ConsumerState { ); } - - Widget _buildHistoryItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) { + Widget _buildHistoryItem( + BuildContext context, + DownloadHistoryItem item, + ColorScheme colorScheme, + ) { final fileExists = _checkFileExists(item.filePath); final isSelected = _selectedIds.contains(item.id); final date = item.downloadedAt; - final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - final dateStr = '${months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; - + final months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + final dateStr = + '${months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : null, + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : null, child: InkWell( - onTap: _isSelectionMode + onTap: _isSelectionMode ? () => _toggleSelection(item.id) : () => _navigateToHistoryMetadataScreen(item), - onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(item.id), borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(12), @@ -1194,12 +1639,23 @@ class _QueueTabState extends ConsumerState { width: 24, height: 24, decoration: BoxDecoration( - color: isSelected ? colorScheme.primary : Colors.transparent, + color: isSelected + ? colorScheme.primary + : Colors.transparent, shape: BoxShape.circle, - border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2), + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), ), - child: isSelected - ? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) + child: isSelected + ? Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 16, + ) : null, ), const SizedBox(width: 12), @@ -1224,10 +1680,13 @@ class _QueueTabState extends ConsumerState { color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), ), const SizedBox(width: 12), - + // Track info Expanded( child: Column( @@ -1237,43 +1696,55 @@ class _QueueTabState extends ConsumerState { item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 2), Text( item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 2), Row( children: [ Text( dateStr, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.7, + ), + ), ), - if (item.quality != null && item.quality!.contains('bit')) ...[ + if (item.quality != null && + item.quality!.contains('bit')) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( - color: item.quality!.startsWith('24') - ? colorScheme.tertiaryContainer + color: item.quality!.startsWith('24') + ? colorScheme.tertiaryContainer : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( item.quality!, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: item.quality!.startsWith('24') - ? colorScheme.onTertiaryContainer - : colorScheme.onSurfaceVariant, - fontSize: 10, - fontWeight: FontWeight.w500, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: item.quality!.startsWith('24') + ? colorScheme.onTertiaryContainer + : colorScheme.onSurfaceVariant, + fontSize: 10, + fontWeight: FontWeight.w500, + ), ), ), ], @@ -1283,7 +1754,7 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(width: 8), - + // Action buttons (hide in selection mode) if (!_isSelectionMode) Row( @@ -1292,14 +1763,22 @@ class _QueueTabState extends ConsumerState { if (fileExists) IconButton( onPressed: () => _openFile(item.filePath), - icon: Icon(Icons.play_arrow, color: colorScheme.primary), + icon: Icon( + Icons.play_arrow, + color: colorScheme.primary, + ), tooltip: 'Play', style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + backgroundColor: colorScheme.primaryContainer + .withValues(alpha: 0.3), ), ) else - Icon(Icons.error_outline, color: colorScheme.error, size: 20), + Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), ], ), ], @@ -1327,9 +1806,11 @@ class _FilterChip extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + return Material( - color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, + color: isSelected + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), child: InkWell( onTap: onTap, @@ -1342,7 +1823,9 @@ class _FilterChip extends StatelessWidget { Text( label, style: TextStyle( - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), ), @@ -1350,8 +1833,8 @@ class _FilterChip extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.2) + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.2) : colorScheme.outline.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(10), ), @@ -1359,7 +1842,9 @@ class _FilterChip extends StatelessWidget { count.toString(), style: TextStyle( fontSize: 11, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, fontWeight: FontWeight.w500, ), ), diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index f178f407..9d7eafb5 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -72,7 +72,7 @@ class SettingsItem extends StatelessWidget { InkWell( onTap: onTap, splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: colorScheme.primary.withValues(alpha: 0.08), + highlightColor: Colors.transparent, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( @@ -159,7 +159,7 @@ class SettingsSwitchItem extends StatelessWidget { child: InkWell( onTap: isDisabled ? null : () => onChanged!(!value), splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: colorScheme.primary.withValues(alpha: 0.08), + highlightColor: Colors.transparent, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row(