From e6fd46558ac39ea93a23fb1d4eaed3e170985fcf Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 23 Oct 2022 17:07:40 +0200 Subject: [PATCH] #105 mp4 rotation --- CHANGELOG.md | 2 +- .../thibault/aves/metadata/Mp4ParserHelper.kt | 107 +++++++++++------- .../aves/model/provider/ImageProvider.kt | 29 +++-- lib/model/entry.dart | 10 +- lib/model/entry_metadata_edition.dart | 71 +++++++++--- lib/model/metadata/fields.dart | 4 + .../collection/entry_set_action_delegate.dart | 6 +- lib/widgets/common/thumbnail/image.dart | 6 +- lib/widgets/viewer/debug/debug_page.dart | 3 +- lib/widgets/viewer/entry_vertical_pager.dart | 8 +- .../viewer/overlay/viewer_buttons.dart | 49 +++++--- lib/widgets/viewer/video/controller.dart | 9 +- lib/widgets/viewer/video/fijkplayer.dart | 3 + .../viewer/visual/entry_page_view.dart | 2 +- 14 files changed, 207 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad89e996e..c08c86547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### Added -- Collection / Info: edit MP4 metadata (date / location / title / description / rating / tags) +- Collection / Info: edit MP4 metadata (date / location / title / description / rating / tags / rotation) - Widget: option to open collection on tap ### Changed diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Mp4ParserHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Mp4ParserHelper.kt index 21c7f0967..d9cd24c6e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Mp4ParserHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Mp4ParserHelper.kt @@ -2,61 +2,19 @@ package deckers.thibault.aves.metadata import android.content.Context import android.net.Uri -import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.StorageUtils import org.mp4parser.* import org.mp4parser.boxes.UserBox import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox -import org.mp4parser.boxes.iso14496.part12.FreeBox -import org.mp4parser.boxes.iso14496.part12.MediaDataBox -import org.mp4parser.boxes.iso14496.part12.MovieBox -import org.mp4parser.boxes.iso14496.part12.UserDataBox +import org.mp4parser.boxes.iso14496.part12.* import org.mp4parser.support.AbstractBox +import org.mp4parser.support.Matrix import org.mp4parser.tools.Path import java.io.ByteArrayOutputStream import java.io.FileInputStream import java.nio.channels.Channels object Mp4ParserHelper { - private val LOG_TAG = LogUtils.createTag() - - fun updateLocation(isoFile: IsoFile, locationIso6709: String?) { - // Apple GPS Coordinates Box can be in various locations: - // - moov[0]/udta[0]/©xyz - // - moov[0]/meta[0]/ilst/©xyz - // - others? - isoFile.removeBoxes(AppleGPSCoordinatesBox::class.java, true) - - locationIso6709 ?: return - - val movieBox = isoFile.movieBox - var userDataBox = Path.getPath(movieBox, UserDataBox.TYPE) - if (userDataBox == null) { - userDataBox = UserDataBox() - movieBox.addBox(userDataBox) - } - - userDataBox.addBox(AppleGPSCoordinatesBox().apply { - value = locationIso6709 - }) - } - - fun updateXmp(isoFile: IsoFile, xmp: String?) { - val xmpBox = isoFile.xmpBox - if (xmp != null) { - val xmpData = xmp.toByteArray(Charsets.UTF_8) - if (xmpBox == null) { - isoFile.addBox(UserBox(XMP.mp4Uuid).apply { - data = xmpData - }) - } else { - xmpBox.data = xmpData - } - } else if (xmpBox != null) { - isoFile.removeBox(xmpBox) - } - } - fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List> { // we can skip uninteresting boxes with a seekable data source val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri") @@ -135,6 +93,67 @@ object Mp4ParserHelper { // extensions + fun IsoFile.updateLocation(locationIso6709: String?) { + // Apple GPS Coordinates Box can be in various locations: + // - moov[0]/udta[0]/©xyz + // - moov[0]/meta[0]/ilst/©xyz + // - others? + removeBoxes(AppleGPSCoordinatesBox::class.java, true) + + locationIso6709 ?: return + + var userDataBox = Path.getPath(movieBox, UserDataBox.TYPE) + if (userDataBox == null) { + userDataBox = UserDataBox() + movieBox.addBox(userDataBox) + } + + userDataBox.addBox(AppleGPSCoordinatesBox().apply { + value = locationIso6709 + }) + } + + fun IsoFile.updateRotation(degrees: Int): Boolean { + val matrix: Matrix = when (degrees) { + 0 -> Matrix.ROTATE_0 + 90 -> Matrix.ROTATE_90 + 180 -> Matrix.ROTATE_180 + 270 -> Matrix.ROTATE_270 + else -> throw Exception("failed because of invalid rotation degrees=$degrees") + } + + var success = false + movieBox.getBoxes(TrackHeaderBox::class.java, true).filter { tkhd -> + if (!tkhd.isParsed) { + tkhd.parseDetails() + } + tkhd.width > 0 && tkhd.height > 0 + }.forEach { tkhd -> + if (!setOf(Matrix.ROTATE_0, Matrix.ROTATE_90, Matrix.ROTATE_180, Matrix.ROTATE_270).contains(tkhd.matrix)) { + throw Exception("failed because existing matrix is not a simple rotation matrix") + } + tkhd.matrix = matrix + success = true + } + return success + } + + fun IsoFile.updateXmp(xmp: String?) { + val xmpBox = xmpBox + if (xmp != null) { + val xmpData = xmp.toByteArray(Charsets.UTF_8) + if (xmpBox == null) { + addBox(UserBox(XMP.mp4Uuid).apply { + data = xmpData + }) + } else { + xmpBox.data = xmpData + } + } else if (xmpBox != null) { + removeBox(xmpBox) + } + } + private fun IsoFile.getBoxOffset(test: (box: Box) -> Boolean): Long? { var offset = 0L for (box in boxes) { 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 5a62d29e6..9976f44b6 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 @@ -26,6 +26,9 @@ import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC import deckers.thibault.aves.metadata.Metadata.TYPE_MP4 import deckers.thibault.aves.metadata.Metadata.TYPE_XMP +import deckers.thibault.aves.metadata.Mp4ParserHelper.updateLocation +import deckers.thibault.aves.metadata.Mp4ParserHelper.updateRotation +import deckers.thibault.aves.metadata.Mp4ParserHelper.updateXmp import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString import deckers.thibault.aves.model.AvesEntry @@ -563,7 +566,8 @@ abstract class ImageProvider { uri: Uri, mimeType: String, callback: ImageOpCallback, - fields: Map<*, *> + fieldsToEdit: Map<*, *>, + newFields: FieldMap? = null, ): Boolean { if (mimeType != MimeTypes.MP4) { callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) @@ -572,12 +576,18 @@ abstract class ImageProvider { try { val edits = Mp4ParserHelper.computeEdits(context, uri) { isoFile -> - fields.forEach { kv -> + fieldsToEdit.forEach { kv -> val tag = kv.key as String val value = kv.value as String? when (tag) { - "gpsCoordinates" -> Mp4ParserHelper.updateLocation(isoFile, value) - "xmp" -> Mp4ParserHelper.updateXmp(isoFile, value) + "gpsCoordinates" -> isoFile.updateLocation(value) + "rotationDegrees" -> { + val degrees = value?.toIntOrNull() ?: throw Exception("failed because of invalid rotation=$value") + if (isoFile.updateRotation(degrees) && newFields != null) { + newFields["rotationDegrees"] = degrees + } + } + "xmp" -> isoFile.updateXmp(value) } } } @@ -637,7 +647,7 @@ abstract class ImageProvider { uri = uri, mimeType = mimeType, callback = callback, - fields = mapOf("xmp" to coreXmp), + fieldsToEdit = mapOf("xmp" to coreXmp), ) } @@ -898,6 +908,7 @@ abstract class ImageProvider { autoCorrectTrailerOffset: Boolean, callback: ImageOpCallback, ) { + val newFields: FieldMap = hashMapOf() if (modifier.containsKey(TYPE_EXIF)) { val fields = modifier[TYPE_EXIF] as Map<*, *>? if (fields != null && fields.isNotEmpty()) { @@ -970,15 +981,16 @@ abstract class ImageProvider { } if (modifier.containsKey(TYPE_MP4)) { - val fields = modifier[TYPE_MP4] as Map<*, *>? - if (fields != null && fields.isNotEmpty()) { + val fieldsToEdit = modifier[TYPE_MP4] as Map<*, *>? + if (fieldsToEdit != null && fieldsToEdit.isNotEmpty()) { if (!editMp4Metadata( context = context, path = path, uri = uri, mimeType = mimeType, callback = callback, - fields = fields, + fieldsToEdit = fieldsToEdit, + newFields = newFields, ) ) return } @@ -1003,7 +1015,6 @@ abstract class ImageProvider { } } - val newFields: FieldMap = hashMapOf() scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 3ee822edd..c42f47af2 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -47,7 +47,7 @@ class AvesEntry { List? burstEntries; - final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); + final AChangeNotifier visualChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); AvesEntry({ required int? id, @@ -176,7 +176,7 @@ class AvesEntry { } void dispose() { - imageChangeNotifier.dispose(); + visualChangeNotifier.dispose(); metadataChangeNotifier.dispose(); addressChangeNotifier.dispose(); } @@ -292,7 +292,9 @@ class AvesEntry { bool get canEditTags => canEdit && canEditXmp; - bool get canRotateAndFlip => canEdit && canEditExif; + bool get canRotate => canEdit && (canEditExif || mimeType == MimeTypes.mp4); + + bool get canFlip => canEdit && canEditExif; bool get canEditExif => MimeTypes.canEditExif(mimeType); @@ -712,7 +714,7 @@ class AvesEntry { ) async { if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { await EntryCache.evict(uri, oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); - imageChangeNotifier.notify(); + visualChangeNotifier.notify(); } } diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index 9593add22..ef1f42a2a 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -20,7 +20,7 @@ import 'package:xml/xml.dart'; extension ExtraAvesEntryMetadataEdition on AvesEntry { Future> editDate(DateModifier userModifier) async { - final Set dataTypes = {}; + final dataTypes = {}; final appliedModifier = await _applyDateModifierToEntry(userModifier); if (appliedModifier == null) { @@ -83,8 +83,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { } Future> editLocation(LatLng? latLng) async { - final Set dataTypes = {}; - final Map metadata = {}; + final dataTypes = {}; + final metadata = {}; final missingDate = await _missingDateCheckAndExifEdit(dataTypes); @@ -151,8 +151,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { return dataTypes; } - Future> _changeOrientation(Future> Function() apply) async { - final Set dataTypes = {}; + Future> _changeExifOrientation(Future> Function() apply) async { + final dataTypes = {}; await _missingDateCheckAndExifEdit(dataTypes); @@ -170,12 +170,51 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { return dataTypes; } + Future> _rotateMp4(int rotationDegrees) async { + final dataTypes = {}; + + final missingDate = await _missingDateCheckAndExifEdit(dataTypes); + + final mp4Fields = { + MetadataField.mp4RotationDegrees: rotationDegrees.toString(), + }; + + if (missingDate != null) { + final xmpParts = await _editXmp((descriptions) { + editCreateDateXmp(descriptions, missingDate); + return true; + }); + mp4Fields[MetadataField.mp4Xmp] = xmpParts[xmpCoreKey]; + } + + final metadata = { + MetadataType.mp4: Map.fromEntries(mp4Fields.entries.map((kv) => MapEntry(kv.key.toPlatform!, kv.value))), + }; + + final newFields = await metadataEditService.editMetadata(this, metadata); + // applying fields is only useful for a smoother visual change, + // as proper refreshing and persistence happens at the caller level + await applyNewFields(newFields, persist: false); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.aspectRatio, + EntryDataType.catalog, + }); + } + return dataTypes; + } + Future> rotate({required bool clockwise}) { - return _changeOrientation(() => metadataEditService.rotate(this, clockwise: clockwise)); + if (mimeType == MimeTypes.mp4) { + return _rotateMp4((rotationDegrees + (clockwise ? 90 : -90) + 360) % 360); + } else { + return _changeExifOrientation(() => metadataEditService.rotate(this, clockwise: clockwise)); + } } Future> flip() { - return _changeOrientation(() => metadataEditService.flip(this)); + return _changeExifOrientation(() => metadataEditService.flip(this)); } // write title: @@ -186,8 +225,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { // - IPTC / caption-abstract, if IPTC exists // - XMP / dc:description Future> editTitleDescription(Map fields) async { - final Set dataTypes = {}; - final Map metadata = {}; + final dataTypes = {}; + final metadata = {}; final missingDate = await _missingDateCheckAndExifEdit(dataTypes); @@ -256,8 +295,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { // - IPTC / keywords, if IPTC exists // - XMP / dc:subject Future> editTags(Set tags) async { - final Set dataTypes = {}; - final Map metadata = {}; + final dataTypes = {}; + final metadata = {}; final missingDate = await _missingDateCheckAndExifEdit(dataTypes); @@ -294,8 +333,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { // - Exif / Rating // - Exif / RatingPercent Future> editRating(int? rating) async { - final Set dataTypes = {}; - final Map metadata = {}; + final dataTypes = {}; + final metadata = {}; final missingDate = await _missingDateCheckAndExifEdit(dataTypes); @@ -322,8 +361,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { // - XMP / GCamera:MicroVideo* // - XMP / GCamera:MotionPhoto* Future> removeTrailerVideo() async { - final Set dataTypes = {}; - final Map metadata = {}; + final dataTypes = {}; + final metadata = {}; if (!canEditXmp) return dataTypes; @@ -347,7 +386,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { } Future> removeMetadata(Set types) async { - final Set dataTypes = {}; + final dataTypes = {}; final newFields = await metadataEditService.removeTypes(this, types); if (newFields.isNotEmpty) { diff --git a/lib/model/metadata/fields.dart b/lib/model/metadata/fields.dart index 8628717e9..b6b279226 100644 --- a/lib/model/metadata/fields.dart +++ b/lib/model/metadata/fields.dart @@ -38,6 +38,7 @@ enum MetadataField { exifGpsVersionId, exifImageDescription, mp4GpsCoordinates, + mp4RotationDegrees, mp4Xmp, xmpXmpCreateDate, } @@ -120,6 +121,7 @@ extension ExtraMetadataField on MetadataField { case MetadataField.exifImageDescription: return MetadataType.exif; case MetadataField.mp4GpsCoordinates: + case MetadataField.mp4RotationDegrees: case MetadataField.mp4Xmp: return MetadataType.mp4; case MetadataField.xmpXmpCreateDate: @@ -134,6 +136,8 @@ extension ExtraMetadataField on MetadataField { switch (this) { case MetadataField.mp4GpsCoordinates: return 'gpsCoordinates'; + case MetadataField.mp4RotationDegrees: + return 'rotationDegrees'; case MetadataField.mp4Xmp: return 'xmp'; default: diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 420faab2d..32c8bfe5d 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -370,7 +370,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware Set obsoleteTags = todoItems.expand((entry) => entry.tags).toSet(); Set obsoleteCountryCodes = todoItems.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull().toSet(); - final Set dataTypes = {}; + final dataTypes = {}; final source = context.read(); source.pauseMonitoring(); var cancelled = false; @@ -463,14 +463,14 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware } Future _rotate(BuildContext context, {required bool clockwise}) async { - final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRotateAndFlip); + final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRotate); if (entries == null || entries.isEmpty) return; await _edit(context, entries, (entry) => entry.rotate(clockwise: clockwise)); } Future _flip(BuildContext context) async { - final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRotateAndFlip); + final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canFlip); if (entries == null || entries.isEmpty) return; await _edit(context, entries, (entry) => entry.flip()); diff --git a/lib/widgets/common/thumbnail/image.dart b/lib/widgets/common/thumbnail/image.dart index 8567539e6..ae89a1577 100644 --- a/lib/widgets/common/thumbnail/image.dart +++ b/lib/widgets/common/thumbnail/image.dart @@ -88,12 +88,12 @@ class _ThumbnailImageState extends State { } void _registerWidget(ThumbnailImage widget) { - widget.entry.imageChangeNotifier.addListener(_onImageChanged); + widget.entry.visualChangeNotifier.addListener(_onVisualChanged); _initProvider(); } void _unregisterWidget(ThumbnailImage widget) { - widget.entry.imageChangeNotifier.removeListener(_onImageChanged); + widget.entry.visualChangeNotifier.removeListener(_onVisualChanged); _pauseProvider(); _currentProviderStream?.stopListening(); _currentProviderStream = null; @@ -313,7 +313,7 @@ class _ThumbnailImageState extends State { } // when the entry image itself changed (e.g. after rotation) - void _onImageChanged() async { + void _onVisualChanged() async { // rebuild to refresh the thumbnails _pauseProvider(); _initProvider(); diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index 589462ba8..24d6c9d3f 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -141,7 +141,8 @@ class ViewerDebugPage extends StatelessWidget { 'canEdit': '${entry.canEdit}', 'canEditDate': '${entry.canEditDate}', 'canEditTags': '${entry.canEditTags}', - 'canRotateAndFlip': '${entry.canRotateAndFlip}', + 'canRotate': '${entry.canRotate}', + 'canFlip': '${entry.canFlip}', 'tags': '${entry.tags}', }, ), diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index 5a06022f4..7ffd3cf1c 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -104,7 +104,7 @@ class _ViewerVerticalPageViewState extends State { ..clear(); widget.verticalPager.removeListener(_onVerticalPageControllerChanged); widget.entryNotifier.removeListener(_onEntryChanged); - _oldEntry?.imageChangeNotifier.removeListener(_onImageChanged); + _oldEntry?.visualChangeNotifier.removeListener(_onVisualChanged); } @override @@ -264,12 +264,12 @@ class _ViewerVerticalPageViewState extends State { // when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted) Future _onEntryChanged() async { - _oldEntry?.imageChangeNotifier.removeListener(_onImageChanged); + _oldEntry?.visualChangeNotifier.removeListener(_onVisualChanged); _oldEntry = entry; final _entry = entry; if (_entry != null) { - _entry.imageChangeNotifier.addListener(_onImageChanged); + _entry.visualChangeNotifier.addListener(_onVisualChanged); // make sure to locate the entry, // so that we can display the address instead of coordinates // even when initial collection locating has not reached this entry yet @@ -286,7 +286,7 @@ class _ViewerVerticalPageViewState extends State { } // when the entry image itself changed (e.g. after rotation) - void _onImageChanged() async { + void _onVisualChanged() async { // rebuild to refresh the Image inside ImagePage if (mounted) { setState(() {}); diff --git a/lib/widgets/viewer/overlay/viewer_buttons.dart b/lib/widgets/viewer/overlay/viewer_buttons.dart index fbe0a3342..aef53584e 100644 --- a/lib/widgets/viewer/overlay/viewer_buttons.dart +++ b/lib/widgets/viewer/overlay/viewer_buttons.dart @@ -4,10 +4,10 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/app_bar/favourite_toggler.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/popup_menu_button.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/app_bar/favourite_toggler.dart'; import 'package:aves/widgets/viewer/action/entry_action_delegate.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/notifications.dart'; @@ -70,8 +70,9 @@ class ViewerButtons extends StatelessWidget { return targetEntry.canEdit; case EntryAction.rotateCCW: case EntryAction.rotateCW: + return targetEntry.canRotate; case EntryAction.flip: - return targetEntry.canRotateAndFlip; + return targetEntry.canFlip; case EntryAction.convert: case EntryAction.print: return !targetEntry.isVideo && device.canPrint; @@ -161,7 +162,7 @@ class ViewerButtonRowContent extends StatelessWidget { @override Widget build(BuildContext context) { - final hasOverflowMenu = pageEntry.canRotateAndFlip || topLevelActions.isNotEmpty || exportActions.isNotEmpty || videoActions.isNotEmpty; + final hasOverflowMenu = pageEntry.canRotate || pageEntry.canFlip || topLevelActions.isNotEmpty || exportActions.isNotEmpty || videoActions.isNotEmpty; return Selector( selector: (context, vc) => vc.getController(pageEntry), builder: (context, videoController, child) { @@ -183,7 +184,7 @@ class ViewerButtonRowContent extends StatelessWidget { final exportInternalActions = exportActions.whereNot(EntryActions.exportExternal.contains).toList(); final exportExternalActions = exportActions.where(EntryActions.exportExternal.contains).toList(); return [ - if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), + if (pageEntry.canRotate || pageEntry.canFlip) _buildRotateAndFlipMenuItems(context), ...topLevelActions.map((action) => _buildPopupMenuItem(context, action, videoController)), if (exportActions.isNotEmpty) PopupMenuItem( @@ -357,6 +358,18 @@ class ViewerButtonRowContent extends StatelessWidget { } PopupMenuItem _buildRotateAndFlipMenuItems(BuildContext context) { + bool canApply(EntryAction action) { + switch (action) { + case EntryAction.rotateCCW: + case EntryAction.rotateCW: + return pageEntry.canRotate; + case EntryAction.flip: + return pageEntry.canFlip; + default: + return true; + } + } + Widget buildDivider() => const SizedBox( height: 16, child: VerticalDivider( @@ -373,6 +386,7 @@ class ViewerButtonRowContent extends StatelessWidget { clipBehavior: Clip.antiAlias, child: PopupMenuItem( value: action, + enabled: canApply(action), child: Tooltip( message: action.getText(context), child: Center(child: action.getIcon()), @@ -382,20 +396,27 @@ class ViewerButtonRowContent extends StatelessWidget { ); return PopupMenuItem( + padding: EdgeInsets.zero, child: IconTheme.merge( data: IconThemeData( color: ListTileTheme.of(context).iconColor, ), - child: Row( - children: [ - buildDivider(), - buildItem(EntryAction.rotateCCW), - buildDivider(), - buildItem(EntryAction.rotateCW), - buildDivider(), - buildItem(EntryAction.flip), - buildDivider(), - ], + child: ColoredBox( + color: PopupMenuTheme.of(context).color!, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + buildDivider(), + buildItem(EntryAction.rotateCCW), + buildDivider(), + buildItem(EntryAction.rotateCW), + buildDivider(), + buildItem(EntryAction.flip), + buildDivider(), + ], + ), + ), ), ), ); diff --git a/lib/widgets/viewer/video/controller.dart b/lib/widgets/viewer/video/controller.dart index 7c02997f4..280ed42cb 100644 --- a/lib/widgets/viewer/video/controller.dart +++ b/lib/widgets/viewer/video/controller.dart @@ -13,14 +13,17 @@ abstract class AvesVideoController { AvesEntry get entry => _entry; - AvesVideoController(AvesEntry entry, {required this.persistPlayback}) : _entry = entry; - static const resumeTimeSaveMinProgress = .05; static const resumeTimeSaveMaxProgress = .95; static const resumeTimeSaveMinDuration = Duration(minutes: 2); + AvesVideoController(AvesEntry entry, {required this.persistPlayback}) : _entry = entry { + entry.visualChangeNotifier.addListener(onVisualChanged); + } + @mustCallSuper Future dispose() async { + entry.visualChangeNotifier.removeListener(onVisualChanged); await _savePlaybackState(); } @@ -76,6 +79,8 @@ abstract class AvesVideoController { return resumeTime; } + void onVisualChanged(); + Future play(); Future pause(); diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index 2954be504..6377b87be 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -293,6 +293,9 @@ class IjkPlayerAvesVideoController extends AvesVideoController { _valueStreamController.add(_instance.value); } + @override + void onVisualChanged() => _init(startMillis: currentPosition); + @override Future play() async { if (isReady) { diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index f767b8d67..fd400b3df 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -137,7 +137,7 @@ class _EntryPageViewState extends State with SingleTickerProvider @override Widget build(BuildContext context) { Widget child = AnimatedBuilder( - animation: entry.imageChangeNotifier, + animation: entry.visualChangeNotifier, builder: (context, child) { Widget? child; if (entry.isSvg) {