MP4 video support

This commit is contained in:
Ming Ming 2021-05-06 19:36:20 +08:00
parent 6e793ff14f
commit 5ca19c6c4f
8 changed files with 420 additions and 22 deletions

View file

@ -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",
];

View file

@ -263,15 +263,30 @@ class _AlbumViewerState extends State<AlbumViewer>
.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;
}

View file

@ -361,12 +361,24 @@ class _HomePhotosState extends State<HomePhotos>
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);

View file

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

View file

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

View file

@ -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<Viewer> {
}
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<Viewer> {
);
}
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<Viewer> {
_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<Viewer> {
}
}
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(

View file

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

View file

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