mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-15 07:30:58 +02:00
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:
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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? ?? '',
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user