diff --git a/lib/model/content.dart b/lib/model/content.dart index 05067c1d58..632051e028 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -361,6 +361,30 @@ class ImageNode extends BlockContentNode { } } +class VideoNode extends BlockContentNode { + const VideoNode({ + super.debugHtmlNode, + required this.srcUrl, + }); + + final String srcUrl; + // TODO: add fields indicating other video sources (Youtube) + + @override + bool operator ==(Object other) { + return other is VideoNode && other.srcUrl == srcUrl; + } + + @override + int get hashCode => Object.hash('VideoNode', srcUrl); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('srcUrl', srcUrl)); + } +} + /// A content node that expects an inline layout context from its parent. /// /// When rendered into a Flutter widget tree, an inline content node @@ -948,6 +972,40 @@ class _ZulipContentParser { return ImageNode(srcUrl: src, debugHtmlNode: debugHtmlNode); } + BlockContentNode parseVideoNode(dom.Element divElement) { + assert(_debugParserContext == _ParserContext.block); + // TODO: handle parsing other video sources (Youtube) + final videoElement = () { + assert(divElement.localName == 'div' + && divElement.classes.containsAll(['message_inline_image', 'message_inline_video'])); + + if (divElement.nodes.length != 1) return null; + final child = divElement.nodes[0]; + if (child is! dom.Element) return null; + if (child.localName != 'a') return null; + if (child.className.isNotEmpty) return null; + + if (child.nodes.length != 1) return null; + final grandchild = child.nodes[0]; + if (grandchild is! dom.Element) return null; + if (grandchild.localName != 'video') return null; + if (grandchild.className.isNotEmpty) return null; + return grandchild; + }(); + + final debugHtmlNode = kDebugMode ? divElement : null; + if (videoElement == null) { + return UnimplementedBlockContentNode(htmlNode: divElement); + } + + final src = videoElement.attributes['src']; + if (src == null) { + return UnimplementedBlockContentNode(htmlNode: divElement); + } + + return VideoNode(srcUrl: src, debugHtmlNode: debugHtmlNode); + } + BlockContentNode parseBlockContent(dom.Node node) { assert(_debugParserContext == _ParserContext.block); final debugHtmlNode = kDebugMode ? node : null; @@ -957,6 +1015,7 @@ class _ZulipContentParser { final element = node; final localName = element.localName; final className = element.className; + final classes = element.classes; if (localName == 'br' && className.isEmpty) { return LineBreakNode(debugHtmlNode: debugHtmlNode); @@ -1024,6 +1083,10 @@ class _ZulipContentParser { return parseImageNode(element); } + if (localName == 'div' && classes.containsAll(['message_inline_image', 'message_inline_video'])) { + return parseVideoNode(element); + } + // TODO more types of node return UnimplementedBlockContentNode(htmlNode: node); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index c023d6d7be..bc313d622f 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -5,13 +5,16 @@ import 'package:flutter/services.dart'; import 'package:html/dom.dart' as dom; import 'package:intl/intl.dart'; import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; +import 'package:video_player/video_player.dart'; import '../api/core.dart'; import '../api/model/model.dart'; +import '../log.dart'; import '../model/avatar_url.dart'; import '../model/binding.dart'; import '../model/content.dart'; import '../model/internal_link.dart'; +import '../model/store.dart'; import 'code_block.dart'; import 'dialog.dart'; import 'icons.dart'; @@ -96,6 +99,8 @@ class BlockContentList extends StatelessWidget { "It should be wrapped in [ImageNodeList]." ); return MessageImage(node: node); + } else if (node is VideoNode) { + return MessageVideo(node: node); } else if (node is UnimplementedBlockContentNode) { return Text.rich(_errorUnimplemented(node)); } else { @@ -382,6 +387,124 @@ class MessageImage extends StatelessWidget { } } +class MessageVideo extends StatelessWidget { + const MessageVideo({super.key, required this.node}); + + final VideoNode node; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final resolvedSrc = store.tryResolveUrl(node.srcUrl); + + // TODO: handle other video sources (Youtube) + return resolvedSrc != null + ? MessageVideoPreview(src: resolvedSrc, store: store) + : Container(); + } +} + +class MessageVideoPreview extends StatefulWidget { + const MessageVideoPreview({super.key, required this.src, required this.store}); + + final Uri src; + final PerAccountStore store; + + @override + State createState() => _MessageVideoPreviewState(); +} + +class _MessageVideoPreviewState extends State { + late VideoPlayerController _controller; + bool _initialized = false; + + @override + void initState() { + _asyncInitState(); + super.initState(); + } + + Future _asyncInitState() async { + try { + assert(debugLog("VideoPlayerController.networkUrl(${widget.src})")); + _controller = VideoPlayerController.networkUrl(widget.src, httpHeaders: { + if (widget.src.origin == widget.store.account.realmUrl.origin) ...authHeader( + email: widget.store.account.email, apiKey: widget.store.account.apiKey, + ), + ...userAgentHeader() + }); + await _controller.initialize(); + } catch (error) { + assert(debugLog("VideoPlayerController.initialize failed: $error")); + } finally { + setState(() { + _initialized = true; + }); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final message = InheritedMessage.of(context); + + return GestureDetector( + onTap: !_initialized + ? null + : () { // TODO(log) + if (_controller.value.hasError) { + ZulipBinding.instance.launchUrl(widget.src); + } else { + Navigator.of(context).push(getLightboxRoute( + context: context, + message: message, + src: widget.src, + videoController: _controller, + )); + } + }, + child: UnconstrainedBox( + alignment: Alignment.centerLeft, + child: Padding( + // TODO clean up this padding by imitating web less precisely; + // in particular, avoid adding loose whitespace at end of message. + padding: const EdgeInsets.only(right: 5, bottom: 5), + child: LightboxHero( + message: message, + src: widget.src, + child: Container( + height: 100, + width: 150, + color: Colors.black, + child: Stack( + alignment: Alignment.center, + children: [ + if (_initialized && !_controller.value.hasError) + AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller)), + Container(color: const Color.fromRGBO(0, 0, 0, 0.30)), + if (_initialized) + const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 25, + ) + else + const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ), + ])))))); + } +} + class CodeBlock extends StatelessWidget { const CodeBlock({super.key, required this.node}); diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index ca133475ce..30bcc9eab7 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:intl/intl.dart'; +import 'package:video_player/video_player.dart'; import '../api/model/model.dart'; import 'content.dart'; @@ -83,8 +84,8 @@ class _CopyLinkButton extends StatelessWidget { } } -class _LightboxPage extends StatefulWidget { - const _LightboxPage({ +class _ImageLightboxPage extends StatefulWidget { + const _ImageLightboxPage({ required this.routeEntranceAnimation, required this.message, required this.src, @@ -95,10 +96,10 @@ class _LightboxPage extends StatefulWidget { final Uri src; @override - State<_LightboxPage> createState() => _LightboxPageState(); + State<_ImageLightboxPage> createState() => _ImageLightboxPageState(); } -class _LightboxPageState extends State<_LightboxPage> { +class _ImageLightboxPageState extends State<_ImageLightboxPage> { // TODO(#38): Animate entrance/exit of header and footer bool _headerFooterVisible = false; @@ -208,11 +209,195 @@ class _LightboxPageState extends State<_LightboxPage> { } } + +class _VideoLightboxPage extends StatefulWidget { + const _VideoLightboxPage({ + required this.routeEntranceAnimation, + required this.message, + required this.src, + required this.controller, + }); + + final Animation routeEntranceAnimation; + final Message message; + final Uri src; + final VideoPlayerController controller; + + @override + State<_VideoLightboxPage> createState() => _VideoLightboxPageState(); +} + +class _VideoLightboxPageState extends State<_VideoLightboxPage> { + // TODO(#38): Animate entrance/exit of header and footer + bool _headerFooterVisible = false; + + @override + void initState() { + super.initState(); + widget.controller.play(); + widget.controller.addListener(_handleVideoControllerUpdates); + widget.routeEntranceAnimation.addStatusListener(_handleRouteEntranceAnimationStatusChange); + } + + @override + void dispose() { + widget.routeEntranceAnimation.removeStatusListener(_handleRouteEntranceAnimationStatusChange); + widget.controller.removeListener(_handleVideoControllerUpdates); + widget.controller.pause(); + super.dispose(); + } + + void _handleRouteEntranceAnimationStatusChange(AnimationStatus status) { + final entranceAnimationComplete = status == AnimationStatus.completed; + setState(() { + _headerFooterVisible = entranceAnimationComplete; + }); + } + + void _handleTap() { + setState(() { + _headerFooterVisible = !_headerFooterVisible; + }); + } + + void _handleVideoControllerUpdates() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + + final appBarBackgroundColor = Colors.grey.shade900.withOpacity(0.87); + const appBarForegroundColor = Colors.white; + const appBarElevation = 0.0; + + PreferredSizeWidget? appBar; + if (_headerFooterVisible) { + // TODO(#45): Format with e.g. "Yesterday at 4:47 PM" + final timestampText = DateFormat + .yMMMd(/* TODO(#278): Pass selected language here, I think? */) + .add_Hms() + .format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000)); + + appBar = AppBar( + centerTitle: false, + foregroundColor: appBarForegroundColor, + backgroundColor: appBarBackgroundColor, + shape: const Border(), // Remove bottom border from [AppBarTheme] + elevation: appBarElevation, + + // TODO(#41): Show message author's avatar + title: RichText( + text: TextSpan(children: [ + TextSpan( + text: '${widget.message.senderFullName}\n', + + // Restate default + style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor)), + TextSpan( + text: timestampText, + + // Make smaller, like a subtitle + style: themeData.textTheme.titleSmall!.copyWith(color: appBarForegroundColor)), + ]))); + } + + Widget? bottomAppBar; + if (_headerFooterVisible) { + bottomAppBar = BottomAppBar( + height: 150, + color: appBarBackgroundColor, + elevation: appBarElevation, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Row( + children: [ + Text( + widget.controller.value.position.formatHHMMSS(), + style: const TextStyle(color: Colors.white), + ), + Expanded( + child: Slider( + value: widget.controller.value.position.inSeconds.toDouble(), + max: widget.controller.value.duration.inSeconds.toDouble(), + activeColor: Colors.white, + onChanged: (value) { + widget.controller.seekTo(Duration(seconds: value.toInt())); + }, + ), + ), + Text( + widget.controller.value.duration.formatHHMMSS(), + style: const TextStyle(color: Colors.white), + ), + ], + ), + IconButton( + onPressed: () { + if (widget.controller.value.isPlaying) { + widget.controller.pause(); + } else { + widget.controller.play(); + } + }, + icon: Icon( + widget.controller.value.isPlaying + ? Icons.pause_circle_rounded + : Icons.play_circle_rounded, + size: 50, + )), + ])); + } + + return Theme( + data: themeData.copyWith( + iconTheme: themeData.iconTheme.copyWith(color: appBarForegroundColor)), + child: Scaffold( + backgroundColor: Colors.black, + extendBody: true, // For the BottomAppBar + extendBodyBehindAppBar: true, // For the AppBar + appBar: appBar, + bottomNavigationBar: bottomAppBar, + body: MediaQuery( + // Clobber the MediaQueryData prepared by Scaffold with one that's not + // affected by the app bars. On this screen, the app bars are + // translucent, dismissible overlays above the pan-zoom layer in the + // Z direction, so the pan-zoom layer doesn't need avoid them in the Y + // direction. + data: MediaQuery.of(context), + + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _handleTap, + child: SafeArea( + child: LightboxHero( + message: widget.message, + src: widget.src, + child: Center( + child: AspectRatio( + aspectRatio: widget.controller.value.aspectRatio, + child: VideoPlayer(widget.controller))))))))); + } +} + +extension DurationFormatting on Duration { + String formatHHMMSS() { + final hoursString = inHours.toString().padLeft(2, '0'); + final minutesString = inMinutes.remainder(60).toString().padLeft(2, '0'); + final secondsString = inSeconds.remainder(60).toString().padLeft(2, '0'); + + return '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString'; + } +} + Route getLightboxRoute({ int? accountId, BuildContext? context, required Message message, required Uri src, + VideoPlayerController? videoController, }) { return AccountPageRouteBuilder( accountId: accountId, @@ -224,7 +409,21 @@ Route getLightboxRoute({ Animation secondaryAnimation, ) { // TODO(#40): Drag down to close? - return _LightboxPage(routeEntranceAnimation: animation, message: message, src: src); + + return switch (videoController) { + null => _ImageLightboxPage( + routeEntranceAnimation: + animation, message: + message, + src: src, + ), + _ => _VideoLightboxPage( + routeEntranceAnimation: animation, + message: message, + src: src, + controller: videoController, + ), + }; }, transitionsBuilder: ( BuildContext context, diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d9af90d6c9..28372ca883 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,6 +15,7 @@ import path_provider_foundation import share_plus import sqlite3_flutter_libs import url_launcher_macos +import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) @@ -27,4 +28,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index daf3de8254..864f482d53 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -90,6 +90,9 @@ PODS: - sqlite3/rtree - url_launcher_macos (0.0.1): - FlutterMacOS + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) @@ -103,6 +106,7 @@ DEPENDENCIES: - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) SPEC REPOS: trunk: @@ -140,6 +144,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + video_player_avfoundation: + :path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin SPEC CHECKSUMS: device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f @@ -163,7 +169,8 @@ SPEC CHECKSUMS: sqlite3: 73b7fc691fdc43277614250e04d183740cb15078 sqlite3_flutter_libs: 06a05802529659a272beac4ee1350bfec294f386 url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 + video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579 PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 -COCOAPODS: 1.13.0 +COCOAPODS: 1.15.0 diff --git a/pubspec.lock b/pubspec.lock index 2f069a73a3..5cfdcb2fee 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1193,6 +1193,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: afc65f4b8bcb2c188f64a591f84fb471f4f2e19fc607c65fd8d2f8fedb3dec23 + url: "https://pub.dev" + source: hosted + version: "2.8.3" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "4dd9b8b86d70d65eecf3dcabfcdfbb9c9115d244d022654aba49a00336d540c2" + url: "https://pub.dev" + source: hosted + version: "2.4.12" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "309e3962795e761be010869bae65c0b0e45b5230c5cee1bec72197ca7db040ed" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "236454725fafcacf98f0f39af0d7c7ab2ce84762e3b63f2cbb3ef9a7e0550bc6" + url: "https://pub.dev" + source: hosted + version: "6.2.2" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "8e9cb7fe94e49490e67bbc15149691792b58a0ade31b32e3f3688d104a0e057b" + url: "https://pub.dev" + source: hosted + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3c85f33b44..1d21ce42d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ dependencies: url_launcher: ^6.1.11 url_launcher_android: ">=6.1.0" sqlite3: ^2.4.0 + video_player: ^2.8.3 dev_dependencies: flutter_driver: