#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
|
### 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
|
- Widget: option to open collection on tap
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
@ -2,61 +2,19 @@ package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import org.mp4parser.*
|
import org.mp4parser.*
|
||||||
import org.mp4parser.boxes.UserBox
|
import org.mp4parser.boxes.UserBox
|
||||||
import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox
|
import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox
|
||||||
import org.mp4parser.boxes.iso14496.part12.FreeBox
|
import org.mp4parser.boxes.iso14496.part12.*
|
||||||
import org.mp4parser.boxes.iso14496.part12.MediaDataBox
|
|
||||||
import org.mp4parser.boxes.iso14496.part12.MovieBox
|
|
||||||
import org.mp4parser.boxes.iso14496.part12.UserDataBox
|
|
||||||
import org.mp4parser.support.AbstractBox
|
import org.mp4parser.support.AbstractBox
|
||||||
|
import org.mp4parser.support.Matrix
|
||||||
import org.mp4parser.tools.Path
|
import org.mp4parser.tools.Path
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.nio.channels.Channels
|
import java.nio.channels.Channels
|
||||||
|
|
||||||
object Mp4ParserHelper {
|
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>> {
|
fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List<Pair<Long, ByteArray>> {
|
||||||
// we can skip uninteresting boxes with a seekable data source
|
// 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")
|
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||||
|
@ -135,6 +93,67 @@ object Mp4ParserHelper {
|
||||||
|
|
||||||
// extensions
|
// 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? {
|
private fun IsoFile.getBoxOffset(test: (box: Box) -> Boolean): Long? {
|
||||||
var offset = 0L
|
var offset = 0L
|
||||||
for (box in boxes) {
|
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_IPTC
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_MP4
|
import deckers.thibault.aves.metadata.Metadata.TYPE_MP4
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
|
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.extendedXmpDocString
|
||||||
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
|
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
|
@ -563,7 +566,8 @@ abstract class ImageProvider {
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
fields: Map<*, *>
|
fieldsToEdit: Map<*, *>,
|
||||||
|
newFields: FieldMap? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (mimeType != MimeTypes.MP4) {
|
if (mimeType != MimeTypes.MP4) {
|
||||||
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||||
|
@ -572,12 +576,18 @@ abstract class ImageProvider {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val edits = Mp4ParserHelper.computeEdits(context, uri) { isoFile ->
|
val edits = Mp4ParserHelper.computeEdits(context, uri) { isoFile ->
|
||||||
fields.forEach { kv ->
|
fieldsToEdit.forEach { kv ->
|
||||||
val tag = kv.key as String
|
val tag = kv.key as String
|
||||||
val value = kv.value as String?
|
val value = kv.value as String?
|
||||||
when (tag) {
|
when (tag) {
|
||||||
"gpsCoordinates" -> Mp4ParserHelper.updateLocation(isoFile, value)
|
"gpsCoordinates" -> isoFile.updateLocation(value)
|
||||||
"xmp" -> Mp4ParserHelper.updateXmp(isoFile, 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,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
callback = callback,
|
callback = callback,
|
||||||
fields = mapOf("xmp" to coreXmp),
|
fieldsToEdit = mapOf("xmp" to coreXmp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -898,6 +908,7 @@ abstract class ImageProvider {
|
||||||
autoCorrectTrailerOffset: Boolean,
|
autoCorrectTrailerOffset: Boolean,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
) {
|
) {
|
||||||
|
val newFields: FieldMap = hashMapOf()
|
||||||
if (modifier.containsKey(TYPE_EXIF)) {
|
if (modifier.containsKey(TYPE_EXIF)) {
|
||||||
val fields = modifier[TYPE_EXIF] as Map<*, *>?
|
val fields = modifier[TYPE_EXIF] as Map<*, *>?
|
||||||
if (fields != null && fields.isNotEmpty()) {
|
if (fields != null && fields.isNotEmpty()) {
|
||||||
|
@ -970,15 +981,16 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modifier.containsKey(TYPE_MP4)) {
|
if (modifier.containsKey(TYPE_MP4)) {
|
||||||
val fields = modifier[TYPE_MP4] as Map<*, *>?
|
val fieldsToEdit = modifier[TYPE_MP4] as Map<*, *>?
|
||||||
if (fields != null && fields.isNotEmpty()) {
|
if (fieldsToEdit != null && fieldsToEdit.isNotEmpty()) {
|
||||||
if (!editMp4Metadata(
|
if (!editMp4Metadata(
|
||||||
context = context,
|
context = context,
|
||||||
path = path,
|
path = path,
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
callback = callback,
|
callback = callback,
|
||||||
fields = fields,
|
fieldsToEdit = fieldsToEdit,
|
||||||
|
newFields = newFields,
|
||||||
)
|
)
|
||||||
) return
|
) return
|
||||||
}
|
}
|
||||||
|
@ -1003,7 +1015,6 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val newFields: FieldMap = hashMapOf()
|
|
||||||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -47,7 +47,7 @@ class AvesEntry {
|
||||||
|
|
||||||
List<AvesEntry>? burstEntries;
|
List<AvesEntry>? burstEntries;
|
||||||
|
|
||||||
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
final AChangeNotifier visualChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
||||||
|
|
||||||
AvesEntry({
|
AvesEntry({
|
||||||
required int? id,
|
required int? id,
|
||||||
|
@ -176,7 +176,7 @@ class AvesEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
imageChangeNotifier.dispose();
|
visualChangeNotifier.dispose();
|
||||||
metadataChangeNotifier.dispose();
|
metadataChangeNotifier.dispose();
|
||||||
addressChangeNotifier.dispose();
|
addressChangeNotifier.dispose();
|
||||||
}
|
}
|
||||||
|
@ -292,7 +292,9 @@ class AvesEntry {
|
||||||
|
|
||||||
bool get canEditTags => canEdit && canEditXmp;
|
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);
|
bool get canEditExif => MimeTypes.canEditExif(mimeType);
|
||||||
|
|
||||||
|
@ -712,7 +714,7 @@ class AvesEntry {
|
||||||
) async {
|
) async {
|
||||||
if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
|
if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
|
||||||
await EntryCache.evict(uri, oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
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 {
|
extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
Future<Set<EntryDataType>> editDate(DateModifier userModifier) async {
|
Future<Set<EntryDataType>> editDate(DateModifier userModifier) async {
|
||||||
final Set<EntryDataType> dataTypes = {};
|
final dataTypes = <EntryDataType>{};
|
||||||
|
|
||||||
final appliedModifier = await _applyDateModifierToEntry(userModifier);
|
final appliedModifier = await _applyDateModifierToEntry(userModifier);
|
||||||
if (appliedModifier == null) {
|
if (appliedModifier == null) {
|
||||||
|
@ -83,8 +83,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Set<EntryDataType>> editLocation(LatLng? latLng) async {
|
Future<Set<EntryDataType>> editLocation(LatLng? latLng) async {
|
||||||
final Set<EntryDataType> dataTypes = {};
|
final dataTypes = <EntryDataType>{};
|
||||||
final Map<MetadataType, dynamic> metadata = {};
|
final metadata = <MetadataType, dynamic>{};
|
||||||
|
|
||||||
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
||||||
|
|
||||||
|
@ -151,8 +151,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
return dataTypes;
|
return dataTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Set<EntryDataType>> _changeOrientation(Future<Map<String, dynamic>> Function() apply) async {
|
Future<Set<EntryDataType>> _changeExifOrientation(Future<Map<String, dynamic>> Function() apply) async {
|
||||||
final Set<EntryDataType> dataTypes = {};
|
final dataTypes = <EntryDataType>{};
|
||||||
|
|
||||||
await _missingDateCheckAndExifEdit(dataTypes);
|
await _missingDateCheckAndExifEdit(dataTypes);
|
||||||
|
|
||||||
|
@ -170,12 +170,51 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
return dataTypes;
|
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}) {
|
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() {
|
Future<Set<EntryDataType>> flip() {
|
||||||
return _changeOrientation(() => metadataEditService.flip(this));
|
return _changeExifOrientation(() => metadataEditService.flip(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
// write title:
|
// write title:
|
||||||
|
@ -186,8 +225,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
// - IPTC / caption-abstract, if IPTC exists
|
// - IPTC / caption-abstract, if IPTC exists
|
||||||
// - XMP / dc:description
|
// - XMP / dc:description
|
||||||
Future<Set<EntryDataType>> editTitleDescription(Map<DescriptionField, String?> fields) async {
|
Future<Set<EntryDataType>> editTitleDescription(Map<DescriptionField, String?> fields) async {
|
||||||
final Set<EntryDataType> dataTypes = {};
|
final dataTypes = <EntryDataType>{};
|
||||||
final Map<MetadataType, dynamic> metadata = {};
|
final metadata = <MetadataType, dynamic>{};
|
||||||
|
|
||||||
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
||||||
|
|
||||||
|
@ -256,8 +295,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
// - IPTC / keywords, if IPTC exists
|
// - IPTC / keywords, if IPTC exists
|
||||||
// - XMP / dc:subject
|
// - XMP / dc:subject
|
||||||
Future<Set<EntryDataType>> editTags(Set<String> tags) async {
|
Future<Set<EntryDataType>> editTags(Set<String> tags) async {
|
||||||
final Set<EntryDataType> dataTypes = {};
|
final dataTypes = <EntryDataType>{};
|
||||||
final Map<MetadataType, dynamic> metadata = {};
|
final metadata = <MetadataType, dynamic>{};
|
||||||
|
|
||||||
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
||||||
|
|
||||||
|
@ -294,8 +333,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
// - Exif / Rating
|
// - Exif / Rating
|
||||||
// - Exif / RatingPercent
|
// - Exif / RatingPercent
|
||||||
Future<Set<EntryDataType>> editRating(int? rating) async {
|
Future<Set<EntryDataType>> editRating(int? rating) async {
|
||||||
final Set<EntryDataType> dataTypes = {};
|
final dataTypes = <EntryDataType>{};
|
||||||
final Map<MetadataType, dynamic> metadata = {};
|
final metadata = <MetadataType, dynamic>{};
|
||||||
|
|
||||||
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
||||||
|
|
||||||
|
@ -322,8 +361,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
// - XMP / GCamera:MicroVideo*
|
// - XMP / GCamera:MicroVideo*
|
||||||
// - XMP / GCamera:MotionPhoto*
|
// - XMP / GCamera:MotionPhoto*
|
||||||
Future<Set<EntryDataType>> removeTrailerVideo() async {
|
Future<Set<EntryDataType>> removeTrailerVideo() async {
|
||||||
final Set<EntryDataType> dataTypes = {};
|
final dataTypes = <EntryDataType>{};
|
||||||
final Map<MetadataType, dynamic> metadata = {};
|
final metadata = <MetadataType, dynamic>{};
|
||||||
|
|
||||||
if (!canEditXmp) return dataTypes;
|
if (!canEditXmp) return dataTypes;
|
||||||
|
|
||||||
|
@ -347,7 +386,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
|
Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
|
||||||
final Set<EntryDataType> dataTypes = {};
|
final dataTypes = <EntryDataType>{};
|
||||||
|
|
||||||
final newFields = await metadataEditService.removeTypes(this, types);
|
final newFields = await metadataEditService.removeTypes(this, types);
|
||||||
if (newFields.isNotEmpty) {
|
if (newFields.isNotEmpty) {
|
||||||
|
|
|
@ -38,6 +38,7 @@ enum MetadataField {
|
||||||
exifGpsVersionId,
|
exifGpsVersionId,
|
||||||
exifImageDescription,
|
exifImageDescription,
|
||||||
mp4GpsCoordinates,
|
mp4GpsCoordinates,
|
||||||
|
mp4RotationDegrees,
|
||||||
mp4Xmp,
|
mp4Xmp,
|
||||||
xmpXmpCreateDate,
|
xmpXmpCreateDate,
|
||||||
}
|
}
|
||||||
|
@ -120,6 +121,7 @@ extension ExtraMetadataField on MetadataField {
|
||||||
case MetadataField.exifImageDescription:
|
case MetadataField.exifImageDescription:
|
||||||
return MetadataType.exif;
|
return MetadataType.exif;
|
||||||
case MetadataField.mp4GpsCoordinates:
|
case MetadataField.mp4GpsCoordinates:
|
||||||
|
case MetadataField.mp4RotationDegrees:
|
||||||
case MetadataField.mp4Xmp:
|
case MetadataField.mp4Xmp:
|
||||||
return MetadataType.mp4;
|
return MetadataType.mp4;
|
||||||
case MetadataField.xmpXmpCreateDate:
|
case MetadataField.xmpXmpCreateDate:
|
||||||
|
@ -134,6 +136,8 @@ extension ExtraMetadataField on MetadataField {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case MetadataField.mp4GpsCoordinates:
|
case MetadataField.mp4GpsCoordinates:
|
||||||
return 'gpsCoordinates';
|
return 'gpsCoordinates';
|
||||||
|
case MetadataField.mp4RotationDegrees:
|
||||||
|
return 'rotationDegrees';
|
||||||
case MetadataField.mp4Xmp:
|
case MetadataField.mp4Xmp:
|
||||||
return 'xmp';
|
return 'xmp';
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -370,7 +370,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
Set<String> obsoleteTags = todoItems.expand((entry) => entry.tags).toSet();
|
Set<String> obsoleteTags = todoItems.expand((entry) => entry.tags).toSet();
|
||||||
Set<String> obsoleteCountryCodes = todoItems.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull().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>();
|
final source = context.read<CollectionSource>();
|
||||||
source.pauseMonitoring();
|
source.pauseMonitoring();
|
||||||
var cancelled = false;
|
var cancelled = false;
|
||||||
|
@ -463,14 +463,14 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _rotate(BuildContext context, {required bool clockwise}) async {
|
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;
|
if (entries == null || entries.isEmpty) return;
|
||||||
|
|
||||||
await _edit(context, entries, (entry) => entry.rotate(clockwise: clockwise));
|
await _edit(context, entries, (entry) => entry.rotate(clockwise: clockwise));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _flip(BuildContext context) async {
|
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;
|
if (entries == null || entries.isEmpty) return;
|
||||||
|
|
||||||
await _edit(context, entries, (entry) => entry.flip());
|
await _edit(context, entries, (entry) => entry.flip());
|
||||||
|
|
|
@ -88,12 +88,12 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _registerWidget(ThumbnailImage widget) {
|
void _registerWidget(ThumbnailImage widget) {
|
||||||
widget.entry.imageChangeNotifier.addListener(_onImageChanged);
|
widget.entry.visualChangeNotifier.addListener(_onVisualChanged);
|
||||||
_initProvider();
|
_initProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _unregisterWidget(ThumbnailImage widget) {
|
void _unregisterWidget(ThumbnailImage widget) {
|
||||||
widget.entry.imageChangeNotifier.removeListener(_onImageChanged);
|
widget.entry.visualChangeNotifier.removeListener(_onVisualChanged);
|
||||||
_pauseProvider();
|
_pauseProvider();
|
||||||
_currentProviderStream?.stopListening();
|
_currentProviderStream?.stopListening();
|
||||||
_currentProviderStream = null;
|
_currentProviderStream = null;
|
||||||
|
@ -313,7 +313,7 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// when the entry image itself changed (e.g. after rotation)
|
// when the entry image itself changed (e.g. after rotation)
|
||||||
void _onImageChanged() async {
|
void _onVisualChanged() async {
|
||||||
// rebuild to refresh the thumbnails
|
// rebuild to refresh the thumbnails
|
||||||
_pauseProvider();
|
_pauseProvider();
|
||||||
_initProvider();
|
_initProvider();
|
||||||
|
|
|
@ -141,7 +141,8 @@ class ViewerDebugPage extends StatelessWidget {
|
||||||
'canEdit': '${entry.canEdit}',
|
'canEdit': '${entry.canEdit}',
|
||||||
'canEditDate': '${entry.canEditDate}',
|
'canEditDate': '${entry.canEditDate}',
|
||||||
'canEditTags': '${entry.canEditTags}',
|
'canEditTags': '${entry.canEditTags}',
|
||||||
'canRotateAndFlip': '${entry.canRotateAndFlip}',
|
'canRotate': '${entry.canRotate}',
|
||||||
|
'canFlip': '${entry.canFlip}',
|
||||||
'tags': '${entry.tags}',
|
'tags': '${entry.tags}',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
@ -104,7 +104,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
||||||
..clear();
|
..clear();
|
||||||
widget.verticalPager.removeListener(_onVerticalPageControllerChanged);
|
widget.verticalPager.removeListener(_onVerticalPageControllerChanged);
|
||||||
widget.entryNotifier.removeListener(_onEntryChanged);
|
widget.entryNotifier.removeListener(_onEntryChanged);
|
||||||
_oldEntry?.imageChangeNotifier.removeListener(_onImageChanged);
|
_oldEntry?.visualChangeNotifier.removeListener(_onVisualChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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)
|
// when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted)
|
||||||
Future<void> _onEntryChanged() async {
|
Future<void> _onEntryChanged() async {
|
||||||
_oldEntry?.imageChangeNotifier.removeListener(_onImageChanged);
|
_oldEntry?.visualChangeNotifier.removeListener(_onVisualChanged);
|
||||||
_oldEntry = entry;
|
_oldEntry = entry;
|
||||||
|
|
||||||
final _entry = entry;
|
final _entry = entry;
|
||||||
if (_entry != null) {
|
if (_entry != null) {
|
||||||
_entry.imageChangeNotifier.addListener(_onImageChanged);
|
_entry.visualChangeNotifier.addListener(_onVisualChanged);
|
||||||
// make sure to locate the entry,
|
// make sure to locate the entry,
|
||||||
// so that we can display the address instead of coordinates
|
// so that we can display the address instead of coordinates
|
||||||
// even when initial collection locating has not reached this entry yet
|
// 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)
|
// when the entry image itself changed (e.g. after rotation)
|
||||||
void _onImageChanged() async {
|
void _onVisualChanged() async {
|
||||||
// rebuild to refresh the Image inside ImagePage
|
// rebuild to refresh the Image inside ImagePage
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
|
|
@ -4,10 +4,10 @@ import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.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/menu.dart';
|
||||||
import 'package:aves/widgets/common/basic/popup_menu_button.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/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/action/entry_action_delegate.dart';
|
||||||
import 'package:aves/widgets/viewer/multipage/conductor.dart';
|
import 'package:aves/widgets/viewer/multipage/conductor.dart';
|
||||||
import 'package:aves/widgets/viewer/notifications.dart';
|
import 'package:aves/widgets/viewer/notifications.dart';
|
||||||
|
@ -70,8 +70,9 @@ class ViewerButtons extends StatelessWidget {
|
||||||
return targetEntry.canEdit;
|
return targetEntry.canEdit;
|
||||||
case EntryAction.rotateCCW:
|
case EntryAction.rotateCCW:
|
||||||
case EntryAction.rotateCW:
|
case EntryAction.rotateCW:
|
||||||
|
return targetEntry.canRotate;
|
||||||
case EntryAction.flip:
|
case EntryAction.flip:
|
||||||
return targetEntry.canRotateAndFlip;
|
return targetEntry.canFlip;
|
||||||
case EntryAction.convert:
|
case EntryAction.convert:
|
||||||
case EntryAction.print:
|
case EntryAction.print:
|
||||||
return !targetEntry.isVideo && device.canPrint;
|
return !targetEntry.isVideo && device.canPrint;
|
||||||
|
@ -161,7 +162,7 @@ class ViewerButtonRowContent extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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?>(
|
return Selector<VideoConductor, AvesVideoController?>(
|
||||||
selector: (context, vc) => vc.getController(pageEntry),
|
selector: (context, vc) => vc.getController(pageEntry),
|
||||||
builder: (context, videoController, child) {
|
builder: (context, videoController, child) {
|
||||||
|
@ -183,7 +184,7 @@ class ViewerButtonRowContent extends StatelessWidget {
|
||||||
final exportInternalActions = exportActions.whereNot(EntryActions.exportExternal.contains).toList();
|
final exportInternalActions = exportActions.whereNot(EntryActions.exportExternal.contains).toList();
|
||||||
final exportExternalActions = exportActions.where(EntryActions.exportExternal.contains).toList();
|
final exportExternalActions = exportActions.where(EntryActions.exportExternal.contains).toList();
|
||||||
return [
|
return [
|
||||||
if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
|
if (pageEntry.canRotate || pageEntry.canFlip) _buildRotateAndFlipMenuItems(context),
|
||||||
...topLevelActions.map((action) => _buildPopupMenuItem(context, action, videoController)),
|
...topLevelActions.map((action) => _buildPopupMenuItem(context, action, videoController)),
|
||||||
if (exportActions.isNotEmpty)
|
if (exportActions.isNotEmpty)
|
||||||
PopupMenuItem<EntryAction>(
|
PopupMenuItem<EntryAction>(
|
||||||
|
@ -357,6 +358,18 @@ class ViewerButtonRowContent extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
PopupMenuItem<EntryAction> _buildRotateAndFlipMenuItems(BuildContext context) {
|
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(
|
Widget buildDivider() => const SizedBox(
|
||||||
height: 16,
|
height: 16,
|
||||||
child: VerticalDivider(
|
child: VerticalDivider(
|
||||||
|
@ -373,6 +386,7 @@ class ViewerButtonRowContent extends StatelessWidget {
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: PopupMenuItem(
|
child: PopupMenuItem(
|
||||||
value: action,
|
value: action,
|
||||||
|
enabled: canApply(action),
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: action.getText(context),
|
message: action.getText(context),
|
||||||
child: Center(child: action.getIcon()),
|
child: Center(child: action.getIcon()),
|
||||||
|
@ -382,10 +396,15 @@ class ViewerButtonRowContent extends StatelessWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
return PopupMenuItem(
|
return PopupMenuItem(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
child: IconTheme.merge(
|
child: IconTheme.merge(
|
||||||
data: IconThemeData(
|
data: IconThemeData(
|
||||||
color: ListTileTheme.of(context).iconColor,
|
color: ListTileTheme.of(context).iconColor,
|
||||||
),
|
),
|
||||||
|
child: ColoredBox(
|
||||||
|
color: PopupMenuTheme.of(context).color!,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
buildDivider(),
|
buildDivider(),
|
||||||
|
@ -398,6 +417,8 @@ class ViewerButtonRowContent extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,14 +13,17 @@ abstract class AvesVideoController {
|
||||||
|
|
||||||
AvesEntry get entry => _entry;
|
AvesEntry get entry => _entry;
|
||||||
|
|
||||||
AvesVideoController(AvesEntry entry, {required this.persistPlayback}) : _entry = entry;
|
|
||||||
|
|
||||||
static const resumeTimeSaveMinProgress = .05;
|
static const resumeTimeSaveMinProgress = .05;
|
||||||
static const resumeTimeSaveMaxProgress = .95;
|
static const resumeTimeSaveMaxProgress = .95;
|
||||||
static const resumeTimeSaveMinDuration = Duration(minutes: 2);
|
static const resumeTimeSaveMinDuration = Duration(minutes: 2);
|
||||||
|
|
||||||
|
AvesVideoController(AvesEntry entry, {required this.persistPlayback}) : _entry = entry {
|
||||||
|
entry.visualChangeNotifier.addListener(onVisualChanged);
|
||||||
|
}
|
||||||
|
|
||||||
@mustCallSuper
|
@mustCallSuper
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
|
entry.visualChangeNotifier.removeListener(onVisualChanged);
|
||||||
await _savePlaybackState();
|
await _savePlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,6 +79,8 @@ abstract class AvesVideoController {
|
||||||
return resumeTime;
|
return resumeTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void onVisualChanged();
|
||||||
|
|
||||||
Future<void> play();
|
Future<void> play();
|
||||||
|
|
||||||
Future<void> pause();
|
Future<void> pause();
|
||||||
|
|
|
@ -293,6 +293,9 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
|
||||||
_valueStreamController.add(_instance.value);
|
_valueStreamController.add(_instance.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onVisualChanged() => _init(startMillis: currentPosition);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> play() async {
|
Future<void> play() async {
|
||||||
if (isReady) {
|
if (isReady) {
|
||||||
|
|
|
@ -137,7 +137,7 @@ class _EntryPageViewState extends State<EntryPageView> with SingleTickerProvider
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Widget child = AnimatedBuilder(
|
Widget child = AnimatedBuilder(
|
||||||
animation: entry.imageChangeNotifier,
|
animation: entry.visualChangeNotifier,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
Widget? child;
|
Widget? child;
|
||||||
if (entry.isSvg) {
|
if (entry.isSvg) {
|
||||||
|
|
Loading…
Reference in a new issue