Support playing live photos taken with a Google Pixel

This commit is contained in:
Ming Ming 2024-06-08 01:33:27 +08:00
parent 4dc2530c01
commit 654f6c0a43
14 changed files with 425 additions and 24 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

View file

@ -1,2 +1,3 @@
const icAddCollectionsOutlined24 =
"assets/ic_add_collections_outlined_24dp.png";
const icMotionPhotosPlay24dp = "assets/ic_motion_photos_play_24dp.png";

View file

@ -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;
}
}

View file

@ -25,7 +25,7 @@ class HorizontalPageViewer extends StatefulWidget {
final HorizontalPageViewerController controller;
final double viewportFraction;
final bool canSwitchPage;
final ValueChanged<int>? onPageChanged;
final void Function(int from, int to)? onPageChanged;
}
class _HorizontalPageViewerState extends State<HorizontalPageViewer> {

View file

@ -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<StatefulWidget> createState() => _LivePhotoViewerState();
final Account account;
final FileDescriptor file;
final VoidCallback? onLoaded;
final VoidCallback? onLoadFailure;
final ValueChanged<double>? onHeightChanged;
final bool canPlay;
final LivePhotoType? livePhotoType;
}
@npLog
class _LivePhotoViewerState extends State<LivePhotoViewer> {
@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<void> _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<String> _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<StatefulWidget> 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),
);
}

View file

@ -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");
}

View file

@ -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<int>? onPageChanged;
final void Function(int from, int to)? onPageChanged;
int _prevPage;
}

View file

@ -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;
}

View file

@ -110,8 +110,8 @@ class _VideoViewerState extends State<VideoViewer>
Future<void> _initController(String url) async {
try {
_controllerValue = VideoPlayerController.network(
url,
_controllerValue = VideoPlayerController.networkUrl(
Uri.parse(url),
httpHeaders: {
"Authorization": AuthUtil.fromAccount(widget.account).toHeaderValue(),
},

View file

@ -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<Viewer>
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<Viewer>
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<Viewer>
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<Viewer>
);
}
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<Viewer>
}
}
void _onPlayMotionPhotosPressed(int index) {
setState(() {
_pageStates[index]!.shouldPlayLivePhoto = true;
});
}
void _onPauseMotionPhotosPressed(int index) {
setState(() {
_pageStates[index]!.shouldPlayLivePhoto = false;
});
}
Future<void> _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 {

View file

@ -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:

View file

@ -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