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)
This commit is contained in:
Vicky committed 2026-10-16 17:39:05 +08:00
1 parent 94ff9f1177
commit d7eff4ca59
48 files changed
+2411 -377

No files matched your search

+122 -4
View File
@@ -6,15 +6,20 @@ 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({
@@ -60,7 +65,10 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
logic = PlayerBinding().create();
logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
);
logic.attach(widget, isMounted: () => mounted);
WidgetsBinding.instance.addObserver(this);
logic.start(
@@ -189,6 +197,39 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
),
),
),
// 票据三态之一:起播前正在向后端要签名地址
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)),
@@ -215,9 +256,18 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
),
),
),
// 锁定集与还没上传片源的剧集都会走到这里,之前是一片纯黑,看不出原因。
// 票据三态之三:后端判定不可播放 —— 用 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(
@@ -284,7 +334,8 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: engagement.isLiked(drama.id),
saved: engagement.isSaved(drama.id),
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
saved: context.watch<FavoriteController>().isSaved(drama.id),
onBack: widget.embedded
? null
: () => unawaited(logic.leave(context)),
@@ -298,8 +349,13 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
}
},
onSave: () async {
final favorites = context.read<FavoriteController>();
if (await ensureSignedIn(context)) {
unawaited(engagement.toggleSaved(drama.id));
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),
@@ -389,6 +445,68 @@ class _FullscreenControls extends StatelessWidget {
);
}
/// 不可播放提示:文案由后端返回的 `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,