|
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';
|
@@ -90,14 +93,14 @@ class BlockContentList extends StatelessWidget {
|
90 | 93 | return MathBlock(node: node);
|
91 | 94 | } else if (node is ImageNodeList) {
|
92 | 95 | return MessageImageList(node: node);
|
93 |
| - } else if (node is VideoNode) { |
94 |
| - return Container(); |
95 | 96 | } else if (node is ImageNode) {
|
96 | 97 | assert(false,
|
97 | 98 | "[ImageNode] not allowed in [BlockContentList]. "
|
98 | 99 | "It should be wrapped in [ImageNodeList]."
|
99 | 100 | );
|
100 | 101 | return MessageImage(node: node);
|
| 102 | + } else if (node is VideoNode) { |
| 103 | + return MessageVideo(node: node); |
101 | 104 | } else if (node is UnimplementedBlockContentNode) {
|
102 | 105 | return Text.rich(_errorUnimplemented(node));
|
103 | 106 | } else {
|
@@ -384,6 +387,188 @@ class MessageImage extends StatelessWidget {
|
384 | 387 | }
|
385 | 388 | }
|
386 | 389 |
|
| 390 | +class MessageVideo extends StatelessWidget { |
| 391 | + const MessageVideo({super.key, required this.node}); |
| 392 | + |
| 393 | + final VideoNode node; |
| 394 | + |
| 395 | + @override |
| 396 | + Widget build(BuildContext context) { |
| 397 | + final store = PerAccountStoreWidget.of(context); |
| 398 | + |
| 399 | + // For YouTube and Vimeo links, display a widget with a thumbnail. |
| 400 | + // When the thumbnail is tapped, open the video link in an external browser or |
| 401 | + // a supported external app. |
| 402 | + if (node.previewImageUrl != null) { |
| 403 | + return MessageEmbedVideoPreview( |
| 404 | + src: node.srcUrl, previewImage: node.previewImageUrl!); |
| 405 | + } |
| 406 | + |
| 407 | + final resolvedSrc = store.tryResolveUrl(node.srcUrl); |
| 408 | + return resolvedSrc != null |
| 409 | + ? MessageInlineVideoPreview(src: resolvedSrc) |
| 410 | + : Container(); |
| 411 | + } |
| 412 | +} |
| 413 | + |
| 414 | +class MessageEmbedVideoPreview extends StatelessWidget { |
| 415 | + const MessageEmbedVideoPreview({ |
| 416 | + super.key, |
| 417 | + required this.previewImage, |
| 418 | + required this.src, |
| 419 | + }); |
| 420 | + |
| 421 | + final String previewImage; |
| 422 | + final String src; |
| 423 | + |
| 424 | + @override |
| 425 | + Widget build(BuildContext context) { |
| 426 | + final store = PerAccountStoreWidget.of(context); |
| 427 | + final previewImageUrl = store.tryResolveUrl(previewImage); |
| 428 | + |
| 429 | + return GestureDetector( |
| 430 | + onTap: () { |
| 431 | + _launchUrl(context, src); |
| 432 | + }, |
| 433 | + child: UnconstrainedBox( |
| 434 | + alignment: Alignment.centerLeft, |
| 435 | + child: Padding( |
| 436 | + // TODO clean up this padding by imitating web less precisely; |
| 437 | + // in particular, avoid adding loose whitespace at end of message. |
| 438 | + padding: const EdgeInsets.only(right: 5, bottom: 5), |
| 439 | + child: Container( |
| 440 | + height: 100, |
| 441 | + width: 150, |
| 442 | + color: Colors.black, |
| 443 | + child: Stack( |
| 444 | + alignment: Alignment.center, |
| 445 | + children: [ |
| 446 | + if (previewImageUrl != null) ...[ |
| 447 | + RealmContentNetworkImage( |
| 448 | + previewImageUrl, |
| 449 | + filterQuality: FilterQuality.medium), |
| 450 | + Container(color: const Color.fromRGBO(0, 0, 0, 0.30)), |
| 451 | + ], |
| 452 | + const Icon( |
| 453 | + Icons.play_arrow_rounded, |
| 454 | + color: Colors.white, |
| 455 | + size: 25), |
| 456 | + ]))))); |
| 457 | + } |
| 458 | +} |
| 459 | + |
| 460 | +class MessageInlineVideoPreview extends StatefulWidget { |
| 461 | + const MessageInlineVideoPreview({super.key, required this.src}); |
| 462 | + |
| 463 | + final Uri src; |
| 464 | + |
| 465 | + @override |
| 466 | + State<MessageInlineVideoPreview> createState() => _MessageInlineVideoPreviewState(); |
| 467 | +} |
| 468 | + |
| 469 | +class _MessageInlineVideoPreviewState extends State<MessageInlineVideoPreview> { |
| 470 | + VideoPlayerController? _controller; |
| 471 | + bool _initialized = false; |
| 472 | + |
| 473 | + @override |
| 474 | + void initState() { |
| 475 | + SchedulerBinding.instance.addPostFrameCallback((_) { |
| 476 | + _asyncInitState(); |
| 477 | + }); |
| 478 | + super.initState(); |
| 479 | + } |
| 480 | + |
| 481 | + Future<void> _asyncInitState() async { |
| 482 | + try { |
| 483 | + final store = PerAccountStoreWidget.of(context); |
| 484 | + assert(debugLog('VideoPlayerController.networkUrl(${widget.src})')); |
| 485 | + _controller = VideoPlayerController.networkUrl(widget.src, httpHeaders: { |
| 486 | + if (widget.src.origin == store.account.realmUrl.origin) ...authHeader( |
| 487 | + email: store.account.email, |
| 488 | + apiKey: store.account.apiKey, |
| 489 | + ), |
| 490 | + ...userAgentHeader() |
| 491 | + }); |
| 492 | + |
| 493 | + await _controller!.initialize(); |
| 494 | + _controller!.addListener(_handleVideoControllerUpdates); |
| 495 | + } catch (error) { |
| 496 | + assert(debugLog("VideoPlayerController.initialize failed: $error")); |
| 497 | + } finally { |
| 498 | + if (mounted) { |
| 499 | + setState(() { _initialized = true; }); |
| 500 | + } |
| 501 | + } |
| 502 | + } |
| 503 | + |
| 504 | + @override |
| 505 | + void dispose() { |
| 506 | + _controller?.removeListener(_handleVideoControllerUpdates); |
| 507 | + _controller?.dispose(); |
| 508 | + super.dispose(); |
| 509 | + } |
| 510 | + |
| 511 | + void _handleVideoControllerUpdates() { |
| 512 | + assert(debugLog("Video buffered: ${_controller?.value.buffered}")); |
| 513 | + assert(debugLog("Video max duration: ${_controller?.value.duration}")); |
| 514 | + } |
| 515 | + |
| 516 | + @override |
| 517 | + Widget build(BuildContext context) { |
| 518 | + final message = InheritedMessage.of(context); |
| 519 | + |
| 520 | + return GestureDetector( |
| 521 | + onTap: !_initialized |
| 522 | + ? null |
| 523 | + : () { // TODO(log) |
| 524 | + if (_controller!.value.hasError) { |
| 525 | + // TODO use webview instead, to support auth headers |
| 526 | + _launchUrl(context, widget.src.toString()); |
| 527 | + } else { |
| 528 | + Navigator.of(context).push(getLightboxRoute( |
| 529 | + context: context, |
| 530 | + message: message, |
| 531 | + src: widget.src, |
| 532 | + videoController: _controller, |
| 533 | + )); |
| 534 | + } |
| 535 | + }, |
| 536 | + child: UnconstrainedBox( |
| 537 | + alignment: Alignment.centerLeft, |
| 538 | + child: Padding( |
| 539 | + // TODO clean up this padding by imitating web less precisely; |
| 540 | + // in particular, avoid adding loose whitespace at end of message. |
| 541 | + padding: const EdgeInsets.only(right: 5, bottom: 5), |
| 542 | + child: LightboxHero( |
| 543 | + message: message, |
| 544 | + src: widget.src, |
| 545 | + child: Container( |
| 546 | + height: 100, |
| 547 | + width: 150, |
| 548 | + color: Colors.black, |
| 549 | + child: Stack( |
| 550 | + alignment: Alignment.center, |
| 551 | + children: [ |
| 552 | + if (_initialized && !_controller!.value.hasError) ...[ |
| 553 | + AspectRatio( |
| 554 | + aspectRatio: _controller!.value.aspectRatio, |
| 555 | + child: VideoPlayer(_controller!)), |
| 556 | + Container(color: const Color.fromRGBO(0, 0, 0, 0.30)), |
| 557 | + ], |
| 558 | + if (_initialized) |
| 559 | + const Icon( |
| 560 | + Icons.play_arrow_rounded, |
| 561 | + color: Colors.white, |
| 562 | + size: 25) |
| 563 | + else |
| 564 | + const SizedBox( |
| 565 | + height: 14, |
| 566 | + width: 14, |
| 567 | + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)), |
| 568 | + ])))))); |
| 569 | + } |
| 570 | +} |
| 571 | + |
387 | 572 | class CodeBlock extends StatelessWidget {
|
388 | 573 | const CodeBlock({super.key, required this.node});
|
389 | 574 |
|
|
0 commit comments