483 lines
15 KiB
Dart
483 lines
15 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/widgets/app_glow_panel.dart';
|
|
import '../../../l10n/app_localizations.dart';
|
|
|
|
const double _sheetRadius = 20;
|
|
const double _rowHeight = 56;
|
|
const double _groupGap = 12;
|
|
const double _groupVerticalPadding = 6;
|
|
const double _groupRadius = 16;
|
|
const double _rowHorizontalPadding = 14;
|
|
const double _rowIconSize = 22;
|
|
const double _rowIconGap = 14;
|
|
const double _rowLabelSize = 15;
|
|
const double _rowChevronGap = 10;
|
|
const double _segmentTrackHeight = 34;
|
|
const double _segmentHeight = 26;
|
|
const double _segmentRadius = 8;
|
|
const double _segmentTrackRadius = 10;
|
|
const double _segmentTrackPadding = 4;
|
|
const double _segmentPadding = 10;
|
|
const List<double> _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0];
|
|
const List<String> _qualityOptions = ['AUTO', '360P', '480P', '720P', '1080P'];
|
|
|
|
/// 更多面板运行状态。
|
|
class PlayerMoreState {
|
|
/// 创建更多面板状态。
|
|
const PlayerMoreState({
|
|
required this.speed,
|
|
required this.qualities,
|
|
this.selectedQuality = 'AUTO',
|
|
});
|
|
|
|
/// 当前倍速。
|
|
final double speed;
|
|
|
|
/// 后端下发的清晰度档位。
|
|
final List<String> qualities;
|
|
|
|
/// 当前选中的档位,如 AUTO / 720P。
|
|
final String selectedQuality;
|
|
}
|
|
|
|
/// 更多面板动作集合。
|
|
class PlayerMoreActions {
|
|
/// 创建动作集合。
|
|
const PlayerMoreActions({
|
|
required this.onSpeedChanged,
|
|
required this.onClear,
|
|
required this.onDownload,
|
|
required this.onQualityChanged,
|
|
this.onPip,
|
|
this.autoPipEnabled = false,
|
|
this.onAutoPipChanged,
|
|
});
|
|
|
|
/// 倍速变化。
|
|
final ValueChanged<double> onSpeedChanged;
|
|
|
|
/// 进入清屏播放。
|
|
final VoidCallback onClear;
|
|
|
|
/// 离线缓存当前集。
|
|
final VoidCallback onDownload;
|
|
|
|
/// 切换清晰度。成功返回 true;失败时面板恢复到上一档。
|
|
final Future<bool> Function(String quality) onQualityChanged;
|
|
|
|
/// 进入小窗播放;平台不支持时为 null。
|
|
final VoidCallback? onPip;
|
|
|
|
/// 退出 App 自动小窗开关的当前状态(仅支持自动小窗的平台展示开关行)。
|
|
final bool autoPipEnabled;
|
|
|
|
/// 切换退出 App 自动小窗;为 null 时隐藏开关行。
|
|
final ValueChanged<bool>? onAutoPipChanged;
|
|
}
|
|
|
|
/// K29.6 精简版播放页更多面板。
|
|
class PlayerMoreSheet extends StatefulWidget {
|
|
/// 创建更多面板。
|
|
const PlayerMoreSheet({
|
|
super.key,
|
|
required this.l10n,
|
|
required this.state,
|
|
required this.actions,
|
|
});
|
|
|
|
/// 当前语言包。
|
|
final AppLocalizations l10n;
|
|
|
|
/// 面板状态。
|
|
final PlayerMoreState state;
|
|
|
|
/// 面板动作。
|
|
final PlayerMoreActions actions;
|
|
|
|
@override
|
|
State<PlayerMoreSheet> createState() => _PlayerMoreSheetState();
|
|
}
|
|
|
|
class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
|
|
late double _speed = widget.state.speed;
|
|
late String _quality = widget.state.selectedQuality.toUpperCase();
|
|
late bool _autoPip = widget.actions.autoPipEnabled;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = widget.l10n;
|
|
return AppGlowPanel(
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(_sheetRadius),
|
|
),
|
|
border: false,
|
|
child: Material(
|
|
key: const Key('player-more-sheet'),
|
|
type: MaterialType.transparency,
|
|
clipBehavior: Clip.antiAlias,
|
|
child: SafeArea(
|
|
top: false,
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
key: const Key('player-more-drag-handle'),
|
|
width: 36,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.border,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_group([_speedPicker(), _qualityPicker(l10n)]),
|
|
const SizedBox(height: _groupGap),
|
|
_group([
|
|
_actionRow(
|
|
key: const Key('player-more-clear'),
|
|
icon: Icons.fullscreen,
|
|
title: l10n.clearPlayback,
|
|
onTap: _clear,
|
|
),
|
|
if (widget.actions.onPip != null)
|
|
_actionRow(
|
|
key: const Key('player-more-pip'),
|
|
icon: Icons.picture_in_picture_alt_outlined,
|
|
title: l10n.pictureInPicture,
|
|
onTap: () => _closeThen(widget.actions.onPip!),
|
|
),
|
|
if (widget.actions.onAutoPipChanged != null)
|
|
_switchRow(
|
|
key: const Key('player-more-auto-pip'),
|
|
icon: Icons.auto_awesome_motion_outlined,
|
|
title: l10n.autoEnterPip,
|
|
value: _autoPip,
|
|
onChanged: (value) {
|
|
setState(() => _autoPip = value);
|
|
widget.actions.onAutoPipChanged!(value);
|
|
},
|
|
),
|
|
_actionRow(
|
|
key: const Key('player-more-offline'),
|
|
icon: Icons.download_outlined,
|
|
title: l10n.offlineCache,
|
|
onTap: () => _closeThen(widget.actions.onDownload),
|
|
),
|
|
]),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _group(List<Widget> rows) => Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(vertical: _groupVerticalPadding),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.06),
|
|
borderRadius: BorderRadius.circular(_groupRadius),
|
|
),
|
|
child: Column(mainAxisSize: MainAxisSize.min, children: rows),
|
|
);
|
|
|
|
Widget _speedPicker() => _optionRow(
|
|
rowKey: const Key('player-more-speed-row'),
|
|
optionsKey: const Key('player-more-speed'),
|
|
trackKey: const Key('player-more-speed-track'),
|
|
icon: Icons.speed_outlined,
|
|
title: widget.l10n.playbackSpeed,
|
|
options: [
|
|
for (final value in _speedOptions)
|
|
_SegmentButton(
|
|
key: Key('player-more-speed-$value'),
|
|
label: '${value}x',
|
|
selected: (_speed - value).abs() < 0.001,
|
|
onTap: () {
|
|
setState(() => _speed = value);
|
|
widget.actions.onSpeedChanged(value);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
|
|
Widget _qualityPicker(AppLocalizations l10n) => _optionRow(
|
|
rowKey: const Key('player-more-quality-row'),
|
|
optionsKey: const Key('player-more-quality'),
|
|
trackKey: const Key('player-more-quality-track'),
|
|
icon: Icons.high_quality_outlined,
|
|
title: l10n.playbackQuality,
|
|
options: [
|
|
for (final quality in _qualityOptions) _qualitySegment(l10n, quality),
|
|
],
|
|
);
|
|
|
|
Widget _optionRow({
|
|
required Key rowKey,
|
|
required Key optionsKey,
|
|
required Key trackKey,
|
|
required IconData icon,
|
|
required String title,
|
|
required List<Widget> options,
|
|
}) {
|
|
return SizedBox(
|
|
key: rowKey,
|
|
height: _rowHeight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: _rowHorizontalPadding),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, color: AppColors.onSurface, size: _rowIconSize),
|
|
const SizedBox(width: _rowIconGap),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: _rowLabelSize,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: _rowChevronGap),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: SingleChildScrollView(
|
|
key: optionsKey,
|
|
scrollDirection: Axis.horizontal,
|
|
child: Container(
|
|
key: trackKey,
|
|
height: _segmentTrackHeight,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: _segmentTrackPadding,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(_segmentTrackRadius),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: options,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _chooseQuality(String quality) async {
|
|
if (_quality == quality) return;
|
|
final previous = _quality;
|
|
setState(() => _quality = quality);
|
|
final switched = await widget.actions.onQualityChanged(quality);
|
|
if (!mounted || switched) return;
|
|
setState(() => _quality = previous);
|
|
}
|
|
|
|
bool _hasQuality(String quality) {
|
|
final wanted = quality.toUpperCase();
|
|
for (final item in widget.state.qualities) {
|
|
if (item.toUpperCase() == wanted) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Widget _qualitySegment(AppLocalizations l10n, String quality) {
|
|
final enabled = _hasQuality(quality);
|
|
return _SegmentButton(
|
|
key: Key('player-more-quality-$quality'),
|
|
label: enabled ? quality : '$quality · ${l10n.qualityUnavailable}',
|
|
selected: enabled && _quality == quality,
|
|
enabled: enabled,
|
|
onTap: enabled ? () => _chooseQuality(quality) : null,
|
|
);
|
|
}
|
|
|
|
/// 开关行:左侧图标+标题,右侧主题色 Switch。
|
|
Widget _switchRow({
|
|
required Key key,
|
|
required IconData icon,
|
|
required String title,
|
|
required bool value,
|
|
required ValueChanged<bool> onChanged,
|
|
}) {
|
|
return SizedBox(
|
|
key: key,
|
|
height: _rowHeight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: _rowHorizontalPadding),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, color: AppColors.onSurface, size: _rowIconSize),
|
|
const SizedBox(width: _rowIconGap),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: _rowLabelSize,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
Transform.scale(
|
|
scale: 0.7,
|
|
child: Switch(
|
|
key: const Key('player-more-auto-pip-switch'),
|
|
value: value,
|
|
activeThumbColor: AppColors.onSurface,
|
|
activeTrackColor: AppColors.brandRed,
|
|
inactiveThumbColor: AppColors.textSecondary,
|
|
inactiveTrackColor: AppColors.contentBorder,
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
onChanged: onChanged,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _actionRow({
|
|
required Key key,
|
|
required IconData icon,
|
|
required String title,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
return Material(
|
|
key: key,
|
|
color: AppColors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
height: _rowHeight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: _rowHorizontalPadding,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, color: AppColors.onSurface, size: _rowIconSize),
|
|
const SizedBox(width: _rowIconGap),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: _rowLabelSize,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: _rowChevronGap),
|
|
const Icon(
|
|
Icons.chevron_right_rounded,
|
|
color: AppColors.subtle,
|
|
size: _rowIconSize,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _clear() {
|
|
Navigator.pop(context);
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => widget.actions.onClear(),
|
|
);
|
|
}
|
|
|
|
void _closeThen(VoidCallback action) {
|
|
Navigator.pop(context);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => action());
|
|
}
|
|
}
|
|
|
|
class _SegmentButton extends StatelessWidget {
|
|
const _SegmentButton({
|
|
super.key,
|
|
required this.label,
|
|
required this.selected,
|
|
this.enabled = true,
|
|
this.onTap,
|
|
});
|
|
|
|
final String label;
|
|
final bool selected;
|
|
final bool enabled;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final foreground = selected
|
|
? AppColors.onSurface
|
|
: enabled
|
|
? AppColors.muted
|
|
: AppColors.subtle;
|
|
return Material(
|
|
color: AppColors.transparent,
|
|
borderRadius: BorderRadius.circular(_segmentRadius),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(_segmentRadius),
|
|
child: Container(
|
|
height: _segmentHeight,
|
|
padding: const EdgeInsets.symmetric(horizontal: _segmentPadding),
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? Colors.white.withValues(alpha: 0.16)
|
|
: AppColors.transparent,
|
|
borderRadius: BorderRadius.circular(_segmentRadius),
|
|
boxShadow: selected
|
|
? const [
|
|
BoxShadow(
|
|
color: AppColors.shadowSubtle,
|
|
blurRadius: 4,
|
|
offset: Offset(0, 1),
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
style: TextStyle(
|
|
color: foreground,
|
|
fontSize: 13,
|
|
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|