From a287684d4296e864e1ed7987f9918a58ec9b5c20 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 3 Jun 2025 00:15:28 +0200 Subject: [PATCH 01/20] Implement FlyerChatVideoMessage --- .../lib/src/flyer_chat_video_message.dart | 246 ++++++++++++++++++ .../src/widgets/full_screen_video_player.dart | 57 ++++ .../lib/src/widgets/hero_video_route.dart | 47 ++++ .../lib/src/widgets/time_and_status.dart | 72 +++++ .../flyer_chat_video_message/pubspec.yaml | 6 +- 5 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart create mode 100644 packages/flyer_chat_video_message/lib/src/widgets/hero_video_route.dart create mode 100644 packages/flyer_chat_video_message/lib/src/widgets/time_and_status.dart diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index 8b1378917..2475714f7 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -1 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; +import 'widgets/full_screen_video_player.dart'; +import 'widgets/hero_video_route.dart'; +import 'widgets/time_and_status.dart'; +/// A widget that displays an [VideoMessage]. +/// +/// Optionally displays upload progress if the [ChatController] +/// implements [UploadProgressMixin]. +class FlyerChatVideoMessage extends StatefulWidget { + /// The video message data model. + final VideoMessage message; + + /// Optional HTTP headers for authenticated video requests. + /// Commonly used for authorization tokens, e.g., {'Authorization': 'Bearer token'}. + final Map? headers; + + /// Border radius of the image container. + final BorderRadiusGeometry? borderRadius; + + /// Constraints for the image size. + final BoxConstraints? constraints; + + /// Background color used while the placeholder is visible. + final Color? placeholderColor; + + /// Color of the overlay shown during image loading. + final Color? loadingOverlayColor; + + /// Color of the circular progress indicator shown during image loading. + final Color? loadingIndicatorColor; + + /// Color of the overlay shown during image upload. + final Color? uploadOverlayColor; + + /// Color of the circular progress indicator shown during image upload. + final Color? uploadIndicatorColor; + + /// Text style for the message timestamp and status. + final TextStyle? timeStyle; + + /// Background color for the timestamp and status display. + final Color? timeBackground; + + /// Whether to display the message timestamp. + final bool showTime; + + /// Whether to display the message status (sent, delivered, seen) for sent messages. + final bool showStatus; + + /// Position of the timestamp and status indicator relative to the image. + final TimeAndStatusPosition timeAndStatusPosition; + + /// Wheter to use the root navigator to push the video player. + final bool useRootNavigator; + + /// Background color for the full screen video player. + final Color? fullScreenPlayerBackgroundColor; + + /// Icon data to display as the play button overlay. + final IconData playIcon; + + /// Size of the play icon. + final double playIconSize; + + /// Color of the play icon. + final Color playIconColor; + + /// Creates a widget to display an image message. + const FlyerChatVideoMessage({ + super.key, + required this.message, + this.headers, + this.borderRadius, + this.constraints = const BoxConstraints(maxHeight: 300), + this.placeholderColor, + this.loadingOverlayColor, + this.loadingIndicatorColor, + this.uploadOverlayColor, + this.uploadIndicatorColor, + this.timeStyle, + this.timeBackground, + this.showTime = true, + this.showStatus = true, + this.timeAndStatusPosition = TimeAndStatusPosition.end, + this.useRootNavigator = false, + this.fullScreenPlayerBackgroundColor, + this.playIcon = Icons.play_circle_fill, + this.playIconSize = 48, + this.playIconColor = Colors.white, + }); + + @override + // ignore: library_private_types_in_public_api + _FlyerChatVideoMessageState createState() => _FlyerChatVideoMessageState(); +} + +/// State for [FlyerChatVideoMessage]. +class _FlyerChatVideoMessageState extends State { + late final ChatController _chatController; + VideoPlayerController? _videoPlayerController; + + @override + void initState() { + super.initState(); + _chatController = context.read(); + _initalizeVideoPlayerAsync(); + } + + Future _initalizeVideoPlayerAsync() async { + _videoPlayerController = VideoPlayerController.networkUrl( + Uri.parse(widget.message.source), + httpHeaders: widget.headers ?? {}, + ); + await _videoPlayerController!.initialize(); + setState(() {}); + } + + @override + void dispose() { + _videoPlayerController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = context.watch(); + final isSentByMe = context.watch() == widget.message.authorId; + final textDirection = Directionality.of(context); + final timeAndStatus = + widget.showTime || (isSentByMe && widget.showStatus) + ? TimeAndStatus( + time: widget.message.time, + status: widget.message.status, + showTime: widget.showTime, + showStatus: isSentByMe && widget.showStatus, + backgroundColor: + widget.timeBackground ?? Colors.black.withValues(alpha: 0.6), + textStyle: + widget.timeStyle ?? + theme.typography.labelSmall.copyWith(color: Colors.white), + ) + : null; + + return ClipRRect( + borderRadius: widget.borderRadius ?? theme.shape, + child: Container( + constraints: widget.constraints, + child: AspectRatio( + aspectRatio: + _videoPlayerController?.value.isInitialized == true + ? _videoPlayerController!.value.aspectRatio + : 16 / 9, // fallback aspect ratio + child: GestureDetector( + onTap: () { + Navigator.of( + context, + rootNavigator: widget.useRootNavigator, + ).push( + HeroVideoRoute( + fullscreenDialog: true, + builder: + (_) => FullscreenVideoPlayer( + videoUrl: widget.message.source, + heroTag: widget.message.id, + backgroundColor: widget.fullScreenPlayerBackgroundColor, + ), + ), + ); + }, + child: Stack( + fit: StackFit.expand, + children: [ + Hero( + tag: widget.message.id, + child: + _videoPlayerController?.value.isInitialized == true + ? VideoPlayer(_videoPlayerController!) + : Container( + color: widget.placeholderColor ?? Colors.black12, + child: const Center( + child: CircularProgressIndicator(), + ), + ), + ), + Icon( + widget.playIcon, + size: widget.playIconSize, + color: widget.playIconColor, + ), + + if (_chatController is UploadProgressMixin) + StreamBuilder( + stream: (_chatController as UploadProgressMixin) + .getUploadProgress(widget.message.id), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data! >= 1) { + return const SizedBox(); + } + + return Container( + color: + widget.uploadOverlayColor ?? + theme.colors.surfaceContainerLow.withValues( + alpha: 0.5, + ), + child: Center( + child: CircularProgressIndicator( + color: + widget.uploadIndicatorColor ?? + theme.colors.onSurface.withValues(alpha: 0.8), + strokeCap: StrokeCap.round, + value: snapshot.data, + ), + ), + ); + }, + ), + if (timeAndStatus != null) + Positioned.directional( + textDirection: textDirection, + bottom: 8, + end: + widget.timeAndStatusPosition == + TimeAndStatusPosition.end || + widget.timeAndStatusPosition == + TimeAndStatusPosition.inline + ? 8 + : null, + start: + widget.timeAndStatusPosition == + TimeAndStatusPosition.start + ? 8 + : null, + child: timeAndStatus, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart new file mode 100644 index 000000000..6132ee630 --- /dev/null +++ b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart @@ -0,0 +1,57 @@ +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +class FullscreenVideoPlayer extends StatefulWidget { + final String videoUrl; + final String heroTag; + final Color? backgroundColor; + + const FullscreenVideoPlayer({ + super.key, + required this.videoUrl, + required this.heroTag, + this.backgroundColor, + }); + + @override + State createState() => _FullscreenVideoPlayerState(); +} + +class _FullscreenVideoPlayerState extends State { + late VideoPlayerController _controller; + late ChewieController _chewieController; + + @override + void initState() { + super.initState(); + _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); + _chewieController = ChewieController( + videoPlayerController: _controller, + autoPlay: true, + allowFullScreen: false, + autoInitialize: true, + ); + } + + @override + void dispose() { + _chewieController.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + backgroundColor: widget.backgroundColor, + body: SafeArea( + child: Hero( + tag: widget.heroTag, + child: Center(child: Chewie(controller: _chewieController)), + ), + ), + ); + } +} diff --git a/packages/flyer_chat_video_message/lib/src/widgets/hero_video_route.dart b/packages/flyer_chat_video_message/lib/src/widgets/hero_video_route.dart new file mode 100644 index 000000000..c8dd07921 --- /dev/null +++ b/packages/flyer_chat_video_message/lib/src/widgets/hero_video_route.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +class HeroVideoRoute extends PageRoute { + // The constructor takes a WidgetBuilder and an optional fullscreenDialog flag. + // The WidgetBuilder is assigned to _builder and the fullscreenDialog flag is passed to the superclass constructor. + HeroVideoRoute({required WidgetBuilder builder, super.fullscreenDialog}) + : _builder = builder; + + final WidgetBuilder _builder; + + @override + Duration get transitionDuration => const Duration(milliseconds: 300); + + @override + bool get maintainState => false; + + // The color of the barrier (the area outside the content) + @override + Color get barrierColor => Colors.transparent; + + // This method builds the transition animation for the route. + // In this case, it simply returns the child widget as is, without any transition. + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return child; + } + + // This method builds the page to be displayed by the route. + // It uses the _builder provided in the constructor to build the page. + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return _builder(context); + } + + // This is the semantic label for the barrier. It is used by screen reading software for visually impaired users. + @override + String get barrierLabel => 'Video player open'; +} diff --git a/packages/flyer_chat_video_message/lib/src/widgets/time_and_status.dart b/packages/flyer_chat_video_message/lib/src/widgets/time_and_status.dart new file mode 100644 index 000000000..ef39a93c3 --- /dev/null +++ b/packages/flyer_chat_video_message/lib/src/widgets/time_and_status.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:provider/provider.dart'; + +/// A widget to display the message timestamp and status indicator over an image. +class TimeAndStatus extends StatelessWidget { + /// The time the message was created. + final DateTime? time; + + /// The status of the message. + final MessageStatus? status; + + /// Whether to display the timestamp. + final bool showTime; + + /// Whether to display the status indicator. + final bool showStatus; + + /// Background color for the time and status container. + final Color? backgroundColor; + + /// Text style for the time and status. + final TextStyle? textStyle; + + /// Creates a widget for displaying time and status over an image. + const TimeAndStatus({ + super.key, + required this.time, + this.status, + this.showTime = true, + this.showStatus = true, + this.backgroundColor, + this.textStyle, + }); + + @override + Widget build(BuildContext context) { + final timeFormat = context.watch(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + spacing: 2, + mainAxisSize: MainAxisSize.min, + children: [ + if (showTime && time != null) + Text(timeFormat.format(time!.toLocal()), style: textStyle), + if (showStatus && status != null) + if (status == MessageStatus.sending) + SizedBox( + width: 6, + height: 6, + child: CircularProgressIndicator( + color: textStyle?.color, + strokeWidth: 2, + ), + ) + else + Icon( + getIconForStatus(status!), + color: textStyle?.color, + size: 12, + ), + ], + ), + ); + } +} diff --git a/packages/flyer_chat_video_message/pubspec.yaml b/packages/flyer_chat_video_message/pubspec.yaml index 7723f73e3..20518aafd 100644 --- a/packages/flyer_chat_video_message/pubspec.yaml +++ b/packages/flyer_chat_video_message/pubspec.yaml @@ -1,5 +1,5 @@ name: flyer_chat_video_message -version: 0.0.12+2 +version: 0.0.15 description: > Video message package for Flutter chat apps, complementing flutter_chat_ui. #chat #ui homepage: https://flyer.chat @@ -10,8 +10,12 @@ environment: flutter: ">=3.29.0" dependencies: + chewie: ^1.11.3 flutter: sdk: flutter + flutter_chat_core: ^2.4.0 + provider: ^6.1.4 + video_player: ^2.9.5 dev_dependencies: flutter_lints: ^6.0.0 From e0e3ce1e4b863242b341e26ad97cb617f268b7f9 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 3 Jun 2025 00:15:47 +0200 Subject: [PATCH 02/20] Add example --- examples/flyer_chat/ios/Podfile.lock | 8 ++++---- examples/flyer_chat/lib/local.dart | 29 ++++++++++++++++++++++++++++ examples/flyer_chat/pubspec.yaml | 1 + 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/examples/flyer_chat/ios/Podfile.lock b/examples/flyer_chat/ios/Podfile.lock index 81472501a..5f2574a8d 100644 --- a/examples/flyer_chat/ios/Podfile.lock +++ b/examples/flyer_chat/ios/Podfile.lock @@ -43,9 +43,9 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - SDWebImage (5.21.0): - - SDWebImage/Core (= 5.21.0) - - SDWebImage/Core (5.21.0) + - SDWebImage (5.21.1): + - SDWebImage/Core (= 5.21.1) + - SDWebImage/Core (5.21.1) - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter @@ -91,7 +91,7 @@ SPEC CHECKSUMS: integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e isar_flutter_libs: 9fc2cfb928c539e1b76c481ba5d143d556d94920 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 + SDWebImage: f29024626962457f3470184232766516dee8dfea SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d diff --git a/examples/flyer_chat/lib/local.dart b/examples/flyer_chat/lib/local.dart index 11a893f9d..f31d62f84 100644 --- a/examples/flyer_chat/lib/local.dart +++ b/examples/flyer_chat/lib/local.dart @@ -12,6 +12,7 @@ import 'package:flyer_chat_file_message/flyer_chat_file_message.dart'; import 'package:flyer_chat_image_message/flyer_chat_image_message.dart'; import 'package:flyer_chat_system_message/flyer_chat_system_message.dart'; import 'package:flyer_chat_text_message/flyer_chat_text_message.dart'; +import 'package:flyer_chat_video_message/flyer_chat_video_message.dart'; import 'package:image_picker/image_picker.dart'; import 'package:pull_down_button/pull_down_button.dart'; import 'package:uuid/uuid.dart'; @@ -98,6 +99,15 @@ class LocalState extends State { required bool isSentByMe, MessageGroupStatus? groupStatus, }) => FlyerChatImageMessage(message: message, index: index), + + videoMessageBuilder: + ( + context, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => FlyerChatVideoMessage(message: message), systemMessageBuilder: ( context, @@ -449,6 +459,25 @@ class LocalState extends State { } }, ), + ListTile( + leading: const Icon(Icons.video_camera_front), + title: const Text('Video'), + onTap: () async { + Navigator.pop(context); + + // Create a proper file message + final fileMessage = VideoMessage( + id: _uuid.v4(), + authorId: _currentUser.id, + createdAt: DateTime.now().toUtc(), + sentAt: DateTime.now().toUtc(), + source: + 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4', + ); + + await _chatController.insertMessage(fileMessage); + }, + ), ], ), ); diff --git a/examples/flyer_chat/pubspec.yaml b/examples/flyer_chat/pubspec.yaml index 0ac6e3ca9..3197f6d4d 100644 --- a/examples/flyer_chat/pubspec.yaml +++ b/examples/flyer_chat/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: flyer_chat_system_message: ^2.1.13 flyer_chat_text_message: ^2.5.1 flyer_chat_text_stream_message: ^2.2.6 + flyer_chat_video_message: ^0.0.15 google_generative_ai: ^0.4.6 hive_ce: ^2.11.3 hive_ce_flutter: ^2.3.1 From 8af596e33dd09e9a633ac6d6be98bc25d66d08b3 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 3 Jun 2025 09:31:30 +0200 Subject: [PATCH 03/20] Use same default IndicatorColor as FlyerChatImage --- .../lib/src/flyer_chat_video_message.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index 2475714f7..fd8619829 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -181,8 +181,15 @@ class _FlyerChatVideoMessageState extends State { ? VideoPlayer(_videoPlayerController!) : Container( color: widget.placeholderColor ?? Colors.black12, - child: const Center( - child: CircularProgressIndicator(), + child: Center( + child: CircularProgressIndicator( + color: + widget + .fullScreenPlayerLoadingIndicatorColor ?? + theme.colors.onSurface.withValues( + alpha: 0.8, + ), + ), ), ), ), From 1a73ddf17fdd1d2baeaa0c06b3b32184812bae5f Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 3 Jun 2025 09:33:03 +0200 Subject: [PATCH 04/20] Handle local file playback --- .../lib/src/flyer_chat_video_message.dart | 34 +++++++++++-- .../lib/src/helpers/is_network_source.dart | 3 ++ .../src/widgets/full_screen_video_player.dart | 50 +++++++++++++++---- 3 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 packages/flyer_chat_video_message/lib/src/helpers/is_network_source.dart diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index fd8619829..811e8c8d4 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -1,7 +1,10 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; +import 'helpers/is_network_source.dart'; import 'widgets/full_screen_video_player.dart'; import 'widgets/hero_video_route.dart'; import 'widgets/time_and_status.dart'; @@ -60,6 +63,9 @@ class FlyerChatVideoMessage extends StatefulWidget { /// Background color for the full screen video player. final Color? fullScreenPlayerBackgroundColor; + /// Background color for the full screen video player. + final Color? fullScreenPlayerLoadingIndicatorColor; + /// Icon data to display as the play button overlay. final IconData playIcon; @@ -88,6 +94,7 @@ class FlyerChatVideoMessage extends StatefulWidget { this.timeAndStatusPosition = TimeAndStatusPosition.end, this.useRootNavigator = false, this.fullScreenPlayerBackgroundColor, + this.fullScreenPlayerLoadingIndicatorColor, this.playIcon = Icons.play_circle_fill, this.playIconSize = 48, this.playIconColor = Colors.white, @@ -111,14 +118,28 @@ class _FlyerChatVideoMessageState extends State { } Future _initalizeVideoPlayerAsync() async { - _videoPlayerController = VideoPlayerController.networkUrl( - Uri.parse(widget.message.source), - httpHeaders: widget.headers ?? {}, - ); + if (isNetworkSource(widget.message.source)) { + _videoPlayerController = VideoPlayerController.networkUrl( + Uri.parse(widget.message.source), + httpHeaders: widget.headers ?? {}, + ); + } else { + _videoPlayerController = VideoPlayerController.file( + File(widget.message.source), + ); + } await _videoPlayerController!.initialize(); setState(() {}); } + @override + void didUpdateWidget(FlyerChatVideoMessage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message.source != widget.message.source) { + _initalizeVideoPlayerAsync(); + } + } + @override void dispose() { _videoPlayerController?.dispose(); @@ -164,9 +185,12 @@ class _FlyerChatVideoMessageState extends State { fullscreenDialog: true, builder: (_) => FullscreenVideoPlayer( - videoUrl: widget.message.source, + source: widget.message.source, heroTag: widget.message.id, backgroundColor: widget.fullScreenPlayerBackgroundColor, + loadingIndicatorColor: + widget.fullScreenPlayerLoadingIndicatorColor ?? + theme.colors.onSurface.withValues(alpha: 0.8), ), ), ); diff --git a/packages/flyer_chat_video_message/lib/src/helpers/is_network_source.dart b/packages/flyer_chat_video_message/lib/src/helpers/is_network_source.dart new file mode 100644 index 000000000..10e0c0df1 --- /dev/null +++ b/packages/flyer_chat_video_message/lib/src/helpers/is_network_source.dart @@ -0,0 +1,3 @@ +bool isNetworkSource(String source) { + return source.startsWith('http') || source.startsWith('blob'); +} diff --git a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart index 6132ee630..a98d81018 100644 --- a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart +++ b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart @@ -1,17 +1,23 @@ +import 'dart:io'; + import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; +import '../helpers/is_network_source.dart'; + class FullscreenVideoPlayer extends StatefulWidget { - final String videoUrl; + final String source; final String heroTag; final Color? backgroundColor; + final Color? loadingIndicatorColor; const FullscreenVideoPlayer({ super.key, - required this.videoUrl, + required this.source, required this.heroTag, this.backgroundColor, + this.loadingIndicatorColor, }); @override @@ -25,13 +31,25 @@ class _FullscreenVideoPlayerState extends State { @override void initState() { super.initState(); - _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); - _chewieController = ChewieController( - videoPlayerController: _controller, - autoPlay: true, - allowFullScreen: false, - autoInitialize: true, - ); + _initializeVideoPlayerAsync(); + } + + Future _initializeVideoPlayerAsync() async { + if (isNetworkSource(widget.source)) { + _controller = VideoPlayerController.networkUrl(Uri.parse(widget.source)); + } else { + _controller = VideoPlayerController.file(File(widget.source)); + } + + await _controller.initialize(); + setState(() { + _chewieController = ChewieController( + videoPlayerController: _controller, + autoPlay: true, + allowFullScreen: false, + autoInitialize: false, + ); + }); } @override @@ -49,7 +67,19 @@ class _FullscreenVideoPlayerState extends State { body: SafeArea( child: Hero( tag: widget.heroTag, - child: Center(child: Chewie(controller: _chewieController)), + child: + _controller.value.isInitialized + ? Center( + child: AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: Chewie(controller: _chewieController), + ), + ) + : Center( + child: CircularProgressIndicator( + color: widget.loadingIndicatorColor, + ), + ), ), ), ); From b3ad6eae6aa9b72b5cef1295d4ca37510d77a0ab Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 3 Jun 2025 10:01:45 +0200 Subject: [PATCH 05/20] Use aspect ratio if provided --- .../lib/src/flyer_chat_video_message.dart | 23 ++++++++--- .../src/widgets/full_screen_video_player.dart | 39 ++++++++++--------- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index 811e8c8d4..f852bcf24 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -109,10 +109,20 @@ class FlyerChatVideoMessage extends StatefulWidget { class _FlyerChatVideoMessageState extends State { late final ChatController _chatController; VideoPlayerController? _videoPlayerController; + late double _aspectRatio; @override void initState() { super.initState(); + + final height = widget.message.height; + final width = widget.message.width; + if (height != null && width != null && height > 0 && width > 0) { + _aspectRatio = width / height; + } else { + _aspectRatio = 9 / 16; + } + _chatController = context.read(); _initalizeVideoPlayerAsync(); } @@ -128,8 +138,11 @@ class _FlyerChatVideoMessageState extends State { File(widget.message.source), ); } - await _videoPlayerController!.initialize(); - setState(() {}); + await _videoPlayerController!.initialize().then((_) { + setState(() { + _aspectRatio = _videoPlayerController!.value.aspectRatio; + }); + }); } @override @@ -171,10 +184,7 @@ class _FlyerChatVideoMessageState extends State { child: Container( constraints: widget.constraints, child: AspectRatio( - aspectRatio: - _videoPlayerController?.value.isInitialized == true - ? _videoPlayerController!.value.aspectRatio - : 16 / 9, // fallback aspect ratio + aspectRatio: _aspectRatio, child: GestureDetector( onTap: () { Navigator.of( @@ -186,6 +196,7 @@ class _FlyerChatVideoMessageState extends State { builder: (_) => FullscreenVideoPlayer( source: widget.message.source, + aspectRatio: _aspectRatio, heroTag: widget.message.id, backgroundColor: widget.fullScreenPlayerBackgroundColor, loadingIndicatorColor: diff --git a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart index a98d81018..c93fb99da 100644 --- a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart +++ b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart @@ -11,6 +11,7 @@ class FullscreenVideoPlayer extends StatefulWidget { final String heroTag; final Color? backgroundColor; final Color? loadingIndicatorColor; + final double? aspectRatio; const FullscreenVideoPlayer({ super.key, @@ -18,6 +19,7 @@ class FullscreenVideoPlayer extends StatefulWidget { required this.heroTag, this.backgroundColor, this.loadingIndicatorColor, + this.aspectRatio, }); @override @@ -25,26 +27,29 @@ class FullscreenVideoPlayer extends StatefulWidget { } class _FullscreenVideoPlayerState extends State { - late VideoPlayerController _controller; + late VideoPlayerController _videoPlayer; late ChewieController _chewieController; + late double _aspectRatio; @override void initState() { super.initState(); + _aspectRatio = widget.aspectRatio ?? 16 / 9; _initializeVideoPlayerAsync(); } Future _initializeVideoPlayerAsync() async { if (isNetworkSource(widget.source)) { - _controller = VideoPlayerController.networkUrl(Uri.parse(widget.source)); + _videoPlayer = VideoPlayerController.networkUrl(Uri.parse(widget.source)); } else { - _controller = VideoPlayerController.file(File(widget.source)); + _videoPlayer = VideoPlayerController.file(File(widget.source)); } - await _controller.initialize(); + await _videoPlayer.initialize(); setState(() { + _aspectRatio = _videoPlayer.value.aspectRatio; _chewieController = ChewieController( - videoPlayerController: _controller, + videoPlayerController: _videoPlayer, autoPlay: true, allowFullScreen: false, autoInitialize: false, @@ -55,7 +60,7 @@ class _FullscreenVideoPlayerState extends State { @override void dispose() { _chewieController.dispose(); - _controller.dispose(); + _videoPlayer.dispose(); super.dispose(); } @@ -67,19 +72,17 @@ class _FullscreenVideoPlayerState extends State { body: SafeArea( child: Hero( tag: widget.heroTag, - child: - _controller.value.isInitialized - ? Center( - child: AspectRatio( - aspectRatio: _controller.value.aspectRatio, - child: Chewie(controller: _chewieController), + child: AspectRatio( + aspectRatio: _aspectRatio, + child: + _videoPlayer.value.isInitialized + ? Center(child: Chewie(controller: _chewieController)) + : Center( + child: CircularProgressIndicator( + color: widget.loadingIndicatorColor, + ), ), - ) - : Center( - child: CircularProgressIndicator( - color: widget.loadingIndicatorColor, - ), - ), + ), ), ), ); From df3a265125c072143e4e64a2e4b15f252b0d7287 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 3 Jun 2025 10:16:26 +0200 Subject: [PATCH 06/20] Fix centering on web --- .../src/widgets/full_screen_video_player.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart index c93fb99da..29796520b 100644 --- a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart +++ b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart @@ -72,16 +72,16 @@ class _FullscreenVideoPlayerState extends State { body: SafeArea( child: Hero( tag: widget.heroTag, - child: AspectRatio( - aspectRatio: _aspectRatio, - child: - _videoPlayer.value.isInitialized - ? Center(child: Chewie(controller: _chewieController)) - : Center( - child: CircularProgressIndicator( + child: Center( + child: AspectRatio( + aspectRatio: _aspectRatio, + child: + _videoPlayer.value.isInitialized + ? Chewie(controller: _chewieController) + : CircularProgressIndicator( color: widget.loadingIndicatorColor, ), - ), + ), ), ), ), From f5d266e9cd30cb35244beda81c648d37a9103dc6 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 3 Jun 2025 10:16:38 +0200 Subject: [PATCH 07/20] Update example --- examples/flyer_chat/lib/local.dart | 46 ++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/examples/flyer_chat/lib/local.dart b/examples/flyer_chat/lib/local.dart index f31d62f84..9f6ccdad5 100644 --- a/examples/flyer_chat/lib/local.dart +++ b/examples/flyer_chat/lib/local.dart @@ -464,18 +464,46 @@ class LocalState extends State { title: const Text('Video'), onTap: () async { Navigator.pop(context); + // Uncomment to use proper path + // Hardcoding for testing since the simulator library doesn't expose video files - // Create a proper file message - final fileMessage = VideoMessage( - id: _uuid.v4(), - authorId: _currentUser.id, - createdAt: DateTime.now().toUtc(), - sentAt: DateTime.now().toUtc(), - source: - 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4', + final picker = ImagePicker(); + final result = await picker.pickVideo( + source: ImageSource.gallery, ); - await _chatController.insertMessage(fileMessage); + if (result != null) { + // Optionally get the file size + // final fileSizeInBytes = await result.length(); + // Note to get the height/width of the video, you can use the following: + // final controller = VideoPlayerController.file(file); + // await controller.initialize(); + // final width = controller.value.size.width; + // final height = controller.value.size.height; + + // Create a proper file message + final videoMessage = VideoMessage( + id: _uuid.v4(), + authorId: _currentUser.id, + createdAt: DateTime.now().toUtc(), + sentAt: DateTime.now().toUtc(), + // Uncomment to use proper path + // Hardcoding for testing since the simulator library doesn't expose video files + source: result.path, + + // size: fileSizeInBytes, + ); + await _chatController.insertMessage(videoMessage); + } + + // final videoMessage = VideoMessage( + // id: _uuid.v4(), + // authorId: _currentUser.id, + // createdAt: DateTime.now().toUtc(), + // source: + // 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4', + // ); + // await _chatController.insertMessage(videoMessage); }, ), ], From 7e96888ec6fa2e58cc0dde3f3fe216186163bfde Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 3 Jun 2025 16:53:55 +0200 Subject: [PATCH 08/20] Fix CircularProgressIndicator size --- .../lib/src/widgets/full_screen_video_player.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart index 29796520b..ff24b2721 100644 --- a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart +++ b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart @@ -78,8 +78,13 @@ class _FullscreenVideoPlayerState extends State { child: _videoPlayer.value.isInitialized ? Chewie(controller: _chewieController) - : CircularProgressIndicator( - color: widget.loadingIndicatorColor, + : Container( + width: 40, + height: 40, + alignment: Alignment.center, + child: CircularProgressIndicator( + color: widget.loadingIndicatorColor, + ), ), ), ), From 16addd369c9fc50a6bad1a5bb4c0b7eeca3bc5e2 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 3 Jun 2025 17:09:12 +0200 Subject: [PATCH 09/20] Fix color param, use same logic as textMessage --- .../lib/src/flyer_chat_video_message.dart | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index f852bcf24..83c3c9c0d 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -27,11 +27,11 @@ class FlyerChatVideoMessage extends StatefulWidget { /// Constraints for the image size. final BoxConstraints? constraints; - /// Background color used while the placeholder is visible. - final Color? placeholderColor; + /// Color of the overlay shown during video loading for a sent message + final Color? sentLoadingOverlayColor; - /// Color of the overlay shown during image loading. - final Color? loadingOverlayColor; + /// Color of the overlay shown during video loading for a received message + final Color? receivedLoadingOverlayColor; /// Color of the circular progress indicator shown during image loading. final Color? loadingIndicatorColor; @@ -82,8 +82,8 @@ class FlyerChatVideoMessage extends StatefulWidget { this.headers, this.borderRadius, this.constraints = const BoxConstraints(maxHeight: 300), - this.placeholderColor, - this.loadingOverlayColor, + this.sentLoadingOverlayColor, + this.receivedLoadingOverlayColor, this.loadingIndicatorColor, this.uploadOverlayColor, this.uploadIndicatorColor, @@ -215,7 +215,7 @@ class _FlyerChatVideoMessageState extends State { _videoPlayerController?.value.isInitialized == true ? VideoPlayer(_videoPlayerController!) : Container( - color: widget.placeholderColor ?? Colors.black12, + color: _resolveBackgroundColor(isSentByMe, theme), child: Center( child: CircularProgressIndicator( color: @@ -286,4 +286,11 @@ class _FlyerChatVideoMessageState extends State { ), ); } + + Color? _resolveBackgroundColor(bool isSentByMe, ChatTheme theme) { + if (isSentByMe) { + return widget.sentLoadingOverlayColor ?? theme.colors.primary; + } + return widget.receivedLoadingOverlayColor ?? theme.colors.surfaceContainer; + } } From 8a772e2556f2b54cf574632af4157513634a9498 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 3 Jun 2025 17:09:26 +0200 Subject: [PATCH 10/20] Cleanup parameters description --- .../lib/src/flyer_chat_video_message.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index 83c3c9c0d..df6f44190 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -21,10 +21,10 @@ class FlyerChatVideoMessage extends StatefulWidget { /// Commonly used for authorization tokens, e.g., {'Authorization': 'Bearer token'}. final Map? headers; - /// Border radius of the image container. + /// Border radius of the video container. final BorderRadiusGeometry? borderRadius; - /// Constraints for the image size. + /// Constraints for the video size. final BoxConstraints? constraints; /// Color of the overlay shown during video loading for a sent message @@ -33,13 +33,13 @@ class FlyerChatVideoMessage extends StatefulWidget { /// Color of the overlay shown during video loading for a received message final Color? receivedLoadingOverlayColor; - /// Color of the circular progress indicator shown during image loading. + /// Color of the circular progress indicator shown during video loading. final Color? loadingIndicatorColor; - /// Color of the overlay shown during image upload. + /// Color of the overlay shown during video upload. final Color? uploadOverlayColor; - /// Color of the circular progress indicator shown during image upload. + /// Color of the circular progress indicator shown during video upload. final Color? uploadIndicatorColor; /// Text style for the message timestamp and status. @@ -54,7 +54,7 @@ class FlyerChatVideoMessage extends StatefulWidget { /// Whether to display the message status (sent, delivered, seen) for sent messages. final bool showStatus; - /// Position of the timestamp and status indicator relative to the image. + /// Position of the timestamp and status indicator relative to the video. final TimeAndStatusPosition timeAndStatusPosition; /// Wheter to use the root navigator to push the video player. @@ -75,7 +75,7 @@ class FlyerChatVideoMessage extends StatefulWidget { /// Color of the play icon. final Color playIconColor; - /// Creates a widget to display an image message. + /// Creates a widget to display an video message. const FlyerChatVideoMessage({ super.key, required this.message, From 65b3df0d51ade96504201b6ef96c6eff1c5d8482 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 5 Jun 2025 09:30:56 +0200 Subject: [PATCH 11/20] Add thumbHash support --- examples/flyer_chat/lib/local.dart | 119 +++++++++++++++--- examples/flyer_chat/pubspec.yaml | 3 + .../lib/src/models/message.dart | 3 + .../lib/src/models/message.freezed.dart | 17 +-- .../lib/src/models/message.g.dart | 2 + .../lib/src/flyer_chat_video_message.dart | 27 ++++ .../flyer_chat_video_message/pubspec.yaml | 1 + 7 files changed, 150 insertions(+), 22 deletions(-) diff --git a/examples/flyer_chat/lib/local.dart b/examples/flyer_chat/lib/local.dart index 9f6ccdad5..cc80644bb 100644 --- a/examples/flyer_chat/lib/local.dart +++ b/examples/flyer_chat/lib/local.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:math'; import 'package:dio/dio.dart'; @@ -13,9 +14,12 @@ import 'package:flyer_chat_image_message/flyer_chat_image_message.dart'; import 'package:flyer_chat_system_message/flyer_chat_system_message.dart'; import 'package:flyer_chat_text_message/flyer_chat_text_message.dart'; import 'package:flyer_chat_video_message/flyer_chat_video_message.dart'; +import 'package:image/image.dart' as img; import 'package:image_picker/image_picker.dart'; import 'package:pull_down_button/pull_down_button.dart'; +import 'package:thumbhash/thumbhash.dart' show rgbaToThumbHash; import 'package:uuid/uuid.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; import 'create_message.dart'; import 'widgets/composer_action_bar.dart'; @@ -464,8 +468,7 @@ class LocalState extends State { title: const Text('Video'), onTap: () async { Navigator.pop(context); - // Uncomment to use proper path - // Hardcoding for testing since the simulator library doesn't expose video files + // Uncomment to use picker instead of hardcoding the video url final picker = ImagePicker(); final result = await picker.pickVideo( @@ -473,13 +476,52 @@ class LocalState extends State { ); if (result != null) { - // Optionally get the file size - // final fileSizeInBytes = await result.length(); - // Note to get the height/width of the video, you can use the following: - // final controller = VideoPlayerController.file(file); - // await controller.initialize(); - // final width = controller.value.size.width; - // final height = controller.value.size.height; + String? thumbHash; + int? width; + int? height; + int? fileSizeInBytes; + try { + // Optionally get the file size + fileSizeInBytes = await result.length(); + + // Get the video width and height + final fullSizeimageBytes = + await VideoThumbnail.thumbnailData( + video: result.path, + imageFormat: ImageFormat.WEBP, + quality: 1, + ); + + final fullSizedecoded = img.decodeImage( + fullSizeimageBytes!, + ); + if (fullSizedecoded != null) { + width = fullSizedecoded.width; + height = fullSizedecoded.height; + } + + // Generate the thumbhash + final thumbSizeImageBytes = + await VideoThumbnail.thumbnailData( + video: result.path, + imageFormat: ImageFormat.WEBP, + maxWidth: 100, + maxHeight: 100, + quality: 25, + ); + final decoded = img.decodeImage(thumbSizeImageBytes!); + if (decoded != null) { + final thumbHashBytes = rgbaToThumbHash( + decoded.width, + decoded.height, + decoded.getBytes(), + ); + + thumbHash = base64.encode(thumbHashBytes); + } + } catch (e) { + debugPrint(e.toString()); + } // Create a proper file message final videoMessage = VideoMessage( @@ -487,21 +529,68 @@ class LocalState extends State { authorId: _currentUser.id, createdAt: DateTime.now().toUtc(), sentAt: DateTime.now().toUtc(), - // Uncomment to use proper path - // Hardcoding for testing since the simulator library doesn't expose video files source: result.path, - - // size: fileSizeInBytes, + thumbhash: thumbHash, + width: width?.toDouble(), + height: height?.toDouble(), + size: fileSizeInBytes, ); await _chatController.insertMessage(videoMessage); } + // const videoUrl = + // 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4'; + // String? thumbHash; + // int? width; + // int? height; + // try { + // // Get the video width and height + // final fullSizeimageBytes = + // await VideoThumbnail.thumbnailData( + // video: videoUrl, + // imageFormat: ImageFormat.WEBP, + // quality: 1, + // ); + + // final fullSizedecoded = img.decodeImage( + // fullSizeimageBytes!, + // ); + // if (fullSizedecoded != null) { + // width = fullSizedecoded.width; + // height = fullSizedecoded.height; + // } + + // // Generate the thumbhash + // final thumbSizeImageBytes = + // await VideoThumbnail.thumbnailData( + // video: videoUrl, + // imageFormat: ImageFormat.WEBP, + // maxWidth: 100, + // maxHeight: 100, + // quality: 25, + // ); + // final decoded = img.decodeImage(thumbSizeImageBytes!); + // if (decoded != null) { + // final thumbHashBytes = rgbaToThumbHash( + // decoded.width, + // decoded.height, + // decoded.getBytes(), + // ); + + // thumbHash = base64.encode(thumbHashBytes); + // } + // } catch (e) { + // debugPrint(e.toString()); + // } + // final videoMessage = VideoMessage( // id: _uuid.v4(), // authorId: _currentUser.id, // createdAt: DateTime.now().toUtc(), - // source: - // 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4', + // source: videoUrl, + // thumbhash: thumbHash, + // width: width?.toDouble(), + // height: height?.toDouble(), // ); // await _chatController.insertMessage(videoMessage); }, diff --git a/examples/flyer_chat/pubspec.yaml b/examples/flyer_chat/pubspec.yaml index 3197f6d4d..d8748e46e 100644 --- a/examples/flyer_chat/pubspec.yaml +++ b/examples/flyer_chat/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: hive_ce: ^2.11.3 hive_ce_flutter: ^2.3.1 http: ^1.4.0 + image: ^4.5.4 image_picker: ^1.1.2 intl: ^0.20.2 isar_flutter_libs: ^4.0.0-dev.14 @@ -58,7 +59,9 @@ dependencies: path_provider: ^2.1.5 provider: ^6.1.5 pull_down_button: ^0.10.2 + thumbhash: ^0.1.0+1 uuid: ^4.5.1 + video_thumbnail: ^0.5.6 web_socket_channel: ^3.0.3 dev_dependencies: diff --git a/packages/flutter_chat_core/lib/src/models/message.dart b/packages/flutter_chat_core/lib/src/models/message.dart index 79bce33c9..388494049 100644 --- a/packages/flutter_chat_core/lib/src/models/message.dart +++ b/packages/flutter_chat_core/lib/src/models/message.dart @@ -309,6 +309,9 @@ sealed class Message with _$Message { /// Height of the video in pixels. double? height, + + /// ThumbHash string for a low-resolution placeholder. + String? thumbhash, }) = VideoMessage; /// Creates an audio message. diff --git a/packages/flutter_chat_core/lib/src/models/message.freezed.dart b/packages/flutter_chat_core/lib/src/models/message.freezed.dart index c360092a4..03db62ec0 100644 --- a/packages/flutter_chat_core/lib/src/models/message.freezed.dart +++ b/packages/flutter_chat_core/lib/src/models/message.freezed.dart @@ -740,7 +740,7 @@ as String?, @JsonSerializable() class VideoMessage extends Message { - const VideoMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final Map>? reactions, this.pinned, final Map? metadata, this.status, required this.source, this.text, this.name, this.size, this.width, this.height, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'video',super._(); + const VideoMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final Map>? reactions, this.pinned, final Map? metadata, this.status, required this.source, this.text, this.name, this.size, this.width, this.height, this.thumbhash, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'video',super._(); factory VideoMessage.fromJson(Map json) => _$VideoMessageFromJson(json); /// Unique identifier for the message. @@ -802,6 +802,8 @@ class VideoMessage extends Message { final double? width; /// Height of the video in pixels. final double? height; +/// ThumbHash string for a low-resolution placeholder. + final String? thumbhash; @JsonKey(name: 'type') final String $type; @@ -820,16 +822,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is VideoMessage&&(identical(other.id, id) || other.id == id)&&(identical(other.authorId, authorId) || other.authorId == authorId)&&(identical(other.replyToMessageId, replyToMessageId) || other.replyToMessageId == replyToMessageId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.failedAt, failedAt) || other.failedAt == failedAt)&&(identical(other.sentAt, sentAt) || other.sentAt == sentAt)&&(identical(other.deliveredAt, deliveredAt) || other.deliveredAt == deliveredAt)&&(identical(other.seenAt, seenAt) || other.seenAt == seenAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&const DeepCollectionEquality().equals(other._reactions, _reactions)&&(identical(other.pinned, pinned) || other.pinned == pinned)&&const DeepCollectionEquality().equals(other._metadata, _metadata)&&(identical(other.status, status) || other.status == status)&&(identical(other.source, source) || other.source == source)&&(identical(other.text, text) || other.text == text)&&(identical(other.name, name) || other.name == name)&&(identical(other.size, size) || other.size == size)&&(identical(other.width, width) || other.width == width)&&(identical(other.height, height) || other.height == height)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is VideoMessage&&(identical(other.id, id) || other.id == id)&&(identical(other.authorId, authorId) || other.authorId == authorId)&&(identical(other.replyToMessageId, replyToMessageId) || other.replyToMessageId == replyToMessageId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.failedAt, failedAt) || other.failedAt == failedAt)&&(identical(other.sentAt, sentAt) || other.sentAt == sentAt)&&(identical(other.deliveredAt, deliveredAt) || other.deliveredAt == deliveredAt)&&(identical(other.seenAt, seenAt) || other.seenAt == seenAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&const DeepCollectionEquality().equals(other._reactions, _reactions)&&(identical(other.pinned, pinned) || other.pinned == pinned)&&const DeepCollectionEquality().equals(other._metadata, _metadata)&&(identical(other.status, status) || other.status == status)&&(identical(other.source, source) || other.source == source)&&(identical(other.text, text) || other.text == text)&&(identical(other.name, name) || other.name == name)&&(identical(other.size, size) || other.size == size)&&(identical(other.width, width) || other.width == width)&&(identical(other.height, height) || other.height == height)&&(identical(other.thumbhash, thumbhash) || other.thumbhash == thumbhash)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hashAll([runtimeType,id,authorId,replyToMessageId,createdAt,deletedAt,failedAt,sentAt,deliveredAt,seenAt,updatedAt,const DeepCollectionEquality().hash(_reactions),pinned,const DeepCollectionEquality().hash(_metadata),status,source,text,name,size,width,height]); +int get hashCode => Object.hashAll([runtimeType,id,authorId,replyToMessageId,createdAt,deletedAt,failedAt,sentAt,deliveredAt,seenAt,updatedAt,const DeepCollectionEquality().hash(_reactions),pinned,const DeepCollectionEquality().hash(_metadata),status,source,text,name,size,width,height,thumbhash]); @override String toString() { - return 'Message.video(id: $id, authorId: $authorId, replyToMessageId: $replyToMessageId, createdAt: $createdAt, deletedAt: $deletedAt, failedAt: $failedAt, sentAt: $sentAt, deliveredAt: $deliveredAt, seenAt: $seenAt, updatedAt: $updatedAt, reactions: $reactions, pinned: $pinned, metadata: $metadata, status: $status, source: $source, text: $text, name: $name, size: $size, width: $width, height: $height)'; + return 'Message.video(id: $id, authorId: $authorId, replyToMessageId: $replyToMessageId, createdAt: $createdAt, deletedAt: $deletedAt, failedAt: $failedAt, sentAt: $sentAt, deliveredAt: $deliveredAt, seenAt: $seenAt, updatedAt: $updatedAt, reactions: $reactions, pinned: $pinned, metadata: $metadata, status: $status, source: $source, text: $text, name: $name, size: $size, width: $width, height: $height, thumbhash: $thumbhash)'; } @@ -840,7 +842,7 @@ abstract mixin class $VideoMessageCopyWith<$Res> implements $MessageCopyWith<$Re factory $VideoMessageCopyWith(VideoMessage value, $Res Function(VideoMessage) _then) = _$VideoMessageCopyWithImpl; @override @useResult $Res call({ - MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, Map>? reactions, bool? pinned, Map? metadata, MessageStatus? status, String source, String? text, String? name, int? size, double? width, double? height + MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, Map>? reactions, bool? pinned, Map? metadata, MessageStatus? status, String source, String? text, String? name, int? size, double? width, double? height, String? thumbhash }); @@ -857,7 +859,7 @@ class _$VideoMessageCopyWithImpl<$Res> /// Create a copy of Message /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? authorId = null,Object? replyToMessageId = freezed,Object? createdAt = freezed,Object? deletedAt = freezed,Object? failedAt = freezed,Object? sentAt = freezed,Object? deliveredAt = freezed,Object? seenAt = freezed,Object? updatedAt = freezed,Object? reactions = freezed,Object? pinned = freezed,Object? metadata = freezed,Object? status = freezed,Object? source = null,Object? text = freezed,Object? name = freezed,Object? size = freezed,Object? width = freezed,Object? height = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? authorId = null,Object? replyToMessageId = freezed,Object? createdAt = freezed,Object? deletedAt = freezed,Object? failedAt = freezed,Object? sentAt = freezed,Object? deliveredAt = freezed,Object? seenAt = freezed,Object? updatedAt = freezed,Object? reactions = freezed,Object? pinned = freezed,Object? metadata = freezed,Object? status = freezed,Object? source = null,Object? text = freezed,Object? name = freezed,Object? size = freezed,Object? width = freezed,Object? height = freezed,Object? thumbhash = freezed,}) { return _then(VideoMessage( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as MessageID,authorId: null == authorId ? _self.authorId : authorId // ignore: cast_nullable_to_non_nullable @@ -879,7 +881,8 @@ as String?,name: freezed == name ? _self.name : name // ignore: cast_nullable_to as String?,size: freezed == size ? _self.size : size // ignore: cast_nullable_to_non_nullable as int?,width: freezed == width ? _self.width : width // ignore: cast_nullable_to_non_nullable as double?,height: freezed == height ? _self.height : height // ignore: cast_nullable_to_non_nullable -as double?, +as double?,thumbhash: freezed == thumbhash ? _self.thumbhash : thumbhash // ignore: cast_nullable_to_non_nullable +as String?, )); } diff --git a/packages/flutter_chat_core/lib/src/models/message.g.dart b/packages/flutter_chat_core/lib/src/models/message.g.dart index d14292f2e..f00ed84d7 100644 --- a/packages/flutter_chat_core/lib/src/models/message.g.dart +++ b/packages/flutter_chat_core/lib/src/models/message.g.dart @@ -508,6 +508,7 @@ VideoMessage _$VideoMessageFromJson(Map json) => VideoMessage( size: (json['size'] as num?)?.toInt(), width: (json['width'] as num?)?.toDouble(), height: (json['height'] as num?)?.toDouble(), + thumbhash: json['thumbhash'] as String?, $type: json['type'] as String?, ); @@ -570,6 +571,7 @@ Map _$VideoMessageToJson( if (instance.size case final value?) 'size': value, if (instance.width case final value?) 'width': value, if (instance.height case final value?) 'height': value, + if (instance.thumbhash case final value?) 'thumbhash': value, 'type': instance.$type, }; diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index df6f44190..87c55f367 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -1,8 +1,11 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:provider/provider.dart'; +import 'package:thumbhash/thumbhash.dart' + show rgbaToBmp, thumbHashToApproximateAspectRatio, thumbHashToRGBA; import 'package:video_player/video_player.dart'; import 'helpers/is_network_source.dart'; import 'widgets/full_screen_video_player.dart'; @@ -75,6 +78,9 @@ class FlyerChatVideoMessage extends StatefulWidget { /// Color of the play icon. final Color playIconColor; + /// Background color used while the image thumbnail is visible. + final Color? placeholderColor; + /// Creates a widget to display an video message. const FlyerChatVideoMessage({ super.key, @@ -98,6 +104,7 @@ class FlyerChatVideoMessage extends StatefulWidget { this.playIcon = Icons.play_circle_fill, this.playIconSize = 48, this.playIconColor = Colors.white, + this.placeholderColor, }); @override @@ -109,6 +116,7 @@ class FlyerChatVideoMessage extends StatefulWidget { class _FlyerChatVideoMessageState extends State { late final ChatController _chatController; VideoPlayerController? _videoPlayerController; + ImageProvider? _placeholderProvider; late double _aspectRatio; @override @@ -123,6 +131,18 @@ class _FlyerChatVideoMessageState extends State { _aspectRatio = 9 / 16; } + if (widget.message.thumbhash?.isNotEmpty ?? false) { + final thumbhashBytes = base64.decode( + base64.normalize(widget.message.thumbhash!), + ); + + _aspectRatio = thumbHashToApproximateAspectRatio(thumbhashBytes); + + final rgbaImage = thumbHashToRGBA(thumbhashBytes); + final bmp = rgbaToBmp(rgbaImage); + _placeholderProvider = MemoryImage(bmp); + } + _chatController = context.read(); _initalizeVideoPlayerAsync(); } @@ -209,6 +229,13 @@ class _FlyerChatVideoMessageState extends State { child: Stack( fit: StackFit.expand, children: [ + _placeholderProvider != null + ? Image(image: _placeholderProvider!, fit: BoxFit.fill) + : Container( + color: + widget.placeholderColor ?? + theme.colors.surfaceContainerLow, + ), Hero( tag: widget.message.id, child: diff --git a/packages/flyer_chat_video_message/pubspec.yaml b/packages/flyer_chat_video_message/pubspec.yaml index 20518aafd..700018967 100644 --- a/packages/flyer_chat_video_message/pubspec.yaml +++ b/packages/flyer_chat_video_message/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: sdk: flutter flutter_chat_core: ^2.4.0 provider: ^6.1.4 + thumbhash: ^0.1.0+1 video_player: ^2.9.5 dev_dependencies: From 50b44711aa49745216ea6bb89fd7aeaa8cf3b648 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 5 Jun 2025 11:05:02 +0200 Subject: [PATCH 12/20] Update example --- examples/flyer_chat/lib/local.dart | 231 +++++++++++++++-------------- 1 file changed, 116 insertions(+), 115 deletions(-) diff --git a/examples/flyer_chat/lib/local.dart b/examples/flyer_chat/lib/local.dart index cc80644bb..31d2e1fa7 100644 --- a/examples/flyer_chat/lib/local.dart +++ b/examples/flyer_chat/lib/local.dart @@ -470,129 +470,130 @@ class LocalState extends State { Navigator.pop(context); // Uncomment to use picker instead of hardcoding the video url - final picker = ImagePicker(); - final result = await picker.pickVideo( - source: ImageSource.gallery, - ); + // final picker = ImagePicker(); + // final result = await picker.pickVideo( + // source: ImageSource.gallery, + // ); - if (result != null) { - String? thumbHash; - int? width; - int? height; - int? fileSizeInBytes; - try { - // Optionally get the file size - fileSizeInBytes = await result.length(); - - // Get the video width and height - final fullSizeimageBytes = - await VideoThumbnail.thumbnailData( - video: result.path, - imageFormat: ImageFormat.WEBP, - quality: 1, - ); - - final fullSizedecoded = img.decodeImage( - fullSizeimageBytes!, - ); - if (fullSizedecoded != null) { - width = fullSizedecoded.width; - height = fullSizedecoded.height; - } - - // Generate the thumbhash - final thumbSizeImageBytes = - await VideoThumbnail.thumbnailData( - video: result.path, - imageFormat: ImageFormat.WEBP, - maxWidth: 100, - maxHeight: 100, - quality: 25, - ); - final decoded = img.decodeImage(thumbSizeImageBytes!); - if (decoded != null) { - final thumbHashBytes = rgbaToThumbHash( - decoded.width, - decoded.height, - decoded.getBytes(), - ); + // if (result != null) { + // String? thumbHash; + // int? width; + // int? height; + // int? fileSizeInBytes; + // try { + // // Optionally get the file size + // fileSizeInBytes = await result.length(); + + // // Get the video width and height + // final fullSizeimageBytes = + // await VideoThumbnail.thumbnailData( + // video: result.path, + // imageFormat: ImageFormat.WEBP, + // quality: 1, + // ); + + // final fullSizedecoded = img.decodeImage( + // fullSizeimageBytes!, + // ); + // if (fullSizedecoded != null) { + // width = fullSizedecoded.width; + // height = fullSizedecoded.height; + // } + + // // Generate the thumbhash + // final thumbSizeImageBytes = + // await VideoThumbnail.thumbnailData( + // video: result.path, + // imageFormat: ImageFormat.WEBP, + // maxWidth: 100, + // maxHeight: 100, + // quality: 25, + // ); + // final decoded = img.decodeImage(thumbSizeImageBytes!); + // if (decoded != null) { + // final thumbHashBytes = rgbaToThumbHash( + // decoded.width, + // decoded.height, + // decoded.getBytes(), + // ); - thumbHash = base64.encode(thumbHashBytes); - } - } catch (e) { - debugPrint(e.toString()); - } + // thumbHash = base64.encode(thumbHashBytes); + // } + // } catch (e) { + // debugPrint(e.toString()); + // } - // Create a proper file message - final videoMessage = VideoMessage( - id: _uuid.v4(), - authorId: _currentUser.id, - createdAt: DateTime.now().toUtc(), - sentAt: DateTime.now().toUtc(), - source: result.path, - thumbhash: thumbHash, - width: width?.toDouble(), - height: height?.toDouble(), - size: fileSizeInBytes, - ); - await _chatController.insertMessage(videoMessage); - } + // // Create a proper file message + // final videoMessage = VideoMessage( + // id: _uuid.v4(), + // authorId: _currentUser.id, + // createdAt: DateTime.now().toUtc(), + // sentAt: DateTime.now().toUtc(), + // source: result.path, + // thumbhash: thumbHash, + // width: width?.toDouble(), + // height: height?.toDouble(), + // size: fileSizeInBytes, + // ); + // await _chatController.insertMessage(videoMessage); + // } - // const videoUrl = - // 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4'; - // String? thumbHash; - // int? width; - // int? height; - // try { - // // Get the video width and height - // final fullSizeimageBytes = - // await VideoThumbnail.thumbnailData( - // video: videoUrl, - // imageFormat: ImageFormat.WEBP, - // quality: 1, - // ); + const videoUrl = + 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4'; + String? thumbHash; + int? width; + int? height; + try { + // Get the video width and height + // iOS/ Android ONLY + final fullSizeimageBytes = + await VideoThumbnail.thumbnailData( + video: videoUrl, + imageFormat: ImageFormat.WEBP, + quality: 1, + ); - // final fullSizedecoded = img.decodeImage( - // fullSizeimageBytes!, - // ); - // if (fullSizedecoded != null) { - // width = fullSizedecoded.width; - // height = fullSizedecoded.height; - // } + final fullSizedecoded = img.decodeImage( + fullSizeimageBytes!, + ); + if (fullSizedecoded != null) { + width = fullSizedecoded.width; + height = fullSizedecoded.height; + } - // // Generate the thumbhash - // final thumbSizeImageBytes = - // await VideoThumbnail.thumbnailData( - // video: videoUrl, - // imageFormat: ImageFormat.WEBP, - // maxWidth: 100, - // maxHeight: 100, - // quality: 25, - // ); - // final decoded = img.decodeImage(thumbSizeImageBytes!); - // if (decoded != null) { - // final thumbHashBytes = rgbaToThumbHash( - // decoded.width, - // decoded.height, - // decoded.getBytes(), - // ); + // Generate the thumbhash + final thumbSizeImageBytes = + await VideoThumbnail.thumbnailData( + video: videoUrl, + imageFormat: ImageFormat.WEBP, + maxWidth: 100, + maxHeight: 100, + quality: 25, + ); + final decoded = img.decodeImage(thumbSizeImageBytes!); + if (decoded != null) { + final thumbHashBytes = rgbaToThumbHash( + decoded.width, + decoded.height, + decoded.getBytes(), + ); - // thumbHash = base64.encode(thumbHashBytes); - // } - // } catch (e) { - // debugPrint(e.toString()); - // } + thumbHash = base64.encode(thumbHashBytes); + } + } catch (e) { + debugPrint(e.toString()); + } - // final videoMessage = VideoMessage( - // id: _uuid.v4(), - // authorId: _currentUser.id, - // createdAt: DateTime.now().toUtc(), - // source: videoUrl, - // thumbhash: thumbHash, - // width: width?.toDouble(), - // height: height?.toDouble(), - // ); - // await _chatController.insertMessage(videoMessage); + final videoMessage = VideoMessage( + id: _uuid.v4(), + authorId: _currentUser.id, + createdAt: DateTime.now().toUtc(), + source: videoUrl, + thumbhash: thumbHash, + width: width?.toDouble(), + height: height?.toDouble(), + ); + await _chatController.insertMessage(videoMessage); }, ), ], From 0d777afa1f6f7a7ad4f54559c8cda187f7895359 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 5 Jun 2025 11:05:33 +0200 Subject: [PATCH 13/20] Use video_thumbnail instead of player --- .../lib/src/flyer_chat_video_message.dart | 106 +++++++++--------- .../flyer_chat_video_message/pubspec.yaml | 2 +- 2 files changed, 51 insertions(+), 57 deletions(-) diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index 87c55f367..b6e0f3155 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -1,12 +1,15 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:provider/provider.dart'; import 'package:thumbhash/thumbhash.dart' show rgbaToBmp, thumbHashToApproximateAspectRatio, thumbHashToRGBA; import 'package:video_player/video_player.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; import 'helpers/is_network_source.dart'; import 'widgets/full_screen_video_player.dart'; import 'widgets/hero_video_route.dart'; @@ -30,14 +33,11 @@ class FlyerChatVideoMessage extends StatefulWidget { /// Constraints for the video size. final BoxConstraints? constraints; - /// Color of the overlay shown during video loading for a sent message - final Color? sentLoadingOverlayColor; + /// Background color for a sent message while image cover is generated + final Color? sentBackGroundColor; - /// Color of the overlay shown during video loading for a received message - final Color? receivedLoadingOverlayColor; - - /// Color of the circular progress indicator shown during video loading. - final Color? loadingIndicatorColor; + /// Background color for a received message while image cover is generated + final Color? receivedBackgroundColor; /// Color of the overlay shown during video upload. final Color? uploadOverlayColor; @@ -78,9 +78,6 @@ class FlyerChatVideoMessage extends StatefulWidget { /// Color of the play icon. final Color playIconColor; - /// Background color used while the image thumbnail is visible. - final Color? placeholderColor; - /// Creates a widget to display an video message. const FlyerChatVideoMessage({ super.key, @@ -88,9 +85,8 @@ class FlyerChatVideoMessage extends StatefulWidget { this.headers, this.borderRadius, this.constraints = const BoxConstraints(maxHeight: 300), - this.sentLoadingOverlayColor, - this.receivedLoadingOverlayColor, - this.loadingIndicatorColor, + this.sentBackGroundColor, + this.receivedBackgroundColor, this.uploadOverlayColor, this.uploadIndicatorColor, this.timeStyle, @@ -104,7 +100,6 @@ class FlyerChatVideoMessage extends StatefulWidget { this.playIcon = Icons.play_circle_fill, this.playIconSize = 48, this.playIconColor = Colors.white, - this.placeholderColor, }); @override @@ -115,7 +110,6 @@ class FlyerChatVideoMessage extends StatefulWidget { /// State for [FlyerChatVideoMessage]. class _FlyerChatVideoMessageState extends State { late final ChatController _chatController; - VideoPlayerController? _videoPlayerController; ImageProvider? _placeholderProvider; late double _aspectRatio; @@ -144,24 +138,35 @@ class _FlyerChatVideoMessageState extends State { } _chatController = context.read(); - _initalizeVideoPlayerAsync(); + if (!false) { + try { + _generateImageCover(); + } catch (e) { + debugPrint('Could not generate image cover: ${e.toString()}'); + } + } } - Future _initalizeVideoPlayerAsync() async { - if (isNetworkSource(widget.message.source)) { - _videoPlayerController = VideoPlayerController.networkUrl( - Uri.parse(widget.message.source), - httpHeaders: widget.headers ?? {}, - ); - } else { - _videoPlayerController = VideoPlayerController.file( - File(widget.message.source), - ); - } - await _videoPlayerController!.initialize().then((_) { - setState(() { - _aspectRatio = _videoPlayerController!.value.aspectRatio; - }); + Future _generateImageCover() async { + final coverImageBytes = await VideoThumbnail.thumbnailData( + video: widget.message.source, + imageFormat: ImageFormat.WEBP, + quality: 25, + headers: widget.headers, + ); + setState(() { + // TODO should we add 'image' package to decode and get height and width + // to update _aspectRatio? + + // import 'package:image/image.dart' as img; + + // final decoded = img.decodeImage(coverImageBytes!); + // if (decoded != null) { + // final width = decoded.width; + // final height = decoded.height; + // } + + _placeholderProvider = MemoryImage(coverImageBytes!); }); } @@ -169,13 +174,15 @@ class _FlyerChatVideoMessageState extends State { void didUpdateWidget(FlyerChatVideoMessage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.message.source != widget.message.source) { - _initalizeVideoPlayerAsync(); + _generateImageCover(); } } @override void dispose() { - _videoPlayerController?.dispose(); + _placeholderProvider?.evict(); + // Evicting the image on dispose will result in images flickering + // PaintingBinding.instance.imageCache.evict(_imageProvider); super.dispose(); } @@ -229,30 +236,18 @@ class _FlyerChatVideoMessageState extends State { child: Stack( fit: StackFit.expand, children: [ - _placeholderProvider != null - ? Image(image: _placeholderProvider!, fit: BoxFit.fill) - : Container( - color: - widget.placeholderColor ?? - theme.colors.surfaceContainerLow, - ), Hero( tag: widget.message.id, child: - _videoPlayerController?.value.isInitialized == true - ? VideoPlayer(_videoPlayerController!) + _placeholderProvider != null + ? Image( + image: _placeholderProvider!, + fit: BoxFit.fill, + ) : Container( - color: _resolveBackgroundColor(isSentByMe, theme), - child: Center( - child: CircularProgressIndicator( - color: - widget - .fullScreenPlayerLoadingIndicatorColor ?? - theme.colors.onSurface.withValues( - alpha: 0.8, - ), - ), - ), + color: + _resolveBackgroundColor(isSentByMe, theme) ?? + theme.colors.surfaceContainerLow, ), ), Icon( @@ -260,7 +255,6 @@ class _FlyerChatVideoMessageState extends State { size: widget.playIconSize, color: widget.playIconColor, ), - if (_chatController is UploadProgressMixin) StreamBuilder( stream: (_chatController as UploadProgressMixin) @@ -316,8 +310,8 @@ class _FlyerChatVideoMessageState extends State { Color? _resolveBackgroundColor(bool isSentByMe, ChatTheme theme) { if (isSentByMe) { - return widget.sentLoadingOverlayColor ?? theme.colors.primary; + return widget.sentBackGroundColor ?? theme.colors.primary; } - return widget.receivedLoadingOverlayColor ?? theme.colors.surfaceContainer; + return widget.receivedBackgroundColor ?? theme.colors.surfaceContainer; } } diff --git a/packages/flyer_chat_video_message/pubspec.yaml b/packages/flyer_chat_video_message/pubspec.yaml index 700018967..6acbeba96 100644 --- a/packages/flyer_chat_video_message/pubspec.yaml +++ b/packages/flyer_chat_video_message/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: flutter_chat_core: ^2.4.0 provider: ^6.1.4 thumbhash: ^0.1.0+1 - video_player: ^2.9.5 + video_thumbnail: ^0.5.6 dev_dependencies: flutter_lints: ^6.0.0 From 7393323da48c5bc772f8f05ddd30eaf503ec771b Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 5 Jun 2025 17:08:06 +0200 Subject: [PATCH 14/20] Fix crash when closing quick before player init --- .../lib/src/widgets/full_screen_video_player.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart index ff24b2721..b0c8186a1 100644 --- a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart +++ b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart @@ -28,7 +28,7 @@ class FullscreenVideoPlayer extends StatefulWidget { class _FullscreenVideoPlayerState extends State { late VideoPlayerController _videoPlayer; - late ChewieController _chewieController; + late ChewieController? _chewieController; late double _aspectRatio; @override @@ -59,7 +59,7 @@ class _FullscreenVideoPlayerState extends State { @override void dispose() { - _chewieController.dispose(); + _chewieController?.dispose(); _videoPlayer.dispose(); super.dispose(); } @@ -76,8 +76,8 @@ class _FullscreenVideoPlayerState extends State { child: AspectRatio( aspectRatio: _aspectRatio, child: - _videoPlayer.value.isInitialized - ? Chewie(controller: _chewieController) + _videoPlayer.value.isInitialized && _chewieController != null + ? Chewie(controller: _chewieController!) : Container( width: 40, height: 40, From 223fae94d4b35d13627b70853a904675bad04df9 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 5 Jun 2025 17:14:18 +0200 Subject: [PATCH 15/20] Cleanup imports --- .../lib/src/flyer_chat_video_message.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index b6e0f3155..4a1d216bf 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -1,16 +1,11 @@ import 'dart:convert'; -import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:provider/provider.dart'; import 'package:thumbhash/thumbhash.dart' show rgbaToBmp, thumbHashToApproximateAspectRatio, thumbHashToRGBA; -import 'package:video_player/video_player.dart'; import 'package:video_thumbnail/video_thumbnail.dart'; -import 'helpers/is_network_source.dart'; import 'widgets/full_screen_video_player.dart'; import 'widgets/hero_video_route.dart'; import 'widgets/time_and_status.dart'; From d3e3912574bc87da31c666d63b4ead13a46b4480 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 5 Jun 2025 17:57:51 +0200 Subject: [PATCH 16/20] Remove extraneous if check --- .../lib/src/flyer_chat_video_message.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index 4a1d216bf..0a208823b 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -133,12 +133,10 @@ class _FlyerChatVideoMessageState extends State { } _chatController = context.read(); - if (!false) { - try { - _generateImageCover(); - } catch (e) { - debugPrint('Could not generate image cover: ${e.toString()}'); - } + try { + _generateImageCover(); + } catch (e) { + debugPrint('Could not generate image cover: ${e.toString()}'); } } From 7781768e3b27bcc75ba77189cce89dbf4353631c Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 5 Jun 2025 17:58:06 +0200 Subject: [PATCH 17/20] Fix setState on unmounted components --- .../lib/src/flyer_chat_video_message.dart | 24 ++++++++++--------- .../src/widgets/full_screen_video_player.dart | 20 +++++++++------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index 0a208823b..078aa13a2 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -147,20 +147,22 @@ class _FlyerChatVideoMessageState extends State { quality: 25, headers: widget.headers, ); - setState(() { - // TODO should we add 'image' package to decode and get height and width - // to update _aspectRatio? + if (mounted) { + setState(() { + // TODO should we add 'image' package to decode and get height and width + // to update _aspectRatio? - // import 'package:image/image.dart' as img; + // import 'package:image/image.dart' as img; - // final decoded = img.decodeImage(coverImageBytes!); - // if (decoded != null) { - // final width = decoded.width; - // final height = decoded.height; - // } + // final decoded = img.decodeImage(coverImageBytes!); + // if (decoded != null) { + // final width = decoded.width; + // final height = decoded.height; + // } - _placeholderProvider = MemoryImage(coverImageBytes!); - }); + _placeholderProvider = MemoryImage(coverImageBytes!); + }); + } } @override diff --git a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart index b0c8186a1..d8cde6ff9 100644 --- a/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart +++ b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart @@ -46,15 +46,17 @@ class _FullscreenVideoPlayerState extends State { } await _videoPlayer.initialize(); - setState(() { - _aspectRatio = _videoPlayer.value.aspectRatio; - _chewieController = ChewieController( - videoPlayerController: _videoPlayer, - autoPlay: true, - allowFullScreen: false, - autoInitialize: false, - ); - }); + if (mounted) { + setState(() { + _aspectRatio = _videoPlayer.value.aspectRatio; + _chewieController = ChewieController( + videoPlayerController: _videoPlayer, + autoPlay: true, + allowFullScreen: false, + autoInitialize: false, + ); + }); + } } @override From af30575ad5acfb207c07c8662d9c954e42df31a2 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 5 Jun 2025 18:46:58 +0200 Subject: [PATCH 18/20] Allow to pass a customHiResImageProvider (useful if user caches those HiRes images on his side) --- .../lib/src/flyer_chat_video_message.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index 078aa13a2..517a5c39f 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -73,6 +73,11 @@ class FlyerChatVideoMessage extends StatefulWidget { /// Color of the play icon. final Color playIconColor; + /// Optional builder function that returns a Future of high resolution thumbnail [ImageProvider] + /// for the video message. If provided, this will be used instead of the default + /// thumbnail generation. + final Future Function()? highResThumbnailProviderBuilder; + /// Creates a widget to display an video message. const FlyerChatVideoMessage({ super.key, @@ -95,6 +100,7 @@ class FlyerChatVideoMessage extends StatefulWidget { this.playIcon = Icons.play_circle_fill, this.playIconSize = 48, this.playIconColor = Colors.white, + this.highResThumbnailProviderBuilder, }); @override @@ -141,6 +147,17 @@ class _FlyerChatVideoMessageState extends State { } Future _generateImageCover() async { + if (widget.highResThumbnailProviderBuilder != null) { + final provider = await widget.highResThumbnailProviderBuilder!(); + if (mounted && provider != null) { + setState(() { + _placeholderProvider = provider; + }); + } + return; + } + + // TODO use cache manager (or crosscache? to save the image) final coverImageBytes = await VideoThumbnail.thumbnailData( video: widget.message.source, imageFormat: ImageFormat.WEBP, From 0436be5ffdd622233a2a5b3cdce3338b64a7425c Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Mon, 9 Jun 2025 09:51:08 +0200 Subject: [PATCH 19/20] Adapt to the new resolvedStatus / resolvedTime --- .../lib/src/flyer_chat_video_message.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index 517a5c39f..de380717e 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -206,8 +206,8 @@ class _FlyerChatVideoMessageState extends State { final timeAndStatus = widget.showTime || (isSentByMe && widget.showStatus) ? TimeAndStatus( - time: widget.message.time, - status: widget.message.status, + time: widget.message.resolvedTime, + status: widget.message.resolvedStatus, showTime: widget.showTime, showStatus: isSentByMe && widget.showStatus, backgroundColor: From 308213d06bafd5daa70357f075effa72577fbb8a Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Wed, 23 Jul 2025 09:03:21 +0200 Subject: [PATCH 20/20] Bump packages --- packages/flyer_chat_video_message/pubspec.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/flyer_chat_video_message/pubspec.yaml b/packages/flyer_chat_video_message/pubspec.yaml index 6acbeba96..e566c2cf9 100644 --- a/packages/flyer_chat_video_message/pubspec.yaml +++ b/packages/flyer_chat_video_message/pubspec.yaml @@ -10,12 +10,13 @@ environment: flutter: ">=3.29.0" dependencies: - chewie: ^1.11.3 + chewie: ^1.12.1 flutter: sdk: flutter - flutter_chat_core: ^2.4.0 - provider: ^6.1.4 + flutter_chat_core: ^2.7.1 + provider: ^6.1.5 thumbhash: ^0.1.0+1 + video_player: ^2.10.0 video_thumbnail: ^0.5.6 dev_dependencies: