#105 mp4 rotation

This commit is contained in:
Thibault Deckers 2022-10-23 17:07:40 +02:00
parent 2db168051e
commit e6fd46558a
14 changed files with 207 additions and 102 deletions

View file

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

View file

@ -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<Mp4ParserHelper>()
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<UserDataBox>(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<Pair<Long, ByteArray>> {
// 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<UserDataBox>(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) {

View file

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

View file

@ -47,7 +47,7 @@ class AvesEntry {
List<AvesEntry>? 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();
}
}

View file

@ -20,7 +20,7 @@ import 'package:xml/xml.dart';
extension ExtraAvesEntryMetadataEdition on AvesEntry {
Future<Set<EntryDataType>> editDate(DateModifier userModifier) async {
final Set<EntryDataType> dataTypes = {};
final dataTypes = <EntryDataType>{};
final appliedModifier = await _applyDateModifierToEntry(userModifier);
if (appliedModifier == null) {
@ -83,8 +83,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
}
Future<Set<EntryDataType>> editLocation(LatLng? latLng) async {
final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> metadata = {};
final dataTypes = <EntryDataType>{};
final metadata = <MetadataType, dynamic>{};
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
@ -151,8 +151,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
return dataTypes;
}
Future<Set<EntryDataType>> _changeOrientation(Future<Map<String, dynamic>> Function() apply) async {
final Set<EntryDataType> dataTypes = {};
Future<Set<EntryDataType>> _changeExifOrientation(Future<Map<String, dynamic>> Function() apply) async {
final dataTypes = <EntryDataType>{};
await _missingDateCheckAndExifEdit(dataTypes);
@ -170,12 +170,51 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
return dataTypes;
}
Future<Set<EntryDataType>> _rotateMp4(int rotationDegrees) async {
final dataTypes = <EntryDataType>{};
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
final mp4Fields = <MetadataField, String?>{
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, dynamic>{
MetadataType.mp4: Map<String, String?>.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<Set<EntryDataType>> 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<Set<EntryDataType>> 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<Set<EntryDataType>> editTitleDescription(Map<DescriptionField, String?> fields) async {
final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> metadata = {};
final dataTypes = <EntryDataType>{};
final metadata = <MetadataType, dynamic>{};
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
@ -256,8 +295,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
// - IPTC / keywords, if IPTC exists
// - XMP / dc:subject
Future<Set<EntryDataType>> editTags(Set<String> tags) async {
final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> metadata = {};
final dataTypes = <EntryDataType>{};
final metadata = <MetadataType, dynamic>{};
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
@ -294,8 +333,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
// - Exif / Rating
// - Exif / RatingPercent
Future<Set<EntryDataType>> editRating(int? rating) async {
final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> metadata = {};
final dataTypes = <EntryDataType>{};
final metadata = <MetadataType, dynamic>{};
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
@ -322,8 +361,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
// - XMP / GCamera:MicroVideo*
// - XMP / GCamera:MotionPhoto*
Future<Set<EntryDataType>> removeTrailerVideo() async {
final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> metadata = {};
final dataTypes = <EntryDataType>{};
final metadata = <MetadataType, dynamic>{};
if (!canEditXmp) return dataTypes;
@ -347,7 +386,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
}
Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
final Set<EntryDataType> dataTypes = {};
final dataTypes = <EntryDataType>{};
final newFields = await metadataEditService.removeTypes(this, types);
if (newFields.isNotEmpty) {

View file

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

View file

@ -370,7 +370,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
Set<String> obsoleteTags = todoItems.expand((entry) => entry.tags).toSet();
Set<String> obsoleteCountryCodes = todoItems.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull().toSet();
final Set<EntryDataType> dataTypes = {};
final dataTypes = <EntryDataType>{};
final source = context.read<CollectionSource>();
source.pauseMonitoring();
var cancelled = false;
@ -463,14 +463,14 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
}
Future<void> _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<void> _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());

View file

@ -88,12 +88,12 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
}
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<ThumbnailImage> {
}
// when the entry image itself changed (e.g. after rotation)
void _onImageChanged() async {
void _onVisualChanged() async {
// rebuild to refresh the thumbnails
_pauseProvider();
_initProvider();

View file

@ -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}',
},
),

View file

@ -104,7 +104,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
..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<ViewerVerticalPageView> {
// when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted)
Future<void> _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<ViewerVerticalPageView> {
}
// 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(() {});

View file

@ -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<VideoConductor, AvesVideoController?>(
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<EntryAction>(
@ -357,6 +358,18 @@ class ViewerButtonRowContent extends StatelessWidget {
}
PopupMenuItem<EntryAction> _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(),
],
),
),
),
),
);

View file

@ -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<void> dispose() async {
entry.visualChangeNotifier.removeListener(onVisualChanged);
await _savePlaybackState();
}
@ -76,6 +79,8 @@ abstract class AvesVideoController {
return resumeTime;
}
void onVisualChanged();
Future<void> play();
Future<void> pause();

View file

@ -293,6 +293,9 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
_valueStreamController.add(_instance.value);
}
@override
void onVisualChanged() => _init(startMillis: currentPosition);
@override
Future<void> play() async {
if (isReady) {

View file

@ -137,7 +137,7 @@ class _EntryPageViewState extends State<EntryPageView> with SingleTickerProvider
@override
Widget build(BuildContext context) {
Widget child = AnimatedBuilder(
animation: entry.imageChangeNotifier,
animation: entry.visualChangeNotifier,
builder: (context, child) {
Widget? child;
if (entry.isSvg) {