diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a7a86970..00afc36fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Added
+- support for animated AVIF (requires rescan)
- Collection: filtering by rating range
- About: data usage
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index dd8cbd10d..d5193fd09 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -57,8 +57,9 @@
allow install on API 19, despite the `minSdkVersion` declared in dependencies:
- Google Maps is from API 20
- the Security library is from API 21
+ - FFmpegKit for Flutter is from API 24
-->
-
+
diff --git a/lib/model/entry/entry.dart b/lib/model/entry/entry.dart
index ab00acb3d..1c7075143 100644
--- a/lib/model/entry/entry.dart
+++ b/lib/model/entry/entry.dart
@@ -294,6 +294,9 @@ class AvesEntry with AvesEntryBase {
return d == null ? null : DateTime(d.year, d.month, d.day);
}
+ @override
+ bool get isAnimated => catalogMetadata?.isAnimated ?? false;
+
@override
int? get durationMillis => _durationMillis;
diff --git a/lib/model/entry/extensions/catalog.dart b/lib/model/entry/extensions/catalog.dart
index 73ee03ac6..626d55e32 100644
--- a/lib/model/entry/extensions/catalog.dart
+++ b/lib/model/entry/extensions/catalog.dart
@@ -3,6 +3,7 @@ import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/geotiff.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/video/metadata.dart';
+import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/metadata/svg_metadata_service.dart';
@@ -23,7 +24,7 @@ extension ExtraAvesEntryCatalog on AvesEntry {
catalogMetadata = CatalogMetadata(id: id);
} else {
// pre-processing
- if (isVideo && (!isSized || durationMillis == 0)) {
+ if ((isVideo && (!isSized || durationMillis == 0)) || mimeType == MimeTypes.avif) {
// exotic video that is not sized during loading
final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
await applyNewFields(fields, persist: persist);
@@ -33,7 +34,7 @@ extension ExtraAvesEntryCatalog on AvesEntry {
catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background);
// post-processing
- if (isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) {
+ if ((isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) || (mimeType == MimeTypes.avif && durationMillis != null)) {
catalogMetadata = await VideoMetadataFormatter.getCatalogMetadata(this);
}
if (isGeotiff && !hasGps) {
diff --git a/lib/model/entry/extensions/props.dart b/lib/model/entry/extensions/props.dart
index 0d78575a9..16e71676a 100644
--- a/lib/model/entry/extensions/props.dart
+++ b/lib/model/entry/extensions/props.dart
@@ -26,7 +26,9 @@ extension ExtraAvesEntryProps on AvesEntry {
bool get isImage => MimeTypes.isImage(mimeType);
- bool get isVideo => MimeTypes.isVideo(mimeType);
+ bool get isVideo => MimeTypes.isVideo(mimeType) || (mimeType == MimeTypes.avif && isAnimated);
+
+ bool get isPureVideo => isVideo && !isAnimated;
// size
@@ -68,8 +70,6 @@ extension ExtraAvesEntryProps on AvesEntry {
// catalog
- bool get isAnimated => catalogMetadata?.isAnimated ?? false;
-
bool get isGeotiff => catalogMetadata?.isGeotiff ?? false;
bool get is360 => catalogMetadata?.is360 ?? false;
diff --git a/lib/model/metadata/catalog.dart b/lib/model/metadata/catalog.dart
index ee09e90b0..0d389eae2 100644
--- a/lib/model/metadata/catalog.dart
+++ b/lib/model/metadata/catalog.dart
@@ -55,6 +55,7 @@ class CatalogMetadata {
int? id,
String? mimeType,
int? dateMillis,
+ bool? isAnimated,
bool? isMultiPage,
int? rotationDegrees,
double? latitude,
@@ -64,7 +65,7 @@ class CatalogMetadata {
id: id ?? this.id,
mimeType: mimeType ?? this.mimeType,
dateMillis: dateMillis ?? this.dateMillis,
- isAnimated: isAnimated,
+ isAnimated: isAnimated ?? this.isAnimated,
isFlipped: isFlipped,
isGeotiff: isGeotiff,
is360: is360,
diff --git a/lib/model/video/metadata.dart b/lib/model/video/metadata.dart
index 2f4c51447..d86b9dc77 100644
--- a/lib/model/video/metadata.dart
+++ b/lib/model/video/metadata.dart
@@ -8,6 +8,7 @@ import 'package:aves/model/video/profiles/aac.dart';
import 'package:aves/model/video/profiles/h264.dart';
import 'package:aves/model/video/profiles/hevc.dart';
import 'package:aves/ref/languages.dart';
+import 'package:aves/ref/mime_types.dart';
import 'package:aves/ref/mp4.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/format.dart';
@@ -24,7 +25,8 @@ class VideoMetadataFormatter {
static final _ambiguousDatePatterns = {
RegExp(r'^\d{2}[-/]\d{2}[-/]\d{4}$'),
};
- static final _durationPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)');
+ static final _durationHmsmPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)');
+ static final _durationSmPattern = RegExp(r'(\d+)(.\d+)');
static final _locationPattern = RegExp(r'([+-][.0-9]+)');
static final Map _codecNames = {
Codecs.ac3: 'AC-3',
@@ -63,13 +65,27 @@ class VideoMetadataFormatter {
final durationMicros = mediaInfo[Keys.durationMicros];
if (durationMicros is num) {
fields['durationMillis'] = (durationMicros / 1000).round();
+ } else {
+ final duration = _parseDuration(mediaInfo[Keys.duration]);
+ if (duration != null) {
+ fields['durationMillis'] = duration.inMilliseconds;
+ }
}
return fields;
}
static Future getCatalogMetadata(AvesEntry entry) async {
+ var catalogMetadata = entry.catalogMetadata ?? CatalogMetadata(id: entry.id);
+
final mediaInfo = await videoMetadataFetcher.getMetadata(entry);
+ if (entry.mimeType == MimeTypes.avif) {
+ final duration = _parseDuration(mediaInfo[Keys.duration]);
+ if (duration == null) return null;
+
+ catalogMetadata = catalogMetadata.copyWith(isAnimated: true);
+ }
+
// only consider values with at least 8 characters (yyyymmdd),
// ignoring unset values like `0`, as well as year values like `2021`
bool isDefined(dynamic value) => value is String && value.length >= 8;
@@ -88,12 +104,12 @@ class VideoMetadataFormatter {
// exclude date if it is suspiciously close to epoch
if (dateMillis != null && !DateTime.fromMillisecondsSinceEpoch(dateMillis).isAtSameDayAs(epoch)) {
- return (entry.catalogMetadata ?? CatalogMetadata(id: entry.id)).copyWith(
+ catalogMetadata = catalogMetadata.copyWith(
dateMillis: dateMillis,
);
}
- return entry.catalogMetadata;
+ return catalogMetadata;
}
static bool isAmbiguousDate(String dateString) {
@@ -180,14 +196,21 @@ class VideoMetadataFormatter {
switch (key) {
case Keys.codecLevel:
+ case Keys.codecTag:
+ case Keys.codecTagString:
+ case Keys.durationTs:
case Keys.fpsNum:
- case Keys.handlerName:
case Keys.index:
+ case Keys.isAvc:
+ case Keys.probeScore:
+ case Keys.programCount:
+ case Keys.refs:
case Keys.sarNum:
case Keys.selectedAudioStream:
case Keys.selectedTextStream:
case Keys.selectedVideoStream:
case Keys.statisticsTags:
+ case Keys.streamCount:
case Keys.streams:
case Keys.streamType:
case Keys.tbrNum:
@@ -205,10 +228,14 @@ class VideoMetadataFormatter {
case Keys.bitrate:
case Keys.bps:
save('Bit Rate', _formatMetric(value, 'b/s'));
+ case Keys.bitsPerRawSample:
+ save('Bits Per Raw Sample', value);
case Keys.byteCount:
save('Size', _formatFilesize(value));
case Keys.channelLayout:
save('Channel Layout', _formatChannelLayout(value));
+ case Keys.chromaLocation:
+ save('Chroma Location', value);
case Keys.codecName:
if (value != 'none') {
save('Format', _formatCodecName(value));
@@ -219,6 +246,18 @@ class VideoMetadataFormatter {
// user-friendly descriptions for related enums are defined in libavutil/pixfmt.h
save('Pixel Format', (value as String).toUpperCase());
}
+ case Keys.codedHeight:
+ save('Coded Height', '$value pixels');
+ case Keys.codedWidth:
+ save('Coded Width', '$value pixels');
+ case Keys.colorPrimaries:
+ save('Color Primaries', (value as String).toUpperCase());
+ case Keys.colorRange:
+ save('Color Range', (value as String).toUpperCase());
+ case Keys.colorSpace:
+ save('Color Space', (value as String).toUpperCase());
+ case Keys.colorTransfer:
+ save('Color Transfer', (value as String).toUpperCase());
case Keys.codecProfileId:
{
final profile = int.tryParse(value);
@@ -228,9 +267,9 @@ class VideoMetadataFormatter {
case Codecs.h264:
case Codecs.hevc:
{
- final levelString = info[Keys.codecLevel];
- if (levelString != null) {
- final level = int.tryParse(levelString) ?? 0;
+ final levelValue = info[Keys.codecLevel];
+ if (levelValue != null) {
+ final level = levelValue is int ? levelValue : int.tryParse(levelValue) ?? 0;
if (codec == Codecs.h264) {
profileString = H264.formatProfile(profile, level);
} else {
@@ -254,6 +293,8 @@ class VideoMetadataFormatter {
save('Compatible Brands', formattedBrands);
case Keys.creationTime:
save('Creation Time', _formatDate(value));
+ case Keys.dar:
+ save('Display Aspect Ratio', value);
case Keys.date:
if (value is String && value != '0') {
final charCount = value.length;
@@ -263,10 +304,18 @@ class VideoMetadataFormatter {
save('Duration', _formatDuration(value));
case Keys.durationMicros:
if (value != 0) save('Duration', formatPreciseDuration(Duration(microseconds: value)));
+ case Keys.extraDataSize:
+ save('Extra Data Size', _formatFilesize(value));
+ case Keys.fieldOrder:
+ save('Field Order', value);
case Keys.fpsDen:
save('Frame Rate', '${roundToPrecision(info[Keys.fpsNum] / info[Keys.fpsDen], decimals: 3).toString()} FPS');
case Keys.frameCount:
save('Frame Count', value);
+ case Keys.handlerName:
+ save('Handler Name', value);
+ case Keys.hasBFrames:
+ save('Has B-Frames', value);
case Keys.height:
save('Height', '$value pixels');
case Keys.language:
@@ -281,6 +330,8 @@ class VideoMetadataFormatter {
save('Media Type', value);
case Keys.minorVersion:
if (value != '0') save('Minor Version', value);
+ case Keys.nalLengthSize:
+ save('NAL Length Size', _formatFilesize(value));
case Keys.quicktimeLocationAccuracyHorizontal:
save('QuickTime Location Horizontal Accuracy', value);
case Keys.quicktimeCreationDate:
@@ -290,10 +341,16 @@ class VideoMetadataFormatter {
case Keys.quicktimeSoftware:
// redundant with `QuickTime Metadata` directory
break;
+ case Keys.rFrameRate:
+ save('R Frame Rate', value);
case Keys.rotate:
save('Rotation', '$value°');
+ case Keys.sampleFormat:
+ save('Sample Format', (value as String).toUpperCase());
case Keys.sampleRate:
save('Sample Rate', _formatMetric(value, 'Hz'));
+ case Keys.sar:
+ save('Sample Aspect Ratio', value);
case Keys.sarDen:
final sarNum = info[Keys.sarNum];
final sarDen = info[Keys.sarDen];
@@ -303,12 +360,20 @@ class VideoMetadataFormatter {
save('Source OSHash', value);
case Keys.startMicros:
if (value != 0) save('Start', formatPreciseDuration(Duration(microseconds: value)));
+ case Keys.startPts:
+ save('Start PTS', value);
+ case Keys.startTime:
+ save('Start', _formatDuration(value));
case Keys.statisticsWritingApp:
save('Stats Writing App', value);
case Keys.statisticsWritingDateUtc:
save('Stats Writing Date', _formatDate(value));
+ case Keys.timeBase:
+ save('Time Base', value);
case Keys.track:
if (value != '0') save('Track', value);
+ case Keys.vendorId:
+ save('Vendor ID', value);
case Keys.width:
save('Width', '$value pixels');
case Keys.xiaomiSlowMoment:
@@ -326,7 +391,12 @@ class VideoMetadataFormatter {
static String _formatBrand(String value) => Mp4.brands[value] ?? value;
- static String _formatChannelLayout(value) => ChannelLayouts.names[value] ?? 'unknown ($value)';
+ static String _formatChannelLayout(dynamic value) {
+ if (value is int) {
+ return ChannelLayouts.names[value] ?? 'unknown ($value)';
+ }
+ return '$value';
+ }
static String _formatCodecName(String value) => _codecNames[value] ?? value.toUpperCase().replaceAll('_', ' ');
@@ -338,28 +408,49 @@ class VideoMetadataFormatter {
return date.toIso8601String();
}
- // input example: '00:00:05.408000000'
- static String _formatDuration(String value) {
- final match = _durationPattern.firstMatch(value);
+ // input example: '00:00:05.408000000' or '5.408000'
+ static Duration? _parseDuration(String? value) {
+ if (value == null) return null;
+
+ var match = _durationHmsmPattern.firstMatch(value);
if (match != null) {
final h = int.tryParse(match.group(1)!);
final m = int.tryParse(match.group(2)!);
final s = int.tryParse(match.group(3)!);
final millis = double.tryParse(match.group(4)!);
if (h != null && m != null && s != null && millis != null) {
- return formatPreciseDuration(Duration(
+ return Duration(
hours: h,
minutes: m,
seconds: s,
milliseconds: (millis * 1000).toInt(),
- ));
+ );
}
}
- return value;
+
+ match = _durationSmPattern.firstMatch(value);
+ if (match != null) {
+ final s = int.tryParse(match.group(1)!);
+ final millis = double.tryParse(match.group(2)!);
+ if (s != null && millis != null) {
+ return Duration(
+ seconds: s,
+ milliseconds: (millis * 1000).toInt(),
+ );
+ }
+ }
+
+ return null;
}
- static String _formatFilesize(String value) {
- final size = int.tryParse(value);
+ // input example: '00:00:05.408000000' or '5.408000'
+ static String _formatDuration(String value) {
+ final duration = _parseDuration(value);
+ return duration != null ? formatPreciseDuration(duration) : value;
+ }
+
+ static String _formatFilesize(dynamic value) {
+ final size = value is int ? value : int.tryParse(value);
return size != null ? formatFileSize('en_US', size) : value;
}
diff --git a/lib/services/common/services.dart b/lib/services/common/services.dart
index 4796d4697..905ab96c0 100644
--- a/lib/services/common/services.dart
+++ b/lib/services/common/services.dart
@@ -20,7 +20,7 @@ import 'package:aves_report_platform/aves_report_platform.dart';
import 'package:aves_services/aves_services.dart';
import 'package:aves_services_platform/aves_services_platform.dart';
import 'package:aves_video/aves_video.dart';
-import 'package:aves_video_ijk/aves_video_ijk.dart';
+import 'package:aves_video_ffmpeg/aves_video_ffmpeg.dart';
import 'package:aves_video_mpv/aves_video_mpv.dart';
import 'package:get_it/get_it.dart';
import 'package:path/path.dart' as p;
@@ -56,7 +56,7 @@ void initPlatformServices() {
getIt.registerLazySingleton(LiveAvesAvailability.new);
getIt.registerLazySingleton(SqfliteMetadataDb.new);
getIt.registerLazySingleton(MpvVideoControllerFactory.new);
- getIt.registerLazySingleton(IjkVideoMetadataFetcher.new);
+ getIt.registerLazySingleton(FfmpegVideoMetadataFetcher.new);
getIt.registerLazySingleton(PlatformAppService.new);
getIt.registerLazySingleton(PlatformDeviceService.new);
diff --git a/lib/widgets/common/grid/theme.dart b/lib/widgets/common/grid/theme.dart
index a7258cfa3..53487f19a 100644
--- a/lib/widgets/common/grid/theme.dart
+++ b/lib/widgets/common/grid/theme.dart
@@ -89,7 +89,7 @@ class GridThemeData {
if (located && showLocated) LocationIcon.located(),
if (!located && showUnlocated) LocationIcon.unlocated(),
if (entry.rating != 0 && showRating) RatingIcon(entry: entry),
- if (entry.isVideo)
+ if (entry.isPureVideo)
VideoIcon(entry: entry)
else if (entry.isAnimated)
const AnimatedImageIcon()
diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart
index d31760d64..e4c8f36db 100644
--- a/lib/widgets/viewer/action/entry_action_delegate.dart
+++ b/lib/widgets/viewer/action/entry_action_delegate.dart
@@ -80,18 +80,18 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.flip:
return targetEntry.canFlip;
case EntryAction.convert:
- return canWrite && !targetEntry.isVideo;
+ return canWrite && !targetEntry.isPureVideo;
case EntryAction.print:
- return !targetEntry.isVideo;
+ return !targetEntry.isPureVideo;
case EntryAction.openMap:
return !settings.useTvLayout && targetEntry.hasGps;
case EntryAction.viewSource:
return targetEntry.isSvg;
case EntryAction.videoCaptureFrame:
- return canWrite && targetEntry.isVideo;
+ return canWrite && targetEntry.isPureVideo;
case EntryAction.lockViewer:
case EntryAction.videoToggleMute:
- return !settings.useTvLayout && targetEntry.isVideo;
+ return !settings.useTvLayout && targetEntry.isPureVideo;
case EntryAction.videoSelectStreams:
case EntryAction.videoSetSpeed:
case EntryAction.videoSettings:
@@ -99,7 +99,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.videoReplay10:
case EntryAction.videoSkip10:
case EntryAction.openVideo:
- return targetEntry.isVideo;
+ return targetEntry.isPureVideo;
case EntryAction.rotateScreen:
return !settings.useTvLayout && settings.isRotationLocked;
case EntryAction.addShortcut:
diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart
index baea7b6fe..eea57a7b3 100644
--- a/lib/widgets/viewer/entry_viewer_stack.dart
+++ b/lib/widgets/viewer/entry_viewer_stack.dart
@@ -417,7 +417,7 @@ class _EntryViewerStackState extends State with EntryViewContr
final targetEntry = pageEntry ?? mainEntry;
Widget? child;
// a 360 video is both a video and a panorama but only the video controls are displayed
- if (targetEntry.isVideo) {
+ if (targetEntry.isPureVideo) {
child = Selector(
selector: (context, vc) => vc.getController(targetEntry),
builder: (context, videoController, child) => VideoControlOverlay(
diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart
index 73445653d..ae9726151 100644
--- a/lib/widgets/viewer/info/basic_section.dart
+++ b/lib/widgets/viewer/info/basic_section.dart
@@ -125,8 +125,8 @@ class _BasicSectionState extends State {
if (entry.isMotionPhoto) TypeFilter.motionPhoto,
if (entry.isRaw) TypeFilter.raw,
if (entry.isImage && entry.is360) TypeFilter.panorama,
- if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo,
- if (entry.isVideo && !entry.is360) MimeFilter.video,
+ if (entry.isPureVideo && entry.is360) TypeFilter.sphericalVideo,
+ if (entry.isPureVideo && !entry.is360) MimeFilter.video,
if (date != null) DateFilter(DateLevel.ymd, date),
if (album != null) AlbumFilter(album, collection?.source.getAlbumDisplayName(context, album)),
if (entry.rating != 0) RatingFilter(entry.rating),
diff --git a/lib/widgets/viewer/visual/controller_mixin.dart b/lib/widgets/viewer/visual/controller_mixin.dart
index 455337b33..a86d281c9 100644
--- a/lib/widgets/viewer/visual/controller_mixin.dart
+++ b/lib/widgets/viewer/visual/controller_mixin.dart
@@ -126,7 +126,7 @@ mixin EntryViewControllerMixin on State {
final controller = context.read().getOrCreateController(entry);
setState(() {});
- if (videoAutoPlayEnabled) {
+ if (videoAutoPlayEnabled || entry.isAnimated) {
final resumeTimeMillis = await controller.getResumeTime(context);
await _autoPlayVideo(controller, () => entry == entryNotifier.value, resumeTimeMillis: resumeTimeMillis);
}
@@ -198,7 +198,7 @@ mixin EntryViewControllerMixin on State {
// so we play after a delay for increased stability
await Future.delayed(const Duration(milliseconds: 300) * timeDilation);
- if (!videoController.isMuted && shouldAutoPlayVideoMuted) {
+ if (!videoController.isMuted && (videoController.entry.isAnimated || shouldAutoPlayVideoMuted)) {
await videoController.mute(true);
}
diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart
index 8d9998668..29f463b99 100644
--- a/lib/widgets/viewer/visual/entry_page_view.dart
+++ b/lib/widgets/viewer/visual/entry_page_view.dart
@@ -201,12 +201,13 @@ class _EntryPageViewState extends State with SingleTickerProvider
valueListenable: videoController.sarNotifier,
builder: (context, sar, child) {
final videoDisplaySize = entry.videoDisplaySize(sar);
+ final isPureVideo = entry.isPureVideo;
return Selector>(
selector: (context, s) => Tuple3(
- s.videoGestureDoubleTapTogglePlay,
- s.videoGestureSideDoubleTapSeek,
- s.videoGestureVerticalDragBrightnessVolume,
+ isPureVideo && s.videoGestureDoubleTapTogglePlay,
+ isPureVideo && s.videoGestureSideDoubleTapSeek,
+ isPureVideo && s.videoGestureVerticalDragBrightnessVolume,
),
builder: (context, s, child) {
final playGesture = s.item1;
diff --git a/lib/widgets/wallpaper_page.dart b/lib/widgets/wallpaper_page.dart
index 0642b57e1..5d9311ac7 100644
--- a/lib/widgets/wallpaper_page.dart
+++ b/lib/widgets/wallpaper_page.dart
@@ -173,7 +173,7 @@ class _EntryEditorState extends State with EntryViewControllerMixin
final targetEntry = pageEntry ?? mainEntry;
Widget? child;
// a 360 video is both a video and a panorama but only the video controls are displayed
- if (targetEntry.isVideo) {
+ if (targetEntry.isPureVideo) {
child = Selector(
selector: (context, vc) => vc.getController(targetEntry),
builder: (context, videoController, child) => VideoControlOverlay(
diff --git a/plugins/aves_model/lib/src/entry/base.dart b/plugins/aves_model/lib/src/entry/base.dart
index 4765bb9c4..68346d1eb 100644
--- a/plugins/aves_model/lib/src/entry/base.dart
+++ b/plugins/aves_model/lib/src/entry/base.dart
@@ -15,6 +15,8 @@ mixin AvesEntryBase {
int? get durationMillis;
+ bool get isAnimated;
+
int get rotationDegrees;
Size get displaySize;
diff --git a/plugins/aves_model/lib/src/video/keys.dart b/plugins/aves_model/lib/src/video/keys.dart
index 9cb9e3fbd..5f824d104 100644
--- a/plugins/aves_model/lib/src/video/keys.dart
+++ b/plugins/aves_model/lib/src/video/keys.dart
@@ -6,41 +6,66 @@ class Keys {
static const androidManufacturer = 'com.android.manufacturer';
static const androidModel = 'com.android.model';
static const androidVersion = 'com.android.version';
+ static const avgFrameRate = 'avg_frame_rate';
static const bps = 'bps';
static const bitrate = 'bitrate';
+ static const bitsPerRawSample = 'bits_per_raw_sample';
static const byteCount = 'number_of_bytes';
static const channelLayout = 'channel_layout';
+ static const chromaLocation = 'chroma_location';
static const codecLevel = 'codec_level';
static const codecName = 'codec_name';
static const codecPixelFormat = 'codec_pixel_format';
static const codecProfileId = 'codec_profile_id';
+ static const codecTag = 'codec_tag';
+ static const codecTagString = 'codec_tag_string';
+ static const codedHeight = 'coded_height';
+ static const codedWidth = 'coded_width';
+ static const colorPrimaries = 'color_primaries';
+ static const colorRange = 'color_range';
+ static const colorSpace = 'color_space';
+ static const colorTransfer = 'color_transfer';
static const compatibleBrands = 'compatible_brands';
static const creationTime = 'creation_time';
+ static const dar = 'display_aspect_ratio';
static const date = 'date';
+ static const disposition = 'disposition';
static const duration = 'duration';
static const durationMicros = 'duration_us';
+ static const durationTs = 'duration_ts';
static const encoder = 'encoder';
+ static const extraDataSize = 'extradata_size';
+ static const fieldOrder = 'field_order';
static const filename = 'filename';
static const fpsDen = 'fps_den';
static const fpsNum = 'fps_num';
static const frameCount = 'number_of_frames';
static const handlerName = 'handler_name';
+ static const hasBFrames = 'has_b_frames';
static const height = 'height';
static const index = 'index';
+ static const isAvc = 'is_avc';
static const language = 'language';
static const location = 'location';
static const majorBrand = 'major_brand';
static const mediaFormat = 'format';
static const mediaType = 'media_type';
static const minorVersion = 'minor_version';
+ static const nalLengthSize = 'nal_length_size';
+ static const probeScore = 'probe_score';
+ static const programCount = 'nb_programs';
static const quicktimeCreationDate = 'com.apple.quicktime.creationdate';
static const quicktimeLocationAccuracyHorizontal = 'com.apple.quicktime.location.accuracy.horizontal';
static const quicktimeLocationIso6709 = 'com.apple.quicktime.location.iso6709';
static const quicktimeMake = 'com.apple.quicktime.make';
static const quicktimeModel = 'com.apple.quicktime.model';
static const quicktimeSoftware = 'com.apple.quicktime.software';
+ static const refs = 'refs';
+ static const rFrameRate = 'r_frame_rate';
static const rotate = 'rotate';
+ static const sampleFormat = 'sample_fmt';
static const sampleRate = 'sample_rate';
+ static const sar = 'sample_aspect_ratio';
static const sarDen = 'sar_den';
static const sarNum = 'sar_num';
static const selectedAudioStream = 'audio';
@@ -48,15 +73,20 @@ class Keys {
static const selectedVideoStream = 'video';
static const sourceOshash = 'source_oshash';
static const startMicros = 'start_us';
+ static const startPts = 'start_pts';
+ static const startTime = 'start_time';
static const statisticsTags = '_statistics_tags';
static const statisticsWritingApp = '_statistics_writing_app';
static const statisticsWritingDateUtc = '_statistics_writing_date_utc';
+ static const streamCount = 'nb_streams';
static const streams = 'streams';
static const tbrDen = 'tbr_den';
static const tbrNum = 'tbr_num';
static const streamType = 'type';
static const title = 'title';
+ static const timeBase = 'time_base';
static const track = 'track';
+ static const vendorId = 'vendor_id';
static const width = 'width';
static const xiaomiSlowMoment = 'com.xiaomi.slow_moment';
}
diff --git a/plugins/aves_video/lib/src/video_loop_mode.dart b/plugins/aves_video/lib/src/video_loop_mode.dart
index 587f5f815..196bd017d 100644
--- a/plugins/aves_video/lib/src/video_loop_mode.dart
+++ b/plugins/aves_video/lib/src/video_loop_mode.dart
@@ -3,11 +3,14 @@ import 'package:aves_model/aves_model.dart';
extension ExtraVideoLoopMode on VideoLoopMode {
static const shortVideoThreshold = Duration(seconds: 30);
- bool shouldLoop(int? durationMillis) {
+ bool shouldLoop(AvesEntryBase entry) {
+ if (entry.isAnimated) return true;
+
switch (this) {
case VideoLoopMode.never:
return false;
case VideoLoopMode.shortOnly:
+ final durationMillis = entry.durationMillis;
return durationMillis != null ? durationMillis < shortVideoThreshold.inMilliseconds : false;
case VideoLoopMode.always:
return true;
diff --git a/plugins/aves_video_ffmpeg/.gitignore b/plugins/aves_video_ffmpeg/.gitignore
new file mode 100644
index 000000000..28124a571
--- /dev/null
+++ b/plugins/aves_video_ffmpeg/.gitignore
@@ -0,0 +1,30 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
+#/pubspec.lock
+**/doc/api/
+.dart_tool/
+.packages
+build/
diff --git a/plugins/aves_video_ffmpeg/.metadata b/plugins/aves_video_ffmpeg/.metadata
new file mode 100644
index 000000000..fa347fc6a
--- /dev/null
+++ b/plugins/aves_video_ffmpeg/.metadata
@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: f468f3366c26a5092eb964a230ce7892fda8f2f8
+ channel: stable
+
+project_type: package
diff --git a/plugins/aves_video_ffmpeg/analysis_options.yaml b/plugins/aves_video_ffmpeg/analysis_options.yaml
new file mode 100644
index 000000000..f04c6cf0f
--- /dev/null
+++ b/plugins/aves_video_ffmpeg/analysis_options.yaml
@@ -0,0 +1 @@
+include: ../../analysis_options.yaml
diff --git a/plugins/aves_video_ffmpeg/lib/aves_video_ffmpeg.dart b/plugins/aves_video_ffmpeg/lib/aves_video_ffmpeg.dart
new file mode 100644
index 000000000..3ca8aa3ca
--- /dev/null
+++ b/plugins/aves_video_ffmpeg/lib/aves_video_ffmpeg.dart
@@ -0,0 +1,3 @@
+library aves_video_ffmpeg;
+
+export 'src/metadata.dart';
diff --git a/plugins/aves_video_ffmpeg/lib/src/metadata.dart b/plugins/aves_video_ffmpeg/lib/src/metadata.dart
new file mode 100644
index 000000000..1c4b4a39c
--- /dev/null
+++ b/plugins/aves_video_ffmpeg/lib/src/metadata.dart
@@ -0,0 +1,146 @@
+import 'package:aves_model/aves_model.dart';
+import 'package:aves_video/aves_video.dart';
+import 'package:ffmpeg_kit_flutter/ffmpeg_kit_config.dart';
+import 'package:ffmpeg_kit_flutter/ffprobe_kit.dart';
+import 'package:flutter/foundation.dart';
+
+class FfmpegVideoMetadataFetcher extends AvesVideoMetadataFetcher {
+ static const chaptersKey = 'chapters';
+ static const formatKey = 'format';
+ static const streamsKey = 'streams';
+
+ @override
+ void init() {}
+
+ @override
+ Future