From 0d879c41f4d111da12afd2ec418ac282cf625547 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 17 Jun 2021 12:27:07 +0900 Subject: [PATCH] video: capture frame --- .../aves/channel/calls/ImageFileHandler.kt | 26 +++ .../aves/metadata/ExifInterfaceHelper.kt | 2 +- .../aves/metadata/MetadataExtractorHelper.kt | 2 +- .../aves/model/provider/ImageProvider.kt | 112 +++++++++++- .../thibault/aves/utils/StorageUtils.kt | 18 +- lib/l10n/app_en.arb | 2 + lib/l10n/app_ko.arb | 1 + lib/model/actions/video_actions.dart | 2 +- lib/model/source/album.dart | 1 + lib/services/image_file_service.dart | 31 ++++ lib/utils/android_file_utils.dart | 9 +- .../common/action_mixins/size_aware.dart | 61 +++++-- lib/widgets/common/identity/aves_icons.dart | 1 + lib/widgets/viewer/entry_action_delegate.dart | 9 +- lib/widgets/viewer/entry_viewer_stack.dart | 18 +- lib/widgets/viewer/overlay/bottom/video.dart | 87 +--------- lib/widgets/viewer/video/controller.dart | 4 +- lib/widgets/viewer/video/fijkplayer.dart | 7 +- lib/widgets/viewer/video_action_delegate.dart | 163 ++++++++++++++++++ .../viewer/visual/entry_page_view.dart | 5 + lib/widgets/viewer/visual/subtitle.dart | 30 ++-- 21 files changed, 447 insertions(+), 144 deletions(-) create mode 100644 lib/widgets/viewer/video_action_delegate.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index b7decb934..aded25222 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -14,6 +14,7 @@ import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -32,6 +33,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { "getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) } "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getThumbnail) } "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getRegion) } + "captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::captureFrame) } "rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) } "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) } "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) } @@ -131,6 +133,30 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { } } + private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) { + val uri = call.argument("uri")?.let { Uri.parse(it) } + val desiredName = call.argument("desiredName") + val exifFields = call.argument("exif") ?: HashMap() + val bytes = call.argument("bytes") + var destinationDir = call.argument("destinationPath") + if (uri == null || desiredName == null || bytes == null || destinationDir == null) { + result.error("captureFrame-args", "failed because of missing arguments", null) + return + } + + val provider = getProvider(uri) + if (provider == null) { + result.error("captureFrame-provider", "failed to find provider for uri=$uri", null) + return + } + + destinationDir = ensureTrailingSeparator(destinationDir) + provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = result.success(fields) + override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame", throwable.message) + }) + } + private suspend fun rename(call: MethodCall, result: MethodChannel.Result) { val entryMap = call.argument("entry") val newName = call.argument("newName") diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt index dd449e012..07a0c45dc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt @@ -18,7 +18,7 @@ import kotlin.math.roundToLong object ExifInterfaceHelper { private val LOG_TAG = LogUtils.createTag() - private val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd hh:mm:ss", Locale.ROOT) + val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT) private const val precisionErrorTolerance = 1e-10 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index 33de19899..3eabccfd5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -8,7 +8,7 @@ import java.util.* object MetadataExtractorHelper { const val PNG_TIME_DIR_NAME = "PNG-tIME" - val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd hh:mm:ss", Locale.ROOT) + val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT) // extensions diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index f5337cf97..ed696ab0e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -17,6 +17,7 @@ import com.bumptech.glide.request.RequestOptions import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.decoder.MultiTrackImage import deckers.thibault.aves.decoder.TiffImage +import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.MultiPage import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.ExifOrientationOp @@ -97,6 +98,7 @@ abstract class ImageProvider { } } + @Suppress("BlockingMethodInNonBlockingContext") private suspend fun exportSingleByTreeDocAndScan( context: Context, sourceEntry: AvesEntry, @@ -109,9 +111,7 @@ abstract class ImageProvider { val pageId = sourceEntry.pageId var desiredNameWithoutExtension = if (sourceEntry.path != null) { - val sourcePath = sourceEntry.path - val sourceFile = File(sourcePath) - val sourceFileName = sourceFile.name + val sourceFileName = File(sourceEntry.path).name sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") } else { sourceUri.lastPathSegment!! @@ -130,13 +130,11 @@ abstract class ImageProvider { // but in order to open an output stream to it, we need to use a `SingleDocumentFile` // through a document URI, not a tree URI // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first - @Suppress("BlockingMethodInNonBlockingContext") val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension) val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) if (isVideo(sourceMimeType)) { val sourceDocFile = DocumentFileCompat.fromSingleUri(context, sourceUri) - @Suppress("BlockingMethodInNonBlockingContext") sourceDocFile.copyTo(destinationDocFile) } else { val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) { @@ -159,14 +157,12 @@ abstract class ImageProvider { .load(model) .submit() try { - @Suppress("BlockingMethodInNonBlockingContext") var bitmap = target.get() if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) } bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId") - @Suppress("BlockingMethodInNonBlockingContext") destinationDocFile.openOutputStream().use { output -> if (exportMimeType == MimeTypes.BMP) { BmpWriter.writeRGB24(bitmap, output) @@ -201,6 +197,108 @@ abstract class ImageProvider { return scanNewPath(context, destinationFullPath, exportMimeType) } + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun captureFrame( + context: Context, + desiredNameWithoutExtension: String, + exifFields: FieldMap, + bytes: ByteArray, + destinationDir: String, + callback: ImageOpCallback, + ) { + val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) + if (destinationDirDocFile == null) { + callback.onFailure(Exception("failed to create directory at path=$destinationDir")) + return + } + + val captureMimeType = MimeTypes.JPEG + val desiredFileName = desiredNameWithoutExtension + extensionFor(captureMimeType) + if (File(destinationDir, desiredFileName).exists()) { + callback.onFailure(Exception("file with name=$desiredFileName already exists in destination directory")) + return + } + + // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` + // but in order to open an output stream to it, we need to use a `SingleDocumentFile` + // through a document URI, not a tree URI + // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first + val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, desiredNameWithoutExtension) + val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) + + try { + if (exifFields.isEmpty()) { + destinationDocFile.openOutputStream().use { output -> + output.write(bytes) + } + } else { + val editableFile = File.createTempFile("aves", null).apply { + deleteOnExit() + outputStream().use { output -> + ByteArrayInputStream(bytes).use { imageInput -> + imageInput.copyTo(output) + } + } + } + + val exif = ExifInterface(editableFile) + + val rotationDegrees = exifFields["rotationDegrees"] as Int? + if (rotationDegrees != null) { + // when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)` + // in that case we explicitly set it to `normal` first + // because ExifInterface fails to rotate an image with undefined orientation + // as of androidx.exifinterface:exifinterface:1.3.0 + val currentOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) { + exif.setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL.toString()) + } + exif.rotate(rotationDegrees) + } + + val dateTimeMillis = (exifFields["dateTimeMillis"] as Number?)?.toLong() + if (dateTimeMillis != null) { + val dateString = ExifInterfaceHelper.DATETIME_FORMAT.format(Date(dateTimeMillis)) + exif.setAttribute(ExifInterface.TAG_DATETIME, dateString) + exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateString) + + val offsetInMinutes = TimeZone.getDefault().getOffset(dateTimeMillis) / 60000 + val offsetSign = if (offsetInMinutes < 0) "-" else "+" + val offsetHours = "${offsetInMinutes / 60}".padStart(2, '0') + val offsetMinutes = "${offsetInMinutes % 60}".padStart(2, '0') + val timeZoneString = "$offsetSign$offsetHours:$offsetMinutes" + exif.setAttribute(ExifInterface.TAG_OFFSET_TIME, timeZoneString) + exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, timeZoneString) + + val sub = dateTimeMillis % 1000 + if (sub > 0) { + val subString = sub.toString() + exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME, subString) + exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, subString) + } + } + + val latitude = (exifFields["latitude"] as Number?)?.toDouble() + val longitude = (exifFields["longitude"] as Number?)?.toDouble() + if (latitude != null && longitude != null) { + exif.setLatLong(latitude, longitude) + } + + exif.saveAttributes() + + // copy the edited temporary file back to the original + DocumentFileCompat.fromFile(editableFile).copyTo(destinationDocFile) + } + + val fileName = destinationDocFile.name + val destinationFullPath = destinationDir + fileName + val newFields = scanNewPath(context, destinationFullPath, captureMimeType) + callback.onSuccess(newFields) + } catch (e: Exception) { + callback.onFailure(e) + } + } + suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) { val oldFile = File(oldPath) val newFile = File(oldFile.parent, newFilename) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index dbcac15a7..9e80a925e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -54,18 +54,18 @@ object StorageUtils { private fun getPathStepIterator(context: Context, anyPath: String, root: String?): Iterator? { val rootLength = (root ?: getVolumePath(context, anyPath))?.length ?: return null - var filename: String? = null + var fileName: String? = null var relativePath: String? = null val lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1 if (lastSeparatorIndex > rootLength) { - filename = anyPath.substring(lastSeparatorIndex) + fileName = anyPath.substring(lastSeparatorIndex) relativePath = anyPath.substring(rootLength, lastSeparatorIndex) } relativePath ?: return null val pathSteps = relativePath.split(File.separator).filter { it.isNotEmpty() }.toMutableList() - if (filename?.isNotEmpty() == true) { - pathSteps.add(filename) + if (fileName?.isNotEmpty() == true) { + pathSteps.add(fileName) } return pathSteps.iterator() } @@ -187,7 +187,7 @@ object StorageUtils { return "primary" } volume.uuid?.let { uuid -> - return uuid.toUpperCase(Locale.ROOT) + return uuid.uppercase(Locale.ROOT) } } } @@ -199,7 +199,7 @@ object StorageUtils { return "primary" } volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }?.let { uuid -> - return uuid.toUpperCase(Locale.ROOT) + return uuid.uppercase(Locale.ROOT) } } @@ -434,11 +434,11 @@ object StorageUtils { return if (dirPath.endsWith(File.separator)) dirPath else dirPath + File.separator } - // `fullPath` should match "volumePath + relativeDir + filename" + // `fullPath` should match "volumePath + relativeDir + fileName" class PathSegments(context: Context, fullPath: String) { var volumePath: String? = null // `volumePath` with trailing "/" var relativeDir: String? = null // `relativeDir` with trailing "/" - private var filename: String? = null // null for directories + private var fileName: String? = null // null for directories init { volumePath = getVolumePath(context, fullPath) @@ -446,7 +446,7 @@ object StorageUtils { val lastSeparatorIndex = fullPath.lastIndexOf(File.separator) + 1 val volumePathLength = volumePath!!.length if (lastSeparatorIndex > volumePathLength) { - filename = fullPath.substring(lastSeparatorIndex) + fileName = fullPath.substring(lastSeparatorIndex) relativeDir = fullPath.substring(volumePathLength, lastSeparatorIndex) } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8846555de..ac51ffb20 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -492,6 +492,8 @@ "@albumScreenshots": {}, "albumScreenRecordings": "Screen recordings", "@albumScreenRecordings": {}, + "albumVideoCaptures": "Video Captures", + "@albumVideoCaptures": {}, "albumPageTitle": "Albums", "@albumPageTitle": {}, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 336d3f98c..26188f853 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -224,6 +224,7 @@ "albumDownload": "다운로드", "albumScreenshots": "스크린샷", "albumScreenRecordings": "화면 녹화 파일", + "albumVideoCaptures": "동영상 캡처", "albumPageTitle": "앨범", "albumEmpty": "앨범이 없습니다", diff --git a/lib/model/actions/video_actions.dart b/lib/model/actions/video_actions.dart index 1abca9b41..b34b096af 100644 --- a/lib/model/actions/video_actions.dart +++ b/lib/model/actions/video_actions.dart @@ -14,7 +14,7 @@ class VideoActions { static const all = [ VideoAction.replay10, VideoAction.togglePlay, - // VideoAction.captureFrame, + VideoAction.captureFrame, VideoAction.setSpeed, VideoAction.selectStreams, ]; diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index 359bef7be..cc5d8d0f9 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -34,6 +34,7 @@ mixin AlbumMixin on SourceBase { if (type == AlbumType.download) return context.l10n.albumDownload; if (type == AlbumType.screenshots) return context.l10n.albumScreenshots; if (type == AlbumType.screenRecordings) return context.l10n.albumScreenRecordings; + if (type == AlbumType.videoCaptures) return context.l10n.albumVideoCaptures; } final dir = VolumeRelativeDirectory.fromPath(dirPath); diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 334529397..6cc8eb19f 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -80,6 +80,14 @@ abstract class ImageFileService { required String destinationAlbum, }); + Future> captureFrame( + AvesEntry entry, { + required String desiredName, + required Map exif, + required Uint8List bytes, + required String destinationAlbum, + }); + Future> rename(AvesEntry entry, String newName); Future> rotate(AvesEntry entry, {required bool clockwise}); @@ -334,6 +342,29 @@ class PlatformImageFileService implements ImageFileService { } } + @override + Future> captureFrame( + AvesEntry entry, { + required String desiredName, + required Map exif, + required Uint8List bytes, + required String destinationAlbum, + }) async { + try { + final result = await platform.invokeMethod('captureFrame', { + 'uri': entry.uri, + 'desiredName': desiredName, + 'exif': exif, + 'bytes': bytes, + 'destinationPath': destinationAlbum, + }); + if (result != null) return (result as Map).cast(); + } on PlatformException catch (e) { + debugPrint('captureFrame failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return {}; + } + @override Future> rename(AvesEntry entry, String newName) async { try { diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 6d2267d9e..f83aafebe 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -9,7 +9,7 @@ import 'package:flutter/widgets.dart'; final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); class AndroidFileUtils { - late String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath; + late String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath; Set storageVolumes = {}; Set _packages = {}; List _potentialAppDirs = []; @@ -28,6 +28,8 @@ class AndroidFileUtils { downloadPath = pContext.join(primaryStorage, 'Download'); moviesPath = pContext.join(primaryStorage, 'Movies'); picturesPath = pContext.join(primaryStorage, 'Pictures'); + // from Aves + videoCapturesPath = pContext.join(dcimPath, 'Video Captures'); } Future initAppNames() async { @@ -42,6 +44,8 @@ class AndroidFileUtils { bool isScreenRecordingsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(moviesPath)) && (path.endsWith('Screen recordings') || path.endsWith('ScreenRecords')); + bool isVideoCapturesPath(String path) => path == videoCapturesPath; + bool isDownloadPath(String path) => path == downloadPath; StorageVolume? getStorageVolume(String? path) { @@ -59,6 +63,7 @@ class AndroidFileUtils { if (isDownloadPath(albumPath)) return AlbumType.download; if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings; if (isScreenshotsPath(albumPath)) return AlbumType.screenshots; + if (isVideoCapturesPath(albumPath)) return AlbumType.videoCaptures; final dir = pContext.split(albumPath).last; if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app; @@ -78,7 +83,7 @@ class AndroidFileUtils { } } -enum AlbumType { regular, app, camera, download, screenRecordings, screenshots } +enum AlbumType { regular, app, camera, download, screenRecordings, screenshots, videoCaptures } class Package { final String packageName; diff --git a/lib/widgets/common/action_mixins/size_aware.dart b/lib/widgets/common/action_mixins/size_aware.dart index 6a8b7f3f8..31f353c2b 100644 --- a/lib/widgets/common/action_mixins/size_aware.dart +++ b/lib/widgets/common/action_mixins/size_aware.dart @@ -46,26 +46,49 @@ mixin SizeAwareMixin { final hasEnoughSpace = needed < free; if (!hasEnoughSpace) { - await showDialog( - context: context, - builder: (context) { - final neededSize = formatFilesize(needed); - final freeSize = formatFilesize(free); - final volume = destinationVolume.getDescription(context); - return AvesDialog( - context: context, - title: context.l10n.notEnoughSpaceDialogTitle, - content: Text(context.l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(MaterialLocalizations.of(context).okButtonLabel), - ), - ], - ); - }, - ); + await _showNotEnoughSpaceDialog(context, needed, free, destinationVolume); } return hasEnoughSpace; } + + Future checkFreeSpace( + BuildContext context, + int needed, + String destinationAlbum, + ) async { + // assume we have enough space if we cannot find the volume or its remaining free space + final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum); + if (destinationVolume == null) return true; + + final free = await storageService.getFreeSpace(destinationVolume); + if (free == null) return true; + + final hasEnoughSpace = needed < free; + if (!hasEnoughSpace) { + await _showNotEnoughSpaceDialog(context, needed, free, destinationVolume); + } + return hasEnoughSpace; + } + + Future _showNotEnoughSpaceDialog(BuildContext context, int needed, int free, StorageVolume destinationVolume) async { + await showDialog( + context: context, + builder: (context) { + final neededSize = formatFilesize(needed); + final freeSize = formatFilesize(free); + final volume = destinationVolume.getDescription(context); + return AvesDialog( + context: context, + title: context.l10n.notEnoughSpaceDialogTitle, + content: Text(context.l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + ); + }, + ); + } } diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 7bb72acc4..3beced0f9 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -199,6 +199,7 @@ class IconUtils { case AlbumType.camera: return buildIcon(AIcons.cameraAlbum); case AlbumType.screenshots: + case AlbumType.videoCaptures: return buildIcon(AIcons.screenshotAlbum); case AlbumType.screenRecordings: return buildIcon(AIcons.recordingAlbum); diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index a0c2da8c5..4b7cc6bfa 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -196,16 +196,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix onDone: (processed) { final movedOps = processed.where((e) => e.success); final movedCount = movedOps.length; - final showAction = collection != null && movedCount > 0 + final _collection = collection; + final showAction = _collection != null && movedCount > 0 ? SnackBarAction( label: context.l10n.showButtonLabel, onPressed: () async { final highlightInfo = context.read(); final targetCollection = CollectionLens( - source: collection!.source, + source: source, filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, - groupFactor: collection!.groupFactor, - sortFactor: collection!.sortFactor, + groupFactor: _collection.groupFactor, + sortFactor: _collection.sortFactor, ); unawaited(Navigator.pushAndRemoveUntil( context, diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 3a404382e..a238444f6 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -26,6 +26,7 @@ import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/overlay/top.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; +import 'package:aves/widgets/viewer/video_action_delegate.dart'; import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -60,7 +61,8 @@ class _EntryViewerStackState extends State with SingleTickerPr late Animation _topOverlayScale, _bottomOverlayScale; late Animation _bottomOverlayOffset; EdgeInsets? _frozenViewInsets, _frozenViewPadding; - late EntryActionDelegate _actionDelegate; + late EntryActionDelegate _entryActionDelegate; + late VideoActionDelegate _videoActionDelegate; final List>> _viewStateNotifiers = []; final ValueNotifier _heroInfoNotifier = ValueNotifier(null); bool _isEntryTracked = true; @@ -108,10 +110,13 @@ class _EntryViewerStackState extends State with SingleTickerPr curve: Curves.easeOutQuad, )); _overlayVisible.addListener(_onOverlayVisibleChange); - _actionDelegate = EntryActionDelegate( + _entryActionDelegate = EntryActionDelegate( collection: collection, showInfo: () => _goToVerticalPage(infoPage), ); + _videoActionDelegate = VideoActionDelegate( + collection: collection, + ); _initEntryControllers(); _registerWidget(widget); WidgetsBinding.instance!.addObserver(this); @@ -243,7 +248,7 @@ class _EntryViewerStackState extends State with SingleTickerPr } } } - _actionDelegate.onActionSelected(context, targetEntry, action); + _entryActionDelegate.onActionSelected(context, targetEntry, action); }, viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2, ); @@ -290,6 +295,11 @@ class _EntryViewerStackState extends State with SingleTickerPr entry: pageEntry, controller: videoController, scale: _bottomOverlayScale, + onActionSelected: (action) { + if (videoController != null) { + _videoActionDelegate.onActionSelected(context, videoController, action); + } + }, ), ); } else if (pageEntry.is360) { @@ -414,7 +424,7 @@ class _EntryViewerStackState extends State with SingleTickerPr void _onVerticalPageChanged(int page) { _currentVerticalPage.value = page; if (page == transitionPage) { - _actionDelegate.dismissFeedback(context); + _entryActionDelegate.dismissFeedback(context); _popVisual(); } else if (page == infoPage) { // prevent hero when viewer is offscreen diff --git a/lib/widgets/viewer/overlay/bottom/video.dart b/lib/widgets/viewer/overlay/bottom/video.dart index 4c90a38f4..a31a3c432 100644 --- a/lib/widgets/viewer/overlay/bottom/video.dart +++ b/lib/widgets/viewer/overlay/bottom/video.dart @@ -12,10 +12,7 @@ import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/borders.dart'; -import 'package:aves/widgets/dialogs/video_speed_dialog.dart'; -import 'package:aves/widgets/dialogs/video_stream_selection_dialog.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; -import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -25,12 +22,14 @@ class VideoControlOverlay extends StatefulWidget { final AvesEntry entry; final AvesVideoController? controller; final Animation scale; + final Function(VideoAction value) onActionSelected; const VideoControlOverlay({ Key? key, required this.entry, required this.controller, required this.scale, + required this.onActionSelected, }) : super(key: key); @override @@ -93,6 +92,7 @@ class _VideoControlOverlayState extends State with SingleTi menuActions: menuActions, scale: scale, controller: controller, + onActionSelected: widget.onActionSelected, ), const SizedBox(height: 8), _buildProgressBar(), @@ -195,6 +195,7 @@ class _ButtonRow extends StatelessWidget { final List quickActions, menuActions; final Animation scale; final AvesVideoController? controller; + final Function(VideoAction value) onActionSelected; const _ButtonRow({ Key? key, @@ -202,6 +203,7 @@ class _ButtonRow extends StatelessWidget { required this.menuActions, required this.scale, required this.controller, + required this.onActionSelected, }) : super(key: key); static const double padding = 8; @@ -223,7 +225,7 @@ class _ButtonRow extends StatelessWidget { itemBuilder: (context) => menuActions.map((action) => _buildPopupMenuItem(context, action)).toList(), onSelected: (action) { // wait for the popup menu to hide before proceeding with the action - Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action)); + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => onActionSelected(action)); }, ), ), @@ -234,7 +236,7 @@ class _ButtonRow extends StatelessWidget { Widget _buildOverlayButton(BuildContext context, VideoAction action) { late Widget child; - void onPressed() => _onActionSelected(context, action); + void onPressed() => onActionSelected(action); switch (action) { case VideoAction.togglePlay: child = _PlayToggler( @@ -283,81 +285,6 @@ class _ButtonRow extends StatelessWidget { child: child, ); } - - void _onActionSelected(BuildContext context, VideoAction action) { - switch (action) { - case VideoAction.togglePlay: - _togglePlayPause(context); - break; - case VideoAction.setSpeed: - _showSpeedDialog(context); - break; - case VideoAction.selectStreams: - _showStreamSelectionDialog(context); - break; - case VideoAction.captureFrame: - controller?.captureFrame(); - break; - case VideoAction.replay10: - { - final _controller = controller; - if (_controller != null && _controller.isReady) { - _controller.seekTo(_controller.currentPosition - 10000); - } - break; - } - } - } - - Future _showSpeedDialog(BuildContext context) async { - final _controller = controller; - if (_controller == null) return; - - final newSpeed = await showDialog( - context: context, - builder: (context) => VideoSpeedDialog( - current: _controller.speed, - min: _controller.minSpeed, - max: _controller.maxSpeed, - ), - ); - if (newSpeed == null) return; - - _controller.speed = newSpeed; - } - - Future _showStreamSelectionDialog(BuildContext context) async { - final _controller = controller; - if (_controller == null) return; - - final selectedStreams = await showDialog>( - context: context, - builder: (context) => VideoStreamSelectionDialog( - streams: _controller.streams, - ), - ); - if (selectedStreams == null || selectedStreams.isEmpty) return; - - // TODO TLAD [video] get stream list & guess default selected streams, when the controller is not initialized yet - await Future.forEach>( - selectedStreams.entries, - (kv) => _controller.selectStream(kv.key, kv.value), - ); - } - - Future _togglePlayPause(BuildContext context) async { - final _controller = controller; - if (_controller == null) return; - - if (isPlaying) { - await _controller.pause(); - } else { - await _controller.play(); - // hide overlay - await Future.delayed(Durations.iconAnimation); - ToggleOverlayNotification().dispatch(context); - } - } } class _PlayToggler extends StatefulWidget { diff --git a/lib/widgets/viewer/video/controller.dart b/lib/widgets/viewer/video/controller.dart index bac2368b1..792b5bdd1 100644 --- a/lib/widgets/viewer/video/controller.dart +++ b/lib/widgets/viewer/video/controller.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:aves/model/entry.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -55,7 +57,7 @@ abstract class AvesVideoController { Map get streams; - Future captureFrame(); + Future captureFrame(); Widget buildPlayerWidget(BuildContext context); } diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index b937e78ac..a2a530e67 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math'; +import 'dart:typed_data'; import 'dart:ui'; import 'package:aves/model/entry.dart'; @@ -339,11 +340,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { } @override - Future captureFrame() async { - final bytes = await _instance.takeSnapShot(); - // TODO TLAD [video] export to DCIM/Videocaptures - debugPrint('captureFrame bytes=${bytes.length}'); - } + Future captureFrame() => _instance.takeSnapShot(); @override Widget buildPlayerWidget(BuildContext context) { diff --git a/lib/widgets/viewer/video_action_delegate.dart b/lib/widgets/viewer/video_action_delegate.dart new file mode 100644 index 000000000..347400fb9 --- /dev/null +++ b/lib/widgets/viewer/video_action_delegate.dart @@ -0,0 +1,163 @@ +import 'dart:async'; + +import 'package:aves/model/actions/video_actions.dart'; +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/highlight.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/services/services.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; +import 'package:aves/widgets/common/action_mixins/size_aware.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/video_speed_dialog.dart'; +import 'package:aves/widgets/dialogs/video_stream_selection_dialog.dart'; +import 'package:aves/widgets/viewer/overlay/notifications.dart'; +import 'package:aves/widgets/viewer/video/controller.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:provider/provider.dart'; + +class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { + final CollectionLens? collection; + + VideoActionDelegate({ + required this.collection, + }); + + void onActionSelected(BuildContext context, AvesVideoController controller, VideoAction action) { + switch (action) { + case VideoAction.captureFrame: + _captureFrame(context, controller); + break; + case VideoAction.replay10: + if (controller.isReady) controller.seekTo(controller.currentPosition - 10000); + break; + case VideoAction.selectStreams: + _showStreamSelectionDialog(context, controller); + break; + case VideoAction.setSpeed: + _showSpeedDialog(context, controller); + break; + case VideoAction.togglePlay: + _togglePlayPause(context, controller); + break; + } + } + + Future _captureFrame(BuildContext context, AvesVideoController controller) async { + final positionMillis = controller.currentPosition; + final bytes = await controller.captureFrame(); + + final destinationAlbum = androidFileUtils.videoCapturesPath; + if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; + + if (!await checkFreeSpace(context, bytes.length, destinationAlbum)) return; + + final entry = controller.entry; + final rotationDegrees = entry.rotationDegrees; + final dateTimeMillis = entry.catalogMetadata?.dateMillis; + final latLng = entry.latLng; + final exif = { + if (rotationDegrees != 0) 'rotationDegrees': rotationDegrees, + if (dateTimeMillis != null && dateTimeMillis != 0) 'dateTimeMillis': dateTimeMillis, + if (latLng != null) ...{ + 'latitude': latLng.latitude, + 'longitude': latLng.longitude, + } + }; + + final newFields = await imageFileService.captureFrame( + entry, + desiredName: '${entry.bestTitle}_${'$positionMillis'.padLeft(8, '0')}', + exif: exif, + bytes: bytes, + destinationAlbum: destinationAlbum, + ); + final success = newFields.isNotEmpty; + + if (success) { + final _collection = collection; + final showAction = _collection != null + ? SnackBarAction( + label: context.l10n.showButtonLabel, + onPressed: () async { + final highlightInfo = context.read(); + final source = _collection.source; + final targetCollection = CollectionLens( + source: source, + filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, + groupFactor: _collection.groupFactor, + sortFactor: _collection.sortFactor, + ); + unawaited(Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) { + return CollectionPage( + targetCollection, + ); + }, + ), + (route) => false, + )); + await Future.delayed(Durations.staggeredAnimationPageTarget + Durations.highlightScrollInitDelay); + final newUri = newFields['uri'] as String?; + final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => entry.uri == newUri); + if (targetEntry != null) { + highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); + } + }, + ) + : null; + showFeedback(context, context.l10n.genericSuccessFeedback, showAction); + } else { + showFeedback(context, context.l10n.genericFailureFeedback); + } + } + + Future _showStreamSelectionDialog(BuildContext context, AvesVideoController controller) async { + final selectedStreams = await showDialog>( + context: context, + builder: (context) => VideoStreamSelectionDialog( + streams: controller.streams, + ), + ); + if (selectedStreams == null || selectedStreams.isEmpty) return; + + // TODO TLAD [video] get stream list & guess default selected streams, when the controller is not initialized yet + await Future.forEach>( + selectedStreams.entries, + (kv) => controller.selectStream(kv.key, kv.value), + ); + } + + Future _showSpeedDialog(BuildContext context, AvesVideoController controller) async { + final newSpeed = await showDialog( + context: context, + builder: (context) => VideoSpeedDialog( + current: controller.speed, + min: controller.minSpeed, + max: controller.maxSpeed, + ), + ); + if (newSpeed == null) return; + + controller.speed = newSpeed; + } + + Future _togglePlayPause(BuildContext context, AvesVideoController controller) async { + if (controller.isPlaying) { + await controller.pause(); + } else { + await controller.play(); + // hide overlay + await Future.delayed(Durations.iconAnimation); + ToggleOverlayNotification().dispatch(context); + } + } +} diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index 4a1a999b0..1fa1c310e 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -209,6 +209,11 @@ class _EntryPageViewState extends State { VideoSubtitles( controller: videoController, ), + if (settings.videoShowRawTimedText) + VideoSubtitles( + controller: videoController, + debugMode: true, + ), ], ); }), diff --git a/lib/widgets/viewer/visual/subtitle.dart b/lib/widgets/viewer/visual/subtitle.dart index 4e606ee47..43dcb790e 100644 --- a/lib/widgets/viewer/visual/subtitle.dart +++ b/lib/widgets/viewer/visual/subtitle.dart @@ -1,4 +1,3 @@ -import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/basic/outlined_text.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:flutter/material.dart'; @@ -6,10 +5,12 @@ import 'package:provider/provider.dart'; class VideoSubtitles extends StatelessWidget { final AvesVideoController controller; + final bool debugMode; const VideoSubtitles({ Key? key, required this.controller, + this.debugMode = false, }) : super(key: key); @override @@ -17,34 +18,43 @@ class VideoSubtitles extends StatelessWidget { return Selector( selector: (c, mq) => mq.orientation, builder: (c, orientation, child) { + final y = orientation == Orientation.portrait ? .5 : .8; return Align( - alignment: Alignment(0, orientation == Orientation.portrait ? .5 : .8), - child: StreamBuilder( - stream: controller.timedTextStream, - builder: (context, snapshot) { - final text = snapshot.data; - return text != null ? SubtitleText(text: text) : const SizedBox(); - }, - ), + alignment: Alignment(0, debugMode ? -y : y), + child: child, ); }, + child: StreamBuilder( + stream: controller.timedTextStream, + builder: (context, snapshot) { + final text = snapshot.data; + return text != null + ? SubtitleText( + text: text, + debugMode: debugMode, + ) + : const SizedBox(); + }, + ), ); } } class SubtitleText extends StatelessWidget { final String text; + final bool debugMode; const SubtitleText({ Key? key, required this.text, + this.debugMode = false, }) : super(key: key); @override Widget build(BuildContext context) { late final String displayText; - if (!settings.videoShowRawTimedText) { + if (debugMode) { displayText = text; } else { // TODO TLAD [video] process ASS tags, cf https://aegi.vmoe.info/docs/3.0/ASS_Tags/