Release/v1.0.0 #1

Merged
jianghaifeng merged 128 commits from release/v1.0.0 into main 2026-10-19 13:42:16 +00:00
48 changed files with 2411 additions and 377 deletions
Showing only changes of commit d7eff4ca59 - Show all commits

No files matched your search

+61
View File
@@ -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())
+20 -2
View File
@@ -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']),
);
}
}
+61
View File
@@ -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;
}
+8
View File
@@ -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, {
-7
View File
@@ -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, '返回数据格式异常');
+35
View File
@@ -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');
}
}
+20
View File
@@ -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, '返回数据格式异常');
}
}
+59
View File
@@ -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();
}
}
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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": {}
}
}
}
+48
View File
@@ -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
+28
View File
@@ -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';
}
}
+27
View File
@@ -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';
}
}
+26
View File
@@ -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
View File
@@ -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": {}
}
}
}
+2 -2
View File
@@ -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,
);
}
+5 -1
View File
@@ -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) {
+1 -1
View File
@@ -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,
);
}
+51 -10
View File
@@ -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;
+169 -11
View File
@@ -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),
),
],
],
);
}
}
+2 -2
View File
@@ -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,
);
}
+33 -1
View File
@@ -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) {
+126 -7
View File
@@ -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) {
+7 -2
View File
@@ -1,10 +1,15 @@
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import 'player_logic.dart';
class PlayerBinding {
PlayerLogic create() => PlayerLogic(
history: SharedPreferencesWatchHistoryRepository(),
PlayerLogic create({
required WatchHistoryRepository history,
required PlaybackRepository playback,
}) => PlayerLogic(
history: history,
playback: playback,
pictureInPicture: AndroidPictureInPicture(),
);
}
+160 -26
View File
@@ -5,7 +5,11 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/play_ticket.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/models/player_port.dart';
import '../../core/data/remote/api_client.dart';
import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import '../../core/data/services/catalog_controller.dart';
@@ -24,9 +28,14 @@ import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart';
class PlayerLogic extends ChangeNotifier {
PlayerLogic({required this.history, required this.pictureInPicture});
PlayerLogic({
required this.history,
required this.playback,
required this.pictureInPicture,
});
final WatchHistoryRepository history;
final PlaybackRepository playback;
final AndroidPictureInPicture pictureInPicture;
late PageController pages;
@@ -37,7 +46,18 @@ class PlayerLogic extends ChangeNotifier {
Episode? trackedEpisode;
Drama? trackedDrama;
DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0);
/// 进度上报节流:契约建议每 10 秒一次
Duration progressInterval = const Duration(seconds: 10);
Future<void> pendingWrite = Future<void>.value();
/// 服务端续看点:进入播放页时定位到"那一集的哪一秒"
ResumePoint? resumePoint;
bool resumeLoaded = false;
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
bool ticketLoading = false;
String? ticketError;
PlayTicket? blockedTicket;
String? blockedEpisodeId;
List<Episode> sessionEpisodes = const [];
int pageRequest = 0;
bool muted = false;
bool stoppedAtFeedEnd = false;
@@ -64,6 +84,9 @@ class PlayerLogic extends ChangeNotifier {
ValueChanged<Drama>? onDramaChanged;
ValueChanged<bool>? onImmersiveChanged;
bool _disposed = false;
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
bool _started = false;
bool get immersive => fullscreen || pip || pipPreparing;
@@ -111,16 +134,19 @@ class PlayerLogic extends ChangeNotifier {
required PlayerFactory factory,
required PlaybackCoordinator coordinator,
}) {
_started = true;
pictureInPicture.setStateListener(onPipChanged);
queue = feedDramas == null
? PlaybackQueue.series(pageDrama)
: PlaybackQueue.home(feedDramas!);
visibleIndices = List<int>.generate(queue.length, (index) => index);
pages = PageController(initialPage: initialIndex);
// 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它)
sessionEpisodes = [for (final entry in queue.entries) entry.episode];
session = PlaybackSession(
episodes: [for (final entry in queue.entries) entry.episode],
episodes: sessionEpisodes,
factory: factory,
// 后端只在可播放时才下发地址(免费集或已解锁),拿不到地址即视为锁定
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
canPlay: (episode) => episode.videoUrl.isNotEmpty,
prepare: preparePlayer,
activate: activatePlayer,
@@ -129,9 +155,88 @@ class PlayerLogic extends ChangeNotifier {
if (!active || visibleIndices.isEmpty) {
session.setRouteVisible(false);
}
unawaited(loadResumePoint());
unawaited(selectEpisode(initialIndex));
}
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
Future<void> loadResumePoint() async {
final dramaId = queue.entryAt(initialIndex).drama.id;
try {
resumePoint = await history.resumePointFor(dramaId);
} catch (_) {
// 拿不到续看位置不影响播放:从当前集开头看
resumePoint = null;
} finally {
resumeLoaded = true;
if (isMounted()) notify();
}
}
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
/// 该集是否该定位到服务端记录的位置:只有"记录里的那一集"才定位,其它集从头播。
Duration? resumePositionFor(String episodeId) {
final point = resumePoint;
if (point == null || point.episodeId != episodeId) return null;
if (point.position <= Duration.zero) return null;
return point.position;
}
/// 进度上报节流:距上次上报不足 [progressInterval] 就不报。
///
/// 切集/退出/切后台走 [saveCurrentProgress],那条路径**不受节流限制**。
bool shouldReportProgress(DateTime now) {
if (now.difference(lastProgressWrite) < progressInterval) return false;
lastProgressWrite = now;
return true;
}
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
/// 拿不到地址就交给 [blockedTicket] 让 UI 提示解锁/暂无片源。
Future<bool> ensureTicket(int index) async {
if (index < 0 || index >= sessionEpisodes.length) return false;
final episode = sessionEpisodes[index];
final request = pageRequest;
ticketLoading = true;
ticketError = null;
notify();
try {
final ticket = await playback.ticketFor(episode.id);
if (request != pageRequest) return false;
blockedTicket = null;
blockedEpisodeId = null;
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
return true;
}
blockedTicket = ticket;
blockedEpisodeId = episode.id;
return false;
} catch (exception) {
if (request != pageRequest) return false;
// 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了
if (episode.videoUrl.isNotEmpty) {
ticketError = null;
return true;
}
ticketError = exception is ApiException
? exception.message
: 'ticketError';
return false;
} finally {
ticketLoading = false;
if (isMounted()) notify();
}
}
/// 重试当前集的票据(失败态的"重试"按钮)。
Future<void> retryTicket() async {
ticketError = null;
notify();
unawaited(selectEpisode(session.currentIndex));
}
void onPageUpdated(PlayerPage previous) {
if (previous.active != active) {
session.setRouteVisible(active && visibleIndices.isNotEmpty);
@@ -161,6 +266,14 @@ class PlayerLogic extends ChangeNotifier {
void onLifecycle(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress());
if (state == AppLifecycleState.resumed) {
// 签名地址有有效期:回前台时若票据已过期,重新取一张再起播,
// 否则会撞上 403(契约 §1.4)
final episode = trackedEpisode;
if (episode != null && !playback.isFresh(episode.id)) {
unawaited(selectEpisode(session.currentIndex));
}
}
session.setAppResumed(
state == AppLifecycleState.resumed || pip || pipPreparing,
);
@@ -179,10 +292,12 @@ class PlayerLogic extends ChangeNotifier {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
}
unawaited(saveCurrentProgress());
session.setRouteVisible(false);
cancelScrub();
unawaited(session.close());
pages.dispose();
if (_started) {
session.setRouteVisible(false);
cancelScrub();
unawaited(session.close());
pages.dispose();
}
super.dispose();
}
@@ -264,23 +379,18 @@ class PlayerLogic extends ChangeNotifier {
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
if (player is! VideoPlayerPort) return;
final drama = queue.entries
.firstWhere((e) => e.episode.id == episode.id)
.drama;
try {
await pendingWrite;
final saved = await history.positionFor(
demoProfileId,
drama.id,
episode.id,
);
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
if (resumePoint == null) await loadResumePoint();
final saved = resumePositionFor(episode.id);
if (saved != null) {
await player.controller.seekTo(
clampResumePosition(saved, player.controller.value.duration),
);
}
} catch (_) {
// A local preference failure does not prevent playback.
// 续看位置取不到不影响播放:从头播。
}
}
@@ -303,7 +413,13 @@ class PlayerLogic extends ChangeNotifier {
if (player.controller.value.isCompleted && !completionProgressSaved) {
completionProgressSaved = true;
unawaited(
queueProgress(drama, episode, player.controller.value.duration),
queueProgress(
drama,
episode,
player.controller.value.duration,
duration: player.controller.value.duration,
finished: true,
),
);
}
if (!completionHandled &&
@@ -313,11 +429,7 @@ class PlayerLogic extends ChangeNotifier {
completionHandled = true;
unawaited(advanceAfterEnd(player, index));
}
final now = DateTime.now();
if (now.difference(lastProgressWrite) < const Duration(seconds: 5)) {
return;
}
lastProgressWrite = now;
if (!shouldReportProgress(DateTime.now())) return;
unawaited(
queueProgress(drama, episode, player.controller.value.position),
);
@@ -347,13 +459,26 @@ class PlayerLogic extends ChangeNotifier {
}
}
Future<void> queueProgress(Drama drama, Episode episode, Duration position) {
/// 进度上报:节流由调用方控制(10 秒),切集/退出/切后台都会补一次。
Future<void> queueProgress(
Drama drama,
Episode episode,
Duration position, {
Duration? duration,
bool finished = false,
}) {
pendingWrite = pendingWrite
.then(
(_) => history.save(demoProfileId, drama.id, episode.id, position),
(_) => history.save(
episodeId: episode.id,
position: position,
duration: duration,
finished: finished,
),
)
.catchError((Object _) {
// Playback continues if local history cannot be stored.
// 进度上报失败不影响播放(下次节流点会再报一次)。
return null;
});
return pendingWrite;
}
@@ -368,7 +493,12 @@ class PlayerLogic extends ChangeNotifier {
!player.controller.value.isInitialized) {
return pendingWrite;
}
return queueProgress(drama, episode, player.controller.value.position);
return queueProgress(
drama,
episode,
player.controller.value.position,
duration: player.controller.value.duration,
);
}
Future<void> selectEpisode(int index) async {
@@ -377,6 +507,10 @@ class PlayerLogic extends ChangeNotifier {
// Capture the old position synchronously, persist without blocking a swipe.
unawaited(saveCurrentProgress());
if (request != pageRequest || !isMounted()) return;
// 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试)
final ready = await ensureTicket(index);
if (request != pageRequest || !isMounted()) return;
if (!ready && ticketError != null) return;
await session.select(index, preloadIndex: nextVisibleIndex(index));
}
+122 -4
View File
@@ -6,15 +6,20 @@ import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/play_ticket.dart';
import '../../core/data/services/engagement_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/widgets/auth_gate.dart';
import '../../l10n/app_localizations.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/seek_progress_bar.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
class PlayerPage extends StatefulWidget {
const PlayerPage({
@@ -60,7 +65,10 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
logic = PlayerBinding().create();
logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
);
logic.attach(widget, isMounted: () => mounted);
WidgetsBinding.instance.addObserver(this);
logic.start(
@@ -189,6 +197,39 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
),
),
),
// 票据三态之一:起播前正在向后端要签名地址
if (selected && logic.ticketLoading)
const Center(
child: CircularProgressIndicator(color: Color(0xFF2F7BFF)),
),
// 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载
if (selected && !logic.ticketLoading && logic.ticketError != null)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.wifi_off_rounded,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
errorText(l10n, logic.ticketError, l10n.playbackError),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 12),
FilledButton(
onPressed: () => unawaited(logic.retryTicket()),
child: Text(l10n.retry),
),
],
),
),
),
if (selected && logic.session.loading)
const Center(
child: CircularProgressIndicator(color: Color(0xFF2F7BFF)),
@@ -215,9 +256,18 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
),
),
),
// 锁定集与还没上传片源的剧集都会走到这里,之前是一片纯黑,看不出原因。
// 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜
if (selected && !ready && logic.blockedEpisodeId == episode.id)
_BlockedNotice(
l10n: l10n,
episode: episode,
accessType: logic.blockedTicket?.accessType,
unlockCoin: logic.blockedTicket?.unlockCoin ?? 0,
),
// 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程)
if (selected &&
!ready &&
logic.blockedEpisodeId != episode.id &&
logic.session.error == null &&
logic.session.isLocked)
Center(
@@ -284,7 +334,8 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: engagement.isLiked(drama.id),
saved: engagement.isSaved(drama.id),
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
saved: context.watch<FavoriteController>().isSaved(drama.id),
onBack: widget.embedded
? null
: () => unawaited(logic.leave(context)),
@@ -298,8 +349,13 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
}
},
onSave: () async {
final favorites = context.read<FavoriteController>();
if (await ensureSignedIn(context)) {
unawaited(engagement.toggleSaved(drama.id));
final ok = await favorites.toggle(drama.id, card: drama);
if (ok || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.favoriteToggleFailed)),
);
}
},
onComments: () => logic.showComments(context, drama),
@@ -389,6 +445,68 @@ class _FullscreenControls extends StatelessWidget {
);
}
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
class _BlockedNotice extends StatelessWidget {
const _BlockedNotice({
required this.l10n,
required this.episode,
required this.accessType,
required this.unlockCoin,
});
final AppLocalizations l10n;
final Episode episode;
final String? accessType;
final int unlockCoin;
@override
Widget build(BuildContext context) {
final needsUnlock =
accessType == PlayTicket.coinRequired ||
accessType == PlayTicket.vipRequired;
final title = needsUnlock
? l10n.lockedTitle(episode.number)
: accessType == PlayTicket.noMedia
? l10n.noMediaNotice
: episode.locked
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
needsUnlock
? Icons.lock_outline_rounded
: Icons.movie_filter_outlined,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
if (needsUnlock) ...[
const SizedBox(height: 6),
Text(
unlockCoin > 0
? '${l10n.unlockComingSoon} · $unlockCoin ${l10n.coins}'
: l10n.unlockComingSoon,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white38, fontSize: 12),
),
],
],
),
),
);
}
}
class _PlaybackOverlay extends StatelessWidget {
const _PlaybackOverlay({
required this.l10n,
-35
View File
@@ -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,
);
}
}
+7 -9
View File
@@ -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();
+15 -12
View File
@@ -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++;
+3 -1
View File
@@ -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/'));
});
+114
View File
@@ -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)));
});
}
+103
View File
@@ -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);
});
}
+3 -3
View File
@@ -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;
}
+178
View File
@@ -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);
});
});
}
+93 -96
View File
@@ -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);
});
}
+24 -8
View File
@@ -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,
-11
View File
@@ -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)],
);
+46 -23
View File
@@ -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
View File
@@ -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')));