video_widget_new.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import "dart:async";
  2. import "dart:io";
  3. import "package:flutter/cupertino.dart";
  4. import "package:flutter/material.dart";
  5. import "package:logging/logging.dart";
  6. import "package:media_kit/media_kit.dart";
  7. import "package:media_kit_video/media_kit_video.dart";
  8. import "package:photos/core/constants.dart";
  9. import "package:photos/core/event_bus.dart";
  10. import "package:photos/events/pause_video_event.dart";
  11. import "package:photos/generated/l10n.dart";
  12. import "package:photos/models/file/extensions/file_props.dart";
  13. import "package:photos/models/file/file.dart";
  14. import "package:photos/services/files_service.dart";
  15. import "package:photos/theme/colors.dart";
  16. import "package:photos/theme/ente_theme.dart";
  17. import "package:photos/ui/actions/file/file_actions.dart";
  18. import "package:photos/ui/viewer/file/thumbnail_widget.dart";
  19. import "package:photos/utils/dialog_util.dart";
  20. import "package:photos/utils/file_util.dart";
  21. import "package:photos/utils/toast_util.dart";
  22. class VideoWidgetNew extends StatefulWidget {
  23. final EnteFile file;
  24. final String? tagPrefix;
  25. final Function(bool)? playbackCallback;
  26. const VideoWidgetNew(
  27. this.file, {
  28. this.tagPrefix,
  29. this.playbackCallback,
  30. super.key,
  31. });
  32. @override
  33. State<VideoWidgetNew> createState() => _VideoWidgetNewState();
  34. }
  35. class _VideoWidgetNewState extends State<VideoWidgetNew>
  36. with WidgetsBindingObserver {
  37. final Logger _logger = Logger("VideoWidgetNew");
  38. static const verticalMargin = 72.0;
  39. late final player = Player();
  40. VideoController? controller;
  41. final _progressNotifier = ValueNotifier<double?>(null);
  42. late StreamSubscription<bool> playingStreamSubscription;
  43. bool _isAppInFG = true;
  44. late StreamSubscription<PauseVideoEvent> pauseVideoSubscription;
  45. @override
  46. void initState() {
  47. _logger.info(
  48. 'initState for ${widget.file.generatedID} with tag ${widget.file.tag} and name ${widget.file.displayName}',
  49. );
  50. super.initState();
  51. WidgetsBinding.instance.addObserver(this);
  52. if (widget.file.isRemoteFile) {
  53. _loadNetworkVideo();
  54. _setFileSizeIfNull();
  55. } else if (widget.file.isSharedMediaToAppSandbox) {
  56. final localFile = File(getSharedMediaFilePath(widget.file));
  57. if (localFile.existsSync()) {
  58. _setVideoController(localFile.path);
  59. } else if (widget.file.uploadedFileID != null) {
  60. _loadNetworkVideo();
  61. }
  62. } else {
  63. widget.file.getAsset.then((asset) async {
  64. if (asset == null || !(await asset.exists)) {
  65. if (widget.file.uploadedFileID != null) {
  66. _loadNetworkVideo();
  67. }
  68. } else {
  69. // ignore: unawaited_futures
  70. asset.getMediaUrl().then((url) {
  71. _setVideoController(
  72. url ??
  73. 'https://user-images.githubusercontent.com/28951144/229373695-22f88f13-d18f-4288-9bf1-c3e078d83722.mp4',
  74. );
  75. });
  76. }
  77. });
  78. }
  79. playingStreamSubscription = player.stream.playing.listen((event) {
  80. if (widget.playbackCallback != null && mounted) {
  81. widget.playbackCallback!(event);
  82. }
  83. });
  84. pauseVideoSubscription = Bus.instance.on<PauseVideoEvent>().listen((event) {
  85. player.pause();
  86. });
  87. }
  88. @override
  89. void didChangeAppLifecycleState(AppLifecycleState state) {
  90. if (state == AppLifecycleState.resumed) {
  91. _isAppInFG = true;
  92. } else {
  93. _isAppInFG = false;
  94. }
  95. }
  96. @override
  97. void dispose() {
  98. pauseVideoSubscription.cancel();
  99. removeCallBack(widget.file);
  100. _progressNotifier.dispose();
  101. WidgetsBinding.instance.removeObserver(this);
  102. playingStreamSubscription.cancel();
  103. player.dispose();
  104. super.dispose();
  105. }
  106. @override
  107. Widget build(BuildContext context) {
  108. final colorScheme = getEnteColorScheme(context);
  109. return Hero(
  110. tag: widget.tagPrefix! + widget.file.tag,
  111. child: MaterialVideoControlsTheme(
  112. normal: MaterialVideoControlsThemeData(
  113. backdropColor: null,
  114. automaticallyImplySkipNextButton: false,
  115. automaticallyImplySkipPreviousButton: false,
  116. seekOnDoubleTap: false,
  117. displaySeekBar: true,
  118. seekBarMargin: const EdgeInsets.only(bottom: verticalMargin),
  119. bottomButtonBarMargin: const EdgeInsets.only(bottom: 112),
  120. controlsHoverDuration: const Duration(seconds: 3),
  121. seekBarHeight: 2,
  122. seekBarThumbSize: 16,
  123. seekBarBufferColor: Colors.transparent,
  124. seekBarThumbColor: backgroundElevatedLight,
  125. seekBarColor: fillMutedDark,
  126. seekBarPositionColor: colorScheme.primary300,
  127. seekBarContainerHeight: 56,
  128. seekBarAlignment: Alignment.center,
  129. ///topButtonBarMargin is needed for keeping the buffering loading
  130. ///indicator to be center aligned
  131. topButtonBarMargin: const EdgeInsets.only(top: verticalMargin),
  132. bottomButtonBar: [
  133. const Spacer(),
  134. PausePlayAndDuration(controller?.player),
  135. const Spacer(),
  136. ],
  137. primaryButtonBar: [],
  138. ),
  139. fullscreen: const MaterialVideoControlsThemeData(),
  140. child: GestureDetector(
  141. onVerticalDragUpdate: (d) => {
  142. if (d.delta.dy > dragSensitivity)
  143. {
  144. Navigator.of(context).pop(),
  145. }
  146. else if (d.delta.dy < (dragSensitivity * -1))
  147. {
  148. showDetailsSheet(context, widget.file),
  149. },
  150. },
  151. child: Center(
  152. child: controller != null
  153. ? Video(
  154. controller: controller!,
  155. )
  156. : _getLoadingWidget(),
  157. ),
  158. ),
  159. ),
  160. );
  161. }
  162. void _loadNetworkVideo() {
  163. getFileFromServer(
  164. widget.file,
  165. progressCallback: (count, total) {
  166. if (!mounted) {
  167. return;
  168. }
  169. _progressNotifier.value = count / (widget.file.fileSize ?? total);
  170. if (_progressNotifier.value == 1) {
  171. if (mounted) {
  172. showShortToast(context, S.of(context).decryptingVideo);
  173. }
  174. }
  175. },
  176. ).then((file) {
  177. if (file != null) {
  178. _setVideoController(file.path);
  179. }
  180. }).onError((error, stackTrace) {
  181. showErrorDialog(context, "Error", S.of(context).failedToDownloadVideo);
  182. });
  183. }
  184. void _setFileSizeIfNull() {
  185. if (widget.file.fileSize == null && widget.file.canEditMetaInfo) {
  186. FilesService.instance
  187. .getFileSize(widget.file.uploadedFileID!)
  188. .then((value) {
  189. widget.file.fileSize = value;
  190. if (mounted) {
  191. setState(() {});
  192. }
  193. });
  194. }
  195. }
  196. Widget _getLoadingWidget() {
  197. return Stack(
  198. children: [
  199. _getThumbnail(),
  200. Container(
  201. color: Colors.black12,
  202. constraints: const BoxConstraints.expand(),
  203. ),
  204. Center(
  205. child: SizedBox.fromSize(
  206. size: const Size.square(20),
  207. child: ValueListenableBuilder(
  208. valueListenable: _progressNotifier,
  209. builder: (BuildContext context, double? progress, _) {
  210. return progress == null || progress == 1
  211. ? const CupertinoActivityIndicator(
  212. color: Colors.white,
  213. )
  214. : CircularProgressIndicator(
  215. backgroundColor: Colors.black,
  216. value: progress,
  217. valueColor: const AlwaysStoppedAnimation<Color>(
  218. Color.fromRGBO(45, 194, 98, 1.0),
  219. ),
  220. );
  221. },
  222. ),
  223. ),
  224. ),
  225. ],
  226. );
  227. }
  228. Widget _getThumbnail() {
  229. return Container(
  230. color: Colors.black,
  231. constraints: const BoxConstraints.expand(),
  232. child: ThumbnailWidget(
  233. widget.file,
  234. fit: BoxFit.contain,
  235. ),
  236. );
  237. }
  238. void _setVideoController(String url) {
  239. if (mounted) {
  240. setState(() {
  241. player.setPlaylistMode(PlaylistMode.single);
  242. controller = VideoController(player);
  243. player.open(Media(url), play: _isAppInFG);
  244. });
  245. }
  246. }
  247. }
  248. class PausePlayAndDuration extends StatefulWidget {
  249. final Player? player;
  250. const PausePlayAndDuration(this.player, {super.key});
  251. @override
  252. State<PausePlayAndDuration> createState() => _PausePlayAndDurationState();
  253. }
  254. class _PausePlayAndDurationState extends State<PausePlayAndDuration> {
  255. Color backgroundColor = fillStrongLight;
  256. @override
  257. Widget build(BuildContext context) {
  258. return GestureDetector(
  259. onTapDown: (details) {
  260. setState(() {
  261. backgroundColor = fillMutedDark;
  262. });
  263. },
  264. onTapUp: (details) {
  265. Future.delayed(const Duration(milliseconds: 175), () {
  266. if (mounted) {
  267. setState(() {
  268. backgroundColor = fillStrongLight;
  269. });
  270. }
  271. });
  272. },
  273. onTapCancel: () {
  274. Future.delayed(const Duration(milliseconds: 175), () {
  275. if (mounted) {
  276. setState(() {
  277. backgroundColor = fillStrongLight;
  278. });
  279. }
  280. });
  281. },
  282. onTap: () => widget.player!.playOrPause(),
  283. child: AnimatedContainer(
  284. duration: const Duration(milliseconds: 150),
  285. curve: Curves.easeInBack,
  286. padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
  287. decoration: BoxDecoration(
  288. color: backgroundColor,
  289. border: Border.all(
  290. color: strokeFaintDark,
  291. width: 1,
  292. ),
  293. borderRadius: BorderRadius.circular(24),
  294. ),
  295. child: AnimatedSize(
  296. duration: const Duration(seconds: 2),
  297. curve: Curves.easeInOutExpo,
  298. child: Row(
  299. children: [
  300. StreamBuilder(
  301. builder: (context, snapshot) {
  302. final bool isPlaying = snapshot.data ?? false;
  303. return AnimatedSwitcher(
  304. duration: const Duration(milliseconds: 350),
  305. switchInCurve: Curves.easeInOutCirc,
  306. switchOutCurve: Curves.easeInOutCirc,
  307. child: Icon(
  308. key: ValueKey(
  309. isPlaying ? "pause_button" : "play_button",
  310. ),
  311. isPlaying
  312. ? Icons.pause_rounded
  313. : Icons.play_arrow_rounded,
  314. color: backdropBaseLight,
  315. size: 24,
  316. ),
  317. );
  318. },
  319. initialData: widget.player?.state.playing,
  320. stream: widget.player?.stream.playing,
  321. ),
  322. const SizedBox(width: 8),
  323. MaterialPositionIndicator(
  324. style: getEnteTextTheme(context).tiny.copyWith(
  325. color: textBaseDark,
  326. ),
  327. ),
  328. const SizedBox(width: 10),
  329. ],
  330. ),
  331. ),
  332. ),
  333. );
  334. }
  335. }