diff --git a/lib/widgets/story_video.dart b/lib/widgets/story_video.dart index 8924855b..8b77ff2a 100644 --- a/lib/widgets/story_video.dart +++ b/lib/widgets/story_video.dart @@ -25,8 +25,8 @@ class VideoLoader { onComplete(); } - final fileStream = DefaultCacheManager() - .getFileStream(this.url, headers: this.requestHeaders as Map?); + final fileStream = DefaultCacheManager().getFileStream(this.url, + headers: this.requestHeaders as Map?); fileStream.listen((fileResponse) { if (fileResponse is FileInfo) { @@ -43,19 +43,24 @@ class VideoLoader { class StoryVideo extends StatefulWidget { final StoryController? storyController; final VideoLoader videoLoader; + final VideoPlayerController? playerController; - StoryVideo(this.videoLoader, {this.storyController, Key? key}) - : super(key: key ?? UniqueKey()); + StoryVideo( + this.videoLoader, { + this.storyController, + this.playerController, + Key? key, + }) : super(key: key ?? UniqueKey()); static StoryVideo url(String url, {StoryController? controller, Map? requestHeaders, + VideoPlayerController? playerController, Key? key}) { - return StoryVideo( - VideoLoader(url, requestHeaders: requestHeaders), - storyController: controller, - key: key, - ); + return StoryVideo(VideoLoader(url, requestHeaders: requestHeaders), + storyController: controller, + key: key, + playerController: playerController); } @override @@ -69,8 +74,6 @@ class StoryVideoState extends State { StreamSubscription? _streamSubscription; - VideoPlayerController? playerController; - @override void initState() { super.initState(); @@ -79,21 +82,30 @@ class StoryVideoState extends State { widget.videoLoader.loadVideo(() { if (widget.videoLoader.state == LoadState.success) { + /* + Moved to `StoryView` + this.playerController = VideoPlayerController.file(widget.videoLoader.videoFile!); - playerController!.initialize().then((v) { + widget.playerController!.initialize().then((v) { setState(() {}); widget.storyController!.play(); }); + */ + + if (widget.playerController!.value.isInitialized) { + widget.storyController!.play(); + setState(() {}); + } else {} if (widget.storyController != null) { _streamSubscription = widget.storyController!.playbackNotifier.listen((playbackState) { if (playbackState == PlaybackState.pause) { - playerController!.pause(); + widget.playerController!.pause(); } else { - playerController!.play(); + widget.playerController!.play(); } }); } @@ -105,11 +117,11 @@ class StoryVideoState extends State { Widget getContentView() { if (widget.videoLoader.state == LoadState.success && - playerController!.value.isInitialized) { + widget.playerController!.value.isInitialized) { return Center( child: AspectRatio( - aspectRatio: playerController!.value.aspectRatio, - child: VideoPlayer(playerController!), + aspectRatio: widget.playerController!.value.aspectRatio, + child: VideoPlayer(widget.playerController!), ), ); } @@ -146,7 +158,7 @@ class StoryVideoState extends State { @override void dispose() { - playerController?.dispose(); + // playerController?.dispose(); moved to `StoryView` _streamSubscription?.cancel(); super.dispose(); } diff --git a/lib/widgets/story_view.dart b/lib/widgets/story_view.dart index c83f0e12..1c086976 100644 --- a/lib/widgets/story_view.dart +++ b/lib/widgets/story_view.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import 'package:collection/collection.dart' show IterableExtension; import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; import '../controller/story_controller.dart'; import '../utils.dart'; @@ -17,6 +18,8 @@ enum ProgressPosition { top, bottom } /// should use [small] enum IndicatorHeight { small, large } +typedef ContentView = Widget Function(VideoPlayerController? playerController); + /// This is a representation of a story item (or page). class StoryItem { /// Specifies how long the page should be displayed. It should be a reasonable @@ -33,13 +36,22 @@ class StoryItem { /// story item. bool shown; + /// url is used to re-initialize video player after its disposed. + /// Situation: When user switch back again to view previous video. + String? url; + + /// VideoPlayerController to play videos. + /// It is initialized and disposed in `StoryView` + VideoPlayerController? playerController; + /// The page content - final Widget view; - StoryItem( - this.view, { - required this.duration, - this.shown = false, - }) : assert(duration != null, "[duration] should not be null"); + final ContentView view; + StoryItem(this.view, + {required this.duration, + this.shown = false, + this.url, + this.playerController}) + : assert(duration != null, "[duration] should not be null"); /// Short hand to create text-only page. /// @@ -70,7 +82,7 @@ class StoryItem { ] /** white text */); return StoryItem( - Container( + (_) => Container( key: key, decoration: BoxDecoration( color: backgroundColor, @@ -116,7 +128,7 @@ class StoryItem { Duration? duration, }) { return StoryItem( - Container( + (_) => Container( key: key, color: Colors.black, child: Stack( @@ -176,7 +188,7 @@ class StoryItem { Duration? duration, }) { return StoryItem( - ClipRRect( + (_) => ClipRRect( key: key, child: Container( color: Colors.grey[100], @@ -227,40 +239,47 @@ class StoryItem { bool shown = false, Map? requestHeaders, }) { + final VideoPlayerController _videoPlayerController = + VideoPlayerController.network(url); + return StoryItem( - Container( - key: key, - color: Colors.black, - child: Stack( - children: [ - StoryVideo.url( - url, - controller: controller, - requestHeaders: requestHeaders, + (playerController) => Container( + key: key, + color: Colors.black, + child: Stack( + children: [ + StoryVideo.url(url, + controller: controller, + requestHeaders: requestHeaders, + playerController: playerController), + SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + margin: EdgeInsets.only(bottom: 24), + padding: + EdgeInsets.symmetric(horizontal: 24, vertical: 8), + color: caption != null + ? Colors.black54 + : Colors.transparent, + child: caption != null + ? Text( + caption, + style: TextStyle( + fontSize: 15, color: Colors.white), + textAlign: TextAlign.center, + ) + : SizedBox(), + ), + ), + ) + ], ), - SafeArea( - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - width: double.infinity, - margin: EdgeInsets.only(bottom: 24), - padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8), - color: - caption != null ? Colors.black54 : Colors.transparent, - child: caption != null - ? Text( - caption, - style: TextStyle(fontSize: 15, color: Colors.white), - textAlign: TextAlign.center, - ) - : SizedBox(), - ), - ), - ) - ], - ), - ), + ), shown: shown, + url: url, + playerController: _videoPlayerController, duration: duration ?? Duration(seconds: 10)); } @@ -277,49 +296,50 @@ class StoryItem { }) { assert(imageFit != null, "[imageFit] should not be null"); return StoryItem( - Container( - key: key, - color: Colors.black, - child: Stack( - children: [ - Center( - child: Image( - image: image, - height: double.infinity, - width: double.infinity, - fit: imageFit, - ), - ), - SafeArea( - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - width: double.infinity, - margin: EdgeInsets.only( - bottom: 24, - ), - padding: EdgeInsets.symmetric( - horizontal: 24, - vertical: 8, + (_) => Container( + key: key, + color: Colors.black, + child: Stack( + children: [ + Center( + child: Image( + image: image, + height: double.infinity, + width: double.infinity, + fit: imageFit, ), - color: - caption != null ? Colors.black54 : Colors.transparent, - child: caption != null - ? Text( - caption, - style: TextStyle( - fontSize: 15, - color: Colors.white, - ), - textAlign: TextAlign.center, - ) - : SizedBox(), ), - ), - ) - ], - ), - ), + SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + margin: EdgeInsets.only( + bottom: 24, + ), + padding: EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + color: caption != null + ? Colors.black54 + : Colors.transparent, + child: caption != null + ? Text( + caption, + style: TextStyle( + fontSize: 15, + color: Colors.white, + ), + textAlign: TextAlign.center, + ) + : SizedBox(), + ), + ), + ) + ], + ), + ), shown: shown, duration: duration ?? Duration(seconds: 3)); } @@ -337,7 +357,7 @@ class StoryItem { Duration? duration, }) { return StoryItem( - Container( + (_) => Container( key: key, decoration: BoxDecoration( color: Colors.grey[100], @@ -439,15 +459,15 @@ class StoryViewState extends State with TickerProviderStateMixin { VerticalDragInfo? verticalDragInfo; + bool _lock = true; + StoryItem? get _currentStory { return widget.storyItems.firstWhereOrNull((it) => !it!.shown); } - Widget get _currentView { - var item = widget.storyItems.firstWhereOrNull((it) => !it!.shown); - item ??= widget.storyItems.last; - return item?.view ?? Container(); - } + ContentView get _currentView => widget.storyItems + .firstWhere((it) => !it!.shown, orElse: () => widget.storyItems.last)! + .view; @override void initState() { @@ -461,6 +481,16 @@ class StoryViewState extends State with TickerProviderStateMixin { it2!.shown = false; }); } else { + final int _index = widget.storyItems.indexOf(firstPage); + + /// Initialize the first controller. + _initializeController(_index).whenComplete(() { + _playController(_index); + }); + + /// Start initializing the next story. + _initializeController(_index + 1).whenComplete(() => _lock = false); + final lastShownPos = widget.storyItems.indexOf(firstPage); widget.storyItems.sublist(lastShownPos).forEach((it) { it!.shown = false; @@ -501,6 +531,7 @@ class StoryViewState extends State with TickerProviderStateMixin { _animationController?.dispose(); _playbackSubscription?.cancel(); + _disposeAllVideoControllers(); super.dispose(); } @@ -512,6 +543,62 @@ class StoryViewState extends State with TickerProviderStateMixin { } } + void _disposeAllVideoControllers() { + for (final StoryItem? _story in widget.storyItems) { + try { + final _controller = _story!.playerController; + _controller!.dispose(); + } catch (e) { + // already disposed + return; + } + } + } + + void _disposeController(int index) { + try { + final _controller = widget.storyItems[index]!.playerController; + _controller!.dispose(); + } catch (e) { + return; + } + } + + void _playController(int index) { + try { + widget.storyItems[index]!.playerController!.play(); + } catch (e) { + // not a video. + } + } + + Future _initializeController(int index) async { + try { + if (widget.storyItems[index]!.playerController!.value.isInitialized) { + final String url = widget.storyItems[index]!.url!; + + final VideoPlayerController _controller = + VideoPlayerController.network(url); + + await _controller.initialize(); + + final _oldStoryItem = widget.storyItems[index]!; + widget.storyItems.removeAt(index); + + _oldStoryItem.playerController = _controller; + widget.storyItems.insert(index, _oldStoryItem); + } else { + await widget.storyItems[index]!.playerController!.initialize(); + } + } catch (e) { + // RangeError and not-a-video error + if (_lock) { + _lock = false; + } + setState(() {}); + } + } + void _play() { _animationController?.dispose(); // get the next playing page @@ -526,11 +613,18 @@ class StoryViewState extends State with TickerProviderStateMixin { _animationController = AnimationController(duration: storyItem.duration, vsync: this); + // start + if (storyItem.playerController != null && + storyItem.playerController!.value.isInitialized) { + storyItem.playerController!.play(); + } + _animationController!.addStatusListener((status) { if (status == AnimationStatus.completed) { - storyItem.shown = true; + // not assigned here anymore. + // storyItem.shown = true; if (widget.storyItems.last != storyItem) { - _beginPlay(); + _goForward(); } else { // done playing _onComplete(); @@ -549,43 +643,79 @@ class StoryViewState extends State with TickerProviderStateMixin { _play(); } - void _onComplete() { - if (widget.onComplete != null) { - widget.controller.pause(); - widget.onComplete!(); - } - - if (widget.repeat) { - widget.storyItems.forEach((it) { - it!.shown = false; - }); - - _beginPlay(); + Future _stopController(int index) async { + try { + if (widget.storyItems[index]?.playerController != null) { + final VideoPlayerController _controller = + widget.storyItems[index]!.playerController!; + _controller.pause(); + await _controller.seekTo(const Duration(seconds: 0)); + } + } catch (e) { + // Range Error and not-a-video-error + return; } } void _goBack() { + if (_lock) { + return; + } + + _lock = true; + _animationController!.stop(); - if (this._currentStory == null) { + if (_currentStory == null) { widget.storyItems.last!.shown = false; } - if (this._currentStory == widget.storyItems.first) { + if (_currentStory == widget.storyItems.first) { + _lock = false; + _stopController(0); _beginPlay(); } else { - this._currentStory!.shown = false; - int lastPos = widget.storyItems.indexOf(this._currentStory); + final int index = widget.storyItems.indexOf(_currentStory); + + //Stop the current controller + _stopController(index); + + ///Dispose [index + 1] controller + _disposeController(index + 1); + + ///Initialize [index-2] player + _initializeController(index - 2).whenComplete(() => _lock = false); + + _currentStory!.shown = false; + int lastPos = widget.storyItems.indexOf(_currentStory); final previous = widget.storyItems[lastPos - 1]!; previous.shown = false; _beginPlay(); } + setState(() {}); } void _goForward() { + if (_lock) { + return; + } + + _lock = true; + if (this._currentStory != widget.storyItems.last) { + final int index = widget.storyItems.indexOf(this._currentStory); + + ///Stop [index] player + _stopController(index); + + ///Dispose [index-1] player + _disposeController(index - 1); + + ///Initialize [index+2] player + _initializeController(index + 2).whenComplete(() => _lock = false); + _animationController!.stop(); // get last showing @@ -602,6 +732,22 @@ class StoryViewState extends State with TickerProviderStateMixin { _animationController! .animateTo(1.0, duration: Duration(milliseconds: 10)); } + setState(() {}); + } + + void _onComplete() { + if (widget.onComplete != null) { + widget.controller.pause(); + widget.onComplete!(); + } + + if (widget.repeat) { + widget.storyItems.forEach((it) { + it!.shown = false; + }); + + _beginPlay(); + } } void _clearDebouncer() { @@ -625,7 +771,14 @@ class StoryViewState extends State with TickerProviderStateMixin { color: Colors.white, child: Stack( children: [ - _currentView, + _currentView(widget.storyItems + .firstWhere( + (it) => + !it!.shown && + it.playerController != + null, // both not shown and is video + orElse: () => widget.storyItems.last)! + .playerController), Align( alignment: widget.progressPosition == ProgressPosition.top ? Alignment.topCenter diff --git a/pubspec.lock b/pubspec.lock index b098917d..dc9811c3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -14,7 +14,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.6.1" + version: "2.5.0" boolean_selector: dependency: transitive description: @@ -237,7 +237,7 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.8.1" + version: "1.8.0" sqflite: dependency: transitive description: @@ -293,7 +293,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.3.0" + version: "0.2.19" typed_data: dependency: transitive description: