feat: YouTube customizable bitrate, improved title matching, SpotubeDL engine fallback

- Add configurable YouTube Opus (96-256kbps) and MP3 (96-320kbps) bitrates
- Improve title matching with loose normalization for symbol-heavy tracks
- Add SpotubeDL engine v2 fallback for MP3 requests
- Improve filename sanitization in track metadata screen
- Bump version to 3.6.9+82
This commit is contained in:
zarzet
2026-02-17 17:08:02 +07:00
parent 3ac9ff1dd7
commit 8e6cbcbc2a
36 changed files with 1317 additions and 166 deletions
+180 -60
View File
@@ -29,18 +29,42 @@ const _builtInServices = [
id: 'tidal',
label: 'Tidal',
qualityOptions: [
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: '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',
),
],
),
BuiltInService(
id: 'qobuz',
label: 'Qobuz',
qualityOptions: [
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: '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',
),
],
),
BuiltInService(
@@ -58,8 +82,16 @@ const _builtInServices = [
id: 'youtube',
label: 'YouTube',
qualityOptions: [
QualityOption(id: 'opus_256', label: 'Opus 256kbps', description: 'Best quality lossy (~8MB per track)'),
QualityOption(id: 'mp3_320', label: 'MP3 320kbps', description: 'Best compatibility (~10MB per track)'),
QualityOption(
id: 'opus_256',
label: 'Opus 256kbps',
description: 'Best quality lossy (~8MB per track)',
),
QualityOption(
id: 'mp3_320',
label: 'MP3 320kbps',
description: 'Best compatibility (~10MB per track)',
),
],
isDisabled: false,
disabledReason: null,
@@ -82,7 +114,8 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
});
@override
ConsumerState<DownloadServicePicker> createState() => _DownloadServicePickerState();
ConsumerState<DownloadServicePicker> createState() =>
_DownloadServicePickerState();
/// Show the download service picker as a modal bottom sheet
static void show(
@@ -93,7 +126,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
required void Function(String quality, String service) onSelect,
}) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -112,6 +145,9 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
}
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
late String _selectedService;
@override
@@ -122,28 +158,76 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
/// Get quality options for the selected service
List<QualityOption> _getQualityOptions() {
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
final settings = ref.read(settingsProvider);
if (_selectedService == 'youtube') {
final opusBitrate = _nearestSupportedBitrate(
settings.youtubeOpusBitrate,
_youtubeOpusSupportedBitrates,
);
final mp3Bitrate = _nearestSupportedBitrate(
settings.youtubeMp3Bitrate,
_youtubeMp3SupportedBitrates,
);
return [
QualityOption(
id: 'opus_$opusBitrate',
label: 'Opus ${opusBitrate}kbps',
description: 'Configured from YouTube settings',
),
QualityOption(
id: 'mp3_$mp3Bitrate',
label: 'MP3 ${mp3Bitrate}kbps',
description: 'Configured from YouTube settings',
),
];
}
final builtIn = _builtInServices
.where((s) => s.id == _selectedService)
.firstOrNull;
if (builtIn != null) {
return builtIn.qualityOptions;
}
final extensionState = ref.read(extensionProvider);
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
final ext = extensionState.extensions
.where((e) => e.id == _selectedService)
.firstOrNull;
if (ext != null && ext.qualityOptions.isNotEmpty) {
return ext.qualityOptions;
}
// Default fallback options
return [
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
const QualityOption(
id: 'DEFAULT',
label: 'Default Quality',
description: 'Best available',
),
];
}
int _nearestSupportedBitrate(int value, List<int> supported) {
var nearest = supported.first;
var nearestDistance = (value - nearest).abs();
for (final option in supported.skip(1)) {
final distance = (value - option).abs();
if (distance < nearestDistance ||
(distance == nearestDistance && option > nearest)) {
nearest = option;
nearestDistance = distance;
}
}
return nearest;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final extensionState = ref.watch(extensionProvider);
final downloadExtensions = extensionState.extensions
.where((ext) => ext.enabled && ext.hasDownloadProvider)
.toList();
@@ -162,7 +246,10 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
artistName: widget.artistName,
coverUrl: widget.coverUrl,
),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
Divider(
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
] else ...[
const SizedBox(height: 8),
Center(
@@ -181,11 +268,13 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
context.l10n.downloadFrom,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Wrap(
spacing: 8,
@@ -193,13 +282,13 @@ Padding(
children: [
for (final service in _builtInServices)
_ServiceChip(
label: service.isDisabled
label: service.isDisabled
? '${service.label} (${service.disabledReason})'
: service.label,
isSelected: _selectedService == service.id,
isDisabled: service.isDisabled,
onTap: service.isDisabled
? null
onTap: service.isDisabled
? null
: () => setState(() => _selectedService = service.id),
),
for (final ext in downloadExtensions)
@@ -217,11 +306,15 @@ Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
context.l10n.downloadSelectQuality,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
if (_builtInServices.any((s) => s.id == _selectedService && s.id != 'youtube'))
if (_builtInServices.any(
(s) => s.id == _selectedService && s.id != 'youtube',
))
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
@@ -264,27 +357,27 @@ Padding(
}
IconData _getQualityIcon(String qualityId) {
switch (qualityId.toUpperCase()) {
final normalized = qualityId.toUpperCase();
if (normalized.startsWith('MP3_') || normalized == 'MP3') {
return Icons.audiotrack;
}
if (normalized.startsWith('OPUS_') || normalized == 'OPUS') {
return Icons.graphic_eq;
}
switch (normalized) {
case 'HI_RES_LOSSLESS':
return Icons.four_k;
case 'HI_RES':
return Icons.high_quality;
case 'LOSSLESS':
return Icons.music_note;
case 'MP3_320':
case 'MP3':
return Icons.audiotrack;
case 'OPUS':
case 'OPUS_128':
case 'OPUS_256':
return Icons.graphic_eq;
default:
return Icons.music_note;
}
}
}
class _QualityOption extends StatelessWidget {
final String title;
final String subtitle;
@@ -313,7 +406,10 @@ class _QualityOption extends StatelessWidget {
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: subtitle.isNotEmpty
? Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant))
? Text(
subtitle,
style: TextStyle(color: colorScheme.onSurfaceVariant),
)
: null,
onTap: onTap,
);
@@ -344,13 +440,17 @@ class _ServiceChip extends StatelessWidget {
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: isDisabled
color: isDisabled
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
border: isSelected
? null
: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -366,11 +466,11 @@ class _ServiceChip extends StatelessWidget {
errorBuilder: (context, error, stackTrace) => Icon(
Icons.extension,
size: 18,
color: isDisabled
color: isDisabled
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
),
@@ -380,11 +480,11 @@ class _ServiceChip extends StatelessWidget {
label,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isDisabled
color: isDisabled
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
@@ -419,7 +519,9 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
onTap: _isOverflowing
? () => setState(() => _expanded = !_expanded)
: null,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
@@ -447,26 +549,39 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
width: 56,
height: 56,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
errorBuilder: (context, error, stackTrace) =>
Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
final titleStyle = Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600);
final titleSpan = TextSpan(
text: widget.trackName,
style: titleStyle,
);
final titlePainter = TextPainter(
text: titleSpan,
maxLines: 1,
@@ -487,17 +602,22 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
widget.trackName,
style: titleStyle,
maxLines: _expanded ? 10 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
overflow: _expanded
? TextOverflow.visible
: TextOverflow.ellipsis,
),
if (widget.artistName != null) ...[
const SizedBox(height: 2),
Text(
widget.artistName!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: _expanded ? 3 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
overflow: _expanded
? TextOverflow.visible
: TextOverflow.ellipsis,
),
],
],