mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-29 17:50:00 +02:00
fix(ui): center modals on large screens, modernize edit-metadata + convert sheets, themed badges, fix artist skeleton and format-editor crash
- app: clear displayFeatures so bottom sheets/dialogs center on large/foldable screens - edit metadata sheet: card sections, modern label-above inputs, elegant collapsible headers, removed title icon - convert + batch convert: modern card-based sheets; shared BatchConvertSheet widget - queue: keep selection toolbar hidden until modal close animation finishes - 24-bit and In Library badges now use primary dynamic color - artist skeleton: remove duplicate name/listeners lines, keep cover placeholder - files settings: own filename-format controller in a StatefulWidget to fix use-after-dispose crash
This commit is contained in:
@@ -1117,6 +1117,16 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
|
||||
)
|
||||
|
||||
// Audio file extensions that the local library scanner accepts. Must stay in
|
||||
// sync with supportedAudioFormats in go_backend/library_scan.go so that every
|
||||
// format the Go engine can read (FLAC, M4A/MP4/AAC, MP3, Opus/OGG, APE/WV/MPC,
|
||||
// WAV, AIFF) is also enumerated here during the SAF folder walk. (.cue is
|
||||
// handled separately.)
|
||||
private val libraryScanAudioExtensions = setOf(
|
||||
".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg",
|
||||
".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif"
|
||||
)
|
||||
|
||||
private fun getSafChildFileLookup(
|
||||
dir: DocumentFile,
|
||||
cache: MutableMap<String, Map<String, DocumentFile>>,
|
||||
@@ -1186,7 +1196,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
it.currentFile = "Scanning folders..."
|
||||
}
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
|
||||
val supportedAudioExt = libraryScanAudioExtensions
|
||||
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
||||
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
@@ -1486,7 +1496,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
it.currentFile = "Scanning folders..."
|
||||
}
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
|
||||
val supportedAudioExt = libraryScanAudioExtensions
|
||||
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
|
||||
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
||||
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
|
||||
@@ -114,6 +114,19 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
scrollBehavior: scrollBehavior,
|
||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||
themeAnimationCurve: Curves.easeInOut,
|
||||
// Treat the display as one continuous surface. Some large/foldable
|
||||
// devices report a full-height display feature (hinge/cutout) which
|
||||
// makes Flutter split modal routes into a sub-screen, leaving bottom
|
||||
// sheets and dialogs visibly off-center instead of centered on the
|
||||
// full screen. Clearing displayFeatures keeps them centered for every
|
||||
// modal/dialog generically, without per-sheet workarounds.
|
||||
builder: (context, child) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
return MediaQuery(
|
||||
data: mediaQuery.copyWith(displayFeatures: const []),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
routerConfig: router,
|
||||
locale: locale,
|
||||
localeResolutionCallback: (deviceLocale, supportedLocales) {
|
||||
|
||||
@@ -3533,7 +3533,7 @@ abstract class AppLocalizations {
|
||||
/// Description of local library feature
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'**
|
||||
/// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.'**
|
||||
String get libraryAboutDescription;
|
||||
|
||||
/// Unit label for tracks count (without the number itself)
|
||||
|
||||
@@ -1935,7 +1935,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
|
||||
@@ -1942,7 +1942,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
'Memindai koleksi musik yang sudah ada untuk mendeteksi duplikat saat mengunduh. Mendukung format FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, dan APE. Metadata dibaca dari tag file jika tersedia.';
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
|
||||
@@ -2556,7 +2556,7 @@
|
||||
"@libraryAbout": {
|
||||
"description": "Section header for about info"
|
||||
},
|
||||
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.",
|
||||
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.",
|
||||
"@libraryAboutDescription": {
|
||||
"description": "Description of local library feature"
|
||||
},
|
||||
|
||||
@@ -2245,7 +2245,7 @@
|
||||
"@libraryAbout": {
|
||||
"description": "Section header for about info"
|
||||
},
|
||||
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.",
|
||||
"libraryAboutDescription": "Memindai koleksi musik yang sudah ada untuk mendeteksi duplikat saat mengunduh. Mendukung format FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, dan APE. Metadata dibaca dari tag file jika tersedia.",
|
||||
"@libraryAboutDescription": {
|
||||
"description": "Description of local library feature"
|
||||
},
|
||||
|
||||
@@ -1088,7 +1088,7 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
@@ -1097,7 +1097,7 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
@@ -1105,7 +1105,7 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1586,7 +1586,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
@@ -1595,7 +1595,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
@@ -1603,7 +1603,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
@@ -967,164 +968,25 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
|
||||
String defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
if (format == 'AAC') return '256k';
|
||||
return '320k';
|
||||
}
|
||||
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: defaultBitrateForFormat(selectedFormat);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.selectionBatchConvertConfirmTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
context.l10n.trackConvertTargetFormat,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget = isLosslessConversionTarget(
|
||||
format,
|
||||
);
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = defaultBitrateForFormat(
|
||||
format,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_performBatchConversion(
|
||||
allTracks: allTracks,
|
||||
targetFormat: selectedFormat,
|
||||
bitrate: selectedBitrate,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.selectionConvertCount(
|
||||
_selectedIds.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
builder: (sheetContext) => BatchConvertSheet(
|
||||
formats: formats,
|
||||
title: context.l10n.selectionBatchConvertConfirmTitle,
|
||||
confirmLabel: context.l10n.selectionConvertCount(_selectedIds.length),
|
||||
onConvert: (format, bitrate) {
|
||||
Navigator.pop(sheetContext);
|
||||
_performBatchConversion(
|
||||
allTracks: allTracks,
|
||||
targetFormat: format,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
part 'home_tab_helpers.dart';
|
||||
part 'home_tab_widgets.dart';
|
||||
@@ -3560,7 +3561,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
decoration: InputDecoration(
|
||||
hintText: _getSearchHint(),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
fillColor: settingsGroupColor(context),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||
|
||||
@@ -347,7 +347,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
@@ -356,7 +356,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
@@ -364,7 +364,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1234,7 +1234,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
@@ -1243,7 +1243,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
@@ -1251,7 +1251,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/replaygain_service.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
@@ -1215,164 +1216,25 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
|
||||
String defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
if (format == 'AAC') return '256k';
|
||||
return '320k';
|
||||
}
|
||||
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: defaultBitrateForFormat(selectedFormat);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.selectionBatchConvertConfirmTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
context.l10n.trackConvertTargetFormat,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget = isLosslessConversionTarget(
|
||||
format,
|
||||
);
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = defaultBitrateForFormat(
|
||||
format,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_performBatchConversion(
|
||||
allTracks: allTracks,
|
||||
targetFormat: selectedFormat,
|
||||
bitrate: selectedBitrate,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.selectionConvertCount(
|
||||
_selectedIds.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
builder: (sheetContext) => BatchConvertSheet(
|
||||
formats: formats,
|
||||
title: context.l10n.selectionBatchConvertConfirmTitle,
|
||||
confirmLabel: context.l10n.selectionConvertCount(_selectedIds.length),
|
||||
onConvert: (format, bitrate) {
|
||||
Navigator.pop(sheetContext);
|
||||
_performBatchConversion(
|
||||
allTracks: allTracks,
|
||||
targetFormat: format,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+19
-14
@@ -23,6 +23,7 @@ import 'package:spotiflac_android/services/update_checker.dart';
|
||||
import 'package:spotiflac_android/widgets/app_announcement_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('MainShell');
|
||||
@@ -570,20 +571,24 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
);
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 500),
|
||||
backgroundColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.03),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
destinations: destinations,
|
||||
bottomNavigationBar: DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 500),
|
||||
backgroundColor: settingsGroupColor(context),
|
||||
destinations: destinations,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -900,7 +900,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
@@ -909,7 +909,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
@@ -917,7 +917,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+56
-161
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
@@ -31,6 +32,7 @@ import 'package:spotiflac_android/screens/favorite_artists_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
||||
@@ -121,6 +123,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
List<UnifiedLibraryItem> _selectionOverlayItems = const [];
|
||||
double _selectionOverlayBottomPadding = 0;
|
||||
|
||||
/// When true, the floating selection overlays are kept hidden even though
|
||||
/// selection mode is still active. Used while a modal sheet/dialog launched
|
||||
/// from the selection toolbar is open, so the overlay does not reappear on
|
||||
/// top of (or behind) the modal's open/close animation.
|
||||
bool _suppressSelectionOverlay = false;
|
||||
|
||||
bool _isPlaylistSelectionMode = false;
|
||||
final Set<String> _selectedPlaylistIds = {};
|
||||
OverlayEntry? _playlistSelectionOverlayEntry;
|
||||
@@ -809,7 +817,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
required double bottomPadding,
|
||||
}) {
|
||||
if (!mounted) return;
|
||||
if (!_isSelectionMode || _isPlaylistSelectionMode) {
|
||||
if (_suppressSelectionOverlay ||
|
||||
!_isSelectionMode ||
|
||||
_isPlaylistSelectionMode) {
|
||||
_hideSelectionOverlay();
|
||||
return;
|
||||
}
|
||||
@@ -857,7 +867,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
required double bottomPadding,
|
||||
}) {
|
||||
if (!mounted) return;
|
||||
if (!_isPlaylistSelectionMode || _isSelectionMode) {
|
||||
if (_suppressSelectionOverlay ||
|
||||
!_isPlaylistSelectionMode ||
|
||||
_isSelectionMode) {
|
||||
_hidePlaylistSelectionOverlay();
|
||||
return;
|
||||
}
|
||||
@@ -2775,7 +2787,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
fillColor: settingsGroupColor(context),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(
|
||||
@@ -4835,19 +4847,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
|
||||
String defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
if (format == 'AAC') return '256k';
|
||||
return '320k';
|
||||
}
|
||||
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: defaultBitrateForFormat(selectedFormat);
|
||||
var didStartConversion = false;
|
||||
|
||||
_suppressSelectionOverlay = true;
|
||||
_hideSelectionOverlay();
|
||||
_hidePlaylistSelectionOverlay();
|
||||
|
||||
@@ -4857,150 +4859,33 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.selectionBatchConvertConfirmTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
context.l10n.trackConvertTargetFormat,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget = isLosslessConversionTarget(
|
||||
format,
|
||||
);
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = defaultBitrateForFormat(
|
||||
format,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
didStartConversion = true;
|
||||
Navigator.pop(context);
|
||||
_performBatchConversion(
|
||||
allItems: allItems,
|
||||
targetFormat: selectedFormat,
|
||||
bitrate: selectedBitrate,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.selectionConvertCount(
|
||||
_selectedIds.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
builder: (sheetContext) => BatchConvertSheet(
|
||||
formats: formats,
|
||||
title: context.l10n.selectionBatchConvertConfirmTitle,
|
||||
confirmLabel: context.l10n.selectionConvertCount(_selectedIds.length),
|
||||
onConvert: (format, bitrate) {
|
||||
didStartConversion = true;
|
||||
Navigator.pop(sheetContext);
|
||||
_performBatchConversion(
|
||||
allItems: allItems,
|
||||
targetFormat: format,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted || didStartConversion) return;
|
||||
// The showModalBottomSheet future completes when the sheet begins closing,
|
||||
// not when its exit animation finishes. Wait out the exit transition
|
||||
// (~200ms) before restoring the selection toolbar so it does not pop in
|
||||
// front of the still-animating sheet.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 260));
|
||||
if (!mounted) {
|
||||
_suppressSelectionOverlay = false;
|
||||
return;
|
||||
}
|
||||
_suppressSelectionOverlay = false;
|
||||
if (didStartConversion) return;
|
||||
if (_isSelectionMode) {
|
||||
_syncSelectionOverlay(
|
||||
items: allItems,
|
||||
@@ -5399,6 +5284,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
if (selectedItems.isEmpty) return;
|
||||
|
||||
_suppressSelectionOverlay = true;
|
||||
_hideSelectionOverlay();
|
||||
_hidePlaylistSelectionOverlay();
|
||||
|
||||
@@ -5422,8 +5308,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (!mounted) {
|
||||
_suppressSelectionOverlay = false;
|
||||
return;
|
||||
}
|
||||
if (confirmed != true) {
|
||||
// Restore after the dialog's exit animation so the toolbar does not
|
||||
// appear in front of the closing dialog.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 220));
|
||||
_suppressSelectionOverlay = false;
|
||||
if (!mounted) return;
|
||||
if (_isSelectionMode) {
|
||||
_syncSelectionOverlay(
|
||||
items: allItems,
|
||||
@@ -5432,6 +5326,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
_suppressSelectionOverlay = false;
|
||||
|
||||
var cancelled = false;
|
||||
int successCount = 0;
|
||||
@@ -6360,7 +6255,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item.quality!.startsWith('24')
|
||||
? colorScheme.tertiaryContainer
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
@@ -6369,7 +6264,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: item.quality!.startsWith('24')
|
||||
? colorScheme.onTertiaryContainer
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -6513,7 +6408,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item.quality!.startsWith('24')
|
||||
? colorScheme.tertiary
|
||||
? colorScheme.primary
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
@@ -6522,7 +6417,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: item.quality!.startsWith('24')
|
||||
? colorScheme.onTertiary
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -89,6 +89,8 @@ class _FilterChip extends StatelessWidget {
|
||||
selected: isSelected,
|
||||
onSelected: (_) => onTap(),
|
||||
showCheckmark: false,
|
||||
backgroundColor: settingsGroupColor(context),
|
||||
side: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.6)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ class _RepoTabState extends ConsumerState<RepoTab> {
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
fillColor: settingsGroupColor(context),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
@@ -651,6 +651,7 @@ class _CategoryChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return FilterChip(
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -659,6 +660,8 @@ class _CategoryChip extends StatelessWidget {
|
||||
selected: isSelected,
|
||||
onSelected: (_) => onTap(),
|
||||
showCheckmark: false,
|
||||
backgroundColor: settingsGroupColor(context),
|
||||
side: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.6)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,53 +708,14 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
String? title,
|
||||
String? description,
|
||||
}) {
|
||||
final controller = TextEditingController(text: current);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final save =
|
||||
onSave ?? ref.read(settingsProvider.notifier).setFilenameFormat;
|
||||
|
||||
final basicTags = [
|
||||
'{artist}',
|
||||
'{title}',
|
||||
'{album}',
|
||||
'{track}',
|
||||
'{year}',
|
||||
'{date}',
|
||||
'{disc}',
|
||||
];
|
||||
final advancedTags = [
|
||||
'{track_raw}',
|
||||
'{track:02}',
|
||||
'{track:1}',
|
||||
'{date:%Y}',
|
||||
'{date:%Y-%m-%d}',
|
||||
'{disc_raw}',
|
||||
'{disc:02}',
|
||||
];
|
||||
var showAdvancedTags = RegExp(
|
||||
r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}',
|
||||
caseSensitive: false,
|
||||
).hasMatch(current);
|
||||
|
||||
void insertTag(String tag) {
|
||||
final text = controller.text;
|
||||
final selection = controller.selection;
|
||||
final start = selection.start >= 0 ? selection.start : text.length;
|
||||
final end = selection.end >= 0 ? selection.end : text.length;
|
||||
String insertion = tag;
|
||||
if (start > 0) {
|
||||
final before = text.substring(0, start);
|
||||
if (!before.trim().endsWith('-')) {
|
||||
insertion = ' - $tag';
|
||||
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
|
||||
insertion = ' $tag';
|
||||
}
|
||||
}
|
||||
final newText = text.replaceRange(start, end, insertion);
|
||||
controller.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: start + insertion.length),
|
||||
);
|
||||
}
|
||||
|
||||
// The controller is owned by a StatefulWidget so it is disposed in its
|
||||
// State.dispose() (after the subtree is removed), instead of in
|
||||
// whenComplete which fires while the closing/keyboard-hide animations can
|
||||
// still rebuild the TextField and touch a disposed controller.
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -763,178 +724,13 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
title ?? context.l10n.filenameFormat,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description ??
|
||||
context.l10n.downloadFilenameDescription(
|
||||
'{album}',
|
||||
'{artist}',
|
||||
'{date}',
|
||||
'{disc}',
|
||||
'{title}',
|
||||
'{track}',
|
||||
'{year}',
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: '{artist} - {title}',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.downloadFilenameInsertTag,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: basicTags.map((tag) {
|
||||
return ActionChip(
|
||||
label: Text(tag),
|
||||
onPressed: () => insertTag(tag),
|
||||
backgroundColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
value: showAdvancedTags,
|
||||
onChanged: (value) =>
|
||||
setModalState(() => showAdvancedTags = value),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.l10n.filenameShowAdvancedTags),
|
||||
subtitle: Text(
|
||||
context.l10n.filenameShowAdvancedTagsDescription,
|
||||
),
|
||||
),
|
||||
if (showAdvancedTags) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: advancedTags.map((tag) {
|
||||
return ActionChip(
|
||||
label: Text(tag),
|
||||
onPressed: () => insertTag(tag),
|
||||
backgroundColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
final save =
|
||||
onSave ??
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFilenameFormat;
|
||||
save(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context) => _FilenameFormatEditorSheet(
|
||||
initialText: current,
|
||||
onSave: save,
|
||||
title: title,
|
||||
description: description,
|
||||
),
|
||||
).whenComplete(controller.dispose);
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlbumFolderStructurePicker(
|
||||
@@ -1140,3 +936,249 @@ class _FolderOption extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Bottom sheet body for editing a filename format. Owns its
|
||||
/// [TextEditingController] and disposes it in [dispose], which runs only after
|
||||
/// the sheet's subtree has been removed from the tree. This avoids the
|
||||
/// "TextEditingController used after being disposed" crash that happens when
|
||||
/// the controller is torn down in `whenComplete` while the closing and
|
||||
/// keyboard-hide animations are still rebuilding the field.
|
||||
class _FilenameFormatEditorSheet extends StatefulWidget {
|
||||
final String initialText;
|
||||
final void Function(String) onSave;
|
||||
final String? title;
|
||||
final String? description;
|
||||
|
||||
const _FilenameFormatEditorSheet({
|
||||
required this.initialText,
|
||||
required this.onSave,
|
||||
this.title,
|
||||
this.description,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_FilenameFormatEditorSheet> createState() =>
|
||||
_FilenameFormatEditorSheetState();
|
||||
}
|
||||
|
||||
class _FilenameFormatEditorSheetState
|
||||
extends State<_FilenameFormatEditorSheet> {
|
||||
static const _basicTags = [
|
||||
'{artist}',
|
||||
'{title}',
|
||||
'{album}',
|
||||
'{track}',
|
||||
'{year}',
|
||||
'{date}',
|
||||
'{disc}',
|
||||
];
|
||||
static const _advancedTags = [
|
||||
'{track_raw}',
|
||||
'{track:02}',
|
||||
'{track:1}',
|
||||
'{date:%Y}',
|
||||
'{date:%Y-%m-%d}',
|
||||
'{disc_raw}',
|
||||
'{disc:02}',
|
||||
];
|
||||
|
||||
late final TextEditingController _controller;
|
||||
late bool _showAdvancedTags;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialText);
|
||||
_showAdvancedTags = RegExp(
|
||||
r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}',
|
||||
caseSensitive: false,
|
||||
).hasMatch(widget.initialText);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _insertTag(String tag) {
|
||||
final text = _controller.text;
|
||||
final selection = _controller.selection;
|
||||
final start = selection.start >= 0 ? selection.start : text.length;
|
||||
final end = selection.end >= 0 ? selection.end : text.length;
|
||||
String insertion = tag;
|
||||
if (start > 0) {
|
||||
final before = text.substring(0, start);
|
||||
if (!before.trim().endsWith('-')) {
|
||||
insertion = ' - $tag';
|
||||
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
|
||||
insertion = ' $tag';
|
||||
}
|
||||
}
|
||||
final newText = text.replaceRange(start, end, insertion);
|
||||
_controller.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: start + insertion.length),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tagChip(ColorScheme colorScheme, String tag) {
|
||||
return ActionChip(
|
||||
label: Text(tag),
|
||||
onPressed: () => _insertTag(tag),
|
||||
backgroundColor: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: SingleChildScrollView(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.title ?? context.l10n.filenameFormat,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.description ??
|
||||
context.l10n.downloadFilenameDescription(
|
||||
'{album}',
|
||||
'{artist}',
|
||||
'{date}',
|
||||
'{disc}',
|
||||
'{title}',
|
||||
'{track}',
|
||||
'{year}',
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: '{artist} - {title}',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.downloadFilenameInsertTag,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _basicTags
|
||||
.map((tag) => _tagChip(colorScheme, tag))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
value: _showAdvancedTags,
|
||||
onChanged: (value) =>
|
||||
setState(() => _showAdvancedTags = value),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.l10n.filenameShowAdvancedTags),
|
||||
subtitle: Text(
|
||||
context.l10n.filenameShowAdvancedTagsDescription,
|
||||
),
|
||||
),
|
||||
if (_showAdvancedTags) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _advancedTags
|
||||
.map((tag) => _tagChip(colorScheme, tag))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
widget.onSave(_controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,7 +1263,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -1275,132 +1275,144 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
),
|
||||
),
|
||||
if (_saving)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
FilledButton.icon(
|
||||
onPressed: _save,
|
||||
child: Text(context.l10n.dialogSave),
|
||||
icon: const Icon(Icons.check, size: 18),
|
||||
label: Text(context.l10n.dialogSave),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.fromLTRB(20, 6, 20, 24),
|
||||
children: [
|
||||
const SizedBox(height: 6),
|
||||
_buildCoverEditor(cs),
|
||||
_buildAutoFillSection(cs),
|
||||
_field(context.l10n.editMetadataFieldTitle, _titleCtrl),
|
||||
_field(context.l10n.editMetadataFieldArtist, _artistCtrl),
|
||||
_field(context.l10n.editMetadataFieldAlbum, _albumCtrl),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldAlbumArtist,
|
||||
_albumArtistCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldDate,
|
||||
_dateCtrl,
|
||||
hint: context.l10n.editMetadataFieldDateHint,
|
||||
),
|
||||
Row(
|
||||
_sectionCard(
|
||||
icon: Icons.info_outline,
|
||||
title: context.l10n.trackMetadata,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldTrackNum,
|
||||
_trackNumCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
_field(context.l10n.editMetadataFieldTitle, _titleCtrl),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldArtist,
|
||||
_artistCtrl,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldTrackTotal,
|
||||
_trackTotalCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
_field(context.l10n.editMetadataFieldAlbum, _albumCtrl),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldAlbumArtist,
|
||||
_albumArtistCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldDate,
|
||||
_dateCtrl,
|
||||
hint: context.l10n.editMetadataFieldDateHint,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldTrackNum,
|
||||
_trackNumCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldTrackTotal,
|
||||
_trackTotalCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldDiscNum,
|
||||
_discNumCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldDiscTotal,
|
||||
_discTotalCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_field(context.l10n.editMetadataFieldGenre, _genreCtrl),
|
||||
_field(context.l10n.editMetadataFieldIsrc, _isrcCtrl),
|
||||
],
|
||||
),
|
||||
_sectionCard(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.trackLyrics,
|
||||
children: [
|
||||
_field(
|
||||
context.l10n.trackLyrics,
|
||||
_lyricsCtrl,
|
||||
maxLines: 8,
|
||||
keyboard: TextInputType.multiline,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
_sectionCard(
|
||||
icon: Icons.tune,
|
||||
title: context.l10n.editMetadataAdvanced,
|
||||
onHeaderTap: () =>
|
||||
setState(() => _showAdvanced = !_showAdvanced),
|
||||
expanded: _showAdvanced,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldDiscNum,
|
||||
_discNumCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
if (_showAdvanced) ...[
|
||||
_field(
|
||||
context.l10n.editMetadataFieldLabel,
|
||||
_labelCtrl,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldDiscTotal,
|
||||
_discTotalCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
_field(
|
||||
context.l10n.editMetadataFieldCopyright,
|
||||
_copyrightCtrl,
|
||||
),
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldComposer,
|
||||
_composerCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldComment,
|
||||
_commentCtrl,
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
_field(context.l10n.editMetadataFieldGenre, _genreCtrl),
|
||||
_field(context.l10n.editMetadataFieldIsrc, _isrcCtrl),
|
||||
_field(
|
||||
context.l10n.trackLyrics,
|
||||
_lyricsCtrl,
|
||||
maxLines: 8,
|
||||
keyboard: TextInputType.multiline,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
setState(() => _showAdvanced = !_showAdvanced),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_showAdvanced
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
size: 20,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.editMetadataAdvanced,
|
||||
style: Theme.of(context).textTheme.labelLarge
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showAdvanced) ...[
|
||||
_field(context.l10n.editMetadataFieldLabel, _labelCtrl),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldCopyright,
|
||||
_copyrightCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldComposer,
|
||||
_composerCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldComment,
|
||||
_commentCtrl,
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1411,149 +1423,104 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
}
|
||||
|
||||
Widget _buildAutoFillSection(ColorScheme cs) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => _showAutoFill = !_showAutoFill),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.travel_explore, size: 20, color: cs.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.editMetadataAutoFill,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: cs.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_showAutoFill ? Icons.expand_less : Icons.expand_more,
|
||||
size: 20,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
return _sectionCard(
|
||||
icon: Icons.travel_explore,
|
||||
title: context.l10n.editMetadataAutoFill,
|
||||
onHeaderTap: () => setState(() => _showAutoFill = !_showAutoFill),
|
||||
expanded: _showAutoFill,
|
||||
children: [
|
||||
if (_showAutoFill) ...[
|
||||
Text(
|
||||
context.l10n.editMetadataAutoFillDesc,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_quickSelectButton(
|
||||
label: context.l10n.editMetadataSelectAll,
|
||||
onTap: _selectAllFields,
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
if (_showAutoFill) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
context.l10n.editMetadataAutoFillDesc,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_quickSelectButton(
|
||||
label: context.l10n.editMetadataSelectEmpty,
|
||||
onTap: _selectEmptyFields,
|
||||
cs: cs,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
_quickSelectButton(
|
||||
label: context.l10n.editMetadataSelectAll,
|
||||
onTap: _selectAllFields,
|
||||
cs: cs,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_quickSelectButton(
|
||||
label: context.l10n.editMetadataSelectEmpty,
|
||||
onTap: _selectEmptyFields,
|
||||
cs: cs,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_quickSelectButton(
|
||||
label: context.l10n.editMetadataSelectNone,
|
||||
onTap: _selectNoFields,
|
||||
cs: cs,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: _fieldDefs.keys.map((key) {
|
||||
final selected = _autoFillFields.contains(key);
|
||||
return FilterChip(
|
||||
label: Text(_fieldLabel(key)),
|
||||
selected: selected,
|
||||
onSelected: _fetching
|
||||
? null
|
||||
: (val) {
|
||||
setState(() {
|
||||
if (val) {
|
||||
_autoFillFields.add(key);
|
||||
} else {
|
||||
_autoFillFields.remove(key);
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedColor: cs.primaryContainer,
|
||||
checkmarkColor: cs.onPrimaryContainer,
|
||||
labelStyle: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: selected
|
||||
? cs.onPrimaryContainer
|
||||
: cs.onSurfaceVariant,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: (_fetching || _saving || _autoFillFields.isEmpty)
|
||||
? null
|
||||
: _fetchAndFill,
|
||||
icon: _fetching
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_fix_high),
|
||||
label: Text(
|
||||
_fetching
|
||||
? context.l10n.editMetadataAutoFillSearching
|
||||
: context.l10n.editMetadataAutoFillFetch,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_quickSelectButton(
|
||||
label: context.l10n.editMetadataSelectNone,
|
||||
onTap: _selectNoFields,
|
||||
cs: cs,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: _fieldDefs.keys.map((key) {
|
||||
final selected = _autoFillFields.contains(key);
|
||||
return FilterChip(
|
||||
label: Text(_fieldLabel(key)),
|
||||
selected: selected,
|
||||
onSelected: _fetching
|
||||
? null
|
||||
: (val) {
|
||||
setState(() {
|
||||
if (val) {
|
||||
_autoFillFields.add(key);
|
||||
} else {
|
||||
_autoFillFields.remove(key);
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedColor: cs.primaryContainer,
|
||||
checkmarkColor: cs.onPrimaryContainer,
|
||||
labelStyle: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: selected ? cs.onPrimaryContainer : cs.onSurfaceVariant,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: (_fetching || _saving || _autoFillFields.isEmpty)
|
||||
? null
|
||||
: _fetchAndFill,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
icon: _fetching
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_fix_high),
|
||||
label: Text(
|
||||
_fetching
|
||||
? context.l10n.editMetadataAutoFillSearching
|
||||
: context.l10n.editMetadataAutoFillFetch,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1566,7 +1533,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
onTap: _fetching ? null : onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: cs.outline.withValues(alpha: 0.5)),
|
||||
@@ -1584,103 +1551,97 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
Widget _buildCoverEditor(ColorScheme cs) {
|
||||
final hasSelectedCover = _hasValue(_selectedCoverPath);
|
||||
final hasCurrentCover = _hasValue(_currentCoverPath);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.editMetadataFieldCover,
|
||||
return _sectionCard(
|
||||
icon: Icons.image_outlined,
|
||||
title: context.l10n.editMetadataFieldCover,
|
||||
children: [
|
||||
if (_loadingCurrentCover)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
)
|
||||
else if (!hasCurrentCover && !hasSelectedCover)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
context.l10n.trackCoverNoEmbeddedArt,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelLarge?.copyWith(color: cs.onSurface),
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (_loadingCurrentCover)
|
||||
const LinearProgressIndicator(minHeight: 2)
|
||||
else if (!hasCurrentCover)
|
||||
Text(
|
||||
context.l10n.trackCoverNoEmbeddedArt,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _saving ? null : _pickCoverImage,
|
||||
icon: const Icon(Icons.image_outlined),
|
||||
label: Text(
|
||||
hasSelectedCover
|
||||
? context.l10n.trackCoverReplace
|
||||
: context.l10n.trackCoverPick,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _saving ? null : _pickCoverImage,
|
||||
icon: const Icon(Icons.image_outlined),
|
||||
label: Text(
|
||||
hasSelectedCover
|
||||
? context.l10n.trackCoverReplace
|
||||
: context.l10n.trackCoverPick,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
if (hasSelectedCover) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: context.l10n.trackCoverClearSelected,
|
||||
onPressed: _saving
|
||||
? null
|
||||
: () async {
|
||||
await _cleanupSelectedCoverTemp();
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (hasCurrentCover || hasSelectedCover) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
if (hasCurrentCover)
|
||||
Expanded(
|
||||
child: _buildCoverPreviewTile(
|
||||
cs: cs,
|
||||
path: _currentCoverPath!,
|
||||
label: context.l10n.trackCoverCurrent,
|
||||
),
|
||||
),
|
||||
if (hasCurrentCover && hasSelectedCover)
|
||||
const SizedBox(width: 12),
|
||||
if (hasSelectedCover)
|
||||
Expanded(
|
||||
child: _buildCoverPreviewTile(
|
||||
cs: cs,
|
||||
path: _selectedCoverPath!,
|
||||
label:
|
||||
_selectedCoverName ??
|
||||
context.l10n.trackCoverSelected,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasSelectedCover) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.trackCoverReplaceNotice,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasSelectedCover) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: context.l10n.trackCoverClearSelected,
|
||||
onPressed: _saving
|
||||
? null
|
||||
: () async {
|
||||
await _cleanupSelectedCoverTemp();
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasCurrentCover || hasSelectedCover) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
if (hasCurrentCover)
|
||||
Expanded(
|
||||
child: _buildCoverPreviewTile(
|
||||
cs: cs,
|
||||
path: _currentCoverPath!,
|
||||
label: context.l10n.trackCoverCurrent,
|
||||
),
|
||||
),
|
||||
if (hasCurrentCover && hasSelectedCover)
|
||||
const SizedBox(width: 12),
|
||||
if (hasSelectedCover)
|
||||
Expanded(
|
||||
child: _buildCoverPreviewTile(
|
||||
cs: cs,
|
||||
path: _selectedCoverPath!,
|
||||
label:
|
||||
_selectedCoverName ?? context.l10n.trackCoverSelected,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasSelectedCover) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.trackCoverReplaceNotice,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1738,6 +1699,16 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Fill for the modern input fields. Sits one elevation step apart from the
|
||||
/// section card so each field reads as a distinct, recessed surface in both
|
||||
/// light and dark (including AMOLED) themes.
|
||||
Color _fieldFill(ColorScheme cs) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), cs.surface)
|
||||
: cs.surface;
|
||||
}
|
||||
|
||||
Widget _field(
|
||||
String label,
|
||||
TextEditingController controller, {
|
||||
@@ -1746,36 +1717,147 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
int maxLines = 1,
|
||||
}) {
|
||||
final cs = widget.colorScheme;
|
||||
final radius = BorderRadius.circular(14);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 6),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboard,
|
||||
maxLines: maxLines,
|
||||
cursorColor: cs.primary,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
filled: true,
|
||||
fillColor: _fieldFill(cs),
|
||||
isDense: true,
|
||||
// Borderless by default; definition comes from the fill contrast.
|
||||
// A soft primary ring appears only on focus for a clean look.
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide(color: cs.primary, width: 1.5),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared shape for the edit sections, mirroring the bounded cards used on the
|
||||
/// track metadata screen (rounded with a subtle outline).
|
||||
RoundedRectangleBorder _sectionCardShape(ColorScheme cs) {
|
||||
return RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
);
|
||||
}
|
||||
|
||||
/// A titled section card matching the track metadata screen layout. When
|
||||
/// [onHeaderTap] is provided the header becomes a full-width tappable row so
|
||||
/// the ink ripple follows the card's rounded shape (clipped to the card),
|
||||
/// and a chevron is rendered automatically based on [expanded].
|
||||
Widget _sectionCard({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required List<Widget> children,
|
||||
VoidCallback? onHeaderTap,
|
||||
bool expanded = true,
|
||||
}) {
|
||||
final cs = widget.colorScheme;
|
||||
final collapsible = onHeaderTap != null;
|
||||
|
||||
final headerRow = Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: cs.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (collapsible)
|
||||
AnimatedRotation(
|
||||
turns: expanded ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Icon(
|
||||
Icons.expand_more,
|
||||
size: 22,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final Widget header = collapsible
|
||||
? InkWell(
|
||||
onTap: onHeaderTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: headerRow,
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: headerRow,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboard,
|
||||
maxLines: maxLines,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: cs.primary, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
color: settingsGroupColor(context),
|
||||
shape: _sectionCardShape(cs),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
header,
|
||||
if (children.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1468,6 +1468,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared shape for the main section cards: rounded with a subtle outline so
|
||||
/// each section (Metadata, File Info, Lyrics, Audio Analysis) is bounded.
|
||||
RoundedRectangleBorder _sectionCardShape(ColorScheme colorScheme) {
|
||||
return RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataCard(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
@@ -1475,8 +1486,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
color: settingsGroupColor(context),
|
||||
shape: _sectionCardShape(colorScheme),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -1789,8 +1800,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
color: settingsGroupColor(context),
|
||||
shape: _sectionCardShape(colorScheme),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -1997,8 +2008,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Widget _buildLyricsCard(BuildContext context, ColorScheme colorScheme) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
color: settingsGroupColor(context),
|
||||
shape: _sectionCardShape(colorScheme),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -3451,6 +3462,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
memCacheHeight: cacheWidth,
|
||||
errorWidget: (_, _, _) => placeholder(),
|
||||
);
|
||||
}
|
||||
@@ -3728,9 +3740,84 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
Widget card({required Widget child}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: settingsGroupColor(context),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget sectionLabel(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget choice({
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Material(
|
||||
color: selected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 11,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? Colors.transparent
|
||||
: colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: selected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface,
|
||||
fontWeight: selected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -3747,97 +3834,112 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
context.l10n.trackConvertTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.trackConvertTargetFormat,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
currentFormat,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget = isLosslessConversionTarget(
|
||||
format,
|
||||
);
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = defaultBitrateForFormat(
|
||||
format,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
|
||||
if (isLosslessTarget && isLosslessSource) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
),
|
||||
|
||||
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 && 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,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_confirmAndConvert(
|
||||
@@ -3847,20 +3949,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bitrate: selectedBitrate,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.swap_horiz),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label: Text(
|
||||
isLosslessTarget
|
||||
? '$currentFormat -> $selectedFormat (Lossless)'
|
||||
: '$currentFormat -> $selectedFormat @ $selectedBitrate',
|
||||
? '$currentFormat → $selectedFormat (Lossless)'
|
||||
: '$currentFormat → $selectedFormat @ $selectedBitrate',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -474,9 +474,12 @@ class GridSkeleton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Artist screen skeleton – mimics the artist page content below the header:
|
||||
/// an optional "Popular" section (rank + cover 48x48 + title + trailing) then
|
||||
/// a horizontal-scroll album section.
|
||||
/// Artist screen skeleton – shown *below* the SliverAppBar header while the
|
||||
/// discography loads. Renders a cover placeholder (only when the header image
|
||||
/// isn't available yet), the "Popular" section (rank + cover 48x48 + title +
|
||||
/// badge + trailing), and the horizontal album sections. The artist name and
|
||||
/// listeners are intentionally omitted here since the header already shows them
|
||||
/// overlaid on the cover.
|
||||
class ArtistScreenSkeleton extends StatelessWidget {
|
||||
final int popularCount;
|
||||
final int albumCount;
|
||||
@@ -500,25 +503,16 @@ class ArtistScreenSkeleton extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showCoverHeader) ...[
|
||||
if (showCoverHeader)
|
||||
SkeletonBox(
|
||||
width: screenWidth,
|
||||
height: screenWidth * 0.75,
|
||||
borderRadius: 0,
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: SkeletonBox(width: 180, height: 24, borderRadius: 4),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: SkeletonBox(width: 120, height: 14, borderRadius: 4),
|
||||
),
|
||||
if (showPopularSection) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: SkeletonBox(width: 90, height: 20, borderRadius: 4),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||
child: SkeletonBox(width: 110, height: 22, borderRadius: 4),
|
||||
),
|
||||
...List.generate(popularCount, (index) {
|
||||
return Padding(
|
||||
@@ -528,7 +522,7 @@ class ArtistScreenSkeleton extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
child: Center(
|
||||
child: SkeletonBox(
|
||||
@@ -546,33 +540,31 @@ class ArtistScreenSkeleton extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SkeletonBox(
|
||||
width: 110 + (index % 4) * 30,
|
||||
width: 120 + (index % 4) * 30,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SkeletonBox(
|
||||
width: 70 + (index % 3) * 15,
|
||||
height: 11,
|
||||
const SizedBox(height: 8),
|
||||
// Mimics the small "In Library" badge pill.
|
||||
const SkeletonBox(
|
||||
width: 64,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SkeletonBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SkeletonBox(width: 18, height: 18, borderRadius: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: SkeletonBox(width: 80, height: 20, borderRadius: 4),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: SkeletonBox(width: 120, height: 22, borderRadius: 4),
|
||||
),
|
||||
SizedBox(
|
||||
height: 190,
|
||||
@@ -606,6 +598,7 @@ class ArtistScreenSkeleton extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:ffmpeg_kit_flutter_new_full/level.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -887,7 +888,12 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
if (_analyzing) {
|
||||
final isRescan = _data != null || _spectrogramImage != null;
|
||||
return Card(
|
||||
color: cs.surfaceContainerLow,
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
@@ -945,10 +951,15 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
|
||||
if (_data == null) {
|
||||
return Card(
|
||||
color: cs.surfaceContainerLow,
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: _analyze,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
@@ -1000,6 +1011,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
image: _spectrogramImage!,
|
||||
sampleRate: data.sampleRate,
|
||||
maxFreq: data.spectrum?.maxFreq ?? data.sampleRate / 2,
|
||||
duration: data.spectrum?.duration ?? data.duration,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -1272,7 +1284,12 @@ class _AudioInfoCard extends StatelessWidget {
|
||||
final nyquist = data.sampleRate / 2;
|
||||
|
||||
return Card(
|
||||
color: cs.surfaceContainerLow,
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -1583,16 +1600,19 @@ class _SpectrogramView extends StatelessWidget {
|
||||
final ui.Image image;
|
||||
final int sampleRate;
|
||||
final double maxFreq;
|
||||
final double duration;
|
||||
|
||||
const _SpectrogramView({
|
||||
required this.image,
|
||||
required this.sampleRate,
|
||||
required this.maxFreq,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
const labelColor = Color(0xFFB5B5B5);
|
||||
|
||||
return Card(
|
||||
color: Colors.black,
|
||||
@@ -1600,13 +1620,60 @@ class _SpectrogramView extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 2.0,
|
||||
child: CustomPaint(
|
||||
painter: _ImagePainter(image),
|
||||
size: Size.infinite,
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 10, 10, 4),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const leftGutter = 34.0;
|
||||
const bottomGutter = 18.0;
|
||||
final plotWidth = constraints.maxWidth - leftGutter;
|
||||
final plotHeight = plotWidth / 2.0;
|
||||
final totalHeight = plotHeight + bottomGutter;
|
||||
return SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
height: totalHeight,
|
||||
child: CustomPaint(
|
||||
painter: _SpectrogramPainter(
|
||||
image: image,
|
||||
maxFreqHz: maxFreq,
|
||||
durationSec: duration,
|
||||
labelColor: labelColor,
|
||||
gridColor: Colors.white.withValues(alpha: 0.10),
|
||||
),
|
||||
size: Size(constraints.maxWidth, totalHeight),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Intensity color legend (matches the spectrogram colormap).
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(40, 0, 10, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Quiet',
|
||||
style: TextStyle(color: labelColor, fontSize: 10),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
gradient: LinearGradient(colors: _legendColors()),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Loud',
|
||||
style: TextStyle(color: labelColor, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.25)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
@@ -1627,27 +1694,149 @@ class _SpectrogramView extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static List<Color> _legendColors() {
|
||||
return List.generate(20, (i) {
|
||||
final c = _spekColorRGB(i / 19.0);
|
||||
return Color.fromARGB(255, c[0], c[1], c[2]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _ImagePainter extends CustomPainter {
|
||||
class _SpectrogramPainter extends CustomPainter {
|
||||
final ui.Image image;
|
||||
_ImagePainter(this.image);
|
||||
final double maxFreqHz;
|
||||
final double durationSec;
|
||||
final Color labelColor;
|
||||
final Color gridColor;
|
||||
|
||||
static const double leftGutter = 34;
|
||||
static const double bottomGutter = 18;
|
||||
|
||||
_SpectrogramPainter({
|
||||
required this.image,
|
||||
required this.maxFreqHz,
|
||||
required this.durationSec,
|
||||
required this.labelColor,
|
||||
required this.gridColor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
paintImage(
|
||||
canvas: canvas,
|
||||
rect: Offset.zero & size,
|
||||
image: image,
|
||||
fit: BoxFit.contain,
|
||||
filterQuality: FilterQuality.medium,
|
||||
final plot = Rect.fromLTWH(
|
||||
leftGutter,
|
||||
0,
|
||||
size.width - leftGutter,
|
||||
size.height - bottomGutter,
|
||||
);
|
||||
if (plot.width <= 0 || plot.height <= 0) return;
|
||||
|
||||
// Spectrogram image.
|
||||
canvas.drawImageRect(
|
||||
image,
|
||||
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
|
||||
plot,
|
||||
Paint()..filterQuality = FilterQuality.medium,
|
||||
);
|
||||
|
||||
final gridPaint = Paint()
|
||||
..color = gridColor
|
||||
..strokeWidth = 1;
|
||||
|
||||
// Frequency axis (Y): 0 Hz at the bottom, maxFreq at the top.
|
||||
final maxKHz = maxFreqHz / 1000.0;
|
||||
if (maxKHz > 0) {
|
||||
final stepKHz = _niceStepKHz(maxKHz);
|
||||
for (double fk = 0; fk <= maxKHz + 0.001; fk += stepKHz) {
|
||||
final ratio = (fk * 1000) / maxFreqHz;
|
||||
final y = plot.bottom - ratio * plot.height;
|
||||
canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), gridPaint);
|
||||
_drawText(
|
||||
canvas,
|
||||
fk == 0 ? '0' : '${fk.toStringAsFixed(0)}k',
|
||||
Offset(plot.left - 5, y),
|
||||
align: _TextAlignV.rightCenter,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Time axis (X): 0 at the left, duration at the right.
|
||||
if (durationSec > 0) {
|
||||
final stepSec = _niceStepSec(durationSec);
|
||||
for (double ts = 0; ts <= durationSec + 0.001; ts += stepSec) {
|
||||
final ratio = ts / durationSec;
|
||||
final x = plot.left + ratio * plot.width;
|
||||
canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), gridPaint);
|
||||
_drawText(
|
||||
canvas,
|
||||
_fmtTime(ts),
|
||||
Offset(x, plot.bottom + 3),
|
||||
align: _TextAlignV.topCenter,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawText(
|
||||
Canvas canvas,
|
||||
String text,
|
||||
Offset anchor, {
|
||||
required _TextAlignV align,
|
||||
}) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(color: labelColor, fontSize: 10),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
double dx = anchor.dx;
|
||||
double dy = anchor.dy;
|
||||
switch (align) {
|
||||
case _TextAlignV.rightCenter:
|
||||
dx = anchor.dx - tp.width;
|
||||
dy = anchor.dy - tp.height / 2;
|
||||
break;
|
||||
case _TextAlignV.topCenter:
|
||||
dx = anchor.dx - tp.width / 2;
|
||||
dy = anchor.dy;
|
||||
break;
|
||||
}
|
||||
tp.paint(canvas, Offset(dx, dy));
|
||||
}
|
||||
|
||||
static double _niceStepKHz(double maxKHz) {
|
||||
const candidates = [1.0, 2.0, 5.0, 10.0, 20.0, 50.0];
|
||||
for (final c in candidates) {
|
||||
if (maxKHz / c <= 6) return c;
|
||||
}
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
static double _niceStepSec(double dur) {
|
||||
const candidates = [5.0, 10.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0];
|
||||
for (final c in candidates) {
|
||||
if (dur / c <= 6) return c;
|
||||
}
|
||||
return 1200.0;
|
||||
}
|
||||
|
||||
static String _fmtTime(double sec) {
|
||||
final s = sec.round();
|
||||
final m = s ~/ 60;
|
||||
final r = s % 60;
|
||||
return '$m:${r.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ImagePainter old) => old.image != image;
|
||||
bool shouldRepaint(covariant _SpectrogramPainter old) =>
|
||||
old.image != image ||
|
||||
old.maxFreqHz != maxFreqHz ||
|
||||
old.durationSec != durationSec;
|
||||
}
|
||||
|
||||
enum _TextAlignV { rightCenter, topCenter }
|
||||
|
||||
class _SpectrogramRenderParams {
|
||||
final SpectrogramData spectrum;
|
||||
final int width;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
/// Modern, card-based batch convert sheet shared by the queue and album
|
||||
/// screens. It mirrors the single-track convert sheet styling so format and
|
||||
/// bitrate selection look consistent across the app.
|
||||
class BatchConvertSheet extends StatefulWidget {
|
||||
/// Available target formats.
|
||||
final List<String> formats;
|
||||
|
||||
/// Sheet title.
|
||||
final String title;
|
||||
|
||||
/// Optional subtitle shown under the title (e.g. number of tracks).
|
||||
final String? subtitle;
|
||||
|
||||
/// Label for the primary action button.
|
||||
final String confirmLabel;
|
||||
|
||||
/// Called with the selected format and bitrate when the user confirms.
|
||||
final void Function(String format, String bitrate) onConvert;
|
||||
|
||||
const BatchConvertSheet({
|
||||
super.key,
|
||||
required this.formats,
|
||||
required this.title,
|
||||
required this.confirmLabel,
|
||||
required this.onConvert,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BatchConvertSheet> createState() => _BatchConvertSheetState();
|
||||
}
|
||||
|
||||
class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
static const _bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
late String _selectedFormat;
|
||||
late bool _isLosslessTarget;
|
||||
late String _selectedBitrate;
|
||||
|
||||
String _defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
if (format == 'AAC') return '256k';
|
||||
return '320k';
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedFormat = widget.formats.first;
|
||||
_isLosslessTarget = isLosslessConversionTarget(_selectedFormat);
|
||||
_selectedBitrate = _isLosslessTarget
|
||||
? '320k'
|
||||
: _defaultBitrateForFormat(_selectedFormat);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
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)
|
||||
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(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
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),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(ColorScheme cs, {required Widget child}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: settingsGroupColor(context),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionLabel(ColorScheme cs, String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _choice(
|
||||
ColorScheme cs, {
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Material(
|
||||
color: selected ? cs.primaryContainer : cs.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? Colors.transparent
|
||||
: cs.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: selected ? cs.onPrimaryContainer : cs.onSurface,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Background fill for grouped cards, matching the Settings group look. Blends a
|
||||
/// translucent overlay over the surface so it stays visible on AMOLED (pure
|
||||
/// black) dark themes as well as normal light/dark themes.
|
||||
Color settingsGroupColor(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.04),
|
||||
colorScheme.surface,
|
||||
);
|
||||
}
|
||||
|
||||
class SettingsGroup extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
@@ -8,24 +25,17 @@ class SettingsGroup extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cardColor = settingsGroupColor(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.04),
|
||||
colorScheme.surface,
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
|
||||
Reference in New Issue
Block a user