Files
SpotiFLAC-Mobile/lib/widgets/cached_cover_image.dart
T
zarzet 4b9853eef7 refactor(ui): consolidate duplicated sheets, rows, and cover widgets
- single-track convert sheet now reuses BatchConvertSheet
  (confirmLabelBuilder + sourceIsLossless params) instead of a
  full inline copy
- provider priority page migrated to PrioritySettingsScaffold;
  shared showDiscardChangesDialog and ReorderablePriorityItem
  replace per-page copies
- home tab search rows unified into one _SearchResultRowItem;
  _parseTrack copies rebuilt on Track.fromBackendMap with only
  the load-bearing local overrides kept; loading/error scaffold
  extracted
- PlayerArtwork widget shared by now-playing and mini player
- LocalOrNetworkCoverImage dispatches file vs network cover in
  one place (playlist picker, queue nav, library folder)
2026-07-12 18:58:16 +07:00

192 lines
5.7 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
class CachedCoverImage extends StatelessWidget {
static const int _defaultMinCacheExtent = 64;
static const int _defaultMaxCacheExtent = 512;
final String imageUrl;
final double? width;
final double? height;
final BoxFit fit;
final Alignment alignment;
final int? memCacheWidth;
final int? memCacheHeight;
final Widget Function(BuildContext, String, Object)? errorWidget;
final Widget Function(BuildContext, String)? placeholder;
final BorderRadius? borderRadius;
final bool resizeDiskCache;
final Duration fadeInDuration;
final Duration fadeOutDuration;
const CachedCoverImage({
super.key,
required this.imageUrl,
this.width,
this.height,
this.fit = BoxFit.cover,
this.alignment = Alignment.center,
this.memCacheWidth,
this.memCacheHeight,
this.errorWidget,
this.placeholder,
this.borderRadius,
this.resizeDiskCache = false,
this.fadeInDuration = Duration.zero,
this.fadeOutDuration = Duration.zero,
});
@override
Widget build(BuildContext context) {
final autoMemCacheWidth =
memCacheWidth ?? _cacheExtentForLogicalSize(context, width);
final autoMemCacheHeight =
memCacheHeight ?? _cacheExtentForLogicalSize(context, height);
final diskCacheWidth = resizeDiskCache ? autoMemCacheWidth : null;
final diskCacheHeight = resizeDiskCache ? autoMemCacheHeight : null;
final image = CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit,
alignment: alignment,
memCacheWidth: autoMemCacheWidth,
memCacheHeight: autoMemCacheHeight,
maxWidthDiskCache: diskCacheWidth,
maxHeightDiskCache: diskCacheHeight,
cacheManager: CoverCacheManager.instance,
fadeInDuration: fadeInDuration,
fadeOutDuration: fadeOutDuration,
useOldImageOnUrlChange: true,
filterQuality: FilterQuality.low,
errorWidget: errorWidget,
placeholder: placeholder,
);
if (borderRadius != null) {
return ClipRRect(borderRadius: borderRadius!, child: image);
}
return image;
}
static int? _cacheExtentForLogicalSize(BuildContext context, double? size) {
if (size == null || !size.isFinite || size <= 0) return null;
final dpr = MediaQuery.devicePixelRatioOf(
context,
).clamp(1.0, 3.0).toDouble();
return (size * dpr)
.round()
.clamp(_defaultMinCacheExtent, _defaultMaxCacheExtent)
.toInt();
}
}
/// Renders [url] as a local file (when it's not an http/https URL) or as a
/// cached network image otherwise, with a shared [placeholder] used for the
/// local error state, the local not-ready frame, and the network
/// placeholder/error states alike.
class LocalOrNetworkCoverImage extends StatelessWidget {
final String url;
final double? width;
final double? height;
final BoxFit fit;
final BorderRadius? borderRadius;
final int? localCacheWidth;
final int? networkCacheWidth;
final Duration? fadeInDuration;
final Duration fadeOutDuration;
final String Function(String)? urlTransform;
final Widget Function(BuildContext) placeholder;
const LocalOrNetworkCoverImage({
super.key,
required this.url,
this.width,
this.height,
this.fit = BoxFit.cover,
this.borderRadius,
this.localCacheWidth,
this.networkCacheWidth,
this.fadeInDuration,
this.fadeOutDuration = Duration.zero,
this.urlTransform,
required this.placeholder,
});
bool get _isLocal =>
!url.startsWith('http://') && !url.startsWith('https://');
@override
Widget build(BuildContext context) {
if (_isLocal) {
final image = Image.file(
File(url),
width: width,
height: height,
fit: fit,
cacheWidth: localCacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
frameBuilder: fadeInDuration == null
? null
: (context, child, frame, wasSynchronouslyLoaded) {
final ready = wasSynchronouslyLoaded || frame != null;
if (fadeInDuration == Duration.zero) {
return ready ? child : placeholder(context);
}
return Stack(
fit: StackFit.expand,
children: [
placeholder(context),
AnimatedOpacity(
opacity: ready ? 1.0 : 0.0,
duration: fadeInDuration!,
curve: Curves.easeOutCubic,
child: child,
),
],
);
},
errorBuilder: (_, _, _) => placeholder(context),
);
return borderRadius == null
? image
: ClipRRect(borderRadius: borderRadius!, child: image);
}
return CachedCoverImage(
imageUrl: urlTransform?.call(url) ?? url,
width: width,
height: height,
fit: fit,
memCacheWidth: networkCacheWidth,
borderRadius: borderRadius,
fadeInDuration: fadeInDuration ?? Duration.zero,
fadeOutDuration: fadeOutDuration,
placeholder: (_, _) => placeholder(context),
errorWidget: (_, _, _) => placeholder(context),
);
}
}
CachedNetworkImageProvider cachedCoverImageProvider(String url) {
return CachedNetworkImageProvider(
url,
cacheManager: CoverCacheManager.instance,
);
}
int coverImageCacheExtent(
BuildContext context,
double logicalSize, {
int min = 64,
int max = 512,
}) {
final dpr = MediaQuery.devicePixelRatioOf(context).clamp(1.0, 3.0).toDouble();
return (logicalSize * dpr).round().clamp(min, max).toInt();
}