From c941783fd374982371ffede73c97cc4de6f1357c Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 12 Jul 2024 17:45:30 +0530 Subject: [PATCH 01/15] [mob] Expose and log video metadata --- mobile/lib/models/ffmpeg/ffprobe_props.dart | 8 ++++++++ mobile/lib/ui/viewer/file/file_details_widget.dart | 11 +++++++++-- mobile/lib/utils/ffprobe_util.dart | 4 ++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index abbc56d41b..6a9fff317a 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -1,5 +1,7 @@ // Adapted from: https://github.com/deckerst/aves +import "dart:developer"; + import "package:collection/collection.dart"; import "package:intl/intl.dart"; import "package:photos/models/ffmpeg/channel_layouts.dart"; @@ -128,6 +130,12 @@ class FFProbeProps { required this.xiaomiSlowMoment, }); + // toString() method + @override + String toString() { + return 'FFProbeProps(captureFps: $captureFps, androidManufacturer: $androidManufacturer, androidModel: $androidModel, androidVersion: $androidVersion, bitRate: $bitRate, bitsPerRawSample: $bitsPerRawSample, byteCount: $byteCount, channelLayout: $channelLayout, chromaLocation: $chromaLocation, codecName: $codecName, codecPixelFormat: $codecPixelFormat, codedHeight: $codedHeight, codedWidth: $codedWidth, colorPrimaries: $colorPrimaries, colorRange: $colorRange, colorSpace: $colorSpace, colorTransfer: $colorTransfer, colorProfile: $colorProfile, compatibleBrands: $compatibleBrands, creationTime: $creationTime, displayAspectRatio: $displayAspectRatio, date: $date, duration: $duration, durationMicros: $durationMicros, extraDataSize: $extraDataSize, fieldOrder: $fieldOrder, fpsDen: $fpsDen, frameCount: $frameCount, handlerName: $handlerName, hasBFrames: $hasBFrames, height: $height, language: $language, location: $location, majorBrand: $majorBrand, mediaFormat: $mediaFormat, mediaType: $mediaType, minorVersion: $minorVersion, nalLengthSize: $nalLengthSize, quicktimeLocationAccuracyHorizontal: $quicktimeLocationAccuracyHorizontal, rFrameRate: $rFrameRate, rotate: $rotate, sampleFormat: $sampleFormat, sampleRate: $sampleRate, sampleAspectRatio: $sampleAspectRatio, sarDen: $sarDen, segmentCount: $segmentCount, sourceOshash: $sourceOshash, startMicros: $startMicros, startPts: $startPts, startTime: $startTime, statisticsWritingApp: $statisticsWritingApp, statisticsWritingDateUtc: $statisticsWritingDateUtc, timeBase: $timeBase, track: $track, vendorId: $vendorId, width: $width, xiaomiSlowMoment: $xiaomiSlowMoment)'; + } + factory FFProbeProps.fromJson(Map? json) { return FFProbeProps( captureFps: diff --git a/mobile/lib/ui/viewer/file/file_details_widget.dart b/mobile/lib/ui/viewer/file/file_details_widget.dart index fb15a3148d..81418ec976 100644 --- a/mobile/lib/ui/viewer/file/file_details_widget.dart +++ b/mobile/lib/ui/viewer/file/file_details_widget.dart @@ -1,4 +1,5 @@ import "dart:async"; +import "dart:developer"; import "dart:io"; import "package:exif/exif.dart"; @@ -110,6 +111,7 @@ class _FileDetailsWidgetState extends State { final File? originFile = await getFile(widget.file, isOrigin: true); if (originFile == null) return; final session = await FFprobeKit.getMediaInformation(originFile.path); + final mediaInfo = session.getMediaInformation(); if (mediaInfo == null) { @@ -120,8 +122,13 @@ class _FileDetailsWidgetState extends State { ); return; } - //todo:(neeraj) Use probe data for back filling location - final _ = await FFProbeUtil.getProperties(mediaInfo); + final properties = await FFProbeUtil.getProperties(mediaInfo); + final parsedMetadata = await FFProbeUtil.getMetadata(mediaInfo); + + // print all the properties + log("videoCustomProps ${properties.toString()}"); + log("videoMetadata ${parsedMetadata.toString()}"); + setState(() {}); } diff --git a/mobile/lib/utils/ffprobe_util.dart b/mobile/lib/utils/ffprobe_util.dart index a4fea7cfff..289bf5962b 100644 --- a/mobile/lib/utils/ffprobe_util.dart +++ b/mobile/lib/utils/ffprobe_util.dart @@ -14,7 +14,7 @@ class FFProbeUtil { static Future getProperties( MediaInformation mediaInformation, ) async { - final properties = await _getMetadata(mediaInformation); + final properties = await getMetadata(mediaInformation); try { return FFProbeProps.fromJson(properties); @@ -28,7 +28,7 @@ class FFProbeUtil { } } - static Future _getMetadata(MediaInformation information) async { + static Future getMetadata(MediaInformation information) async { final props = information.getAllProperties(); if (props == null) return {}; From 74f4698fd6e90bda061b28456329a0139f607cbe Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 15 Jul 2024 10:21:19 +0530 Subject: [PATCH 02/15] [mob] Format brand --- mobile/lib/models/ffmpeg/ffprobe_props.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index 6a9fff317a..9dd5b13b17 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -183,7 +183,7 @@ class FFProbeProps { height: int.tryParse(json?[FFProbeKeys.height] ?? ""), language: json?[FFProbeKeys.language], location: _formatLocation(json?[FFProbeKeys.location]), - majorBrand: json?[FFProbeKeys.majorBrand], + majorBrand: _formatBrand(json?[FFProbeKeys.majorBrand]), mediaFormat: json?[FFProbeKeys.mediaFormat], mediaType: json?[FFProbeKeys.mediaType], minorVersion: json?[FFProbeKeys.minorVersion], From e2ef2eacc4f6f2c2740470b6a9dfdff6e418b9e4 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 14:12:46 +0530 Subject: [PATCH 03/15] [mob] Display video metadata in file info --- mobile/lib/models/ffmpeg/ffprobe_props.dart | 247 ++++-------------- .../ui/viewer/file/file_details_widget.dart | 46 +++- .../lib/ui/viewer/file/video_ffmpeg_info.dart | 122 +++++++++ .../ui/viewer/file_details/exif_video.dart | 90 +++++++ 4 files changed, 313 insertions(+), 192 deletions(-) create mode 100644 mobile/lib/ui/viewer/file/video_ffmpeg_info.dart create mode 100644 mobile/lib/ui/viewer/file_details/exif_video.dart diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index 9dd5b13b17..075bd4e5d6 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -1,7 +1,5 @@ // Adapted from: https://github.com/deckerst/aves -import "dart:developer"; - import "package:collection/collection.dart"; import "package:intl/intl.dart"; import "package:photos/models/ffmpeg/channel_layouts.dart"; @@ -12,203 +10,74 @@ import "package:photos/models/ffmpeg/mp4.dart"; import "package:photos/models/location/location.dart"; class FFProbeProps { - final double? captureFps; - final String? androidManufacturer; - final String? androidModel; - final String? androidVersion; - final String? bitRate; - final String? bitsPerRawSample; - final String? byteCount; - final String? channelLayout; - final String? chromaLocation; - final String? codecName; - final String? codecPixelFormat; - final int? codedHeight; - final int? codedWidth; - final String? colorPrimaries; - final String? colorRange; - final String? colorSpace; - final String? colorTransfer; - final String? colorProfile; - final String? compatibleBrands; - final String? creationTime; - final String? displayAspectRatio; - final DateTime? date; - final String? duration; - final String? durationMicros; - final String? extraDataSize; - final String? fieldOrder; - final String? fpsDen; - final int? frameCount; - final String? handlerName; - final bool? hasBFrames; - final int? height; - final String? language; - final Location? location; - final String? majorBrand; - final String? mediaFormat; - final String? mediaType; - final String? minorVersion; - final String? nalLengthSize; - final String? quicktimeLocationAccuracyHorizontal; - final int? rFrameRate; - final String? rotate; - final String? sampleFormat; - final String? sampleRate; - final String? sampleAspectRatio; - final String? sarDen; - final int? segmentCount; - final String? sourceOshash; - final String? startMicros; - final String? startPts; - final String? startTime; - final String? statisticsWritingApp; - final String? statisticsWritingDateUtc; - final String? timeBase; - final String? track; - final String? vendorId; - final int? width; - final String? xiaomiSlowMoment; + final Map? prodData; FFProbeProps({ - required this.captureFps, - required this.androidManufacturer, - required this.androidModel, - required this.androidVersion, - required this.bitRate, - required this.bitsPerRawSample, - required this.byteCount, - required this.channelLayout, - required this.chromaLocation, - required this.codecName, - required this.codecPixelFormat, - required this.codedHeight, - required this.codedWidth, - required this.colorPrimaries, - required this.colorRange, - required this.colorSpace, - required this.colorTransfer, - required this.colorProfile, - required this.compatibleBrands, - required this.creationTime, - required this.displayAspectRatio, - required this.date, - required this.duration, - required this.durationMicros, - required this.extraDataSize, - required this.fieldOrder, - required this.fpsDen, - required this.frameCount, - required this.handlerName, - required this.hasBFrames, - required this.height, - required this.language, - required this.location, - required this.majorBrand, - required this.mediaFormat, - required this.mediaType, - required this.minorVersion, - required this.nalLengthSize, - required this.quicktimeLocationAccuracyHorizontal, - required this.rFrameRate, - required this.rotate, - required this.sampleFormat, - required this.sampleRate, - required this.sampleAspectRatio, - required this.sarDen, - required this.segmentCount, - required this.sourceOshash, - required this.startMicros, - required this.startPts, - required this.startTime, - required this.statisticsWritingApp, - required this.statisticsWritingDateUtc, - required this.timeBase, - required this.track, - required this.vendorId, - required this.width, - required this.xiaomiSlowMoment, + required this.prodData, }); // toString() method @override String toString() { - return 'FFProbeProps(captureFps: $captureFps, androidManufacturer: $androidManufacturer, androidModel: $androidModel, androidVersion: $androidVersion, bitRate: $bitRate, bitsPerRawSample: $bitsPerRawSample, byteCount: $byteCount, channelLayout: $channelLayout, chromaLocation: $chromaLocation, codecName: $codecName, codecPixelFormat: $codecPixelFormat, codedHeight: $codedHeight, codedWidth: $codedWidth, colorPrimaries: $colorPrimaries, colorRange: $colorRange, colorSpace: $colorSpace, colorTransfer: $colorTransfer, colorProfile: $colorProfile, compatibleBrands: $compatibleBrands, creationTime: $creationTime, displayAspectRatio: $displayAspectRatio, date: $date, duration: $duration, durationMicros: $durationMicros, extraDataSize: $extraDataSize, fieldOrder: $fieldOrder, fpsDen: $fpsDen, frameCount: $frameCount, handlerName: $handlerName, hasBFrames: $hasBFrames, height: $height, language: $language, location: $location, majorBrand: $majorBrand, mediaFormat: $mediaFormat, mediaType: $mediaType, minorVersion: $minorVersion, nalLengthSize: $nalLengthSize, quicktimeLocationAccuracyHorizontal: $quicktimeLocationAccuracyHorizontal, rFrameRate: $rFrameRate, rotate: $rotate, sampleFormat: $sampleFormat, sampleRate: $sampleRate, sampleAspectRatio: $sampleAspectRatio, sarDen: $sarDen, segmentCount: $segmentCount, sourceOshash: $sourceOshash, startMicros: $startMicros, startPts: $startPts, startTime: $startTime, statisticsWritingApp: $statisticsWritingApp, statisticsWritingDateUtc: $statisticsWritingDateUtc, timeBase: $timeBase, track: $track, vendorId: $vendorId, width: $width, xiaomiSlowMoment: $xiaomiSlowMoment)'; + final buffer = StringBuffer(); + for (final key in prodData!.keys) { + final value = prodData![key]; + if (value != null) { + buffer.writeln('$key: $value'); + } + } + return buffer.toString(); } factory FFProbeProps.fromJson(Map? json) { - return FFProbeProps( - captureFps: - double.tryParse(json?[FFProbeKeys.androidCaptureFramerate] ?? ""), - androidManufacturer: json?[FFProbeKeys.androidManufacturer], - androidModel: json?[FFProbeKeys.androidModel], - androidVersion: json?[FFProbeKeys.androidVersion], - bitRate: _formatMetric( - json?[FFProbeKeys.bitrate] ?? json?[FFProbeKeys.bps], - 'b/s', - ), - bitsPerRawSample: json?[FFProbeKeys.bitsPerRawSample], - byteCount: _formatFilesize(json?[FFProbeKeys.byteCount]), - channelLayout: _formatChannelLayout(json?[FFProbeKeys.channelLayout]), - chromaLocation: json?[FFProbeKeys.chromaLocation], - codecName: _formatCodecName(json?[FFProbeKeys.codecName]), - codecPixelFormat: - (json?[FFProbeKeys.codecPixelFormat] as String?)?.toUpperCase(), - codedHeight: int.tryParse(json?[FFProbeKeys.codedHeight] ?? ""), - codedWidth: int.tryParse(json?[FFProbeKeys.codedWidth] ?? ""), - colorPrimaries: - (json?[FFProbeKeys.colorPrimaries] as String?)?.toUpperCase(), - colorRange: (json?[FFProbeKeys.colorRange] as String?)?.toUpperCase(), - colorSpace: (json?[FFProbeKeys.colorSpace] as String?)?.toUpperCase(), - colorTransfer: - (json?[FFProbeKeys.colorTransfer] as String?)?.toUpperCase(), - colorProfile: json?[FFProbeKeys.colorTransfer], - compatibleBrands: json?[FFProbeKeys.compatibleBrands], - creationTime: _formatDate(json?[FFProbeKeys.creationTime] ?? ""), - displayAspectRatio: json?[FFProbeKeys.dar], - date: DateTime.tryParse(json?[FFProbeKeys.date] ?? ""), - duration: _formatDuration(json?[FFProbeKeys.durationMicros]), - durationMicros: formatPreciseDuration( - Duration( - microseconds: - int.tryParse(json?[FFProbeKeys.durationMicros] ?? "") ?? 0, - ), - ), - extraDataSize: json?[FFProbeKeys.extraDataSize], - fieldOrder: json?[FFProbeKeys.fieldOrder], - fpsDen: json?[FFProbeKeys.fpsDen], - frameCount: int.tryParse(json?[FFProbeKeys.frameCount] ?? ""), - handlerName: json?[FFProbeKeys.handlerName], - hasBFrames: json?[FFProbeKeys.hasBFrames], - height: int.tryParse(json?[FFProbeKeys.height] ?? ""), - language: json?[FFProbeKeys.language], - location: _formatLocation(json?[FFProbeKeys.location]), - majorBrand: _formatBrand(json?[FFProbeKeys.majorBrand]), - mediaFormat: json?[FFProbeKeys.mediaFormat], - mediaType: json?[FFProbeKeys.mediaType], - minorVersion: json?[FFProbeKeys.minorVersion], - nalLengthSize: json?[FFProbeKeys.nalLengthSize], - quicktimeLocationAccuracyHorizontal: - json?[FFProbeKeys.quicktimeLocationAccuracyHorizontal], - rFrameRate: int.tryParse(json?[FFProbeKeys.rFrameRate] ?? ""), - rotate: json?[FFProbeKeys.rotate], - sampleFormat: json?[FFProbeKeys.sampleFormat], - sampleRate: json?[FFProbeKeys.sampleRate], - sampleAspectRatio: json?[FFProbeKeys.sar], - sarDen: json?[FFProbeKeys.sarDen], - segmentCount: int.tryParse(json?[FFProbeKeys.segmentCount] ?? ""), - sourceOshash: json?[FFProbeKeys.sourceOshash], - startMicros: json?[FFProbeKeys.startMicros], - startPts: json?[FFProbeKeys.startPts], - startTime: _formatDuration(json?[FFProbeKeys.startTime]), - statisticsWritingApp: json?[FFProbeKeys.statisticsWritingApp], - statisticsWritingDateUtc: json?[FFProbeKeys.statisticsWritingDateUtc], - timeBase: json?[FFProbeKeys.timeBase], - track: json?[FFProbeKeys.title], - vendorId: json?[FFProbeKeys.vendorId], - width: int.tryParse(json?[FFProbeKeys.width] ?? ""), - xiaomiSlowMoment: json?[FFProbeKeys.xiaomiSlowMoment], - ); + final Map parsedData = {}; + + for (final key in json!.keys) { + final stringKey = key.toString(); + switch (stringKey) { + case FFProbeKeys.bitrate: + case FFProbeKeys.bps: + parsedData[stringKey] = _formatMetric(json[key], 'b/s'); + break; + case FFProbeKeys.byteCount: + parsedData[stringKey] = _formatFilesize(json[key]); + break; + case FFProbeKeys.channelLayout: + parsedData[stringKey] = _formatChannelLayout(json[key]); + break; + case FFProbeKeys.codecName: + parsedData[stringKey] = _formatCodecName(json[key]); + break; + case FFProbeKeys.codecPixelFormat: + case FFProbeKeys.colorPrimaries: + case FFProbeKeys.colorRange: + case FFProbeKeys.colorSpace: + case FFProbeKeys.colorTransfer: + parsedData[stringKey] = (json[key] as String?)?.toUpperCase(); + break; + case FFProbeKeys.creationTime: + parsedData[stringKey] = _formatDate(json[key] ?? ""); + break; + case FFProbeKeys.durationMicros: + parsedData[stringKey] = formatPreciseDuration( + Duration(microseconds: int.tryParse(json[key] ?? "") ?? 0), + ); + break; + case FFProbeKeys.location: + parsedData[stringKey] = _formatLocation(json[key]); + break; + case FFProbeKeys.majorBrand: + parsedData[stringKey] = _formatBrand(json[key]); + break; + case FFProbeKeys.startTime: + parsedData[stringKey] = _formatDuration(json[key]); + break; + default: + parsedData[stringKey] = json[key]; + } + } + + return FFProbeProps(prodData: parsedData); } static String _formatBrand(String value) => Mp4.brands[value] ?? value; diff --git a/mobile/lib/ui/viewer/file/file_details_widget.dart b/mobile/lib/ui/viewer/file/file_details_widget.dart index 81418ec976..a73cd4b2d0 100644 --- a/mobile/lib/ui/viewer/file/file_details_widget.dart +++ b/mobile/lib/ui/viewer/file/file_details_widget.dart @@ -10,6 +10,7 @@ import "package:photos/core/configuration.dart"; import "package:photos/core/event_bus.dart"; import "package:photos/events/people_changed_event.dart"; import "package:photos/generated/l10n.dart"; +import "package:photos/models/ffmpeg/ffprobe_props.dart"; import 'package:photos/models/file/file.dart'; import 'package:photos/models/file/file_type.dart'; import "package:photos/models/metadata/file_magic.dart"; @@ -24,6 +25,7 @@ import "package:photos/ui/viewer/file_details/albums_item_widget.dart"; import 'package:photos/ui/viewer/file_details/backed_up_time_item_widget.dart'; import "package:photos/ui/viewer/file_details/creation_time_item_widget.dart"; import 'package:photos/ui/viewer/file_details/exif_item_widgets.dart'; +import "package:photos/ui/viewer/file_details/exif_video.dart"; import "package:photos/ui/viewer/file_details/faces_item_widget.dart"; import "package:photos/ui/viewer/file_details/file_properties_item_widget.dart"; import "package:photos/ui/viewer/file_details/location_tags_widget.dart"; @@ -45,7 +47,6 @@ class FileDetailsWidget extends StatefulWidget { } class _FileDetailsWidgetState extends State { - final ValueNotifier?> _exifNotifier = ValueNotifier(null); final Map _exifData = { "focalLength": null, "fNumber": null, @@ -65,8 +66,11 @@ class _FileDetailsWidgetState extends State { bool _isImage = false; late int _currentUserID; bool showExifListTile = false; + final ValueNotifier?> _exifNotifier = ValueNotifier(null); final ValueNotifier hasLocationData = ValueNotifier(false); final Logger _logger = Logger("_FileDetailsWidgetState"); + final ValueNotifier _videoMetadataNotifier = + ValueNotifier(null); @override void initState() { @@ -123,11 +127,11 @@ class _FileDetailsWidgetState extends State { return; } final properties = await FFProbeUtil.getProperties(mediaInfo); - final parsedMetadata = await FFProbeUtil.getMetadata(mediaInfo); + _videoMetadataNotifier.value = properties; // print all the properties log("videoCustomProps ${properties.toString()}"); - log("videoMetadata ${parsedMetadata.toString()}"); + log("PropData ${properties.prodData.toString()}"); setState(() {}); } @@ -135,6 +139,7 @@ class _FileDetailsWidgetState extends State { @override void dispose() { _exifNotifier.dispose(); + _videoMetadataNotifier.dispose(); _peopleChangedEvent.cancel(); super.dispose(); } @@ -192,6 +197,25 @@ class _FileDetailsWidgetState extends State { ), ); + fileDetailsTiles.add( + ValueListenableBuilder( + valueListenable: _videoMetadataNotifier, + builder: (context, value, _) { + return value != null && + value.prodData != null && + value.prodData!.isNotEmpty + ? const Column( + children: [ + Text("show video info"), + // VideoProbeInfo(probeData: value.prodData!), + FileDetailsDivider(), + ], + ) + : const SizedBox.shrink(); + }, + ), + ); + fileDetailsTiles.addAll([ ValueListenableBuilder( valueListenable: hasLocationData, @@ -263,6 +287,22 @@ class _FileDetailsWidgetState extends State { }, ), ]); + } else if (_videoMetadataNotifier.value != null) { + fileDetailsTiles.addAll([ + ValueListenableBuilder( + valueListenable: _videoMetadataNotifier, + builder: (context, value, _) { + return (value != null && value.prodData != null) + ? Column( + children: [ + VideoProbeInfoDetail(file, value.prodData), + const FileDetailsDivider(), + ], + ) + : const SizedBox.shrink(); + }, + ), + ]); } if (LocalSettings.instance.isFaceIndexingEnabled) { diff --git a/mobile/lib/ui/viewer/file/video_ffmpeg_info.dart b/mobile/lib/ui/viewer/file/video_ffmpeg_info.dart new file mode 100644 index 0000000000..d3c3de4dbf --- /dev/null +++ b/mobile/lib/ui/viewer/file/video_ffmpeg_info.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +class VideoProbeInfo extends StatelessWidget { + final Map probeData; + + const VideoProbeInfo({Key? key, required this.probeData}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSection('General Information', _buildGeneralInfo()), + const SizedBox(height: 8), + _buildSection('Streams', _buildStreamsList()), + ], + ), + ), + ); + } + + Widget _buildSection(String title, Widget content) { + return ExpansionTile( + initiallyExpanded: true, + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + children: [content], + ); + } + + Widget _buildGeneralInfo() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow('Duration', probeData, 'duration'), + _buildInfoRow('Probe Score', probeData, 'probe_score'), + _buildInfoRow('Number of Programs', probeData, 'nb_programs'), + _buildInfoRow('Number of Streams', probeData, 'nb_streams'), + _buildInfoRow('Bitrate', probeData, 'bitrate'), + _buildInfoRow('Format', probeData, 'format'), + _buildInfoRow('Creation Time', probeData, 'creation_time'), + ], + ); + } + + Widget _buildStreamsList() { + final List streams = probeData['streams']; + final List> data = []; + for (final stream in streams) { + final Map streamData = {}; + + for (final key in stream.keys) { + final dynamic value = stream[key]; + // print type of value + if (value is int || + value is double || + value is String || + value is bool) { + streamData[key] = stream[key]; + } else { + streamData[key] = stream[key].toString(); + } + } + data.add(streamData); + } + + return Column( + children: data.map((stream) => _buildStreamInfo(stream)).toList(), + ); + } + + Widget _buildStreamInfo(Map stream) { + return ExpansionTile( + title: Text( + 'Stream ${stream['index']}: ${stream['codec_name']} (${stream['type']})', + ), + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: stream.entries + .map((entry) => _buildInfoRow(entry.key, stream, entry.key)) + .toList(), + ), + ], + ); + } + + Widget _buildInfoRow( + String rowName, + Map data, + String dataKey, + ) { + rowName = rowName.replaceAll('_', ' '); + rowName = rowName[0].toUpperCase() + rowName.substring(1); + try { + final value = data[dataKey]; + if (value == null) { + return Container(); // Return an empty container if there's no data for the key. + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 150, + child: Text( + rowName, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + Expanded(child: Text(value.toString())), + ], + ), + ); + } catch (e, s) { + return Container(); + } + } +} diff --git a/mobile/lib/ui/viewer/file_details/exif_video.dart b/mobile/lib/ui/viewer/file_details/exif_video.dart new file mode 100644 index 0000000000..9898cbc7fd --- /dev/null +++ b/mobile/lib/ui/viewer/file_details/exif_video.dart @@ -0,0 +1,90 @@ +import "package:flutter/material.dart"; +import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; +import "package:photos/generated/l10n.dart"; +import 'package:photos/models/file/file.dart'; +import "package:photos/theme/colors.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/components/info_item_widget.dart"; +import "package:photos/ui/viewer/file/video_ffmpeg_info.dart"; +import "package:photos/utils/toast_util.dart"; + +class VideoProbeInfoDetail extends StatefulWidget { + final EnteFile file; + final Map? exif; + const VideoProbeInfoDetail( + this.file, + this.exif, { + super.key, + }); + + @override + State createState() => _VideoProbeInfoState(); +} + +class _VideoProbeInfoState extends State { + VoidCallback? _onTap; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return InfoItemWidget( + leadingIcon: Icons.text_snippet_outlined, + title: S.of(context).exif, + subtitleSection: _exifButton(context, widget.file, widget.exif), + onTap: _onTap, + ); + } + + Future> _exifButton( + BuildContext context, + EnteFile file, + Map? exif, + ) async { + late final String label; + late final VoidCallback? onTap; + final Map data = {}; + for (final key in exif!.keys) { + if (exif[key] != null) { + data[key] = exif[key]; + } + } + if (exif == null) { + label = S.of(context).loadingExifData; + onTap = null; + } else if (exif.isNotEmpty) { + label = S.of(context).viewAllExifData; + onTap = () => showBarModalBottomSheet( + context: context, + builder: (BuildContext context) { + return VideoProbeInfo( + probeData: data, + ); + }, + shape: const RoundedRectangleBorder( + side: BorderSide(width: 0), + borderRadius: BorderRadius.vertical( + top: Radius.circular(5), + ), + ), + topControl: const SizedBox.shrink(), + backgroundColor: getEnteColorScheme(context).backgroundElevated, + barrierColor: backdropFaintDark, + enableDrag: false, + ); + } else { + label = S.of(context).noExifData; + onTap = + () => showShortToast(context, S.of(context).thisImageHasNoExifData); + } + setState(() { + _onTap = onTap; + }); + return Future.value([ + Text(label, style: getEnteTextTheme(context).miniBoldMuted), + ]); + } +} From aeeed9cd11109b0fc2190d8964fb8bb522088576 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 14:45:34 +0530 Subject: [PATCH 04/15] [mob] Rename --- .../ui/viewer/file/file_details_widget.dart | 4 ++-- ..._ffmpeg_info.dart => vid_exif_dialog.dart} | 4 ++-- .../{exif_video.dart => video_exif_item.dart} | 20 ++++++++++--------- 3 files changed, 15 insertions(+), 13 deletions(-) rename mobile/lib/ui/viewer/file/{video_ffmpeg_info.dart => vid_exif_dialog.dart} (96%) rename mobile/lib/ui/viewer/file_details/{exif_video.dart => video_exif_item.dart} (83%) diff --git a/mobile/lib/ui/viewer/file/file_details_widget.dart b/mobile/lib/ui/viewer/file/file_details_widget.dart index a73cd4b2d0..532b414362 100644 --- a/mobile/lib/ui/viewer/file/file_details_widget.dart +++ b/mobile/lib/ui/viewer/file/file_details_widget.dart @@ -25,10 +25,10 @@ import "package:photos/ui/viewer/file_details/albums_item_widget.dart"; import 'package:photos/ui/viewer/file_details/backed_up_time_item_widget.dart'; import "package:photos/ui/viewer/file_details/creation_time_item_widget.dart"; import 'package:photos/ui/viewer/file_details/exif_item_widgets.dart'; -import "package:photos/ui/viewer/file_details/exif_video.dart"; import "package:photos/ui/viewer/file_details/faces_item_widget.dart"; import "package:photos/ui/viewer/file_details/file_properties_item_widget.dart"; import "package:photos/ui/viewer/file_details/location_tags_widget.dart"; +import "package:photos/ui/viewer/file_details/video_exif_item.dart"; import "package:photos/utils/exif_util.dart"; import "package:photos/utils/ffprobe_util.dart"; import "package:photos/utils/file_util.dart"; @@ -295,7 +295,7 @@ class _FileDetailsWidgetState extends State { return (value != null && value.prodData != null) ? Column( children: [ - VideoProbeInfoDetail(file, value.prodData), + VideoExifRowItem(file, value.prodData), const FileDetailsDivider(), ], ) diff --git a/mobile/lib/ui/viewer/file/video_ffmpeg_info.dart b/mobile/lib/ui/viewer/file/vid_exif_dialog.dart similarity index 96% rename from mobile/lib/ui/viewer/file/video_ffmpeg_info.dart rename to mobile/lib/ui/viewer/file/vid_exif_dialog.dart index d3c3de4dbf..6ee5b201ae 100644 --- a/mobile/lib/ui/viewer/file/video_ffmpeg_info.dart +++ b/mobile/lib/ui/viewer/file/vid_exif_dialog.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -class VideoProbeInfo extends StatelessWidget { +class VideoExifDialog extends StatelessWidget { final Map probeData; - const VideoProbeInfo({Key? key, required this.probeData}) : super(key: key); + const VideoExifDialog({Key? key, required this.probeData}) : super(key: key); @override Widget build(BuildContext context) { diff --git a/mobile/lib/ui/viewer/file_details/exif_video.dart b/mobile/lib/ui/viewer/file_details/video_exif_item.dart similarity index 83% rename from mobile/lib/ui/viewer/file_details/exif_video.dart rename to mobile/lib/ui/viewer/file_details/video_exif_item.dart index 9898cbc7fd..7293f9247d 100644 --- a/mobile/lib/ui/viewer/file_details/exif_video.dart +++ b/mobile/lib/ui/viewer/file_details/video_exif_item.dart @@ -5,23 +5,23 @@ import 'package:photos/models/file/file.dart'; import "package:photos/theme/colors.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/components/info_item_widget.dart"; -import "package:photos/ui/viewer/file/video_ffmpeg_info.dart"; +import "package:photos/ui/viewer/file/vid_exif_dialog.dart"; import "package:photos/utils/toast_util.dart"; -class VideoProbeInfoDetail extends StatefulWidget { +class VideoExifRowItem extends StatefulWidget { final EnteFile file; final Map? exif; - const VideoProbeInfoDetail( + const VideoExifRowItem( this.file, this.exif, { super.key, }); @override - State createState() => _VideoProbeInfoState(); + State createState() => _VideoProbeInfoState(); } -class _VideoProbeInfoState extends State { +class _VideoProbeInfoState extends State { VoidCallback? _onTap; @override @@ -47,9 +47,11 @@ class _VideoProbeInfoState extends State { late final String label; late final VoidCallback? onTap; final Map data = {}; - for (final key in exif!.keys) { - if (exif[key] != null) { - data[key] = exif[key]; + if (exif != null) { + for (final key in exif.keys) { + if (exif[key] != null) { + data[key] = exif[key]; + } } } if (exif == null) { @@ -60,7 +62,7 @@ class _VideoProbeInfoState extends State { onTap = () => showBarModalBottomSheet( context: context, builder: (BuildContext context) { - return VideoProbeInfo( + return VideoExifDialog( probeData: data, ); }, From f2ed6802d21daa9cc6232d27651d068abd88b57d Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 14:48:38 +0530 Subject: [PATCH 05/15] [mob] Rename --- .../ui/viewer/file/file_details_widget.dart | 19 ------------------- .../viewer/file_details/video_exif_item.dart | 4 ++-- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/mobile/lib/ui/viewer/file/file_details_widget.dart b/mobile/lib/ui/viewer/file/file_details_widget.dart index 532b414362..2ff0cf2db4 100644 --- a/mobile/lib/ui/viewer/file/file_details_widget.dart +++ b/mobile/lib/ui/viewer/file/file_details_widget.dart @@ -197,25 +197,6 @@ class _FileDetailsWidgetState extends State { ), ); - fileDetailsTiles.add( - ValueListenableBuilder( - valueListenable: _videoMetadataNotifier, - builder: (context, value, _) { - return value != null && - value.prodData != null && - value.prodData!.isNotEmpty - ? const Column( - children: [ - Text("show video info"), - // VideoProbeInfo(probeData: value.prodData!), - FileDetailsDivider(), - ], - ) - : const SizedBox.shrink(); - }, - ), - ); - fileDetailsTiles.addAll([ ValueListenableBuilder( valueListenable: hasLocationData, diff --git a/mobile/lib/ui/viewer/file_details/video_exif_item.dart b/mobile/lib/ui/viewer/file_details/video_exif_item.dart index 7293f9247d..d160d7f40d 100644 --- a/mobile/lib/ui/viewer/file_details/video_exif_item.dart +++ b/mobile/lib/ui/viewer/file_details/video_exif_item.dart @@ -33,7 +33,7 @@ class _VideoProbeInfoState extends State { Widget build(BuildContext context) { return InfoItemWidget( leadingIcon: Icons.text_snippet_outlined, - title: S.of(context).exif, + title: "Video Info", subtitleSection: _exifButton(context, widget.file, widget.exif), onTap: _onTap, ); @@ -58,7 +58,7 @@ class _VideoProbeInfoState extends State { label = S.of(context).loadingExifData; onTap = null; } else if (exif.isNotEmpty) { - label = S.of(context).viewAllExifData; + label = "Tap to view more details"; onTap = () => showBarModalBottomSheet( context: context, builder: (BuildContext context) { From daaa1d74236e540b3383b265354ecacce11827de Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 15:35:46 +0530 Subject: [PATCH 06/15] Format creation time --- mobile/lib/models/ffmpeg/ffprobe_props.dart | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index 075bd4e5d6..f85747edc8 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -63,6 +63,8 @@ class FFProbeProps { Duration(microseconds: int.tryParse(json[key] ?? "") ?? 0), ); break; + case FFProbeKeys.duration: + parsedData[stringKey] = _formatDuration(json[key]); case FFProbeKeys.location: parsedData[stringKey] = _formatLocation(json[key]); break; @@ -112,11 +114,13 @@ class FFProbeProps { // input example: '2021-04-12T09:14:37.000000Z' static String? _formatDate(String value) { - final date = DateTime.tryParse(value); - if (date == null) return value; + final dateInUtc = DateTime.tryParse(value); + if (dateInUtc == null) return value; final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - if (date == epoch) return null; - return date.toIso8601String(); + if (dateInUtc == epoch) return null; + final newDate = + DateTime.fromMicrosecondsSinceEpoch(dateInUtc.microsecondsSinceEpoch); + return formatDateTime(newDate, 'en_US', false); } // input example: '00:00:05.408000000' or '5.408000' @@ -158,7 +162,7 @@ class FFProbeProps { static String? _formatDuration(String? value) { if (value == null) return null; final duration = _parseDuration(value); - return duration != null ? formatPreciseDuration(duration) : value; + return duration != null ? formatFriendlyDuration(duration) : value; } static String? _formatFilesize(dynamic value) { From 92b188bc2124f2d3f58ad938890ecce6e8497faf Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 15:44:36 +0530 Subject: [PATCH 07/15] Improve UI --- .../lib/ui/viewer/file/vid_exif_dialog.dart | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/mobile/lib/ui/viewer/file/vid_exif_dialog.dart b/mobile/lib/ui/viewer/file/vid_exif_dialog.dart index 6ee5b201ae..f320b9a6c1 100644 --- a/mobile/lib/ui/viewer/file/vid_exif_dialog.dart +++ b/mobile/lib/ui/viewer/file/vid_exif_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import "package:photos/theme/ente_theme.dart"; class VideoExifDialog extends StatelessWidget { final Map probeData; @@ -13,39 +14,41 @@ class VideoExifDialog extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSection('General Information', _buildGeneralInfo()), + _buildGeneralInfo(context), const SizedBox(height: 8), - _buildSection('Streams', _buildStreamsList()), + _buildSection(context, 'Streams', _buildStreamsList(context)), ], ), ), ); } - Widget _buildSection(String title, Widget content) { + Widget _buildSection(BuildContext context, String title, Widget content) { return ExpansionTile( initiallyExpanded: true, - title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + title: Text(title, style: getEnteTextTheme(context).largeFaint), + childrenPadding: const EdgeInsets.symmetric(vertical: 2), + tilePadding: const EdgeInsets.symmetric(vertical: 4), children: [content], ); } - Widget _buildGeneralInfo() { + Widget _buildGeneralInfo(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildInfoRow('Duration', probeData, 'duration'), - _buildInfoRow('Probe Score', probeData, 'probe_score'), - _buildInfoRow('Number of Programs', probeData, 'nb_programs'), - _buildInfoRow('Number of Streams', probeData, 'nb_streams'), - _buildInfoRow('Bitrate', probeData, 'bitrate'), - _buildInfoRow('Format', probeData, 'format'), - _buildInfoRow('Creation Time', probeData, 'creation_time'), + _buildInfoRow(context, 'Duration', probeData, 'duration'), + _buildInfoRow(context, 'Probe Score', probeData, 'probe_score'), + _buildInfoRow(context, 'Number of Programs', probeData, 'nb_programs'), + _buildInfoRow(context, 'Number of Streams', probeData, 'nb_streams'), + _buildInfoRow(context, 'Bitrate', probeData, 'bitrate'), + _buildInfoRow(context, 'Format', probeData, 'format'), + _buildInfoRow(context, 'Creation Time', probeData, 'creation_time'), ], ); } - Widget _buildStreamsList() { + Widget _buildStreamsList(BuildContext context) { final List streams = probeData['streams']; final List> data = []; for (final stream in streams) { @@ -67,20 +70,31 @@ class VideoExifDialog extends StatelessWidget { } return Column( - children: data.map((stream) => _buildStreamInfo(stream)).toList(), + children: + data.map((stream) => _buildStreamInfo(context, stream)).toList(), ); } - Widget _buildStreamInfo(Map stream) { + Widget _buildStreamInfo(BuildContext context, Map stream) { + String titleString = stream['type']?.toString().toUpperCase() ?? ''; + final codeName = stream['codec_name']?.toString().toUpperCase() ?? ''; + if (codeName != 'NULL') { + titleString += ' - $codeName'; + } return ExpansionTile( title: Text( - 'Stream ${stream['index']}: ${stream['codec_name']} (${stream['type']})', + titleString, + style: getEnteTextTheme(context).smallBold, ), + childrenPadding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4), + tilePadding: const EdgeInsets.symmetric(vertical: 4), children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: stream.entries - .map((entry) => _buildInfoRow(entry.key, stream, entry.key)) + .map( + (entry) => _buildInfoRow(context, entry.key, stream, entry.key), + ) .toList(), ), ], @@ -88,6 +102,7 @@ class VideoExifDialog extends StatelessWidget { } Widget _buildInfoRow( + BuildContext context, String rowName, Map data, String dataKey, @@ -108,7 +123,7 @@ class VideoExifDialog extends StatelessWidget { width: 150, child: Text( rowName, - style: const TextStyle(fontWeight: FontWeight.bold), + style: getEnteTextTheme(context).smallMuted, ), ), Expanded(child: Text(value.toString())), From c28b4934c427807072db1797a80687ee79cdb9fb Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 16:52:55 +0530 Subject: [PATCH 08/15] Parse make, model and location for iPhone Videos --- mobile/lib/models/ffmpeg/ffprobe_keys.dart | 1 + mobile/lib/models/ffmpeg/ffprobe_props.dart | 39 ++++++++++++++----- ...xif_dialog.dart => video_exif_dialog.dart} | 13 ++++--- 3 files changed, 37 insertions(+), 16 deletions(-) rename mobile/lib/ui/viewer/file/{vid_exif_dialog.dart => video_exif_dialog.dart} (93%) diff --git a/mobile/lib/models/ffmpeg/ffprobe_keys.dart b/mobile/lib/models/ffmpeg/ffprobe_keys.dart index 99befc1858..081fe0ff7e 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_keys.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_keys.dart @@ -28,6 +28,7 @@ class FFProbeKeys { static const date = 'date'; static const disposition = 'disposition'; static const duration = 'duration'; + static const quickTimeLocation ="com.apple.quicktime.location.ISO6709"; static const durationMicros = 'duration_us'; static const encoder = 'encoder'; static const extraDataSize = 'extradata_size'; diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index f85747edc8..71478e01a1 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -1,5 +1,7 @@ // Adapted from: https://github.com/deckerst/aves +import "dart:developer"; + import "package:collection/collection.dart"; import "package:intl/intl.dart"; import "package:photos/models/ffmpeg/channel_layouts.dart"; @@ -10,11 +12,11 @@ import "package:photos/models/ffmpeg/mp4.dart"; import "package:photos/models/location/location.dart"; class FFProbeProps { - final Map? prodData; - - FFProbeProps({ - required this.prodData, - }); + Map? prodData; + Location? location; + DateTime? creationTime; + String? bitrate; + String? majorBrand; // toString() method @override @@ -29,15 +31,17 @@ class FFProbeProps { return buffer.toString(); } - factory FFProbeProps.fromJson(Map? json) { + static fromJson(Map? json) { final Map parsedData = {}; + final FFProbeProps result = FFProbeProps(); for (final key in json!.keys) { final stringKey = key.toString(); switch (stringKey) { case FFProbeKeys.bitrate: case FFProbeKeys.bps: - parsedData[stringKey] = _formatMetric(json[key], 'b/s'); + result.bitrate = _formatMetric(json[key], 'b/s'); + parsedData[stringKey] = result.bitrate; break; case FFProbeKeys.byteCount: parsedData[stringKey] = _formatFilesize(json[key]); @@ -66,10 +70,23 @@ class FFProbeProps { case FFProbeKeys.duration: parsedData[stringKey] = _formatDuration(json[key]); case FFProbeKeys.location: - parsedData[stringKey] = _formatLocation(json[key]); + result.location = _formatLocation(json[key]); + if (result.location != null) { + parsedData[stringKey] = + '${result.location!.latitude}, ${result.location!.longitude}'; + } + break; + case FFProbeKeys.quickTimeLocation: + result.location = + _formatLocation(json[FFProbeKeys.quickTimeLocation]); + if (result.location != null) { + parsedData[FFProbeKeys.location] = + '${result.location!.latitude}, ${result.location!.longitude}'; + } break; case FFProbeKeys.majorBrand: - parsedData[stringKey] = _formatBrand(json[key]); + result.majorBrand = _formatBrand(json[key]); + parsedData[stringKey] = result.majorBrand; break; case FFProbeKeys.startTime: parsedData[stringKey] = _formatDuration(json[key]); @@ -78,8 +95,9 @@ class FFProbeProps { parsedData[stringKey] = json[key]; } } + result.prodData = parsedData; - return FFProbeProps(prodData: parsedData); + return result; } static String _formatBrand(String value) => Mp4.brands[value] ?? value; @@ -196,6 +214,7 @@ class FFProbeProps { longitude: coordinates[1], ); } catch (e) { + log('failed to parse location: $value', error: e); return null; } } diff --git a/mobile/lib/ui/viewer/file/vid_exif_dialog.dart b/mobile/lib/ui/viewer/file/video_exif_dialog.dart similarity index 93% rename from mobile/lib/ui/viewer/file/vid_exif_dialog.dart rename to mobile/lib/ui/viewer/file/video_exif_dialog.dart index f320b9a6c1..5f621f85ea 100644 --- a/mobile/lib/ui/viewer/file/vid_exif_dialog.dart +++ b/mobile/lib/ui/viewer/file/video_exif_dialog.dart @@ -37,13 +37,14 @@ class VideoExifDialog extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildInfoRow(context, 'Duration', probeData, 'duration'), - _buildInfoRow(context, 'Probe Score', probeData, 'probe_score'), - _buildInfoRow(context, 'Number of Programs', probeData, 'nb_programs'), - _buildInfoRow(context, 'Number of Streams', probeData, 'nb_streams'), - _buildInfoRow(context, 'Bitrate', probeData, 'bitrate'), - _buildInfoRow(context, 'Format', probeData, 'format'), _buildInfoRow(context, 'Creation Time', probeData, 'creation_time'), + _buildInfoRow(context, 'Duration', probeData, 'duration'), + _buildInfoRow(context, 'Location', probeData, 'location'), + _buildInfoRow(context, 'Bitrate', probeData, 'bitrate'), + _buildInfoRow(context, 'Model', probeData, 'com.apple.quicktime.model'), + _buildInfoRow(context, 'OS', probeData, 'com.apple.quicktime.software'), + _buildInfoRow(context, 'Major Brand', probeData, 'major_brand'), + _buildInfoRow(context, 'Format', probeData, 'format'), ], ); } From 95c92b05723bcec3c7c794c550aa5c5dc5a2f89d Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:38:52 +0530 Subject: [PATCH 09/15] Show fps, bitrate and dim for video --- mobile/lib/models/ffmpeg/ffprobe_props.dart | 30 ++++++++++++++++- .../models/file/extensions/file_props.dart | 2 ++ .../ui/viewer/file/file_details_widget.dart | 32 +++++++++---------- .../lib/ui/viewer/file/video_exif_dialog.dart | 4 +-- .../viewer/file_details/video_exif_item.dart | 22 +++++-------- 5 files changed, 56 insertions(+), 34 deletions(-) diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index 71478e01a1..dfb286897a 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -17,6 +17,20 @@ class FFProbeProps { DateTime? creationTime; String? bitrate; String? majorBrand; + String? fps; + String? codecWidth; + String? codecHeight; + + // dot separated bitrate, fps, codecWidth, codecHeight. Ignore null value + String get videoInfo { + final List info = []; + if (bitrate != null) info.add('$bitrate'); + if (fps != null) info.add('ƒ/$fps'); + if (codecWidth != null && codecHeight != null) { + info.add('$codecWidth x $codecHeight'); + } + return info.join(' * '); + } // toString() method @override @@ -37,6 +51,7 @@ class FFProbeProps { for (final key in json!.keys) { final stringKey = key.toString(); + switch (stringKey) { case FFProbeKeys.bitrate: case FFProbeKeys.bps: @@ -95,8 +110,21 @@ class FFProbeProps { parsedData[stringKey] = json[key]; } } + // iterate through the streams + final List streams = json["streams"]; + for (final stream in streams) { + final Map streamData = {}; + for (final key in stream.keys) { + if (key == "r_frame_rate") { + result.fps = stream[key]; + } else if (key == "coded_width") { + result.codecWidth = stream[key].toString(); + } else if (key == "coded_height") { + result.codecHeight = stream[key].toString(); + } + } + } result.prodData = parsedData; - return result; } diff --git a/mobile/lib/models/file/extensions/file_props.dart b/mobile/lib/models/file/extensions/file_props.dart index b3f5ae672a..64436ef2e3 100644 --- a/mobile/lib/models/file/extensions/file_props.dart +++ b/mobile/lib/models/file/extensions/file_props.dart @@ -14,6 +14,8 @@ extension FilePropsExtn on EnteFile { bool get isOwner => (ownerID == null) || (ownerID == Configuration.instance.getUserID()); + bool get isVideo => fileType == FileType.video; + bool get canEditMetaInfo => isUploaded && isOwner; bool get isTrash => this is TrashFile; diff --git a/mobile/lib/ui/viewer/file/file_details_widget.dart b/mobile/lib/ui/viewer/file/file_details_widget.dart index 2ff0cf2db4..51e6ea2914 100644 --- a/mobile/lib/ui/viewer/file/file_details_widget.dart +++ b/mobile/lib/ui/viewer/file/file_details_widget.dart @@ -4,6 +4,7 @@ import "dart:io"; import "package:exif/exif.dart"; import "package:ffmpeg_kit_flutter_min/ffprobe_kit.dart"; +import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; import "package:photos/core/configuration.dart"; @@ -11,9 +12,11 @@ import "package:photos/core/event_bus.dart"; import "package:photos/events/people_changed_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/ffmpeg/ffprobe_props.dart"; +import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; import 'package:photos/models/file/file_type.dart'; import "package:photos/models/metadata/file_magic.dart"; +import "package:photos/service_locator.dart"; import "package:photos/services/file_magic_service.dart"; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/buttons/icon_button_widget.dart'; @@ -101,7 +104,7 @@ class _FileDetailsWidgetState extends State { _exifData["exposureTime"] != null || _exifData["ISO"] != null; }); - } else { + } else if (flagService.internalUser && widget.file.isVideo) { getMediaInfo(); } getExif(widget.file).then((exif) { @@ -115,9 +118,7 @@ class _FileDetailsWidgetState extends State { final File? originFile = await getFile(widget.file, isOrigin: true); if (originFile == null) return; final session = await FFprobeKit.getMediaInformation(originFile.path); - final mediaInfo = session.getMediaInformation(); - if (mediaInfo == null) { final failStackTrace = await session.getFailStackTrace(); final output = await session.getOutput(); @@ -128,11 +129,10 @@ class _FileDetailsWidgetState extends State { } final properties = await FFProbeUtil.getProperties(mediaInfo); _videoMetadataNotifier.value = properties; - - // print all the properties - log("videoCustomProps ${properties.toString()}"); - log("PropData ${properties.prodData.toString()}"); - + if (kDebugMode) { + log("videoCustomProps ${properties.toString()}"); + log("PropData ${properties.prodData.toString()}"); + } setState(() {}); } @@ -268,19 +268,17 @@ class _FileDetailsWidgetState extends State { }, ), ]); - } else if (_videoMetadataNotifier.value != null) { + } else if (flagService.internalUser && widget.file.isVideo) { fileDetailsTiles.addAll([ ValueListenableBuilder( valueListenable: _videoMetadataNotifier, builder: (context, value, _) { - return (value != null && value.prodData != null) - ? Column( - children: [ - VideoExifRowItem(file, value.prodData), - const FileDetailsDivider(), - ], - ) - : const SizedBox.shrink(); + return Column( + children: [ + VideoExifRowItem(file, value), + const FileDetailsDivider(), + ], + ); }, ), ]); diff --git a/mobile/lib/ui/viewer/file/video_exif_dialog.dart b/mobile/lib/ui/viewer/file/video_exif_dialog.dart index 5f621f85ea..487a6e7f4e 100644 --- a/mobile/lib/ui/viewer/file/video_exif_dialog.dart +++ b/mobile/lib/ui/viewer/file/video_exif_dialog.dart @@ -79,7 +79,7 @@ class VideoExifDialog extends StatelessWidget { Widget _buildStreamInfo(BuildContext context, Map stream) { String titleString = stream['type']?.toString().toUpperCase() ?? ''; final codeName = stream['codec_name']?.toString().toUpperCase() ?? ''; - if (codeName != 'NULL') { + if (codeName != 'NULL' && codeName.isNotEmpty) { titleString += ' - $codeName'; } return ExpansionTile( @@ -87,7 +87,7 @@ class VideoExifDialog extends StatelessWidget { titleString, style: getEnteTextTheme(context).smallBold, ), - childrenPadding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4), + childrenPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 4), tilePadding: const EdgeInsets.symmetric(vertical: 4), children: [ Column( diff --git a/mobile/lib/ui/viewer/file_details/video_exif_item.dart b/mobile/lib/ui/viewer/file_details/video_exif_item.dart index d160d7f40d..8b1e27eb4a 100644 --- a/mobile/lib/ui/viewer/file_details/video_exif_item.dart +++ b/mobile/lib/ui/viewer/file_details/video_exif_item.dart @@ -1,19 +1,20 @@ import "package:flutter/material.dart"; import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; import "package:photos/generated/l10n.dart"; +import "package:photos/models/ffmpeg/ffprobe_props.dart"; import 'package:photos/models/file/file.dart'; import "package:photos/theme/colors.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/components/info_item_widget.dart"; -import "package:photos/ui/viewer/file/vid_exif_dialog.dart"; +import "package:photos/ui/viewer/file/video_exif_dialog.dart"; import "package:photos/utils/toast_util.dart"; class VideoExifRowItem extends StatefulWidget { final EnteFile file; - final Map? exif; + final FFProbeProps? props; const VideoExifRowItem( this.file, - this.exif, { + this.props, { super.key, }); @@ -34,7 +35,8 @@ class _VideoProbeInfoState extends State { return InfoItemWidget( leadingIcon: Icons.text_snippet_outlined, title: "Video Info", - subtitleSection: _exifButton(context, widget.file, widget.exif), + subtitleSection: + _exifButton(context, widget.file, widget.props?.prodData), onTap: _onTap, ); } @@ -46,24 +48,16 @@ class _VideoProbeInfoState extends State { ) async { late final String label; late final VoidCallback? onTap; - final Map data = {}; - if (exif != null) { - for (final key in exif.keys) { - if (exif[key] != null) { - data[key] = exif[key]; - } - } - } if (exif == null) { label = S.of(context).loadingExifData; onTap = null; } else if (exif.isNotEmpty) { - label = "Tap to view more details"; + label = "${widget.props?.videoInfo ?? ''} .."; onTap = () => showBarModalBottomSheet( context: context, builder: (BuildContext context) { return VideoExifDialog( - probeData: data, + probeData: exif, ); }, shape: const RoundedRectangleBorder( From 2b7dc88281d25c64243a2bbe13ce76821022ce22 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:39:02 +0530 Subject: [PATCH 10/15] iOS build changes --- mobile/ios/Podfile.lock | 8 +++++++- mobile/ios/Runner.xcodeproj/project.pbxproj | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index fd11b0a5b1..76780932b8 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -199,6 +199,8 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - smart_auth (0.0.1): + - Flutter - sqflite (0.0.3): - Flutter - FlutterMacOS @@ -277,6 +279,7 @@ DEPENDENCIES: - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - smart_auth (from `.symlinks/plugins/smart_auth/ios`) - sqflite (from `.symlinks/plugins/sqflite/darwin`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`) - uni_links (from `.symlinks/plugins/uni_links/ios`) @@ -397,6 +400,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + smart_auth: + :path: ".symlinks/plugins/smart_auth/ios" sqflite: :path: ".symlinks/plugins/sqflite/darwin" sqlite3_flutter_libs: @@ -466,7 +471,7 @@ SPEC CHECKSUMS: package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 - photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 + photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 receive_sharing_intent: 6837b01768e567fe8562182397bf43d63d8c6437 screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 @@ -477,6 +482,7 @@ SPEC CHECKSUMS: SentryPrivate: d651efb234cf385ec9a1cdd3eff94b5e78a0e0fe share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + smart_auth: 4bedbc118723912d0e45a07e8ab34039c19e04f2 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec sqlite3: 02d1f07eaaa01f80a1c16b4b31dfcbb3345ee01a sqlite3_flutter_libs: af0e8fe9bce48abddd1ffdbbf839db0302d72d80 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index c2a5bfb377..2a7fa2a840 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -329,6 +329,7 @@ "${BUILT_PRODUCTS_DIR}/sentry_flutter/sentry_flutter.framework", "${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework", "${BUILT_PRODUCTS_DIR}/shared_preferences_foundation/shared_preferences_foundation.framework", + "${BUILT_PRODUCTS_DIR}/smart_auth/smart_auth.framework", "${BUILT_PRODUCTS_DIR}/sqflite/sqflite.framework", "${BUILT_PRODUCTS_DIR}/sqlite3/sqlite3.framework", "${BUILT_PRODUCTS_DIR}/sqlite3_flutter_libs/sqlite3_flutter_libs.framework", @@ -420,6 +421,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sentry_flutter.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences_foundation.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/smart_auth.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqflite.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3_flutter_libs.framework", From 1c5154ac264a886f505e8eb33f62d7a8c829cba9 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:47:10 +0530 Subject: [PATCH 11/15] Show dims and fps in the video info dialog --- mobile/lib/models/ffmpeg/ffprobe_props.dart | 10 ++++++---- mobile/lib/ui/viewer/file/file_widget.dart | 4 ++-- mobile/lib/ui/viewer/file/video_exif_dialog.dart | 8 ++++++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index dfb286897a..a95eff9e2f 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -113,14 +113,16 @@ class FFProbeProps { // iterate through the streams final List streams = json["streams"]; for (final stream in streams) { - final Map streamData = {}; for (final key in stream.keys) { - if (key == "r_frame_rate") { + if (key == FFProbeKeys.rFrameRate) { result.fps = stream[key]; - } else if (key == "coded_width") { + parsedData[key] = result.fps; + } else if (key == FFProbeKeys.codedWidth) { result.codecWidth = stream[key].toString(); - } else if (key == "coded_height") { + parsedData[key] = result.codecWidth; + } else if (key == FFProbeKeys.codedHeight) { result.codecHeight = stream[key].toString(); + parsedData[key] = result.codecHeight; } } } diff --git a/mobile/lib/ui/viewer/file/file_widget.dart b/mobile/lib/ui/viewer/file/file_widget.dart index 6e6dcc7f17..704d85dd13 100644 --- a/mobile/lib/ui/viewer/file/file_widget.dart +++ b/mobile/lib/ui/viewer/file/file_widget.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:photos/models/file/file.dart'; import 'package:photos/models/file/file_type.dart'; -import "package:photos/ui/viewer/file/video_widget_new.dart"; +import "package:photos/ui/viewer/file/video_widget.dart"; import "package:photos/ui/viewer/file/zoomable_live_image_new.dart"; class FileWidget extends StatelessWidget { @@ -38,7 +38,7 @@ class FileWidget extends StatelessWidget { key: key ?? ValueKey(fileKey), ); } else if (file.fileType == FileType.video) { - return VideoWidgetNew( + return VideoWidget( file, tagPrefix: tagPrefix, playbackCallback: playbackCallback, diff --git a/mobile/lib/ui/viewer/file/video_exif_dialog.dart b/mobile/lib/ui/viewer/file/video_exif_dialog.dart index 487a6e7f4e..7ad586b7f5 100644 --- a/mobile/lib/ui/viewer/file/video_exif_dialog.dart +++ b/mobile/lib/ui/viewer/file/video_exif_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import "package:photos/models/ffmpeg/ffprobe_keys.dart"; import "package:photos/theme/ente_theme.dart"; class VideoExifDialog extends StatelessWidget { @@ -41,6 +42,9 @@ class VideoExifDialog extends StatelessWidget { _buildInfoRow(context, 'Duration', probeData, 'duration'), _buildInfoRow(context, 'Location', probeData, 'location'), _buildInfoRow(context, 'Bitrate', probeData, 'bitrate'), + _buildInfoRow(context, 'Frame Rate', probeData, FFProbeKeys.rFrameRate), + _buildInfoRow(context, 'Width', probeData, FFProbeKeys.codedWidth), + _buildInfoRow(context, 'Height', probeData, FFProbeKeys.codedHeight), _buildInfoRow(context, 'Model', probeData, 'com.apple.quicktime.model'), _buildInfoRow(context, 'OS', probeData, 'com.apple.quicktime.software'), _buildInfoRow(context, 'Major Brand', probeData, 'major_brand'), @@ -131,8 +135,8 @@ class VideoExifDialog extends StatelessWidget { ], ), ); - } catch (e, s) { - return Container(); + } catch (e, _) { + return const SizedBox.shrink(); } } } From 428b3e2cd6e6a86d69962ba6d8e614c79994ffdd Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:49:49 +0530 Subject: [PATCH 12/15] Enable drag to close --- mobile/lib/ui/viewer/file_details/video_exif_item.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/ui/viewer/file_details/video_exif_item.dart b/mobile/lib/ui/viewer/file_details/video_exif_item.dart index 8b1e27eb4a..b53bc69da8 100644 --- a/mobile/lib/ui/viewer/file_details/video_exif_item.dart +++ b/mobile/lib/ui/viewer/file_details/video_exif_item.dart @@ -69,7 +69,7 @@ class _VideoProbeInfoState extends State { topControl: const SizedBox.shrink(), backgroundColor: getEnteColorScheme(context).backgroundElevated, barrierColor: backdropFaintDark, - enableDrag: false, + enableDrag: true, ); } else { label = S.of(context).noExifData; From d6a970274c58f6328b509f9f195878559615b33e Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:57:02 +0530 Subject: [PATCH 13/15] Format fps value --- mobile/lib/models/ffmpeg/ffprobe_props.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index a95eff9e2f..575382a409 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -115,7 +115,7 @@ class FFProbeProps { for (final stream in streams) { for (final key in stream.keys) { if (key == FFProbeKeys.rFrameRate) { - result.fps = stream[key]; + result.fps = _formatFPS(stream[key]); parsedData[key] = result.fps; } else if (key == FFProbeKeys.codedWidth) { result.codecWidth = stream[key].toString(); @@ -220,6 +220,19 @@ class FFProbeProps { return size != null ? formatFileSize(asciiLocale, size) : value; } + static String? _formatFPS(dynamic value) { + if (value == null) return null; + int? t = int.tryParse(value.split('/')[0]); + int? b = int.tryParse(value.split('/')[1]); + if (t != null && b != null) { + // return the value upto 2 decimal places. ignore even two decimal places + // if t is perfectly divisible by b + return (t % b == 0) + ? (t / b).toStringAsFixed(0) + : (t / b).toStringAsFixed(2); + } + } + static String _formatLanguage(String value) { final language = Language.living639_2 .firstWhereOrNull((language) => language.iso639_2 == value); From fd001a9181e7fd8ea746bd661778e272b82bea32 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:59:56 +0530 Subject: [PATCH 14/15] Fix lint --- mobile/lib/models/ffmpeg/ffprobe_props.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index 575382a409..a7c0dbd872 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -222,8 +222,8 @@ class FFProbeProps { static String? _formatFPS(dynamic value) { if (value == null) return null; - int? t = int.tryParse(value.split('/')[0]); - int? b = int.tryParse(value.split('/')[1]); + final int? t = int.tryParse(value.split('/')[0]); + final int? b = int.tryParse(value.split('/')[1]); if (t != null && b != null) { // return the value upto 2 decimal places. ignore even two decimal places // if t is perfectly divisible by b @@ -231,6 +231,7 @@ class FFProbeProps { ? (t / b).toStringAsFixed(0) : (t / b).toStringAsFixed(2); } + return value; } static String _formatLanguage(String value) { From 24aff0b9c1f5858887518d233857a149579976cf Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 16 Jul 2024 18:00:19 +0530 Subject: [PATCH 15/15] bump version --- mobile/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b187f3c09a..9e79396dd3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,7 @@ description: ente photos application # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.9.7+907 +version: 0.9.8+908 publish_to: none environment: