mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
845 lines
30 KiB
Dart
845 lines
30 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:ui' show ImageFilter;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:spotiflac_android/services/conversion_library_service.dart';
|
|
import 'package:spotiflac_android/services/library_database.dart';
|
|
import 'package:spotiflac_android/utils/file_access.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:spotiflac_android/providers/download_queue_provider.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/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
|
import 'package:spotiflac_android/services/replaygain_service.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
|
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
|
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
|
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
|
import 'package:spotiflac_android/utils/logger.dart';
|
|
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
|
import 'package:spotiflac_android/utils/mime_utils.dart';
|
|
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
|
|
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
|
import 'package:spotiflac_android/utils/user_facing_error.dart';
|
|
import 'package:spotiflac_android/utils/int_utils.dart';
|
|
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
|
import 'package:spotiflac_android/utils/re_enrich_release_policy.dart';
|
|
import 'package:spotiflac_android/widgets/album_detail_header.dart'
|
|
show HeaderMetaRow, HeaderMetaItem;
|
|
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
|
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
|
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
|
import 'package:spotiflac_android/widgets/open_on_platform_sheet.dart';
|
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
|
import 'package:spotiflac_android/constants/music_services.dart';
|
|
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
|
|
|
part 'track_metadata_screen_cover.dart';
|
|
part 'track_metadata_screen_display.dart';
|
|
part 'track_metadata_screen_menu.dart';
|
|
|
|
part 'track_metadata_edit_sheet.dart';
|
|
part 'track_metadata_cards.dart';
|
|
part 'track_metadata_lyrics.dart';
|
|
part 'track_metadata_convert.dart';
|
|
part 'track_metadata_actions.dart';
|
|
|
|
final _log = AppLogger('TrackMetadata');
|
|
|
|
class _EmbeddedCoverPreviewCacheEntry {
|
|
final String previewPath;
|
|
final String? sourceValidationToken;
|
|
|
|
const _EmbeddedCoverPreviewCacheEntry({
|
|
required this.previewPath,
|
|
this.sourceValidationToken,
|
|
});
|
|
}
|
|
|
|
class TrackMetadataScreen extends ConsumerStatefulWidget {
|
|
final DownloadHistoryItem? item;
|
|
final LocalLibraryItem? localItem;
|
|
final List<DownloadHistoryItem>? historyNavigationItems;
|
|
final List<LocalLibraryItem>? localNavigationItems;
|
|
final int? navigationIndex;
|
|
final String? coverHeroTag;
|
|
|
|
const TrackMetadataScreen({
|
|
super.key,
|
|
this.item,
|
|
this.localItem,
|
|
this.historyNavigationItems,
|
|
this.localNavigationItems,
|
|
this.navigationIndex,
|
|
this.coverHeroTag,
|
|
}) : assert(
|
|
item != null || localItem != null,
|
|
'Either item or localItem must be provided',
|
|
),
|
|
assert(
|
|
historyNavigationItems == null || localNavigationItems == null,
|
|
'Provide only one navigation list type',
|
|
),
|
|
assert(
|
|
navigationIndex == null ||
|
|
((historyNavigationItems != null &&
|
|
navigationIndex >= 0 &&
|
|
navigationIndex < historyNavigationItems.length) ||
|
|
(localNavigationItems != null &&
|
|
navigationIndex >= 0 &&
|
|
navigationIndex < localNavigationItems.length)),
|
|
'navigationIndex must be within the provided navigation list',
|
|
);
|
|
|
|
@override
|
|
ConsumerState<TrackMetadataScreen> createState() =>
|
|
_TrackMetadataScreenState();
|
|
}
|
|
|
|
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
|
with CollapsingHeaderScrollMixin<TrackMetadataScreen> {
|
|
static const int _maxCoverPreviewCacheEntries = 96;
|
|
static final Map<String, _EmbeddedCoverPreviewCacheEntry>
|
|
_embeddedCoverPreviewCache = {};
|
|
|
|
bool _fileExists = false;
|
|
bool _hasCheckedFile = false;
|
|
int? _fileSize;
|
|
String? _lyrics;
|
|
String? _rawLyrics;
|
|
bool _lyricsLoading = false;
|
|
String? _lyricsError;
|
|
String? _lyricsSource;
|
|
bool _lyricsEmbedded = false;
|
|
bool _isEmbedding = false;
|
|
bool _isInstrumental = false;
|
|
bool _embeddedLyricsChecked = false;
|
|
bool _isConverting = false;
|
|
bool _hasMetadataChanges = false;
|
|
bool _hasLoadedResolvedAudioMetadata = false;
|
|
bool _isTrackSwipeNavigationInFlight = false;
|
|
int _metadataLoadGeneration = 0;
|
|
int _metadataTransitionDirection = 0;
|
|
late DownloadHistoryItem? _currentDownloadItem;
|
|
late LocalLibraryItem? _currentLocalLibraryItem;
|
|
late int? _currentNavigationIndex;
|
|
Map<String, dynamic>? _editedMetadata;
|
|
String? _resolvedAudioFormat;
|
|
String? _embeddedCoverPreviewPath;
|
|
static final RegExp _lrcTimestampPattern = RegExp(
|
|
r'^\[\d{2}:\d{2}\.\d{2,3}\]',
|
|
);
|
|
static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$');
|
|
static final RegExp _lrcInlineTimestampPattern = RegExp(
|
|
r'<\d{2}:\d{2}\.\d{2,3}>',
|
|
);
|
|
static final RegExp _lrcSpeakerPrefixPattern = RegExp(r'^(v1|v2):\s*');
|
|
static final RegExp _lrcBackgroundLinePattern = RegExp(r'^\[bg:(.*)\]$');
|
|
static final RegExp _invalidFileNameChars = RegExp(r'[<>:"/\\|?*\x00-\x1f]');
|
|
static final RegExp _multiUnderscore = RegExp(r'_+');
|
|
static final RegExp _leadingOrTrailingDots = RegExp(r'^\.+|\.+$');
|
|
static const List<String> _months = [
|
|
'Jan',
|
|
'Feb',
|
|
'Mar',
|
|
'Apr',
|
|
'May',
|
|
'Jun',
|
|
'Jul',
|
|
'Aug',
|
|
'Sep',
|
|
'Oct',
|
|
'Nov',
|
|
'Dec',
|
|
];
|
|
|
|
String get _coverCacheKey => _itemId;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_currentDownloadItem = widget.item;
|
|
_currentLocalLibraryItem = widget.localItem;
|
|
_currentNavigationIndex = widget.navigationIndex;
|
|
_checkFile();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
unawaited(_cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath));
|
|
super.dispose();
|
|
}
|
|
|
|
/// setState is @protected, so the extension part files route rebuilds
|
|
/// through this forwarder.
|
|
void _setState(VoidCallback fn) => setState(fn);
|
|
|
|
@override
|
|
double calculateExpandedHeight(BuildContext context) {
|
|
final mediaSize = MediaQuery.sizeOf(context);
|
|
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
|
}
|
|
|
|
Future<void> _checkFile() async {
|
|
final generation = _metadataLoadGeneration;
|
|
final filePath = cleanFilePath;
|
|
|
|
bool exists = false;
|
|
int? size;
|
|
try {
|
|
final stat = await fileStat(filePath);
|
|
if (stat != null) {
|
|
exists = true;
|
|
size = stat.size;
|
|
}
|
|
} catch (_) {}
|
|
|
|
if (mounted &&
|
|
generation == _metadataLoadGeneration &&
|
|
filePath == cleanFilePath &&
|
|
(exists != _fileExists || size != _fileSize || !_hasCheckedFile)) {
|
|
setState(() {
|
|
_fileExists = exists;
|
|
_fileSize = size;
|
|
_hasCheckedFile = true;
|
|
});
|
|
}
|
|
|
|
if (mounted &&
|
|
generation == _metadataLoadGeneration &&
|
|
filePath == cleanFilePath &&
|
|
exists &&
|
|
_lyrics == null &&
|
|
!_lyricsLoading) {
|
|
_checkEmbeddedLyrics();
|
|
}
|
|
if (mounted &&
|
|
generation == _metadataLoadGeneration &&
|
|
filePath == cleanFilePath &&
|
|
exists &&
|
|
!_isCueVirtualTrack &&
|
|
!_hasLoadedResolvedAudioMetadata) {
|
|
unawaited(_refreshResolvedAudioMetadataFromFile());
|
|
}
|
|
if (mounted &&
|
|
generation == _metadataLoadGeneration &&
|
|
filePath == cleanFilePath &&
|
|
exists &&
|
|
!_hasPath(_embeddedCoverPreviewPath)) {
|
|
final cachedPath = await _getCachedEmbeddedCoverPreviewPathIfValid(
|
|
_coverCacheKey,
|
|
filePath,
|
|
);
|
|
if (mounted &&
|
|
generation == _metadataLoadGeneration &&
|
|
filePath == cleanFilePath &&
|
|
_hasPath(cachedPath)) {
|
|
setState(() => _embeddedCoverPreviewPath = cachedPath);
|
|
} else if (mounted &&
|
|
generation == _metadataLoadGeneration &&
|
|
filePath == cleanFilePath) {
|
|
final localCoverExists =
|
|
_hasPath(_localCoverPath) && await fileExists(_localCoverPath!);
|
|
if (!mounted ||
|
|
generation != _metadataLoadGeneration ||
|
|
filePath != cleanFilePath) {
|
|
return;
|
|
}
|
|
if (!localCoverExists && !_hasPath(_coverUrl)) {
|
|
unawaited(_refreshEmbeddedCoverPreview());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _hasPath(String? path) => path != null && path.trim().isNotEmpty;
|
|
|
|
Future<void> _cleanupTempFileAndParent(String? path) async {
|
|
if (!_hasPath(path)) return;
|
|
final file = File(path!);
|
|
try {
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
} catch (_) {}
|
|
try {
|
|
final dir = file.parent;
|
|
if (await dir.exists()) {
|
|
await dir.delete(recursive: true);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _cleanupTempFileAndParentIfNotCached(String? path) async {
|
|
if (_isCacheTrackedPath(path)) return;
|
|
await _cleanupTempFileAndParent(path);
|
|
}
|
|
|
|
Future<void> _refreshResolvedAudioMetadataFromFile() async {
|
|
final generation = _metadataLoadGeneration;
|
|
final sourcePath = cleanFilePath;
|
|
if ((_isLocalItem && _localLibraryItem == null) ||
|
|
(!_isLocalItem && _downloadItem == null) ||
|
|
_isCueVirtualTrack ||
|
|
_hasLoadedResolvedAudioMetadata) {
|
|
return;
|
|
}
|
|
|
|
_hasLoadedResolvedAudioMetadata = true;
|
|
|
|
try {
|
|
final metadata = await PlatformBridge.readDisplayAudioMetadata(
|
|
sourcePath,
|
|
);
|
|
if (!mounted ||
|
|
generation != _metadataLoadGeneration ||
|
|
sourcePath != cleanFilePath) {
|
|
return;
|
|
}
|
|
if (metadata['error'] != null) {
|
|
return;
|
|
}
|
|
|
|
final resolvedBitDepth = readPositiveInt(metadata['bit_depth']);
|
|
final resolvedSampleRate = readPositiveInt(metadata['sample_rate']);
|
|
final resolvedFormat = detectedAudioFormatFromMetadata(metadata);
|
|
final storedFormat = _isLocalItem
|
|
? _localLibraryItem?.format
|
|
: _downloadItem?.format;
|
|
final formatChanged =
|
|
resolvedFormat != null &&
|
|
resolvedFormat != normalizeAudioFormatValue(storedFormat);
|
|
// Lossless files carry an average bitrate too (computed by the backend
|
|
// from file size / duration) — useful for spotting fake 24-bit rips.
|
|
final resolvedBitrate = _readPlausibleBitrateKbps(
|
|
metadata['bitrate'] ?? metadata['bit_rate'],
|
|
);
|
|
final resolvedDuration = readPositiveInt(metadata['duration']);
|
|
final resolvedAlbum = metadata['album']?.toString();
|
|
final resolvedTitle = metadata['title']?.toString();
|
|
final resolvedArtist = metadata['artist']?.toString();
|
|
final resolvedAlbumArtist = metadata['album_artist']?.toString();
|
|
final resolvedDate = metadata['date']?.toString();
|
|
final resolvedGenre = metadata['genre']?.toString();
|
|
final resolvedQuality = _displayQualityForValues(
|
|
format: resolvedFormat ?? _storedAudioFormat,
|
|
bitDepth: resolvedBitDepth ?? bitDepth,
|
|
sampleRate: resolvedSampleRate ?? sampleRate,
|
|
bitrateKbps: resolvedBitrate ?? _audioBitrate,
|
|
storedQuality: _quality,
|
|
);
|
|
|
|
final needsDuration =
|
|
resolvedDuration != null &&
|
|
resolvedDuration > 0 &&
|
|
(duration == null || duration == 0);
|
|
|
|
// Resolve label/copyright from file when the model doesn't carry them
|
|
// (e.g. local library items, or download history items without these fields).
|
|
final resolvedTrackNumber = readPositiveInt(metadata['track_number']);
|
|
final resolvedTotalTracks = readPositiveInt(metadata['total_tracks']);
|
|
final resolvedDiscNumber = readPositiveInt(metadata['disc_number']);
|
|
final resolvedTotalDiscs = readPositiveInt(metadata['total_discs']);
|
|
final resolvedComposer = metadata['composer']?.toString();
|
|
final resolvedLabel = metadata['label']?.toString();
|
|
final resolvedCopyright = metadata['copyright']?.toString();
|
|
final resolvedISRC = metadata['isrc']?.toString();
|
|
final needsTrackNumber =
|
|
resolvedTrackNumber != null &&
|
|
resolvedTrackNumber > 0 &&
|
|
trackNumber == null;
|
|
final needsTotalTracks =
|
|
resolvedTotalTracks != null &&
|
|
resolvedTotalTracks > 0 &&
|
|
totalTracks == null;
|
|
final needsDiscNumber =
|
|
resolvedDiscNumber != null &&
|
|
resolvedDiscNumber > 0 &&
|
|
discNumber == null;
|
|
final needsTotalDiscs =
|
|
resolvedTotalDiscs != null &&
|
|
resolvedTotalDiscs > 0 &&
|
|
totalDiscs == null;
|
|
final needsComposer =
|
|
resolvedComposer != null &&
|
|
resolvedComposer.isNotEmpty &&
|
|
(composer == null || composer!.isEmpty);
|
|
// The file is the source of truth for edited tags: an edit writes the
|
|
// new value into the file, but the cached history item may still carry
|
|
// the old one. Prefer the file value whenever present so re-opening the
|
|
// screen reflects the latest saved tags instead of the stale model.
|
|
// (Empty file values never override the model, so nothing is hidden.)
|
|
bool present(String? v) => v != null && v.trim().isNotEmpty;
|
|
final fileHasTitle = present(resolvedTitle);
|
|
final fileHasArtist = present(resolvedArtist);
|
|
final fileHasAlbumArtist = present(resolvedAlbumArtist);
|
|
final fileHasAlbum = present(resolvedAlbum);
|
|
final fileHasDate = present(resolvedDate);
|
|
final fileHasGenre = present(resolvedGenre);
|
|
final fileHasComposer = present(resolvedComposer);
|
|
final fileHasCopyright = present(resolvedCopyright);
|
|
final fileHasISRC = present(resolvedISRC);
|
|
final fileHasLabel = present(resolvedLabel);
|
|
final fileHasTrackNumber =
|
|
resolvedTrackNumber != null && resolvedTrackNumber > 0;
|
|
final fileHasTotalTracks =
|
|
resolvedTotalTracks != null && resolvedTotalTracks > 0;
|
|
final fileHasDiscNumber =
|
|
resolvedDiscNumber != null && resolvedDiscNumber > 0;
|
|
final fileHasTotalDiscs =
|
|
resolvedTotalDiscs != null && resolvedTotalDiscs > 0;
|
|
|
|
final shouldPersistResolvedAudioMetadata =
|
|
!_isLocalItem &&
|
|
(resolvedBitDepth != null ||
|
|
resolvedSampleRate != null ||
|
|
resolvedBitrate != null ||
|
|
resolvedFormat != null ||
|
|
needsTrackNumber ||
|
|
needsTotalTracks ||
|
|
needsDiscNumber ||
|
|
needsTotalDiscs ||
|
|
needsDuration ||
|
|
needsComposer ||
|
|
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null));
|
|
final localItem = _localLibraryItem;
|
|
final localAudioMetadataChanged =
|
|
localItem != null &&
|
|
((resolvedBitDepth != null &&
|
|
resolvedBitDepth != localItem.bitDepth) ||
|
|
(resolvedSampleRate != null &&
|
|
resolvedSampleRate != localItem.sampleRate) ||
|
|
(resolvedBitrate != null &&
|
|
resolvedBitrate != localItem.bitrate) ||
|
|
needsDuration ||
|
|
formatChanged);
|
|
|
|
if ((resolvedBitDepth != null ||
|
|
resolvedSampleRate != null ||
|
|
resolvedFormat != null ||
|
|
fileHasTitle ||
|
|
fileHasArtist ||
|
|
fileHasAlbumArtist ||
|
|
fileHasAlbum ||
|
|
fileHasDate ||
|
|
fileHasGenre ||
|
|
needsDuration ||
|
|
fileHasTrackNumber ||
|
|
fileHasTotalTracks ||
|
|
fileHasDiscNumber ||
|
|
fileHasTotalDiscs ||
|
|
fileHasComposer ||
|
|
fileHasISRC ||
|
|
fileHasLabel ||
|
|
fileHasCopyright ||
|
|
isPlaceholderQualityLabel(_quality)) &&
|
|
mounted) {
|
|
setState(() {
|
|
_resolvedAudioFormat = resolvedFormat;
|
|
_editedMetadata = {
|
|
...?_editedMetadata,
|
|
// ignore: use_null_aware_elements
|
|
if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth,
|
|
// ignore: use_null_aware_elements
|
|
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
|
|
if (fileHasTitle) 'title': resolvedTitle,
|
|
if (fileHasArtist) 'artist': resolvedArtist,
|
|
if (fileHasAlbumArtist) 'album_artist': resolvedAlbumArtist,
|
|
if (fileHasAlbum) 'album': resolvedAlbum,
|
|
if (fileHasDate) 'date': resolvedDate,
|
|
if (fileHasGenre) 'genre': resolvedGenre,
|
|
if (needsDuration) 'duration': resolvedDuration,
|
|
if (fileHasTrackNumber) 'track_number': resolvedTrackNumber,
|
|
if (fileHasTotalTracks) 'total_tracks': resolvedTotalTracks,
|
|
if (fileHasDiscNumber) 'disc_number': resolvedDiscNumber,
|
|
if (fileHasTotalDiscs) 'total_discs': resolvedTotalDiscs,
|
|
if (fileHasComposer) 'composer': resolvedComposer,
|
|
if (fileHasISRC) 'isrc': resolvedISRC,
|
|
if (fileHasLabel) 'label': resolvedLabel,
|
|
if (fileHasCopyright) 'copyright': resolvedCopyright,
|
|
};
|
|
});
|
|
}
|
|
|
|
if (shouldPersistResolvedAudioMetadata) {
|
|
await ref
|
|
.read(downloadHistoryProvider.notifier)
|
|
.updateAudioMetadataForItem(
|
|
id: _downloadItem!.id,
|
|
quality: resolvedQuality,
|
|
bitDepth: resolvedBitDepth,
|
|
sampleRate: resolvedSampleRate,
|
|
bitrate: resolvedBitrate,
|
|
format: resolvedFormat,
|
|
trackNumber: needsTrackNumber ? resolvedTrackNumber : null,
|
|
totalTracks: needsTotalTracks ? resolvedTotalTracks : null,
|
|
discNumber: needsDiscNumber ? resolvedDiscNumber : null,
|
|
totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null,
|
|
duration: needsDuration ? resolvedDuration : null,
|
|
composer: needsComposer ? resolvedComposer : null,
|
|
);
|
|
if (mounted && _downloadItem != null) {
|
|
setState(() {
|
|
_currentDownloadItem = _downloadItem!.copyWith(
|
|
quality: resolvedQuality,
|
|
bitDepth: resolvedBitDepth,
|
|
sampleRate: resolvedSampleRate,
|
|
bitrate: resolvedBitrate,
|
|
format: resolvedFormat,
|
|
trackNumber: needsTrackNumber ? resolvedTrackNumber : null,
|
|
totalTracks: needsTotalTracks ? resolvedTotalTracks : null,
|
|
discNumber: needsDiscNumber ? resolvedDiscNumber : null,
|
|
totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null,
|
|
duration: needsDuration ? resolvedDuration : null,
|
|
composer: needsComposer ? resolvedComposer : null,
|
|
);
|
|
});
|
|
}
|
|
} else if (_isLocalItem && localAudioMetadataChanged) {
|
|
await LibraryDatabase.instance.updateAudioMetadata(
|
|
localItem.id,
|
|
duration: resolvedDuration,
|
|
bitDepth: resolvedBitDepth,
|
|
sampleRate: resolvedSampleRate,
|
|
bitrate: resolvedBitrate,
|
|
format: formatChanged ? resolvedFormat : null,
|
|
);
|
|
if (mounted &&
|
|
generation == _metadataLoadGeneration &&
|
|
sourcePath == cleanFilePath) {
|
|
setState(() {
|
|
_currentLocalLibraryItem = localItem.withAudioMetadata(
|
|
duration: resolvedDuration,
|
|
bitDepth: resolvedBitDepth,
|
|
sampleRate: resolvedSampleRate,
|
|
bitrate: resolvedBitrate,
|
|
format: resolvedFormat,
|
|
);
|
|
});
|
|
}
|
|
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
|
}
|
|
} catch (e) {
|
|
_log.w('Failed to resolve audio metadata from file: $e');
|
|
}
|
|
}
|
|
|
|
void _markMetadataChanged() {
|
|
_hasMetadataChanges = true;
|
|
}
|
|
|
|
void _popWithMetadataResult() {
|
|
Navigator.pop(context, _hasMetadataChanges ? true : null);
|
|
}
|
|
|
|
void _handleHorizontalDragEnd(DragEndDetails details) {
|
|
final velocity = details.primaryVelocity;
|
|
if (velocity == null || velocity.abs() < 350) return;
|
|
if (velocity < 0) {
|
|
unawaited(_navigateToAdjacentTrack(1));
|
|
} else {
|
|
unawaited(_navigateToAdjacentTrack(-1));
|
|
}
|
|
}
|
|
|
|
Future<void> _navigateToAdjacentTrack(int offset) async {
|
|
if (_isTrackSwipeNavigationInFlight || !_hasTrackSwipeNavigation) return;
|
|
final currentIndex = _navigationIndex;
|
|
if (currentIndex == null) return;
|
|
final targetIndex = currentIndex + offset;
|
|
if (targetIndex < 0 || targetIndex >= _navigationLength) return;
|
|
|
|
_isTrackSwipeNavigationInFlight = true;
|
|
final oldPreviewPath = _embeddedCoverPreviewPath;
|
|
|
|
try {
|
|
setState(() {
|
|
_metadataLoadGeneration++;
|
|
_metadataTransitionDirection = offset > 0 ? 1 : -1;
|
|
_currentNavigationIndex = targetIndex;
|
|
if (_hasHistoryNavigation) {
|
|
_currentDownloadItem = widget.historyNavigationItems![targetIndex];
|
|
_currentLocalLibraryItem = null;
|
|
} else {
|
|
_currentDownloadItem = null;
|
|
_currentLocalLibraryItem = widget.localNavigationItems![targetIndex];
|
|
}
|
|
_fileExists = false;
|
|
_hasCheckedFile = false;
|
|
_fileSize = null;
|
|
_lyrics = null;
|
|
_rawLyrics = null;
|
|
_lyricsLoading = false;
|
|
_lyricsError = null;
|
|
_lyricsSource = null;
|
|
showTitleInAppBar = false;
|
|
_lyricsEmbedded = false;
|
|
_isInstrumental = false;
|
|
_embeddedLyricsChecked = false;
|
|
_hasLoadedResolvedAudioMetadata = false;
|
|
_editedMetadata = null;
|
|
_resolvedAudioFormat = null;
|
|
_embeddedCoverPreviewPath = null;
|
|
});
|
|
|
|
if (scrollController.hasClients) {
|
|
scrollController.jumpTo(0);
|
|
}
|
|
|
|
if (oldPreviewPath != null) {
|
|
unawaited(_cleanupTempFileAndParentIfNotCached(oldPreviewPath));
|
|
}
|
|
await _checkFile();
|
|
} finally {
|
|
if (mounted) {
|
|
_isTrackSwipeNavigationInFlight = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final expandedHeight = calculateExpandedHeight(context);
|
|
final bottomInset = context.navBarBottomInset;
|
|
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onHorizontalDragEnd: _handleHorizontalDragEnd,
|
|
child: Scaffold(
|
|
body: CustomScrollView(
|
|
controller: scrollController,
|
|
slivers: [
|
|
SliverAppBar(
|
|
expandedHeight: expandedHeight,
|
|
pinned: true,
|
|
stretch: true,
|
|
backgroundColor: colorScheme.surface,
|
|
surfaceTintColor: Colors.transparent,
|
|
title: AnimatedOpacity(
|
|
duration: const Duration(milliseconds: 200),
|
|
opacity: showTitleInAppBar ? 1.0 : 0.0,
|
|
child: Text(
|
|
trackName,
|
|
style: TextStyle(
|
|
color: colorScheme.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 16,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
flexibleSpace: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final collapseRatio =
|
|
(constraints.maxHeight - kToolbarHeight) /
|
|
(expandedHeight - kToolbarHeight);
|
|
final showContent = collapseRatio > 0.3;
|
|
|
|
return FlexibleSpaceBar(
|
|
collapseMode: CollapseMode.pin,
|
|
background: _buildHeaderBackground(
|
|
context,
|
|
colorScheme,
|
|
expandedHeight,
|
|
showContent,
|
|
),
|
|
stretchModes: const [StretchMode.zoomBackground],
|
|
);
|
|
},
|
|
),
|
|
leading: IconButton(
|
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
|
icon: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.4),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.arrow_back, color: Colors.white),
|
|
),
|
|
onPressed: _popWithMetadataResult,
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
|
|
icon: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.4),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.more_vert, color: Colors.white),
|
|
),
|
|
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
|
|
),
|
|
],
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxWidth: adaptiveContentMaxWidth(
|
|
MediaQuery.sizeOf(context).width,
|
|
),
|
|
),
|
|
child: _buildAnimatedTrackContent(context, ref, colorScheme),
|
|
),
|
|
),
|
|
),
|
|
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _enqueueThis(WidgetRef ref, {required bool playNext}) async {
|
|
final controller = ref.read(musicPlayerControllerProvider);
|
|
final item = widget.item;
|
|
final localItem = widget.localItem;
|
|
if (item != null) {
|
|
if (playNext) {
|
|
await controller.playNextHistory(item);
|
|
} else {
|
|
await controller.addToQueueHistory(item);
|
|
}
|
|
} else if (localItem != null) {
|
|
if (playNext) {
|
|
await controller.playNextLocal(localItem);
|
|
} else {
|
|
await controller.addToQueueLocal(localItem);
|
|
}
|
|
}
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
playNext
|
|
? context.l10n.snackbarPlayingNext
|
|
: context.l10n.snackbarAddedToQueueGeneric,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActionButtons(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
ColorScheme colorScheme,
|
|
bool fileExists,
|
|
) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: FilledButton.icon(
|
|
onPressed: fileExists
|
|
? () => _openFile(context, rawFilePath)
|
|
: null,
|
|
icon: const Icon(Icons.play_arrow),
|
|
label: Text(context.l10n.trackMetadataPlay),
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => _confirmDelete(context, ref, colorScheme),
|
|
icon: Icon(Icons.delete_outline, color: colorScheme.error),
|
|
label: Text(
|
|
context.l10n.trackMetadataDelete,
|
|
style: TextStyle(color: colorScheme.error),
|
|
),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
side: BorderSide(color: colorScheme.error.withValues(alpha: 0.5)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MetadataOption {
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
final bool destructive;
|
|
final bool dividerAbove;
|
|
|
|
const _MetadataOption({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
this.destructive = false,
|
|
this.dividerAbove = false,
|
|
});
|
|
}
|
|
|
|
class _MetadataOptionTile extends StatelessWidget {
|
|
final _MetadataOption option;
|
|
final ColorScheme colorScheme;
|
|
final VoidCallback onTap;
|
|
|
|
const _MetadataOptionTile({
|
|
required this.option,
|
|
required this.colorScheme,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final iconColor = option.destructive
|
|
? colorScheme.error
|
|
: colorScheme.onSurfaceVariant;
|
|
final titleColor = option.destructive ? colorScheme.error : null;
|
|
|
|
return InkWell(
|
|
onTap: onTap,
|
|
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
|
highlightColor: Colors.transparent,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
|
child: Row(
|
|
children: [
|
|
Icon(option.icon, color: iconColor, size: 24),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Text(
|
|
option.label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyLarge?.copyWith(color: titleColor),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|