refactor(metadata): separate editor state, autofill and artwork ownership

This commit is contained in:
zarzet committed 2026-10-05 03:28:14 +07:00
1 parent 3047d5974c
commit e77996ddbd
11 files changed
+1682 -1396

No files matched your search

@@ -0,0 +1,105 @@
import 'package:flutter/widgets.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
/// Editable tags and request generations, independent of sheet navigation.
class MetadataEditorController {
static const fieldKeys = <String>[
'title',
'artist',
'album',
'album_artist',
'date',
'track_number',
'total_tracks',
'disc_number',
'total_discs',
'genre',
'isrc',
'lyrics',
'label',
'copyright',
'composer',
'comment',
'album_type',
'explicit',
'upc',
'cover',
];
final Map<String, TextEditingController> _fields;
final Set<String> autoFillFields = {};
bool explicit;
int _generation = 0;
bool _disposed = false;
MetadataEditorController(Map<String, String> initialValues)
: _fields = {
for (final key in fieldKeys)
if (key != 'explicit' && key != 'cover')
key: TextEditingController(text: initialValues[key] ?? ''),
},
explicit = parseExplicitFlag(initialValues['explicit']) == true {
for (final field in _fields.values) {
var previousText = field.text;
field.addListener(() {
if (field.text == previousText) return;
previousText = field.text;
invalidateLookup();
});
}
}
TextEditingController? operator [](String key) => _fields[key];
Map<String, String> get values => {
for (final entry in _fields.entries) entry.key: entry.value.text,
'explicit': explicit ? '1' : '0',
};
int beginLookup() => ++_generation;
void invalidateLookup() => _generation++;
bool isCurrent(int generation) => !_disposed && generation == _generation;
int apply(Map<String, String> values) {
var filled = 0;
for (final key in autoFillFields) {
final value = values[key];
if (value == null) continue;
if (key == 'explicit') {
final parsed = parseExplicitFlag(value);
if (parsed == null) continue;
explicit = parsed;
} else {
final field = _fields[key];
if (field == null) continue;
field.text = value;
}
filled++;
}
return filled;
}
void selectEmptyFields({required bool hasCover}) {
autoFillFields
..clear()
..addAll(_fields.keys.where((key) => _fields[key]!.text.trim().isEmpty));
if (!hasCover) autoFillFields.add('cover');
}
Map<String, String> saveMetadata({required String artistTagMode}) {
final tags = values;
tags['track_total'] = tags.remove('total_tracks')!;
tags['disc_total'] = tags.remove('total_discs')!;
tags['compilation'] =
tags['album_type']!.trim().toLowerCase() == 'compilation' ? '1' : '0';
tags['artist_tag_mode'] = artistTagMode;
return tags;
}
void dispose() {
_disposed = true;
for (final field in _fields.values) {
field.dispose();
}
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState {
backgroundColor: context.isMornye
? colorScheme.surfaceContainerLow
: colorScheme.surface,
builder: (sheetContext) => _EditMetadataSheet(
builder: (sheetContext) => EditMetadataSheet(
colorScheme: colorScheme,
initialValues: initialValues,
filePath: cleanFilePath,
File diff suppressed because it is too large. Load diff
+11 -15
View File
@@ -3,22 +3,12 @@ import 'dart:io';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:flutter/cupertino.dart'
show
CupertinoButton,
CupertinoIcons,
CupertinoTextField,
CupertinoActivityIndicator;
import 'package:spotiflac_android/widgets/app_action_button.dart';
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
import 'package:spotiflac_android/widgets/app_switch.dart';
import 'package:flutter/cupertino.dart' show CupertinoButton, CupertinoIcons;
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as p;
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';
@@ -31,7 +21,6 @@ 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/music_player_service.dart'
show readPlaybackFileMetadataWithRetry;
@@ -48,7 +37,6 @@ 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/extension_auth_launcher.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/user_facing_error.dart';
@@ -62,7 +50,6 @@ import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/theme/mornye_icons.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart'
show HeaderMetaRow, HeaderMetaItem, HeaderCircleButton, HeaderFilledButton;
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
import 'package:spotiflac_android/widgets/mornye_metadata_row.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
@@ -76,6 +63,7 @@ import 'package:spotiflac_android/widgets/metadata_barcode.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';
import 'package:spotiflac_android/screens/track_metadata_edit_sheet.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
part 'track_metadata_screen_cover.dart';
@@ -83,7 +71,6 @@ part 'track_metadata_screen_display.dart';
part 'track_metadata_screen_menu.dart';
part 'track_metadata_mornye.dart';
part 'track_metadata_edit_sheet.dart';
part 'track_metadata_cards.dart';
part 'track_metadata_lyrics.dart';
part 'track_metadata_convert.dart';
@@ -91,6 +78,15 @@ part 'track_metadata_actions.dart';
final _log = AppLogger('TrackMetadata');
class _MetadataItem {
final String label;
final String value;
final String rawValue;
_MetadataItem(this.label, this.value, {String? rawValue})
: rawValue = rawValue ?? value;
}
class _EmbeddedCoverPreviewCacheEntry {
final String previewPath;
final String? sourceValidationToken;
+537
View File
@@ -0,0 +1,537 @@
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
class MetadataAutofillResult {
final Map<String, String> values;
final String? coverUrl;
final String sourceId;
final String? lyricsSource;
const MetadataAutofillResult({
required this.values,
required this.coverUrl,
required this.sourceId,
required this.lyricsSource,
});
}
/// Provider lookup and enrichment without widget state or navigation access.
class MetadataAutofillService {
static final _log = AppLogger('MetadataAutofill');
static final RegExp _metadataCollapsePattern = RegExp(
r'[^\p{L}\p{M}\p{N}]+',
unicode: true,
);
static final RegExp _metadataWhitespacePattern = RegExp(r'\s+');
static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
static final RegExp _deezerTrackIdPattern = RegExp(r'^\d+$');
static final RegExp _isrcPattern = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$');
Future<List<Map<String, dynamic>>> search(
String query, {
Extension? provider,
}) {
if (provider == null) {
return PlatformBridge.searchTracksWithMetadataProviders(query, limit: 5);
}
if (provider.hasCustomSearch) {
final filter = provider.searchBehavior?.filterIdForKind('track');
return PlatformBridge.customSearchWithExtension(
provider.id,
query,
options: {'limit': 5, 'filter': ?filter},
);
}
return PlatformBridge.searchTracksWithMetadataProvider(
provider.id,
query,
limit: 5,
);
}
Future<Map<String, dynamic>> resolveDetails(
Map<String, dynamic> candidate, {
required String providerId,
required bool includeCover,
}) async {
var resolved = candidate;
if (providerId.isEmpty) return resolved;
final trackId = candidate['id']?.toString().trim() ?? '';
if (trackId.isNotEmpty) {
try {
final details = await PlatformBridge.getProviderMetadata(
providerId,
'track',
trackId,
);
resolved = {
...candidate,
for (final entry in unwrapTrackPayload(details).entries)
if (entry.value != null && entry.value.toString().trim().isNotEmpty)
entry.key: entry.value,
};
} catch (error) {
_log.w(
'Detailed metadata lookup failed for $providerId/$trackId: $error',
);
}
}
if (includeCover && coverUrl(resolved) == null) {
final albumId = resolved['album_id']?.toString().trim() ?? '';
if (albumId.isNotEmpty) {
try {
final details = await PlatformBridge.getProviderMetadata(
providerId,
'album',
albumId,
);
final album = details['album_info'] ?? details['album'];
final url = coverUrl(album is Map<String, dynamic> ? album : details);
if (url != null) resolved = {...resolved, 'cover_url': url};
} catch (error) {
_log.w(
'Album artwork lookup failed for $providerId/$albumId: $error',
);
}
}
}
return resolved;
}
Future<MetadataAutofillResult?> enrich(
Map<String, dynamic>? selectedBest, {
required Map<String, String> currentValues,
required Set<String> fields,
required String? providerId,
required String? sourceTrackId,
required int durationMs,
required Future<void> Function() syncLyricsSettings,
required bool Function() isCurrent,
}) async {
if (!isCurrent()) return null;
final title = currentValues['title']?.trim() ?? '';
final artist = currentValues['artist']?.trim() ?? '';
final currentIsrc = currentValues['isrc']?.trim().toUpperCase() ?? '';
final usesAutomaticProvider = providerId?.isNotEmpty != true;
final shouldFetchLyrics = fields.contains('lyrics');
String? deezerId;
String? lyricsSourceName;
final enriched = <String, String>{};
if (selectedBest != null) {
mergeTrackData(enriched, selectedBest);
}
final enrichedIsrc = (enriched['isrc'] ?? '').trim();
final needsIsrc = fields.contains('isrc') && enrichedIsrc.isEmpty;
final needsExtended =
fields.contains('genre') ||
fields.contains('label') ||
fields.contains('copyright') ||
fields.contains('composer');
final rawSpotifyId = selectedBest == null
? spotifyTrackIdFromValue(sourceTrackId)
: spotifyTrackId(selectedBest);
deezerId ??= selectedBest == null ? null : deezerTrackId(selectedBest);
final candidateIsrc = enrichedIsrc.toUpperCase();
final deezerLookupIsrc = looksLikeIsrc(currentIsrc)
? currentIsrc
: (looksLikeIsrc(candidateIsrc) ? candidateIsrc : '');
if (usesAutomaticProvider && (needsIsrc || needsExtended)) {
try {
if (deezerId == null && deezerLookupIsrc.isNotEmpty) {
final deezerResult = await PlatformBridge.searchDeezerByISRC(
deezerLookupIsrc,
);
deezerId = deezerTrackId(deezerResult);
mergeTrackData(enriched, deezerResult);
}
if (deezerId == null && rawSpotifyId != null) {
// Spotify IDs can be mapped through SongLink to a Deezer track.
final deezerData = await PlatformBridge.convertSpotifyToDeezer(
'track',
rawSpotifyId,
);
final trackData = deezerData['track'];
if (trackData is Map<String, dynamic>) {
deezerId = deezerTrackId(trackData);
mergeTrackData(enriched, trackData);
}
deezerId ??= deezerTrackId(deezerData);
}
} catch (_) {
// Deezer resolution is best-effort
}
}
if (!isCurrent()) return null;
if (usesAutomaticProvider &&
needsIsrc &&
(enriched['isrc'] ?? '').trim().isEmpty &&
deezerId != null) {
try {
final deezerMeta = await PlatformBridge.getProviderMetadata(
'deezer',
'track',
deezerId,
);
final trackData = unwrapTrackPayload(deezerMeta);
mergeTrackData(enriched, trackData);
final deezerIsrc = (trackData['isrc'] ?? '').toString().trim();
if (deezerIsrc.isNotEmpty) {
enriched['isrc'] = deezerIsrc;
}
} catch (_) {}
}
if (!isCurrent()) return null;
if (usesAutomaticProvider && needsExtended && deezerId != null) {
try {
final extended = await PlatformBridge.getDeezerExtendedMetadata(
deezerId,
);
if (extended != null) {
enriched['genre'] = extended['genre'] ?? '';
enriched['label'] = extended['label'] ?? '';
enriched['copyright'] = extended['copyright'] ?? '';
}
} catch (_) {
// Extended metadata is best-effort
}
}
if (shouldFetchLyrics) {
await syncLyricsSettings();
if (!isCurrent()) return null;
final lyricsTitle =
((selectedBest?['name'] ?? selectedBest?['title'] ?? title)
.toString())
.trim();
final lyricsArtist =
((selectedBest?['artists'] ?? selectedBest?['artist'] ?? artist)
.toString())
.trim();
if (lyricsTitle.isNotEmpty && lyricsArtist.isNotEmpty) {
try {
final lyricsResult = await PlatformBridge.getLyricsLRCWithSource(
rawSpotifyId ?? '',
lyricsTitle,
lyricsArtist,
durationMs: durationMs,
);
final lyricsText = lyricsResult['lyrics']?.toString().trim() ?? '';
final lyricsSource = lyricsResult['source']?.toString().trim() ?? '';
if (lyricsSource.isNotEmpty) lyricsSourceName = lyricsSource;
final instrumental =
(lyricsResult['instrumental'] as bool? ?? false) ||
lyricsText == '[instrumental:true]';
if (!instrumental && lyricsText.isNotEmpty) {
enriched['lyrics'] = lyricsText;
}
} catch (e) {
_log.w('Lyrics autofill failed: $e');
}
}
}
if (!isCurrent()) return null;
final availableValues = <String, String>{};
for (final entry in enriched.entries) {
final value = entry.value.trim();
final isExplicitValue = entry.key == 'explicit';
if (value.isNotEmpty &&
(isExplicitValue || value != '0') &&
value != 'null') {
availableValues[entry.key] = value;
}
}
final artworkUrl = selectedBest == null ? null : coverUrl(selectedBest);
return MetadataAutofillResult(
values: availableValues,
coverUrl: artworkUrl,
sourceId: providerId?.isNotEmpty == true
? providerId!
: selectedBest?['provider_id']?.toString().trim() ?? '',
lyricsSource: selectedBest == null ? lyricsSourceName : null,
);
}
List<Extension> orderedProviders(ExtensionState state) {
final providersById = <String, Extension>{
for (final extension in state.extensions)
if (extension.enabled && extension.hasMetadataProvider)
extension.id: extension,
};
final ordered = <Extension>[];
for (final id in state.metadataProviderPriority) {
final extension = providersById.remove(id);
if (extension != null) ordered.add(extension);
}
final remaining = providersById.values.toList()
..sort(
(a, b) =>
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
);
return [...ordered, ...remaining];
}
String normalizeText(String value) {
final collapsed = value
.toLowerCase()
.replaceAll(_metadataCollapsePattern, ' ')
.trim();
return collapsed.replaceAll(_metadataWhitespacePattern, ' ');
}
bool looksLikeIsrc(String value) {
return _isrcPattern.hasMatch(value.trim().toUpperCase());
}
String? spotifyTrackIdFromValue(Object? value) {
final raw = value?.toString().trim() ?? '';
if (raw.isEmpty) return null;
if (_spotifyTrackIdPattern.hasMatch(raw)) {
return raw;
}
if (raw.startsWith('spotify:')) {
final parts = raw.split(':');
final last = parts.isNotEmpty ? parts.last.trim() : '';
if (_spotifyTrackIdPattern.hasMatch(last)) {
return last;
}
return null;
}
final uri = Uri.tryParse(raw);
if (uri != null &&
uri.host.contains('spotify.com') &&
uri.pathSegments.length >= 2 &&
uri.pathSegments.first == 'track') {
final candidate = uri.pathSegments[1].trim();
if (_spotifyTrackIdPattern.hasMatch(candidate)) {
return candidate;
}
}
return null;
}
String? deezerTrackIdFromValue(Object? value) {
final raw = value?.toString().trim() ?? '';
if (raw.isEmpty) return null;
if (_deezerTrackIdPattern.hasMatch(raw)) {
return raw;
}
if (raw.startsWith('deezer:')) {
final parts = raw.split(':');
final last = parts.isNotEmpty ? parts.last.trim() : '';
if (_deezerTrackIdPattern.hasMatch(last)) {
return last;
}
}
final uri = Uri.tryParse(raw);
if (uri != null && uri.host.contains('deezer.com')) {
final trackIndex = uri.pathSegments.indexOf('track');
if (trackIndex >= 0 && trackIndex + 1 < uri.pathSegments.length) {
final candidate = uri.pathSegments[trackIndex + 1].trim();
if (_deezerTrackIdPattern.hasMatch(candidate)) {
return candidate;
}
}
}
return null;
}
String? spotifyTrackId(Map<String, dynamic> track) {
for (final candidate in [track['spotify_id'], track['id']]) {
final spotifyId = spotifyTrackIdFromValue(candidate);
if (spotifyId != null) return spotifyId;
}
final externalLinks = track['external_links'];
if (externalLinks is Map) {
final spotifyId = spotifyTrackIdFromValue(externalLinks['spotify']);
if (spotifyId != null) return spotifyId;
}
return null;
}
String? deezerTrackId(Map<String, dynamic> track) {
for (final candidate in [
track['deezer_id'],
track['spotify_id'],
track['id'],
]) {
final deezerId = deezerTrackIdFromValue(candidate);
if (deezerId != null) return deezerId;
}
final externalLinks = track['external_links'];
if (externalLinks is Map) {
final deezerId = deezerTrackIdFromValue(externalLinks['deezer']);
if (deezerId != null) return deezerId;
}
return null;
}
Map<String, dynamic> unwrapTrackPayload(Map<String, dynamic> payload) {
final track = payload['track'];
if (track is Map<String, dynamic>) {
return track;
}
return payload;
}
void mergeTrackData(
Map<String, String> enriched,
Map<String, dynamic> track,
) {
void put(String key, Object? value) {
final text = value?.toString().trim() ?? '';
if (text.isNotEmpty && text != 'null') {
enriched[key] = text;
}
}
put('title', track['name'] ?? track['title']);
put('artist', track['artists'] ?? track['artist']);
put('album', track['album_name'] ?? track['album']);
put('album_artist', track['album_artist']);
put('date', track['release_date']);
put('track_number', track['track_number']);
put('total_tracks', track['total_tracks']);
put('disc_number', track['disc_number']);
put('total_discs', track['total_discs']);
put('isrc', track['isrc']);
put('genre', track['genre']);
put('label', track['label']);
put('copyright', track['copyright']);
put('composer', track['composer']);
put('comment', track['comment']);
put('album_type', track['album_type'] ?? track['albumType']);
put('explicit', track['explicit'] ?? track['is_explicit']);
put('upc', track['upc'] ?? track['barcode']);
}
int matchScore(
Map<String, dynamic> track, {
required String currentTitle,
required String currentArtist,
required String currentAlbum,
required String currentIsrc,
}) {
int textScore(String current, Object? value, int exact, int partial) {
final candidate = normalizeText((value ?? '').toString());
if (current.isEmpty || candidate.isEmpty) return 0;
if (candidate == current) return exact;
return _metadataTextMatches(current, candidate) ? partial : 0;
}
final candidateIsrc = track['isrc']?.toString().trim().toUpperCase() ?? '';
return (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc
? 10000
: 0) +
textScore(currentTitle, track['name'] ?? track['title'], 400, 180) +
textScore(
currentArtist,
track['artists'] ?? track['artist'],
320,
140,
) +
textScore(currentAlbum, track['album_name'] ?? track['album'], 120, 50);
}
bool _metadataTextMatches(String current, String candidate) {
if (current.isEmpty || candidate.isEmpty) return false;
return current == candidate ||
candidate.contains(current) ||
current.contains(candidate);
}
bool matchIsConfident(
Map<String, dynamic> track, {
required String currentTitle,
required String currentArtist,
required String currentAlbum,
required String currentIsrc,
}) {
final candidateIsrc = (track['isrc']?.toString() ?? '')
.trim()
.toUpperCase();
if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) {
return true;
}
final candidateTitle = normalizeText(
(track['name'] ?? track['title'] ?? '').toString(),
);
final candidateArtist = normalizeText(
(track['artists'] ?? track['artist'] ?? '').toString(),
);
final candidateAlbum = normalizeText(
(track['album_name'] ?? track['album'] ?? '').toString(),
);
final titleMatches = _metadataTextMatches(currentTitle, candidateTitle);
final artistMatches = _metadataTextMatches(currentArtist, candidateArtist);
final albumMatches = _metadataTextMatches(currentAlbum, candidateAlbum);
if (currentTitle.isNotEmpty && currentArtist.isNotEmpty) {
return titleMatches && artistMatches;
}
if (currentTitle.isNotEmpty && currentAlbum.isNotEmpty) {
return titleMatches && albumMatches;
}
if (currentTitle.isNotEmpty) {
return titleMatches;
}
if (currentAlbum.isNotEmpty) {
return albumMatches;
}
return false;
}
String? coverUrl(Map<String, dynamic> track) {
String? fromValue(Object? value) {
if (value is String) {
final normalized = value.trim();
return normalized.isEmpty ? null : normalized;
}
if (value is List) {
for (final item in value) {
final resolved = fromValue(item);
if (resolved != null) return resolved;
}
}
if (value is Map) {
for (final key in const ['url', 'original', 'large', 'src']) {
final resolved = fromValue(value[key]);
if (resolved != null) return resolved;
}
}
return null;
}
return fromValue(track['cover_url']) ?? fromValue(track['images']);
}
}
+175
View File
@@ -0,0 +1,175 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
typedef MetadataCoverDetails = ({int width, int height, int bytes});
class MetadataCoverPreview {
final String path;
final String tempDir;
final MetadataCoverDetails? details;
const MetadataCoverPreview({
required this.path,
required this.tempDir,
this.details,
});
}
/// Owns editor artwork, including resources created after a sheet is closed.
/// A save lease keeps every owned file alive until the writer has finished.
class MetadataCoverResources {
static final _log = AppLogger('MetadataCoverResources');
final Set<String> _directories = {};
final Set<String> _deferredReleases = {};
bool _disposed = false;
int _leases = 0;
int _pending = 0;
Future<void> Function() retain() {
assert(!_disposed);
_leases++;
var released = false;
return () async {
if (released) return;
released = true;
_leases--;
await _cleanupIfIdle();
};
}
Future<void> dispose() async {
_disposed = true;
await _cleanupIfIdle();
}
Future<void> _cleanupIfIdle() async {
if (_leases != 0) return;
final directories = _disposed && _pending == 0
? _directories.toList()
: _deferredReleases.toList();
await Future.wait(directories.map(release));
}
Future<void> release(String? directory) async {
if (directory == null || !_directories.contains(directory)) return;
if (_leases != 0) {
_deferredReleases.add(directory);
return;
}
_directories.remove(directory);
_deferredReleases.remove(directory);
try {
final dir = Directory(directory);
if (await dir.exists()) await dir.delete(recursive: true);
} catch (_) {}
}
Future<MetadataCoverDetails?> readDetails(String path) async {
try {
final dimensions = await FFmpegService.probeImageDimensions(path);
if (dimensions == null) return null;
return (
width: dimensions.width,
height: dimensions.height,
bytes: await File(path).length(),
);
} catch (_) {
return null;
}
}
Future<MetadataCoverPreview> _create(
String prefix,
String filename,
Future<void> Function(String path) write, {
bool probe = true,
}) async {
_pending++;
String? directory;
try {
directory = (await Directory.systemTemp.createTemp(prefix)).path;
_directories.add(directory);
final path = '$directory${Platform.pathSeparator}$filename';
await write(path);
final file = File(path);
if (!await file.exists() || await file.length() == 0) {
throw StateError('Artwork file is empty or inaccessible');
}
return MetadataCoverPreview(
path: path,
tempDir: directory,
details: probe ? await readDetails(path) : null,
);
} catch (_) {
await release(directory);
rethrow;
} finally {
_pending--;
await _cleanupIfIdle();
}
}
Future<MetadataCoverPreview?> download(String url) async {
try {
return await _create('metadata_cover_', 'cover.jpg', (path) async {
final result = await PlatformBridge.downloadCoverToFile(url, path);
if (result['error'] != null || result['success'] == false) {
throw StateError(
result['error']?.toString() ?? 'Cover download failed',
);
}
});
} catch (error) {
_log.w('Could not download metadata artwork: $error');
return null;
}
}
Future<MetadataCoverPreview?> extract(String filePath) async {
try {
return await _create('metadata_embedded_cover_', 'cover.jpg', (
path,
) async {
final result = await PlatformBridge.extractCoverToFile(filePath, path);
if (result['error'] != null) {
throw StateError(result['error'].toString());
}
});
} catch (_) {
return null;
}
}
Future<MetadataCoverPreview> copyPicked({
required String extension,
String? sourcePath,
Uint8List? bytes,
}) {
return _create('metadata_selected_cover_', 'cover.$extension', (
path,
) async {
if (sourcePath?.isNotEmpty == true) {
await File(sourcePath!).copy(path);
} else if (bytes?.isNotEmpty == true) {
await File(path).writeAsBytes(bytes!, flush: true);
} else {
throw StateError('Unable to read selected image');
}
}, probe: false);
}
Future<MetadataCoverPreview> resize(String source, int maxDimension) {
return _create('metadata_resized_cover_', 'cover.jpg', (path) async {
final resized = await FFmpegService.resizeCoverArt(
inputPath: source,
outputPath: path,
maxDimension: maxDimension,
);
if (!resized) throw StateError('Cover resize failed');
}, probe: false);
}
}
+164
View File
@@ -0,0 +1,164 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/services/metadata_autofill_service.dart';
import 'package:spotiflac_android/services/metadata_cover_resources.dart';
import 'package:spotiflac_android/utils/isrc_utils.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
class MetadataCandidateTile extends StatelessWidget {
final Map<String, dynamic> candidate;
final VoidCallback onTap;
const MetadataCandidateTile({
super.key,
required this.candidate,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final title = (candidate['name'] ?? candidate['title'] ?? '')
.toString()
.trim();
final artists = (candidate['artists'] ?? candidate['artist'] ?? '')
.toString()
.trim();
final album = (candidate['album_name'] ?? candidate['album'] ?? '')
.toString()
.trim();
final date = candidate['release_date']?.toString().trim() ?? '';
final isrc = candidate['isrc']?.toString().trim() ?? '';
final subtitle = [
artists,
album,
if (date.isNotEmpty) date,
if (isrc.isNotEmpty) 'ISRC ${formatIsrcForDisplay(isrc)}',
].where((value) => value.isNotEmpty).join('\n');
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
leading: _MetadataCandidateArtwork(
coverUrl: MetadataAutofillService().coverUrl(candidate),
),
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: subtitle.isEmpty
? null
: Text(subtitle, maxLines: 4, overflow: TextOverflow.ellipsis),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
}
class _MetadataCandidateArtwork extends StatefulWidget {
final String? coverUrl;
const _MetadataCandidateArtwork({required this.coverUrl});
@override
State<_MetadataCandidateArtwork> createState() =>
_MetadataCandidateArtworkState();
}
class _MetadataCandidateArtworkState extends State<_MetadataCandidateArtwork> {
final _covers = MetadataCoverResources();
String? _coverPath;
String? _tempDir;
({int width, int height, int bytes})? _details;
int _generation = 0;
@override
void initState() {
super.initState();
if (widget.coverUrl?.isNotEmpty == true) {
unawaited(_loadCover());
}
}
Future<void> _loadCover() async {
final generation = ++_generation;
final preview = await _covers.download(widget.coverUrl!);
if (!mounted || generation != _generation) {
await _covers.release(preview?.tempDir);
return;
}
setState(() {
_coverPath = preview?.path;
_tempDir = preview?.tempDir;
_details = preview?.details;
});
}
@override
void didUpdateWidget(covariant _MetadataCandidateArtwork oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.coverUrl == oldWidget.coverUrl) return;
_generation++;
unawaited(_covers.release(_tempDir));
_tempDir = null;
_coverPath = null;
_details = null;
if (widget.coverUrl?.isNotEmpty == true) unawaited(_loadCover());
}
@override
void dispose() {
unawaited(_covers.dispose());
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final coverUrl = widget.coverUrl;
final details = _details;
final image = _coverPath != null
? Image.file(
File(_coverPath!),
width: 56,
height: 56,
fit: BoxFit.cover,
cacheWidth: (56 * MediaQuery.devicePixelRatioOf(context)).round(),
cacheHeight: (56 * MediaQuery.devicePixelRatioOf(context)).round(),
filterQuality: FilterQuality.low,
)
: coverUrl != null
? CachedCoverImage(
imageUrl: coverUrl,
width: 56,
height: 56,
fit: BoxFit.cover,
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: const Icon(Icons.music_note),
);
return SizedBox(
width: 72,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(borderRadius: BorderRadius.circular(10), child: image),
if (details != null) ...[
const SizedBox(height: 3),
Text(
'${details.width}×${details.height} · '
'${(details.bytes / 1024).toStringAsFixed(1)} KB',
maxLines: 2,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.primary,
fontSize: 9,
height: 1.1,
),
),
],
],
),
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
Color metadataEditorFieldFill(BuildContext context, ColorScheme cs) {
if (context.isMornye) return Colors.transparent;
final isDark = Theme.of(context).brightness == Brightness.dark;
return isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), cs.surface)
: cs.surface;
}
class MetadataTextField extends StatelessWidget {
final ColorScheme colorScheme;
final String label;
final TextEditingController controller;
final String? hint;
final TextInputType? keyboard;
final int maxLines;
final List<TextInputFormatter>? inputFormatters;
const MetadataTextField(
this.colorScheme,
this.label,
this.controller, {
super.key,
this.hint,
this.keyboard,
this.maxLines = 1,
this.inputFormatters,
});
@override
Widget build(BuildContext context) {
final cs = colorScheme;
final radius = BorderRadius.circular(14);
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 6),
child: Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: cs.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
),
),
if (context.isMornye) ...[
CupertinoTextField(
controller: controller,
keyboardType: keyboard,
inputFormatters: inputFormatters,
maxLines: maxLines,
placeholder: hint,
cursorColor: cs.primary,
style: Theme.of(context).textTheme.bodyLarge,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14),
decoration: null,
),
const Divider(height: 0.5),
] else
TextField(
controller: controller,
keyboardType: keyboard,
inputFormatters: inputFormatters,
maxLines: maxLines,
cursorColor: cs.primary,
style: Theme.of(context).textTheme.bodyLarge,
decoration: InputDecoration(
hintText: hint,
filled: true,
fillColor: metadataEditorFieldFill(context, cs),
isDense: true,
border: OutlineInputBorder(
borderRadius: radius,
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: radius,
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: radius,
borderSide: BorderSide(color: cs.primary, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 15,
),
),
),
],
),
);
}
}
+160
View File
@@ -0,0 +1,160 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/metadata_autofill_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final service = MetadataAutofillService();
Extension provider(String id, String name, {bool enabled = true}) =>
Extension(
id: id,
name: id,
displayName: name,
version: '1.0.0',
description: 'Metadata fixture',
enabled: enabled,
status: 'loaded',
hasMetadataProvider: true,
);
test(
'configured providers precede remaining enabled alphabetical providers',
() {
final ordered = service.orderedProviders(
ExtensionState(
extensions: [
provider('third', 'Zeta'),
provider('second', 'Alpha'),
provider('first', 'Beta'),
provider('disabled', 'Disabled', enabled: false),
],
metadataProviderPriority: const ['missing', 'third', 'third'],
),
);
expect(ordered.map((extension) => extension.id), [
'third',
'second',
'first',
]);
},
);
test('Unicode title/artist and exact ISRC matches retain confidence', () {
expect(service.normalizeText(' 春の歌 — 歌手 '), '春の歌 歌手');
expect(
service.matchIsConfident(
{'name': '春の歌', 'artists': '歌手'},
currentTitle: '春の歌',
currentArtist: '歌手',
currentAlbum: '',
currentIsrc: '',
),
isTrue,
);
expect(
service.matchIsConfident(
{
'name': 'Other Song',
'artists': 'Other Artist',
'isrc': 'USABC1234567',
},
currentTitle: 'song',
currentArtist: 'artist',
currentAlbum: '',
currentIsrc: 'USABC1234567',
),
isTrue,
);
expect(
service.matchIsConfident(
{'name': 'Song', 'artists': 'Another Performer'},
currentTitle: 'song',
currentArtist: 'artist',
currentAlbum: '',
currentIsrc: '',
),
isFalse,
);
});
test(
'selected-provider enrichment filters unknown counts but keeps explicit zero',
() async {
final result = await service.enrich(
{
'title': 'Song',
'track_number': 0,
'explicit': 0,
'images': [
{'large': 'https://example.invalid/cover.jpg'},
],
},
currentValues: const {},
fields: {'title', 'explicit', 'cover'},
providerId: 'example-provider',
sourceTrackId: null,
durationMs: 0,
syncLyricsSettings: () async {},
isCurrent: () => true,
);
expect(result!.values, {'title': 'Song', 'explicit': '0'});
expect(result.coverUrl, 'https://example.invalid/cover.jpg');
expect(result.sourceId, 'example-provider');
},
);
test('cancelled enrichment performs no lyrics work', () async {
var synced = false;
final result = await service.enrich(
null,
currentValues: const {'title': 'Song', 'artist': 'Artist'},
fields: {'lyrics'},
providerId: null,
sourceTrackId: null,
durationMs: 0,
syncLyricsSettings: () async {
synced = true;
},
isCurrent: () => false,
);
expect(result, isNull);
expect(synced, isFalse);
});
for (final instrumental in [false, true]) {
test('lyrics-only enrichment handles instrumental=$instrumental', () async {
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
var synced = false;
messenger.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'getLyricsLRCWithSource');
expect(synced, isTrue);
return jsonEncode({
'lyrics': instrumental ? '[instrumental:true]' : '[00:01.00]Lyric',
'instrumental': instrumental,
'source': 'Example Lyrics',
});
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final result = await service.enrich(
null,
currentValues: const {'title': 'Song', 'artist': 'Artist'},
fields: {'lyrics'},
providerId: null,
sourceTrackId: null,
durationMs: 180000,
syncLyricsSettings: () async {
synced = true;
},
isCurrent: () => true,
);
expect(result!.lyricsSource, 'Example Lyrics');
expect(result.values.containsKey('lyrics'), !instrumental);
});
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/metadata_cover_resources.dart';
void main() {
test(
'closing an editor preserves artwork until its save lease finishes',
() async {
final resources = MetadataCoverResources();
final preview = await resources.copyPicked(
extension: 'png',
bytes: Uint8List.fromList([1, 2, 3]),
);
final release = resources.retain();
await resources.dispose();
expect(await File(preview.path).readAsBytes(), [1, 2, 3]);
await release();
expect(await Directory(preview.tempDir).exists(), isFalse);
await release();
},
);
test(
'disposal during a file creation cleans the completed resource',
() async {
final resources = MetadataCoverResources();
final pending = resources.copyPicked(
extension: 'jpg',
bytes: Uint8List.fromList([1, 2, 3]),
);
await resources.dispose();
final preview = await pending;
expect(await Directory(preview.tempDir).exists(), isFalse);
},
);
test(
'replacing artwork defers release until every save lease finishes',
() async {
final resources = MetadataCoverResources();
addTearDown(resources.dispose);
final preview = await resources.copyPicked(
extension: 'png',
bytes: Uint8List.fromList([1, 2, 3]),
);
final firstSave = resources.retain();
final secondSave = resources.retain();
await resources.release(preview.tempDir);
expect(await File(preview.path).readAsBytes(), [1, 2, 3]);
await firstSave();
expect(await File(preview.path).exists(), isTrue);
await secondSave();
expect(await Directory(preview.tempDir).exists(), isFalse);
},
);
test(
'releasing a selected image leaves other editor resources intact',
() async {
final resources = MetadataCoverResources();
addTearDown(resources.dispose);
final first = await resources.copyPicked(
extension: 'png',
bytes: Uint8List.fromList([1]),
);
final second = await resources.copyPicked(
extension: 'jpg',
bytes: Uint8List.fromList([2]),
);
await resources.release(first.tempDir);
expect(await Directory(first.tempDir).exists(), isFalse);
expect(await File(second.path).exists(), isTrue);
},
);
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/controllers/metadata_editor_controller.dart';
void main() {
test('save snapshots preserve empty tags and explicit track/disc totals', () {
final editor = MetadataEditorController({
'title': 'Song',
'artist': 'Artist One; Artist Two',
'total_tracks': '12',
'total_discs': '2',
'album_type': ' Compilation ',
'explicit': 'true',
});
final tags = editor.saveMetadata(artistTagMode: 'all');
editor['title']!.text = 'Changed';
editor.dispose();
expect(tags['title'], 'Song');
expect(tags['artist'], 'Artist One; Artist Two');
expect(tags['lyrics'], '');
expect(tags['track_total'], '12');
expect(tags['disc_total'], '2');
expect(tags['compilation'], '1');
expect(tags['explicit'], '1');
expect(tags['artist_tag_mode'], 'all');
expect(tags.containsKey('total_tracks'), isFalse);
expect(tags.containsKey('total_discs'), isFalse);
});
test('edits and disposal invalidate asynchronous lookup results', () {
final editor = MetadataEditorController({'title': 'Song'});
final first = editor.beginLookup();
expect(editor.isCurrent(first), isTrue);
editor['title']!.text = 'Other Song';
expect(editor.isCurrent(first), isFalse);
final second = editor.beginLookup();
editor.dispose();
expect(editor.isCurrent(second), isFalse);
});
test('caret and composition changes keep pending lookup results current', () {
final editor = MetadataEditorController({'title': 'Song'});
addTearDown(editor.dispose);
final generation = editor.beginLookup();
final title = editor['title']!;
title.selection = const TextSelection.collapsed(offset: 2);
expect(editor.isCurrent(generation), isTrue);
title.value = title.value.copyWith(
composing: const TextRange(start: 0, end: 4),
);
expect(editor.isCurrent(generation), isTrue);
title.clearComposing();
expect(editor.isCurrent(generation), isTrue);
title.text = 'Other Song';
expect(editor.isCurrent(generation), isFalse);
});
test(
'empty-field selection and apply respect manual tags and explicit false',
() {
final editor = MetadataEditorController({'title': 'Manual Title'});
addTearDown(editor.dispose);
editor.selectEmptyFields(hasCover: true);
expect(editor.autoFillFields, isNot(contains('title')));
expect(editor.autoFillFields, isNot(contains('cover')));
expect(editor.autoFillFields, isNot(contains('explicit')));
editor.autoFillFields.add('explicit');
editor.explicit = true;
final filled = editor.apply({
'title': 'Online Title',
'artist': 'Online Artist',
'explicit': '0',
});
expect(filled, 2);
expect(editor['title']!.text, 'Manual Title');
expect(editor['artist']!.text, 'Online Artist');
expect(editor.explicit, isFalse);
editor.selectEmptyFields(hasCover: false);
expect(editor.autoFillFields, contains('cover'));
},
);
}