mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-02-24 18:38:48 +01:00
MP4 video support
This commit is contained in:
parent
6e793ff14f
commit
5ca19c6c4f
8 changed files with 420 additions and 22 deletions
|
@ -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",
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
195
lib/widget/video_viewer.dart
Normal file
195
lib/widget/video_viewer.dart
Normal 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;
|
||||
}
|
|
@ -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(
|
||||
|
|
41
pubspec.lock
41
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:
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue