import 'dart:async'; import 'package:flutter/material.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/pref.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/np_api_util.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/use_case/request_public_link.dart'; import 'package:nc_photos/widget/disposable.dart'; import 'package:nc_photos/widget/wakelock_util.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_platform_util/np_platform_util.dart'; import 'package:np_ui/np_ui.dart'; import 'package:video_player/video_player.dart'; part 'video_viewer.g.dart'; class VideoViewer extends StatefulWidget { const VideoViewer({ super.key, required this.account, required this.file, this.onLoaded, this.onLoadFailure, this.onHeightChanged, this.onPlay, this.onPause, this.isControlVisible = false, this.canPlay = true, this.canLoop = true, }); @override createState() => _VideoViewerState(); final Account account; final FileDescriptor file; final VoidCallback? onLoaded; final VoidCallback? onLoadFailure; final ValueChanged? onHeightChanged; final VoidCallback? onPlay; final VoidCallback? onPause; final bool isControlVisible; final bool canPlay; /// If false, disable the loop control and always stop after playing once final bool canLoop; } @npLog class _VideoViewerState extends State with DisposableManagerMixin { @override void initState() { super.initState(); _getVideoUrl().then((url) { if (mounted) { setState(() { _initController(url); }); } }).onError((e, stacktrace) { _log.shout("[initState] Failed while _getVideoUrl", e, stacktrace); SnackBarManager().showSnackBarForException(e); widget.onLoadFailure?.call(); }); } @override void dispose() { _controllerValue?.dispose(); super.dispose(); } @override initDisposables() { return [ ...super.initDisposables(), WakelockControllerDisposable(), ]; } @override build(BuildContext context) { Widget content; if (_isControllerInitialized && _controller.value.isInitialized) { content = _buildPlayer(context); } else { content = Container(); } return Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, alignment: Alignment.center, child: content, ); } Future _initController(String url) async { try { _controllerValue = VideoPlayerController.networkUrl( Uri.parse(url), httpHeaders: { "Authorization": AuthUtil.fromAccount(widget.account).toHeaderValue(), }, ); await _controller.initialize(); final c = KiwiContainer().resolve(); unawaited(_controller.setVolume(c.pref.isVideoPlayerMuteOr() ? 0 : 1)); if (widget.canLoop) { unawaited(_controller.setLooping(c.pref.isVideoPlayerLoopOr())); } widget.onLoaded?.call(); WidgetsBinding.instance.addPostFrameCallback((_) { if (_key.currentContext != null) { widget.onHeightChanged?.call(_key.currentContext!.size!.height); } }); _controller.addListener(_onControllerChanged); _isControllerInitialized = true; setState(() { _play(); }); } catch (e, stackTrace) { _log.shout("[_initController] Failed while initialize", e, stackTrace); SnackBarManager().showSnackBarForException(e); widget.onLoadFailure?.call(); } } Widget _buildPlayer(BuildContext context) { if (_controller.value.isPlaying && !widget.canPlay) { WidgetsBinding.instance.addPostFrameCallback((_) { _pause(); }); } return Stack( children: [ Align( alignment: Alignment.center, child: AspectRatio( key: _key, aspectRatio: _controller.value.aspectRatio, child: IgnorePointer( child: VideoPlayer(_controller), ), ), ), Positioned.fill( child: AnimatedVisibility( opacity: widget.isControlVisible ? 1.0 : 0.0, duration: k.animationDurationNormal, child: Container( color: Colors.black45, child: Center( child: IconButton( icon: Icon(_controller.value.isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled), iconSize: 48, padding: const EdgeInsets.all(16), color: Colors.white, onPressed: () => _controller.value.isPlaying ? _onPausePressed() : _onPlayPressed(), ), ), ), ), ), Align( alignment: Alignment.bottomCenter, child: Padding( padding: const EdgeInsets.only( bottom: kToolbarHeight + 8, left: 8, right: 8), child: AnimatedVisibility( opacity: widget.isControlVisible ? 1.0 : 0.0, duration: k.animationDurationNormal, child: Material( type: MaterialType.transparency, child: Row( children: [ ValueListenableBuilder( valueListenable: _controller, builder: (context, VideoPlayerValue value, child) => Text( _durationToString(value.position), style: Theme.of(context).textTheme.labelLarge!.copyWith( color: Theme.of(context).colorScheme.onSurface, ), ), ), const SizedBox(width: 8), Expanded( child: VideoProgressIndicator( _controller, allowScrubbing: true, padding: const EdgeInsets.symmetric(vertical: 8), colors: VideoProgressColors( backgroundColor: Theme.of(context).colorScheme.surface, bufferedColor: Theme.of(context).colorScheme.surfaceVariant, playedColor: Theme.of(context).colorScheme.primary, ), ), ), const SizedBox(width: 8), if (_controller.value.duration != Duration.zero) Text( _durationToString(_controller.value.duration), style: Theme.of(context).textTheme.labelLarge!.copyWith( color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(width: 4), if (widget.canLoop) _LoopToggle(controller: _controller), _MuteToggle(controller: _controller), ], ), ), ), ), ), ], ); } void _onPlayPressed() { if (_controller.value.position == _controller.value.duration) { _controller.seekTo(const Duration()).then((_) { setState(() { _play(); }); }); } else { setState(() { _play(); }); } } void _onPausePressed() { setState(() { _pause(); }); } void _onControllerChanged() { if (!_controller.value.isInitialized) { return; } if (!_isFinished && _controller.value.position == _controller.value.duration) { _isFinished = true; setState(() { _pause(); }); } } void _play() { if (widget.canPlay) { _isFinished = false; _controller.play(); widget.onPlay?.call(); } } void _pause() { _controller.pause(); widget.onPause?.call(); } Future _getVideoUrl() async { if (getRawPlatform() == NpPlatform.web) { return RequestPublicLink()(widget.account, widget.file); } else { return api_util.getFileUrl(widget.account, widget.file); } } VideoPlayerController get _controller => _controllerValue!; final _key = GlobalKey(); bool _isControllerInitialized = false; VideoPlayerController? _controllerValue; var _isFinished = false; } class _LoopToggle extends StatefulWidget { const _LoopToggle({ required this.controller, }); @override State createState() => _LoopToggleState(); final VideoPlayerController controller; } class _LoopToggleState extends State<_LoopToggle> { @override Widget build(BuildContext context) { return Tooltip( message: L10n.global().loopTooltip, child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(32)), onTap: () { final willLoop = !widget.controller.value.isLooping; setState(() { widget.controller.setLooping(willLoop); }); final c = KiwiContainer().resolve(); c.pref.setVideoPlayerLoop(willLoop); }, child: Padding( padding: const EdgeInsets.all(4), child: AnimatedSwitcher( duration: k.animationDurationNormal, child: widget.controller.value.isLooping ? const Icon( Icons.loop, key: Key("loop_on"), ) : const Icon( Icons.sync_disabled, key: Key("loop_off"), ), ), ), ), ); } } class _MuteToggle extends StatefulWidget { const _MuteToggle({ required this.controller, }); @override State createState() => _MuteToggleState(); final VideoPlayerController controller; } class _MuteToggleState extends State<_MuteToggle> { @override Widget build(BuildContext context) { return Tooltip( message: widget.controller.value.volume == 0 ? L10n.global().unmuteTooltip : L10n.global().muteTooltip, child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(32)), onTap: () { final willMute = widget.controller.value.volume != 0; setState(() { widget.controller.setVolume(willMute ? 0 : 1); }); final c = KiwiContainer().resolve(); c.pref.setVideoPlayerMute(willMute); }, child: Padding( padding: const EdgeInsets.all(4), child: AnimatedSwitcher( duration: k.animationDurationNormal, child: widget.controller.value.volume == 0 ? const Icon( Icons.volume_off_outlined, key: Key("mute_on"), ) : const Icon( Icons.volume_up_outlined, key: Key("mute_off"), ), ), ), ), ); } } String _durationToString(Duration duration) { String product = ""; if (duration.inHours > 0) { product += "${duration.inHours}:"; } final minStr = (duration.inMinutes % 60).toString().padLeft(2, "0"); final secStr = (duration.inSeconds % 60).toString().padLeft(2, "0"); product += "$minStr:$secStr"; return product; }