mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-07 13:17:57 +02:00
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:
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user