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 onSubtitleSettingsChanged; final List subtitles; final ValueListenable activeSubtitleStreamIndex; final ValueListenable 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 createState() => _SubtitlePanelBodyState(); } class _SubtitlePanelBodyState extends State { _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.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( 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 _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 createState() => _AudioPanelBodyState(); } class _AudioPanelBodyState extends State { AudioTrack _activeAudio = const AudioTrack.auto(); List _audioTracks = const []; StreamSubscription? _trackSub; StreamSubscription? _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 _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 = >[ 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( entries: entries, onSelected: (track) => unawaited(_selectAudio(track)), ); } }