|
1 | 1 | import 'package:flutter/foundation.dart';
|
2 | 2 | import 'package:flutter/gestures.dart';
|
3 | 3 | import 'package:flutter/material.dart';
|
| 4 | +import 'package:flutter/scheduler.dart'; |
4 | 5 | import 'package:flutter/services.dart';
|
5 | 6 | import 'package:html/dom.dart' as dom;
|
6 | 7 | import 'package:intl/intl.dart';
|
7 | 8 | import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
|
| 9 | +import 'package:video_player/video_player.dart'; |
8 | 10 |
|
9 | 11 | import '../api/core.dart';
|
10 | 12 | import '../api/model/model.dart';
|
| 13 | +import '../log.dart'; |
11 | 14 | import '../model/avatar_url.dart';
|
12 | 15 | import '../model/binding.dart';
|
13 | 16 | import '../model/content.dart';
|
@@ -99,13 +102,7 @@ class BlockContentList extends StatelessWidget {
|
99 | 102 | );
|
100 | 103 | return MessageImage(node: node);
|
101 | 104 | } else if (node is InlineVideoNode) {
|
102 |
| - return Text.rich( |
103 |
| - TextSpan(children: [ |
104 |
| - const TextSpan(text: "(unimplemented:", style: errorStyle), |
105 |
| - TextSpan(text: node.debugHtmlText, style: errorCodeStyle), |
106 |
| - const TextSpan(text: ")", style: errorStyle), |
107 |
| - ]), |
108 |
| - style: errorStyle); |
| 105 | + return MessageInlineVideo(node: node); |
109 | 106 | } else if (node is EmbedVideoNode) {
|
110 | 107 | return MessageEmbedVideo(node: node);
|
111 | 108 | } else if (node is UnimplementedBlockContentNode) {
|
@@ -397,6 +394,136 @@ class MessageImage extends StatelessWidget {
|
397 | 394 | }
|
398 | 395 | }
|
399 | 396 |
|
| 397 | +class MessageInlineVideo extends StatefulWidget { |
| 398 | + const MessageInlineVideo({super.key, required this.node}); |
| 399 | + |
| 400 | + final InlineVideoNode node; |
| 401 | + |
| 402 | + @override |
| 403 | + State<MessageInlineVideo> createState() => _MessageInlineVideoState(); |
| 404 | +} |
| 405 | + |
| 406 | +class _MessageInlineVideoState extends State<MessageInlineVideo> { |
| 407 | + Uri? _resolvedSrcUrl; |
| 408 | + |
| 409 | + VideoPlayerController? _controller; |
| 410 | + bool _didAttemptInitialization = false; |
| 411 | + bool _hasNonPlatformError = false; |
| 412 | + |
| 413 | + @override |
| 414 | + void initState() { |
| 415 | + // We delay initialization by a single frame to make sure the |
| 416 | + // BuildContext is a valid context as its needed to retrieve |
| 417 | + // the PerAccountStore during initialization. |
| 418 | + SchedulerBinding.instance.addPostFrameCallback((_) => _initialize()); |
| 419 | + super.initState(); |
| 420 | + } |
| 421 | + |
| 422 | + Future<void> _initialize() async { |
| 423 | + try { |
| 424 | + final store = PerAccountStoreWidget.of(context); |
| 425 | + _resolvedSrcUrl = store.tryResolveUrl(widget.node.srcUrl); |
| 426 | + if (_resolvedSrcUrl == null) { |
| 427 | + return; // TODO(log) |
| 428 | + } |
| 429 | + |
| 430 | + assert(debugLog('VideoPlayerController.networkUrl($_resolvedSrcUrl)')); |
| 431 | + _controller = VideoPlayerController.networkUrl(_resolvedSrcUrl!, httpHeaders: { |
| 432 | + if (_resolvedSrcUrl!.origin == store.account.realmUrl.origin) ...authHeader( |
| 433 | + email: store.account.email, |
| 434 | + apiKey: store.account.apiKey, |
| 435 | + ), |
| 436 | + ...userAgentHeader() |
| 437 | + }); |
| 438 | + |
| 439 | + await _controller!.initialize(); |
| 440 | + _controller!.addListener(_handleVideoControllerUpdates); |
| 441 | + } on PlatformException catch (error) { |
| 442 | + // VideoPlayerController.initialize throws a PlatformException |
| 443 | + // if the provide video source is unsupported by the player. |
| 444 | + // In which case we fallback to opening the video externally |
| 445 | + // when user clicks on the play button, precondition is determined |
| 446 | + // by '_controller.value.hasError'. |
| 447 | + assert(debugLog("PlatformException: VideoPlayerController.initialize failed: $error")); |
| 448 | + } catch (error) { |
| 449 | + _hasNonPlatformError = true; |
| 450 | + assert(debugLog("VideoPlayerController.initialize failed: $error")); |
| 451 | + } finally { |
| 452 | + if (mounted) setState(() { _didAttemptInitialization = true; }); |
| 453 | + } |
| 454 | + } |
| 455 | + |
| 456 | + @override |
| 457 | + void dispose() { |
| 458 | + _controller?.removeListener(_handleVideoControllerUpdates); |
| 459 | + _controller?.dispose(); |
| 460 | + super.dispose(); |
| 461 | + } |
| 462 | + |
| 463 | + void _handleVideoControllerUpdates() { |
| 464 | + assert(debugLog("Video buffered: ${_controller?.value.buffered}")); |
| 465 | + assert(debugLog("Video max duration: ${_controller?.value.duration}")); |
| 466 | + } |
| 467 | + |
| 468 | + @override |
| 469 | + Widget build(BuildContext context) { |
| 470 | + final message = InheritedMessage.of(context); |
| 471 | + |
| 472 | + return MessageMediaContainer( |
| 473 | + onTap: !_didAttemptInitialization |
| 474 | + ? null |
| 475 | + : () { // TODO(log) |
| 476 | + if (_resolvedSrcUrl == null || _hasNonPlatformError) { |
| 477 | + showErrorDialog(context: context, |
| 478 | + title: 'Unable to open video', |
| 479 | + message: 'Video could not be opened: $_resolvedSrcUrl'); |
| 480 | + return; |
| 481 | + } |
| 482 | + |
| 483 | + if (_controller!.value.hasError) { |
| 484 | + // TODO use webview instead, to support auth headers |
| 485 | + _launchUrl(context, widget.node.srcUrl); |
| 486 | + } else { |
| 487 | + Navigator.of(context).push(getLightboxRoute( |
| 488 | + context: context, |
| 489 | + message: message, |
| 490 | + src: _resolvedSrcUrl!, |
| 491 | + videoController: _controller, |
| 492 | + )); |
| 493 | + } |
| 494 | + }, |
| 495 | + child: !_didAttemptInitialization |
| 496 | + ? Container( |
| 497 | + color: Colors.black, |
| 498 | + child: const Center( |
| 499 | + child: SizedBox( |
| 500 | + height: 16, |
| 501 | + width: 16, |
| 502 | + child: CircularProgressIndicator( |
| 503 | + color: Colors.white, |
| 504 | + strokeWidth: 2)))) |
| 505 | + : Stack( |
| 506 | + alignment: Alignment.center, |
| 507 | + children: [ |
| 508 | + if (_resolvedSrcUrl == null || _controller!.value.hasError) |
| 509 | + Container(color: Colors.black) |
| 510 | + else |
| 511 | + LightboxHero( |
| 512 | + message: message, |
| 513 | + src: _resolvedSrcUrl!, |
| 514 | + child: AspectRatio( |
| 515 | + aspectRatio: _controller!.value.aspectRatio, |
| 516 | + child: VideoPlayer(_controller!))), |
| 517 | + const Icon( |
| 518 | + Icons.play_arrow_rounded, |
| 519 | + color: Colors.white, |
| 520 | + size: 32) |
| 521 | + ])); |
| 522 | + } |
| 523 | +} |
| 524 | + |
| 525 | +/// MessageEmbedVideo opens the video href externally, and |
| 526 | +/// a preview image is visible in the content message UI. |
400 | 527 | class MessageEmbedVideo extends StatelessWidget {
|
401 | 528 | const MessageEmbedVideo({super.key, required this.node});
|
402 | 529 |
|
|
0 commit comments