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

417 lines
14 KiB
Dart

// 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;
}