Files

454 lines
15 KiB
Dart

// 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 '../../../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<Shadow> _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.textPrimary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: const Key('player-episodes'),
onTap: actions.onEpisodes,
borderRadius: BorderRadius.circular(8),
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,
),
],
),
);
}
}