// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/services/catalog_controller.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/app_press_scale.dart'; import '../../../l10n/app_localizations.dart'; import 'expandable_synopsis.dart'; import 'player_center_toggle.dart'; import 'interaction_rail.dart'; import 'player_overlay_models.dart'; import 'player_tap_gesture_region.dart'; import 'seek_progress_bar.dart'; part 'player_overlay_parts.dart'; const double _topButtonSize = 40; const double _topBarHeight = 58; /// 底部信息、选集条、进度条之间的统一行距。 const double _blockGap = 8; /// 底部选集条与右侧清屏按钮的统一高度,两者必须等高。 const double _bottomActionHeight = 34; /// 选集条内部左右留白。 const double _bottomActionInset = 10; const double _boostHintIconSize = 16; const double _boostHintFontSize = 13; const double _synopsisRailInset = 54; const List _playerTextShadows = [ Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), ]; /// K29 竖屏播放页浮层。 class PlayerPlaybackOverlay extends StatelessWidget { /// 创建播放浮层。 const PlayerPlaybackOverlay({ super.key, required this.data, required this.actions, required this.controlsVisible, required this.scrubbing, this.suppressProgressLoading = false, }); /// 展示数据。 final PlayerOverlayData data; /// 交互动作。 final PlayerOverlayActions actions; /// 控制层是否可见。 final bool controlsVisible; /// 正在拖动进度条。拖动期间清屏,只留进度条。 final bool scrubbing; final bool suppressProgressLoading; @override Widget build(BuildContext context) { return SafeArea( child: Stack( clipBehavior: Clip.none, children: [ Positioned.fill( child: PlayerTapGestureRegion( key: const ValueKey('player-tap-gesture-region'), gestureKey: const Key('player-video-tap'), enabled: !scrubbing, doubleTapEnabled: !data.locked, onSingleTap: actions.onTogglePlay, onDoubleTapLike: actions.onDoubleTapLike, onLongPressStart: _handleLongPressStart, onInteractionEnd: actions.onBoostEnd, ), ), _top(context), _bottom(context), if (controlsVisible) _rail(context), if (!data.playing && !scrubbing) 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: IgnorePointer( ignoring: scrubbing, child: Opacity( opacity: scrubbing ? 0 : 1, 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, iconSize: 30, onPressed: actions.onBack!, ) else const SizedBox(width: _topButtonSize), const SizedBox(width: 2), 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_horiz_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) { return Positioned( right: AppDimens.spaceMd, // 底部锚定:无论屏幕高矮,始终悬在进度条上方,不会压到进度条。 bottom: 120, child: IgnorePointer( ignoring: scrubbing, child: Opacity( opacity: scrubbing ? 0 : 1, child: InteractionRail( key: const Key('player-interaction-rail'), itemGap: interactionRailCompactGap, buttonSize: 48, iconSize: 36, countFontSize: 15, 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, animateTap: true, onTap: actions.onSave, ), 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, animateTap: true, selectedColor: AppColors.brandRed, onTap: actions.onLike, ), 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-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: [ Offstage( offstage: !controlsVisible || scrubbing, child: _bottomInfo(context), ), if (data.boosting) _boostHint(), // 加速时收起选集条。拖动进度时只隐藏,占位保留,底部不跳动。 scrubbing ? IgnorePointer( child: Opacity(opacity: 0, child: _bottomActions()), ) : Offstage(offstage: data.boosting, child: _bottomActions()), // 进度条压在最底部,和首页 feed 一致。 _bottomProgress(), ], ), ); } /// 加速提示:橙色小三角 + 当前倍速文案,压在底部信息区下方。 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, _blockGap, 14 + _synopsisRailInset, 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, drama: data.drama, synopsis: synopsis, authorNote: data.authorNote, ), ], ), ); } Widget _bottomProgress() { // 进度条不贴底:底部留 16pt,线心离底边更远,也不再和选集条挤在一起。 return Padding( key: const Key('player-progress-container'), padding: const EdgeInsets.fromLTRB(14, 2, 14, 16), child: SeekProgressBar( key: const Key('player-progress'), valueListenable: data.controller, semanticsLabel: data.l10n.seekVideo, showTimes: true, compact: true, locked: data.locked, thumbnailService: data.thumbnailService, spriteAxisService: data.spriteAxisService, thumbnailsUrl: data.episode.thumbnailsUrl, episodeId: data.episode.id, inactiveTrackColor: AppColors.inactiveTrack, fallbackDuration: Duration(seconds: data.episode.durationSec), suppressBufferLoading: suppressProgressLoading, onScrubStart: actions.onScrubStart, onScrubEnd: actions.onScrubEnd, onSeek: actions.onSeek, ), ); } Widget _detailRow(String series) { return Material( color: AppColors.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.w400, shadows: _playerTextShadows, ), ), ), const Icon( Icons.chevron_right_rounded, color: AppColors.onPrimary, size: 20, shadows: _playerTextShadows, ), ], ), ), ), ); } Widget _bottomActions() { return Padding( key: const Key('player-bottom-actions'), padding: const EdgeInsets.fromLTRB(14, 2, 14, 0), child: Row( children: [ // 清屏后只留右下角的退出按钮,左侧选集条一起收起。 if (!controlsVisible) const Spacer() else ...[ Expanded( // 选集条给一层浅底色:视频画面亮时纯文字不好认,也顺带标出可点区域。 child: Material( key: const Key('player-episodes-pill'), color: AppColors.glassSurface, borderRadius: BorderRadius.circular(8), clipBehavior: Clip.antiAlias, child: InkWell( key: const Key('player-episodes'), onTap: actions.onEpisodes, borderRadius: BorderRadius.circular(8), child: AppPressScale( child: Padding( padding: const EdgeInsets.symmetric( horizontal: _bottomActionInset, ), child: SizedBox( height: _bottomActionHeight, 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.w400, 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, height: _bottomActionHeight, onPressed: controlsVisible ? actions.onClear : actions.onExitClear, ), ], ), ); } }