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

+7 -2
View File
@@ -1,10 +1,15 @@
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import 'player_logic.dart';
class PlayerBinding {
PlayerLogic create() => PlayerLogic(
history: SharedPreferencesWatchHistoryRepository(),
PlayerLogic create({
required WatchHistoryRepository history,
required PlaybackRepository playback,
}) => PlayerLogic(
history: history,
playback: playback,
pictureInPicture: AndroidPictureInPicture(),
);
}
+160 -26
View File
@@ -5,7 +5,11 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/play_ticket.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/models/player_port.dart';
import '../../core/data/remote/api_client.dart';
import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import '../../core/data/services/catalog_controller.dart';
@@ -24,9 +28,14 @@ import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart';
class PlayerLogic extends ChangeNotifier {
PlayerLogic({required this.history, required this.pictureInPicture});
PlayerLogic({
required this.history,
required this.playback,
required this.pictureInPicture,
});
final WatchHistoryRepository history;
final PlaybackRepository playback;
final AndroidPictureInPicture pictureInPicture;
late PageController pages;
@@ -37,7 +46,18 @@ class PlayerLogic extends ChangeNotifier {
Episode? trackedEpisode;
Drama? trackedDrama;
DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0);
/// 进度上报节流:契约建议每 10 秒一次
Duration progressInterval = const Duration(seconds: 10);
Future<void> pendingWrite = Future<void>.value();
/// 服务端续看点:进入播放页时定位到"那一集的哪一秒"
ResumePoint? resumePoint;
bool resumeLoaded = false;
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
bool ticketLoading = false;
String? ticketError;
PlayTicket? blockedTicket;
String? blockedEpisodeId;
List<Episode> sessionEpisodes = const [];
int pageRequest = 0;
bool muted = false;
bool stoppedAtFeedEnd = false;
@@ -64,6 +84,9 @@ class PlayerLogic extends ChangeNotifier {
ValueChanged<Drama>? onDramaChanged;
ValueChanged<bool>? onImmersiveChanged;
bool _disposed = false;
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
bool _started = false;
bool get immersive => fullscreen || pip || pipPreparing;
@@ -111,16 +134,19 @@ class PlayerLogic extends ChangeNotifier {
required PlayerFactory factory,
required PlaybackCoordinator coordinator,
}) {
_started = true;
pictureInPicture.setStateListener(onPipChanged);
queue = feedDramas == null
? PlaybackQueue.series(pageDrama)
: PlaybackQueue.home(feedDramas!);
visibleIndices = List<int>.generate(queue.length, (index) => index);
pages = PageController(initialPage: initialIndex);
// 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它)
sessionEpisodes = [for (final entry in queue.entries) entry.episode];
session = PlaybackSession(
episodes: [for (final entry in queue.entries) entry.episode],
episodes: sessionEpisodes,
factory: factory,
// 后端只在可播放时才下发地址(免费集或已解锁),拿不到地址即视为锁定
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
canPlay: (episode) => episode.videoUrl.isNotEmpty,
prepare: preparePlayer,
activate: activatePlayer,
@@ -129,9 +155,88 @@ class PlayerLogic extends ChangeNotifier {
if (!active || visibleIndices.isEmpty) {
session.setRouteVisible(false);
}
unawaited(loadResumePoint());
unawaited(selectEpisode(initialIndex));
}
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
Future<void> loadResumePoint() async {
final dramaId = queue.entryAt(initialIndex).drama.id;
try {
resumePoint = await history.resumePointFor(dramaId);
} catch (_) {
// 拿不到续看位置不影响播放:从当前集开头看
resumePoint = null;
} finally {
resumeLoaded = true;
if (isMounted()) notify();
}
}
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
/// 该集是否该定位到服务端记录的位置:只有"记录里的那一集"才定位,其它集从头播。
Duration? resumePositionFor(String episodeId) {
final point = resumePoint;
if (point == null || point.episodeId != episodeId) return null;
if (point.position <= Duration.zero) return null;
return point.position;
}
/// 进度上报节流:距上次上报不足 [progressInterval] 就不报。
///
/// 切集/退出/切后台走 [saveCurrentProgress],那条路径**不受节流限制**。
bool shouldReportProgress(DateTime now) {
if (now.difference(lastProgressWrite) < progressInterval) return false;
lastProgressWrite = now;
return true;
}
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
/// 拿不到地址就交给 [blockedTicket] 让 UI 提示解锁/暂无片源。
Future<bool> ensureTicket(int index) async {
if (index < 0 || index >= sessionEpisodes.length) return false;
final episode = sessionEpisodes[index];
final request = pageRequest;
ticketLoading = true;
ticketError = null;
notify();
try {
final ticket = await playback.ticketFor(episode.id);
if (request != pageRequest) return false;
blockedTicket = null;
blockedEpisodeId = null;
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
return true;
}
blockedTicket = ticket;
blockedEpisodeId = episode.id;
return false;
} catch (exception) {
if (request != pageRequest) return false;
// 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了
if (episode.videoUrl.isNotEmpty) {
ticketError = null;
return true;
}
ticketError = exception is ApiException
? exception.message
: 'ticketError';
return false;
} finally {
ticketLoading = false;
if (isMounted()) notify();
}
}
/// 重试当前集的票据(失败态的"重试"按钮)。
Future<void> retryTicket() async {
ticketError = null;
notify();
unawaited(selectEpisode(session.currentIndex));
}
void onPageUpdated(PlayerPage previous) {
if (previous.active != active) {
session.setRouteVisible(active && visibleIndices.isNotEmpty);
@@ -161,6 +266,14 @@ class PlayerLogic extends ChangeNotifier {
void onLifecycle(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress());
if (state == AppLifecycleState.resumed) {
// 签名地址有有效期:回前台时若票据已过期,重新取一张再起播,
// 否则会撞上 403(契约 §1.4)
final episode = trackedEpisode;
if (episode != null && !playback.isFresh(episode.id)) {
unawaited(selectEpisode(session.currentIndex));
}
}
session.setAppResumed(
state == AppLifecycleState.resumed || pip || pipPreparing,
);
@@ -179,10 +292,12 @@ class PlayerLogic extends ChangeNotifier {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
}
unawaited(saveCurrentProgress());
session.setRouteVisible(false);
cancelScrub();
unawaited(session.close());
pages.dispose();
if (_started) {
session.setRouteVisible(false);
cancelScrub();
unawaited(session.close());
pages.dispose();
}
super.dispose();
}
@@ -264,23 +379,18 @@ class PlayerLogic extends ChangeNotifier {
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
if (player is! VideoPlayerPort) return;
final drama = queue.entries
.firstWhere((e) => e.episode.id == episode.id)
.drama;
try {
await pendingWrite;
final saved = await history.positionFor(
demoProfileId,
drama.id,
episode.id,
);
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
if (resumePoint == null) await loadResumePoint();
final saved = resumePositionFor(episode.id);
if (saved != null) {
await player.controller.seekTo(
clampResumePosition(saved, player.controller.value.duration),
);
}
} catch (_) {
// A local preference failure does not prevent playback.
// 续看位置取不到不影响播放:从头播。
}
}
@@ -303,7 +413,13 @@ class PlayerLogic extends ChangeNotifier {
if (player.controller.value.isCompleted && !completionProgressSaved) {
completionProgressSaved = true;
unawaited(
queueProgress(drama, episode, player.controller.value.duration),
queueProgress(
drama,
episode,
player.controller.value.duration,
duration: player.controller.value.duration,
finished: true,
),
);
}
if (!completionHandled &&
@@ -313,11 +429,7 @@ class PlayerLogic extends ChangeNotifier {
completionHandled = true;
unawaited(advanceAfterEnd(player, index));
}
final now = DateTime.now();
if (now.difference(lastProgressWrite) < const Duration(seconds: 5)) {
return;
}
lastProgressWrite = now;
if (!shouldReportProgress(DateTime.now())) return;
unawaited(
queueProgress(drama, episode, player.controller.value.position),
);
@@ -347,13 +459,26 @@ class PlayerLogic extends ChangeNotifier {
}
}
Future<void> queueProgress(Drama drama, Episode episode, Duration position) {
/// 进度上报:节流由调用方控制(10 秒),切集/退出/切后台都会补一次。
Future<void> queueProgress(
Drama drama,
Episode episode,
Duration position, {
Duration? duration,
bool finished = false,
}) {
pendingWrite = pendingWrite
.then(
(_) => history.save(demoProfileId, drama.id, episode.id, position),
(_) => history.save(
episodeId: episode.id,
position: position,
duration: duration,
finished: finished,
),
)
.catchError((Object _) {
// Playback continues if local history cannot be stored.
// 进度上报失败不影响播放(下次节流点会再报一次)。
return null;
});
return pendingWrite;
}
@@ -368,7 +493,12 @@ class PlayerLogic extends ChangeNotifier {
!player.controller.value.isInitialized) {
return pendingWrite;
}
return queueProgress(drama, episode, player.controller.value.position);
return queueProgress(
drama,
episode,
player.controller.value.position,
duration: player.controller.value.duration,
);
}
Future<void> selectEpisode(int index) async {
@@ -377,6 +507,10 @@ class PlayerLogic extends ChangeNotifier {
// Capture the old position synchronously, persist without blocking a swipe.
unawaited(saveCurrentProgress());
if (request != pageRequest || !isMounted()) return;
// 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试)
final ready = await ensureTicket(index);
if (request != pageRequest || !isMounted()) return;
if (!ready && ticketError != null) return;
await session.select(index, preloadIndex: nextVisibleIndex(index));
}
+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,