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,19 +1,17 @@
|
||||
import '../models/drama.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../models/play_ticket.dart';
|
||||
import '../remote/catalog_api.dart';
|
||||
|
||||
/// 目录数据来源:首页/发现页聚合、详情、播放票据。
|
||||
/// 目录数据来源:首页/发现页聚合、详情。
|
||||
///
|
||||
/// 首页卡片不带剧集,进详情或播放时才按需取,保证列表接口足够轻。
|
||||
/// **播放票据不在这里**:它属于 [PlaybackRepository](票据有有效期,需要单独缓存策略)。
|
||||
abstract interface class CatalogRepository {
|
||||
Future<HomeFeed> fetchHomeFeed();
|
||||
|
||||
Future<HomeFeed> fetchDiscoverFeed();
|
||||
|
||||
Future<Drama> fetchDrama(String dramaId);
|
||||
|
||||
Future<PlayTicket> resolvePlay(String episodeId);
|
||||
}
|
||||
|
||||
class HttpCatalogRepository implements CatalogRepository {
|
||||
@@ -30,7 +28,4 @@ class HttpCatalogRepository implements CatalogRepository {
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
|
||||
|
||||
@override
|
||||
Future<PlayTicket> resolvePlay(String episodeId) =>
|
||||
_api.playTicket(episodeId);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../remote/library_api.dart';
|
||||
|
||||
/// 收藏来源:**以服务端为准**(本地不再存收藏状态)。
|
||||
abstract interface class FavoriteRepository {
|
||||
/// 切换收藏,返回切换后的状态(true = 已收藏)。
|
||||
Future<bool> toggle(String dramaId);
|
||||
|
||||
/// 收藏列表(卡片结构与首页一致)。
|
||||
Future<List<Drama>> list();
|
||||
|
||||
Future<void> remove(String dramaId);
|
||||
}
|
||||
|
||||
class HttpFavoriteRepository implements FavoriteRepository {
|
||||
const HttpFavoriteRepository(this._api, {this.isSignedIn});
|
||||
|
||||
final LibraryApi _api;
|
||||
|
||||
/// 未登录时不请求:收藏属于账号,匿名浏览时列表按空处理(点收藏按钮会先引导登录)。
|
||||
final bool Function()? isSignedIn;
|
||||
|
||||
bool get _signedIn => isSignedIn?.call() ?? true;
|
||||
|
||||
@override
|
||||
Future<bool> toggle(String dramaId) => _api.toggleFavorite(dramaId);
|
||||
|
||||
@override
|
||||
Future<List<Drama>> list() async {
|
||||
if (!_signedIn) return const [];
|
||||
return _api.favorites();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String dramaId) => _api.removeFavorite(dramaId);
|
||||
}
|
||||
|
||||
/// 内存实现:单测与 widget 测试注入用。
|
||||
class InMemoryFavoriteRepository implements FavoriteRepository {
|
||||
InMemoryFavoriteRepository({Set<String>? saved, List<Drama>? cards})
|
||||
: _saved = {...?saved},
|
||||
_cards = [...?cards];
|
||||
|
||||
final Set<String> _saved;
|
||||
final List<Drama> _cards;
|
||||
bool failToggle = false;
|
||||
bool failList = false;
|
||||
int toggleCalls = 0;
|
||||
|
||||
@override
|
||||
Future<bool> toggle(String dramaId) async {
|
||||
toggleCalls += 1;
|
||||
if (failToggle) throw StateError('favorite unavailable');
|
||||
if (!_saved.add(dramaId)) _saved.remove(dramaId);
|
||||
return _saved.contains(dramaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Drama>> list() async {
|
||||
if (failList) throw StateError('favorite unavailable');
|
||||
return _cards.where((card) => _saved.contains(card.id)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String dramaId) async => _saved.remove(dramaId);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/play_ticket.dart';
|
||||
import '../remote/playback_api.dart';
|
||||
|
||||
/// 播放票据来源。
|
||||
///
|
||||
/// 票据里的签名地址有有效期(契约 §1.4:签名默认 15 分钟),所以**不能一直缓存**:
|
||||
/// [ticketFor] 命中未过期的缓存就用缓存,过期或 [forceRefresh] 时重新向后端要一张。
|
||||
abstract interface class PlaybackRepository {
|
||||
Future<PlayTicket> ticketFor(String episodeId, {bool forceRefresh = false});
|
||||
|
||||
/// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。
|
||||
bool isFresh(String episodeId);
|
||||
}
|
||||
|
||||
class HttpPlaybackRepository implements PlaybackRepository {
|
||||
HttpPlaybackRepository(
|
||||
this._api, {
|
||||
this.ttl = const Duration(minutes: 10),
|
||||
DateTime Function()? clock,
|
||||
}) : _clock = clock ?? DateTime.now;
|
||||
|
||||
final PlaybackApi _api;
|
||||
|
||||
/// 缓存时长:比后端 15 分钟签名短 5 分钟,避免"地址刚好在播放中途失效"。
|
||||
final Duration ttl;
|
||||
final DateTime Function() _clock;
|
||||
final Map<String, _CachedTicket> _cache = {};
|
||||
|
||||
@override
|
||||
Future<PlayTicket> ticketFor(
|
||||
String episodeId, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final cached = _cache[episodeId];
|
||||
if (!forceRefresh && cached != null && !cached.isExpired(_clock(), ttl)) {
|
||||
return cached.ticket;
|
||||
}
|
||||
final ticket = await _api.ticket(episodeId);
|
||||
_cache[episodeId] = _CachedTicket(ticket, _clock());
|
||||
return ticket;
|
||||
}
|
||||
|
||||
@override
|
||||
bool isFresh(String episodeId) {
|
||||
final cached = _cache[episodeId];
|
||||
if (cached == null) return false;
|
||||
return !cached.isExpired(_clock(), ttl);
|
||||
}
|
||||
|
||||
/// 供测试与"退出播放页"清理用。
|
||||
void clear() => _cache.clear();
|
||||
}
|
||||
|
||||
class _CachedTicket {
|
||||
const _CachedTicket(this.ticket, this.fetchedAt);
|
||||
|
||||
final PlayTicket ticket;
|
||||
final DateTime fetchedAt;
|
||||
|
||||
bool isExpired(DateTime now, Duration ttl) => now.difference(fetchedAt) >= ttl;
|
||||
}
|
||||
|
||||
/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。
|
||||
class FakePlaybackRepository implements PlaybackRepository {
|
||||
FakePlaybackRepository({this.ticket, this.error, this.fresh = true});
|
||||
|
||||
PlayTicket? ticket;
|
||||
Object? error;
|
||||
final List<String> requests = [];
|
||||
final List<bool> forced = [];
|
||||
|
||||
/// 模拟"票据是否还有效":置 false 可验证回前台会重新取。
|
||||
bool fresh;
|
||||
|
||||
@override
|
||||
bool isFresh(String episodeId) => fresh;
|
||||
|
||||
@override
|
||||
Future<PlayTicket> ticketFor(
|
||||
String episodeId, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
requests.add(episodeId);
|
||||
forced.add(forceRefresh);
|
||||
if (error != null) throw error!;
|
||||
return ticket ??
|
||||
PlayTicket(
|
||||
episodeId: episodeId,
|
||||
accessible: true,
|
||||
accessType: PlayTicket.free,
|
||||
playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +1,142 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/drama.dart';
|
||||
|
||||
const demoProfileId = 'demo-local-v1';
|
||||
import '../models/watch_progress.dart';
|
||||
import '../remote/watch_api.dart';
|
||||
|
||||
/// 观看进度来源。
|
||||
///
|
||||
/// **服务端按 `(user, drama)` 只留一条续看记录**(契约 §1.5),所以这里不再是
|
||||
/// "本地按 profile+剧+集存位置",而是"这部剧看到哪一集、哪一秒"。
|
||||
/// 本地实现已删除:进度以服务端为准,换设备也能续看。
|
||||
abstract interface class WatchHistoryRepository {
|
||||
Future<Duration?> positionFor(
|
||||
String profileId,
|
||||
String dramaId,
|
||||
String episodeId,
|
||||
);
|
||||
Future<String?> lastEpisodeId(String profileId, String dramaId);
|
||||
Future<void> save(
|
||||
String profileId,
|
||||
String dramaId,
|
||||
String episodeId,
|
||||
Duration position,
|
||||
);
|
||||
/// 某部剧的续看记录;没有记录返回 null(不是异常)。
|
||||
Future<WatchProgress?> progressFor(String dramaId);
|
||||
|
||||
/// 上报进度(客户端每 10 秒一次,切集/退出/切后台各补一次)。
|
||||
Future<WatchProgress?> save({
|
||||
required String episodeId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
});
|
||||
|
||||
/// 最近观看(默认 20 条)。
|
||||
Future<List<WatchProgress>> recent({int limit = 20});
|
||||
|
||||
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
|
||||
Future<void> remove(String dramaId);
|
||||
}
|
||||
|
||||
class SharedPreferencesWatchHistoryRepository
|
||||
implements WatchHistoryRepository {
|
||||
SharedPreferencesWatchHistoryRepository({SharedPreferencesAsync? preferences})
|
||||
: _preferences = preferences ?? SharedPreferencesAsync();
|
||||
class HttpWatchHistoryRepository implements WatchHistoryRepository {
|
||||
const HttpWatchHistoryRepository(this._api, {this.isSignedIn});
|
||||
|
||||
final SharedPreferencesAsync _preferences;
|
||||
final WatchApi _api;
|
||||
|
||||
String _positionKey(String profile, String drama, String episode) =>
|
||||
'watch.v1.$profile.$drama.$episode.positionMs';
|
||||
String _lastKey(String profile, String drama) =>
|
||||
'watch.v1.$profile.$drama.lastEpisode';
|
||||
/// 未登录时这些接口必然 401:直接短路成"没有记录",让浏览/试看保持匿名可用。
|
||||
final bool Function()? isSignedIn;
|
||||
|
||||
bool get _signedIn => isSignedIn?.call() ?? true;
|
||||
|
||||
@override
|
||||
Future<Duration?> positionFor(
|
||||
String profileId,
|
||||
String dramaId,
|
||||
String episodeId,
|
||||
) async {
|
||||
final milliseconds = await _preferences.getInt(
|
||||
_positionKey(profileId, dramaId, episodeId),
|
||||
Future<WatchProgress?> progressFor(String dramaId) async {
|
||||
if (!_signedIn) return null;
|
||||
return _api.progressOfDrama(dramaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WatchProgress?> save({
|
||||
required String episodeId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
}) async {
|
||||
if (!_signedIn) return null;
|
||||
return _api.saveProgress(
|
||||
episodeId: episodeId,
|
||||
position: position,
|
||||
duration: duration,
|
||||
finished: finished,
|
||||
);
|
||||
return milliseconds == null ? null : Duration(milliseconds: milliseconds);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> lastEpisodeId(String profileId, String dramaId) =>
|
||||
_preferences.getString(_lastKey(profileId, dramaId));
|
||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
||||
if (!_signedIn) return const [];
|
||||
return _api.recent(limit: limit);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(
|
||||
String profileId,
|
||||
String dramaId,
|
||||
String episodeId,
|
||||
Duration position,
|
||||
) async {
|
||||
await _preferences.setInt(
|
||||
_positionKey(profileId, dramaId, episodeId),
|
||||
position.inMilliseconds,
|
||||
Future<void> remove(String dramaId) async {
|
||||
if (!_signedIn) return;
|
||||
await _api.removeProgress(dramaId);
|
||||
}
|
||||
}
|
||||
|
||||
/// 内存实现:单测与 widget 测试注入用(不依赖网络与偏好存储)。
|
||||
class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
|
||||
InMemoryWatchHistoryRepository({List<WatchProgress>? seed})
|
||||
: _records = {for (final item in seed ?? const <WatchProgress>[]) item.dramaId: item};
|
||||
|
||||
final Map<String, WatchProgress> _records;
|
||||
int failReads = 0;
|
||||
bool failWrites = false;
|
||||
final List<String> writes = [];
|
||||
|
||||
@override
|
||||
Future<WatchProgress?> progressFor(String dramaId) async {
|
||||
if (failReads > 0) {
|
||||
failReads -= 1;
|
||||
throw StateError('progress unavailable');
|
||||
}
|
||||
return _records[dramaId];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WatchProgress?> save({
|
||||
required String episodeId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
}) async {
|
||||
if (failWrites) throw StateError('progress unavailable');
|
||||
writes.add('$episodeId|${position.inMilliseconds}|$finished');
|
||||
final existing = _records[episodeId];
|
||||
final record = WatchProgress(
|
||||
dramaId: existing?.dramaId ?? '',
|
||||
episodeId: episodeId,
|
||||
position: position,
|
||||
duration: duration ?? Duration.zero,
|
||||
finished: finished,
|
||||
);
|
||||
await _preferences.setString(_lastKey(profileId, dramaId), episodeId);
|
||||
return record;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
||||
if (failReads > 0) {
|
||||
failReads -= 1;
|
||||
throw StateError('progress unavailable');
|
||||
}
|
||||
return _records.values.take(limit).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String dramaId) async => _records.remove(dramaId);
|
||||
}
|
||||
|
||||
/// 续看位置:列表卡片没有剧集列表,取不到首集时退回最近观看的那一集。
|
||||
extension WatchHistoryResume on WatchHistoryRepository {
|
||||
Future<Duration?> resumePositionFor(String profileId, Drama drama) async {
|
||||
final episodeId = drama.episodes.isNotEmpty
|
||||
? drama.episodes.first.id
|
||||
: await lastEpisodeId(profileId, drama.id);
|
||||
if (episodeId == null) return null;
|
||||
return positionFor(profileId, drama.id, episodeId);
|
||||
/// 续看点辅助:把服务端记录换算成"第几集 + 从哪一秒开始"。
|
||||
extension WatchProgressResume on WatchHistoryRepository {
|
||||
Future<ResumePoint?> resumePointFor(String dramaId) async {
|
||||
final progress = await progressFor(dramaId);
|
||||
if (progress == null || progress.episodeId.isEmpty) return null;
|
||||
return ResumePoint(
|
||||
episodeId: progress.episodeId,
|
||||
position: progress.position,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 续看位置钳位:片尾附近与异常值一律从 0 开始,避免"一进来就播完"。
|
||||
Duration clampResumePosition(Duration saved, Duration duration) {
|
||||
if (duration <= const Duration(milliseconds: 500)) return Duration.zero;
|
||||
if (saved <= Duration.zero) return Duration.zero;
|
||||
|
||||
Reference in new issue
Block a user