feat(player): 新增卡片式播放器

This commit is contained in:
Zhou Weidong committed 2026-10-19 15:16:40 +08:00
1 parent ec163f905d
commit 689063a694
6 files changed
+1229 -4

No files matched your search

+620
View File
@@ -21,10 +21,14 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../core/widgets/glass_panel.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/card_seek_bar.dart';
import 'widgets/home_feed_overlay.dart';
import 'widgets/player_featured_comment.dart';
import 'widgets/seek_progress_bar.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
@@ -114,6 +118,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
Widget _buildPlayer(BuildContext context) {
final interactions = context.watch<InteractionController>();
if (!widget.homeMode && !logic.fullscreen) {
return _buildCardPlayer(context, interactions);
}
final indices = logic.visibleIndices;
final pages = logic.pages;
final content = AnimatedBuilder(
@@ -143,6 +150,533 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
);
}
Widget _buildCardPlayer(
BuildContext context,
InteractionController interactions,
) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Theme(
data: AppTheme.dark(),
child: Scaffold(
backgroundColor: AppColors.ink,
body: SafeArea(
child: AnimatedBuilder(
animation: logic.session,
builder: (context, _) {
final l10n = AppLocalizations.of(context)!;
final index = logic.session.currentIndex;
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final episode = logic.displayEpisode(drama, entry.episode);
final port =
(index == logic.session.currentIndex
? logic.session.active
: null) ??
logic.session.preparedPlayerAt(index);
final videoPort = port is VideoPlayerPort ? port : null;
final ready =
videoPort?.controller.value.isInitialized ?? false;
return Column(
children: [
_cardTopBar(context, l10n, drama, episode),
Expanded(
child: SingleChildScrollView(
key: const Key('card-player-scroll'),
padding: const EdgeInsets.fromLTRB(16, 6, 16, 20),
child: Column(
children: [
AspectRatio(
key: const Key('card-player-video-frame'),
aspectRatio: 9 / 10,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AnimatedBuilder(
animation: logic.session,
builder: (context, _) => PageView.builder(
controller: logic.pages,
scrollDirection: Axis.vertical,
allowImplicitScrolling: true,
itemCount: logic.visibleIndices.length,
onPageChanged: (position) {
final next =
logic.visibleIndices[position];
widget.onDramaChanged?.call(
logic.queue.entryAt(next).drama,
);
unawaited(logic.selectEpisode(next));
},
itemBuilder: (context, position) =>
_buildCardVideo(
context,
logic.visibleIndices[position],
interactions,
),
),
),
),
),
const SizedBox(height: 6),
CardSeekBar(
controller: ready ? videoPort!.controller : null,
semanticsLabel: l10n.seekVideo,
episodeLabel: l10n.episodeLabel(episode.number),
totalEpisodes: drama.episodeCount,
onScrubStart: ready
? () => logic.beginScrub(videoPort!)
: null,
onSeek: ready
? (position) => logic.seekTo(
context,
videoPort!,
position,
)
: null,
),
const SizedBox(height: 10),
_cardActions(context, l10n, drama, episode),
const SizedBox(height: 12),
PlayerFeaturedComment(
drama: drama,
onViewAll: () =>
logic.showComments(context, drama),
),
],
),
),
),
],
);
},
),
),
),
),
);
}
Widget _cardTopBar(
BuildContext context,
AppLocalizations l10n,
Drama drama,
Episode episode,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 6),
child: Row(
children: [
IconButton(
key: const Key('player-back'),
tooltip: l10n.backToSeries,
onPressed: () => unawaited(logic.leave(context)),
style: IconButton.styleFrom(
backgroundColor: AppColors.raised,
foregroundColor: AppColors.onSurface,
),
icon: const Icon(Icons.chevron_left_rounded),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${localizedDramaTitle(l10n, drama)} · '
'${l10n.episodeLabel(episode.number)}',
key: const Key('player-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
l10n.seasonOneOriginalRelease,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.subtle, fontSize: 10),
),
],
),
),
const SizedBox(width: 8),
IconButton(
key: const Key('player-more'),
tooltip: l10n.more,
onPressed: () => unawaited(_showCardMore(context, l10n, episode)),
style: IconButton.styleFrom(
backgroundColor: AppColors.raised,
foregroundColor: AppColors.onSurface,
),
icon: const Icon(Icons.more_horiz_rounded),
),
],
),
);
}
Widget _buildCardVideo(
BuildContext context,
int index,
InteractionController interactions,
) {
final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final episode = logic.displayEpisode(drama, entry.episode);
final selected = index == logic.session.currentIndex;
final port =
(selected ? logic.session.active : null) ??
logic.session.preparedPlayerAt(index);
final videoPort = port is VideoPlayerPort ? port : null;
final ready = videoPort?.controller.value.isInitialized ?? false;
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: AppColors.ink),
if (ready)
GestureDetector(
onTap: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
child: FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width: videoPort!.controller.value.size.width,
height: videoPort.controller.value.size.height,
child: VideoPlayer(
videoPort.controller,
key: ValueKey(videoPort.controller),
),
),
),
)
else
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
const IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x66000000),
Color(0x11000000),
Color(0x99000000),
],
stops: [0, 0.46, 1],
),
),
),
),
Positioned(
top: 10,
left: 10,
right: 10,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GlassPanel(
radius: 999,
blur: 12,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.skip_next_rounded,
size: 12,
color: AppColors.secondary,
),
const SizedBox(width: 3),
Text(
l10n.autoNextOn,
style: const TextStyle(
color: AppColors.secondary,
fontSize: 9.5,
fontWeight: FontWeight.w600,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Colors.white.withValues(alpha: 0.18),
),
),
child: const Text(
'1.0x',
style: TextStyle(
color: AppColors.cream,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
if (selected && logic.ticketLoading)
const Center(child: CircularProgressIndicator(color: AppColors.blue)),
if (selected && !logic.ticketLoading && logic.ticketError != null)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.wifi_off_rounded,
color: AppColors.muted,
size: 30,
),
const SizedBox(height: 8),
Text(
errorText(l10n, logic.ticketError, l10n.playbackError),
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.cream),
),
const SizedBox(height: 8),
FilledButton(
onPressed: () => unawaited(logic.retryTicket()),
child: Text(l10n.retry),
),
],
),
),
),
if (selected && logic.session.loading)
const Center(child: CircularProgressIndicator(color: AppColors.blue)),
if (selected && !ready && logic.session.error != null)
Center(
child: Text(
l10n.playbackError,
style: const TextStyle(color: AppColors.cream),
),
),
if (selected && !ready && logic.blockedEpisodeId == episode.id)
_BlockedNotice(
l10n: l10n,
episode: episode,
accessType: logic.blockedTicket?.accessType,
unlockCoin: logic.blockedTicket?.unlockCoin ?? 0,
),
if (selected &&
!ready &&
logic.blockedEpisodeId != episode.id &&
logic.session.error == null &&
logic.session.isLocked)
Center(
child: Text(
episode.locked
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.cream),
),
),
if (selected && ready && !logic.session.userWantsPlay)
Center(
child: GestureDetector(
key: const Key('card-player-play'),
onTap: () => logic.session.setUserWantsPlay(true),
child: Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: AppColors.blue.withValues(alpha: 0.92),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 2,
),
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 32,
),
),
),
),
Positioned(
right: 10,
bottom: 12,
child: Column(
children: [
_CardIconButton(
key: const Key('card-player-like'),
tooltip: l10n.like,
icon: interactions.isLiked(drama.id)
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
color: interactions.isLiked(drama.id)
? AppColors.blue
: AppColors.onSurface,
onPressed: () =>
unawaited(_toggleLike(context, drama, interactions)),
),
const SizedBox(height: 8),
_CardIconButton(
key: const Key('card-player-fullscreen'),
tooltip: l10n.enterFullscreen,
icon: Icons.fullscreen_rounded,
onPressed: () => unawaited(logic.setFullscreen(true)),
),
],
),
),
],
);
}
Widget _cardActions(
BuildContext context,
AppLocalizations l10n,
Drama drama,
Episode episode,
) {
return Row(
children: [
Expanded(
child: _CardActionButton(
key: const Key('card-player-episodes'),
icon: Icons.view_list_rounded,
label: l10n.episodes,
onPressed: () => unawaited(logic.showEpisodes(context)),
),
),
const SizedBox(width: 8),
Expanded(
child: _CardActionButton(
key: const Key('card-player-comments'),
icon: Icons.chat_bubble_outline_rounded,
label: l10n.comments,
onPressed: () => unawaited(logic.showComments(context, drama)),
),
),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
height: 42,
child: FilledButton(
key: const Key('card-player-next'),
onPressed: logic.hasNextEpisode
? () => unawaited(logic.playNextEpisode())
: null,
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
disabledBackgroundColor: AppColors.raised,
disabledForegroundColor: AppColors.subtle,
foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 4),
textStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
l10n.nextEpisode,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 2),
const Icon(Icons.skip_next_rounded, size: 15),
],
),
),
),
),
],
);
}
Future<void> _showCardMore(
BuildContext context,
AppLocalizations l10n,
Episode episode,
) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.card,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: const Key('player-download'),
leading: const Icon(
Icons.download_outlined,
color: AppColors.secondary,
),
title: Text(l10n.downloads),
onTap: () {
Navigator.pop(sheetContext);
requestEpisodeDownload(context, episode.id);
},
),
if (logic.pictureInPicture.isPlatformSupported)
ListTile(
key: const Key('player-pip'),
leading: const Icon(
Icons.picture_in_picture_alt_outlined,
color: AppColors.secondary,
),
title: Text(l10n.pictureInPicture),
onTap: () {
Navigator.pop(sheetContext);
unawaited(logic.enterPip(context));
},
),
],
),
),
);
}
Future<void> _toggleLike(
BuildContext context,
Drama drama,
InteractionController interactions,
) async {
if (!await ensureSignedIn(context)) return;
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleDramaLike(drama.id);
if (!result.isSuccess && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.errorMessage ?? AppLocalizations.of(context)!.likeFailed,
),
),
);
}
}
Widget _buildEpisode(
BuildContext context,
int index,
@@ -561,6 +1095,92 @@ class _BlockedNotice extends StatelessWidget {
}
}
class _CardIconButton extends StatelessWidget {
const _CardIconButton({
super.key,
required this.tooltip,
required this.icon,
required this.onPressed,
this.color = AppColors.onSurface,
});
final String tooltip;
final IconData icon;
final VoidCallback onPressed;
final Color color;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: 0.55),
foregroundColor: color,
side: BorderSide(color: Colors.white.withValues(alpha: 0.15)),
),
icon: Icon(icon, size: 18),
),
);
}
}
class _CardActionButton extends StatelessWidget {
const _CardActionButton({
super.key,
required this.icon,
required this.label,
required this.onPressed,
});
final IconData icon;
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.raised,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 15, color: AppColors.secondary),
const SizedBox(width: 4),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
}
/// Coin lock presented as a reward card: gold chip, chili only on the icon.
class _UnlockCard extends StatelessWidget {
const _UnlockCard({