From 61f62363b31a2d3483312824352e6f579d5173d1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 1 Jul 2026 23:46:39 +0700 Subject: [PATCH] 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. --- lib/screens/downloaded_album_screen.dart | 1 + lib/screens/local_album_screen.dart | 1 + lib/screens/queue_tab.dart | 1 + lib/screens/track_metadata_screen.dart | 612 ++++++++++++----------- lib/widgets/batch_convert_sheet.dart | 569 +++++++++++---------- 5 files changed, 618 insertions(+), 566 deletions(-) diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 26ab66e9..f9af5e6b 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1140,6 +1140,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { showModalBottomSheet( context: context, useRootNavigator: true, + isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index b558679c..97c66226 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1319,6 +1319,7 @@ class _LocalAlbumScreenState extends ConsumerState { showModalBottomSheet( context: context, useRootNavigator: true, + isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 6a16eb34..a4d7b00a 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -5560,6 +5560,7 @@ class _QueueTabState extends ConsumerState { await showModalBottomSheet( context: context, useRootNavigator: true, + isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index b3f87e73..76c577a2 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -3830,6 +3830,7 @@ class _TrackMetadataScreenState extends ConsumerState { showModalBottomSheet( context: context, useRootNavigator: true, + isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -3913,327 +3914,352 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - 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, - ), - ), - ), + ], ), - ], + ), ), ), ); diff --git a/lib/widgets/batch_convert_sheet.dart b/lib/widgets/batch_convert_sheet.dart index d91feda6..ccfd77f5 100644 --- a/lib/widgets/batch_convert_sheet.dart +++ b/lib/widgets/batch_convert_sheet.dart @@ -73,299 +73,322 @@ class _BatchConvertSheetState extends State { 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), - ), + ], ), - ], + ), ), ), );