mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 05:10:28 +02:00
perf: optimize providers, throttle polling, queued settings save, remove dead screens
This commit is contained in:
+29
-1
@@ -1,12 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
## [3.5.1] - 2026-02-07
|
||||
## [3.5.1] - 2026-02-08
|
||||
|
||||
### Performance
|
||||
|
||||
- Removed PaletteService (palette_generator) from all screens for faster navigation and reduced memory usage
|
||||
- Album, Playlist, Downloaded Album, Local Album, and Track Metadata screens now use blurred cover art as header background instead of dominant color extraction
|
||||
- Removed `palette_generator` dependency
|
||||
- App startup now renders immediately (`runApp`) while service initialization runs asynchronously in eager init
|
||||
- Main shell provider subscriptions now use field-level `select(...)` to reduce unnecessary rebuilds
|
||||
- Settings persistence now uses single-flight + queued save coalescing to avoid redundant disk writes
|
||||
- Progress polling cadence adjusted to 800ms for download queue, local library scan progress, and Go log polling
|
||||
- Android foreground download service progress updates are throttled (change-based updates + 5s heartbeat)
|
||||
- SAF history repair is now batched (`20` items per batch) and capped per launch (`60`) to reduce startup I/O spikes
|
||||
- Incremental library scan now builds final item list in-memory instead of reloading from database
|
||||
- Local cover images in queue/library use direct `Image.file` with `errorBuilder` instead of `FutureBuilder` existence check
|
||||
- CSV parser `_parseLine` rewritten: correct escaped-quote handling, no quote characters in output
|
||||
- Removed unused legacy screen files (`home_screen.dart`, `queue_screen.dart`, `settings_screen.dart`, `settings_tab.dart`)
|
||||
- Incremental local library scan now merges delta results in-memory and sorts once, avoiding full-state reload churn
|
||||
- Queue local cover rendering now uses direct `Image.file` + `errorBuilder` (removed repeated async file-exists checks)
|
||||
|
||||
### Added
|
||||
|
||||
- Auto-cleanup orphaned downloads on history load (files that no longer exist are automatically removed from history)
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed legacy screen files that were no longer used after the tab/part refactor:
|
||||
- `lib/screens/home_screen.dart`
|
||||
- `lib/screens/queue_screen.dart`
|
||||
- `lib/screens/settings_screen.dart`
|
||||
- `lib/screens/settings_tab.dart`
|
||||
|
||||
### Fixed
|
||||
|
||||
- CSV parser now correctly handles escaped quotes (`""`) inside quoted fields during import
|
||||
|
||||
---
|
||||
|
||||
|
||||
+22
-18
@@ -11,21 +11,9 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await CoverCacheManager.initialize();
|
||||
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
|
||||
|
||||
await Future.wait([
|
||||
NotificationService().initialize(),
|
||||
ShareIntentService().initialize(),
|
||||
]);
|
||||
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
child: const _EagerInitialization(
|
||||
child: SpotiFLACApp(),
|
||||
),
|
||||
),
|
||||
ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,27 +23,43 @@ class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState();
|
||||
ConsumerState<_EagerInitialization> createState() =>
|
||||
_EagerInitializationState();
|
||||
}
|
||||
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAppServices();
|
||||
_initializeExtensions();
|
||||
ref.read(downloadHistoryProvider);
|
||||
}
|
||||
|
||||
Future<void> _initializeAppServices() async {
|
||||
try {
|
||||
await CoverCacheManager.initialize();
|
||||
await Future.wait([
|
||||
NotificationService().initialize(),
|
||||
ShareIntentService().initialize(),
|
||||
]);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to initialize app services: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeExtensions() async {
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.initialize(extensionsDir, dataDir);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to initialize extensions: $e');
|
||||
}
|
||||
|
||||
@@ -226,8 +226,11 @@ class DownloadHistoryState {
|
||||
}
|
||||
|
||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
static const int _safRepairBatchSize = 20;
|
||||
static const int _safRepairMaxPerLaunch = 60;
|
||||
final HistoryDatabase _db = HistoryDatabase.instance;
|
||||
bool _isLoaded = false;
|
||||
bool _isSafRepairInProgress = false;
|
||||
|
||||
@override
|
||||
DownloadHistoryState build() {
|
||||
@@ -267,8 +270,14 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
Future.microtask(() async {
|
||||
await _repairMissingSafEntries(items);
|
||||
await _repairMissingSafEntries(
|
||||
items,
|
||||
maxItems: _safRepairMaxPerLaunch,
|
||||
);
|
||||
await cleanupOrphanedDownloads();
|
||||
});
|
||||
} else {
|
||||
Future.microtask(() => cleanupOrphanedDownloads());
|
||||
}
|
||||
} catch (e, stack) {
|
||||
_historyLog.e('Failed to load history from database: $e', e, stack);
|
||||
@@ -285,10 +294,16 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return '';
|
||||
}
|
||||
|
||||
Future<void> _repairMissingSafEntries(List<DownloadHistoryItem> items) async {
|
||||
final updatedItems = [...items];
|
||||
var changed = false;
|
||||
Future<void> _repairMissingSafEntries(
|
||||
List<DownloadHistoryItem> items, {
|
||||
required int maxItems,
|
||||
}) async {
|
||||
if (_isSafRepairInProgress || items.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_isSafRepairInProgress = true;
|
||||
|
||||
final candidateIndexes = <int>[];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
final item = items[i];
|
||||
if (item.storageMode != 'saf') continue;
|
||||
@@ -299,46 +314,85 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
if (item.filePath.isEmpty || !isContentUri(item.filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final exists = await fileExists(item.filePath);
|
||||
if (exists) continue;
|
||||
|
||||
final fallbackName = item.safFileName ?? _fileNameFromUri(item.filePath);
|
||||
if (fallbackName.isEmpty) {
|
||||
_historyLog.w('Missing SAF filename for history item: ${item.id}');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
final resolved = await PlatformBridge.resolveSafFile(
|
||||
treeUri: item.downloadTreeUri!,
|
||||
relativeDir: item.safRelativeDir ?? '',
|
||||
fileName: fallbackName,
|
||||
);
|
||||
final newUri = resolved['uri'] as String? ?? '';
|
||||
if (newUri.isEmpty) continue;
|
||||
|
||||
final newRelativeDir = resolved['relative_dir'] as String?;
|
||||
final updated = item.copyWith(
|
||||
filePath: newUri,
|
||||
safRelativeDir: (newRelativeDir != null && newRelativeDir.isNotEmpty)
|
||||
? newRelativeDir
|
||||
: item.safRelativeDir,
|
||||
safFileName: fallbackName,
|
||||
safRepaired: true,
|
||||
);
|
||||
|
||||
updatedItems[i] = updated;
|
||||
changed = true;
|
||||
await _db.upsert(updated.toJson());
|
||||
_historyLog.i('Repaired SAF URI for history item: ${item.id}');
|
||||
} catch (e) {
|
||||
_historyLog.w('Failed to repair SAF URI: $e');
|
||||
}
|
||||
candidateIndexes.add(i);
|
||||
if (candidateIndexes.length >= maxItems) break;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
state = state.copyWith(items: updatedItems);
|
||||
if (candidateIndexes.isEmpty) {
|
||||
_isSafRepairInProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedItems = [...items];
|
||||
var changed = false;
|
||||
var repairedCount = 0;
|
||||
var verifiedCount = 0;
|
||||
|
||||
try {
|
||||
for (var c = 0; c < candidateIndexes.length; c++) {
|
||||
final i = candidateIndexes[c];
|
||||
final item = items[i];
|
||||
|
||||
final exists = await fileExists(item.filePath);
|
||||
if (exists) {
|
||||
final verified = item.copyWith(
|
||||
safRepaired: true,
|
||||
safFileName: item.safFileName ?? _fileNameFromUri(item.filePath),
|
||||
);
|
||||
updatedItems[i] = verified;
|
||||
changed = true;
|
||||
verifiedCount++;
|
||||
await _db.upsert(verified.toJson());
|
||||
} else {
|
||||
final fallbackName =
|
||||
item.safFileName ?? _fileNameFromUri(item.filePath);
|
||||
if (fallbackName.isEmpty) {
|
||||
_historyLog.w('Missing SAF filename for history item: ${item.id}');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
final resolved = await PlatformBridge.resolveSafFile(
|
||||
treeUri: item.downloadTreeUri!,
|
||||
relativeDir: item.safRelativeDir ?? '',
|
||||
fileName: fallbackName,
|
||||
);
|
||||
final newUri = resolved['uri'] as String? ?? '';
|
||||
if (newUri.isEmpty) continue;
|
||||
|
||||
final newRelativeDir = resolved['relative_dir'] as String?;
|
||||
final updated = item.copyWith(
|
||||
filePath: newUri,
|
||||
safRelativeDir:
|
||||
(newRelativeDir != null && newRelativeDir.isNotEmpty)
|
||||
? newRelativeDir
|
||||
: item.safRelativeDir,
|
||||
safFileName: fallbackName,
|
||||
safRepaired: true,
|
||||
);
|
||||
|
||||
updatedItems[i] = updated;
|
||||
changed = true;
|
||||
repairedCount++;
|
||||
await _db.upsert(updated.toJson());
|
||||
} catch (e) {
|
||||
_historyLog.w('Failed to repair SAF URI: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if ((c + 1) % _safRepairBatchSize == 0) {
|
||||
await Future.delayed(const Duration(milliseconds: 16));
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
state = state.copyWith(items: updatedItems);
|
||||
_historyLog.i(
|
||||
'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${candidateIndexes.length}',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_isSafRepairInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,18 +466,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
/// Returns the number of orphaned entries removed
|
||||
Future<int> cleanupOrphanedDownloads() async {
|
||||
_historyLog.i('Starting orphaned downloads cleanup...');
|
||||
|
||||
|
||||
final entries = await _db.getAllEntriesWithPaths();
|
||||
final orphanedIds = <String>[];
|
||||
|
||||
|
||||
for (final entry in entries) {
|
||||
final id = entry['id'] as String;
|
||||
final filePath = entry['file_path'] as String?;
|
||||
|
||||
|
||||
if (filePath == null || filePath.isEmpty) continue;
|
||||
|
||||
|
||||
bool exists = false;
|
||||
|
||||
|
||||
if (filePath.startsWith('content://')) {
|
||||
// SAF path - check via platform bridge
|
||||
try {
|
||||
@@ -436,31 +490,33 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
// Regular file path
|
||||
exists = File(filePath).existsSync();
|
||||
}
|
||||
|
||||
|
||||
if (!exists) {
|
||||
orphanedIds.add(id);
|
||||
_historyLog.d('Found orphaned entry: $id ($filePath)');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (orphanedIds.isEmpty) {
|
||||
_historyLog.i('No orphaned entries found');
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// Delete from database
|
||||
final deletedCount = await _db.deleteByIds(orphanedIds);
|
||||
|
||||
|
||||
// Update in-memory state
|
||||
final orphanedSet = orphanedIds.toSet();
|
||||
state = state.copyWith(
|
||||
items: state.items.where((item) => !orphanedSet.contains(item.id)).toList(),
|
||||
items: state.items
|
||||
.where((item) => !orphanedSet.contains(item.id))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
|
||||
_historyLog.i('Cleaned up $deletedCount orphaned entries');
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
|
||||
void clearHistory() {
|
||||
state = DownloadHistoryState();
|
||||
_db.clearAll().catchError((e) {
|
||||
@@ -557,6 +613,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
int _downloadCount = 0;
|
||||
static const _cleanupInterval = 50;
|
||||
static const _queueStorageKey = 'download_queue';
|
||||
static const _progressPollingInterval = Duration(milliseconds: 800);
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
int _totalQueuedAtStart = 0;
|
||||
@@ -564,6 +621,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
int _failedInSession = 0;
|
||||
bool _isLoaded = false;
|
||||
final Set<String> _ensuredDirs = {};
|
||||
int _progressPollingErrorCount = 0;
|
||||
String? _lastServiceTrackName;
|
||||
String? _lastServiceArtistName;
|
||||
int _lastServicePercent = -1;
|
||||
int _lastServiceQueueCount = -1;
|
||||
DateTime _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
DownloadQueueState build() {
|
||||
@@ -647,9 +710,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
void _startMultiProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (
|
||||
timer,
|
||||
) async {
|
||||
_progressTimer = Timer.periodic(_progressPollingInterval, (timer) async {
|
||||
try {
|
||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||
@@ -818,23 +879,76 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
PlatformBridge.updateDownloadServiceProgress(
|
||||
_maybeUpdateAndroidDownloadService(
|
||||
trackName: firstDownloading.track.name,
|
||||
artistName: firstDownloading.track.artistName,
|
||||
progress: notifProgress,
|
||||
total: notifTotal > 0 ? notifTotal : 1,
|
||||
queueCount: queuedCount,
|
||||
).catchError((_) {});
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Progress polling failed: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _maybeUpdateAndroidDownloadService({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
required int progress,
|
||||
required int total,
|
||||
required int queueCount,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final safeTotal = total > 0 ? total : 1;
|
||||
final progressPercent = ((progress * 100) / safeTotal)
|
||||
.round()
|
||||
.clamp(0, 100)
|
||||
.toInt();
|
||||
|
||||
final didContentChange =
|
||||
trackName != _lastServiceTrackName ||
|
||||
artistName != _lastServiceArtistName ||
|
||||
queueCount != _lastServiceQueueCount ||
|
||||
progressPercent != _lastServicePercent;
|
||||
final allowHeartbeat =
|
||||
now.difference(_lastServiceUpdateAt) >= const Duration(seconds: 5);
|
||||
|
||||
if (!didContentChange && !allowHeartbeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
_lastServiceTrackName = trackName;
|
||||
_lastServiceArtistName = artistName;
|
||||
_lastServicePercent = progressPercent;
|
||||
_lastServiceQueueCount = queueCount;
|
||||
_lastServiceUpdateAt = now;
|
||||
|
||||
PlatformBridge.updateDownloadServiceProgress(
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
progress: progress,
|
||||
total: safeTotal,
|
||||
queueCount: queueCount,
|
||||
).catchError((_) {});
|
||||
}
|
||||
|
||||
void _stopProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressPollingErrorCount = 0;
|
||||
_lastServiceTrackName = null;
|
||||
_lastServiceArtistName = null;
|
||||
_lastServicePercent = -1;
|
||||
_lastServiceQueueCount = -1;
|
||||
_lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
|
||||
Future<void> _initOutputDir() async {
|
||||
@@ -2241,7 +2355,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
while (true) {
|
||||
if (state.isPaused) {
|
||||
_log.d('Queue is paused, waiting...');
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
await Future.delayed(_progressPollingInterval);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2280,7 +2394,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (activeDownloads.isNotEmpty) {
|
||||
await Future.any(activeDownloads.values);
|
||||
} else {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
await Future.delayed(_progressPollingInterval);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -36,16 +36,16 @@ class LocalLibraryState {
|
||||
this.scanErrorCount = 0,
|
||||
this.scanWasCancelled = false,
|
||||
this.lastScannedAt,
|
||||
}) : _isrcSet = items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => item.isrc!)
|
||||
.toSet(),
|
||||
_trackKeySet = items.map((item) => item.matchKey).toSet(),
|
||||
_byIsrc = Map.fromEntries(
|
||||
items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
}) : _isrcSet = items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => item.isrc!)
|
||||
.toSet(),
|
||||
_trackKeySet = items.map((item) => item.matchKey).toSet(),
|
||||
_byIsrc = Map.fromEntries(
|
||||
items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
|
||||
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
|
||||
|
||||
@@ -99,9 +99,11 @@ class LocalLibraryState {
|
||||
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final LibraryDatabase _db = LibraryDatabase.instance;
|
||||
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
||||
static const _progressPollingInterval = Duration(milliseconds: 800);
|
||||
Timer? _progressTimer;
|
||||
bool _isLoaded = false;
|
||||
bool _scanCancelRequested = false;
|
||||
int _progressPollingErrorCount = 0;
|
||||
|
||||
@override
|
||||
LocalLibraryState build() {
|
||||
@@ -121,10 +123,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
try {
|
||||
final jsonList = await _db.getAll();
|
||||
final items = jsonList
|
||||
.map((e) => LocalLibraryItem.fromJson(e))
|
||||
.toList();
|
||||
|
||||
final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
||||
|
||||
DateTime? lastScannedAt;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -135,9 +135,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
} catch (e) {
|
||||
_log.w('Failed to load lastScannedAt: $e');
|
||||
}
|
||||
|
||||
|
||||
state = state.copyWith(items: items, lastScannedAt: lastScannedAt);
|
||||
_log.i('Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt');
|
||||
_log.i(
|
||||
'Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt',
|
||||
);
|
||||
} catch (e, stack) {
|
||||
_log.e('Failed to load library from database: $e', e, stack);
|
||||
}
|
||||
@@ -148,14 +150,19 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
await _loadFromDatabase();
|
||||
}
|
||||
|
||||
Future<void> startScan(String folderPath, {bool forceFullScan = false}) async {
|
||||
Future<void> startScan(
|
||||
String folderPath, {
|
||||
bool forceFullScan = false,
|
||||
}) async {
|
||||
if (state.isScanning) {
|
||||
_log.w('Scan already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
_scanCancelRequested = false;
|
||||
_log.i('Starting library scan: $folderPath (incremental: ${!forceFullScan})');
|
||||
_log.i(
|
||||
'Starting library scan: $folderPath (incremental: ${!forceFullScan})',
|
||||
);
|
||||
state = state.copyWith(
|
||||
isScanning: true,
|
||||
scanProgress: 0,
|
||||
@@ -179,11 +186,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
try {
|
||||
final isSaf = folderPath.startsWith('content://');
|
||||
|
||||
|
||||
// Get all file paths from download history to exclude them
|
||||
final downloadedPaths = await _historyDb.getAllFilePaths();
|
||||
_log.i('Excluding ${downloadedPaths.length} downloaded files from library scan');
|
||||
|
||||
_log.i(
|
||||
'Excluding ${downloadedPaths.length} downloaded files from library scan',
|
||||
);
|
||||
|
||||
if (forceFullScan) {
|
||||
// Full scan path - ignores existing data
|
||||
final results = isSaf
|
||||
@@ -193,7 +202,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
final items = <LocalLibraryItem>[];
|
||||
int skippedDownloads = 0;
|
||||
for (final json in results) {
|
||||
@@ -206,7 +215,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final item = LocalLibraryItem.fromJson(json);
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
|
||||
if (skippedDownloads > 0) {
|
||||
_log.i('Skipped $skippedDownloads files already in download history');
|
||||
}
|
||||
@@ -234,7 +243,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
} else {
|
||||
// Incremental scan path - only scans new/modified files
|
||||
final existingFiles = await _db.getFileModTimes();
|
||||
_log.i('Incremental scan: ${existingFiles.length} existing files in database');
|
||||
_log.i(
|
||||
'Incremental scan: ${existingFiles.length} existing files in database',
|
||||
);
|
||||
|
||||
final backfilledModTimes = await _backfillLegacyFileModTimes(
|
||||
isSaf: isSaf,
|
||||
@@ -245,7 +256,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
existingFiles.addAll(backfilledModTimes);
|
||||
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
|
||||
}
|
||||
|
||||
|
||||
// Use appropriate incremental scan method based on SAF or not
|
||||
final Map<String, dynamic> result;
|
||||
if (isSaf) {
|
||||
@@ -259,63 +270,76 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
existingFiles,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (_scanCancelRequested) {
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Parse incremental scan result
|
||||
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
|
||||
final scannedList = (result['files'] as List<dynamic>?)
|
||||
?? (result['scanned'] as List<dynamic>?)
|
||||
?? [];
|
||||
final deletedPaths = (result['removedUris'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList()
|
||||
?? (result['deletedPaths'] as List<dynamic>?)
|
||||
final scannedList =
|
||||
(result['files'] as List<dynamic>?) ??
|
||||
(result['scanned'] as List<dynamic>?) ??
|
||||
[];
|
||||
final deletedPaths =
|
||||
(result['removedUris'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList()
|
||||
?? [];
|
||||
.toList() ??
|
||||
(result['deletedPaths'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[];
|
||||
final skippedCount = result['skippedCount'] as int? ?? 0;
|
||||
final totalFiles = result['totalFiles'] as int? ?? 0;
|
||||
|
||||
_log.i('Incremental result: ${scannedList.length} scanned, '
|
||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total');
|
||||
|
||||
|
||||
_log.i(
|
||||
'Incremental result: ${scannedList.length} scanned, '
|
||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||
);
|
||||
|
||||
final currentByPath = <String, LocalLibraryItem>{
|
||||
for (final item in state.items) item.filePath: item,
|
||||
};
|
||||
|
||||
// Upsert new/modified items (excluding downloaded files)
|
||||
final updatedItems = <LocalLibraryItem>[];
|
||||
int skippedDownloads = 0;
|
||||
if (scannedList.isNotEmpty) {
|
||||
final items = <LocalLibraryItem>[];
|
||||
int skippedDownloads = 0;
|
||||
for (final json in scannedList) {
|
||||
final map = json as Map<String, dynamic>;
|
||||
final filePath = map['filePath'] as String?;
|
||||
// Skip files that are already in download history
|
||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
||||
skippedDownloads++;
|
||||
continue;
|
||||
}
|
||||
items.add(LocalLibraryItem.fromJson(map));
|
||||
final item = LocalLibraryItem.fromJson(map);
|
||||
updatedItems.add(item);
|
||||
currentByPath[item.filePath] = item;
|
||||
}
|
||||
if (items.isNotEmpty) {
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
_log.i('Upserted ${items.length} items');
|
||||
if (updatedItems.isNotEmpty) {
|
||||
await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList());
|
||||
_log.i('Upserted ${updatedItems.length} items');
|
||||
}
|
||||
if (skippedDownloads > 0) {
|
||||
_log.i('Skipped $skippedDownloads files already in download history');
|
||||
_log.i(
|
||||
'Skipped $skippedDownloads files already in download history',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Delete removed items
|
||||
if (deletedPaths.isNotEmpty) {
|
||||
final deleteCount = await _db.deleteByPaths(deletedPaths);
|
||||
for (final path in deletedPaths) {
|
||||
currentByPath.remove(path);
|
||||
}
|
||||
_log.i('Deleted $deleteCount items from database');
|
||||
}
|
||||
|
||||
// Reload all items from database to get complete list
|
||||
final allItems = await _db.getAll();
|
||||
final items = allItems.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
||||
|
||||
|
||||
final items = currentByPath.values.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -333,8 +357,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
scanWasCancelled: false,
|
||||
);
|
||||
|
||||
_log.i('Incremental scan complete: ${items.length} total tracks '
|
||||
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)');
|
||||
_log.i(
|
||||
'Incremental scan complete: ${items.length} total tracks '
|
||||
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
_log.e('Library scan failed: $e', e, stack);
|
||||
@@ -346,10 +372,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
void _startProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
||||
try {
|
||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||
|
||||
|
||||
state = state.copyWith(
|
||||
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
|
||||
scanCurrentFile: progress['current_file'] as String?,
|
||||
@@ -361,18 +387,25 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
if (progress['is_complete'] == true) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
} catch (_) {}
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Library scan progress polling failed: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressPollingErrorCount = 0;
|
||||
}
|
||||
|
||||
Future<void> cancelScan() async {
|
||||
if (!state.isScanning) return;
|
||||
|
||||
|
||||
_log.i('Cancelling library scan');
|
||||
_scanCancelRequested = true;
|
||||
await PlatformBridge.cancelLibraryScan();
|
||||
@@ -390,14 +423,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
Future<void> clearLibrary() async {
|
||||
await _db.clearAll();
|
||||
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_lastScannedAtKey);
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear lastScannedAt: $e');
|
||||
}
|
||||
|
||||
|
||||
state = LocalLibraryState();
|
||||
_log.i('Library cleared');
|
||||
}
|
||||
@@ -421,7 +454,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
return state.getByIsrc(isrc);
|
||||
}
|
||||
|
||||
LocalLibraryItem? findExisting({String? isrc, String? trackName, String? artistName}) {
|
||||
LocalLibraryItem? findExisting({
|
||||
String? isrc,
|
||||
String? trackName,
|
||||
String? artistName,
|
||||
}) {
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = state.getByIsrc(isrc);
|
||||
if (byIsrc != null) return byIsrc;
|
||||
@@ -434,7 +471,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
Future<List<LocalLibraryItem>> search(String query) async {
|
||||
if (query.isEmpty) return [];
|
||||
|
||||
|
||||
final results = await _db.search(query);
|
||||
return results.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
||||
}
|
||||
@@ -443,6 +480,23 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
return await _db.getCount();
|
||||
}
|
||||
|
||||
int _compareLibraryItems(LocalLibraryItem a, LocalLibraryItem b) {
|
||||
final artistA = (a.albumArtist ?? a.artistName).toLowerCase();
|
||||
final artistB = (b.albumArtist ?? b.artistName).toLowerCase();
|
||||
final artistCompare = artistA.compareTo(artistB);
|
||||
if (artistCompare != 0) return artistCompare;
|
||||
|
||||
final albumCompare = a.albumName.toLowerCase().compareTo(
|
||||
b.albumName.toLowerCase(),
|
||||
);
|
||||
if (albumCompare != 0) return albumCompare;
|
||||
|
||||
final discCompare = (a.discNumber ?? 0).compareTo(b.discNumber ?? 0);
|
||||
if (discCompare != 0) return discCompare;
|
||||
|
||||
return (a.trackNumber ?? 0).compareTo(b.trackNumber ?? 0);
|
||||
}
|
||||
|
||||
Future<Map<String, int>> _backfillLegacyFileModTimes({
|
||||
required bool isSaf,
|
||||
required Map<String, int> existingFiles,
|
||||
@@ -469,7 +523,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
if (_scanCancelRequested) {
|
||||
break;
|
||||
}
|
||||
final end = (i + chunkSize < uris.length) ? i + chunkSize : uris.length;
|
||||
final end = (i + chunkSize < uris.length)
|
||||
? i + chunkSize
|
||||
: uris.length;
|
||||
final chunk = uris.sublist(i, end);
|
||||
final chunkResult = await PlatformBridge.getSafFileModTimes(chunk);
|
||||
backfilled.addAll(chunkResult);
|
||||
|
||||
@@ -10,10 +10,14 @@ const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 2;
|
||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
final _log = AppLogger('SettingsProvider');
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
bool _isSavingSettings = false;
|
||||
bool _saveQueued = false;
|
||||
String? _pendingSettingsJson;
|
||||
|
||||
@override
|
||||
AppSettings build() {
|
||||
@@ -26,27 +30,27 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final json = prefs.getString(_settingsKey);
|
||||
if (json != null) {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
|
||||
|
||||
await _runMigrations(prefs);
|
||||
}
|
||||
|
||||
await _loadSpotifyClientSecret(prefs);
|
||||
|
||||
_applySpotifyCredentials();
|
||||
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
}
|
||||
|
||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||
|
||||
|
||||
if (lastMigration < 1) {
|
||||
if (!state.useCustomSpotifyCredentials) {
|
||||
state = state.copyWith(metadataSource: 'deezer');
|
||||
await _saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (lastMigration < _currentMigrationVersion) {
|
||||
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
|
||||
state = state.copyWith(storageMode: 'saf');
|
||||
@@ -61,20 +65,43 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final prefs = await _prefs;
|
||||
final settingsToSave = state.copyWith(
|
||||
spotifyClientSecret: '',
|
||||
);
|
||||
await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson()));
|
||||
final settingsToSave = state.copyWith(spotifyClientSecret: '');
|
||||
_pendingSettingsJson = jsonEncode(settingsToSave.toJson());
|
||||
|
||||
if (_isSavingSettings) {
|
||||
_saveQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_isSavingSettings = true;
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
do {
|
||||
final jsonToWrite = _pendingSettingsJson;
|
||||
_saveQueued = false;
|
||||
if (jsonToWrite != null) {
|
||||
await prefs.setString(_settingsKey, jsonToWrite);
|
||||
}
|
||||
} while (_saveQueued);
|
||||
} catch (e) {
|
||||
_log.e('Failed to save settings: $e');
|
||||
} finally {
|
||||
_isSavingSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
||||
final storedSecret = await _secureStorage.read(key: _spotifyClientSecretKey);
|
||||
final storedSecret = await _secureStorage.read(
|
||||
key: _spotifyClientSecretKey,
|
||||
);
|
||||
final prefsSecret = state.spotifyClientSecret;
|
||||
|
||||
if ((storedSecret == null || storedSecret.isEmpty) &&
|
||||
prefsSecret.isNotEmpty) {
|
||||
await _secureStorage.write(key: _spotifyClientSecretKey, value: prefsSecret);
|
||||
await _secureStorage.write(
|
||||
key: _spotifyClientSecretKey,
|
||||
value: prefsSecret,
|
||||
);
|
||||
}
|
||||
|
||||
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
|
||||
@@ -99,7 +126,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
Future<void> _applySpotifyCredentials() async {
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
state.spotifyClientSecret.isNotEmpty) {
|
||||
await PlatformBridge.setSpotifyCredentials(
|
||||
state.spotifyClientId,
|
||||
@@ -225,7 +252,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
||||
Future<void> setSpotifyCredentials(
|
||||
String clientId,
|
||||
String clientSecret,
|
||||
) async {
|
||||
state = state.copyWith(
|
||||
spotifyClientId: clientId,
|
||||
spotifyClientSecret: clientSecret,
|
||||
@@ -236,10 +266,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
Future<void> clearSpotifyCredentials() async {
|
||||
state = state.copyWith(
|
||||
spotifyClientId: '',
|
||||
spotifyClientSecret: '',
|
||||
);
|
||||
state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
|
||||
await _storeSpotifyClientSecret('');
|
||||
_saveSettings();
|
||||
_applySpotifyCredentials();
|
||||
@@ -301,7 +328,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAllFilesAccess(bool enabled) {
|
||||
void setUseAllFilesAccess(bool enabled) {
|
||||
state = state.copyWith(useAllFilesAccess: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
class HomeScreen extends ConsumerStatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
final _urlController = TextEditingController();
|
||||
int _currentIndex = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pasteFromClipboard() async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data?.text != null) {
|
||||
_urlController.text = data!.text!;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchMetadata() async {
|
||||
final url = _urlController.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
|
||||
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||
} else {
|
||||
final settings = ref.read(settingsProvider);
|
||||
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadTrack(Track track) {
|
||||
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')),
|
||||
);
|
||||
}
|
||||
|
||||
void _downloadAll() {
|
||||
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')),
|
||||
);
|
||||
}
|
||||
|
||||
void _onNavTap(int index) {
|
||||
setState(() => _currentIndex = index);
|
||||
switch (index) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
context.push('/queue');
|
||||
break;
|
||||
case 2:
|
||||
context.push('/history');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final queuedCount =
|
||||
ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final tracks = trackState.tracks;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
),
|
||||
),
|
||||
title: const Text('SpotiFLAC'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () => context.push('/settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _urlController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Paste Spotify URL or search...',
|
||||
prefixIcon: const Icon(Icons.link),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard),
|
||||
IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata),
|
||||
],
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _fetchMetadata(),
|
||||
),
|
||||
),
|
||||
|
||||
if (trackState.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
trackState.error!,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
|
||||
if (trackState.isLoading)
|
||||
LinearProgressIndicator(color: colorScheme.primary),
|
||||
|
||||
if (trackState.albumName != null || trackState.playlistName != null)
|
||||
_buildHeader(trackState, colorScheme),
|
||||
|
||||
if (tracks.length > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _downloadAll,
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text('Download All (${tracks.length})'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: tracks.isEmpty
|
||||
? _buildEmptyState(colorScheme)
|
||||
: ListView.builder(
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildTrackTile(tracks[index], colorScheme),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _onNavTap,
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.search_outlined),
|
||||
selectedIcon: Icon(Icons.search),
|
||||
label: 'Search',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queuedCount > 0,
|
||||
label: Text('$queuedCount'),
|
||||
child: const Icon(Icons.queue_music_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queuedCount > 0,
|
||||
label: Text('$queuedCount'),
|
||||
child: const Icon(Icons.queue_music),
|
||||
),
|
||||
label: 'Queue',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.history_outlined),
|
||||
selectedIcon: Icon(Icons.history),
|
||||
label: 'History',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(TrackState state, ColorScheme colorScheme) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
if (state.coverUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: state.coverUrl!,
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.albumName ?? state.playlistName ?? '',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${state.tracks.length} tracks',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: _downloadAll,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
),
|
||||
child: const Icon(Icons.download),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
|
||||
final isCollection = track.isCollection;
|
||||
|
||||
String subtitleText;
|
||||
if (isCollection) {
|
||||
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
|
||||
final capitalizedType = typeLabel.isNotEmpty
|
||||
? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}'
|
||||
: 'Album';
|
||||
final year = track.releaseDate != null && track.releaseDate!.length >= 4
|
||||
? track.releaseDate!.substring(0, 4)
|
||||
: '';
|
||||
subtitleText = '$capitalizedType • ${track.artistName}${year.isNotEmpty ? ' • $year' : ''}';
|
||||
} else {
|
||||
subtitleText = track.artistName;
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
isCollection ? Icons.album : Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
subtitleText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
trailing: isCollection
|
||||
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
|
||||
: Text(
|
||||
_formatDuration(track.duration),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openCollection(Track track) async {
|
||||
final extensionId = track.source;
|
||||
if (extensionId == null) return;
|
||||
|
||||
try {
|
||||
if (track.isAlbumItem) {
|
||||
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
|
||||
if (albumData != null && mounted) {
|
||||
final trackList = albumData['tracks'] as List<dynamic>? ?? [];
|
||||
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
|
||||
ref.read(trackProvider.notifier).setTracksFromCollection(
|
||||
tracks: tracks,
|
||||
albumName: albumData['name'] as String? ?? track.name,
|
||||
coverUrl: albumData['cover_url'] as String? ?? track.coverUrl,
|
||||
);
|
||||
}
|
||||
} else if (track.isPlaylistItem) {
|
||||
final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id);
|
||||
if (playlistData != null && mounted) {
|
||||
final trackList = playlistData['tracks'] as List<dynamic>? ?? [];
|
||||
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
|
||||
ref.read(trackProvider.notifier).setTracksFromCollection(
|
||||
tracks: tracks,
|
||||
playlistName: playlistData['name'] as String? ?? track.name,
|
||||
coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to load: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseExtensionTrack(Map<String, dynamic> data, String source) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
durationMs = durationValue;
|
||||
} else if (durationValue is double) {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
return Track(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? '').toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
source: source,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int ms) {
|
||||
if (ms == 0) return '';
|
||||
final duration = Duration(milliseconds: ms);
|
||||
final minutes = duration.inMinutes;
|
||||
final seconds = duration.inSeconds % 60;
|
||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Paste a Spotify URL to get started',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+24
-11
@@ -82,9 +82,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
|
||||
if (_currentIndex != 0) {
|
||||
@@ -181,10 +181,14 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final treeUri = result['tree_uri'] as String? ?? '';
|
||||
final displayName = result['display_name'] as String? ?? '';
|
||||
if (treeUri.isNotEmpty) {
|
||||
ref.read(settingsProvider.notifier).setDownloadTreeUri(
|
||||
treeUri,
|
||||
displayName: displayName.isNotEmpty ? displayName : treeUri,
|
||||
);
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDownloadTreeUri(
|
||||
treeUri,
|
||||
displayName: displayName.isNotEmpty
|
||||
? displayName
|
||||
: treeUri,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -280,7 +284,16 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final queueState = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.queuedCount),
|
||||
);
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final trackHasSearchText = ref.watch(
|
||||
trackProvider.select((s) => s.hasSearchText),
|
||||
);
|
||||
final trackHasContent = ref.watch(
|
||||
trackProvider.select((s) => s.hasContent),
|
||||
);
|
||||
final trackIsLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||
final trackIsShowingRecentAccess = ref.watch(
|
||||
trackProvider.select((s) => s.isShowingRecentAccess),
|
||||
);
|
||||
final showStore = ref.watch(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
@@ -292,10 +305,10 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
|
||||
final canPop =
|
||||
_currentIndex == 0 &&
|
||||
!trackState.hasSearchText &&
|
||||
!trackState.hasContent &&
|
||||
!trackState.isLoading &&
|
||||
!trackState.isShowingRecentAccess &&
|
||||
!trackHasSearchText &&
|
||||
!trackHasContent &&
|
||||
!trackIsLoading &&
|
||||
!trackIsShowingRecentAccess &&
|
||||
!isKeyboardVisible;
|
||||
|
||||
final tabs = <Widget>[
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
|
||||
class QueueScreen extends ConsumerWidget {
|
||||
const QueueScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.queueTitle),
|
||||
actions: [
|
||||
if (items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_sweep),
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
|
||||
tooltip: context.l10n.queueClearCompleted,
|
||||
),
|
||||
if (items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear_all),
|
||||
onPressed: () => _showClearAllDialog(context, ref),
|
||||
tooltip: context.l10n.queueClearAll,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: items.isEmpty
|
||||
? _buildEmptyState(context, colorScheme)
|
||||
: ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildQueueItem(context, ref, items[index], colorScheme),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.queue,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.queueEmpty,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.queueEmptySubtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
|
||||
return ListTile(
|
||||
leading: item.track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
title: Text(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
if (item.status == DownloadStatus.downloading) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: item.progress > 0 ? item.progress : null,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${(item.progress * 100).toStringAsFixed(0)}%',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: _buildStatusIcon(context, item, colorScheme),
|
||||
onTap: item.status == DownloadStatus.queued
|
||||
? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
|
||||
switch (item.status) {
|
||||
case DownloadStatus.queued:
|
||||
return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant);
|
||||
case DownloadStatus.downloading:
|
||||
return SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
value: item.progress,
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
);
|
||||
case DownloadStatus.finalizing:
|
||||
return SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary),
|
||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12),
|
||||
],
|
||||
),
|
||||
);
|
||||
case DownloadStatus.completed:
|
||||
return Icon(Icons.check_circle, color: colorScheme.primary);
|
||||
case DownloadStatus.failed:
|
||||
return IconButton(
|
||||
icon: Icon(Icons.error, color: colorScheme.error),
|
||||
onPressed: () => _showErrorDialog(context, item, colorScheme),
|
||||
tooltip: 'Tap to see error details',
|
||||
);
|
||||
case DownloadStatus.skipped:
|
||||
return Icon(Icons.skip_next, color: colorScheme.primary);
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.error, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.queueDownloadFailed),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
|
||||
const SizedBox(height: 16),
|
||||
Text(context.l10n.queueErrorLabel, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
item.error ?? context.l10n.queueUnknownError,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.dialogClose),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showClearAllDialog(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.queueClearAll),
|
||||
content: Text(context.l10n.queueClearAllMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(downloadQueueProvider.notifier).clearAll();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+595
-613
File diff suppressed because it is too large
Load Diff
@@ -1,540 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final themeSettings = ref.watch(themeProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
children: [
|
||||
// Theme Section
|
||||
_buildSectionHeader(context, 'Appearance', colorScheme),
|
||||
|
||||
// Theme Mode
|
||||
ListTile(
|
||||
leading: Icon(Icons.brightness_6, color: colorScheme.primary),
|
||||
title: const Text('Theme Mode'),
|
||||
subtitle: Text(_getThemeModeName(themeSettings.themeMode)),
|
||||
onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode),
|
||||
),
|
||||
|
||||
// Dynamic Color Toggle
|
||||
SwitchListTile(
|
||||
secondary: Icon(Icons.palette, color: colorScheme.primary),
|
||||
title: const Text('Dynamic Color'),
|
||||
subtitle: const Text('Use colors from your wallpaper'),
|
||||
value: themeSettings.useDynamicColor,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
||||
),
|
||||
|
||||
// Seed Color Picker (only when dynamic color is disabled)
|
||||
if (!themeSettings.useDynamicColor)
|
||||
ListTile(
|
||||
leading: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(themeSettings.seedColorValue),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
),
|
||||
),
|
||||
title: const Text('Accent Color'),
|
||||
subtitle: const Text('Choose your preferred color'),
|
||||
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Download Section
|
||||
_buildSectionHeader(context, 'Download', colorScheme),
|
||||
|
||||
// Download Service
|
||||
ListTile(
|
||||
leading: Icon(Icons.cloud_download, color: colorScheme.primary),
|
||||
title: const Text('Default Service'),
|
||||
subtitle: Text(_getServiceName(settings.defaultService)),
|
||||
onTap: () => _showServicePicker(context, ref, settings.defaultService),
|
||||
),
|
||||
|
||||
// Audio Quality
|
||||
ListTile(
|
||||
leading: Icon(Icons.high_quality, color: colorScheme.primary),
|
||||
title: const Text('Audio Quality'),
|
||||
subtitle: Text(_getQualityName(settings.audioQuality)),
|
||||
onTap: () => _showQualityPicker(context, ref, settings.audioQuality),
|
||||
),
|
||||
|
||||
// Filename Format
|
||||
ListTile(
|
||||
leading: Icon(Icons.text_fields, color: colorScheme.primary),
|
||||
title: const Text('Filename Format'),
|
||||
subtitle: Text(settings.filenameFormat),
|
||||
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
|
||||
),
|
||||
|
||||
// Download Directory
|
||||
ListTile(
|
||||
leading: Icon(Icons.folder, color: colorScheme.primary),
|
||||
title: const Text('Download Directory'),
|
||||
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory),
|
||||
onTap: () => _pickDirectory(context, ref),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Options Section
|
||||
_buildSectionHeader(context, 'Options', colorScheme),
|
||||
|
||||
// Auto Fallback
|
||||
SwitchListTile(
|
||||
secondary: Icon(Icons.sync, color: colorScheme.primary),
|
||||
title: const Text('Auto Fallback'),
|
||||
subtitle: const Text('Try other services if download fails'),
|
||||
value: settings.autoFallback,
|
||||
onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value),
|
||||
),
|
||||
|
||||
// Embed Lyrics
|
||||
SwitchListTile(
|
||||
secondary: Icon(Icons.lyrics, color: colorScheme.primary),
|
||||
title: const Text('Embed Lyrics'),
|
||||
subtitle: const Text('Embed synced lyrics into FLAC files'),
|
||||
value: settings.embedLyrics,
|
||||
onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value),
|
||||
),
|
||||
|
||||
// Max Quality Cover
|
||||
SwitchListTile(
|
||||
secondary: Icon(Icons.image, color: colorScheme.primary),
|
||||
title: const Text('Max Quality Cover'),
|
||||
subtitle: const Text('Download highest resolution cover art'),
|
||||
value: settings.maxQualityCover,
|
||||
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
|
||||
),
|
||||
|
||||
// Concurrent Downloads
|
||||
ListTile(
|
||||
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
|
||||
title: const Text('Concurrent Downloads'),
|
||||
subtitle: Text(settings.concurrentDownloads == 1
|
||||
? 'Sequential (1 at a time)'
|
||||
: '${settings.concurrentDownloads} parallel downloads'),
|
||||
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
|
||||
),
|
||||
|
||||
// Check for Updates
|
||||
SwitchListTile(
|
||||
secondary: Icon(Icons.system_update, color: colorScheme.primary),
|
||||
title: const Text('Check for Updates'),
|
||||
subtitle: const Text('Notify when new version is available'),
|
||||
value: settings.checkForUpdates,
|
||||
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// GitHub & Credits Section
|
||||
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.code, color: colorScheme.primary),
|
||||
title: Text('${AppInfo.appName} Mobile'),
|
||||
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.computer, color: colorScheme.primary),
|
||||
title: Text('Original ${AppInfo.appName} (Desktop)'),
|
||||
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// About
|
||||
ListTile(
|
||||
leading: Icon(Icons.info, color: colorScheme.primary),
|
||||
title: const Text('About'),
|
||||
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
|
||||
onTap: () => _showAboutDialog(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAboutDialog(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
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)),
|
||||
const SizedBox(width: 12),
|
||||
Text(AppInfo.appName),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildAboutRow('Version', AppInfo.version, colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppInfo.copyright,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getThemeModeName(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.light: return 'Light';
|
||||
case ThemeMode.dark: return 'Dark';
|
||||
case ThemeMode.system: return 'System';
|
||||
}
|
||||
}
|
||||
|
||||
String _getServiceName(String service) {
|
||||
switch (service) {
|
||||
case 'tidal': return 'Tidal';
|
||||
case 'qobuz': return 'Qobuz';
|
||||
case 'amazon': return 'Amazon Music';
|
||||
default: return service;
|
||||
}
|
||||
}
|
||||
|
||||
String _getQualityName(String quality) {
|
||||
switch (quality) {
|
||||
case 'LOSSLESS': return 'FLAC (Lossless)';
|
||||
case 'HI_RES': return 'Hi-Res FLAC (24-bit)';
|
||||
default: return quality;
|
||||
}
|
||||
}
|
||||
|
||||
void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Theme Mode'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme),
|
||||
_buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme),
|
||||
_buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) {
|
||||
final isSelected = mode == current;
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: isSelected ? colorScheme.primary : null),
|
||||
title: Text(label),
|
||||
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(themeProvider.notifier).setThemeMode(mode);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) {
|
||||
final colors = [
|
||||
const Color(0xFF1DB954), // Spotify Green
|
||||
const Color(0xFF6750A4), // Purple
|
||||
const Color(0xFF0061A4), // Blue
|
||||
const Color(0xFF006E1C), // Green
|
||||
const Color(0xFFBA1A1A), // Red
|
||||
const Color(0xFF984061), // Pink
|
||||
const Color(0xFF7D5260), // Brown
|
||||
const Color(0xFF006874), // Teal
|
||||
const Color(0xFFFF6F00), // Orange
|
||||
];
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Choose Accent Color'),
|
||||
content: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: colors.map((color) {
|
||||
final isSelected = color.toARGB32() == currentColor;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
ref.read(themeProvider.notifier).setSeedColor(color);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: isSelected
|
||||
? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3)
|
||||
: null,
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(Icons.check, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showServicePicker(BuildContext context, WidgetRef ref, String current) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Select Service'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme),
|
||||
_buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme),
|
||||
_buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) {
|
||||
final isSelected = value == current;
|
||||
return ListTile(
|
||||
title: Text(label),
|
||||
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setDefaultService(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showQualityPicker(BuildContext context, WidgetRef ref, String current) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Select Quality'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
||||
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) {
|
||||
final isSelected = value == current;
|
||||
return ListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAudioQuality(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
||||
final controller = TextEditingController(text: current);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Filename Format'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '{artist} - {title}',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Available placeholders:',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'{title}, {artist}, {album}, {track}, {year}, {disc}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
ref.read(settingsProvider.notifier).setFilenameFormat(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) {
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||
}
|
||||
}
|
||||
|
||||
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Concurrent Downloads'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
|
||||
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
|
||||
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Parallel downloads may trigger rate limiting from streaming services.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
|
||||
final isSelected = value == current;
|
||||
return ListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||
|
||||
class SettingsTab extends ConsumerStatefulWidget {
|
||||
const SettingsTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsTab> createState() => _SettingsTabState();
|
||||
}
|
||||
|
||||
class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final themeSettings = ref.watch(themeProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
// Theme Section
|
||||
_buildSectionHeader(context, 'Appearance', colorScheme),
|
||||
|
||||
// Theme Mode
|
||||
ListTile(
|
||||
leading: Icon(Icons.brightness_6, color: colorScheme.primary),
|
||||
title: const Text('Theme Mode'),
|
||||
subtitle: Text(_getThemeModeName(themeSettings.themeMode)),
|
||||
onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode),
|
||||
),
|
||||
|
||||
// Dynamic Color Toggle
|
||||
SwitchListTile(
|
||||
secondary: Icon(Icons.palette, color: colorScheme.primary),
|
||||
title: const Text('Dynamic Color'),
|
||||
subtitle: const Text('Use colors from your wallpaper'),
|
||||
value: themeSettings.useDynamicColor,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
||||
),
|
||||
|
||||
// Seed Color Picker (only when dynamic color is disabled)
|
||||
if (!themeSettings.useDynamicColor)
|
||||
ListTile(
|
||||
leading: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(themeSettings.seedColorValue),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
),
|
||||
),
|
||||
title: const Text('Accent Color'),
|
||||
subtitle: const Text('Choose your preferred color'),
|
||||
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Download Section
|
||||
_buildSectionHeader(context, 'Download', colorScheme),
|
||||
|
||||
// Download Service
|
||||
ListTile(
|
||||
leading: Icon(Icons.cloud_download, color: colorScheme.primary),
|
||||
title: const Text('Default Service'),
|
||||
subtitle: Text(_getServiceName(settings.defaultService)),
|
||||
onTap: () => _showServicePicker(context, ref, settings.defaultService),
|
||||
),
|
||||
|
||||
// Audio Quality
|
||||
ListTile(
|
||||
leading: Icon(Icons.high_quality, color: colorScheme.primary),
|
||||
title: const Text('Audio Quality'),
|
||||
subtitle: Text(_getQualityName(settings.audioQuality)),
|
||||
onTap: () => _showQualityPicker(context, ref, settings.audioQuality),
|
||||
),
|
||||
|
||||
// Filename Format
|
||||
ListTile(
|
||||
leading: Icon(Icons.text_fields, color: colorScheme.primary),
|
||||
title: const Text('Filename Format'),
|
||||
subtitle: Text(settings.filenameFormat),
|
||||
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
|
||||
),
|
||||
|
||||
// Download Directory
|
||||
ListTile(
|
||||
leading: Icon(Icons.folder, color: colorScheme.primary),
|
||||
title: const Text('Download Directory'),
|
||||
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory),
|
||||
onTap: () => _pickDirectory(context, ref),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Options Section
|
||||
_buildSectionHeader(context, 'Options', colorScheme),
|
||||
|
||||
// Auto Fallback
|
||||
SwitchListTile(
|
||||
secondary: Icon(Icons.sync, color: colorScheme.primary),
|
||||
title: const Text('Auto Fallback'),
|
||||
subtitle: const Text('Try other services if download fails'),
|
||||
value: settings.autoFallback,
|
||||
onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value),
|
||||
),
|
||||
|
||||
// Embed Lyrics
|
||||
SwitchListTile(
|
||||
secondary: Icon(Icons.lyrics, color: colorScheme.primary),
|
||||
title: const Text('Embed Lyrics'),
|
||||
subtitle: const Text('Embed synced lyrics into FLAC files'),
|
||||
value: settings.embedLyrics,
|
||||
onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value),
|
||||
),
|
||||
|
||||
// Max Quality Cover
|
||||
SwitchListTile(
|
||||
secondary: Icon(Icons.image, color: colorScheme.primary),
|
||||
title: const Text('Max Quality Cover'),
|
||||
subtitle: const Text('Download highest resolution cover art'),
|
||||
value: settings.maxQualityCover,
|
||||
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
|
||||
),
|
||||
|
||||
// Concurrent Downloads
|
||||
ListTile(
|
||||
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
|
||||
title: const Text('Concurrent Downloads'),
|
||||
subtitle: Text(settings.concurrentDownloads == 1
|
||||
? 'Sequential (1 at a time)'
|
||||
: '${settings.concurrentDownloads} parallel downloads'),
|
||||
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
|
||||
),
|
||||
|
||||
// Check for Updates
|
||||
SwitchListTile(
|
||||
secondary: Icon(Icons.system_update, color: colorScheme.primary),
|
||||
title: const Text('Check for Updates'),
|
||||
subtitle: const Text('Notify when new version is available'),
|
||||
value: settings.checkForUpdates,
|
||||
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// GitHub & Credits Section
|
||||
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.code, color: colorScheme.primary),
|
||||
title: Text('${AppInfo.appName} Mobile'),
|
||||
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.computer, color: colorScheme.primary),
|
||||
title: Text('Original ${AppInfo.appName} (Desktop)'),
|
||||
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// About
|
||||
ListTile(
|
||||
leading: Icon(Icons.info, color: colorScheme.primary),
|
||||
title: const Text('About'),
|
||||
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
|
||||
onTap: () => _showAboutDialog(context),
|
||||
),
|
||||
|
||||
// Bottom padding for navigation bar
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showAboutDialog(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
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)),
|
||||
const SizedBox(width: 12),
|
||||
Text(AppInfo.appName),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildAboutRow('Version', AppInfo.version, colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppInfo.copyright,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getThemeModeName(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.light: return 'Light';
|
||||
case ThemeMode.dark: return 'Dark';
|
||||
case ThemeMode.system: return 'System';
|
||||
}
|
||||
}
|
||||
|
||||
String _getServiceName(String service) {
|
||||
switch (service) {
|
||||
case 'tidal': return 'Tidal';
|
||||
case 'qobuz': return 'Qobuz';
|
||||
case 'amazon': return 'Amazon Music';
|
||||
default: return service;
|
||||
}
|
||||
}
|
||||
|
||||
String _getQualityName(String quality) {
|
||||
switch (quality) {
|
||||
case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)';
|
||||
case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)';
|
||||
case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)';
|
||||
default: return quality;
|
||||
}
|
||||
}
|
||||
|
||||
void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Theme Mode'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme),
|
||||
_buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme),
|
||||
_buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) {
|
||||
final isSelected = mode == current;
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: isSelected ? colorScheme.primary : null),
|
||||
title: Text(label),
|
||||
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(themeProvider.notifier).setThemeMode(mode);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) {
|
||||
final colors = [
|
||||
const Color(0xFF1DB954), const Color(0xFF6750A4), const Color(0xFF0061A4),
|
||||
const Color(0xFF006E1C), const Color(0xFFBA1A1A), const Color(0xFF984061),
|
||||
const Color(0xFF7D5260), const Color(0xFF006874), const Color(0xFFFF6F00),
|
||||
];
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Choose Accent Color'),
|
||||
content: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: colors.map((color) {
|
||||
final isSelected = color.toARGB32() == currentColor;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
ref.read(themeProvider.notifier).setSeedColor(color);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Container(
|
||||
width: 48, height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: color, shape: BoxShape.circle,
|
||||
border: isSelected ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) : null,
|
||||
),
|
||||
child: isSelected ? const Icon(Icons.check, color: Colors.white) : null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showServicePicker(BuildContext context, WidgetRef ref, String current) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Select Service'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme),
|
||||
_buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme),
|
||||
_buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) {
|
||||
final isSelected = value == current;
|
||||
return ListTile(
|
||||
title: Text(label),
|
||||
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setDefaultService(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showQualityPicker(BuildContext context, WidgetRef ref, String current) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Select Quality'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
||||
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
|
||||
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) {
|
||||
final isSelected = value == current;
|
||||
return ListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAudioQuality(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
||||
final controller = TextEditingController(text: current);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Filename Format'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}')),
|
||||
const SizedBox(height: 16),
|
||||
Text('Available placeholders:', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 4),
|
||||
Text('{title}, {artist}, {album}, {track}, {year}, {disc}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
ref.read(settingsProvider.notifier).setFilenameFormat(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) {
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||
}
|
||||
}
|
||||
|
||||
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Concurrent Downloads'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
|
||||
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
|
||||
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Parallel downloads may trigger rate limiting from streaming services.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
|
||||
final isSelected = value == current;
|
||||
return ListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class CsvImportService {
|
||||
final file = File(result.files.single.path!);
|
||||
final content = await file.readAsString();
|
||||
final tracks = _parseCsv(content);
|
||||
|
||||
|
||||
if (tracks.isNotEmpty) {
|
||||
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
|
||||
}
|
||||
@@ -39,43 +39,50 @@ class CsvImportService {
|
||||
}) async {
|
||||
_log.i('Enriching metadata for ${tracks.length} tracks from Deezer...');
|
||||
final enrichedTracks = <Track>[];
|
||||
|
||||
|
||||
for (int i = 0; i < tracks.length; i++) {
|
||||
final track = tracks[i];
|
||||
onProgress?.call(i + 1, tracks.length);
|
||||
|
||||
|
||||
if (track.coverUrl == null || track.duration == 0) {
|
||||
Map<String, dynamic>? trackData;
|
||||
|
||||
|
||||
if (track.isrc != null && track.isrc!.isNotEmpty) {
|
||||
try {
|
||||
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
|
||||
_log.d('ISRC enrichment success for ${track.name}');
|
||||
} catch (e) {
|
||||
_log.w('ISRC search failed for ${track.name}, trying text search...');
|
||||
_log.w(
|
||||
'ISRC search failed for ${track.name}, trying text search...',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (trackData == null) {
|
||||
try {
|
||||
final query = '${track.artistName} ${track.name}';
|
||||
final searchResult = await PlatformBridge.searchDeezerAll(query, trackLimit: 5);
|
||||
|
||||
final searchResult = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 5,
|
||||
);
|
||||
|
||||
if (searchResult.containsKey('tracks')) {
|
||||
final tracksList = searchResult['tracks'] as List<dynamic>?;
|
||||
if (tracksList != null && tracksList.isNotEmpty) {
|
||||
for (final result in tracksList) {
|
||||
final resultMap = result as Map<String, dynamic>;
|
||||
final resultName = (resultMap['name'] as String?)?.toLowerCase() ?? '';
|
||||
final resultName =
|
||||
(resultMap['name'] as String?)?.toLowerCase() ?? '';
|
||||
final trackNameLower = track.name.toLowerCase();
|
||||
|
||||
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) {
|
||||
|
||||
if (resultName.contains(trackNameLower) ||
|
||||
trackNameLower.contains(resultName)) {
|
||||
trackData = resultMap;
|
||||
_log.d('Text search match for ${track.name}: $resultName');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (trackData == null && tracksList.isNotEmpty) {
|
||||
trackData = tracksList.first as Map<String, dynamic>;
|
||||
_log.d('Using first search result for ${track.name}');
|
||||
@@ -86,38 +93,44 @@ class CsvImportService {
|
||||
_log.w('Text search also failed for ${track.name}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (trackData != null) {
|
||||
final coverUrl = trackData['images'] as String?;
|
||||
final durationMs = trackData['duration_ms'] as int? ?? 0;
|
||||
final deezerIdRaw = trackData['spotify_id'] as String?;
|
||||
|
||||
enrichedTracks.add(Track(
|
||||
id: deezerIdRaw ?? track.id,
|
||||
name: trackData['name'] as String? ?? track.name,
|
||||
artistName: trackData['artists'] as String? ?? track.artistName,
|
||||
albumName: trackData['album_name'] as String? ?? track.albumName,
|
||||
albumArtist: trackData['album_artist'] as String?,
|
||||
coverUrl: coverUrl ?? track.coverUrl,
|
||||
isrc: trackData['isrc'] as String? ?? track.isrc,
|
||||
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
|
||||
trackNumber: trackData['track_number'] as int? ?? track.trackNumber,
|
||||
discNumber: trackData['disc_number'] as int? ?? track.discNumber,
|
||||
releaseDate: trackData['release_date'] as String? ?? track.releaseDate,
|
||||
));
|
||||
|
||||
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s');
|
||||
|
||||
|
||||
enrichedTracks.add(
|
||||
Track(
|
||||
id: deezerIdRaw ?? track.id,
|
||||
name: trackData['name'] as String? ?? track.name,
|
||||
artistName: trackData['artists'] as String? ?? track.artistName,
|
||||
albumName: trackData['album_name'] as String? ?? track.albumName,
|
||||
albumArtist: trackData['album_artist'] as String?,
|
||||
coverUrl: coverUrl ?? track.coverUrl,
|
||||
isrc: trackData['isrc'] as String? ?? track.isrc,
|
||||
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
|
||||
trackNumber:
|
||||
trackData['track_number'] as int? ?? track.trackNumber,
|
||||
discNumber: trackData['disc_number'] as int? ?? track.discNumber,
|
||||
releaseDate:
|
||||
trackData['release_date'] as String? ?? track.releaseDate,
|
||||
),
|
||||
);
|
||||
|
||||
_log.d(
|
||||
'Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s',
|
||||
);
|
||||
|
||||
if (i < tracks.length - 1) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enrichedTracks.add(track);
|
||||
}
|
||||
|
||||
|
||||
_log.i('Enrichment complete: ${enrichedTracks.length} tracks');
|
||||
return enrichedTracks;
|
||||
}
|
||||
@@ -136,8 +149,8 @@ class CsvImportService {
|
||||
final headers = _parseLine(lines[startIdx]);
|
||||
final colMap = <String, int>{};
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
String h = _cleanValue(headers[i]).toLowerCase();
|
||||
colMap[h] = i;
|
||||
String h = _cleanValue(headers[i]).toLowerCase();
|
||||
colMap[h] = i;
|
||||
}
|
||||
|
||||
_log.d('CSV Headers: ${colMap.keys.toList()}');
|
||||
@@ -147,48 +160,67 @@ class CsvImportService {
|
||||
if (line.isEmpty) continue;
|
||||
|
||||
final values = _parseLine(line);
|
||||
|
||||
|
||||
String? getVal(List<String> keys) {
|
||||
return _getValue(values, colMap, keys);
|
||||
}
|
||||
|
||||
String? trackName = getVal(['track name', 'track', 'name', 'title']);
|
||||
String? artistName = getVal(['artist name(s)', 'artist name', 'artist', 'artists']);
|
||||
String? artistName = getVal([
|
||||
'artist name(s)',
|
||||
'artist name',
|
||||
'artist',
|
||||
'artists',
|
||||
]);
|
||||
String? albumName = getVal(['album name', 'album']);
|
||||
String? isrc = getVal(['isrc']);
|
||||
String? spotifyId = getVal(['track uri', 'spotify - id', 'spotify id', 'spotify_id', 'id', 'uri']);
|
||||
String? spotifyId = getVal([
|
||||
'track uri',
|
||||
'spotify - id',
|
||||
'spotify id',
|
||||
'spotify_id',
|
||||
'id',
|
||||
'uri',
|
||||
]);
|
||||
|
||||
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
|
||||
spotifyId = spotifyId.replaceAll('spotify:track:', '');
|
||||
}
|
||||
|
||||
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) {
|
||||
tracks.add(Track(
|
||||
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
|
||||
name: trackName ?? 'Unknown Track',
|
||||
artistName: artistName ?? 'Unknown Artist',
|
||||
albumName: albumName ?? 'Unknown Album',
|
||||
isrc: isrc,
|
||||
duration: 0, // Will be updated by enrichment later
|
||||
coverUrl: null, // Will be fetched by enrichment
|
||||
));
|
||||
if ((trackName != null && trackName.isNotEmpty && artistName != null) ||
|
||||
(spotifyId != null && spotifyId.isNotEmpty)) {
|
||||
tracks.add(
|
||||
Track(
|
||||
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
|
||||
name: trackName ?? 'Unknown Track',
|
||||
artistName: artistName ?? 'Unknown Artist',
|
||||
albumName: albumName ?? 'Unknown Album',
|
||||
isrc: isrc,
|
||||
duration: 0, // Will be updated by enrichment later
|
||||
coverUrl: null, // Will be fetched by enrichment
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_log.i('Parsed ${tracks.length} tracks from CSV');
|
||||
return tracks;
|
||||
}
|
||||
|
||||
static String? _getValue(List<String> values, Map<String, int> colMap, List<String> possibleKeys) {
|
||||
for (final key in possibleKeys) {
|
||||
if (colMap.containsKey(key)) {
|
||||
final index = colMap[key]!;
|
||||
if (index < values.length) {
|
||||
return _cleanValue(values[index]);
|
||||
}
|
||||
}
|
||||
|
||||
static String? _getValue(
|
||||
List<String> values,
|
||||
Map<String, int> colMap,
|
||||
List<String> possibleKeys,
|
||||
) {
|
||||
for (final key in possibleKeys) {
|
||||
if (colMap.containsKey(key)) {
|
||||
final index = colMap[key]!;
|
||||
if (index < values.length) {
|
||||
return _cleanValue(values[index]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _cleanValue(String val) {
|
||||
@@ -201,30 +233,29 @@ class CsvImportService {
|
||||
}
|
||||
|
||||
static List<String> _parseLine(String line) {
|
||||
final List<String> result = [];
|
||||
bool inQuote = false;
|
||||
StringBuffer buffer = StringBuffer();
|
||||
|
||||
for (int i=0; i<line.length; i++) {
|
||||
String char = line[i];
|
||||
if (char == '"') {
|
||||
if (i + 1 < line.length && line[i+1] == '"') {
|
||||
buffer.write('"');
|
||||
buffer.write('"');
|
||||
i++; // Skip next quote char loop
|
||||
buffer.write('"'); // Write 2nd quote
|
||||
} else {
|
||||
inQuote = !inQuote;
|
||||
buffer.write(char);
|
||||
}
|
||||
} else if (char == ',' && !inQuote) {
|
||||
result.add(buffer.toString());
|
||||
buffer.clear();
|
||||
} else {
|
||||
buffer.write(char);
|
||||
}
|
||||
}
|
||||
result.add(buffer.toString());
|
||||
return result;
|
||||
final List<String> result = [];
|
||||
bool inQuote = false;
|
||||
var buffer = StringBuffer();
|
||||
|
||||
for (int i = 0; i < line.length; i++) {
|
||||
final char = line[i];
|
||||
if (char == '"') {
|
||||
if (inQuote && i + 1 < line.length && line[i + 1] == '"') {
|
||||
buffer.write('"');
|
||||
i++;
|
||||
} else {
|
||||
inQuote = !inQuote;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char == ',' && !inQuote) {
|
||||
result.add(buffer.toString());
|
||||
buffer = StringBuffer();
|
||||
continue;
|
||||
}
|
||||
buffer.write(char);
|
||||
}
|
||||
result.add(buffer.toString());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+58
-45
@@ -46,10 +46,11 @@ class LogBuffer extends ChangeNotifier {
|
||||
LogBuffer._internal();
|
||||
|
||||
static const int maxEntries = 500;
|
||||
static const Duration _goLogPollingInterval = Duration(milliseconds: 800);
|
||||
final Queue<LogEntry> _entries = Queue<LogEntry>();
|
||||
Timer? _goLogTimer;
|
||||
int _lastGoLogIndex = 0;
|
||||
|
||||
|
||||
static bool _loggingEnabled = false;
|
||||
static bool get loggingEnabled => _loggingEnabled;
|
||||
static set loggingEnabled(bool value) {
|
||||
@@ -68,7 +69,7 @@ class LogBuffer extends ChangeNotifier {
|
||||
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (_entries.length >= maxEntries) {
|
||||
_entries.removeFirst();
|
||||
}
|
||||
@@ -78,7 +79,7 @@ class LogBuffer extends ChangeNotifier {
|
||||
|
||||
void startGoLogPolling() {
|
||||
_goLogTimer?.cancel();
|
||||
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||
_goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
|
||||
await _fetchGoLogs();
|
||||
});
|
||||
}
|
||||
@@ -93,13 +94,13 @@ class LogBuffer extends ChangeNotifier {
|
||||
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
||||
final logs = result['logs'] as List<dynamic>? ?? [];
|
||||
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
|
||||
|
||||
|
||||
for (final log in logs) {
|
||||
final timestamp = log['timestamp'] as String? ?? '';
|
||||
final level = log['level'] as String? ?? 'INFO';
|
||||
final tag = log['tag'] as String? ?? 'Go';
|
||||
final message = log['message'] as String? ?? '';
|
||||
|
||||
|
||||
DateTime parsedTime = DateTime.now();
|
||||
if (timestamp.isNotEmpty) {
|
||||
try {
|
||||
@@ -107,25 +108,29 @@ class LogBuffer extends ChangeNotifier {
|
||||
if (parts.length >= 3) {
|
||||
final secParts = parts[2].split('.');
|
||||
parsedTime = DateTime(
|
||||
parsedTime.year, parsedTime.month, parsedTime.day,
|
||||
int.parse(parts[0]), int.parse(parts[1]),
|
||||
parsedTime.year,
|
||||
parsedTime.month,
|
||||
parsedTime.day,
|
||||
int.parse(parts[0]),
|
||||
int.parse(parts[1]),
|
||||
int.parse(secParts[0]),
|
||||
secParts.length > 1 ? int.parse(secParts[1]) : 0,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
add(LogEntry(
|
||||
timestamp: parsedTime,
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
isFromGo: true,
|
||||
));
|
||||
|
||||
add(
|
||||
LogEntry(
|
||||
timestamp: parsedTime,
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
isFromGo: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
_lastGoLogIndex = nextIndex;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
@@ -156,27 +161,31 @@ class LogBuffer extends ChangeNotifier {
|
||||
|
||||
Future<String> exportWithDeviceInfo() async {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln('SPOTIFLAC LOG EXPORT');
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln();
|
||||
|
||||
|
||||
buffer.writeln('--- App Information ---');
|
||||
buffer.writeln('App Version: ${AppInfo.version} (Build ${AppInfo.buildNumber})');
|
||||
buffer.writeln(
|
||||
'App Version: ${AppInfo.version} (Build ${AppInfo.buildNumber})',
|
||||
);
|
||||
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln();
|
||||
|
||||
|
||||
buffer.writeln('--- Device Information ---');
|
||||
try {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final android = await deviceInfo.androidInfo;
|
||||
buffer.writeln('Platform: Android');
|
||||
buffer.writeln('Device: ${android.manufacturer} ${android.model}');
|
||||
buffer.writeln('Brand: ${android.brand}');
|
||||
buffer.writeln('Android Version: ${android.version.release} (SDK ${android.version.sdkInt})');
|
||||
buffer.writeln(
|
||||
'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})',
|
||||
);
|
||||
buffer.writeln('Device ID: ${android.id}');
|
||||
buffer.writeln('Hardware: ${android.hardware}');
|
||||
buffer.writeln('Product: ${android.product}');
|
||||
@@ -196,16 +205,16 @@ class LogBuffer extends ChangeNotifier {
|
||||
buffer.writeln('Failed to get device info: $e');
|
||||
}
|
||||
buffer.writeln();
|
||||
|
||||
|
||||
buffer.writeln('--- Log Summary ---');
|
||||
buffer.writeln('Total Entries: ${_entries.length}');
|
||||
|
||||
|
||||
int errorCount = 0;
|
||||
int warnCount = 0;
|
||||
int infoCount = 0;
|
||||
int debugCount = 0;
|
||||
int goCount = 0;
|
||||
|
||||
|
||||
for (final entry in _entries) {
|
||||
switch (entry.level) {
|
||||
case 'ERROR':
|
||||
@@ -224,23 +233,23 @@ class LogBuffer extends ChangeNotifier {
|
||||
}
|
||||
if (entry.isFromGo) goCount++;
|
||||
}
|
||||
|
||||
|
||||
buffer.writeln('Errors: $errorCount');
|
||||
buffer.writeln('Warnings: $warnCount');
|
||||
buffer.writeln('Info: $infoCount');
|
||||
buffer.writeln('Debug: $debugCount');
|
||||
buffer.writeln('From Go Backend: $goCount');
|
||||
buffer.writeln();
|
||||
|
||||
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln('LOG ENTRIES');
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln();
|
||||
|
||||
|
||||
for (final entry in _entries) {
|
||||
buffer.writeln(entry.toString());
|
||||
}
|
||||
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@@ -280,13 +289,15 @@ class BufferedOutput extends LogOutput {
|
||||
|
||||
final level = _levelToString(event.level);
|
||||
final message = event.lines.join('\n');
|
||||
|
||||
LogBuffer().add(LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
));
|
||||
|
||||
LogBuffer().add(
|
||||
LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _levelToString(Level level) {
|
||||
@@ -336,13 +347,15 @@ class AppLogger {
|
||||
}
|
||||
|
||||
void _addToBuffer(String level, String message, {String? error}) {
|
||||
LogBuffer().add(LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: _tag,
|
||||
message: message,
|
||||
error: error,
|
||||
));
|
||||
LogBuffer().add(
|
||||
LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: _tag,
|
||||
message: message,
|
||||
error: error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void d(String message) {
|
||||
|
||||
Reference in New Issue
Block a user