feat(ui): add shared widgets, utils, and models for deduplicated screens

Single homes for logic that was copy-pasted across screens: the settings
collapsing header, album detail header (online screen's design as the
reference), selection pill button + bottom-bar chrome, disc separator
chip, error card, cover URL upgrade, byte/clock formatting, duration
extraction, UnifiedLibraryItem (moved out of the queue_tab library so
other screens can import it), and the batch convert/ReplayGain engine
keyed on it.
This commit is contained in:
zarzet
2026-07-11 16:34:16 +07:00
parent 49ecfe261a
commit 9580fafe4f
11 changed files with 1508 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
enum LibraryItemSource { downloaded, local }
/// A library track backed by either a download-history record or a
/// local-library scan record, unified for shared list and batch handling.
class UnifiedLibraryItem {
final String id;
final String trackName;
final String artistName;
final String albumName;
final String? coverUrl;
final String? localCoverPath;
final String filePath;
final String? quality;
final DateTime addedAt;
final LibraryItemSource source;
final DownloadHistoryItem? historyItem;
final LocalLibraryItem? localItem;
UnifiedLibraryItem({
required this.id,
required this.trackName,
required this.artistName,
required this.albumName,
this.coverUrl,
this.localCoverPath,
required this.filePath,
this.quality,
required this.addedAt,
required this.source,
this.historyItem,
this.localItem,
});
factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) {
String? quality;
if (item.bitrate != null && item.bitrate! > 0) {
quality = buildDisplayAudioQuality(
bitrateKbps: item.bitrate,
format: item.format,
);
} else if (item.bitDepth != null &&
item.bitDepth! > 0 &&
item.sampleRate != null) {
quality = buildDisplayAudioQuality(
bitDepth: item.bitDepth,
sampleRate: item.sampleRate,
);
}
quality ??= item.quality;
return UnifiedLibraryItem(
id: 'dl_${item.id}',
trackName: item.trackName,
artistName: item.artistName,
albumName: item.albumName,
coverUrl: item.coverUrl,
filePath: item.filePath,
quality: quality,
addedAt: item.downloadedAt,
source: LibraryItemSource.downloaded,
historyItem: item,
);
}
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
String? quality;
if (item.bitrate != null && item.bitrate! > 0) {
quality = buildDisplayAudioQuality(
bitrateKbps: item.bitrate,
format: item.format,
);
} else if (item.bitDepth != null &&
item.bitDepth! > 0 &&
item.sampleRate != null) {
quality = buildDisplayAudioQuality(
bitDepth: item.bitDepth,
sampleRate: item.sampleRate,
);
}
return UnifiedLibraryItem(
id: 'local_${item.id}',
trackName: item.trackName,
artistName: item.artistName,
albumName: item.albumName,
coverUrl: null,
localCoverPath: item.coverPath,
filePath: item.filePath,
quality: quality,
addedAt: item.fileModTime != null
? DateTime.fromMillisecondsSinceEpoch(item.fileModTime!)
: item.scannedAt,
source: LibraryItemSource.local,
localItem: item,
);
}
bool get hasCover =>
coverUrl != null ||
(localCoverPath != null && localCoverPath!.isNotEmpty);
String? get albumArtist => historyItem?.albumArtist ?? localItem?.albumArtist;
String? get releaseDate => historyItem?.releaseDate ?? localItem?.releaseDate;
String? get genre => historyItem?.genre ?? localItem?.genre;
int? get trackNumber => historyItem?.trackNumber ?? localItem?.trackNumber;
int? get discNumber => historyItem?.discNumber ?? localItem?.discNumber;
String? get isrc => historyItem?.isrc ?? localItem?.isrc;
String? get label => historyItem?.label ?? localItem?.label;
String get searchKey =>
'${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}';
String get albumKey =>
'${albumName.toLowerCase()}|${artistName.toLowerCase()}';
/// Returns the collection key used to match this item against playlist
/// entries. Uses the same logic as [trackCollectionKey] from the collections
/// provider: prefer ISRC, fall back to source:id.
String get collectionKey {
if (historyItem != null) {
final isrc = historyItem!.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}';
final source = historyItem!.service.trim().isNotEmpty
? historyItem!.service.trim()
: 'builtin';
return '$source:${historyItem!.id}';
}
if (localItem != null) {
final isrc = localItem!.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}';
return 'local:${localItem!.id}';
}
return 'builtin:$id';
}
Track toTrack() {
if (historyItem != null) {
final h = historyItem!;
return Track(
id: h.id,
name: h.trackName,
artistName: h.artistName,
albumName: h.albumName,
albumArtist: h.albumArtist,
coverUrl: h.coverUrl,
isrc: h.isrc,
duration: h.duration ?? 0,
trackNumber: h.trackNumber,
discNumber: h.discNumber,
releaseDate: h.releaseDate,
source: h.service,
);
}
if (localItem != null) {
final l = localItem!;
return Track(
id: l.id,
name: l.trackName,
artistName: l.artistName,
albumName: l.albumName,
albumArtist: l.albumArtist,
coverUrl: l.coverPath,
isrc: l.isrc,
duration: l.duration ?? 0,
trackNumber: l.trackNumber,
discNumber: l.discNumber,
releaseDate: l.releaseDate,
source: 'local',
);
}
return Track(
id: id,
name: trackName,
artistName: artistName,
albumName: albumName,
coverUrl: coverUrl,
duration: 0,
);
}
}