feat(flutter): 重设计播放页与操作面板

This commit is contained in:
Zhou Weidong committed 2026-10-20 10:41:56 +08:00
1 parent 148fbc3c93
commit 9a4269b847
24 files changed
+2395 -1101

No files matched your search

@@ -23,6 +23,7 @@ const double _minSheetHeight = 280;
const double _maxSheetHeight = 520;
const int _maxCommentLength = 300;
const double _composerRadius = 22;
const double _sheetRadius = 20;
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。
class DramaCommentsSheet extends StatefulWidget {
@@ -160,15 +161,24 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
24;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SizedBox(
height: available.clamp(_minSheetHeight, _maxSheetHeight),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) => _content(context),
child: Container(
key: const Key('player-comments-sheet'),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
),
child: SizedBox(
height: available.clamp(_minSheetHeight, _maxSheetHeight),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) => _content(context),
),
),
),
),
@@ -182,13 +192,35 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
count == null ? l10n.comments : l10n.commentsWithCount(count),
key: count == null ? null : const Key('comment-count-label'),
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
IconButton(
key: const Key('player-comments-close'),
tooltip: l10n.close,
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close_rounded),
),
],
),
Text(
count == null ? l10n.comments : '${l10n.comments} · $count',
key: count == null ? null : const Key('comment-count-label'),
widget.drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
@@ -312,7 +344,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
maxLength: _maxCommentLength,
maxLines: 1,
style: const TextStyle(color: AppColors.cream, fontSize: 14),
cursorColor: AppColors.gold,
cursorColor: AppColors.blue,
decoration: _composerDecoration(l10n.writeComment),
onSubmitted: (_) => _post(),
),
@@ -10,7 +10,7 @@ const double _numberSize = 15;
const double _lockSize = 16;
const double _lockGap = 4;
const double _lockWeight = 300;
const double _coverAlpha = 0.45;
const double _coverAlpha = 0.55;
/// 选集格子。需要解锁时铺一层透明底,锁在上,集数在锁下面。
class EpisodePickerCell extends StatelessWidget {
@@ -38,12 +38,19 @@ class EpisodePickerCell extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
if (!episode.locked) Center(child: _EpisodeNumber(episode.number)),
if (!episode.locked)
Center(
child: _EpisodeNumber(
episode.number,
color: selected ? AppColors.onPrimary : AppColors.cream,
),
),
if (episode.locked)
_LockCover(
episodeId: episode.id,
number: episode.number,
label: lockLabel,
selected: selected,
),
],
),
@@ -53,16 +60,17 @@ class EpisodePickerCell extends StatelessWidget {
}
class _EpisodeNumber extends StatelessWidget {
const _EpisodeNumber(this.number);
const _EpisodeNumber(this.number, {required this.color});
final int number;
final Color color;
@override
Widget build(BuildContext context) {
return Text(
'$number',
style: const TextStyle(
color: AppColors.cream,
style: TextStyle(
color: color,
fontSize: _numberSize,
fontWeight: FontWeight.w600,
height: 1,
@@ -77,11 +85,13 @@ class _LockCover extends StatelessWidget {
required this.episodeId,
required this.number,
required this.label,
required this.selected,
});
final String episodeId;
final int number;
final String label;
final bool selected;
@override
Widget build(BuildContext context) {
@@ -90,7 +100,7 @@ class _LockCover extends StatelessWidget {
children: [
ColoredBox(
key: ValueKey('episode-lock-cover-$episodeId'),
color: AppColors.ink.withValues(alpha: _coverAlpha),
color: AppColors.card.withValues(alpha: _coverAlpha),
),
Center(
child: Column(
@@ -102,11 +112,14 @@ class _LockCover extends StatelessWidget {
size: _lockSize,
weight: _lockWeight,
fill: 0,
color: AppColors.gold,
color: AppColors.secondary,
semanticLabel: label,
),
const SizedBox(height: _lockGap),
_EpisodeNumber(number),
_EpisodeNumber(
number,
color: selected ? AppColors.onPrimary : AppColors.cream,
),
],
),
),
@@ -0,0 +1,131 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.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 'episode_picker_cell.dart';
const int _columns = 6;
const double _sheetHeightFactor = 0.52;
const double _cellAspect = 1.05;
const double _sheetRadius = 20;
/// K28 选集面板:六列等宽网格、当前集高亮、锁定集显示锁标。
class EpisodePickerSheet extends StatelessWidget {
/// 创建选集面板。
const EpisodePickerSheet({
super.key,
required this.drama,
required this.currentNumber,
required this.onSelect,
});
/// 当前短剧。
final Drama drama;
/// 当前集号。
final int currentNumber;
/// 点击某集,参数为该集在原始列表中的索引。
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final episodes = drama.episodes;
return Container(
key: const Key('player-episodes-sheet'),
height: MediaQuery.sizeOf(context).height * _sheetHeightFactor,
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)),
),
child: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 8, 4),
child: Row(
children: [
Expanded(
child: Text(
l10n.episodes,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
IconButton(
key: const Key('player-episodes-close'),
tooltip: l10n.close,
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close_rounded),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Row(
children: [
Expanded(
child: Text(
localizedDramaTitle(l10n, drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
Text(
l10n.episodeCountLabel(episodes.length),
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
],
),
),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _columns,
mainAxisSpacing: AppDimens.spaceSm,
crossAxisSpacing: AppDimens.spaceSm,
childAspectRatio: _cellAspect,
),
itemCount: episodes.length,
itemBuilder: (context, index) {
final episode = episodes[index];
return EpisodePickerCell(
key: Key('player-episode-${episode.number}'),
episode: episode,
selected: episode.number == currentNumber,
lockLabel: l10n.locked,
onTap: () {
Navigator.pop(context);
onSelect(index);
},
);
},
),
),
],
),
),
);
}
}
@@ -1,158 +0,0 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/comment.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/repositories/comment_repository.dart';
import '../../../core/data/repositories/interaction_repository.dart';
import '../../../core/data/services/comment_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
class PlayerFeaturedComment extends StatefulWidget {
const PlayerFeaturedComment({
super.key,
required this.drama,
required this.onViewAll,
});
final Drama drama;
final VoidCallback onViewAll;
@override
State<PlayerFeaturedComment> createState() => _PlayerFeaturedCommentState();
}
class _PlayerFeaturedCommentState extends State<PlayerFeaturedComment> {
late final CommentController _controller;
@override
void initState() {
super.initState();
_controller = CommentController(
comments: context.read<CommentRepository>(),
interactions: context.read<InteractionRepository>(),
dramaId: widget.drama.id,
);
unawaited(_controller.load());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
if (_controller.comments.isEmpty) return const SizedBox.shrink();
return _card(context, _controller.comments.first);
},
);
}
Widget _card(BuildContext context, DramaComment comment) {
final l10n = AppLocalizations.of(context)!;
final time = _time(l10n, comment.createdAt);
return InkWell(
key: const Key('player-featured-comment'),
onTap: widget.onViewAll,
borderRadius: BorderRadius.circular(10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 18,
height: 18,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.secondary.withValues(alpha: 0.2),
),
child: Text(
_initial(comment.nickname),
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 8,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 6),
Expanded(
child: Row(
children: [
Flexible(
child: Text(
comment.nickname.isEmpty
? l10n.localViewer
: comment.nickname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
if (time != null) ...[
const SizedBox(width: 4),
Text(
'· $time',
style: const TextStyle(
color: AppColors.muted,
fontSize: 9,
),
),
],
],
),
),
],
),
const SizedBox(height: 5),
Text(
comment.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 10,
height: 1.5,
),
),
],
),
),
);
}
String? _time(AppLocalizations l10n, DateTime? createdAt) {
if (createdAt == null) return null;
final diff = DateTime.now().difference(createdAt);
if (diff.inMinutes < 1) return null;
return l10n.minutesAgo(diff.inMinutes);
}
String _initial(String nickname) {
final trimmed = nickname.trim();
return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase();
}
}
@@ -0,0 +1,228 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import 'player_text_size_sheet.dart';
import 'quality_sheet.dart';
const double _sheetRadius = 20;
/// 更多面板运行状态。
class PlayerMoreState {
/// 创建更多面板状态。
const PlayerMoreState({
required this.speed,
required this.qualities,
required this.textScale,
required this.pipAvailable,
});
/// 当前倍速。
final double speed;
/// 后端下发的清晰度档位。
final List<String> qualities;
/// 当前信息文字缩放比例。
final double textScale;
/// 设备是否提供画中画入口。
final bool pipAvailable;
}
/// 更多面板动作集合。
class PlayerMoreActions {
/// 创建动作集合。
const PlayerMoreActions({
required this.onSpeed,
required this.onQuality,
required this.onDownload,
required this.onTextSize,
required this.onReport,
this.onPip,
});
/// 打开倍速面板。
final VoidCallback onSpeed;
/// 打开清晰度面板。
final VoidCallback onQuality;
/// 下载当前集。
final VoidCallback onDownload;
/// 打开文字大小面板。
final VoidCallback onTextSize;
/// 举报当前剧。
final VoidCallback onReport;
/// 进入画中画。
final VoidCallback? onPip;
}
/// K28 播放页更多操作面板。
class PlayerMoreSheet extends StatelessWidget {
/// 创建更多操作面板。
const PlayerMoreSheet({
super.key,
required this.l10n,
required this.state,
required this.actions,
});
/// 当前语言包。
final AppLocalizations l10n;
/// 面板状态。
final PlayerMoreState state;
/// 面板动作。
final PlayerMoreActions actions;
@override
Widget build(BuildContext context) {
return Container(
key: const Key('player-more-sheet'),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 10),
child: Text(
l10n.more,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
_MoreRow(
key: const Key('player-more-speed'),
icon: Icons.speed_rounded,
title: l10n.playbackSpeed,
value: '${state.speed}x',
action: actions.onSpeed,
),
if (state.qualities.isNotEmpty)
_MoreRow(
key: const Key('player-more-quality'),
icon: Icons.high_quality_rounded,
title: l10n.playbackQuality,
value: state.qualities.contains(QualitySheet.autoQuality)
? l10n.qualityAuto
: state.qualities.first,
action: actions.onQuality,
),
_MoreRow(
key: const Key('player-more-download'),
icon: Icons.download_rounded,
title: l10n.downloads,
value: '',
action: actions.onDownload,
),
_MoreRow(
key: const Key('player-more-text-size'),
icon: Icons.format_size_rounded,
title: l10n.textSize,
value: playerTextSizeLabel(l10n, state.textScale),
action: actions.onTextSize,
),
if (state.pipAvailable && actions.onPip != null)
_MoreRow(
key: const Key('player-more-pip'),
icon: Icons.picture_in_picture_alt_rounded,
title: l10n.pictureInPicture,
value: '',
action: actions.onPip!,
),
_MoreRow(
key: const Key('player-more-report'),
icon: Icons.flag_outlined,
title: l10n.reportDrama,
value: '',
action: actions.onReport,
),
],
),
),
),
);
}
}
class _MoreRow extends StatelessWidget {
const _MoreRow({
super.key,
required this.icon,
required this.title,
required this.value,
required this.action,
});
final IconData icon;
final String title;
final String value;
final VoidCallback action;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
Navigator.pop(context);
WidgetsBinding.instance.addPostFrameCallback((_) => action());
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 13),
child: Row(
children: [
Icon(icon, color: AppColors.muted, size: 21),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
if (value.isNotEmpty)
Text(
value,
style: const TextStyle(
color: AppColors.blue,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 4),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 20,
),
],
),
),
),
);
}
}
@@ -0,0 +1,416 @@
// 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;
}
@@ -0,0 +1,117 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
import 'package:video_player/video_player.dart';
import '../../../core/data/models/drama.dart';
import '../../../l10n/app_localizations.dart';
/// 播放浮层展示数据。
class PlayerOverlayData {
/// 创建播放浮层数据。
const PlayerOverlayData({
required this.l10n,
required this.drama,
required this.episode,
required this.controller,
required this.liked,
required this.likeCount,
required this.saved,
required this.playing,
required this.textScale,
required this.currentIndex,
required this.totalEpisodes,
});
/// 当前语言包。
final AppLocalizations l10n;
/// 当前短剧。
final Drama drama;
/// 当前剧集。
final Episode episode;
/// 当前播放器。
final VideoPlayerController? controller;
/// 是否已点赞。
final bool liked;
/// 点赞数;未知时隐藏。
final int? likeCount;
/// 是否已收藏。
final bool saved;
/// 是否处于播放状态。
final bool playing;
/// 信息文字缩放比例。
final double textScale;
/// 当前集索引。
final int currentIndex;
/// 可见集数量。
final int totalEpisodes;
}
/// 播放浮层动作集合。
class PlayerOverlayActions {
/// 创建动作集合。
const PlayerOverlayActions({
required this.onBack,
required this.onTogglePlay,
required this.onClear,
required this.onExitClear,
required this.onEpisodes,
required this.onComments,
required this.onLike,
required this.onSave,
required this.onShare,
required this.onMore,
required this.onScrubStart,
required this.onSeek,
this.onNext,
});
/// 返回上一页。
final VoidCallback? onBack;
/// 切换播放状态。
final VoidCallback onTogglePlay;
/// 进入清屏。
final VoidCallback onClear;
/// 退出清屏。
final VoidCallback onExitClear;
/// 打开选集。
final VoidCallback onEpisodes;
/// 打开评论。
final VoidCallback onComments;
/// 切换点赞。
final VoidCallback onLike;
/// 切换收藏。
final VoidCallback onSave;
/// 分享。
final VoidCallback onShare;
/// 打开更多面板。
final VoidCallback onMore;
/// 下一集。
final VoidCallback? onNext;
/// 开始拖动进度。
final VoidCallback? onScrubStart;
/// 提交进度跳转。
final Future<void> Function(Duration)? onSeek;
}
@@ -0,0 +1,243 @@
// Author: fruit.com.cn
part of 'player_overlay.dart';
class _TopButton extends StatelessWidget {
const _TopButton({
super.key,
required this.tooltip,
required this.icon,
required this.onPressed,
});
final String tooltip;
final IconData icon;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: _topButtonSize,
child: IconButton(
tooltip: tooltip,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: AppColors.card,
foregroundColor: AppColors.onSurface,
),
onPressed: onPressed,
icon: Icon(icon, size: 18),
),
);
}
}
class _Rail extends StatelessWidget {
const _Rail({
required this.l10n,
required this.liked,
required this.saved,
required this.likeCount,
required this.onLike,
required this.onComments,
required this.onSave,
required this.onShare,
required this.compact,
});
final AppLocalizations l10n;
final bool liked;
final bool saved;
final int? likeCount;
final VoidCallback onLike;
final VoidCallback onComments;
final VoidCallback onSave;
final VoidCallback onShare;
final bool compact;
@override
Widget build(BuildContext context) {
return Column(
key: const Key('player-interaction-rail'),
children: [
_RailButton(
key: const Key('player-rail-like'),
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
color: liked ? AppColors.blueSoft : AppColors.onPrimary,
label: likeCount?.toString() ?? l10n.like,
onTap: onLike,
compact: compact,
),
_RailButton(
key: const Key('player-rail-comments'),
icon: Icons.chat_bubble_outline_rounded,
label: l10n.comments,
onTap: onComments,
compact: compact,
),
_RailButton(
key: const Key('player-rail-save'),
icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
label: l10n.favorite,
onTap: onSave,
compact: compact,
),
_RailButton(
key: const Key('player-rail-share'),
icon: Icons.ios_share_rounded,
label: l10n.share,
onTap: onShare,
compact: compact,
),
],
);
}
}
class _RailButton extends StatelessWidget {
const _RailButton({
super.key,
required this.icon,
required this.label,
required this.onTap,
required this.compact,
this.color = AppColors.onPrimary,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool compact;
final Color color;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 2 : _railGap),
child: Column(
children: [
SizedBox.square(
dimension: _railButtonSize,
child: IconButton(
onPressed: onTap,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: AppColors.overlayDark,
foregroundColor: color,
),
icon: Icon(icon, size: 18),
),
),
SizedBox(height: compact ? 1 : 3),
Text(
label,
maxLines: 1,
style: TextStyle(
color: AppColors.onPrimary,
fontSize: compact ? 8 : 9,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
class _PageDots extends StatelessWidget {
const _PageDots({super.key, required this.current, required this.total});
final int current;
final int total;
@override
Widget build(BuildContext context) {
if (total <= 1) return const SizedBox.shrink();
final start = (current - 2).clamp(0, total > 5 ? total - 5 : 0);
final end = (start + 5).clamp(0, total);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var index = start; index < end; index++)
Container(
width: index == current ? 14 : _dotSize,
height: _dotSize,
margin: const EdgeInsets.only(left: 4),
decoration: BoxDecoration(
color: index == current ? AppColors.blue : Colors.white54,
borderRadius: BorderRadius.circular(_dotRadius),
),
),
],
);
}
}
class _TimeLabel extends StatelessWidget {
const _TimeLabel({super.key, required this.controller});
final VideoPlayerController? controller;
@override
Widget build(BuildContext context) {
final source = controller;
if (source == null) return _label(Duration.zero, Duration.zero);
return ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source,
builder: (context, value, _) => _label(value.position, value.duration),
);
}
Widget _label(Duration position, Duration duration) => Text(
'${_format(position)} / ${_format(duration)}',
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 10,
fontWeight: FontWeight.w600,
),
);
String _format(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
super.key,
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 38,
child: FilledButton.icon(
onPressed: onTap,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
backgroundColor: AppColors.card,
foregroundColor: AppColors.onSurface,
disabledBackgroundColor: Colors.black38,
disabledForegroundColor: Colors.white54,
),
icon: Icon(icon, size: 16),
label: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w700),
),
),
);
}
}
@@ -0,0 +1,75 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const List<double> _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
const double _sheetRadius = 20;
/// 播放倍速选择弹窗。
class PlayerSpeedSheet extends StatelessWidget {
/// 创建倍速选择弹窗。
const PlayerSpeedSheet({super.key, required this.selected});
/// 当前倍速。
final double selected;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Material(
key: const Key('player-speed-sheet'),
color: AppColors.card,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.playbackSpeed,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
),
),
for (final value in _speedOptions) _option(context, value),
const SizedBox(height: 8),
],
),
),
);
}
Widget _option(BuildContext context, double value) {
final active = value == selected;
return ListTile(
key: Key('player-speed-${value.toString()}'),
dense: true,
title: Text(
'${value.toString()}x',
style: TextStyle(
color: active ? AppColors.blue : AppColors.onSurface,
fontSize: 14,
fontWeight: active ? FontWeight.w800 : FontWeight.w500,
),
),
trailing: active
? const Icon(Icons.check_rounded, color: AppColors.blue, size: 20)
: null,
onTap: () => Navigator.pop(context, value),
);
}
}
@@ -0,0 +1,110 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const double playerTextSizeSmall = 0.9;
const double playerTextSizeStandard = 1.0;
const double playerTextSizeLarge = 1.15;
const double _sheetRadius = 20;
/// 返回播放信息文字档位的本地化名称。
String playerTextSizeLabel(AppLocalizations l10n, double scale) {
if (scale <= playerTextSizeSmall) return l10n.textSizeSmall;
if (scale >= playerTextSizeLarge) return l10n.textSizeLarge;
return l10n.textSizeStandard;
}
/// 播放页文字大小选择弹窗。
class PlayerTextSizeSheet extends StatelessWidget {
/// 创建文字大小选择弹窗。
const PlayerTextSizeSheet({super.key, required this.selected});
/// 当前文字缩放比例。
final double selected;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final options = [
(playerTextSizeSmall, 'small', l10n.textSizeSmall),
(playerTextSizeStandard, 'standard', l10n.textSizeStandard),
(playerTextSizeLarge, 'large', l10n.textSizeLarge),
];
return Container(
key: const Key('player-text-size-sheet'),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.textSize,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
for (final option in options) ...[
_option(context, option.$1, option.$2, option.$3),
if (option != options.last) const SizedBox(height: 8),
],
],
),
),
),
);
}
Widget _option(
BuildContext context,
double scale,
String keySuffix,
String label,
) {
final active = (scale - selected).abs() < 0.01;
return Material(
key: Key('player-text-size-$keySuffix'),
color: active ? AppColors.quick : AppColors.raised,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => Navigator.pop(context, scale),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
child: Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
color: active ? AppColors.blue : AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
if (active)
const Icon(
Icons.check_circle_rounded,
color: AppColors.blue,
size: 20,
),
],
),
),
),
);
}
}
@@ -0,0 +1,373 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/models/play_ticket.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const double _cardRadius = 20;
const double _optionRadius = 14;
const double _sheetWidth = 360;
/// 视频区占位提示:由后端票据决定是解锁、无片源还是会员限制。
class PlayerBlockedNotice extends StatelessWidget {
/// 创建不可播放提示。
const PlayerBlockedNotice({
super.key,
required this.l10n,
required this.episode,
required this.accessType,
required this.unlockCoin,
required this.unlocking,
required this.error,
required this.onOpenUnlock,
});
/// 当前语言包。
final AppLocalizations l10n;
/// 当前剧集。
final Episode episode;
/// 后端下发的内容访问类型。
final String? accessType;
/// 金币解锁价格。
final int unlockCoin;
/// 是否正在解锁。
final bool unlocking;
/// 最近一次解锁失败信息。
final String? error;
/// 打开内容解锁面板。
final VoidCallback? onOpenUnlock;
bool get _needsUnlock =>
accessType == PlayTicket.coinRequired ||
accessType == PlayTicket.vipRequired;
@override
Widget build(BuildContext context) {
final title = _needsUnlock
? l10n.lockedTitle(episode.number)
: accessType == PlayTicket.noMedia || !episode.locked
? l10n.noMediaNotice
: l10n.lockedTitle(episode.number);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _sheetWidth),
child: Container(
key: const Key('player-unlock-notice'),
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_cardRadius),
boxShadow: const [
BoxShadow(
color: AppColors.cardShadow,
blurRadius: 18,
offset: Offset(0, 6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_needsUnlock ? Icons.lock_rounded : Icons.movie_filter_outlined,
color: _needsUnlock ? AppColors.blue : AppColors.muted,
size: 30,
),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
if (_needsUnlock && unlockCoin > 0) ...[
const SizedBox(height: 10),
_coinLabel(l10n),
],
if (error != null) ...[
const SizedBox(height: 8),
Text(
errorText(l10n, error, l10n.unlockFailed),
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.badgePinkText,
fontSize: 12,
),
),
],
if (_needsUnlock) ...[
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const Key('player-open-unlock'),
onPressed: unlocking ? null : onOpenUnlock,
child: unlocking
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.unlockEpisode),
),
),
],
],
),
),
),
);
}
Widget _coinLabel(AppLocalizations l10n) => DecoratedBox(
decoration: BoxDecoration(
color: AppColors.goldCard,
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
l10n.walletBalanceShort(unlockCoin),
style: const TextStyle(
color: AppColors.onGold,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
);
}
/// 内容解锁面板:金币可用,广告与会员在契约缺失时明确禁用。
class PlayerUnlockSheet extends StatefulWidget {
/// 创建内容解锁面板。
const PlayerUnlockSheet({
super.key,
required this.l10n,
required this.episodeNumber,
required this.coinCost,
required this.balance,
required this.onUnlock,
});
/// 当前语言包。
final AppLocalizations l10n;
/// 当前集号。
final int episodeNumber;
/// 金币解锁价格。
final int coinCost;
/// 当前金币余额;未知时隐藏。
final int? balance;
/// 金币解锁动作,成功后返回 true。
final Future<bool> Function()? onUnlock;
@override
State<PlayerUnlockSheet> createState() => _PlayerUnlockSheetState();
}
class _PlayerUnlockSheetState extends State<PlayerUnlockSheet> {
bool _busy = false;
String? _error;
Future<void> _unlock() async {
final action = widget.onUnlock;
if (action == null || _busy) return;
setState(() {
_busy = true;
_error = null;
});
final success = await action();
if (!mounted) return;
if (success) {
Navigator.of(context).pop(true);
return;
}
setState(() {
_busy = false;
_error = widget.l10n.unlockFailed;
});
}
@override
Widget build(BuildContext context) {
final l10n = widget.l10n;
return Container(
key: const Key('player-unlock-panel'),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(_cardRadius)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.lockedTitle(widget.episodeNumber),
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
IconButton(
key: const Key('player-unlock-close'),
tooltip: l10n.close,
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close_rounded),
),
],
),
const SizedBox(height: 8),
_UnlockOption(
optionKey: const Key('player-unlock-coins'),
icon: Icons.toll_rounded,
title: l10n.unlockWithCoins,
subtitle: widget.balance == null
? l10n.walletBalanceShort(widget.coinCost)
: '${l10n.walletBalanceShort(widget.coinCost)} · '
'${l10n.walletBalanceShort(widget.balance!)}',
enabled: widget.onUnlock != null && !_busy,
busy: _busy,
onTap: _unlock,
),
const SizedBox(height: 10),
_UnlockOption(
optionKey: const Key('player-unlock-ad'),
icon: Icons.ondemand_video_rounded,
title: l10n.watchAdToUnlock,
subtitle: l10n.comingSoon,
enabled: false,
onTap: null,
),
const SizedBox(height: 10),
_UnlockOption(
optionKey: const Key('player-unlock-vip'),
icon: Icons.workspace_premium_rounded,
title: l10n.unlockWithVip,
subtitle: l10n.comingSoon,
enabled: false,
onTap: null,
),
if (_error != null) ...[
const SizedBox(height: 10),
Text(
_error!,
style: const TextStyle(
color: AppColors.badgePinkText,
fontSize: 12,
),
),
],
const SizedBox(height: 14),
Text(
l10n.unlockMethodsVary,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
],
),
),
),
);
}
}
class _UnlockOption extends StatelessWidget {
const _UnlockOption({
required this.optionKey,
required this.icon,
required this.title,
required this.subtitle,
required this.enabled,
this.busy = false,
this.onTap,
});
final Key optionKey;
final IconData icon;
final String title;
final String subtitle;
final bool enabled;
final bool busy;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
key: optionKey,
color: enabled ? AppColors.quick : AppColors.raised,
borderRadius: BorderRadius.circular(_optionRadius),
child: InkWell(
borderRadius: BorderRadius.circular(_optionRadius),
onTap: enabled ? onTap : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Icon(
icon,
color: enabled ? AppColors.blue : AppColors.subtle,
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
color: enabled ? AppColors.onSurface : AppColors.subtle,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
color: AppColors.muted,
fontSize: 11,
),
),
],
),
),
if (busy)
const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (enabled)
const Icon(Icons.chevron_right_rounded, color: AppColors.blue),
],
),
),
),
);
}
}