Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -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),
),
),
),
],
),
),
],
),
),
),
],
),
),
);
}
}