514 lines
15 KiB
Dart
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)),
|
|
);
|
|
}
|
|
}
|