// Author: fruit.com.cn import 'package:flutter/material.dart'; import 'package:video_player/video_player.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 'exit_clear_screen_button.dart'; import 'player_center_toggle.dart'; import 'player_overlay_models.dart'; import 'seek_progress_bar.dart'; part 'player_overlay_parts.dart'; const double _topGradientHeight = 230; const double _bottomGradientHeight = 390; const double _topButtonSize = 36; const double _titleSize = 24; const double _dotSize = 7; const double _railButtonSize = 36; const double _railGap = 9; const double _chipRadius = 999; const double _dotRadius = 3; const double _railTopFactor = 0.28; const double _railTopCompactFactor = 0.18; /// K28 竖屏播放页浮层。 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: GestureDetector( key: const Key('player-video-tap'), behavior: HitTestBehavior.opaque, onTap: actions.onTogglePlay, ), ), if (controlsVisible) ...[ _top(context), _bottom(context), _rail(context), ], if (!controlsVisible) Positioned( top: AppDimens.spaceSm, right: AppDimens.spaceMd, child: ExitClearScreenButton( key: const Key('player-exit-clear-screen'), tooltip: data.l10n.exitClearScreen, onPressed: actions.onExitClear, ), ), if (!data.playing) Center( child: PlayerCenterToggle( key: const Key('player-play-toggle'), playing: false, onTap: actions.onTogglePlay, ), ), ], ), ); } Widget _top(BuildContext context) { final title = data.episode.title.trim().isEmpty ? data.l10n.episodeLabel(data.episode.number) : data.episode.title; final series = localizedDramaTitle(data.l10n, data.drama); return Positioned( top: 0, left: 0, right: 0, child: Container( key: const Key('player-controls-top'), height: _topGradientHeight, padding: const EdgeInsets.fromLTRB(14, 4, 14, 20), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xCC000000), Color(0x00000000)], ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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 Spacer(), _TopButton( key: const Key('player-clear-screen'), tooltip: data.l10n.clearScreen, icon: Icons.fullscreen_rounded, onPressed: actions.onClear, ), const SizedBox(width: AppDimens.spaceSm), _TopButton( key: const Key('player-more'), tooltip: data.l10n.more, icon: Icons.more_horiz_rounded, onPressed: actions.onMore, ), ], ), const SizedBox(height: 18), Row( children: [ Flexible( child: Text( title, key: const Key('player-title'), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: AppColors.onPrimary, fontFamily: 'Outfit', fontSize: _titleSize * data.textScale, fontWeight: FontWeight.w800, ), ), ), const SizedBox(width: 7), Container( key: const Key('player-title-dot'), width: _dotSize, height: _dotSize, decoration: const BoxDecoration( color: AppColors.blue, shape: BoxShape.circle, ), ), ], ), const SizedBox(height: 6), Text( '$series · ${data.l10n.episodeLabel(data.episode.number)}', key: const Key('player-subtitle'), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Colors.white70, fontSize: 14 * data.textScale, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 10), Row( children: [ if (_genre.isNotEmpty) _infoChip(const Key('player-genre-chip'), _genre), const Spacer(), _PageDots( key: const Key('player-page-dots'), current: data.currentIndex, total: data.totalEpisodes, ), ], ), ], ), ), ); } Widget _rail(BuildContext context) { final likeCount = data.likeCount; final compact = MediaQuery.sizeOf(context).height < 700; return Positioned( right: AppDimens.spaceMd, top: MediaQuery.sizeOf(context).height * (compact ? _railTopCompactFactor : _railTopFactor), child: _Rail( l10n: data.l10n, liked: data.liked, saved: data.saved, likeCount: likeCount, onLike: actions.onLike, onComments: actions.onComments, onSave: actions.onSave, onShare: actions.onShare, compact: compact, ), ); } Widget _bottom(BuildContext context) { final series = localizedDramaTitle(data.l10n, data.drama); final synopsis = localizedDramaSynopsis(data.l10n, data.drama); return Positioned( left: 0, right: 0, bottom: 0, child: Stack( children: [ const Positioned.fill( child: IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color(0x00000000), Color(0xDD000000), Color(0xF2000000), ], ), ), ), ), ), ConstrainedBox( constraints: const BoxConstraints(maxHeight: _bottomGradientHeight), child: Padding( key: const Key('player-controls-bottom'), padding: const EdgeInsets.fromLTRB(14, 48, 14, 10), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ 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: 22 * data.textScale, fontWeight: FontWeight.w800, ), ), ), const SizedBox(width: 8), Text( data.l10n.episodeLabel(data.episode.number), key: const Key('player-episode-label'), style: TextStyle( color: AppColors.blue, fontSize: 12 * data.textScale, fontWeight: FontWeight.w700, ), ), ], ), const SizedBox(height: 8), Row( children: [ Expanded( child: Align( alignment: Alignment.centerLeft, child: _genre.isEmpty ? const SizedBox.shrink() : _softChip(_genre), ), ), const SizedBox(width: 8), SizedBox( height: AppDimens.buttonSmall, child: FilledButton( key: const Key('player-follow'), onPressed: actions.onSave, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 16), ), child: Text( data.saved ? data.l10n.followingTab : data.l10n.followSeries, ), ), ), ], ), const SizedBox(height: 8), Text( synopsis, key: const Key('player-synopsis'), maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( color: Colors.white70, fontSize: 12 * data.textScale, height: 1.45, ), ), const SizedBox(height: 4), _TimeLabel( key: const Key('player-time-label'), controller: data.controller, ), SeekProgressBar( key: const Key('player-progress'), valueListenable: data.controller, semanticsLabel: data.l10n.seekVideo, showTimes: true, compact: true, onScrubStart: actions.onScrubStart, onSeek: actions.onSeek, ), const SizedBox(height: 4), Row( children: [ Expanded( child: _ActionButton( key: const Key('player-episodes'), icon: Icons.grid_view_rounded, label: data.l10n.episodes, onTap: actions.onEpisodes, ), ), const SizedBox(width: 8), Expanded( child: _ActionButton( key: const Key('player-next'), icon: Icons.skip_next_rounded, label: data.l10n.nextEpisode, onTap: actions.onNext, ), ), const SizedBox(width: 8), Expanded( child: _ActionButton( key: const Key('player-bottom-more'), icon: Icons.more_horiz_rounded, label: data.l10n.more, onTap: actions.onMore, ), ), ], ), ], ), ), ), ], ), ); } Widget _infoChip(Key key, String label) => Container( key: key, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: AppColors.blue, borderRadius: BorderRadius.circular(_chipRadius), ), child: Text( label, style: const TextStyle( color: AppColors.onPrimary, fontSize: 10, fontWeight: FontWeight.w700, ), ), ); Widget _softChip(String label) => Container( constraints: const BoxConstraints(maxWidth: 180), padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(_chipRadius), ), child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600, ), ), ); String get _genre => data.drama.genre.isNotEmpty ? data.drama.genre : data.drama.category; }