import 'dart:async'; import 'package:aves/model/actions/video_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/popup_menu_button.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; class VideoControlOverlay extends StatefulWidget { final AvesEntry entry; final AvesVideoController? controller; final Animation scale; final Function(VideoAction value) onActionSelected; final VoidCallback onActionMenuOpened; const VideoControlOverlay({ Key? key, required this.entry, required this.controller, required this.scale, required this.onActionSelected, required this.onActionMenuOpened, }) : super(key: key); @override State createState() => _VideoControlOverlayState(); } class _VideoControlOverlayState extends State with SingleTickerProviderStateMixin { final GlobalKey _progressBarKey = GlobalKey(debugLabel: 'video-progress-bar'); bool _playingOnDragStart = false; AvesEntry get entry => widget.entry; Animation get scale => widget.scale; AvesVideoController? get controller => widget.controller; Stream get statusStream => controller?.statusStream ?? Stream.value(VideoStatus.idle); Stream get positionStream => controller?.positionStream ?? Stream.value(0); bool get isPlaying => controller?.isPlaying ?? false; static const double outerPadding = 8; static const double innerPadding = 8; @override Widget build(BuildContext context) { return StreamBuilder( stream: statusStream, builder: (context, snapshot) { // do not use stream snapshot because it is obsolete when switching between videos final status = controller?.status ?? VideoStatus.idle; Widget child; if (status == VideoStatus.error) { child = Align( alignment: AlignmentDirectional.centerEnd, child: OverlayButton( scale: scale, child: IconButton( icon: const Icon(AIcons.openOutside), onPressed: () => androidAppService.open(entry.uri, entry.mimeTypeAnySubtype), tooltip: context.l10n.viewerOpenTooltip, ), ), ); } else { child = Selector( selector: (context, mq) => mq.size.width - mq.padding.horizontal, builder: (context, mqWidth, child) { final buttonWidth = OverlayButton.getSize(context); final availableCount = ((mqWidth - outerPadding * 2) / (buttonWidth + innerPadding)).floor(); final quickActions = settings.videoQuickActions.take(availableCount - 1).toList(); final menuActions = VideoActions.all.where((action) => !quickActions.contains(action)).toList(); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ _ButtonRow( quickActions: quickActions, menuActions: menuActions, scale: scale, controller: controller, onActionSelected: widget.onActionSelected, onActionMenuOpened: widget.onActionMenuOpened, ), const SizedBox(height: 8), _buildProgressBar(), ], ); }, ); } return TooltipTheme( data: TooltipTheme.of(context).copyWith( preferBelow: false, ), child: child, ); }); } Widget _buildProgressBar() { const progressBarBorderRadius = 123.0; final blurred = settings.enableOverlayBlurEffect; const textStyle = TextStyle(shadows: Constants.embossShadows); return SizeTransition( sizeFactor: scale, child: BlurredRRect( enabled: blurred, borderRadius: progressBarBorderRadius, child: GestureDetector( onTapDown: (details) { _seekFromTap(details.globalPosition); }, onHorizontalDragStart: (details) { _playingOnDragStart = isPlaying; if (_playingOnDragStart) controller!.pause(); }, onHorizontalDragUpdate: (details) { _seekFromTap(details.globalPosition); }, onHorizontalDragEnd: (details) { if (_playingOnDragStart) controller!.play(); }, child: ConstrainedBox( constraints: const BoxConstraints( minHeight: kMinInteractiveDimension, ), child: Container( alignment: Alignment.center, padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), decoration: BoxDecoration( color: overlayBackgroundColor(blurred: blurred), border: AvesBorder.border, borderRadius: const BorderRadius.all(Radius.circular(progressBarBorderRadius)), ), child: Column( key: _progressBarKey, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ StreamBuilder( stream: positionStream, builder: (context, snapshot) { // do not use stream snapshot because it is obsolete when switching between videos final position = controller?.currentPosition.floor() ?? 0; return Text( formatFriendlyDuration(Duration(milliseconds: position)), style: textStyle, ); }), const Spacer(), Text( entry.durationText, style: textStyle, ), ], ), ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(4)), child: StreamBuilder( stream: positionStream, builder: (context, snapshot) { // do not use stream snapshot because it is obsolete when switching between videos var progress = controller?.progress ?? 0.0; if (!progress.isFinite) progress = 0.0; return LinearProgressIndicator( value: progress, backgroundColor: Colors.grey.shade700, ); }), ), const Text( // fake text below to match the height of the text above and center the whole thing '', style: textStyle, ), ], ), ), ), ), ), ); } void _seekFromTap(Offset globalPosition) async { if (controller == null) return; final keyContext = _progressBarKey.currentContext!; final box = keyContext.findRenderObject() as RenderBox; final localPosition = box.globalToLocal(globalPosition); await controller!.seekToProgress(localPosition.dx / box.size.width); } } class _ButtonRow extends StatelessWidget { final List quickActions, menuActions; final Animation scale; final AvesVideoController? controller; final Function(VideoAction value) onActionSelected; final VoidCallback onActionMenuOpened; const _ButtonRow({ Key? key, required this.quickActions, required this.menuActions, required this.scale, required this.controller, required this.onActionSelected, required this.onActionMenuOpened, }) : super(key: key); static const double padding = 8; bool get isPlaying => controller?.isPlaying ?? false; @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ ...quickActions.map((action) => _buildOverlayButton(context, action)), if (menuActions.isNotEmpty) Padding( padding: const EdgeInsetsDirectional.only(start: padding), child: OverlayButton( scale: scale, child: MenuIconTheme( child: AvesPopupMenuButton( itemBuilder: (context) => menuActions.map((action) => _buildPopupMenuItem(context, action)).toList(), onSelected: (action) async { // wait for the popup menu to hide before proceeding with the action await Future.delayed(Durations.popupMenuAnimation * timeDilation); onActionSelected(action); }, onMenuOpened: onActionMenuOpened, ), ), ), ), ], ); } Widget _buildOverlayButton(BuildContext context, VideoAction action) { late Widget child; void onPressed() => onActionSelected(action); ValueListenableBuilder _buildFromListenable(ValueListenable? enabledNotifier) { return ValueListenableBuilder( valueListenable: enabledNotifier ?? ValueNotifier(false), builder: (context, canDo, child) => IconButton( icon: child!, onPressed: canDo ? onPressed : null, tooltip: action.getText(context), ), child: action.getIcon(), ); } switch (action) { case VideoAction.captureFrame: child = _buildFromListenable(controller?.canCaptureFrameNotifier); break; case VideoAction.selectStreams: child = _buildFromListenable(controller?.canSelectStreamNotifier); break; case VideoAction.setSpeed: child = _buildFromListenable(controller?.canSetSpeedNotifier); break; case VideoAction.togglePlay: child = _PlayToggler( controller: controller, onPressed: onPressed, ); break; case VideoAction.replay10: case VideoAction.skip10: case VideoAction.settings: child = IconButton( icon: action.getIcon(), onPressed: onPressed, tooltip: action.getText(context), ); break; } return Padding( padding: const EdgeInsetsDirectional.only(start: padding), child: OverlayButton( scale: scale, child: child, ), ); } PopupMenuEntry _buildPopupMenuItem(BuildContext context, VideoAction action) { late final bool enabled; switch (action) { case VideoAction.captureFrame: enabled = controller?.canCaptureFrameNotifier.value ?? false; break; case VideoAction.selectStreams: enabled = controller?.canSelectStreamNotifier.value ?? false; break; case VideoAction.setSpeed: enabled = controller?.canSetSpeedNotifier.value ?? false; break; case VideoAction.replay10: case VideoAction.skip10: case VideoAction.settings: case VideoAction.togglePlay: enabled = true; break; } Widget? child; switch (action) { case VideoAction.togglePlay: child = _PlayToggler( controller: controller, isMenuItem: true, ); break; case VideoAction.captureFrame: case VideoAction.replay10: case VideoAction.skip10: case VideoAction.selectStreams: case VideoAction.setSpeed: case VideoAction.settings: child = MenuRow(text: action.getText(context), icon: action.getIcon()); break; } return PopupMenuItem( value: action, enabled: enabled, child: child, ); } } class _PlayToggler extends StatefulWidget { final AvesVideoController? controller; final bool isMenuItem; final VoidCallback? onPressed; const _PlayToggler({ required this.controller, this.isMenuItem = false, this.onPressed, }); @override _PlayTogglerState createState() => _PlayTogglerState(); } class _PlayTogglerState extends State<_PlayToggler> with SingleTickerProviderStateMixin { final List _subscriptions = []; late AnimationController _playPauseAnimation; AvesVideoController? get controller => widget.controller; bool get isPlaying => controller?.isPlaying ?? false; @override void initState() { super.initState(); _playPauseAnimation = AnimationController( duration: context.read().iconAnimation, vsync: this, ); _registerWidget(widget); } @override void didUpdateWidget(covariant _PlayToggler oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); } @override void dispose() { _unregisterWidget(widget); _playPauseAnimation.dispose(); super.dispose(); } void _registerWidget(_PlayToggler widget) { final controller = widget.controller; if (controller != null) { _subscriptions.add(controller.statusStream.listen(_onStatusChange)); _onStatusChange(controller.status); } } void _unregisterWidget(_PlayToggler widget) { _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); } @override Widget build(BuildContext context) { if (widget.isMenuItem) { return isPlaying ? MenuRow( text: context.l10n.videoActionPause, icon: const Icon(AIcons.pause), ) : MenuRow( text: context.l10n.videoActionPlay, icon: const Icon(AIcons.play), ); } return IconButton( icon: AnimatedIcon( icon: AnimatedIcons.play_pause, progress: _playPauseAnimation, ), onPressed: widget.onPressed, tooltip: isPlaying ? context.l10n.videoActionPause : context.l10n.videoActionPlay, ); } void _onStatusChange(VideoStatus status) { final status = _playPauseAnimation.status; if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) { _playPauseAnimation.forward(); } else if (!isPlaying && status != AnimationStatus.reverse && status != AnimationStatus.dismissed) { _playPauseAnimation.reverse(); } } }