#105 mp4 rotation
This commit is contained in:
parent
2db168051e
commit
e6fd46558a
14 changed files with 207 additions and 102 deletions
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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}',
|
||||
},
|
||||
),
|
||||
|
|
|
@ -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(() {});
|
||||
|
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue