diff --git a/lib/pods/activity/activity_rpc.dart b/lib/pods/activity/activity_rpc.dart index 8458f320..6ae0388e 100644 --- a/lib/pods/activity/activity_rpc.dart +++ b/lib/pods/activity/activity_rpc.dart @@ -467,10 +467,12 @@ 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(':')) + if (imageSmall != null && !imageSmall!.contains(':')) { imageSmall = 'discord:$imageSmall'; - if (imageLarge != null && !imageLarge!.contains(':')) + } + if (imageLarge != null && !imageLarge!.contains(':')) { imageLarge = 'discord:$imageLarge'; + } try { final apiClient = ref.watch(apiClientProvider); final activityData = { @@ -528,6 +530,13 @@ Future> presenceActivities( Ref ref, String uname, ) async { + ref.keepAlive(); + final timer = Timer.periodic( + const Duration(minutes: 1), + (_) => ref.invalidateSelf(), + ); + ref.onDispose(() => timer.cancel()); + final apiClient = ref.watch(apiClientProvider); final response = await apiClient.get('/pass/activities/$uname'); final data = response.data as List; diff --git a/lib/widgets/account/activity_presence.dart b/lib/widgets/account/activity_presence.dart index 283a405d..be438921 100644 --- a/lib/widgets/account/activity_presence.dart +++ b/lib/widgets/account/activity_presence.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/activity.dart'; import 'package:island/pods/activity/activity_rpc.dart'; +import 'package:island/widgets/content/image.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -66,7 +67,7 @@ const kPresenceActivityIcons = [ Symbols.running_with_errors, ]; -class ActivityPresenceWidget extends ConsumerWidget { +class ActivityPresenceWidget extends StatefulWidget { final String uname; final bool isCompact; final EdgeInsets compactPadding; @@ -78,324 +79,526 @@ class ActivityPresenceWidget extends ConsumerWidget { this.compactPadding = EdgeInsets.zero, }); - List _buildDiscordImages(WidgetRef ref, SnPresenceActivity activity) { + @override + State createState() => _ActivityPresenceWidgetState(); +} + +class _ActivityPresenceWidgetState extends State + with TickerProviderStateMixin { + late AnimationController _progressController; + late Animation _progressAnimation; + double _startProgress = 0.0; + double _endProgress = 0.0; + + @override + void initState() { + super.initState(); + _progressController = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + _progressAnimation = Tween( + begin: 0.0, + end: 0.0, + ).animate(_progressController); + } + + @override + void dispose() { + _progressController.dispose(); + super.dispose(); + } + + List _buildImages(WidgetRef ref, SnPresenceActivity activity) { final List images = []; - if (activity.largeImage != null && - activity.largeImage!.startsWith('discord:')) { - final key = activity.largeImage!.substring('discord:'.length); - final urlAsync = ref.watch(discordAssetsUrlProvider(activity, key)); - images.add( - urlAsync.when( - data: - (url) => - url != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: url, - width: 64, - height: 64, - ), - ) - : const SizedBox.shrink(), - loading: - () => const SizedBox( - width: 64, - height: 64, - child: CircularProgressIndicator(strokeWidth: 2), - ), - error: (error, stack) => const SizedBox.shrink(), - ), - ); + if (activity.largeImage != null) { + if (activity.largeImage!.startsWith('discord:')) { + final key = activity.largeImage!.substring('discord:'.length); + final urlAsync = ref.watch(discordAssetsUrlProvider(activity, key)); + images.add( + urlAsync.when( + data: + (url) => + url != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: url, + width: 64, + height: 64, + ), + ) + : const SizedBox.shrink(), + loading: + () => const SizedBox( + width: 64, + height: 64, + child: CircularProgressIndicator(strokeWidth: 2), + ), + error: (error, stack) => const SizedBox.shrink(), + ), + ); + } else { + images.add( + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: UniversalImage( + uri: activity.largeImage!, + width: 64, + height: 64, + ), + ), + ); + } } - if (activity.smallImage != null && - activity.smallImage!.startsWith('discord:')) { - final key = activity.smallImage!.substring('discord:'.length); - final urlAsync = ref.watch(discordAssetsUrlProvider(activity, key)); - images.add( - urlAsync.when( - data: - (url) => - url != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: url, - width: 32, - height: 32, - ), - ) - : const SizedBox.shrink(), - loading: - () => const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - error: (error, stack) => const SizedBox.shrink(), - ), - ); + if (activity.smallImage != null) { + if (activity.smallImage!.startsWith('discord:')) { + final key = activity.smallImage!.substring('discord:'.length); + final urlAsync = ref.watch(discordAssetsUrlProvider(activity, key)); + images.add( + urlAsync.when( + data: + (url) => + url != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: url, + width: 32, + height: 32, + ), + ) + : const SizedBox.shrink(), + loading: + () => const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + error: (error, stack) => const SizedBox.shrink(), + ), + ); + } else { + images.add( + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: UniversalImage( + uri: activity.smallImage!, + width: 32, + height: 32, + ), + ), + ); + } } return images; } @override - Widget build(BuildContext context, WidgetRef ref) { - final activitiesAsync = ref.watch(presenceActivitiesProvider(uname)); + Widget build(BuildContext context) { + return Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) { + final activitiesAsync = ref.watch( + presenceActivitiesProvider(widget.uname), + ); - if (isCompact) { - return activitiesAsync.when( - data: (activities) { - if (activities.isEmpty) return const SizedBox.shrink(); - final activity = activities.first; - return Padding( - padding: compactPadding, - child: Row( - spacing: 8, - children: [ - if (activity.largeImage != null && - activity.largeImage!.startsWith('discord:')) - ref - .watch( - discordAssetsUrlProvider( - activity, - activity.largeImage!.substring('discord:'.length), - ), - ) - .when( - data: - (url) => - url != null - ? ClipRRect( - borderRadius: BorderRadius.circular(4), - child: CachedNetworkImage( - imageUrl: url, - width: 32, - height: 32, + if (widget.isCompact) { + return activitiesAsync.when( + data: (activities) { + if (activities.isEmpty) return const SizedBox.shrink(); + final activity = activities.first; + return Padding( + padding: widget.compactPadding, + child: Row( + spacing: 8, + children: [ + if (activity.largeImage != null) + activity.largeImage!.startsWith('discord:') + ? ref + .watch( + discordAssetsUrlProvider( + activity, + activity.largeImage!.substring( + 'discord:'.length, + ), + ), + ) + .when( + data: + (url) => + url != null + ? ClipRRect( + borderRadius: + BorderRadius.circular(4), + child: CachedNetworkImage( + imageUrl: url, + width: 32, + height: 32, + ), + ) + : const SizedBox.shrink(), + loading: + () => const SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + strokeWidth: 1, ), - ) - : const SizedBox.shrink(), - loading: - () => const SizedBox( + ), + error: + (error, stack) => const SizedBox.shrink(), + ) + : ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + uri: activity.largeImage!, width: 32, height: 32, - child: CircularProgressIndicator(strokeWidth: 1), ), - error: (error, stack) => const SizedBox.shrink(), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - (activity.title?.isEmpty ?? true) - ? 'unknown'.tr() - : activity.title!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ).fontSize(13), - Row( - children: [ - Text( - kPresenceActivityTypes[activity.type], - ).tr().fontSize(11), - Icon( - kPresenceActivityIcons[activity.type], - size: 15, - fill: 1, ), - ], - ), - ], - ), - ), - StreamBuilder( - stream: Stream.periodic(const Duration(seconds: 1)), - builder: (context, snapshot) { - final now = DateTime.now(); - - // Check if lease has expired and refresh if needed - if (now.isAfter(activity.leaseExpiresAt)) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.invalidate(presenceActivitiesProvider(uname)); - }); - } - - final duration = now.difference(activity.createdAt); - final hours = duration.inHours.toString().padLeft(2, '0'); - final minutes = (duration.inMinutes % 60) - .toString() - .padLeft(2, '0'); - final seconds = (duration.inSeconds % 60) - .toString() - .padLeft(2, '0'); - return Text( - '$hours:$minutes:$seconds', - ).textColor(Colors.green).fontSize(12); - }, - ), - ], - ), - ); - }, - loading: () => const SizedBox.shrink(), - error: (error, stack) => const SizedBox.shrink(), - ); - } - - return activitiesAsync.when( - data: - (activities) => Card( - margin: EdgeInsets.zero, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - Text( - 'activities', - ).tr().bold().padding(horizontal: 16, vertical: 4), - if (activities.isEmpty) - Row( - spacing: 4, - children: [ - const Icon(Symbols.inbox, size: 16), - Text('dataEmpty').tr().fontSize(13), - ], - ).opacity(0.75).padding(horizontal: 16, bottom: 8), - ...activities.map((activity) { - final dcImages = _buildDiscordImages(ref, activity); - - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - side: BorderSide(color: Colors.grey.shade300, width: 1), - borderRadius: BorderRadius.circular(8), - ), - margin: EdgeInsets.zero, - child: ListTile( - title: Column( + Expanded( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (dcImages.isNotEmpty) - Row( - crossAxisAlignment: CrossAxisAlignment.end, - spacing: 8, - children: dcImages, - ).padding(vertical: 4), Text( (activity.title?.isEmpty ?? true) ? 'unknown'.tr() : activity.title!, - ), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + maxLines: 1, + overflow: TextOverflow.ellipsis, + ).fontSize(13), Row( - spacing: 4, children: [ - Text(kPresenceActivityTypes[activity.type]).tr(), + Text( + kPresenceActivityTypes[activity.type], + ).tr().fontSize(11), Icon( kPresenceActivityIcons[activity.type], - size: 16, + size: 15, fill: 1, ), ], ), - StreamBuilder( - stream: Stream.periodic(const Duration(seconds: 1)), - builder: (context, snapshot) { - final now = DateTime.now(); - - // Check if lease has expired and refresh if needed - if (now.isAfter(activity.leaseExpiresAt)) { - WidgetsBinding.instance.addPostFrameCallback(( - _, - ) { - ref.invalidate( - presenceActivitiesProvider(uname), - ); - }); - } - - final duration = now.difference( - activity.createdAt, - ); - final hours = duration.inHours.toString().padLeft( - 2, - '0', - ); - final minutes = (duration.inMinutes % 60) - .toString() - .padLeft(2, '0'); - final seconds = (duration.inSeconds % 60) - .toString() - .padLeft(2, '0'); - return Text( - '$hours:$minutes:$seconds', - ).textColor(Colors.green); - }, - ), - if (activity.subtitle?.isNotEmpty ?? false) - Text(activity.subtitle!), - if (activity.caption?.isNotEmpty ?? false) - Text(activity.caption!), - if ((activity.titleUrl?.isNotEmpty ?? false) || - (activity.subtitleUrl?.isNotEmpty ?? false)) - Row( - spacing: 8, - children: [ - if (activity.titleUrl != null && - activity.titleUrl!.isNotEmpty) - ElevatedButton.icon( - onPressed: - () => - launchUrlString(activity.titleUrl!), - icon: const Icon(Symbols.link, size: 16), - label: const Text('Open Title Link'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - textStyle: const TextStyle(fontSize: 12), - ), - ), - if (activity.subtitleUrl != null && - activity.subtitleUrl!.isNotEmpty) - ElevatedButton.icon( - onPressed: - () => launchUrlString( - activity.subtitleUrl!, - ), - icon: const Icon(Symbols.link, size: 16), - label: const Text('Open Subtitle Link'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - textStyle: const TextStyle(fontSize: 12), - ), - ), - ], - ), ], ), ), - ); - }), - ], - ).padding(all: 8), - ), - loading: () => const Center(child: CircularProgressIndicator()), - error: - (error, stack) => - Center(child: Text('Error loading activities: $error')), + StreamBuilder( + stream: Stream.periodic(const Duration(seconds: 1)), + builder: (context, snapshot) { + final now = DateTime.now(); + + if (activity.manualId == 'spotify' && + activity.meta != null) { + final meta = activity.meta as Map; + final progressMs = meta['progress_ms'] as int? ?? 0; + final durationMs = + meta['track_duration_ms'] as int? ?? 1; + final elapsed = + now.difference(activity.createdAt).inMilliseconds; + final currentProgressMs = + (progressMs + elapsed) % durationMs; + final progressValue = currentProgressMs / durationMs; + if (progressValue != _endProgress) { + _startProgress = _endProgress; + _endProgress = progressValue; + _progressAnimation = Tween( + begin: _startProgress, + end: _endProgress, + ).animate(_progressController); + _progressController.forward(from: 0.0); + } + return AnimatedBuilder( + animation: _progressAnimation, + builder: (context, child) { + final animatedValue = _progressAnimation.value; + final animatedProgressMs = + (animatedValue * durationMs).toInt(); + final currentMin = animatedProgressMs ~/ 60000; + final currentSec = + (animatedProgressMs % 60000) ~/ 1000; + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 2, + children: [ + Text( + '${currentMin.toString().padLeft(2, '0')}:${currentSec.toString().padLeft(2, '0')}', + style: TextStyle( + fontSize: 10, + color: Colors.green, + ), + ), + SizedBox( + width: 120, + child: LinearProgressIndicator( + value: animatedValue, + backgroundColor: Colors.grey.shade300, + stopIndicatorColor: Colors.green, + trackGap: 0, + valueColor: AlwaysStoppedAnimation( + Colors.green, + ), + ), + ).padding(top: 2), + ], + ); + }, + ); + } else { + final duration = now.difference(activity.createdAt); + final hours = duration.inHours.toString().padLeft( + 2, + '0', + ); + final minutes = (duration.inMinutes % 60) + .toString() + .padLeft(2, '0'); + final seconds = (duration.inSeconds % 60) + .toString() + .padLeft(2, '0'); + return Text( + '$hours:$minutes:$seconds', + ).textColor(Colors.green).fontSize(12); + } + }, + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (error, stack) => const SizedBox.shrink(), + ); + } + + return activitiesAsync.when( + data: + (activities) => Card( + margin: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + 'activities', + ).tr().bold().padding(horizontal: 16, vertical: 4), + if (activities.isEmpty) + Row( + spacing: 4, + children: [ + const Icon(Symbols.inbox, size: 16), + Text('dataEmpty').tr().fontSize(13), + ], + ).opacity(0.75).padding(horizontal: 16, bottom: 8), + ...activities.map((activity) { + final dcImages = _buildImages(ref, activity); + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide( + color: Colors.grey.shade300, + width: 1, + ), + borderRadius: BorderRadius.circular(8), + ), + margin: EdgeInsets.zero, + child: ListTile( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (dcImages.isNotEmpty) + Row( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 8, + children: dcImages, + ).padding(vertical: 4), + Row( + spacing: 2, + children: [ + Flexible( + child: Text( + (activity.title?.isEmpty ?? true) + ? 'unknown'.tr() + : activity.title!, + ), + ), + if (activity.titleUrl != null && + activity.titleUrl!.isNotEmpty) + IconButton( + onPressed: () { + launchUrlString(activity.titleUrl!); + }, + icon: const Icon(Symbols.launch_rounded), + iconSize: 16, + padding: EdgeInsets.all(4), + constraints: const BoxConstraints( + maxWidth: 28, + maxHeight: 28, + ), + ), + ], + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 4, + children: [ + Text( + kPresenceActivityTypes[activity.type], + ).tr(), + Icon( + kPresenceActivityIcons[activity.type], + size: 16, + fill: 1, + ), + ], + ), + if (activity.manualId == 'spotify' && + activity.meta != null) + StreamBuilder( + stream: Stream.periodic( + const Duration(seconds: 1), + ), + builder: (context, snapshot) { + final now = DateTime.now(); + final meta = + activity.meta as Map; + final progressMs = + meta['progress_ms'] as int? ?? 0; + final durationMs = + meta['track_duration_ms'] as int? ?? 1; + final elapsed = + now + .difference(activity.createdAt) + .inMilliseconds; + final currentProgressMs = + (progressMs + elapsed) % durationMs; + final progressValue = + currentProgressMs / durationMs; + if (progressValue != _endProgress) { + _startProgress = _endProgress; + _endProgress = progressValue; + _progressAnimation = Tween( + begin: _startProgress, + end: _endProgress, + ).animate(_progressController); + _progressController.forward(from: 0.0); + } + return AnimatedBuilder( + animation: _progressAnimation, + builder: (context, child) { + final animatedValue = + _progressAnimation.value; + final animatedProgressMs = + (animatedValue * durationMs) + .toInt(); + final currentMin = + animatedProgressMs ~/ 60000; + final currentSec = + (animatedProgressMs % 60000) ~/ + 1000; + final totalMin = durationMs ~/ 60000; + final totalSec = + (durationMs % 60000) ~/ 1000; + return Column( + crossAxisAlignment: + CrossAxisAlignment.start, + spacing: 4, + children: [ + LinearProgressIndicator( + value: animatedValue, + backgroundColor: + Colors.grey.shade300, + trackGap: 0, + stopIndicatorColor: Colors.green, + valueColor: + AlwaysStoppedAnimation( + Colors.green, + ), + ).padding(top: 3), + Text( + '${currentMin.toString().padLeft(2, '0')}:${currentSec.toString().padLeft(2, '0')} / ${totalMin.toString().padLeft(2, '0')}:${totalSec.toString().padLeft(2, '0')}', + style: TextStyle( + fontSize: 12, + color: Colors.green, + ), + ), + ], + ); + }, + ); + }, + ) + else + StreamBuilder( + stream: Stream.periodic( + const Duration(seconds: 1), + ), + builder: (context, snapshot) { + final now = DateTime.now(); + + final duration = now.difference( + activity.createdAt, + ); + final hours = duration.inHours + .toString() + .padLeft(2, '0'); + final minutes = (duration.inMinutes % 60) + .toString() + .padLeft(2, '0'); + final seconds = (duration.inSeconds % 60) + .toString() + .padLeft(2, '0'); + return Text( + '$hours:$minutes:$seconds', + ).textColor(Colors.green); + }, + ), + if (activity.subtitle?.isNotEmpty ?? false) + Row( + spacing: 2, + children: [ + Flexible(child: Text(activity.subtitle!)), + if (activity.titleUrl != null && + activity.titleUrl!.isNotEmpty) + IconButton( + onPressed: () { + launchUrlString(activity.titleUrl!); + }, + icon: const Icon( + Symbols.launch_rounded, + ), + iconSize: 16, + padding: EdgeInsets.all(4), + constraints: const BoxConstraints( + maxWidth: 28, + maxHeight: 28, + ), + ), + ], + ), + if (activity.caption?.isNotEmpty ?? false) + Text(activity.caption!), + ], + ), + ), + ).padding(horizontal: 8); + }), + ], + ).padding(horizontal: 8, top: 8, bottom: 16), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, stack) => + Center(child: Text('Error loading activities: $error')), + ); + }, ); } } diff --git a/lib/widgets/content/embed/link.dart b/lib/widgets/content/embed/link.dart index 6f1627d8..4e40179a 100644 --- a/lib/widgets/content/embed/link.dart +++ b/lib/widgets/content/embed/link.dart @@ -36,8 +36,9 @@ class _EmbedLinkWidgetState extends State { Future _checkIfSquare() async { if (widget.link.imageUrl == null || widget.link.imageUrl!.isEmpty || - widget.link.imageUrl == widget.link.faviconUrl) + widget.link.imageUrl == widget.link.faviconUrl) { return; + } try { final image = CachedNetworkImageProvider(widget.link.imageUrl!);