386 lines
12 KiB
Dart
386 lines
12 KiB
Dart
// 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<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,
|
|
});
|
|
|
|
/// 展示数据。
|
|
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,
|
|
locked: data.locked,
|
|
thumbnailService: data.thumbnailService,
|
|
spriteAxisService: data.spriteAxisService,
|
|
thumbnailsUrl: data.episode.thumbnailsUrl,
|
|
episodeId: data.episode.id,
|
|
posterUrl: data.drama.posterUrl,
|
|
inactiveTrackColor: AppColors.inactiveTrack,
|
|
onScrubStart: actions.onScrubStart,
|
|
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.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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|