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.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<String, Any>()
|
||||
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"
|
||||
|
|
|
@ -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) },
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
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 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 minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0');
|
||||
return '${d.inHours}:$minutes:$seconds';
|
||||
}
|
||||
|
||||
final twoDigitSeconds = twoDigits(d.inSeconds.remainder(Duration.secondsPerMinute));
|
||||
if (d.inHours == 0) return '${d.inMinutes}:$twoDigitSeconds';
|
||||
|
||||
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 {
|
||||
|
|
|
@ -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<MetadataSectionSliver> with Auto
|
|||
|
||||
Future<List<MetadataDirectory>> _getStreamDirectories() async {
|
||||
final directories = <MetadataDirectory>[];
|
||||
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<Map>();
|
||||
final unknownStreams = allStreams.where((stream) => stream[StreamInfo.keyType] == StreamInfo.typeUnknown).toList();
|
||||
final allStreams = (mediaInfo['streams'] as List).cast<Map>();
|
||||
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<MetadataSectionSliver> 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 formattedStreamTags = VideoMetadataFormatter.formatInfo(stream);
|
||||
if (formattedStreamTags.isNotEmpty) {
|
||||
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)
|
||||
if (unknownStreams.isNotEmpty) {
|
||||
final unknownCodecCount = <String, int>{};
|
||||
final unknownCodecCount = <String, List<String>>{};
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -163,7 +163,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> 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),
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue