mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-24 16:54:03 +02:00
feat: live search with back navigation and animated transitions
This commit is contained in:
+16
-4
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -8,7 +8,7 @@ final log = Logger(
|
||||
errorMethodCount: 5,
|
||||
lineLength: 80,
|
||||
colors: true,
|
||||
printEmojis: true,
|
||||
printEmojis: false,
|
||||
dateTimeFormat: DateTimeFormat.none,
|
||||
),
|
||||
level: Level.debug,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user