mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
refactor(dart): extract queue search and media policies
This commit is contained in:
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_state.dart';
|
||||
import 'package:spotiflac_android/services/app_state_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||
@@ -29,6 +30,7 @@ import 'package:spotiflac_android/utils/progress_stream_poller.dart';
|
||||
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||
|
||||
export 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||
export 'package:spotiflac_android/providers/download_queue_state.dart';
|
||||
|
||||
export 'package:spotiflac_android/services/history_database.dart'
|
||||
show HistoryLookupRequest, HistoryBatchLookupRequest;
|
||||
@@ -85,72 +87,6 @@ final _qualityFilenameTokenPattern = RegExp(
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
class DownloadQueueState {
|
||||
static const Object _noChange = Object();
|
||||
final List<DownloadItem> items;
|
||||
final DownloadQueueLookup lookup;
|
||||
final DownloadItem? currentDownload;
|
||||
final bool isProcessing;
|
||||
final bool isPaused;
|
||||
final String outputDir;
|
||||
final String filenameFormat;
|
||||
final String singleFilenameFormat;
|
||||
final String audioQuality;
|
||||
final bool autoFallback;
|
||||
|
||||
const DownloadQueueState({
|
||||
this.items = const [],
|
||||
this.lookup = const DownloadQueueLookup.empty(),
|
||||
this.currentDownload,
|
||||
this.isProcessing = false,
|
||||
this.isPaused = false,
|
||||
this.outputDir = '',
|
||||
this.filenameFormat = '{artist} - {title}',
|
||||
this.singleFilenameFormat = '{title} - {artist}',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
this.autoFallback = true,
|
||||
});
|
||||
|
||||
DownloadQueueState copyWith({
|
||||
List<DownloadItem>? items,
|
||||
DownloadQueueLookup? lookup,
|
||||
Object? currentDownload = _noChange,
|
||||
bool? isProcessing,
|
||||
bool? isPaused,
|
||||
String? outputDir,
|
||||
String? filenameFormat,
|
||||
String? singleFilenameFormat,
|
||||
String? audioQuality,
|
||||
bool? autoFallback,
|
||||
}) {
|
||||
final resolvedItems = items ?? this.items;
|
||||
return DownloadQueueState(
|
||||
items: resolvedItems,
|
||||
lookup:
|
||||
lookup ??
|
||||
(items != null
|
||||
? DownloadQueueLookup.fromItems(resolvedItems)
|
||||
: this.lookup),
|
||||
currentDownload: identical(currentDownload, _noChange)
|
||||
? this.currentDownload
|
||||
: currentDownload as DownloadItem?,
|
||||
isProcessing: isProcessing ?? this.isProcessing,
|
||||
isPaused: isPaused ?? this.isPaused,
|
||||
outputDir: outputDir ?? this.outputDir,
|
||||
filenameFormat: filenameFormat ?? this.filenameFormat,
|
||||
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
|
||||
audioQuality: audioQuality ?? this.audioQuality,
|
||||
autoFallback: autoFallback ?? this.autoFallback,
|
||||
);
|
||||
}
|
||||
|
||||
int get queuedCount => items.isEmpty ? 0 : lookup.queuedCount;
|
||||
int get completedCount => items.isEmpty ? 0 : lookup.completedCount;
|
||||
int get failedCount => items.isEmpty ? 0 : lookup.failedCount;
|
||||
int get activeDownloadsCount =>
|
||||
items.isEmpty ? 0 : lookup.activeDownloadsCount;
|
||||
}
|
||||
|
||||
class _ProgressUpdate {
|
||||
final DownloadStatus status;
|
||||
final double progress;
|
||||
@@ -4056,177 +3992,6 @@ final downloadQueueProvider =
|
||||
DownloadQueueNotifier.new,
|
||||
);
|
||||
|
||||
class DownloadQueueLookup {
|
||||
final Map<String, DownloadItem> byTrackId;
|
||||
final Map<String, DownloadItem> byItemId;
|
||||
final Map<String, int> indexByItemId;
|
||||
final List<String> itemIds;
|
||||
final int queuedCount;
|
||||
final int completedCount;
|
||||
final int failedCount;
|
||||
final int activeDownloadsCount;
|
||||
final int finalizingCount;
|
||||
|
||||
const DownloadQueueLookup.empty()
|
||||
: byTrackId = const {},
|
||||
byItemId = const {},
|
||||
indexByItemId = const {},
|
||||
itemIds = const [],
|
||||
queuedCount = 0,
|
||||
completedCount = 0,
|
||||
failedCount = 0,
|
||||
activeDownloadsCount = 0,
|
||||
finalizingCount = 0;
|
||||
|
||||
DownloadQueueLookup._({
|
||||
required this.byTrackId,
|
||||
required this.byItemId,
|
||||
required this.indexByItemId,
|
||||
required this.itemIds,
|
||||
required this.queuedCount,
|
||||
required this.completedCount,
|
||||
required this.failedCount,
|
||||
required this.activeDownloadsCount,
|
||||
required this.finalizingCount,
|
||||
});
|
||||
|
||||
factory DownloadQueueLookup.fromItems(List<DownloadItem> items) {
|
||||
final byTrackId = <String, DownloadItem>{};
|
||||
final byItemId = <String, DownloadItem>{};
|
||||
final indexByItemId = <String, int>{};
|
||||
final itemIds = <String>[];
|
||||
var queuedCount = 0;
|
||||
var completedCount = 0;
|
||||
var failedCount = 0;
|
||||
var activeDownloadsCount = 0;
|
||||
var finalizingCount = 0;
|
||||
for (var index = 0; index < items.length; index++) {
|
||||
final item = items[index];
|
||||
byTrackId.putIfAbsent(item.track.id, () => item);
|
||||
byItemId[item.id] = item;
|
||||
indexByItemId[item.id] = index;
|
||||
itemIds.add(item.id);
|
||||
if (_countsAsQueued(item.status)) queuedCount++;
|
||||
if (item.status == DownloadStatus.completed) completedCount++;
|
||||
if (item.status == DownloadStatus.failed) failedCount++;
|
||||
if (item.status == DownloadStatus.downloading) activeDownloadsCount++;
|
||||
if (item.status == DownloadStatus.finalizing) finalizingCount++;
|
||||
}
|
||||
return DownloadQueueLookup._(
|
||||
byTrackId: Map.unmodifiable(byTrackId),
|
||||
byItemId: Map.unmodifiable(byItemId),
|
||||
indexByItemId: Map.unmodifiable(indexByItemId),
|
||||
itemIds: List.unmodifiable(itemIds),
|
||||
queuedCount: queuedCount,
|
||||
completedCount: completedCount,
|
||||
failedCount: failedCount,
|
||||
activeDownloadsCount: activeDownloadsCount,
|
||||
finalizingCount: finalizingCount,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _countsAsQueued(DownloadStatus status) =>
|
||||
status == DownloadStatus.queued ||
|
||||
status == DownloadStatus.downloading ||
|
||||
status == DownloadStatus.finalizing;
|
||||
|
||||
static int _deltaForStatus({
|
||||
required DownloadStatus previous,
|
||||
required DownloadStatus next,
|
||||
required bool Function(DownloadStatus status) predicate,
|
||||
}) {
|
||||
final had = predicate(previous);
|
||||
final has = predicate(next);
|
||||
if (had == has) return 0;
|
||||
return has ? 1 : -1;
|
||||
}
|
||||
|
||||
DownloadQueueLookup updatedForIndices({
|
||||
required List<DownloadItem> previousItems,
|
||||
required List<DownloadItem> nextItems,
|
||||
required Iterable<int> changedIndices,
|
||||
}) {
|
||||
if (previousItems.length != nextItems.length ||
|
||||
itemIds.length != nextItems.length ||
|
||||
indexByItemId.length != nextItems.length) {
|
||||
return DownloadQueueLookup.fromItems(nextItems);
|
||||
}
|
||||
|
||||
final normalizedChanged = <int>[];
|
||||
for (final index in changedIndices) {
|
||||
if (index < 0 || index >= nextItems.length) {
|
||||
return DownloadQueueLookup.fromItems(nextItems);
|
||||
}
|
||||
normalizedChanged.add(index);
|
||||
}
|
||||
if (normalizedChanged.isEmpty) return this;
|
||||
|
||||
var nextQueuedCount = queuedCount;
|
||||
var nextCompletedCount = completedCount;
|
||||
var nextFailedCount = failedCount;
|
||||
var nextActiveDownloadsCount = activeDownloadsCount;
|
||||
var nextFinalizingCount = finalizingCount;
|
||||
Map<String, DownloadItem>? nextByItemId;
|
||||
Map<String, DownloadItem>? nextByTrackId;
|
||||
|
||||
for (final index in normalizedChanged) {
|
||||
final previous = previousItems[index];
|
||||
final next = nextItems[index];
|
||||
if (previous.id != next.id || previous.track.id != next.track.id) {
|
||||
return DownloadQueueLookup.fromItems(nextItems);
|
||||
}
|
||||
|
||||
nextByItemId ??= Map<String, DownloadItem>.from(byItemId);
|
||||
nextByItemId[next.id] = next;
|
||||
if (byTrackId[next.track.id]?.id == previous.id) {
|
||||
nextByTrackId ??= Map<String, DownloadItem>.from(byTrackId);
|
||||
nextByTrackId[next.track.id] = next;
|
||||
}
|
||||
nextQueuedCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: _countsAsQueued,
|
||||
);
|
||||
nextCompletedCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: (status) => status == DownloadStatus.completed,
|
||||
);
|
||||
nextFailedCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: (status) => status == DownloadStatus.failed,
|
||||
);
|
||||
nextActiveDownloadsCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: (status) => status == DownloadStatus.downloading,
|
||||
);
|
||||
nextFinalizingCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: (status) => status == DownloadStatus.finalizing,
|
||||
);
|
||||
}
|
||||
|
||||
return DownloadQueueLookup._(
|
||||
byTrackId: nextByTrackId == null
|
||||
? byTrackId
|
||||
: Map.unmodifiable(nextByTrackId),
|
||||
byItemId: nextByItemId == null
|
||||
? byItemId
|
||||
: Map.unmodifiable(nextByItemId),
|
||||
indexByItemId: indexByItemId,
|
||||
itemIds: itemIds,
|
||||
queuedCount: nextQueuedCount,
|
||||
completedCount: nextCompletedCount,
|
||||
failedCount: nextFailedCount,
|
||||
activeDownloadsCount: nextActiveDownloadsCount,
|
||||
finalizingCount: nextFinalizingCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final downloadQueueLookupProvider = Provider<DownloadQueueLookup>((ref) {
|
||||
return ref.watch(downloadQueueProvider.select((s) => s.lookup));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
|
||||
/// Immutable queue state shared by the notifier and read-only UI consumers.
|
||||
///
|
||||
/// Keeping this model outside the notifier implementation makes queue state
|
||||
/// transitions testable without importing the download orchestration pipeline.
|
||||
class DownloadQueueState {
|
||||
static const Object _noChange = Object();
|
||||
|
||||
final List<DownloadItem> items;
|
||||
final DownloadQueueLookup lookup;
|
||||
final DownloadItem? currentDownload;
|
||||
final bool isProcessing;
|
||||
final bool isPaused;
|
||||
final String outputDir;
|
||||
final String filenameFormat;
|
||||
final String singleFilenameFormat;
|
||||
final String audioQuality;
|
||||
final bool autoFallback;
|
||||
|
||||
const DownloadQueueState({
|
||||
this.items = const [],
|
||||
this.lookup = const DownloadQueueLookup.empty(),
|
||||
this.currentDownload,
|
||||
this.isProcessing = false,
|
||||
this.isPaused = false,
|
||||
this.outputDir = '',
|
||||
this.filenameFormat = '{artist} - {title}',
|
||||
this.singleFilenameFormat = '{title} - {artist}',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
this.autoFallback = true,
|
||||
});
|
||||
|
||||
DownloadQueueState copyWith({
|
||||
List<DownloadItem>? items,
|
||||
DownloadQueueLookup? lookup,
|
||||
Object? currentDownload = _noChange,
|
||||
bool? isProcessing,
|
||||
bool? isPaused,
|
||||
String? outputDir,
|
||||
String? filenameFormat,
|
||||
String? singleFilenameFormat,
|
||||
String? audioQuality,
|
||||
bool? autoFallback,
|
||||
}) {
|
||||
final resolvedItems = items ?? this.items;
|
||||
return DownloadQueueState(
|
||||
items: resolvedItems,
|
||||
lookup:
|
||||
lookup ??
|
||||
(items != null
|
||||
? DownloadQueueLookup.fromItems(resolvedItems)
|
||||
: this.lookup),
|
||||
currentDownload: identical(currentDownload, _noChange)
|
||||
? this.currentDownload
|
||||
: currentDownload as DownloadItem?,
|
||||
isProcessing: isProcessing ?? this.isProcessing,
|
||||
isPaused: isPaused ?? this.isPaused,
|
||||
outputDir: outputDir ?? this.outputDir,
|
||||
filenameFormat: filenameFormat ?? this.filenameFormat,
|
||||
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
|
||||
audioQuality: audioQuality ?? this.audioQuality,
|
||||
autoFallback: autoFallback ?? this.autoFallback,
|
||||
);
|
||||
}
|
||||
|
||||
int get queuedCount => items.isEmpty ? 0 : lookup.queuedCount;
|
||||
int get completedCount => items.isEmpty ? 0 : lookup.completedCount;
|
||||
int get failedCount => items.isEmpty ? 0 : lookup.failedCount;
|
||||
int get activeDownloadsCount =>
|
||||
items.isEmpty ? 0 : lookup.activeDownloadsCount;
|
||||
}
|
||||
|
||||
/// Precomputed queue indexes and counters.
|
||||
///
|
||||
/// The lookup can update in O(changed items) when item identities are stable,
|
||||
/// avoiding full queue scans for every progress event.
|
||||
class DownloadQueueLookup {
|
||||
final Map<String, DownloadItem> byTrackId;
|
||||
final Map<String, DownloadItem> byItemId;
|
||||
final Map<String, int> indexByItemId;
|
||||
final List<String> itemIds;
|
||||
final int queuedCount;
|
||||
final int completedCount;
|
||||
final int failedCount;
|
||||
final int activeDownloadsCount;
|
||||
final int finalizingCount;
|
||||
|
||||
const DownloadQueueLookup.empty()
|
||||
: byTrackId = const {},
|
||||
byItemId = const {},
|
||||
indexByItemId = const {},
|
||||
itemIds = const [],
|
||||
queuedCount = 0,
|
||||
completedCount = 0,
|
||||
failedCount = 0,
|
||||
activeDownloadsCount = 0,
|
||||
finalizingCount = 0;
|
||||
|
||||
DownloadQueueLookup._({
|
||||
required this.byTrackId,
|
||||
required this.byItemId,
|
||||
required this.indexByItemId,
|
||||
required this.itemIds,
|
||||
required this.queuedCount,
|
||||
required this.completedCount,
|
||||
required this.failedCount,
|
||||
required this.activeDownloadsCount,
|
||||
required this.finalizingCount,
|
||||
});
|
||||
|
||||
factory DownloadQueueLookup.fromItems(List<DownloadItem> items) {
|
||||
final byTrackId = <String, DownloadItem>{};
|
||||
final byItemId = <String, DownloadItem>{};
|
||||
final indexByItemId = <String, int>{};
|
||||
final itemIds = <String>[];
|
||||
var queuedCount = 0;
|
||||
var completedCount = 0;
|
||||
var failedCount = 0;
|
||||
var activeDownloadsCount = 0;
|
||||
var finalizingCount = 0;
|
||||
for (var index = 0; index < items.length; index++) {
|
||||
final item = items[index];
|
||||
byTrackId.putIfAbsent(item.track.id, () => item);
|
||||
byItemId[item.id] = item;
|
||||
indexByItemId[item.id] = index;
|
||||
itemIds.add(item.id);
|
||||
if (_countsAsQueued(item.status)) queuedCount++;
|
||||
if (item.status == DownloadStatus.completed) completedCount++;
|
||||
if (item.status == DownloadStatus.failed) failedCount++;
|
||||
if (item.status == DownloadStatus.downloading) activeDownloadsCount++;
|
||||
if (item.status == DownloadStatus.finalizing) finalizingCount++;
|
||||
}
|
||||
return DownloadQueueLookup._(
|
||||
byTrackId: Map.unmodifiable(byTrackId),
|
||||
byItemId: Map.unmodifiable(byItemId),
|
||||
indexByItemId: Map.unmodifiable(indexByItemId),
|
||||
itemIds: List.unmodifiable(itemIds),
|
||||
queuedCount: queuedCount,
|
||||
completedCount: completedCount,
|
||||
failedCount: failedCount,
|
||||
activeDownloadsCount: activeDownloadsCount,
|
||||
finalizingCount: finalizingCount,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _countsAsQueued(DownloadStatus status) =>
|
||||
status == DownloadStatus.queued ||
|
||||
status == DownloadStatus.downloading ||
|
||||
status == DownloadStatus.finalizing;
|
||||
|
||||
static int _deltaForStatus({
|
||||
required DownloadStatus previous,
|
||||
required DownloadStatus next,
|
||||
required bool Function(DownloadStatus status) predicate,
|
||||
}) {
|
||||
final had = predicate(previous);
|
||||
final has = predicate(next);
|
||||
if (had == has) return 0;
|
||||
return has ? 1 : -1;
|
||||
}
|
||||
|
||||
DownloadQueueLookup updatedForIndices({
|
||||
required List<DownloadItem> previousItems,
|
||||
required List<DownloadItem> nextItems,
|
||||
required Iterable<int> changedIndices,
|
||||
}) {
|
||||
if (previousItems.length != nextItems.length ||
|
||||
itemIds.length != nextItems.length ||
|
||||
indexByItemId.length != nextItems.length) {
|
||||
return DownloadQueueLookup.fromItems(nextItems);
|
||||
}
|
||||
|
||||
final normalizedChanged = <int>[];
|
||||
for (final index in changedIndices) {
|
||||
if (index < 0 || index >= nextItems.length) {
|
||||
return DownloadQueueLookup.fromItems(nextItems);
|
||||
}
|
||||
normalizedChanged.add(index);
|
||||
}
|
||||
if (normalizedChanged.isEmpty) return this;
|
||||
|
||||
var nextQueuedCount = queuedCount;
|
||||
var nextCompletedCount = completedCount;
|
||||
var nextFailedCount = failedCount;
|
||||
var nextActiveDownloadsCount = activeDownloadsCount;
|
||||
var nextFinalizingCount = finalizingCount;
|
||||
Map<String, DownloadItem>? nextByItemId;
|
||||
Map<String, DownloadItem>? nextByTrackId;
|
||||
|
||||
for (final index in normalizedChanged) {
|
||||
final previous = previousItems[index];
|
||||
final next = nextItems[index];
|
||||
if (previous.id != next.id || previous.track.id != next.track.id) {
|
||||
return DownloadQueueLookup.fromItems(nextItems);
|
||||
}
|
||||
|
||||
nextByItemId ??= Map<String, DownloadItem>.from(byItemId);
|
||||
nextByItemId[next.id] = next;
|
||||
if (byTrackId[next.track.id]?.id == previous.id) {
|
||||
nextByTrackId ??= Map<String, DownloadItem>.from(byTrackId);
|
||||
nextByTrackId[next.track.id] = next;
|
||||
}
|
||||
nextQueuedCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: _countsAsQueued,
|
||||
);
|
||||
nextCompletedCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: (status) => status == DownloadStatus.completed,
|
||||
);
|
||||
nextFailedCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: (status) => status == DownloadStatus.failed,
|
||||
);
|
||||
nextActiveDownloadsCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: (status) => status == DownloadStatus.downloading,
|
||||
);
|
||||
nextFinalizingCount += _deltaForStatus(
|
||||
previous: previous.status,
|
||||
next: next.status,
|
||||
predicate: (status) => status == DownloadStatus.finalizing,
|
||||
);
|
||||
}
|
||||
|
||||
return DownloadQueueLookup._(
|
||||
byTrackId: nextByTrackId == null
|
||||
? byTrackId
|
||||
: Map.unmodifiable(nextByTrackId),
|
||||
byItemId: nextByItemId == null
|
||||
? byItemId
|
||||
: Map.unmodifiable(nextByItemId),
|
||||
indexByItemId: indexByItemId,
|
||||
itemIds: itemIds,
|
||||
queuedCount: nextQueuedCount,
|
||||
completedCount: nextCompletedCount,
|
||||
failedCount: nextFailedCount,
|
||||
activeDownloadsCount: nextActiveDownloadsCount,
|
||||
finalizingCount: nextFinalizingCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
bool looksLikeUrlOrSpotifyUri(String text) =>
|
||||
text.startsWith('http') || text.startsWith('spotify:');
|
||||
|
||||
class HomeSearchResultBuckets {
|
||||
final List<Track> realTracks;
|
||||
final List<int> realTrackIndexes;
|
||||
final List<Track> albumItems;
|
||||
final List<Track> playlistItems;
|
||||
final List<Track> artistItems;
|
||||
|
||||
const HomeSearchResultBuckets({
|
||||
required this.realTracks,
|
||||
required this.realTrackIndexes,
|
||||
required this.albumItems,
|
||||
required this.playlistItems,
|
||||
required this.artistItems,
|
||||
});
|
||||
}
|
||||
|
||||
HomeSearchResultBuckets bucketHomeSearchResults(List<Track> tracks) {
|
||||
final realTracks = <Track>[];
|
||||
final realTrackIndexes = <int>[];
|
||||
final albumItems = <Track>[];
|
||||
final playlistItems = <Track>[];
|
||||
final artistItems = <Track>[];
|
||||
|
||||
for (var index = 0; index < tracks.length; index++) {
|
||||
final track = tracks[index];
|
||||
if (!track.isCollection) {
|
||||
realTracks.add(track);
|
||||
realTrackIndexes.add(index);
|
||||
}
|
||||
if (track.isAlbumItem) albumItems.add(track);
|
||||
if (track.isPlaylistItem) playlistItems.add(track);
|
||||
if (track.isArtistItem) artistItems.add(track);
|
||||
}
|
||||
|
||||
return HomeSearchResultBuckets(
|
||||
realTracks: realTracks,
|
||||
realTrackIndexes: realTrackIndexes,
|
||||
albumItems: albumItems,
|
||||
playlistItems: playlistItems,
|
||||
artistItems: artistItems,
|
||||
);
|
||||
}
|
||||
|
||||
enum HomeSearchSortOption {
|
||||
defaultOrder,
|
||||
titleAsc,
|
||||
titleDesc,
|
||||
artistAsc,
|
||||
artistDesc,
|
||||
durationAsc,
|
||||
durationDesc,
|
||||
dateAsc,
|
||||
dateDesc,
|
||||
}
|
||||
|
||||
List<T> sortHomeSearchItems<T>({
|
||||
required List<T> items,
|
||||
required HomeSearchSortOption option,
|
||||
required String Function(T) nameOf,
|
||||
required String Function(T) artistOf,
|
||||
required int Function(T) durationOf,
|
||||
required String? Function(T) dateOf,
|
||||
}) {
|
||||
if (option == HomeSearchSortOption.defaultOrder) return items;
|
||||
|
||||
final sorted = List<T>.of(items);
|
||||
switch (option) {
|
||||
case HomeSearchSortOption.defaultOrder:
|
||||
break;
|
||||
case HomeSearchSortOption.titleAsc:
|
||||
sorted.sort(
|
||||
(a, b) => nameOf(a).toLowerCase().compareTo(nameOf(b).toLowerCase()),
|
||||
);
|
||||
case HomeSearchSortOption.titleDesc:
|
||||
sorted.sort(
|
||||
(a, b) => nameOf(b).toLowerCase().compareTo(nameOf(a).toLowerCase()),
|
||||
);
|
||||
case HomeSearchSortOption.artistAsc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
artistOf(a).toLowerCase().compareTo(artistOf(b).toLowerCase()),
|
||||
);
|
||||
case HomeSearchSortOption.artistDesc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
artistOf(b).toLowerCase().compareTo(artistOf(a).toLowerCase()),
|
||||
);
|
||||
case HomeSearchSortOption.durationAsc:
|
||||
sorted.sort((a, b) => durationOf(a).compareTo(durationOf(b)));
|
||||
case HomeSearchSortOption.durationDesc:
|
||||
sorted.sort((a, b) => durationOf(b).compareTo(durationOf(a)));
|
||||
case HomeSearchSortOption.dateAsc:
|
||||
sorted.sort((a, b) => (dateOf(a) ?? '').compareTo(dateOf(b) ?? ''));
|
||||
case HomeSearchSortOption.dateDesc:
|
||||
sorted.sort((a, b) => (dateOf(b) ?? '').compareTo(dateOf(a) ?? ''));
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/// Provider and filter selection rules shared by the home search surface and
|
||||
/// its provider dropdown.
|
||||
class HomeSearchProviderPolicy {
|
||||
const HomeSearchProviderPolicy._();
|
||||
|
||||
static Extension? defaultExtension(List<Extension> extensions) {
|
||||
return extensions
|
||||
.where(
|
||||
(extension) =>
|
||||
extension.enabled &&
|
||||
extension.hasCustomSearch &&
|
||||
extension.searchBehavior?.primary == true,
|
||||
)
|
||||
.firstOrNull ??
|
||||
extensions
|
||||
.where(
|
||||
(extension) => extension.enabled && extension.hasCustomSearch,
|
||||
)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
static String? resolveProvider(
|
||||
String? explicitSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
final explicit = explicitSearchProvider?.trim();
|
||||
if (explicit != null &&
|
||||
explicit.isNotEmpty &&
|
||||
extensions.any(
|
||||
(extension) =>
|
||||
extension.enabled &&
|
||||
extension.hasCustomSearch &&
|
||||
extension.id == explicit,
|
||||
)) {
|
||||
return explicit;
|
||||
}
|
||||
return defaultExtension(extensions)?.id;
|
||||
}
|
||||
|
||||
static bool hasProvider(
|
||||
String? explicitSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
final explicit = explicitSearchProvider?.trim();
|
||||
if (explicit != null &&
|
||||
explicit.isNotEmpty &&
|
||||
extensions.any(
|
||||
(extension) =>
|
||||
extension.enabled &&
|
||||
extension.hasCustomSearch &&
|
||||
extension.id == explicit,
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return extensions.any(
|
||||
(extension) => extension.enabled && extension.hasCustomSearch,
|
||||
);
|
||||
}
|
||||
|
||||
static String? sanitizeFilter(
|
||||
String? filter,
|
||||
String? currentSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
if (filter == null || filter.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final canonicalFilter = canonicalFilterId(filter);
|
||||
if (currentSearchProvider == null || currentSearchProvider.isEmpty) {
|
||||
return switch (canonicalFilter) {
|
||||
'track' || 'artist' || 'album' || 'playlist' => canonicalFilter,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
final extension = extensions
|
||||
.where(
|
||||
(candidate) =>
|
||||
candidate.id == currentSearchProvider && candidate.enabled,
|
||||
)
|
||||
.firstOrNull;
|
||||
final filters = extension?.searchBehavior?.filters;
|
||||
if (filters == null || filters.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return filters
|
||||
.where(
|
||||
(candidate) =>
|
||||
canonicalFilterId(candidate.id) == canonicalFilter ||
|
||||
(candidate.label != null &&
|
||||
canonicalFilterId(candidate.label!) == canonicalFilter) ||
|
||||
(candidate.icon != null &&
|
||||
canonicalFilterId(candidate.icon!) == canonicalFilter),
|
||||
)
|
||||
.firstOrNull
|
||||
?.id;
|
||||
}
|
||||
|
||||
static String canonicalFilterId(String value) {
|
||||
final normalized = value.trim().toLowerCase().replaceAll(
|
||||
RegExp(r'[^a-z0-9]+'),
|
||||
'',
|
||||
);
|
||||
return switch (normalized) {
|
||||
'track' || 'tracks' || 'song' || 'songs' || 'music' => 'track',
|
||||
'artist' || 'artists' => 'artist',
|
||||
'album' || 'albums' => 'album',
|
||||
'playlist' || 'playlists' => 'playlist',
|
||||
_ => normalized,
|
||||
};
|
||||
}
|
||||
|
||||
static String? preferredFilter(
|
||||
String preferredSearchTab,
|
||||
String? currentSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
final preferred = switch (preferredSearchTab) {
|
||||
'track' => 'track',
|
||||
'artist' => 'artist',
|
||||
'album' => 'album',
|
||||
'playlist' => 'playlist',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return sanitizeFilter(preferred, currentSearchProvider, extensions);
|
||||
}
|
||||
|
||||
static String displayFilterSelection(
|
||||
String? selectedSearchFilter,
|
||||
String preferredSearchTab,
|
||||
String? currentSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
if (selectedSearchFilter == 'all') {
|
||||
return 'all';
|
||||
}
|
||||
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
|
||||
return sanitizeFilter(
|
||||
selectedSearchFilter,
|
||||
currentSearchProvider,
|
||||
extensions,
|
||||
) ??
|
||||
'all';
|
||||
}
|
||||
return preferredFilter(
|
||||
preferredSearchTab,
|
||||
currentSearchProvider,
|
||||
extensions,
|
||||
) ??
|
||||
'all';
|
||||
}
|
||||
}
|
||||
+48
-288
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_search_logic.dart';
|
||||
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -42,9 +43,6 @@ import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart';
|
||||
part 'home_tab_helpers.dart';
|
||||
part 'home_tab_widgets.dart';
|
||||
|
||||
bool _looksLikeUrlOrSpotifyUri(String text) =>
|
||||
text.startsWith('http') || text.startsWith('spotify:');
|
||||
|
||||
class HomeTab extends ConsumerStatefulWidget {
|
||||
const HomeTab({super.key});
|
||||
@override
|
||||
@@ -85,11 +83,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
Map<String, (double, double)>? _thumbnailSizesCache;
|
||||
List<Track>? _searchBucketsSourceTracks;
|
||||
_SearchResultBuckets? _searchBucketsCache;
|
||||
_SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder;
|
||||
HomeSearchResultBuckets? _searchBucketsCache;
|
||||
HomeSearchSortOption _searchSortOption = HomeSearchSortOption.defaultOrder;
|
||||
List<Track>? _sortedTracksSource;
|
||||
List<int>? _sortedTrackIndexesSource;
|
||||
_SearchSortOption? _sortedTracksMode;
|
||||
HomeSearchSortOption? _sortedTracksMode;
|
||||
List<Track>? _sortedTracksCache;
|
||||
List<int>? _sortedTrackIndexesCache;
|
||||
|
||||
@@ -252,7 +250,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
String? currentSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
final resolvedSearchProvider = _resolveSearchProvider(
|
||||
final resolvedSearchProvider = HomeSearchProviderPolicy.resolveProvider(
|
||||
currentSearchProvider,
|
||||
extensions,
|
||||
);
|
||||
@@ -295,203 +293,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
];
|
||||
}
|
||||
|
||||
Extension? _defaultSearchExtension(List<Extension> extensions) {
|
||||
return extensions
|
||||
.where(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
ext.hasCustomSearch &&
|
||||
ext.searchBehavior?.primary == true,
|
||||
)
|
||||
.firstOrNull ??
|
||||
extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
String? _resolveSearchProvider(
|
||||
String? explicitSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
final explicit = explicitSearchProvider?.trim();
|
||||
if (explicit != null &&
|
||||
explicit.isNotEmpty &&
|
||||
extensions.any(
|
||||
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
|
||||
)) {
|
||||
return explicit;
|
||||
}
|
||||
return _defaultSearchExtension(extensions)?.id;
|
||||
}
|
||||
|
||||
bool _hasSearchProvider(
|
||||
String? explicitSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
final explicit = explicitSearchProvider?.trim();
|
||||
if (explicit != null && explicit.isNotEmpty) {
|
||||
if (extensions.any(
|
||||
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return extensions.any((ext) => ext.enabled && ext.hasCustomSearch);
|
||||
}
|
||||
|
||||
String? _sanitizeSearchFilterForProvider(
|
||||
String? filter,
|
||||
String? currentSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
if (filter == null || filter.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final canonicalFilter = _canonicalSearchFilterId(filter);
|
||||
|
||||
if (currentSearchProvider == null || currentSearchProvider.isEmpty) {
|
||||
switch (canonicalFilter) {
|
||||
case 'track':
|
||||
case 'artist':
|
||||
case 'album':
|
||||
case 'playlist':
|
||||
return canonicalFilter;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final extension = extensions
|
||||
.where((e) => e.id == currentSearchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
final filters = extension?.searchBehavior?.filters;
|
||||
if (filters == null || filters.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final match = filters
|
||||
.where(
|
||||
(candidate) =>
|
||||
_canonicalSearchFilterId(candidate.id) == canonicalFilter ||
|
||||
(candidate.label != null &&
|
||||
_canonicalSearchFilterId(candidate.label!) ==
|
||||
canonicalFilter) ||
|
||||
(candidate.icon != null &&
|
||||
_canonicalSearchFilterId(candidate.icon!) == canonicalFilter),
|
||||
)
|
||||
.firstOrNull;
|
||||
return match?.id;
|
||||
}
|
||||
|
||||
String _canonicalSearchFilterId(String value) {
|
||||
final normalized = value.trim().toLowerCase().replaceAll(
|
||||
RegExp(r'[^a-z0-9]+'),
|
||||
'',
|
||||
);
|
||||
switch (normalized) {
|
||||
case 'track':
|
||||
case 'tracks':
|
||||
case 'song':
|
||||
case 'songs':
|
||||
case 'music':
|
||||
return 'track';
|
||||
case 'artist':
|
||||
case 'artists':
|
||||
return 'artist';
|
||||
case 'album':
|
||||
case 'albums':
|
||||
return 'album';
|
||||
case 'playlist':
|
||||
case 'playlists':
|
||||
return 'playlist';
|
||||
default:
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
String? _preferredSearchFilter(
|
||||
String preferredSearchTab,
|
||||
String? currentSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
final preferred = switch (preferredSearchTab) {
|
||||
'track' => 'track',
|
||||
'artist' => 'artist',
|
||||
'album' => 'album',
|
||||
'playlist' => 'playlist',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return _sanitizeSearchFilterForProvider(
|
||||
preferred,
|
||||
currentSearchProvider,
|
||||
extensions,
|
||||
);
|
||||
}
|
||||
|
||||
String _displaySearchFilterSelection(
|
||||
String? selectedSearchFilter,
|
||||
String preferredSearchTab,
|
||||
String? currentSearchProvider,
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
if (selectedSearchFilter == 'all') {
|
||||
return 'all';
|
||||
}
|
||||
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
|
||||
return _sanitizeSearchFilterForProvider(
|
||||
selectedSearchFilter,
|
||||
currentSearchProvider,
|
||||
extensions,
|
||||
) ??
|
||||
'all';
|
||||
}
|
||||
return _preferredSearchFilter(
|
||||
preferredSearchTab,
|
||||
currentSearchProvider,
|
||||
extensions,
|
||||
) ??
|
||||
'all';
|
||||
}
|
||||
|
||||
_SearchResultBuckets _getSearchResultBuckets(List<Track> tracks) {
|
||||
HomeSearchResultBuckets _getSearchResultBuckets(List<Track> tracks) {
|
||||
final cached = _searchBucketsCache;
|
||||
if (cached != null && identical(tracks, _searchBucketsSourceTracks)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final realTracks = <Track>[];
|
||||
final realTrackIndexes = <int>[];
|
||||
final albumItems = <Track>[];
|
||||
final playlistItems = <Track>[];
|
||||
final artistItems = <Track>[];
|
||||
|
||||
for (int i = 0; i < tracks.length; i++) {
|
||||
final track = tracks[i];
|
||||
if (!track.isCollection) {
|
||||
realTracks.add(track);
|
||||
realTrackIndexes.add(i);
|
||||
}
|
||||
if (track.isAlbumItem) {
|
||||
albumItems.add(track);
|
||||
}
|
||||
if (track.isPlaylistItem) {
|
||||
playlistItems.add(track);
|
||||
}
|
||||
if (track.isArtistItem) {
|
||||
artistItems.add(track);
|
||||
}
|
||||
}
|
||||
|
||||
final buckets = _SearchResultBuckets(
|
||||
realTracks: realTracks,
|
||||
realTrackIndexes: realTrackIndexes,
|
||||
albumItems: albumItems,
|
||||
playlistItems: playlistItems,
|
||||
artistItems: artistItems,
|
||||
);
|
||||
final buckets = bucketHomeSearchResults(tracks);
|
||||
_searchBucketsSourceTracks = tracks;
|
||||
_searchBucketsCache = buckets;
|
||||
return buckets;
|
||||
@@ -530,7 +338,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final extState = ref.read(extensionProvider);
|
||||
if (!extState.isInitialized && extState.error == null) return true;
|
||||
|
||||
final searchProvider = _resolveSearchProvider(
|
||||
final searchProvider = HomeSearchProviderPolicy.resolveProvider(
|
||||
settings.searchProvider,
|
||||
extState.extensions,
|
||||
);
|
||||
@@ -563,7 +371,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) {
|
||||
if (_looksLikeUrlOrSpotifyUri(text)) return;
|
||||
if (looksLikeUrlOrSpotifyUri(text)) return;
|
||||
|
||||
_liveSearchDebounce?.cancel();
|
||||
_liveSearchDebounce = Timer(_liveSearchDelay, () {
|
||||
@@ -611,26 +419,26 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final searchProvider = _resolveSearchProvider(
|
||||
final searchProvider = HomeSearchProviderPolicy.resolveProvider(
|
||||
settings.searchProvider,
|
||||
extState.extensions,
|
||||
);
|
||||
final storedFilter = ref.read(trackProvider).selectedSearchFilter;
|
||||
final selectedFilter = switch (filterOverride) {
|
||||
'all' => null,
|
||||
final explicit? => _sanitizeSearchFilterForProvider(
|
||||
final explicit? => HomeSearchProviderPolicy.sanitizeFilter(
|
||||
explicit,
|
||||
searchProvider,
|
||||
extState.extensions,
|
||||
),
|
||||
null => switch (storedFilter) {
|
||||
'all' => null,
|
||||
final stored? => _sanitizeSearchFilterForProvider(
|
||||
final stored? => HomeSearchProviderPolicy.sanitizeFilter(
|
||||
stored,
|
||||
searchProvider,
|
||||
extState.extensions,
|
||||
),
|
||||
null => _preferredSearchFilter(
|
||||
null => HomeSearchProviderPolicy.preferredFilter(
|
||||
settings.defaultSearchTab,
|
||||
searchProvider,
|
||||
extState.extensions,
|
||||
@@ -648,7 +456,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
_lastSearchQuery = searchKey;
|
||||
_activeSearchInput = query;
|
||||
_searchSortOption = _SearchSortOption.defaultOrder;
|
||||
_searchSortOption = HomeSearchSortOption.defaultOrder;
|
||||
_invalidateSearchSortCaches();
|
||||
ref.read(trackProvider.notifier).setSearchText(query.trim().isNotEmpty);
|
||||
|
||||
@@ -688,7 +496,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (data?.text != null) {
|
||||
_urlController.text = data!.text!;
|
||||
final text = data.text!.trim();
|
||||
if (_looksLikeUrlOrSpotifyUri(text)) {
|
||||
if (looksLikeUrlOrSpotifyUri(text)) {
|
||||
_fetchMetadata();
|
||||
}
|
||||
}
|
||||
@@ -720,7 +528,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
Future<void> _fetchMetadata() async {
|
||||
final url = _urlController.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
if (_looksLikeUrlOrSpotifyUri(url)) {
|
||||
if (looksLikeUrlOrSpotifyUri(url)) {
|
||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||
final trackState = ref.read(trackProvider);
|
||||
if (trackState.error != null && mounted) {
|
||||
@@ -1200,7 +1008,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
!isLoading;
|
||||
final isSearchProviderLoading =
|
||||
!extensionReadiness.isInitialized && extensionReadiness.error == null;
|
||||
final hasSearchProvider = _hasSearchProvider(
|
||||
final hasSearchProvider = HomeSearchProviderPolicy.hasProvider(
|
||||
explicitSearchProvider,
|
||||
extensions,
|
||||
);
|
||||
@@ -1236,7 +1044,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
final text = _urlController.text.trim();
|
||||
if (text.isEmpty || text.length < _minLiveSearchChars) return;
|
||||
if (_looksLikeUrlOrSpotifyUri(text)) return;
|
||||
if (looksLikeUrlOrSpotifyUri(text)) return;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
@@ -1355,7 +1163,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return SliverToBoxAdapter(
|
||||
child: _buildSearchFilterBar(
|
||||
searchFilters,
|
||||
_displaySearchFilterSelection(
|
||||
HomeSearchProviderPolicy.displayFilterSelection(
|
||||
selectedSearchFilter,
|
||||
defaultSearchTab,
|
||||
currentSearchProvider,
|
||||
@@ -2690,25 +2498,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
}
|
||||
|
||||
String _sortOptionLabel(_SearchSortOption option) {
|
||||
String _sortOptionLabel(HomeSearchSortOption option) {
|
||||
switch (option) {
|
||||
case _SearchSortOption.defaultOrder:
|
||||
case HomeSearchSortOption.defaultOrder:
|
||||
return context.l10n.searchSortDefault;
|
||||
case _SearchSortOption.titleAsc:
|
||||
case HomeSearchSortOption.titleAsc:
|
||||
return context.l10n.searchSortTitleAZ;
|
||||
case _SearchSortOption.titleDesc:
|
||||
case HomeSearchSortOption.titleDesc:
|
||||
return context.l10n.searchSortTitleZA;
|
||||
case _SearchSortOption.artistAsc:
|
||||
case HomeSearchSortOption.artistAsc:
|
||||
return context.l10n.searchSortArtistAZ;
|
||||
case _SearchSortOption.artistDesc:
|
||||
case HomeSearchSortOption.artistDesc:
|
||||
return context.l10n.searchSortArtistZA;
|
||||
case _SearchSortOption.durationAsc:
|
||||
case HomeSearchSortOption.durationAsc:
|
||||
return context.l10n.searchSortDurationShort;
|
||||
case _SearchSortOption.durationDesc:
|
||||
case HomeSearchSortOption.durationDesc:
|
||||
return context.l10n.searchSortDurationLong;
|
||||
case _SearchSortOption.dateAsc:
|
||||
case HomeSearchSortOption.dateAsc:
|
||||
return context.l10n.searchSortDateOldest;
|
||||
case _SearchSortOption.dateDesc:
|
||||
case HomeSearchSortOption.dateDesc:
|
||||
return context.l10n.searchSortDateNewest;
|
||||
}
|
||||
}
|
||||
@@ -2754,7 +2562,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () => setSheetState(
|
||||
() => tempSort = _SearchSortOption.defaultOrder,
|
||||
() => tempSort = HomeSearchSortOption.defaultOrder,
|
||||
),
|
||||
child: Text(context.l10n.libraryFilterReset),
|
||||
),
|
||||
@@ -2764,7 +2572,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _SearchSortOption.values.map((option) {
|
||||
children: HomeSearchSortOption.values.map((option) {
|
||||
return FilterChip(
|
||||
label: Text(_sortOptionLabel(option)),
|
||||
selected: tempSort == option,
|
||||
@@ -2798,63 +2606,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
}
|
||||
|
||||
List<T> _applySortToList<T>(
|
||||
List<T> items,
|
||||
String Function(T) getName,
|
||||
String Function(T) getArtist,
|
||||
int Function(T) getDuration,
|
||||
String? Function(T) getDate,
|
||||
) {
|
||||
if (_searchSortOption == _SearchSortOption.defaultOrder) return items;
|
||||
final sorted = List<T>.of(items);
|
||||
switch (_searchSortOption) {
|
||||
case _SearchSortOption.defaultOrder:
|
||||
break;
|
||||
case _SearchSortOption.titleAsc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
getName(a).toLowerCase().compareTo(getName(b).toLowerCase()),
|
||||
);
|
||||
case _SearchSortOption.titleDesc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
getName(b).toLowerCase().compareTo(getName(a).toLowerCase()),
|
||||
);
|
||||
case _SearchSortOption.artistAsc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
getArtist(a).toLowerCase().compareTo(getArtist(b).toLowerCase()),
|
||||
);
|
||||
case _SearchSortOption.artistDesc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
getArtist(b).toLowerCase().compareTo(getArtist(a).toLowerCase()),
|
||||
);
|
||||
case _SearchSortOption.durationAsc:
|
||||
sorted.sort((a, b) => getDuration(a).compareTo(getDuration(b)));
|
||||
case _SearchSortOption.durationDesc:
|
||||
sorted.sort((a, b) => getDuration(b).compareTo(getDuration(a)));
|
||||
case _SearchSortOption.dateAsc:
|
||||
sorted.sort((a, b) {
|
||||
final da = getDate(a) ?? '';
|
||||
final db = getDate(b) ?? '';
|
||||
return da.compareTo(db);
|
||||
});
|
||||
case _SearchSortOption.dateDesc:
|
||||
sorted.sort((a, b) {
|
||||
final da = getDate(a) ?? '';
|
||||
final db = getDate(b) ?? '';
|
||||
return db.compareTo(da);
|
||||
});
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
({List<Track> tracks, List<int> indexes}) _sortTrackResults(
|
||||
List<Track> tracks,
|
||||
List<int> indexes,
|
||||
) {
|
||||
if (tracks.isEmpty || _searchSortOption == _SearchSortOption.defaultOrder) {
|
||||
if (tracks.isEmpty ||
|
||||
_searchSortOption == HomeSearchSortOption.defaultOrder) {
|
||||
return (tracks: tracks, indexes: indexes);
|
||||
}
|
||||
if (identical(tracks, _sortedTracksSource) &&
|
||||
@@ -2869,12 +2626,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
(i) => (tracks[i], indexes[i]),
|
||||
growable: false,
|
||||
);
|
||||
final sortedPairs = _applySortToList<(Track, int)>(
|
||||
paired,
|
||||
(p) => p.$1.name,
|
||||
(p) => p.$1.artistName,
|
||||
(p) => p.$1.duration,
|
||||
(p) => p.$1.releaseDate,
|
||||
final sortedPairs = sortHomeSearchItems<(Track, int)>(
|
||||
items: paired,
|
||||
option: _searchSortOption,
|
||||
nameOf: (p) => p.$1.name,
|
||||
artistOf: (p) => p.$1.artistName,
|
||||
durationOf: (p) => p.$1.duration,
|
||||
dateOf: (p) => p.$1.releaseDate,
|
||||
);
|
||||
final sortedTracks = sortedPairs.map((p) => p.$1).toList(growable: false);
|
||||
final sortedIndexes = sortedPairs.map((p) => p.$2).toList(growable: false);
|
||||
@@ -3067,17 +2825,19 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
icon: Icon(
|
||||
Icons.swap_vert,
|
||||
size: 18,
|
||||
color: _searchSortOption != _SearchSortOption.defaultOrder
|
||||
color:
|
||||
_searchSortOption != HomeSearchSortOption.defaultOrder
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
label: Text(
|
||||
_searchSortOption != _SearchSortOption.defaultOrder
|
||||
_searchSortOption != HomeSearchSortOption.defaultOrder
|
||||
? _sortOptionLabel(_searchSortOption)
|
||||
: context.l10n.libraryFilterSort,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color:
|
||||
_searchSortOption != _SearchSortOption.defaultOrder
|
||||
_searchSortOption !=
|
||||
HomeSearchSortOption.defaultOrder
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -3234,7 +2994,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
String _getSearchHint() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extState = ref.read(extensionProvider);
|
||||
final searchProvider = _resolveSearchProvider(
|
||||
final searchProvider = HomeSearchProviderPolicy.resolveProvider(
|
||||
settings.searchProvider,
|
||||
extState.extensions,
|
||||
);
|
||||
@@ -3328,7 +3088,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
void _triggerSearchWithFilter(String? filter) {
|
||||
final text = _urlController.text.trim();
|
||||
if (text.isEmpty || text.length < _minLiveSearchChars) return;
|
||||
if (_looksLikeUrlOrSpotifyUri(text)) return;
|
||||
if (looksLikeUrlOrSpotifyUri(text)) return;
|
||||
|
||||
_lastSearchQuery = null;
|
||||
_performSearch(text, filterOverride: filter);
|
||||
@@ -3412,7 +3172,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final text = _urlController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
if (_looksLikeUrlOrSpotifyUri(text)) {
|
||||
if (looksLikeUrlOrSpotifyUri(text)) {
|
||||
_fetchMetadata();
|
||||
_searchFocusNode.unfocus();
|
||||
return;
|
||||
|
||||
@@ -31,34 +31,6 @@ class _CsvImportOptions {
|
||||
});
|
||||
}
|
||||
|
||||
class _SearchResultBuckets {
|
||||
final List<Track> realTracks;
|
||||
final List<int> realTrackIndexes;
|
||||
final List<Track> albumItems;
|
||||
final List<Track> playlistItems;
|
||||
final List<Track> artistItems;
|
||||
|
||||
const _SearchResultBuckets({
|
||||
required this.realTracks,
|
||||
required this.realTrackIndexes,
|
||||
required this.albumItems,
|
||||
required this.playlistItems,
|
||||
required this.artistItems,
|
||||
});
|
||||
}
|
||||
|
||||
enum _SearchSortOption {
|
||||
defaultOrder,
|
||||
titleAsc,
|
||||
titleDesc,
|
||||
artistAsc,
|
||||
artistDesc,
|
||||
durationAsc,
|
||||
durationDesc,
|
||||
dateAsc,
|
||||
dateDesc,
|
||||
}
|
||||
|
||||
const _homeHistoryPreviewLimit = 48;
|
||||
|
||||
class _HomeHistoryPreview {
|
||||
|
||||
@@ -6,20 +6,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
|
||||
const _SearchProviderDropdown({this.onProviderChanged});
|
||||
|
||||
Extension? _defaultSearchExtension(List<Extension> extensions) {
|
||||
return extensions
|
||||
.where(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
ext.hasCustomSearch &&
|
||||
ext.searchBehavior?.primary == true,
|
||||
)
|
||||
.firstOrNull ??
|
||||
extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final rawCurrentProvider = ref.watch(
|
||||
@@ -71,7 +57,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
rawCurrentProvider.isNotEmpty &&
|
||||
searchProviders.any((e) => e.id == rawCurrentProvider)
|
||||
? rawCurrentProvider
|
||||
: _defaultSearchExtension(searchProviders)?.id;
|
||||
: HomeSearchProviderPolicy.defaultExtension(searchProviders)?.id;
|
||||
final currentProvider =
|
||||
resolvedCurrentProvider != null && resolvedCurrentProvider.isNotEmpty
|
||||
? resolvedCurrentProvider
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
|
||||
/// Pure metadata-key conversion used by the different audio containers.
|
||||
///
|
||||
/// Keeping these mappings independent from FFmpeg execution makes the tag
|
||||
/// contract directly testable and prevents container-specific policy from
|
||||
/// being buried in the conversion orchestration.
|
||||
class AudioMetadataMapper {
|
||||
const AudioMetadataMapper._();
|
||||
|
||||
static String? extractLyricsForId3(Map<String, String>? metadata) {
|
||||
if (metadata == null) return null;
|
||||
|
||||
String? fallback;
|
||||
for (final entry in metadata.entries) {
|
||||
final key = _normalizeKey(entry.key);
|
||||
if (key != 'UNSYNCEDLYRICS' && key != 'LYRICS') continue;
|
||||
|
||||
final value = entry.value;
|
||||
if (value.trim().isEmpty) continue;
|
||||
if (key == 'UNSYNCEDLYRICS') return value;
|
||||
fallback ??= value;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// Maps Vorbis-comment keys to the fields consumed by the native WAV/AIFF
|
||||
/// metadata writer.
|
||||
static Map<String, String> vorbisToNativeChunkFields(
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
final fields = <String, String>{};
|
||||
|
||||
void setIndexPair(String numberKey, String totalKey, String value) {
|
||||
final normalized = value.trim();
|
||||
if (normalized.isEmpty || normalized == '0') return;
|
||||
if (normalized.contains('/')) {
|
||||
final parts = normalized.split('/');
|
||||
fields[numberKey] = parts[0].trim();
|
||||
if (parts.length > 1 && parts[1].trim().isNotEmpty) {
|
||||
fields[totalKey] = parts[1].trim();
|
||||
}
|
||||
} else {
|
||||
fields[numberKey] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
for (final entry in metadata.entries) {
|
||||
final key = _normalizeKey(entry.key);
|
||||
final value = entry.value;
|
||||
if (value.trim().isEmpty) continue;
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
fields['title'] = value;
|
||||
case 'ARTIST':
|
||||
fields['artist'] = value;
|
||||
case 'ALBUM':
|
||||
fields['album'] = value;
|
||||
case 'ALBUMARTIST':
|
||||
fields['album_artist'] = value;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
setIndexPair('track_number', 'track_total', value);
|
||||
case 'TRACKTOTAL':
|
||||
case 'TOTALTRACKS':
|
||||
if (value.trim() != '0') fields['track_total'] = value.trim();
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
setIndexPair('disc_number', 'disc_total', value);
|
||||
case 'DISCTOTAL':
|
||||
case 'TOTALDISCS':
|
||||
if (value.trim() != '0') fields['disc_total'] = value.trim();
|
||||
case 'DATE':
|
||||
fields['date'] = value;
|
||||
case 'YEAR':
|
||||
if ((fields['date'] ?? '').isEmpty) fields['date'] = value;
|
||||
case 'ISRC':
|
||||
fields['isrc'] = value;
|
||||
case 'GENRE':
|
||||
fields['genre'] = value;
|
||||
case 'COMPOSER':
|
||||
fields['composer'] = value;
|
||||
case 'ORGANIZATION':
|
||||
case 'LABEL':
|
||||
case 'PUBLISHER':
|
||||
fields['label'] = value;
|
||||
case 'COPYRIGHT':
|
||||
fields['copyright'] = value;
|
||||
case 'COMMENT':
|
||||
case 'DESCRIPTION':
|
||||
fields['comment'] = value;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
fields['lyrics'] = value;
|
||||
case 'REPLAYGAINTRACKGAIN':
|
||||
fields['replaygain_track_gain'] = value;
|
||||
case 'REPLAYGAINTRACKPEAK':
|
||||
fields['replaygain_track_peak'] = value;
|
||||
case 'REPLAYGAINALBUMGAIN':
|
||||
fields['replaygain_album_gain'] = value;
|
||||
case 'REPLAYGAINALBUMPEAK':
|
||||
fields['replaygain_album_peak'] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/// Normalizes arbitrary tag spellings to standard Vorbis comment names.
|
||||
static Map<String, String> normalizeToVorbisComments(
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
final vorbis = <String, String>{};
|
||||
|
||||
for (final entry in metadata.entries) {
|
||||
final key = _normalizeKey(entry.key);
|
||||
final value = entry.value;
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
vorbis['TITLE'] = value;
|
||||
case 'ARTIST':
|
||||
vorbis['ARTIST'] = value;
|
||||
case 'ALBUM':
|
||||
vorbis['ALBUM'] = value;
|
||||
case 'ALBUMARTIST':
|
||||
vorbis['ALBUMARTIST'] = value;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACKNBR':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
if (value != '0') vorbis['TRACKNUMBER'] = value;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
if (value != '0') vorbis['DISCNUMBER'] = value;
|
||||
case 'DATE':
|
||||
vorbis['DATE'] = value;
|
||||
final yearMatch = RegExp(r'^(\d{4})').firstMatch(value);
|
||||
if (yearMatch != null &&
|
||||
(!vorbis.containsKey('YEAR') || vorbis['YEAR']!.isEmpty)) {
|
||||
vorbis['YEAR'] = yearMatch.group(1)!;
|
||||
}
|
||||
case 'YEAR':
|
||||
vorbis['YEAR'] = value;
|
||||
if (!vorbis.containsKey('DATE') || vorbis['DATE']!.isEmpty) {
|
||||
vorbis['DATE'] = value;
|
||||
}
|
||||
case 'GENRE':
|
||||
vorbis['GENRE'] = value;
|
||||
case 'ISRC':
|
||||
vorbis['ISRC'] = value;
|
||||
case 'LABEL':
|
||||
case 'ORGANIZATION':
|
||||
vorbis['ORGANIZATION'] = value;
|
||||
case 'COPYRIGHT':
|
||||
vorbis['COPYRIGHT'] = value;
|
||||
case 'COMPOSER':
|
||||
vorbis['COMPOSER'] = value;
|
||||
case 'COMMENT':
|
||||
vorbis['COMMENT'] = value;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
vorbis['LYRICS'] = value;
|
||||
vorbis['UNSYNCEDLYRICS'] = value;
|
||||
case 'REPLAYGAINTRACKGAIN':
|
||||
vorbis['REPLAYGAIN_TRACK_GAIN'] = value;
|
||||
case 'REPLAYGAINTRACKPEAK':
|
||||
vorbis['REPLAYGAIN_TRACK_PEAK'] = value;
|
||||
case 'REPLAYGAINALBUMGAIN':
|
||||
vorbis['REPLAYGAIN_ALBUM_GAIN'] = value;
|
||||
case 'REPLAYGAINALBUMPEAK':
|
||||
vorbis['REPLAYGAIN_ALBUM_PEAK'] = value;
|
||||
case 'R128TRACKGAIN':
|
||||
vorbis['R128_TRACK_GAIN'] = value;
|
||||
case 'R128ALBUMGAIN':
|
||||
vorbis['R128_ALBUM_GAIN'] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return vorbis;
|
||||
}
|
||||
|
||||
static void appendVorbisMetadataArguments(
|
||||
List<String> arguments,
|
||||
Map<String, String> metadata, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) {
|
||||
for (final entry in buildVorbisMetadataEntries(
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
)) {
|
||||
arguments
|
||||
..add('-metadata')
|
||||
..add('${entry.key}=${entry.value}');
|
||||
}
|
||||
}
|
||||
|
||||
static void appendMappedMetadataArguments(
|
||||
List<String> arguments,
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
for (final entry in metadata.entries) {
|
||||
arguments
|
||||
..add('-metadata')
|
||||
..add('${entry.key}=${entry.value}');
|
||||
}
|
||||
}
|
||||
|
||||
static List<MapEntry<String, String>> buildVorbisMetadataEntries(
|
||||
Map<String, String> metadata, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) {
|
||||
final vorbis = normalizeToVorbisComments(metadata);
|
||||
final entries = <MapEntry<String, String>>[];
|
||||
|
||||
for (final entry in vorbis.entries) {
|
||||
if (entry.key == 'ARTIST' || entry.key == 'ALBUMARTIST') {
|
||||
continue;
|
||||
}
|
||||
entries.add(entry);
|
||||
}
|
||||
|
||||
_appendVorbisArtistEntries(
|
||||
entries,
|
||||
'ARTIST',
|
||||
vorbis['ARTIST'],
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
_appendVorbisArtistEntries(
|
||||
entries,
|
||||
'ALBUMARTIST',
|
||||
vorbis['ALBUMARTIST'],
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// Maps generic metadata keys to M4A/MP4 tag names understood by FFmpeg.
|
||||
static Map<String, String> convertToM4aTags(Map<String, String> metadata) {
|
||||
final m4a = <String, String>{};
|
||||
|
||||
for (final entry in metadata.entries) {
|
||||
final key = _normalizeKey(entry.key);
|
||||
final value = entry.value;
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
m4a['title'] = value;
|
||||
case 'ARTIST':
|
||||
m4a['artist'] = value;
|
||||
case 'ALBUM':
|
||||
m4a['album'] = value;
|
||||
case 'ALBUMARTIST':
|
||||
m4a['album_artist'] = value;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
m4a['track'] = value;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
m4a['disc'] = value;
|
||||
case 'DATE':
|
||||
m4a['date'] = value;
|
||||
case 'YEAR':
|
||||
if (!m4a.containsKey('date') || m4a['date']!.isEmpty) {
|
||||
m4a['date'] = value;
|
||||
}
|
||||
case 'GENRE':
|
||||
m4a['genre'] = value;
|
||||
case 'ISRC':
|
||||
m4a['isrc'] = value;
|
||||
case 'COMPOSER':
|
||||
m4a['composer'] = value;
|
||||
case 'COMMENT':
|
||||
m4a['comment'] = value;
|
||||
case 'COPYRIGHT':
|
||||
m4a['copyright'] = value;
|
||||
case 'LABEL':
|
||||
case 'ORGANIZATION':
|
||||
m4a['organization'] = value;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
m4a['lyrics'] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return m4a;
|
||||
}
|
||||
|
||||
/// Maps generic metadata keys to ID3 names understood by FFmpeg.
|
||||
static Map<String, String> convertToId3Tags(Map<String, String> metadata) {
|
||||
final id3 = <String, String>{};
|
||||
|
||||
for (final entry in metadata.entries) {
|
||||
final originalKey = entry.key.toUpperCase();
|
||||
final key = _normalizeKey(originalKey);
|
||||
final value = entry.value;
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
id3['title'] = value;
|
||||
case 'ARTIST':
|
||||
id3['artist'] = value;
|
||||
case 'ALBUM':
|
||||
id3['album'] = value;
|
||||
case 'ALBUMARTIST':
|
||||
id3['album_artist'] = value;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
if (value != '0') id3['track'] = value;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
if (value != '0') id3['disc'] = value;
|
||||
case 'DATE':
|
||||
id3['date'] = value;
|
||||
case 'YEAR':
|
||||
if (!id3.containsKey('date') || id3['date']!.isEmpty) {
|
||||
id3['date'] = value;
|
||||
}
|
||||
case 'ISRC':
|
||||
id3['TSRC'] = value;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
id3['lyrics'] = value;
|
||||
case 'COMPOSER':
|
||||
id3['composer'] = value;
|
||||
case 'COMMENT':
|
||||
id3['comment'] = value;
|
||||
case 'REPLAYGAINTRACKGAIN':
|
||||
id3['REPLAYGAIN_TRACK_GAIN'] = value;
|
||||
case 'REPLAYGAINTRACKPEAK':
|
||||
id3['REPLAYGAIN_TRACK_PEAK'] = value;
|
||||
case 'REPLAYGAINALBUMGAIN':
|
||||
id3['REPLAYGAIN_ALBUM_GAIN'] = value;
|
||||
case 'REPLAYGAINALBUMPEAK':
|
||||
id3['REPLAYGAIN_ALBUM_PEAK'] = value;
|
||||
default:
|
||||
id3[originalKey.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return id3;
|
||||
}
|
||||
|
||||
static void _appendVorbisArtistEntries(
|
||||
List<MapEntry<String, String>> entries,
|
||||
String key,
|
||||
String? rawValue, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) {
|
||||
if (rawValue == null) return;
|
||||
final value = rawValue.trim();
|
||||
if (value.isEmpty) {
|
||||
// Preserve an empty entry so FFmpeg clears an existing tag.
|
||||
entries.add(MapEntry(key, ''));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldSplitVorbisArtistTags(artistTagMode)) {
|
||||
entries.add(MapEntry(key, value));
|
||||
return;
|
||||
}
|
||||
|
||||
for (final artist in splitArtistTagValues(value)) {
|
||||
entries.add(MapEntry(key, artist));
|
||||
}
|
||||
}
|
||||
|
||||
static String _normalizeKey(String key) =>
|
||||
key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart';
|
||||
|
||||
/// Describes an extension-requested decryption step without coupling callers
|
||||
/// to the FFmpeg execution service.
|
||||
class DownloadDecryptionDescriptor {
|
||||
final String strategy;
|
||||
final String key;
|
||||
final String? iv;
|
||||
final String? inputFormat;
|
||||
final String? outputExtension;
|
||||
final Map<String, dynamic> options;
|
||||
|
||||
const DownloadDecryptionDescriptor({
|
||||
required this.strategy,
|
||||
required this.key,
|
||||
this.iv,
|
||||
this.inputFormat,
|
||||
this.outputExtension,
|
||||
this.options = const {},
|
||||
});
|
||||
|
||||
factory DownloadDecryptionDescriptor.fromJson(Map<String, dynamic> json) {
|
||||
final rawOptions = json['options'];
|
||||
return DownloadDecryptionDescriptor(
|
||||
strategy: (json['strategy'] as String? ?? '').trim(),
|
||||
key: (json['key'] as String? ?? '').trim(),
|
||||
iv: (json['iv'] as String?)?.trim(),
|
||||
inputFormat: (json['input_format'] as String?)?.trim(),
|
||||
outputExtension: (json['output_extension'] as String?)?.trim(),
|
||||
options: rawOptions is Map
|
||||
? Map<String, dynamic>.from(rawOptions)
|
||||
: const {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{'strategy': strategy, 'key': key};
|
||||
if (iv != null && iv!.isNotEmpty) {
|
||||
json['iv'] = iv;
|
||||
}
|
||||
if (inputFormat != null && inputFormat!.isNotEmpty) {
|
||||
json['input_format'] = inputFormat;
|
||||
}
|
||||
if (outputExtension != null && outputExtension!.isNotEmpty) {
|
||||
json['output_extension'] = outputExtension;
|
||||
}
|
||||
if (options.isNotEmpty) {
|
||||
json['options'] = options;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
static DownloadDecryptionDescriptor? fromDownloadResult(
|
||||
Map<String, dynamic> result,
|
||||
) {
|
||||
final rawDecryption = result['decryption'];
|
||||
if (rawDecryption is Map) {
|
||||
final descriptorJson = Map<String, dynamic>.from(rawDecryption);
|
||||
descriptorJson['output_extension'] ??= result['output_extension'];
|
||||
final descriptor = DownloadDecryptionDescriptor.fromJson(descriptorJson);
|
||||
if (descriptor.normalizedStrategy == 'ffmpeg.mov_key' &&
|
||||
descriptor.key.isNotEmpty) {
|
||||
return descriptor;
|
||||
}
|
||||
}
|
||||
|
||||
final legacyKey = (result['decryption_key'] as String?)?.trim() ?? '';
|
||||
if (legacyKey.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DownloadDecryptionDescriptor(
|
||||
strategy: 'ffmpeg.mov_key',
|
||||
key: legacyKey,
|
||||
inputFormat: 'mov',
|
||||
outputExtension: (result['output_extension'] as String?)?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
String get normalizedStrategy {
|
||||
switch (strategy.trim().toLowerCase()) {
|
||||
case '':
|
||||
case 'ffmpeg.mov_key':
|
||||
case 'ffmpeg_mov_key':
|
||||
case 'mov_decryption_key':
|
||||
case 'mp4_decryption_key':
|
||||
case 'ffmpeg.mp4_decryption_key':
|
||||
return 'ffmpeg.mov_key';
|
||||
default:
|
||||
return strategy.trim();
|
||||
}
|
||||
}
|
||||
|
||||
String? get normalizedOutputExtension {
|
||||
final trimmed = (outputExtension ?? '').trim().toLowerCase();
|
||||
if (trimmed.isEmpty) return null;
|
||||
return trimmed.startsWith('.') ? trimmed : '.$trimmed';
|
||||
}
|
||||
}
|
||||
|
||||
class CueSplitTrackInfo {
|
||||
final int number;
|
||||
final String title;
|
||||
final String artist;
|
||||
final String isrc;
|
||||
final String composer;
|
||||
final double startSec;
|
||||
final double endSec;
|
||||
|
||||
CueSplitTrackInfo({
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
this.isrc = '',
|
||||
this.composer = '',
|
||||
required this.startSec,
|
||||
required this.endSec,
|
||||
});
|
||||
|
||||
factory CueSplitTrackInfo.fromJson(Map<String, dynamic> json) {
|
||||
return CueSplitTrackInfo(
|
||||
number: json['number'] as int? ?? 0,
|
||||
title: json['title'] as String? ?? '',
|
||||
artist: json['artist'] as String? ?? '',
|
||||
isrc: json['isrc'] as String? ?? '',
|
||||
composer: json['composer'] as String? ?? '',
|
||||
startSec: (json['start_sec'] as num?)?.toDouble() ?? 0.0,
|
||||
endSec: (json['end_sec'] as num?)?.toDouble() ?? -1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FFmpegResult {
|
||||
final bool success;
|
||||
final int returnCode;
|
||||
final String output;
|
||||
|
||||
FFmpegResult({
|
||||
required this.success,
|
||||
required this.returnCode,
|
||||
required this.output,
|
||||
});
|
||||
}
|
||||
|
||||
class LiveDecryptedStreamResult {
|
||||
final String localUrl;
|
||||
final String format;
|
||||
final FFmpegSession session;
|
||||
|
||||
LiveDecryptedStreamResult({
|
||||
required this.localUrl,
|
||||
required this.format,
|
||||
required this.session,
|
||||
});
|
||||
}
|
||||
|
||||
/// Result of an EBU R128 loudness scan, used to compute ReplayGain tags.
|
||||
class ReplayGainResult {
|
||||
/// Track gain in dB, e.g. "-6.50 dB".
|
||||
final String trackGain;
|
||||
|
||||
/// Track peak as a linear ratio, e.g. "0.988831".
|
||||
final String trackPeak;
|
||||
|
||||
/// Raw integrated loudness in LUFS (needed for album gain computation).
|
||||
final double integratedLufs;
|
||||
|
||||
/// Raw true peak as linear ratio (needed for album peak computation).
|
||||
final double truePeakLinear;
|
||||
|
||||
const ReplayGainResult({
|
||||
required this.trackGain,
|
||||
required this.trackPeak,
|
||||
required this.integratedLufs,
|
||||
required this.truePeakLinear,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ReplayGainResult(trackGain: $trackGain, trackPeak: $trackPeak)';
|
||||
}
|
||||
@@ -9,109 +9,18 @@ import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/audio_metadata_mapper.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_models.dart';
|
||||
import 'package:spotiflac_android/services/id3v23_lyrics.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
export 'package:spotiflac_android/services/ffmpeg_models.dart';
|
||||
|
||||
final _log = AppLogger('FFmpeg');
|
||||
|
||||
class DownloadDecryptionDescriptor {
|
||||
final String strategy;
|
||||
final String key;
|
||||
final String? iv;
|
||||
final String? inputFormat;
|
||||
final String? outputExtension;
|
||||
final Map<String, dynamic> options;
|
||||
|
||||
const DownloadDecryptionDescriptor({
|
||||
required this.strategy,
|
||||
required this.key,
|
||||
this.iv,
|
||||
this.inputFormat,
|
||||
this.outputExtension,
|
||||
this.options = const {},
|
||||
});
|
||||
|
||||
factory DownloadDecryptionDescriptor.fromJson(Map<String, dynamic> json) {
|
||||
final rawOptions = json['options'];
|
||||
return DownloadDecryptionDescriptor(
|
||||
strategy: (json['strategy'] as String? ?? '').trim(),
|
||||
key: (json['key'] as String? ?? '').trim(),
|
||||
iv: (json['iv'] as String?)?.trim(),
|
||||
inputFormat: (json['input_format'] as String?)?.trim(),
|
||||
outputExtension: (json['output_extension'] as String?)?.trim(),
|
||||
options: rawOptions is Map
|
||||
? Map<String, dynamic>.from(rawOptions)
|
||||
: const {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{'strategy': strategy, 'key': key};
|
||||
if (iv != null && iv!.isNotEmpty) {
|
||||
json['iv'] = iv;
|
||||
}
|
||||
if (inputFormat != null && inputFormat!.isNotEmpty) {
|
||||
json['input_format'] = inputFormat;
|
||||
}
|
||||
if (outputExtension != null && outputExtension!.isNotEmpty) {
|
||||
json['output_extension'] = outputExtension;
|
||||
}
|
||||
if (options.isNotEmpty) {
|
||||
json['options'] = options;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
static DownloadDecryptionDescriptor? fromDownloadResult(
|
||||
Map<String, dynamic> result,
|
||||
) {
|
||||
final rawDecryption = result['decryption'];
|
||||
if (rawDecryption is Map) {
|
||||
final descriptorJson = Map<String, dynamic>.from(rawDecryption);
|
||||
descriptorJson['output_extension'] ??= result['output_extension'];
|
||||
final descriptor = DownloadDecryptionDescriptor.fromJson(descriptorJson);
|
||||
if (descriptor.normalizedStrategy == 'ffmpeg.mov_key' &&
|
||||
descriptor.key.isNotEmpty) {
|
||||
return descriptor;
|
||||
}
|
||||
}
|
||||
|
||||
final legacyKey = (result['decryption_key'] as String?)?.trim() ?? '';
|
||||
if (legacyKey.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DownloadDecryptionDescriptor(
|
||||
strategy: 'ffmpeg.mov_key',
|
||||
key: legacyKey,
|
||||
inputFormat: 'mov',
|
||||
outputExtension: (result['output_extension'] as String?)?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
String get normalizedStrategy {
|
||||
switch (strategy.trim().toLowerCase()) {
|
||||
case '':
|
||||
case 'ffmpeg.mov_key':
|
||||
case 'ffmpeg_mov_key':
|
||||
case 'mov_decryption_key':
|
||||
case 'mp4_decryption_key':
|
||||
case 'ffmpeg.mp4_decryption_key':
|
||||
return 'ffmpeg.mov_key';
|
||||
default:
|
||||
return strategy.trim();
|
||||
}
|
||||
}
|
||||
|
||||
String? get normalizedOutputExtension {
|
||||
final trimmed = (outputExtension ?? '').trim().toLowerCase();
|
||||
if (trimmed.isEmpty) return null;
|
||||
return trimmed.startsWith('.') ? trimmed : '.$trimmed';
|
||||
}
|
||||
}
|
||||
|
||||
class _ResolvedLosslessConversionQuality {
|
||||
final int? targetBitDepth;
|
||||
final int? targetSampleRate;
|
||||
@@ -1764,7 +1673,7 @@ class FFmpegService {
|
||||
..add('copy');
|
||||
|
||||
if (metadata != null) {
|
||||
_appendVorbisMetadataToArguments(
|
||||
AudioMetadataMapper.appendVorbisMetadataArguments(
|
||||
arguments,
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
@@ -1810,7 +1719,7 @@ class FFmpegService {
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
|
||||
final lyrics = _extractLyricsForId3(metadata);
|
||||
final lyrics = AudioMetadataMapper.extractLyricsForId3(metadata);
|
||||
|
||||
// Try with -c:a copy first (fastest, preserves original codec)
|
||||
var result = await _runMp3Embed(
|
||||
@@ -1928,7 +1837,10 @@ class FFmpegService {
|
||||
}
|
||||
|
||||
if (metadata != null) {
|
||||
_appendMappedMetadataToArguments(arguments, _convertToId3Tags(metadata));
|
||||
AudioMetadataMapper.appendMappedMetadataArguments(
|
||||
arguments,
|
||||
AudioMetadataMapper.convertToId3Tags(metadata),
|
||||
);
|
||||
}
|
||||
|
||||
arguments
|
||||
@@ -1958,23 +1870,6 @@ class FFmpegService {
|
||||
return mp3Path;
|
||||
}
|
||||
|
||||
static String? _extractLyricsForId3(Map<String, String>? metadata) {
|
||||
if (metadata == null) return null;
|
||||
|
||||
String? fallback;
|
||||
for (final entry in metadata.entries) {
|
||||
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
if (key != 'UNSYNCEDLYRICS' && key != 'LYRICS') continue;
|
||||
|
||||
final value = entry.value;
|
||||
if (value.trim().isEmpty) continue;
|
||||
if (key == 'UNSYNCEDLYRICS') return value;
|
||||
fallback ??= value;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static Future<void> _ensureMp3UnsyncedLyricsFrame(
|
||||
String mp3Path,
|
||||
String lyrics,
|
||||
@@ -1984,7 +1879,7 @@ class FFmpegService {
|
||||
if (!await file.exists()) return;
|
||||
|
||||
final bytes = await file.readAsBytes();
|
||||
final updated = _writeId3v23UnsyncedLyrics(bytes, lyrics);
|
||||
final updated = Id3v23Lyrics.writeUnsyncedLyrics(bytes, lyrics);
|
||||
if (updated == null) {
|
||||
_log.w('Skipping MP3 USLT lyrics frame update: unsupported ID3 tag');
|
||||
return;
|
||||
@@ -1997,165 +1892,6 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
static Uint8List? _writeId3v23UnsyncedLyrics(Uint8List bytes, String lyrics) {
|
||||
final lyricsFrame = _buildId3v23UnsyncedLyricsFrame(lyrics);
|
||||
|
||||
if (!_hasId3Header(bytes)) {
|
||||
final builder = BytesBuilder(copy: false)
|
||||
..add(_buildId3v23Tag(lyricsFrame))
|
||||
..add(bytes);
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
if (bytes.length < 10 || bytes[3] != 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final flags = bytes[5];
|
||||
const unsupportedFlags = 0x80 | 0x40 | 0x20;
|
||||
if ((flags & unsupportedFlags) != 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final tagSize = _readSynchsafeInt(bytes, 6);
|
||||
if (tagSize == null) return null;
|
||||
|
||||
final tagEnd = 10 + tagSize;
|
||||
if (tagEnd < 10 || tagEnd > bytes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final tagPayload = bytes.sublist(10, tagEnd);
|
||||
final preservedFrames = _removeId3v23Frames(tagPayload, {'USLT'});
|
||||
final newPayload = BytesBuilder(copy: false)
|
||||
..add(preservedFrames)
|
||||
..add(lyricsFrame);
|
||||
|
||||
final newTag = _buildId3v23Tag(newPayload.toBytes());
|
||||
final builder = BytesBuilder(copy: false)
|
||||
..add(newTag)
|
||||
..add(bytes.sublist(tagEnd));
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static bool _hasId3Header(Uint8List bytes) {
|
||||
return bytes.length >= 10 &&
|
||||
bytes[0] == 0x49 &&
|
||||
bytes[1] == 0x44 &&
|
||||
bytes[2] == 0x33;
|
||||
}
|
||||
|
||||
static Uint8List _removeId3v23Frames(
|
||||
Uint8List tagPayload,
|
||||
Set<String> frameIds,
|
||||
) {
|
||||
final builder = BytesBuilder(copy: false);
|
||||
var offset = 0;
|
||||
|
||||
while (offset + 10 <= tagPayload.length) {
|
||||
final idBytes = tagPayload.sublist(offset, offset + 4);
|
||||
if (idBytes.every((byte) => byte == 0)) break;
|
||||
|
||||
final frameId = ascii.decode(idBytes, allowInvalid: true);
|
||||
if (!RegExp(r'^[A-Z0-9]{4}$').hasMatch(frameId)) break;
|
||||
|
||||
final frameSize = _readUint32(tagPayload, offset + 4);
|
||||
if (frameSize <= 0 || offset + 10 + frameSize > tagPayload.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!frameIds.contains(frameId)) {
|
||||
builder.add(tagPayload.sublist(offset, offset + 10 + frameSize));
|
||||
}
|
||||
|
||||
offset += 10 + frameSize;
|
||||
}
|
||||
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static Uint8List _buildId3v23Tag(Uint8List payload) {
|
||||
final header = Uint8List(10)
|
||||
..[0] = 0x49
|
||||
..[1] = 0x44
|
||||
..[2] = 0x33
|
||||
..[3] = 3;
|
||||
|
||||
final size = _writeSynchsafeInt(payload.length);
|
||||
header.setRange(6, 10, size);
|
||||
|
||||
final builder = BytesBuilder(copy: false)
|
||||
..add(header)
|
||||
..add(payload);
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static Uint8List _buildId3v23UnsyncedLyricsFrame(String lyrics) {
|
||||
final payload = BytesBuilder(copy: false)
|
||||
..add(const [0x01, 0x65, 0x6e, 0x67])
|
||||
..add(const [0xff, 0xfe, 0x00, 0x00])
|
||||
..add(_utf16LeWithBom(lyrics));
|
||||
|
||||
return _buildId3v23Frame('USLT', payload.toBytes());
|
||||
}
|
||||
|
||||
static Uint8List _buildId3v23Frame(String frameId, Uint8List payload) {
|
||||
final header = Uint8List(10);
|
||||
header.setRange(0, 4, ascii.encode(frameId));
|
||||
final size = _writeUint32(payload.length);
|
||||
header.setRange(4, 8, size);
|
||||
|
||||
final builder = BytesBuilder(copy: false)
|
||||
..add(header)
|
||||
..add(payload);
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static Uint8List _utf16LeWithBom(String value) {
|
||||
final bytes = BytesBuilder(copy: false)..add(const [0xff, 0xfe]);
|
||||
for (final codeUnit in value.codeUnits) {
|
||||
bytes.add([codeUnit & 0xff, (codeUnit >> 8) & 0xff]);
|
||||
}
|
||||
return bytes.toBytes();
|
||||
}
|
||||
|
||||
static int? _readSynchsafeInt(Uint8List bytes, int offset) {
|
||||
if (offset + 4 > bytes.length) return null;
|
||||
|
||||
final b0 = bytes[offset];
|
||||
final b1 = bytes[offset + 1];
|
||||
final b2 = bytes[offset + 2];
|
||||
final b3 = bytes[offset + 3];
|
||||
if ((b0 | b1 | b2 | b3) & 0x80 != 0) return null;
|
||||
|
||||
return (b0 << 21) | (b1 << 14) | (b2 << 7) | b3;
|
||||
}
|
||||
|
||||
static Uint8List _writeSynchsafeInt(int value) {
|
||||
return Uint8List.fromList([
|
||||
(value >> 21) & 0x7f,
|
||||
(value >> 14) & 0x7f,
|
||||
(value >> 7) & 0x7f,
|
||||
value & 0x7f,
|
||||
]);
|
||||
}
|
||||
|
||||
static int _readUint32(Uint8List bytes, int offset) {
|
||||
return (bytes[offset] << 24) |
|
||||
(bytes[offset + 1] << 16) |
|
||||
(bytes[offset + 2] << 8) |
|
||||
bytes[offset + 3];
|
||||
}
|
||||
|
||||
static Uint8List _writeUint32(int value) {
|
||||
return Uint8List.fromList([
|
||||
(value >> 24) & 0xff,
|
||||
(value >> 16) & 0xff,
|
||||
(value >> 8) & 0xff,
|
||||
value & 0xff,
|
||||
]);
|
||||
}
|
||||
|
||||
static Future<String?> embedMetadataToOpus({
|
||||
required String opusPath,
|
||||
String? coverPath,
|
||||
@@ -2183,7 +1919,7 @@ class FFmpegService {
|
||||
];
|
||||
|
||||
if (metadata != null) {
|
||||
_appendVorbisMetadataToArguments(
|
||||
AudioMetadataMapper.appendVorbisMetadataArguments(
|
||||
arguments,
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
@@ -2290,9 +2026,9 @@ class FFmpegService {
|
||||
}
|
||||
|
||||
if (metadata != null) {
|
||||
_appendMappedMetadataToArguments(
|
||||
AudioMetadataMapper.appendMappedMetadataArguments(
|
||||
arguments,
|
||||
_convertToM4aTags(metadata),
|
||||
AudioMetadataMapper.convertToM4aTags(metadata),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2668,9 +2404,12 @@ class FFmpegService {
|
||||
..add(isAlac ? '-1' : '0');
|
||||
|
||||
if (isAlac) {
|
||||
_appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
|
||||
AudioMetadataMapper.appendMappedMetadataArguments(
|
||||
arguments,
|
||||
AudioMetadataMapper.convertToM4aTags(metadata),
|
||||
);
|
||||
} else {
|
||||
_appendVorbisMetadataToArguments(
|
||||
AudioMetadataMapper.appendVorbisMetadataArguments(
|
||||
arguments,
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
@@ -2796,7 +2535,9 @@ class FFmpegService {
|
||||
Map<String, String> vorbisMetadata,
|
||||
String? coverPath,
|
||||
) async {
|
||||
final fields = _vorbisToNativeChunkFields(vorbisMetadata);
|
||||
final fields = AudioMetadataMapper.vorbisToNativeChunkFields(
|
||||
vorbisMetadata,
|
||||
);
|
||||
if (coverPath != null && coverPath.trim().isNotEmpty) {
|
||||
fields['cover_path'] = coverPath;
|
||||
}
|
||||
@@ -2810,293 +2551,6 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps Vorbis-comment style metadata (UPPERCASE keys) to the lowercase field
|
||||
/// names consumed by the Go EditFileMetadata native WAV/AIFF tag writer.
|
||||
static Map<String, String> _vorbisToNativeChunkFields(
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
final out = <String, String>{};
|
||||
|
||||
void setIndexPair(String numberKey, String totalKey, String value) {
|
||||
final v = value.trim();
|
||||
if (v.isEmpty || v == '0') return;
|
||||
if (v.contains('/')) {
|
||||
final parts = v.split('/');
|
||||
out[numberKey] = parts[0].trim();
|
||||
if (parts.length > 1 && parts[1].trim().isNotEmpty) {
|
||||
out[totalKey] = parts[1].trim();
|
||||
}
|
||||
} else {
|
||||
out[numberKey] = v;
|
||||
}
|
||||
}
|
||||
|
||||
for (final entry in metadata.entries) {
|
||||
final normalizedKey = entry.key.toUpperCase().replaceAll(
|
||||
RegExp(r'[^A-Z0-9]'),
|
||||
'',
|
||||
);
|
||||
final value = entry.value;
|
||||
if (value.trim().isEmpty) continue;
|
||||
|
||||
switch (normalizedKey) {
|
||||
case 'TITLE':
|
||||
out['title'] = value;
|
||||
break;
|
||||
case 'ARTIST':
|
||||
out['artist'] = value;
|
||||
break;
|
||||
case 'ALBUM':
|
||||
out['album'] = value;
|
||||
break;
|
||||
case 'ALBUMARTIST':
|
||||
out['album_artist'] = value;
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
setIndexPair('track_number', 'track_total', value);
|
||||
break;
|
||||
case 'TRACKTOTAL':
|
||||
case 'TOTALTRACKS':
|
||||
if (value.trim() != '0') out['track_total'] = value.trim();
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
setIndexPair('disc_number', 'disc_total', value);
|
||||
break;
|
||||
case 'DISCTOTAL':
|
||||
case 'TOTALDISCS':
|
||||
if (value.trim() != '0') out['disc_total'] = value.trim();
|
||||
break;
|
||||
case 'DATE':
|
||||
out['date'] = value;
|
||||
break;
|
||||
case 'YEAR':
|
||||
if ((out['date'] ?? '').isEmpty) out['date'] = value;
|
||||
break;
|
||||
case 'ISRC':
|
||||
out['isrc'] = value;
|
||||
break;
|
||||
case 'GENRE':
|
||||
out['genre'] = value;
|
||||
break;
|
||||
case 'COMPOSER':
|
||||
out['composer'] = value;
|
||||
break;
|
||||
case 'ORGANIZATION':
|
||||
case 'LABEL':
|
||||
case 'PUBLISHER':
|
||||
out['label'] = value;
|
||||
break;
|
||||
case 'COPYRIGHT':
|
||||
out['copyright'] = value;
|
||||
break;
|
||||
case 'COMMENT':
|
||||
case 'DESCRIPTION':
|
||||
out['comment'] = value;
|
||||
break;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
out['lyrics'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINTRACKGAIN':
|
||||
out['replaygain_track_gain'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINTRACKPEAK':
|
||||
out['replaygain_track_peak'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMGAIN':
|
||||
out['replaygain_album_gain'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMPEAK':
|
||||
out['replaygain_album_peak'] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Normalize metadata keys to standard Vorbis comment names, filtering out
|
||||
/// technical fields (bit_depth, sample_rate, duration, etc.).
|
||||
static Map<String, String> _normalizeToVorbisComments(
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
final vorbis = <String, String>{};
|
||||
|
||||
for (final entry in metadata.entries) {
|
||||
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final value = entry.value;
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
vorbis['TITLE'] = value;
|
||||
break;
|
||||
case 'ARTIST':
|
||||
vorbis['ARTIST'] = value;
|
||||
break;
|
||||
case 'ALBUM':
|
||||
vorbis['ALBUM'] = value;
|
||||
break;
|
||||
case 'ALBUMARTIST':
|
||||
vorbis['ALBUMARTIST'] = value;
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACKNBR':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
if (value != '0') vorbis['TRACKNUMBER'] = value;
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
if (value != '0') vorbis['DISCNUMBER'] = value;
|
||||
break;
|
||||
case 'DATE':
|
||||
vorbis['DATE'] = value;
|
||||
final yearMatch = RegExp(r'^(\d{4})').firstMatch(value);
|
||||
if (yearMatch != null &&
|
||||
(!vorbis.containsKey('YEAR') || vorbis['YEAR']!.isEmpty)) {
|
||||
vorbis['YEAR'] = yearMatch.group(1)!;
|
||||
}
|
||||
break;
|
||||
case 'YEAR':
|
||||
vorbis['YEAR'] = value;
|
||||
if (!vorbis.containsKey('DATE') || vorbis['DATE']!.isEmpty) {
|
||||
vorbis['DATE'] = value;
|
||||
}
|
||||
break;
|
||||
case 'GENRE':
|
||||
vorbis['GENRE'] = value;
|
||||
break;
|
||||
case 'ISRC':
|
||||
vorbis['ISRC'] = value;
|
||||
break;
|
||||
case 'LABEL':
|
||||
case 'ORGANIZATION':
|
||||
vorbis['ORGANIZATION'] = value;
|
||||
break;
|
||||
case 'COPYRIGHT':
|
||||
vorbis['COPYRIGHT'] = value;
|
||||
break;
|
||||
case 'COMPOSER':
|
||||
vorbis['COMPOSER'] = value;
|
||||
break;
|
||||
case 'COMMENT':
|
||||
vorbis['COMMENT'] = value;
|
||||
break;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
vorbis['LYRICS'] = value;
|
||||
vorbis['UNSYNCEDLYRICS'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINTRACKGAIN':
|
||||
vorbis['REPLAYGAIN_TRACK_GAIN'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINTRACKPEAK':
|
||||
vorbis['REPLAYGAIN_TRACK_PEAK'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMGAIN':
|
||||
vorbis['REPLAYGAIN_ALBUM_GAIN'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMPEAK':
|
||||
vorbis['REPLAYGAIN_ALBUM_PEAK'] = value;
|
||||
break;
|
||||
case 'R128TRACKGAIN':
|
||||
vorbis['R128_TRACK_GAIN'] = value;
|
||||
break;
|
||||
case 'R128ALBUMGAIN':
|
||||
vorbis['R128_ALBUM_GAIN'] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return vorbis;
|
||||
}
|
||||
|
||||
static void _appendVorbisMetadataToArguments(
|
||||
List<String> arguments,
|
||||
Map<String, String> metadata, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) {
|
||||
for (final entry in _buildVorbisMetadataEntries(
|
||||
metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
)) {
|
||||
arguments
|
||||
..add('-metadata')
|
||||
..add('${entry.key}=${entry.value}');
|
||||
}
|
||||
}
|
||||
|
||||
static void _appendMappedMetadataToArguments(
|
||||
List<String> arguments,
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
for (final entry in metadata.entries) {
|
||||
arguments
|
||||
..add('-metadata')
|
||||
..add('${entry.key}=${entry.value}');
|
||||
}
|
||||
}
|
||||
|
||||
static List<MapEntry<String, String>> _buildVorbisMetadataEntries(
|
||||
Map<String, String> metadata, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) {
|
||||
final vorbis = _normalizeToVorbisComments(metadata);
|
||||
final entries = <MapEntry<String, String>>[];
|
||||
|
||||
for (final entry in vorbis.entries) {
|
||||
if (entry.key == 'ARTIST' || entry.key == 'ALBUMARTIST') {
|
||||
continue;
|
||||
}
|
||||
entries.add(entry);
|
||||
}
|
||||
|
||||
_appendVorbisArtistEntries(
|
||||
entries,
|
||||
'ARTIST',
|
||||
vorbis['ARTIST'],
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
_appendVorbisArtistEntries(
|
||||
entries,
|
||||
'ALBUMARTIST',
|
||||
vorbis['ALBUMARTIST'],
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
static void _appendVorbisArtistEntries(
|
||||
List<MapEntry<String, String>> entries,
|
||||
String key,
|
||||
String? rawValue, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) {
|
||||
if (rawValue == null) return;
|
||||
final value = rawValue.trim();
|
||||
if (value.isEmpty) {
|
||||
// Emit an empty entry so that with preserveMetadata the old tag is
|
||||
// overridden (cleared) by FFmpeg's `-metadata key=""`.
|
||||
entries.add(MapEntry(key, ''));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldSplitVorbisArtistTags(artistTagMode)) {
|
||||
entries.add(MapEntry(key, value));
|
||||
return;
|
||||
}
|
||||
|
||||
for (final artist in splitArtistTagValues(value)) {
|
||||
entries.add(MapEntry(key, artist));
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes ISRC and label into an M4A/MP4 file natively (iTunes freeform
|
||||
/// atoms), since FFmpeg's MP4 muxer drops these keys. Only keys present in
|
||||
/// [metadata] are written; an empty value clears the corresponding tag.
|
||||
@@ -3125,153 +2579,6 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
|
||||
static Map<String, String> _convertToM4aTags(Map<String, String> metadata) {
|
||||
final m4aMap = <String, String>{};
|
||||
|
||||
for (final entry in metadata.entries) {
|
||||
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final value = entry.value;
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
m4aMap['title'] = value;
|
||||
break;
|
||||
case 'ARTIST':
|
||||
m4aMap['artist'] = value;
|
||||
break;
|
||||
case 'ALBUM':
|
||||
m4aMap['album'] = value;
|
||||
break;
|
||||
case 'ALBUMARTIST':
|
||||
m4aMap['album_artist'] = value;
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
m4aMap['track'] = value;
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
m4aMap['disc'] = value;
|
||||
break;
|
||||
case 'DATE':
|
||||
m4aMap['date'] = value;
|
||||
break;
|
||||
case 'YEAR':
|
||||
if (!m4aMap.containsKey('date') || m4aMap['date']!.isEmpty) {
|
||||
m4aMap['date'] = value;
|
||||
}
|
||||
break;
|
||||
case 'GENRE':
|
||||
m4aMap['genre'] = value;
|
||||
break;
|
||||
case 'ISRC':
|
||||
m4aMap['isrc'] = value;
|
||||
break;
|
||||
case 'COMPOSER':
|
||||
m4aMap['composer'] = value;
|
||||
break;
|
||||
case 'COMMENT':
|
||||
m4aMap['comment'] = value;
|
||||
break;
|
||||
case 'COPYRIGHT':
|
||||
m4aMap['copyright'] = value;
|
||||
break;
|
||||
case 'LABEL':
|
||||
case 'ORGANIZATION':
|
||||
m4aMap['organization'] = value;
|
||||
break;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
m4aMap['lyrics'] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return m4aMap;
|
||||
}
|
||||
|
||||
static Map<String, String> _convertToId3Tags(
|
||||
Map<String, String> vorbisMetadata,
|
||||
) {
|
||||
final id3Map = <String, String>{};
|
||||
|
||||
for (final entry in vorbisMetadata.entries) {
|
||||
final key = entry.key.toUpperCase();
|
||||
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final value = entry.value;
|
||||
|
||||
switch (normalizedKey) {
|
||||
case 'TITLE':
|
||||
id3Map['title'] = value;
|
||||
break;
|
||||
case 'ARTIST':
|
||||
id3Map['artist'] = value;
|
||||
break;
|
||||
case 'ALBUM':
|
||||
id3Map['album'] = value;
|
||||
break;
|
||||
case 'ALBUMARTIST':
|
||||
id3Map['album_artist'] = value;
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
if (value != '0') {
|
||||
id3Map['track'] = value;
|
||||
}
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
if (value != '0') {
|
||||
id3Map['disc'] = value;
|
||||
}
|
||||
break;
|
||||
case 'DATE':
|
||||
id3Map['date'] = value;
|
||||
break;
|
||||
case 'YEAR':
|
||||
if (!id3Map.containsKey('date') || id3Map['date']!.isEmpty) {
|
||||
id3Map['date'] = value;
|
||||
}
|
||||
break;
|
||||
case 'ISRC':
|
||||
id3Map['TSRC'] = value;
|
||||
break;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
id3Map['lyrics'] = value;
|
||||
break;
|
||||
case 'COMPOSER':
|
||||
id3Map['composer'] = value;
|
||||
break;
|
||||
case 'COMMENT':
|
||||
id3Map['comment'] = value;
|
||||
break;
|
||||
// FFmpeg writes these as TXXX frames automatically with uppercase keys
|
||||
case 'REPLAYGAINTRACKGAIN':
|
||||
id3Map['REPLAYGAIN_TRACK_GAIN'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINTRACKPEAK':
|
||||
id3Map['REPLAYGAIN_TRACK_PEAK'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMGAIN':
|
||||
id3Map['REPLAYGAIN_ALBUM_GAIN'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMPEAK':
|
||||
id3Map['REPLAYGAIN_ALBUM_PEAK'] = value;
|
||||
break;
|
||||
default:
|
||||
id3Map[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return id3Map;
|
||||
}
|
||||
|
||||
/// Split a CUE+audio file into individual track files using FFmpeg.
|
||||
/// Each track is extracted with `-c copy` (no re-encoding) and metadata is embedded.
|
||||
/// [audioPath] is the source audio file (FLAC, WAV, etc.)
|
||||
@@ -3368,7 +2675,7 @@ class FFmpegService {
|
||||
if (track.isrc.isNotEmpty) addMeta('ISRC', track.isrc);
|
||||
if (track.composer.isNotEmpty) addMeta('COMPOSER', track.composer);
|
||||
|
||||
_appendMappedMetadataToArguments(arguments, cueMetadata);
|
||||
AudioMetadataMapper.appendMappedMetadataArguments(arguments, cueMetadata);
|
||||
arguments
|
||||
..add(outputPath)
|
||||
..add('-y');
|
||||
@@ -3404,86 +2711,4 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
class CueSplitTrackInfo {
|
||||
final int number;
|
||||
final String title;
|
||||
final String artist;
|
||||
final String isrc;
|
||||
final String composer;
|
||||
final double startSec;
|
||||
final double endSec;
|
||||
|
||||
CueSplitTrackInfo({
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
this.isrc = '',
|
||||
this.composer = '',
|
||||
required this.startSec,
|
||||
required this.endSec,
|
||||
});
|
||||
|
||||
factory CueSplitTrackInfo.fromJson(Map<String, dynamic> json) {
|
||||
return CueSplitTrackInfo(
|
||||
number: json['number'] as int? ?? 0,
|
||||
title: json['title'] as String? ?? '',
|
||||
artist: json['artist'] as String? ?? '',
|
||||
isrc: json['isrc'] as String? ?? '',
|
||||
composer: json['composer'] as String? ?? '',
|
||||
startSec: (json['start_sec'] as num?)?.toDouble() ?? 0.0,
|
||||
endSec: (json['end_sec'] as num?)?.toDouble() ?? -1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FFmpegResult {
|
||||
final bool success;
|
||||
final int returnCode;
|
||||
final String output;
|
||||
|
||||
FFmpegResult({
|
||||
required this.success,
|
||||
required this.returnCode,
|
||||
required this.output,
|
||||
});
|
||||
}
|
||||
|
||||
enum _LiveDecryptFormat { flac, m4a }
|
||||
|
||||
class LiveDecryptedStreamResult {
|
||||
final String localUrl;
|
||||
final String format;
|
||||
final FFmpegSession session;
|
||||
|
||||
LiveDecryptedStreamResult({
|
||||
required this.localUrl,
|
||||
required this.format,
|
||||
required this.session,
|
||||
});
|
||||
}
|
||||
|
||||
/// Result of an EBU R128 loudness scan, used to compute ReplayGain tags.
|
||||
class ReplayGainResult {
|
||||
/// Track gain in dB, e.g. "-6.50 dB"
|
||||
final String trackGain;
|
||||
|
||||
/// Track peak as a linear ratio, e.g. "0.988831"
|
||||
final String trackPeak;
|
||||
|
||||
/// Raw integrated loudness in LUFS (needed for album gain computation)
|
||||
final double integratedLufs;
|
||||
|
||||
/// Raw true peak as linear ratio (needed for album peak computation)
|
||||
final double truePeakLinear;
|
||||
|
||||
const ReplayGainResult({
|
||||
required this.trackGain,
|
||||
required this.trackPeak,
|
||||
required this.integratedLufs,
|
||||
required this.truePeakLinear,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ReplayGainResult(trackGain: $trackGain, trackPeak: $trackPeak)';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Minimal ID3v2.3 USLT writer used after FFmpeg metadata embedding.
|
||||
///
|
||||
/// It intentionally supports only plain ID3v2.3 tags without extended,
|
||||
/// compressed, or unsynchronized headers. Unsupported tags are left unchanged.
|
||||
class Id3v23Lyrics {
|
||||
const Id3v23Lyrics._();
|
||||
|
||||
static Uint8List? writeUnsyncedLyrics(Uint8List bytes, String lyrics) {
|
||||
final lyricsFrame = _buildUnsyncedLyricsFrame(lyrics);
|
||||
|
||||
if (!_hasId3Header(bytes)) {
|
||||
final builder = BytesBuilder(copy: false)
|
||||
..add(_buildTag(lyricsFrame))
|
||||
..add(bytes);
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
if (bytes.length < 10 || bytes[3] != 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final flags = bytes[5];
|
||||
const unsupportedFlags = 0x80 | 0x40 | 0x20;
|
||||
if ((flags & unsupportedFlags) != 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final tagSize = _readSynchsafeInt(bytes, 6);
|
||||
if (tagSize == null) return null;
|
||||
|
||||
final tagEnd = 10 + tagSize;
|
||||
if (tagEnd < 10 || tagEnd > bytes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final tagPayload = bytes.sublist(10, tagEnd);
|
||||
final preservedFrames = _removeFrames(tagPayload, {'USLT'});
|
||||
final newPayload = BytesBuilder(copy: false)
|
||||
..add(preservedFrames)
|
||||
..add(lyricsFrame);
|
||||
|
||||
final newTag = _buildTag(newPayload.toBytes());
|
||||
final builder = BytesBuilder(copy: false)
|
||||
..add(newTag)
|
||||
..add(bytes.sublist(tagEnd));
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static bool _hasId3Header(Uint8List bytes) {
|
||||
return bytes.length >= 10 &&
|
||||
bytes[0] == 0x49 &&
|
||||
bytes[1] == 0x44 &&
|
||||
bytes[2] == 0x33;
|
||||
}
|
||||
|
||||
static Uint8List _removeFrames(Uint8List tagPayload, Set<String> frameIds) {
|
||||
final builder = BytesBuilder(copy: false);
|
||||
var offset = 0;
|
||||
|
||||
while (offset + 10 <= tagPayload.length) {
|
||||
final idBytes = tagPayload.sublist(offset, offset + 4);
|
||||
if (idBytes.every((byte) => byte == 0)) break;
|
||||
|
||||
final frameId = ascii.decode(idBytes, allowInvalid: true);
|
||||
if (!RegExp(r'^[A-Z0-9]{4}$').hasMatch(frameId)) break;
|
||||
|
||||
final frameSize = _readUint32(tagPayload, offset + 4);
|
||||
if (frameSize <= 0 || offset + 10 + frameSize > tagPayload.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!frameIds.contains(frameId)) {
|
||||
builder.add(tagPayload.sublist(offset, offset + 10 + frameSize));
|
||||
}
|
||||
|
||||
offset += 10 + frameSize;
|
||||
}
|
||||
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static Uint8List _buildTag(Uint8List payload) {
|
||||
final header = Uint8List(10)
|
||||
..[0] = 0x49
|
||||
..[1] = 0x44
|
||||
..[2] = 0x33
|
||||
..[3] = 3;
|
||||
|
||||
final size = _writeSynchsafeInt(payload.length);
|
||||
header.setRange(6, 10, size);
|
||||
|
||||
final builder = BytesBuilder(copy: false)
|
||||
..add(header)
|
||||
..add(payload);
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static Uint8List _buildUnsyncedLyricsFrame(String lyrics) {
|
||||
final payload = BytesBuilder(copy: false)
|
||||
..add(const [0x01, 0x65, 0x6e, 0x67])
|
||||
..add(const [0xff, 0xfe, 0x00, 0x00])
|
||||
..add(_utf16LeWithBom(lyrics));
|
||||
|
||||
return _buildFrame('USLT', payload.toBytes());
|
||||
}
|
||||
|
||||
static Uint8List _buildFrame(String frameId, Uint8List payload) {
|
||||
final header = Uint8List(10);
|
||||
header.setRange(0, 4, ascii.encode(frameId));
|
||||
final size = _writeUint32(payload.length);
|
||||
header.setRange(4, 8, size);
|
||||
|
||||
final builder = BytesBuilder(copy: false)
|
||||
..add(header)
|
||||
..add(payload);
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static Uint8List _utf16LeWithBom(String value) {
|
||||
final bytes = BytesBuilder(copy: false)..add(const [0xff, 0xfe]);
|
||||
for (final codeUnit in value.codeUnits) {
|
||||
bytes.add([codeUnit & 0xff, (codeUnit >> 8) & 0xff]);
|
||||
}
|
||||
return bytes.toBytes();
|
||||
}
|
||||
|
||||
static int? _readSynchsafeInt(Uint8List bytes, int offset) {
|
||||
if (offset + 4 > bytes.length) return null;
|
||||
|
||||
final b0 = bytes[offset];
|
||||
final b1 = bytes[offset + 1];
|
||||
final b2 = bytes[offset + 2];
|
||||
final b3 = bytes[offset + 3];
|
||||
if ((b0 | b1 | b2 | b3) & 0x80 != 0) return null;
|
||||
|
||||
return (b0 << 21) | (b1 << 14) | (b2 << 7) | b3;
|
||||
}
|
||||
|
||||
static Uint8List _writeSynchsafeInt(int value) {
|
||||
return Uint8List.fromList([
|
||||
(value >> 21) & 0x7f,
|
||||
(value >> 14) & 0x7f,
|
||||
(value >> 7) & 0x7f,
|
||||
value & 0x7f,
|
||||
]);
|
||||
}
|
||||
|
||||
static int _readUint32(Uint8List bytes, int offset) {
|
||||
return (bytes[offset] << 24) |
|
||||
(bytes[offset + 1] << 16) |
|
||||
(bytes[offset + 2] << 8) |
|
||||
bytes[offset + 3];
|
||||
}
|
||||
|
||||
static Uint8List _writeUint32(int value) {
|
||||
return Uint8List.fromList([
|
||||
(value >> 24) & 0xff,
|
||||
(value >> 16) & 0xff,
|
||||
(value >> 8) & 0xff,
|
||||
value & 0xff,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_state.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/screens/home_search_logic.dart';
|
||||
import 'package:spotiflac_android/services/audio_metadata_mapper.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_models.dart';
|
||||
import 'package:spotiflac_android/services/id3v23_lyrics.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
|
||||
void main() {
|
||||
Track track({
|
||||
required String id,
|
||||
required String name,
|
||||
String artist = 'Artist',
|
||||
int duration = 180,
|
||||
String? releaseDate,
|
||||
String? itemType,
|
||||
}) {
|
||||
return Track(
|
||||
id: id,
|
||||
name: name,
|
||||
artistName: artist,
|
||||
albumName: 'Album',
|
||||
duration: duration,
|
||||
releaseDate: releaseDate,
|
||||
itemType: itemType,
|
||||
);
|
||||
}
|
||||
|
||||
group('home search logic', () {
|
||||
test('separates playable tracks from collection result types', () {
|
||||
final results = [
|
||||
track(id: 'track', name: 'Track'),
|
||||
track(id: 'album', name: 'Album', itemType: 'album'),
|
||||
track(id: 'playlist', name: 'Playlist', itemType: 'playlist'),
|
||||
track(id: 'artist', name: 'Artist', itemType: 'artist'),
|
||||
];
|
||||
|
||||
final buckets = bucketHomeSearchResults(results);
|
||||
|
||||
expect(buckets.realTracks.map((item) => item.id), ['track']);
|
||||
expect(buckets.realTrackIndexes, [0]);
|
||||
expect(buckets.albumItems.single.id, 'album');
|
||||
expect(buckets.playlistItems.single.id, 'playlist');
|
||||
expect(buckets.artistItems.single.id, 'artist');
|
||||
});
|
||||
|
||||
test('sorts a copy and preserves default result identity', () {
|
||||
final results = [
|
||||
track(
|
||||
id: 'later',
|
||||
name: 'Zulu',
|
||||
artist: 'Beta',
|
||||
duration: 220,
|
||||
releaseDate: '2026-02-01',
|
||||
),
|
||||
track(
|
||||
id: 'earlier',
|
||||
name: 'Alpha',
|
||||
artist: 'Alpha',
|
||||
duration: 120,
|
||||
releaseDate: '2025-01-01',
|
||||
),
|
||||
];
|
||||
|
||||
final unchanged = sortHomeSearchItems(
|
||||
items: results,
|
||||
option: HomeSearchSortOption.defaultOrder,
|
||||
nameOf: (item) => item.name,
|
||||
artistOf: (item) => item.artistName,
|
||||
durationOf: (item) => item.duration,
|
||||
dateOf: (item) => item.releaseDate,
|
||||
);
|
||||
final sorted = sortHomeSearchItems(
|
||||
items: results,
|
||||
option: HomeSearchSortOption.titleAsc,
|
||||
nameOf: (item) => item.name,
|
||||
artistOf: (item) => item.artistName,
|
||||
durationOf: (item) => item.duration,
|
||||
dateOf: (item) => item.releaseDate,
|
||||
);
|
||||
|
||||
expect(identical(unchanged, results), isTrue);
|
||||
expect(identical(sorted, results), isFalse);
|
||||
expect(sorted.map((item) => item.id), ['earlier', 'later']);
|
||||
expect(results.first.id, 'later');
|
||||
});
|
||||
|
||||
test('recognizes URLs and Spotify URIs without treating text as a URL', () {
|
||||
expect(looksLikeUrlOrSpotifyUri('https://example.test/track'), isTrue);
|
||||
expect(looksLikeUrlOrSpotifyUri('spotify:track:123'), isTrue);
|
||||
expect(looksLikeUrlOrSpotifyUri('artist track title'), isFalse);
|
||||
});
|
||||
|
||||
test('uses the primary enabled extension as the provider fallback', () {
|
||||
final secondary = _searchExtension(id: 'secondary');
|
||||
final primary = _searchExtension(id: 'primary', primary: true);
|
||||
final disabled = _searchExtension(id: 'disabled', enabled: false);
|
||||
final extensions = [secondary, primary, disabled];
|
||||
|
||||
expect(
|
||||
HomeSearchProviderPolicy.resolveProvider(null, extensions),
|
||||
'primary',
|
||||
);
|
||||
expect(
|
||||
HomeSearchProviderPolicy.resolveProvider('secondary', extensions),
|
||||
'secondary',
|
||||
);
|
||||
expect(
|
||||
HomeSearchProviderPolicy.resolveProvider('disabled', extensions),
|
||||
'primary',
|
||||
);
|
||||
expect(
|
||||
HomeSearchProviderPolicy.hasProvider('missing', extensions),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('canonicalizes built-in and extension search filters', () {
|
||||
final extension = _searchExtension(
|
||||
id: 'provider',
|
||||
filters: const [
|
||||
SearchFilter(id: 'songs', label: 'Tracks', icon: 'music'),
|
||||
SearchFilter(id: 'records', label: 'Albums', icon: 'album'),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
HomeSearchProviderPolicy.sanitizeFilter('Music', null, const []),
|
||||
'track',
|
||||
);
|
||||
expect(
|
||||
HomeSearchProviderPolicy.sanitizeFilter('track', 'provider', [
|
||||
extension,
|
||||
]),
|
||||
'songs',
|
||||
);
|
||||
expect(
|
||||
HomeSearchProviderPolicy.displayFilterSelection(
|
||||
null,
|
||||
'album',
|
||||
'provider',
|
||||
[extension],
|
||||
),
|
||||
'records',
|
||||
);
|
||||
expect(
|
||||
HomeSearchProviderPolicy.sanitizeFilter('podcast', 'provider', [
|
||||
extension,
|
||||
]),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('FFmpeg value models', () {
|
||||
test('normalizes structured and legacy decryption descriptors', () {
|
||||
final structured = DownloadDecryptionDescriptor.fromDownloadResult({
|
||||
'output_extension': 'M4A',
|
||||
'decryption': {
|
||||
'strategy': 'ffmpeg_mov_key',
|
||||
'key': ' secret ',
|
||||
'options': {'repair_ac4': true},
|
||||
},
|
||||
});
|
||||
final legacy = DownloadDecryptionDescriptor.fromDownloadResult({
|
||||
'decryption_key': 'legacy',
|
||||
'output_extension': '.flac',
|
||||
});
|
||||
|
||||
expect(structured, isNotNull);
|
||||
expect(structured!.normalizedStrategy, 'ffmpeg.mov_key');
|
||||
expect(structured.normalizedOutputExtension, '.m4a');
|
||||
expect(structured.key, 'secret');
|
||||
expect(structured.options['repair_ac4'], isTrue);
|
||||
expect(legacy!.inputFormat, 'mov');
|
||||
expect(legacy.normalizedOutputExtension, '.flac');
|
||||
});
|
||||
|
||||
test('rejects empty or unsupported structured decryption requests', () {
|
||||
expect(
|
||||
DownloadDecryptionDescriptor.fromDownloadResult({
|
||||
'decryption': {'strategy': 'custom', 'key': 'secret'},
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
DownloadDecryptionDescriptor.fromDownloadResult({
|
||||
'decryption': {'strategy': 'ffmpeg.mov_key', 'key': ' '},
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('audio metadata modules', () {
|
||||
test('maps shared tags consistently for native, M4A, and ID3 writers', () {
|
||||
final metadata = {
|
||||
'ALBUM_ARTIST': 'Album Artist',
|
||||
'TRACKNUMBER': '3/12',
|
||||
'DISCNUMBER': '1/2',
|
||||
'ISRC': 'TEST12345678',
|
||||
'UNSYNCEDLYRICS': 'Lyrics',
|
||||
'REPLAYGAIN_TRACK_GAIN': '-5.00 dB',
|
||||
'BIT_DEPTH': '24',
|
||||
};
|
||||
|
||||
final native = AudioMetadataMapper.vorbisToNativeChunkFields(metadata);
|
||||
final m4a = AudioMetadataMapper.convertToM4aTags(metadata);
|
||||
final id3 = AudioMetadataMapper.convertToId3Tags(metadata);
|
||||
|
||||
expect(native['album_artist'], 'Album Artist');
|
||||
expect(native['track_number'], '3');
|
||||
expect(native['track_total'], '12');
|
||||
expect(native['disc_number'], '1');
|
||||
expect(native['disc_total'], '2');
|
||||
expect(native['replaygain_track_gain'], '-5.00 dB');
|
||||
expect(m4a['isrc'], 'TEST12345678');
|
||||
expect(m4a['lyrics'], 'Lyrics');
|
||||
expect(id3['TSRC'], 'TEST12345678');
|
||||
expect(id3['REPLAYGAIN_TRACK_GAIN'], '-5.00 dB');
|
||||
expect(native, isNot(contains('bit_depth')));
|
||||
});
|
||||
|
||||
test('builds split Vorbis artist entries without losing other tags', () {
|
||||
final entries = AudioMetadataMapper.buildVorbisMetadataEntries({
|
||||
'ARTIST': 'First & Second',
|
||||
'TITLE': 'Track',
|
||||
}, artistTagMode: artistTagModeSplitVorbis);
|
||||
|
||||
expect(
|
||||
entries.where((entry) => entry.key == 'ARTIST').map((e) => e.value),
|
||||
['First', 'Second'],
|
||||
);
|
||||
expect(
|
||||
entries.where((entry) => entry.key == 'TITLE').single.value,
|
||||
'Track',
|
||||
);
|
||||
});
|
||||
|
||||
test('writes one ID3v2.3 USLT frame and replaces it on update', () {
|
||||
final audio = Uint8List.fromList([0xff, 0xfb, 0x90, 0x64]);
|
||||
final first = Id3v23Lyrics.writeUnsyncedLyrics(audio, 'First lyrics');
|
||||
final second = Id3v23Lyrics.writeUnsyncedLyrics(
|
||||
first!,
|
||||
'Replacement lyrics',
|
||||
);
|
||||
|
||||
expect(second, isNotNull);
|
||||
expect(second!.sublist(0, 4), [0x49, 0x44, 0x33, 0x03]);
|
||||
expect(_sequenceCount(second, ascii.encode('USLT')), 1);
|
||||
expect(second.sublist(second.length - audio.length), audio);
|
||||
});
|
||||
|
||||
test('leaves unsupported ID3 versions unchanged', () {
|
||||
final id3v24 = Uint8List.fromList([
|
||||
0x49,
|
||||
0x44,
|
||||
0x33,
|
||||
0x04,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]);
|
||||
|
||||
expect(Id3v23Lyrics.writeUnsyncedLyrics(id3v24, 'Lyrics'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('queue state owns lookup rebuilding independently of notifier', () {
|
||||
final queued = DownloadItem(
|
||||
id: 'one',
|
||||
track: track(id: 'track-one', name: 'One'),
|
||||
service: 'extension.test',
|
||||
createdAt: DateTime.utc(2026),
|
||||
);
|
||||
final completed = queued.copyWith(status: DownloadStatus.completed);
|
||||
|
||||
final initial = const DownloadQueueState().copyWith(items: [queued]);
|
||||
final next = initial.copyWith(
|
||||
items: [completed],
|
||||
currentDownload: completed,
|
||||
);
|
||||
final cleared = next.copyWith(currentDownload: null);
|
||||
|
||||
expect(initial.queuedCount, 1);
|
||||
expect(next.completedCount, 1);
|
||||
expect(next.lookup.byItemId['one']?.status, DownloadStatus.completed);
|
||||
expect(next.currentDownload, completed);
|
||||
expect(cleared.currentDownload, isNull);
|
||||
});
|
||||
}
|
||||
|
||||
int _sequenceCount(Uint8List bytes, List<int> sequence) {
|
||||
var count = 0;
|
||||
for (var index = 0; index <= bytes.length - sequence.length; index++) {
|
||||
var matches = true;
|
||||
for (var offset = 0; offset < sequence.length; offset++) {
|
||||
if (bytes[index + offset] != sequence[offset]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
Extension _searchExtension({
|
||||
required String id,
|
||||
bool enabled = true,
|
||||
bool primary = false,
|
||||
List<SearchFilter> filters = const [],
|
||||
}) {
|
||||
return Extension(
|
||||
id: id,
|
||||
name: id,
|
||||
displayName: id,
|
||||
version: '1.0.0',
|
||||
description: 'test',
|
||||
enabled: enabled,
|
||||
status: 'loaded',
|
||||
searchBehavior: SearchBehavior(
|
||||
enabled: true,
|
||||
primary: primary,
|
||||
filters: filters,
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user