feat(tidal): convert M4A to MP3/Opus for HIGH quality, remove LOSSY option

- Add tidalHighFormat setting (mp3_320 or opus_128) for Tidal HIGH quality
- Add convertM4aToLossy() in FFmpegService for M4A to MP3/Opus conversion
- Remove inefficient LOSSY option (FLAC download then convert)
- Update download_queue_provider to handle HIGH quality conversion
- Clean up LOSSY references from download_service_picker and log messages
- Update Go backend: amazon.go, tidal.go, metadata.go improvements
- UI: minor updates to album, playlist, and home screens
This commit is contained in:
zarzet
2026-02-01 19:07:02 +07:00
parent ee212a0e48
commit eb0cdbeba8
16 changed files with 288 additions and 749 deletions
+1 -1
View File
@@ -2196,7 +2196,7 @@ class AppLocalizationsId extends AppLocalizations {
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Gagal mengambil beberapa album';
String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
+4 -12
View File
@@ -31,10 +31,8 @@ class AppSettings {
final String albumFolderStructure;
final bool showExtensionStore;
final String locale;
final bool enableLossyOption;
final String lossyFormat;
final String lossyBitrate; // e.g., 'mp3_320', 'mp3_256', 'mp3_192', 'mp3_128', 'opus_128', 'opus_96', 'opus_64'
final String lyricsMode;
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128'
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
const AppSettings({
@@ -65,10 +63,8 @@ class AppSettings {
this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true,
this.locale = 'system',
this.enableLossyOption = false,
this.lossyFormat = 'mp3',
this.lossyBitrate = 'mp3_320',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.useAllFilesAccess = false,
});
@@ -101,10 +97,8 @@ class AppSettings {
String? albumFolderStructure,
bool? showExtensionStore,
String? locale,
bool? enableLossyOption,
String? lossyFormat,
String? lossyBitrate,
String? lyricsMode,
String? tidalHighFormat,
bool? useAllFilesAccess,
}) {
return AppSettings(
@@ -135,10 +129,8 @@ class AppSettings {
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
enableLossyOption: enableLossyOption ?? this.enableLossyOption,
lossyFormat: lossyFormat ?? this.lossyFormat,
lossyBitrate: lossyBitrate ?? this.lossyBitrate,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
);
}
+2 -6
View File
@@ -36,10 +36,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
enableLossyOption: json['enableLossyOption'] as bool? ?? false,
lossyFormat: json['lossyFormat'] as String? ?? 'mp3',
lossyBitrate: json['lossyBitrate'] as String? ?? 'mp3_320',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
);
@@ -72,9 +70,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
'enableLossyOption': instance.enableLossyOption,
'lossyFormat': instance.lossyFormat,
'lossyBitrate': instance.lossyBitrate,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'useAllFilesAccess': instance.useAllFilesAccess,
};
+71 -80
View File
@@ -1804,10 +1804,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
final quality = item.qualityOverride ?? state.audioQuality;
// For LOSSY, we need to download FLAC first then convert
// Servers don't support lossy quality directly
final downloadQuality = quality == 'LOSSY' ? 'LOSSLESS' : quality;
// Fetch extended metadata (genre, label) from Deezer if available
String? genre;
@@ -1858,7 +1854,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (useExtensions) {
_log.d('Using extension providers for download');
_log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}${quality == 'LOSSY' ? ' (downloading as LOSSLESS for conversion)' : ''}',
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
);
_log.d('Output dir: $outputDir');
result = await PlatformBridge.downloadWithExtensions(
@@ -1871,7 +1867,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: downloadQuality,
quality: quality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
@@ -1885,7 +1881,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else if (state.autoFallback) {
_log.d('Using auto-fallback mode');
_log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}${quality == 'LOSSY' ? ' (downloading as LOSSLESS for conversion)' : ''}',
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
);
_log.d('Output dir: $outputDir');
result = await PlatformBridge.downloadWithFallback(
@@ -1898,7 +1894,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: downloadQuality,
quality: quality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
@@ -1921,7 +1917,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: downloadQuality,
quality: quality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
@@ -1980,10 +1976,73 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
if (filePath != null && filePath.endsWith('.m4a')) {
// For HIGH quality (native AAC 320kbps), skip M4A to FLAC conversion
// For HIGH quality (Tidal AAC 320kbps), convert to MP3 or Opus
if (quality == 'HIGH') {
_log.i('Native AAC 320kbps download (HIGH quality), keeping M4A file');
actualQuality = 'AAC 320kbps';
final tidalHighFormat = settings.tidalHighFormat;
_log.i('Tidal HIGH quality download, converting M4A to $tidalHighFormat...');
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
// Convert M4A to the selected format
final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3';
final convertedPath = await FFmpegService.convertM4aToLossy(
filePath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: true,
);
if (convertedPath != null) {
filePath = convertedPath;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
_log.i('Successfully converted M4A to $format: $convertedPath');
// Embed metadata
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (format == 'mp3') {
await _embedMetadataToMp3(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
_log.d('Metadata embedded successfully');
} else {
_log.w('M4A to $format conversion failed, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('M4A conversion process failed: $e, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} else {
_log.d(
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
@@ -2112,74 +2171,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
if (quality == 'LOSSY' && filePath != null && filePath.endsWith('.flac')) {
if (wasExisting) {
_log.i('Lossy requested but existing FLAC found - skipping conversion to preserve original file');
} else {
final lossyFormat = settings.lossyFormat;
final lossyBitrate = settings.lossyBitrate;
_log.i('Lossy quality selected, converting FLAC to $lossyFormat ($lossyBitrate)...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.97,
);
try {
final convertedPath = await FFmpegService.convertFlacToLossy(
filePath,
format: lossyFormat,
bitrate: lossyBitrate,
deleteOriginal: true,
);
if (convertedPath != null) {
filePath = convertedPath;
// Extract bitrate for display (e.g., 'mp3_320' -> '320kbps')
final bitrateDisplay = lossyBitrate.contains('_')
? '${lossyBitrate.split('_').last}kbps'
: (lossyFormat == 'opus' ? '128kbps' : '320kbps');
actualQuality = '${lossyFormat.toUpperCase()} $bitrateDisplay';
_log.i('Successfully converted to $lossyFormat ($bitrateDisplay): $convertedPath');
// Embed metadata and cover for both MP3 and Opus
_log.i('Embedding metadata to $lossyFormat...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
final lossyBackendGenre = result['genre'] as String?;
final lossyBackendLabel = result['label'] as String?;
final lossyBackendCopyright = result['copyright'] as String?;
if (lossyFormat == 'mp3') {
await _embedMetadataToMp3(
convertedPath,
trackToDownload,
genre: lossyBackendGenre ?? genre,
label: lossyBackendLabel ?? label,
copyright: lossyBackendCopyright,
);
} else if (lossyFormat == 'opus') {
await _embedMetadataToOpus(
convertedPath,
trackToDownload,
genre: lossyBackendGenre ?? genre,
label: lossyBackendLabel ?? label,
copyright: lossyBackendCopyright,
);
}
} else {
_log.w('$lossyFormat conversion failed, keeping FLAC file');
}
} catch (e) {
_log.e('Lossy conversion error: $e, keeping FLAC file');
}
}
}
updateItemStatus(
item.id,
DownloadStatus.completed,
+2 -18
View File
@@ -231,24 +231,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setEnableLossyOption(bool enabled) {
state = state.copyWith(enableLossyOption: enabled);
// If Lossy is disabled and current quality is LOSSY, reset to LOSSLESS
if (!enabled && state.audioQuality == 'LOSSY') {
state = state.copyWith(audioQuality: 'LOSSLESS');
}
_saveSettings();
}
void setLossyFormat(String format) {
state = state.copyWith(lossyFormat: format);
_saveSettings();
}
void setLossyBitrate(String bitrate) {
// Extract format from bitrate (e.g., 'mp3_320' -> 'mp3')
final format = bitrate.split('_').first;
state = state.copyWith(lossyBitrate: bitrate, lossyFormat: format);
void setTidalHighFormat(String format) {
state = state.copyWith(tidalHighFormat: format);
_saveSettings();
}
+3 -1
View File
@@ -80,7 +80,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
// Use extensionId if available, otherwise detect from albumId prefix
final providerId = widget.extensionId ??
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
+4 -14
View File
@@ -1887,12 +1887,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
void _navigateToSearchAlbum(SearchAlbum album) {
ref.read(settingsProvider.notifier).setHasSearchedBefore();
// Extract the numeric ID from "deezer:123" format
String albumId = album.id;
if (albumId.startsWith('deezer:')) {
albumId = albumId.substring(7);
}
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
id: album.id,
name: album.name,
@@ -1901,9 +1895,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
providerId: 'deezer',
);
// Keep the full ID with prefix (e.g., "deezer:123") for AlbumScreen to detect source
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: albumId,
albumId: album.id,
albumName: album.name,
coverUrl: album.imageUrl,
tracks: const [], // Will be fetched by AlbumScreen
@@ -1914,12 +1909,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
void _navigateToSearchPlaylist(SearchPlaylist playlist) {
ref.read(settingsProvider.notifier).setHasSearchedBefore();
// Extract the numeric ID from "deezer:123" format
String playlistId = playlist.id;
if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7);
}
ref.read(recentAccessProvider.notifier).recordPlaylistAccess(
id: playlist.id,
name: playlist.name,
@@ -1928,12 +1917,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
providerId: 'deezer',
);
// Keep the full ID with prefix (e.g., "deezer:123") for PlaylistScreen to detect source
Navigator.push(context, MaterialPageRoute(
builder: (context) => PlaylistScreen(
playlistName: playlist.name,
coverUrl: playlist.imageUrl,
tracks: const [], // Will be fetched
playlistId: playlistId,
playlistId: playlist.id,
),
));
}
+9 -2
View File
@@ -64,10 +64,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
});
try {
final result = await PlatformBridge.getDeezerMetadata('playlist', widget.playlistId!);
// Extract numeric ID from "deezer:123" format
String playlistId = widget.playlistId!;
if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7);
}
final result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
if (!mounted) return;
final trackList = result['tracks'] as List<dynamic>? ?? [];
// Go backend returns 'track_list' not 'tracks'
final trackList = result['track_list'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
setState(() {
+58 -166
View File
@@ -174,24 +174,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
.read(settingsProvider.notifier)
.setAskQualityBeforeDownload(value),
),
SettingsSwitchItem(
icon: Icons.audiotrack,
title: context.l10n.enableLossyOption,
subtitle: settings.enableLossyOption
? context.l10n.enableLossyOptionSubtitleOn
: context.l10n.enableLossyOptionSubtitleOff,
value: settings.enableLossyOption,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setEnableLossyOption(value),
),
if (settings.enableLossyOption)
SettingsItem(
icon: Icons.tune,
title: context.l10n.lossyFormat,
subtitle: _getLossyBitrateLabel(settings.lossyBitrate),
onTap: () => _showLossyBitratePicker(context, ref, settings.lossyBitrate),
),
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
_QualityOption(
title: context.l10n.qualityFlacLossless,
@@ -216,29 +198,25 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES_LOSSLESS'),
showDivider: isTidalService || settings.enableLossyOption,
showDivider: isTidalService,
),
// Native AAC 320kbps option (Tidal only)
// Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus
if (isTidalService)
_QualityOption(
title: 'AAC 320kbps',
subtitle: 'Native AAC (no conversion)',
title: 'Lossy 320kbps',
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
isSelected: settings.audioQuality == 'HIGH',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HIGH'),
showDivider: settings.enableLossyOption,
showDivider: false,
),
if (settings.enableLossyOption)
_QualityOption(
title: context.l10n.qualityLossy,
subtitle: settings.lossyFormat == 'opus'
? context.l10n.qualityLossyOpusSubtitle
: context.l10n.qualityLossyMp3Subtitle,
isSelected: settings.audioQuality == 'LOSSY',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('LOSSY'),
if (isTidalService && settings.audioQuality == 'HIGH')
SettingsItem(
icon: Icons.tune,
title: 'Lossy Format',
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
onTap: () => _showTidalHighFormatPicker(context, ref, settings.tidalHighFormat),
showDivider: false,
),
],
@@ -870,28 +848,18 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
);
}
String _getLossyBitrateLabel(String bitrate) {
switch (bitrate) {
String _getTidalHighFormatLabel(String format) {
switch (format) {
case 'mp3_320':
return 'MP3 320kbps (Best)';
case 'mp3_256':
return 'MP3 256kbps';
case 'mp3_192':
return 'MP3 192kbps';
case 'mp3_128':
return 'MP3 128kbps';
return 'MP3 320kbps';
case 'opus_128':
return 'Opus 128kbps (Best)';
case 'opus_96':
return 'Opus 96kbps';
case 'opus_64':
return 'Opus 64kbps';
return 'Opus 128kbps';
default:
return 'MP3 320kbps';
}
}
void _showLossyBitratePicker(
void _showTidalHighFormatPicker(
BuildContext context,
WidgetRef ref,
String current,
@@ -900,130 +868,54 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.lossyFormat,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Lossy 320kbps Format',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.lossyFormatDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
// MP3 Section
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 4),
child: Text(
'MP3',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('320kbps'),
subtitle: const Text('Best quality, larger files'),
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('256kbps'),
subtitle: const Text('High quality'),
trailing: current == 'mp3_256' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_256');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('192kbps'),
subtitle: const Text('Good quality'),
trailing: current == 'mp3_192' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_192');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('128kbps'),
subtitle: const Text('Smaller files'),
trailing: current == 'mp3_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_128');
Navigator.pop(context);
},
),
const Divider(indent: 24, endIndent: 24),
// Opus Section
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 4),
child: Text(
'Opus',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('128kbps'),
subtitle: const Text('Best quality, efficient codec'),
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('opus_128');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('96kbps'),
subtitle: const Text('Good quality'),
trailing: current == 'opus_96' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('opus_96');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('64kbps'),
subtitle: const Text('Smallest files'),
trailing: current == 'opus_64' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('opus_64');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('MP3 320kbps'),
subtitle: const Text('Best compatibility, ~10MB per track'),
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Opus 128kbps'),
subtitle: const Text('Modern codec, ~4MB per track'),
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
+47
View File
@@ -48,6 +48,53 @@ class FFmpegService {
return null;
}
/// Convert M4A (AAC) to lossy format (MP3 or Opus)
/// format: 'mp3' or 'opus'
/// bitrate: e.g., 'mp3_320', 'opus_128' - extracts the kbps value
static Future<String?> convertM4aToLossy(
String inputPath, {
required String format,
String? bitrate,
bool deleteOriginal = true,
}) async {
// Extract bitrate value from format like 'mp3_320' -> '320k'
String bitrateValue = format == 'opus' ? '128k' : '320k';
if (bitrate != null && bitrate.contains('_')) {
final parts = bitrate.split('_');
if (parts.length == 2) {
bitrateValue = '${parts[1]}k';
}
}
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = inputPath.replaceAll('.m4a', extension);
String command;
if (format == 'opus') {
// M4A -> Opus conversion
command =
'-i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
} else {
// M4A -> MP3 conversion
command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y';
}
final result = await _execute(command);
if (result.success) {
if (deleteOriginal) {
try {
await File(inputPath).delete();
} catch (_) {}
}
return outputPath;
}
_log.e('M4A to $format conversion failed: ${result.output}');
return null;
}
static Future<String?> convertFlacToMp3(
String inputPath, {
String bitrate = '320k',
+2 -23
View File
@@ -27,7 +27,7 @@ const _builtInServices = [
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
QualityOption(id: 'HIGH', label: 'AAC 320kbps', description: 'Native AAC (no conversion)'),
QualityOption(id: 'HIGH', label: 'Lossy 320kbps', description: 'MP3 or Opus (smaller files)'),
],
),
BuiltInService(
@@ -50,13 +50,6 @@ const _builtInServices = [
),
];
/// Lossy quality option (shown when enabled in settings)
const _lossyQualityOption = QualityOption(
id: 'LOSSY',
label: 'Lossy',
description: 'MP3 320kbps or Opus 128kbps',
);
/// A reusable widget for selecting download service (built-in + extensions)
class DownloadServicePicker extends ConsumerStatefulWidget {
final String? trackName;
@@ -113,34 +106,21 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
/// Get quality options for the selected service
List<QualityOption> _getQualityOptions() {
final settings = ref.read(settingsProvider);
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
if (builtIn != null) {
// Add Lossy option if enabled in settings
if (settings.enableLossyOption) {
return [...builtIn.qualityOptions, _lossyQualityOption];
}
return builtIn.qualityOptions;
}
final extensionState = ref.read(extensionProvider);
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
if (ext != null && ext.qualityOptions.isNotEmpty) {
// Add Lossy option for extensions too if enabled
if (settings.enableLossyOption) {
return [...ext.qualityOptions, _lossyQualityOption];
}
return ext.qualityOptions;
}
// Default fallback options
final defaultOptions = [
return [
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
];
if (settings.enableLossyOption) {
return [...defaultOptions, _lossyQualityOption];
}
return defaultOptions;
}
@override
@@ -262,7 +242,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return Icons.aod;
case 'MP3_320':
case 'MP3':
case 'LOSSY':
return Icons.audiotrack;
case 'OPUS':
case 'OPUS_128':