diff --git a/app/assets/2.0x/ic_motion_photos_play_24dp.png b/app/assets/2.0x/ic_motion_photos_play_24dp.png new file mode 100644 index 00000000..ca44a7ab Binary files /dev/null and b/app/assets/2.0x/ic_motion_photos_play_24dp.png differ diff --git a/app/assets/3.0x/ic_motion_photos_play_24dp.png b/app/assets/3.0x/ic_motion_photos_play_24dp.png new file mode 100644 index 00000000..4f11e8b2 Binary files /dev/null and b/app/assets/3.0x/ic_motion_photos_play_24dp.png differ diff --git a/app/assets/ic_motion_photos_play_24dp.png b/app/assets/ic_motion_photos_play_24dp.png new file mode 100644 index 00000000..c7a84c82 Binary files /dev/null and b/app/assets/ic_motion_photos_play_24dp.png differ diff --git a/app/lib/asset.dart b/app/lib/asset.dart index 1f7893cb..a130ae87 100644 --- a/app/lib/asset.dart +++ b/app/lib/asset.dart @@ -1,2 +1,3 @@ const icAddCollectionsOutlined24 = "assets/ic_add_collections_outlined_24dp.png"; +const icMotionPhotosPlay24dp = "assets/ic_motion_photos_play_24dp.png"; diff --git a/app/lib/live_photo_util.dart b/app/lib/live_photo_util.dart new file mode 100644 index 00000000..1c8cf3aa --- /dev/null +++ b/app/lib/live_photo_util.dart @@ -0,0 +1,13 @@ +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +LivePhotoType? getLivePhotoTypeFromFile(FileDescriptor file) { + final filenameL = file.filename.toLowerCase(); + if (filenameL.startsWith("pxl_") && filenameL.endsWith(".mp.jpg")) { + return LivePhotoType.googleMp; + } else if (filenameL.startsWith("mvimg_") && filenameL.endsWith(".jpg")) { + return LivePhotoType.googleMvimg; + } else { + return null; + } +} diff --git a/app/lib/widget/horizontal_page_viewer.dart b/app/lib/widget/horizontal_page_viewer.dart index 6b48df36..78d6ef25 100644 --- a/app/lib/widget/horizontal_page_viewer.dart +++ b/app/lib/widget/horizontal_page_viewer.dart @@ -25,7 +25,7 @@ class HorizontalPageViewer extends StatefulWidget { final HorizontalPageViewerController controller; final double viewportFraction; final bool canSwitchPage; - final ValueChanged? onPageChanged; + final void Function(int from, int to)? onPageChanged; } class _HorizontalPageViewerState extends State { diff --git a/app/lib/widget/live_photo_viewer.dart b/app/lib/widget/live_photo_viewer.dart new file mode 100644 index 00000000..4c1475df --- /dev/null +++ b/app/lib/widget/live_photo_viewer.dart @@ -0,0 +1,290 @@ +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/exception_util.dart' as exception_util; +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().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(e)), + duration: k.snackBarDurationNormal, + )); + 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().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(e)), + duration: k.snackBarDurationNormal, + )); + 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), + ); +} diff --git a/app/lib/widget/live_photo_viewer.g.dart b/app/lib/widget/live_photo_viewer.g.dart new file mode 100644 index 00000000..44f0272e --- /dev/null +++ b/app/lib/widget/live_photo_viewer.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'live_photo_viewer.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_LivePhotoViewerStateNpLog on _LivePhotoViewerState { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.live_photo_viewer._LivePhotoViewerState"); +} diff --git a/app/lib/widget/page_changed_listener.dart b/app/lib/widget/page_changed_listener.dart index f3aeeac5..9e1084d3 100644 --- a/app/lib/widget/page_changed_listener.dart +++ b/app/lib/widget/page_changed_listener.dart @@ -10,13 +10,13 @@ class PageChangedListener { if (pageController.hasClients) { final page = pageController.page!.round(); if (page != _prevPage) { - onPageChanged?.call(page); + onPageChanged?.call(_prevPage, page); _prevPage = page; } } } final PageController pageController; - final ValueChanged? onPageChanged; + final void Function(int from, int to)? onPageChanged; int _prevPage; } diff --git a/app/lib/widget/png_icon.dart b/app/lib/widget/png_icon.dart new file mode 100644 index 00000000..2215a5c2 --- /dev/null +++ b/app/lib/widget/png_icon.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class PngIcon extends StatelessWidget { + const PngIcon( + this.asset, { + super.key, + this.size = 24, + }); + + @override + Widget build(BuildContext context) { + return Image.asset(asset, width: size, height: size); + } + + final String asset; + final double size; +} diff --git a/app/lib/widget/video_viewer.dart b/app/lib/widget/video_viewer.dart index 9ed13a9b..6ae10867 100644 --- a/app/lib/widget/video_viewer.dart +++ b/app/lib/widget/video_viewer.dart @@ -110,8 +110,8 @@ class _VideoViewerState extends State Future _initController(String url) async { try { - _controllerValue = VideoPlayerController.network( - url, + _controllerValue = VideoPlayerController.networkUrl( + Uri.parse(url), httpHeaders: { "Authorization": AuthUtil.fromAccount(widget.account).toHeaderValue(), }, diff --git a/app/lib/widget/viewer.dart b/app/lib/widget/viewer.dart index 58f06c48..511970f8 100644 --- a/app/lib/widget/viewer.dart +++ b/app/lib/widget/viewer.dart @@ -11,6 +11,7 @@ import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/asset.dart'; import 'package:nc_photos/controller/account_controller.dart'; import 'package:nc_photos/controller/collection_items_controller.dart'; import 'package:nc_photos/di_container.dart'; @@ -23,6 +24,7 @@ import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/pref.dart'; import 'package:nc_photos/flutter_util.dart'; import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/live_photo_util.dart'; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/share_handler.dart'; @@ -34,6 +36,8 @@ import 'package:nc_photos/widget/horizontal_page_viewer.dart'; import 'package:nc_photos/widget/image_editor.dart'; import 'package:nc_photos/widget/image_enhancer.dart'; import 'package:nc_photos/widget/image_viewer.dart'; +import 'package:nc_photos/widget/live_photo_viewer.dart'; +import 'package:nc_photos/widget/png_icon.dart'; import 'package:nc_photos/widget/slideshow_dialog.dart'; import 'package:nc_photos/widget/slideshow_viewer.dart'; import 'package:nc_photos/widget/video_viewer.dart'; @@ -43,6 +47,7 @@ import 'package:nc_photos/widget/viewer_mixin.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/or_null.dart'; import 'package:np_platform_util/np_platform_util.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; part 'viewer.g.dart'; @@ -164,8 +169,10 @@ class _ViewerState extends State controller: _viewerController, viewportFraction: _viewportFraction, canSwitchPage: _canSwitchPage(), - onPageChanged: (_) { - setState(() {}); + onPageChanged: (from, to) { + setState(() { + _pageStates[from]?.shouldPlayLivePhoto = false; + }); }, ), if (_isShowAppBar) @@ -206,6 +213,17 @@ class _ViewerState extends State centerTitle: isCentered, actions: [ if (!_isDetailPaneActive && _canOpenDetailPane()) ...[ + if (getLivePhotoTypeFromFile(file) != null) + if (_pageStates[index]?.shouldPlayLivePhoto ?? false) + IconButton( + icon: const Icon(Icons.motion_photos_pause_outlined), + onPressed: () => _onPauseMotionPhotosPressed(index), + ) + else + IconButton( + icon: const PngIcon(icMotionPhotosPlay24dp), + onPressed: () => _onPlayMotionPhotosPressed(index), + ), (_pageStates[index]?.favoriteOverride ?? file.fdIsFavorite) == true ? IconButton( icon: const Icon(Icons.star), @@ -361,6 +379,15 @@ class _ViewerState extends State Widget _buildItemView(BuildContext context, int index) { final file = _streamFilesView[index]; if (file_util.isSupportedImageFormat(file)) { + final shouldPlayLivePhoto = _pageStates[index]!.shouldPlayLivePhoto; + if (shouldPlayLivePhoto) { + final livePhotoType = getLivePhotoTypeFromFile(file); + if (livePhotoType != null) { + return _buildLivePhotoView(context, index, livePhotoType); + } else { + _log.warning("[_buildItemView] Not a live photo"); + } + } return _buildImageView(context, index); } else if (file_util.isSupportedVideoFormat(file)) { return _buildVideoView(context, index); @@ -404,6 +431,25 @@ class _ViewerState extends State ); } + Widget _buildLivePhotoView( + BuildContext context, int index, LivePhotoType livePhotoType) { + return LivePhotoViewer( + account: widget.account, + file: _streamFilesView[index], + onLoaded: () => _onVideoLoaded(index), + onHeightChanged: (height) => _updateItemHeight(index, height), + canPlay: !_isDetailPaneActive, + livePhotoType: livePhotoType, + onLoadFailure: () { + if (mounted) { + setState(() { + _pageStates[index]!.shouldPlayLivePhoto = false; + }); + } + }, + ); + } + bool _onPageContentScrolled(ScrollNotification notification, int index) { if (!_canOpenDetailPane()) { return false; @@ -538,6 +584,18 @@ class _ViewerState extends State } } + void _onPlayMotionPhotosPressed(int index) { + setState(() { + _pageStates[index]!.shouldPlayLivePhoto = true; + }); + } + + void _onPauseMotionPhotosPressed(int index) { + setState(() { + _pageStates[index]!.shouldPlayLivePhoto = false; + }); + } + Future _onFavoritePressed(int index) async { if (_pageStates[index]!.isProcessingFavorite) { _log.fine("[_onFavoritePressed] Process ongoing, ignored"); @@ -931,6 +989,7 @@ class _PageState { bool isProcessingFavorite = false; bool? favoriteOverride; + bool shouldPlayLivePhoto = false; } class _AppBarTitle extends StatelessWidget { diff --git a/app/pubspec.lock b/app/pubspec.lock index 26a33005..45510a7e 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1776,36 +1776,37 @@ packages: dependency: "direct main" description: path: "packages/video_player/video_player" - ref: "video_player-v2.4.5-nc-photos-2" - resolved-ref: b5c28c21f29f09b623900d5a8cc88a70da29de3a + ref: "video_player-v2.8.6-nc-photos-1" + resolved-ref: ea754fd61b8bb3c431bd33d1a07709b6f501345c url: "https://gitlab.com/nc-photos/flutter-plugins" source: git - version: "2.4.5" + version: "2.8.6" video_player_android: dependency: "direct overridden" description: path: "packages/video_player/video_player_android" - ref: "video_player-v2.4.5-nc-photos-2" - resolved-ref: b5c28c21f29f09b623900d5a8cc88a70da29de3a + ref: "video_player-v2.8.6-nc-photos-1" + resolved-ref: ea754fd61b8bb3c431bd33d1a07709b6f501345c url: "https://gitlab.com/nc-photos/flutter-plugins" source: git - version: "2.3.6" + version: "2.4.12" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "90468226c8687adf7b567d9bb42c25588783c4d30509af1fbd663b2dd049f700" + sha256: d1e9a824f2b324000dc8fb2dcb2a3285b6c1c7c487521c63306cc5b394f68a7c url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.6.1" video_player_platform_interface: - dependency: transitive + dependency: "direct main" description: - name: video_player_platform_interface - sha256: "318a6d20577e1c78cf0bf40670883cc571ea860c72a4f7426d7dacce4bdd4343" - url: "https://pub.dev" - source: hosted - version: "5.1.4" + path: "packages/video_player/video_player_platform_interface" + ref: "video_player-v2.8.6-nc-photos-1" + resolved-ref: ea754fd61b8bb3c431bd33d1a07709b6f501345c + url: "https://gitlab.com/nc-photos/flutter-plugins" + source: git + version: "6.2.2" video_player_web: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 67a9147e..88b4c26c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -153,7 +153,8 @@ dependencies: tuple: ^2.0.2 url_launcher: ^6.2.6 uuid: ^3.0.7 - video_player: 2.4.5 + video_player: + video_player_platform_interface: visibility_detector: ^0.4.0+2 wakelock_plus: ^1.1.1 woozy_search: ^2.0.3 @@ -162,13 +163,18 @@ dependency_overrides: video_player: git: url: https://gitlab.com/nc-photos/flutter-plugins - ref: video_player-v2.4.5-nc-photos-2 + ref: video_player-v2.8.6-nc-photos-1 path: packages/video_player/video_player video_player_android: git: url: https://gitlab.com/nc-photos/flutter-plugins - ref: video_player-v2.4.5-nc-photos-2 + ref: video_player-v2.8.6-nc-photos-1 path: packages/video_player/video_player_android + video_player_platform_interface: + git: + url: https://gitlab.com/nc-photos/flutter-plugins + ref: video_player-v2.8.6-nc-photos-1 + path: packages/video_player/video_player_platform_interface dev_dependencies: test: ^1.22.1