diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 640465bec..f7c4a80b3 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -19,10 +19,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:photo_view/photo_view.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -import 'package:video_player/video_player.dart'; class FullscreenBody extends StatefulWidget { final CollectionLens collection; @@ -50,7 +50,7 @@ class FullscreenBodyState extends State with SingleTickerProvide Animation _bottomOverlayOffset; EdgeInsets _frozenViewInsets, _frozenViewPadding; FullscreenActionDelegate _actionDelegate; - final List> _videoControllers = []; + final List> _videoControllers = []; CollectionLens get collection => widget.collection; @@ -114,6 +114,7 @@ class FullscreenBodyState extends State with SingleTickerProvide _overlayAnimationController.dispose(); _overlayVisible.removeListener(_onOverlayVisibleChange); _videoControllers.forEach((kv) => kv.item2.dispose()); + _videoControllers.clear(); _verticalPager.removeListener(_onVerticalPageControllerChange); _unregisterWidget(widget); super.dispose(); @@ -330,7 +331,7 @@ class FullscreenBodyState extends State with SingleTickerProvide void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); - void _initVideoController() { + Future _initVideoController() async { if (_entry == null || !_entry.isVideo) return; final uri = _entry.uri; @@ -338,9 +339,8 @@ class FullscreenBodyState extends State with SingleTickerProvide if (controllerEntry != null) { _videoControllers.remove(controllerEntry); } else { - // unsupported by video_player 0.10.8+2 (backed by ExoPlayer): AVI - final controller = VideoPlayerController.uri(uri)..initialize(); - controllerEntry = Tuple2(uri, controller); + // do not set data source of IjkMediaController here + controllerEntry = Tuple2(uri, IjkMediaController()); } _videoControllers.insert(0, controllerEntry); while (_videoControllers.length > 3) { @@ -352,7 +352,7 @@ class FullscreenBodyState extends State with SingleTickerProvide class FullscreenVerticalPageView extends StatefulWidget { final CollectionLens collection; final ImageEntry entry; - final List> videoControllers; + final List> videoControllers; final PageController horizontalPager, verticalPager; final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; final VoidCallback onImageTap, onImagePageRequested; diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index 1ec790069..b59d2a07e 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -1,10 +1,10 @@ import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; +import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; import 'package:tuple/tuple.dart'; -import 'package:video_player/video_player.dart'; class MultiImagePage extends StatefulWidget { final CollectionLens collection; @@ -12,7 +12,7 @@ class MultiImagePage extends StatefulWidget { final ValueChanged onPageChanged; final ValueChanged onScaleChanged; final VoidCallback onTap; - final List> videoControllers; + final List> videoControllers; const MultiImagePage({ this.collection, @@ -65,7 +65,7 @@ class SingleImagePage extends StatefulWidget { final ImageEntry entry; final ValueChanged onScaleChanged; final VoidCallback onTap; - final List> videoControllers; + final List> videoControllers; const SingleImagePage({ this.entry, diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index c3be77fe6..3ab3d3ed6 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -4,18 +4,18 @@ import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; import 'package:aves/widgets/fullscreen/video_view.dart'; +import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:photo_view/photo_view.dart'; import 'package:tuple/tuple.dart'; -import 'package:video_player/video_player.dart'; class ImageView extends StatelessWidget { final ImageEntry entry; final Object heroTag; final ValueChanged onScaleChanged; final VoidCallback onTap; - final List> videoControllers; + final List> videoControllers; const ImageView({ this.entry, diff --git a/lib/widgets/fullscreen/overlay/video.dart b/lib/widgets/fullscreen/overlay/video.dart index 6939f0ade..047fa959c 100644 --- a/lib/widgets/fullscreen/overlay/video.dart +++ b/lib/widgets/fullscreen/overlay/video.dart @@ -1,18 +1,20 @@ +import 'dart:async'; + import 'package:aves/model/image_entry.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -import 'package:video_player/video_player.dart'; class VideoControlOverlay extends StatefulWidget { final ImageEntry entry; final Animation scale; - final VideoPlayerController controller; + final IjkMediaController controller; final EdgeInsets viewInsets, viewPadding; const VideoControlOverlay({ @@ -32,16 +34,28 @@ class VideoControlOverlayState extends State with SingleTic final GlobalKey _progressBarKey = GlobalKey(); bool _playingOnDragStart = false; AnimationController _playPauseAnimation; + final List _subscriptions = []; + double _seekTargetPercent; + + // video info is not refreshed by default, so we use a timer to do so + Timer _progressTimer; ImageEntry get entry => widget.entry; Animation get scale => widget.scale; - VideoPlayerController get controller => widget.controller; + IjkMediaController get controller => widget.controller; - VideoPlayerValue get value => widget.controller.value; + // `videoInfo` is never null (even if `toString` prints `null`) + // check presence with `hasData` instead + VideoInfo get videoInfo => controller.videoInfo; - double get progress => value.position != null && value.duration != null ? value.position.inMilliseconds / value.duration.inMilliseconds : 0; + // we check whether video info is ready instead of checking for `noDatasource` status, + // as the controller could also be uninitialized with the `pause` status + // (e.g. when switching between video entries without playing them the first time) + bool get isInitialized => videoInfo.hasData; + + bool get isPlaying => controller.ijkStatus == IjkStatus.playing; @override void initState() { @@ -51,7 +65,6 @@ class VideoControlOverlayState extends State with SingleTic vsync: this, ); _registerWidget(widget); - _onValueChange(); } @override @@ -69,11 +82,17 @@ class VideoControlOverlayState extends State with SingleTic } void _registerWidget(VideoControlOverlay widget) { - widget.controller.addListener(_onValueChange); + _subscriptions.add(widget.controller.ijkStatusStream.listen(_onStatusChange)); + _subscriptions.add(widget.controller.textureIdStream.listen(_onTextureIdChange)); + _onStatusChange(widget.controller.ijkStatus); + _onTextureIdChange(widget.controller.textureId); } void _unregisterWidget(VideoControlOverlay widget) { - widget.controller.removeListener(_onValueChange); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + _stopTimer(); } @override @@ -93,37 +112,43 @@ class VideoControlOverlayState extends State with SingleTic padding: safePadding, child: SizedBox( width: mqWidth - safePadding.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: value.hasError - ? [ - OverlayButton( - scale: scale, - child: IconButton( - icon: Icon(OMIcons.openInNew), - onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype), - tooltip: 'Open', - ), - ), - ] - : [ - Expanded( - child: _buildProgressBar(), - ), - const SizedBox(width: 8), - OverlayButton( - scale: scale, - child: IconButton( - icon: AnimatedIcon( - icon: AnimatedIcons.play_pause, - progress: _playPauseAnimation, - ), - onPressed: _playPause, - tooltip: value.isPlaying ? 'Pause' : 'Play', - ), - ), - ], - ), + child: StreamBuilder( + stream: controller.ijkStatusStream, + builder: (context, snapshot) { + // do not use stream snapshot because it is obsolete when switching between videos + final status = controller.ijkStatus; + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: status == IjkStatus.error + ? [ + OverlayButton( + scale: scale, + child: IconButton( + icon: Icon(OMIcons.openInNew), + onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype), + tooltip: 'Open', + ), + ), + ] + : [ + Expanded( + child: _buildProgressBar(), + ), + const SizedBox(width: 8), + OverlayButton( + scale: scale, + child: IconButton( + icon: AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: _playPauseAnimation, + ), + onPressed: _playPause, + tooltip: isPlaying ? 'Pause' : 'Play', + ), + ), + ], + ); + }), ), ); }, @@ -138,14 +163,14 @@ class VideoControlOverlayState extends State with SingleTic borderRadius: progressBarBorderRadius, child: GestureDetector( onTapDown: (TapDownDetails details) { - _seek(details.globalPosition); + _seekFromTap(details.globalPosition); }, onHorizontalDragStart: (DragStartDetails details) { - _playingOnDragStart = controller.value.isPlaying; + _playingOnDragStart = isPlaying; if (_playingOnDragStart) controller.pause(); }, onHorizontalDragUpdate: (DragUpdateDetails details) { - _seek(details.globalPosition); + _seekFromTap(details.globalPosition); }, onHorizontalDragEnd: (DragEndDetails details) { if (_playingOnDragStart) controller.play(); @@ -164,12 +189,25 @@ class VideoControlOverlayState extends State with SingleTic children: [ Row( children: [ - Text(formatDuration(value.position ?? Duration.zero)), + StreamBuilder( + stream: controller.videoInfoStream, + builder: (context, snapshot) { + // do not use stream snapshot because it is obsolete when switching between videos + final position = videoInfo.currentPosition?.floor() ?? 0; + return Text(formatDuration(Duration(seconds: position))); + }), const Spacer(), - Text(formatDuration(value.duration ?? Duration.zero)), + Text(entry.durationText), ], ), - LinearProgressIndicator(value: progress), + StreamBuilder( + stream: controller.videoInfoStream, + builder: (context, snapshot) { + // do not use stream snapshot because it is obsolete when switching between videos + var progress = videoInfo.progress; + if (!progress.isFinite) progress = 0.0; + return LinearProgressIndicator(value: progress); + }), ], ), ), @@ -178,23 +216,44 @@ class VideoControlOverlayState extends State with SingleTic ); } - void _onValueChange() { - setState(() {}); - updatePlayPauseIcon(); + void _startTimer() { + if (controller.textureId == null) return; + _progressTimer?.cancel(); + _progressTimer = Timer.periodic(const Duration(milliseconds: 350), (timer) { + controller.refreshVideoInfo(); + }); + } + + void _stopTimer() { + _progressTimer?.cancel(); + } + + void _onTextureIdChange(int textureId) { + if (textureId != null) { + _startTimer(); + } else { + _stopTimer(); + } + } + + void _onStatusChange(IjkStatus status) { + if (status == IjkStatus.playing && _seekTargetPercent != null) { + _seekFromTarget(); + } + _updatePlayPauseIcon(); } Future _playPause() async { - if (value.isPlaying) { + if (isPlaying) { await controller.pause(); - } else { - if (!value.initialized) await controller.initialize(); + } else if (isInitialized) { await controller.play(); + } else { + await controller.setDataSource(DataSource.photoManagerUrl(entry.uri), autoPlay: true); } - setState(() {}); } - void updatePlayPauseIcon() { - final isPlaying = value.isPlaying; + void _updatePlayPauseIcon() { final status = _playPauseAnimation.status; if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) { _playPauseAnimation.forward(); @@ -203,10 +262,29 @@ class VideoControlOverlayState extends State with SingleTic } } - void _seek(Offset globalPosition) { + void _seekFromTap(Offset globalPosition) async { final keyContext = _progressBarKey.currentContext; final RenderBox box = keyContext.findRenderObject(); final localPosition = box.globalToLocal(globalPosition); - controller.seekTo(value.duration * (localPosition.dx / box.size.width)); + _seekTargetPercent = (localPosition.dx / box.size.width); + + if (isInitialized) { + await _seekFromTarget(); + } else { + // autoplay when seeking on uninitialized player, otherwise the texture is not updated + // as a workaround, pausing after a brief duration is possible, but fiddly + await controller.setDataSource(DataSource.photoManagerUrl(entry.uri), autoPlay: true); + } + } + + Future _seekFromTarget() async { + // `seekToProgress` is not safe as it can be called when the `duration` is not set yet + // so we make sure the video info is up to date first + if (videoInfo.duration == null) { + await controller.refreshVideoInfo(); + } else { + await controller.seekToProgress(_seekTargetPercent); + _seekTargetPercent = null; + } } } diff --git a/lib/widgets/fullscreen/video_view.dart b/lib/widgets/fullscreen/video_view.dart index 309cd3745..64d1c1c59 100644 --- a/lib/widgets/fullscreen/video_view.dart +++ b/lib/widgets/fullscreen/video_view.dart @@ -1,13 +1,14 @@ +import 'dart:async'; import 'dart:ui'; import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:flutter/material.dart'; -import 'package:video_player/video_player.dart'; +import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; class AvesVideo extends StatefulWidget { final ImageEntry entry; - final VideoPlayerController controller; + final IjkMediaController controller; const AvesVideo({ Key key, @@ -20,15 +21,16 @@ class AvesVideo extends StatefulWidget { } class AvesVideoState extends State { + final List _subscriptions = []; + ImageEntry get entry => widget.entry; - VideoPlayerValue get value => widget.controller.value; + IjkMediaController get controller => widget.controller; @override void initState() { super.initState(); _registerWidget(widget); - _onValueChange(); } @override @@ -45,38 +47,78 @@ class AvesVideoState extends State { } void _registerWidget(AvesVideo widget) { - widget.controller.addListener(_onValueChange); + _subscriptions.add(widget.controller.playFinishStream.listen(_onPlayFinish)); } void _unregisterWidget(AvesVideo widget) { - widget.controller.removeListener(_onValueChange); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); } + bool isPlayable(IjkStatus status) => [IjkStatus.prepared, IjkStatus.playing, IjkStatus.pause, IjkStatus.complete].contains(status); + @override Widget build(BuildContext context) { - if (value == null) return const SizedBox(); - if (value.hasError) { - return Image( - image: UriImage(uri: entry.uri, mimeType: entry.mimeType), - width: entry.width.toDouble(), - height: entry.height.toDouble(), - ); - } - return Center( - child: AspectRatio( - aspectRatio: entry.displayAspectRatio, - child: VideoPlayer(widget.controller), - ), - ); + if (controller == null) return const SizedBox(); + return StreamBuilder( + stream: widget.controller.ijkStatusStream, + builder: (context, snapshot) { + final status = snapshot.data; + return isPlayable(status) + ? IjkPlayer( + mediaController: controller, + controllerWidgetBuilder: (controller) => const SizedBox.shrink(), + statusWidgetBuilder: (context, controller, status) => const SizedBox.shrink(), + textureBuilder: (context, controller, info) { + var id = controller.textureId; + if (id == null) { + return AspectRatio( + aspectRatio: entry.displayAspectRatio, + child: Container( + color: Colors.green, + ), + ); + } + + Widget child = Container( + color: Colors.blue, + child: Texture( + textureId: id, + ), + ); + + if (!controller.autoRotate) { + return child; + } + + final degree = entry.catalogMetadata?.videoRotation ?? 0; + if (degree != 0) { + child = RotatedBox( + quarterTurns: degree ~/ 90, + child: child, + ); + } + + child = AspectRatio( + aspectRatio: entry.displayAspectRatio, + child: child, + ); + + return Container( + child: child, + alignment: Alignment.center, + color: Colors.transparent, + ); + }, + ) + : Image( + image: UriImage(uri: entry.uri, mimeType: entry.mimeType), + width: entry.width.toDouble(), + height: entry.height.toDouble(), + ); + }); } - void _onValueChange() { - if (!value.isPlaying && value.position == value.duration) _goToStart(); - setState(() {}); - } - - Future _goToStart() async { - await widget.controller.seekTo(Duration.zero); - await widget.controller.pause(); - } + void _onPlayFinish(IjkMediaController controller) => controller.seekTo(0); } diff --git a/pubspec.lock b/pubspec.lock index c022e688b..5284b4654 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -35,7 +35,7 @@ packages: name: barcode url: "https://pub.dartlang.org" source: hosted - version: "1.5.0" + version: "1.6.0" boolean_selector: dependency: transitive description: @@ -88,11 +88,9 @@ packages: draggable_scrollbar: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "3b823ae0a9def4edec62771f18e6348312bfce15" - url: "git://github.com/deckerst/flutter-draggable-scrollbar.git" - source: git + path: "../flutter-draggable-scrollbar" + relative: true + source: path version: "0.0.4" event_bus: dependency: "direct main" @@ -104,26 +102,29 @@ packages: expansion_tile_card: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: edb6b11bb448fc2f30e566a20605b37093503176 - url: "git://github.com/deckerst/expansion_tile_card.git" - source: git + path: "../expansion_tile_card" + relative: true + source: path version: "1.0.3" flushbar: dependency: "direct main" description: - path: "." - ref: "13c55a8" - resolved-ref: "13c55a888c1693f1c8269ea30d55c614a1bfee16" - url: "https://github.com/AndreHaueisen/flushbar.git" - source: git - version: "1.9.1" + name: flushbar + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_ijkplayer: + dependency: "direct main" + description: + path: "../flutter_ijkplayer" + relative: true + source: path + version: "0.3.6" flutter_native_timezone: dependency: "direct main" description: @@ -144,7 +145,7 @@ packages: name: flutter_svg url: "https://pub.dartlang.org" source: hosted - version: "0.17.3+1" + version: "0.17.4" flutter_test: dependency: "direct dev" description: flutter @@ -168,7 +169,7 @@ packages: name: google_maps_flutter url: "https://pub.dartlang.org" source: hosted - version: "0.5.25+2" + version: "0.5.26" image: dependency: transitive description: @@ -189,7 +190,7 @@ packages: name: io url: "https://pub.dartlang.org" source: hosted - version: "0.3.3" + version: "0.3.4" logger: dependency: "direct main" description: @@ -266,7 +267,7 @@ packages: name: pdf url: "https://pub.dartlang.org" source: hosted - version: "1.6.0" + version: "1.6.1" pedantic: dependency: "direct main" description: @@ -280,14 +281,14 @@ packages: name: percent_indicator url: "https://pub.dartlang.org" source: hosted - version: "2.1.1+1" + version: "2.1.3" permission_handler: dependency: "direct main" description: name: permission_handler url: "https://pub.dartlang.org" source: hosted - version: "5.0.0+hotfix.2" + version: "5.0.0+hotfix.3" permission_handler_platform_interface: dependency: transitive description: @@ -322,14 +323,14 @@ packages: name: printing url: "https://pub.dartlang.org" source: hosted - version: "3.2.1" + version: "3.3.1" provider: dependency: "direct main" description: name: provider url: "https://pub.dartlang.org" source: hosted - version: "4.0.4" + version: "4.0.5" qr: dependency: transitive description: @@ -489,27 +490,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.8" - video_player: - dependency: "direct main" - description: - path: "../plugins/packages/video_player/video_player" - relative: true - source: path - version: "0.10.8+2" - video_player_platform_interface: - dependency: transitive - description: - name: video_player_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.5" - video_player_web: - dependency: transitive - description: - name: video_player_web - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.2+1" xml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bb3440d4d..97dad91e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,25 @@ description: A new Flutter application. # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html version: 1.0.0+1 +# video_player (as of v0.10.8+2, backed by ExoPlayer): +# - no URI handling by default but trivial by fork +# - support: AVI/XVID/MP3 nothing, MP2T nothing +# - cannot support more formats +# - playable only when both the video and audio streams are supported + +# fijkplayer (as of v0.7.1, backed by IJKPlayer & ffmpeg): +# - URI handling +# - support: AVI/XVID/MP3 audio only, MP2T video only +# - possible support for more formats by customizing ffmpeg build, +# - playable when only the video or audio stream is supported +# - crash when calling `seekTo` for some files (e.g. TED talk videos) + +# flutter_ijkplayer (as of v0.3.5+1, backed by IJKPlayer & ffmpeg): +# - URI handling (from v0.3.6, `DataSource.photoManagerUrl`) +# - support: AVI/XVID/MP3 video/audio, MP2T video only +# - possible support for more formats by TODO TLAD customizing ffmpeg build? +# - playable when only the video or audio stream is supported + dependencies: flutter: sdk: flutter @@ -20,19 +39,19 @@ dependencies: charts_flutter: collection: draggable_scrollbar: - # path: ../flutter-draggable-scrollbar - git: - url: git://github.com/deckerst/flutter-draggable-scrollbar.git + path: ../flutter-draggable-scrollbar +# git: +# url: git://github.com/deckerst/flutter-draggable-scrollbar.git event_bus: expansion_tile_card: - # path: ../expansion_tile_card - git: - url: git://github.com/deckerst/expansion_tile_card.git + path: ../expansion_tile_card +# git: +# url: git://github.com/deckerst/expansion_tile_card.git + flutter_ijkplayer: + path: ../flutter_ijkplayer +# git: +# url: git://github.com/deckerst/flutter_ijkplayer.git flushbar: - # flushbar-1.9.1 cannot be built with Flutter 1.15.17 - git: - url: https://github.com/AndreHaueisen/flushbar.git - ref: 13c55a8 flutter_native_timezone: flutter_svg: geocoder: @@ -55,8 +74,6 @@ dependencies: transparent_image: tuple: uuid: - video_player: - path: ../plugins/packages/video_player/video_player dev_dependencies: flutter_test: