diff --git a/lib/models/unified_library_item.dart b/lib/models/unified_library_item.dart new file mode 100644 index 00000000..e9f480ef --- /dev/null +++ b/lib/models/unified_library_item.dart @@ -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, + ); + } +} diff --git a/lib/services/batch_track_actions.dart b/lib/services/batch_track_actions.dart new file mode 100644 index 00000000..22a0e075 --- /dev/null +++ b/lib/services/batch_track_actions.dart @@ -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 showBatchConvertSheet( + BuildContext context, + WidgetRef ref, + List selectedItems, { + required VoidCallback onExitSelectionMode, + VoidCallback? onSheetOpen, + Future Function(bool didStartConversion)? onSheetClosed, +}) async { + final sourceFormats = {}; + final sourceBitDepths = []; + final sourceSampleRates = []; + 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( + 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 _performBatchConversion( + BuildContext context, + WidgetRef ref, { + required List selectedItems, + required String targetFormat, + required String bitrate, + LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), + LosslessConversionProcessing losslessProcessing = + const LosslessConversionProcessing(), + required VoidCallback onExitSelectionMode, +}) async { + final convertibleItems = []; + 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( + 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 = { + '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 runBatchReplayGain( + BuildContext context, + List selectedItems, { + required VoidCallback onExitSelectionMode, + VoidCallback? onConfirmOpen, + Future Function(bool confirmed)? onConfirmClosed, +}) async { + if (selectedItems.isEmpty) return; + + onConfirmOpen?.call(); + + final confirmed = await showDialog( + 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)), + ), + ); +} diff --git a/lib/utils/cover_art_utils.dart b/lib/utils/cover_art_utils.dart new file mode 100644 index 00000000..5572a363 --- /dev/null +++ b/lib/utils/cover_art_utils.dart @@ -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; +} diff --git a/lib/utils/int_utils.dart b/lib/utils/int_utils.dart index 52022da3..805fbd55 100644 --- a/lib/utils/int_utils.dart +++ b/lib/utils/int_utils.dart @@ -11,3 +11,29 @@ int? readPositiveInt(dynamic value) { if (parsed == null || parsed <= 0) return null; return parsed; } + +int extractDurationMs(Map 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; +} diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart index 8d8c1023..4559596a 100644 --- a/lib/utils/string_utils.dart +++ b/lib/utils/string_utils.dart @@ -55,6 +55,24 @@ String? normalizeRemoteHttpUrl(String? value) { 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) { final khz = sampleRate / 1000; final precision = sampleRate % 1000 == 0 ? 0 : 1; diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart new file mode 100644 index 00000000..acebe832 --- /dev/null +++ b/lib/widgets/album_detail_header.dart @@ -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? 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), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/disc_separator_chip.dart b/lib/widgets/disc_separator_chip.dart new file mode 100644 index 00000000..4bce7686 --- /dev/null +++ b/lib/widgets/disc_separator_chip.dart @@ -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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/error_card.dart b/lib/widgets/error_card.dart new file mode 100644 index 00000000..9a9d66d5 --- /dev/null +++ b/lib/widgets/error_card.dart @@ -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)), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/selection_action_button.dart b/lib/widgets/selection_action_button.dart new file mode 100644 index 00000000..426240c3 --- /dev/null +++ b/lib/widgets/selection_action_button.dart @@ -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, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/selection_bottom_bar.dart b/lib/widgets/selection_bottom_bar.dart new file mode 100644 index 00000000..0067427d --- /dev/null +++ b/lib/widgets/selection_bottom_bar.dart @@ -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 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, + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings_sliver_app_bar.dart b/lib/widgets/settings_sliver_app_bar.dart new file mode 100644 index 00000000..54e7ccfb --- /dev/null +++ b/lib/widgets/settings_sliver_app_bar.dart @@ -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? 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, + ), + ), + ); + }, + ), + ); + } +}