feat(flutter): 重设计播放页与操作面板
This commit is contained in:
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user