From caf2f5f1f62ee1498ad71254ebac29cac9854d35 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 2 Nov 2025 15:43:40 +0800 Subject: [PATCH] :lipstick: Optimize the link embed --- lib/pods/activity/activity_rpc.dart | 26 +- lib/screens/explore.dart | 2 + lib/widgets/account/activity_presence.dart | 12 +- lib/widgets/account/status.dart | 12 +- lib/widgets/content/embed/link.dart | 355 +++++++++++++-------- lib/widgets/content/image.dart | 35 +- 6 files changed, 289 insertions(+), 153 deletions(-) diff --git a/lib/pods/activity/activity_rpc.dart b/lib/pods/activity/activity_rpc.dart index ae83b0b6..8458f320 100644 --- a/lib/pods/activity/activity_rpc.dart +++ b/lib/pods/activity/activity_rpc.dart @@ -362,7 +362,10 @@ class ServerStateNotifier extends StateNotifier { } void setCurrentActivity(String? id, Map? data) { - state = state.copyWith(currentActivityManualId: id, currentActivityData: data); + state = state.copyWith( + currentActivityManualId: id, + currentActivityData: data, + ); if (id != null && data != null) { _startRenewal(); } else { @@ -372,8 +375,10 @@ class ServerStateNotifier extends StateNotifier { void _startRenewal() { _renewalTimer?.cancel(); - const int renewalIntervalSeconds = kPresenseActivityLease * 60 - 30; - _renewalTimer = Timer.periodic(Duration(seconds: renewalIntervalSeconds), (timer) { + const int renewalIntervalSeconds = kPresenceActivityLease * 60 - 30; + _renewalTimer = Timer.periodic(Duration(seconds: renewalIntervalSeconds), ( + timer, + ) { _renewActivity(); }); } @@ -386,7 +391,10 @@ class ServerStateNotifier extends StateNotifier { Future _renewActivity() async { if (state.currentActivityData != null) { try { - await apiClient.post('/pass/activities', data: state.currentActivityData); + await apiClient.post( + '/pass/activities', + data: state.currentActivityData, + ); talker.log('Activity lease renewed'); } catch (e) { talker.log('Failed to renew activity lease: $e'); @@ -395,7 +403,7 @@ class ServerStateNotifier extends StateNotifier { } } -const kPresenseActivityLease = 5; +const kPresenceActivityLease = 5; // Providers final rpcServerStateProvider = StateNotifierProvider< @@ -459,8 +467,10 @@ final rpcServerStateProvider = StateNotifierProvider< activity['details'] ?? activity['assets']?['large_text']; var imageSmall = activity['assets']?['small_image']; var imageLarge = activity['assets']?['large_image']; - if (imageSmall != null && !imageSmall!.contains(':')) imageSmall = 'discord:$imageSmall'; - if (imageLarge != null && !imageLarge!.contains(':')) imageLarge = 'discord:$imageLarge'; + if (imageSmall != null && !imageSmall!.contains(':')) + imageSmall = 'discord:$imageSmall'; + if (imageLarge != null && !imageLarge!.contains(':')) + imageLarge = 'discord:$imageLarge'; try { final apiClient = ref.watch(apiClientProvider); final activityData = { @@ -474,7 +484,7 @@ final rpcServerStateProvider = StateNotifierProvider< 'small_image': imageSmall, 'large_image': imageLarge, 'meta': activity, - 'lease_minutes': kPresenseActivityLease, + 'lease_minutes': kPresenceActivityLease, }; await apiClient.post('/pass/activities', data: activityData); diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 38384d1b..24a45bc7 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -147,6 +147,7 @@ class ExploreScreen extends HookConsumerWidget { tabAlignment: TabAlignment.start, isScrollable: true, dividerColor: Colors.transparent, + labelPadding: const EdgeInsets.symmetric(horizontal: 12), tabs: [ Tab( icon: Tooltip( @@ -392,6 +393,7 @@ class ExploreScreen extends HookConsumerWidget { tabAlignment: TabAlignment.start, isScrollable: true, dividerColor: Colors.transparent, + labelPadding: const EdgeInsets.symmetric(horizontal: 12), tabs: [ Tab( icon: Tooltip( diff --git a/lib/widgets/account/activity_presence.dart b/lib/widgets/account/activity_presence.dart index e627e498..283a405d 100644 --- a/lib/widgets/account/activity_presence.dart +++ b/lib/widgets/account/activity_presence.dart @@ -52,14 +52,14 @@ Future discordAssetsUrl( return null; } -const kPresenseActivityTypes = [ +const kPresenceActivityTypes = [ 'unknown', 'presenceTypeGaming', 'presenceTypeMusic', 'presenceTypeWorkout', ]; -const kPresenseActivityIcons = [ +const kPresenceActivityIcons = [ Symbols.question_mark_rounded, Symbols.play_arrow_rounded, Symbols.music_note_rounded, @@ -200,10 +200,10 @@ class ActivityPresenceWidget extends ConsumerWidget { Row( children: [ Text( - kPresenseActivityTypes[activity.type], + kPresenceActivityTypes[activity.type], ).tr().fontSize(11), Icon( - kPresenseActivityIcons[activity.type], + kPresenceActivityIcons[activity.type], size: 15, fill: 1, ), @@ -298,9 +298,9 @@ class ActivityPresenceWidget extends ConsumerWidget { Row( spacing: 4, children: [ - Text(kPresenseActivityTypes[activity.type]).tr(), + Text(kPresenceActivityTypes[activity.type]).tr(), Icon( - kPresenseActivityIcons[activity.type], + kPresenceActivityIcons[activity.type], size: 16, fill: 1, ), diff --git a/lib/widgets/account/status.dart b/lib/widgets/account/status.dart index 61c571ef..5933ca22 100644 --- a/lib/widgets/account/status.dart +++ b/lib/widgets/account/status.dart @@ -71,6 +71,9 @@ class AccountStatusCreationWidget extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final userStatus = ref.watch(accountStatusProvider(uname)); + final renderPadding = + padding ?? EdgeInsets.symmetric(horizontal: 16, vertical: 8); + return InkWell( borderRadius: BorderRadius.circular(8), child: userStatus.when( @@ -79,12 +82,13 @@ class AccountStatusCreationWidget extends HookConsumerWidget { (status?.isCustomized ?? false) ? Padding( padding: const EdgeInsets.only(left: 4), - child: AccountStatusWidget(uname: uname), + child: AccountStatusWidget( + uname: uname, + padding: renderPadding, + ), ) : Padding( - padding: - padding ?? - EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: renderPadding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/widgets/content/embed/link.dart b/lib/widgets/content/embed/link.dart index d1c483c6..6f1627d8 100644 --- a/lib/widgets/content/embed/link.dart +++ b/lib/widgets/content/embed/link.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:island/models/embed.dart'; @@ -5,7 +8,7 @@ import 'package:island/widgets/content/image.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:url_launcher/url_launcher.dart'; -class EmbedLinkWidget extends StatelessWidget { +class EmbedLinkWidget extends StatefulWidget { final SnScrappedLink link; final double? maxWidth; final EdgeInsetsGeometry? margin; @@ -17,167 +20,263 @@ class EmbedLinkWidget extends StatelessWidget { this.margin, }); + @override + State createState() => _EmbedLinkWidgetState(); +} + +class _EmbedLinkWidgetState extends State { + bool? _isSquare; + + @override + void initState() { + super.initState(); + _checkIfSquare(); + } + + Future _checkIfSquare() async { + if (widget.link.imageUrl == null || + widget.link.imageUrl!.isEmpty || + widget.link.imageUrl == widget.link.faviconUrl) + return; + + try { + final image = CachedNetworkImageProvider(widget.link.imageUrl!); + final ImageStream stream = image.resolve(ImageConfiguration.empty); + final completer = Completer(); + final listener = ImageStreamListener(( + ImageInfo info, + bool synchronousCall, + ) { + completer.complete(info); + }); + stream.addListener(listener); + final info = await completer.future; + stream.removeListener(listener); + + final aspectRatio = info.image.width / info.image.height; + setState(() { + _isSquare = aspectRatio >= 0.9 && aspectRatio <= 1.1; + }); + } catch (e) { + // If error, assume not square + setState(() { + _isSquare = false; + }); + } + } + Future _launchUrl() async { - final uri = Uri.parse(link.url); + final uri = Uri.parse(widget.link.url); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } } + String _getBaseUrl(String url) { + final uri = Uri.parse(url); + final port = uri.port; + final defaultPort = uri.scheme == 'https' ? 443 : 80; + final portString = port != defaultPort ? ':$port' : ''; + return '${uri.scheme}://${uri.host}$portString'; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Container( - width: maxWidth, - margin: margin ?? const EdgeInsets.symmetric(vertical: 8), + width: widget.maxWidth, + margin: widget.margin ?? const EdgeInsets.symmetric(vertical: 8), child: Card( margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, child: InkWell( onTap: _launchUrl, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - // Preview Image - if (link.imageUrl != null && link.imageUrl!.isNotEmpty) - AspectRatio( - aspectRatio: 16 / 9, - child: UniversalImage(uri: link.imageUrl!, fit: BoxFit.cover), + // Sqaure open graph image + if (_isSquare == true) ...[ + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 120), + child: AspectRatio( + aspectRatio: 1, + child: UniversalImage( + uri: widget.link.imageUrl!, + fit: BoxFit.cover, + ), + ), + ), ), - - // Content - Padding( - padding: const EdgeInsets.all(16), + const Gap(8), + ], + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Site info row - Row( - children: [ - // Favicon - if (link.faviconUrl?.isNotEmpty ?? false) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: UniversalImage( - uri: link.faviconUrl!, - width: 16, - height: 16, - fit: BoxFit.cover, - ), + // Preview Image + if (widget.link.imageUrl != null && + widget.link.imageUrl!.isNotEmpty && + widget.link.imageUrl != widget.link.faviconUrl && + _isSquare != true) + Container( + width: double.infinity, + color: + Theme.of(context).colorScheme.surfaceContainerHigh, + child: IntrinsicHeight( + child: UniversalImage( + uri: widget.link.imageUrl!, + fit: BoxFit.cover, + useFallbackImage: false, ), - const Gap(8), - ] else ...[ - Icon( - Symbols.link, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - const Gap(8), - ], + ), + ), - // Site name - Expanded( - child: Text( - (link.siteName?.isNotEmpty ?? false) - ? link.siteName! - : Uri.parse(link.url).host, + // Content + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Site info row + Row( + children: [ + if (widget.link.faviconUrl?.isNotEmpty ?? + false) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + uri: + widget.link.faviconUrl!.startsWith('//') + ? 'https:${widget.link.faviconUrl!}' + : widget.link.faviconUrl! + .startsWith('/') + ? _getBaseUrl(widget.link.url) + + widget.link.faviconUrl! + : widget.link.faviconUrl!, + width: 16, + height: 16, + fit: BoxFit.cover, + useFallbackImage: false, + ), + ), + const Gap(8), + ] else ...[ + Icon( + Symbols.link, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const Gap(8), + ], + + // Site name + Expanded( + child: Text( + (widget.link.siteName?.isNotEmpty ?? false) + ? widget.link.siteName! + : Uri.parse(widget.link.url).host, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + + // External link icon + Icon( + Symbols.open_in_new, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + + const Gap(8), + + // Title + if (widget.link.title.isNotEmpty) ...[ + Text( + widget.link.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: _isSquare == true ? 1 : 2, + overflow: TextOverflow.ellipsis, + ), + Gap(_isSquare == true ? 2 : 4), + ], + + // Description + if (widget.link.description != null && + widget.link.description!.isNotEmpty) ...[ + Text( + widget.link.description!, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: _isSquare == true ? 1 : 3, + overflow: TextOverflow.ellipsis, + ), + Gap(_isSquare == true ? 4 : 8), + ], + + // URL + Text( + widget.link.url, style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + color: colorScheme.primary, + decoration: TextDecoration.underline, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - ), - // External link icon - Icon( - Symbols.open_in_new, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - ], - ), - - const Gap(8), - - // Title - if (link.title.isNotEmpty) ...[ - Text( - link.title, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const Gap(4), - ], - - // Description - if (link.description != null && - link.description!.isNotEmpty) ...[ - Text( - link.description!, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - const Gap(8), - ], - - // URL - Text( - link.url, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.primary, - decoration: TextDecoration.underline, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - - // Author and publish date - if (link.author != null || link.publishedDate != null) ...[ - const Gap(8), - Row( - children: [ - if (link.author != null) ...[ - Icon( - Symbols.person, - size: 14, - color: colorScheme.onSurfaceVariant, - ), - const Gap(4), - Text( - link.author!, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - if (link.author != null && link.publishedDate != null) - const Gap(16), - if (link.publishedDate != null) ...[ - Icon( - Symbols.schedule, - size: 14, - color: colorScheme.onSurfaceVariant, - ), - const Gap(4), - Text( - _formatDate(link.publishedDate!), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + // Author and publish date + if (widget.link.author != null || + widget.link.publishedDate != null) ...[ + const Gap(8), + Row( + children: [ + if (widget.link.author != null) ...[ + Icon( + Symbols.person, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + const Gap(4), + Text( + widget.link.author!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + if (widget.link.author != null && + widget.link.publishedDate != null) + const Gap(16), + if (widget.link.publishedDate != null) ...[ + Icon( + Symbols.schedule, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + const Gap(4), + Text( + _formatDate(widget.link.publishedDate!), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], ), ], ], ), - ], + ), ], ), ), diff --git a/lib/widgets/content/image.dart b/lib/widgets/content/image.dart index bf17fb39..e8fd70f6 100644 --- a/lib/widgets/content/image.dart +++ b/lib/widgets/content/image.dart @@ -1,6 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_blurhash/flutter_blurhash.dart'; +import 'package:flutter_svg/flutter_svg.dart'; class UniversalImage extends StatelessWidget { final String uri; @@ -9,6 +10,8 @@ class UniversalImage extends StatelessWidget { final double? width; final double? height; final bool noCacheOptimization; + final bool isSvg; + final bool useFallbackImage; const UniversalImage({ super.key, @@ -18,10 +21,26 @@ class UniversalImage extends StatelessWidget { this.width, this.height, this.noCacheOptimization = false, + this.isSvg = false, + this.useFallbackImage = true, }); @override Widget build(BuildContext context) { + final isSvgImage = isSvg || uri.toLowerCase().endsWith('.svg'); + + if (isSvgImage) { + return SvgPicture.network( + uri, + fit: fit, + width: width, + height: height, + placeholderBuilder: + (BuildContext context) => + Center(child: CircularProgressIndicator()), + ); + } + int? cacheWidth; int? cacheHeight; if (width != null && height != null && !noCacheOptimization) { @@ -50,13 +69,15 @@ class UniversalImage extends StatelessWidget { child: CircularProgressIndicator(value: progress.progress), ); }, - errorWidget: (context, url, error) { - return Image.asset( - 'assets/images/media-offline.jpg', - fit: BoxFit.cover, - key: Key('image-broke-$uri'), - ); - }, + errorWidget: + (context, url, error) => + useFallbackImage + ? Image.asset( + 'assets/images/media-offline.jpg', + fit: BoxFit.cover, + key: Key('image-broke-$uri'), + ) + : SizedBox.shrink(), ), ], ),