diff --git a/android/app/libs/fijkplayer-full-release.aar b/android/app/libs/fijkplayer-full-release.aar index f9aa1a0a7..17f13e5b2 100644 Binary files a/android/app/libs/fijkplayer-full-release.aar and b/android/app/libs/fijkplayer-full-release.aar differ diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 4fc0f41cc..ccfd75f8e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -18,14 +18,11 @@ import com.bumptech.glide.load.resource.bitmap.TransformationUtils import com.drew.imaging.ImageMetadataReader import com.drew.lang.Rational import com.drew.metadata.Tag +import com.drew.metadata.avi.AviDirectory import com.drew.metadata.exif.* import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.gif.GifAnimationDirectory import com.drew.metadata.iptc.IptcDirectory -import com.drew.metadata.mov.QuickTimeDirectory -import com.drew.metadata.mov.media.QuickTimeMediaDirectory -import com.drew.metadata.mp4.Mp4Directory -import com.drew.metadata.mp4.media.Mp4MediaDirectory import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory import com.drew.metadata.png.PngDirectory import com.drew.metadata.webp.WebpDirectory @@ -62,9 +59,8 @@ import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes -import deckers.thibault.aves.utils.MimeTypes.isHeifLike +import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isImage -import deckers.thibault.aves.utils.MimeTypes.isMultimedia import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.isVideo @@ -80,7 +76,6 @@ import kotlinx.coroutines.launch import org.beyka.tiffbitmapfactory.TiffBitmapFactory import java.io.File import java.text.ParseException -import java.text.SimpleDateFormat import java.util.* import kotlin.math.roundToLong @@ -120,9 +115,19 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) - for (dir in metadata.directories.filter { it.tagCount > 0 && it !is FileTypeDirectory }) { + for (dir in metadata.directories.filter { + it.tagCount > 0 + && it !is FileTypeDirectory + && it !is AviDirectory + }) { // directory name var dirName = dir.name + + // exclude directories known to be redundant with info derived on the Dart side + // they are excluded by name instead of runtime type because excluding `Mp4Directory` + // would also exclude derived directories, such as `Mp4UuidBoxDirectory` + if (allMetadataRedundantDirNames.contains(dirName)) continue + // optional parent to distinguish child directories of the same type dir.parent?.name?.let { dirName = "$it/$dirName" } @@ -151,33 +156,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } else { dirMap.putAll(tags.map { tagMapper(it) }) } - } else if (dir is Mp4Directory || dir is QuickTimeDirectory) { - tags.map { tag -> - val tagName = tag.tagName - when (tag.tagType) { - Mp4Directory.TAG_CREATION_TIME, - Mp4Directory.TAG_MODIFICATION_TIME, - Mp4MediaDirectory.TAG_CREATION_TIME, - Mp4MediaDirectory.TAG_MODIFICATION_TIME, - QuickTimeMediaDirectory.TAG_CREATION_TIME, - QuickTimeMediaDirectory.TAG_MODIFICATION_TIME -> { - val date = dir.getObject(tag.tagType) - if (date is Date) { - // only consider dates after Epoch time - date.takeIf { it.time > 0 }?.let { - // harmonize date format for further processing on Dart side - dirMap[tagName] = MP4_DATE_FORMAT.format(date) - } - } else { - dirMap[tagName] = tag.description - } - } - Mp4MediaDirectory.TAG_LANGUAGE_CODE -> { - tag.description.takeIf { it != "```" && it != "und" }?.let { dirMap[tagName] = it } - } - else -> dirMap[tagName] = tag.description - } - } } else { dirMap.putAll(tags.map { Pair(it.tagName, it.description) }) } @@ -237,7 +215,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (isMultimedia(mimeType)) { + if (isVideo(mimeType)) { + // this is used as fallback when the video metadata cannot be found on the Dart side + // do not include HEIC here val mediaDir = getAllMetadataByMediaMetadataRetriever(uri) if (mediaDir.isNotEmpty()) { metadataMap[Metadata.DIR_MEDIA] = mediaDir @@ -298,7 +278,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { val metadataMap = HashMap() getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap) - if (isMultimedia(mimeType)) { + if (isVideo(mimeType) || isHeic(mimeType)) { getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap) } @@ -514,7 +494,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (isHeifLike(mimeType)) { + if (isHeic(mimeType)) { retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) { if (it > 1) flags = flags or MASK_IS_MULTIPAGE } @@ -622,7 +602,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { getTiffPageInfo(uri, i)?.let { pages.add(toMap(i, it)) } } } - } else if (isHeifLike(mimeType)) { + } else if (isHeic(mimeType)) { fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) { if (this.containsKey(key)) save(this.getInteger(key)) } @@ -907,9 +887,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java) const val CHANNEL = "deckers.thibault/aves/metadata" - private val MP4_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXX", Locale.ROOT).apply { - timeZone = TimeZone.getTimeZone("UTC") - } + private val allMetadataRedundantDirNames = setOf( + "MP4", + "MP4 Sound", + "MP4 Video", + "QuickTime", + "QuickTime Sound", + "QuickTime Video", + ) // catalog metadata & page info private const val KEY_MIME_TYPE = "mimeType" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt index ad7035157..47d7a60f7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt @@ -40,7 +40,7 @@ class RegionFetcher internal constructor( imageHeight: Int, result: MethodChannel.Result, ) { - if (MimeTypes.isHeifLike(mimeType) && pageId != null) { + if (MimeTypes.isHeic(mimeType) && pageId != null) { val id = Pair(uri, pageId) fetch( uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) }, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index 8af006947..5b0b1f8c7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -18,7 +18,7 @@ import deckers.thibault.aves.decoder.VideoThumbnail import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.MimeTypes -import deckers.thibault.aves.utils.MimeTypes.isHeifLike +import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide @@ -42,7 +42,7 @@ class ThumbnailFetcher internal constructor( private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize private val tiffFetch = mimeType == MimeTypes.TIFF - private val multiTrackFetch = isHeifLike(mimeType) && pageId != null + private val multiTrackFetch = isHeic(mimeType) && pageId != null private val customFetch = tiffFetch || multiTrackFetch fun fetch() { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index b0e464483..58746c997 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -16,7 +16,7 @@ import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes -import deckers.thibault.aves.utils.MimeTypes.isHeifLike +import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide @@ -115,7 +115,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) { - val model: Any = if (isHeifLike(mimeType) && pageId != null) { + val model: Any = if (isHeic(mimeType) && pageId != null) { MultiTrackImage(activity, uri, pageId) } else if (mimeType == MimeTypes.TIFF) { TiffImage(activity, uri, pageId) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 62fa4a3cb..fcde6e512 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -130,7 +130,7 @@ abstract class ImageProvider { val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension) val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) - val model: Any = if (MimeTypes.isHeifLike(sourceMimeType) && pageId != null) { + val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) { MultiTrackImage(context, sourceUri, pageId) } else if (sourceMimeType == MimeTypes.TIFF) { TiffImage(context, sourceUri, pageId) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 0c099863a..59661cf8b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -43,9 +43,7 @@ object MimeTypes { fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO) - fun isHeifLike(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF) - - fun isMultimedia(mimeType: String?) = isVideo(mimeType) || isHeifLike(mimeType) + fun isHeic(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF) fun isRaw(mimeType: String): Boolean { return when (mimeType) { diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 5f0cb0f0d..5b69c138c 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -366,7 +366,7 @@ class AvesEntry { String _durationText; String get durationText { - _durationText ??= formatDuration(Duration(milliseconds: durationMillis ?? 0)); + _durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0)); return _durationText; } diff --git a/lib/model/video/h264.dart b/lib/model/video/h264.dart index 8c8b3ad6d..675875244 100644 --- a/lib/model/video/h264.dart +++ b/lib/model/video/h264.dart @@ -13,7 +13,7 @@ class H264 { static const profileHigh444 = 144; static const profileHigh444Predictive = 244; static const profileHigh444Intra = 244 | profileIntra; - static const profileCAVLC_444 = 44; + static const profileCAVLC444 = 44; static String formatProfile(int profileIndex, int level) { String profile; @@ -54,7 +54,7 @@ class H264 { case profileHigh444Intra: profile = 'High 4:4:4 Intra'; break; - case profileCAVLC_444: + case profileCAVLC444: profile = 'CAVLC 4:4:4'; break; default: diff --git a/lib/model/video/keys.dart b/lib/model/video/keys.dart new file mode 100644 index 000000000..ebdefad36 --- /dev/null +++ b/lib/model/video/keys.dart @@ -0,0 +1,48 @@ +// keys returned by fijkplayer when getting media and streams info +// they originate from FFmpeg, fijkplayer, and other software +// that write additional metadata to media files +class Keys { + static const androidCaptureFramerate = 'com.android.capture.fps'; + static const androidVersion = 'com.android.version'; + static const bps = 'bps'; + static const bitrate = 'bitrate'; + static const byteCount = 'number_of_bytes'; + static const channelLayout = 'channel_layout'; + static const codecLevel = 'codec_level'; + static const codecName = 'codec_name'; + static const codecPixelFormat = 'codec_pixel_format'; + static const codecProfileId = 'codec_profile_id'; + static const compatibleBrands = 'compatible_brands'; + static const creationTime = 'creation_time'; + static const date = 'date'; + static const duration = 'duration'; + static const durationMicros = 'duration_us'; + static const encoder = 'encoder'; + 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 height = 'height'; + static const index = 'index'; + 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 rotate = 'rotate'; + static const sampleRate = 'sample_rate'; + static const sarDen = 'sar_den'; + static const sarNum = 'sar_num'; + static const startMicros = 'start_us'; + static const statisticsTags = '_statistics_tags'; + static const statisticsWritingApp = '_statistics_writing_app'; + static const statisticsWritingDateUtc = '_statistics_writing_date_utc'; + static const streams = 'streams'; + static const tbrDen = 'tbr_den'; + static const tbrNum = 'tbr_num'; + static const streamType = 'type'; + static const track = 'track'; + static const width = 'width'; +} diff --git a/lib/model/video/metadata.dart b/lib/model/video/metadata.dart new file mode 100644 index 000000000..d04190340 --- /dev/null +++ b/lib/model/video/metadata.dart @@ -0,0 +1,310 @@ +import 'dart:async'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/video/channel_layouts.dart'; +import 'package:aves/model/video/h264.dart'; +import 'package:aves/model/video/keys.dart'; +import 'package:aves/ref/languages.dart'; +import 'package:aves/ref/mp4.dart'; +import 'package:aves/utils/file_utils.dart'; +import 'package:aves/utils/math_utils.dart'; +import 'package:aves/utils/string_utils.dart'; +import 'package:aves/utils/time_utils.dart'; +import 'package:fijkplayer/fijkplayer.dart'; +import 'package:flutter/foundation.dart'; + +class VideoMetadataFormatter { + static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + static final _durationPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)'); + static final _locationPattern = RegExp(r'([+-][.0-9]+)'); + static final Map _codecNames = { + 'ac3': 'AC-3', + 'eac3': 'E-AC-3', + 'h264': 'AVC (H.264)', + 'hdmv_pgs_subtitle': 'PGS', + 'hevc': 'HEVC (H.265)', + 'matroska': 'Matroska', + 'mpeg4': 'MPEG-4 Visual', + 'mpegts': 'MPEG-TS', + 'subrip': 'SubRip', + 'webm': 'WebM', + }; + + static Future getVideoMetadata(AvesEntry entry) async { + final player = FijkPlayer(); + await player.setDataSource(entry.uri, autoPlay: false); + + final completer = Completer(); + void onChange() { + if ([FijkState.prepared, FijkState.error].contains(player.state)) { + completer.complete(); + } + } + + player.addListener(onChange); + await player.prepareAsync(); + await completer.future; + player.removeListener(onChange); + + final info = await player.getInfo(); + await player.release(); + return info; + } + + // pattern to extract optional language code suffix, e.g. 'location-eng' + static final keyWithLanguagePattern = RegExp(r'^(.*)-([a-z]{3})$'); + + static Map formatInfo(Map info) { + final dir = {}; + final streamType = info[Keys.streamType]; + final codec = info[Keys.codecName]; + for (final kv in info.entries) { + final value = kv.value; + if (value != null) { + try { + String key; + String keyLanguage; + // some keys have a language suffix, but they may be duplicates + // we only keep the root key when they have the same value as the same key with no language + final languageMatch = keyWithLanguagePattern.firstMatch(kv.key); + if (languageMatch != null) { + final code = languageMatch.group(2); + final native = _formatLanguage(code); + if (native != code) { + final root = languageMatch.group(1); + final rootValue = info[root]; + // skip if it is a duplicate of the same entry with no language + if (rootValue == value) continue; + key = root; + if (info.keys.cast().where((k) => k.startsWith('$root-')).length > 1) { + // only keep language when multiple languages are present + keyLanguage = native; + } + } + } + key = (key ?? (kv.key as String)).toLowerCase(); + + void save(String key, String value) { + if (value != null) { + dir[keyLanguage != null ? '$key ($keyLanguage)' : key] = value; + } + } + + switch (key) { + case Keys.codecLevel: + case Keys.fpsNum: + case Keys.handlerName: + case Keys.index: + case Keys.sarNum: + case Keys.streams: + case Keys.tbrNum: + case Keys.tbrDen: + case Keys.statisticsTags: + case Keys.streamType: + break; + case Keys.androidCaptureFramerate: + final captureFps = double.parse(value); + save('Capture Frame Rate', '${roundToPrecision(captureFps, decimals: 3).toString()} FPS'); + break; + case Keys.androidVersion: + save('Android Version', value); + break; + case Keys.bitrate: + case Keys.bps: + save('Bit Rate', _formatMetric(value, 'b/s')); + break; + case Keys.byteCount: + save('Size', _formatFilesize(value)); + break; + case Keys.channelLayout: + save('Channel Layout', _formatChannelLayout(value)); + break; + case Keys.codecName: + save('Format', _formatCodecName(value)); + break; + case Keys.codecPixelFormat: + if (streamType == StreamTypes.video) { + // this is just a short name used by FFmpeg + // user-friendly descriptions for related enums are defined in libavutil/pixfmt.h + save('Pixel Format', (value as String).toUpperCase()); + } + break; + case Keys.codecProfileId: + if (codec == 'h264') { + final profile = int.tryParse(value); + if (profile != null && profile != 0) { + final level = int.tryParse(info[Keys.codecLevel]); + save('Codec Profile', H264.formatProfile(profile, level)); + } + } + break; + case Keys.compatibleBrands: + save('Compatible Brands', RegExp(r'.{4}').allMatches(value).map((m) => _formatBrand(m.group(0))).join(', ')); + break; + case Keys.creationTime: + save('Creation Time', _formatDate(value)); + break; + case Keys.date: + if (value != '0') { + final charCount = (value as String)?.length ?? 0; + save(charCount == 4 ? 'Year' : 'Date', value); + } + break; + case Keys.duration: + save('Duration', _formatDuration(value)); + break; + case Keys.durationMicros: + if (value != 0) save('Duration', formatPreciseDuration(Duration(microseconds: value))); + break; + case Keys.fpsDen: + save('Frame Rate', '${roundToPrecision(info[Keys.fpsNum] / info[Keys.fpsDen], decimals: 3).toString()} FPS'); + break; + case Keys.frameCount: + save('Frame Count', value); + break; + case Keys.height: + save('Height', '$value pixels'); + break; + case Keys.language: + if (value != 'und') save('Language', _formatLanguage(value)); + break; + case Keys.location: + save('Location', _formatLocation(value)); + break; + case Keys.majorBrand: + save('Major Brand', _formatBrand(value)); + break; + case Keys.mediaFormat: + save('Format', (value as String).splitMapJoin(',', onMatch: (s) => ', ', onNonMatch: _formatCodecName)); + break; + case Keys.mediaType: + save('Media Type', value); + break; + case Keys.minorVersion: + if (value != '0') save('Minor Version', value); + break; + case Keys.rotate: + save('Rotation', '$value°'); + break; + case Keys.sampleRate: + save('Sample Rate', _formatMetric(value, 'Hz')); + break; + case Keys.sarDen: + final sarNum = info[Keys.sarNum]; + final sarDen = info[Keys.sarDen]; + // skip common square pixels (1:1) + if (sarNum != sarDen) save('SAR', '$sarNum:$sarDen'); + break; + case Keys.startMicros: + if (value != 0) save('Start', formatPreciseDuration(Duration(microseconds: value))); + break; + case Keys.statisticsWritingApp: + save('Stats Writing App', value); + break; + case Keys.statisticsWritingDateUtc: + save('Stats Writing Date', _formatDate(value)); + break; + case Keys.track: + if (value != '0') save('Track', value); + break; + case Keys.width: + save('Width', '$value pixels'); + break; + default: + save(key.toSentenceCase(), value.toString()); + } + } catch (error) { + debugPrint('failed to process video info key=${kv.key} value=${kv.value}, error=$error'); + } + } + } + return dir; + } + + static String _formatBrand(String value) => Mp4.brands[value] ?? value; + + static String _formatChannelLayout(value) => ChannelLayouts.names[value] ?? 'unknown ($value)'; + + static String _formatCodecName(String value) => _codecNames[value] ?? value?.toUpperCase()?.replaceAll('_', ' '); + + // input example: '2021-04-12T09:14:37.000000Z' + static String _formatDate(String value) { + final date = DateTime.tryParse(value); + if (date == null) return value; + if (date == _epoch) return null; + return date.toIso8601String(); + } + + // input example: '00:00:05.408000000' + static String _formatDuration(String value) { + final match = _durationPattern.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( + hours: h, + minutes: m, + seconds: s, + milliseconds: (millis * 1000).toInt(), + )); + } + } + return value; + } + + static String _formatFilesize(String value) { + final size = int.tryParse(value); + return size != null ? formatFilesize(size) : value; + } + + static String _formatLanguage(String value) { + final language = Language.living639_2.firstWhere((language) => language.iso639_2 == value, orElse: () => null); + return language?.native ?? value; + } + + // format ISO 6709 input, e.g. '+37.5090+127.0243/' (Samsung), '+51.3328-000.7053+113.474/' (Apple) + static String _formatLocation(String value) { + final matches = _locationPattern.allMatches(value); + if (matches.isNotEmpty) { + final coordinates = matches.map((m) => double.tryParse(m.group(0))).toList(); + if (coordinates.every((c) => c == 0)) return null; + return coordinates.join(', '); + } + return value; + } + + static String _formatMetric(dynamic size, String unit, {int round = 2}) { + if (size is String) { + final parsed = int.tryParse(size); + if (parsed == null) return size; + size = parsed; + } + const divider = 1000; + + if (size < divider) return '$size $unit'; + + if (size < divider * divider && size % divider == 0) { + return '${(size / divider).toStringAsFixed(0)} K$unit'; + } + if (size < divider * divider) { + return '${(size / divider).toStringAsFixed(round)} K$unit'; + } + + if (size < divider * divider * divider && size % divider == 0) { + return '${(size / (divider * divider)).toStringAsFixed(0)} M$unit'; + } + return '${(size / divider / divider).toStringAsFixed(round)} M$unit'; + } +} + +class StreamTypes { + static const audio = 'audio'; + static const metadata = 'metadata'; + static const subtitle = 'subtitle'; + static const timedText = 'timedtext'; + static const unknown = 'unknown'; + static const video = 'video'; +} diff --git a/lib/model/video/streams.dart b/lib/model/video/streams.dart deleted file mode 100644 index 9002242d1..000000000 --- a/lib/model/video/streams.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'dart:async'; - -import 'package:aves/model/entry.dart'; -import 'package:aves/model/video/channel_layouts.dart'; -import 'package:aves/model/video/h264.dart'; -import 'package:aves/ref/languages.dart'; -import 'package:aves/utils/math_utils.dart'; -import 'package:fijkplayer/fijkplayer.dart'; - -class StreamInfo { - static const keyBitrate = 'bitrate'; - static const keyChannelLayout = 'channel_layout'; - static const keyCodecLevel = 'codec_level'; - static const keyCodecName = 'codec_name'; - static const keyCodecPixelFormat = 'codec_pixel_format'; - static const keyCodecProfileId = 'codec_profile_id'; - static const keyDurationMicro = 'duration_us'; - static const keyFormat = 'format'; - static const keyFpsDen = 'fps_den'; - static const keyFpsNum = 'fps_num'; - static const keyHeight = 'height'; - static const keyIndex = 'index'; - static const keyLanguage = 'language'; - static const keySampleRate = 'sample_rate'; - static const keySarDen = 'sar_den'; - static const keySarNum = 'sar_num'; - static const keyStartMicro = 'start_us'; - static const keyTbrDen = 'tbr_den'; - static const keyTbrNum = 'tbr_num'; - static const keyType = 'type'; - static const keyWidth = 'width'; - - static const typeAudio = 'audio'; - static const typeMetadata = 'metadata'; - static const typeSubtitle = 'subtitle'; - static const typeTimedText = 'timedtext'; - static const typeUnknown = 'unknown'; - static const typeVideo = 'video'; - - static Future getVideoInfo(AvesEntry entry) async { - final player = FijkPlayer(); - await player.setDataSource(entry.uri, autoPlay: false); - - final completer = Completer(); - void onChange() { - if ([FijkState.prepared, FijkState.error].contains(player.state)) { - completer.complete(); - } - } - - player.addListener(onChange); - await player.prepareAsync(); - await completer.future; - player.removeListener(onChange); - - final info = await player.getInfo(); - await player.release(); - return info; - } - - static String formatBitrate(int size, {int round = 2}) { - const divider = 1000; - const symbol = 'bit/s'; - - if (size < divider) return '$size $symbol'; - - if (size < divider * divider && size % divider == 0) { - return '${(size / divider).toStringAsFixed(0)} K$symbol'; - } - if (size < divider * divider) { - return '${(size / divider).toStringAsFixed(round)} K$symbol'; - } - - if (size < divider * divider * divider && size % divider == 0) { - return '${(size / (divider * divider)).toStringAsFixed(0)} M$symbol'; - } - return '${(size / divider / divider).toStringAsFixed(round)} M$symbol'; - } - - static Map formatStreamInfo(Map stream) { - final dir = {}; - final type = stream[keyType]; - final codec = stream[keyCodecName]; - for (final kv in stream.entries) { - final value = kv.value; - if (value != null) { - final key = kv.key; - switch (key) { - case keyCodecLevel: - case keyFpsNum: - case keyIndex: - case keySarNum: - case keyTbrNum: - case keyTbrDen: - case keyType: - break; - case keyBitrate: - dir['Bitrate'] = formatBitrate(value, round: 1); - break; - case keyChannelLayout: - dir['Channel Layout'] = ChannelLayouts.names[value] ?? 'unknown ($value)'; - break; - case keyCodecName: - dir['Codec'] = _getCodecName(value as String); - break; - case keyCodecPixelFormat: - if (type == typeVideo) { - dir['Pixel Format'] = (value as String).toUpperCase(); - } - break; - case keyCodecProfileId: - if (codec == 'h264') { - final profile = int.tryParse(value as String); - if (profile != null && profile != 0) { - final level = int.tryParse(stream[keyCodecLevel] as String); - dir['Codec Profile'] = H264.formatProfile(profile, level); - } - } - break; - case keyFpsDen: - dir['Frame Rate'] = roundToPrecision(stream[keyFpsNum] / stream[keyFpsDen], decimals: 3).toString(); - break; - case keyHeight: - dir['Height'] = '$value pixels'; - break; - case keyLanguage: - if (value != 'und') { - final language = Language.living639_2.firstWhere((language) => language.iso639_2 == value, orElse: () => null); - dir['Language'] = language?.native ?? value; - } - break; - case keySampleRate: - dir['Sample Rate'] = '$value Hz'; - break; - case keySarDen: - dir['SAR'] = '${stream[keySarNum]}:${stream[keySarDen]}'; - break; - case keyWidth: - dir['Width'] = '$value pixels'; - break; - default: - dir[key] = value.toString(); - } - } - } - return dir; - } - - static String _getCodecName(String value) { - switch (value) { - case 'ac3': - return 'AC-3'; - case 'eac3': - return 'E-AC-3'; - case 'h264': - return 'AVC (H.264)'; - case 'hdmv_pgs_subtitle': - return 'PGS'; - case 'hevc': - return 'HEVC (H.265)'; - case 'mpeg4': - return 'MPEG-4 Visual'; - case 'subrip': - return 'SubRip'; - default: - return value.toUpperCase().replaceAll('_', ' '); - } - } -} diff --git a/lib/ref/mp4.dart b/lib/ref/mp4.dart new file mode 100644 index 000000000..572157ad5 --- /dev/null +++ b/lib/ref/mp4.dart @@ -0,0 +1,88 @@ +class Mp4 { + // adapted from `metadata-extractor` + static final brands = { + '3g2a': '3GPP2 Media compliant with 3GPP2 C.S0050-0 V1.0', + '3g2b': '3GPP2 Media compliant with 3GPP2 C.S0050-A V1.0.0', + '3g2c': '3GPP2 Media compliant with 3GPP2 C.S0050-B v1.0', + '3ge6': '3GPP Release 6 MBMS Extended Presentations', + '3ge7': '3GPP Release 7 MBMS Extended Presentations', + '3gg6': '3GPP Release 6 General Profile', + '3gp1': '3GPP Media Release 1', + '3gp2': '3GPP Media Release 2', + '3gp3': '3GPP Media Release 3', + '3gp4': '3GPP Media Release 4', + '3gp5': '3GPP Media Release 5', + '3gp6': '3GPP Media Release 6', + '3gs7': '3GPP Media Release 7', + 'avc1': 'MP4 Base w/ AVC ext', + 'CAEP': 'Canon Digital Camera', + 'caqv': 'Casio Digital Camera', + 'CDes': 'Convergent Design', + 'da0a': 'DMB MAF w/ MPEG Layer II aud, MOT slides, DLS, JPG/PNG/MNG images', + 'da0b': 'DMB MAF, extending DA0A, with 3GPP timed text, DID, TVA, REL, IPMP', + 'da1a': 'DMB MAF audio with ER-BSAC audio, JPG/PNG/MNG images', + 'da1b': 'DMB MAF, extending da1a, with 3GPP timed text, DID, TVA, REL, IPMP', + 'da2a': 'DMB MAF aud w/ HE-AAC v2 aud, MOT slides, DLS, JPG/PNG/MNG images', + 'da2b': 'DMB MAF, extending da2a, with 3GPP timed text, DID, TVA, REL, IPMP', + 'da3a': 'DMB MAF aud with HE-AAC aud, JPG/PNG/MNG images', + 'da3b': 'DMB MAF, extending da3a w/ BIFS, 3GPP timed text, DID, TVA, REL, IPMP', + 'dmb1': 'DMB MAF supporting all the components defined in the specification', + 'dmpf': 'Digital Media Project', + 'drc1': 'Dirac (wavelet compression), encapsulated in ISO base media (MP4)', + 'dv1a': 'DMB MAF vid w/ AVC vid, ER-BSAC aud, BIFS, JPG/PNG/MNG images, TS', + 'dv1b': 'DMB MAF, extending dv1a, with 3GPP timed text, DID, TVA, REL, IPMP', + 'dv2a': 'DMB MAF vid w/ AVC vid, HE-AAC v2 aud, BIFS, JPG/PNG/MNG images, TS', + 'dv2b': 'DMB MAF, extending dv2a, with 3GPP timed text, DID, TVA, REL, IPMP', + 'dv3a': 'DMB MAF vid w/ AVC vid, HE-AAC aud, BIFS, JPG/PNG/MNG images, TS', + 'dv3b': 'DMB MAF, extending dv3a, with 3GPP timed text, DID, TVA, REL, IPMP', + 'dvr1': 'DVB over RTP', + 'dvt1': 'DVB over MPEG-2 Transport Stream', + 'F4V ': 'Video for Adobe Flash Player 9+', + 'F4P ': 'Protected Video for Adobe Flash Player 9+', + 'F4A ': 'Audio for Adobe Flash Player 9+', + 'F4B ': 'Audio Book for Adobe Flash Player 9+', + 'isc2': 'ISMACryp 2.0 Encrypted File', + 'iso2': 'MP4 Base Media v2', + 'isom': 'MP4 Base Media v1', + 'JP2 ': 'JPEG 2000 Image', + 'jpm ': 'JPEG 2000 Compound Image', + 'jpx ': 'JPEG 2000 w/ extensions', + 'KDDI': '3GPP2 EZmovie for KDDI 3G cellphones', + 'M4A ': 'Apple iTunes AAC-LC Audio', + 'M4B ': 'Apple iTunes AAC-LC Audio Book', + 'M4P ': 'Apple iTunes AAC-LC AES Protected Audio', + 'M4V ': 'Apple iTunes Video', + 'M4VH': 'Apple TV', + 'M4VP': 'Apple iPhone', + 'mj2s': 'Motion JPEG 2000 Simple Profile', + 'mjp2': 'Motion JPEG 2000 General Profile', + 'mmp4': 'MPEG-4/3GPP Mobile Profile', + 'mp21': 'MPEG-21', + 'mp41': 'MP4 v1', + 'mp42': 'MP4 v2', + 'mp71': 'MP4 w/ MPEG-7 Metadata', + 'MPPI': 'Photo Player, MAF', + 'mqt ': 'Sony / Mobile QuickTime', + 'MSNV': 'MPEG-4 for SonyPSP', + 'NDAS': 'MP4 v2 Nero Digital AAC Audio', + 'NDSC': 'MPEG-4 Nero Cinema Profile', + 'NDSH': 'MPEG-4 Nero HDTV Profile', + 'NDSM': 'MPEG-4 Nero Mobile Profile', + 'NDSP': 'MPEG-4 Nero Portable Profile', + 'NDSS': 'MPEG-4 Nero Standard Profile', + 'NDXC': 'H.264/MPEG-4 AVC Nero Cinema Profile', + 'NDXH': 'H.264/MPEG-4 AVC Nero HDTV Profile', + 'NDXM': 'H.264/MPEG-4 AVC Nero Mobile Profile', + 'NDXP': 'H.264/MPEG-4 AVC Nero Portable Profile', + 'NDXS': 'H.264/MPEG-4 AVC Nero Standard Profile', + 'odcf': 'OMA DCF DRM Format 2.0', + 'opf2': 'OMA PDCF DRM Format 2.1', + 'opx2': 'OMA PDCF DRM + XBS extensions', + 'pana': 'Panasonic Digital Camera', + 'qt ': 'Apple QuickTime', + 'ROSS': 'Ross Video', + 'sdv ': 'SD Memory Card Video', + 'ssc1': 'Samsung stereoscopic, single stream', + 'ssc2': 'Samsung stereoscopic, dual stream', + }; +} diff --git a/lib/utils/time_utils.dart b/lib/utils/time_utils.dart index 428a799b5..9e95fea75 100644 --- a/lib/utils/time_utils.dart +++ b/lib/utils/time_utils.dart @@ -1,14 +1,17 @@ -String formatDuration(Duration d) { - String twoDigits(int n) { - if (n >= 10) return '$n'; - return '0$n'; - } +String formatFriendlyDuration(Duration d) { + final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0'); + if (d.inHours == 0) return '${d.inMinutes}:$seconds'; - final twoDigitSeconds = twoDigits(d.inSeconds.remainder(Duration.secondsPerMinute)); - if (d.inHours == 0) return '${d.inMinutes}:$twoDigitSeconds'; + final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0'); + return '${d.inHours}:$minutes:$seconds'; +} - final twoDigitMinutes = twoDigits(d.inMinutes.remainder(Duration.minutesPerHour)); - return '${d.inHours}:$twoDigitMinutes:$twoDigitSeconds'; +String formatPreciseDuration(Duration d) { + final millis = ((d.inMicroseconds / 1000.0).round() % 1000).toString().padLeft(3, '0'); + final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0'); + final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0'); + final hours = (d.inHours).toString().padLeft(2, '0'); + return '$hours:$minutes:$seconds.$millis'; } extension ExtraDateTime on DateTime { diff --git a/lib/widgets/viewer/info/metadata/metadata_section.dart b/lib/widgets/viewer/info/metadata/metadata_section.dart index 80d162a63..334944b2c 100644 --- a/lib/widgets/viewer/info/metadata/metadata_section.dart +++ b/lib/widgets/viewer/info/metadata/metadata_section.dart @@ -2,7 +2,8 @@ import 'dart:async'; import 'dart:collection'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/video/streams.dart'; +import 'package:aves/model/video/keys.dart'; +import 'package:aves/model/video/metadata.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/services.dart'; import 'package:aves/services/svg_metadata_service.dart'; @@ -180,28 +181,35 @@ class _MetadataSectionSliverState extends State with Auto Future> _getStreamDirectories() async { final directories = []; - final info = await StreamInfo.getVideoInfo(entry); - if (info.containsKey('streams')) { + final mediaInfo = await VideoMetadataFormatter.getVideoMetadata(entry); + + final formattedMediaTags = VideoMetadataFormatter.formatInfo(mediaInfo); + if (formattedMediaTags.isNotEmpty) { + // overwrite generic directory found from the platform side + directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, null, _toSortedTags(formattedMediaTags))); + } + + if (mediaInfo.containsKey('streams')) { String getTypeText(Map stream) { - final type = stream[StreamInfo.keyType] ?? StreamInfo.typeUnknown; + final type = stream[Keys.streamType] ?? StreamTypes.unknown; switch (type) { - case StreamInfo.typeAudio: + case StreamTypes.audio: return 'Audio'; - case StreamInfo.typeMetadata: + case StreamTypes.metadata: return 'Metadata'; - case StreamInfo.typeSubtitle: - case StreamInfo.typeTimedText: + case StreamTypes.subtitle: + case StreamTypes.timedText: return 'Text'; - case StreamInfo.typeVideo: - return stream.containsKey(StreamInfo.keyFpsDen) ? 'Video' : 'Image'; - case StreamInfo.typeUnknown: + case StreamTypes.video: + return stream.containsKey(Keys.fpsDen) ? 'Video' : 'Image'; + case StreamTypes.unknown: default: return 'Unknown'; } } - final allStreams = (info['streams'] as List).cast(); - final unknownStreams = allStreams.where((stream) => stream[StreamInfo.keyType] == StreamInfo.typeUnknown).toList(); + final allStreams = (mediaInfo['streams'] as List).cast(); + final unknownStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.unknown).toList(); final knownStreams = allStreams.whereNot(unknownStreams.contains); // display known streams as separate directories (e.g. video, audio, subs) @@ -209,24 +217,34 @@ class _MetadataSectionSliverState extends State with Auto final indexDigits = knownStreams.length.toString().length; for (final stream in knownStreams) { - final index = (stream[StreamInfo.keyIndex] ?? 0) + 1; + final index = (stream[Keys.index] ?? 0) + 1; final typeText = getTypeText(stream); final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')} • $typeText'; - final rawTags = StreamInfo.formatStreamInfo(stream); - final color = stringToColor(typeText); - directories.add(MetadataDirectory(dirName, null, _toSortedTags(rawTags), color: color)); + final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream); + if (formattedStreamTags.isNotEmpty) { + final color = stringToColor(typeText); + directories.add(MetadataDirectory(dirName, null, _toSortedTags(formattedStreamTags), color: color)); + } } } // display unknown streams as attachments (e.g. fonts) if (unknownStreams.isNotEmpty) { - final unknownCodecCount = {}; + final unknownCodecCount = >{}; for (final stream in unknownStreams) { - final codec = (stream[StreamInfo.keyCodecName] as String ?? 'unknown').toUpperCase(); - unknownCodecCount[codec] = (unknownCodecCount[codec] ?? 0) + 1; + final codec = (stream[Keys.codecName] as String ?? 'unknown').toUpperCase(); + if (!unknownCodecCount.containsKey(codec)) { + unknownCodecCount[codec] = []; + } + unknownCodecCount[codec].add(stream[Keys.filename]); } if (unknownCodecCount.isNotEmpty) { - final rawTags = unknownCodecCount.map((key, value) => MapEntry(key, value.toString())); + final rawTags = unknownCodecCount.map((key, value) { + final count = value.length; + // remove duplicate names, so number of displayed names may not match displayed count + final names = value.where((v) => v != null).toSet().toList()..sort(compareAsciiUpperCase); + return MapEntry(key, '$count items: ${names.join(', ')}'); + }); directories.add(MetadataDirectory('Attachments', null, _toSortedTags(rawTags))); } } diff --git a/lib/widgets/viewer/overlay/video.dart b/lib/widgets/viewer/overlay/video.dart index 59451765b..22fa53844 100644 --- a/lib/widgets/viewer/overlay/video.dart +++ b/lib/widgets/viewer/overlay/video.dart @@ -163,7 +163,7 @@ class _VideoControlOverlayState extends State with SingleTi builder: (context, snapshot) { // do not use stream snapshot because it is obsolete when switching between videos final position = controller.currentPosition?.floor() ?? 0; - return Text(formatDuration(Duration(milliseconds: position))); + return Text(formatFriendlyDuration(Duration(milliseconds: position))); }), Spacer(), Text(entry.durationText), diff --git a/pubspec.lock b/pubspec.lock index 07c9789b6..a949baef3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -211,7 +211,7 @@ packages: description: path: "." ref: aves - resolved-ref: c217373cfe61fb17941571d17e38236765a8ec07 + resolved-ref: "3c6f4e0d350416932b3a4efcbf1833b7eaf4adc1" url: "git://github.com/deckerst/fijkplayer.git" source: git version: "0.8.7"