Files
ReelShort/lib/pages/player/widgets/player_more_sheet.dart
T

409 lines
12 KiB
Dart

// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.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,
});
/// 倍速变化。
final ValueChanged<double> onSpeedChanged;
/// 进入清屏播放。
final VoidCallback onClear;
/// 离线缓存当前集。
final VoidCallback onDownload;
/// 切换清晰度。成功返回 true;失败时面板恢复到上一档。
final Future<bool> Function(String quality) onQualityChanged;
/// 进入小窗播放;平台不支持时为 null。
final VoidCallback? onPip;
}
/// 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();
@override
Widget build(BuildContext context) {
final l10n = widget.l10n;
return Material(
key: const Key('player-more-sheet'),
color: AppColors.ink,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
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!),
),
_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: AppColors.card,
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: AppColors.raised,
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,
);
}
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 ? AppColors.card : 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,
),
),
),
),
);
}
}