mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 07:18:49 +02:00
The local/downloaded album screens had drifted copies of the album header, selection bottom bar, disc chip, batch convert/ReplayGain trio, and assorted small helpers. All now delegate to the shared widgets, with the online album screen's design as the reference, and both album screens run batch actions through the queue_tab engine via UnifiedLibraryItem (strict-superset implementation covering both DB writebacks and SAF paths). Also folds the remaining per-screen helper copies (cover URL, error card, byte/clock formatting, readPositiveInt) into their shared homes. Intentional deltas: selection-bar strings follow queue_tab's l10n keys, both providers reload after a conversion, and audio-analysis durations round instead of floor.
1187 lines
44 KiB
Dart
1187 lines
44 KiB
Dart
part of 'queue_tab.dart';
|
|
|
|
extension _QueueTabItemWidgets on _QueueTabState {
|
|
Widget _buildSelectionBottomBar(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
List<UnifiedLibraryItem> unifiedItems,
|
|
double bottomPadding,
|
|
) {
|
|
final selectedCount = _selectedIds.length;
|
|
final allSelected =
|
|
selectedCount == unifiedItems.length && unifiedItems.isNotEmpty;
|
|
final localOnlySelection = _isLocalOnlySelection(unifiedItems);
|
|
final flacEligibleCount = _selectedFlacEligibleLocalItems(
|
|
unifiedItems,
|
|
).length;
|
|
|
|
return SelectionBottomBar(
|
|
selectedCount: selectedCount,
|
|
allSelected: allSelected,
|
|
onClose: _exitSelectionMode,
|
|
onToggleSelectAll: () {
|
|
if (allSelected) {
|
|
_exitSelectionMode();
|
|
} else {
|
|
_selectAll(unifiedItems);
|
|
}
|
|
},
|
|
bottomPadding: bottomPadding,
|
|
children: [
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
const spacing = 8.0;
|
|
final itemWidth = (constraints.maxWidth - spacing) / 2;
|
|
final actions = <Widget>[];
|
|
|
|
if (localOnlySelection && flacEligibleCount > 0) {
|
|
actions.add(
|
|
SelectionActionButton(
|
|
icon: Icons.download_for_offline_outlined,
|
|
label: '${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
|
onPressed: () => _queueSelectedLocalAsFlac(unifiedItems),
|
|
colorScheme: colorScheme,
|
|
),
|
|
);
|
|
}
|
|
|
|
actions.add(
|
|
SelectionActionButton(
|
|
icon: localOnlySelection
|
|
? Icons.auto_fix_high_outlined
|
|
: Icons.share_outlined,
|
|
label: localOnlySelection
|
|
? '${context.l10n.trackReEnrich} ($selectedCount)'
|
|
: context.l10n.selectionShareCount(selectedCount),
|
|
onPressed: selectedCount > 0
|
|
? () => localOnlySelection
|
|
? _reEnrichSelectedLocalFromQueue(unifiedItems)
|
|
: _shareSelected(unifiedItems)
|
|
: null,
|
|
colorScheme: colorScheme,
|
|
),
|
|
);
|
|
|
|
actions.add(
|
|
SelectionActionButton(
|
|
icon: Icons.swap_horiz,
|
|
label: context.l10n.selectionConvertCount(selectedCount),
|
|
onPressed: selectedCount > 0
|
|
? () => _showBatchConvertSheet(context, unifiedItems)
|
|
: null,
|
|
colorScheme: colorScheme,
|
|
),
|
|
);
|
|
|
|
actions.add(
|
|
SelectionActionButton(
|
|
icon: Icons.graphic_eq,
|
|
label: context.l10n.selectionReplayGainCount(selectedCount),
|
|
onPressed: selectedCount > 0
|
|
? () => _runBatchReplayGain(unifiedItems)
|
|
: null,
|
|
colorScheme: colorScheme,
|
|
),
|
|
);
|
|
|
|
return Wrap(
|
|
spacing: spacing,
|
|
runSpacing: spacing,
|
|
children: [
|
|
for (final action in actions)
|
|
SizedBox(width: itemWidth, child: action),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
onPressed: selectedCount > 0
|
|
? () => _deleteSelected(unifiedItems)
|
|
: null,
|
|
icon: const Icon(Icons.delete_outline),
|
|
label: Text(
|
|
selectedCount > 0
|
|
? context.l10n.selectionDeleteTracksCount(selectedCount)
|
|
: context.l10n.selectionSelectToDelete,
|
|
),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: selectedCount > 0
|
|
? colorScheme.error
|
|
: colorScheme.surfaceContainerHighest,
|
|
foregroundColor: selectedCount > 0
|
|
? colorScheme.onError
|
|
: colorScheme.onSurfaceVariant,
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildQueueItem(
|
|
BuildContext context,
|
|
DownloadItem item,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
final isCompleted = item.status == DownloadStatus.completed;
|
|
final isActive =
|
|
item.status == DownloadStatus.queued ||
|
|
item.status == DownloadStatus.downloading ||
|
|
item.status == DownloadStatus.finalizing;
|
|
|
|
return Dismissible(
|
|
key: ValueKey('dismiss_${item.id}'),
|
|
direction: DismissDirection.endToStart,
|
|
confirmDismiss: isActive
|
|
? (_) async {
|
|
return await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(context.l10n.cancelDownloadTitle),
|
|
content: Text(
|
|
context.l10n.cancelDownloadContent(item.track.name),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(false),
|
|
child: Text(context.l10n.cancelDownloadKeep),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(true),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
],
|
|
),
|
|
) ??
|
|
false;
|
|
}
|
|
: null,
|
|
onDismissed: (_) {
|
|
ref.read(downloadQueueProvider.notifier).dismissItem(item.id);
|
|
},
|
|
background: Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.errorContainer,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
alignment: Alignment.centerRight,
|
|
padding: const EdgeInsets.only(right: 20),
|
|
child: Icon(Icons.delete_outline, color: colorScheme.onErrorContainer),
|
|
),
|
|
child: DownloadSuccessOverlay(
|
|
showSuccess: isCompleted,
|
|
child: Card(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: isCompleted
|
|
? () => _navigateToMetadataScreen(item)
|
|
: item.status == DownloadStatus.failed
|
|
? () => _showDownloadErrorDialog(context, item)
|
|
: null,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Stack(
|
|
children: [
|
|
if (item.status == DownloadStatus.downloading)
|
|
Positioned.fill(
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: FractionallySizedBox(
|
|
widthFactor: item.progress.clamp(0.0, 1.0),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.centerLeft,
|
|
end: Alignment.centerRight,
|
|
colors: [
|
|
colorScheme.primary.withValues(alpha: 0.16),
|
|
colorScheme.primary.withValues(alpha: 0.04),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
isCompleted
|
|
? Hero(
|
|
tag: 'cover_${item.id}',
|
|
child: _buildCoverArt(item, colorScheme),
|
|
)
|
|
: _buildCoverArt(item, colorScheme),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.track.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.titleSmall
|
|
?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 2),
|
|
ClickableArtistName(
|
|
artistName: item.track.artistName,
|
|
artistId: item.track.artistId,
|
|
coverUrl: item.track.coverUrl,
|
|
extensionId: item.track.source,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall
|
|
?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
if (item.status == DownloadStatus.downloading) ...[
|
|
const SizedBox(height: 5),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.download_rounded,
|
|
size: 12,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Expanded(
|
|
child: Text(
|
|
_formatDownloadStatusLine(context, item),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.labelSmall
|
|
?.copyWith(
|
|
color: colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
if (item.status == DownloadStatus.failed) ...[
|
|
const SizedBox(height: 4),
|
|
_buildDownloadFailureMessage(
|
|
context,
|
|
item,
|
|
colorScheme,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_buildActionButtons(context, item, colorScheme),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Download error messages are stored as fixed English sentinels on the
|
|
/// item (so code can match on them); translate the known ones for display.
|
|
String _localizedDownloadError(BuildContext context, String raw) {
|
|
if (raw == safPermissionLostErrorMessage) {
|
|
return context.l10n.downloadErrorSafPermissionLost;
|
|
}
|
|
if (raw == downloadFolderAccessLostErrorMessage) {
|
|
return context.l10n.downloadErrorFolderAccessLost;
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
Widget _buildDownloadFailureMessage(
|
|
BuildContext context,
|
|
DownloadItem item,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
if (item.errorType != DownloadErrorType.rateLimit) {
|
|
return Text(
|
|
_localizedDownloadError(context, item.errorMessage),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelSmall?.copyWith(color: colorScheme.error),
|
|
);
|
|
}
|
|
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 1),
|
|
child: Icon(
|
|
Icons.hourglass_top_rounded,
|
|
size: 14,
|
|
color: colorScheme.tertiary,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
context.l10n.queueRateLimitTitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: colorScheme.tertiary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 1),
|
|
Text(
|
|
context.l10n.queueRateLimitMessage,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: colorScheme.tertiary.withValues(alpha: 0.8),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
|
|
final coverSize = _queueCoverSize();
|
|
final radius = BorderRadius.circular(8);
|
|
|
|
final cover = item.track.coverUrl != null
|
|
? CachedCoverImage(
|
|
imageUrl: item.track.coverUrl!,
|
|
width: coverSize,
|
|
height: coverSize,
|
|
borderRadius: radius,
|
|
fadeInDuration: const Duration(milliseconds: 180),
|
|
fadeOutDuration: const Duration(milliseconds: 90),
|
|
)
|
|
: Container(
|
|
width: coverSize,
|
|
height: coverSize,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
borderRadius: radius,
|
|
),
|
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
|
);
|
|
|
|
final isDownloading =
|
|
item.status == DownloadStatus.downloading ||
|
|
item.status == DownloadStatus.finalizing;
|
|
if (!isDownloading) return cover;
|
|
|
|
final progress = item.progress.clamp(0.0, 1.0);
|
|
final indeterminate =
|
|
item.status == DownloadStatus.finalizing || progress <= 0;
|
|
|
|
return SizedBox(
|
|
width: coverSize,
|
|
height: coverSize,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
cover,
|
|
ClipRRect(
|
|
borderRadius: radius,
|
|
child: ColoredBox(color: Colors.black.withValues(alpha: 0.45)),
|
|
),
|
|
Center(
|
|
child: SizedBox(
|
|
width: coverSize * 0.6,
|
|
height: coverSize * 0.6,
|
|
child: CircularProgressIndicator(
|
|
value: indeterminate ? null : progress,
|
|
strokeWidth: 3,
|
|
color: Colors.white,
|
|
backgroundColor: Colors.white.withValues(alpha: 0.25),
|
|
),
|
|
),
|
|
),
|
|
if (!indeterminate)
|
|
Center(
|
|
child: Text(
|
|
'${(progress * 100).round()}',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActionButtons(
|
|
BuildContext context,
|
|
DownloadItem item,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
switch (item.status) {
|
|
case DownloadStatus.queued:
|
|
return IconButton(
|
|
onPressed: () =>
|
|
ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
|
|
icon: Icon(Icons.close, color: colorScheme.error),
|
|
tooltip: context.l10n.dialogCancel,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
|
|
),
|
|
);
|
|
case DownloadStatus.downloading:
|
|
return IconButton(
|
|
onPressed: () =>
|
|
ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
|
|
icon: Icon(Icons.stop, color: colorScheme.error),
|
|
tooltip: context.l10n.actionStop,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
|
|
),
|
|
);
|
|
case DownloadStatus.finalizing:
|
|
return Semantics(
|
|
label: context.l10n.queueFinalizingDownload,
|
|
child: SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
strokeWidth: 3,
|
|
color: colorScheme.tertiary,
|
|
),
|
|
ExcludeSemantics(
|
|
child: Icon(
|
|
Icons.edit_note,
|
|
color: colorScheme.tertiary,
|
|
size: 16,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
case DownloadStatus.completed:
|
|
return ValueListenableBuilder<bool>(
|
|
valueListenable: _fileExistsListenable(item.filePath),
|
|
builder: (context, fileExists, child) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (fileExists)
|
|
IconButton(
|
|
onPressed: () => _openFile(
|
|
item.filePath!,
|
|
title: item.track.name,
|
|
artist: item.track.artistName,
|
|
album: item.track.albumName,
|
|
coverUrl: item.track.coverUrl ?? '',
|
|
),
|
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
tooltip: context.l10n.tooltipPlay,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: colorScheme.primaryContainer.withValues(
|
|
alpha: 0.3,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
Semantics(
|
|
label: context.l10n.queueDownloadedFileMissing,
|
|
child: ExcludeSemantics(
|
|
child: Icon(
|
|
Icons.error_outline,
|
|
color: colorScheme.error,
|
|
size: 20,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Semantics(
|
|
label: context.l10n.queueDownloadCompleted,
|
|
child: ExcludeSemantics(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primaryContainer,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.check,
|
|
color: colorScheme.onPrimaryContainer,
|
|
size: 20,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
case DownloadStatus.failed:
|
|
case DownloadStatus.skipped:
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
onPressed: () =>
|
|
ref.read(downloadQueueProvider.notifier).retryItem(item.id),
|
|
icon: Icon(Icons.refresh, color: colorScheme.primary),
|
|
tooltip: context.l10n.dialogRetry,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: colorScheme.primaryContainer.withValues(
|
|
alpha: 0.3,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
IconButton(
|
|
onPressed: () =>
|
|
ref.read(downloadQueueProvider.notifier).removeItem(item.id),
|
|
icon: Icon(
|
|
Icons.close,
|
|
color: item.status == DownloadStatus.failed
|
|
? colorScheme.error
|
|
: colorScheme.onSurfaceVariant,
|
|
),
|
|
tooltip: context.l10n.dialogRemove,
|
|
style: item.status == DownloadStatus.failed
|
|
? IconButton.styleFrom(
|
|
backgroundColor: colorScheme.errorContainer.withValues(
|
|
alpha: 0.3,
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildFilterButton(
|
|
BuildContext context,
|
|
List<UnifiedLibraryItem> unifiedItems,
|
|
) {
|
|
return GestureDetector(
|
|
onLongPress: _activeFilterCount > 0 ? _resetFilters : null,
|
|
child: TextButton.icon(
|
|
onPressed: () => _showFilterSheet(context, unifiedItems),
|
|
icon: Badge(
|
|
isLabelVisible: _activeFilterCount > 0,
|
|
label: Text('$_activeFilterCount'),
|
|
child: const Icon(Icons.filter_list, size: 18),
|
|
),
|
|
label: Text(context.l10n.libraryFilterTitle),
|
|
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// When [size] is provided, renders at fixed dimensions (list mode).
|
|
/// When [size] is null, fills the parent container (grid mode).
|
|
Widget _buildUnifiedCoverImage(
|
|
UnifiedLibraryItem item,
|
|
ColorScheme colorScheme, [
|
|
double? size,
|
|
]) {
|
|
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
|
|
|
// For downloaded items, listen to embedded cover version so the cover
|
|
// updates after async extraction completes.
|
|
if (isDownloaded) {
|
|
return ValueListenableBuilder<int>(
|
|
valueListenable: _embeddedCoverVersion,
|
|
builder: (context, _, child) =>
|
|
_buildUnifiedCoverImageInner(item, colorScheme, isDownloaded, size),
|
|
);
|
|
}
|
|
|
|
return _buildUnifiedCoverImageInner(item, colorScheme, isDownloaded, size);
|
|
}
|
|
|
|
Widget _buildUnifiedCoverImageInner(
|
|
UnifiedLibraryItem item,
|
|
ColorScheme colorScheme,
|
|
bool isDownloaded, [
|
|
double? size,
|
|
]) {
|
|
final cacheSize = size != null ? (size * 2).toInt() : 200;
|
|
final iconSize = size != null ? size * 0.4 : 32.0;
|
|
|
|
Widget buildPlaceholder({bool isLocal = false}) {
|
|
final bgColor = (isDownloaded && !isLocal)
|
|
? colorScheme.surfaceContainerHighest
|
|
: colorScheme.secondaryContainer;
|
|
final fgColor = (isDownloaded && !isLocal)
|
|
? colorScheme.onSurfaceVariant
|
|
: colorScheme.onSecondaryContainer;
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: size != null
|
|
? BoxDecoration(
|
|
color: bgColor,
|
|
borderRadius: BorderRadius.circular(8),
|
|
)
|
|
: null,
|
|
color: size != null ? null : bgColor,
|
|
child: Center(
|
|
child: Icon(Icons.music_note, color: fgColor, size: iconSize),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget fadeInFileImage(Widget child, int? frame, bool wasSync) {
|
|
if (wasSync) return child;
|
|
final Widget backdrop;
|
|
if (isDownloaded && item.coverUrl != null) {
|
|
backdrop = CachedCoverImage(
|
|
imageUrl: item.coverUrl!,
|
|
width: size,
|
|
height: size,
|
|
memCacheWidth: cacheSize,
|
|
memCacheHeight: cacheSize,
|
|
placeholder: (context, url) => buildPlaceholder(),
|
|
errorWidget: (context, url, error) => buildPlaceholder(),
|
|
);
|
|
} else {
|
|
backdrop = buildPlaceholder(isLocal: !isDownloaded);
|
|
}
|
|
final animated = Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
backdrop,
|
|
AnimatedOpacity(
|
|
opacity: frame == null ? 0.0 : 1.0,
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
child: child,
|
|
),
|
|
],
|
|
);
|
|
if (size == null) return animated;
|
|
return SizedBox(width: size, height: size, child: animated);
|
|
}
|
|
|
|
if (isDownloaded) {
|
|
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
|
|
item.filePath,
|
|
);
|
|
if (embeddedCoverPath != null) {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Image.file(
|
|
File(embeddedCoverPath),
|
|
width: size,
|
|
height: size,
|
|
fit: BoxFit.cover,
|
|
cacheWidth: cacheSize,
|
|
cacheHeight: cacheSize,
|
|
gaplessPlayback: true,
|
|
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) =>
|
|
fadeInFileImage(child, frame, wasSynchronouslyLoaded),
|
|
errorBuilder: (context, error, stackTrace) => buildPlaceholder(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (item.coverUrl != null) {
|
|
return CachedCoverImage(
|
|
imageUrl: item.coverUrl!,
|
|
width: size,
|
|
height: size,
|
|
memCacheWidth: cacheSize,
|
|
memCacheHeight: cacheSize,
|
|
borderRadius: BorderRadius.circular(8),
|
|
placeholder: (context, url) => buildPlaceholder(),
|
|
errorWidget: (context, url, error) => buildPlaceholder(),
|
|
fadeInDuration: const Duration(milliseconds: 180),
|
|
fadeOutDuration: const Duration(milliseconds: 90),
|
|
);
|
|
}
|
|
|
|
if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Image.file(
|
|
File(item.localCoverPath!),
|
|
width: size,
|
|
height: size,
|
|
fit: BoxFit.cover,
|
|
cacheWidth: cacheSize,
|
|
cacheHeight: cacheSize,
|
|
gaplessPlayback: true,
|
|
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) =>
|
|
fadeInFileImage(child, frame, wasSynchronouslyLoaded),
|
|
errorBuilder: (context, error, stackTrace) =>
|
|
buildPlaceholder(isLocal: true),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (size != null) {
|
|
return buildPlaceholder();
|
|
}
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: buildPlaceholder(),
|
|
);
|
|
}
|
|
|
|
Widget _buildUnifiedLibraryItem(
|
|
BuildContext context,
|
|
UnifiedLibraryItem item,
|
|
ColorScheme colorScheme, {
|
|
required List<DownloadHistoryItem> downloadedNavigationItems,
|
|
required int? downloadedNavigationIndex,
|
|
required List<LocalLibraryItem> localNavigationItems,
|
|
required int? localNavigationIndex,
|
|
required List<UnifiedLibraryItem> libraryItems,
|
|
}) {
|
|
final fileExistsListenable = _fileExistsListenable(item.filePath);
|
|
final isSelected = _selectedIds.contains(item.id);
|
|
final date = item.addedAt;
|
|
final dateStr =
|
|
'${_QueueTabState._months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
|
|
|
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
|
final sourceLabel = isDownloaded
|
|
? context.l10n.librarySourceDownloaded
|
|
: context.l10n.librarySourceLocal;
|
|
final sourceColor = isDownloaded
|
|
? colorScheme.primaryContainer
|
|
: colorScheme.secondaryContainer;
|
|
final sourceTextColor = isDownloaded
|
|
? colorScheme.onPrimaryContainer
|
|
: colorScheme.onSecondaryContainer;
|
|
|
|
return Semantics(
|
|
label: context.l10n.a11yTrackByArtist(item.trackName, item.artistName),
|
|
selected: isSelected,
|
|
child: Card(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
color: isSelected
|
|
? colorScheme.primaryContainer.withValues(alpha: 0.3)
|
|
: null,
|
|
child: InkWell(
|
|
onTap: _isSelectionMode
|
|
? () => _toggleSelection(item.id)
|
|
: isDownloaded
|
|
? () => _navigateToHistoryMetadataScreen(
|
|
item.historyItem!,
|
|
navigationItems: downloadedNavigationItems,
|
|
navigationIndex: downloadedNavigationIndex,
|
|
)
|
|
: item.localItem != null
|
|
? () => _navigateToLocalMetadataScreen(
|
|
item.localItem!,
|
|
navigationItems: localNavigationItems,
|
|
navigationIndex: localNavigationIndex,
|
|
)
|
|
: () => _openFile(
|
|
item.filePath,
|
|
title: item.trackName,
|
|
artist: item.artistName,
|
|
album: item.albumName,
|
|
coverUrl: item.coverUrl ?? item.localCoverPath ?? '',
|
|
),
|
|
onLongPress: _isSelectionMode
|
|
? null
|
|
: () => _enterSelectionMode(item.id),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
if (_isSelectionMode) ...[
|
|
Semantics(
|
|
checked: isSelected,
|
|
label: isSelected
|
|
? context.l10n.a11yDeselectTrack
|
|
: context.l10n.a11ySelectTrack,
|
|
child: AnimatedSelectionCheckbox(
|
|
visible: true,
|
|
selected: isSelected,
|
|
colorScheme: colorScheme,
|
|
size: 24,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
],
|
|
Hero(
|
|
tag: 'cover_lib_${item.id}',
|
|
child: _buildUnifiedCoverImage(item, colorScheme, 56),
|
|
),
|
|
const SizedBox(width: 12),
|
|
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.trackName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
ClickableArtistName(
|
|
artistName: item.artistName,
|
|
coverUrl: item.coverUrl,
|
|
extensionId: item.historyItem?.service,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: sourceColor,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
sourceLabel,
|
|
style: Theme.of(context).textTheme.labelSmall
|
|
?.copyWith(
|
|
color: sourceTextColor,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(
|
|
dateStr,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.labelSmall
|
|
?.copyWith(
|
|
color: colorScheme.onSurfaceVariant
|
|
.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
),
|
|
if (item.quality != null &&
|
|
item.quality!.isNotEmpty) ...[
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: item.quality!.startsWith('24')
|
|
? colorScheme.primaryContainer
|
|
: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
item.quality!,
|
|
style: Theme.of(context).textTheme.labelSmall
|
|
?.copyWith(
|
|
color: item.quality!.startsWith('24')
|
|
? colorScheme.onPrimaryContainer
|
|
: colorScheme.onSurfaceVariant,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
|
|
if (!_isSelectionMode)
|
|
ValueListenableBuilder<bool>(
|
|
valueListenable: fileExistsListenable,
|
|
builder: (context, fileExists, child) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (fileExists)
|
|
IconButton(
|
|
onPressed: () =>
|
|
_playLibraryItem(item, libraryItems),
|
|
icon: Icon(
|
|
Icons.play_arrow,
|
|
color: colorScheme.primary,
|
|
),
|
|
tooltip: context.l10n.tooltipPlay,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: colorScheme.primaryContainer
|
|
.withValues(alpha: 0.3),
|
|
),
|
|
)
|
|
else
|
|
Icon(
|
|
Icons.error_outline,
|
|
color: colorScheme.error,
|
|
size: 20,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildUnifiedGridItem(
|
|
BuildContext context,
|
|
UnifiedLibraryItem item,
|
|
ColorScheme colorScheme, {
|
|
required List<DownloadHistoryItem> downloadedNavigationItems,
|
|
required int? downloadedNavigationIndex,
|
|
required List<LocalLibraryItem> localNavigationItems,
|
|
required int? localNavigationIndex,
|
|
required List<UnifiedLibraryItem> libraryItems,
|
|
}) {
|
|
final fileExistsListenable = _fileExistsListenable(item.filePath);
|
|
final isSelected = _selectedIds.contains(item.id);
|
|
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
|
|
|
return GestureDetector(
|
|
onTap: _isSelectionMode
|
|
? () => _toggleSelection(item.id)
|
|
: isDownloaded
|
|
? () => _navigateToHistoryMetadataScreen(
|
|
item.historyItem!,
|
|
navigationItems: downloadedNavigationItems,
|
|
navigationIndex: downloadedNavigationIndex,
|
|
)
|
|
: item.localItem != null
|
|
? () => _navigateToLocalMetadataScreen(
|
|
item.localItem!,
|
|
navigationItems: localNavigationItems,
|
|
navigationIndex: localNavigationIndex,
|
|
)
|
|
: () => _openFile(
|
|
item.filePath,
|
|
title: item.trackName,
|
|
artist: item.artistName,
|
|
album: item.albumName,
|
|
coverUrl: item.coverUrl ?? item.localCoverPath ?? '',
|
|
),
|
|
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id),
|
|
child: Stack(
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Stack(
|
|
children: [
|
|
AspectRatio(
|
|
aspectRatio: 1,
|
|
child: Hero(
|
|
tag: 'cover_lib_${item.id}',
|
|
child: _buildUnifiedCoverImage(item, colorScheme),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 4,
|
|
top: 4,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 4,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isDownloaded
|
|
? colorScheme.primaryContainer
|
|
: colorScheme.secondaryContainer,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Icon(
|
|
isDownloaded ? Icons.download_done : Icons.folder,
|
|
size: 12,
|
|
color: isDownloaded
|
|
? colorScheme.onPrimaryContainer
|
|
: colorScheme.onSecondaryContainer,
|
|
),
|
|
),
|
|
),
|
|
if (item.quality != null && item.quality!.isNotEmpty)
|
|
Positioned(
|
|
left: 4,
|
|
top: 4,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 4,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: item.quality!.startsWith('24')
|
|
? colorScheme.primary
|
|
: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
_getQualityBadgeText(item.quality!),
|
|
style: Theme.of(context).textTheme.labelSmall
|
|
?.copyWith(
|
|
color: item.quality!.startsWith('24')
|
|
? colorScheme.onPrimary
|
|
: colorScheme.onSurfaceVariant,
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (!_isSelectionMode)
|
|
Positioned(
|
|
right: 4,
|
|
bottom: 4,
|
|
child: ValueListenableBuilder<bool>(
|
|
valueListenable: fileExistsListenable,
|
|
builder: (context, fileExists, child) {
|
|
return fileExists
|
|
? Semantics(
|
|
button: true,
|
|
label: context.l10n.a11yPlayTrackByArtist(
|
|
item.trackName,
|
|
item.artistName,
|
|
),
|
|
child: GestureDetector(
|
|
onTap: () =>
|
|
_playLibraryItem(item, libraryItems),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primary,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: ExcludeSemantics(
|
|
child: Icon(
|
|
Icons.play_arrow,
|
|
color: colorScheme.onPrimary,
|
|
size: 16,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.errorContainer,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.error_outline,
|
|
color: colorScheme.error,
|
|
size: 14,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (_isSelectionMode)
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? colorScheme.primary.withValues(alpha: 0.3)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
item.trackName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
|
),
|
|
ClickableArtistName(
|
|
artistName: item.artistName,
|
|
coverUrl: item.coverUrl,
|
|
extensionId: item.historyItem?.service,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_isSelectionMode)
|
|
Positioned(
|
|
right: 4,
|
|
top: 4,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? colorScheme.primary : colorScheme.surface,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: isSelected
|
|
? colorScheme.primary
|
|
: colorScheme.outline,
|
|
width: 2,
|
|
),
|
|
),
|
|
child: isSelected
|
|
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
|
|
: const SizedBox(width: 16, height: 16),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|