fix(convert): make convert bottom sheets draggable and scroll-controlled

Use DraggableScrollableSheet for single and batch convert flows and open
convert sheets with isScrollControlled so long option lists remain usable
on smaller screens.
This commit is contained in:
zarzet
2026-07-01 23:46:39 +07:00
parent 3278e32711
commit 61f62363b3
5 changed files with 618 additions and 566 deletions
+1
View File
@@ -1140,6 +1140,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
+1
View File
@@ -1319,6 +1319,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
+1
View File
@@ -5560,6 +5560,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
await showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
+319 -293
View File
@@ -3830,6 +3830,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
@@ -3913,327 +3914,352 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => SafeArea(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(2),
),
),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 18),
Text(
context.l10n.trackConvertTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
currentFormat,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
const SizedBox(height: 18),
Text(
context.l10n.trackConvertTitle,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
currentFormat,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 20),
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.trackConvertTargetFormat),
Wrap(
spacing: 8,
runSpacing: 8,
children: formats.map((format) {
return choice(
label: format,
selected: format == selectedFormat,
onTap: () {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
isLosslessConversionTarget(format);
if (!isLosslessTarget) {
selectedBitrate = defaultBitrateForFormat(
format,
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(
context.l10n.trackConvertTargetFormat,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: formats.map((format) {
return choice(
label: format,
selected: format == selectedFormat,
onTap: () {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
isLosslessConversionTarget(format);
if (!isLosslessTarget) {
selectedBitrate =
defaultBitrateForFormat(format);
} else {
selectedMaxBitDepth = null;
selectedMaxSampleRate = null;
selectedDither = 'none';
selectedResampler = 'swr';
}
});
},
);
}).toList(),
),
],
),
),
if (!isLosslessTarget)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.trackConvertBitrate),
Wrap(
spacing: 8,
runSpacing: 8,
children: bitrates.map((br) {
return choice(
label: br,
selected: br == selectedBitrate,
onTap: () => setSheetState(
() => selectedBitrate = br,
),
);
}).toList(),
),
],
),
),
if (isLosslessTarget && bitDepthOptions.isNotEmpty)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(
context.l10n.audioAnalysisBitDepth,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
choice(
label: losslessBitDepthLabel(
null,
originalLabel: labels.original,
),
selected: selectedMaxBitDepth == null,
onTap: () => setSheetState(() {
selectedMaxBitDepth = null;
selectedDither = 'none';
}),
),
...bitDepthOptions.map((depth) {
return choice(
label: losslessBitDepthLabel(
depth,
originalLabel: labels.original,
),
selected: depth == selectedMaxBitDepth,
onTap: () => setSheetState(
() => selectedMaxBitDepth = depth,
),
);
} else {
selectedMaxBitDepth = null;
selectedMaxSampleRate = null;
selectedDither = 'none';
selectedResampler = 'swr';
}
});
},
);
}).toList(),
}),
],
),
],
),
),
],
),
),
if (!isLosslessTarget)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.trackConvertBitrate),
Wrap(
spacing: 8,
runSpacing: 8,
children: bitrates.map((br) {
return choice(
label: br,
selected: br == selectedBitrate,
onTap: () =>
setSheetState(() => selectedBitrate = br),
);
}).toList(),
),
],
),
),
if (isLosslessTarget && bitDepthOptions.isNotEmpty)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.audioAnalysisBitDepth),
Wrap(
spacing: 8,
runSpacing: 8,
if (isLosslessTarget && sampleRateOptions.isNotEmpty)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
choice(
label: losslessBitDepthLabel(
null,
originalLabel: labels.original,
),
selected: selectedMaxBitDepth == null,
onTap: () => setSheetState(() {
selectedMaxBitDepth = null;
selectedDither = 'none';
}),
sectionLabel(
context.l10n.audioAnalysisSampleRate,
),
...bitDepthOptions.map((depth) {
return choice(
label: losslessBitDepthLabel(
depth,
originalLabel: labels.original,
Wrap(
spacing: 8,
runSpacing: 8,
children: [
choice(
label: losslessSampleRateLabel(
null,
originalLabel: labels.original,
),
selected: selectedMaxSampleRate == null,
onTap: () => setSheetState(() {
selectedMaxSampleRate = null;
selectedResampler = 'swr';
}),
),
selected: depth == selectedMaxBitDepth,
onTap: () => setSheetState(
() => selectedMaxBitDepth = depth,
),
);
}),
...sampleRateOptions.map((rate) {
return choice(
label: losslessSampleRateLabel(
rate,
originalLabel: labels.original,
),
selected: rate == selectedMaxSampleRate,
onTap: () => setSheetState(
() => selectedMaxSampleRate = rate,
),
);
}),
],
),
],
),
],
),
),
),
if (isLosslessTarget && sampleRateOptions.isNotEmpty)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.audioAnalysisSampleRate),
Wrap(
spacing: 8,
runSpacing: 8,
if (isLosslessTarget && selectedMaxBitDepth != null)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
choice(
label: losslessSampleRateLabel(
null,
originalLabel: labels.original,
),
selected: selectedMaxSampleRate == null,
onTap: () => setSheetState(() {
selectedMaxSampleRate = null;
selectedResampler = 'swr';
}),
sectionLabel(
context.l10n.trackConvertDithering,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessDitherOptions.map((mode) {
return choice(
label: context.l10n
.losslessDitherOptionLabel(mode),
selected: mode == selectedDither,
onTap: () => setSheetState(
() => selectedDither = mode,
),
);
}).toList(),
),
...sampleRateOptions.map((rate) {
return choice(
label: losslessSampleRateLabel(
rate,
originalLabel: labels.original,
),
selected: rate == selectedMaxSampleRate,
onTap: () => setSheetState(
() => selectedMaxSampleRate = rate,
),
);
}),
],
),
],
),
),
),
if (isLosslessTarget && selectedMaxBitDepth != null)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.trackConvertDithering),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessDitherOptions.map((mode) {
return choice(
label: context.l10n.losslessDitherOptionLabel(
if (isLosslessTarget && selectedMaxSampleRate != null)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(
context.l10n.trackConvertResampler,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessResamplerOptions.map((
mode,
),
selected: mode == selectedDither,
onTap: () => setSheetState(
() => selectedDither = mode,
),
);
}).toList(),
) {
return choice(
label: context.l10n
.losslessResamplerOptionLabel(mode),
selected: mode == selectedResampler,
onTap: () => setSheetState(
() => selectedResampler = mode,
),
);
}).toList(),
),
],
),
],
),
),
if (isLosslessTarget && selectedMaxSampleRate != null)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.trackConvertResampler),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessResamplerOptions.map((mode) {
return choice(
label: context.l10n
.losslessResamplerOptionLabel(mode),
selected: mode == selectedResampler,
onTap: () => setSheetState(
() => selectedResampler = mode,
),
);
}).toList(),
),
],
),
),
if (isLosslessTarget && isLosslessSource)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(
Icons.verified,
size: 18,
color: colorScheme.primary,
if (isLosslessTarget && isLosslessSource)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
const SizedBox(width: 8),
Expanded(
child: Text(
selectedMaxBitDepth == null &&
selectedMaxSampleRate == null
? context.l10n.trackConvertLosslessHint
: context.l10n
.trackConvertLosslessOutputWithCap(
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth:
selectedMaxBitDepth,
maxSampleRate:
selectedMaxSampleRate,
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(
Icons.verified,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
selectedMaxBitDepth == null &&
selectedMaxSampleRate == null
? context.l10n.trackConvertLosslessHint
: context.l10n
.trackConvertLosslessOutputWithCap(
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth:
selectedMaxBitDepth,
maxSampleRate:
selectedMaxSampleRate,
),
originalLabel:
labels.original,
originalQualityLabel:
labels.originalQuality,
),
),
originalLabel: labels.original,
originalQualityLabel:
labels.originalQuality,
),
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
),
],
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
Navigator.pop(context);
_confirmAndConvert(
context: this.context,
sourceFormat: currentFormat,
targetFormat: selectedFormat,
bitrate: selectedBitrate,
losslessQuality: LosslessConversionQuality(
maxBitDepth: selectedMaxBitDepth,
maxSampleRate: selectedMaxSampleRate,
),
losslessProcessing:
LosslessConversionProcessing(
dither: selectedDither,
resampler: selectedResampler,
),
);
},
icon: const Icon(Icons.swap_horiz),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
],
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
Navigator.pop(context);
_confirmAndConvert(
context: this.context,
sourceFormat: currentFormat,
targetFormat: selectedFormat,
bitrate: selectedBitrate,
losslessQuality: LosslessConversionQuality(
maxBitDepth: selectedMaxBitDepth,
maxSampleRate: selectedMaxSampleRate,
label: Text(
isLosslessTarget
? context.l10n
.trackConvertActionLabelLossless(
currentFormat,
selectedFormat,
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth: selectedMaxBitDepth,
maxSampleRate:
selectedMaxSampleRate,
),
originalLabel: labels.original,
originalQualityLabel:
labels.originalQuality,
),
)
: context.l10n.trackConvertActionLabelLossy(
currentFormat,
selectedFormat,
selectedBitrate,
),
),
losslessProcessing: LosslessConversionProcessing(
dither: selectedDither,
resampler: selectedResampler,
),
);
},
icon: const Icon(Icons.swap_horiz),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
label: Text(
isLosslessTarget
? context.l10n.trackConvertActionLabelLossless(
currentFormat,
selectedFormat,
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth: selectedMaxBitDepth,
maxSampleRate: selectedMaxSampleRate,
),
originalLabel: labels.original,
originalQualityLabel:
labels.originalQuality,
),
)
: context.l10n.trackConvertActionLabelLossy(
currentFormat,
selectedFormat,
selectedBitrate,
),
),
),
],
),
],
),
),
),
);
+296 -273
View File
@@ -73,299 +73,322 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
widget.sourceSampleRate,
);
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => SafeArea(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
),
),
const SizedBox(height: 18),
Text(
widget.title,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
if (widget.subtitle != null) ...[
const SizedBox(height: 4),
Text(
widget.subtitle!,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
),
],
const SizedBox(height: 20),
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.trackConvertTargetFormat),
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.formats.map((format) {
return _choice(
cs,
label: format,
selected: format == _selectedFormat,
onTap: () {
setState(() {
_selectedFormat = format;
_isLosslessTarget = isLosslessConversionTarget(
format,
);
if (!_isLosslessTarget) {
_selectedBitrate = _defaultBitrateForFormat(
format,
);
} else {
_selectedMaxBitDepth = null;
_selectedMaxSampleRate = null;
_selectedDither = 'none';
_selectedResampler = 'swr';
}
});
},
);
}).toList(),
const SizedBox(height: 18),
Text(
widget.title,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
if (widget.subtitle != null) ...[
const SizedBox(height: 4),
Text(
widget.subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: cs.onSurfaceVariant,
),
),
],
),
),
const SizedBox(height: 20),
if (!_isLosslessTarget)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.trackConvertBitrate),
Wrap(
spacing: 8,
runSpacing: 8,
children: _bitrates.map((br) {
return _choice(
cs,
label: br,
selected: br == _selectedBitrate,
onTap: () => setState(() => _selectedBitrate = br),
);
}).toList(),
),
],
),
),
if (_isLosslessTarget && bitDepthOptions.isNotEmpty)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.audioAnalysisBitDepth),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_choice(
cs,
label: losslessBitDepthLabel(
null,
originalLabel: labels.original,
),
selected: _selectedMaxBitDepth == null,
onTap: () => setState(() {
_selectedMaxBitDepth = null;
_selectedDither = 'none';
}),
),
...bitDepthOptions.map((depth) {
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.trackConvertTargetFormat),
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.formats.map((format) {
return _choice(
cs,
label: losslessBitDepthLabel(
depth,
originalLabel: labels.original,
),
selected: depth == _selectedMaxBitDepth,
onTap: () =>
setState(() => _selectedMaxBitDepth = depth),
label: format,
selected: format == _selectedFormat,
onTap: () {
setState(() {
_selectedFormat = format;
_isLosslessTarget = isLosslessConversionTarget(
format,
);
if (!_isLosslessTarget) {
_selectedBitrate = _defaultBitrateForFormat(
format,
);
} else {
_selectedMaxBitDepth = null;
_selectedMaxSampleRate = null;
_selectedDither = 'none';
_selectedResampler = 'swr';
}
});
},
);
}),
],
),
],
}).toList(),
),
],
),
),
),
if (_isLosslessTarget && sampleRateOptions.isNotEmpty)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.audioAnalysisSampleRate),
Wrap(
spacing: 8,
runSpacing: 8,
if (!_isLosslessTarget)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_choice(
cs,
label: losslessSampleRateLabel(
null,
originalLabel: labels.original,
),
selected: _selectedMaxSampleRate == null,
onTap: () => setState(() {
_selectedMaxSampleRate = null;
_selectedResampler = 'swr';
}),
_sectionLabel(cs, context.l10n.trackConvertBitrate),
Wrap(
spacing: 8,
runSpacing: 8,
children: _bitrates.map((br) {
return _choice(
cs,
label: br,
selected: br == _selectedBitrate,
onTap: () =>
setState(() => _selectedBitrate = br),
);
}).toList(),
),
...sampleRateOptions.map((rate) {
return _choice(
cs,
label: losslessSampleRateLabel(
rate,
originalLabel: labels.original,
),
selected: rate == _selectedMaxSampleRate,
onTap: () =>
setState(() => _selectedMaxSampleRate = rate),
);
}),
],
),
],
),
),
),
if (_isLosslessTarget && _selectedMaxBitDepth != null)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.trackConvertDithering),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessDitherOptions.map((mode) {
return _choice(
cs,
label: context.l10n.losslessDitherOptionLabel(mode),
selected: mode == _selectedDither,
onTap: () => setState(() => _selectedDither = mode),
);
}).toList(),
),
],
),
),
if (_isLosslessTarget && _selectedMaxSampleRate != null)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.trackConvertResampler),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessResamplerOptions.map((mode) {
return _choice(
cs,
label: context.l10n.losslessResamplerOptionLabel(mode),
selected: mode == _selectedResampler,
onTap: () =>
setState(() => _selectedResampler = mode),
);
}).toList(),
),
],
),
),
if (_isLosslessTarget)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
decoration: BoxDecoration(
color: cs.primaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(Icons.verified, size: 18, color: cs.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedMaxBitDepth == null &&
_selectedMaxSampleRate == null
? context.l10n.trackConvertLosslessHint
: context.l10n.trackConvertLosslessOutputWithCap(
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth: _selectedMaxBitDepth,
maxSampleRate: _selectedMaxSampleRate,
),
originalLabel: labels.original,
originalQualityLabel: labels.originalQuality,
),
if (_isLosslessTarget && bitDepthOptions.isNotEmpty)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.audioAnalysisBitDepth),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_choice(
cs,
label: losslessBitDepthLabel(
null,
originalLabel: labels.original,
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.primary),
selected: _selectedMaxBitDepth == null,
onTap: () => setState(() {
_selectedMaxBitDepth = null;
_selectedDither = 'none';
}),
),
...bitDepthOptions.map((depth) {
return _choice(
cs,
label: losslessBitDepthLabel(
depth,
originalLabel: labels.original,
),
selected: depth == _selectedMaxBitDepth,
onTap: () => setState(
() => _selectedMaxBitDepth = depth,
),
);
}),
],
),
],
),
),
if (_isLosslessTarget && sampleRateOptions.isNotEmpty)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.audioAnalysisSampleRate),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_choice(
cs,
label: losslessSampleRateLabel(
null,
originalLabel: labels.original,
),
selected: _selectedMaxSampleRate == null,
onTap: () => setState(() {
_selectedMaxSampleRate = null;
_selectedResampler = 'swr';
}),
),
...sampleRateOptions.map((rate) {
return _choice(
cs,
label: losslessSampleRateLabel(
rate,
originalLabel: labels.original,
),
selected: rate == _selectedMaxSampleRate,
onTap: () => setState(
() => _selectedMaxSampleRate = rate,
),
);
}),
],
),
],
),
),
if (_isLosslessTarget && _selectedMaxBitDepth != null)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.trackConvertDithering),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessDitherOptions.map((mode) {
return _choice(
cs,
label: context.l10n.losslessDitherOptionLabel(
mode,
),
selected: mode == _selectedDither,
onTap: () =>
setState(() => _selectedDither = mode),
);
}).toList(),
),
],
),
),
if (_isLosslessTarget && _selectedMaxSampleRate != null)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.trackConvertResampler),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessResamplerOptions.map((mode) {
return _choice(
cs,
label: context.l10n.losslessResamplerOptionLabel(
mode,
),
selected: mode == _selectedResampler,
onTap: () =>
setState(() => _selectedResampler = mode),
);
}).toList(),
),
],
),
),
if (_isLosslessTarget)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
decoration: BoxDecoration(
color: cs.primaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(Icons.verified, size: 18, color: cs.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedMaxBitDepth == null &&
_selectedMaxSampleRate == null
? context.l10n.trackConvertLosslessHint
: context.l10n
.trackConvertLosslessOutputWithCap(
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth: _selectedMaxBitDepth,
maxSampleRate:
_selectedMaxSampleRate,
),
originalLabel: labels.original,
originalQualityLabel:
labels.originalQuality,
),
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.primary),
),
),
],
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => widget.onConvert(
_selectedFormat,
_selectedBitrate,
LosslessConversionQuality(
maxBitDepth: _selectedMaxBitDepth,
maxSampleRate: _selectedMaxSampleRate,
),
LosslessConversionProcessing(
dither: _selectedDither,
resampler: _selectedResampler,
),
),
],
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => widget.onConvert(
_selectedFormat,
_selectedBitrate,
LosslessConversionQuality(
maxBitDepth: _selectedMaxBitDepth,
maxSampleRate: _selectedMaxSampleRate,
),
LosslessConversionProcessing(
dither: _selectedDither,
resampler: _selectedResampler,
icon: const Icon(Icons.swap_horiz),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
label: Text(widget.confirmLabel),
),
),
icon: const Icon(Icons.swap_horiz),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
label: Text(widget.confirmLabel),
),
],
),
],
),
),
),
);