ente/mobile/lib/ui/viewer/file/video_widget_native.dart
ashilkn 4c02e8ffa3 [mob][photos] Create seek bar that moves with video and animate the seek bar between each second with also handling edge cases
The native video player package that is used only emits an event at each second when the video is played. For a good looking seek bar, have animate it in between seconds
2024-08-07 12:31:15 +05:30

555 lines
16 KiB
Dart

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/ui/actions/file/file_actions.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<VideoWidgetNative> createState() => _VideoWidgetNativeState();
}
class _VideoWidgetNativeState extends State<VideoWidgetNative>
with WidgetsBindingObserver {
final Logger _logger = Logger("VideoWidgetNew");
static const verticalMargin = 72.0;
// late final player = Player();
// VideoController? controller;
final _progressNotifier = ValueNotifier<double?>(null);
// late StreamSubscription<bool> playingStreamSubscription;
bool _isAppInFG = true;
// late StreamSubscription<PauseVideoEvent> pauseVideoSubscription;
bool _isFileSwipeLocked = false;
late final StreamSubscription<FileSwipeLockEvent>
_fileSwipeLockEventSubscription;
NativeVideoPlayerController? _controller;
String? _filePath;
///Duration in seconds
int? duration;
double? aspectRatio;
final _isControllerInitialized = 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<PauseVideoEvent>().listen((event) {
// player.pause();
// });
_fileSwipeLockEventSubscription =
Bus.instance.on<FileSwipeLockEvent>().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);
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: _isControllerInitialized,
),
),
),
Positioned(
bottom: verticalMargin,
right: 0,
left: 0,
child: ValueListenableBuilder(
builder: (BuildContext context, bool value, _) {
return value
? _SeekBar(_controller!, duration)
: const SizedBox();
},
valueListenable: _isControllerInitialized,
),
),
],
),
),
);
}
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<void> _initializeController(
NativeVideoPlayerController controller,
) async {
_controller = controller;
controller.onPlaybackEnded.addListener(_onPlaybackEnded);
final videoSource = await VideoSource.init(
path: _filePath!,
type: VideoSourceType.file,
);
await controller.loadVideoSource(videoSource);
await controller.play();
_isControllerInitialized.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>(
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<void> _setAspectRatioFromVideoProps() async {
final videoProps = await getVideoPropsAsync(File(_filePath!));
if (videoProps != null) {
duration = _durationToSeconds(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;
}
}
}
class PlayPauseButton extends StatefulWidget {
final NativeVideoPlayerController? controller;
const PlayPauseButton(this.controller, {super.key});
@override
State<PlayPauseButton> createState() => _PlayPauseButtonState();
}
class _PlayPauseButtonState extends State<PlayPauseButton> {
bool _isPlaying = true;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (_playbackStatus == PlaybackStatus.playing) {
widget.controller?.pause();
setState(() {
_isPlaying = false;
});
} else {
widget.controller?.play();
setState(() {
_isPlaying = true;
});
}
},
child: Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
shape: BoxShape.circle,
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
switchInCurve: Curves.easeInOutQuart,
switchOutCurve: Curves.easeInOutQuart,
child: _isPlaying
? const Icon(
Icons.pause,
size: 32,
key: ValueKey("pause"),
)
: const Icon(
Icons.play_arrow,
size: 36,
key: ValueKey("play"),
),
),
),
);
}
PlaybackStatus? get _playbackStatus =>
widget.controller?.playbackInfo?.status;
}
class _SeekBar extends StatefulWidget {
final NativeVideoPlayerController controller;
final int? duration;
const _SeekBar(this.controller, this.duration);
@override
State<_SeekBar> createState() => _SeekBarState();
}
class _SeekBarState extends State<_SeekBar>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
double _prevPositionFraction = 0.0;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
value: 0,
);
widget.controller.onPlaybackStatusChanged.addListener(
_onPlaybackStatusChanged,
);
widget.controller.onPlaybackPositionChanged.addListener(
_onPlaybackPositionChanged,
);
_startMovingSeekbar();
}
@override
void dispose() {
_animationController.dispose();
widget.controller.onPlaybackStatusChanged.removeListener(
_onPlaybackStatusChanged,
);
widget.controller.onPlaybackPositionChanged.removeListener(
_onPlaybackPositionChanged,
);
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (_, __) {
return SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2.0,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8.0),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14.0),
activeTrackColor: Colors.red,
inactiveTrackColor: Colors.grey,
thumbColor: Colors.red,
overlayColor: Colors.red.withOpacity(0.4),
),
child: Slider(
min: 0.0,
max: 1.0,
value: _animationController.value,
onChanged: (value) {
// setState(() {
// });
// widget.controller?.seekTo(value.toInt());
},
onChangeEnd: (value) {
// widget.onSeek(Duration(milliseconds: value.round()));
},
allowedInteraction: SliderInteraction.tapAndSlide,
),
);
},
);
}
void _startMovingSeekbar() {
//Video starts playing after a slight delay. This delay is to ensure that
//the seek bar animation starts after the video starts playing.
Future.delayed(const Duration(milliseconds: 700), () {
if (widget.duration != null) {
unawaited(
_animationController.animateTo(
(1 / widget.duration!),
duration: const Duration(seconds: 1),
),
);
} else {
unawaited(
_animationController.animateTo(
0,
duration: const Duration(seconds: 1),
),
);
}
});
}
void _onPlaybackStatusChanged() {
if (widget.controller.playbackInfo?.status == PlaybackStatus.paused) {
_animationController.stop();
}
}
void _onPlaybackPositionChanged() async {
final target = widget.controller.playbackInfo?.positionFraction ?? 0;
//To immediately set the position to 0 when the ends when playing in loop
if (_prevPositionFraction == 1.0 && target == 0.0) {
unawaited(
_animationController.animateTo(0, duration: const Duration(seconds: 0)),
);
}
//There is a slight delay (around 350 ms) for the event being listened to
//by this listener on the next target (target that comes after 0). Adding
//this buffer to keep the seek bar animation smooth.
if (target == 0) {
await Future.delayed(const Duration(milliseconds: 450));
}
if (widget.duration != null) {
unawaited(
_animationController.animateTo(
target + (1 / widget.duration!),
duration: const Duration(seconds: 1),
),
);
} else {
unawaited(
_animationController.animateTo(
target,
duration: const Duration(seconds: 1),
),
);
}
_prevPositionFraction = target;
}
}