info: streams

This commit is contained in:
Thibault Deckers 2021-04-11 12:42:11 +09:00
parent 4526df5a77
commit 51a593e0fc
7 changed files with 159 additions and 87 deletions

View file

@ -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',
};
}

View file

@ -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<Map> 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:

View file

@ -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;

View file

@ -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(

View file

@ -108,7 +108,7 @@ class InfoSearchDelegate extends SearchDelegate {
title: kv.key,
dir: kv.value,
initiallyExpanded: true,
showPrefixChildren: false,
showThumbnails: false,
))
.toList();
return SafeArea(

View file

@ -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<String> 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 = <Widget>[];
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),

View file

@ -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<MetadataSectionSliver> 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<Map>();
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<MetadataSectionSliver> 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<MetadataSectionSliver> with Auto
_expandedDirectoryNotifier.value = null;
}
Future<List<MetadataDirectory>> _getStreamDirectories() async {
final directories = <MetadataDirectory>[];
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<Map>();
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 = <String, int>{};
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<String, String> _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<String, String> allTags;
final SplayTreeMap<String, String> 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<String, String> allTags, {SplayTreeMap<String, String> tags})
const MetadataDirectory(this.name, this.parent, SplayTreeMap<String, String> allTags, {SplayTreeMap<String, String> 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);
}
}