Files
SpotiFLAC-Mobile/lib/screens/settings/library_settings_page.dart
T
zarzet 63e1a9b9c8 feat(ui): add Mornye glass theme and adaptive music player
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components.

Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures.

Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
2026-09-19 12:10:10 +07:00

1554 lines
55 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/discord_presence_service.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: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.libraryAbout,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 4),
Text(
context.l10n.libraryAboutDescription,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onPrimaryContainer
.withValues(alpha: 0.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),
showDivider: Platform.isAndroid,
),
if (Platform.isAndroid)
ValueListenableBuilder<String>(
valueListenable: DiscordPresenceService.instance.status,
builder: (context, status, _) => SettingsSwitchItem(
icon: Icons.share_outlined,
title: context.l10n.discordRichPresence,
subtitle: status == 'discord_missing'
? context.l10n.discordPresenceMissing
: status == 'sdk_unavailable' ||
status == 'unavailable'
? context.l10n.discordPresenceUnavailable
: context.l10n.discordPresenceDescription,
value: settings.discordRichPresence,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setDiscordRichPresence(value),
showDivider: false,
),
),
],
),
),
SliverToBoxAdapter(
child: SettingsInfoCard(
icon: Icons.info_outline,
tone: SettingsInfoTone.warning,
message: context.l10n.libraryBuiltInPlayerInfo,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
),
),
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,
);
}
}