Merge dev into main: v3.6.7 release

This commit is contained in:
zarzet
2026-02-13 21:42:02 +07:00
72 changed files with 24173 additions and 4044 deletions
+170 -54
View File
@@ -490,6 +490,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
0,
(sum, a) => sum + a.totalTracks,
);
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
final compactLayout =
MediaQuery.sizeOf(context).width < 430 || textScale > 1.15;
return Positioned(
left: 0,
@@ -510,53 +513,145 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
top: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
IconButton(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
tooltip: context.l10n.dialogCancel,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: compactLayout
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.discographySelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
Row(
children: [
IconButton(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
tooltip: context.l10n.dialogCancel,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.discographySelectedCount(
selectedCount,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
if (selectedCount > 0)
Text(
context.l10n.tracksCount(totalTracks),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
if (selectedCount > 0)
Text(
context.l10n.tracksCount(totalTracks),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: allSelected
? _deselectAll
: () => _selectAll(allAlbums),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: selectedCount > 0
? () => _downloadSelectedAlbums(
context,
selectedAlbums,
)
: null,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
context.l10n.discographyDownloadSelected,
),
),
),
),
],
),
],
)
: Row(
children: [
IconButton(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
tooltip: context.l10n.dialogCancel,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.discographySelectedCount(
selectedCount,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
if (selectedCount > 0)
Text(
context.l10n.tracksCount(totalTracks),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
TextButton(
onPressed: allSelected
? _deselectAll
: () => _selectAll(allAlbums),
child: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: selectedCount > 0
? () => _downloadSelectedAlbums(
context,
selectedAlbums,
)
: null,
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.discographyDownloadSelected),
),
],
),
),
TextButton(
onPressed: allSelected
? _deselectAll
: () => _selectAll(allAlbums),
child: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: selectedCount > 0
? () => _downloadSelectedAlbums(context, selectedAlbums)
: null,
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.discographyDownloadSelected),
),
],
),
),
),
),
@@ -1427,15 +1522,31 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
duration: const Duration(seconds: 2),
),
);
void enqueue(String service, {String? quality}) {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
duration: const Duration(seconds: 2),
),
);
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
onSelect: (quality, service) {
if (!mounted) return;
enqueue(service, quality: quality);
},
);
return;
}
enqueue(settings.defaultService);
}
Widget _buildAlbumSection(
@@ -1468,7 +1579,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final album = albums[index];
return KeyedSubtree(
key: ValueKey(album.id),
child: _buildAlbumCard(album, colorScheme, tileSize: tileSize, sectionHeight: sectionHeight),
child: _buildAlbumCard(
album,
colorScheme,
tileSize: tileSize,
sectionHeight: sectionHeight,
),
);
},
),
@@ -1601,9 +1717,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Flexible(
child: Text(
album.name,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
+15 -1
View File
@@ -560,7 +560,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
if (tracks.isEmpty) return null;
final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null;
// For lossy formats, use bitrate
if (first.bitrate != null && first.bitrate! > 0) {
final fmt = first.format?.toUpperCase() ?? '';
final firstBitrate = first.bitrate;
for (final track in tracks) {
if (track.bitrate != firstBitrate) {
return null;
}
}
return '$fmt ${firstBitrate}kbps'.trim();
}
// For lossless formats, use bit depth / sample rate
if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) return null;
final firstQuality =
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
+10 -2
View File
@@ -70,7 +70,12 @@ class UnifiedLibraryItem {
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
String? quality;
if (item.bitDepth != null && item.sampleRate != null) {
if (item.bitrate != null && item.bitrate! > 0) {
// Lossy format with bitrate
final fmt = item.format?.toUpperCase() ?? '';
quality = '$fmt ${item.bitrate}kbps'.trim();
} else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) {
// Lossless format with actual bit depth
quality =
'${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
}
@@ -897,7 +902,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
String? _localQualityLabel(LocalLibraryItem item) {
if (item.bitDepth == null || item.sampleRate == null) {
if (item.bitrate != null && item.bitrate! > 0) {
return '${item.bitrate}kbps';
}
if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) {
return null;
}
return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
+233 -123
View File
@@ -25,6 +25,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false;
bool _artistFolderFiltersExpanded = false;
@override
void initState() {
@@ -363,7 +364,53 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUseAlbumArtistForFolders(value),
showDivider: false,
),
SettingsItem(
icon: Icons.filter_alt_outlined,
title: 'Artist Name Filters',
subtitle: _getArtistFolderFilterSubtitle(
context,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterAlbumArtistContributors:
settings.filterContributingArtistsInAlbumArtist,
),
trailing: Icon(
_artistFolderFiltersExpanded
? Icons.expand_less
: Icons.expand_more,
),
onTap: () {
setState(() {
_artistFolderFiltersExpanded =
!_artistFolderFiltersExpanded;
});
},
showDivider: !_artistFolderFiltersExpanded,
),
if (_artistFolderFiltersExpanded)
SettingsSwitchItem(
icon: Icons.person_outline,
title: context.l10n.downloadUsePrimaryArtistOnly,
subtitle: settings.usePrimaryArtistOnly
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
value: settings.usePrimaryArtistOnly,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsePrimaryArtistOnly(value),
),
if (_artistFolderFiltersExpanded)
SettingsSwitchItem(
icon: Icons.group_remove_outlined,
title: 'Filter contributing artists in Album Artist',
subtitle: settings.filterContributingArtistsInAlbumArtist
? 'Album Artist metadata uses primary artist only'
: 'Keep full Album Artist metadata value',
value: settings.filterContributingArtistsInAlbumArtist,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setFilterContributingArtistsInAlbumArtist(value),
showDivider: false,
),
SettingsSwitchItem(
icon: Icons.person_outline,
@@ -585,14 +632,28 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
final tags = [
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;
@@ -624,130 +685,164 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => 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(
context.l10n.filenameFormat,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Customize how your files are named.',
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(
'Tap to insert tag:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: tags.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),
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),
),
labelStyle: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
Text(
context.l10n.filenameFormat,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Customize how your files are named.',
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,
),
);
}).toList(),
),
),
autofocus: true,
),
const SizedBox(height: 24),
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),
),
Text(
'Tap to insert tag:',
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),
),
child: Text(context.l10n.dialogCancel),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: FilledButton(
onPressed: () {
ref
.read(settingsProvider.notifier)
.setFilenameFormat(controller.text);
Navigator.pop(context);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
labelStyle: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
child: Text(context.l10n.dialogSave),
),
);
}).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: 8),
],
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: () {
ref
.read(settingsProvider.notifier)
.setFilenameFormat(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),
],
),
),
),
),
@@ -937,7 +1032,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported),
content: Text(
validation.errorReason ??
context.l10n.setupIcloudNotSupported,
),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
@@ -1000,6 +1098,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
}
}
String _getArtistFolderFilterSubtitle(
BuildContext context, {
required bool usePrimaryArtistOnly,
required bool filterAlbumArtistContributors,
}) {
final statuses = <String>[
usePrimaryArtistOnly ? 'Primary only: On' : 'Primary only: Off',
filterAlbumArtistContributors
? 'Album Artist metadata: Primary only'
: 'Album Artist metadata: Full',
];
return statuses.join(' | ');
}
String _getLyricsModeLabel(BuildContext context, String mode) {
switch (mode) {
case 'external':
@@ -1456,9 +1568,7 @@ class _ServiceChip extends StatelessWidget {
return Expanded(
child: Material(
color: isSelected
? colorScheme.primaryContainer
: unselectedColor,
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
+11 -2
View File
@@ -68,10 +68,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _checkInitialPermissions() async {
if (Platform.isIOS) {
final notificationStatus = await Permission.notification.status;
if (mounted) {
setState(() {
_storagePermissionGranted = true;
_notificationPermissionGranted = true;
_notificationPermissionGranted =
notificationStatus.isGranted || notificationStatus.isProvisional;
});
}
} else if (Platform.isAndroid) {
@@ -181,7 +183,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _requestNotificationPermission() async {
setState(() => _isLoading = true);
try {
if (_androidSdkVersion >= 33) {
if (Platform.isIOS) {
final status = await Permission.notification.request();
if (status.isGranted || status.isProvisional) {
setState(() => _notificationPermissionGranted = true);
} else if (status.isPermanentlyDenied) {
await _showPermissionDeniedDialog('Notification');
}
} else if (_androidSdkVersion >= 33) {
final status = await Permission.notification.request();
if (status.isGranted) {
setState(() => _notificationPermissionGranted = true);
+94 -8
View File
@@ -416,6 +416,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
int? get sampleRate =>
_isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null;
String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
@@ -424,8 +425,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_isLocalItem ? _localLibraryItem!.coverPath : null;
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
DateTime get _addedAt =>
_isLocalItem ? _localLibraryItem!.scannedAt : _downloadItem!.downloadedAt;
DateTime get _addedAt {
if (_isLocalItem) {
// Use file modification time if available, otherwise fall back to scannedAt
final modTime = _localLibraryItem!.fileModTime;
if (modTime != null && modTime > 0) {
return DateTime.fromMillisecondsSinceEpoch(modTime);
}
return _localLibraryItem!.scannedAt;
}
return _downloadItem!.downloadedAt;
}
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
String get cleanFilePath {
@@ -433,6 +444,50 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return path.startsWith('EXISTS:') ? path.substring(7) : path;
}
String _formatPathForDisplay(String pathOrUri) {
if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) {
return pathOrUri;
}
try {
final uri = Uri.parse(pathOrUri);
final segments = uri.pathSegments;
String? documentId;
final documentIndex = segments.indexOf('document');
if (documentIndex != -1 && documentIndex + 1 < segments.length) {
documentId = Uri.decodeComponent(segments[documentIndex + 1]);
}
if (documentId == null || documentId.isEmpty) {
final treeIndex = segments.indexOf('tree');
if (treeIndex != -1 && treeIndex + 1 < segments.length) {
documentId = Uri.decodeComponent(segments[treeIndex + 1]);
}
}
if (documentId == null || documentId.isEmpty) return pathOrUri;
final separatorIndex = documentId.indexOf(':');
if (separatorIndex <= 0) return documentId;
final volumeId = documentId.substring(0, separatorIndex);
final relativePath = documentId
.substring(separatorIndex + 1)
.replaceAll('\\', '/');
if (volumeId.toLowerCase() == 'primary') {
if (relativePath.isEmpty) return '/storage/emulated/0';
return '/storage/emulated/0/$relativePath';
}
if (relativePath.isEmpty) return volumeId;
return 'SD Card/$relativePath';
} catch (_) {
return pathOrUri;
}
}
void _markMetadataChanged() {
_hasMetadataChanges = true;
}
@@ -913,7 +968,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Determine audio quality string - prefer stored quality from download
String? audioQualityStr;
final fileName = _filePath.split('/').last;
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExt = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: '';
@@ -921,8 +976,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// Use stored quality from download history if available
if (_quality != null && _quality!.isNotEmpty) {
audioQualityStr = _quality;
} else if (bitDepth != null && sampleRate != null) {
// Fallback for FLAC files without stored quality
} else if (_isLocalItem && _localBitrate != null && _localBitrate! > 0) {
// Lossy local file with bitrate info
final fmt = _localLibraryItem!.format?.toUpperCase() ?? fileExt;
audioQualityStr = '$fmt ${_localBitrate}kbps';
} else if (bitDepth != null && bitDepth! > 0 && sampleRate != null) {
// Lossless file with actual bit depth (FLAC, ALAC)
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
} else {
@@ -1031,7 +1090,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool fileExists,
int? fileSize,
) {
final fileName = cleanFilePath.split(Platform.pathSeparator).last;
final displayFilePath = _formatPathForDisplay(cleanFilePath);
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExtension = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: 'Unknown';
@@ -1128,7 +1188,33 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
)
else if (bitDepth != null && sampleRate != null)
else if (_isLocalItem &&
_localBitrate != null &&
_localBitrate! > 0 &&
(fileExtension == 'MP3' ||
fileExtension == 'OPUS' ||
fileExtension == 'OGG'))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_localBitrate}kbps',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
)
else if (bitDepth != null &&
bitDepth! > 0 &&
sampleRate != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
@@ -1194,7 +1280,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
children: [
Expanded(
child: Text(
cleanFilePath,
displayFilePath,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant,