From cb8ae5d16afd223de28e57fea7fda0cdc1310457 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 21 Jul 2026 16:06:10 +0200 Subject: [PATCH] remove mediakit dependency from stream_video --- packages/stream_chat_flutter/CHANGELOG.md | 5 + .../linux/flutter/generated_plugins.cmake | 1 - .../windows/flutter/generated_plugins.cmake | 1 - .../lib/src/fullscreen_media/fsm_stub.dart | 19 - .../fullscreen_media/full_screen_media.dart | 173 +++---- .../full_screen_media_builder.dart | 38 +- .../full_screen_media_desktop.dart | 458 ------------------ .../full_screen_media_scope.dart | 72 +++ .../full_screen_media_widget.dart | 10 - .../default_stream_video_player.dart | 121 +++++ .../video_player/stream_video_player.dart | 57 +++ .../stream_video_player_activity_mixin.dart | 90 ++++ .../stream_video_player_props.dart | 56 +++ .../lib/src/keyboard_shortcuts/keysets.dart | 6 +- .../lib/src/stream_chat.dart | 10 - .../lib/src/stream_chat_configuration.dart | 15 + .../lib/src/video/vlc/vlc_manager.dart | 22 - .../src/video/vlc/vlc_manager_desktop.dart | 13 - .../lib/src/video/vlc/vlc_manager_web.dart | 14 - .../lib/src/video/vlc/vlc_stub.dart | 5 - .../lib/stream_chat_flutter.dart | 5 + packages/stream_chat_flutter/pubspec.yaml | 2 - .../stream_chat_flutter/test/src/mocks.dart | 3 - sample_app/lib/app.dart | 18 + .../lib/widgets/media_kit_video_player.dart | 123 +++++ sample_app/pubspec.yaml | 2 + 26 files changed, 634 insertions(+), 705 deletions(-) delete mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_stub.dart delete mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart create mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_scope.dart delete mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_widget.dart create mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/default_stream_video_player.dart create mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/stream_video_player.dart create mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/stream_video_player_activity_mixin.dart create mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/stream_video_player_props.dart delete mode 100644 packages/stream_chat_flutter/lib/src/video/vlc/vlc_manager.dart delete mode 100644 packages/stream_chat_flutter/lib/src/video/vlc/vlc_manager_desktop.dart delete mode 100644 packages/stream_chat_flutter/lib/src/video/vlc/vlc_manager_web.dart delete mode 100644 packages/stream_chat_flutter/lib/src/video/vlc/vlc_stub.dart create mode 100644 sample_app/lib/widgets/media_kit_video_player.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index ba091bee94..03e57450c2 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -3,6 +3,11 @@ ✅ Added - Added a `LastMessagePredicate` typedef for the `ChannelLastMessageText.lastMessagePredicate` filter. +- Added `StreamChatConfigurationData.videoPlayer`, letting apps override the video player used inside `StreamFullScreenMedia` (e.g. to plug in a `media_kit`-based player for desktop). The default implementation is now available as `DefaultStreamVideoPlayer`. + +🔄 Changed + +- Removed the `media_kit`/`media_kit_video` dependencies from the SDK. Windows and Linux full-screen video no longer works out of the box, since the new default player (`chewie`/`video_player`) doesn't support those platforms; apps that need desktop video playback should provide one via `StreamChatConfigurationData.videoPlayer` (see the sample app for a `media_kit`-based example). 🐞 Fixed diff --git a/packages/stream_chat_flutter/example/linux/flutter/generated_plugins.cmake b/packages/stream_chat_flutter/example/linux/flutter/generated_plugins.cmake index 809de92757..6f2e5dd17a 100644 --- a/packages/stream_chat_flutter/example/linux/flutter/generated_plugins.cmake +++ b/packages/stream_chat_flutter/example/linux/flutter/generated_plugins.cmake @@ -5,7 +5,6 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop file_selector_linux - media_kit_video record_linux sqlite3_flutter_libs url_launcher_linux diff --git a/packages/stream_chat_flutter/example/windows/flutter/generated_plugins.cmake b/packages/stream_chat_flutter/example/windows/flutter/generated_plugins.cmake index 0fc96fdcf1..708ce1f414 100644 --- a/packages/stream_chat_flutter/example/windows/flutter/generated_plugins.cmake +++ b/packages/stream_chat_flutter/example/windows/flutter/generated_plugins.cmake @@ -7,7 +7,6 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop file_selector_windows gal - media_kit_video record_windows share_plus sqlite3_flutter_libs diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_stub.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_stub.dart deleted file mode 100644 index 1bf7c435b3..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_stub.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Stub function for returning an instance of either [FullScreenMedia] or -/// [FullScreenMediaDesktop]. -/// -/// This should ONLY be used in [FullScreenMediaBuilder]. -FullScreenMediaWidget getFsm({ - Key? key, - required List mediaAttachmentPackages, - required int startIndex, - required String userName, - ShowMessageCallback? onShowMessage, - ReplyMessageCallback? onReplyMessage, - AttachmentActionsBuilder? attachmentActionsModalBuilder, - bool? autoplayVideos, -}) => - throw UnsupportedError('Cannot create FullScreenMedia'); diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart index eb95489c24..7410303e4a 100644 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart @@ -1,17 +1,15 @@ -import 'dart:async'; import 'dart:io'; import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart'; import 'package:stream_chat_flutter/src/fullscreen_media/gallery_navigation_item.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:video_player/video_player.dart'; /// A full screen image widget -class StreamFullScreenMedia extends FullScreenMediaWidget { +class StreamFullScreenMedia extends StatefulWidget { /// Instantiate a new FullScreenImage const StreamFullScreenMedia({ super.key, @@ -61,40 +59,10 @@ class _FullScreenMediaState extends State { _isDisplayingDetail.value = !_isDisplayingDetail.value; } - final videoPackages = {}; - @override void initState() { super.initState(); _pageController = PageController(initialPage: widget.startIndex); - for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) { - final attachment = widget.mediaAttachmentPackages[i].attachment; - if (attachment.type != AttachmentType.video) continue; - final package = VideoPackage(attachment, showControls: true); - videoPackages[attachment.id] = package; - } - initializePlayers(); - } - - Future initializePlayers() async { - if (videoPackages.isEmpty) { - return; - } - - final currentAttachment = - widget.mediaAttachmentPackages[widget.startIndex].attachment; - - await Future.wait(videoPackages.values.map( - (it) => it.initialize(), - )); - - if (widget.autoplayVideos && - currentAttachment.type == AttachmentType.video) { - final package = videoPackages.values - .firstWhere((e) => e._attachment == currentAttachment); - package._chewieController?.play(); - } - setState(() {}); // ignore: no-empty-block } @override @@ -102,9 +70,6 @@ class _FullScreenMediaState extends State { _currentPage.dispose(); _pageController.dispose(); _isDisplayingDetail.dispose(); - for (final package in videoPackages.values) { - package.dispose(); - } super.dispose(); } @@ -255,81 +220,64 @@ class _FullScreenMediaState extends State { ); } }, - child: PageView.builder( - controller: _pageController, - itemCount: widget.mediaAttachmentPackages.length, - onPageChanged: (val) { - _currentPage.value = val; - if (videoPackages.isEmpty) return; - final currentAttachment = - widget.mediaAttachmentPackages[val].attachment; - for (final e in videoPackages.values) { - if (e._attachment != currentAttachment) { - e._chewieController?.pause(); - } - } - if (widget.autoplayVideos && - currentAttachment.type == AttachmentType.video) { - final controller = videoPackages[currentAttachment.id]!; - controller._chewieController?.play(); - } - }, - itemBuilder: (context, index) { - final currentAttachmentPackage = - widget.mediaAttachmentPackages[index]; - final attachment = currentAttachmentPackage.attachment; - return ValueListenableBuilder( - valueListenable: _isDisplayingDetail, - builder: (context, isDisplayingDetail, child) { - final padding = MediaQuery.paddingOf(context); - - return AnimatedContainer( - duration: kThemeChangeDuration, - color: switch (isDisplayingDetail) { - true => StreamChannelHeaderTheme.of(context).color, - false => Colors.black, - }, - padding: EdgeInsetsDirectional.only( - top: padding.top + kToolbarHeight, - bottom: padding.bottom + kToolbarHeight, - ), - child: child, - ); - }, - child: Builder( - builder: (context) { - if (attachment.type == AttachmentType.image || - attachment.type == AttachmentType.giphy) { - return PhotoView.customChild( - maxScale: PhotoViewComputedScale.covered, - minScale: PhotoViewComputedScale.contained, - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), - child: StreamMediaAttachmentThumbnail( - media: attachment, - width: double.infinity, - height: double.infinity, - ), - ); - } else if (attachment.type == AttachmentType.video) { - final controller = videoPackages[attachment.id]!; - if (!controller.initialized) { - return const Center( - child: CircularProgressIndicator.adaptive(), + child: FullScreenMediaScope( + activeIndex: _currentPage, + child: PageView.builder( + controller: _pageController, + itemCount: widget.mediaAttachmentPackages.length, + onPageChanged: (val) => _currentPage.value = val, + itemBuilder: (context, index) { + final currentAttachmentPackage = + widget.mediaAttachmentPackages[index]; + final attachment = currentAttachmentPackage.attachment; + return ValueListenableBuilder( + valueListenable: _isDisplayingDetail, + builder: (context, isDisplayingDetail, child) { + final padding = MediaQuery.paddingOf(context); + + return AnimatedContainer( + duration: kThemeChangeDuration, + color: switch (isDisplayingDetail) { + true => StreamChannelHeaderTheme.of(context).color, + false => Colors.black, + }, + padding: EdgeInsetsDirectional.only( + top: padding.top + kToolbarHeight, + bottom: padding.bottom + kToolbarHeight, + ), + child: child, + ); + }, + child: Builder( + builder: (context) { + if (attachment.type == AttachmentType.image || + attachment.type == AttachmentType.giphy) { + return PhotoView.customChild( + maxScale: PhotoViewComputedScale.covered, + minScale: PhotoViewComputedScale.contained, + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), + child: StreamMediaAttachmentThumbnail( + media: attachment, + width: double.infinity, + height: double.infinity, + ), + ); + } else if (attachment.type == AttachmentType.video) { + return StreamVideoPlayer( + attachment: attachment, + pageIndex: index, + autoplay: widget.autoplayVideos, ); } - return Chewie( - controller: controller.chewieController!, - ); - } - - return const Empty(); - }, - ), - ); - }, + return const Empty(); + }, + ), + ); + }, + ), ), ), ), @@ -342,20 +290,19 @@ class _FullScreenMediaState extends State { class VideoPackage { /// Constructor for creating [VideoPackage] VideoPackage( - this._attachment, { + Attachment attachment, { bool showControls = false, bool autoInitialize = true, }) : _showControls = showControls, _autoInitialize = autoInitialize, - _videoPlayerController = _attachment.localUri != null + _videoPlayerController = attachment.localUri != null ? VideoPlayerController.file( - File.fromUri(_attachment.localUri!), + File.fromUri(attachment.localUri!), ) : VideoPlayerController.networkUrl( - Uri.parse(_attachment.assetUrl!), + Uri.parse(attachment.assetUrl!), ); - final Attachment _attachment; final bool _showControls; final bool _autoInitialize; final VideoPlayerController _videoPlayerController; diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_builder.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_builder.dart index f1919db192..9fe43428d0 100644 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_builder.dart +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_builder.dart @@ -1,27 +1,15 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/fsm_stub.dart' - if (dart.library.io) 'full_screen_media_desktop.dart' as desktop_fsm; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template fsmBuilder} -/// A wrapper widget for conditionally providing the proper -/// StreamFullScreenMedia widget when writing an application that targets -/// all available Flutter platforms (Android, iOS, macOS, Windows, Linux, -/// & Web). +/// A wrapper widget for building [StreamFullScreenMedia]. /// -/// This is required because: -/// * `package:video_player` and `package:chewie` do not support macOS, Windows, -/// & Linux, but _do_ support Android, iOS, & Web. -/// * `package:dart_vlc` _does_ support macOS, Windows, & Linux via FFI. This -/// has the unfortunate consequence of not supporting Web. -/// -/// This widget makes use of dart's conditional imports to ensure that Stream's -/// desktop implementation of StreamFullScreenMedia is not imported when -/// building applications that target web. Additionally, this widget ensures -/// that applications targeting mobile platforms do not build the version of -/// StreamFullScreenMedia that targets desktop platforms (even though -/// `package:dart_vlc` technically supports iOS). +/// Video attachments are played by [StreamVideoPlayer], which uses the +/// `chewie`/`video_player`-backed [DefaultStreamVideoPlayer] by default. +/// `video_player` has no Windows or Linux implementation, so apps that +/// target those platforms and need video playback should override +/// [StreamChatConfigurationData.videoPlayer] (e.g. with a `media_kit`-based +/// player). /// {@endtemplate} class StreamFullScreenMediaBuilder extends StatelessWidget { /// {@macro fsmBuilder} @@ -61,18 +49,6 @@ class StreamFullScreenMediaBuilder extends StatelessWidget { @override Widget build(BuildContext context) { - if (!kIsWeb && isDesktopVideoPlayerSupported) { - return desktop_fsm.getFsm( - mediaAttachmentPackages: mediaAttachmentPackages, - startIndex: startIndex, - userName: userName, - autoplayVideos: autoplayVideos, - onShowMessage: onShowMessage, - onReplyMessage: onReplyMessage, - attachmentActionsModalBuilder: attachmentActionsModalBuilder, - ); - } - return StreamFullScreenMedia( mediaAttachmentPackages: mediaAttachmentPackages, startIndex: startIndex, diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart deleted file mode 100644 index 3e04fc5e7d..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart +++ /dev/null @@ -1,458 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:stream_chat_flutter/src/context_menu/context_menu.dart'; -import 'package:stream_chat_flutter/src/context_menu/context_menu_region.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/download_menu_item.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/gallery_navigation_item.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Returns an instance of [FullScreenMediaDesktop]. -/// -/// This should ONLY be used in [FullScreenMediaBuilder]. -FullScreenMediaWidget getFsm({ - Key? key, - required List mediaAttachmentPackages, - required int startIndex, - required String userName, - ShowMessageCallback? onShowMessage, - ReplyMessageCallback? onReplyMessage, - AttachmentActionsBuilder? attachmentActionsModalBuilder, - bool? autoplayVideos, -}) { - return FullScreenMediaDesktop( - key: key, - mediaAttachmentPackages: mediaAttachmentPackages, - startIndex: startIndex, - userName: userName, - onReplyMessage: onReplyMessage, - onShowMessage: onShowMessage, - attachmentActionsModalBuilder: attachmentActionsModalBuilder, - autoplayVideos: autoplayVideos ?? false, - ); -} - -/// A full screen image widget -class FullScreenMediaDesktop extends FullScreenMediaWidget { - /// Instantiate a new FullScreenImage - const FullScreenMediaDesktop({ - super.key, - required this.mediaAttachmentPackages, - this.startIndex = 0, - String? userName, - this.onShowMessage, - this.onReplyMessage, - this.attachmentActionsModalBuilder, - this.autoplayVideos = false, - }) : userName = userName ?? ''; - - /// The url of the image - final List mediaAttachmentPackages; - - /// First index of media shown - final int startIndex; - - /// Username of sender - final String userName; - - /// Callback for when show message is tapped - final ShowMessageCallback? onShowMessage; - - /// Callback for when reply message is tapped - final ReplyMessageCallback? onReplyMessage; - - /// Widget builder for attachment actions modal - /// [defaultActionsModal] is the default [AttachmentActionsModal] config - /// Use [defaultActionsModal.copyWith] to easily customize it - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - - /// Auto-play videos when page is opened - final bool autoplayVideos; - - @override - _FullScreenMediaDesktopState createState() => _FullScreenMediaDesktopState(); -} - -class _FullScreenMediaDesktopState extends State { - late final PageController _pageController; - - late final _currentPage = ValueNotifier(widget.startIndex); - late final _isDisplayingDetail = ValueNotifier(true); - - void switchDisplayingDetail() { - _isDisplayingDetail.value = !_isDisplayingDetail.value; - } - - final videoPackages = {}; - - @override - void initState() { - super.initState(); - _pageController = PageController(initialPage: widget.startIndex); - for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) { - final attachment = widget.mediaAttachmentPackages[i].attachment; - if (attachment.type != AttachmentType.video) continue; - final package = DesktopVideoPackage(attachment); - videoPackages[attachment.id] = package; - } - } - - @override - void dispose() { - _currentPage.dispose(); - _pageController.dispose(); - _isDisplayingDetail.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final containsOnlyVideos = - widget.mediaAttachmentPackages.length == videoPackages.length; - - return Scaffold( - resizeToAvoidBottomInset: false, - body: containsOnlyVideos ? _buildVideoPageView() : _buildPageView(), - ); - } - - Widget _buildVideoPageView() { - return Stack( - children: [ - ContextMenuRegion( - contextMenuBuilder: (_, anchor) { - return ContextMenu( - anchor: anchor, - menuItems: [ - DownloadMenuItem( - attachment: widget - .mediaAttachmentPackages[_currentPage.value].attachment, - ), - ], - ); - }, - child: _PlaylistPlayer( - packages: videoPackages.values.toList(), - autoStart: widget.autoplayVideos, - ), - ), - Positioned( - left: 8, - top: 8, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - videoPackages.values.first.player.stop(); - Navigator.of(context).pop(); - }, - child: const StreamSvgIcon( - size: 30, - icon: StreamSvgIcons.close, - ), - ), - ), - ), - ], - ); - } - - Widget _buildPageView() { - return ValueListenableBuilder( - valueListenable: _currentPage, - builder: (context, currentPage, child) { - final _currentAttachmentPackage = - widget.mediaAttachmentPackages[currentPage]; - final _currentMessage = _currentAttachmentPackage.message; - final _currentAttachment = _currentAttachmentPackage.attachment; - return Stack( - children: [ - child!, - ValueListenableBuilder( - valueListenable: _isDisplayingDetail, - builder: (context, isDisplayingDetail, child) { - final mediaQuery = MediaQuery.of(context); - final topPadding = mediaQuery.padding.top; - return AnimatedPositionedDirectional( - duration: kThemeAnimationDuration, - curve: Curves.easeInOut, - top: isDisplayingDetail ? 0 : -(topPadding + kToolbarHeight), - start: 0, - end: 0, - height: topPadding + kToolbarHeight, - child: StreamGalleryHeader( - userName: widget.userName, - sentAt: context.translations.sentAtText( - date: _currentAttachmentPackage.message.createdAt, - time: _currentAttachmentPackage.message.createdAt, - ), - onBackPressed: Navigator.of(context).pop, - message: _currentMessage, - attachment: _currentAttachment, - onShowMessage: () { - widget.onShowMessage?.call( - _currentMessage, - StreamChannel.of(context).channel, - ); - }, - attachmentActionsModalBuilder: - widget.attachmentActionsModalBuilder, - ), - ); - }, - ), - if (!_currentMessage.isEphemeral) - ValueListenableBuilder( - valueListenable: _isDisplayingDetail, - builder: (context, isDisplayingDetail, child) { - final mediaQuery = MediaQuery.of(context); - final bottomPadding = mediaQuery.padding.bottom; - return AnimatedPositionedDirectional( - duration: kThemeAnimationDuration, - curve: Curves.easeInOut, - bottom: isDisplayingDetail - ? 0 - : -(bottomPadding + kToolbarHeight), - start: 0, - end: 0, - height: bottomPadding + kToolbarHeight, - child: StreamGalleryFooter( - currentPage: currentPage, - totalPages: widget.mediaAttachmentPackages.length, - mediaAttachmentPackages: widget.mediaAttachmentPackages, - mediaSelectedCallBack: (val) { - _currentPage.value = val; - _pageController.animateToPage( - val, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - Navigator.pop(context); - }, - ), - ); - }, - ), - if (widget.mediaAttachmentPackages.length > 1) ...[ - if (currentPage > 0) - GalleryNavigationItem( - left: 8, - opacityAnimation: _isDisplayingDetail, - icon: const Icon(Icons.chevron_left_rounded), - onPressed: () { - _currentPage.value--; - _pageController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - if (currentPage < widget.mediaAttachmentPackages.length - 1) - GalleryNavigationItem( - right: 8, - opacityAnimation: _isDisplayingDetail, - icon: const Icon(Icons.chevron_right_rounded), - onPressed: () { - _currentPage.value++; - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - ], - ], - ); - }, - child: InkWell( - onTap: switchDisplayingDetail, - child: KeyboardShortcutRunner( - onEscapeKeypress: Navigator.of(context).pop, - onLeftArrowKeypress: () { - if (_currentPage.value > 0) { - _currentPage.value--; - _pageController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, - onRightArrowKeypress: () { - if (_currentPage.value < - widget.mediaAttachmentPackages.length - 1) { - _currentPage.value++; - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, - child: PageView.builder( - controller: _pageController, - itemCount: widget.mediaAttachmentPackages.length, - onPageChanged: (val) { - _currentPage.value = val; - if (videoPackages.isEmpty) return; - final currentAttachment = - widget.mediaAttachmentPackages[val].attachment; - for (final p in videoPackages.values) { - if (p.attachment != currentAttachment) { - p.player.pause(); - } - } - if (widget.autoplayVideos && - currentAttachment.type == AttachmentType.video) { - final package = videoPackages[currentAttachment.id]!; - package.player.play(); - } - }, - itemBuilder: (context, index) { - final currentAttachmentPackage = - widget.mediaAttachmentPackages[index]; - final attachment = currentAttachmentPackage.attachment; - - return ValueListenableBuilder( - valueListenable: _isDisplayingDetail, - builder: (context, isDisplayingDetail, child) { - final padding = MediaQuery.paddingOf(context); - - return AnimatedContainer( - duration: kThemeChangeDuration, - color: switch (isDisplayingDetail) { - true => StreamChannelHeaderTheme.of(context).color, - false => Colors.black, - }, - padding: EdgeInsetsDirectional.only( - top: padding.top + kToolbarHeight, - bottom: padding.bottom + kToolbarHeight, - ), - child: child, - ); - }, - child: Builder( - builder: (context) { - if (attachment.type == AttachmentType.image || - attachment.type == AttachmentType.giphy) { - return PhotoView.customChild( - maxScale: PhotoViewComputedScale.covered, - minScale: PhotoViewComputedScale.contained, - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), - child: StreamMediaAttachmentThumbnail( - media: attachment, - width: double.infinity, - height: double.infinity, - ), - ); - } else if (attachment.type == AttachmentType.video) { - final package = videoPackages[attachment.id]!; - if (package.attachment.assetUrl != null) { - package.player.open( - Playlist( - [ - Media(package.attachment.assetUrl!), - ], - ), - play: widget.autoplayVideos, - ); - } - - return ContextMenuRegion( - contextMenuBuilder: (_, anchor) { - return ContextMenu( - anchor: anchor, - menuItems: [ - DownloadMenuItem( - attachment: attachment, - ), - ], - ); - }, - child: Video( - controller: package.controller, - ), - ); - } - - return const Empty(); - }, - ), - ); - }, - ), - ), - ), - ); - } -} - -/// Class for packaging up things required for videos -class DesktopVideoPackage { - /// Constructor for creating [VideoPackage] - factory DesktopVideoPackage( - Attachment attachment, { - bool showControls = true, - }) { - final player = Player(); - final controller = VideoController(player); - return DesktopVideoPackage._internal( - attachment, - player, - controller, - showControls, - ); - } - - DesktopVideoPackage._internal( - this.attachment, - this.player, - this.controller, - this.showControls, - ); - - /// The video attachment to play. - final Attachment attachment; - - /// The VLC player to use. - final Player player; - - /// The VLC video controller to use. - final VideoController controller; - - /// Whether to show the player controls or not. - final bool showControls; -} - -class _PlaylistPlayer extends StatelessWidget { - const _PlaylistPlayer({ - required this.packages, - required this.autoStart, - }); - - final List packages; - final bool autoStart; - - @override - Widget build(BuildContext context) { - final _media = []; - for (final package in packages) { - if (package.attachment.assetUrl != null) { - _media.add(Media(package.attachment.assetUrl!)); - } - } - packages.first.player.open( - Playlist( - _media, - ), - play: autoStart, - ); - return Video( - controller: packages.first.controller, - fit: BoxFit.cover, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_scope.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_scope.dart new file mode 100644 index 0000000000..583e96fde2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_scope.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// Exposes the active page index of the enclosing `StreamFullScreenMedia` +/// gallery to descendants. +/// +/// Per-page widgets that need to react when their page is no longer +/// visible — e.g. video players that pause themselves while off-screen — +/// read [activeIndex] from this scope and compare it against their own +/// page index. +/// +/// {@tool snippet} +/// +/// ```dart +/// final scope = FullScreenMediaScope.maybeOf(context); +/// final isActive = scope == null || scope.activeIndex.value == myPageIndex; +/// ``` +/// {@end-tool} +class FullScreenMediaScope extends InheritedWidget { + /// Creates a [FullScreenMediaScope]. + const FullScreenMediaScope({ + super.key, + required this.activeIndex, + required super.child, + }); + + /// The active page index of the enclosing gallery. + final ValueListenable activeIndex; + + /// Returns the [FullScreenMediaScope] of the nearest enclosing + /// `StreamFullScreenMedia`, or `null` if there isn't one. + /// + /// Prefer [of] when the absence of the scope is a programmer error. + static FullScreenMediaScope? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + /// Returns the [FullScreenMediaScope] of the nearest enclosing + /// `StreamFullScreenMedia`. + /// + /// Throws a [FlutterError] when no scope is in scope — typically because + /// the calling widget is rendered outside the gallery's page tree. + /// Use [maybeOf] when the absence is a recoverable case. + static FullScreenMediaScope of(BuildContext context) { + final scope = maybeOf(context); + if (scope != null) return scope; + + throw FlutterError.fromParts([ + ErrorSummary( + 'FullScreenMediaScope.of() called with a context that does not ' + 'contain a StreamFullScreenMedia.', + ), + ErrorDescription( + 'No StreamFullScreenMedia ancestor could be found starting from ' + 'the context that was passed to FullScreenMediaScope.of(). This ' + 'usually means the caller is being built outside the page tree ' + 'owned by the gallery, or the context predates the ' + 'StreamFullScreenMedia itself.', + ), + ErrorHint( + 'Make sure to only call FullScreenMediaScope.of() from a widget ' + 'that is a descendant of a StreamFullScreenMedia, or use ' + 'FullScreenMediaScope.maybeOf() instead.', + ), + context.describeElement('The context used was'), + ]); + } + + @override + bool updateShouldNotify(FullScreenMediaScope oldWidget) => + activeIndex != oldWidget.activeIndex; +} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_widget.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_widget.dart deleted file mode 100644 index adcac9d3ff..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_widget.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -/// {@template fsmWidget} -/// An ultra-simple abstract class that allows [FullScreenMediaBuilder] -/// to call `getFsm()` and build the correct version of FullScreenMedia. -/// {@endtemplate} -abstract class FullScreenMediaWidget extends StatefulWidget { - /// {@macro fsmWidget} - const FullScreenMediaWidget({super.key}); -} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/default_stream_video_player.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/default_stream_video_player.dart new file mode 100644 index 0000000000..e36abac281 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/default_stream_video_player.dart @@ -0,0 +1,121 @@ +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// The default `chewie`/`video_player`-backed implementation of +/// [StreamVideoPlayer]. +/// +/// Initializes its own [VideoPackage] lazily once built, and pauses itself +/// when the enclosing gallery swipes away from this page, resuming on +/// return if the user had it playing before. +/// +/// Note: `video_player` has no Windows or Linux implementation, so on +/// those platforms this widget shows an error placeholder unless the app +/// overrides [StreamChatConfigurationData.videoPlayer] with a player that +/// supports desktop playback (e.g. via `media_kit`). +/// +/// Routed to internally by [StreamVideoPlayer]; you generally don't need +/// to construct this directly. +class DefaultStreamVideoPlayer extends StatefulWidget { + /// Creates a [DefaultStreamVideoPlayer]. + const DefaultStreamVideoPlayer({super.key, required this.props}); + + /// The properties that configure this video player. + final StreamVideoPlayerProps props; + + /// The video attachment to play. + Attachment get attachment => props.attachment; + + /// The 0-based index of this page in the enclosing + /// `StreamFullScreenMedia`. + int get pageIndex => props.pageIndex; + + /// Whether playback should auto-start when this page first becomes + /// active. + bool get autoplay => props.autoplay; + + @override + State createState() => + _DefaultStreamVideoPlayerState(); +} + +class _DefaultStreamVideoPlayerState extends State + with + StreamVideoPlayerActivityMixin, + AutomaticKeepAliveClientMixin { + VideoPackage? _package; + Object? _error; + + @override + int get pageIndex => widget.pageIndex; + + @override + bool get autoplay => widget.autoplay; + + @override + bool get isPlayerReady => _package?.initialized ?? false; + + @override + bool get isPlaying => _package?.videoPlayer.value.isPlaying ?? false; + + @override + void play() => _package?.chewieController?.play(); + + @override + void pause() => _package?.chewieController?.pause(); + + // Keep the page mounted in the enclosing PageView once the controller is + // ready, so swiping away and back doesn't re-initialise and replay the + // loading spinner. + @override + bool get wantKeepAlive => isPlayerReady; + + @override + void initState() { + super.initState(); + _initialize(); + } + + Future _initialize() async { + final package = VideoPackage(widget.attachment, showControls: true); + _package = package; + try { + await package.initialize(); + if (!mounted) return; + setState(() {}); + updateKeepAlive(); + syncPlayState(); + } catch (error) { + if (!mounted) return; + setState(() => _error = error); + } + } + + @override + void dispose() { + _package?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + if (_error != null) { + return Center( + child: Icon( + Icons.error_outline_rounded, + size: 48, + color: StreamChatTheme.of(context).colorTheme.disabled, + ), + ); + } + + final chewieController = _package?.chewieController; + if (chewieController == null) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + + return Chewie(controller: chewieController); + } +} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/stream_video_player.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/stream_video_player.dart new file mode 100644 index 0000000000..f894550adc --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/stream_video_player.dart @@ -0,0 +1,57 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Plays a chat video attachment inside a `StreamFullScreenMedia` gallery. +/// +/// Renders whatever [StreamChatConfigurationData.videoPlayer] returns for +/// [props], falling back to [DefaultStreamVideoPlayer] (`chewie` / +/// `video_player`) when no override is configured or the override returns +/// `null`. +/// +/// `video_player` doesn't support Windows or Linux, so apps that need +/// video playback on those platforms should override +/// [StreamChatConfigurationData.videoPlayer], e.g. with a `media_kit`-based +/// player: +/// +/// ```dart +/// StreamChat( +/// client: client, +/// streamChatConfigData: StreamChatConfigurationData( +/// videoPlayer: (context, props) { +/// if (kIsWeb || !isDesktopVideoPlayerSupported) return null; +/// return MyMediaKitVideoPlayer(props: props); +/// }, +/// ), +/// child: ..., +/// ) +/// ``` +/// +/// See also: +/// +/// * [StreamVideoPlayerProps], which configures this widget. +/// * [DefaultStreamVideoPlayer], the default implementation. +/// * [StreamFullScreenMedia], the host viewer this widget plays into. +class StreamVideoPlayer extends StatelessWidget { + /// Creates a [StreamVideoPlayer]. + StreamVideoPlayer({ + super.key, + required Attachment attachment, + required int pageIndex, + bool autoplay = false, + }) : props = StreamVideoPlayerProps( + attachment: attachment, + pageIndex: pageIndex, + autoplay: autoplay, + ); + + /// The properties that configure this video player. + final StreamVideoPlayerProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamChatConfiguration.of(context).videoPlayer; + final override = builder?.call(context, props); + if (override != null) return override; + return DefaultStreamVideoPlayer(props: props); + } +} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/stream_video_player_activity_mixin.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/stream_video_player_activity_mixin.dart new file mode 100644 index 0000000000..303d34bdfa --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/stream_video_player_activity_mixin.dart @@ -0,0 +1,90 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_scope.dart'; + +/// State mixin for playback controllers (video / audio) that want to +/// behave well inside a `StreamFullScreenMedia` gallery. +/// +/// When the widget is hosted inside the gallery, the mixin subscribes to +/// the enclosing [FullScreenMediaScope]'s active page index and toggles +/// playback so: +/// +/// - When the page becomes active again, playback resumes if the user had +/// it playing before swiping away — or if [autoplay] is true on the +/// first activation. +/// - When the page goes off-screen, playback pauses; the prior state is +/// remembered so swiping back doesn't drop the user's position or +/// force-restart paused videos. +/// +/// When there is no [FullScreenMediaScope] ancestor, the mixin treats the +/// widget as always active — [autoplay] kicks in on init and the caller +/// controls play / pause manually after that. This lets the same player +/// class be reused outside the gallery without breaking. +mixin StreamVideoPlayerActivityMixin on State { + ValueListenable? _activeIndex; + bool _wasPlayingBeforeInactive = false; + + /// The 0-based index of this page in the enclosing + /// `StreamFullScreenMedia`. Ignored when no gallery ancestor is in scope. + int get pageIndex; + + /// Whether playback should auto-start when this page first becomes + /// active. Already-paused-by-user state is preserved on re-activation + /// regardless of this flag. + bool get autoplay; + + /// Whether the underlying controller is ready for [play] / [pause] + /// calls. The mixin gates its sync on this flag so subclasses can + /// safely return false during async initialisation. + bool get isPlayerReady; + + /// Whether the underlying controller is currently playing. + bool get isPlaying; + + /// Starts playback. Called only when [isPlayerReady] is true. + void play(); + + /// Pauses playback. Called only when [isPlayerReady] is true. + void pause(); + + /// True when this page is currently the active gallery page. + /// + /// Treated as always active when no [FullScreenMediaScope] ancestor is + /// in scope, so the player still auto-plays / can be controlled + /// manually outside the gallery. + bool get isActive => _activeIndex == null || _activeIndex!.value == pageIndex; + + /// Subclasses should call this when their async initialisation completes + /// so the mixin can apply the right initial play / pause state. + void syncPlayState() => _syncPlayState(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final next = FullScreenMediaScope.maybeOf(context)?.activeIndex; + if (_activeIndex != next) { + _activeIndex?.removeListener(_syncPlayState); + _activeIndex = next?..addListener(_syncPlayState); + _syncPlayState(); + } + } + + @override + void dispose() { + _activeIndex?.removeListener(_syncPlayState); + super.dispose(); + } + + void _syncPlayState() { + if (!isPlayerReady) return; + if (isActive) { + if (autoplay || _wasPlayingBeforeInactive) { + play(); + _wasPlayingBeforeInactive = false; + } + } else if (isPlaying) { + _wasPlayingBeforeInactive = true; + pause(); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/stream_video_player_props.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/stream_video_player_props.dart new file mode 100644 index 0000000000..686c54f669 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/video_player/stream_video_player_props.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Properties for configuring a [StreamVideoPlayer]. +/// +/// This class holds all the configuration options for a video player, +/// allowing them to be passed through +/// [StreamChatConfigurationData.videoPlayer]. +/// +/// See also: +/// +/// * [StreamVideoPlayer], which uses these properties. +/// * [DefaultStreamVideoPlayer], the default implementation. +@immutable +class StreamVideoPlayerProps { + /// Creates properties for a video player. + const StreamVideoPlayerProps({ + required this.attachment, + required this.pageIndex, + this.autoplay = false, + }); + + /// The video attachment to play. + final Attachment attachment; + + /// The 0-based index of this page in the enclosing + /// [StreamFullScreenMedia]. + final int pageIndex; + + /// Whether playback should auto-start the first time this page becomes + /// active. Already-paused-by-user state is preserved on re-activation. + final bool autoplay; +} + +/// Builds a custom video player for a video [Attachment]. +/// +/// Return `null` to fall back to the default video player +/// ([DefaultStreamVideoPlayer]). +/// +/// Set via [StreamChatConfigurationData.videoPlayer] to override the video +/// player used inside [StreamFullScreenMedia], e.g. to plug in a +/// `media_kit`-based player on desktop platforms that `chewie` doesn't +/// support: +/// +/// ```dart +/// StreamChatConfigurationData( +/// videoPlayer: (context, props) { +/// if (kIsWeb || !isDesktopVideoPlayerSupported) return null; +/// return MyMediaKitVideoPlayer(props: props); +/// }, +/// ) +/// ``` +typedef StreamVideoPlayerBuilder = Widget? Function( + BuildContext context, + StreamVideoPlayerProps props, +); diff --git a/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keysets.dart b/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keysets.dart index 495be7d922..509214c74f 100644 --- a/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keysets.dart +++ b/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keysets.dart @@ -12,21 +12,21 @@ final enterKeySet = LogicalKeySet( /// /// Use for: /// * Removing a reply from [StreamMessageInput]. -/// * Closing [FullScreenMediaDesktop]. +/// * Closing [StreamFullScreenMedia]. final escapeKeySet = LogicalKeySet( LogicalKeyboardKey.escape, ); /// The "right arrow" keyset. /// -/// Use for navigating to the next [FullScreenMediaDesktop] item. +/// Use for navigating to the next [StreamFullScreenMedia] item. final rightArrowKeySet = LogicalKeySet( LogicalKeyboardKey.arrowRight, ); /// The "left arrow" keyset. /// -/// Use for navigating to the previous [FullScreenMediaDesktop] item. +/// Use for navigating to the previous [StreamFullScreenMedia] item. final leftArrowKeySet = LogicalKeySet( LogicalKeyboardKey.arrowLeft, ); diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart index f074251512..f41bf38fd7 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/video/vlc/vlc_manager.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamChat} @@ -144,15 +143,6 @@ class StreamChatState extends State { StreamChatConfigurationData get streamChatConfigData => widget.streamChatConfigData ?? StreamChatConfigurationData(); - @override - void initState() { - super.initState(); - // Ensures that VLC only initializes in real desktop environments - if (!isTestEnvironment && isDesktopVideoPlayerSupported) { - VlcManager.instance.initialize(); - } - } - @override Widget build(BuildContext context) { final theme = _getTheme(context, widget.streamChatThemeData); diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart index 54fec9887b..8afa87e5c7 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -116,6 +116,7 @@ class StreamChatConfigurationData { bool? enforceUniqueReactions, bool draftMessagesEnabled = false, MessagePreviewFormatter? messagePreviewFormatter, + StreamVideoPlayerBuilder? videoPlayer, }) { return StreamChatConfigurationData._( loadingIndicator: loadingIndicator, @@ -126,6 +127,7 @@ class StreamChatConfigurationData { draftMessagesEnabled: draftMessagesEnabled, messagePreviewFormatter: messagePreviewFormatter ?? MessagePreviewFormatter(), + videoPlayer: videoPlayer, ); } @@ -137,6 +139,7 @@ class StreamChatConfigurationData { required this.enforceUniqueReactions, required this.draftMessagesEnabled, required this.messagePreviewFormatter, + required this.videoPlayer, }); /// Copies the configuration options from one [StreamChatConfigurationData] to @@ -149,6 +152,7 @@ class StreamChatConfigurationData { bool? enforceUniqueReactions, bool? draftMessagesEnabled, MessagePreviewFormatter? messagePreviewFormatter, + StreamVideoPlayerBuilder? videoPlayer, }) { return StreamChatConfigurationData( reactionIcons: reactionIcons ?? this.reactionIcons, @@ -160,6 +164,7 @@ class StreamChatConfigurationData { draftMessagesEnabled: draftMessagesEnabled ?? this.draftMessagesEnabled, messagePreviewFormatter: messagePreviewFormatter ?? this.messagePreviewFormatter, + videoPlayer: videoPlayer ?? this.videoPlayer, ); } @@ -183,6 +188,16 @@ class StreamChatConfigurationData { /// Whether a new reaction should replace the existing one. final bool enforceUniqueReactions; + /// Builds a custom video player for video attachments shown inside + /// `StreamFullScreenMedia`. + /// + /// Return `null` (or leave this unset) to use the default `chewie`-based + /// player ([DefaultStreamVideoPlayer]). `video_player` (and therefore the + /// default player) has no Windows or Linux implementation, so apps that + /// need desktop video playback should provide a player here, e.g. one + /// backed by `media_kit`. + final StreamVideoPlayerBuilder? videoPlayer; + /// The formatter used for message previews throughout the application. /// /// Defaults to [MessagePreviewFormatter]. diff --git a/packages/stream_chat_flutter/lib/src/video/vlc/vlc_manager.dart b/packages/stream_chat_flutter/lib/src/video/vlc/vlc_manager.dart deleted file mode 100644 index adecfc56b9..0000000000 --- a/packages/stream_chat_flutter/lib/src/video/vlc/vlc_manager.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:stream_chat_flutter/src/video/vlc/vlc_stub.dart' - if (dart.library.io) 'vlc_manager_desktop.dart' - if (dart.library.html) 'vlc_manager_web.dart'; - -/// {@template vlcManager} -/// An abstract class for the purpose of ensuring Flutter applications that -/// target both desktop & web do not crash when building for web targets. -/// {@endtemplate} -abstract class VlcManager { - // ignore: use_late_for_private_fields_and_variables - static VlcManager? _instance; - - /// The current instance of [VlcManager]. - static VlcManager get instance { - _instance = getVlc(); - - return _instance!; - } - - /// Initializes VLC. - void initialize(); -} diff --git a/packages/stream_chat_flutter/lib/src/video/vlc/vlc_manager_desktop.dart b/packages/stream_chat_flutter/lib/src/video/vlc/vlc_manager_desktop.dart deleted file mode 100644 index 4064f40547..0000000000 --- a/packages/stream_chat_flutter/lib/src/video/vlc/vlc_manager_desktop.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:media_kit/media_kit.dart'; -import 'package:stream_chat_flutter/src/video/vlc/vlc_manager.dart'; - -/// The desktop implementation of [VlcManager]. It simply initializes VLC. -class VlcManagerDesktop extends VlcManager { - @override - void initialize() { - MediaKit.ensureInitialized(); - } -} - -/// Allows [VlcManager] to return the correct implementation. -VlcManager getVlc() => VlcManagerDesktop(); diff --git a/packages/stream_chat_flutter/lib/src/video/vlc/vlc_manager_web.dart b/packages/stream_chat_flutter/lib/src/video/vlc/vlc_manager_web.dart deleted file mode 100644 index 3462350b1e..0000000000 --- a/packages/stream_chat_flutter/lib/src/video/vlc/vlc_manager_web.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter/rendering.dart'; -import 'package:stream_chat_flutter/src/video/vlc/vlc_manager.dart'; - -/// The web implementation of [VlcManager]. Naturally, it does nothing. It -/// exists simply to satisfy the requirements of conditional imports. -class VlcManagerWeb extends VlcManager { - @override - void initialize() { - debugPrint('Stub initialization for VLC.'); - } -} - -/// Allows [VlcManager] to return the correct implementation. -VlcManager getVlc() => VlcManagerWeb(); diff --git a/packages/stream_chat_flutter/lib/src/video/vlc/vlc_stub.dart b/packages/stream_chat_flutter/lib/src/video/vlc/vlc_stub.dart deleted file mode 100644 index 289961fd0b..0000000000 --- a/packages/stream_chat_flutter/lib/src/video/vlc/vlc_stub.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:stream_chat_flutter/src/video/vlc/vlc_manager.dart'; - -/// Method stub for getting the right instance of [VlcManager] on the right -/// platform. -VlcManager getVlc() => throw UnsupportedError('Cannot create VLC Manager'); diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 3d149312ea..94db6ce612 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -43,6 +43,11 @@ export 'src/channel/stream_draft_message_preview_text.dart'; export 'src/channel/stream_message_preview_text.dart'; export 'src/fullscreen_media/full_screen_media.dart'; export 'src/fullscreen_media/full_screen_media_builder.dart'; +export 'src/fullscreen_media/full_screen_media_scope.dart'; +export 'src/fullscreen_media/video_player/default_stream_video_player.dart'; +export 'src/fullscreen_media/video_player/stream_video_player.dart'; +export 'src/fullscreen_media/video_player/stream_video_player_activity_mixin.dart'; +export 'src/fullscreen_media/video_player/stream_video_player_props.dart'; export 'src/gallery/gallery_footer.dart'; export 'src/gallery/gallery_header.dart'; export 'src/icons/stream_svg_icon.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index f3991f75d6..6d37b7e7ce 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -44,8 +44,6 @@ dependencies: jiffy: ^6.2.1 just_audio: ">=0.9.38 <0.11.0" lottie: ^3.1.2 - media_kit: ^1.2.2 - media_kit_video: ^2.0.0 meta: ^1.9.1 path_provider: ^2.1.3 photo_manager: ^3.2.0 diff --git a/packages/stream_chat_flutter/test/src/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart index 5418d29303..c4562e9de7 100644 --- a/packages/stream_chat_flutter/test/src/mocks.dart +++ b/packages/stream_chat_flutter/test/src/mocks.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/src/video/vlc/vlc_manager_desktop.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class MockClient extends Mock implements StreamChatClient { @@ -93,8 +92,6 @@ class MockOwnUser extends Mock implements OwnUser {} class MockAttachment extends Mock implements Attachment {} -class MockVlcManagerDesktop extends Mock implements VlcManagerDesktop {} - class MockStreamMemberListController extends Mock implements StreamMemberListController { @override diff --git a/sample_app/lib/app.dart b/sample_app/lib/app.dart index c453bd3113..8ff8c48fbe 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -12,6 +12,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:go_router/go_router.dart'; +import 'package:media_kit/media_kit.dart'; import 'package:provider/provider.dart'; import 'package:sample_app/firebase_options.dart'; import 'package:sample_app/pages/choose_user_page.dart'; @@ -22,6 +23,7 @@ import 'package:sample_app/state/init_data.dart'; import 'package:sample_app/utils/app_config.dart'; import 'package:sample_app/utils/local_notification_observer.dart'; import 'package:sample_app/utils/localizations.dart'; +import 'package:sample_app/widgets/media_kit_video_player.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_localizations/stream_chat_localizations.dart'; import 'package:stream_chat_persistence/stream_chat_persistence.dart'; @@ -407,6 +409,12 @@ class _StreamChatSampleAppState extends State void initState() { final timeOfStartMs = DateTime.now().millisecondsSinceEpoch; + // Ensures that media kit is initialized on Windows and Linux platforms, + // where it backs the video player registered below. + if (!isTestEnvironment && isDesktopVideoPlayerSupported) { + MediaKit.ensureInitialized(); + } + _initConnection().then( (initData) { setState(() { @@ -516,6 +524,16 @@ class _StreamChatSampleAppState extends State client: _initNotifier.initData!.client, streamChatConfigData: StreamChatConfigurationData( draftMessagesEnabled: true, + // `chewie`/`video_player`, the SDK's default video + // player, doesn't support Windows or Linux, so we + // provide a `media_kit`-based player for those + // platforms instead. + videoPlayer: (context, props) { + if (kIsWeb || !isDesktopVideoPlayerSupported) { + return null; + } + return MediaKitVideoPlayer(props: props); + }, ), child: child, ), diff --git a/sample_app/lib/widgets/media_kit_video_player.dart b/sample_app/lib/widgets/media_kit_video_player.dart new file mode 100644 index 0000000000..1de727f44f --- /dev/null +++ b/sample_app/lib/widgets/media_kit_video_player.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A `media_kit`-backed video player for [StreamVideoPlayerProps]. +/// +/// The SDK's default video player (`chewie`/`video_player`) has no Windows +/// or Linux implementation, so this app registers this player via +/// `StreamChatConfigurationData.videoPlayer` to support video playback on +/// desktop. See `app.dart` for the registration. +/// +/// Like the SDK's `DefaultStreamVideoPlayer`, this pauses itself when the +/// enclosing `StreamFullScreenMedia` gallery swipes away from this page and +/// resumes on return if the user had it playing before. +class MediaKitVideoPlayer extends StatefulWidget { + /// Creates a [MediaKitVideoPlayer]. + const MediaKitVideoPlayer({super.key, required this.props}); + + /// The properties that configure this video player. + final StreamVideoPlayerProps props; + + /// The video attachment to play. + Attachment get attachment => props.attachment; + + /// The 0-based index of this page in the enclosing + /// `StreamFullScreenMedia`. + int get pageIndex => props.pageIndex; + + /// Whether playback should auto-start when this page first becomes + /// active. + bool get autoplay => props.autoplay; + + @override + State createState() => _MediaKitVideoPlayerState(); +} + +class _MediaKitVideoPlayerState extends State + with + StreamVideoPlayerActivityMixin, + AutomaticKeepAliveClientMixin { + late final _player = Player(); + late final _controller = VideoController(_player); + Object? _error; + bool _isReady = false; + + @override + int get pageIndex => widget.pageIndex; + + @override + bool get autoplay => widget.autoplay; + + @override + bool get isPlayerReady => _isReady; + + @override + bool get isPlaying => _player.state.playing; + + @override + void play() => _player.play(); + + @override + void pause() => _player.pause(); + + // Keep the page mounted in the enclosing PageView once the controller is + // ready, so swiping away and back doesn't re-initialise the player. + @override + bool get wantKeepAlive => _isReady; + + @override + void initState() { + super.initState(); + _initialize(); + } + + Future _initialize() async { + final url = widget.attachment.assetUrl; + if (url == null) { + setState(() => _error = StateError( + 'No video source on attachment ${widget.attachment.id}', + )); + return; + } + + try { + await _player.open(Media(url), play: false); + if (!mounted) return; + setState(() => _isReady = true); + updateKeepAlive(); + syncPlayState(); + } catch (error) { + if (!mounted) return; + setState(() => _error = error); + } + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + if (_error != null) { + return Center( + child: Icon( + Icons.error_outline_rounded, + size: 48, + color: StreamChatTheme.of(context).colorTheme.disabled, + ), + ); + } + + if (!_isReady) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + + return Video(controller: _controller); + } +} diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index 6c3af35d7b..c90b366e0e 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -33,6 +33,8 @@ dependencies: flutter_svg: ^2.0.10+1 go_router: ^14.6.2 lottie: ^3.1.2 + media_kit: ^1.2.2 + media_kit_video: ^2.0.0 provider: ^6.0.5 stream_chat_flutter: ^9.26.0 stream_chat_localizations: ^9.26.0