From 51a593e0fcfafe02dc4701fb1791935294aac933 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 11 Apr 2021 12:42:11 +0900 Subject: [PATCH] info: streams --- lib/model/video/channel_layouts.dart | 52 +++++----- lib/model/video/streams.dart | 71 +++++++++----- lib/theme/icons.dart | 1 - lib/widgets/common/video/fijkplayer.dart | 6 ++ lib/widgets/viewer/info/info_search.dart | 2 +- .../info/metadata/metadata_dir_tile.dart | 18 +--- .../info/metadata/metadata_section.dart | 96 +++++++++++++++---- 7 files changed, 159 insertions(+), 87 deletions(-) diff --git a/lib/model/video/channel_layouts.dart b/lib/model/video/channel_layouts.dart index f98a2364c..c2ed8538a 100644 --- a/lib/model/video/channel_layouts.dart +++ b/lib/model/video/channel_layouts.dart @@ -63,32 +63,32 @@ class ChannelLayouts { static const names = { LAYOUT_NATIVE: 'native', LAYOUT_MONO: 'mono', - LAYOUT_STEREO: 'stereo', - LAYOUT_2POINT1: '2.1', - LAYOUT_2_1: '2_1', - LAYOUT_SURROUND: 'surround', - LAYOUT_3POINT1: '3.1', - LAYOUT_4POINT0: '4.0', - LAYOUT_4POINT1: '4.1', - LAYOUT_2_2: '2_2', - LAYOUT_QUAD: 'quad', - LAYOUT_5POINT0: '5.0', - LAYOUT_5POINT1: '5.1', - LAYOUT_5POINT0_BACK: '5.0 back', - LAYOUT_5POINT1_BACK: '5.1 back', - LAYOUT_6POINT0: '6.0', - LAYOUT_6POINT0_FRONT: '6.0 front', - LAYOUT_HEXAGONAL: 'hexagonal', - LAYOUT_6POINT1: '6.1', - LAYOUT_6POINT1_BACK: '6.1 back', - LAYOUT_6POINT1_FRONT: '6.1 front', - LAYOUT_7POINT0: '7.0', - LAYOUT_7POINT0_FRONT: '7.0 front', - LAYOUT_7POINT1: '7.1', - LAYOUT_7POINT1_WIDE: '7.1 wide', - LAYOUT_7POINT1_WIDE_BACK: '7.1 wide back', - LAYOUT_OCTAGONAL: 'octagonal', - LAYOUT_HEXADECAGONAL: 'hexadecagonal', + LAYOUT_STEREO: 'stereo 2.0 • FL FR', + LAYOUT_2POINT1: 'stereo 2.1 • FL FR LFE', + LAYOUT_2_1: 'surround 3.0 • FL FR BC', + LAYOUT_SURROUND: 'stereo 3.0 • FL FR FC', + LAYOUT_3POINT1: 'stereo 3.1 • FL FR FC LFE', + LAYOUT_4POINT0: 'surround 4.0 • FL FR FC BC', + LAYOUT_4POINT1: 'surround 4.1 • FL FR FC BC LFE', + LAYOUT_2_2: 'quad (side) • FL FR SL SR', + LAYOUT_QUAD: 'quad (back) • FL FR BL BR', + LAYOUT_5POINT0: '5.0 (side) • FL FR FC SL SR', + LAYOUT_5POINT1: '5.1 (side) • FL FR FC SL SR LFE', + LAYOUT_5POINT0_BACK: '5.0 (back) • FL FR FC BL BR', + LAYOUT_5POINT1_BACK: '5.1 (back) • FL FR FC BL BR LFE', + LAYOUT_6POINT0: '6.0 (side) • FL FR FC SL SR BC', + LAYOUT_6POINT0_FRONT: '6.0 (front) • FL FR FLC FRC SL SR', + LAYOUT_HEXAGONAL: 'hexagonal • FL FR FC BL BR BC', + LAYOUT_6POINT1: '6.1 (side) • FL FR FC SL SR BC LFE', + LAYOUT_6POINT1_BACK: '6.1 (back) • FL FR FC BL BR BC LFE', + LAYOUT_6POINT1_FRONT: '6.1 (front) • FL FR FLC FRC SL SR LFE', + LAYOUT_7POINT0: 'surround 7.0 • FL FR FC SL SR BL BR', + LAYOUT_7POINT0_FRONT: 'wide 7.0 • FL FR FC FLC FRC SL SR', + LAYOUT_7POINT1: 'surround 7.1 • FL FR FC SL SR BL BR LFE', + LAYOUT_7POINT1_WIDE: 'wide 7.1 • FL FR FC FLC FRC SL SR LFE', + LAYOUT_7POINT1_WIDE_BACK: 'wide 7.1 (back) • FL FR FC FLC FRC BL BR LFE', + LAYOUT_OCTAGONAL: 'octagonal • FL FR FC SL SR BL BR BC', + LAYOUT_HEXADECAGONAL: 'hexadecagonal • FL FR FC WL WR TFL TFR TFC SL SR BL BR BC TBL TBR TBC', LAYOUT_STEREO_DOWNMIX: 'stereo downmix', }; } diff --git a/lib/model/video/streams.dart b/lib/model/video/streams.dart index 753c564e3..caa716720 100644 --- a/lib/model/video/streams.dart +++ b/lib/model/video/streams.dart @@ -6,6 +6,29 @@ 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 keyCodecName = 'codec_name'; + 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 keyTbrDen = 'tbr_den'; + static const keyTbrNum = 'tbr_num'; + static const keyType = 'type'; + static const keyWidth = 'width'; + + static const typeAudio = 'audio'; + static const typeMetadata = 'metadata'; + static const typeSubtitle = 'subtitle'; + static const typeTimedText = 'timedtext'; + static const typeUnknown = 'unknown'; + static const typeVideo = 'video'; + static Future getVideoInfo(AvesEntry entry) async { final player = FijkPlayer(); await player.setDataSource(entry.uri, autoPlay: false); @@ -53,51 +76,51 @@ class StreamInfo { if (value != null) { final key = kv.key; switch (key) { - case 'index': - case 'fps_num': - case 'sar_num': - case 'tbr_num': - case 'tbr_den': + case keyIndex: + case keyFpsNum: + case keySarNum: + case keyTbrNum: + case keyTbrDen: break; - case 'bitrate': + case keyBitrate: dir['Bitrate'] = formatBitrate(value, round: 1); break; - case 'channel_layout': - dir['Channel Layout'] = ChannelLayouts.names[value] ?? value.toString(); + case keyChannelLayout: + dir['Channel Layout'] = ChannelLayouts.names[value] ?? 'unknown ($value)'; break; - case 'codec_name': + case keyCodecName: dir['Codec'] = value.toString().toUpperCase().replaceAll('_', ' '); break; - case 'fps_den': - dir['Frame Rate'] = roundToPrecision(stream['fps_num'] / stream['fps_den'], decimals: 3).toString(); + case keyFpsDen: + dir['Frame Rate'] = roundToPrecision(stream[keyFpsNum] / stream[keyFpsDen], decimals: 3).toString(); break; - case 'height': + case keyHeight: dir['Height'] = '$value pixels'; break; - case 'language': + case keyLanguage: dir['Language'] = value; break; - case 'sample_rate': + case keySampleRate: dir['Sample Rate'] = '$value Hz'; break; - case 'sar_den': - dir['SAR'] = '${stream['sar_num']}:${stream['sar_den']}'; + case keySarDen: + dir['SAR'] = '${stream[keySarNum]}:${stream[keySarDen]}'; break; - case 'type': + case keyType: switch (value) { - case 'timedtext': + case typeTimedText: dir['Type'] = 'timed text'; break; - case 'audio': - case 'video': - case 'metadata': - case 'subtitle': - case 'unknown': + case typeAudio: + case typeMetadata: + case typeSubtitle: + case typeUnknown: + case typeVideo: default: dir['Type'] = value; } break; - case 'width': + case keyWidth: dir['Width'] = '$value pixels'; break; default: diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 4573ac7e7..b4a14c7cf 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -5,7 +5,6 @@ class AIcons { static const IconData allCollection = Icons.collections_outlined; static const IconData image = Icons.photo_outlined; static const IconData video = Icons.movie_outlined; - static const IconData audio = Icons.audiotrack_outlined; static const IconData vector = Icons.code_outlined; static const IconData android = Icons.android; diff --git a/lib/widgets/common/video/fijkplayer.dart b/lib/widgets/common/video/fijkplayer.dart index 857bc69f9..80c7870b8 100644 --- a/lib/widgets/common/video/fijkplayer.dart +++ b/lib/widgets/common/video/fijkplayer.dart @@ -61,6 +61,10 @@ class IjkPlayerAvesVideoController extends AvesVideoController { // `mediacodec-all-videos`: MediaCodec: enable all videos, default: 0, in [0, 1] option.setPlayerOption('mediacodec-all-videos', hwAccelerationEnabled ? 1 : 0); + // TODO TLAD try subs + // `subtitle`: decode subtitle stream, default: 0, in [0, 1] + // option.setPlayerOption('subtitle', 1); + _instance.applyOptions(option); _instance.addListener(_onValueChanged); @@ -122,6 +126,8 @@ class IjkPlayerAvesVideoController extends AvesVideoController { @override Widget buildPlayerWidget(BuildContext context, AvesEntry entry) { + // TODO TLAD derive DAR (Display Aspect Ratio) from SAR (Storage Aspect Ratio), if any + // e.g. 960x536 (~16:9) with SAR 4:3 should be displayed as ~2.39:1 return FijkView( player: _instance, fit: FijkFit( diff --git a/lib/widgets/viewer/info/info_search.dart b/lib/widgets/viewer/info/info_search.dart index a594ad2be..2fb5b0bba 100644 --- a/lib/widgets/viewer/info/info_search.dart +++ b/lib/widgets/viewer/info/info_search.dart @@ -108,7 +108,7 @@ class InfoSearchDelegate extends SearchDelegate { title: kv.key, dir: kv.value, initiallyExpanded: true, - showPrefixChildren: false, + showThumbnails: false, )) .toList(); return SafeArea( diff --git a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart index ad0a7776e..d3285174c 100644 --- a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart +++ b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart @@ -3,7 +3,6 @@ import 'dart:collection'; import 'package:aves/model/entry.dart'; import 'package:aves/ref/brand_colors.dart'; import 'package:aves/services/svg_metadata_service.dart'; -import 'package:aves/theme/icons.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -21,7 +20,7 @@ class MetadataDirTile extends StatelessWidget { final String title; final MetadataDirectory dir; final ValueNotifier expandedDirectoryNotifier; - final bool initiallyExpanded, showPrefixChildren; + final bool initiallyExpanded, showThumbnails; const MetadataDirTile({ @required this.entry, @@ -29,7 +28,7 @@ class MetadataDirTile extends StatelessWidget { @required this.dir, this.expandedDirectoryNotifier, this.initiallyExpanded = false, - this.showPrefixChildren = true, + this.showThumbnails = true, }); @override @@ -48,32 +47,23 @@ class MetadataDirTile extends StatelessWidget { } Widget thumbnail; - final prefixChildren = []; - if (showPrefixChildren) { + if (showThumbnails) { switch (dirName) { case MetadataDirectory.exifThumbnailDirectory: thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry); break; case MetadataDirectory.mediaDirectory: thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry); - Widget builder(IconData data) => Padding( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: Icon(data), - ); - if (tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video)); - if (tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio)); - if (tags['Has Image'] == 'yes') prefixChildren.add(builder(AIcons.image)); break; } } return AvesExpansionTile( title: title, - color: BrandColors.get(dirName) ?? stringToColor(dirName), + color: dir.color ?? BrandColors.get(dirName) ?? stringToColor(dirName), expandedNotifier: expandedDirectoryNotifier, initiallyExpanded: initiallyExpanded, children: [ - if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren), if (thumbnail != null) thumbnail, Padding( padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), diff --git a/lib/widgets/viewer/info/metadata/metadata_section.dart b/lib/widgets/viewer/info/metadata/metadata_section.dart index e774b0954..80d162a63 100644 --- a/lib/widgets/viewer/info/metadata/metadata_section.dart +++ b/lib/widgets/viewer/info/metadata/metadata_section.dart @@ -8,6 +8,7 @@ import 'package:aves/services/services.dart'; import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/color_utils.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart'; import 'package:collection/collection.dart'; @@ -142,18 +143,6 @@ class _MetadataSectionSliverState extends State with Auto if (_loadedMetadataUri.value == entry.uri) return; if (isVisible) { final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataService.getAllMetadata(entry)) ?? {}; - if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultipage)) { - final info = await StreamInfo.getVideoInfo(entry); - if (info.containsKey('streams')) { - final streams = (info['streams'] as List).cast(); - for (final stream in streams) { - if (stream.containsKey('index')) { - final index = stream['index'] + 1; - rawMetadata['Stream $index'] = StreamInfo.formatStreamInfo(stream); - } - } - } - } final directories = rawMetadata.entries.map((dirKV) { var directoryName = dirKV.key as String ?? ''; @@ -165,15 +154,13 @@ class _MetadataSectionSliverState extends State with Auto } final rawTags = dirKV.value as Map ?? {}; - final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) { - final value = (tagKV.value as String ?? '').trim(); - if (value.isEmpty) return null; - final tagName = tagKV.key as String ?? ''; - return MapEntry(tagName, value); - }).where((kv) => kv != null))); - return MetadataDirectory(directoryName, parent, tags); + return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags)); }).toList(); + if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultipage)) { + directories.addAll(await _getStreamDirectories()); + } + final titledDirectories = directories.map((dir) { var title = dir.name; if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) { @@ -191,12 +178,79 @@ class _MetadataSectionSliverState extends State with Auto _expandedDirectoryNotifier.value = null; } + Future> _getStreamDirectories() async { + final directories = []; + final info = await StreamInfo.getVideoInfo(entry); + if (info.containsKey('streams')) { + String getTypeText(Map stream) { + final type = stream[StreamInfo.keyType] ?? StreamInfo.typeUnknown; + switch (type) { + case StreamInfo.typeAudio: + return 'Audio'; + case StreamInfo.typeMetadata: + return 'Metadata'; + case StreamInfo.typeSubtitle: + case StreamInfo.typeTimedText: + return 'Text'; + case StreamInfo.typeVideo: + return stream.containsKey(StreamInfo.keyFpsDen) ? 'Video' : 'Image'; + case StreamInfo.typeUnknown: + default: + return 'Unknown'; + } + } + + final allStreams = (info['streams'] as List).cast(); + final unknownStreams = allStreams.where((stream) => stream[StreamInfo.keyType] == StreamInfo.typeUnknown).toList(); + final knownStreams = allStreams.whereNot(unknownStreams.contains); + + // display known streams as separate directories (e.g. video, audio, subs) + if (knownStreams.isNotEmpty) { + final indexDigits = knownStreams.length.toString().length; + + for (final stream in knownStreams) { + final index = (stream[StreamInfo.keyIndex] ?? 0) + 1; + final typeText = getTypeText(stream); + final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')} • $typeText'; + final rawTags = StreamInfo.formatStreamInfo(stream); + final color = stringToColor(typeText); + directories.add(MetadataDirectory(dirName, null, _toSortedTags(rawTags), color: color)); + } + } + + // display unknown streams as attachments (e.g. fonts) + if (unknownStreams.isNotEmpty) { + final unknownCodecCount = {}; + for (final stream in unknownStreams) { + final codec = (stream[StreamInfo.keyCodecName] as String ?? 'unknown').toUpperCase(); + unknownCodecCount[codec] = (unknownCodecCount[codec] ?? 0) + 1; + } + if (unknownCodecCount.isNotEmpty) { + final rawTags = unknownCodecCount.map((key, value) => MapEntry(key, value.toString())); + directories.add(MetadataDirectory('Attachments', null, _toSortedTags(rawTags))); + } + } + } + return directories; + } + + SplayTreeMap _toSortedTags(Map rawTags) { + final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) { + final value = (tagKV.value as String ?? '').trim(); + if (value.isEmpty) return null; + final tagName = tagKV.key as String ?? ''; + return MapEntry(tagName, value); + }).where((kv) => kv != null))); + return tags; + } + @override bool get wantKeepAlive => true; } class MetadataDirectory { final String name; + final Color color; final String parent; final SplayTreeMap allTags; final SplayTreeMap tags; @@ -206,12 +260,12 @@ class MetadataDirectory { static const xmpDirectory = 'XMP'; // from metadata-extractor static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory - const MetadataDirectory(this.name, this.parent, SplayTreeMap allTags, {SplayTreeMap tags}) + const MetadataDirectory(this.name, this.parent, SplayTreeMap allTags, {SplayTreeMap tags, this.color}) : allTags = allTags, tags = tags ?? allTags; MetadataDirectory filterKeys(bool Function(String key) testKey) { final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key)))); - return MetadataDirectory(name, parent, tags, tags: filteredTags); + return MetadataDirectory(name, parent, tags, tags: filteredTags, color: color); } }