Files
sm-emby-share/lib/features/player/widgets/panels/tracks_panel_body.dart
T
2026-07-14 11:11:36 +08:00

514 lines
15 KiB
Dart

import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../../../../core/contracts/playback.dart';
import '../../../../core/infra/player_engine/player_engine.dart';
import '../../../../providers/subtitle_settings_provider.dart';
import '../player_drawer_panel.dart';
import '../player_panel_shell.dart';
import 'panel_selection_list.dart';
import 'panel_setting_rows.dart';
import 'selection_panel_entry.dart';
const _activeColor = Color(0xFF2196F3);
String? _translateLanguage(String? code) {
if (code == null || code.isEmpty) return null;
return kLanguageNames[code.toLowerCase()];
}
enum _SubtitlePanelView { subtitles, style }
class SubtitlePanelBody extends StatefulWidget {
final SubtitleStyleSettings subtitleSettings;
final ValueChanged<SubtitleStyleSettings> onSubtitleSettingsChanged;
final List<EmbySubtitleTrack> subtitles;
final ValueListenable<int?> activeSubtitleStreamIndex;
final ValueListenable<bool> subtitleLoading;
final void Function(int? streamIndex)? onSubtitleSelected;
const SubtitlePanelBody({
super.key,
required this.subtitleSettings,
required this.onSubtitleSettingsChanged,
required this.subtitles,
required this.activeSubtitleStreamIndex,
required this.subtitleLoading,
this.onSubtitleSelected,
});
@override
State<SubtitlePanelBody> createState() => _SubtitlePanelBodyState();
}
class _SubtitlePanelBodyState extends State<SubtitlePanelBody> {
_SubtitlePanelView _selectedView = _SubtitlePanelView.subtitles;
int? _activeStreamIndex;
bool _isSubtitleLoading = false;
@override
void initState() {
super.initState();
widget.activeSubtitleStreamIndex.addListener(_onActiveIndexChanged);
widget.subtitleLoading.addListener(_onLoadingChanged);
_activeStreamIndex = widget.activeSubtitleStreamIndex.value;
_isSubtitleLoading = widget.subtitleLoading.value;
}
@override
void didUpdateWidget(covariant SubtitlePanelBody oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.activeSubtitleStreamIndex !=
widget.activeSubtitleStreamIndex) {
oldWidget.activeSubtitleStreamIndex.removeListener(_onActiveIndexChanged);
widget.activeSubtitleStreamIndex.addListener(_onActiveIndexChanged);
_activeStreamIndex = widget.activeSubtitleStreamIndex.value;
}
if (oldWidget.subtitleLoading != widget.subtitleLoading) {
oldWidget.subtitleLoading.removeListener(_onLoadingChanged);
widget.subtitleLoading.addListener(_onLoadingChanged);
_isSubtitleLoading = widget.subtitleLoading.value;
}
}
@override
void dispose() {
widget.activeSubtitleStreamIndex.removeListener(_onActiveIndexChanged);
widget.subtitleLoading.removeListener(_onLoadingChanged);
super.dispose();
}
void _onActiveIndexChanged() {
if (!mounted) return;
setState(() => _activeStreamIndex = widget.activeSubtitleStreamIndex.value);
}
void _onLoadingChanged() {
if (!mounted) return;
setState(() => _isSubtitleLoading = widget.subtitleLoading.value);
}
void _selectSubtitle(int? streamIndex) {
if (!mounted) return;
setState(() => _activeStreamIndex = streamIndex);
widget.onSubtitleSelected?.call(streamIndex);
}
@override
Widget build(BuildContext context) {
if (Platform.isAndroid) return _buildAndroidBody();
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 150, child: _leftMenu()),
const FDivider(
axis: Axis.vertical,
style: .delta(color: kPanelDivider, padding: .value(EdgeInsets.zero)),
),
Expanded(child: _content()),
],
);
}
Widget _buildAndroidBody() {
if (_selectedView == _SubtitlePanelView.style) {
return PlayerDrawerPanel(
title: '字幕样式',
onBack: () =>
setState(() => _selectedView = _SubtitlePanelView.subtitles),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _subtitleStyleRows(),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_androidStyleEntry(),
panelDivider(),
Flexible(child: _subtitleList()),
],
);
}
Widget _androidStyleEntry() {
return InkWell(
onTap: () => setState(() => _selectedView = _SubtitlePanelView.style),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
const Icon(
Icons.closed_caption_outlined,
color: Colors.white54,
size: 16,
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'字幕样式',
style: TextStyle(color: Colors.white, fontSize: 13),
),
),
Text(
_subtitleStyleLabel(),
style: const TextStyle(color: Colors.white38, fontSize: 11),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right, color: Colors.white24, size: 16),
],
),
),
);
}
Widget _content() {
switch (_selectedView) {
case _SubtitlePanelView.subtitles:
return _subtitleList();
case _SubtitlePanelView.style:
return _subtitleSettings();
}
}
Widget _leftMenu() {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_menuItem(
view: _SubtitlePanelView.subtitles,
label: '字幕',
subtitle: _activeSubtitleLabel(),
icon: Icons.subtitles_outlined,
),
_menuItem(
view: _SubtitlePanelView.style,
label: '字幕样式',
subtitle: _subtitleStyleLabel(),
icon: Icons.closed_caption_outlined,
),
],
),
);
}
Widget _menuItem({
required _SubtitlePanelView view,
required String label,
required String subtitle,
required IconData icon,
}) {
final selected = _selectedView == view;
return GestureDetector(
onTap: () => setState(() => _selectedView = view),
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: selected
? BoxDecoration(
color: Colors.white.withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
)
: null,
child: Row(
children: [
Icon(
icon,
size: 16,
color: selected ? _activeColor : Colors.white54,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: selected ? _activeColor : Colors.white,
fontSize: 13,
fontWeight: selected
? FontWeight.w600
: FontWeight.normal,
),
),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: selected
? _activeColor.withOpacity(0.7)
: Colors.white38,
fontSize: 11,
),
),
],
),
),
Icon(
Icons.chevron_right,
size: 16,
color: selected ? _activeColor : Colors.white24,
),
],
),
),
);
}
Widget _subtitleList() {
final embedded = widget.subtitles
.where((track) => !track.isExternal)
.toList(growable: false);
final external = widget.subtitles
.where((track) => track.isExternal)
.toList(growable: false);
const disableValue = -1;
final entries = <SelectionPanelEntry<int>>[
SelectionPanelEntry.item(
value: disableValue,
label: '禁用',
isActive: _activeStreamIndex == null,
),
if (embedded.isNotEmpty) const SelectionPanelEntry.divider(),
for (final track in embedded)
SelectionPanelEntry.item(
value: track.index,
label: track.title ?? track.language ?? '字幕 ${track.index}',
subtitle: _translateLanguage(track.language),
isActive: _activeStreamIndex == track.index,
isLoading: _isSubtitleLoading && _activeStreamIndex == track.index,
),
if (external.isNotEmpty) const SelectionPanelEntry.divider(),
for (final track in external)
SelectionPanelEntry.item(
value: track.index,
label: track.title ?? track.language ?? '外挂字幕 ${track.index}',
subtitle: _translateLanguage(track.language),
badge: '外挂',
isActive: _activeStreamIndex == track.index,
isLoading: _isSubtitleLoading && _activeStreamIndex == track.index,
),
];
return PanelSelectionList<int>(
entries: entries,
onSelected: (value) =>
_selectSubtitle(value == disableValue ? null : value),
);
}
Widget _subtitleSettings() {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const PanelGroupHeader(title: '主字幕样式'),
..._subtitleStyleRows(),
const SizedBox(height: 8),
],
),
);
}
List<Widget> _subtitleStyleRows() {
final settings = widget.subtitleSettings;
return [
PanelSliderRow(
label: '位置',
value: settings.position.toDouble(),
min: 0,
max: 150,
step: 5,
display: settings.position.toString(),
onChanged: (v) => widget.onSubtitleSettingsChanged(
settings.copyWith(position: v.round()),
),
),
PanelSliderRow(
label: '大小',
value: settings.fontSize,
min: 10,
max: 120,
step: 2,
display: settings.fontSize.round().toString(),
onChanged: (v) =>
widget.onSubtitleSettingsChanged(settings.copyWith(fontSize: v)),
),
PanelSliderRow(
label: '延迟',
value: settings.delay,
min: -10.0,
max: 10.0,
step: 0.5,
display:
'${settings.delay >= 0 ? '+' : ''}${settings.delay.toStringAsFixed(1)}s',
onChanged: (v) =>
widget.onSubtitleSettingsChanged(settings.copyWith(delay: v)),
),
PanelSliderRow(
label: '背景',
value: settings.bgOpacity,
min: 0.0,
max: 1.0,
step: 0.1,
display: '${(settings.bgOpacity * 100).round()}%',
onChanged: (v) =>
widget.onSubtitleSettingsChanged(settings.copyWith(bgOpacity: v)),
),
];
}
String _activeSubtitleLabel() {
final index = _activeStreamIndex;
if (index == null) return '禁用';
final track = widget.subtitles
.where((subtitle) => subtitle.index == index)
.firstOrNull;
if (track == null) return '字幕';
return track.title ?? track.language ?? '字幕 ${track.index}';
}
String _subtitleStyleLabel() {
final settings = widget.subtitleSettings;
return '${settings.fontSize.round()} / ${settings.delay >= 0 ? '+' : ''}${settings.delay.toStringAsFixed(1)}s';
}
}
class AudioPanelBody extends StatefulWidget {
final PlayerEngine player;
final void Function(AudioTrack track)? onAudioTrackSelected;
const AudioPanelBody({
super.key,
required this.player,
this.onAudioTrackSelected,
});
@override
State<AudioPanelBody> createState() => _AudioPanelBodyState();
}
class _AudioPanelBodyState extends State<AudioPanelBody> {
AudioTrack _activeAudio = const AudioTrack.auto();
List<AudioTrack> _audioTracks = const [];
StreamSubscription<PlayerTrack>? _trackSub;
StreamSubscription<PlayerTracks>? _tracksSub;
@override
void initState() {
super.initState();
_syncFromPlayer(widget.player);
_subscribePlayer(widget.player);
}
@override
void didUpdateWidget(covariant AudioPanelBody oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.player != widget.player) {
_cancelSubscriptions();
_syncFromPlayer(widget.player);
_subscribePlayer(widget.player);
}
}
@override
void dispose() {
_cancelSubscriptions();
super.dispose();
}
void _syncFromPlayer(PlayerEngine player) {
_activeAudio = player.state.track.audio;
_audioTracks = player.state.tracks.embeddedAudio;
}
void _subscribePlayer(PlayerEngine player) {
_trackSub = player.stream.track.listen((track) {
if (!mounted) return;
setState(() => _activeAudio = track.audio);
});
_tracksSub = player.stream.tracks.listen((tracks) {
if (!mounted) return;
setState(() => _audioTracks = tracks.embeddedAudio);
});
}
void _cancelSubscriptions() {
unawaited(_trackSub?.cancel());
unawaited(_tracksSub?.cancel());
_trackSub = null;
_tracksSub = null;
}
Future<void> _selectAudio(AudioTrack track) async {
if (!mounted) return;
setState(() => _activeAudio = track);
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
try {
await widget.player.setAudioTrack(track);
widget.onAudioTrackSelected?.call(track);
} catch (_) {
if (mounted) {
setState(() => _activeAudio = widget.player.state.track.audio);
}
}
}
@override
Widget build(BuildContext context) {
return _audioList();
}
Widget _audioList() {
final entries = <SelectionPanelEntry<AudioTrack>>[
for (final t in _audioTracks)
SelectionPanelEntry.item(
value: t,
label: t.title ?? t.language ?? '音频 ${t.id}',
subtitle: _translateLanguage(t.language) ?? t.language,
isActive: _activeAudio.id == t.id,
),
if (_audioTracks.isNotEmpty) const SelectionPanelEntry.divider(),
SelectionPanelEntry.item(
value: const AudioTrack.no(),
label: '关闭音频',
isActive: _activeAudio.id == 'no',
),
];
return PanelSelectionList<AudioTrack>(
entries: entries,
onSelected: (track) => unawaited(_selectAudio(track)),
);
}
}