Fix metadata consistency (Go->Flutter) and build optimization

- Backend: Return full metadata (Track, Disc, Year) from Tidal/Qobuz/Amazon download results
- Flutter: Use backend metadata for tagging converted M4A and history entries
- Fix: Duplicate convertTrack method in deezer.go
- Fix: Better error message for Deezer fallback failure
- Changed: Default service fallback to Tidal -> Qobuz -> Amazon
- Build: Re-enabled resource shrinking and minification for release build
This commit is contained in:
zarzet
2026-01-09 02:12:24 +07:00
parent ee807a44cc
commit 01b8fd2480
31 changed files with 3080 additions and 470 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '2.1.5';
static const String buildNumber = '43';
static const String version = '2.1.6';
static const String buildNumber = '44';
static const String fullVersion = '$version+$buildNumber';
+32
View File
@@ -13,6 +13,14 @@ enum DownloadStatus {
skipped,
}
/// Error type enum for better error handling
enum DownloadErrorType {
unknown,
notFound, // Track not found on any service
rateLimit, // Rate limited by service
network, // Network/connection error
}
@JsonSerializable()
class DownloadItem {
final String id;
@@ -20,8 +28,10 @@ class DownloadItem {
final String service;
final DownloadStatus status;
final double progress;
final double speedMBps; // Download speed in MB/s
final String? filePath;
final String? error;
final DownloadErrorType? errorType;
final DateTime createdAt;
final String? qualityOverride; // Override quality for this specific download
@@ -31,8 +41,10 @@ class DownloadItem {
required this.service,
this.status = DownloadStatus.queued,
this.progress = 0.0,
this.speedMBps = 0.0,
this.filePath,
this.error,
this.errorType,
required this.createdAt,
this.qualityOverride,
});
@@ -43,8 +55,10 @@ class DownloadItem {
String? service,
DownloadStatus? status,
double? progress,
double? speedMBps,
String? filePath,
String? error,
DownloadErrorType? errorType,
DateTime? createdAt,
String? qualityOverride,
}) {
@@ -54,13 +68,31 @@ class DownloadItem {
service: service ?? this.service,
status: status ?? this.status,
progress: progress ?? this.progress,
speedMBps: speedMBps ?? this.speedMBps,
filePath: filePath ?? this.filePath,
error: error ?? this.error,
errorType: errorType ?? this.errorType,
createdAt: createdAt ?? this.createdAt,
qualityOverride: qualityOverride ?? this.qualityOverride,
);
}
/// Get user-friendly error message based on error type
String get errorMessage {
if (error == null) return '';
switch (errorType) {
case DownloadErrorType.notFound:
return 'Song not found on any service';
case DownloadErrorType.rateLimit:
return 'Rate limit reached, try again later';
case DownloadErrorType.network:
return 'Connection failed, check your internet';
default:
return error ?? 'An error occurred';
}
}
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
_$DownloadItemFromJson(json);
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
+11
View File
@@ -14,8 +14,10 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
createdAt: DateTime.parse(json['createdAt'] as String),
qualityOverride: json['qualityOverride'] as String?,
);
@@ -27,8 +29,10 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'service': instance.service,
'status': _$DownloadStatusEnumMap[instance.status]!,
'progress': instance.progress,
'speedMBps': instance.speedMBps,
'filePath': instance.filePath,
'error': instance.error,
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
};
@@ -41,3 +45,10 @@ const _$DownloadStatusEnumMap = {
DownloadStatus.failed: 'failed',
DownloadStatus.skipped: 'skipped',
};
const _$DownloadErrorTypeEnumMap = {
DownloadErrorType.unknown: 'unknown',
DownloadErrorType.notFound: 'notFound',
DownloadErrorType.rateLimit: 'rateLimit',
DownloadErrorType.network: 'network',
};
+1 -1
View File
@@ -7,7 +7,7 @@ part of 'settings.dart';
// **************************************************************************
AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
defaultService: json['defaultService'] as String? ?? 'tidal',
defaultService: json['defaultService'] as String? ?? 'qobuz',
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '',
+8
View File
@@ -16,6 +16,7 @@ class Track {
final int? trackNumber;
final int? discNumber;
final String? releaseDate;
final String? deezerId;
final ServiceAvailability? availability;
const Track({
@@ -30,6 +31,7 @@ class Track {
this.trackNumber,
this.discNumber,
this.releaseDate,
this.deezerId,
this.availability,
});
@@ -42,17 +44,23 @@ class ServiceAvailability {
final bool tidal;
final bool qobuz;
final bool amazon;
final bool deezer;
final String? tidalUrl;
final String? qobuzUrl;
final String? amazonUrl;
final String? deezerUrl;
final String? deezerId;
const ServiceAvailability({
this.tidal = false,
this.qobuz = false,
this.amazon = false,
this.deezer = false,
this.tidalUrl,
this.qobuzUrl,
this.amazonUrl,
this.deezerUrl,
this.deezerId,
});
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
+8
View File
@@ -18,6 +18,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?,
deezerId: json['deezerId'] as String?,
availability: json['availability'] == null
? null
: ServiceAvailability.fromJson(
@@ -37,6 +38,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
'releaseDate': instance.releaseDate,
'deezerId': instance.deezerId,
'availability': instance.availability,
};
@@ -45,9 +47,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
tidal: json['tidal'] as bool? ?? false,
qobuz: json['qobuz'] as bool? ?? false,
amazon: json['amazon'] as bool? ?? false,
deezer: json['deezer'] as bool? ?? false,
tidalUrl: json['tidalUrl'] as String?,
qobuzUrl: json['qobuzUrl'] as String?,
amazonUrl: json['amazonUrl'] as String?,
deezerUrl: json['deezerUrl'] as String?,
deezerId: json['deezerId'] as String?,
);
Map<String, dynamic> _$ServiceAvailabilityToJson(
@@ -56,7 +61,10 @@ Map<String, dynamic> _$ServiceAvailabilityToJson(
'tidal': instance.tidal,
'qobuz': instance.qobuz,
'amazon': instance.amazon,
'deezer': instance.deezer,
'tidalUrl': instance.tidalUrl,
'qobuzUrl': instance.qobuzUrl,
'amazonUrl': instance.amazonUrl,
'deezerUrl': instance.deezerUrl,
'deezerId': instance.deezerId,
};
+302 -76
View File
@@ -371,6 +371,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final itemProgress = entry.value as Map<String, dynamic>;
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
final status = itemProgress['status'] as String? ?? 'downloading';
@@ -389,14 +390,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
continue;
}
if (isDownloading && bytesTotal > 0) {
final percentage = bytesReceived / bytesTotal;
updateProgress(itemId, percentage);
// Use progress from backend if available (handles both explicit progress and byte-based)
final progressFromBackend = (itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
if (isDownloading) {
double percentage = 0.0;
if (bytesTotal > 0) {
// Calculate from bytes if available for precision
percentage = bytesReceived / bytesTotal;
} else {
// Fallback to backend-reported progress (e.g. for DASH segments)
percentage = progressFromBackend;
}
// Log progress for each item
updateProgress(itemId, percentage, speedMBps: speedMBps);
// Log progress for each item with speed
final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024);
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
if (bytesTotal > 0) {
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s');
} else {
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s');
}
}
}
@@ -427,11 +443,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? downloadingItems.first.track.artistName
: 'Downloading...';
// Calculate notification progress values
int notifProgress = bytesReceived;
int notifTotal = bytesTotal;
if (bytesTotal <= 0) {
// Fallback to percentage for DASH/unknown size
final progressPercent = (firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
notifProgress = (progressPercent * 100).toInt();
notifTotal = 100;
}
_notificationService.showDownloadProgress(
trackName: trackName,
artistName: artistName,
progress: bytesReceived,
total: bytesTotal > 0 ? bytesTotal : 1,
progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1,
);
// Update foreground service notification (Android)
@@ -439,8 +466,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
PlatformBridge.updateDownloadServiceProgress(
trackName: downloadingItems.first.track.name,
artistName: downloadingItems.first.track.artistName,
progress: bytesReceived,
total: bytesTotal > 0 ? bytesTotal : 1,
progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1,
queueCount: state.queuedCount,
).catchError((_) {}); // Ignore errors
}
@@ -609,14 +636,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
void updateItemStatus(String id, DownloadStatus status, {double? progress, String? filePath, String? error}) {
void updateItemStatus(String id, DownloadStatus status, {double? progress, double? speedMBps, String? filePath, String? error, DownloadErrorType? errorType}) {
final items = state.items.map((item) {
if (item.id == id) {
return item.copyWith(
status: status,
progress: progress ?? item.progress,
speedMBps: speedMBps ?? item.speedMBps,
filePath: filePath,
error: error,
errorType: errorType,
);
}
return item;
@@ -632,8 +661,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
void updateProgress(String id, double progress) {
updateItemStatus(id, DownloadStatus.downloading, progress: progress);
void updateProgress(String id, double progress, {double? speedMBps}) {
updateItemStatus(id, DownloadStatus.downloading, progress: progress, speedMBps: speedMBps);
}
void cancelItem(String id) {
@@ -732,18 +761,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Download cover first
String? coverPath;
if (track.coverUrl != null && track.coverUrl!.isNotEmpty) {
coverPath = '$flacPath.cover.jpg';
try {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
// Download cover using HTTP
final httpClient = HttpClient();
final request = await httpClient.getUrl(Uri.parse(track.coverUrl!));
final response = await request.close();
if (response.statusCode == 200) {
final file = File(coverPath);
final file = File(coverPath!);
final sink = file.openWrite();
await response.pipe(sink);
await sink.close();
_log.d('Cover downloaded to: $coverPath');
_log.d('Cover downloaded to temp: $coverPath');
} else {
_log.w('Failed to download cover: HTTP ${response.statusCode}');
coverPath = null;
@@ -757,20 +789,85 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Use Go backend to embed metadata
try {
// For now, we'll use FFmpeg to embed cover since Go backend expects to download the file
// FFmpeg can embed cover art to FLAC
if (coverPath != null && await File(coverPath).exists()) {
final result = await FFmpegService.embedCover(flacPath, coverPath);
// Use FFmpeg to embed cover art AND text metadata
// FFmpeg can embed cover art to FLAC and also set tags
// Construct metadata map
final metadata = <String, String>{
'TITLE': track.name,
'ARTIST': track.artistName,
'ALBUM': track.albumName,
};
if (track.albumArtist != null) {
metadata['ALBUMARTIST'] = track.albumArtist!;
}
if (track.trackNumber != null) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString(); // Compatibility
}
if (track.discNumber != null) {
metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString(); // Compatibility
}
if (track.releaseDate != null) {
metadata['DATE'] = track.releaseDate!;
metadata['YEAR'] = track.releaseDate!.split('-').first;
}
if (track.isrc != null) {
metadata['ISRC'] = track.isrc!;
}
// Fetch Lyrics (Critical for M4A->FLAC conversion parity)
// Since we are in the Flutter context, we can call the bridge to get lyrics
// This ensures even converted files have lyrics embedded if available
try {
final lrcContent = await PlatformBridge.getLyricsLRC(
track.id, // spotifyID
track.name,
track.artistName,
filePath: '', // No local file path yet (processed in memory)
);
if (result != null) {
_log.d('Cover embedded via FFmpeg');
} else {
_log.w('FFmpeg cover embed failed');
if (lrcContent != null && lrcContent.isNotEmpty) {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
}
// Clean up cover file
} catch (e) {
_log.w('Failed to fetch lyrics for embedding: $e');
}
_log.d('Generating tags for FLAC: $metadata');
// Perform embedding (cover + text metadata)
// Note: FFmpegService.embedMetadata handles safe temp file creation
final result = await FFmpegService.embedMetadata(
flacPath: flacPath,
coverPath: coverPath != null && await File(coverPath).exists() ? coverPath : null,
metadata: metadata,
);
if (result != null) {
_log.d('Metadata and cover embedded via FFmpeg');
} else {
_log.w('FFmpeg metadata/cover embed failed');
}
// Clean up cover file if it exists
if (coverPath != null) {
try {
await File(coverPath).delete();
final coverFile = File(coverPath);
if (await coverFile.exists()) {
// In Android 10+ scoped storage, we can't easily delete if we didn't create it
// in this session or if it's not in our app dir.
// But coverPath is typically in temp dir now.
await coverFile.delete();
}
} catch (_) {}
}
} catch (e) {
@@ -992,7 +1089,49 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
// Get folder organization setting and build output directory
final settings = ref.read(settingsProvider);
final outputDir = await _buildOutputDir(item.track, settings.folderOrganization);
// Metadata Enrichment:
// If track number is missing/0 (common from Search results), fetch full metadata
// This ensures the downloaded file has correct tags (Track, Disc, Year)
Track trackToDownload = item.track;
if (trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0) {
try {
if (trackToDownload.id.startsWith('deezer:')) {
_log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}');
final rawId = trackToDownload.id.split(':')[1];
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId);
if (fullData.containsKey('track')) {
final fullTrack = Track.fromJson(fullData['track'] as Map<String, dynamic>);
// Merge with existing (keep override quality/service if any, but update metadata)
trackToDownload = Track(
id: fullTrack.id.isNotEmpty ? fullTrack.id : trackToDownload.id,
name: fullTrack.name,
artistName: fullTrack.artistName,
albumName: fullTrack.albumName,
albumArtist: fullTrack.albumArtist,
coverUrl: fullTrack.coverUrl,
duration: fullTrack.duration,
isrc: fullTrack.isrc ?? trackToDownload.isrc,
trackNumber: fullTrack.trackNumber,
discNumber: fullTrack.discNumber,
releaseDate: fullTrack.releaseDate,
deezerId: fullTrack.deezerId,
availability: trackToDownload.availability,
);
_log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, Year ${trackToDownload.releaseDate}');
// Update item in state with enriched track
// This is important so the UI (and history) reflects the enriched data
// We don't perform a full `updateItemStatus` here to avoid UI flicker, just local var
}
}
} catch (e) {
_log.w('Failed to enrich metadata: $e');
}
}
final outputDir = await _buildOutputDir(trackToDownload, settings.folderOrganization);
// Use quality override if set, otherwise use default from settings
final quality = item.qualityOverride ?? state.audioQuality;
@@ -1004,41 +1143,41 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}');
_log.d('Output dir: $outputDir');
result = await PlatformBridge.downloadWithFallback(
isrc: item.track.isrc ?? '',
spotifyId: item.track.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
isrc: trackToDownload.isrc ?? '',
spotifyId: trackToDownload.id,
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: quality,
trackNumber: item.track.trackNumber ?? 1,
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
preferredService: item.service,
itemId: item.id, // Pass item ID for progress tracking
durationMs: item.track.duration, // Duration in ms for verification
durationMs: trackToDownload.duration, // Duration in ms for verification
);
} else {
result = await PlatformBridge.downloadTrack(
isrc: item.track.isrc ?? '',
isrc: trackToDownload.isrc ?? '',
service: item.service,
spotifyId: item.track.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
spotifyId: trackToDownload.id,
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: quality,
trackNumber: item.track.trackNumber ?? 1,
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
itemId: item.id, // Pass item ID for progress tracking
durationMs: item.track.duration, // Duration in ms for verification
durationMs: trackToDownload.duration, // Duration in ms for verification
);
}
@@ -1082,26 +1221,74 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Actual quality: $actualQuality');
}
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
if (filePath != null && filePath.endsWith('.m4a')) {
_log.d('Converting M4A to FLAC...');
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
if (flacPath != null) {
filePath = flacPath;
_log.d('Converted to: $flacPath');
// After conversion, embed metadata and cover to the new FLAC file
_log.d('Embedding metadata and cover to converted FLAC...');
try {
await _embedMetadataAndCover(
flacPath,
item.track,
);
_log.d('Metadata and cover embedded successfully');
} catch (e) {
_log.w('Warning: Failed to embed metadata/cover: $e');
// M4A files from Tidal DASH streams - try to convert to FLAC
// M4A files from Tidal DASH streams - try to convert to FLAC
if (filePath != null && filePath!.endsWith('.m4a')) {
_log.d('M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...');
try {
final file = File(filePath!);
if (!await file.exists()) {
_log.e('File does not exist at path: $filePath');
} else {
final length = await file.length();
_log.i('File size before conversion: ${length / 1024} KB');
if (length < 1024) {
_log.w('File is too small (<1KB), skipping conversion. Download might be corrupt.');
} else {
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.95);
final flacPath = await FFmpegService.convertM4aToFlac(filePath!);
if (flacPath != null) {
filePath = flacPath;
_log.d('Converted to FLAC: $flacPath');
// After conversion, embed metadata and cover to the new FLAC file
_log.d('Embedding metadata and cover to converted FLAC...');
try {
// Update track with actual metadata from backend result (if available)
// This creates the most accurate metadata possible (from the service itself)
Track finalTrack = trackToDownload;
if (result.containsKey('track_number') || result.containsKey('release_date')) {
_log.d('Using metadata from backend response for embedding');
final backendTrackNum = result['track_number'] as int?;
final backendDiscNum = result['disc_number'] as int?;
final backendYear = result['release_date'] as String?;
final backendAlbum = result['album'] as String?;
// Create updated track object
finalTrack = Track(
id: trackToDownload.id,
name: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: backendAlbum ?? trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
coverUrl: trackToDownload.coverUrl,
duration: trackToDownload.duration,
isrc: trackToDownload.isrc,
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber,
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber,
releaseDate: backendYear ?? trackToDownload.releaseDate,
deezerId: trackToDownload.deezerId,
availability: trackToDownload.availability,
);
}
// Use enriched/updated track for metadata embedding
await _embedMetadataAndCover(flacPath, finalTrack);
_log.d('Metadata and cover embedded successfully');
} catch (e) {
_log.w('Warning: Failed to embed metadata/cover: $e');
}
} else {
_log.w('FFmpeg conversion returned null, keeping M4A file');
}
}
}
} catch (e) {
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
// Keep the M4A file if conversion fails
}
}
@@ -1143,12 +1330,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
if (filePath != null) {
// Extract updated metadata from backend result if available
final backendTitle = result['title'] as String?;
final backendArtist = result['artist'] as String?;
final backendAlbum = result['album'] as String?;
final backendYear = result['release_date'] as String?;
final backendTrackNum = result['track_number'] as int?;
final backendDiscNum = result['disc_number'] as int?;
ref.read(downloadHistoryProvider.notifier).addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
trackName: (backendTitle != null && backendTitle.isNotEmpty) ? backendTitle : item.track.name,
artistName: (backendArtist != null && backendArtist.isNotEmpty) ? backendArtist : item.track.artistName,
albumName: (backendAlbum != null && backendAlbum.isNotEmpty) ? backendAlbum : item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
filePath: filePath,
@@ -1157,10 +1352,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Additional metadata
isrc: item.track.isrc,
spotifyId: item.track.id,
trackNumber: item.track.trackNumber,
discNumber: item.track.discNumber,
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber,
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber,
duration: item.track.duration,
releaseDate: item.track.releaseDate,
releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate,
quality: actualQuality,
),
);
@@ -1170,11 +1365,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
} else {
final errorMsg = result['error'] as String? ?? 'Download failed';
_log.e('Download failed: $errorMsg');
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
// Convert error type string to enum
DownloadErrorType errorType;
switch (errorTypeStr) {
case 'not_found':
errorType = DownloadErrorType.notFound;
break;
case 'rate_limit':
errorType = DownloadErrorType.rateLimit;
break;
case 'network':
errorType = DownloadErrorType.network;
break;
default:
errorType = DownloadErrorType.unknown;
}
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
errorType: errorType,
);
_failedInSession++;
}
@@ -1191,10 +1405,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
} catch (e, stackTrace) {
_log.e('Exception: $e', e, stackTrace);
String errorMsg = e.toString();
DownloadErrorType errorType = DownloadErrorType.unknown;
// Check for specific Deezer fallback error
if (errorMsg.contains('could not find Deezer equivalent') ||
errorMsg.contains('track not found on Deezer')) {
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
errorType = DownloadErrorType.notFound;
}
updateItemStatus(
item.id,
DownloadStatus.failed,
error: e.toString(),
error: errorMsg,
errorType: errorType,
);
_failedInSession++;
}
+5 -2
View File
@@ -331,8 +331,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
const SizedBox(width: 8),
// Show percentage and speed
Text(
'${(item.progress * 100).toStringAsFixed(0)}%',
item.speedMBps > 0
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: '${(item.progress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
@@ -344,7 +347,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (item.status == DownloadStatus.failed) ...[
const SizedBox(height: 4),
Text(
item.error ?? 'Download failed',
item.errorMessage, // Use user-friendly error message
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
+351 -79
View File
@@ -23,9 +23,15 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
String? _selectedDirectory;
bool _isLoading = false;
int _androidSdkVersion = 0;
// Spotify API credentials
final _clientIdController = TextEditingController();
final _clientSecretController = TextEditingController();
bool _useSpotifyApi = false;
bool _showClientSecret = false;
// Total steps: Storage -> Notification (Android 13+) -> Folder
int get _totalSteps => _androidSdkVersion >= 33 ? 3 : 2;
// Total steps: Storage -> Notification (Android 13+) -> Folder -> Spotify API
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
@override
void initState() {
@@ -33,6 +39,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
_initDeviceInfo();
}
@override
void dispose() {
_clientIdController.dispose();
_clientSecretController.dispose();
super.dispose();
}
Future<void> _initDeviceInfo() async {
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
@@ -358,6 +371,23 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
// Save Spotify credentials if provided
if (_useSpotifyApi &&
_clientIdController.text.trim().isNotEmpty &&
_clientSecretController.text.trim().isNotEmpty) {
ref.read(settingsProvider.notifier).setSpotifyCredentials(
_clientIdController.text.trim(),
_clientSecretController.text.trim(),
);
ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(true);
// Set search source to Spotify when using custom credentials
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
} else {
// Use Deezer as default search source
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
}
ref.read(settingsProvider.notifier).setFirstLaunchComplete();
if (mounted) {
@@ -436,8 +466,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Widget _buildStepIndicator(ColorScheme colorScheme) {
final steps = _androidSdkVersion >= 33
? ['Storage', 'Notification', 'Folder']
: ['Permission', 'Folder'];
? ['Storage', 'Notification', 'Folder', 'Spotify']
: ['Permission', 'Folder', 'Spotify'];
return Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -461,48 +491,61 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
final isActive = _currentStep >= step;
final isCompleted = _isStepCompleted(step);
final isCurrent = _currentStep == step;
return Column(
children: [
Container(
width: 32,
height: 32,
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isCompleted
? colorScheme.primary
: isActive ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
: isCurrent
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
border: isCurrent && !isCompleted
? Border.all(color: colorScheme.primary, width: 2)
: null,
),
child: Center(
child: isCompleted
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
? Icon(Icons.check_rounded, size: 20, color: colorScheme.onPrimary)
: Text('${step + 1}',
style: TextStyle(
color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold)),
color: isCurrent ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 14,
)),
),
),
const SizedBox(height: 4),
const SizedBox(height: 6),
Text(label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant)),
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal,
)),
],
);
}
bool _isStepCompleted(int step) {
if (_androidSdkVersion >= 33) {
// 3 steps: Storage, Notification, Folder
// 4 steps: Storage, Notification, Folder, Spotify
switch (step) {
case 0: return _storagePermissionGranted;
case 1: return _notificationPermissionGranted;
case 2: return _selectedDirectory != null;
case 3: return false; // Spotify step never shows checkmark (optional)
}
} else {
// 2 steps: Permission, Folder
// 3 steps: Permission, Folder, Spotify
switch (step) {
case 0: return _storagePermissionGranted;
case 1: return _selectedDirectory != null;
case 2: return false; // Spotify step never shows checkmark (optional)
}
}
return false;
@@ -514,11 +557,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
case 0: return _buildStoragePermissionStep(colorScheme);
case 1: return _buildNotificationPermissionStep(colorScheme);
case 2: return _buildDirectoryStep(colorScheme);
case 3: return _buildSpotifyApiStep(colorScheme);
}
} else {
switch (_currentStep) {
case 0: return _buildStoragePermissionStep(colorScheme);
case 1: return _buildDirectoryStep(colorScheme);
case 2: return _buildSpotifyApiStep(colorScheme);
}
}
return const SizedBox();
@@ -529,35 +574,50 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_storagePermissionGranted ? Icons.check_circle : Icons.folder_open,
size: 56,
color: _storagePermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
// Icon with container background (M3 style)
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: _storagePermissionGranted ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24),
),
child: Icon(
_storagePermissionGranted ? Icons.check_rounded : Icons.folder_open_rounded,
size: 40,
color: _storagePermissionGranted ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
const SizedBox(height: 20),
Text(
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_storagePermissionGranted
? 'You can now proceed to the next step.'
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_storagePermissionGranted
? 'You can now proceed to the next step.'
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
const SizedBox(height: 24),
if (!_storagePermissionGranted)
FilledButton.icon(
onPressed: _isLoading ? null : _requestStoragePermission,
icon: _isLoading
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.security),
: const Icon(Icons.security_rounded),
label: const Text('Grant Permission'),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
],
);
@@ -568,39 +628,57 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_notificationPermissionGranted ? Icons.check_circle : Icons.notifications_outlined,
size: 56,
color: _notificationPermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
// Icon with container background (M3 style)
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: _notificationPermissionGranted ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24),
),
child: Icon(
_notificationPermissionGranted ? Icons.check_rounded : Icons.notifications_outlined,
size: 40,
color: _notificationPermissionGranted ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
const SizedBox(height: 20),
Text(
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_notificationPermissionGranted
? 'You will receive download progress notifications.'
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_notificationPermissionGranted
? 'You will receive download progress notifications.'
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
const SizedBox(height: 24),
if (!_notificationPermissionGranted) ...[
FilledButton.icon(
onPressed: _isLoading ? null : _requestNotificationPermission,
icon: _isLoading
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.notifications_active),
: const Icon(Icons.notifications_active_rounded),
label: const Text('Enable Notifications'),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
const SizedBox(height: 12),
TextButton(
onPressed: _skipNotificationPermission,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: const Text('Skip for now'),
),
],
@@ -613,51 +691,226 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_selectedDirectory != null ? Icons.folder : Icons.create_new_folder,
size: 56,
color: _selectedDirectory != null ? colorScheme.primary : colorScheme.onSurfaceVariant,
// Icon with container background (M3 style)
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: _selectedDirectory != null ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24),
),
child: Icon(
_selectedDirectory != null ? Icons.folder_rounded : Icons.create_new_folder_rounded,
size: 40,
color: _selectedDirectory != null ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
const SizedBox(height: 20),
Text(
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
if (_selectedDirectory != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder, color: colorScheme.primary, size: 20),
const SizedBox(width: 8),
Flexible(
child: Text(_selectedDirectory!,
style: Theme.of(context).textTheme.bodySmall,
overflow: TextOverflow.ellipsis),
),
],
Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder_rounded, color: colorScheme.primary, size: 20),
const SizedBox(width: 12),
Flexible(
child: Text(
_selectedDirectory!,
style: Theme.of(context).textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
)
else
Text('Select a folder where your downloaded music will be saved.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Select a folder where your downloaded music will be saved.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _isLoading ? null : _selectDirectory,
icon: _isLoading
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: Icon(_selectedDirectory != null ? Icons.edit : Icons.folder_open),
: Icon(_selectedDirectory != null ? Icons.edit_rounded : Icons.folder_open_rounded),
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
],
);
}
Widget _buildSpotifyApiStep(ColorScheme colorScheme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Icon with container background (M3 style)
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: _useSpotifyApi ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24),
),
child: Icon(
Icons.api_rounded,
size: 40,
color: _useSpotifyApi ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
Text(
'Spotify API (Optional)',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Add your Spotify API credentials for better search results, or skip to use Deezer instead.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
// Toggle card (M3 style)
Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
clipBehavior: Clip.antiAlias,
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
title: Text('Use Spotify API', style: Theme.of(context).textTheme.titleSmall),
subtitle: Text(
_useSpotifyApi ? 'Enter your credentials below' : 'Using Deezer (no account needed)',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
secondary: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _useSpotifyApi ? colorScheme.primary : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_useSpotifyApi ? Icons.music_note_rounded : Icons.album_rounded,
size: 20,
color: _useSpotifyApi ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
),
),
value: _useSpotifyApi,
onChanged: (value) => setState(() => _useSpotifyApi = value),
),
),
// Credentials form (animated)
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: _useSpotifyApi ? Padding(
padding: const EdgeInsets.only(top: 16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Client ID
Text('Client ID', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 8),
TextField(
controller: _clientIdController,
decoration: InputDecoration(
hintText: 'Enter Spotify Client ID',
prefixIcon: const Icon(Icons.key_rounded),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
const SizedBox(height: 16),
// Client Secret
Text('Client Secret', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 8),
TextField(
controller: _clientSecretController,
obscureText: !_showClientSecret,
decoration: InputDecoration(
hintText: 'Enter Spotify Client Secret',
prefixIcon: const Icon(Icons.lock_rounded),
suffixIcon: IconButton(
icon: Icon(_showClientSecret ? Icons.visibility_off_rounded : Icons.visibility_rounded),
onPressed: () => setState(() => _showClientSecret = !_showClientSecret),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
const SizedBox(height: 16),
// Info banner
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.info_outline_rounded, size: 20, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
'Get credentials from developer.spotify.com',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
),
),
],
),
),
],
),
),
),
) : const SizedBox.shrink(),
),
],
);
@@ -666,6 +919,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Widget _buildNavigationButtons(ColorScheme colorScheme) {
final isLastStep = _currentStep == _totalSteps - 1;
final canProceed = _isStepCompleted(_currentStep);
// For Spotify step, check if credentials are valid when enabled
final isSpotifyStepValid = !_useSpotifyApi ||
(_clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -674,8 +931,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (_currentStep > 0)
TextButton.icon(
onPressed: () => setState(() => _currentStep--),
icon: const Icon(Icons.arrow_back),
icon: const Icon(Icons.arrow_back_rounded),
label: const Text('Back'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
)
else
const SizedBox(width: 100),
@@ -684,20 +944,32 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (!isLastStep)
FilledButton(
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)],
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward_rounded, size: 18)],
),
)
else
FilledButton(
onPressed: _selectedDirectory != null && !_isLoading ? _completeSetup : null,
onPressed: isSpotifyStepValid && !_isLoading ? _completeSetup : null,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: _isLoading
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Row(
: Row(
mainAxisSize: MainAxisSize.min,
children: [Text('Get Started'), SizedBox(width: 8), Icon(Icons.check, size: 18)],
children: [
Text(_useSpotifyApi ? 'Get Started' : 'Skip & Start'),
const SizedBox(width: 8),
const Icon(Icons.check_rounded, size: 18),
],
),
),
],
+72 -10
View File
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg');
@@ -133,22 +134,83 @@ class FFmpegService {
}
}
/// Embed cover art to FLAC file
/// Embed metadata and cover art to FLAC file
/// Returns the file path on success, null on failure
static Future<String?> embedCover(String flacPath, String coverPath) async {
final tempOutput = '$flacPath.tmp';
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
static Future<String?> embedMetadata({
required String flacPath,
String? coverPath,
Map<String, String>? metadata,
}) async {
// Android Scoped Storage: Cannot write directly to Music folder with FFmpeg
// Use app-internal cache directory for temp output
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
// Construct command
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$flacPath" ');
// Add cover input if available
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
// Map audio stream
cmdBuffer.write('-map 0:a ');
// Map cover stream if available
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v copy ');
cmdBuffer.write('-disposition:v attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
// Copy audio codec (don't re-encode)
cmdBuffer.write('-c:a copy ');
// Add text metadata
if (metadata != null) {
metadata.forEach((key, value) {
// Sanitize value: escape double quotes
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('"$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg command: $command');
final result = await _execute(command);
if (result.success) {
try {
// Replace original with temp
await File(flacPath).delete();
await File(tempOutput).rename(flacPath);
return flacPath;
// Copy temp output back to original location (replace)
final tempFile = File(tempOutput);
final originalFile = File(flacPath);
if (await tempFile.exists()) {
// Delete original file
if (await originalFile.exists()) {
await originalFile.delete();
}
// Copy temp file to original location
await tempFile.copy(flacPath);
// Delete temp file
await tempFile.delete();
return flacPath;
} else {
_log.e('Temp output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace file after cover embed: $e');
_log.e('Failed to replace file after metadata embed: $e');
return null;
}
}
@@ -161,7 +223,7 @@ class FFmpegService {
}
} catch (_) {}
_log.e('Cover embed failed: ${result.output}');
_log.e('Metadata/Cover embed failed: ${result.output}');
return null;
}
}