feat(flutter): 重构播放页并增加语言切换
This commit is contained in:
1 parent
f1efbc5be8
commit
8c43e9e385
34 files changed
+1331
-879
No files matched your search
@@ -4,8 +4,10 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/comment.dart';
|
||||
import '../../../core/data/services/comment_controller.dart';
|
||||
import '../../../core/formatting/localized_date_time.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const String _deleteAction = 'delete';
|
||||
@@ -53,15 +55,7 @@ class CommentTile extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: isReply ? 12 : 16,
|
||||
backgroundColor: AppColors.raised,
|
||||
child: Icon(
|
||||
Icons.person_rounded,
|
||||
size: isReply ? 14 : 18,
|
||||
color: AppColors.cream,
|
||||
),
|
||||
),
|
||||
_avatar(),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -78,6 +72,15 @@ class CommentTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (comment.createdAt != null)
|
||||
Text(
|
||||
formatLocalizedDateTime(context, comment.createdAt!),
|
||||
style: const TextStyle(
|
||||
color: AppColors.subtle,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
PopupMenuButton<String>(
|
||||
key: Key('comment-menu-${comment.id}'),
|
||||
tooltip: l10n.more,
|
||||
@@ -148,4 +151,26 @@ class CommentTile extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _avatar() {
|
||||
final size = isReply ? 24.0 : 32.0;
|
||||
if (comment.avatarUrl.isEmpty) {
|
||||
return CircleAvatar(
|
||||
radius: size / 2,
|
||||
backgroundColor: AppColors.raised,
|
||||
child: Icon(
|
||||
Icons.person_rounded,
|
||||
size: isReply ? 14 : 18,
|
||||
color: AppColors.cream,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ClipOval(
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: RemoteImage(comment.avatarUrl, fit: BoxFit.cover),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ 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';
|
||||
import 'player_text_scale.dart';
|
||||
|
||||
const double _sheetRadius = 20;
|
||||
const List<double> _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0];
|
||||
const List<String> _qualityOptions = ['AUTO', '720P', '1080P'];
|
||||
const String _autoQuality = 'AUTO';
|
||||
|
||||
/// 更多面板运行状态。
|
||||
class PlayerMoreState {
|
||||
@@ -16,7 +18,6 @@ class PlayerMoreState {
|
||||
required this.speed,
|
||||
required this.qualities,
|
||||
required this.textScale,
|
||||
required this.pipAvailable,
|
||||
});
|
||||
|
||||
/// 当前倍速。
|
||||
@@ -25,47 +26,40 @@ class PlayerMoreState {
|
||||
/// 后端下发的清晰度档位。
|
||||
final List<String> qualities;
|
||||
|
||||
/// 当前信息文字缩放比例。
|
||||
/// 当前文字缩放比例。
|
||||
final double textScale;
|
||||
|
||||
/// 设备是否提供画中画入口。
|
||||
final bool pipAvailable;
|
||||
}
|
||||
|
||||
/// 更多面板动作集合。
|
||||
class PlayerMoreActions {
|
||||
/// 创建动作集合。
|
||||
const PlayerMoreActions({
|
||||
required this.onSpeed,
|
||||
required this.onQuality,
|
||||
required this.onSpeedChanged,
|
||||
required this.onTextScaleChanged,
|
||||
required this.onClear,
|
||||
required this.onDownload,
|
||||
required this.onTextSize,
|
||||
required this.onReport,
|
||||
this.onPip,
|
||||
});
|
||||
|
||||
/// 打开倍速面板。
|
||||
final VoidCallback onSpeed;
|
||||
/// 倍速变化。
|
||||
final ValueChanged<double> onSpeedChanged;
|
||||
|
||||
/// 打开清晰度面板。
|
||||
final VoidCallback onQuality;
|
||||
/// 字体大小变化。
|
||||
final ValueChanged<double> onTextScaleChanged;
|
||||
|
||||
/// 下载当前集。
|
||||
/// 进入清屏播放。
|
||||
final VoidCallback onClear;
|
||||
|
||||
/// 离线缓存当前集。
|
||||
final VoidCallback onDownload;
|
||||
|
||||
/// 打开文字大小面板。
|
||||
final VoidCallback onTextSize;
|
||||
|
||||
/// 举报当前剧。
|
||||
/// 投诉当前剧。
|
||||
final VoidCallback onReport;
|
||||
|
||||
/// 进入画中画。
|
||||
final VoidCallback? onPip;
|
||||
}
|
||||
|
||||
/// K28 播放页更多操作面板。
|
||||
class PlayerMoreSheet extends StatelessWidget {
|
||||
/// 创建更多操作面板。
|
||||
/// K29 播放页白色更多面板。
|
||||
class PlayerMoreSheet extends StatefulWidget {
|
||||
/// 创建更多面板。
|
||||
const PlayerMoreSheet({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
@@ -82,78 +76,82 @@ class PlayerMoreSheet extends StatelessWidget {
|
||||
/// 面板动作。
|
||||
final PlayerMoreActions actions;
|
||||
|
||||
@override
|
||||
State<PlayerMoreSheet> createState() => _PlayerMoreSheetState();
|
||||
}
|
||||
|
||||
class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
|
||||
late double _speed = widget.state.speed;
|
||||
late double _textScale = widget.state.textScale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = widget.l10n;
|
||||
return Container(
|
||||
key: const Key('player-more-sheet'),
|
||||
constraints: const BoxConstraints(maxHeight: 620),
|
||||
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: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 18),
|
||||
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,
|
||||
),
|
||||
),
|
||||
_header(l10n),
|
||||
_sectionTitle(l10n.playbackSpeed),
|
||||
_speedPicker(),
|
||||
const SizedBox(height: 16),
|
||||
_sectionTitle(l10n.playbackQuality),
|
||||
_qualityPicker(l10n),
|
||||
const SizedBox(height: 12),
|
||||
_disabledRow(
|
||||
key: const Key('player-more-not-interested'),
|
||||
icon: Icons.thumb_down_alt_outlined,
|
||||
title: l10n.notInterested,
|
||||
),
|
||||
_MoreRow(
|
||||
key: const Key('player-more-speed'),
|
||||
icon: Icons.speed_rounded,
|
||||
title: l10n.playbackSpeed,
|
||||
value: '${state.speed}x',
|
||||
action: actions.onSpeed,
|
||||
_enabledRow(
|
||||
key: const Key('player-more-clear'),
|
||||
icon: Icons.fullscreen_rounded,
|
||||
title: l10n.clearPlayback,
|
||||
onTap: _clear,
|
||||
),
|
||||
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'),
|
||||
_disabledSwitchRow(
|
||||
key: const Key('player-more-danmaku'),
|
||||
icon: Icons.subtitles_rounded,
|
||||
title: l10n.danmaku,
|
||||
unavailable: l10n.danmakuUnavailable,
|
||||
),
|
||||
_disabledRow(
|
||||
key: const Key('player-more-danmaku-settings'),
|
||||
icon: Icons.tune_rounded,
|
||||
title: l10n.danmakuSettings,
|
||||
),
|
||||
_enabledRow(
|
||||
key: const Key('player-more-offline'),
|
||||
icon: Icons.download_rounded,
|
||||
title: l10n.downloads,
|
||||
value: '',
|
||||
action: actions.onDownload,
|
||||
title: l10n.offlineCache,
|
||||
onTap: () => _closeThen(widget.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,
|
||||
_textSizeRow(l10n),
|
||||
_disabledRow(
|
||||
key: const Key('player-more-cast'),
|
||||
icon: Icons.cast_rounded,
|
||||
title: l10n.screenCast,
|
||||
),
|
||||
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(
|
||||
_disabledRow(
|
||||
key: const Key('player-more-timer'),
|
||||
icon: Icons.timer_outlined,
|
||||
title: l10n.sleepTimer,
|
||||
),
|
||||
_enabledRow(
|
||||
key: const Key('player-more-report'),
|
||||
icon: Icons.flag_outlined,
|
||||
title: l10n.reportDrama,
|
||||
value: '',
|
||||
action: actions.onReport,
|
||||
onTap: () => _closeThen(widget.actions.onReport),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -161,6 +159,177 @@ class PlayerMoreSheet extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(AppLocalizations l10n) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.more,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('player-more-close'),
|
||||
tooltip: l10n.close,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _sectionTitle(String title) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _speedPicker() => Wrap(
|
||||
key: const Key('player-more-speed'),
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final value in _speedOptions)
|
||||
ChoiceChip(
|
||||
key: Key('player-more-speed-$value'),
|
||||
label: Text('${value}x'),
|
||||
selected: (_speed - value).abs() < 0.001,
|
||||
showCheckmark: false,
|
||||
onSelected: (_) {
|
||||
setState(() => _speed = value);
|
||||
widget.actions.onSpeedChanged(value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _qualityPicker(AppLocalizations l10n) => Wrap(
|
||||
key: const Key('player-more-quality'),
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final quality in _qualityOptions) _qualityChip(l10n, quality),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _qualityChip(AppLocalizations l10n, String quality) {
|
||||
final enabled = widget.state.qualities.contains(quality);
|
||||
final selected =
|
||||
quality == _autoQuality && enabled ||
|
||||
!widget.state.qualities.contains(_autoQuality) &&
|
||||
widget.state.qualities.isNotEmpty &&
|
||||
widget.state.qualities.first == quality;
|
||||
return ChoiceChip(
|
||||
key: Key('player-more-quality-$quality'),
|
||||
label: Text(enabled ? quality : '$quality · ${l10n.qualityUnavailable}'),
|
||||
selected: selected,
|
||||
showCheckmark: false,
|
||||
onSelected: enabled ? (_) {} : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _textSizeRow(AppLocalizations l10n) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
key: const Key('player-more-text-size'),
|
||||
children: [
|
||||
const Icon(Icons.format_size_rounded, color: AppColors.muted, size: 21),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.textSize,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<double>(
|
||||
tooltip: l10n.textSize,
|
||||
initialValue: _textScale,
|
||||
onSelected: (value) {
|
||||
setState(() => _textScale = value);
|
||||
widget.actions.onTextScaleChanged(value);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
key: const Key('player-text-size-small'),
|
||||
value: playerTextSizeSmall,
|
||||
child: Text(l10n.textSizeSmall),
|
||||
),
|
||||
PopupMenuItem(
|
||||
key: const Key('player-text-size-standard'),
|
||||
value: playerTextSizeStandard,
|
||||
child: Text(l10n.textSizeStandard),
|
||||
),
|
||||
PopupMenuItem(
|
||||
key: const Key('player-text-size-large'),
|
||||
value: playerTextSizeLarge,
|
||||
child: Text(l10n.textSizeLarge),
|
||||
),
|
||||
],
|
||||
child: Text(
|
||||
playerTextSizeLabel(l10n, _textScale),
|
||||
style: const TextStyle(
|
||||
color: AppColors.blue,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _enabledRow({
|
||||
required Key key,
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required VoidCallback onTap,
|
||||
}) =>
|
||||
_MoreRow(key: key, icon: icon, title: title, enabled: true, onTap: onTap);
|
||||
|
||||
Widget _disabledRow({
|
||||
required Key key,
|
||||
required IconData icon,
|
||||
required String title,
|
||||
}) => _MoreRow(key: key, icon: icon, title: title, enabled: false);
|
||||
|
||||
Widget _disabledSwitchRow({
|
||||
required Key key,
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String unavailable,
|
||||
}) => _MoreRow(
|
||||
key: key,
|
||||
icon: icon,
|
||||
title: title,
|
||||
enabled: false,
|
||||
value: unavailable,
|
||||
trailing: const Switch(value: false, onChanged: null),
|
||||
);
|
||||
|
||||
void _clear() {
|
||||
Navigator.pop(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => widget.actions.onClear(),
|
||||
);
|
||||
}
|
||||
|
||||
void _closeThen(VoidCallback action) {
|
||||
Navigator.pop(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => action());
|
||||
}
|
||||
}
|
||||
|
||||
class _MoreRow extends StatelessWidget {
|
||||
@@ -168,57 +337,50 @@ class _MoreRow extends StatelessWidget {
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.action,
|
||||
required this.enabled,
|
||||
this.value = '',
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final bool enabled;
|
||||
final String value;
|
||||
final VoidCallback action;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = enabled ? AppColors.onSurface : AppColors.subtle;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => action());
|
||||
},
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 13),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: AppColors.muted, size: 21),
|
||||
Icon(icon, color: color, size: 21),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (value.isNotEmpty)
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: AppColors.blue,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style: const TextStyle(color: AppColors.subtle, fontSize: 11),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.subtle,
|
||||
size: 20,
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -14,19 +14,14 @@ import 'seek_progress_bar.dart';
|
||||
|
||||
part 'player_overlay_parts.dart';
|
||||
|
||||
const double _topGradientHeight = 230;
|
||||
const double _bottomGradientHeight = 390;
|
||||
const double _topBarHeight = 58;
|
||||
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;
|
||||
const double _fullScreenPillBottom = 246;
|
||||
const double _panelRadius = 16;
|
||||
|
||||
/// K28 竖屏播放页浮层。
|
||||
/// K29 竖屏播放页浮层。
|
||||
class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
/// 创建播放浮层。
|
||||
const PlayerPlaybackOverlay({
|
||||
@@ -61,6 +56,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
_top(context),
|
||||
_bottom(context),
|
||||
_rail(context),
|
||||
_fullScreenPill(),
|
||||
],
|
||||
if (!controlsVisible)
|
||||
Positioned(
|
||||
@@ -86,18 +82,14 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
}
|
||||
|
||||
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),
|
||||
height: _topBarHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
@@ -105,89 +97,42 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
colors: [Color(0xCC000000), Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
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,
|
||||
if (actions.onBack != null)
|
||||
_TopButton(
|
||||
key: const Key('player-back'),
|
||||
tooltip: data.l10n.backToSeries,
|
||||
icon: Icons.chevron_left_rounded,
|
||||
onPressed: actions.onBack!,
|
||||
)
|
||||
else
|
||||
const SizedBox(width: _topButtonSize),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.l10n.episodeLabel(data.episode.number),
|
||||
key: const Key('player-episode-heading'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
_SpeedEntry(
|
||||
key: const Key('player-speed-entry'),
|
||||
speed: data.playbackSpeed,
|
||||
onTap: actions.onSpeed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_TopButton(
|
||||
key: const Key('player-more'),
|
||||
tooltip: data.l10n.more,
|
||||
icon: Icons.more_vert_rounded,
|
||||
onPressed: actions.onMore,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -196,18 +141,17 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
}
|
||||
|
||||
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),
|
||||
top: _topBarHeight + (compact ? 18 : 54),
|
||||
child: _Rail(
|
||||
l10n: data.l10n,
|
||||
liked: data.liked,
|
||||
saved: data.saved,
|
||||
likeCount: likeCount,
|
||||
likeCount: data.likeCount,
|
||||
favoriteCount: data.favoriteCount,
|
||||
commentCount: data.commentCount,
|
||||
onLike: actions.onLike,
|
||||
onComments: actions.onComments,
|
||||
onSave: actions.onSave,
|
||||
@@ -224,193 +168,185 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
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),
|
||||
],
|
||||
child: Container(
|
||||
key: const Key('player-controls-bottom'),
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xED000000),
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(_panelRadius),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_detailRow(series),
|
||||
const SizedBox(height: 6),
|
||||
_EpisodeSynopsis(
|
||||
key: const Key('player-episode-synopsis'),
|
||||
l10n: data.l10n,
|
||||
episodeNumber: data.episode.number,
|
||||
synopsis: synopsis,
|
||||
textScale: data.textScale,
|
||||
),
|
||||
if (data.authorNote.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
data.authorNote,
|
||||
key: const Key('player-author-note'),
|
||||
style: const TextStyle(color: Colors.white60, fontSize: 11),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
_PlayerTimeLabel(
|
||||
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,
|
||||
),
|
||||
_bottomActions(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailRow(String series) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
key: const Key('player-open-detail'),
|
||||
onTap: actions.onDetail,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
series,
|
||||
key: const Key('player-drama-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontFamily: 'Outfit',
|
||||
fontSize: 19 * data.textScale,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.white70,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomActions() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
key: const Key('player-episodes'),
|
||||
onTap: actions.onEpisodes,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${data.l10n.episodes} · '
|
||||
'${data.l10n.allEpisodesBadge(data.totalEpisodes)} · '
|
||||
'${data.l10n.freeWatch}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_up_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: _bottomGradientHeight),
|
||||
),
|
||||
if (actions.onPip != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
_TopButton(
|
||||
key: const Key('player-pip'),
|
||||
tooltip: data.l10n.pictureInPicture,
|
||||
icon: Icons.picture_in_picture_alt_rounded,
|
||||
onPressed: actions.onPip!,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fullScreenPill() {
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _fullScreenPillBottom,
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: const Color(0x99000000),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: InkWell(
|
||||
key: const Key('player-fullscreen-toggle'),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: actions.onClear,
|
||||
child: Padding(
|
||||
key: const Key('player-controls-bottom'),
|
||||
padding: const EdgeInsets.fromLTRB(14, 48, 14, 10),
|
||||
child: Column(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||
child: Row(
|
||||
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 Icon(
|
||||
Icons.fullscreen_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 16,
|
||||
),
|
||||
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),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
synopsis,
|
||||
key: const Key('player-synopsis'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12 * data.textScale,
|
||||
height: 1.45,
|
||||
data.l10n.fullScreenWatch,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
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;
|
||||
}
|
||||
@@ -16,11 +16,14 @@ class PlayerOverlayData {
|
||||
required this.controller,
|
||||
required this.liked,
|
||||
required this.likeCount,
|
||||
required this.commentCount,
|
||||
required this.favoriteCount,
|
||||
required this.saved,
|
||||
required this.playing,
|
||||
required this.playbackSpeed,
|
||||
required this.textScale,
|
||||
required this.currentIndex,
|
||||
required this.totalEpisodes,
|
||||
this.authorNote = '',
|
||||
});
|
||||
|
||||
/// 当前语言包。
|
||||
@@ -41,20 +44,29 @@ class PlayerOverlayData {
|
||||
/// 点赞数;未知时隐藏。
|
||||
final int? likeCount;
|
||||
|
||||
/// 评论数;未知时隐藏。
|
||||
final int? commentCount;
|
||||
|
||||
/// 收藏数;未知时隐藏。
|
||||
final int? favoriteCount;
|
||||
|
||||
/// 是否已收藏。
|
||||
final bool saved;
|
||||
|
||||
/// 是否处于播放状态。
|
||||
final bool playing;
|
||||
|
||||
/// 当前倍速。
|
||||
final double playbackSpeed;
|
||||
|
||||
/// 信息文字缩放比例。
|
||||
final double textScale;
|
||||
|
||||
/// 当前集索引。
|
||||
final int currentIndex;
|
||||
|
||||
/// 可见集数量。
|
||||
final int totalEpisodes;
|
||||
|
||||
/// 作者声明;没有后端字段时为空。
|
||||
final String authorNote;
|
||||
}
|
||||
|
||||
/// 播放浮层动作集合。
|
||||
@@ -65,6 +77,8 @@ class PlayerOverlayActions {
|
||||
required this.onTogglePlay,
|
||||
required this.onClear,
|
||||
required this.onExitClear,
|
||||
required this.onDetail,
|
||||
required this.onSpeed,
|
||||
required this.onEpisodes,
|
||||
required this.onComments,
|
||||
required this.onLike,
|
||||
@@ -73,7 +87,7 @@ class PlayerOverlayActions {
|
||||
required this.onMore,
|
||||
required this.onScrubStart,
|
||||
required this.onSeek,
|
||||
this.onNext,
|
||||
this.onPip,
|
||||
});
|
||||
|
||||
/// 返回上一页。
|
||||
@@ -88,6 +102,12 @@ class PlayerOverlayActions {
|
||||
/// 退出清屏。
|
||||
final VoidCallback onExitClear;
|
||||
|
||||
/// 打开详情。
|
||||
final VoidCallback onDetail;
|
||||
|
||||
/// 打开顶部倍速入口。
|
||||
final VoidCallback onSpeed;
|
||||
|
||||
/// 打开选集。
|
||||
final VoidCallback onEpisodes;
|
||||
|
||||
@@ -106,12 +126,12 @@ class PlayerOverlayActions {
|
||||
/// 打开更多面板。
|
||||
final VoidCallback onMore;
|
||||
|
||||
/// 下一集。
|
||||
final VoidCallback? onNext;
|
||||
|
||||
/// 开始拖动进度。
|
||||
final VoidCallback? onScrubStart;
|
||||
|
||||
/// 提交进度跳转。
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
|
||||
/// 进入小窗播放。
|
||||
final VoidCallback? onPip;
|
||||
}
|
||||
@@ -22,8 +22,8 @@ class _TopButton extends StatelessWidget {
|
||||
tooltip: tooltip,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppColors.card,
|
||||
foregroundColor: AppColors.onSurface,
|
||||
backgroundColor: const Color(0x80000000),
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 18),
|
||||
@@ -32,12 +32,55 @@ class _TopButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SpeedEntry extends StatelessWidget {
|
||||
const _SpeedEntry({super.key, required this.speed, required this.onTap});
|
||||
|
||||
final double speed;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: const Color(0x80000000),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.speed_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 15,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${speed}x',
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Rail extends StatelessWidget {
|
||||
const _Rail({
|
||||
required this.l10n,
|
||||
required this.liked,
|
||||
required this.saved,
|
||||
required this.likeCount,
|
||||
required this.favoriteCount,
|
||||
required this.commentCount,
|
||||
required this.onLike,
|
||||
required this.onComments,
|
||||
required this.onSave,
|
||||
@@ -49,6 +92,8 @@ class _Rail extends StatelessWidget {
|
||||
final bool liked;
|
||||
final bool saved;
|
||||
final int? likeCount;
|
||||
final int? favoriteCount;
|
||||
final int? commentCount;
|
||||
final VoidCallback onLike;
|
||||
final VoidCallback onComments;
|
||||
final VoidCallback onSave;
|
||||
@@ -61,25 +106,26 @@ class _Rail extends StatelessWidget {
|
||||
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,
|
||||
key: const Key('player-rail-save'),
|
||||
icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||
label: favoriteCount?.toString() ?? l10n.favorite,
|
||||
active: saved,
|
||||
onTap: onSave,
|
||||
compact: compact,
|
||||
),
|
||||
_RailButton(
|
||||
key: const Key('player-rail-comments'),
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
label: l10n.comments,
|
||||
label: commentCount?.toString() ?? 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,
|
||||
key: const Key('player-rail-like'),
|
||||
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
label: likeCount?.toString() ?? l10n.like,
|
||||
active: liked,
|
||||
onTap: onLike,
|
||||
compact: compact,
|
||||
),
|
||||
_RailButton(
|
||||
@@ -101,19 +147,19 @@ class _RailButton extends StatelessWidget {
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
required this.compact,
|
||||
this.color = AppColors.onPrimary,
|
||||
this.active = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool compact;
|
||||
final Color color;
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: compact ? 2 : _railGap),
|
||||
padding: EdgeInsets.only(bottom: compact ? 3 : _railGap),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
@@ -122,8 +168,8 @@ class _RailButton extends StatelessWidget {
|
||||
onPressed: onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppColors.overlayDark,
|
||||
foregroundColor: color,
|
||||
backgroundColor: const Color(0x80000000),
|
||||
foregroundColor: active ? AppColors.blue : AppColors.onPrimary,
|
||||
),
|
||||
icon: Icon(icon, size: 18),
|
||||
),
|
||||
@@ -144,28 +190,78 @@ class _RailButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PageDots extends StatelessWidget {
|
||||
const _PageDots({super.key, required this.current, required this.total});
|
||||
class _EpisodeSynopsis extends StatefulWidget {
|
||||
const _EpisodeSynopsis({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.episodeNumber,
|
||||
required this.synopsis,
|
||||
required this.textScale,
|
||||
});
|
||||
|
||||
final int current;
|
||||
final int total;
|
||||
final AppLocalizations l10n;
|
||||
final int episodeNumber;
|
||||
final String synopsis;
|
||||
final double textScale;
|
||||
|
||||
@override
|
||||
State<_EpisodeSynopsis> createState() => _EpisodeSynopsisState();
|
||||
}
|
||||
|
||||
class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
bool _expanded = false;
|
||||
|
||||
@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,
|
||||
final canExpand = widget.synopsis.trim().length > 28;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.l10n.episodeLabel(widget.episodeNumber),
|
||||
key: const Key('player-episode-label'),
|
||||
style: TextStyle(
|
||||
color: AppColors.blue,
|
||||
fontSize: 11 * widget.textScale,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Text('|', style: TextStyle(color: Colors.white38)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.synopsis,
|
||||
key: const Key('player-synopsis'),
|
||||
maxLines: _expanded ? 4 : 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 11 * widget.textScale,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (canExpand)
|
||||
GestureDetector(
|
||||
key: const Key('player-synopsis-toggle'),
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
_expanded ? widget.l10n.showLess : widget.l10n.showMore,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -173,8 +269,8 @@ class _PageDots extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TimeLabel extends StatelessWidget {
|
||||
const _TimeLabel({super.key, required this.controller});
|
||||
class _PlayerTimeLabel extends StatelessWidget {
|
||||
const _PlayerTimeLabel({super.key, required this.controller});
|
||||
|
||||
final VideoPlayerController? controller;
|
||||
|
||||
@@ -188,13 +284,15 @@ class _TimeLabel extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
Widget _label(Duration position, Duration duration) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
'${_format(position)} / ${_format(duration)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white60,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -204,40 +302,3 @@ class _TimeLabel extends StatelessWidget {
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ 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 List<double> _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0];
|
||||
const double _sheetRadius = 20;
|
||||
|
||||
/// 播放倍速选择弹窗。
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 播放信息小号文字比例。
|
||||
const double playerTextSizeSmall = 0.9;
|
||||
|
||||
/// 播放信息标准文字比例。
|
||||
const double playerTextSizeStandard = 1.0;
|
||||
|
||||
/// 播放信息大号文字比例。
|
||||
const double playerTextSizeLarge = 1.15;
|
||||
|
||||
/// 返回播放信息文字档位的本地化名称。
|
||||
String playerTextSizeLabel(AppLocalizations l10n, double scale) {
|
||||
if (scale <= playerTextSizeSmall) return l10n.textSizeSmall;
|
||||
if (scale >= playerTextSizeLarge) return l10n.textSizeLarge;
|
||||
return l10n.textSizeStandard;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 清晰度档位面板:展示后端下发档位;HLS 多码率由播放器自动切换。
|
||||
class QualitySheet extends StatelessWidget {
|
||||
const QualitySheet({
|
||||
super.key,
|
||||
required this.qualities,
|
||||
this.selected = autoQuality,
|
||||
});
|
||||
|
||||
static const String autoQuality = 'AUTO';
|
||||
|
||||
final List<String> qualities;
|
||||
final String selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
key: const Key('player-quality-sheet'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_header(l10n),
|
||||
for (final quality in qualities) _qualityTile(l10n, quality),
|
||||
_hint(l10n),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 2, 20, 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
l10n.playbackQuality,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _qualityTile(AppLocalizations l10n, String quality) {
|
||||
final active = quality == selected;
|
||||
return ListTile(
|
||||
key: Key('player-quality-$quality'),
|
||||
dense: true,
|
||||
title: Text(
|
||||
_label(l10n, quality),
|
||||
style: TextStyle(
|
||||
color: active ? AppColors.blue : AppColors.cream,
|
||||
fontSize: 13,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
trailing: active
|
||||
? const Icon(Icons.check_rounded, color: AppColors.blue, size: 18)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _hint(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 6, 20, 14),
|
||||
child: Text(
|
||||
l10n.qualityAutoHint,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 11),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _label(AppLocalizations l10n, String quality) =>
|
||||
quality == autoQuality ? l10n.qualityAuto : quality;
|
||||
}
|
||||
Reference in new issue
Block a user