mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
fix(ui): smooth metadata transitions and refine settings
This commit is contained in:
@@ -214,10 +214,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
|
||||
}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
precacheCoverImage(context, item.coverUrl);
|
||||
final backdropReady = precacheMetadataBackdrop(context, item.coverUrl);
|
||||
final beforeModTime =
|
||||
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
|
||||
item.filePath,
|
||||
);
|
||||
await backdropReady;
|
||||
if (!mounted) return;
|
||||
|
||||
final result = await navigator.push(
|
||||
|
||||
@@ -352,10 +352,12 @@ extension _HomeTabRecentUI on _HomeTabState {
|
||||
}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
precacheCoverImage(context, item.coverUrl);
|
||||
final backdropReady = precacheMetadataBackdrop(context, item.coverUrl);
|
||||
final beforeModTime =
|
||||
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
|
||||
item.filePath,
|
||||
);
|
||||
await backdropReady;
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(
|
||||
|
||||
@@ -1083,6 +1083,8 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (historyItem != null) {
|
||||
await precacheMetadataBackdrop(context, historyItem.coverUrl);
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
slidePageRoute<void>(page: TrackMetadataScreen(item: historyItem)),
|
||||
);
|
||||
@@ -1099,6 +1101,8 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (localItem != null) {
|
||||
await precacheMetadataBackdrop(context, localItem.coverPath);
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
slidePageRoute<void>(page: TrackMetadataScreen(localItem: localItem)),
|
||||
);
|
||||
|
||||
@@ -117,8 +117,13 @@ extension _QueueTabNavigation on _QueueTabState {
|
||||
|
||||
final navigator = Navigator.of(context);
|
||||
precacheCoverImage(context, historyItem.coverUrl);
|
||||
final backdropReady = precacheMetadataBackdrop(
|
||||
context,
|
||||
historyItem.coverUrl,
|
||||
);
|
||||
_searchFocusNode.unfocus();
|
||||
final beforeModTime = await _readFileModTimeMillis(historyItem.filePath);
|
||||
await backdropReady;
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(page: TrackMetadataScreen(item: historyItem)),
|
||||
@@ -145,8 +150,10 @@ extension _QueueTabNavigation on _QueueTabState {
|
||||
}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
precacheCoverImage(context, item.coverUrl);
|
||||
final backdropReady = precacheMetadataBackdrop(context, item.coverUrl);
|
||||
_searchFocusNode.unfocus();
|
||||
final beforeModTime = await _readFileModTimeMillis(item.filePath);
|
||||
await backdropReady;
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(
|
||||
@@ -173,13 +180,15 @@ extension _QueueTabNavigation on _QueueTabState {
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToLocalMetadataScreen(
|
||||
Future<void> _navigateToLocalMetadataScreen(
|
||||
LocalLibraryItem item, {
|
||||
List<LocalLibraryItem>? navigationItems,
|
||||
int? navigationIndex,
|
||||
}) {
|
||||
}) async {
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.push(
|
||||
await precacheMetadataBackdrop(context, item.coverPath);
|
||||
if (!mounted) return;
|
||||
await Navigator.push(
|
||||
context,
|
||||
slidePageRoute<void>(
|
||||
page: TrackMetadataScreen(
|
||||
@@ -189,7 +198,8 @@ extension _QueueTabNavigation on _QueueTabState {
|
||||
coverHeroTag: 'cover_lib_local_${item.id}',
|
||||
),
|
||||
),
|
||||
).then((_) => _searchFocusNode.unfocus());
|
||||
);
|
||||
if (mounted) _searchFocusNode.unfocus();
|
||||
}
|
||||
|
||||
void _navigateWithUnfocus(Route<dynamic> route) {
|
||||
|
||||
@@ -95,17 +95,6 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
|
||||
|
||||
final searchCatalog = SettingsSearchCatalog(l10n);
|
||||
final groups = [
|
||||
_Group(
|
||||
destinations: [
|
||||
_Destination(
|
||||
icon: Icons.favorite_outline,
|
||||
title: l10n.settingsDonate,
|
||||
subtitle: l10n.settingsDonateSubtitle,
|
||||
keywords: const ['support', 'ko-fi', 'sponsor'],
|
||||
pageBuilder: () => const DonatePage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
_Group(
|
||||
destinations: [
|
||||
_Destination(
|
||||
@@ -243,6 +232,13 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
|
||||
),
|
||||
_Group(
|
||||
destinations: [
|
||||
_Destination(
|
||||
icon: Icons.favorite_outline,
|
||||
title: l10n.settingsDonate,
|
||||
subtitle: l10n.settingsDonateSubtitle,
|
||||
keywords: const ['support', 'ko-fi', 'sponsor'],
|
||||
pageBuilder: () => const DonatePage(),
|
||||
),
|
||||
_Destination(
|
||||
icon: Icons.info_outline,
|
||||
title: l10n.settingsAbout,
|
||||
|
||||
@@ -5,6 +5,10 @@ const _trackMetadataHeroScheme = ColorScheme.dark(
|
||||
onSurface: Colors.white,
|
||||
onSurfaceVariant: Colors.white70,
|
||||
);
|
||||
final ImageFilter _trackMetadataBackdropBlur = ImageFilter.blur(
|
||||
sigmaX: 32,
|
||||
sigmaY: 32,
|
||||
);
|
||||
|
||||
extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
Widget _buildAnimatedTrackContent(
|
||||
@@ -90,12 +94,17 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
double expandedHeight,
|
||||
bool showContent,
|
||||
) {
|
||||
final cacheWidth = coverCacheWidthForViewport(context);
|
||||
final coverCacheWidth = coverCacheWidthForViewport(context);
|
||||
final backdropCacheWidth = metadataBackdropCacheExtent(context);
|
||||
final backdropRemoteUrl = _isLocalItem
|
||||
? null
|
||||
: normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
|
||||
|
||||
Widget coverImage() => _hasPath(_embeddedCoverPreviewPath)
|
||||
? Image.file(
|
||||
File(_embeddedCoverPreviewPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
cacheWidth: coverCacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
||||
@@ -104,7 +113,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
? CachedCoverImage(
|
||||
imageUrl: _coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
memCacheWidth: coverCacheWidth,
|
||||
placeholder: (_, _) => Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
@@ -112,7 +121,49 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
? Image.file(
|
||||
File(_localCoverPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
cacheWidth: coverCacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 80,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
// Keep the backdrop on the stable Library artwork source instead of
|
||||
// switching to the asynchronously extracted embedded preview mid-route.
|
||||
// The smaller resize key is prewarmed before navigation and is sufficient
|
||||
// behind a strong blur, while the foreground Hero keeps full resolution.
|
||||
Widget backdropImage() => backdropRemoteUrl != null
|
||||
? CachedCoverImage(
|
||||
imageUrl: backdropRemoteUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: backdropCacheWidth,
|
||||
memCacheHeight: backdropCacheWidth,
|
||||
placeholder: (_, _) => Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
: _localCoverPath != null && _localCoverPath!.isNotEmpty
|
||||
? Image.file(
|
||||
File(_localCoverPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: backdropCacheWidth,
|
||||
cacheHeight: backdropCacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
: _hasPath(_embeddedCoverPreviewPath)
|
||||
? Image.file(
|
||||
File(_embeddedCoverPreviewPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: backdropCacheWidth,
|
||||
cacheHeight: backdropCacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
||||
@@ -135,9 +186,12 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
|
||||
child: coverImage(),
|
||||
RepaintBoundary(
|
||||
key: ValueKey<String>('metadata_backdrop_$_itemId'),
|
||||
child: ImageFiltered(
|
||||
imageFilter: _trackMetadataBackdropBlur,
|
||||
child: backdropImage(),
|
||||
),
|
||||
),
|
||||
Container(color: Colors.black.withValues(alpha: 0.35)),
|
||||
Positioned(
|
||||
|
||||
@@ -180,6 +180,47 @@ CachedNetworkImageProvider cachedCoverImageProvider(String url) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Decode size shared by Track Metadata's blurred backdrop and its prewarm.
|
||||
/// The backdrop is heavily blurred, so a modest square bitmap is sufficient
|
||||
/// and avoids allocating a second full-viewport image beside the Hero cover.
|
||||
int metadataBackdropCacheExtent(BuildContext context) {
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context).clamp(1.0, 3.0);
|
||||
final logicalWidth = MediaQuery.sizeOf(context).width;
|
||||
return (logicalWidth * dpr * 0.35).round().clamp(192, 384).toInt();
|
||||
}
|
||||
|
||||
/// Warms the exact resized image used by Track Metadata's blurred backdrop.
|
||||
/// Navigation waits for the common memory/disk-cache path, but a bounded budget
|
||||
/// prevents a cold network request from delaying the route for too long.
|
||||
Future<void> precacheMetadataBackdrop(
|
||||
BuildContext context,
|
||||
String? source,
|
||||
) async {
|
||||
final normalized = source?.trim();
|
||||
if (normalized == null || normalized.isEmpty) return;
|
||||
|
||||
final ImageProvider provider;
|
||||
if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
|
||||
provider = cachedCoverImageProvider(normalized);
|
||||
} else if (!normalized.startsWith('content://')) {
|
||||
final filePath = normalized.startsWith('file://')
|
||||
? Uri.parse(normalized).toFilePath()
|
||||
: normalized;
|
||||
provider = FileImage(File(filePath));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
final extent = metadataBackdropCacheExtent(context);
|
||||
try {
|
||||
await precacheImage(
|
||||
ResizeImage(provider, width: extent, height: extent),
|
||||
context,
|
||||
onError: (_, _) {},
|
||||
).timeout(const Duration(milliseconds: 200));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Pre-warms the cover cache at the metadata-screen display size so the hero
|
||||
/// transition doesn't pop in a low-res frame. Http(s) URLs only.
|
||||
void precacheCoverImage(BuildContext context, String? url) {
|
||||
|
||||
Reference in New Issue
Block a user