Initial commit
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user