// 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 _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0]; const List _qualityOptions = ['AUTO', '360P', '480P', '720P', '1080P']; /// 更多面板运行状态。 class PlayerMoreState { /// 创建更多面板状态。 const PlayerMoreState({ required this.speed, required this.qualities, this.selectedQuality = 'AUTO', }); /// 当前倍速。 final double speed; /// 后端下发的清晰度档位。 final List 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 onSpeedChanged; /// 进入清屏播放。 final VoidCallback onClear; /// 离线缓存当前集。 final VoidCallback onDownload; /// 切换清晰度。成功返回 true;失败时面板恢复到上一档。 final Future Function(String quality) onQualityChanged; /// 进入小窗播放;平台不支持时为 null。 final VoidCallback? onPip; /// 退出 App 自动小窗开关的当前状态(仅支持自动小窗的平台展示开关行)。 final bool autoPipEnabled; /// 切换退出 App 自动小窗;为 null 时隐藏开关行。 final ValueChanged? 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 createState() => _PlayerMoreSheetState(); } class _PlayerMoreSheetState extends State { 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 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 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 _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 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, ), ), ), ), ); } }