Files
ReelShort/lib/pages/player/player_view.dart
T
Vicky d7eff4ca59 feat(client): 对接详情/播放/进度续看/收藏(K1 看剧闭环)+ 三态 UI
后端接口从 6 个扩到 13 个,全部走真数据:

- 新增 api:playback_api(/app/episodes/{id}/play)、watch_api(/app/watch/*)、
  library_api(/app/favorites*);票据从 CatalogApi 搬到 PlaybackApi
- 新增 repository:PlaybackRepository(含票据 TTL 缓存,默认 10 分钟,时钟可注入)、
  FavoriteRepository、WatchHistoryRepository 换成服务端模型(每剧一条续看记录,删掉 demoProfileId)
- PlayerLogic:起播前取票(拿不到给三态 UI,失败可重试);accessType 驱动锁定文案,
  不在前端猜;签名地址原样交给播放器(只补 host,不动 path/query);
  进度上报节流 5s → 10s,切集/退出/切后台各补一次,播完带 finished
- 续看定位:服务端记录里的那一集才 seek(其它集从头播),续看位置进详情/播放页时读一次
- FavoriteController:收藏以服务端为准,乐观更新 + 失败回滚;退出登录清空
- UI 三态:详情页(加载/失败可重试/空剧集 + 续看进度条 + 收藏按钮)、
  播放页(票据加载中/失败可重试/后端判定不可播)、我的页(续看失败可重试、收藏三态、最近观看)
- l10n 新增 8 个键(en/zh/id 三份):网络/进度/收藏失败、锁定提示、续看文案等

测试:flutter test 188 全过(存量 27 个测试文件未跑红);新增票据过期重取、进度节流、
续看定位、收藏回滚、签名 query 原样保留、真实后端端到端联调(live_watch_flow)
2026-10-16 17:39:05 +08:00

802 lines
25 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/play_ticket.dart';
import '../../core/data/services/engagement_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/widgets/auth_gate.dart';
import '../../l10n/app_localizations.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/seek_progress_bar.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
class PlayerPage extends StatefulWidget {
const PlayerPage({
super.key,
required this.drama,
required this.initialIndex,
this.homeMode = false,
this.embedded = false,
this.active = true,
this.resumeRequest = 0,
this.backRequest = 0,
this.onDiscover,
this.onMy,
this.onSearch,
this.onMenu,
this.feedDramas,
this.onDramaChanged,
this.onImmersiveChanged,
});
final Drama drama;
final int initialIndex;
final bool homeMode;
final bool embedded;
final bool active;
final int resumeRequest;
final int backRequest;
final VoidCallback? onDiscover;
final VoidCallback? onMy;
final VoidCallback? onSearch;
final VoidCallback? onMenu;
final List<Drama>? feedDramas;
final ValueChanged<Drama>? onDramaChanged;
final ValueChanged<bool>? onImmersiveChanged;
@override
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
late final PlayerLogic logic;
@override
void initState() {
super.initState();
logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
);
logic.attach(widget, isMounted: () => mounted);
WidgetsBinding.instance.addObserver(this);
logic.start(
factory: context.read<PlayerFactory>(),
coordinator: context.read<PlaybackCoordinator>(),
);
}
@override
void didUpdateWidget(covariant PlayerPage oldWidget) {
super.didUpdateWidget(oldWidget);
logic.attach(widget, isMounted: () => mounted);
logic.onPageUpdated(oldWidget);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
logic.onLifecycle(state);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
logic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: logic,
builder: (context, _) => _buildPlayer(context),
);
}
Widget _buildPlayer(BuildContext context) {
final engagement = context.watch<EngagementController>();
final indices = logic.visibleIndices;
final pages = logic.pages;
final content = AnimatedBuilder(
animation: logic.session,
builder: (context, _) => PageView.builder(
controller: pages,
scrollDirection: Axis.vertical,
allowImplicitScrolling: true,
itemCount: indices.length,
onPageChanged: (position) {
final index = indices[position];
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(logic.selectEpisode(index));
},
itemBuilder: (context, position) =>
_buildEpisode(context, indices[position], engagement),
),
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Theme(
data: ThemeData.dark(useMaterial3: true),
child: widget.embedded
? content
: Scaffold(backgroundColor: Colors.black, body: content),
),
);
}
Widget _buildEpisode(
BuildContext context,
int index,
EngagementController engagement,
) {
final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final episode = widget.homeMode
? logic.displayEpisode(drama, entry.episode)
: entry.episode;
final selected = index == logic.session.currentIndex;
// Selection changes before the prepared slot is promoted. Use its controller
// throughout the handoff instead of briefly falling back to a placeholder.
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;
final theater = logic.fullscreen || logic.pip || logic.pipPreparing;
Widget? video;
if (ready) {
video = GestureDetector(
onTap: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
child: FittedBox(
fit: theater ? BoxFit.contain : BoxFit.cover,
child: SizedBox(
width: videoPort!.controller.value.size.width,
height: videoPort.controller.value.size.height,
child: VideoPlayer(
videoPort.controller,
key: ValueKey(videoPort.controller),
),
),
),
);
}
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Colors.black),
?video,
if (!logic.chromeHidden)
const IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x66000000),
Color(0x00000000),
Color(0x33000000),
Color(0xE6000000),
],
stops: [0, 0.16, 0.55, 1],
),
),
),
),
// 票据三态之一:起播前正在向后端要签名地址
if (selected && logic.ticketLoading)
const Center(
child: CircularProgressIndicator(color: Color(0xFF2F7BFF)),
),
// 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载
if (selected && !logic.ticketLoading && logic.ticketError != null)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.wifi_off_rounded,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
errorText(l10n, logic.ticketError, l10n.playbackError),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 12),
FilledButton(
onPressed: () => unawaited(logic.retryTicket()),
child: Text(l10n.retry),
),
],
),
),
),
if (selected && logic.session.loading)
const Center(
child: CircularProgressIndicator(color: Color(0xFF2F7BFF)),
),
if (selected && !ready && logic.session.error != null)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline_rounded,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
l10n.playbackError,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
],
),
),
),
// 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜
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: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.movie_filter_outlined,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
episode.locked
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
],
),
),
),
if (selected && ready && !logic.session.userWantsPlay && !logic.pip)
const Center(
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: Color(0x73000000),
shape: BoxShape.circle,
),
child: Padding(
padding: EdgeInsets.all(10),
child: Icon(
Icons.play_arrow_rounded,
size: 48,
color: Colors.white,
),
),
),
),
),
if (logic.pip || logic.pipPreparing)
const SizedBox.expand()
else if (logic.fullscreen)
_FullscreenControls(
l10n: l10n,
controller: ready ? videoPort!.controller : null,
onClose: () => unawaited(logic.setFullscreen(false)),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
)
else if (!logic.screenCleared)
_PlaybackOverlay(
l10n: l10n,
drama: drama,
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: engagement.isLiked(drama.id),
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
saved: context.watch<FavoriteController>().isSaved(drama.id),
onBack: widget.embedded
? null
: () => unawaited(logic.leave(context)),
onDiscover: () => logic.showDiscover(context),
onEpisodes: () => logic.showEpisodes(context),
onMy: () => logic.showMy(context),
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
onLike: () async {
if (await ensureSignedIn(context)) {
unawaited(engagement.toggleLiked(drama.id));
}
},
onSave: () async {
final favorites = context.read<FavoriteController>();
if (await ensureSignedIn(context)) {
final ok = await favorites.toggle(drama.id, card: drama);
if (ok || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.favoriteToggleFailed)),
);
}
},
onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama),
onClear: () => logic.setScreenCleared(true),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
showBottomNav: false,
),
if (logic.screenCleared)
SafeArea(
child: Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.only(top: 4, right: 8),
child: IconButton(
tooltip: l10n.exitClearScreen,
onPressed: () => logic.setScreenCleared(false),
style: IconButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: const Color(0x59000000),
),
icon: const Icon(Icons.close_fullscreen_rounded),
),
),
),
),
],
);
}
}
class _FullscreenControls extends StatelessWidget {
const _FullscreenControls({
required this.l10n,
required this.controller,
required this.onClose,
required this.onPip,
required this.onScrubStart,
required this.onSeek,
});
final AppLocalizations l10n;
final VideoPlayerController? controller;
final VoidCallback onClose;
final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
@override
Widget build(BuildContext context) => SafeArea(
child: Column(
children: [
Row(
children: [
IconButton(
tooltip: l10n.exitFullscreen,
onPressed: onClose,
icon: const Icon(Icons.close_fullscreen_rounded),
),
const Spacer(),
if (onPip != null)
IconButton(
tooltip: l10n.pictureInPicture,
onPressed: onPip,
icon: const Icon(Icons.picture_in_picture_alt_outlined),
),
],
),
const Spacer(),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: SeekProgressBar(
valueListenable: controller,
semanticsLabel: l10n.seekVideo,
onScrubStart: onScrubStart,
onSeek: onSeek,
),
),
],
),
);
}
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
class _BlockedNotice extends StatelessWidget {
const _BlockedNotice({
required this.l10n,
required this.episode,
required this.accessType,
required this.unlockCoin,
});
final AppLocalizations l10n;
final Episode episode;
final String? accessType;
final int unlockCoin;
@override
Widget build(BuildContext context) {
final needsUnlock =
accessType == PlayTicket.coinRequired ||
accessType == PlayTicket.vipRequired;
final title = needsUnlock
? l10n.lockedTitle(episode.number)
: accessType == PlayTicket.noMedia
? l10n.noMediaNotice
: episode.locked
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
needsUnlock
? Icons.lock_outline_rounded
: Icons.movie_filter_outlined,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
if (needsUnlock) ...[
const SizedBox(height: 6),
Text(
unlockCoin > 0
? '${l10n.unlockComingSoon} · $unlockCoin ${l10n.coins}'
: l10n.unlockComingSoon,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white38, fontSize: 12),
),
],
],
),
),
);
}
}
class _PlaybackOverlay extends StatelessWidget {
const _PlaybackOverlay({
required this.l10n,
required this.drama,
required this.episode,
required this.controller,
required this.liked,
required this.saved,
required this.onBack,
required this.onDiscover,
required this.onEpisodes,
required this.onMy,
required this.onLike,
required this.onSave,
required this.onComments,
required this.onShare,
required this.onClear,
required this.onPip,
required this.onScrubStart,
required this.onSeek,
required this.showBottomNav,
});
final AppLocalizations l10n;
final Drama drama;
final Episode episode;
final VideoPlayerController? controller;
final bool liked;
final bool saved;
final VoidCallback? onBack;
final VoidCallback onDiscover;
final VoidCallback onEpisodes;
final VoidCallback onMy;
final VoidCallback onLike;
final VoidCallback onSave;
final VoidCallback onComments;
final VoidCallback onShare;
final VoidCallback onClear;
final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
final bool showBottomNav;
@override
Widget build(BuildContext context) => SafeArea(
bottom: showBottomNav,
child: Stack(
children: [
Column(
children: [
const Spacer(),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: _info()),
_rail(),
],
),
),
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.fromLTRB(
16,
0,
16,
20 + MediaQuery.paddingOf(context).bottom,
),
child: SeekProgressBar(
valueListenable: controller,
semanticsLabel: l10n.seekVideo,
onScrubStart: onScrubStart,
onSeek: onSeek,
),
),
if (showBottomNav) _fallbackNav(),
],
),
if (onBack != null)
Positioned(
top: 4,
left: 8,
child: IconButton(
tooltip: l10n.backToSeries,
onPressed: onBack,
style: IconButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: const Color(0x59000000),
),
icon: const Icon(Icons.arrow_back_rounded),
),
),
Positioned(
top: 4,
right: 8,
child: IconButton(
tooltip: l10n.clearScreen,
onPressed: onClear,
style: IconButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: const Color(0x59000000),
),
icon: const Icon(Icons.fit_screen_outlined),
),
),
],
),
);
Widget _info() => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w800,
height: 1.15,
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
),
),
const SizedBox(height: 6),
Text(
'${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
),
),
],
);
Widget _rail() => Column(
mainAxisSize: MainAxisSize.min,
children: [
_RailAction(
key: const Key('rail-like'),
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
label: l10n.like,
selected: liked,
onTap: onLike,
),
_RailAction(
key: const Key('rail-save'),
icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
label: l10n.save,
selected: saved,
onTap: onSave,
),
_RailAction(
icon: Icons.grid_view_rounded,
label: l10n.episodes,
onTap: onEpisodes,
),
_RailAction(
key: const Key('rail-comments'),
icon: Icons.mode_comment_outlined,
label: l10n.comments,
onTap: onComments,
),
_RailAction(
key: const Key('rail-share'),
icon: Icons.share_rounded,
label: l10n.share,
onTap: onShare,
),
if (onPip != null)
IconButton(
tooltip: l10n.pictureInPicture,
onPressed: onPip,
visualDensity: VisualDensity.compact,
iconSize: 20,
color: Colors.white,
icon: const Icon(Icons.picture_in_picture_alt_outlined),
),
],
);
Widget _fallbackNav() => Container(
height: 62,
color: const Color(0xF20C0C0F),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_BottomAction(
icon: Icons.play_circle_outline_rounded,
label: l10n.homeTab,
selected: true,
onTap: () {},
),
_BottomAction(
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: onDiscover,
),
_BottomAction(
icon: Icons.video_library_outlined,
label: l10n.myTab,
onTap: onMy,
),
],
),
);
}
class _RailAction extends StatelessWidget {
const _RailAction({
super.key,
required this.icon,
required this.label,
required this.onTap,
this.selected = false,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
final bool selected;
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: label,
onPressed: onTap,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 42, height: 36),
color: selected ? const Color(0xFF8FB4FF) : Colors.white,
icon: Icon(icon, size: 24),
),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
),
),
],
);
}
class _BottomAction extends StatelessWidget {
const _BottomAction({
required this.icon,
required this.label,
required this.onTap,
this.selected = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool selected;
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: selected ? const Color(0xFF2F7BFF) : const Color(0xFFF3EEEA),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 11,
color: selected
? const Color(0xFF2F7BFF)
: const Color(0xFFF3EEEA),
),
),
],
),
),
);
}