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..31d2e1fa7 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'; @@ -12,9 +13,13 @@ 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/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'; @@ -98,6 +103,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 +463,139 @@ class LocalState extends State { } }, ), + ListTile( + leading: const Icon(Icons.video_camera_front), + title: const Text('Video'), + onTap: () async { + Navigator.pop(context); + // Uncomment to use picker instead of hardcoding the video url + + // 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(), + // ); + + // 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); + // } + + 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; + } + + // 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: 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 0ac6e3ca9..d8748e46e 100644 --- a/examples/flyer_chat/pubspec.yaml +++ b/examples/flyer_chat/pubspec.yaml @@ -46,10 +46,12 @@ 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 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 @@ -57,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 8b1378917..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 @@ -1 +1,329 @@ +import 'dart:convert'; +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_thumbnail/video_thumbnail.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 video container. + final BorderRadiusGeometry? borderRadius; + + /// Constraints for the video size. + final BoxConstraints? constraints; + + /// Background color for a sent message while image cover is generated + final Color? sentBackGroundColor; + + /// 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; + + /// Color of the circular progress indicator shown during video 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 video. + 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; + + /// Background color for the full screen video player. + final Color? fullScreenPlayerLoadingIndicatorColor; + + /// 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; + + /// 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, + required this.message, + this.headers, + this.borderRadius, + this.constraints = const BoxConstraints(maxHeight: 300), + this.sentBackGroundColor, + this.receivedBackgroundColor, + this.uploadOverlayColor, + this.uploadIndicatorColor, + this.timeStyle, + this.timeBackground, + this.showTime = true, + this.showStatus = true, + this.timeAndStatusPosition = TimeAndStatusPosition.end, + this.useRootNavigator = false, + this.fullScreenPlayerBackgroundColor, + this.fullScreenPlayerLoadingIndicatorColor, + this.playIcon = Icons.play_circle_fill, + this.playIconSize = 48, + this.playIconColor = Colors.white, + this.highResThumbnailProviderBuilder, + }); + + @override + // ignore: library_private_types_in_public_api + _FlyerChatVideoMessageState createState() => _FlyerChatVideoMessageState(); +} + +/// State for [FlyerChatVideoMessage]. +class _FlyerChatVideoMessageState extends State { + late final ChatController _chatController; + ImageProvider? _placeholderProvider; + 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; + } + + 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(); + try { + _generateImageCover(); + } catch (e) { + debugPrint('Could not generate image cover: ${e.toString()}'); + } + } + + 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, + quality: 25, + headers: widget.headers, + ); + 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; + + // final decoded = img.decodeImage(coverImageBytes!); + // if (decoded != null) { + // final width = decoded.width; + // final height = decoded.height; + // } + + _placeholderProvider = MemoryImage(coverImageBytes!); + }); + } + } + + @override + void didUpdateWidget(FlyerChatVideoMessage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message.source != widget.message.source) { + _generateImageCover(); + } + } + + @override + void dispose() { + _placeholderProvider?.evict(); + // Evicting the image on dispose will result in images flickering + // PaintingBinding.instance.imageCache.evict(_imageProvider); + 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.resolvedTime, + status: widget.message.resolvedStatus, + 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: _aspectRatio, + child: GestureDetector( + onTap: () { + Navigator.of( + context, + rootNavigator: widget.useRootNavigator, + ).push( + HeroVideoRoute( + fullscreenDialog: true, + builder: + (_) => FullscreenVideoPlayer( + source: widget.message.source, + aspectRatio: _aspectRatio, + heroTag: widget.message.id, + backgroundColor: widget.fullScreenPlayerBackgroundColor, + loadingIndicatorColor: + widget.fullScreenPlayerLoadingIndicatorColor ?? + theme.colors.onSurface.withValues(alpha: 0.8), + ), + ), + ); + }, + child: Stack( + fit: StackFit.expand, + children: [ + Hero( + tag: widget.message.id, + child: + _placeholderProvider != null + ? Image( + image: _placeholderProvider!, + fit: BoxFit.fill, + ) + : Container( + color: + _resolveBackgroundColor(isSentByMe, theme) ?? + theme.colors.surfaceContainerLow, + ), + ), + 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, + ), + ], + ), + ), + ), + ), + ); + } + + Color? _resolveBackgroundColor(bool isSentByMe, ChatTheme theme) { + if (isSentByMe) { + return widget.sentBackGroundColor ?? theme.colors.primary; + } + return widget.receivedBackgroundColor ?? theme.colors.surfaceContainer; + } +} 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 new file mode 100644 index 000000000..d8cde6ff9 --- /dev/null +++ b/packages/flyer_chat_video_message/lib/src/widgets/full_screen_video_player.dart @@ -0,0 +1,97 @@ +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 source; + final String heroTag; + final Color? backgroundColor; + final Color? loadingIndicatorColor; + final double? aspectRatio; + + const FullscreenVideoPlayer({ + super.key, + required this.source, + required this.heroTag, + this.backgroundColor, + this.loadingIndicatorColor, + this.aspectRatio, + }); + + @override + State createState() => _FullscreenVideoPlayerState(); +} + +class _FullscreenVideoPlayerState extends State { + 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)) { + _videoPlayer = VideoPlayerController.networkUrl(Uri.parse(widget.source)); + } else { + _videoPlayer = VideoPlayerController.file(File(widget.source)); + } + + await _videoPlayer.initialize(); + if (mounted) { + setState(() { + _aspectRatio = _videoPlayer.value.aspectRatio; + _chewieController = ChewieController( + videoPlayerController: _videoPlayer, + autoPlay: true, + allowFullScreen: false, + autoInitialize: false, + ); + }); + } + } + + @override + void dispose() { + _chewieController?.dispose(); + _videoPlayer.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: AspectRatio( + aspectRatio: _aspectRatio, + child: + _videoPlayer.value.isInitialized && _chewieController != null + ? Chewie(controller: _chewieController!) + : Container( + width: 40, + height: 40, + alignment: Alignment.center, + child: CircularProgressIndicator( + color: widget.loadingIndicatorColor, + ), + ), + ), + ), + ), + ), + ); + } +} 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..e566c2cf9 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,14 @@ environment: flutter: ">=3.29.0" dependencies: + chewie: ^1.12.1 flutter: sdk: flutter + 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: flutter_lints: ^6.0.0