fix: lazy-resolve image dimensions for providers without width/height metadata (Pawchive)

- Convert _PostInfoCard to StatefulWidget
- Lazily decode real pixel dimensions (e.g. 1920×1080) from network when post.width==0 && post.height==0
- Show '… × …' while resolving, actual size once decoded
- Hide resolution badge for video/link posts where dimensions can't be resolved
- In-memory cache prevents redundant re-downloads
- Bump version to 2.3.7+47
This commit is contained in:
2026-09-04 14:42:07 +03:00
parent d4e52ff429
commit 848fee6ff1
3 changed files with 112 additions and 12 deletions
+9
View File
@@ -1,4 +1,13 @@
const lunarisChangelog = [ const lunarisChangelog = [
LunarisChange(
version: '2.3.7',
title: 'Lazy Media Dimensions for Pawchive & Unknown-Size Providers',
bullets: [
'Auto-Resolve Image Dimensions: when a provider (such as Pawchive) does not supply width/height metadata, the app now lazily resolves the real pixel dimensions by decoding the image in the background and displays them as soon as they are ready.',
'No More "0 × 0": the resolution badge now shows "… × …" while dimensions are being fetched, and the correct resolution (e.g. 1920 × 1080) once decoded. The badge is hidden entirely for video/link posts where dimensions cannot be resolved.',
'Session Cache: resolved image dimensions are cached in memory for the lifetime of the app so repeated visits to the same post do not trigger re-downloads.',
],
),
LunarisChange( LunarisChange(
version: '2.3.6', version: '2.3.6',
title: 'Pawchive Accounts Integration & Favorites Synchronization', title: 'Pawchive Accounts Integration & Favorites Synchronization',
@@ -1185,7 +1185,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
} }
} }
class _PostInfoCard extends StatelessWidget { class _PostInfoCard extends StatefulWidget {
const _PostInfoCard({ const _PostInfoCard({
required this.post, required this.post,
required this.strings, required this.strings,
@@ -1198,8 +1198,90 @@ class _PostInfoCard extends StatelessWidget {
final DownloadedMedia? localMedia; final DownloadedMedia? localMedia;
final int? fileSizeBytes; final int? fileSizeBytes;
@override
State<_PostInfoCard> createState() => _PostInfoCardState();
}
class _PostInfoCardState extends State<_PostInfoCard> {
// Lazily resolved dimensions (used when post.width/height == 0).
int? _resolvedWidth;
int? _resolvedHeight;
bool _resolvingDimensions = false;
static final _dimensionCache = <String, (int, int)>{};
static bool _isImageType(String fileType) {
const imageTypes = {'image', 'jpeg', 'jpg', 'png', 'gif', 'webp', 'avif'};
return imageTypes.contains(fileType.toLowerCase());
}
@override
void initState() {
super.initState();
_maybeResolveDimensions();
}
@override
void didUpdateWidget(_PostInfoCard old) {
super.didUpdateWidget(old);
if (old.post.fileUrl != widget.post.fileUrl) {
_resolvedWidth = null;
_resolvedHeight = null;
_resolvingDimensions = false;
_maybeResolveDimensions();
}
}
void _maybeResolveDimensions() {
final post = widget.post;
// Only attempt lazy resolution when dimensions are unknown (0) and it's
// an image (not video/link which we can't resolve this way).
if (post.width != 0 || post.height != 0) return;
if (!_isImageType(post.fileType)) return;
final url = post.fileUrl.isNotEmpty ? post.fileUrl : post.sampleUrl;
if (url.isEmpty) return;
// Check cache first.
if (_dimensionCache.containsKey(url)) {
final cached = _dimensionCache[url]!;
_resolvedWidth = cached.$1;
_resolvedHeight = cached.$2;
return;
}
if (_resolvingDimensions) return;
_resolvingDimensions = true;
final imageProvider = NetworkImage(url);
final completer = imageProvider.resolve(ImageConfiguration.empty);
completer.addListener(
ImageStreamListener(
(info, _) {
final w = info.image.width;
final h = info.image.height;
_dimensionCache[url] = (w, h);
if (mounted) {
setState(() {
_resolvedWidth = w;
_resolvedHeight = h;
_resolvingDimensions = false;
});
}
},
onError: (_, __) {
if (mounted) setState(() => _resolvingDimensions = false);
},
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final post = widget.post;
final strings = widget.strings;
final localMedia = widget.localMedia;
final fileSizeBytes = widget.fileSizeBytes;
final theme = Theme.of(context); final theme = Theme.of(context);
final scheme = theme.colorScheme; final scheme = theme.colorScheme;
final artists = post.tagGroups['artist'] ?? final artists = post.tagGroups['artist'] ??
@@ -1208,6 +1290,14 @@ class _PostInfoCard extends StatelessWidget {
.map((t) => t.replaceFirst('artist:', '')) .map((t) => t.replaceFirst('artist:', ''))
.toList(); .toList();
// Determine which dimensions to display.
final displayWidth = post.width != 0 ? post.width : _resolvedWidth;
final displayHeight = post.height != 0 ? post.height : _resolvedHeight;
final hasDimensions = displayWidth != null && displayHeight != null;
final dimensionLabel = hasDimensions
? '$displayWidth × $displayHeight'
: (_resolvingDimensions ? '… × …' : null);
return Container( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1293,7 +1383,7 @@ class _PostInfoCard extends StatelessWidget {
), ),
), ),
Text( Text(
_formatDate(post.createdAt), _formatPostDate(post.createdAt),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
), ),
@@ -1321,8 +1411,8 @@ class _PostInfoCard extends StatelessWidget {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
fileSizeBytes != null && fileSizeBytes! > 0 fileSizeBytes != null && fileSizeBytes > 0
? DownloadedMediaService.formatBytes(fileSizeBytes!) ? DownloadedMediaService.formatBytes(fileSizeBytes)
: (strings.ru ? 'Офлайн' : 'Offline'), : (strings.ru ? 'Офлайн' : 'Offline'),
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onPrimaryContainer, color: scheme.onPrimaryContainer,
@@ -1341,9 +1431,10 @@ class _PostInfoCard extends StatelessWidget {
runSpacing: 8, runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
if (dimensionLabel != null)
_SpecBadge( _SpecBadge(
icon: Icons.aspect_ratio_rounded, icon: Icons.aspect_ratio_rounded,
label: '${post.width} × ${post.height}', label: dimensionLabel,
), ),
if (post.fileType.isNotEmpty) if (post.fileType.isNotEmpty)
_SpecBadge( _SpecBadge(
@@ -1373,10 +1464,10 @@ class _PostInfoCard extends StatelessWidget {
), ),
); );
} }
static String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
} }
String _formatPostDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
} }
class _SpecBadge extends StatelessWidget { class _SpecBadge extends StatelessWidget {
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 2.3.6+46 version: 2.3.7+47
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"