import "dart:async"; import "dart:io"; import "package:flutter/cupertino.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; import "package:native_video_player/native_video_player.dart"; import "package:photos/core/constants.dart"; import "package:photos/core/event_bus.dart"; import "package:photos/events/file_swipe_lock_event.dart"; // import "package:photos/events/pause_video_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/file/extensions/file_props.dart"; import "package:photos/models/file/file.dart"; import "package:photos/services/files_service.dart"; import "package:photos/theme/colors.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/actions/file/file_actions.dart"; import "package:photos/ui/viewer/file/native_video_player_controls/play_pause_button.dart"; import "package:photos/ui/viewer/file/native_video_player_controls/seek_bar.dart"; import "package:photos/ui/viewer/file/thumbnail_widget.dart"; import "package:photos/utils/dialog_util.dart"; import "package:photos/utils/exif_util.dart"; import "package:photos/utils/file_util.dart"; import "package:photos/utils/toast_util.dart"; class VideoWidgetNative extends StatefulWidget { final EnteFile file; final String? tagPrefix; final Function(bool)? playbackCallback; const VideoWidgetNative( this.file, { this.tagPrefix, this.playbackCallback, super.key, }); @override State createState() => _VideoWidgetNativeState(); } class _VideoWidgetNativeState extends State with WidgetsBindingObserver { final Logger _logger = Logger("VideoWidgetNew"); static const verticalMargin = 72.0; // late final player = Player(); // VideoController? controller; final _progressNotifier = ValueNotifier(null); // late StreamSubscription playingStreamSubscription; bool _isAppInFG = true; // late StreamSubscription pauseVideoSubscription; bool _isFileSwipeLocked = false; late final StreamSubscription _fileSwipeLockEventSubscription; NativeVideoPlayerController? _controller; String? _filePath; String? duration; double? aspectRatio; final _isPlaybackReady = ValueNotifier(false); @override void initState() { _logger.info( 'initState for ${widget.file.generatedID} with tag ${widget.file.tag} and name ${widget.file.displayName}', ); super.initState(); WidgetsBinding.instance.addObserver(this); if (widget.file.isRemoteFile) { _loadNetworkVideo(); _setFileSizeIfNull(); } else if (widget.file.isSharedMediaToAppSandbox) { final localFile = File(getSharedMediaFilePath(widget.file)); if (localFile.existsSync()) { _setFilePathForNativePlayer(localFile.path); } else if (widget.file.uploadedFileID != null) { _loadNetworkVideo(); } } else { widget.file.getAsset.then((asset) async { if (asset == null || !(await asset.exists)) { if (widget.file.uploadedFileID != null) { _loadNetworkVideo(); } } else { // ignore: unawaited_futures getFile(widget.file, isOrigin: true).then((file) { _setFilePathForNativePlayer(file!.path); file.delete(); }); } }); } // playingStreamSubscription = player.stream.playing.listen((event) { // if (widget.playbackCallback != null && mounted) { // widget.playbackCallback!(event); // } // }); // pauseVideoSubscription = Bus.instance.on().listen((event) { // player.pause(); // }); _fileSwipeLockEventSubscription = Bus.instance.on().listen((event) { setState(() { _isFileSwipeLocked = event.shouldSwipeLock; }); }); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _isAppInFG = true; } else { _isAppInFG = false; } } @override void dispose() { _fileSwipeLockEventSubscription.cancel(); // pauseVideoSubscription.cancel(); removeCallBack(widget.file); _progressNotifier.dispose(); WidgetsBinding.instance.removeObserver(this); // playingStreamSubscription.cancel(); // player.dispose(); _controller?.onPlaybackEnded.removeListener(_onPlaybackEnded); _controller?.onPlaybackReady.removeListener(_onPlaybackReady); _isPlaybackReady.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Hero( tag: widget.tagPrefix! + widget.file.tag, child: GestureDetector( onVerticalDragUpdate: _isFileSwipeLocked ? null : (d) => { if (d.delta.dy > dragSensitivity) { Navigator.of(context).pop(), } else if (d.delta.dy < (dragSensitivity * -1)) { showDetailsSheet(context, widget.file), }, }, child: _filePath == null ? _getLoadingWidget() : Stack( children: [ Center( child: AspectRatio( aspectRatio: aspectRatio ?? 1, child: NativeVideoPlayerView( onViewReady: _initializeController, ), ), ), Positioned.fill( child: Center( child: ValueListenableBuilder( builder: (BuildContext context, bool value, _) { return value ? PlayPauseButton(_controller) : const SizedBox(); }, valueListenable: _isPlaybackReady, ), ), ), Positioned( bottom: verticalMargin, right: 0, left: 0, child: ValueListenableBuilder( builder: (BuildContext context, bool value, _) { return value ? Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Container( padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), decoration: BoxDecoration( color: Colors.black.withOpacity(0.3), borderRadius: const BorderRadius.all( Radius.circular(8), ), border: Border.all( color: getEnteColorScheme(context) .strokeFaint, width: 1, ), ), child: Row( children: [ AnimatedSize( duration: const Duration(seconds: 5), curve: Curves.easeInOut, child: ValueListenableBuilder( valueListenable: _controller! .onPlaybackPositionChanged, builder: ( BuildContext context, int value, _, ) { return Text( secondsToDuration(value), style: getEnteTextTheme(context) .mini .copyWith( color: textBaseDark, ), ); }, ), ), Expanded( child: SeekBar( _controller!, _durationToSeconds(duration), ), ), Text( duration ?? "0:00", style: getEnteTextTheme(context) .mini .copyWith( color: textBaseDark, ), ), ], ), ), ) : const SizedBox(); }, valueListenable: _isPlaybackReady, ), ), ], ), ), ); } String secondsToDuration(int totalSeconds) { final hours = totalSeconds ~/ 3600; final minutes = (totalSeconds % 3600) ~/ 60; final seconds = totalSeconds % 60; if (hours > 0) { return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; } else { return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}'; } } int? _durationToSeconds(String? duration) { if (duration == null) { _logger.warning("Duration is null"); return null; } final parts = duration.split(':'); int seconds = 0; if (parts.length == 3) { // Format: "h:mm:ss" seconds += int.parse(parts[0]) * 3600; // Hours to seconds seconds += int.parse(parts[1]) * 60; // Minutes to seconds seconds += int.parse(parts[2]); // Seconds } else if (parts.length == 2) { // Format: "m:ss" seconds += int.parse(parts[0]) * 60; // Minutes to seconds seconds += int.parse(parts[1]); // Seconds } else { throw FormatException('Invalid duration format: $duration'); } return seconds; } Future _initializeController( NativeVideoPlayerController controller, ) async { _logger.info("initializing native video player controller"); _controller = controller; controller.onPlaybackEnded.addListener(_onPlaybackEnded); controller.onPlaybackReady.addListener(_onPlaybackReady); final videoSource = await VideoSource.init( path: _filePath!, //Check when to set this to VideoSourceType.asset type: VideoSourceType.file, ); await controller.loadVideoSource(videoSource); } Future _onPlaybackReady() async { await _controller!.play(); Future.delayed(const Duration(seconds: 2), () { _controller!.setVolume(1); }); _isPlaybackReady.value = true; } void _onPlaybackEnded() { _controller?.play(); } void _loadNetworkVideo() { getFileFromServer( widget.file, progressCallback: (count, total) { if (!mounted) { return; } _progressNotifier.value = count / (widget.file.fileSize ?? total); if (_progressNotifier.value == 1) { if (mounted) { showShortToast(context, S.of(context).decryptingVideo); } } }, ).then((file) { if (file != null) { _setFilePathForNativePlayer(file.path); } }).onError((error, stackTrace) { showErrorDialog(context, "Error", S.of(context).failedToDownloadVideo); }); } void _setFileSizeIfNull() { if (widget.file.fileSize == null && widget.file.canEditMetaInfo) { FilesService.instance .getFileSize(widget.file.uploadedFileID!) .then((value) { widget.file.fileSize = value; if (mounted) { setState(() {}); } }); } } Widget _getLoadingWidget() { return Stack( children: [ _getThumbnail(), Container( color: Colors.black12, constraints: const BoxConstraints.expand(), ), Center( child: SizedBox.fromSize( size: const Size.square(20), child: ValueListenableBuilder( valueListenable: _progressNotifier, builder: (BuildContext context, double? progress, _) { return progress == null || progress == 1 ? const CupertinoActivityIndicator( color: Colors.white, ) : CircularProgressIndicator( backgroundColor: Colors.black, value: progress, valueColor: const AlwaysStoppedAnimation( Color.fromRGBO(45, 194, 98, 1.0), ), ); }, ), ), ), ], ); } Widget _getThumbnail() { return Container( color: Colors.black, constraints: const BoxConstraints.expand(), child: ThumbnailWidget( widget.file, fit: BoxFit.contain, ), ); } void _setFilePathForNativePlayer(String url) { if (mounted) { setState(() { _filePath = url; }); _setAspectRatioFromVideoProps().then((_) { setState(() {}); }); } } Future _setAspectRatioFromVideoProps() async { final videoProps = await getVideoPropsAsync(File(_filePath!)); if (videoProps != null) { duration = videoProps.propData?["duration"]; if (videoProps.width != null && videoProps.height != null) { if (videoProps.width != null && videoProps.height != 0) { aspectRatio = videoProps.width! / videoProps.height!; } else { _logger.info("Video props height or width is 0"); aspectRatio = 1; } } else { _logger.info("Video props width and height are null"); aspectRatio = 1; } } else { _logger.info("Video props are null"); aspectRatio = 1; } } }