info: improved video sections
This commit is contained in:
parent
fb9defb131
commit
04e7f76a66
17 changed files with 536 additions and 255 deletions
Binary file not shown.
|
@ -18,14 +18,11 @@ import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||||
import com.drew.imaging.ImageMetadataReader
|
import com.drew.imaging.ImageMetadataReader
|
||||||
import com.drew.lang.Rational
|
import com.drew.lang.Rational
|
||||||
import com.drew.metadata.Tag
|
import com.drew.metadata.Tag
|
||||||
|
import com.drew.metadata.avi.AviDirectory
|
||||||
import com.drew.metadata.exif.*
|
import com.drew.metadata.exif.*
|
||||||
import com.drew.metadata.file.FileTypeDirectory
|
import com.drew.metadata.file.FileTypeDirectory
|
||||||
import com.drew.metadata.gif.GifAnimationDirectory
|
import com.drew.metadata.gif.GifAnimationDirectory
|
||||||
import com.drew.metadata.iptc.IptcDirectory
|
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.mp4.media.Mp4UuidBoxDirectory
|
||||||
import com.drew.metadata.png.PngDirectory
|
import com.drew.metadata.png.PngDirectory
|
||||||
import com.drew.metadata.webp.WebpDirectory
|
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.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
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.isImage
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
|
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
|
@ -80,7 +76,6 @@ import kotlinx.coroutines.launch
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.ParseException
|
import java.text.ParseException
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
|
@ -120,9 +115,19 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
||||||
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::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
|
// directory name
|
||||||
var dirName = dir.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
|
// optional parent to distinguish child directories of the same type
|
||||||
dir.parent?.name?.let { dirName = "$it/$dirName" }
|
dir.parent?.name?.let { dirName = "$it/$dirName" }
|
||||||
|
|
||||||
|
@ -151,33 +156,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
} else {
|
} else {
|
||||||
dirMap.putAll(tags.map { tagMapper(it) })
|
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 {
|
} else {
|
||||||
dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
|
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)
|
val mediaDir = getAllMetadataByMediaMetadataRetriever(uri)
|
||||||
if (mediaDir.isNotEmpty()) {
|
if (mediaDir.isNotEmpty()) {
|
||||||
metadataMap[Metadata.DIR_MEDIA] = mediaDir
|
metadataMap[Metadata.DIR_MEDIA] = mediaDir
|
||||||
|
@ -298,7 +278,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
val metadataMap = HashMap<String, Any>()
|
val metadataMap = HashMap<String, Any>()
|
||||||
getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap)
|
getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap)
|
||||||
if (isMultimedia(mimeType)) {
|
if (isVideo(mimeType) || isHeic(mimeType)) {
|
||||||
getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap)
|
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) {
|
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) {
|
||||||
if (it > 1) flags = flags or MASK_IS_MULTIPAGE
|
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)) }
|
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) {
|
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
||||||
if (this.containsKey(key)) save(this.getInteger(key))
|
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)
|
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
|
||||||
const val CHANNEL = "deckers.thibault/aves/metadata"
|
const val CHANNEL = "deckers.thibault/aves/metadata"
|
||||||
|
|
||||||
private val MP4_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXX", Locale.ROOT).apply {
|
private val allMetadataRedundantDirNames = setOf(
|
||||||
timeZone = TimeZone.getTimeZone("UTC")
|
"MP4",
|
||||||
}
|
"MP4 Sound",
|
||||||
|
"MP4 Video",
|
||||||
|
"QuickTime",
|
||||||
|
"QuickTime Sound",
|
||||||
|
"QuickTime Video",
|
||||||
|
)
|
||||||
|
|
||||||
// catalog metadata & page info
|
// catalog metadata & page info
|
||||||
private const val KEY_MIME_TYPE = "mimeType"
|
private const val KEY_MIME_TYPE = "mimeType"
|
||||||
|
|
|
@ -40,7 +40,7 @@ class RegionFetcher internal constructor(
|
||||||
imageHeight: Int,
|
imageHeight: Int,
|
||||||
result: MethodChannel.Result,
|
result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
if (MimeTypes.isHeifLike(mimeType) && pageId != null) {
|
if (MimeTypes.isHeic(mimeType) && pageId != null) {
|
||||||
val id = Pair(uri, pageId)
|
val id = Pair(uri, pageId)
|
||||||
fetch(
|
fetch(
|
||||||
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) },
|
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) },
|
||||||
|
|
|
@ -18,7 +18,7 @@ import deckers.thibault.aves.decoder.VideoThumbnail
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
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.isVideo
|
||||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
|
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
|
||||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
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 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 height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
||||||
private val tiffFetch = mimeType == MimeTypes.TIFF
|
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
|
private val customFetch = tiffFetch || multiTrackFetch
|
||||||
|
|
||||||
fun fetch() {
|
fun fetch() {
|
||||||
|
|
|
@ -16,7 +16,7 @@ import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
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.isSupportedByFlutter
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
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) {
|
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)
|
MultiTrackImage(activity, uri, pageId)
|
||||||
} else if (mimeType == MimeTypes.TIFF) {
|
} else if (mimeType == MimeTypes.TIFF) {
|
||||||
TiffImage(activity, uri, pageId)
|
TiffImage(activity, uri, pageId)
|
||||||
|
|
|
@ -130,7 +130,7 @@ abstract class ImageProvider {
|
||||||
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
|
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
|
||||||
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
|
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)
|
MultiTrackImage(context, sourceUri, pageId)
|
||||||
} else if (sourceMimeType == MimeTypes.TIFF) {
|
} else if (sourceMimeType == MimeTypes.TIFF) {
|
||||||
TiffImage(context, sourceUri, pageId)
|
TiffImage(context, sourceUri, pageId)
|
||||||
|
|
|
@ -43,9 +43,7 @@ object MimeTypes {
|
||||||
|
|
||||||
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
|
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
|
||||||
|
|
||||||
fun isHeifLike(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF)
|
fun isHeic(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF)
|
||||||
|
|
||||||
fun isMultimedia(mimeType: String?) = isVideo(mimeType) || isHeifLike(mimeType)
|
|
||||||
|
|
||||||
fun isRaw(mimeType: String): Boolean {
|
fun isRaw(mimeType: String): Boolean {
|
||||||
return when (mimeType) {
|
return when (mimeType) {
|
||||||
|
|
|
@ -366,7 +366,7 @@ class AvesEntry {
|
||||||
String _durationText;
|
String _durationText;
|
||||||
|
|
||||||
String get durationText {
|
String get durationText {
|
||||||
_durationText ??= formatDuration(Duration(milliseconds: durationMillis ?? 0));
|
_durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0));
|
||||||
return _durationText;
|
return _durationText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ class H264 {
|
||||||
static const profileHigh444 = 144;
|
static const profileHigh444 = 144;
|
||||||
static const profileHigh444Predictive = 244;
|
static const profileHigh444Predictive = 244;
|
||||||
static const profileHigh444Intra = 244 | profileIntra;
|
static const profileHigh444Intra = 244 | profileIntra;
|
||||||
static const profileCAVLC_444 = 44;
|
static const profileCAVLC444 = 44;
|
||||||
|
|
||||||
static String formatProfile(int profileIndex, int level) {
|
static String formatProfile(int profileIndex, int level) {
|
||||||
String profile;
|
String profile;
|
||||||
|
@ -54,7 +54,7 @@ class H264 {
|
||||||
case profileHigh444Intra:
|
case profileHigh444Intra:
|
||||||
profile = 'High 4:4:4 Intra';
|
profile = 'High 4:4:4 Intra';
|
||||||
break;
|
break;
|
||||||
case profileCAVLC_444:
|
case profileCAVLC444:
|
||||||
profile = 'CAVLC 4:4:4';
|
profile = 'CAVLC 4:4:4';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|
48
lib/model/video/keys.dart
Normal file
48
lib/model/video/keys.dart
Normal file
|
@ -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';
|
||||||
|
}
|
310
lib/model/video/metadata.dart
Normal file
310
lib/model/video/metadata.dart
Normal file
|
@ -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<String, String> _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<Map> 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<String, String> formatInfo(Map info) {
|
||||||
|
final dir = <String, String>{};
|
||||||
|
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<String>().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';
|
||||||
|
}
|
|
@ -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<Map> 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<String, String> formatStreamInfo(Map stream) {
|
|
||||||
final dir = <String, String>{};
|
|
||||||
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('_', ' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
88
lib/ref/mp4.dart
Normal file
88
lib/ref/mp4.dart
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
class Mp4 {
|
||||||
|
// adapted from `metadata-extractor`
|
||||||
|
static final brands = <String, String>{
|
||||||
|
'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',
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,14 +1,17 @@
|
||||||
String formatDuration(Duration d) {
|
String formatFriendlyDuration(Duration d) {
|
||||||
String twoDigits(int n) {
|
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0');
|
||||||
if (n >= 10) return '$n';
|
if (d.inHours == 0) return '${d.inMinutes}:$seconds';
|
||||||
return '0$n';
|
|
||||||
}
|
|
||||||
|
|
||||||
final twoDigitSeconds = twoDigits(d.inSeconds.remainder(Duration.secondsPerMinute));
|
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0');
|
||||||
if (d.inHours == 0) return '${d.inMinutes}:$twoDigitSeconds';
|
return '${d.inHours}:$minutes:$seconds';
|
||||||
|
}
|
||||||
|
|
||||||
final twoDigitMinutes = twoDigits(d.inMinutes.remainder(Duration.minutesPerHour));
|
String formatPreciseDuration(Duration d) {
|
||||||
return '${d.inHours}:$twoDigitMinutes:$twoDigitSeconds';
|
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 {
|
extension ExtraDateTime on DateTime {
|
||||||
|
|
|
@ -2,7 +2,8 @@ import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
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/ref/mime_types.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/services/svg_metadata_service.dart';
|
import 'package:aves/services/svg_metadata_service.dart';
|
||||||
|
@ -180,28 +181,35 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
|
|
||||||
Future<List<MetadataDirectory>> _getStreamDirectories() async {
|
Future<List<MetadataDirectory>> _getStreamDirectories() async {
|
||||||
final directories = <MetadataDirectory>[];
|
final directories = <MetadataDirectory>[];
|
||||||
final info = await StreamInfo.getVideoInfo(entry);
|
final mediaInfo = await VideoMetadataFormatter.getVideoMetadata(entry);
|
||||||
if (info.containsKey('streams')) {
|
|
||||||
|
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) {
|
String getTypeText(Map stream) {
|
||||||
final type = stream[StreamInfo.keyType] ?? StreamInfo.typeUnknown;
|
final type = stream[Keys.streamType] ?? StreamTypes.unknown;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case StreamInfo.typeAudio:
|
case StreamTypes.audio:
|
||||||
return 'Audio';
|
return 'Audio';
|
||||||
case StreamInfo.typeMetadata:
|
case StreamTypes.metadata:
|
||||||
return 'Metadata';
|
return 'Metadata';
|
||||||
case StreamInfo.typeSubtitle:
|
case StreamTypes.subtitle:
|
||||||
case StreamInfo.typeTimedText:
|
case StreamTypes.timedText:
|
||||||
return 'Text';
|
return 'Text';
|
||||||
case StreamInfo.typeVideo:
|
case StreamTypes.video:
|
||||||
return stream.containsKey(StreamInfo.keyFpsDen) ? 'Video' : 'Image';
|
return stream.containsKey(Keys.fpsDen) ? 'Video' : 'Image';
|
||||||
case StreamInfo.typeUnknown:
|
case StreamTypes.unknown:
|
||||||
default:
|
default:
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final allStreams = (info['streams'] as List).cast<Map>();
|
final allStreams = (mediaInfo['streams'] as List).cast<Map>();
|
||||||
final unknownStreams = allStreams.where((stream) => stream[StreamInfo.keyType] == StreamInfo.typeUnknown).toList();
|
final unknownStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.unknown).toList();
|
||||||
final knownStreams = allStreams.whereNot(unknownStreams.contains);
|
final knownStreams = allStreams.whereNot(unknownStreams.contains);
|
||||||
|
|
||||||
// display known streams as separate directories (e.g. video, audio, subs)
|
// display known streams as separate directories (e.g. video, audio, subs)
|
||||||
|
@ -209,24 +217,34 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
final indexDigits = knownStreams.length.toString().length;
|
final indexDigits = knownStreams.length.toString().length;
|
||||||
|
|
||||||
for (final stream in knownStreams) {
|
for (final stream in knownStreams) {
|
||||||
final index = (stream[StreamInfo.keyIndex] ?? 0) + 1;
|
final index = (stream[Keys.index] ?? 0) + 1;
|
||||||
final typeText = getTypeText(stream);
|
final typeText = getTypeText(stream);
|
||||||
final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')} • $typeText';
|
final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')} • $typeText';
|
||||||
final rawTags = StreamInfo.formatStreamInfo(stream);
|
final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream);
|
||||||
|
if (formattedStreamTags.isNotEmpty) {
|
||||||
final color = stringToColor(typeText);
|
final color = stringToColor(typeText);
|
||||||
directories.add(MetadataDirectory(dirName, null, _toSortedTags(rawTags), color: color));
|
directories.add(MetadataDirectory(dirName, null, _toSortedTags(formattedStreamTags), color: color));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// display unknown streams as attachments (e.g. fonts)
|
// display unknown streams as attachments (e.g. fonts)
|
||||||
if (unknownStreams.isNotEmpty) {
|
if (unknownStreams.isNotEmpty) {
|
||||||
final unknownCodecCount = <String, int>{};
|
final unknownCodecCount = <String, List<String>>{};
|
||||||
for (final stream in unknownStreams) {
|
for (final stream in unknownStreams) {
|
||||||
final codec = (stream[StreamInfo.keyCodecName] as String ?? 'unknown').toUpperCase();
|
final codec = (stream[Keys.codecName] as String ?? 'unknown').toUpperCase();
|
||||||
unknownCodecCount[codec] = (unknownCodecCount[codec] ?? 0) + 1;
|
if (!unknownCodecCount.containsKey(codec)) {
|
||||||
|
unknownCodecCount[codec] = [];
|
||||||
|
}
|
||||||
|
unknownCodecCount[codec].add(stream[Keys.filename]);
|
||||||
}
|
}
|
||||||
if (unknownCodecCount.isNotEmpty) {
|
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)));
|
directories.add(MetadataDirectory('Attachments', null, _toSortedTags(rawTags)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -163,7 +163,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
// do not use stream snapshot because it is obsolete when switching between videos
|
// do not use stream snapshot because it is obsolete when switching between videos
|
||||||
final position = controller.currentPosition?.floor() ?? 0;
|
final position = controller.currentPosition?.floor() ?? 0;
|
||||||
return Text(formatDuration(Duration(milliseconds: position)));
|
return Text(formatFriendlyDuration(Duration(milliseconds: position)));
|
||||||
}),
|
}),
|
||||||
Spacer(),
|
Spacer(),
|
||||||
Text(entry.durationText),
|
Text(entry.durationText),
|
||||||
|
|
|
@ -211,7 +211,7 @@ packages:
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: aves
|
ref: aves
|
||||||
resolved-ref: c217373cfe61fb17941571d17e38236765a8ec07
|
resolved-ref: "3c6f4e0d350416932b3a4efcbf1833b7eaf4adc1"
|
||||||
url: "git://github.com/deckerst/fijkplayer.git"
|
url: "git://github.com/deckerst/fijkplayer.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.8.7"
|
version: "0.8.7"
|
||||||
|
|
Loading…
Reference in a new issue