283 lines
9.2 KiB
Dart
283 lines
9.2 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:forui/forui.dart';
|
||
|
|
|
||
|
|
import '../../../core/contracts/library.dart';
|
||
|
|
import '../../../shared/constants/breakpoints.dart';
|
||
|
|
import '../../../shared/widgets/frosted_panel.dart';
|
||
|
|
import '../../../shared/widgets/hover_scrollable_row.dart';
|
||
|
|
|
||
|
|
class MediaInfoSection extends StatelessWidget {
|
||
|
|
final EmbyRawItem item;
|
||
|
|
final int selectedSourceIndex;
|
||
|
|
final EmbyRawMediaSource? selectedSource;
|
||
|
|
final bool embedded;
|
||
|
|
|
||
|
|
const MediaInfoSection({
|
||
|
|
super.key,
|
||
|
|
required this.item,
|
||
|
|
this.selectedSourceIndex = 0,
|
||
|
|
this.selectedSource,
|
||
|
|
this.embedded = false,
|
||
|
|
});
|
||
|
|
|
||
|
|
static bool hasVisibleContent({
|
||
|
|
required EmbyRawItem item,
|
||
|
|
int selectedSourceIndex = 0,
|
||
|
|
EmbyRawMediaSource? selectedSource,
|
||
|
|
}) {
|
||
|
|
if (item.Type == 'Series') return false;
|
||
|
|
|
||
|
|
final EmbyRawMediaSource source;
|
||
|
|
if (selectedSource != null) {
|
||
|
|
source = selectedSource;
|
||
|
|
} else {
|
||
|
|
final sources = mediaSourcesOfItem(item);
|
||
|
|
if (sources.isEmpty) return false;
|
||
|
|
source = sources[selectedSourceIndex.clamp(0, sources.length - 1)];
|
||
|
|
}
|
||
|
|
final streams = source.MediaStreams ?? [];
|
||
|
|
if (streams.isEmpty) return false;
|
||
|
|
|
||
|
|
return streams.any(
|
||
|
|
(stream) =>
|
||
|
|
stream.Type == 'Video' ||
|
||
|
|
stream.Type == 'Audio' ||
|
||
|
|
stream.Type == 'Subtitle',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
if (!hasVisibleContent(
|
||
|
|
item: item,
|
||
|
|
selectedSourceIndex: selectedSourceIndex,
|
||
|
|
selectedSource: selectedSource,
|
||
|
|
)) {
|
||
|
|
return const SizedBox.shrink();
|
||
|
|
}
|
||
|
|
|
||
|
|
final EmbyRawMediaSource source;
|
||
|
|
if (selectedSource != null) {
|
||
|
|
source = selectedSource!;
|
||
|
|
} else {
|
||
|
|
final sources = mediaSourcesOfItem(item);
|
||
|
|
if (sources.isEmpty) return const SizedBox.shrink();
|
||
|
|
source = sources[selectedSourceIndex.clamp(0, sources.length - 1)];
|
||
|
|
}
|
||
|
|
final streams = source.MediaStreams ?? [];
|
||
|
|
if (streams.isEmpty) return const SizedBox.shrink();
|
||
|
|
|
||
|
|
final hPad = embedded
|
||
|
|
? 0.0
|
||
|
|
: (MediaQuery.sizeOf(context).width < Breakpoints.detail ? 16.0 : 32.0);
|
||
|
|
|
||
|
|
final defaultAudioIdx = source.DefaultAudioStreamIndex;
|
||
|
|
final cards = <Widget>[];
|
||
|
|
|
||
|
|
for (final s in streams.where((s) => s.Type == 'Video')) {
|
||
|
|
cards.add(_StreamCard(stream: s, kind: _Kind.video));
|
||
|
|
}
|
||
|
|
for (final s in streams.where((s) => s.Type == 'Audio')) {
|
||
|
|
final isDefault =
|
||
|
|
(s.Index != null && s.Index == defaultAudioIdx) ||
|
||
|
|
(s.IsDefault ?? false);
|
||
|
|
cards.add(
|
||
|
|
_StreamCard(stream: s, kind: _Kind.audio, isDefault: isDefault),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
for (final s in streams.where((s) => s.Type == 'Subtitle')) {
|
||
|
|
cards.add(
|
||
|
|
_StreamCard(
|
||
|
|
stream: s,
|
||
|
|
kind: _Kind.subtitle,
|
||
|
|
isDefault: s.IsDefault ?? false,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (cards.isEmpty) return const SizedBox.shrink();
|
||
|
|
|
||
|
|
return Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
const FDivider(style: .delta(color: Color(0x1AFFFFFF), padding: .value(EdgeInsets.symmetric(vertical: 24)))),
|
||
|
|
Padding(
|
||
|
|
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 16),
|
||
|
|
child: Text(
|
||
|
|
'媒体信息',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 20,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
color: Colors.white.withValues(alpha: 0.9),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
SizedBox(
|
||
|
|
height: 280,
|
||
|
|
child: HoverScrollableRow(
|
||
|
|
builder: (_, controller) => ListView.builder(
|
||
|
|
controller: controller,
|
||
|
|
scrollDirection: Axis.horizontal,
|
||
|
|
padding: EdgeInsets.symmetric(horizontal: hPad),
|
||
|
|
itemCount: cards.length,
|
||
|
|
itemBuilder: (_, i) => Padding(
|
||
|
|
padding: EdgeInsets.only(right: i < cards.length - 1 ? 12 : 0),
|
||
|
|
child: cards[i],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
enum _Kind { video, audio, subtitle }
|
||
|
|
|
||
|
|
class _StreamCard extends StatelessWidget {
|
||
|
|
final EmbyRawMediaStream stream;
|
||
|
|
final _Kind kind;
|
||
|
|
final bool isDefault;
|
||
|
|
|
||
|
|
const _StreamCard({
|
||
|
|
required this.stream,
|
||
|
|
required this.kind,
|
||
|
|
this.isDefault = false,
|
||
|
|
});
|
||
|
|
|
||
|
|
List<(String label, String value)> _fields() {
|
||
|
|
final ex = stream.extra;
|
||
|
|
final rows = <(String, String)>[];
|
||
|
|
|
||
|
|
switch (kind) {
|
||
|
|
case _Kind.video:
|
||
|
|
_add(rows, '编码', stream.Codec);
|
||
|
|
final w = ex['Width'] as int?;
|
||
|
|
final h = ex['Height'] as int?;
|
||
|
|
if (w != null && h != null) rows.add(('分辨率', '${w}x$h'));
|
||
|
|
final fr =
|
||
|
|
(ex['RealFrameRate'] as num?) ?? (ex['AverageFrameRate'] as num?);
|
||
|
|
if (fr != null) rows.add(('帧率', fr.toDouble().toStringAsFixed(1)));
|
||
|
|
final br = ex['BitRate'] as int?;
|
||
|
|
if (br != null) rows.add(('比特率', '${(br / 1000).round()}kbps'));
|
||
|
|
_add(rows, '动态范围', ex['VideoRange'] as String?);
|
||
|
|
_add(rows, '配置', ex['Profile'] as String?);
|
||
|
|
case _Kind.audio:
|
||
|
|
_add(rows, '标题', stream.Title);
|
||
|
|
_add(rows, '内嵌标题', stream.DisplayTitle);
|
||
|
|
_add(rows, '语言', stream.Language);
|
||
|
|
_add(rows, '布局', ex['ChannelLayout'] as String?);
|
||
|
|
if (stream.Channels != null) rows.add(('声道', '${stream.Channels}'));
|
||
|
|
_add(rows, '编码', stream.Codec);
|
||
|
|
final br = ex['BitRate'] as int?;
|
||
|
|
if (br != null) rows.add(('比特率', '${(br / 1000).round()}kbps'));
|
||
|
|
final sr = ex['SampleRate'] as int?;
|
||
|
|
if (sr != null) rows.add(('采样率', '${sr}Hz'));
|
||
|
|
rows.add(('外部', (stream.IsExternal ?? false) ? '是' : '否'));
|
||
|
|
rows.add(('默认', (stream.IsDefault ?? false) ? '是' : '否'));
|
||
|
|
case _Kind.subtitle:
|
||
|
|
_add(rows, '标题', stream.Title);
|
||
|
|
_add(rows, '内嵌标题', stream.DisplayTitle);
|
||
|
|
_add(rows, '语言', stream.Language);
|
||
|
|
_add(rows, '格式', stream.Codec);
|
||
|
|
rows.add(('外部', (stream.IsExternal ?? false) ? '是' : '否'));
|
||
|
|
if (stream.IsForced ?? false) rows.add(('强制', '是'));
|
||
|
|
rows.add(('默认', (stream.IsDefault ?? false) ? '是' : '否'));
|
||
|
|
}
|
||
|
|
return rows;
|
||
|
|
}
|
||
|
|
|
||
|
|
static void _add(List<(String, String)> rows, String label, String? value) {
|
||
|
|
if (value != null && value.isNotEmpty) rows.add((label, value));
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final fields = _fields();
|
||
|
|
|
||
|
|
final (IconData icon, String name) = switch (kind) {
|
||
|
|
_Kind.video => (Icons.videocam_outlined, '视频'),
|
||
|
|
_Kind.audio => (Icons.music_note_outlined, '音频'),
|
||
|
|
_Kind.subtitle => (Icons.subtitles_outlined, '字幕'),
|
||
|
|
};
|
||
|
|
|
||
|
|
return SizedBox(
|
||
|
|
width: 260,
|
||
|
|
height: 280,
|
||
|
|
child: FrostedPanel(
|
||
|
|
radius: 12,
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
blurSigma: 15,
|
||
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||
|
|
color: Colors.black26,
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
Icon(icon, size: 16, color: Colors.white),
|
||
|
|
const SizedBox(width: 6),
|
||
|
|
Text(
|
||
|
|
name,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
color: Colors.white,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
if (isDefault) ...[
|
||
|
|
const SizedBox(width: 6),
|
||
|
|
Icon(
|
||
|
|
Icons.check,
|
||
|
|
size: 14,
|
||
|
|
color: Colors.white.withValues(alpha: 0.7),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
Expanded(
|
||
|
|
child: SingleChildScrollView(
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
for (final (label, value) in fields)
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.only(bottom: 5),
|
||
|
|
child: Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
SizedBox(
|
||
|
|
width: 60,
|
||
|
|
child: Text(
|
||
|
|
label,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: Colors.white.withValues(alpha: 0.55),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Expanded(
|
||
|
|
child: Text(
|
||
|
|
value,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: Colors.white.withValues(alpha: 0.85),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|