diff --git a/lib/entity/file_util.dart b/lib/entity/file_util.dart index 6b68d866..157dc0c4 100644 --- a/lib/entity/file_util.dart +++ b/lib/entity/file_util.dart @@ -1,4 +1,5 @@ import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/platform/k.dart' as platform_k; bool isSupportedFormat(File file) => _supportedFormatMimes.contains(file.contentType); @@ -6,9 +7,14 @@ bool isSupportedFormat(File file) => bool isSupportedImageFormat(File file) => isSupportedFormat(file) && file.contentType?.startsWith("image/") == true; +bool isSupportedVideoFormat(File file) => + isSupportedFormat(file) && file.contentType?.startsWith("video/") == true; + const _supportedFormatMimes = [ "image/jpeg", "image/png", "image/webp", "image/heic", + // video player currently doesn't work on web + if (!platform_k.isWeb) "video/mp4", ]; diff --git a/lib/widget/album_viewer.dart b/lib/widget/album_viewer.dart index 9eec2551..d96b355a 100644 --- a/lib/widget/album_viewer.dart +++ b/lib/widget/album_viewer.dart @@ -263,15 +263,30 @@ class _AlbumViewerState extends State .where((element) => file_util.isSupportedFormat(element)) .sorted(compareFileDateTimeDescending); - itemStreamListItems = _backingFiles.mapWithIndex((i, e) { - final previewUrl = api_util.getFilePreviewUrl(widget.account, e, - width: _thumbSize, height: _thumbSize); - return _ImageListItem( - account: widget.account, - previewUrl: previewUrl, - onTap: () => _onItemTap(i), - ); - }); + itemStreamListItems = () sync* { + for (int i = 0; i < _backingFiles.length; ++i) { + final f = _backingFiles[i]; + + final previewUrl = api_util.getFilePreviewUrl(widget.account, f, + width: _thumbSize, height: _thumbSize); + if (file_util.isSupportedImageFormat(f)) { + yield _ImageListItem( + account: widget.account, + previewUrl: previewUrl, + onTap: () => _onItemTap(i), + ); + } else if (file_util.isSupportedVideoFormat(f)) { + yield _VideoListItem( + account: widget.account, + previewUrl: previewUrl, + onTap: () => _onItemTap(i), + ); + } else { + _log.shout( + "[_transformItems] Unsupported file format: ${f.contentType}"); + } + } + }(); } int get _thumbSize { @@ -315,3 +330,22 @@ class _ImageListItem extends SelectableItemStreamListItem { final Account account; final String previewUrl; } + +class _VideoListItem extends SelectableItemStreamListItem { + _VideoListItem({ + @required this.account, + @required this.previewUrl, + VoidCallback onTap, + }) : super(onTap: onTap, isSelectable: true); + + @override + buildWidget(BuildContext context) { + return PhotoListVideo( + account: account, + previewUrl: previewUrl, + ); + } + + final Account account; + final String previewUrl; +} diff --git a/lib/widget/home_photos.dart b/lib/widget/home_photos.dart index f9c1fba5..a05a9dc8 100644 --- a/lib/widget/home_photos.dart +++ b/lib/widget/home_photos.dart @@ -361,12 +361,24 @@ class _HomePhotosState extends State final previewUrl = api_util.getFilePreviewUrl(widget.account, f, width: _thumbSize, height: _thumbSize); - yield _ImageListItem( - file: f, - account: widget.account, - previewUrl: previewUrl, - onTap: () => _onItemTap(i), - ); + if (file_util.isSupportedImageFormat(f)) { + yield _ImageListItem( + file: f, + account: widget.account, + previewUrl: previewUrl, + onTap: () => _onItemTap(i), + ); + } else if (file_util.isSupportedVideoFormat(f)) { + yield _VideoListItem( + file: f, + account: widget.account, + previewUrl: previewUrl, + onTap: () => _onItemTap(i), + ); + } else { + _log.shout( + "[_transformItems] Unsupported file format: ${f.contentType}"); + } } }(); } @@ -485,6 +497,26 @@ class _ImageListItem extends _FileListItem { final String previewUrl; } +class _VideoListItem extends _FileListItem { + _VideoListItem({ + @required File file, + @required this.account, + @required this.previewUrl, + VoidCallback onTap, + }) : super(file: file, onTap: onTap); + + @override + buildWidget(BuildContext context) { + return PhotoListVideo( + account: account, + previewUrl: previewUrl, + ); + } + + final Account account; + final String previewUrl; +} + extension on DateTime { String toDailySubtitleString() { final format = DateFormat(DateFormat.YEAR_MONTH_DAY); diff --git a/lib/widget/photo_list_item.dart b/lib/widget/photo_list_item.dart index cbaa467c..384460ed 100644 --- a/lib/widget/photo_list_item.dart +++ b/lib/widget/photo_list_item.dart @@ -47,3 +47,47 @@ class PhotoListImage extends StatelessWidget { final Account account; final String previewUrl; } + +class PhotoListVideo extends StatelessWidget { + const PhotoListVideo({ + Key key, + @required this.account, + @required this.previewUrl, + }) : super(key: key); + + @override + build(BuildContext context) { + return FittedBox( + clipBehavior: Clip.hardEdge, + fit: BoxFit.cover, + child: CachedNetworkImage( + imageUrl: previewUrl, + httpHeaders: { + "Authorization": Api.getAuthorizationHeaderValue(account), + }, + fadeInDuration: const Duration(), + filterQuality: FilterQuality.high, + errorWidget: (context, url, error) { + // no preview for this video. Normal since video preview is disabled + // by default + return Container( + color: AppTheme.getListItemBackgroundColor(context), + width: 128, + height: 128, + child: Center( + child: Icon( + Icons.videocam, + size: 56, + color: Colors.white.withOpacity(.8), + ), + ), + ); + }, + imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, + ), + ); + } + + final Account account; + final String previewUrl; +} diff --git a/lib/widget/video_viewer.dart b/lib/widget/video_viewer.dart new file mode 100644 index 00000000..bf3f93ba --- /dev/null +++ b/lib/widget/video_viewer.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/widget/animated_visibility.dart'; +import 'package:video_player/video_player.dart'; + +class VideoViewer extends StatefulWidget { + VideoViewer({ + @required this.account, + @required this.file, + this.onLoaded, + this.onHeightChanged, + this.onPlay, + this.onPause, + this.isControlVisible = false, + this.canPlay = true, + }); + + @override + createState() => _VideoViewerState(); + + final Account account; + final File file; + final VoidCallback onLoaded; + final void Function(double height) onHeightChanged; + final VoidCallback onPlay; + final VoidCallback onPause; + final bool isControlVisible; + final bool canPlay; +} + +class _VideoViewerState extends State { + @override + initState() { + super.initState(); + _controller = VideoPlayerController.network( + api_util.getFileUrl(widget.account, widget.file), + httpHeaders: { + "Authorization": Api.getAuthorizationHeaderValue(widget.account), + }, + )..initialize().then((_) { + widget.onLoaded?.call(); + setState(() {}); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_key.currentContext != null) { + widget.onHeightChanged?.call(_key.currentContext.size.height); + } + }); + }); + _controller.addListener(_onControllerChanged); + } + + @override + build(BuildContext context) { + Widget content; + if (_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, + ); + } + + @override + dispose() { + super.dispose(); + _controller?.dispose(); + } + + 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: 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(), + ), + ), + ), + ), + ), + Container( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.only( + bottom: kToolbarHeight + 16, left: 16, right: 16), + child: AnimatedVisibility( + opacity: widget.isControlVisible ? 1.0 : 0.0, + duration: k.animationDurationNormal, + child: VideoProgressIndicator( + _controller, + allowScrubbing: true, + colors: VideoProgressColors( + backgroundColor: Colors.white24, + bufferedColor: Colors.white38, + playedColor: Colors.white, + ), + ), + ), + ), + ), + ), + ], + ); + } + + void _onPlayPressed() { + if (_isFinished) { + _controller.seekTo(const Duration()).then((_) { + setState(() { + _isFinished = false; + _play(); + }); + }); + } else { + setState(() { + _play(); + }); + } + } + + void _onPausePressed() { + setState(() { + _pause(); + }); + } + + void _onControllerChanged() { + if (!_controller.value.isInitialized) { + return; + } + if (!_isFinished) { + if (_controller.value.position == _controller.value.duration) { + setState(() { + _isFinished = true; + _pause(); + }); + } + } + } + + void _play() { + if (widget.canPlay) { + _controller.play(); + widget.onPlay?.call(); + } + } + + void _pause() { + _controller.pause(); + widget.onPause?.call(); + } + + final _key = GlobalKey(); + VideoPlayerController _controller; + var _isFinished = false; +} diff --git a/lib/widget/viewer.dart b/lib/widget/viewer.dart index 686beb98..9eca4c71 100644 --- a/lib/widget/viewer.dart +++ b/lib/widget/viewer.dart @@ -9,6 +9,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; @@ -21,6 +22,7 @@ import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/remove.dart'; import 'package:nc_photos/widget/animated_visibility.dart'; import 'package:nc_photos/widget/image_viewer.dart'; +import 'package:nc_photos/widget/video_viewer.dart'; import 'package:nc_photos/widget/viewer_detail_pane.dart'; class ViewerArguments { @@ -374,6 +376,19 @@ class _ViewerState extends State { } Widget _buildItemView(BuildContext context, int index) { + final file = widget.streamFiles[index]; + if (file_util.isSupportedImageFormat(file)) { + return _buildImageView(context, index); + } else if (file_util.isSupportedVideoFormat(file)) { + return _buildVideoView(context, index); + } else { + _log.shout("[_buildItemView] Unknown file format: ${file.contentType}"); + _pageStates[index].itemHeight = 0; + return Container(); + } + } + + Widget _buildImageView(BuildContext context, int index) { return ImageViewer( account: widget.account, file: widget.streamFiles[index], @@ -393,6 +408,19 @@ class _ViewerState extends State { ); } + Widget _buildVideoView(BuildContext context, int index) { + return VideoViewer( + account: widget.account, + file: widget.streamFiles[index], + onLoaded: () => _onVideoLoaded(index), + onHeightChanged: (height) => _updateItemHeight(index, height), + onPlay: _onVideoPlay, + onPause: _onVideoPause, + isControlVisible: _isShowAppBar && !_isDetailPaneActive, + canPlay: !_isDetailPaneActive, + ); + } + bool _onPageContentScrolled(ScrollNotification notification, int index) { if (!_canOpenDetailPane()) { return false; @@ -432,11 +460,15 @@ class _ViewerState extends State { _log.info("[_onImageLoaded] Pre-loading nearby images"); if (index > 0) { final prevFile = widget.streamFiles[index - 1]; - ImageViewer.preloadImage(widget.account, prevFile); + if (file_util.isSupportedImageFormat(prevFile)) { + ImageViewer.preloadImage(widget.account, prevFile); + } } if (index + 1 < widget.streamFiles.length) { final nextFile = widget.streamFiles[index + 1]; - ImageViewer.preloadImage(widget.account, nextFile); + if (file_util.isSupportedImageFormat(nextFile)) { + ImageViewer.preloadImage(widget.account, nextFile); + } } setState(() { _pageStates[index].hasLoaded = true; @@ -444,6 +476,27 @@ class _ViewerState extends State { } } + void _onVideoLoaded(int index) { + if (_pageController.page.round() == index && + !_pageStates[index].hasLoaded) { + setState(() { + _pageStates[index].hasLoaded = true; + }); + } + } + + void _onVideoPlay() { + setState(() { + _setShowActionBar(false); + }); + } + + void _onVideoPause() { + setState(() { + _setShowActionBar(true); + }); + } + /// Called when the page is being built for the first time void _onCreateNewPage(BuildContext context, int index) { _pageStates[index] = _PageState(ScrollController( diff --git a/pubspec.lock b/pubspec.lock index 90a9f977..fc313664 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -171,6 +171,13 @@ packages: url: "https://gitlab.com/nkming2/exifdart.git" source: git version: "1.1.0" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" ffi: dependency: transitive description: @@ -230,6 +237,11 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.3.4" + flutter_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_web_plugins: dependency: transitive description: flutter @@ -383,7 +395,7 @@ packages: name: node_preamble url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "1.4.13" octo_image: dependency: transitive description: @@ -682,21 +694,21 @@ packages: name: test url: "https://pub.dartlang.org" source: hosted - version: "1.16.8" + version: "1.16.5" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.3.0" + version: "0.2.19" test_core: dependency: transitive description: name: test_core url: "https://pub.dartlang.org" source: hosted - version: "0.3.19" + version: "0.3.15" tuple: dependency: "direct main" description: @@ -767,6 +779,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + video_player: + dependency: "direct main" + description: + name: video_player + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d0ff82b7..b9217b62 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,10 +62,11 @@ dependencies: synchronized: ^3.0.0 tuple: ^2.0.0 url_launcher: ^6.0.3 + video_player: ^2.1.1 xml: ^5.0.2 dev_dependencies: - test: ^1.16.8 + test: any # flutter_test: # sdk: flutter # integration_test: