mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
feat(ui): add shared widgets, utils, and models for deduplicated screens
Single homes for logic that was copy-pasted across screens: the settings collapsing header, album detail header (online screen's design as the reference), selection pill button + bottom-bar chrome, disc separator chip, error card, cover URL upgrade, byte/clock formatting, duration extraction, UnifiedLibraryItem (moved out of the queue_tab library so other screens can import it), and the batch convert/ReplayGain engine keyed on it.
This commit is contained in:
1 parent
49ecfe261a
commit
9580fafe4f
11 files changed
+1508
No files matched your search
@@ -0,0 +1,189 @@
|
|||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||||
|
|
||||||
|
enum LibraryItemSource { downloaded, local }
|
||||||
|
|
||||||
|
/// A library track backed by either a download-history record or a
|
||||||
|
/// local-library scan record, unified for shared list and batch handling.
|
||||||
|
class UnifiedLibraryItem {
|
||||||
|
final String id;
|
||||||
|
final String trackName;
|
||||||
|
final String artistName;
|
||||||
|
final String albumName;
|
||||||
|
final String? coverUrl;
|
||||||
|
final String? localCoverPath;
|
||||||
|
final String filePath;
|
||||||
|
final String? quality;
|
||||||
|
final DateTime addedAt;
|
||||||
|
final LibraryItemSource source;
|
||||||
|
|
||||||
|
final DownloadHistoryItem? historyItem;
|
||||||
|
final LocalLibraryItem? localItem;
|
||||||
|
|
||||||
|
UnifiedLibraryItem({
|
||||||
|
required this.id,
|
||||||
|
required this.trackName,
|
||||||
|
required this.artistName,
|
||||||
|
required this.albumName,
|
||||||
|
this.coverUrl,
|
||||||
|
this.localCoverPath,
|
||||||
|
required this.filePath,
|
||||||
|
this.quality,
|
||||||
|
required this.addedAt,
|
||||||
|
required this.source,
|
||||||
|
this.historyItem,
|
||||||
|
this.localItem,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) {
|
||||||
|
String? quality;
|
||||||
|
if (item.bitrate != null && item.bitrate! > 0) {
|
||||||
|
quality = buildDisplayAudioQuality(
|
||||||
|
bitrateKbps: item.bitrate,
|
||||||
|
format: item.format,
|
||||||
|
);
|
||||||
|
} else if (item.bitDepth != null &&
|
||||||
|
item.bitDepth! > 0 &&
|
||||||
|
item.sampleRate != null) {
|
||||||
|
quality = buildDisplayAudioQuality(
|
||||||
|
bitDepth: item.bitDepth,
|
||||||
|
sampleRate: item.sampleRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
quality ??= item.quality;
|
||||||
|
return UnifiedLibraryItem(
|
||||||
|
id: 'dl_${item.id}',
|
||||||
|
trackName: item.trackName,
|
||||||
|
artistName: item.artistName,
|
||||||
|
albumName: item.albumName,
|
||||||
|
coverUrl: item.coverUrl,
|
||||||
|
filePath: item.filePath,
|
||||||
|
quality: quality,
|
||||||
|
addedAt: item.downloadedAt,
|
||||||
|
source: LibraryItemSource.downloaded,
|
||||||
|
historyItem: item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
|
||||||
|
String? quality;
|
||||||
|
if (item.bitrate != null && item.bitrate! > 0) {
|
||||||
|
quality = buildDisplayAudioQuality(
|
||||||
|
bitrateKbps: item.bitrate,
|
||||||
|
format: item.format,
|
||||||
|
);
|
||||||
|
} else if (item.bitDepth != null &&
|
||||||
|
item.bitDepth! > 0 &&
|
||||||
|
item.sampleRate != null) {
|
||||||
|
quality = buildDisplayAudioQuality(
|
||||||
|
bitDepth: item.bitDepth,
|
||||||
|
sampleRate: item.sampleRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return UnifiedLibraryItem(
|
||||||
|
id: 'local_${item.id}',
|
||||||
|
trackName: item.trackName,
|
||||||
|
artistName: item.artistName,
|
||||||
|
albumName: item.albumName,
|
||||||
|
coverUrl: null,
|
||||||
|
localCoverPath: item.coverPath,
|
||||||
|
filePath: item.filePath,
|
||||||
|
quality: quality,
|
||||||
|
addedAt: item.fileModTime != null
|
||||||
|
? DateTime.fromMillisecondsSinceEpoch(item.fileModTime!)
|
||||||
|
: item.scannedAt,
|
||||||
|
source: LibraryItemSource.local,
|
||||||
|
localItem: item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasCover =>
|
||||||
|
coverUrl != null ||
|
||||||
|
(localCoverPath != null && localCoverPath!.isNotEmpty);
|
||||||
|
|
||||||
|
String? get albumArtist => historyItem?.albumArtist ?? localItem?.albumArtist;
|
||||||
|
|
||||||
|
String? get releaseDate => historyItem?.releaseDate ?? localItem?.releaseDate;
|
||||||
|
|
||||||
|
String? get genre => historyItem?.genre ?? localItem?.genre;
|
||||||
|
|
||||||
|
int? get trackNumber => historyItem?.trackNumber ?? localItem?.trackNumber;
|
||||||
|
|
||||||
|
int? get discNumber => historyItem?.discNumber ?? localItem?.discNumber;
|
||||||
|
|
||||||
|
String? get isrc => historyItem?.isrc ?? localItem?.isrc;
|
||||||
|
|
||||||
|
String? get label => historyItem?.label ?? localItem?.label;
|
||||||
|
|
||||||
|
String get searchKey =>
|
||||||
|
'${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}';
|
||||||
|
String get albumKey =>
|
||||||
|
'${albumName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||||
|
|
||||||
|
/// Returns the collection key used to match this item against playlist
|
||||||
|
/// entries. Uses the same logic as [trackCollectionKey] from the collections
|
||||||
|
/// provider: prefer ISRC, fall back to source:id.
|
||||||
|
String get collectionKey {
|
||||||
|
if (historyItem != null) {
|
||||||
|
final isrc = historyItem!.isrc?.trim();
|
||||||
|
if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}';
|
||||||
|
final source = historyItem!.service.trim().isNotEmpty
|
||||||
|
? historyItem!.service.trim()
|
||||||
|
: 'builtin';
|
||||||
|
return '$source:${historyItem!.id}';
|
||||||
|
}
|
||||||
|
if (localItem != null) {
|
||||||
|
final isrc = localItem!.isrc?.trim();
|
||||||
|
if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}';
|
||||||
|
return 'local:${localItem!.id}';
|
||||||
|
}
|
||||||
|
return 'builtin:$id';
|
||||||
|
}
|
||||||
|
|
||||||
|
Track toTrack() {
|
||||||
|
if (historyItem != null) {
|
||||||
|
final h = historyItem!;
|
||||||
|
return Track(
|
||||||
|
id: h.id,
|
||||||
|
name: h.trackName,
|
||||||
|
artistName: h.artistName,
|
||||||
|
albumName: h.albumName,
|
||||||
|
albumArtist: h.albumArtist,
|
||||||
|
coverUrl: h.coverUrl,
|
||||||
|
isrc: h.isrc,
|
||||||
|
duration: h.duration ?? 0,
|
||||||
|
trackNumber: h.trackNumber,
|
||||||
|
discNumber: h.discNumber,
|
||||||
|
releaseDate: h.releaseDate,
|
||||||
|
source: h.service,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (localItem != null) {
|
||||||
|
final l = localItem!;
|
||||||
|
return Track(
|
||||||
|
id: l.id,
|
||||||
|
name: l.trackName,
|
||||||
|
artistName: l.artistName,
|
||||||
|
albumName: l.albumName,
|
||||||
|
albumArtist: l.albumArtist,
|
||||||
|
coverUrl: l.coverPath,
|
||||||
|
isrc: l.isrc,
|
||||||
|
duration: l.duration ?? 0,
|
||||||
|
trackNumber: l.trackNumber,
|
||||||
|
discNumber: l.discNumber,
|
||||||
|
releaseDate: l.releaseDate,
|
||||||
|
source: 'local',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Track(
|
||||||
|
id: id,
|
||||||
|
name: trackName,
|
||||||
|
artistName: artistName,
|
||||||
|
albumName: albumName,
|
||||||
|
coverUrl: coverUrl,
|
||||||
|
duration: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
import 'package:spotiflac_android/models/unified_library_item.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/history_database.dart';
|
||||||
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/services/replaygain_service.dart';
|
||||||
|
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||||
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
|
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||||
|
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||||
|
|
||||||
|
/// Shows the batch format-conversion sheet for [selectedItems] and runs the
|
||||||
|
/// conversion when confirmed. Handles both history-backed and local-backed
|
||||||
|
/// items, including SAF-hosted files.
|
||||||
|
///
|
||||||
|
/// [onSheetOpen] / [onSheetClosed] let the caller hide and restore any
|
||||||
|
/// selection UI around the sheet; [onSheetClosed] receives whether a
|
||||||
|
/// conversion was started.
|
||||||
|
Future<void> showBatchConvertSheet(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
List<UnifiedLibraryItem> selectedItems, {
|
||||||
|
required VoidCallback onExitSelectionMode,
|
||||||
|
VoidCallback? onSheetOpen,
|
||||||
|
Future<void> Function(bool didStartConversion)? onSheetClosed,
|
||||||
|
}) async {
|
||||||
|
final sourceFormats = <String>{};
|
||||||
|
final sourceBitDepths = <int?>[];
|
||||||
|
final sourceSampleRates = <int?>[];
|
||||||
|
for (final item in selectedItems) {
|
||||||
|
final sourceFormat = convertibleAudioSourceFormat(
|
||||||
|
storedFormat: item.localItem?.format ?? item.historyItem?.format,
|
||||||
|
filePath: item.filePath,
|
||||||
|
fileName: item.historyItem?.safFileName,
|
||||||
|
);
|
||||||
|
if (sourceFormat != null) sourceFormats.add(sourceFormat);
|
||||||
|
sourceBitDepths.add(item.historyItem?.bitDepth ?? item.localItem?.bitDepth);
|
||||||
|
sourceSampleRates.add(
|
||||||
|
item.historyItem?.sampleRate ?? item.localItem?.sampleRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final formats = audioConversionTargetFormats
|
||||||
|
.where(
|
||||||
|
(target) => sourceFormats.any(
|
||||||
|
(source) =>
|
||||||
|
canConvertAudioFormat(sourceFormat: source, targetFormat: target),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (formats.isEmpty) return;
|
||||||
|
|
||||||
|
var didStartConversion = false;
|
||||||
|
|
||||||
|
// Resolve localized strings up front; the builder must not look up
|
||||||
|
// Localizations via a possibly deactivated context.
|
||||||
|
final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle;
|
||||||
|
final sheetConfirmLabel = context.l10n.selectionConvertCount(
|
||||||
|
selectedItems.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
onSheetOpen?.call();
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) => BatchConvertSheet(
|
||||||
|
formats: formats,
|
||||||
|
title: sheetTitle,
|
||||||
|
confirmLabel: sheetConfirmLabel,
|
||||||
|
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
|
||||||
|
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
|
||||||
|
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
|
||||||
|
didStartConversion = true;
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_performBatchConversion(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
selectedItems: selectedItems,
|
||||||
|
targetFormat: format,
|
||||||
|
bitrate: bitrate,
|
||||||
|
losslessQuality: losslessQuality,
|
||||||
|
losslessProcessing: losslessProcessing,
|
||||||
|
onExitSelectionMode: onExitSelectionMode,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await onSheetClosed?.call(didStartConversion);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performBatchConversion(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref, {
|
||||||
|
required List<UnifiedLibraryItem> selectedItems,
|
||||||
|
required String targetFormat,
|
||||||
|
required String bitrate,
|
||||||
|
LosslessConversionQuality losslessQuality = const LosslessConversionQuality(),
|
||||||
|
LosslessConversionProcessing losslessProcessing =
|
||||||
|
const LosslessConversionProcessing(),
|
||||||
|
required VoidCallback onExitSelectionMode,
|
||||||
|
}) async {
|
||||||
|
final convertibleItems = <UnifiedLibraryItem>[];
|
||||||
|
for (final item in selectedItems) {
|
||||||
|
final sourceFormat = convertibleAudioSourceFormat(
|
||||||
|
storedFormat: item.localItem?.format ?? item.historyItem?.format,
|
||||||
|
filePath: item.filePath,
|
||||||
|
fileName: item.historyItem?.safFileName,
|
||||||
|
);
|
||||||
|
if (sourceFormat == null ||
|
||||||
|
!canConvertAudioFormat(
|
||||||
|
sourceFormat: sourceFormat,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
convertibleItems.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (convertibleItems.isEmpty) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isLossless = isLosslessConversionTarget(targetFormat);
|
||||||
|
final losslessLabels = context.l10n.losslessConversionLabels;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||||
|
content: Text(
|
||||||
|
isLossless && losslessQuality.hasCaps
|
||||||
|
? context.l10n.selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
convertibleItems.length,
|
||||||
|
targetFormat,
|
||||||
|
losslessQualityLabel(
|
||||||
|
losslessQuality,
|
||||||
|
originalLabel: losslessLabels.original,
|
||||||
|
originalQualityLabel: losslessLabels.originalQuality,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: isLossless
|
||||||
|
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||||
|
convertibleItems.length,
|
||||||
|
targetFormat,
|
||||||
|
)
|
||||||
|
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||||
|
convertibleItems.length,
|
||||||
|
targetFormat,
|
||||||
|
bitrate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(context.l10n.dialogCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(context.l10n.trackConvertFormat),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
|
||||||
|
int successCount = 0;
|
||||||
|
final total = convertibleItems.length;
|
||||||
|
final historyDb = HistoryDatabase.instance;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
final shouldEmbedLyrics =
|
||||||
|
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||||
|
|
||||||
|
var cancelled = false;
|
||||||
|
BatchProgressDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.trackConvertConverting,
|
||||||
|
total: total,
|
||||||
|
icon: Icons.transform,
|
||||||
|
onCancel: () {
|
||||||
|
cancelled = true;
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (int i = 0; i < total; i++) {
|
||||||
|
if (!context.mounted || cancelled) break;
|
||||||
|
final item = convertibleItems[i];
|
||||||
|
|
||||||
|
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final metadata = <String, String>{
|
||||||
|
'TITLE': item.trackName,
|
||||||
|
'ARTIST': item.artistName,
|
||||||
|
'ALBUM': item.albumName,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
||||||
|
if (result['error'] == null) {
|
||||||
|
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
await ensureLyricsMetadataForConversion(
|
||||||
|
metadata: metadata,
|
||||||
|
sourcePath: item.filePath,
|
||||||
|
shouldEmbedLyrics: shouldEmbedLyrics,
|
||||||
|
trackName: item.trackName,
|
||||||
|
artistName: item.artistName,
|
||||||
|
spotifyId: item.historyItem?.spotifyId ?? '',
|
||||||
|
durationMs:
|
||||||
|
((item.historyItem?.duration ?? item.localItem?.duration) ?? 0) *
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
String? coverPath;
|
||||||
|
try {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final coverOutput =
|
||||||
|
'${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||||
|
final coverResult = await PlatformBridge.extractCoverToFile(
|
||||||
|
item.filePath,
|
||||||
|
coverOutput,
|
||||||
|
);
|
||||||
|
if (coverResult['error'] == null) {
|
||||||
|
coverPath = coverOutput;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
String workingPath = item.filePath;
|
||||||
|
final isSaf = isContentUri(item.filePath);
|
||||||
|
String? safTempPath;
|
||||||
|
|
||||||
|
if (isSaf) {
|
||||||
|
safTempPath = await PlatformBridge.copyContentUriToTemp(item.filePath);
|
||||||
|
if (safTempPath == null) continue;
|
||||||
|
workingPath = safTempPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newPath = await FFmpegService.convertAudioFormat(
|
||||||
|
inputPath: workingPath,
|
||||||
|
targetFormat: targetFormat.toLowerCase(),
|
||||||
|
bitrate: bitrate,
|
||||||
|
metadata: metadata,
|
||||||
|
coverPath: coverPath,
|
||||||
|
artistTagMode: settings.artistTagMode,
|
||||||
|
deleteOriginal: !isSaf,
|
||||||
|
sourceBitDepth: item.historyItem?.bitDepth ?? item.localItem?.bitDepth,
|
||||||
|
losslessQuality: losslessQuality,
|
||||||
|
losslessProcessing: losslessProcessing,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (coverPath != null) {
|
||||||
|
try {
|
||||||
|
await File(coverPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPath == null) {
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try {
|
||||||
|
await File(safTempPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final sourceBitDepth =
|
||||||
|
item.historyItem?.bitDepth ?? item.localItem?.bitDepth;
|
||||||
|
final sourceSampleRate =
|
||||||
|
item.historyItem?.sampleRate ?? item.localItem?.sampleRate;
|
||||||
|
final isLosslessOutput = isLosslessConversionTarget(targetFormat);
|
||||||
|
int? convertedBitDepth;
|
||||||
|
int? convertedSampleRate;
|
||||||
|
if (isLosslessOutput) {
|
||||||
|
try {
|
||||||
|
final convertedMetadata = await PlatformBridge.readFileMetadata(
|
||||||
|
newPath,
|
||||||
|
);
|
||||||
|
if (convertedMetadata['error'] == null) {
|
||||||
|
convertedBitDepth = readPositiveInt(convertedMetadata['bit_depth']);
|
||||||
|
convertedSampleRate = readPositiveInt(
|
||||||
|
convertedMetadata['sample_rate'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
convertedBitDepth ??= losslessQuality.effectiveBitDepth(sourceBitDepth);
|
||||||
|
convertedSampleRate ??= losslessQuality.effectiveSampleRate(
|
||||||
|
sourceSampleRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final newQuality = convertedAudioQualityLabel(
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
labels: losslessLabels,
|
||||||
|
losslessQuality: losslessQuality,
|
||||||
|
actualBitDepth: convertedBitDepth,
|
||||||
|
actualSampleRate: convertedSampleRate,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSaf && item.historyItem != null) {
|
||||||
|
final hi = item.historyItem!;
|
||||||
|
final treeUri = hi.downloadTreeUri;
|
||||||
|
final relativeDir = hi.safRelativeDir ?? '';
|
||||||
|
if (treeUri != null && treeUri.isNotEmpty) {
|
||||||
|
final oldFileName = hi.safFileName ?? '';
|
||||||
|
final dotIdx = oldFileName.lastIndexOf('.');
|
||||||
|
final baseName = dotIdx > 0
|
||||||
|
? oldFileName.substring(0, dotIdx)
|
||||||
|
: oldFileName;
|
||||||
|
final convTarget = convertTargetExtAndMime(targetFormat);
|
||||||
|
final newExt = convTarget.ext;
|
||||||
|
final mimeType = convTarget.mime;
|
||||||
|
final newFileName = '$baseName$newExt';
|
||||||
|
|
||||||
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||||
|
treeUri: treeUri,
|
||||||
|
relativeDir: relativeDir,
|
||||||
|
fileName: newFileName,
|
||||||
|
mimeType: mimeType,
|
||||||
|
srcPath: newPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (safUri == null || safUri.isEmpty) {
|
||||||
|
try {
|
||||||
|
await File(newPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try {
|
||||||
|
await File(safTempPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSameContentUri(item.filePath, safUri)) {
|
||||||
|
try {
|
||||||
|
await PlatformBridge.safDelete(item.filePath);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
await historyDb.updateFilePath(
|
||||||
|
hi.id,
|
||||||
|
safUri,
|
||||||
|
newSafFileName: newFileName,
|
||||||
|
newQuality: newQuality,
|
||||||
|
newFormat: normalizedConvertedAudioFormat(targetFormat),
|
||||||
|
newBitrate: convertedAudioBitrateKbps(
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
),
|
||||||
|
newBitDepth: convertedBitDepth,
|
||||||
|
newSampleRate: convertedSampleRate,
|
||||||
|
clearAudioSpecs: !isLosslessOutput,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await File(newPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try {
|
||||||
|
await File(safTempPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} else if (isSaf && item.localItem != null) {
|
||||||
|
final uri = Uri.parse(item.filePath);
|
||||||
|
final pathSegments = uri.pathSegments;
|
||||||
|
|
||||||
|
String? treeUri;
|
||||||
|
String relativeDir = '';
|
||||||
|
String oldFileName = '';
|
||||||
|
|
||||||
|
final treeIdx = pathSegments.indexOf('tree');
|
||||||
|
final docIdx = pathSegments.indexOf('document');
|
||||||
|
if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) {
|
||||||
|
final treeId = pathSegments[treeIdx + 1];
|
||||||
|
treeUri =
|
||||||
|
'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}';
|
||||||
|
}
|
||||||
|
if (docIdx >= 0 && docIdx + 1 < pathSegments.length) {
|
||||||
|
final docPath = Uri.decodeFull(pathSegments[docIdx + 1]);
|
||||||
|
final slashIdx = docPath.lastIndexOf('/');
|
||||||
|
if (slashIdx >= 0) {
|
||||||
|
oldFileName = docPath.substring(slashIdx + 1);
|
||||||
|
final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length
|
||||||
|
? Uri.decodeFull(pathSegments[treeIdx + 1])
|
||||||
|
: '';
|
||||||
|
if (treeId.isNotEmpty && docPath.startsWith(treeId)) {
|
||||||
|
final afterTree = docPath.substring(treeId.length);
|
||||||
|
final trimmed = afterTree.startsWith('/')
|
||||||
|
? afterTree.substring(1)
|
||||||
|
: afterTree;
|
||||||
|
final lastSlash = trimmed.lastIndexOf('/');
|
||||||
|
relativeDir = lastSlash >= 0
|
||||||
|
? trimmed.substring(0, lastSlash)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
oldFileName = docPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (treeUri != null && oldFileName.isNotEmpty) {
|
||||||
|
final dotIdx = oldFileName.lastIndexOf('.');
|
||||||
|
final baseName = dotIdx > 0
|
||||||
|
? oldFileName.substring(0, dotIdx)
|
||||||
|
: oldFileName;
|
||||||
|
final convTarget = convertTargetExtAndMime(targetFormat);
|
||||||
|
final newExt = convTarget.ext;
|
||||||
|
final mimeType = convTarget.mime;
|
||||||
|
final newFileName = '$baseName$newExt';
|
||||||
|
|
||||||
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||||
|
treeUri: treeUri,
|
||||||
|
relativeDir: relativeDir,
|
||||||
|
fileName: newFileName,
|
||||||
|
mimeType: mimeType,
|
||||||
|
srcPath: newPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (safUri == null || safUri.isEmpty) {
|
||||||
|
try {
|
||||||
|
await File(newPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try {
|
||||||
|
await File(safTempPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSameContentUri(item.filePath, safUri)) {
|
||||||
|
try {
|
||||||
|
await PlatformBridge.safDelete(item.filePath);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||||
|
item: item.localItem!,
|
||||||
|
newFilePath: safUri,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
bitDepth: convertedBitDepth,
|
||||||
|
sampleRate: convertedSampleRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await File(newPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try {
|
||||||
|
await File(safTempPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} else if (item.historyItem != null) {
|
||||||
|
await historyDb.updateFilePath(
|
||||||
|
item.historyItem!.id,
|
||||||
|
newPath,
|
||||||
|
newQuality: newQuality,
|
||||||
|
newFormat: normalizedConvertedAudioFormat(targetFormat),
|
||||||
|
newBitrate: convertedAudioBitrateKbps(
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
),
|
||||||
|
newBitDepth: convertedBitDepth,
|
||||||
|
newSampleRate: convertedSampleRate,
|
||||||
|
clearAudioSpecs: !isLosslessOutput,
|
||||||
|
);
|
||||||
|
} else if (item.localItem != null) {
|
||||||
|
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||||
|
item: item.localItem!,
|
||||||
|
newFilePath: newPath,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
bitDepth: convertedBitDepth,
|
||||||
|
sampleRate: convertedSampleRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||||
|
ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||||
|
|
||||||
|
onExitSelectionMode();
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
if (!cancelled) {
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.selectionBatchConvertSuccess(
|
||||||
|
successCount,
|
||||||
|
total,
|
||||||
|
targetFormat,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Batch-scans loudness and writes ReplayGain tags to [selectedItems].
|
||||||
|
///
|
||||||
|
/// [onConfirmOpen] / [onConfirmClosed] let the caller hide and restore any
|
||||||
|
/// selection UI around the confirmation dialog; [onConfirmClosed] receives
|
||||||
|
/// whether the user confirmed.
|
||||||
|
Future<void> runBatchReplayGain(
|
||||||
|
BuildContext context,
|
||||||
|
List<UnifiedLibraryItem> selectedItems, {
|
||||||
|
required VoidCallback onExitSelectionMode,
|
||||||
|
VoidCallback? onConfirmOpen,
|
||||||
|
Future<void> Function(bool confirmed)? onConfirmClosed,
|
||||||
|
}) async {
|
||||||
|
if (selectedItems.isEmpty) return;
|
||||||
|
|
||||||
|
onConfirmOpen?.call();
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||||
|
content: Text(
|
||||||
|
ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(ctx.l10n.dialogCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await onConfirmClosed?.call(confirmed == true);
|
||||||
|
|
||||||
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
|
||||||
|
var cancelled = false;
|
||||||
|
int successCount = 0;
|
||||||
|
final total = selectedItems.length;
|
||||||
|
|
||||||
|
BatchProgressDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.replayGainBatchAnalyzing,
|
||||||
|
total: total,
|
||||||
|
icon: Icons.graphic_eq,
|
||||||
|
onCancel: () {
|
||||||
|
cancelled = true;
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (int i = 0; i < total; i++) {
|
||||||
|
if (!context.mounted || cancelled) break;
|
||||||
|
final item = selectedItems[i];
|
||||||
|
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||||
|
try {
|
||||||
|
final ok = await ReplayGainService.applyToFile(item.filePath);
|
||||||
|
if (ok) successCount++;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onExitSelectionMode();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (!cancelled) {
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.replayGainBatchSuccess(successCount, total)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// Deezer CDN cover size pattern: /WxH-q-p-o-n.jpg
|
||||||
|
final RegExp _deezerCoverSizeRegex = RegExp(
|
||||||
|
r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Upgrades a Spotify/Deezer cover URL to a display-quality resolution
|
||||||
|
/// (Spotify 300px → 640px, Deezer → 1000x1000 preserving quality params).
|
||||||
|
/// Non-matching URLs pass through unchanged.
|
||||||
|
String? highResCoverUrl(String? url) {
|
||||||
|
if (url == null) return null;
|
||||||
|
if (url.contains('ab67616d00001e02')) {
|
||||||
|
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
|
||||||
|
}
|
||||||
|
if (url.contains('cdn-images.dzcdn.net') &&
|
||||||
|
_deezerCoverSizeRegex.hasMatch(url)) {
|
||||||
|
return url.replaceAllMapped(
|
||||||
|
_deezerCoverSizeRegex,
|
||||||
|
(m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
@@ -11,3 +11,29 @@ int? readPositiveInt(dynamic value) {
|
|||||||
if (parsed == null || parsed <= 0) return null;
|
if (parsed == null || parsed <= 0) return null;
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int extractDurationMs(Map<String, dynamic> data) {
|
||||||
|
final durationMsRaw = data['duration_ms'];
|
||||||
|
if (durationMsRaw is num && durationMsRaw > 0) {
|
||||||
|
return durationMsRaw.toInt();
|
||||||
|
}
|
||||||
|
if (durationMsRaw is String) {
|
||||||
|
final parsed = num.tryParse(durationMsRaw.trim());
|
||||||
|
if (parsed != null && parsed > 0) {
|
||||||
|
return parsed.toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final durationSecRaw = data['duration'];
|
||||||
|
if (durationSecRaw is num && durationSecRaw > 0) {
|
||||||
|
return (durationSecRaw * 1000).toInt();
|
||||||
|
}
|
||||||
|
if (durationSecRaw is String) {
|
||||||
|
final parsed = num.tryParse(durationSecRaw.trim());
|
||||||
|
if (parsed != null && parsed > 0) {
|
||||||
|
return (parsed * 1000).toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -55,6 +55,24 @@ String? normalizeRemoteHttpUrl(String? value) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Human-readable byte size: "512 B", "3.4 KB", "12.0 MB", "1.25 GB".
|
||||||
|
String formatBytes(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
}
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "m:ss" clock formatting for track durations.
|
||||||
|
String formatClock(num seconds) {
|
||||||
|
final total = seconds.round();
|
||||||
|
final minutes = total ~/ 60;
|
||||||
|
final secs = total % 60;
|
||||||
|
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
String formatSampleRateKHz(int sampleRate) {
|
String formatSampleRateKHz(int sampleRate) {
|
||||||
final khz = sampleRate / 1000;
|
final khz = sampleRate / 1000;
|
||||||
final precision = sampleRate % 1000 == 0 ? 0 : 1;
|
final precision = sampleRate % 1000 == 0 ? 0 : 1;
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Collapsing album-detail header shared by the album, local-album, and
|
||||||
|
/// downloaded-album screens: full-bleed [background] (optionally blurred and
|
||||||
|
/// scrimmed), bottom gradient, centered square cover, title, optional
|
||||||
|
/// subtitle/meta/actions rows, and a circular back button. Content fades out
|
||||||
|
/// below 30% expansion; the [title] fades into the toolbar instead.
|
||||||
|
class AlbumDetailHeader extends StatelessWidget {
|
||||||
|
const AlbumDetailHeader({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.expandedHeight,
|
||||||
|
required this.showTitleInAppBar,
|
||||||
|
required this.background,
|
||||||
|
this.blurAndScrimBackground = true,
|
||||||
|
this.coverBuilder,
|
||||||
|
this.subtitle,
|
||||||
|
this.meta,
|
||||||
|
this.actions,
|
||||||
|
this.appBarActions,
|
||||||
|
this.backgroundColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final double expandedHeight;
|
||||||
|
final bool showTitleInAppBar;
|
||||||
|
|
||||||
|
/// Full-bleed background content (cover image, motion banner, placeholder).
|
||||||
|
final Widget background;
|
||||||
|
|
||||||
|
/// Blur the background and dim it 35%; disable for motion banners and
|
||||||
|
/// placeholder backgrounds.
|
||||||
|
final bool blurAndScrimBackground;
|
||||||
|
|
||||||
|
/// Builds the centered square cover for the given side length; null hides
|
||||||
|
/// the cover block (the header wraps it in the shared shadow + rounding).
|
||||||
|
final Widget Function(BuildContext context, double coverSize)? coverBuilder;
|
||||||
|
|
||||||
|
/// Artist line under the title (plain text or a clickable artist widget).
|
||||||
|
final Widget? subtitle;
|
||||||
|
|
||||||
|
/// "•"-joined meta row under the subtitle.
|
||||||
|
final Widget? meta;
|
||||||
|
|
||||||
|
/// Action row (play/shuffle, download-all, ...) under the meta row.
|
||||||
|
final Widget? actions;
|
||||||
|
|
||||||
|
/// Extra toolbar actions (top-right).
|
||||||
|
final List<Widget>? appBarActions;
|
||||||
|
|
||||||
|
final Color? backgroundColor;
|
||||||
|
|
||||||
|
/// Shrinks long titles so up to three lines fit the header.
|
||||||
|
double _titleFontSize() {
|
||||||
|
final length = title.trim().length;
|
||||||
|
if (length > 45) return 18;
|
||||||
|
if (length > 30) return 21;
|
||||||
|
return 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: expandedHeight,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: backgroundColor ?? colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
title: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
opacity: showTitleInAppBar ? 1.0 : 0.0,
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
flexibleSpace: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final collapseRatio =
|
||||||
|
(constraints.maxHeight - kToolbarHeight) /
|
||||||
|
(expandedHeight - kToolbarHeight);
|
||||||
|
final showContent = collapseRatio > 0.3;
|
||||||
|
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
collapseMode: CollapseMode.pin,
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (blurAndScrimBackground)
|
||||||
|
ImageFiltered(
|
||||||
|
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
|
||||||
|
child: background,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
background,
|
||||||
|
if (blurAndScrimBackground)
|
||||||
|
Container(color: Colors.black.withValues(alpha: 0.35)),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: expandedHeight * 0.65,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.black.withValues(alpha: 0.85),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 40,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
opacity: showContent ? 1.0 : 0.0,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (coverBuilder != null) ...[
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final coverSize = (constraints.maxWidth * 0.5)
|
||||||
|
.clamp(150.0, 210.0)
|
||||||
|
.toDouble();
|
||||||
|
return Container(
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(
|
||||||
|
alpha: 0.45,
|
||||||
|
),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: coverBuilder!(context, coverSize),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: _titleFontSize(),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
subtitle!,
|
||||||
|
],
|
||||||
|
if (meta != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
meta!,
|
||||||
|
],
|
||||||
|
if (actions != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
actions!,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
|
icon: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.4),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
actions: appBarActions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// White "play" pill + translucent shuffle circle used by the local and
|
||||||
|
/// downloaded album headers.
|
||||||
|
class AlbumPlayActions extends StatelessWidget {
|
||||||
|
const AlbumPlayActions({
|
||||||
|
super.key,
|
||||||
|
required this.playLabel,
|
||||||
|
required this.shuffleTooltip,
|
||||||
|
required this.onPlay,
|
||||||
|
required this.onShuffle,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String playLabel;
|
||||||
|
final String shuffleTooltip;
|
||||||
|
final VoidCallback onPlay;
|
||||||
|
final VoidCallback onShuffle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: onPlay,
|
||||||
|
icon: const Icon(Icons.play_arrow, size: 20),
|
||||||
|
label: Text(playLabel, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black87,
|
||||||
|
minimumSize: const Size(0, 48),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: shuffleTooltip,
|
||||||
|
onPressed: onShuffle,
|
||||||
|
icon: const Icon(Icons.shuffle, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
|
||||||
|
/// "Disc N" chip with a trailing hairline, shown between disc groups in
|
||||||
|
/// album track lists.
|
||||||
|
class DiscSeparatorChip extends StatelessWidget {
|
||||||
|
const DiscSeparatorChip({super.key, required this.discNumber});
|
||||||
|
|
||||||
|
final int discNumber;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
context.l10n.downloadedAlbumDiscHeader(discNumber),
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 1,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
|
||||||
|
/// Error card shown by detail screens: a dedicated rate-limit presentation
|
||||||
|
/// when the message looks like an HTTP 429, otherwise a generic error row.
|
||||||
|
class ErrorCard extends StatelessWidget {
|
||||||
|
const ErrorCard({super.key, required this.error, required this.colorScheme});
|
||||||
|
|
||||||
|
final String error;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isRateLimit =
|
||||||
|
error.contains('429') ||
|
||||||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
|
error.toLowerCase().contains('too many requests');
|
||||||
|
|
||||||
|
if (isRateLimit) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.l10n.errorRateLimited,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
context.l10n.errorRateLimitedMessage,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: colorScheme.error),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Icon+label pill used in selection-mode bottom bars. Disabled state dims
|
||||||
|
/// both the fill and the content to 50% alpha.
|
||||||
|
class SelectionActionButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
|
const SelectionActionButton({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
required this.colorScheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDisabled = onPressed == null;
|
||||||
|
return Material(
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 18,
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
|
||||||
|
/// Shared chrome for the multi-select bottom bar: rounded surface, drag
|
||||||
|
/// handle, close button, "N selected" header and select-all toggle.
|
||||||
|
/// The screen-specific action buttons go in [children].
|
||||||
|
class SelectionBottomBar extends StatelessWidget {
|
||||||
|
const SelectionBottomBar({
|
||||||
|
super.key,
|
||||||
|
required this.selectedCount,
|
||||||
|
required this.allSelected,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onToggleSelectAll,
|
||||||
|
required this.bottomPadding,
|
||||||
|
required this.children,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int selectedCount;
|
||||||
|
final bool allSelected;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback onToggleSelectAll;
|
||||||
|
final double bottomPadding;
|
||||||
|
final List<Widget> children;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.15),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, -4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: onClose,
|
||||||
|
tooltip: MaterialLocalizations.of(
|
||||||
|
context,
|
||||||
|
).closeButtonTooltip,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.l10n.selectionSelected(selectedCount),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
allSelected
|
||||||
|
? context.l10n.selectionAllSelected
|
||||||
|
: context.l10n.downloadedAlbumTapToSelect,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onToggleSelectAll,
|
||||||
|
icon: Icon(
|
||||||
|
allSelected ? Icons.deselect : Icons.select_all,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
allSelected
|
||||||
|
? context.l10n.actionDeselect
|
||||||
|
: context.l10n.actionSelectAll,
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
|
|
||||||
|
/// The collapsing header shared by settings-style pages: a pinned
|
||||||
|
/// [SliverAppBar] whose title slides toward the leading edge and shrinks
|
||||||
|
/// from 28 to 20 logical pixels as the header collapses.
|
||||||
|
class SettingsSliverAppBar extends StatelessWidget {
|
||||||
|
const SettingsSliverAppBar({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.actions,
|
||||||
|
this.leading,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final List<Widget>? actions;
|
||||||
|
|
||||||
|
/// Defaults to a back button popping the current route.
|
||||||
|
final Widget? leading;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = normalizedHeaderTopPadding(context);
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 120 + topPadding,
|
||||||
|
collapsedHeight: kToolbarHeight,
|
||||||
|
floating: false,
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
leading: leading ??
|
||||||
|
IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
actions: actions,
|
||||||
|
flexibleSpace: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxHeight = 120 + topPadding;
|
||||||
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
|
final expandRatio =
|
||||||
|
((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
final leftPadding = 56 - (32 * expandRatio);
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
expandedTitleScale: 1.0,
|
||||||
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (8 * expandRatio),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user