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] [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), + ]); + } +}