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", 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 abbc56d41b..a7c0dbd872 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,197 +12,122 @@ 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; + Map? prodData; + Location? location; + DateTime? creationTime; + String? bitrate; + String? majorBrand; + String? fps; + String? codecWidth; + String? codecHeight; - 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, - }); + // 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(' * '); + } - 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: 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], - ); + // toString() method + @override + String toString() { + final buffer = StringBuffer(); + for (final key in prodData!.keys) { + final value = prodData![key]; + if (value != null) { + buffer.writeln('$key: $value'); + } + } + return buffer.toString(); + } + + 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: + result.bitrate = _formatMetric(json[key], 'b/s'); + parsedData[stringKey] = result.bitrate; + 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.duration: + parsedData[stringKey] = _formatDuration(json[key]); + case FFProbeKeys.location: + 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: + result.majorBrand = _formatBrand(json[key]); + parsedData[stringKey] = result.majorBrand; + break; + case FFProbeKeys.startTime: + parsedData[stringKey] = _formatDuration(json[key]); + break; + default: + parsedData[stringKey] = json[key]; + } + } + // iterate through the streams + final List streams = json["streams"]; + for (final stream in streams) { + for (final key in stream.keys) { + if (key == FFProbeKeys.rFrameRate) { + result.fps = _formatFPS(stream[key]); + parsedData[key] = result.fps; + } else if (key == FFProbeKeys.codedWidth) { + result.codecWidth = stream[key].toString(); + parsedData[key] = result.codecWidth; + } else if (key == FFProbeKeys.codedHeight) { + result.codecHeight = stream[key].toString(); + parsedData[key] = result.codecHeight; + } + } + } + result.prodData = parsedData; + return result; } static String _formatBrand(String value) => Mp4.brands[value] ?? value; @@ -235,11 +162,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' @@ -281,7 +210,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) { @@ -291,6 +220,20 @@ class FFProbeProps { return size != null ? formatFileSize(asciiLocale, size) : value; } + static String? _formatFPS(dynamic value) { + if (value == null) return null; + 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 + return (t % b == 0) + ? (t / b).toStringAsFixed(0) + : (t / b).toStringAsFixed(2); + } + return value; + } + static String _formatLanguage(String value) { final language = Language.living639_2 .firstWhereOrNull((language) => language.iso639_2 == value); @@ -315,6 +258,7 @@ class FFProbeProps { longitude: coordinates[1], ); } catch (e) { + log('failed to parse location: $value', error: e); return null; } } 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 fb15a3148d..51e6ea2914 100644 --- a/mobile/lib/ui/viewer/file/file_details_widget.dart +++ b/mobile/lib/ui/viewer/file/file_details_widget.dart @@ -1,17 +1,22 @@ import "dart:async"; +import "dart:developer"; 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"; 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'; @@ -26,6 +31,7 @@ import 'package:photos/ui/viewer/file_details/exif_item_widgets.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"; @@ -44,7 +50,6 @@ class FileDetailsWidget extends StatefulWidget { } class _FileDetailsWidgetState extends State { - final ValueNotifier?> _exifNotifier = ValueNotifier(null); final Map _exifData = { "focalLength": null, "fNumber": null, @@ -64,8 +69,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() { @@ -96,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) { @@ -111,7 +119,6 @@ class _FileDetailsWidgetState extends State { 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(); @@ -120,14 +127,19 @@ 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); + _videoMetadataNotifier.value = properties; + if (kDebugMode) { + log("videoCustomProps ${properties.toString()}"); + log("PropData ${properties.prodData.toString()}"); + } setState(() {}); } @override void dispose() { _exifNotifier.dispose(); + _videoMetadataNotifier.dispose(); _peopleChangedEvent.cancel(); super.dispose(); } @@ -256,6 +268,20 @@ class _FileDetailsWidgetState extends State { }, ), ]); + } else if (flagService.internalUser && widget.file.isVideo) { + fileDetailsTiles.addAll([ + ValueListenableBuilder( + valueListenable: _videoMetadataNotifier, + builder: (context, value, _) { + return Column( + children: [ + VideoExifRowItem(file, value), + const FileDetailsDivider(), + ], + ); + }, + ), + ]); } if (LocalSettings.instance.isFaceIndexingEnabled) { 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 new file mode 100644 index 0000000000..7ad586b7f5 --- /dev/null +++ b/mobile/lib/ui/viewer/file/video_exif_dialog.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import "package:photos/models/ffmpeg/ffprobe_keys.dart"; +import "package:photos/theme/ente_theme.dart"; + +class VideoExifDialog extends StatelessWidget { + final Map probeData; + + const VideoExifDialog({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: [ + _buildGeneralInfo(context), + const SizedBox(height: 8), + _buildSection(context, 'Streams', _buildStreamsList(context)), + ], + ), + ), + ); + } + + Widget _buildSection(BuildContext context, String title, Widget content) { + return ExpansionTile( + initiallyExpanded: true, + title: Text(title, style: getEnteTextTheme(context).largeFaint), + childrenPadding: const EdgeInsets.symmetric(vertical: 2), + tilePadding: const EdgeInsets.symmetric(vertical: 4), + children: [content], + ); + } + + Widget _buildGeneralInfo(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow(context, 'Creation Time', probeData, 'creation_time'), + _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'), + _buildInfoRow(context, 'Format', probeData, 'format'), + ], + ); + } + + Widget _buildStreamsList(BuildContext context) { + 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(context, stream)).toList(), + ); + } + + Widget _buildStreamInfo(BuildContext context, Map stream) { + String titleString = stream['type']?.toString().toUpperCase() ?? ''; + final codeName = stream['codec_name']?.toString().toUpperCase() ?? ''; + if (codeName != 'NULL' && codeName.isNotEmpty) { + titleString += ' - $codeName'; + } + return ExpansionTile( + title: Text( + titleString, + style: getEnteTextTheme(context).smallBold, + ), + childrenPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 4), + tilePadding: const EdgeInsets.symmetric(vertical: 4), + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: stream.entries + .map( + (entry) => _buildInfoRow(context, entry.key, stream, entry.key), + ) + .toList(), + ), + ], + ); + } + + Widget _buildInfoRow( + BuildContext context, + 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: getEnteTextTheme(context).smallMuted, + ), + ), + Expanded(child: Text(value.toString())), + ], + ), + ); + } catch (e, _) { + return const SizedBox.shrink(); + } + } +} diff --git a/mobile/lib/ui/viewer/file_details/video_exif_item.dart b/mobile/lib/ui/viewer/file_details/video_exif_item.dart new file mode 100644 index 0000000000..b53bc69da8 --- /dev/null +++ b/mobile/lib/ui/viewer/file_details/video_exif_item.dart @@ -0,0 +1,86 @@ +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/video_exif_dialog.dart"; +import "package:photos/utils/toast_util.dart"; + +class VideoExifRowItem extends StatefulWidget { + final EnteFile file; + final FFProbeProps? props; + const VideoExifRowItem( + this.file, + this.props, { + 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: "Video Info", + subtitleSection: + _exifButton(context, widget.file, widget.props?.prodData), + onTap: _onTap, + ); + } + + Future> _exifButton( + BuildContext context, + EnteFile file, + Map? exif, + ) async { + late final String label; + late final VoidCallback? onTap; + if (exif == null) { + label = S.of(context).loadingExifData; + onTap = null; + } else if (exif.isNotEmpty) { + label = "${widget.props?.videoInfo ?? ''} .."; + onTap = () => showBarModalBottomSheet( + context: context, + builder: (BuildContext context) { + return VideoExifDialog( + probeData: exif, + ); + }, + 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: true, + ); + } 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), + ]); + } +} 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 {}; 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: