Files
SpotiFLAC-Mobile/lib/screens/settings/library_settings_page.dart
T
zarzet 728d179d69 refactor(discord): remove rich presence and bundled SDK
Remove the presence setting, platform bridge, SDK archive, and SDK preparation from local and CI builds. Reject leftover Discord native libraries in release APK audits.
2026-09-26 03:07:04 +07:00

1495 lines
53 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_switch.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
class LibrarySettingsPage extends ConsumerStatefulWidget {
const LibrarySettingsPage({super.key});
@override
ConsumerState<LibrarySettingsPage> createState() =>
_LibrarySettingsPageState();
}
class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
int _androidSdkVersion = 0;
bool _hasStoragePermission = false;
String _getDisplayPath(String path) {
if (!path.startsWith('content://')) return path;
try {
final uri = Uri.parse(path);
final treePath = uri.pathSegments.last;
final decoded = Uri.decodeComponent(treePath);
if (decoded.startsWith('primary:')) {
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
}
return decoded;
} catch (_) {
return path;
}
}
@override
void initState() {
super.initState();
_initDeviceInfo();
}
Future<void> _initDeviceInfo() async {
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
final sdkVersion = androidInfo.version.sdkInt;
if (mounted) {
setState(() {
_androidSdkVersion = sdkVersion;
_hasStoragePermission = sdkVersion >= 29 ? true : false;
});
if (sdkVersion < 29) {
final hasPermission = await Permission.storage.isGranted;
if (mounted) {
setState(() => _hasStoragePermission = hasPermission);
}
}
}
} else if (Platform.isIOS) {
setState(() => _hasStoragePermission = true);
} else {
setState(() => _hasStoragePermission = true);
}
}
Future<bool> _requestStoragePermission() async {
if (!Platform.isAndroid) return true;
if (_androidSdkVersion >= 29) return true;
final status = await Permission.storage.request();
if (status.isGranted) {
setState(() => _hasStoragePermission = true);
return true;
} else if (status.isPermanentlyDenied) {
if (mounted) {
final shouldOpen = await showAppDialog<bool>(
context: context,
builder: (context) => AppAlertDialog(
title: Text(context.l10n.libraryStorageAccessRequired),
content: Text(context.l10n.libraryStorageAccessMessage),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDefault: true,
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.setupOpenSettings),
),
],
),
);
if (shouldOpen == true) {
await openAppSettings();
}
}
}
return false;
}
Future<void> _pickLibraryFolder() async {
if (Platform.isAndroid && _androidSdkVersion >= 29) {
// Use SAF tree picker - no MANAGE_EXTERNAL_STORAGE needed
final result = await PlatformBridge.pickSafTree();
if (result != null) {
final treeUri = result['tree_uri'] as String? ?? '';
if (treeUri.isNotEmpty) {
final source = await ref
.read(localLibraryProvider.notifier)
.addSource(
path: treeUri,
displayName:
result['display_name'] as String? ??
_getDisplayPath(treeUri),
volumeId: result['volume_id'] as String?,
isRemovable: result['is_removable'] == true,
);
await ref
.read(localLibraryProvider.notifier)
.startSourceScan(source.id);
}
}
} else {
// Legacy: request permission and use file picker for older Android / iOS
if (!_hasStoragePermission) {
final granted = await _requestStoragePermission();
if (!granted) return;
}
if (Platform.isIOS) {
IosPickedDirectory? picked;
try {
picked = await PlatformBridge.pickIosDirectory();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarFolderPickerFailed(
context.friendlyError(e),
),
),
),
);
}
return;
}
if (picked != null) {
final source = await ref
.read(localLibraryProvider.notifier)
.addSource(
path: picked.path,
displayName: picked.path,
bookmark: picked.bookmark,
);
await ref
.read(localLibraryProvider.notifier)
.startSourceScan(source.id);
}
return;
}
final result = await FilePicker.getDirectoryPath();
if (result != null) {
final source = await ref
.read(localLibraryProvider.notifier)
.addSource(path: result, displayName: result);
await ref
.read(localLibraryProvider.notifier)
.startSourceScan(source.id);
}
}
}
Future<void> _startScan({bool forceFullScan = false}) async {
final sources = ref.read(localLibraryProvider).sources;
if (sources.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.libraryScanSelectFolderFirst)),
);
return;
}
await ref
.read(localLibraryProvider.notifier)
.scanAllSources(forceFullScan: forceFullScan);
}
Future<void> _cancelScan() async {
await ref.read(localLibraryProvider.notifier).cancelScan();
}
Future<void> _clearLibrary() async {
final confirmed = await showAppDialog<bool>(
context: context,
builder: (context) => AppAlertDialog(
title: Text(context.l10n.libraryClearConfirmTitle),
content: Text(context.l10n.libraryClearConfirmMessage),
actions: [
AppDialogAction(
isDefault: true,
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDestructive: true,
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogClear),
),
],
),
);
if (confirmed == true) {
await ref.read(localLibraryProvider.notifier).clearLibrary();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.libraryCleared)));
}
}
}
Future<void> _cleanupMissingFiles() async {
final removed = await ref
.read(localLibraryProvider.notifier)
.cleanupMissingFiles();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.libraryRemovedMissingFiles(removed)),
),
);
}
}
Future<void> _removeSource(LocalLibrarySource source) async {
final confirmed = await showAppDialog<bool>(
context: context,
builder: (context) => AppAlertDialog(
title: Text(context.l10n.libraryRemoveFolder),
content: Text(context.l10n.libraryRemoveFolderMessage),
actions: [
AppDialogAction(
isDefault: true,
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDestructive: true,
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogRemove),
),
],
),
);
if (confirmed != true) return;
await ref.read(localLibraryProvider.notifier).removeSource(source.id);
final settings = ref.read(settingsProvider);
if (settings.localLibraryPath == source.path) {
ref
.read(settingsProvider.notifier)
.setLocalLibraryPathAndBookmark('', '');
}
}
String _getAutoScanLabel(BuildContext context, String mode) {
switch (mode) {
case 'on_open':
return context.l10n.libraryAutoScanOnOpen;
case 'daily':
return context.l10n.libraryAutoScanDaily;
case 'weekly':
return context.l10n.libraryAutoScanWeekly;
default:
return context.l10n.libraryAutoScanOff;
}
}
void _showAutoScanPicker(BuildContext context, String current) {
final colorScheme = Theme.of(context).colorScheme;
showAppModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.libraryAutoScan,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.libraryAutoScanSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
_AutoScanOption(
icon: Icons.block,
title: context.l10n.libraryAutoScanOff,
selected: current == 'off',
colorScheme: colorScheme,
onTap: () {
ref
.read(settingsProvider.notifier)
.setLocalLibraryAutoScan('off');
Navigator.pop(context);
},
),
_AutoScanOption(
icon: Icons.open_in_new,
title: context.l10n.libraryAutoScanOnOpen,
selected: current == 'on_open',
colorScheme: colorScheme,
onTap: () {
ref
.read(settingsProvider.notifier)
.setLocalLibraryAutoScan('on_open');
Navigator.pop(context);
},
),
_AutoScanOption(
icon: Icons.today,
title: context.l10n.libraryAutoScanDaily,
selected: current == 'daily',
colorScheme: colorScheme,
onTap: () {
ref
.read(settingsProvider.notifier)
.setLocalLibraryAutoScan('daily');
Navigator.pop(context);
},
),
_AutoScanOption(
icon: Icons.date_range,
title: context.l10n.libraryAutoScanWeekly,
selected: current == 'weekly',
colorScheme: colorScheme,
onTap: () {
ref
.read(settingsProvider.notifier)
.setLocalLibraryAutoScan('weekly');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
String _getDefaultViewLabel(BuildContext context, String view) {
switch (view) {
case 'all':
return context.l10n.historyFilterAll;
case 'albums':
return context.l10n.historyFilterAlbums;
case 'singles':
return context.l10n.historyFilterSingles;
case 'playlists':
return context.l10n.searchPlaylists;
default:
return context.l10n.libraryDefaultViewLastUsed;
}
}
void _showDefaultViewPicker(BuildContext context, String current) {
final colorScheme = Theme.of(context).colorScheme;
final options = [
('last', Icons.history, context.l10n.libraryDefaultViewLastUsed),
('all', Icons.apps, context.l10n.historyFilterAll),
('albums', Icons.album, context.l10n.historyFilterAlbums),
('singles', Icons.music_note, context.l10n.historyFilterSingles),
('playlists', Icons.queue_music, context.l10n.searchPlaylists),
];
showAppModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
child: Text(
context.l10n.libraryDefaultView,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
for (final (value, icon, label) in options)
_AutoScanOption(
icon: icon,
title: label,
selected: current == value,
colorScheme: colorScheme,
onTap: () {
ref
.read(settingsProvider.notifier)
.setDefaultLibraryView(value);
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
String _getQualityLabelModeLabel(BuildContext context, String mode) {
if (mode == AppSettings.libraryQualityLabelFileFormat) {
return context.l10n.libraryQualityLabelFileFormat;
}
if (mode == AppSettings.libraryQualityLabelBitDepthOnly) {
return context.l10n.audioAnalysisBitDepth;
}
if (mode == AppSettings.libraryQualityLabelBitDepth) {
return '${context.l10n.audioAnalysisBitDepth} & '
'${context.l10n.audioAnalysisSampleRate}';
}
if (mode == AppSettings.libraryQualityLabelBitDepthBitrate) {
return '${context.l10n.audioAnalysisBitDepth} & '
'${context.l10n.trackConvertBitrate}';
}
return context.l10n.trackConvertBitrate;
}
void _showQualityLabelModePicker(BuildContext context, String current) {
final colorScheme = Theme.of(context).colorScheme;
final options = [
(
AppSettings.libraryQualityLabelBitrate,
Icons.speed_rounded,
context.l10n.trackConvertBitrate,
),
(
AppSettings.libraryQualityLabelBitDepthOnly,
Icons.tune_rounded,
context.l10n.audioAnalysisBitDepth,
),
(
AppSettings.libraryQualityLabelBitDepth,
Icons.graphic_eq_rounded,
'${context.l10n.audioAnalysisBitDepth} & '
'${context.l10n.audioAnalysisSampleRate}',
),
(
AppSettings.libraryQualityLabelBitDepthBitrate,
Icons.multiline_chart_rounded,
'${context.l10n.audioAnalysisBitDepth} & '
'${context.l10n.trackConvertBitrate}',
),
(
AppSettings.libraryQualityLabelFileFormat,
Icons.audio_file_rounded,
context.l10n.libraryQualityLabelFileFormat,
),
];
showAppModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
child: Text(
context.l10n.trackAudioQuality,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
for (final (value, icon, label) in options)
_AutoScanOption(
icon: icon,
title: label,
selected: current == value,
colorScheme: colorScheme,
onTap: () {
ref
.read(settingsProvider.notifier)
.setLibraryQualityLabelMode(value);
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
final librarySources = ref.watch(
localLibraryProvider.select((state) => state.sources),
);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: CustomScrollView(
slivers: [
AppSliverHeader.page(title: context.l10n.libraryTitle),
// Scan snapshots land ~2.5x/s; keep the per-snapshot rebuild scoped
// to the widgets that actually display scan state instead of the
// whole settings page.
SliverToBoxAdapter(
child: Consumer(
builder: (context, ref, _) {
final libraryState = ref.watch(localLibraryProvider);
return _LibraryHeroCard(
itemCount: libraryState.totalCount,
excludedDownloadedCount: libraryState.excludedDownloadedCount,
isScanning: libraryState.isScanning,
scanIsFinalizing: libraryState.scanIsFinalizing,
scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile,
scanTotalFiles: libraryState.scanTotalFiles,
scannedFiles: libraryState.scannedFiles,
lastScannedAt: libraryState.lastScannedAt,
);
},
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.grid_view_rounded,
title: context.l10n.libraryDefaultView,
subtitle: _getDefaultViewLabel(
context,
settings.defaultLibraryView,
),
onTap: () => _showDefaultViewPicker(
context,
settings.defaultLibraryView,
),
),
SettingsItem(
icon: Icons.graphic_eq_rounded,
title: context.l10n.trackAudioQuality,
subtitle: _getQualityLabelModeLabel(
context,
settings.libraryQualityLabelMode,
),
onTap: () => _showQualityLabelModePicker(
context,
settings.libraryQualityLabelMode,
),
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.libraryScanSettings,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.library_music_outlined,
title: context.l10n.libraryEnableLocalLibrary,
subtitle: settings.localLibraryEnabled
? context.l10n.libraryEnableLocalLibrarySubtitle
: context.l10n.extensionsDisabled,
value: settings.localLibraryEnabled,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setLocalLibraryEnabled(value),
),
for (final source in librarySources)
Consumer(
builder: (context, ref, _) {
final scan = ref.watch(
localLibraryProvider.select((state) {
final active = state.scanningSourceId == source.id;
return (
active: active,
finalizing: active && state.scanIsFinalizing,
scanned: active ? state.scannedFiles : 0,
total: active ? state.scanTotalFiles : 0,
progress: active ? state.scanProgress : 0.0,
);
}),
);
return _LibrarySourceSettingsItem(
source: source,
isScanning: scan.active,
isFinalizing: scan.finalizing,
scannedFiles: scan.scanned,
totalFiles: scan.total,
progress: scan.progress,
enabled: settings.localLibraryEnabled,
onEnabledChanged: (value) => ref
.read(localLibraryProvider.notifier)
.setSourceEnabled(source.id, value),
onScan: () => ref
.read(localLibraryProvider.notifier)
.startSourceScan(source.id),
onFullScan: () => ref
.read(localLibraryProvider.notifier)
.startSourceScan(source.id, forceFullScan: true),
onRemove: () => _removeSource(source),
);
},
),
Opacity(
opacity: settings.localLibraryEnabled ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.create_new_folder_outlined,
title: context.l10n.libraryAddFolder,
subtitle: librarySources.isEmpty
? context.l10n.libraryFolderHint
: context.l10n.libraryAddFolderSubtitle,
onTap: settings.localLibraryEnabled
? _pickLibraryFolder
: null,
),
),
SettingsSwitchItem(
icon: Icons.content_copy_outlined,
title: context.l10n.libraryShowDuplicateIndicator,
subtitle: settings.localLibraryShowDuplicates
? context.l10n.libraryShowDuplicateIndicatorSubtitle
: context.l10n.extensionsDisabled,
value: settings.localLibraryShowDuplicates,
enabled: settings.localLibraryEnabled,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setLocalLibraryShowDuplicates(value),
),
SettingsItem(
icon: Icons.difference_outlined,
title: context.l10n.libraryReviewDuplicates,
subtitle: context.l10n.libraryReviewDuplicatesSubtitle,
onTap: () => DuplicateReviewSheet.show(context),
),
Opacity(
opacity: settings.localLibraryEnabled ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.autorenew_rounded,
title: context.l10n.libraryAutoScan,
subtitle: _getAutoScanLabel(
context,
settings.localLibraryAutoScan,
),
onTap: settings.localLibraryEnabled
? () => _showAutoScanPicker(
context,
settings.localLibraryAutoScan,
)
: null,
showDivider: false,
),
),
],
),
),
if (settings.localLibraryEnabled) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.libraryActions),
),
if (ref.watch(
localLibraryProvider.select((s) => s.scanWasCancelled),
))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(
alpha: 0.6,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.warning_amber_outlined,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.libraryScanCancelled,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onTertiaryContainer,
),
),
const SizedBox(height: 2),
Text(
context.l10n.libraryScanCancelledSubtitle,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onTertiaryContainer
.withValues(alpha: 0.8),
),
),
],
),
),
TextButton(
onPressed: _startScan,
child: Text(context.l10n.dialogRetry),
),
],
),
),
),
),
SliverToBoxAdapter(
child: Consumer(
builder: (context, ref, _) {
final libraryState = ref.watch(localLibraryProvider);
return SettingsGroup(
children: [
if (libraryState.isScanning)
_ScanProgressTile(
isFinalizing: libraryState.scanIsFinalizing,
progress: libraryState.scanProgress,
currentFile: libraryState.scanCurrentFile,
scannedFiles: libraryState.scannedFiles,
totalFiles: libraryState.scanTotalFiles,
onCancel: _cancelScan,
)
else ...[
Opacity(
opacity: librarySources.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.refresh,
title: context.l10n.libraryScan,
subtitle: librarySources.isEmpty
? context.l10n.libraryScanSelectFolderFirst
: context.l10n.libraryScanSubtitle,
onTap: librarySources.isNotEmpty
? _startScan
: null,
),
),
Opacity(
opacity: librarySources.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.sync,
title: context.l10n.libraryForceFullScan,
subtitle: context.l10n.libraryForceFullScanSubtitle,
onTap: librarySources.isNotEmpty
? () => _startScan(forceFullScan: true)
: null,
),
),
],
Opacity(
opacity: libraryState.totalCount > 0 ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.cleaning_services_outlined,
title: context.l10n.libraryCleanupMissingFiles,
subtitle:
context.l10n.libraryCleanupMissingFilesSubtitle,
onTap: libraryState.totalCount > 0
? _cleanupMissingFiles
: null,
),
),
Opacity(
opacity: libraryState.totalCount > 0 ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.delete_outline,
title: context.l10n.libraryClear,
subtitle: context.l10n.libraryClearSubtitle,
onTap: libraryState.totalCount > 0
? _clearLibrary
: null,
showDivider: false,
),
),
],
);
},
),
),
],
SliverToBoxAdapter(
child: SettingsInfoCard(
glass: true,
icon: Icons.info_outline,
title: context.l10n.libraryAbout,
message: context.l10n.libraryAboutDescription,
margin: const EdgeInsets.fromLTRB(16, 16, 16, 8),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.libraryPlayback),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.open_in_new,
title: context.l10n.libraryExternalPlayer,
subtitle: context.l10n.libraryExternalPlayerSubtitle,
trailing: settings.playerMode == 'external'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () => ref
.read(settingsProvider.notifier)
.setPlayerMode('external'),
),
SettingsItem(
icon: Icons.play_circle_outline,
title: context.l10n.libraryBuiltInPreviewPlayer,
subtitle: context.l10n.libraryBuiltInPreviewPlayerSubtitle,
trailing: settings.playerMode == 'internal'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () => ref
.read(settingsProvider.notifier)
.setPlayerMode('internal'),
),
SettingsSwitchItem(
icon: Icons.graphic_eq,
title: context.l10n.libraryPlaybackNormalization,
subtitle: context.l10n.libraryPlaybackNormalizationSubtitle,
value: settings.playbackNormalization,
onChanged: (v) => ref
.read(settingsProvider.notifier)
.setPlaybackNormalization(v),
),
SettingsSwitchItem(
icon: Icons.compare_arrows,
title: 'AutoMix',
subtitle: context.l10n.autoMixDescription,
value: settings.autoMix,
onChanged: (value) =>
ref.read(settingsProvider.notifier).setAutoMix(value),
showDivider: false,
),
],
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
}
class _LibrarySourceSettingsItem extends StatelessWidget {
final LocalLibrarySource source;
final bool isScanning;
final bool isFinalizing;
final int scannedFiles;
final int totalFiles;
final double progress;
final bool enabled;
final ValueChanged<bool> onEnabledChanged;
final VoidCallback onScan;
final VoidCallback onFullScan;
final VoidCallback onRemove;
const _LibrarySourceSettingsItem({
required this.source,
required this.isScanning,
required this.isFinalizing,
required this.scannedFiles,
required this.totalFiles,
required this.progress,
required this.enabled,
required this.onEnabledChanged,
required this.onScan,
required this.onFullScan,
required this.onRemove,
});
String _title() {
final normalized = source.displayName
.replaceAll('\\', '/')
.replaceAll(RegExp(r'/+$'), '');
final name = normalized.split('/').last.trim();
return name.isEmpty ? source.displayName : name;
}
String _lastScanned(BuildContext context) {
final value = source.lastScannedAt;
if (value == null) return context.l10n.libraryLastScannedNever;
final now = DateTime.now();
final diff = now.difference(value);
if (diff.inMinutes < 1) return context.l10n.timeJustNow;
if (diff.inHours < 1) return context.l10n.timeMinutesAgo(diff.inMinutes);
if (diff.inDays < 1) return context.l10n.timeHoursAgo(diff.inHours);
if (diff.inDays < 7) return context.l10n.dateDaysAgo(diff.inDays);
return '${value.day}/${value.month}/${value.year}';
}
Future<void> _showActions(BuildContext context) async {
final canScan =
enabled && source.enabled && source.available && !isScanning;
final action = await showAppBottomSheet<String>(
context: context,
useRootNavigator: true,
title: _title(),
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
AppSheetOption(
enabled: canScan,
leading: const Icon(Icons.refresh_rounded),
title: Text(context.l10n.libraryScan),
subtitle: Text(context.l10n.libraryScanSubtitle),
onTap: canScan ? () => Navigator.pop(context, 'scan') : null,
),
AppSheetOption(
enabled: canScan,
leading: const Icon(Icons.sync_rounded),
title: Text(context.l10n.libraryForceFullScan),
subtitle: Text(context.l10n.libraryForceFullScanSubtitle),
onTap: canScan ? () => Navigator.pop(context, 'full_scan') : null,
),
AppSheetOption(
leading: Icon(
Icons.delete_outline,
color: Theme.of(context).colorScheme.error,
),
title: Text(
context.l10n.libraryRemoveFolder,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onTap: () => Navigator.pop(context, 'remove'),
),
const SizedBox(height: 8),
],
),
);
switch (action) {
case 'scan':
onScan();
case 'full_scan':
onFullScan();
case 'remove':
onRemove();
}
}
@override
Widget build(BuildContext context) {
final title = _title();
final status = !source.enabled
? context.l10n.librarySourceDisabled
: !source.available
? context.l10n.librarySourceOffline
: isScanning
? isFinalizing
? context.l10n.libraryScanFinalizing
: totalFiles > 0
? context.l10n.librarySourceScanCount(
scannedFiles,
totalFiles,
progress.toStringAsFixed(0),
)
: context.l10n.libraryScanning
: source.lastScanError?.trim().isNotEmpty == true
? context.l10n.notifLibraryScanFailed
: context.l10n.librarySourceOnline;
final pathLine = source.displayName == title ? null : source.displayName;
final details = [
status,
context.l10n.libraryTracksUnit(source.trackCount),
context.l10n.libraryLastScanned(_lastScanned(context)),
].join(' · ');
return Opacity(
opacity: enabled ? 1 : 0.5,
child: SettingsItem(
icon: source.isRemovable ? Icons.usb_rounded : Icons.folder_outlined,
title: title,
titleTrailing: source.isRemovable
? Tooltip(
message: context.l10n.libraryExternalStorage,
child: const Icon(Icons.sd_storage_outlined, size: 16),
)
: null,
subtitle: pathLine == null ? details : '$pathLine\n$details',
onTap: () => _showActions(context),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isScanning)
Padding(
padding: const EdgeInsets.only(right: 8),
child: _AnimatedScanCount(
count: scannedFiles,
progress: progress,
determinate: totalFiles > 0 && !isFinalizing,
),
),
AppSwitch(
adaptive: true,
semanticLabel: title,
value: source.enabled,
onChanged: enabled ? onEnabledChanged : null,
),
],
),
),
);
}
}
class _AnimatedScanCount extends StatelessWidget {
final int count;
final double progress;
final bool determinate;
const _AnimatedScanCount({
required this.count,
required this.progress,
required this.determinate,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SizedBox(
width: 42,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
value: determinate ? (progress / 100).clamp(0.0, 1.0) : null,
strokeWidth: 2,
),
),
const SizedBox(height: 2),
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: count.toDouble()),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
builder: (context, value, _) => Text(
value.round().toString(),
maxLines: 1,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
),
);
}
}
class _LibraryHeroCard extends StatelessWidget {
final int itemCount;
final int excludedDownloadedCount;
final bool isScanning;
final bool scanIsFinalizing;
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
final int scannedFiles;
final DateTime? lastScannedAt;
const _LibraryHeroCard({
required this.itemCount,
required this.excludedDownloadedCount,
required this.isScanning,
required this.scanIsFinalizing,
required this.scanProgress,
this.scanCurrentFile,
required this.scanTotalFiles,
required this.scannedFiles,
this.lastScannedAt,
});
String _formatLastScanned(BuildContext context) {
if (lastScannedAt == null) return context.l10n.libraryLastScannedNever;
final now = DateTime.now();
final diff = now.difference(lastScannedAt!);
if (diff.inMinutes < 1) return context.l10n.timeJustNow;
if (diff.inHours < 1) return context.l10n.timeMinutesAgo(diff.inMinutes);
if (diff.inDays < 1) return context.l10n.timeHoursAgo(diff.inHours);
if (diff.inDays < 7) return context.l10n.dateDaysAgo(diff.inDays);
return '${lastScannedAt!.day}/${lastScannedAt!.month}/${lastScannedAt!.year}';
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final showIndeterminateProgress =
isScanning &&
(scanIsFinalizing ||
scanTotalFiles <= 0 ||
(scannedFiles <= 0 && scanProgress <= 0));
final displayCount = isScanning
? scannedFiles
: itemCount + excludedDownloadedCount;
return Container(
margin: EdgeInsets.symmetric(
horizontal: 16 + wideListInset(context),
vertical: 8,
),
constraints: const BoxConstraints(minHeight: 220),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
boxShadow: [
if (!isDark)
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Positioned(
right: -20,
top: -20,
child: Icon(
Icons.library_music,
size: 200,
color: colorScheme.primary.withValues(alpha: 0.05),
),
),
Positioned(
left: -40,
bottom: -40,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
),
),
),
Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
isScanning ? Icons.sync : Icons.music_note,
color: colorScheme.onPrimaryContainer,
size: 32,
),
),
const Spacer(),
if (isScanning)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
),
const SizedBox(width: 8),
Text(
context.l10n.libraryScanning,
style: TextStyle(
color: colorScheme.onPrimary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 16),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
displayCount.toString(),
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
height: 1.0,
letterSpacing: -2,
),
),
),
const SizedBox(height: 4),
Text(
isScanning
? context.l10n.libraryFilesUnit(scannedFiles)
: context.l10n.libraryTracksUnit(displayCount),
style: TextStyle(
fontSize: 16,
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
if (!isScanning && excludedDownloadedCount > 0) ...[
const SizedBox(height: 4),
Text(
context.l10n.libraryDownloadsHistoryExcluded(
excludedDownloadedCount,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.8,
),
),
),
],
if (isScanning) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
value: showIndeterminateProgress
? null
: scanProgress / 100,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 8),
Text(
scanIsFinalizing
? context.l10n.libraryScanFinalizing
: scanTotalFiles > 0
? context.l10n.libraryScanProgress(
scanProgress.toStringAsFixed(0),
scanTotalFiles,
)
: context.l10n.libraryScanning,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.8,
),
),
),
if (!scanIsFinalizing &&
scanCurrentFile != null &&
scanCurrentFile!.trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
scanCurrentFile!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
),
),
],
] else ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.history,
size: 14,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
),
const SizedBox(width: 6),
Text(
context.l10n.libraryLastScanned(
_formatLastScanned(context),
),
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
),
),
],
),
],
],
),
),
],
),
);
}
}
class _ScanProgressTile extends StatelessWidget {
final bool isFinalizing;
final double progress;
final String? currentFile;
final int scannedFiles;
final int totalFiles;
final VoidCallback onCancel;
const _ScanProgressTile({
required this.isFinalizing,
required this.progress,
this.currentFile,
required this.scannedFiles,
required this.totalFiles,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final showIndeterminateProgress =
isFinalizing || totalFiles <= 0 || (scannedFiles <= 0 && progress <= 0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.scanner, color: colorScheme.primary),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.libraryScanning,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
isFinalizing
? context.l10n.libraryScanFinalizing
: totalFiles > 0
? context.l10n.libraryScanProgress(
progress.toStringAsFixed(0),
totalFiles,
)
: context.l10n.libraryScanning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
TextButton(
onPressed: onCancel,
child: Text(context.l10n.actionCancel),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: showIndeterminateProgress ? null : progress / 100,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
if (!isFinalizing &&
currentFile != null &&
currentFile!.trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
currentFile!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}
}
class _AutoScanOption extends StatelessWidget {
final IconData icon;
final String title;
final bool selected;
final ColorScheme colorScheme;
final VoidCallback onTap;
const _AutoScanOption({
required this.icon,
required this.title,
required this.selected,
required this.colorScheme,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return AppSheetOption(
leading: Icon(icon),
title: Text(title),
trailing: selected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: onTap,
);
}
}