Files
SpotiFLAC-Mobile/lib/screens/local_album_screen.dart
T

851 lines
26 KiB
Dart

import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart';
import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart';
import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/re_enrich_release_policy.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/batch_track_actions.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/widgets/album_track_tile.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/destructive_selection_button.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/disc_separator_chip.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
class LocalAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
final String? coverPath;
final List<LocalLibraryItem> tracks;
const LocalAlbumScreen({
super.key,
required this.albumName,
required this.artistName,
this.coverPath,
required this.tracks,
});
@override
ConsumerState<LocalAlbumScreen> createState() => _LocalAlbumScreenState();
}
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
with
SelectionModeMixin<LocalAlbumScreen>,
CollapsingHeaderScrollMixin<LocalAlbumScreen> {
late List<LocalLibraryItem> _sortedTracksCache;
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
void _showCueVirtualTrackSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
);
}
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
String? _commonQualityModeCache;
@override
void initState() {
super.initState();
_rebuildTrackCaches();
}
@override
void didUpdateWidget(covariant LocalAlbumScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.tracks, widget.tracks) ||
oldWidget.tracks.length != widget.tracks.length) {
_rebuildTrackCaches();
}
}
List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) {
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
return a.trackName.compareTo(b.trackName);
});
return tracks;
}
void _rebuildTrackCaches() {
_sortedTracksCache = _buildSortedTracks();
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
_commonQualityCache = null;
_commonQualityModeCache = null;
}
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
List<LocalLibraryItem> tracks,
) {
final discMap = <int, List<LocalLibraryItem>>{};
for (final track in tracks) {
final discNumber = track.discNumber ?? 1;
discMap.putIfAbsent(discNumber, () => []).add(track);
}
return discMap;
}
Future<void> _deleteSelected(List<LocalLibraryItem> currentTracks) async {
final libraryNotifier = ref.read(localLibraryProvider.notifier);
final tracksById = {for (final track in currentTracks) track.id: track};
final deletedCount = await confirmAndDeleteTracks(
context: context,
ids: selectedIds.toList(),
deleteItem: (id) async {
final item = tracksById[id];
if (item == null) return false;
if (!isCueVirtualPath(item.filePath)) {
final deleted = await deleteFile(item.filePath);
if (!deleted) return false;
}
await libraryNotifier.removeItem(id);
return true;
},
onExitSelectionMode: exitSelectionMode,
);
if (deletedCount == currentTracks.length && mounted) {
Navigator.pop(context);
}
}
Future<void> _openFile(LocalLibraryItem track) async {
if (isCueVirtualPath(track.filePath)) {
_showCueVirtualTrackSnackBar();
return;
}
try {
await ref
.read(playbackProvider.notifier)
.playLocalLibraryQueue(_sortedTracksCache, startItem: track);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarCannotOpenFile(context.friendlyError(e)),
),
),
);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final qualityLabelMode = ref.watch(
settingsProvider.select((s) => s.libraryQualityLabelMode),
);
final bottomPadding = MediaQuery.paddingOf(context).bottom;
final bottomInset = context.navBarBottomInset;
final tracks = _sortedTracksCache;
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: Center(child: Text(context.l10n.noTracksFoundForAlbum)),
);
}
pruneSelection(tracks.map((t) => t.id).toSet());
return CollectionScaffold(
scrollController: scrollController,
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
appBar: _buildAppBar(context, colorScheme, qualityLabelMode),
slivers: [_buildTrackList(context, colorScheme, tracks)],
selectionBar: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
bottomInset: bottomInset,
);
}
Widget _buildAppBar(
BuildContext context,
ColorScheme colorScheme,
String qualityLabelMode,
) {
final expandedHeight = calculateExpandedHeight(context);
final cacheWidth = coverCacheWidthForViewport(context);
final Widget background = widget.coverPath != null
? Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 80,
color: colorScheme.onSurfaceVariant,
),
);
return AlbumDetailHeader(
title: widget.albumName,
expandedHeight: expandedHeight,
showTitleInAppBar: showTitleInAppBar,
background: background,
paletteSource: widget.coverPath,
blurAndScrimBackground: widget.coverPath != null,
coverBuilder: (context, coverSize) => widget.coverPath != null
? Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
cacheWidth: cacheWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
subtitle: Text(
widget.artistName,
style: TextStyle(
color: HeaderPalette.of(context).onSurfaceVariant,
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
meta: _buildLocalHeaderMeta(context, qualityLabelMode),
actions: AlbumPlayActions(
playLabel: context.l10n.tooltipPlay,
shuffleTooltip: context.l10n.actionShuffle,
onPlay: _playAll,
onShuffle: _shuffleAll,
),
);
}
Widget _buildLocalHeaderMeta(BuildContext context, String qualityLabelMode) {
final tracks = _sortedTracksCache;
final totalSeconds = tracks.fold<int>(
0,
(sum, t) => sum + ((t.duration ?? 0) > 0 ? t.duration! : 0),
);
final totalMinutes = (totalSeconds / 60).round();
final quality = _getCommonQuality(qualityLabelMode);
return HeaderMetaRow(
items: [
HeaderMetaItem(context.l10n.queueTrackCount(tracks.length)),
if (totalMinutes > 0) HeaderMetaItem('$totalMinutes min'),
if (quality != null && quality.isNotEmpty)
HeaderMetaItem(quality, icon: Icons.graphic_eq),
],
);
}
Future<void> _playAll() async {
final tracks = _sortedTracksCache;
if (tracks.isEmpty) return;
await ref.read(musicPlayerControllerProvider).setShuffle(false);
await _openFile(tracks.first);
}
Future<void> _shuffleAll() async {
final tracks = _sortedTracksCache
.where((t) => !isCueVirtualPath(t.filePath))
.toList();
if (tracks.isEmpty) return;
await ref.read(musicPlayerControllerProvider).setShuffle(true);
await _openFile(tracks[Random().nextInt(tracks.length)]);
}
String? _getCommonQuality(String mode) {
if (_commonQualityModeCache == mode) return _commonQualityCache;
_commonQualityModeCache = mode;
_commonQualityCache = _computeCommonQuality(_sortedTracksCache, mode);
return _commonQualityCache;
}
String? _computeCommonQuality(List<LocalLibraryItem> tracks, String mode) {
if (tracks.isEmpty) return null;
String? label(LocalLibraryItem track) => buildLibraryAudioQualityLabel(
mode: mode,
format: track.format,
bitrateKbps: track.bitrate,
bitDepth: track.bitDepth,
sampleRate: track.sampleRate,
);
final firstQuality = label(tracks.first);
if (firstQuality == null) return null;
for (final track in tracks) {
if (label(track) != firstQuality) return null;
}
return firstQuality;
}
Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
final discGroups = _discGroupsCache;
final hasMultipleDiscs = _hasMultipleDiscsCache;
final slivers = <Widget>[];
for (final discNumber in _sortedDiscNumbersCache) {
final discTracks = discGroups[discNumber]!;
if (hasMultipleDiscs) {
slivers.add(
SliverToBoxAdapter(child: DiscSeparatorChip(discNumber: discNumber)),
);
}
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final track = discTracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: _buildTrackItem(context, colorScheme, track),
),
);
}, childCount: discTracks.length),
),
);
}
return SliverPadding(
padding: EdgeInsets.symmetric(horizontal: wideListInset(context)),
sliver: SliverMainAxisGroup(slivers: slivers),
);
}
Widget _buildTrackItem(
BuildContext context,
ColorScheme colorScheme,
LocalLibraryItem track,
) {
return AlbumTrackTile(
trackNumber: track.trackNumber,
trackName: track.trackName,
subtitle: Row(
children: [
Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (track.format != null) ...[
Text(
' • ',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
Text(
track.format!.toUpperCase(),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
],
),
isSelectionMode: isSelectionMode,
isSelected: selectedIds.contains(track.id),
colorScheme: colorScheme,
onToggleSelection: () => toggleSelection(track.id),
onOpen: () => _openFile(track),
onEnterSelectionMode: () => enterSelectionMode(track.id),
onPlay: () => _openFile(track),
);
}
Future<bool> _reEnrichLocalTrack(
LocalLibraryItem item, {
List<String>? updateFields,
}) async {
final durationMs = (item.duration ?? 0) * 1000;
final settings = ref.read(settingsProvider);
final artistTagMode = settings.artistTagMode;
await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend();
final request = <String, dynamic>{
'file_path': item.filePath,
'cover_url': '',
'max_quality': true,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': artistTagMode,
'spotify_id': '',
'track_name': item.trackName,
'artist_name': item.artistName,
'album_name': item.albumName,
'album_artist': item.albumArtist ?? '',
'track_number': item.trackNumber ?? 0,
'disc_number': item.discNumber ?? 0,
'release_date': item.releaseDate ?? '',
'isrc': item.isrc ?? '',
'genre': item.genre ?? '',
'label': '',
'copyright': '',
'duration_ms': durationMs,
'search_online': true,
'replace_release_metadata': allowsReleaseIdentityReplacement(
ReEnrichOperationScope.batch,
),
// ignore: use_null_aware_elements
if (updateFields != null) 'update_fields': updateFields,
};
final result = await PlatformBridge.reEnrichFile(request);
final method = result['method'] as String?;
if (method == 'native') {
// Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin).
await writeReEnrichSidecarLrc(
audioFilePath: item.filePath,
reEnrichResult: result,
);
return true;
}
if (method == 'ffmpeg') {
return applyFfmpegReEnrichResult(
item: item,
result: result,
artistTagMode: ref.read(settingsProvider).artistTagMode,
);
}
return false;
}
List<LocalLibraryItem> _selectedFlacEligibleItems(
List<LocalLibraryItem> allTracks,
) {
final tracksById = {for (final t in allTracks) t.id: t};
return selectedIds
.map((id) => tracksById[id])
.whereType<LocalLibraryItem>()
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
.toList(growable: false);
}
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
final selected = _selectedFlacEligibleItems(allTracks);
if (selected.isEmpty) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.queueFlacAction),
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.queueFlacAction),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final includeExtensions =
settings.useExtensionProviders &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.hasMetadataProvider,
);
final targetService = LocalTrackRedownloadService.preferredFlacService(
settings,
extensionState,
);
if (targetService.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
);
return;
}
final targetQuality =
LocalTrackRedownloadService.preferredFlacQualityForService(
targetService,
extensionState,
);
final matchedTracks = <Track>[];
var skippedCount = 0;
final total = selected.length;
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.queueFlacAction,
total: total,
icon: Icons.queue_music,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (var i = 0; i < total; i++) {
if (!mounted || cancelled) break;
BatchProgressDialog.update(current: i + 1, detail: selected[i].trackName);
try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
selected[i],
includeExtensions: includeExtensions,
);
if (resolution.canQueue && resolution.match != null) {
matchedTracks.add(resolution.match!);
} else {
skippedCount++;
}
} catch (_) {
skippedCount++;
}
}
if (!mounted) {
return;
}
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
);
return;
}
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
matchedTracks,
targetService,
qualityOverride: targetQuality,
);
final summary = skippedCount == 0
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
: context.l10n.queueFlacQueuedWithSkipped(
matchedTracks.length,
skippedCount,
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
exitSelectionMode();
}
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
for (final id in selectedIds) {
final item = tracksById[id];
if (item != null) {
selected.add(item);
}
}
if (selected.isEmpty) {
return;
}
// The bar uses AnimatedPositioned (250ms), so wait for the slide-out.
setState(() => isSelectionMode = false);
await Future<void>.delayed(const Duration(milliseconds: 300));
if (!mounted) return;
final selection = await showReEnrichFieldDialog(
context,
selectedCount: selected.length,
);
if (selection == null || !mounted) {
// Cancelled — restore selection mode (IDs are still intact).
if (mounted) setState(() => isSelectionMode = true);
return;
}
final updateFields = selection.isAll ? null : selection.fields;
var successCount = 0;
final total = selected.length;
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackReEnrichProgress,
total: total,
icon: Icons.auto_fix_high,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (var i = 0; i < total; i++) {
if (!mounted || cancelled) break;
final item = selected[i];
BatchProgressDialog.update(
current: i + 1,
detail: '${item.trackName} - ${item.artistName}',
);
try {
final ok = await _reEnrichLocalTrack(item, updateFields: updateFields);
if (ok) {
successCount++;
}
} catch (_) {}
}
if (!mounted) {
return;
}
final settings = ref.read(settingsProvider);
final localLibraryPath = settings.localLibraryPath.trim();
final iosBookmark = settings.localLibraryBookmark;
try {
if (localLibraryPath.isNotEmpty &&
!ref.read(localLibraryProvider).isScanning) {
await ref
.read(localLibraryProvider.notifier)
.startScan(
localLibraryPath,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
} else {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
} catch (_) {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
exitSelectionMode();
if (!mounted) {
return;
}
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
final failedCount = total - successCount;
final summary = failedCount <= 0
? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)'
: context.l10n.trackReEnrichSuccessWithFailures(
successCount,
total,
failedCount,
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
}
List<UnifiedLibraryItem> _selectedUnifiedItems(
List<LocalLibraryItem> tracks,
) {
final tracksById = {for (final t in tracks) t.id: t};
return [
for (final t in selectedIds.map((id) => tracksById[id]))
if (t != null) UnifiedLibraryItem.fromLocalLibrary(t),
];
}
Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
double bottomPadding,
) {
final selectedCount = selectedIds.length;
final flacEligibleCount = _selectedFlacEligibleItems(tracks).length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return SelectionBottomBar(
selectedCount: selectedCount,
allSelected: allSelected,
onClose: exitSelectionMode,
onToggleSelectAll: () {
if (allSelected) {
exitSelectionMode();
} else {
selectAll(tracks.map((e) => e.id));
}
},
bottomPadding: bottomPadding,
children: [
LayoutBuilder(
builder: (context, constraints) {
const spacing = 8.0;
final columns = (constraints.maxWidth / 320).floor().clamp(2, 4);
final itemWidth =
(constraints.maxWidth - spacing * (columns - 1)) / columns;
final actions = <Widget>[];
if (flacEligibleCount > 0) {
actions.add(
SelectionActionButton(
icon: Icons.download_for_offline_outlined,
label: '${context.l10n.queueFlacAction} ($flacEligibleCount)',
onPressed: () => _queueSelectedAsFlac(tracks),
colorScheme: colorScheme,
),
);
}
actions.add(
SelectionActionButton(
icon: Icons.auto_fix_high_outlined,
label: '${context.l10n.trackReEnrich} ($selectedCount)',
onPressed: selectedCount > 0
? () => _reEnrichSelected(tracks)
: null,
colorScheme: colorScheme,
),
);
actions.add(
SelectionActionButton(
icon: Icons.swap_horiz,
label: context.l10n.selectionConvertCount(selectedCount),
onPressed: selectedCount > 0
? () => showBatchConvertSheet(
context,
ref,
_selectedUnifiedItems(tracks),
onExitSelectionMode: exitSelectionMode,
)
: null,
colorScheme: colorScheme,
),
);
actions.add(
SelectionActionButton(
icon: Icons.graphic_eq,
label: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => runBatchReplayGain(
context,
_selectedUnifiedItems(tracks),
onExitSelectionMode: exitSelectionMode,
)
: null,
colorScheme: colorScheme,
),
);
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final action in actions)
SizedBox(width: itemWidth, child: action),
],
);
},
),
const SizedBox(height: 8),
DestructiveSelectionButton(
count: selectedCount,
colorScheme: colorScheme,
onPressed: () => _deleteSelected(tracks),
),
],
);
}
}