diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 3f42b6d31..ab50472b2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -18,6 +18,7 @@ import com.drew.metadata.exif.GpsDirectory import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.gif.GifAnimationDirectory import com.drew.metadata.iptc.IptcDirectory +import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.xmp.XmpDirectory import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll @@ -299,6 +300,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } } + + // identification of panorama (aka photo sphere) + if (XMP.panoramaRequiredProps.all { xmpMeta.doesPropertyExist(XMP.GPANO_SCHEMA_NS, it) }) { + flags = flags or MASK_IS_360 + } } catch (e: XMPException) { Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) } @@ -329,6 +335,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } } + + // identification of spherical video (aka 360° video) + if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any { + it.getString(Mp4UuidBoxDirectory.TAG_UUID) == Metadata.SPHERICAL_VIDEO_V1_UUID + }) { + flags = flags or MASK_IS_360 + } } } catch (e: Exception) { Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=$uri, mimeType=$mimeType", e) @@ -635,6 +648,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private const val MASK_IS_ANIMATED = 1 shl 0 private const val MASK_IS_FLIPPED = 1 shl 1 private const val MASK_IS_GEOTIFF = 1 shl 2 + private const val MASK_IS_360 = 1 shl 3 private const val XMP_SUBJECTS_SEPARATOR = ";" // overlay metadata diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 036ac6b18..22442e254 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -13,6 +13,9 @@ object Metadata { // "+51.3328-000.7053+113.474/" (Apple) val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*") + // cf https://github.com/google/spatial-media + const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd" + // directory names, as shown when listing all metadata const val DIR_GPS = "GPS" // from metadata-extractor const val DIR_XMP = "XMP" // from metadata-extractor diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index cd49d4ea2..9572a76d6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -23,27 +23,66 @@ object XMP { private const val GENERIC_LANG = "" private const val SPECIFIC_LANG = "en-US" + // panorama + // cf https://developers.google.com/streetview/spherical-metadata + + const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/" + + private const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels" + private const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels" + private const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels" + private const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels" + private const val GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME = "GPano:FullPanoHeightPixels" + private const val GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME = "GPano:FullPanoWidthPixels" + private const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType" + + val panoramaRequiredProps = listOf( + GPANO_CROPPED_AREA_HEIGHT_PROP_NAME, + GPANO_CROPPED_AREA_WIDTH_PROP_NAME, + GPANO_CROPPED_AREA_LEFT_PROP_NAME, + GPANO_CROPPED_AREA_TOP_PROP_NAME, + GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME, + GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME, + GPANO_PROJECTION_TYPE_PROP_NAME, + ) + // embedded media data properties + // cf https://developers.google.com/depthmap-metadata + // cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/" private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/" private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/" private const val GAUDIO_DATA_PROP_NAME = "GAudio:Data" - private const val GDEPTH_DATA_PROP_NAME = "GDepth:Data" private const val GIMAGE_DATA_PROP_NAME = "GImage:Data" + private const val GDEPTH_DATA_PROP_NAME = "GDepth:Data" + private const val GDEPTH_CONFIDENCE_PROP_NAME = "GDepth:Confidence" - private val dataProps = hashMapOf( + private const val GAUDIO_MIME_PROP_NAME = "GAudio:Mime" + private const val GIMAGE_MIME_PROP_NAME = "GImage:Mime" + private const val GDEPTH_MIME_PROP_NAME = "GDepth:Mime" + private const val GDEPTH_CONFIDENCE_MIME_PROP_NAME = "GDepth:ConfidenceMime" + + private val dataPropNamespaces = hashMapOf( GAUDIO_DATA_PROP_NAME to GAUDIO_SCHEMA_NS, - GDEPTH_DATA_PROP_NAME to GDEPTH_SCHEMA_NS, GIMAGE_DATA_PROP_NAME to GIMAGE_SCHEMA_NS, + GDEPTH_DATA_PROP_NAME to GDEPTH_SCHEMA_NS, + GDEPTH_CONFIDENCE_PROP_NAME to GDEPTH_SCHEMA_NS, ) - fun isDataPath(path: String) = dataProps.containsKey(path) + private val dataPropMimeProps = hashMapOf( + GAUDIO_DATA_PROP_NAME to GAUDIO_MIME_PROP_NAME, + GIMAGE_DATA_PROP_NAME to GIMAGE_MIME_PROP_NAME, + GDEPTH_DATA_PROP_NAME to GDEPTH_MIME_PROP_NAME, + GDEPTH_CONFIDENCE_PROP_NAME to GDEPTH_CONFIDENCE_MIME_PROP_NAME, + ) - fun namespaceForDataPath(path: String) = dataProps[path] + fun isDataPath(path: String) = dataPropNamespaces.containsKey(path) - fun mimeTypePathForDataPath(dataPropPath: String) = dataPropPath.replace("Data", "Mime") + fun namespaceForDataPath(dataPropPath: String) = dataPropNamespaces[dataPropPath] + + fun mimeTypePathForDataPath(dataPropPath: String) = dataPropMimeProps[dataPropPath] // extensions diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 02ac50252..9b9cb7213 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -10,6 +10,9 @@ class MimeFilter extends CollectionFilter { // fake mime type static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp` + static const panorama = 'aves/panorama'; // subset of images + static const sphericalVideo = 'aves/spherical_video'; // subset of videos + static const geotiff = 'aves/geotiff'; // subset of `image/tiff` final String mime; bool Function(ImageEntry) _filter; @@ -22,6 +25,18 @@ class MimeFilter extends CollectionFilter { _filter = (entry) => entry.isAnimated; _label = 'Animated'; _icon = AIcons.animated; + } else if (mime == panorama) { + _filter = (entry) => entry.isImage && entry.is360; + _label = 'Panorama'; + _icon = AIcons.threesixty; + } else if (mime == sphericalVideo) { + _filter = (entry) => entry.isVideo && entry.is360; + _label = '360° Video'; + _icon = AIcons.threesixty; + } else if (mime == geotiff) { + _filter = (entry) => entry.isGeotiff; + _label = 'GeoTIFF'; + _icon = AIcons.geo; } else if (lowMime.endsWith('/*')) { lowMime = lowMime.substring(0, lowMime.length - 2); _filter = (entry) => entry.mimeType.startsWith(lowMime); diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 73d2d9c79..56251265e 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -200,6 +200,8 @@ class ImageEntry { bool get isRaw => MimeTypes.rawImages.contains(mimeType); + bool get isImage => MimeTypes.isImage(mimeType); + bool get isVideo => MimeTypes.isVideo(mimeType); bool get isCatalogued => _catalogMetadata != null; @@ -208,6 +210,8 @@ class ImageEntry { bool get isGeotiff => _catalogMetadata?.isGeotiff ?? false; + bool get is360 => _catalogMetadata?.is360 ?? false; + bool get canEdit => path != null; bool get canPrint => !isVideo; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index bfc6b84b7..ac10537d9 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -30,7 +30,7 @@ class DateMetadata { class CatalogMetadata { final int contentId, dateMillis; - final bool isAnimated, isGeotiff; + final bool isAnimated, isGeotiff, is360; bool isFlipped; int rotationDegrees; final String mimeType, xmpSubjects, xmpTitleDescription; @@ -38,9 +38,10 @@ class CatalogMetadata { Address address; static const double _precisionErrorTolerance = 1e-9; - static const isAnimatedMask = 1 << 0; - static const isFlippedMask = 1 << 1; - static const isGeotiffMask = 1 << 2; + static const _isAnimatedMask = 1 << 0; + static const _isFlippedMask = 1 << 1; + static const _isGeotiffMask = 1 << 2; + static const _is360Mask = 1 << 3; CatalogMetadata({ this.contentId, @@ -49,6 +50,7 @@ class CatalogMetadata { this.isAnimated, this.isFlipped, this.isGeotiff, + this.is360, this.rotationDegrees, this.xmpSubjects, this.xmpTitleDescription, @@ -74,6 +76,7 @@ class CatalogMetadata { isAnimated: isAnimated, isFlipped: isFlipped, isGeotiff: isGeotiff, + is360: is360, rotationDegrees: rotationDegrees, xmpSubjects: xmpSubjects, xmpTitleDescription: xmpTitleDescription, @@ -88,9 +91,10 @@ class CatalogMetadata { contentId: map['contentId'], mimeType: map['mimeType'], dateMillis: map['dateMillis'] ?? 0, - isAnimated: flags & isAnimatedMask != 0, - isFlipped: flags & isFlippedMask != 0, - isGeotiff: flags & isGeotiffMask != 0, + isAnimated: flags & _isAnimatedMask != 0, + isFlipped: flags & _isFlippedMask != 0, + isGeotiff: flags & _isGeotiffMask != 0, + is360: flags & _is360Mask != 0, // `rotationDegrees` should default to `sourceRotationDegrees`, not 0 rotationDegrees: map['rotationDegrees'], xmpSubjects: map['xmpSubjects'] ?? '', @@ -104,7 +108,7 @@ class CatalogMetadata { 'contentId': contentId, 'mimeType': mimeType, 'dateMillis': dateMillis, - 'flags': (isAnimated ? isAnimatedMask : 0) | (isFlipped ? isFlippedMask : 0) | (isGeotiff ? isGeotiffMask : 0), + 'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0), 'rotationDegrees': rotationDegrees, 'xmpSubjects': xmpSubjects, 'xmpTitleDescription': xmpTitleDescription, @@ -114,7 +118,7 @@ class CatalogMetadata { @override String toString() { - return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; + return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; } } diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index d198c454a..755e14a37 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -63,6 +63,7 @@ class AIcons { static const IconData animated = Icons.slideshow; static const IconData geo = Icons.language_outlined; static const IconData play = Icons.play_circle_outline; + static const IconData threesixty = Icons.threesixty_outlined; static const IconData selected = Icons.check_circle_outline; static const IconData unselected = Icons.radio_button_unchecked; } diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index a834070cc..29b464c74 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -50,7 +50,9 @@ class ThumbnailEntryOverlay extends StatelessWidget { iconSize: iconSize, showDuration: settings.showThumbnailVideoDuration, ), - ), + ) + else if (entry.is360) + SphericalImageIcon(iconSize: iconSize), ], ); }); diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 382b26f75..4ac616a21 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -23,9 +23,10 @@ class VideoIcon extends StatelessWidget { @override Widget build(BuildContext context) { return OverlayIcon( - icon: AIcons.play, + icon: entry.is360 ? AIcons.threesixty : AIcons.play, size: iconSize, text: showDuration ? entry.durationText : null, + iconScale: entry.is360 && showDuration ? .9 : 1, ); } } @@ -59,6 +60,20 @@ class GeotiffIcon extends StatelessWidget { } } +class SphericalImageIcon extends StatelessWidget { + final double iconSize; + + const SphericalImageIcon({Key key, this.iconSize}) : super(key: key); + + @override + Widget build(BuildContext context) { + return OverlayIcon( + icon: AIcons.threesixty, + size: iconSize, + ); + } +} + class GpsIcon extends StatelessWidget { final double iconSize; diff --git a/lib/widgets/fullscreen/fullscreen_debug_page.dart b/lib/widgets/fullscreen/fullscreen_debug_page.dart index 0afd2e51a..0abf248a0 100644 --- a/lib/widgets/fullscreen/fullscreen_debug_page.dart +++ b/lib/widgets/fullscreen/fullscreen_debug_page.dart @@ -97,6 +97,7 @@ class FullscreenDebugPage extends StatelessWidget { 'isCatalogued': '${entry.isCatalogued}', 'isAnimated': '${entry.isAnimated}', 'isGeotiff': '${entry.isGeotiff}', + 'is360': '${entry.is360}', 'canEdit': '${entry.canEdit}', 'canEditExif': '${entry.canEditExif}', 'canPrint': '${entry.canPrint}', diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index 7b07a39a9..958ba72b1 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -54,8 +54,10 @@ class BasicSection extends StatelessWidget { final tags = entry.xmpSubjects..sort(compareAsciiUpperCase); final album = entry.directory; final filters = [ - if (entry.isVideo) MimeFilter(MimeTypes.anyVideo), if (entry.isAnimated) MimeFilter(MimeFilter.animated), + if (entry.isImage && entry.is360) MimeFilter(MimeFilter.panorama), + if (entry.isVideo) MimeFilter(entry.is360 ? MimeFilter.sphericalVideo : MimeTypes.anyVideo), + if (entry.isGeotiff) MimeFilter(MimeFilter.geotiff), if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)), ...tags.map((tag) => TagFilter(tag)), ]; diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 754150d56..febeec7b5 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -82,6 +82,9 @@ class ImageSearchDelegate { MimeFilter(MimeTypes.anyImage), MimeFilter(MimeTypes.anyVideo), MimeFilter(MimeFilter.animated), + MimeFilter(MimeFilter.panorama), + MimeFilter(MimeFilter.sphericalVideo), + MimeFilter(MimeFilter.geotiff), MimeFilter(MimeTypes.svg), ].where((f) => f != null && containQuery(f.label)), // usually perform hero animation only on tapped chips,