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:
1 parent
94ff9f1177
commit
d7eff4ca59
48 files changed
+2411
-377
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -6,6 +8,8 @@ import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/data/services/engagement_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
@@ -61,7 +65,7 @@ class _MyPageState extends State<MyPage> {
|
||||
logic = MineBinding().create(
|
||||
drama: widget.drama,
|
||||
initialSection: widget.initialSection,
|
||||
history: widget.history,
|
||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -438,11 +442,14 @@ class _MyPageState extends State<MyPage> {
|
||||
(logic.filter == 1 && fraction < 0.99) ||
|
||||
(logic.filter == 2 && fraction >= 0.99);
|
||||
final engagement = context.watch<EngagementController>();
|
||||
final markedDramas = (widget.dramas ?? [widget.drama]).where((drama) {
|
||||
return logic.section == 1
|
||||
? engagement.isSaved(drama.id)
|
||||
: engagement.isLiked(drama.id);
|
||||
}).toList();
|
||||
// 收藏以服务端为准(收藏列表接口返回卡片结构);点赞仍是本地状态(K3 接互动)。
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
final isWatchlist = logic.section == 1;
|
||||
final markedDramas = isWatchlist
|
||||
? favorites.cards
|
||||
: (widget.dramas ?? [widget.drama])
|
||||
.where((drama) => engagement.isLiked(drama.id))
|
||||
.toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -452,7 +459,11 @@ class _MyPageState extends State<MyPage> {
|
||||
children: [
|
||||
for (var index = 0; index < tabs.length; index++) ...[
|
||||
InkWell(
|
||||
onTap: () => logic.selectSection(index),
|
||||
onTap: () {
|
||||
logic.selectSection(index);
|
||||
// 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐)
|
||||
if (index == 1) unawaited(favorites.load());
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -577,6 +588,34 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (isWatchlist && favorites.loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 22),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (isWatchlist && favorites.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
errorText(l10n, favorites.error, l10n.favoritesError),
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(favorites.load()),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (logic.section != 0 && markedDramas.isNotEmpty)
|
||||
for (final drama in markedDramas) ...[
|
||||
_markedDramaCard(drama, l10n),
|
||||
@@ -590,10 +629,90 @@ class _MyPageState extends State<MyPage> {
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
),
|
||||
// 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧;
|
||||
// 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。
|
||||
if (logic.section == 0) ...[
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
l10n.history,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
if (logic.positionFailed)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.progressError,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: logic.refreshPosition,
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (logic.recentLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (logic.recentError != null)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
errorText(l10n, logic.recentError, l10n.progressError),
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(logic.refreshRecent()),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (_recentDramas.isEmpty)
|
||||
Text(
|
||||
l10n.noStories,
|
||||
style: const TextStyle(color: Color(0xFFB7B3B8)),
|
||||
)
|
||||
else
|
||||
for (final drama in _recentDramas) ...[
|
||||
_markedDramaCard(drama, l10n),
|
||||
const SizedBox(height: 9),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 服务端最近观看 → 已加载目录里的卡片(按 id 匹配,去重,当前剧也算一条)。
|
||||
List<Drama> get _recentDramas {
|
||||
final pool = <Drama>[...?widget.dramas, widget.drama];
|
||||
final result = <Drama>[];
|
||||
for (final item in logic.recent) {
|
||||
for (final drama in pool) {
|
||||
if (drama.id == item.dramaId &&
|
||||
!result.any((picked) => picked.id == drama.id)) {
|
||||
result.add(drama);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell(
|
||||
onTap: () {
|
||||
if (widget.onDramaSelected != null) {
|
||||
|
||||
Reference in new issue
Block a user