import 'dart:async'; import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; import 'package:flutter/material.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/cache_manager_util.dart'; import 'package:nc_photos/entity/file_descriptor.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/cached_network_image_mod.dart' as mod; import 'package:np_codegen/np_codegen.dart'; import 'package:np_platform_util/np_platform_util.dart'; import 'package:video_player/video_player.dart'; import 'package:video_player_platform_interface/video_player_platform_interface.dart'; part 'live_photo_viewer.g.dart'; class LivePhotoViewer extends StatefulWidget { const LivePhotoViewer({ super.key, required this.account, required this.file, this.onLoaded, this.onLoadFailure, this.onHeightChanged, this.canPlay = true, this.livePhotoType, }); @override State createState() => _LivePhotoViewerState(); final Account account; final FileDescriptor file; final VoidCallback? onLoaded; final VoidCallback? onLoadFailure; final ValueChanged? onHeightChanged; final bool canPlay; final LivePhotoType? livePhotoType; } @npLog class _LivePhotoViewerState extends State { @override void initState() { super.initState(); _getVideoUrl().then((url) { if (mounted) { _initController(url); } }).onError((e, stacktrace) { _log.shout("[initState] Failed while _getVideoUrl", e, stacktrace); SnackBarManager().showSnackBarForException(e); widget.onLoadFailure?.call(); }); _lifecycleListener = AppLifecycleListener(onShow: () { if (_controller.value.isInitialized) { _controller.pause(); } }); } @override void dispose() { _lifecycleListener.dispose(); _controller.removeListener(_onControllerChanged); _controllerValue?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { Widget content; if (_isControllerInitialized && _controller.value.isInitialized) { content = _buildPlayer(context); } else { content = _PlaceHolderView( account: widget.account, file: widget.file, ); } 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(), }, livePhotoType: widget.livePhotoType, ); await _controller.initialize(); await _controller.setVolume(0); await _controller.setLooping(true); widget.onLoaded?.call(); WidgetsBinding.instance.addPostFrameCallback((_) { if (_key.currentContext != null) { widget.onHeightChanged?.call(_key.currentContext!.size!.height); } }); _controller.addListener(_onControllerChanged); setState(() { _isControllerInitialized = true; }); await _controller.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) { _log.info("Pause playback"); WidgetsBinding.instance.addPostFrameCallback((_) { _controller.pause(); }); } else if (!_controller.value.isPlaying && widget.canPlay) { _log.info("Resume playback"); WidgetsBinding.instance.addPostFrameCallback((_) { _controller.play(); }); } return Center( child: Stack( fit: StackFit.expand, children: [ Center( child: AspectRatio( key: _key, aspectRatio: _controller.value.aspectRatio, child: IgnorePointer( child: VideoPlayer(_controller), ), ), ), if (!_isLoaded) ...[ _PlaceHolderView( account: widget.account, file: widget.file, ), ], ], ), ); } Future _getVideoUrl() async { if (getRawPlatform() == NpPlatform.web) { return RequestPublicLink()(widget.account, widget.file); } else { return api_util.getFileUrl(widget.account, widget.file); } } void _onControllerChanged() { if (!_controller.value.isInitialized) { return; } if (_controller.value.isPlaying != _isPlaying) { setState(() { _isPlaying = !_isPlaying; _isLoaded = true; }); } } VideoPlayerController get _controller => _controllerValue!; final _key = GlobalKey(); bool _isControllerInitialized = false; VideoPlayerController? _controllerValue; var _isPlaying = false; var _isLoaded = false; late final AppLifecycleListener _lifecycleListener; } class _PlaceHolderView extends StatelessWidget { const _PlaceHolderView({ required this.account, required this.file, }); @override Widget build(BuildContext context) { return Stack( fit: StackFit.expand, children: [ mod.CachedNetworkImage( fit: BoxFit.contain, cacheManager: LargeImageCacheManager.inst, imageUrl: api_util.getFilePreviewUrl( account, file, width: k.photoLargeSize, height: k.photoLargeSize, isKeepAspectRatio: true, ), httpHeaders: { "Authorization": AuthUtil.fromAccount(account).toHeaderValue(), }, fadeInDuration: const Duration(), filterQuality: FilterQuality.high, imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, imageBuilder: (context, child, imageProvider) { const SizeChangedLayoutNotification().dispatch(context); return child; }, ), ColoredBox(color: Colors.black.withOpacity(.7)), const Center(child: _ProgressIndicator()), ], ); } final Account account; final FileDescriptor file; } class _ProgressIndicator extends StatefulWidget { const _ProgressIndicator(); @override State createState() => _ProgressIndicatorState(); } class _ProgressIndicatorState extends State<_ProgressIndicator> with TickerProviderStateMixin { @override void initState() { super.initState(); animationController.repeat(); } @override void dispose() { animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Stack( alignment: Alignment.center, children: [ RotationTransition( turns: animationController .drive(CurveTween(curve: Curves.easeInOutCubic)), filterQuality: FilterQuality.high, child: Icon( Icons.motion_photos_on_outlined, size: 64, color: Theme.of(context).colorScheme.secondary, ), ), Icon( Icons.play_arrow_rounded, size: 48, color: Theme.of(context).colorScheme.primary, ), ], ); } late final animationController = AnimationController( vsync: this, duration: const Duration(seconds: 1), ); }