feat(flutter): 重设计播放页与操作面板
This commit is contained in:
1 parent
148fbc3c93
commit
9a4269b847
24 files changed
+2395
-1101
No files matched your search
@@ -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