// Author: fruit.com.cn import 'package:flutter/material.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; import 'player_center_toggle.dart'; import 'interaction_rail.dart'; import 'player_overlay_models.dart'; import 'seek_progress_bar.dart'; part 'player_overlay_parts.dart'; const double _topButtonSize = 36; const double _topBarHeight = 58; const double _boostHintIconSize = 16; const double _boostHintFontSize = 13; const List _playerTextShadows = [ Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), ]; /// K29 竖屏播放页浮层。 class PlayerPlaybackOverlay extends StatelessWidget { /// 创建播放浮层。 const PlayerPlaybackOverlay({ super.key, required this.data, required this.actions, required this.controlsVisible, }); /// 展示数据。 final PlayerOverlayData data; /// 交互动作。 final PlayerOverlayActions actions; /// 控制层是否可见。 final bool controlsVisible; @override Widget build(BuildContext context) { return SafeArea( child: Stack( children: [ Positioned.fill( child: LayoutBuilder( builder: (context, constraints) => GestureDetector( key: const Key('player-video-tap'), behavior: HitTestBehavior.opaque, onTap: actions.onTogglePlay, onLongPressStart: (details) => _handleLongPressStart(details, constraints.maxWidth), onLongPressEnd: (_) => actions.onBoostEnd(), onLongPressCancel: actions.onBoostEnd, ), ), ), _top(context), _bottom(context), if (controlsVisible) _rail(context), if (!data.playing) Center( child: PlayerCenterToggle( key: const Key('player-play-toggle'), playing: false, onTap: actions.onTogglePlay, ), ), ], ), ); } Widget _top(BuildContext context) { return Positioned( top: 0, left: 0, right: 0, child: Container( key: const Key('player-controls-top'), height: _topBarHeight, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), child: Row( children: [ if (actions.onBack != null) _TopButton( key: const Key('player-back'), tooltip: data.l10n.backToSeries, icon: Icons.chevron_left_rounded, onPressed: actions.onBack!, ) else const SizedBox(width: _topButtonSize), const SizedBox(width: 8), Expanded( child: Text( data.l10n.episodeLabel(data.episode.number), key: const Key('player-episode-heading'), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onPrimary, fontSize: 15, fontWeight: FontWeight.w700, shadows: _playerTextShadows, ), ), ), _SpeedEntry( key: const Key('player-speed-entry'), speed: data.playbackSpeed, onTap: actions.onSpeed, ), const SizedBox(width: 8), _TopButton( key: const Key('player-more'), tooltip: data.l10n.more, icon: Icons.more_vert_rounded, onPressed: actions.onMore, ), ], ), ), ); } /// 长按分区:中间 1/3 弹更多,左右各 1/3 开始加速。 void _handleLongPressStart(LongPressStartDetails details, double width) { final third = width / 3; final dx = details.localPosition.dx; if (dx < third || dx > width - third) { actions.onBoostStart(); return; } actions.onLongPressCenter(); } Widget _rail(BuildContext context) { final compact = MediaQuery.sizeOf(context).height < 700; return Positioned( right: AppDimens.spaceMd, top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43), child: InteractionRail( key: const Key('player-interaction-rail'), items: [ InteractionRailItem( key: const Key('player-rail-save'), asset: 'assets/icon/rail/rail_save.png', label: data.l10n.favorite, count: data.favoriteCount, countKey: const Key('player-rail-save-count'), selected: data.saved, onTap: actions.onSave, ), InteractionRailItem( key: const Key('player-rail-comments'), asset: 'assets/icon/rail/rail_comment.png', label: data.l10n.comments, count: data.commentCount, countKey: const Key('player-rail-comments-count'), onTap: actions.onComments, ), InteractionRailItem( key: const Key('player-rail-like'), asset: 'assets/icon/rail/rail_like.png', label: data.l10n.like, count: data.likeCount, countKey: const Key('player-rail-like-count'), selected: data.liked, onTap: actions.onLike, ), InteractionRailItem( key: const Key('player-rail-share'), asset: 'assets/icon/rail/rail_share.png', label: data.l10n.share, onTap: actions.onShare, ), ], ), ); } Widget _bottom(BuildContext context) { return Positioned( left: 0, right: 0, bottom: 0, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (controlsVisible) _bottomInfo(context), if (data.boosting) _boostHint(), _bottomProgress(), // 加速态只保留进度条与提示,便于全屏快进时看清画面。 if (!data.boosting) _bottomActions(), ], ), ); } /// 加速提示:橙色小三角 + 当前倍速文案,悬在进度条上方。 Widget _boostHint() { return Padding( key: const Key('player-speed-boost-hint'), padding: const EdgeInsets.only(bottom: 6), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.play_arrow_rounded, color: AppColors.blue, size: _boostHintIconSize, shadows: _playerTextShadows, ), const SizedBox(width: 4), Text( data.l10n.speedBoosting(data.playbackSpeed.toStringAsFixed(1)), style: const TextStyle( color: AppColors.blue, fontSize: _boostHintFontSize, fontWeight: FontWeight.w700, shadows: _playerTextShadows, ), ), ], ), ); } Widget _bottomInfo(BuildContext context) { final series = localizedDramaTitle(data.l10n, data.drama); final synopsis = localizedDramaSynopsis(data.l10n, data.drama); return Padding( key: const Key('player-controls-bottom'), padding: const EdgeInsets.fromLTRB(14, 8, 14, 0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _detailRow(series), const SizedBox(height: 4), _EpisodeSynopsis( key: const Key('player-episode-synopsis'), l10n: data.l10n, episodeNumber: data.episode.number, synopsis: synopsis, authorNote: data.authorNote, ), ], ), ); } Widget _bottomProgress() { return Padding( key: const Key('player-progress-container'), padding: EdgeInsets.fromLTRB(14, controlsVisible ? 2 : 8, 14, 0), child: SeekProgressBar( key: const Key('player-progress'), valueListenable: data.controller, semanticsLabel: data.l10n.seekVideo, showTimes: true, compact: true, inactiveTrackColor: Colors.white24, onScrubStart: actions.onScrubStart, onSeek: actions.onSeek, ), ); } Widget _detailRow(String series) { return Material( color: Colors.transparent, child: InkWell( key: const Key('player-open-detail'), onTap: actions.onDetail, child: Padding( padding: const EdgeInsets.symmetric(vertical: 1), child: Row( children: [ Flexible( child: Text( series, key: const Key('player-drama-title'), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: AppColors.onPrimary, fontFamily: 'Outfit', fontSize: 18, fontWeight: FontWeight.w800, shadows: _playerTextShadows, ), ), ), const SizedBox(width: 4), const Icon( Icons.chevron_right_rounded, color: AppColors.playerMuted, size: 20, shadows: _playerTextShadows, ), ], ), ), ), ); } Widget _bottomActions() { return Padding( key: const Key('player-bottom-actions'), padding: const EdgeInsets.fromLTRB(14, 6, 14, 6), child: Row( children: [ Expanded( child: Material( key: const Key('player-episodes-pill'), color: AppColors.playerOverlayPill, borderRadius: BorderRadius.circular(7), clipBehavior: Clip.antiAlias, child: InkWell( key: const Key('player-episodes'), onTap: actions.onEpisodes, borderRadius: BorderRadius.circular(7), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 8, ), child: Row( children: [ Expanded( child: Text( '${data.l10n.episodes} · ' '${data.l10n.allEpisodesBadge(data.totalEpisodes)} · ' '${data.l10n.freeWatch}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onPrimary, fontSize: 13.5, fontWeight: FontWeight.w800, shadows: _playerTextShadows, ), ), ), const SizedBox(width: 8), const Icon( Icons.keyboard_arrow_up_rounded, color: AppColors.onPrimary, size: 18, shadows: _playerTextShadows, ), ], ), ), ), ), ), const SizedBox(width: 8), _BottomIconButton( key: const Key('player-bottom-clear-screen'), tooltip: controlsVisible ? data.l10n.clearScreen : data.l10n.exitClearScreen, icon: controlsVisible ? Icons.fullscreen_rounded : Icons.fullscreen_exit_rounded, onPressed: controlsVisible ? actions.onClear : actions.onExitClear, ), ], ), ); } }