mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
307 lines
10 KiB
Dart
307 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
|
import 'package:spotiflac_android/services/library_database.dart';
|
|
import 'package:spotiflac_android/utils/file_access.dart';
|
|
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
|
|
|
/// Bottom sheet listing tracks that exist more than once (same ISRC) across
|
|
/// download history and the local library, with keep-best and per-copy
|
|
/// delete actions.
|
|
class DuplicateReviewSheet extends ConsumerStatefulWidget {
|
|
const DuplicateReviewSheet({super.key});
|
|
|
|
static Future<void> show(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return showModalBottomSheet<void>(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
isScrollControlled: true,
|
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
|
),
|
|
builder: (_) => const DuplicateReviewSheet(),
|
|
);
|
|
}
|
|
|
|
@override
|
|
ConsumerState<DuplicateReviewSheet> createState() =>
|
|
_DuplicateReviewSheetState();
|
|
}
|
|
|
|
class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
|
|
late Future<List<IsrcDuplicateGroup>> _groupsFuture;
|
|
bool _deletedLocalRows = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_groupsFuture = LibraryDatabase.instance.findIsrcDuplicateGroups();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (_deletedLocalRows) {
|
|
ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
void _reload() {
|
|
setState(() {
|
|
_groupsFuture = LibraryDatabase.instance.findIsrcDuplicateGroups();
|
|
});
|
|
}
|
|
|
|
String _qualityLabel(IsrcDuplicateEntry entry) {
|
|
var format = entry.format?.trim().toUpperCase() ?? '';
|
|
if (format.isEmpty) {
|
|
format = p
|
|
.extension(
|
|
DownloadedEmbeddedCoverResolver.cleanFilePath(entry.filePath),
|
|
)
|
|
.replaceFirst('.', '')
|
|
.toUpperCase();
|
|
}
|
|
final bitDepth = entry.bitDepth ?? 0;
|
|
final sampleRate = entry.sampleRate ?? 0;
|
|
if (bitDepth > 0 && sampleRate > 0) {
|
|
final khz = sampleRate / 1000;
|
|
final khzText = khz % 1 == 0 ? khz.toInt().toString() : khz.toString();
|
|
return format.isEmpty ? '$bitDepth/$khzText' : '$bitDepth/$khzText $format';
|
|
}
|
|
final bitrate = entry.bitrate ?? 0;
|
|
if (bitrate > 0) {
|
|
return '${(bitrate / 1000).round()}k${format.isEmpty ? '' : ' $format'}';
|
|
}
|
|
return format;
|
|
}
|
|
|
|
Future<bool> _confirmDelete(String message) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(ctx.l10n.dialogDeleteSelectedTitle),
|
|
content: Text(message),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(ctx.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
|
),
|
|
child: Text(ctx.l10n.dialogDelete),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return confirmed == true && mounted;
|
|
}
|
|
|
|
Future<void> _deleteEntries(List<IsrcDuplicateEntry> entries) async {
|
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
|
var deleted = 0;
|
|
for (final entry in entries) {
|
|
try {
|
|
await deleteFile(
|
|
DownloadedEmbeddedCoverResolver.cleanFilePath(entry.filePath),
|
|
);
|
|
} catch (_) {}
|
|
if (entry.source == 'downloaded') {
|
|
historyNotifier.removeFromHistory(entry.id);
|
|
} else {
|
|
await LibraryDatabase.instance.deleteByPath(entry.filePath);
|
|
_deletedLocalRows = true;
|
|
}
|
|
deleted++;
|
|
}
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deleted))),
|
|
);
|
|
_reload();
|
|
}
|
|
|
|
Future<void> _keepBest(IsrcDuplicateGroup group) async {
|
|
final toDelete = group.entries.sublist(1);
|
|
final confirmed = await _confirmDelete(
|
|
context.l10n.duplicatesKeepBestMessage(
|
|
toDelete.length,
|
|
group.entries.first.trackName,
|
|
),
|
|
);
|
|
if (confirmed) await _deleteEntries(toDelete);
|
|
}
|
|
|
|
Future<void> _deleteSingle(IsrcDuplicateEntry entry) async {
|
|
final confirmed = await _confirmDelete(
|
|
context.l10n.duplicatesDeleteCopyMessage(entry.trackName),
|
|
);
|
|
if (confirmed) await _deleteEntries([entry]);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return SafeArea(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.sizeOf(context).height * 0.85,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
Center(
|
|
child: Container(
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
context.l10n.duplicatesTitle,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Flexible(
|
|
child: FutureBuilder<List<IsrcDuplicateGroup>>(
|
|
future: _groupsFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState != ConnectionState.done) {
|
|
return const Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
final groups = snapshot.data ?? const [];
|
|
if (groups.isEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
|
|
child: Text(
|
|
context.l10n.duplicatesEmpty,
|
|
style: Theme.of(context).textTheme.bodyMedium
|
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
);
|
|
}
|
|
// shrinkWrap keeps a small sheet compact but builds every
|
|
// group eagerly; past a screenful, switch to a lazy
|
|
// full-height list so a large duplicate set can't jank the
|
|
// opening frame.
|
|
final compact = groups.length <= 12;
|
|
return ListView.builder(
|
|
shrinkWrap: compact,
|
|
itemCount: groups.length,
|
|
itemBuilder: (context, index) =>
|
|
_buildGroup(context, colorScheme, groups[index]),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildGroup(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
IsrcDuplicateGroup group,
|
|
) {
|
|
final first = group.entries.first;
|
|
return SettingsGroup(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 8, 4),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
first.trackName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'${first.artistName} • ${group.isrc}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () => _keepBest(group),
|
|
child: Text(context.l10n.duplicatesKeepBest),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
for (var i = 0; i < group.entries.length; i++)
|
|
ListTile(
|
|
dense: true,
|
|
title: Row(
|
|
children: [
|
|
AudioQualityBadge(
|
|
label: _qualityLabel(group.entries[i]),
|
|
colorScheme: colorScheme,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
p.basename(
|
|
DownloadedEmbeddedCoverResolver.cleanFilePath(
|
|
group.entries[i].filePath,
|
|
),
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
trailing: i == 0
|
|
? Icon(Icons.check_circle_outline, color: colorScheme.primary)
|
|
: IconButton(
|
|
tooltip: context.l10n.dialogDelete,
|
|
icon: Icon(Icons.delete_outline, color: colorScheme.error),
|
|
onPressed: () => _deleteSingle(group.entries[i]),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|