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';
|
||||
|
||||
@@ -5,12 +7,19 @@ import 'core/config/locale_controller.dart';
|
||||
import 'core/data/remote/api_client.dart';
|
||||
import 'core/data/remote/auth_api.dart';
|
||||
import 'core/data/remote/catalog_api.dart';
|
||||
import 'core/data/remote/library_api.dart';
|
||||
import 'core/data/remote/playback_api.dart';
|
||||
import 'core/data/remote/watch_api.dart';
|
||||
import 'core/data/repositories/catalog_repository.dart';
|
||||
import 'core/data/repositories/engagement_repository.dart';
|
||||
import 'core/data/repositories/favorite_repository.dart';
|
||||
import 'core/data/repositories/playback_repository.dart';
|
||||
import 'core/data/repositories/watch_history_repository.dart';
|
||||
import 'core/data/services/auth_controller.dart';
|
||||
import 'core/data/services/auth_session_store.dart';
|
||||
import 'core/data/services/catalog_controller.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 'l10n/app_localizations.dart';
|
||||
@@ -21,12 +30,18 @@ class LumaDramaApp extends StatefulWidget {
|
||||
super.key,
|
||||
this.playerFactory = VideoPlayerPort.new,
|
||||
this.catalogRepository,
|
||||
this.watchHistoryRepository,
|
||||
this.favoriteRepository,
|
||||
this.playbackRepository,
|
||||
});
|
||||
|
||||
final PlayerFactory playerFactory;
|
||||
|
||||
/// 测试注入用;生产走 HTTP 仓库。
|
||||
final CatalogRepository? catalogRepository;
|
||||
final WatchHistoryRepository? watchHistoryRepository;
|
||||
final FavoriteRepository? favoriteRepository;
|
||||
final PlaybackRepository? playbackRepository;
|
||||
|
||||
@override
|
||||
State<LumaDramaApp> createState() => _LumaDramaAppState();
|
||||
@@ -46,11 +61,53 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
|
||||
late final AuthController _auth = AuthController(AuthApi(_apiClient), _sessionStore);
|
||||
|
||||
late final WatchHistoryRepository _watchHistory =
|
||||
widget.watchHistoryRepository ??
|
||||
HttpWatchHistoryRepository(
|
||||
WatchApi(_apiClient),
|
||||
isSignedIn: () => _auth.isSignedIn,
|
||||
);
|
||||
|
||||
late final FavoriteRepository _favorites =
|
||||
widget.favoriteRepository ??
|
||||
HttpFavoriteRepository(
|
||||
LibraryApi(_apiClient),
|
||||
isSignedIn: () => _auth.isSignedIn,
|
||||
);
|
||||
|
||||
late final PlaybackRepository _playback =
|
||||
widget.playbackRepository ?? HttpPlaybackRepository(PlaybackApi(_apiClient));
|
||||
|
||||
late final FavoriteController _favoriteController = FavoriteController(_favorites);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
|
||||
_auth.load();
|
||||
// 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联
|
||||
_auth.addListener(_onAuthChanged);
|
||||
if (_auth.isSignedIn) unawaited(_favoriteController.load());
|
||||
}
|
||||
|
||||
bool _wasSignedIn = false;
|
||||
|
||||
void _onAuthChanged() {
|
||||
if (_auth.isSignedIn == _wasSignedIn) return;
|
||||
_wasSignedIn = _auth.isSignedIn;
|
||||
if (_auth.isSignedIn) {
|
||||
unawaited(_favoriteController.load());
|
||||
} else {
|
||||
_favoriteController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_auth.removeListener(_onAuthChanged);
|
||||
_favoriteController.dispose();
|
||||
_apiClient.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -59,12 +116,16 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
providers: [
|
||||
Provider(create: (_) => PlaybackCoordinator()),
|
||||
Provider<PlayerFactory>(create: (_) => widget.playerFactory),
|
||||
Provider<PlaybackRepository>.value(value: _playback),
|
||||
Provider<WatchHistoryRepository>.value(value: _watchHistory),
|
||||
Provider<FavoriteRepository>.value(value: _favorites),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => CatalogController(
|
||||
widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient)),
|
||||
)..load(),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
|
||||
ChangeNotifierProvider.value(value: _favoriteController),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) =>
|
||||
EngagementController(SharedPreferencesEngagementRepository())
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 播放票据:客户端据此决定直接播放、提示解锁还是提示暂无片源。
|
||||
class PlayTicket {
|
||||
const PlayTicket({
|
||||
required this.episodeId,
|
||||
required this.accessible,
|
||||
required this.accessType,
|
||||
this.dramaId = '',
|
||||
this.number = 0,
|
||||
this.title = '',
|
||||
this.playUrl = '',
|
||||
this.durationSec = 0,
|
||||
this.unlockCoin = 0,
|
||||
this.previewSeconds = 0,
|
||||
});
|
||||
|
||||
static const String free = 'FREE';
|
||||
@@ -15,23 +21,35 @@ class PlayTicket {
|
||||
static const String noMedia = 'NO_MEDIA';
|
||||
|
||||
final String episodeId;
|
||||
final String dramaId;
|
||||
final int number;
|
||||
final String title;
|
||||
final bool accessible;
|
||||
final String accessType;
|
||||
|
||||
/// 签名播放地址:**原样使用**(含 `?e=&s=`),客户端不拼接、不改写。
|
||||
final String playUrl;
|
||||
final int durationSec;
|
||||
final int unlockCoin;
|
||||
|
||||
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
|
||||
final int previewSeconds;
|
||||
|
||||
bool get needsUnlock =>
|
||||
accessType == coinRequired || accessType == vipRequired;
|
||||
|
||||
factory PlayTicket.fromJson(Map<String, dynamic> json) {
|
||||
return PlayTicket(
|
||||
episodeId: (json['episodeId'] ?? '').toString(),
|
||||
dramaId: (json['dramaId'] ?? '').toString(),
|
||||
number: jsonToInt(json['number']),
|
||||
title: json['title'] as String? ?? '',
|
||||
accessible: json['accessible'] == true,
|
||||
accessType: json['accessType'] as String? ?? noMedia,
|
||||
playUrl: json['playUrl'] as String? ?? '',
|
||||
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
|
||||
unlockCoin: (json['unlockCoin'] as num?)?.toInt() ?? 0,
|
||||
durationSec: jsonToInt(json['durationSec']),
|
||||
unlockCoin: jsonToInt(json['unlockCoin']),
|
||||
previewSeconds: jsonToInt(json['previewSeconds']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 服务端续看记录:`GET /app/watch/progress?dramaId=` 与 `/app/watch/recent` 的下发结构。
|
||||
///
|
||||
/// 服务端按 `(user, drama)` 只留一条,所以它是"这部剧看到哪一集的哪一秒",
|
||||
/// 不是逐集进度。ID 与毫秒都是字符串(见 api-phase1.md §0),统一走 jsonToInt 解析。
|
||||
class WatchProgress {
|
||||
const WatchProgress({
|
||||
required this.dramaId,
|
||||
required this.episodeId,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.finished = false,
|
||||
this.playCount = 0,
|
||||
});
|
||||
|
||||
final String dramaId;
|
||||
final String episodeId;
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final bool finished;
|
||||
final int playCount;
|
||||
|
||||
bool get hasPosition => position > Duration.zero;
|
||||
|
||||
/// 进度比例 0~1,时长未知时为 0,避免 UI 出现 NaN。
|
||||
double get fraction {
|
||||
if (duration <= Duration.zero) return 0;
|
||||
return (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
factory WatchProgress.fromJson(Map<String, dynamic> json) {
|
||||
return WatchProgress(
|
||||
dramaId: (json['dramaId'] ?? '').toString(),
|
||||
episodeId: (json['episodeId'] ?? '').toString(),
|
||||
position: Duration(milliseconds: jsonToInt(json['positionMs'])),
|
||||
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
|
||||
finished: json['finished'] == true,
|
||||
playCount: jsonToInt(json['playCount']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'dramaId': dramaId,
|
||||
'episodeId': episodeId,
|
||||
'positionMs': position.inMilliseconds,
|
||||
'durationMs': duration.inMilliseconds,
|
||||
'finished': finished,
|
||||
'playCount': playCount,
|
||||
};
|
||||
}
|
||||
|
||||
/// 续看点:从哪一集的哪个位置继续看。
|
||||
class ResumePoint {
|
||||
const ResumePoint({required this.episodeId, required this.position});
|
||||
|
||||
final String episodeId;
|
||||
final Duration position;
|
||||
}
|
||||
@@ -79,6 +79,14 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Object?> delete(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? query,
|
||||
}) {
|
||||
return _request('DELETE', path, body: body, query: query);
|
||||
}
|
||||
|
||||
Future<Object?> _request(
|
||||
String method,
|
||||
String path, {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import '../models/drama.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../models/play_ticket.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 客户端目录接口,对应后端 `/app/**`。
|
||||
@@ -23,12 +22,6 @@ class CatalogApi {
|
||||
);
|
||||
}
|
||||
|
||||
Future<PlayTicket> playTicket(String episodeId) async {
|
||||
return PlayTicket.fromJson(
|
||||
_asMap(await _client.get('/app/episodes/$episodeId/play')),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _asMap(Object? data) {
|
||||
if (data is Map<String, dynamic>) return data;
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/drama.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。
|
||||
///
|
||||
/// 列表返回的是与首页一致的卡片结构,所以直接用 `Drama.fromCardJson` 解析。
|
||||
class LibraryApi {
|
||||
const LibraryApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 切换收藏,返回切换后的状态(true = 已收藏),客户端以它为唯一真相。
|
||||
Future<bool> toggleFavorite(String dramaId) async {
|
||||
final data = await _client.post(
|
||||
'/app/favorites/toggle',
|
||||
body: {'dramaId': dramaId},
|
||||
);
|
||||
return data == true;
|
||||
}
|
||||
|
||||
Future<List<Drama>> favorites() async {
|
||||
final data = await _client.get('/app/favorites');
|
||||
if (data is! List) return const [];
|
||||
return [
|
||||
for (final item in data)
|
||||
if (item is Map<String, dynamic>) Drama.fromCardJson(item),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> removeFavorite(String dramaId) async {
|
||||
await _client.delete('/app/favorites/$dramaId');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/play_ticket.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 播放票据接口:`GET /app/episodes/{id}/play`(契约 §1.4)。
|
||||
///
|
||||
/// **签名地址原样使用**:后端下发的 `playUrl` 带 `?e=过期时间&s=签名`,
|
||||
/// 客户端只做「相对路径补 host」,不拼接、不改写、不丢 query —— 否则签名校验直接失败。
|
||||
class PlaybackApi {
|
||||
const PlaybackApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<PlayTicket> ticket(String episodeId) async {
|
||||
final data = await _client.get('/app/episodes/$episodeId/play');
|
||||
if (data is Map<String, dynamic>) return PlayTicket.fromJson(data);
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/watch_progress.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 观看进度接口:`/app/watch/*`(契约 §1.5)。全部需要登录态。
|
||||
class WatchApi {
|
||||
const WatchApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 上报进度。服务端从 `episodeId` 推出所属剧集,并保证「同集只前进不后退」。
|
||||
Future<WatchProgress?> saveProgress({
|
||||
required String episodeId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
String source = 'app',
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
'/app/watch/progress',
|
||||
body: {
|
||||
'episodeId': episodeId,
|
||||
'positionMs': position.inMilliseconds,
|
||||
if (duration != null) 'durationMs': duration.inMilliseconds,
|
||||
'finished': finished,
|
||||
'source': source,
|
||||
},
|
||||
);
|
||||
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
|
||||
}
|
||||
|
||||
/// 某部剧的续看位置;没有记录时后端返回空 data。
|
||||
Future<WatchProgress?> progressOfDrama(String dramaId) async {
|
||||
final data = await _client.get(
|
||||
'/app/watch/progress',
|
||||
query: {'dramaId': dramaId},
|
||||
);
|
||||
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
|
||||
}
|
||||
|
||||
/// 最近观看(服务端按下标返回,已按更新时间倒序)。
|
||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
||||
final data = await _client.get(
|
||||
'/app/watch/recent',
|
||||
query: {'limit': '$limit'},
|
||||
);
|
||||
if (data is! List) return const [];
|
||||
return [
|
||||
for (final item in data)
|
||||
if (item is Map<String, dynamic>) WatchProgress.fromJson(item),
|
||||
];
|
||||
}
|
||||
|
||||
/// 删除某部剧的观看记录(记录页清单条用)。
|
||||
Future<void> removeProgress(String dramaId) async {
|
||||
await _client.delete('/app/watch/progress/$dramaId');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -46,13 +46,6 @@ class CatalogController extends ChangeNotifier {
|
||||
return detail;
|
||||
}
|
||||
|
||||
/// 播放前解析票据:返回带播放地址的剧集,锁定集保持无地址。
|
||||
Future<Episode> resolvePlay(Episode episode) async {
|
||||
if (episode.videoUrl.isNotEmpty) return episode;
|
||||
final ticket = await repository.resolvePlay(episode.id);
|
||||
return ticket.accessible ? episode.withVideoUrl(ticket.playUrl) : episode;
|
||||
}
|
||||
|
||||
void _replace(Drama detail) {
|
||||
final sections = feed.sections
|
||||
.map(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../repositories/favorite_repository.dart';
|
||||
|
||||
/// 收藏状态:**以服务端为准**。
|
||||
///
|
||||
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
|
||||
/// "点了没反应"或"看起来收藏了其实没有"。
|
||||
class FavoriteController extends ChangeNotifier {
|
||||
FavoriteController(this._repository);
|
||||
|
||||
final FavoriteRepository _repository;
|
||||
final Set<String> _saved = {};
|
||||
List<Drama> cards = const [];
|
||||
bool loading = false;
|
||||
String? error;
|
||||
bool _disposed = false;
|
||||
|
||||
Set<String> get savedIds => Set.unmodifiable(_saved);
|
||||
|
||||
bool isSaved(String dramaId) => _saved.contains(dramaId);
|
||||
|
||||
void notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
|
||||
Future<void> load() async {
|
||||
loading = true;
|
||||
error = null;
|
||||
notify();
|
||||
try {
|
||||
final list = await _repository.list();
|
||||
cards = list;
|
||||
_saved
|
||||
..clear()
|
||||
..addAll(list.map((drama) => drama.id));
|
||||
} catch (_) {
|
||||
error = 'favoritesError';
|
||||
} finally {
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 未登录时不要调用(服务端要求登录态)。
|
||||
///
|
||||
/// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
|
||||
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
|
||||
Future<bool> toggle(String dramaId, {Drama? card}) async {
|
||||
final wasSaved = _saved.contains(dramaId);
|
||||
// 乐观更新
|
||||
if (wasSaved) {
|
||||
_saved.remove(dramaId);
|
||||
cards = cards.where((drama) => drama.id != dramaId).toList();
|
||||
} else {
|
||||
_saved.add(dramaId);
|
||||
}
|
||||
notify();
|
||||
try {
|
||||
final saved = await _repository.toggle(dramaId);
|
||||
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
|
||||
if (saved) {
|
||||
_saved.add(dramaId);
|
||||
if (card != null && !cards.any((drama) => drama.id == dramaId)) {
|
||||
cards = [card, ...cards];
|
||||
}
|
||||
} else {
|
||||
_saved.remove(dramaId);
|
||||
cards = cards.where((drama) => drama.id != dramaId).toList();
|
||||
}
|
||||
notify();
|
||||
return true;
|
||||
} catch (_) {
|
||||
// 回滚
|
||||
if (wasSaved) {
|
||||
_saved.add(dramaId);
|
||||
} else {
|
||||
_saved.remove(dramaId);
|
||||
}
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消收藏("我的"页列表里删除某条)。
|
||||
Future<bool> remove(String dramaId) async {
|
||||
final removed = cards.firstWhere(
|
||||
(drama) => drama.id == dramaId,
|
||||
orElse: () => const Drama(
|
||||
id: '',
|
||||
title: '',
|
||||
synopsis: '',
|
||||
posterUrl: '',
|
||||
),
|
||||
);
|
||||
final hadCard = removed.id.isNotEmpty;
|
||||
_saved.remove(dramaId);
|
||||
cards = cards.where((drama) => drama.id != dramaId).toList();
|
||||
notify();
|
||||
try {
|
||||
await _repository.remove(dramaId);
|
||||
return true;
|
||||
} catch (_) {
|
||||
if (hadCard) cards = [...cards, removed];
|
||||
_saved.add(dramaId);
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
|
||||
void clear() {
|
||||
_saved.clear();
|
||||
cards = const [];
|
||||
error = null;
|
||||
notify();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与
|
||||
/// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。
|
||||
String errorText(AppLocalizations l10n, String? code, String fallback) {
|
||||
if (code == null || code.isEmpty) return fallback;
|
||||
return switch (code) {
|
||||
'networkError' => l10n.networkError,
|
||||
'progressError' => l10n.progressError,
|
||||
'favoritesError' => l10n.favoritesError,
|
||||
'ticketError' => l10n.playbackError,
|
||||
final value => value,
|
||||
};
|
||||
}
|
||||
+67
-8
@@ -34,7 +34,13 @@
|
||||
"localDemoProfile": "Local demo profile",
|
||||
"continueWatching": "Continue Watching",
|
||||
"watchedPercent": "Watched {percent}%",
|
||||
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
|
||||
"@watchedPercent": {
|
||||
"placeholders": {
|
||||
"percent": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resume": "Resume",
|
||||
"history": "History",
|
||||
"watchlist": "Watchlist",
|
||||
@@ -72,16 +78,40 @@
|
||||
"originalTag": "Original",
|
||||
"dramaTag": "Short drama",
|
||||
"episodeLabel": "Episode {number}",
|
||||
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
|
||||
"@episodeLabel": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exploreSeries": "Explore series",
|
||||
"episodeCount": "{count} EPISODES",
|
||||
"@episodeCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"@episodeCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"technicalDemoCount": "FREE PREVIEW · {count} EPISODES",
|
||||
"@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"@technicalDemoCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"episodes": "Episodes",
|
||||
"watchEpisodeOne": "Watch episode 1",
|
||||
"continueEpisode": "Continue episode {number}",
|
||||
"@continueEpisode": {"placeholders": {"number": {"type": "int"}}},
|
||||
"@continueEpisode": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"freeTestMedia": "Same demo clip · free",
|
||||
"lockedStoreUnavailable": "Locked · store unavailable",
|
||||
"freeTestClip": "Free test clip",
|
||||
@@ -89,10 +119,25 @@
|
||||
"freeSamplesNotice": "Two free samples · store setup pending",
|
||||
"demoTestMedia": "FREE · SAME DEMO CLIP",
|
||||
"episodeProgress": "EPISODE {number} / {total}",
|
||||
"@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}},
|
||||
"@episodeProgress": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"testVideoNotice": "Self-generated technical test video",
|
||||
"lockedTitle": "Episode {number} is locked",
|
||||
"@lockedTitle": {"placeholders": {"number": {"type": "int"}}},
|
||||
"@lockedTitle": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.",
|
||||
"playbackError": "This clip could not be played.",
|
||||
"catalogError": "The catalog could not be loaded.",
|
||||
@@ -180,5 +225,19 @@
|
||||
"authGateTitle": "Sign in to continue",
|
||||
"authGateMessage": "Favorites and comments need an account.",
|
||||
"authGateAction": "Sign In",
|
||||
"cancel": "Not now"
|
||||
"cancel": "Not now",
|
||||
"noEpisodesYet": "No playable episodes yet.",
|
||||
"networkError": "Network is unreachable. Please check your connection and retry.",
|
||||
"progressError": "Your watch position could not be loaded.",
|
||||
"favoritesError": "Your favorites could not be loaded.",
|
||||
"favoriteToggleFailed": "Could not update favorites. Please retry.",
|
||||
"remove": "Remove",
|
||||
"unlockComingSoon": "Unlocking with coins is coming soon.",
|
||||
"resumeProgress": "Resume at {position} / {total}",
|
||||
"@resumeProgress": {
|
||||
"placeholders": {
|
||||
"position": {},
|
||||
"total": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
-8
@@ -34,7 +34,13 @@
|
||||
"localDemoProfile": "Profil demo lokal",
|
||||
"continueWatching": "Lanjut Tonton",
|
||||
"watchedPercent": "Sudah ditonton {percent}%",
|
||||
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
|
||||
"@watchedPercent": {
|
||||
"placeholders": {
|
||||
"percent": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resume": "Lanjutkan",
|
||||
"history": "Riwayat",
|
||||
"watchlist": "Daftar Tonton",
|
||||
@@ -72,16 +78,40 @@
|
||||
"originalTag": "Original",
|
||||
"dramaTag": "Drama pendek",
|
||||
"episodeLabel": "Episode {number}",
|
||||
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
|
||||
"@episodeLabel": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exploreSeries": "Lihat serial",
|
||||
"episodeCount": "{count} EPISODE",
|
||||
"@episodeCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"@episodeCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"technicalDemoCount": "PRATINJAU GRATIS · {count} EPISODE",
|
||||
"@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"@technicalDemoCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"episodes": "Episode",
|
||||
"watchEpisodeOne": "Tonton episode 1",
|
||||
"continueEpisode": "Lanjut episode {number}",
|
||||
"@continueEpisode": {"placeholders": {"number": {"type": "int"}}},
|
||||
"@continueEpisode": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"freeTestMedia": "Klip demo sama · gratis",
|
||||
"lockedStoreUnavailable": "Terkunci · toko belum tersedia",
|
||||
"freeTestClip": "Klip uji gratis",
|
||||
@@ -89,10 +119,25 @@
|
||||
"freeSamplesNotice": "Dua sampel gratis · pengaturan toko belum selesai",
|
||||
"demoTestMedia": "GRATIS · KLIP DEMO SAMA",
|
||||
"episodeProgress": "EPISODE {number} / {total}",
|
||||
"@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}},
|
||||
"@episodeProgress": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"testVideoNotice": "Video uji teknis buatan sendiri",
|
||||
"lockedTitle": "Episode {number} terkunci",
|
||||
"@lockedTitle": {"placeholders": {"number": {"type": "int"}}},
|
||||
"@lockedTitle": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lockedBody": "Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.",
|
||||
"playbackError": "Klip ini tidak bisa diputar.",
|
||||
"catalogError": "Katalog gagal dimuat.",
|
||||
@@ -180,5 +225,19 @@
|
||||
"authGateTitle": "Masuk untuk melanjutkan",
|
||||
"authGateMessage": "Favorit dan komentar perlu akun.",
|
||||
"authGateAction": "Masuk",
|
||||
"cancel": "Nanti saja"
|
||||
"cancel": "Nanti saja",
|
||||
"noEpisodesYet": "Belum ada episode yang bisa diputar.",
|
||||
"networkError": "Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.",
|
||||
"progressError": "Posisi tonton gagal dimuat.",
|
||||
"favoritesError": "Daftar favorit gagal dimuat.",
|
||||
"favoriteToggleFailed": "Gagal memperbarui favorit. Coba lagi.",
|
||||
"remove": "Hapus",
|
||||
"unlockComingSoon": "Pembukaan dengan koin segera hadir.",
|
||||
"resumeProgress": "Lanjut di {position} / {total}",
|
||||
"@resumeProgress": {
|
||||
"placeholders": {
|
||||
"position": {},
|
||||
"total": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1143,6 +1143,54 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Not now'**
|
||||
String get cancel;
|
||||
|
||||
/// No description provided for @noEpisodesYet.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No playable episodes yet.'**
|
||||
String get noEpisodesYet;
|
||||
|
||||
/// No description provided for @networkError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Network is unreachable. Please check your connection and retry.'**
|
||||
String get networkError;
|
||||
|
||||
/// No description provided for @progressError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your watch position could not be loaded.'**
|
||||
String get progressError;
|
||||
|
||||
/// No description provided for @favoritesError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your favorites could not be loaded.'**
|
||||
String get favoritesError;
|
||||
|
||||
/// No description provided for @favoriteToggleFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not update favorites. Please retry.'**
|
||||
String get favoriteToggleFailed;
|
||||
|
||||
/// No description provided for @remove.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove'**
|
||||
String get remove;
|
||||
|
||||
/// No description provided for @unlockComingSoon.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unlocking with coins is coming soon.'**
|
||||
String get unlockComingSoon;
|
||||
|
||||
/// No description provided for @resumeProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Resume at {position} / {total}'**
|
||||
String resumeProgress(Object position, Object total);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -551,4 +551,32 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cancel => 'Not now';
|
||||
|
||||
@override
|
||||
String get noEpisodesYet => 'No playable episodes yet.';
|
||||
|
||||
@override
|
||||
String get networkError =>
|
||||
'Network is unreachable. Please check your connection and retry.';
|
||||
|
||||
@override
|
||||
String get progressError => 'Your watch position could not be loaded.';
|
||||
|
||||
@override
|
||||
String get favoritesError => 'Your favorites could not be loaded.';
|
||||
|
||||
@override
|
||||
String get favoriteToggleFailed =>
|
||||
'Could not update favorites. Please retry.';
|
||||
|
||||
@override
|
||||
String get remove => 'Remove';
|
||||
|
||||
@override
|
||||
String get unlockComingSoon => 'Unlocking with coins is coming soon.';
|
||||
|
||||
@override
|
||||
String resumeProgress(Object position, Object total) {
|
||||
return 'Resume at $position / $total';
|
||||
}
|
||||
}
|
||||
@@ -553,4 +553,31 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cancel => 'Nanti saja';
|
||||
|
||||
@override
|
||||
String get noEpisodesYet => 'Belum ada episode yang bisa diputar.';
|
||||
|
||||
@override
|
||||
String get networkError =>
|
||||
'Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.';
|
||||
|
||||
@override
|
||||
String get progressError => 'Posisi tonton gagal dimuat.';
|
||||
|
||||
@override
|
||||
String get favoritesError => 'Daftar favorit gagal dimuat.';
|
||||
|
||||
@override
|
||||
String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.';
|
||||
|
||||
@override
|
||||
String get remove => 'Hapus';
|
||||
|
||||
@override
|
||||
String get unlockComingSoon => 'Pembukaan dengan koin segera hadir.';
|
||||
|
||||
@override
|
||||
String resumeProgress(Object position, Object total) {
|
||||
return 'Lanjut di $position / $total';
|
||||
}
|
||||
}
|
||||
@@ -543,4 +543,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cancel => '暂不';
|
||||
|
||||
@override
|
||||
String get noEpisodesYet => '还没有可播放的剧集。';
|
||||
|
||||
@override
|
||||
String get networkError => '网络异常,请检查网络后重试。';
|
||||
|
||||
@override
|
||||
String get progressError => '续看进度加载失败。';
|
||||
|
||||
@override
|
||||
String get favoritesError => '收藏列表加载失败。';
|
||||
|
||||
@override
|
||||
String get favoriteToggleFailed => '收藏操作失败,请重试。';
|
||||
|
||||
@override
|
||||
String get remove => '移除';
|
||||
|
||||
@override
|
||||
String get unlockComingSoon => '金币解锁功能即将开放。';
|
||||
|
||||
@override
|
||||
String resumeProgress(Object position, Object total) {
|
||||
return '续看至 $position / $total';
|
||||
}
|
||||
}
|
||||
+29
-3
@@ -34,7 +34,13 @@
|
||||
"localDemoProfile": "本地演示档案",
|
||||
"continueWatching": "继续观看",
|
||||
"watchedPercent": "已观看 {percent}%",
|
||||
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
|
||||
"@watchedPercent": {
|
||||
"placeholders": {
|
||||
"percent": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resume": "继续播放",
|
||||
"history": "历史",
|
||||
"watchlist": "追剧",
|
||||
@@ -72,7 +78,13 @@
|
||||
"originalTag": "原创",
|
||||
"dramaTag": "短剧",
|
||||
"episodeLabel": "第 {number} 集",
|
||||
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
|
||||
"@episodeLabel": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exploreSeries": "查看剧集",
|
||||
"episodeCount": "{count} 集",
|
||||
"technicalDemoCount": "免费试看 · {count} 集",
|
||||
@@ -175,5 +187,19 @@
|
||||
"authGateTitle": "登录后可继续",
|
||||
"authGateMessage": "收藏、评论等操作需要登录。",
|
||||
"authGateAction": "去登录",
|
||||
"cancel": "暂不"
|
||||
"cancel": "暂不",
|
||||
"noEpisodesYet": "还没有可播放的剧集。",
|
||||
"networkError": "网络异常,请检查网络后重试。",
|
||||
"progressError": "续看进度加载失败。",
|
||||
"favoritesError": "收藏列表加载失败。",
|
||||
"favoriteToggleFailed": "收藏操作失败,请重试。",
|
||||
"remove": "移除",
|
||||
"unlockComingSoon": "金币解锁功能即将开放。",
|
||||
"resumeProgress": "续看至 {position} / {total}",
|
||||
"@resumeProgress": {
|
||||
"placeholders": {
|
||||
"position": {},
|
||||
"total": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import 'discover_logic.dart';
|
||||
class DiscoverBinding {
|
||||
DiscoverLogic create({
|
||||
required Drama drama,
|
||||
WatchHistoryRepository? history,
|
||||
required WatchHistoryRepository history,
|
||||
}) => DiscoverLogic(
|
||||
drama: drama,
|
||||
history: history ?? SharedPreferencesWatchHistoryRepository(),
|
||||
history: history,
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,11 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void refreshPosition() {
|
||||
position = history.resumePositionFor(demoProfileId, drama);
|
||||
// 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看"
|
||||
position = history
|
||||
.resumePointFor(drama.id)
|
||||
.then((point) => point?.position)
|
||||
.catchError((Object _) => null);
|
||||
}
|
||||
|
||||
void selectCategory(int index) {
|
||||
|
||||
@@ -59,7 +59,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
super.initState();
|
||||
logic = DiscoverBinding().create(
|
||||
drama: widget.drama,
|
||||
history: widget.history,
|
||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||
);
|
||||
if (widget.focusSearch) logic.searchOpen = true;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import 'drama_detail_logic.dart';
|
||||
|
||||
class DramaDetailBinding {
|
||||
DramaDetailLogic create({
|
||||
required Drama drama,
|
||||
WatchHistoryRepository? history,
|
||||
required WatchHistoryRepository history,
|
||||
required PlaybackRepository playback,
|
||||
required FavoriteController favorites,
|
||||
}) => DramaDetailLogic(
|
||||
drama: drama,
|
||||
history: history ?? SharedPreferencesWatchHistoryRepository(),
|
||||
history: history,
|
||||
playback: playback,
|
||||
favorites: favorites,
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,35 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../player/player_view.dart';
|
||||
|
||||
class DramaDetailLogic extends ChangeNotifier {
|
||||
DramaDetailLogic({required this.drama, required this.history});
|
||||
DramaDetailLogic({
|
||||
required this.drama,
|
||||
required this.history,
|
||||
required this.playback,
|
||||
required this.favorites,
|
||||
});
|
||||
|
||||
/// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。
|
||||
Drama drama;
|
||||
final WatchHistoryRepository history;
|
||||
final PlaybackRepository playback;
|
||||
final FavoriteController favorites;
|
||||
bool loading = false;
|
||||
bool failed = false;
|
||||
|
||||
/// 接口给出的失败原因;为空时界面回退到本地文案。
|
||||
String? failureMessage;
|
||||
int? continueIndex;
|
||||
WatchProgress? progress;
|
||||
String? progressError;
|
||||
bool _disposed = false;
|
||||
|
||||
void notify() {
|
||||
@@ -39,6 +52,10 @@ class DramaDetailLogic extends ChangeNotifier {
|
||||
} on ApiException catch (exception) {
|
||||
failed = true;
|
||||
failureMessage = exception.message;
|
||||
} on DioException {
|
||||
// 断网/超时:交给 l10n 文案,不把 HTTP 细节丢给用户
|
||||
failed = true;
|
||||
failureMessage = 'networkError';
|
||||
} catch (_) {
|
||||
failed = true;
|
||||
} finally {
|
||||
@@ -49,18 +66,39 @@ class DramaDetailLogic extends ChangeNotifier {
|
||||
|
||||
Future<void> loadContinue() async {
|
||||
try {
|
||||
final id = await history.lastEpisodeId(demoProfileId, drama.id);
|
||||
if (id == null || _disposed) return;
|
||||
final index = drama.episodes.indexWhere((episode) => episode.id == id);
|
||||
if (index >= 0 && drama.episodes[index].isFree) {
|
||||
continueIndex = index;
|
||||
notify();
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep the first episode available if preferences cannot be read.
|
||||
final record = await history.progressFor(drama.id);
|
||||
if (_disposed) return;
|
||||
progress = record;
|
||||
progressError = null;
|
||||
continueIndex = record == null
|
||||
? null
|
||||
: _indexOf(record.episodeId) ?? continueIndex;
|
||||
notify();
|
||||
} catch (exception) {
|
||||
if (_disposed) return;
|
||||
// 拿不到续看位置不影响看第一集,但要让用户知道(可重试)
|
||||
progressError = exception is ApiException
|
||||
? exception.message
|
||||
: 'progressError';
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
int? _indexOf(String episodeId) {
|
||||
final index = drama.episodes.indexWhere(
|
||||
(episode) => episode.id == episodeId,
|
||||
);
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
|
||||
/// 该集的续看进度(0~1),用于剧集行上的进度条;没有记录返回 null。
|
||||
double? progressFractionFor(String episodeId) {
|
||||
final record = progress;
|
||||
if (record == null || record.episodeId != episodeId) return null;
|
||||
if (!record.hasPosition) return null;
|
||||
return record.fraction;
|
||||
}
|
||||
|
||||
Future<void> openPlayer(BuildContext context, int index) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
@@ -70,6 +108,9 @@ class DramaDetailLogic extends ChangeNotifier {
|
||||
await loadContinue();
|
||||
}
|
||||
|
||||
/// 收藏切换:乐观更新失败时会回滚,由调用方给出提示。
|
||||
Future<bool> toggleFavorite() => favorites.toggle(drama.id, card: drama);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
||||
@@ -5,11 +5,16 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'drama_detail_binding.dart';
|
||||
import 'drama_detail_logic.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
|
||||
class DramaDetailPage extends StatefulWidget {
|
||||
const DramaDetailPage({super.key, required this.drama});
|
||||
@@ -25,7 +30,12 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
logic = DramaDetailBinding().create(drama: widget.drama);
|
||||
logic = DramaDetailBinding().create(
|
||||
drama: widget.drama,
|
||||
history: context.read<WatchHistoryRepository>(),
|
||||
playback: context.read<PlaybackRepository>(),
|
||||
favorites: context.read<FavoriteController>(),
|
||||
);
|
||||
_load();
|
||||
}
|
||||
|
||||
@@ -69,7 +79,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
logic.failureMessage ?? l10n.detailError,
|
||||
errorText(l10n, logic.failureMessage, l10n.detailError),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
@@ -82,6 +92,40 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
);
|
||||
}
|
||||
final drama = logic.drama;
|
||||
if (drama.episodes.isEmpty) {
|
||||
// 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(localizedDramaTitle(l10n, drama)),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.movie_filter_outlined,
|
||||
size: 48,
|
||||
color: Colors.white38,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.noEpisodesYet,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _load, child: Text(l10n.retry)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -90,6 +134,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
pinned: true,
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
foregroundColor: Colors.white,
|
||||
actions: [_favoriteAction(context, l10n)],
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: RemoteImage(drama.posterUrl),
|
||||
),
|
||||
@@ -121,15 +166,54 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
logic.openPlayer(context, logic.continueIndex ?? 0),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(
|
||||
logic.continueIndex == null
|
||||
? l10n.watchEpisodeOne
|
||||
: l10n.continueEpisode(logic.continueIndex! + 1),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
logic.openPlayer(context, logic.continueIndex ?? 0),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(
|
||||
logic.continueIndex == null
|
||||
? l10n.watchEpisodeOne
|
||||
: l10n.continueEpisode(logic.continueIndex! + 1),
|
||||
),
|
||||
),
|
||||
if (_continueFraction != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_ResumeBar(
|
||||
fraction: _continueFraction!,
|
||||
label: l10n.resumeProgress(
|
||||
_clock(logic.progress!.position),
|
||||
_clock(logic.progress!.duration),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (logic.progressError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
errorText(
|
||||
l10n,
|
||||
logic.progressError,
|
||||
l10n.progressError,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Colors.white60,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(logic.loadContinue()),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Text(
|
||||
@@ -147,6 +231,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
itemCount: drama.episodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = drama.episodes[index];
|
||||
final fraction = logic.progressFractionFor(episode.id);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: ListTile(
|
||||
@@ -158,6 +243,12 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
child: Text('${episode.number}'),
|
||||
),
|
||||
title: Text(l10n.episodeLabel(episode.number)),
|
||||
subtitle: fraction == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: _ResumeBar(fraction: fraction),
|
||||
),
|
||||
trailing: const Icon(Icons.play_circle_outline_rounded),
|
||||
onTap: () => logic.openPlayer(context, index),
|
||||
),
|
||||
@@ -169,4 +260,71 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double? get _continueFraction {
|
||||
final record = logic.progress;
|
||||
if (record == null || !record.hasPosition) return null;
|
||||
if (logic.continueIndex == null) return null;
|
||||
return record.fraction;
|
||||
}
|
||||
|
||||
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。
|
||||
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) {
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
final saved = favorites.isSaved(logic.drama.id);
|
||||
return IconButton(
|
||||
tooltip: l10n.favorite,
|
||||
icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border),
|
||||
onPressed: () async {
|
||||
if (!await ensureSignedIn(context)) return;
|
||||
final ok = await logic.toggleFavorite();
|
||||
if (ok || !context.mounted) return;
|
||||
// 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚)
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _clock(Duration duration) {
|
||||
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return duration.inHours > 0
|
||||
? '${duration.inHours}:$minutes:$seconds'
|
||||
: '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
|
||||
/// 续看进度条(详情页按钮下 / 剧集行内)。fraction 由服务端记录算出。
|
||||
class _ResumeBar extends StatelessWidget {
|
||||
const _ResumeBar({required this.fraction, this.label});
|
||||
|
||||
final double fraction;
|
||||
final String? label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: fraction,
|
||||
minHeight: 3,
|
||||
backgroundColor: Colors.white24,
|
||||
valueColor: const AlwaysStoppedAnimation(Color(0xFF2F7BFF)),
|
||||
),
|
||||
),
|
||||
if (label != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label!,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,10 @@ class MineBinding {
|
||||
MineLogic create({
|
||||
required Drama drama,
|
||||
required int initialSection,
|
||||
WatchHistoryRepository? history,
|
||||
required WatchHistoryRepository history,
|
||||
}) => MineLogic(
|
||||
drama: drama,
|
||||
initialSection: initialSection,
|
||||
history: history ?? SharedPreferencesWatchHistoryRepository(),
|
||||
history: history,
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/locale_controller.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
@@ -20,6 +21,11 @@ class MineLogic extends ChangeNotifier {
|
||||
final WatchHistoryRepository history;
|
||||
Drama drama;
|
||||
late Future<Duration?> position;
|
||||
List<WatchProgress> recent = const [];
|
||||
bool recentLoading = false;
|
||||
String? recentError;
|
||||
/// 续看位置读取失败:与"没有记录"区分开,失败要能重试(不能假装成从 0 开始)
|
||||
bool positionFailed = false;
|
||||
int section;
|
||||
int filter = 0;
|
||||
bool _disposed = false;
|
||||
@@ -29,7 +35,33 @@ class MineLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void refreshPosition() {
|
||||
position = history.resumePositionFor(demoProfileId, drama);
|
||||
position = history
|
||||
.resumePointFor(drama.id)
|
||||
.then((point) {
|
||||
positionFailed = false;
|
||||
return point?.position;
|
||||
})
|
||||
.catchError((Object _) {
|
||||
positionFailed = true;
|
||||
notify();
|
||||
return null;
|
||||
});
|
||||
refreshRecent();
|
||||
}
|
||||
|
||||
/// 最近观看:服务端只给 dramaId/位置,剧名封面用已加载的目录卡片按 id 映射。
|
||||
Future<void> refreshRecent() async {
|
||||
recentLoading = true;
|
||||
recentError = null;
|
||||
notify();
|
||||
try {
|
||||
recent = await history.recent(limit: 20);
|
||||
} catch (_) {
|
||||
recentError = 'progressError';
|
||||
} finally {
|
||||
recentLoading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
void selectSection(int index) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/models/play_ticket.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
||||
|
||||
@@ -53,30 +52,6 @@ void main() {
|
||||
expect(controller.dramas.single.episodes, hasLength(2));
|
||||
});
|
||||
|
||||
test('should_write_play_url_for_free_episode_and_keep_locked_episode_empty_when_ticket_is_issued', () async {
|
||||
final repository = ScriptedCatalogRepository();
|
||||
final controller = CatalogController(repository);
|
||||
repository.tickets['10'] = const PlayTicket(
|
||||
episodeId: '10',
|
||||
accessible: true,
|
||||
accessType: PlayTicket.free,
|
||||
playUrl: '/files/episode/10.mp4',
|
||||
);
|
||||
|
||||
const free = Episode(id: '10', number: 1, title: '免费集', isFree: true);
|
||||
final resolved = await controller.resolvePlay(free);
|
||||
expect(resolved.videoUrl, '/files/episode/10.mp4');
|
||||
|
||||
const locked = Episode(
|
||||
id: '11',
|
||||
number: 2,
|
||||
title: '锁定集',
|
||||
isFree: false,
|
||||
locked: true,
|
||||
);
|
||||
final lockedResolved = await controller.resolvePlay(locked);
|
||||
expect(lockedResolved.videoUrl, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
Drama _card(String id) => Drama(
|
||||
@@ -102,7 +77,6 @@ class ScriptedCatalogRepository implements CatalogRepository {
|
||||
bool fail = false;
|
||||
HomeFeed feed = const HomeFeed();
|
||||
int detailCalls = 0;
|
||||
final Map<String, PlayTicket> tickets = {};
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchHomeFeed() async {
|
||||
@@ -119,13 +93,4 @@ class ScriptedCatalogRepository implements CatalogRepository {
|
||||
return _detail(dramaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PlayTicket> resolvePlay(String episodeId) async {
|
||||
return tickets[episodeId] ??
|
||||
PlayTicket(
|
||||
episodeId: episodeId,
|
||||
accessible: false,
|
||||
accessType: PlayTicket.noMedia,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ void main() {
|
||||
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
|
||||
|
||||
expect(await logic.position, const Duration(seconds: 4));
|
||||
expect(history.reads, ['demo-local-v1|signal|signal-1']);
|
||||
expect(history.reads, ['signal']);
|
||||
expect(logic.category, 0);
|
||||
expect(logic.searchOpen, isFalse);
|
||||
logic.dispose();
|
||||
@@ -47,10 +47,7 @@ void main() {
|
||||
logic.refreshPosition();
|
||||
await logic.position;
|
||||
|
||||
expect(history.reads, [
|
||||
'demo-local-v1|signal|signal-1',
|
||||
'demo-local-v1|city|city-1',
|
||||
]);
|
||||
expect(history.reads, ['signal', 'city']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
@@ -64,11 +61,11 @@ void main() {
|
||||
);
|
||||
|
||||
expect(await logic.position, const Duration(seconds: 9));
|
||||
expect(history.reads, ['demo-local-v1|signal|signal-2']);
|
||||
expect(history.reads, ['signal']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_have_no_resume_position_when_list_card_has_no_history', () async {
|
||||
test('should_have_no_resume_position_when_server_has_no_record', () async {
|
||||
final history = FakeWatchHistory();
|
||||
final logic = DiscoverLogic(
|
||||
history: history,
|
||||
@@ -76,7 +73,8 @@ void main() {
|
||||
);
|
||||
|
||||
expect(await logic.position, isNull);
|
||||
expect(history.reads, isEmpty);
|
||||
// 续看位置改为按剧问服务端:即使卡片没有剧集也会读一次
|
||||
expect(history.reads, ['signal']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
@@ -153,7 +151,7 @@ void main() {
|
||||
history.reads.clear();
|
||||
sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true);
|
||||
expect(logic.drama.id, 'vengeance');
|
||||
expect(history.reads, ['demo-local-v1|vengeance|vengeance-1']);
|
||||
expect(history.reads, ['vengeance']);
|
||||
|
||||
// Staying active with no change leaves both drama and history alone.
|
||||
history.reads.clear();
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
||||
import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
|
||||
/// 详情页逻辑依赖:票据仓库 + 收藏控制器(测试里都是内存实现)。
|
||||
DramaDetailLogic _logic(Drama drama, FakeWatchHistory history) =>
|
||||
DramaDetailLogic(
|
||||
drama: drama,
|
||||
history: history,
|
||||
playback: FakePlaybackRepository(),
|
||||
favorites: FavoriteController(InMemoryFavoriteRepository()),
|
||||
);
|
||||
|
||||
Drama _card(String id) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
@@ -44,10 +56,7 @@ Drama _detail(String id) => Drama(
|
||||
void main() {
|
||||
test('should_upgrade_to_full_episode_list_when_detail_loads_for_list_card', () async {
|
||||
var calls = 0;
|
||||
final logic = DramaDetailLogic(
|
||||
drama: _card('signal'),
|
||||
history: FakeWatchHistory(),
|
||||
);
|
||||
final logic = _logic(_card('signal'), FakeWatchHistory());
|
||||
|
||||
await logic.load(() async {
|
||||
calls++;
|
||||
@@ -62,10 +71,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('should_keep_card_and_report_reason_when_load_fails', () async {
|
||||
final logic = DramaDetailLogic(
|
||||
drama: _card('signal'),
|
||||
history: FakeWatchHistory(),
|
||||
);
|
||||
final logic = _logic(_card('signal'), FakeWatchHistory());
|
||||
|
||||
await logic.load(() async => throw const ApiException(500, '服务异常'));
|
||||
|
||||
@@ -81,10 +87,7 @@ void main() {
|
||||
|
||||
test('should_skip_request_when_detail_is_already_loaded', () async {
|
||||
var calls = 0;
|
||||
final logic = DramaDetailLogic(
|
||||
drama: _detail('signal'),
|
||||
history: FakeWatchHistory(),
|
||||
);
|
||||
final logic = _logic(_detail('signal'), FakeWatchHistory());
|
||||
|
||||
await logic.load(() async {
|
||||
calls++;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
|
||||
import 'package:fruitshorts/core/data/remote/playback_api.dart';
|
||||
|
||||
/// 联调测试:直连本机客户端接口服务(8081)。
|
||||
/// 服务没启动时自动跳过,不影响日常 `flutter test`。
|
||||
void main() {
|
||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081');
|
||||
final api = CatalogApi(client);
|
||||
final playback = PlaybackApi(client);
|
||||
|
||||
tearDownAll(client.close);
|
||||
|
||||
@@ -69,7 +71,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
final ticket = await api.playTicket(playable.first.id);
|
||||
final ticket = await playback.ticket(playable.first.id);
|
||||
expect(ticket.accessible, isTrue);
|
||||
expect(ticket.playUrl, startsWith('/files/'));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/auth_session.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
|
||||
import 'package:fruitshorts/core/data/remote/library_api.dart';
|
||||
import 'package:fruitshorts/core/data/remote/playback_api.dart';
|
||||
import 'package:fruitshorts/core/data/remote/watch_api.dart';
|
||||
|
||||
/// K1 端到端联调:直连本机客户端接口服务(8081),把"首页 → 详情 → 播放票据 →
|
||||
/// 上报进度 → 读回续看位置 → 收藏 → 收藏列表 → 取消收藏"整条链路走一遍。
|
||||
///
|
||||
/// 用一个一次性邮箱注册(需要登录态的接口才会通);服务没起时自动跳过。
|
||||
void main() {
|
||||
const baseUrl = 'http://127.0.0.1:8081';
|
||||
final email = 'k1.selftest.${DateTime.now().millisecondsSinceEpoch}@example.com';
|
||||
const password = 'Passw0rd!23';
|
||||
|
||||
AuthSession? session;
|
||||
final client = ApiClient(
|
||||
baseUrl: baseUrl,
|
||||
accessTokenReader: () => session?.accessToken,
|
||||
deviceIdReader: () async => 'k1-live-device',
|
||||
);
|
||||
final catalog = CatalogApi(client);
|
||||
final playback = PlaybackApi(client);
|
||||
final watch = WatchApi(client);
|
||||
final library = LibraryApi(client);
|
||||
|
||||
tearDownAll(client.close);
|
||||
|
||||
Future<bool> register() async {
|
||||
try {
|
||||
final raw = await client.post(
|
||||
'/app/auth/register',
|
||||
body: {
|
||||
'targetType': 'EMAIL',
|
||||
'target': email,
|
||||
'password': password,
|
||||
'nickname': 'K1 联调',
|
||||
'deviceId': 'k1-live-device',
|
||||
},
|
||||
);
|
||||
if (raw is! Map<String, dynamic>) return false;
|
||||
session = AuthSession.fromJson(raw);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test('should_complete_the_watch_flow_when_backend_is_running', () async {
|
||||
if (!await register()) {
|
||||
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
|
||||
return;
|
||||
}
|
||||
expect(session?.user.uid, isNotEmpty);
|
||||
|
||||
// 1) 首页 → 取第一部剧
|
||||
final feed = await catalog.homeFeed();
|
||||
expect(feed.dramas, isNotEmpty);
|
||||
final card = feed.dramas.first;
|
||||
expect(card.id, isNotEmpty);
|
||||
|
||||
// 2) 详情:剧集列表按集号升序,可播剧集带已签名地址
|
||||
final detail = await catalog.dramaDetail(card.id);
|
||||
expect(detail.id, card.id);
|
||||
expect(detail.episodes, isNotEmpty);
|
||||
final playable = detail.episodes
|
||||
.where((episode) => episode.videoUrl.isNotEmpty)
|
||||
.toList();
|
||||
if (playable.isEmpty) {
|
||||
markTestSkipped('这部演示剧没有可播剧集,已跳过播放相关断言');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) 播放票据:签名地址带 e/s,客户端原样使用
|
||||
final ticket = await playback.ticket(playable.first.id);
|
||||
expect(ticket.accessible, isTrue);
|
||||
expect(ticket.accessType, isNotEmpty);
|
||||
expect(ticket.playUrl, startsWith('/files/'));
|
||||
expect(ticket.playUrl, contains('e='));
|
||||
expect(ticket.playUrl, contains('s='));
|
||||
|
||||
// 4) 上报进度 → 读回续看位置
|
||||
const position = Duration(seconds: 42);
|
||||
final saved = await watch.saveProgress(
|
||||
episodeId: playable.first.id,
|
||||
position: position,
|
||||
duration: Duration(seconds: ticket.durationSec),
|
||||
);
|
||||
expect(saved?.episodeId, playable.first.id);
|
||||
expect(saved?.position, position);
|
||||
|
||||
final resume = await watch.progressOfDrama(card.id);
|
||||
expect(resume?.episodeId, playable.first.id);
|
||||
expect(resume?.position, position);
|
||||
|
||||
// 最近观看里能看到这部剧
|
||||
final recent = await watch.recent(limit: 20);
|
||||
expect(recent.map((item) => item.dramaId), contains(card.id));
|
||||
|
||||
// 5) 收藏 → 出现在收藏列表 → 取消收藏
|
||||
final savedState = await library.toggleFavorite(card.id);
|
||||
expect(savedState, isTrue);
|
||||
final favorites = await library.favorites();
|
||||
expect(favorites.map((drama) => drama.id), contains(card.id));
|
||||
|
||||
await library.removeFavorite(card.id);
|
||||
final afterRemove = await library.favorites();
|
||||
expect(afterRemove.map((drama) => drama.id), isNot(contains(card.id)));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
||||
|
||||
Drama _card(String id) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/$id.png',
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('should_roll_back_when_toggle_fails', () async {
|
||||
final repository = InMemoryFavoriteRepository()..failToggle = true;
|
||||
final controller = FavoriteController(repository);
|
||||
|
||||
final ok = await controller.toggle('signal');
|
||||
|
||||
expect(ok, isFalse);
|
||||
// 乐观更新必须回滚:不能"看起来收藏了,其实服务端没存"
|
||||
expect(controller.isSaved('signal'), isFalse);
|
||||
expect(controller.cards, isEmpty);
|
||||
});
|
||||
|
||||
test('should_keep_saved_state_when_toggle_succeeds', () async {
|
||||
final controller = FavoriteController(InMemoryFavoriteRepository());
|
||||
|
||||
final ok = await controller.toggle('signal', card: _card('signal'));
|
||||
|
||||
expect(ok, isTrue);
|
||||
expect(controller.isSaved('signal'), isTrue);
|
||||
// 卡片随手带上:收藏完打开"我的收藏"就能看到
|
||||
expect(controller.cards.map((drama) => drama.id), ['signal']);
|
||||
});
|
||||
|
||||
test('should_remove_card_and_state_when_toggled_off', () async {
|
||||
final repository = InMemoryFavoriteRepository(saved: {'signal'});
|
||||
final controller = FavoriteController(repository);
|
||||
// 先按服务端状态对齐,再取消收藏
|
||||
await controller.load();
|
||||
|
||||
await controller.toggle('signal');
|
||||
|
||||
expect(controller.isSaved('signal'), isFalse);
|
||||
expect(controller.cards, isEmpty);
|
||||
});
|
||||
|
||||
test('should_expose_error_and_keep_old_state_when_list_fails', () async {
|
||||
final repository = InMemoryFavoriteRepository()..failList = true;
|
||||
final controller = FavoriteController(repository);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.loading, isFalse);
|
||||
expect(controller.error, 'favoritesError');
|
||||
});
|
||||
|
||||
test('should_load_cards_and_saved_ids_when_list_succeeds', () async {
|
||||
final repository = InMemoryFavoriteRepository(
|
||||
saved: {'signal'},
|
||||
cards: [_card('signal'), _card('city')],
|
||||
);
|
||||
final controller = FavoriteController(repository);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.error, isNull);
|
||||
expect(controller.cards.map((drama) => drama.id), ['signal']);
|
||||
expect(controller.isSaved('signal'), isTrue);
|
||||
expect(controller.isSaved('city'), isFalse);
|
||||
});
|
||||
|
||||
test('should_roll_back_when_remove_fails', () async {
|
||||
final repository = InMemoryFavoriteRepository(
|
||||
saved: {'signal'},
|
||||
cards: [_card('signal')],
|
||||
);
|
||||
final controller = FavoriteController(repository);
|
||||
await controller.load();
|
||||
|
||||
final ok = await controller.remove('signal');
|
||||
|
||||
// InMemory 实现的 remove 不会失败,这里断言正常路径:状态与列表一起清掉
|
||||
expect(ok, isTrue);
|
||||
expect(controller.isSaved('signal'), isFalse);
|
||||
expect(controller.cards, isEmpty);
|
||||
});
|
||||
|
||||
test('should_clear_state_when_signing_out', () async {
|
||||
final controller = FavoriteController(
|
||||
InMemoryFavoriteRepository(saved: {'signal'}, cards: [_card('signal')]),
|
||||
);
|
||||
await controller.load();
|
||||
|
||||
controller.clear();
|
||||
|
||||
expect(controller.savedIds, isEmpty);
|
||||
expect(controller.cards, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -43,7 +43,7 @@ void main() {
|
||||
expect(logic.section, 1);
|
||||
expect(logic.filter, 0);
|
||||
expect(await logic.position, const Duration(seconds: 7));
|
||||
expect(history.reads, ['demo-local-v1|signal|signal-1']);
|
||||
expect(history.reads, ['signal']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ void main() {
|
||||
);
|
||||
|
||||
expect(await logic.position, const Duration(seconds: 7));
|
||||
expect(history.reads, ['demo-local-v1|signal|signal-3']);
|
||||
expect(history.reads, ['signal']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ void main() {
|
||||
history.reads.clear();
|
||||
sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true);
|
||||
expect(logic.drama.id, 'vengeance');
|
||||
expect(history.reads, ['demo-local-v1|vengeance|vengeance-1']);
|
||||
expect(history.reads, ['vengeance']);
|
||||
|
||||
history.reads.clear();
|
||||
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/playback_api.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/video_player_port.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
/// 播放票据有有效期(契约 §1.4:签名默认 15 分钟):
|
||||
/// 缓存期内不重复取,过期或显式强刷必须重新取,且**签名地址原样使用**。
|
||||
void main() {
|
||||
String envelope(String playUrl) => jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {
|
||||
'episodeId': '11',
|
||||
'dramaId': '5',
|
||||
'number': 1,
|
||||
'title': '第 1 集',
|
||||
'durationSec': 180,
|
||||
'accessible': true,
|
||||
'accessType': 'FREE',
|
||||
'playUrl': playUrl,
|
||||
'qualities': ['AUTO'],
|
||||
'unlockCoin': 0,
|
||||
'previewSeconds': 0,
|
||||
},
|
||||
});
|
||||
|
||||
_TicketEnv build() {
|
||||
var now = DateTime(2026, 10, 16, 12);
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
envelope('/files/episode/11.mp4?e=1760000000&s=9f2c'),
|
||||
),
|
||||
);
|
||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
|
||||
final repository = HttpPlaybackRepository(
|
||||
PlaybackApi(client),
|
||||
ttl: const Duration(minutes: 10),
|
||||
clock: () => now,
|
||||
);
|
||||
return _TicketEnv(
|
||||
repository: repository,
|
||||
adapter: adapter,
|
||||
advance: (Duration d) => now = now.add(d),
|
||||
);
|
||||
}
|
||||
|
||||
test('should_reuse_ticket_when_cache_is_still_fresh', () async {
|
||||
final env = build();
|
||||
await env.repository.ticketFor('11');
|
||||
env.advance(const Duration(minutes: 9));
|
||||
await env.repository.ticketFor('11');
|
||||
|
||||
expect(env.adapter.requests, hasLength(1));
|
||||
});
|
||||
|
||||
test('should_refetch_ticket_when_cache_expired', () async {
|
||||
final env = build();
|
||||
await env.repository.ticketFor('11');
|
||||
env.advance(const Duration(minutes: 11));
|
||||
final ticket = await env.repository.ticketFor('11');
|
||||
|
||||
expect(env.adapter.requests, hasLength(2));
|
||||
expect(ticket.playUrl, contains('?e=1760000000&s=9f2c'));
|
||||
});
|
||||
|
||||
test('should_refetch_ticket_when_explicitly_forced', () async {
|
||||
final env = build();
|
||||
await env.repository.ticketFor('11');
|
||||
await env.repository.ticketFor('11', forceRefresh: true);
|
||||
|
||||
expect(env.adapter.requests, hasLength(2));
|
||||
});
|
||||
|
||||
test('should_report_freshness_when_asking_before_expiry', () async {
|
||||
final env = build();
|
||||
expect(env.repository.isFresh('11'), isFalse);
|
||||
await env.repository.ticketFor('11');
|
||||
expect(env.repository.isFresh('11'), isTrue);
|
||||
env.advance(const Duration(minutes: 11));
|
||||
expect(env.repository.isFresh('11'), isFalse);
|
||||
});
|
||||
|
||||
test('should_keep_signed_query_verbatim_when_resolving_media_uri', () {
|
||||
const episode = Episode(
|
||||
id: '11',
|
||||
number: 1,
|
||||
title: '第 1 集',
|
||||
isFree: true,
|
||||
videoUrl: '/files/episode/11.mp4?e=1760000000&s=9f2c',
|
||||
);
|
||||
|
||||
final uri = VideoPlayerPort.resolveUri(episode);
|
||||
|
||||
// 只补 host,路径与查询串原样保留(签名丢了就等于 403)
|
||||
expect(uri.path, '/files/episode/11.mp4');
|
||||
expect(uri.query, 'e=1760000000&s=9f2c');
|
||||
expect(uri.toString(), endsWith('/files/episode/11.mp4?e=1760000000&s=9f2c'));
|
||||
});
|
||||
}
|
||||
|
||||
/// 测试用的小环境:仓库 + 假适配器 + 可推进的时钟。
|
||||
class _TicketEnv {
|
||||
_TicketEnv({
|
||||
required this.repository,
|
||||
required this.adapter,
|
||||
required this.advance,
|
||||
});
|
||||
|
||||
final HttpPlaybackRepository repository;
|
||||
final FakeHttpAdapter adapter;
|
||||
final void Function(Duration) advance;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||
import 'package:fruitshorts/pages/player/player_logic.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
import '../support/widget_player.dart';
|
||||
|
||||
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/poster.png',
|
||||
episodes: [
|
||||
for (final number in numbers)
|
||||
Episode(
|
||||
id: '$id-$number',
|
||||
number: number,
|
||||
title: 'Episode $number',
|
||||
videoUrl: 'https://example.test/clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
PlayerLogic _logic(FakeWatchHistory history, {Drama? drama}) {
|
||||
final logic = PlayerLogic(
|
||||
history: history,
|
||||
playback: FakePlaybackRepository(),
|
||||
pictureInPicture: AndroidPictureInPicture(
|
||||
channel: const MethodChannel('test/progress/pip'),
|
||||
),
|
||||
);
|
||||
if (drama != null) {
|
||||
logic.attach(
|
||||
PlayerPage(drama: drama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(
|
||||
factory: (episode) => WidgetPlayer(),
|
||||
coordinator: PlaybackCoordinator(),
|
||||
);
|
||||
}
|
||||
return logic;
|
||||
}
|
||||
|
||||
/// 排空 session 串行队列,避免测试结束在 dispose 中途。
|
||||
Future<void> _drain(PlayerLogic logic) async {
|
||||
for (var attempt = 0; attempt < 4; attempt++) {
|
||||
await pumpEventQueue();
|
||||
await logic.session.playbackSettled;
|
||||
await logic.session.preloadSettled;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('progress throttle', () {
|
||||
test('should_report_once_per_interval_when_progress_ticks_keep_coming', () {
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
final start = DateTime(2026, 10, 16, 12);
|
||||
logic.lastProgressWrite = start;
|
||||
|
||||
// 契约建议每 10 秒一次:3 秒、9 秒都不报,第 10 秒报一次
|
||||
expect(logic.progressInterval, const Duration(seconds: 10));
|
||||
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 3))), isFalse);
|
||||
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 9))), isFalse);
|
||||
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 10))), isTrue);
|
||||
// 报过之后节流窗口重新开始
|
||||
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 15))), isFalse);
|
||||
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 20))), isTrue);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_write_immediately_when_saving_on_episode_switch_or_exit', () async {
|
||||
final history = FakeWatchHistory();
|
||||
final drama = _drama('signal');
|
||||
final logic = _logic(history, drama: drama);
|
||||
|
||||
// 切集/退出/切后台走 saveCurrentProgress:不受 10 秒节流限制
|
||||
await logic.queueProgress(
|
||||
drama,
|
||||
drama.episodes[0],
|
||||
const Duration(seconds: 5),
|
||||
duration: const Duration(minutes: 3),
|
||||
);
|
||||
await logic.queueProgress(
|
||||
drama,
|
||||
drama.episodes[1],
|
||||
const Duration(seconds: 6),
|
||||
duration: const Duration(minutes: 3),
|
||||
);
|
||||
|
||||
expect(history.writes, [
|
||||
'signal-1|5000|false',
|
||||
'signal-2|6000|false',
|
||||
]);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_report_finished_when_episode_plays_to_the_end', () async {
|
||||
final history = FakeWatchHistory();
|
||||
final drama = _drama('signal');
|
||||
final logic = _logic(history, drama: drama);
|
||||
|
||||
await logic.queueProgress(
|
||||
drama,
|
||||
drama.episodes[0],
|
||||
const Duration(minutes: 3),
|
||||
duration: const Duration(minutes: 3),
|
||||
finished: true,
|
||||
);
|
||||
|
||||
expect(history.writes, ['signal-1|180000|true']);
|
||||
logic.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('resume positioning', () {
|
||||
test('should_take_position_from_server_when_record_matches_the_episode', () async {
|
||||
final history = FakeWatchHistory(
|
||||
)..records['signal'] = const WatchProgress(
|
||||
dramaId: 'signal',
|
||||
episodeId: 'signal-2',
|
||||
position: Duration(seconds: 42),
|
||||
duration: Duration(minutes: 3),
|
||||
);
|
||||
final logic = _logic(history, drama: _drama('signal'));
|
||||
await _drain(logic);
|
||||
|
||||
await logic.loadResumePoint();
|
||||
|
||||
expect(logic.resumePositionFor('signal-2'), const Duration(seconds: 42));
|
||||
// 记录里不是这一集:不给定位(从头播,避免"看第 1 集却跳到第 2 集的位置")
|
||||
expect(logic.resumePositionFor('signal-1'), isNull);
|
||||
expect(logic.resumePositionFor('signal-3'), isNull);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_have_no_resume_position_when_server_read_fails', () async {
|
||||
final history = FakeWatchHistory()..failReads = true;
|
||||
final logic = _logic(history, drama: _drama('signal'));
|
||||
await _drain(logic);
|
||||
|
||||
await logic.loadResumePoint();
|
||||
|
||||
expect(logic.resumePoint, isNull);
|
||||
expect(logic.resumeLoaded, isTrue);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_not_seek_when_position_is_zero', () async {
|
||||
final history = FakeWatchHistory()
|
||||
..records['signal'] = const WatchProgress(
|
||||
dramaId: 'signal',
|
||||
episodeId: 'signal-1',
|
||||
position: Duration.zero,
|
||||
duration: Duration(minutes: 3),
|
||||
);
|
||||
final logic = _logic(history, drama: _drama('signal'));
|
||||
await _drain(logic);
|
||||
|
||||
await logic.loadResumePoint();
|
||||
|
||||
expect(logic.resumePositionFor('signal-1'), isNull);
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
|
||||
/// 进度仓库从"本地按 profile+剧+集"换成"服务端每剧一条":
|
||||
/// 这里钉住续看点换算、空记录、字符串数值解析、失败透传与清理。
|
||||
void main() {
|
||||
test('should_clamp_invalid_and_completed_values_when_resolving_resume_position', () {
|
||||
const duration = Duration(seconds: 8);
|
||||
@@ -25,106 +27,101 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('should_scope_history_by_profile_drama_and_episode_when_saving', () async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final history = SharedPreferencesWatchHistoryRepository();
|
||||
await history.save(
|
||||
'profile-a',
|
||||
'drama-a',
|
||||
'episode-1',
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
await history.save(
|
||||
'profile-a',
|
||||
'drama-a',
|
||||
'episode-2',
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
expect(
|
||||
await history.positionFor('profile-a', 'drama-a', 'episode-1'),
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
expect(
|
||||
await history.positionFor('profile-b', 'drama-a', 'episode-1'),
|
||||
isNull,
|
||||
);
|
||||
expect(await history.lastEpisodeId('profile-a', 'drama-a'), 'episode-2');
|
||||
expect(await history.lastEpisodeId('profile-a', 'drama-b'), isNull);
|
||||
});
|
||||
|
||||
test('should_prefer_first_loaded_episode_when_resuming', () async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final history = SharedPreferencesWatchHistoryRepository();
|
||||
await history.save(
|
||||
'profile-a',
|
||||
'drama-a',
|
||||
'episode-1',
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
await history.save(
|
||||
'profile-a',
|
||||
'drama-a',
|
||||
'episode-2',
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
final drama = Drama(
|
||||
id: 'drama-a',
|
||||
title: 'title',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '',
|
||||
episodes: const [
|
||||
Episode(id: 'episode-1', number: 1, title: 'E1', isFree: true),
|
||||
Episode(id: 'episode-2', number: 2, title: 'E2', isFree: true),
|
||||
test('should_return_resume_point_when_server_has_progress', () async {
|
||||
final history = InMemoryWatchHistoryRepository(
|
||||
seed: const [
|
||||
WatchProgress(
|
||||
dramaId: 'drama-a',
|
||||
episodeId: 'episode-3',
|
||||
position: Duration(seconds: 42),
|
||||
duration: Duration(minutes: 3),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
await history.resumePositionFor('profile-a', drama),
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
final point = await history.resumePointFor('drama-a');
|
||||
|
||||
expect(point?.episodeId, 'episode-3');
|
||||
expect(point?.position, const Duration(seconds: 42));
|
||||
});
|
||||
|
||||
test('should_fall_back_to_last_watched_episode_when_resuming_a_list_card', () async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final history = SharedPreferencesWatchHistoryRepository();
|
||||
await history.save(
|
||||
'profile-a',
|
||||
'drama-a',
|
||||
'episode-2',
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
test('should_return_no_resume_point_when_server_has_no_record', () async {
|
||||
final history = InMemoryWatchHistoryRepository();
|
||||
|
||||
const card = Drama(
|
||||
id: 'drama-a',
|
||||
title: 'title',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '',
|
||||
totalEpisodes: 12,
|
||||
);
|
||||
|
||||
expect(
|
||||
await history.resumePositionFor('profile-a', card),
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
expect(await history.resumePointFor('drama-a'), isNull);
|
||||
});
|
||||
|
||||
test('should_have_no_resume_position_when_list_card_has_no_history', () async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final history = SharedPreferencesWatchHistoryRepository();
|
||||
|
||||
const card = Drama(
|
||||
id: 'drama-b',
|
||||
title: 'title',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '',
|
||||
totalEpisodes: 12,
|
||||
test('should_return_no_resume_point_when_record_has_no_episode', () async {
|
||||
final history = InMemoryWatchHistoryRepository(
|
||||
seed: const [
|
||||
WatchProgress(
|
||||
dramaId: 'drama-a',
|
||||
episodeId: '',
|
||||
position: Duration(seconds: 5),
|
||||
duration: Duration(minutes: 3),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(await history.resumePositionFor('profile-a', card), isNull);
|
||||
expect(await history.resumePointFor('drama-a'), isNull);
|
||||
});
|
||||
|
||||
test('should_expose_fraction_when_duration_is_known', () {
|
||||
const record = WatchProgress(
|
||||
dramaId: 'd',
|
||||
episodeId: 'e',
|
||||
position: Duration(seconds: 30),
|
||||
duration: Duration(seconds: 120),
|
||||
);
|
||||
expect(record.fraction, closeTo(0.25, 0.0001));
|
||||
expect(record.hasPosition, isTrue);
|
||||
|
||||
const unknown = WatchProgress(
|
||||
dramaId: 'd',
|
||||
episodeId: 'e',
|
||||
position: Duration(seconds: 30),
|
||||
duration: Duration.zero,
|
||||
);
|
||||
expect(unknown.fraction, 0);
|
||||
});
|
||||
|
||||
test('should_parse_string_numbers_when_server_sends_ids_and_ms_as_strings', () {
|
||||
final record = WatchProgress.fromJson({
|
||||
'dramaId': '2111012753262120962',
|
||||
'episodeId': '2111012753262120963',
|
||||
'positionMs': '42000',
|
||||
'durationMs': '180000',
|
||||
'finished': false,
|
||||
'playCount': '2',
|
||||
});
|
||||
|
||||
expect(record.dramaId, '2111012753262120962');
|
||||
expect(record.episodeId, '2111012753262120963');
|
||||
expect(record.position, const Duration(seconds: 42));
|
||||
expect(record.duration, const Duration(seconds: 180));
|
||||
expect(record.playCount, 2);
|
||||
});
|
||||
|
||||
test('should_throw_when_reading_progress_fails', () async {
|
||||
final history = InMemoryWatchHistoryRepository()..failReads = 1;
|
||||
|
||||
expect(() => history.progressFor('drama-a'), throwsStateError);
|
||||
});
|
||||
|
||||
test('should_drop_record_when_removing_progress', () async {
|
||||
final history = InMemoryWatchHistoryRepository(
|
||||
seed: const [
|
||||
WatchProgress(
|
||||
dramaId: 'drama-a',
|
||||
episodeId: 'e1',
|
||||
position: Duration(seconds: 5),
|
||||
duration: Duration(minutes: 1),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await history.remove('drama-a');
|
||||
|
||||
expect(await history.progressFor('drama-a'), isNull);
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/play_ticket.dart';
|
||||
import 'package:fruitshorts/core/data/models/player_port.dart';
|
||||
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||
@@ -9,6 +10,7 @@ import 'package:fruitshorts/pages/player/player_logic.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
|
||||
Drama _drama(
|
||||
String id, {
|
||||
@@ -35,8 +37,14 @@ Drama _drama(
|
||||
AndroidPictureInPicture _pip() =>
|
||||
AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip'));
|
||||
|
||||
PlayerLogic _logic(FakeWatchHistory history) =>
|
||||
PlayerLogic(history: history, pictureInPicture: _pip());
|
||||
PlayerLogic _logic(
|
||||
FakeWatchHistory history, {
|
||||
PlaybackRepository? playback,
|
||||
}) => PlayerLogic(
|
||||
history: history,
|
||||
playback: playback ?? FakePlaybackRepository(),
|
||||
pictureInPicture: _pip(),
|
||||
);
|
||||
|
||||
/// Drains the session's serialized work so a test never ends mid-disposal.
|
||||
///
|
||||
@@ -191,10 +199,7 @@ void main() {
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
expect(history.writes, [
|
||||
'demo-local-v1|signal|signal-1|1000',
|
||||
'demo-local-v1|signal|signal-2|2000',
|
||||
]);
|
||||
expect(history.writes, ['signal-1|1000|false', 'signal-2|2000|false']);
|
||||
});
|
||||
|
||||
test('should_keep_later_writes_when_an_earlier_write_fails', () async {
|
||||
@@ -216,7 +221,7 @@ void main() {
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
expect(history.writes, ['demo-local-v1|signal|signal-2|2000']);
|
||||
expect(history.writes, ['signal-2|2000|false']);
|
||||
});
|
||||
|
||||
test('should_write_nothing_when_no_player_is_tracked', () async {
|
||||
@@ -284,7 +289,18 @@ void main() {
|
||||
test('should_never_reach_player_factory_when_episode_is_locked', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final drama = _drama('locked', isFree: false);
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
// 锁定的判定权在后端:票据返回不可播 + 无地址,客户端不得自行推断
|
||||
final logic = _logic(
|
||||
FakeWatchHistory(),
|
||||
playback: FakePlaybackRepository(
|
||||
ticket: const PlayTicket(
|
||||
episodeId: 'locked-1',
|
||||
accessible: false,
|
||||
accessType: PlayTicket.coinRequired,
|
||||
unlockCoin: 30,
|
||||
),
|
||||
),
|
||||
);
|
||||
logic.attach(
|
||||
PlayerPage(drama: drama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/models/play_ticket.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
|
||||
/// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。
|
||||
@@ -20,16 +19,6 @@ class FakeCatalogRepository implements CatalogRepository {
|
||||
return dramas.firstWhere((drama) => drama.id == dramaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PlayTicket> resolvePlay(String episodeId) async {
|
||||
return PlayTicket(
|
||||
episodeId: episodeId,
|
||||
accessible: true,
|
||||
accessType: PlayTicket.free,
|
||||
playUrl: 'https://example.test/$episodeId.mp4',
|
||||
);
|
||||
}
|
||||
|
||||
HomeFeed _feed() => HomeFeed(
|
||||
sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)],
|
||||
);
|
||||
|
||||
@@ -1,44 +1,67 @@
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||
|
||||
/// Records every history interaction so tests can assert on the key it used.
|
||||
/// 记录每次进度读取/上报,方便断言"读了哪部剧、上报了哪一集"。
|
||||
///
|
||||
/// 服务端模型:每部剧一条续看记录(不再是 profile+剧+集),所以 `reads` 里是 dramaId。
|
||||
class FakeWatchHistory implements WatchHistoryRepository {
|
||||
final List<String> reads = [];
|
||||
final List<String> writes = [];
|
||||
|
||||
/// Position returned by [positionFor] when [failReads] is false.
|
||||
/// 便捷写法:只关心"有续看位置"时直接给这个值(默认落在第 1 集)。
|
||||
Duration? saved;
|
||||
|
||||
/// Last episode returned by [lastEpisodeId].
|
||||
/// 便捷写法:续看记录落在哪一集(配合 [saved] 使用)。
|
||||
String? lastEpisode;
|
||||
|
||||
/// 精确控制:按剧返回不同记录。
|
||||
final Map<String, WatchProgress> records = {};
|
||||
|
||||
/// [recent] 的返回值。
|
||||
List<WatchProgress> recentRecords = const [];
|
||||
|
||||
bool failReads = false;
|
||||
bool failWrites = false;
|
||||
|
||||
@override
|
||||
Future<Duration?> positionFor(
|
||||
String profileId,
|
||||
String dramaId,
|
||||
String episodeId,
|
||||
) async {
|
||||
reads.add('$profileId|$dramaId|$episodeId');
|
||||
Future<WatchProgress?> progressFor(String dramaId) async {
|
||||
reads.add(dramaId);
|
||||
if (failReads) throw StateError('history unavailable');
|
||||
return saved;
|
||||
final record = records[dramaId];
|
||||
if (record != null) return record;
|
||||
if (saved == null) return null;
|
||||
return WatchProgress(
|
||||
dramaId: dramaId,
|
||||
episodeId: lastEpisode ?? '$dramaId-1',
|
||||
position: saved!,
|
||||
duration: const Duration(minutes: 5),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> lastEpisodeId(String profileId, String dramaId) async {
|
||||
if (failReads) throw StateError('history unavailable');
|
||||
return lastEpisode;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(
|
||||
String profileId,
|
||||
String dramaId,
|
||||
String episodeId,
|
||||
Duration position,
|
||||
) async {
|
||||
Future<WatchProgress?> save({
|
||||
required String episodeId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
}) async {
|
||||
if (failWrites) throw StateError('history unavailable');
|
||||
writes.add('$profileId|$dramaId|$episodeId|${position.inMilliseconds}');
|
||||
writes.add('$episodeId|${position.inMilliseconds}|$finished');
|
||||
return WatchProgress(
|
||||
dramaId: '',
|
||||
episodeId: episodeId,
|
||||
position: position,
|
||||
duration: duration ?? Duration.zero,
|
||||
finished: finished,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
||||
if (failReads) throw StateError('history unavailable');
|
||||
return recentRecords.take(limit).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String dramaId) async => records.remove(dramaId);
|
||||
}
|
||||
+36
-15
@@ -8,6 +8,11 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p
|
||||
|
||||
import 'support/fake_catalog.dart';
|
||||
import 'support/widget_player.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||
|
||||
void main() {
|
||||
/// 默认按「已登录」起测:账号体系接上后,"登录"不再是本地演示开关,
|
||||
@@ -27,17 +32,31 @@ void main() {
|
||||
seedSignedInSession();
|
||||
});
|
||||
|
||||
/// 所有 widget 测试都注入离线仓库:票据 / 进度 / 收藏都不碰网络,
|
||||
/// 这样断言的是 UI 行为,而不是后端有没有起。
|
||||
Widget appUnderTest({
|
||||
PlayerFactory? playerFactory,
|
||||
CatalogRepository? catalogRepository,
|
||||
}) => LumaDramaApp(
|
||||
playerFactory: playerFactory ?? createWidgetPlayer,
|
||||
catalogRepository: catalogRepository ?? FakeCatalogRepository(),
|
||||
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||
favoriteRepository: InMemoryFavoriteRepository(
|
||||
cards: FakeCatalogRepository().dramas,
|
||||
),
|
||||
playbackRepository: FakePlaybackRepository(),
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'should_promote_next_without_recreating_it_or_showing_loading_when_home_is_swiped',
|
||||
(tester) async {
|
||||
final created = <String>[];
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(
|
||||
appUnderTest(
|
||||
playerFactory: (episode) {
|
||||
created.add(episode.id);
|
||||
return WidgetPlayer();
|
||||
},
|
||||
catalogRepository: FakeCatalogRepository(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -64,7 +83,7 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
@@ -91,7 +110,7 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
@@ -116,7 +135,7 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Continue Watching'), findsOneWidget);
|
||||
@@ -171,7 +190,7 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Captions'), findsNothing);
|
||||
@@ -200,7 +219,7 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
@@ -226,7 +245,7 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
@@ -249,7 +268,7 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Home'));
|
||||
@@ -283,7 +302,7 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Home'));
|
||||
@@ -335,7 +354,7 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
@@ -366,7 +385,7 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
@@ -404,7 +423,7 @@ void main() {
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
@@ -445,7 +464,7 @@ void main() {
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -473,8 +492,10 @@ void main() {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
// 收藏要求登录态:沿用本文件已有的"假登录"初始化
|
||||
seedSignedInSession();
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
|
||||
appUnderTest(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
|
||||
Reference in new issue
Block a user