feat: live search with back navigation and animated transitions

This commit is contained in:
zarzet
2026-01-02 16:43:59 +07:00
parent 973c2e3b41
commit 39bcc2c547
17 changed files with 448 additions and 232 deletions
+16 -4
View File
@@ -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
+2
View File
@@ -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.
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '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';
+4
View File
@@ -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,
);
}
+2
View File
@@ -17,6 +17,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> 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<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
@@ -29,6 +30,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'filePath': instance.filePath,
'error': instance.error,
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
};
const _$DownloadStatusEnumMap = {
+4
View File
@@ -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,
);
}
+3 -1
View File
@@ -20,7 +20,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -39,4 +40,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'folderOrganization': instance.folderOrganization,
'convertLyricsToRomaji': instance.convertLyricsToRomaji,
'historyViewMode': instance.historyViewMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
};
+11 -6
View File
@@ -446,7 +446,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
}
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<DownloadQueueState> {
track: track,
service: service,
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
);
state = state.copyWith(items: [...state.items, item]);
@@ -469,7 +470,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return id;
}
void addMultipleToQueue(List<Track> tracks, String service) {
void addMultipleToQueue(List<Track> 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<DownloadQueueState> {
track: track,
service: service,
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
);
}).toList();
@@ -814,11 +816,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<String, dynamic> 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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
discNumber: item.track.discNumber,
duration: item.track.duration,
releaseDate: item.track.releaseDate,
quality: state.audioQuality,
quality: quality,
),
);
+5
View File
@@ -98,6 +98,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(historyViewMode: mode);
_saveSettings();
}
void setAskQualityBeforeDownload(bool enabled) {
state = state.copyWith(askQualityBeforeDownload: enabled);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+1 -1
View File
@@ -219,7 +219,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
placeholder: (_, _) => Container(
width: 80,
height: 80,
color: colorScheme.surfaceContainerHighest,
+339 -193
View File
@@ -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<HomeTab> 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<void> _performSearch(String query) async {
await ref.read(trackProvider.notifier).search(query);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
Future<void> _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<void> _clearAndRefresh() async {
_debounce?.cancel();
_urlController.clear();
_searchFocusNode.unfocus();
setState(() => _isTyping = false);
ref.read(trackProvider.notifier).clear();
await Future.delayed(const Duration(milliseconds: 300));
}
Future<void> _fetchMetadata() async {
@@ -48,8 +136,16 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<Widget> _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<HomeTab> 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<HomeTab> 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,
);
}
}
@@ -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,
),
],
],
),
),
+1 -1
View File
@@ -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),
],
+19 -1
View File
@@ -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<TrackMetadataScreen> {
title: const Text('Share'),
onTap: () {
Navigator.pop(context);
// TODO: Implement share
_shareFile(context);
},
),
ListTile(
@@ -926,6 +927,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
Future<void> _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'];
+1 -1
View File
@@ -8,7 +8,7 @@ final log = Logger(
errorMethodCount: 5,
lineLength: 80,
colors: true,
printEmojis: true,
printEmojis: false,
dateTimeFormat: DateTimeFormat.none,
),
level: Level.debug,
+10 -3
View File
@@ -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(
+1 -1
View File
@@ -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