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 = { static const names = {
LAYOUT_NATIVE: 'native', LAYOUT_NATIVE: 'native',
LAYOUT_MONO: 'mono', LAYOUT_MONO: 'mono',
LAYOUT_STEREO: 'stereo', LAYOUT_STEREO: 'stereo 2.0 • FL FR',
LAYOUT_2POINT1: '2.1', LAYOUT_2POINT1: 'stereo 2.1 • FL FR LFE',
LAYOUT_2_1: '2_1', LAYOUT_2_1: 'surround 3.0 • FL FR BC',
LAYOUT_SURROUND: 'surround', LAYOUT_SURROUND: 'stereo 3.0 • FL FR FC',
LAYOUT_3POINT1: '3.1', LAYOUT_3POINT1: 'stereo 3.1 • FL FR FC LFE',
LAYOUT_4POINT0: '4.0', LAYOUT_4POINT0: 'surround 4.0 • FL FR FC BC',
LAYOUT_4POINT1: '4.1', LAYOUT_4POINT1: 'surround 4.1 • FL FR FC BC LFE',
LAYOUT_2_2: '2_2', LAYOUT_2_2: 'quad (side) • FL FR SL SR',
LAYOUT_QUAD: 'quad', LAYOUT_QUAD: 'quad (back) • FL FR BL BR',
LAYOUT_5POINT0: '5.0', LAYOUT_5POINT0: '5.0 (side) • FL FR FC SL SR',
LAYOUT_5POINT1: '5.1', LAYOUT_5POINT1: '5.1 (side) • FL FR FC SL SR LFE',
LAYOUT_5POINT0_BACK: '5.0 back', LAYOUT_5POINT0_BACK: '5.0 (back) • FL FR FC BL BR',
LAYOUT_5POINT1_BACK: '5.1 back', LAYOUT_5POINT1_BACK: '5.1 (back) • FL FR FC BL BR LFE',
LAYOUT_6POINT0: '6.0', LAYOUT_6POINT0: '6.0 (side) • FL FR FC SL SR BC',
LAYOUT_6POINT0_FRONT: '6.0 front', LAYOUT_6POINT0_FRONT: '6.0 (front) • FL FR FLC FRC SL SR',
LAYOUT_HEXAGONAL: 'hexagonal', LAYOUT_HEXAGONAL: 'hexagonal • FL FR FC BL BR BC',
LAYOUT_6POINT1: '6.1', LAYOUT_6POINT1: '6.1 (side) • FL FR FC SL SR BC LFE',
LAYOUT_6POINT1_BACK: '6.1 back', LAYOUT_6POINT1_BACK: '6.1 (back) • FL FR FC BL BR BC LFE',
LAYOUT_6POINT1_FRONT: '6.1 front', LAYOUT_6POINT1_FRONT: '6.1 (front) • FL FR FLC FRC SL SR LFE',
LAYOUT_7POINT0: '7.0', LAYOUT_7POINT0: 'surround 7.0 • FL FR FC SL SR BL BR',
LAYOUT_7POINT0_FRONT: '7.0 front', LAYOUT_7POINT0_FRONT: 'wide 7.0 • FL FR FC FLC FRC SL SR',
LAYOUT_7POINT1: '7.1', LAYOUT_7POINT1: 'surround 7.1 • FL FR FC SL SR BL BR LFE',
LAYOUT_7POINT1_WIDE: '7.1 wide', LAYOUT_7POINT1_WIDE: 'wide 7.1 • FL FR FC FLC FRC SL SR LFE',
LAYOUT_7POINT1_WIDE_BACK: '7.1 wide back', LAYOUT_7POINT1_WIDE_BACK: 'wide 7.1 (back) • FL FR FC FLC FRC BL BR LFE',
LAYOUT_OCTAGONAL: 'octagonal', LAYOUT_OCTAGONAL: 'octagonal • FL FR FC SL SR BL BR BC',
LAYOUT_HEXADECAGONAL: 'hexadecagonal', LAYOUT_HEXADECAGONAL: 'hexadecagonal • FL FR FC WL WR TFL TFR TFC SL SR BL BR BC TBL TBR TBC',
LAYOUT_STEREO_DOWNMIX: 'stereo downmix', LAYOUT_STEREO_DOWNMIX: 'stereo downmix',
}; };
} }

View file

@ -6,6 +6,29 @@ import 'package:aves/utils/math_utils.dart';
import 'package:fijkplayer/fijkplayer.dart'; import 'package:fijkplayer/fijkplayer.dart';
class StreamInfo { 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 { static Future<Map> getVideoInfo(AvesEntry entry) async {
final player = FijkPlayer(); final player = FijkPlayer();
await player.setDataSource(entry.uri, autoPlay: false); await player.setDataSource(entry.uri, autoPlay: false);
@ -53,51 +76,51 @@ class StreamInfo {
if (value != null) { if (value != null) {
final key = kv.key; final key = kv.key;
switch (key) { switch (key) {
case 'index': case keyIndex:
case 'fps_num': case keyFpsNum:
case 'sar_num': case keySarNum:
case 'tbr_num': case keyTbrNum:
case 'tbr_den': case keyTbrDen:
break; break;
case 'bitrate': case keyBitrate:
dir['Bitrate'] = formatBitrate(value, round: 1); dir['Bitrate'] = formatBitrate(value, round: 1);
break; break;
case 'channel_layout': case keyChannelLayout:
dir['Channel Layout'] = ChannelLayouts.names[value] ?? value.toString(); dir['Channel Layout'] = ChannelLayouts.names[value] ?? 'unknown ($value)';
break; break;
case 'codec_name': case keyCodecName:
dir['Codec'] = value.toString().toUpperCase().replaceAll('_', ' '); dir['Codec'] = value.toString().toUpperCase().replaceAll('_', ' ');
break; break;
case 'fps_den': case keyFpsDen:
dir['Frame Rate'] = roundToPrecision(stream['fps_num'] / stream['fps_den'], decimals: 3).toString(); dir['Frame Rate'] = roundToPrecision(stream[keyFpsNum] / stream[keyFpsDen], decimals: 3).toString();
break; break;
case 'height': case keyHeight:
dir['Height'] = '$value pixels'; dir['Height'] = '$value pixels';
break; break;
case 'language': case keyLanguage:
dir['Language'] = value; dir['Language'] = value;
break; break;
case 'sample_rate': case keySampleRate:
dir['Sample Rate'] = '$value Hz'; dir['Sample Rate'] = '$value Hz';
break; break;
case 'sar_den': case keySarDen:
dir['SAR'] = '${stream['sar_num']}:${stream['sar_den']}'; dir['SAR'] = '${stream[keySarNum]}:${stream[keySarDen]}';
break; break;
case 'type': case keyType:
switch (value) { switch (value) {
case 'timedtext': case typeTimedText:
dir['Type'] = 'timed text'; dir['Type'] = 'timed text';
break; break;
case 'audio': case typeAudio:
case 'video': case typeMetadata:
case 'metadata': case typeSubtitle:
case 'subtitle': case typeUnknown:
case 'unknown': case typeVideo:
default: default:
dir['Type'] = value; dir['Type'] = value;
} }
break; break;
case 'width': case keyWidth:
dir['Width'] = '$value pixels'; dir['Width'] = '$value pixels';
break; break;
default: default:

View file

@ -5,7 +5,6 @@ class AIcons {
static const IconData allCollection = Icons.collections_outlined; static const IconData allCollection = Icons.collections_outlined;
static const IconData image = Icons.photo_outlined; static const IconData image = Icons.photo_outlined;
static const IconData video = Icons.movie_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 vector = Icons.code_outlined;
static const IconData android = Icons.android; 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] // `mediacodec-all-videos`: MediaCodec: enable all videos, default: 0, in [0, 1]
option.setPlayerOption('mediacodec-all-videos', hwAccelerationEnabled ? 1 : 0); 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.applyOptions(option);
_instance.addListener(_onValueChanged); _instance.addListener(_onValueChanged);
@ -122,6 +126,8 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
@override @override
Widget buildPlayerWidget(BuildContext context, AvesEntry entry) { 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( return FijkView(
player: _instance, player: _instance,
fit: FijkFit( fit: FijkFit(

View file

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

View file

@ -3,7 +3,6 @@ import 'dart:collection';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/ref/brand_colors.dart'; import 'package:aves/ref/brand_colors.dart';
import 'package:aves/services/svg_metadata_service.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/color_utils.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
@ -21,7 +20,7 @@ class MetadataDirTile extends StatelessWidget {
final String title; final String title;
final MetadataDirectory dir; final MetadataDirectory dir;
final ValueNotifier<String> expandedDirectoryNotifier; final ValueNotifier<String> expandedDirectoryNotifier;
final bool initiallyExpanded, showPrefixChildren; final bool initiallyExpanded, showThumbnails;
const MetadataDirTile({ const MetadataDirTile({
@required this.entry, @required this.entry,
@ -29,7 +28,7 @@ class MetadataDirTile extends StatelessWidget {
@required this.dir, @required this.dir,
this.expandedDirectoryNotifier, this.expandedDirectoryNotifier,
this.initiallyExpanded = false, this.initiallyExpanded = false,
this.showPrefixChildren = true, this.showThumbnails = true,
}); });
@override @override
@ -48,32 +47,23 @@ class MetadataDirTile extends StatelessWidget {
} }
Widget thumbnail; Widget thumbnail;
final prefixChildren = <Widget>[]; if (showThumbnails) {
if (showPrefixChildren) {
switch (dirName) { switch (dirName) {
case MetadataDirectory.exifThumbnailDirectory: case MetadataDirectory.exifThumbnailDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry); thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
break; break;
case MetadataDirectory.mediaDirectory: case MetadataDirectory.mediaDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry); 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; break;
} }
} }
return AvesExpansionTile( return AvesExpansionTile(
title: title, title: title,
color: BrandColors.get(dirName) ?? stringToColor(dirName), color: dir.color ?? BrandColors.get(dirName) ?? stringToColor(dirName),
expandedNotifier: expandedDirectoryNotifier, expandedNotifier: expandedDirectoryNotifier,
initiallyExpanded: initiallyExpanded, initiallyExpanded: initiallyExpanded,
children: [ children: [
if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren),
if (thumbnail != null) thumbnail, if (thumbnail != null) thumbnail,
Padding( Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), 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/services/svg_metadata_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.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/common.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -142,18 +143,6 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
if (_loadedMetadataUri.value == entry.uri) return; if (_loadedMetadataUri.value == entry.uri) return;
if (isVisible) { if (isVisible) {
final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataService.getAllMetadata(entry)) ?? {}; 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) { final directories = rawMetadata.entries.map((dirKV) {
var directoryName = dirKV.key as String ?? ''; var directoryName = dirKV.key as String ?? '';
@ -165,15 +154,13 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
} }
final rawTags = dirKV.value as Map ?? {}; final rawTags = dirKV.value as Map ?? {};
final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) { return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags));
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);
}).toList(); }).toList();
if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultipage)) {
directories.addAll(await _getStreamDirectories());
}
final titledDirectories = directories.map((dir) { final titledDirectories = directories.map((dir) {
var title = dir.name; var title = dir.name;
if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) { 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; _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 @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
} }
class MetadataDirectory { class MetadataDirectory {
final String name; final String name;
final Color color;
final String parent; final String parent;
final SplayTreeMap<String, String> allTags; final SplayTreeMap<String, String> allTags;
final SplayTreeMap<String, String> tags; final SplayTreeMap<String, String> tags;
@ -206,12 +260,12 @@ class MetadataDirectory {
static const xmpDirectory = 'XMP'; // from metadata-extractor static const xmpDirectory = 'XMP'; // from metadata-extractor
static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory 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, : allTags = allTags,
tags = tags ?? allTags; tags = tags ?? allTags;
MetadataDirectory filterKeys(bool Function(String key) testKey) { MetadataDirectory filterKeys(bool Function(String key) testKey) {
final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key)))); 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);
} }
} }