Compare commits
11
Commits
0f27daf664
...
6be024f86f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6be024f86f | ||
|
|
6512808506 | ||
|
|
2d65cd3006 | ||
|
|
391b01e9d0 | ||
|
|
d4ce3a5d8b | ||
|
|
bf47c065c9 | ||
|
|
c6b3ca9311 | ||
|
|
a3d50eb935 | ||
|
|
188811fde6 | ||
|
|
37353dac15 | ||
|
|
350d16db75 |
No files matched your search
@@ -60,6 +60,20 @@ class Episode {
|
||||
posterUrl: posterUrl ?? this.posterUrl,
|
||||
);
|
||||
|
||||
Episode withStats({int? likeCount, int? commentCount}) => Episode(
|
||||
id: id,
|
||||
number: number,
|
||||
title: title,
|
||||
isFree: isFree,
|
||||
videoUrl: videoUrl,
|
||||
durationSec: durationSec,
|
||||
locked: locked,
|
||||
likeCount: likeCount ?? this.likeCount,
|
||||
commentCount: commentCount ?? this.commentCount,
|
||||
thumbnailsUrl: thumbnailsUrl,
|
||||
posterUrl: posterUrl,
|
||||
);
|
||||
|
||||
factory Episode.fromJson(Map<String, dynamic> json) {
|
||||
return Episode(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 播放页轻量互动计数快照,不包含播放地址或票据信息。
|
||||
class EpisodeStats {
|
||||
const EpisodeStats({
|
||||
required this.episodeId,
|
||||
required this.dramaId,
|
||||
required this.likeCount,
|
||||
required this.commentCount,
|
||||
required this.favoriteCount,
|
||||
required this.liked,
|
||||
required this.favorited,
|
||||
});
|
||||
|
||||
final String episodeId;
|
||||
final String dramaId;
|
||||
final int likeCount;
|
||||
final int commentCount;
|
||||
final int favoriteCount;
|
||||
final bool liked;
|
||||
final bool favorited;
|
||||
|
||||
EpisodeStats copyWith({
|
||||
String? dramaId,
|
||||
int? likeCount,
|
||||
int? commentCount,
|
||||
int? favoriteCount,
|
||||
bool? liked,
|
||||
bool? favorited,
|
||||
}) {
|
||||
return EpisodeStats(
|
||||
episodeId: episodeId,
|
||||
dramaId: dramaId ?? this.dramaId,
|
||||
likeCount: likeCount ?? this.likeCount,
|
||||
commentCount: commentCount ?? this.commentCount,
|
||||
favoriteCount: favoriteCount ?? this.favoriteCount,
|
||||
liked: liked ?? this.liked,
|
||||
favorited: favorited ?? this.favorited,
|
||||
);
|
||||
}
|
||||
|
||||
factory EpisodeStats.fromJson(Map<String, dynamic> json) {
|
||||
return EpisodeStats(
|
||||
episodeId: jsonToId(json['episodeId']),
|
||||
dramaId: jsonToId(json['dramaId']),
|
||||
likeCount: jsonToInt(json['likeCount']),
|
||||
commentCount: jsonToInt(json['commentCount']),
|
||||
favoriteCount: jsonToInt(json['favoriteCount']),
|
||||
liked: json['liked'] == true,
|
||||
favorited: json['favorited'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,14 +58,16 @@ class LikeToggleResult {
|
||||
|
||||
/// 控制器统一返回结果;失败消息可直接展示后端 `message`。
|
||||
class OperationResult<T> {
|
||||
const OperationResult._(this.data, this.errorMessage);
|
||||
const OperationResult._(this.data, this.errorMessage, this.errorCode);
|
||||
|
||||
const OperationResult.success([T? data]) : this._(data, null);
|
||||
const OperationResult.success([T? data]) : this._(data, null, null);
|
||||
|
||||
const OperationResult.failure(this.errorMessage) : data = null;
|
||||
const OperationResult.failure(this.errorMessage, {this.errorCode})
|
||||
: data = null;
|
||||
|
||||
final T? data;
|
||||
final String? errorMessage;
|
||||
final int? errorCode;
|
||||
|
||||
bool get isSuccess => errorMessage == null;
|
||||
}
|
||||
@@ -134,6 +134,7 @@ class AuthApi {
|
||||
'platform': platform,
|
||||
'osVersion': osVersion,
|
||||
},
|
||||
skipAuthRetry: true,
|
||||
);
|
||||
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/episode_stats.dart';
|
||||
import '../models/play_ticket.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
@@ -17,4 +18,11 @@ class PlaybackApi {
|
||||
if (data is Map<String, dynamic>) return PlayTicket.fromJson(data);
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
}
|
||||
|
||||
/// 轻量刷新互动计数:不签发地址,也不占用播放票据缓存。
|
||||
Future<EpisodeStats> episodeStats(String episodeId) async {
|
||||
final data = await _client.get('/app/episodes/$episodeId/stats');
|
||||
if (data is Map<String, dynamic>) return EpisodeStats.fromJson(data);
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/episode_stats.dart';
|
||||
import '../models/play_ticket.dart';
|
||||
import '../remote/playback_api.dart';
|
||||
|
||||
@@ -10,6 +11,9 @@ import '../remote/playback_api.dart';
|
||||
abstract interface class PlaybackRepository {
|
||||
Future<PlayTicket> ticketFor(String episodeId, {bool forceRefresh = false});
|
||||
|
||||
/// 读取互动计数快照;与 [ticketFor] 分离,避免为了刷新计数重新签发地址。
|
||||
Future<EpisodeStats> episodeStats(String episodeId);
|
||||
|
||||
/// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。
|
||||
bool isFresh(String episodeId);
|
||||
}
|
||||
@@ -42,6 +46,10 @@ class HttpPlaybackRepository implements PlaybackRepository {
|
||||
return ticket;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EpisodeStats> episodeStats(String episodeId) =>
|
||||
_api.episodeStats(episodeId);
|
||||
|
||||
@override
|
||||
bool isFresh(String episodeId) {
|
||||
final cached = _cache[episodeId];
|
||||
@@ -65,11 +73,22 @@ class _CachedTicket {
|
||||
|
||||
/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。
|
||||
class FakePlaybackRepository implements PlaybackRepository {
|
||||
FakePlaybackRepository({this.ticket, this.error, this.fresh = true});
|
||||
FakePlaybackRepository({
|
||||
this.ticket,
|
||||
this.error,
|
||||
this.fresh = true,
|
||||
this.stats,
|
||||
this.statsError,
|
||||
Map<String, EpisodeStats>? statsByEpisode,
|
||||
}) : statsByEpisode = statsByEpisode ?? {};
|
||||
|
||||
PlayTicket? ticket;
|
||||
Object? error;
|
||||
EpisodeStats? stats;
|
||||
Object? statsError;
|
||||
final Map<String, EpisodeStats> statsByEpisode;
|
||||
final List<String> requests = [];
|
||||
final List<String> statsRequests = [];
|
||||
final List<bool> forced = [];
|
||||
|
||||
/// 模拟"票据是否还有效":置 false 可验证回前台会重新取。
|
||||
@@ -95,4 +114,13 @@ class FakePlaybackRepository implements PlaybackRepository {
|
||||
qualities: const ['AUTO', '720P', '1080P'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EpisodeStats> episodeStats(String episodeId) async {
|
||||
statsRequests.add(episodeId);
|
||||
if (statsError != null) throw statsError!;
|
||||
final value = statsByEpisode[episodeId] ?? stats;
|
||||
if (value != null) return value;
|
||||
throw StateError('episode stats not configured for $episodeId');
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,10 @@ class CommentController extends ChangeNotifier {
|
||||
_insertPublished(comment, replyTo);
|
||||
return OperationResult.success(comment);
|
||||
} catch (failure) {
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
return OperationResult.failure(
|
||||
_errorMessage(failure),
|
||||
errorCode: failure is ApiException ? failure.code : null,
|
||||
);
|
||||
} finally {
|
||||
submitting = false;
|
||||
_notify();
|
||||
@@ -217,7 +220,10 @@ class CommentController extends ChangeNotifier {
|
||||
return const OperationResult.success();
|
||||
} catch (failure) {
|
||||
_restoreComment(comment, location);
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
return OperationResult.failure(
|
||||
_errorMessage(failure),
|
||||
errorCode: failure is ApiException ? failure.code : null,
|
||||
);
|
||||
} finally {
|
||||
_deletingCommentIds.remove(comment.id);
|
||||
_notify();
|
||||
@@ -253,7 +259,10 @@ class CommentController extends ChangeNotifier {
|
||||
return OperationResult.success(result);
|
||||
} catch (failure) {
|
||||
_replaceComment(current);
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
return OperationResult.failure(
|
||||
_errorMessage(failure),
|
||||
errorCode: failure is ApiException ? failure.code : null,
|
||||
);
|
||||
} finally {
|
||||
_likingCommentIds.remove(comment.id);
|
||||
_notify();
|
||||
@@ -277,7 +286,10 @@ class CommentController extends ChangeNotifier {
|
||||
);
|
||||
return OperationResult.success(reportId);
|
||||
} catch (failure) {
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
return OperationResult.failure(
|
||||
_errorMessage(failure),
|
||||
errorCode: failure is ApiException ? failure.code : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../remote/api_client.dart';
|
||||
import '../repositories/favorite_repository.dart';
|
||||
|
||||
/// 收藏状态:**以服务端为准**。
|
||||
@@ -24,6 +25,7 @@ class FavoriteController extends ChangeNotifier {
|
||||
bool hasMore = false;
|
||||
String? error;
|
||||
String? loadMoreError;
|
||||
int? lastErrorCode;
|
||||
int _pageIndex = 0;
|
||||
int _requestSeq = 0;
|
||||
bool _disposed = false;
|
||||
@@ -32,6 +34,17 @@ class FavoriteController extends ChangeNotifier {
|
||||
|
||||
bool isSaved(String dramaId) => _saved.contains(dramaId);
|
||||
|
||||
/// 用统计快照校准收藏状态;已有列表卡片结构不因一次状态同步而改动。
|
||||
void applyServerState(String dramaId, {required bool saved}) {
|
||||
final changed = _saved.contains(dramaId) != saved;
|
||||
if (saved) {
|
||||
_saved.add(dramaId);
|
||||
} else {
|
||||
_saved.remove(dramaId);
|
||||
}
|
||||
if (changed) notify();
|
||||
}
|
||||
|
||||
void notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
@@ -101,11 +114,12 @@ class FavoriteController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 未登录时不要调用(服务端要求登录态)。
|
||||
/// 每次切换都以接口状态为准;匿名请求由设备号归属。
|
||||
///
|
||||
/// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
|
||||
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
|
||||
Future<bool> toggle(String dramaId, {Drama? card}) async {
|
||||
lastErrorCode = null;
|
||||
final wasSaved = _saved.contains(dramaId);
|
||||
final previousCards = cards;
|
||||
final previousTotal = total;
|
||||
@@ -146,7 +160,7 @@ class FavoriteController extends ChangeNotifier {
|
||||
}
|
||||
notify();
|
||||
return true;
|
||||
} catch (_) {
|
||||
} catch (failure) {
|
||||
// 回滚
|
||||
cards = previousCards;
|
||||
total = previousTotal;
|
||||
@@ -157,6 +171,7 @@ class FavoriteController extends ChangeNotifier {
|
||||
_saved.remove(dramaId);
|
||||
}
|
||||
notify();
|
||||
if (failure is ApiException) lastErrorCode = failure.code;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,24 @@ class InteractionController extends ChangeNotifier {
|
||||
/// 该集服务端最新点赞数。
|
||||
int? episodeLikeCount(String episodeId) => _episodeLikeCounts[episodeId];
|
||||
|
||||
/// 用轻量统计快照校准当前用户的分集点赞状态与计数。
|
||||
void applyEpisodeStats(
|
||||
String episodeId, {
|
||||
required bool liked,
|
||||
required int likeCount,
|
||||
}) {
|
||||
final changed =
|
||||
_likedEpisodeIds.contains(episodeId) != liked ||
|
||||
_episodeLikeCounts[episodeId] != likeCount;
|
||||
if (liked) {
|
||||
_likedEpisodeIds.add(episodeId);
|
||||
} else {
|
||||
_likedEpisodeIds.remove(episodeId);
|
||||
}
|
||||
_episodeLikeCounts[episodeId] = likeCount;
|
||||
if (changed) _notify();
|
||||
}
|
||||
|
||||
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
|
||||
Future<OperationResult<LikeToggleResult>> toggleDramaLike(String dramaId) =>
|
||||
_toggleLike(
|
||||
@@ -97,7 +115,10 @@ class InteractionController extends ChangeNotifier {
|
||||
} else {
|
||||
likedIds.remove(id);
|
||||
}
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
return OperationResult.failure(
|
||||
_errorMessage(failure),
|
||||
errorCode: failure is ApiException ? failure.code : null,
|
||||
);
|
||||
} finally {
|
||||
busyIds.remove(id);
|
||||
_notify();
|
||||
@@ -121,7 +142,10 @@ class InteractionController extends ChangeNotifier {
|
||||
);
|
||||
return OperationResult.success(reportId);
|
||||
} catch (failure) {
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
return OperationResult.failure(
|
||||
_errorMessage(failure),
|
||||
errorCode: failure is ApiException ? failure.code : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,18 @@ class PlaybackCoordinator {
|
||||
Future<bool> _acquire(PlaybackSession session, bool Function() valid) {
|
||||
final result = _handoff.then((_) async {
|
||||
if (!valid()) return false;
|
||||
final previous = _owner;
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[player-coordinator] phase=acquire '
|
||||
'session=${identityHashCode(session)} owner=${session.owner.name} '
|
||||
'previous=${previous == null ? '-' : identityHashCode(previous)} '
|
||||
'previousOwner=${previous?.owner.name ?? '-'} '
|
||||
'previousLive=${previous?.livePlayerCount ?? 0} '
|
||||
'same=${identical(previous, session)}',
|
||||
);
|
||||
}
|
||||
if (_owner != session) {
|
||||
final previous = _owner;
|
||||
if (previous != null && previous.owner != session.owner) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
@@ -31,6 +41,12 @@ class PlaybackCoordinator {
|
||||
if (!valid()) return false;
|
||||
_owner = session;
|
||||
}
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[player-coordinator] phase=acquired '
|
||||
'session=${identityHashCode(session)} live=${session.livePlayerCount}',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
_handoff = result.then<void>((_) {});
|
||||
|
||||
@@ -84,6 +84,11 @@ class PlaybackSession extends ChangeNotifier {
|
||||
bool get canAutoAdvance => _mayPlay && !_loading && active != null;
|
||||
bool get isLocked => !canPlay(currentEpisode);
|
||||
String? get error => _error;
|
||||
bool get debugAppResumed => _appResumed;
|
||||
bool get debugRouteVisible => _routeVisible;
|
||||
bool get debugOverlayOpen => _overlayOpen;
|
||||
bool get debugVisible => _visible;
|
||||
bool get debugMayPlay => _mayPlay;
|
||||
// Includes initialization and asynchronous disposal, until native release ends.
|
||||
int get livePlayerCount => _live.length;
|
||||
bool get activeSlotActivated => _active?.activated ?? false;
|
||||
@@ -254,6 +259,16 @@ class PlaybackSession extends ChangeNotifier {
|
||||
bool? userWantsPlay,
|
||||
}) async {
|
||||
if (_closed || index < 0 || index >= episodes.length) return;
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[player-session] phase=select-enter session=${identityHashCode(this)} '
|
||||
'owner=${owner.name} index=$index user=$_userWantsPlay '
|
||||
'selected=$_selected current=$_currentIndex '
|
||||
'appResumed=$_appResumed routeVisible=$_routeVisible '
|
||||
'overlayOpen=$_overlayOpen visible=$_visible mayPlay=$_mayPlay '
|
||||
'live=${_live.length}',
|
||||
);
|
||||
}
|
||||
// 切换到不同剧集时恢复播放;仍停留在同一集时保留用户暂停。
|
||||
if (userWantsPlay != null) {
|
||||
_userWantsPlay = userWantsPlay;
|
||||
@@ -465,6 +480,22 @@ class PlaybackSession extends ChangeNotifier {
|
||||
Future<void> _applyPlayback(_PlayerSlot slot) async {
|
||||
if (_closed || _active != slot || !slot.activated) return;
|
||||
final shouldPlay = _mayPlay && slot.index == _currentIndex && !isLocked;
|
||||
final video = slot.player is VideoPlayerPort
|
||||
? slot.player as VideoPlayerPort
|
||||
: null;
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[player-session] phase=apply-playback '
|
||||
'session=${identityHashCode(this)} owner=${owner.name} '
|
||||
'index=${slot.index} shouldPlay=$shouldPlay user=$_userWantsPlay '
|
||||
'appResumed=$_appResumed routeVisible=$_routeVisible '
|
||||
'overlayOpen=$_overlayOpen locked=$isLocked '
|
||||
'player=${identityHashCode(slot.player)} '
|
||||
'controller=${video == null ? '-' : identityHashCode(video.controller)} '
|
||||
'isPlaying=${video?.controller.value.isPlaying ?? '-'} '
|
||||
'slotPlaying=${slot.playing}',
|
||||
);
|
||||
}
|
||||
if (shouldPlay == slot.playing) return;
|
||||
if (shouldPlay) {
|
||||
await slot.player.play();
|
||||
@@ -472,6 +503,15 @@ class PlaybackSession extends ChangeNotifier {
|
||||
await slot.player.pause();
|
||||
}
|
||||
slot.playing = shouldPlay;
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[player-session] phase=apply-playback-result '
|
||||
'session=${identityHashCode(this)} index=${slot.index} '
|
||||
'shouldPlay=$shouldPlay '
|
||||
'isPlaying=${video?.controller.value.isPlaying ?? '-'} '
|
||||
'slotPlaying=${slot.playing}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _syncPlayback() {
|
||||
|
||||
@@ -229,7 +229,7 @@ abstract final class AppDimens {
|
||||
static const double mineSectionLinkFont = 12;
|
||||
static const double mineSectionGap = 20;
|
||||
static const double mineContinueCardWidth = 96;
|
||||
static const double mineContinueRowHeight = 168;
|
||||
static const double mineContinueRowHeight = 172;
|
||||
static const double mineContinueTitleFont = 13;
|
||||
static const double mineContinueMetaFont = 11;
|
||||
static const double mineStatsValueFont = 18;
|
||||
|
||||
@@ -15,7 +15,7 @@ Future<void> openAuthPage(BuildContext context, {bool signUpMode = false}) {
|
||||
|
||||
/// 受限动作的登录闸:已登录返回 true;未登录弹底部提示并引导登录,返回 false。
|
||||
///
|
||||
/// 首页/发现/详情保持匿名可看,只有收藏、点赞、评论这类"要对到具体账号"的动作才走这里。
|
||||
/// 首页/发现/详情与非付费互动保持匿名可用;解锁、权益与消耗类动作走这里。
|
||||
Future<bool> ensureSignedIn(BuildContext context) async {
|
||||
final auth = context.read<AuthController>();
|
||||
if (auth.isSignedIn) return true;
|
||||
@@ -76,6 +76,7 @@ Future<bool> ensureSignedIn(BuildContext context) async {
|
||||
|
||||
if (go == true && context.mounted) {
|
||||
await openAuthPage(context);
|
||||
if (context.mounted) return context.read<AuthController>().isSignedIn;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,9 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
),
|
||||
),
|
||||
if (_tagValues(context, drama).isNotEmpty)
|
||||
SliverToBoxAdapter(child: DramaTags(tags: _tagValues(context, drama))),
|
||||
SliverToBoxAdapter(
|
||||
child: DramaTags(tags: _tagValues(context, drama)),
|
||||
),
|
||||
SliverToBoxAdapter(child: _episodes(l10n, drama, continueIndex)),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
@@ -396,11 +398,14 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(AppLocalizations l10n) async {
|
||||
if (!await ensureSignedIn(context)) return;
|
||||
if (!mounted) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final ok = await logic.toggleFavorite();
|
||||
if (ok || !mounted) return;
|
||||
if (logic.favorites.lastErrorCode == 200007) {
|
||||
await ensureSignedIn(context);
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
|
||||
@@ -443,7 +448,9 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
}
|
||||
|
||||
List<String> _tagValues(BuildContext context, Drama drama) {
|
||||
final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
|
||||
final localized = drama.localizedTypeLabels(
|
||||
Localizations.localeOf(context).languageCode,
|
||||
);
|
||||
final result = <String>[];
|
||||
if (localized.isNotEmpty) {
|
||||
result.addAll(localized.take(_maxTags));
|
||||
|
||||
@@ -104,6 +104,7 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
return _categoryPlaceholder(
|
||||
l10n,
|
||||
context.watch<CategoryController>(),
|
||||
catalog,
|
||||
);
|
||||
}
|
||||
final category = shortcuts[index];
|
||||
@@ -146,7 +147,11 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
Widget _categoryPlaceholder(
|
||||
AppLocalizations l10n,
|
||||
CategoryController controller,
|
||||
CatalogController catalog,
|
||||
) {
|
||||
if (catalog.error != null) {
|
||||
return _catalogErrorState(l10n, catalog);
|
||||
}
|
||||
if (controller.loading) {
|
||||
return const DramaGridSkeleton(
|
||||
key: Key('home-grid-skeleton'),
|
||||
|
||||
@@ -432,7 +432,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
|
||||
/// 评论入口统一登录闸:登录成功且页面仍存在时再打开评论面板。
|
||||
Future<void> openCommentsWithAuth(
|
||||
/// 评论列表与非付费互动均支持设备身份,不再把登录作为入口前置条件。
|
||||
Future<void> openComments(
|
||||
BuildContext context,
|
||||
Drama drama, {
|
||||
required Future<void> Function(BuildContext context, Drama drama) show,
|
||||
}) async {
|
||||
final auth = context.read<AuthController>();
|
||||
if (auth.isSignedIn) {
|
||||
await show(context, drama);
|
||||
return;
|
||||
}
|
||||
await ensureSignedIn(context);
|
||||
if (!context.mounted || !auth.isSignedIn) return;
|
||||
await show(context, drama);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class PlayerBinding {
|
||||
required PlaybackPositionCache positionCache,
|
||||
WalletController? wallet,
|
||||
DownloadController? downloads,
|
||||
bool Function()? isSignedIn,
|
||||
}) => PlayerLogic(
|
||||
history: history,
|
||||
playback: playback,
|
||||
@@ -22,5 +23,6 @@ class PlayerBinding {
|
||||
pictureInPicture: AndroidPictureInPicture(),
|
||||
wallet: wallet,
|
||||
downloads: downloads,
|
||||
isSignedIn: isSignedIn,
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/episode_stats.dart';
|
||||
import '../../core/data/models/play_ticket.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/models/player_port.dart';
|
||||
@@ -51,9 +52,13 @@ class PlayerLogic extends ChangeNotifier {
|
||||
this.downloads,
|
||||
PlaybackPositionCache? positionCache,
|
||||
Future<void> Function()? waitForFrame,
|
||||
DateTime Function()? clock,
|
||||
bool Function()? isSignedIn,
|
||||
}) : positionCache = positionCache ?? PlaybackPositionCache(),
|
||||
_waitForFrame =
|
||||
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame);
|
||||
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame),
|
||||
_clock = clock ?? DateTime.now,
|
||||
_isSignedIn = isSignedIn ?? (() => true);
|
||||
|
||||
final WatchHistoryRepository history;
|
||||
final PlaybackRepository playback;
|
||||
@@ -62,6 +67,8 @@ class PlayerLogic extends ChangeNotifier {
|
||||
final DownloadController? downloads;
|
||||
final PlaybackPositionCache positionCache;
|
||||
final Future<void> Function() _waitForFrame;
|
||||
final DateTime Function() _clock;
|
||||
final bool Function() _isSignedIn;
|
||||
|
||||
late PageController pages;
|
||||
late PlaybackQueue queue;
|
||||
@@ -100,11 +107,19 @@ class PlayerLogic extends ChangeNotifier {
|
||||
bool muted = false;
|
||||
double playbackSpeed = 1.0;
|
||||
bool stoppedAtFeedEnd = false;
|
||||
Duration episodeStatsTtl = const Duration(seconds: 60);
|
||||
ValueChanged<EpisodeStats>? onEpisodeStatsUpdated;
|
||||
VideoPlayerPort? scrubbingPlayer;
|
||||
bool pip = false;
|
||||
bool pipPreparing = false;
|
||||
final Map<String, int> pickedEpisode = {};
|
||||
final Set<String> _unlockedEpisodeIds = {};
|
||||
final Map<String, EpisodeStats> _episodeStats = {};
|
||||
final Map<String, DateTime> _episodeStatsFetchedAt = {};
|
||||
final Map<String, Future<void>> _episodeStatsInFlight = {};
|
||||
final Map<String, int> _episodeStatsRevision = {};
|
||||
final Map<String, int> _dramaStatsRevision = {};
|
||||
final Map<String, int> _dramaFavoriteCounts = {};
|
||||
|
||||
/// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。
|
||||
String? unlockingEpisodeId;
|
||||
@@ -168,6 +183,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
EpisodeStats? episodeStats(String episodeId) => _episodeStats[episodeId];
|
||||
|
||||
int? dramaFavoriteCount(String dramaId) => _dramaFavoriteCounts[dramaId];
|
||||
|
||||
void notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
@@ -249,6 +268,8 @@ class PlayerLogic extends ChangeNotifier {
|
||||
'[feed-session] phase=$phase session=${identityHashCode(session)} '
|
||||
'index=${session.currentIndex} episode=${session.currentEpisode.id} '
|
||||
'controller=${video == null ? '-' : identityHashCode(video.controller)} '
|
||||
'user=${session.userWantsPlay} routeVisible=${session.debugRouteVisible} '
|
||||
'appResumed=${session.debugAppResumed} overlayOpen=${session.debugOverlayOpen} '
|
||||
'live=${session.livePlayerCount}',
|
||||
);
|
||||
}
|
||||
@@ -534,6 +555,179 @@ class PlayerLogic extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前集激活后刷新轻量互动计数;同集命中 TTL 或已有请求时不重复发请求。
|
||||
Future<void> refreshEpisodeStats(int index) {
|
||||
if (_disposed || index < 0 || index >= sessionEpisodes.length) {
|
||||
return Future<void>.value();
|
||||
}
|
||||
final episodeId = sessionEpisodes[index].id;
|
||||
final cached = _episodeStats[episodeId];
|
||||
final fetchedAt = _episodeStatsFetchedAt[episodeId];
|
||||
if (cached != null &&
|
||||
fetchedAt != null &&
|
||||
_clock().difference(fetchedAt) < episodeStatsTtl) {
|
||||
_logEpisodeStatsRefresh(episodeId, cached: true);
|
||||
onEpisodeStatsUpdated?.call(cached);
|
||||
return Future<void>.value();
|
||||
}
|
||||
final inFlight = _episodeStatsInFlight[episodeId];
|
||||
if (inFlight != null) {
|
||||
_logEpisodeStatsRefresh(episodeId, cached: true, inFlight: true);
|
||||
return inFlight;
|
||||
}
|
||||
_logEpisodeStatsRefresh(episodeId, cached: false);
|
||||
final expectedDramaId = index < queue.length
|
||||
? queue.entryAt(index).drama.id
|
||||
: '';
|
||||
final episodeRevision = _episodeStatsRevision[episodeId] ?? 0;
|
||||
final dramaRevision = _dramaStatsRevision[expectedDramaId] ?? 0;
|
||||
late final Future<void> request;
|
||||
request =
|
||||
_fetchEpisodeStats(
|
||||
episodeId,
|
||||
expectedDramaId: expectedDramaId,
|
||||
episodeRevision: episodeRevision,
|
||||
dramaRevision: dramaRevision,
|
||||
).whenComplete(() {
|
||||
if (identical(_episodeStatsInFlight[episodeId], request)) {
|
||||
_episodeStatsInFlight.remove(episodeId);
|
||||
}
|
||||
});
|
||||
_episodeStatsInFlight[episodeId] = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
Future<void> _fetchEpisodeStats(
|
||||
String episodeId, {
|
||||
required String expectedDramaId,
|
||||
required int episodeRevision,
|
||||
required int dramaRevision,
|
||||
}) async {
|
||||
try {
|
||||
final fetched = await playback.episodeStats(episodeId);
|
||||
if (_disposed) return;
|
||||
if ((_episodeStatsRevision[episodeId] ?? 0) != episodeRevision ||
|
||||
(_dramaStatsRevision[expectedDramaId] ?? 0) != dramaRevision) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[player] phase=episode-stats-discarded episodeId=$episodeId '
|
||||
'reason=local-state-newer',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final stats = fetched.copyWith(
|
||||
dramaId: fetched.dramaId.isEmpty ? expectedDramaId : fetched.dramaId,
|
||||
);
|
||||
_episodeStats[episodeId] = stats;
|
||||
_episodeStatsFetchedAt[episodeId] = _clock();
|
||||
if (stats.dramaId.isNotEmpty) {
|
||||
_dramaFavoriteCounts[stats.dramaId] = stats.favoriteCount;
|
||||
}
|
||||
final episodeIndex = sessionEpisodes.indexWhere(
|
||||
(episode) => episode.id == episodeId,
|
||||
);
|
||||
if (episodeIndex >= 0) {
|
||||
sessionEpisodes[episodeIndex] = sessionEpisodes[episodeIndex].withStats(
|
||||
likeCount: stats.likeCount,
|
||||
commentCount: stats.commentCount,
|
||||
);
|
||||
}
|
||||
onEpisodeStatsUpdated?.call(stats);
|
||||
notify();
|
||||
} catch (_) {
|
||||
// 统计接口只负责校准计数,失败时保留播放与旧值。
|
||||
}
|
||||
}
|
||||
|
||||
void _logEpisodeStatsRefresh(
|
||||
String episodeId, {
|
||||
required bool cached,
|
||||
bool inFlight = false,
|
||||
}) {
|
||||
if (!kDebugMode) return;
|
||||
debugPrint(
|
||||
'[player] phase=episode-stats-refresh episodeId=$episodeId '
|
||||
'cached=${cached ? 1 : 0}${inFlight ? ' inFlight=1' : ''}',
|
||||
);
|
||||
}
|
||||
|
||||
/// 点赞成功后同步本地统计快照,避免切回时被旧缓存覆盖。
|
||||
void applyEpisodeLikeState(
|
||||
String episodeId, {
|
||||
required bool liked,
|
||||
required int likeCount,
|
||||
}) {
|
||||
_episodeStatsRevision.update(
|
||||
episodeId,
|
||||
(value) => value + 1,
|
||||
ifAbsent: () => 1,
|
||||
);
|
||||
final stats = _episodeStats[episodeId];
|
||||
if (stats != null) {
|
||||
_episodeStats[episodeId] = stats.copyWith(
|
||||
liked: liked,
|
||||
likeCount: likeCount,
|
||||
);
|
||||
}
|
||||
final index = sessionEpisodes.indexWhere(
|
||||
(episode) => episode.id == episodeId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
sessionEpisodes[index] = sessionEpisodes[index].withStats(
|
||||
likeCount: likeCount,
|
||||
);
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
/// 评论面板关闭后同步评论数。
|
||||
void applyEpisodeCommentCount(String episodeId, int commentCount) {
|
||||
_episodeStatsRevision.update(
|
||||
episodeId,
|
||||
(value) => value + 1,
|
||||
ifAbsent: () => 1,
|
||||
);
|
||||
final stats = _episodeStats[episodeId];
|
||||
if (stats != null) {
|
||||
_episodeStats[episodeId] = stats.copyWith(commentCount: commentCount);
|
||||
}
|
||||
final index = sessionEpisodes.indexWhere(
|
||||
(episode) => episode.id == episodeId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
sessionEpisodes[index] = sessionEpisodes[index].withStats(
|
||||
commentCount: commentCount,
|
||||
);
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
/// 收藏成功时同时修正剧级计数和所有已缓存的同剧互动状态。
|
||||
void applyDramaFavoriteState(
|
||||
String dramaId, {
|
||||
required bool favorited,
|
||||
int? favoriteCount,
|
||||
}) {
|
||||
_dramaStatsRevision.update(
|
||||
dramaId,
|
||||
(value) => value + 1,
|
||||
ifAbsent: () => 1,
|
||||
);
|
||||
if (favoriteCount != null) {
|
||||
_dramaFavoriteCounts[dramaId] = favoriteCount;
|
||||
}
|
||||
for (final entry in _episodeStats.entries.toList()) {
|
||||
final stats = entry.value;
|
||||
if (stats.dramaId != dramaId) continue;
|
||||
_episodeStats[entry.key] = stats.copyWith(
|
||||
favorited: favorited,
|
||||
favoriteCount: favoriteCount,
|
||||
);
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
String _fileUrl(String path) =>
|
||||
path.startsWith('file:') ? path : Uri.file(path).toString();
|
||||
|
||||
@@ -606,7 +800,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
/// 金币解锁当前集;成功后强制刷新票据并直接起播。
|
||||
Future<bool> unlockEpisode(int index) async {
|
||||
final service = wallet;
|
||||
if (service == null || index < 0 || index >= sessionEpisodes.length) {
|
||||
if (service == null ||
|
||||
!_isSignedIn() ||
|
||||
index < 0 ||
|
||||
index >= sessionEpisodes.length) {
|
||||
return false;
|
||||
}
|
||||
final episode = sessionEpisodes[index];
|
||||
@@ -1041,6 +1238,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
int? revision,
|
||||
bool? userWantsPlay,
|
||||
}) async {
|
||||
if (index < 0 || index >= sessionEpisodes.length) return;
|
||||
final feedRevisionAtStart = revision ?? feedRevision;
|
||||
final request = ++pageRequest;
|
||||
cancelScrub();
|
||||
@@ -1066,6 +1264,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
!isMounted()) {
|
||||
return;
|
||||
}
|
||||
if (!_isSignedIn()) {
|
||||
unawaited(refreshEpisodeStats(index));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试)
|
||||
final ready = await ensureTicket(index);
|
||||
@@ -1074,7 +1276,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
!isMounted()) {
|
||||
return;
|
||||
}
|
||||
if (!ready) return;
|
||||
if (!ready) {
|
||||
unawaited(refreshEpisodeStats(index));
|
||||
return;
|
||||
}
|
||||
await _selectVisible(index, userWantsPlay: userWantsPlay);
|
||||
}
|
||||
|
||||
@@ -1085,13 +1290,16 @@ class PlayerLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 按可见队列选择一集,并预热上一集与下一集。
|
||||
Future<void> _selectVisible(int index, {bool? userWantsPlay}) {
|
||||
return session.select(
|
||||
Future<void> _selectVisible(int index, {bool? userWantsPlay}) async {
|
||||
await session.select(
|
||||
index,
|
||||
preloadIndex: nextVisibleIndex(index),
|
||||
previousIndex: previousVisibleIndex(index),
|
||||
userWantsPlay: userWantsPlay,
|
||||
);
|
||||
if (!_disposed && session.currentIndex == index) {
|
||||
unawaited(refreshEpisodeStats(index));
|
||||
}
|
||||
}
|
||||
|
||||
int? nextVisibleIndex(int index) {
|
||||
|
||||
@@ -9,8 +9,10 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/episode_stats.dart';
|
||||
import '../../core/data/repositories/comment_repository.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/data/services/download_controller.dart';
|
||||
import '../../core/data/services/interaction_controller.dart';
|
||||
@@ -148,25 +150,28 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
bool _loadingRemoved = true;
|
||||
bool _loadingFadeScheduled = false;
|
||||
bool _loggedLoadingUiVisible = false;
|
||||
bool _loggedStartConditions = false;
|
||||
final PlayerHeartOverlayController _heartOverlayController =
|
||||
PlayerHeartOverlayController();
|
||||
final Set<String> _precachedPosterUrls = {};
|
||||
|
||||
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
||||
final Map<String, int> _favoriteDelta = {};
|
||||
/// 评论接口返回的最新数量;统计快照优先,失败时保留这里的旧值。
|
||||
final Map<String, int> _commentCounts = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final handoff = widget.handoff;
|
||||
final initialUserWantsPlay = handoff?.sourceUserWantsPlay ?? true;
|
||||
logic = PlayerBinding().create(
|
||||
history: context.read<WatchHistoryRepository>(),
|
||||
playback: context.read<PlaybackRepository>(),
|
||||
positionCache: context.read<PlaybackPositionCache>(),
|
||||
wallet: widget.wallet,
|
||||
downloads: widget.downloads,
|
||||
isSignedIn: () => context.read<AuthController>().isSignedIn,
|
||||
);
|
||||
logic.onEpisodeStatsUpdated = _applyEpisodeStats;
|
||||
logic.attach(widget, isMounted: () => mounted);
|
||||
_thumbnailService = PlaybackThumbnailService(
|
||||
sourceResolver: _resolveThumbnailSource,
|
||||
@@ -179,8 +184,21 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
coordinator: widget.embedded
|
||||
? context.read<PlaybackCoordinator>()
|
||||
: PlaybackCoordinator(),
|
||||
initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true,
|
||||
initialUserWantsPlay: initialUserWantsPlay,
|
||||
);
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[player-start] phase=initialized '
|
||||
'session=${identityHashCode(logic.session)} '
|
||||
'initialUserWantsPlay=$initialUserWantsPlay '
|
||||
'sessionUserWantsPlay=${logic.session.userWantsPlay} '
|
||||
'active=${logic.active} embedded=${widget.embedded} '
|
||||
'appResumed=${logic.session.debugAppResumed} '
|
||||
'routeVisible=${logic.session.debugRouteVisible} '
|
||||
'overlayOpen=${logic.session.debugOverlayOpen} '
|
||||
'live=${logic.session.livePlayerCount}',
|
||||
);
|
||||
}
|
||||
_pageController = logic.pages;
|
||||
if (!widget.embedded &&
|
||||
!logic.isEpisodeLocked(logic.session.currentEpisode)) {
|
||||
@@ -535,21 +553,46 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
/// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。
|
||||
void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) {
|
||||
void _applyEpisodeStats(EpisodeStats stats) {
|
||||
if (!mounted) return;
|
||||
context.read<InteractionController>().applyEpisodeStats(
|
||||
stats.episodeId,
|
||||
liked: stats.liked,
|
||||
likeCount: stats.likeCount,
|
||||
);
|
||||
if (stats.dramaId.isNotEmpty) {
|
||||
context.read<FavoriteController>().applyServerState(
|
||||
stats.dramaId,
|
||||
saved: stats.favorited,
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_favoriteDelta[dramaId] =
|
||||
(_favoriteDelta[dramaId] ?? 0) + (nowSaved ? 1 : -1);
|
||||
_commentCounts[stats.episodeId] = stats.commentCount;
|
||||
});
|
||||
}
|
||||
|
||||
/// 收藏状态确认后同步右侧剧级计数。
|
||||
void _applyFavoriteDelta(Drama drama, {required bool nowSaved}) {
|
||||
final current = logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount;
|
||||
final next = current == null
|
||||
? null
|
||||
: nowSaved
|
||||
? current + 1
|
||||
: (current > 0 ? current - 1 : 0);
|
||||
logic.applyDramaFavoriteState(
|
||||
drama.id,
|
||||
favorited: nowSaved,
|
||||
favoriteCount: next,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openComments(
|
||||
BuildContext context,
|
||||
Drama drama,
|
||||
Episode episode,
|
||||
) async {
|
||||
final comments = context.read<CommentRepository>();
|
||||
await openCommentsWithAuth(
|
||||
await openComments(
|
||||
context,
|
||||
drama,
|
||||
show: (context, drama) =>
|
||||
@@ -558,6 +601,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
try {
|
||||
final count = await comments.count(drama.id, episodeId: episode.id);
|
||||
if (!mounted) return;
|
||||
logic.applyEpisodeCommentCount(episode.id, count);
|
||||
setState(() => _commentCounts[episode.id] = count);
|
||||
} catch (_) {
|
||||
// 评论面板已经关闭,计数刷新失败时保留旧值即可。
|
||||
@@ -576,6 +620,25 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
final interactions = context.watch<InteractionController>();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final indices = logic.visibleIndices;
|
||||
if (indices.isEmpty) {
|
||||
return const ColoredBox(
|
||||
color: AppColors.mediaBlack,
|
||||
child: SizedBox.expand(),
|
||||
);
|
||||
}
|
||||
if (kDebugMode && !_loggedStartConditions) {
|
||||
_loggedStartConditions = true;
|
||||
debugPrint(
|
||||
'[player-start] phase=first-build '
|
||||
'session=${identityHashCode(logic.session)} '
|
||||
'tickerMode=${TickerMode.valuesOf(context).enabled} '
|
||||
'routeVisible=${logic.session.debugRouteVisible} '
|
||||
'appResumed=${logic.session.debugAppResumed} '
|
||||
'overlayOpen=${logic.session.debugOverlayOpen} '
|
||||
'visible=${logic.session.debugVisible} '
|
||||
'mayPlay=${logic.session.debugMayPlay}',
|
||||
);
|
||||
}
|
||||
final activePort = logic.session.active;
|
||||
final activeVideo = activePort is VideoPlayerPort ? activePort : null;
|
||||
final canWaitForVideoFrame =
|
||||
@@ -1131,10 +1194,12 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
liked: interactions.isEpisodeLiked(episode.id),
|
||||
likeCount:
|
||||
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
|
||||
favoriteCount: drama.favoriteCount == null
|
||||
? null
|
||||
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
|
||||
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
|
||||
favoriteCount:
|
||||
logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount,
|
||||
commentCount:
|
||||
logic.episodeStats(episode.id)?.commentCount ??
|
||||
_commentCounts[episode.id] ??
|
||||
episode.commentCount,
|
||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||
onLike: () async {
|
||||
await _toggleLike(context, interactions, episode);
|
||||
@@ -1161,6 +1226,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
: null,
|
||||
thumbnailService: _thumbnailService,
|
||||
spriteAxisService: _spriteAxisService,
|
||||
suppressBufferLoading: !widget.embedded && !_loadingRemoved,
|
||||
)
|
||||
else if (!widget.homeMode)
|
||||
_standardPlaybackOverlay(context, index, interactions),
|
||||
@@ -1236,10 +1302,12 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
liked: interactions.isEpisodeLiked(episode.id),
|
||||
likeCount:
|
||||
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
|
||||
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
|
||||
favoriteCount: drama.favoriteCount == null
|
||||
? null
|
||||
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
|
||||
commentCount:
|
||||
logic.episodeStats(episode.id)?.commentCount ??
|
||||
_commentCounts[episode.id] ??
|
||||
episode.commentCount,
|
||||
favoriteCount:
|
||||
logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount,
|
||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||
playing: logic.session.userWantsPlay,
|
||||
playbackSpeed: _playbackSpeed,
|
||||
@@ -1284,6 +1352,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
onBoostEnd: _endBoost,
|
||||
),
|
||||
controlsVisible: _chromeVisible,
|
||||
suppressProgressLoading: !widget.embedded && !_loadingRemoved,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1339,8 +1408,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
seriesEpisodes.insert(0, sourceEpisode);
|
||||
episodeIndex = 0;
|
||||
}
|
||||
final sourceUserWantsPlay = logic.session.userWantsPlay;
|
||||
final handoff = PlayerPageHandoff(sourceUserWantsPlay: sourceUserWantsPlay);
|
||||
final handoff = PlayerPageHandoff(sourceUserWantsPlay: true);
|
||||
_logFullPlayerRoute('present');
|
||||
setState(() => _presentationActive = true);
|
||||
final route = Navigator.of(context).push<void>(
|
||||
|
||||
@@ -65,6 +65,11 @@ extension _PlayerViewActions on _PlayerPageState {
|
||||
) async {
|
||||
final wallet = logic.wallet;
|
||||
if (wallet == null) return;
|
||||
if (!context.read<AuthController>().isSignedIn &&
|
||||
!await ensureSignedIn(context)) {
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.transparent,
|
||||
@@ -81,7 +86,7 @@ extension _PlayerViewActions on _PlayerPageState {
|
||||
}
|
||||
|
||||
Future<bool> _unlockEpisode(BuildContext context, int index) async {
|
||||
if (!await ensureSignedIn(context) || !context.mounted) return false;
|
||||
if (!context.read<AuthController>().isSignedIn) return false;
|
||||
final ok = await logic.unlockEpisode(index);
|
||||
if (ok || !context.mounted) return ok;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -119,27 +124,6 @@ extension _PlayerViewActions on _PlayerPageState {
|
||||
}
|
||||
return;
|
||||
}
|
||||
final signedIn = await ensureSignedIn(context);
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[player-like] phase=auth-result '
|
||||
'episodeId=${episode.id} signedIn=$signedIn '
|
||||
'mounted=${context.mounted}',
|
||||
);
|
||||
}
|
||||
if (!signedIn || !context.mounted) return;
|
||||
final busyAfterAuth = interactions.isEpisodeBusy(episode.id);
|
||||
final likedAfterAuth = interactions.isEpisodeLiked(episode.id);
|
||||
if (busyAfterAuth || (likeOnly && likedAfterAuth)) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[player-like] phase=toggle-skipped-after-auth '
|
||||
'episodeId=${episode.id} '
|
||||
'reason=${busyAfterAuth ? 'busy' : 'already-liked'}',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final result = await interactions.toggleEpisodeLike(episode.id);
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
@@ -151,10 +135,22 @@ extension _PlayerViewActions on _PlayerPageState {
|
||||
);
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
final likeCount = interactions.episodeLikeCount(episode.id);
|
||||
if (likeCount != null) {
|
||||
logic.applyEpisodeLikeState(
|
||||
episode.id,
|
||||
liked: interactions.isEpisodeLiked(episode.id),
|
||||
likeCount: likeCount,
|
||||
);
|
||||
}
|
||||
_refreshLikeState();
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (result.errorCode == 200007) {
|
||||
await ensureSignedIn(context);
|
||||
return;
|
||||
}
|
||||
showCenteredToast(
|
||||
context,
|
||||
result.errorMessage ?? AppLocalizations.of(context)!.likeFailed,
|
||||
@@ -163,7 +159,6 @@ extension _PlayerViewActions on _PlayerPageState {
|
||||
|
||||
Future<void> _toggleSave(BuildContext context, Drama drama) async {
|
||||
final favorites = context.read<FavoriteController>();
|
||||
if (!await ensureSignedIn(context) || !context.mounted) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final wasSaved = favorites.isSaved(drama.id);
|
||||
final ok = await favorites.toggle(drama.id, card: drama);
|
||||
@@ -171,10 +166,14 @@ extension _PlayerViewActions on _PlayerPageState {
|
||||
if (ok) {
|
||||
final nowSaved = favorites.isSaved(drama.id);
|
||||
if (nowSaved != wasSaved) {
|
||||
_applyFavoriteDelta(drama.id, nowSaved: nowSaved);
|
||||
_applyFavoriteDelta(drama, nowSaved: nowSaved);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (favorites.lastErrorCode == 200007) {
|
||||
await ensureSignedIn(context);
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.favoriteToggleFailed),
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/data/models/comment.dart';
|
||||
import '../../../core/data/services/auth_controller.dart';
|
||||
import '../../../core/data/services/comment_controller.dart';
|
||||
import '../../../core/platform/haptics.dart';
|
||||
import '../../../core/platform/keyboard_inset.dart';
|
||||
@@ -196,23 +195,20 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
|
||||
return;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (result.errorCode == 200007) {
|
||||
await ensureSignedIn(context);
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(result.errorMessage ?? l10n.commentPostFailed)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _authenticate() async {
|
||||
await ensureSignedIn(context);
|
||||
if (!mounted || !context.read<AuthController>().isSignedIn) return;
|
||||
_inputFocus.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
final hasContent = _input.text.trim().isNotEmpty;
|
||||
final canPost = signedIn && hasContent && !widget.controller.submitting;
|
||||
final canPost = hasContent && !widget.controller.submitting;
|
||||
final multiline = _input.text.contains('\n');
|
||||
final bottomInset = _isAndroid
|
||||
? _pinnedKeyboardInset
|
||||
@@ -247,8 +243,8 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
|
||||
key: const Key('comment-composer-input'),
|
||||
controller: _input,
|
||||
focusNode: _inputFocus,
|
||||
readOnly: !signedIn,
|
||||
canRequestFocus: signedIn,
|
||||
readOnly: false,
|
||||
canRequestFocus: true,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
maxLength: _maxCommentLength,
|
||||
@@ -262,9 +258,7 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
|
||||
),
|
||||
cursorColor: AppColors.blue,
|
||||
decoration: InputDecoration(
|
||||
hintText: signedIn
|
||||
? l10n.writeComment
|
||||
: l10n.commentLoginHint,
|
||||
hintText: l10n.writeComment,
|
||||
hintStyle: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 14,
|
||||
@@ -278,7 +272,7 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
onTap: signedIn ? null : _authenticate,
|
||||
onTap: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -11,11 +11,9 @@ import '../../../core/data/models/comment.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/repositories/comment_repository.dart';
|
||||
import '../../../core/data/repositories/interaction_repository.dart';
|
||||
import '../../../core/data/services/auth_controller.dart';
|
||||
import '../../../core/data/services/comment_controller.dart';
|
||||
import '../../../core/formatting/error_text.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/auth_gate.dart';
|
||||
import '../../../core/widgets/empty_state.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'comment_composer_sheet.dart';
|
||||
@@ -29,7 +27,7 @@ const double _sheetRadius = 20;
|
||||
const double _headerHeight = 44;
|
||||
const double _composerTopGap = 8;
|
||||
|
||||
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复与登录发布。
|
||||
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复与设备身份发布。
|
||||
class DramaCommentsSheet extends StatefulWidget {
|
||||
const DramaCommentsSheet({super.key, required this.drama, this.episodeId});
|
||||
|
||||
@@ -92,8 +90,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
}
|
||||
|
||||
Future<void> _openComposer({DramaComment? replyTo}) async {
|
||||
await ensureSignedIn(context);
|
||||
if (!mounted || !context.read<AuthController>().isSignedIn) return;
|
||||
if (!mounted) return;
|
||||
_removeComposerOverlay();
|
||||
final entry = OverlayEntry(
|
||||
builder: (_) => CommentComposerSheet(
|
||||
@@ -222,7 +219,6 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
Widget _content(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final count = _serverCount ?? _controller.exactCount;
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
final composerReserve = _composerHeight + _composerTopGap;
|
||||
return Column(
|
||||
key: const Key('comment-sheet-content'),
|
||||
@@ -269,7 +265,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
Padding(
|
||||
key: const Key('comment-composer-area'),
|
||||
padding: const EdgeInsets.only(top: _composerTopGap),
|
||||
child: _fakeComposer(l10n, signedIn: signedIn),
|
||||
child: _fakeComposer(l10n),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -352,7 +348,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
return const SizedBox(height: 8);
|
||||
}
|
||||
|
||||
Widget _fakeComposer(AppLocalizations l10n, {required bool signedIn}) {
|
||||
Widget _fakeComposer(AppLocalizations l10n) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _openComposer,
|
||||
@@ -372,7 +368,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Text(
|
||||
signedIn ? l10n.writeComment : l10n.commentLoginHint,
|
||||
l10n.writeComment,
|
||||
key: const Key('comment-input-placeholder-label'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -69,6 +69,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
this.cancelScrubRequest = 0,
|
||||
this.thumbnailService,
|
||||
this.spriteAxisService,
|
||||
this.suppressBufferLoading = false,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
@@ -104,6 +105,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
final int cancelScrubRequest;
|
||||
final PlaybackThumbnailService? thumbnailService;
|
||||
final SpriteThumbnailAxisService? spriteAxisService;
|
||||
final bool suppressBufferLoading;
|
||||
|
||||
@override
|
||||
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
|
||||
@@ -195,6 +197,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
spriteAxisService: widget.spriteAxisService,
|
||||
thumbnailsUrl: widget.episode.thumbnailsUrl,
|
||||
episodeId: widget.episode.id,
|
||||
suppressBufferLoading: widget.suppressBufferLoading,
|
||||
onScrubStart: _startScrub,
|
||||
onScrubEnd: _endScrub,
|
||||
onSeek: widget.onSeek,
|
||||
|
||||
@@ -23,6 +23,7 @@ class _FeedProgressBar extends StatefulWidget {
|
||||
this.spriteAxisService,
|
||||
this.thumbnailsUrl,
|
||||
this.episodeId,
|
||||
this.suppressBufferLoading = false,
|
||||
this.onScrubStart,
|
||||
this.onScrubEnd,
|
||||
this.onSeek,
|
||||
@@ -37,6 +38,7 @@ class _FeedProgressBar extends StatefulWidget {
|
||||
final SpriteThumbnailAxisSource? spriteAxisService;
|
||||
final String? thumbnailsUrl;
|
||||
final String? episodeId;
|
||||
final bool suppressBufferLoading;
|
||||
final VoidCallback? onScrubStart;
|
||||
final VoidCallback? onScrubEnd;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
@@ -52,6 +54,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
Timer? _bufferTimer;
|
||||
bool _buffering = false;
|
||||
bool _dragging = false;
|
||||
Duration? _lastPosition;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -67,6 +70,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
_lastPosition = null;
|
||||
_attachSource();
|
||||
}
|
||||
if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) {
|
||||
@@ -77,6 +81,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
_lastPosition = null;
|
||||
if (widget.locked) {
|
||||
_dragging = false;
|
||||
_preview = null;
|
||||
@@ -84,6 +89,13 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
_syncBufferState(notify: false);
|
||||
}
|
||||
}
|
||||
if (oldWidget.suppressBufferLoading != widget.suppressBufferLoading) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
_lastPosition = null;
|
||||
_syncBufferState(notify: false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -100,7 +112,11 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
final width = constraints.maxWidth;
|
||||
final source = widget.controller;
|
||||
final scrubbing = _dragging;
|
||||
final loading = !widget.locked && !scrubbing && _buffering;
|
||||
final loading =
|
||||
!widget.locked &&
|
||||
!widget.suppressBufferLoading &&
|
||||
!scrubbing &&
|
||||
_buffering;
|
||||
final track = widget.locked
|
||||
? _inactiveTrack()
|
||||
: source == null
|
||||
@@ -199,14 +215,24 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
|
||||
void _syncBufferState({bool notify = true}) {
|
||||
final source = widget.controller;
|
||||
if (widget.locked || source == null) {
|
||||
if (widget.locked || widget.suppressBufferLoading || source == null) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_lastPosition = null;
|
||||
_setBuffering(false, notify: notify);
|
||||
return;
|
||||
}
|
||||
final value = source.value;
|
||||
if (!value.isInitialized) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_lastPosition = null;
|
||||
_setBuffering(false, notify: notify);
|
||||
return;
|
||||
}
|
||||
final previousPosition = _lastPosition;
|
||||
_lastPosition = value.position;
|
||||
if (previousPosition != null && value.position > previousPosition) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_setBuffering(false, notify: notify);
|
||||
@@ -218,14 +244,22 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
_setBuffering(false, notify: notify);
|
||||
return;
|
||||
}
|
||||
if (previousPosition == null &&
|
||||
value.isPlaying &&
|
||||
value.position > Duration.zero) {
|
||||
_setBuffering(false, notify: notify);
|
||||
return;
|
||||
}
|
||||
if (_buffering || _bufferTimer != null) return;
|
||||
final bufferingPosition = value.position;
|
||||
_bufferTimer = Timer(_bufferDebounce, () {
|
||||
_bufferTimer = null;
|
||||
final current = widget.controller;
|
||||
if (!mounted ||
|
||||
current != source ||
|
||||
!current!.value.isInitialized ||
|
||||
!current.value.isBuffering) {
|
||||
!current.value.isBuffering ||
|
||||
current.value.position != bufferingPosition) {
|
||||
return;
|
||||
}
|
||||
_setBuffering(true);
|
||||
|
||||
@@ -37,6 +37,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
required this.actions,
|
||||
required this.controlsVisible,
|
||||
required this.scrubbing,
|
||||
this.suppressProgressLoading = false,
|
||||
});
|
||||
|
||||
/// 展示数据。
|
||||
@@ -50,6 +51,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
|
||||
/// 正在拖动进度条。拖动期间清屏,只留进度条。
|
||||
final bool scrubbing;
|
||||
final bool suppressProgressLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -306,6 +308,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
episodeId: data.episode.id,
|
||||
inactiveTrackColor: AppColors.inactiveTrack,
|
||||
fallbackDuration: Duration(seconds: data.episode.durationSec),
|
||||
suppressBufferLoading: suppressProgressLoading,
|
||||
onScrubStart: actions.onScrubStart,
|
||||
onScrubEnd: actions.onScrubEnd,
|
||||
onSeek: actions.onSeek,
|
||||
|
||||
@@ -49,7 +49,8 @@ class PlayerBlockedNotice extends StatelessWidget {
|
||||
|
||||
bool get _needsUnlock =>
|
||||
accessType == PlayTicket.coinRequired ||
|
||||
accessType == PlayTicket.vipRequired;
|
||||
accessType == PlayTicket.vipRequired ||
|
||||
(accessType == null && episode.locked);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -43,6 +43,7 @@ class SeekProgressBar extends StatefulWidget {
|
||||
this.episodeId,
|
||||
this.thumbnailsUrl,
|
||||
this.fallbackDuration = Duration.zero,
|
||||
this.suppressBufferLoading = false,
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||
@@ -59,6 +60,7 @@ class SeekProgressBar extends StatefulWidget {
|
||||
final String? episodeId;
|
||||
final String? thumbnailsUrl;
|
||||
final Duration fallbackDuration;
|
||||
final bool suppressBufferLoading;
|
||||
|
||||
@override
|
||||
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
||||
@@ -74,6 +76,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
bool _pointerMoved = false;
|
||||
Offset? _pointerStart;
|
||||
double? _dragStartFraction;
|
||||
Duration? _lastPosition;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -90,13 +93,22 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
_lastPosition = null;
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_attachSource();
|
||||
}
|
||||
if (oldWidget.suppressBufferLoading != widget.suppressBufferLoading) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_lastPosition = null;
|
||||
_buffering = false;
|
||||
_syncBufferState(notify: false);
|
||||
}
|
||||
if (oldWidget.locked != widget.locked) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
_lastPosition = null;
|
||||
if (widget.locked) {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_previewFraction.value = null;
|
||||
@@ -130,14 +142,24 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
|
||||
void _syncBufferState({bool notify = true}) {
|
||||
final source = widget.valueListenable;
|
||||
if (widget.locked || source == null) {
|
||||
if (widget.locked || widget.suppressBufferLoading || source == null) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_lastPosition = null;
|
||||
_setBuffering(false, notify: notify);
|
||||
return;
|
||||
}
|
||||
final value = source.value;
|
||||
if (!value.isInitialized) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_lastPosition = null;
|
||||
_setBuffering(false, notify: notify);
|
||||
return;
|
||||
}
|
||||
final previousPosition = _lastPosition;
|
||||
_lastPosition = value.position;
|
||||
if (previousPosition != null && value.position > previousPosition) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_setBuffering(false, notify: notify);
|
||||
@@ -149,14 +171,24 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
_setBuffering(false, notify: notify);
|
||||
return;
|
||||
}
|
||||
// 首次看到“已播放且有画面”的 buffering 状态时,以 position 为基线,
|
||||
// 不抢先显示 loading;后续 position 停滞超过阈值才视为真实卡顿。
|
||||
if (previousPosition == null &&
|
||||
value.isPlaying &&
|
||||
value.position > Duration.zero) {
|
||||
_setBuffering(false, notify: notify);
|
||||
return;
|
||||
}
|
||||
if (_buffering || _bufferTimer != null) return;
|
||||
final bufferingPosition = value.position;
|
||||
_bufferTimer = Timer(_bufferDebounce, () {
|
||||
_bufferTimer = null;
|
||||
final current = widget.valueListenable;
|
||||
if (!mounted ||
|
||||
current != source ||
|
||||
!current!.value.isInitialized ||
|
||||
!current.value.isBuffering) {
|
||||
!current.value.isBuffering ||
|
||||
current.value.position != bufferingPosition) {
|
||||
return;
|
||||
}
|
||||
_setBuffering(true);
|
||||
@@ -227,7 +259,11 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
: widget.fallbackDuration;
|
||||
final total = duration.inMilliseconds;
|
||||
final scrubbing = _dragging;
|
||||
final loading = !widget.locked && !scrubbing && _buffering;
|
||||
final loading =
|
||||
!widget.locked &&
|
||||
!widget.suppressBufferLoading &&
|
||||
!scrubbing &&
|
||||
_buffering;
|
||||
final enabled =
|
||||
!widget.locked &&
|
||||
video.isInitialized &&
|
||||
@@ -243,6 +279,8 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
final showPreview =
|
||||
scrubbing &&
|
||||
widget.showTimes &&
|
||||
video.isInitialized &&
|
||||
total > 0 &&
|
||||
widget.thumbnailService != null &&
|
||||
(widget.episodeId?.isNotEmpty ?? false);
|
||||
final trackHeight = scrubbing ? 6.0 : 2.0;
|
||||
@@ -253,21 +291,6 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
final track = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.showTimes && !video.isInitialized)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
'00:00 / ${_format(duration)}',
|
||||
key: const Key('player-progress-uninitialized-time'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.showTimes && scrubbing && !showPreview)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
|
||||
@@ -509,11 +509,13 @@ Widget _heat(int count, AppLocalizations l10n) {
|
||||
size: 11,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
l10n.heatLabel(formatPlayCount(count)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: AppColors.textTertiary, fontSize: 11),
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.heatLabel(formatPlayCount(count)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: AppColors.textTertiary, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -112,7 +112,7 @@ void main() {
|
||||
|
||||
final toast = find.byKey(const Key('centered-toast'));
|
||||
expect(toast, findsOneWidget);
|
||||
expect(find.text('Invalid email or password'), findsOneWidget);
|
||||
expect(find.text('Sign in to continue'), findsOneWidget);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(api.loginCalls, 1);
|
||||
expect(
|
||||
|
||||
@@ -14,7 +14,6 @@ import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||
import 'package:fruitshorts/core/platform/haptics.dart';
|
||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/core/theme/app_dimens.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/auth/auth_view.dart';
|
||||
|
||||
@@ -136,7 +135,7 @@ void main() {
|
||||
cardDecoration.borderRadius,
|
||||
BorderRadius.circular(AppDimens.authCardRadius),
|
||||
);
|
||||
expect(cardDecoration.boxShadow, contains(AppTheme.cardShadow));
|
||||
expect(cardDecoration.boxShadow, isNull);
|
||||
expect(card.padding, const EdgeInsets.all(20));
|
||||
|
||||
final email = tester.widget<TextField>(
|
||||
@@ -222,7 +221,7 @@ void main() {
|
||||
find.descendant(of: back, matching: find.byType(Material)),
|
||||
)
|
||||
.color,
|
||||
AppColors.card,
|
||||
AppColors.transparent,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -135,7 +135,9 @@ void main() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('should_gate_guest_comment_input_without_focus', (tester) async {
|
||||
testWidgets('should_open_guest_comment_composer_without_login', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final repository = FakeCommentRepository(pages: [const CommentPage()]);
|
||||
@@ -144,34 +146,20 @@ void main() {
|
||||
final placeholder = find.byKey(const Key('comment-input-placeholder'));
|
||||
|
||||
expect(placeholder, findsOneWidget);
|
||||
expect(find.text('Sign in to comment'), findsOneWidget);
|
||||
await tester.tap(placeholder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sign in to continue'), findsOneWidget);
|
||||
await tester.tap(find.text('Not now'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('comment-composer-sheet')), findsNothing);
|
||||
expect(placeholder, findsOneWidget);
|
||||
expect(find.byKey(const Key('comment-composer-sheet')), findsOneWidget);
|
||||
expect(find.byKey(const Key('auth-landing')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_focus_comment_input_after_successful_login', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('should_focus_guest_comment_input_immediately', (tester) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final repository = FakeCommentRepository(pages: [const CommentPage()]);
|
||||
final auth = await _pumpSheet(tester, repository: repository);
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.text('Sign In'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
(auth as _TestAuthController).setSignedIn(true);
|
||||
await tester.pump();
|
||||
Navigator.of(tester.element(find.byKey(const Key('auth-landing')))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
@@ -218,9 +206,7 @@ void main() {
|
||||
expect(multilineField.style?.height, 1.4);
|
||||
});
|
||||
|
||||
testWidgets('should_gate_guest_post_without_creating_comment', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('should_publish_guest_comment_without_login', (tester) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final repository = FakeCommentRepository(pages: [const CommentPage()]);
|
||||
@@ -229,8 +215,18 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('comment-post')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sign in to continue'), findsOneWidget);
|
||||
expect(repository.publishCalls, isEmpty);
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('comment-composer-input')),
|
||||
'Guest comment',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const Key('comment-composer-post')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('auth-landing')), findsNothing);
|
||||
expect(repository.publishCalls, hasLength(1));
|
||||
expect(repository.publishCalls.single.content, 'Guest comment');
|
||||
expect(find.text('Guest comment'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_show_server_comments_and_append_next_cursor_page', (
|
||||
|
||||
@@ -63,7 +63,11 @@ void main() {
|
||||
});
|
||||
|
||||
test('should_use_the_configured_local_address_for_device_default', () {
|
||||
expect(AppConfig.devHostBaseUrl, 'http://192.168.31.243:8081');
|
||||
final uri = Uri.parse(AppConfig.devHostBaseUrl);
|
||||
expect(uri.scheme, 'http');
|
||||
expect(uri.host, isNotEmpty);
|
||||
expect(uri.port, 8081);
|
||||
expect(uri.path, isEmpty);
|
||||
});
|
||||
|
||||
test('should_skip_probe_when_compiled_override_is_set', () async {
|
||||
|
||||
@@ -43,7 +43,7 @@ void main() {
|
||||
final scrollable = tester.state<ScrollableState>(
|
||||
find.descendant(of: list, matching: find.byType(Scrollable)).first,
|
||||
);
|
||||
expect(scrollable.position.pixels, 0);
|
||||
expect(scrollable.position.pixels, lessThan(0));
|
||||
final indicator = find.byKey(const Key('pull-refresh-indicator'));
|
||||
expect(indicator, findsOneWidget);
|
||||
expect(
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/pages/discover/discover_view.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';
|
||||
|
||||
@@ -151,7 +152,10 @@ Future<void> _pumpFilterPage(WidgetTester tester) async {
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('home-search-entry')));
|
||||
Navigator.of(tester.element(find.byKey(const Key('home-search-entry'))))
|
||||
.push<void>(
|
||||
MaterialPageRoute(builder: (_) => const DiscoverPage()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('theater-toolbar-filter')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart';
|
||||
import 'package:fruitshorts/pages/discover/discover_view.dart';
|
||||
import 'package:fruitshorts/pages/discover/widgets/discover_category_tab.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';
|
||||
@@ -423,9 +424,10 @@ Future<void> _pumpTheater(
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('home-search-entry')));
|
||||
await tester.pumpAndSettle();
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
Navigator.of(tester.element(find.byKey(const Key('home-search-entry'))))
|
||||
.push<void>(
|
||||
MaterialPageRoute(builder: (_) => const DiscoverPage()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart';
|
||||
import 'package:fruitshorts/pages/discover/discover_view.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
@@ -119,7 +120,7 @@ void main() {
|
||||
expect(top2.width, greaterThan(100));
|
||||
expect(top2.left, lessThan(top1.left));
|
||||
expect(top3.left, greaterThan(top1.right));
|
||||
expect(top1.bottom, closeTo(top2.bottom - 12, 0.5));
|
||||
expect(top1.bottom, closeTo(top2.bottom - 30, 0.5));
|
||||
expect(top2.bottom, closeTo(top3.bottom, 0.5));
|
||||
expect(find.byKey(const Key('ranking-row-3')), findsNothing);
|
||||
});
|
||||
@@ -219,7 +220,7 @@ void main() {
|
||||
expect(position.pixels, 0);
|
||||
position.jumpTo(position.maxScrollExtent);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const Key('ranking-load-more')), findsNothing);
|
||||
expect(find.byKey(const Key('ranking-load-more')), findsOneWidget);
|
||||
expect(find.byKey(const Key('ranking-no-more')), findsNothing);
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
@@ -311,7 +312,10 @@ Future<void> _pumpTheater(
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('home-search-entry')));
|
||||
Navigator.of(tester.element(find.byKey(const Key('home-search-entry'))))
|
||||
.push<void>(
|
||||
MaterialPageRoute(builder: (_) => const DiscoverPage()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ void main() {
|
||||
test('should_use_exact_root_navigation_colors', () {
|
||||
expect(AppColors.navBackground, const Color(0xFF000000));
|
||||
expect(AppColors.navSelected, const Color(0xFFFFFFFF));
|
||||
expect(AppColors.navUnselected, const Color(0xFF8E8E93));
|
||||
expect(AppColors.navUnselected, const Color(0xFFB0B0B0));
|
||||
});
|
||||
|
||||
testWidgets('should_render_four_slots_with_white_selected_state', (
|
||||
|
||||
@@ -82,7 +82,13 @@ void main() {
|
||||
expect(viewsText.style?.fontSize, AppDimens.cardPlayCountFont);
|
||||
expect(viewsText.style?.fontWeight, FontWeight.w400);
|
||||
expect(viewsText.style?.color, AppColors.navSelected);
|
||||
expect(find.descendant(of: cover, matching: views), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('home-feed-cover-clip-anime-card')),
|
||||
matching: views,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester.getTopRight(cover).dx - tester.getTopRight(views).dx,
|
||||
AppDimens.cardPlayCountRight,
|
||||
@@ -201,7 +207,9 @@ Future<void> _pumpCard(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 130,
|
||||
height: 240,
|
||||
height:
|
||||
130 / AppDimens.cardCoverAspectRatio +
|
||||
AppDimens.homeCardBodyExtent,
|
||||
child: HomeFeedCard(
|
||||
drama: drama,
|
||||
onTap: onTap,
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/theme/app_dimens.dart';
|
||||
import 'package:fruitshorts/pages/home/widgets/home_feed_card.dart';
|
||||
import 'package:fruitshorts/pages/home/widgets/home_feed_grid.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.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';
|
||||
@@ -102,7 +103,7 @@ void main() {
|
||||
|
||||
await tester.tap(find.byKey(const Key('home-section-category-社群')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-grid-empty')), findsOneWidget);
|
||||
expect(firstDramaId(), 'page1-0');
|
||||
expect(find.byKey(const Key('filter-page')), findsNothing);
|
||||
});
|
||||
|
||||
@@ -185,17 +186,30 @@ void main() {
|
||||
expect(repository.cursors, contains('page1'));
|
||||
expect(repository.requestedSizes.every((size) => size <= 20), isTrue);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.byKey(const Key('home-feed-card-page2-1')),
|
||||
260,
|
||||
scrollable: find
|
||||
.descendant(
|
||||
of: find.byKey(const Key('home-grid-refresh')),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
expect(find.byKey(const Key('home-grid-refresh')), findsOneWidget);
|
||||
final gridScrollable = find
|
||||
.descendant(
|
||||
of: find.byKey(const Key('home-grid-scroll')),
|
||||
matching: find.byType(Scrollable),
|
||||
);
|
||||
expect(gridScrollable, findsOneWidget);
|
||||
final secondPageCard = find.byKey(
|
||||
const Key('home-feed-card-page2-0'),
|
||||
);
|
||||
expect(find.byKey(const Key('home-feed-card-page2-1')), findsOneWidget);
|
||||
final feed = tester
|
||||
.widget<HomeFeedGrid>(find.byType(HomeFeedGrid))
|
||||
.feedController;
|
||||
expect(
|
||||
feed.dramas.map((drama) => drama.id),
|
||||
contains('page2-0'),
|
||||
);
|
||||
final position = tester.state<ScrollableState>(gridScrollable).position;
|
||||
await tester.pump();
|
||||
position.jumpTo(position.maxScrollExtent);
|
||||
await tester.pump();
|
||||
position.jumpTo(position.maxScrollExtent);
|
||||
await tester.pump();
|
||||
expect(secondPageCard, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_open_player_when_grid_card_is_tapped', (tester) async {
|
||||
@@ -242,7 +256,7 @@ Future<void> _pumpApp(
|
||||
|
||||
class _PagedCatalogRepository implements CatalogRepository {
|
||||
_PagedCatalogRepository()
|
||||
: pageOne = List.generate(10, (index) => _drama('page1-$index')),
|
||||
: pageOne = List.generate(30, (index) => _drama('page1-$index')),
|
||||
pageTwo = List.generate(2, (index) => _drama('page2-$index'));
|
||||
|
||||
final List<Drama> pageOne;
|
||||
|
||||
@@ -98,22 +98,12 @@ void main() {
|
||||
tester.getTopRight(walletEntry).dx,
|
||||
closeTo(390 - AppDimens.feedPageHorizontal, 0.1),
|
||||
);
|
||||
final crown = tester.widget<Icon>(
|
||||
find.descendant(
|
||||
of: vipEntry,
|
||||
matching: find.byIcon(Icons.workspace_premium_outlined),
|
||||
),
|
||||
);
|
||||
final gift = tester.widget<Icon>(
|
||||
find.descendant(
|
||||
of: walletEntry,
|
||||
matching: find.byIcon(Icons.card_giftcard_rounded),
|
||||
),
|
||||
);
|
||||
expect(crown.size, AppDimens.homeActionIcon);
|
||||
expect(crown.color, AppColors.homeCrownGold);
|
||||
expect(gift.size, AppDimens.homeActionIcon);
|
||||
expect(gift.color, AppColors.homeGiftPink);
|
||||
expect(crownImage.width, AppDimens.homeActionIcon);
|
||||
expect(crownImage.height, AppDimens.homeActionIcon);
|
||||
expect(crownImage.fit, BoxFit.contain);
|
||||
expect(giftImage.width, AppDimens.homeActionIcon);
|
||||
expect(giftImage.height, AppDimens.homeActionIcon);
|
||||
expect(giftImage.fit, BoxFit.contain);
|
||||
expect(
|
||||
tester
|
||||
.widget<Material>(
|
||||
|
||||
@@ -305,7 +305,7 @@ void main() {
|
||||
find.byKey(const Key('home-feed-episode-progress')),
|
||||
);
|
||||
expect(episodeProgress.maxLines, 1);
|
||||
expect(episodeProgress.softWrap, isFalse);
|
||||
expect(episodeProgress.overflow, TextOverflow.ellipsis);
|
||||
expect(episodeProgress.style?.fontSize, 13);
|
||||
expect(episodeProgress.style?.color, AppColors.overlayLabel);
|
||||
expect(
|
||||
@@ -646,13 +646,14 @@ void main() {
|
||||
.ignoring,
|
||||
isTrue,
|
||||
);
|
||||
expect(find.byKey(const Key('scrub-preview-time-bubble')), findsOneWidget);
|
||||
final bubbleRect = tester.getRect(
|
||||
find.byKey(const Key('scrub-preview-time-bubble')),
|
||||
expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget);
|
||||
expect(find.byKey(const Key('scrub-preview-time')), findsOneWidget);
|
||||
final previewRect = tester.getRect(
|
||||
find.byKey(const Key('scrub-preview-card')),
|
||||
);
|
||||
expect(bubbleRect.left, greaterThanOrEqualTo(12));
|
||||
expect(previewRect.left, greaterThanOrEqualTo(12));
|
||||
expect(
|
||||
bubbleRect.right,
|
||||
previewRect.right,
|
||||
lessThanOrEqualTo(
|
||||
tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 12,
|
||||
),
|
||||
@@ -662,33 +663,26 @@ void main() {
|
||||
.getRect(find.byKey(const Key('home-feed-progress-track')))
|
||||
.center
|
||||
.dy -
|
||||
bubbleRect.bottom,
|
||||
previewRect.bottom,
|
||||
closeTo(16, 0.1),
|
||||
);
|
||||
final bubbleContainer = tester.widget<Container>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('scrub-preview-time-bubble')),
|
||||
matching: find.byType(Container),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(bubbleContainer.decoration! as BoxDecoration).color,
|
||||
AppColors.timeBubbleScrim,
|
||||
);
|
||||
expect(
|
||||
bubbleContainer.padding,
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
final previewContainer = tester.widget<Container>(
|
||||
find.byKey(const Key('scrub-preview-card')),
|
||||
);
|
||||
final previewDecoration = previewContainer.decoration! as BoxDecoration;
|
||||
expect(previewDecoration.color, AppColors.card);
|
||||
expect(previewDecoration.borderRadius, BorderRadius.circular(8));
|
||||
expect((previewDecoration.border! as Border).top.color, AppColors.onPrimary);
|
||||
await gesture.moveBy(const Offset(-600, 0));
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester.getRect(find.byKey(const Key('scrub-preview-time-bubble'))).left,
|
||||
tester.getRect(find.byKey(const Key('scrub-preview-card'))).left,
|
||||
closeTo(12, 0.1),
|
||||
);
|
||||
await gesture.moveBy(const Offset(1200, 0));
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester.getRect(find.byKey(const Key('scrub-preview-time-bubble'))).right,
|
||||
tester.getRect(find.byKey(const Key('scrub-preview-card'))).right,
|
||||
closeTo(
|
||||
tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 12,
|
||||
0.1,
|
||||
@@ -855,6 +849,16 @@ void main() {
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
controller.value = const VideoPlayerValue(
|
||||
duration: Duration(seconds: 100),
|
||||
position: Duration(seconds: 40),
|
||||
isInitialized: true,
|
||||
isBuffering: true,
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget);
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_show_refresh_spinner_until_refresh_finishes', (
|
||||
@@ -970,7 +974,7 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget);
|
||||
expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget);
|
||||
expect(find.byKey(const Key('scrub-preview-background')), findsOneWidget);
|
||||
expect(find.textContaining('/ 01:40'), findsOneWidget);
|
||||
|
||||
await gesture.up();
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/config/app_config.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
@@ -13,6 +14,8 @@ import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_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/catalog_controller.dart';
|
||||
import 'package:fruitshorts/core/data/services/category_controller.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';
|
||||
|
||||
@@ -51,7 +54,11 @@ void main() {
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
|
||||
repository.complete(_feed);
|
||||
await tester.pumpAndSettle();
|
||||
await _pumpForAppData(tester);
|
||||
await _pumpUntilFound(
|
||||
tester,
|
||||
find.byKey(const Key('home-feed-card-the-signal-demo')),
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('startup-splash')), findsNothing);
|
||||
expect(find.byKey(const Key('home-grid-skeleton')), findsNothing);
|
||||
@@ -62,7 +69,8 @@ void main() {
|
||||
tester,
|
||||
) async {
|
||||
await _pumpApp(tester, _FailingCatalogRepository());
|
||||
await tester.pumpAndSettle();
|
||||
await _pumpForAppData(tester);
|
||||
await _pumpUntilFound(tester, find.byKey(const Key('home-grid-error')));
|
||||
|
||||
expect(find.byKey(const Key('home-grid-header')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-grid-error')), findsOneWidget);
|
||||
@@ -72,7 +80,8 @@ void main() {
|
||||
|
||||
testWidgets('should_show_catalog_empty_state_inside_grid', (tester) async {
|
||||
await _pumpApp(tester, _EmptyCatalogRepository());
|
||||
await tester.pumpAndSettle();
|
||||
await _pumpForAppData(tester);
|
||||
await _pumpUntilFound(tester, find.byKey(const Key('home-grid-empty')));
|
||||
|
||||
expect(find.byKey(const Key('home-grid-header')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-grid-empty')), findsOneWidget);
|
||||
@@ -99,6 +108,34 @@ Future<void> _pumpApp(WidgetTester tester, CatalogRepository repository) async {
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> _pumpUntilFound(WidgetTester tester, Finder finder) async {
|
||||
for (var frame = 0; frame < 40; frame++) {
|
||||
if (finder.evaluate().isNotEmpty) return;
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> _pumpForAppData(WidgetTester tester) async {
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(Duration.zero),
|
||||
);
|
||||
for (var frame = 0; frame < 40; frame++) {
|
||||
final root = find.byKey(const Key('home-tabs'));
|
||||
if (root.evaluate().isNotEmpty) {
|
||||
final context = tester.element(root);
|
||||
final catalog = context.read<CatalogController>();
|
||||
final categories = context.read<CategoryController>();
|
||||
if (catalog.settled && !catalog.loading && !categories.loading) {
|
||||
await tester.pump();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
final _feed = HomeFeed(
|
||||
sections: [
|
||||
FeedSection(code: 'hot', title: 'Hot', dramas: [demoDramas.first]),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:fruitshorts/core/data/repositories/favorite_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/widgets/app_bottom_nav.dart';
|
||||
import 'package:fruitshorts/pages/home/widgets/home_feed_grid.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.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';
|
||||
@@ -45,7 +46,7 @@ void main() {
|
||||
|
||||
expect(find.byKey(const Key('home-grid')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-grid-cards')), findsOneWidget);
|
||||
expect(find.byType(PageView), findsNothing);
|
||||
expect(find.byKey(const Key('home-primary-page-view')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-page-gradient')), findsNothing);
|
||||
expect(_hasLightStatusBar(tester), isTrue);
|
||||
});
|
||||
@@ -158,7 +159,20 @@ void main() {
|
||||
|
||||
testWidgets('should_open_player_from_home_grid_card', (tester) async {
|
||||
await _pumpApp(tester);
|
||||
await tester.tap(find.byKey(const Key('home-feed-card-the-signal-demo')));
|
||||
final grid = tester.widget<HomeFeedGrid>(find.byType(HomeFeedGrid));
|
||||
expect(grid.feedController.dramas, isNotEmpty);
|
||||
final card = find.byKey(
|
||||
const Key('home-feed-card-city-lights-demo'),
|
||||
);
|
||||
for (var attempt = 0; attempt < 10; attempt++) {
|
||||
if (card.evaluate().isNotEmpty) break;
|
||||
await tester.drag(
|
||||
find.byKey(const Key('home-grid-scroll')),
|
||||
const Offset(0, -240),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.tap(card);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final player = tester.widget<PlayerPage>(find.byType(PlayerPage).last);
|
||||
|
||||
@@ -443,7 +443,10 @@ Future<void> _pumpApp(
|
||||
catalogRepository: catalog,
|
||||
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
|
||||
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
|
||||
favoriteRepository: InMemoryFavoriteRepository(
|
||||
saved: catalog.dramas.map((drama) => drama.id).toSet(),
|
||||
cards: catalog.dramas,
|
||||
),
|
||||
playbackRepository: FakePlaybackRepository(),
|
||||
commentRepository: FakeCommentRepository(),
|
||||
interactionRepository: FakeInteractionRepository(),
|
||||
|
||||
@@ -54,6 +54,11 @@ void main() {
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
|
||||
expect(find.byType(Slider), findsNothing);
|
||||
expect(
|
||||
find.byKey(const Key('player-progress-uninitialized-time')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.textContaining('00:00'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_show_inactive_track_and_disable_seek_when_locked', (
|
||||
@@ -144,6 +149,119 @@ void main() {
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'should_hide_buffering_loading_immediately_when_position_advances',
|
||||
(tester) async {
|
||||
final video = ValueNotifier<VideoPlayerValue>(
|
||||
const VideoPlayerValue(
|
||||
duration: Duration(seconds: 12),
|
||||
position: Duration(seconds: 3),
|
||||
isInitialized: true,
|
||||
isPlaying: true,
|
||||
isBuffering: true,
|
||||
),
|
||||
);
|
||||
addTearDown(video.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SeekProgressBar(
|
||||
valueListenable: video,
|
||||
semanticsLabel: 'Seek video',
|
||||
onSeek: (_) async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
video.value = const VideoPlayerValue(
|
||||
duration: Duration(seconds: 12),
|
||||
position: Duration(seconds: 4),
|
||||
isInitialized: true,
|
||||
isBuffering: true,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
|
||||
video.value = const VideoPlayerValue(
|
||||
duration: Duration(seconds: 12),
|
||||
position: Duration(seconds: 4),
|
||||
isInitialized: true,
|
||||
isPlaying: true,
|
||||
isBuffering: true,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
|
||||
expect(find.byType(Slider), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should_not_show_loading_for_prewarmed_episode', (tester) async {
|
||||
final video = ValueNotifier<VideoPlayerValue>(
|
||||
const VideoPlayerValue(
|
||||
duration: Duration(seconds: 12),
|
||||
position: Duration(seconds: 5),
|
||||
isInitialized: true,
|
||||
isPlaying: true,
|
||||
isBuffering: true,
|
||||
),
|
||||
);
|
||||
addTearDown(video.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SeekProgressBar(
|
||||
valueListenable: video,
|
||||
semanticsLabel: 'Seek video',
|
||||
onSeek: (_) async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'should_suppress_progress_loading_while_entry_loading_is_visible',
|
||||
(tester) async {
|
||||
final video = ValueNotifier<VideoPlayerValue>(
|
||||
const VideoPlayerValue(
|
||||
duration: Duration(seconds: 12),
|
||||
position: Duration.zero,
|
||||
isInitialized: true,
|
||||
isBuffering: true,
|
||||
),
|
||||
);
|
||||
addTearDown(video.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SeekProgressBar(
|
||||
valueListenable: video,
|
||||
semanticsLabel: 'Seek video',
|
||||
suppressBufferLoading: true,
|
||||
onSeek: (_) async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should_keep_slider_while_scrubbing_during_buffering', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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/episode_stats.dart';
|
||||
import 'package:fruitshorts/core/data/models/player_port.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/android_picture_in_picture.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_http_adapter.dart';
|
||||
import '../support/fake_watch_history.dart';
|
||||
|
||||
Drama _drama(String id) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/poster.png',
|
||||
episodes: [
|
||||
for (var number = 1; number <= 3; number++)
|
||||
Episode(
|
||||
id: '$id-$number',
|
||||
number: number,
|
||||
title: 'Episode $number',
|
||||
isFree: true,
|
||||
videoUrl: 'https://example.test/$id-$number.mp4',
|
||||
likeCount: 1,
|
||||
commentCount: 1,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
AndroidPictureInPicture _pip() => AndroidPictureInPicture(
|
||||
channel: const MethodChannel('test/player_episode_stats/pip'),
|
||||
);
|
||||
|
||||
PlayerLogic _logic({
|
||||
required PlaybackRepository playback,
|
||||
DateTime Function()? clock,
|
||||
}) => PlayerLogic(
|
||||
history: FakeWatchHistory(),
|
||||
playback: playback,
|
||||
pictureInPicture: _pip(),
|
||||
waitForFrame: () async {},
|
||||
clock: clock,
|
||||
);
|
||||
|
||||
Future<void> _drain(PlayerLogic logic) async {
|
||||
for (var attempt = 0; attempt < 4; attempt++) {
|
||||
await pumpEventQueue();
|
||||
await logic.session.playbackSettled;
|
||||
await logic.session.preloadSettled;
|
||||
}
|
||||
await pumpEventQueue();
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test(
|
||||
'should_refresh_episode_stats_on_up_and_down_swipes_without_reopening_play',
|
||||
() async {
|
||||
var now = DateTime(2026, 10, 26, 12);
|
||||
final adapter = FakeHttpAdapter((options) async {
|
||||
final path = options.uri.path;
|
||||
if (path.endsWith('/play')) {
|
||||
final episodeId = path.split('/')[3];
|
||||
return FakeHttpResponse(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {
|
||||
'episodeId': episodeId,
|
||||
'accessible': true,
|
||||
'accessType': 'FREE',
|
||||
'playUrl': '/files/$episodeId.mp4',
|
||||
'qualities': ['AUTO'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path.endsWith('/stats')) {
|
||||
final episodeId = path.split('/')[3];
|
||||
return FakeHttpResponse(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {
|
||||
'episodeId': episodeId,
|
||||
'dramaId': 'signal',
|
||||
'likeCount': episodeId == 'signal-1' ? '11' : '22',
|
||||
'commentCount': episodeId == 'signal-1' ? '3' : '4',
|
||||
'favoriteCount': '30',
|
||||
'liked': episodeId == 'signal-1',
|
||||
'favorited': false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected request $path');
|
||||
});
|
||||
final client = ApiClient(
|
||||
baseUrl: 'http://127.0.0.1:8081',
|
||||
adapter: adapter,
|
||||
);
|
||||
final playback = HttpPlaybackRepository(
|
||||
PlaybackApi(client),
|
||||
clock: () => now,
|
||||
);
|
||||
final logs = <String>[];
|
||||
final previousDebugPrint = debugPrint;
|
||||
debugPrint = (message, {wrapWidth}) {
|
||||
if (message == null) return;
|
||||
logs.add(message);
|
||||
if (message.contains('[player] phase=episode-stats-refresh')) {
|
||||
previousDebugPrint(message, wrapWidth: wrapWidth);
|
||||
}
|
||||
};
|
||||
addTearDown(() {
|
||||
debugPrint = previousDebugPrint;
|
||||
client.close();
|
||||
});
|
||||
|
||||
final factory = _RecordingFactory();
|
||||
final logic = _logic(
|
||||
playback: playback,
|
||||
clock: () => now,
|
||||
);
|
||||
logic.attach(
|
||||
PlayerPage(drama: _drama('signal'), initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(
|
||||
factory: factory.create,
|
||||
coordinator: PlaybackCoordinator(),
|
||||
);
|
||||
await _drain(logic);
|
||||
|
||||
await logic.requestEpisodeSelection(1);
|
||||
await _drain(logic);
|
||||
final playRequestsBeforeBack = adapter.requests
|
||||
.where((request) => request.uri.path.endsWith('/play'))
|
||||
.length;
|
||||
final statsRequestsBeforeBack = adapter.requests
|
||||
.where((request) => request.uri.path.endsWith('/stats'))
|
||||
.length;
|
||||
|
||||
await logic.requestEpisodeSelection(0);
|
||||
await _drain(logic);
|
||||
|
||||
expect(
|
||||
adapter.requests.where((request) => request.uri.path.endsWith('/play')),
|
||||
hasLength(playRequestsBeforeBack),
|
||||
);
|
||||
expect(
|
||||
adapter.requests.where(
|
||||
(request) => request.uri.path.endsWith('/stats'),
|
||||
),
|
||||
hasLength(statsRequestsBeforeBack),
|
||||
);
|
||||
expect(
|
||||
logs,
|
||||
contains(
|
||||
'[player] phase=episode-stats-refresh episodeId=signal-1 cached=1',
|
||||
),
|
||||
);
|
||||
expect(logic.episodeStats('signal-1')?.likeCount, 11);
|
||||
|
||||
now = now.add(const Duration(seconds: 61));
|
||||
await logic.requestEpisodeSelection(1);
|
||||
await _drain(logic);
|
||||
await logic.requestEpisodeSelection(0);
|
||||
await _drain(logic);
|
||||
|
||||
expect(
|
||||
adapter.requests.where(
|
||||
(request) => request.uri.path.endsWith('/stats'),
|
||||
),
|
||||
hasLength(statsRequestsBeforeBack + 2),
|
||||
);
|
||||
expect(
|
||||
adapter.requests.where((request) => request.uri.path.endsWith('/play')),
|
||||
hasLength(playRequestsBeforeBack),
|
||||
);
|
||||
expect(
|
||||
logs.where(
|
||||
(line) => line.contains(
|
||||
'episode-stats-refresh episodeId=signal-1 cached=0',
|
||||
),
|
||||
),
|
||||
hasLength(2),
|
||||
);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
expect(factory.live, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test('should_keep_local_like_count_when_switching_back', () async {
|
||||
final stats = EpisodeStats(
|
||||
episodeId: 'signal-1',
|
||||
dramaId: 'signal',
|
||||
likeCount: 7,
|
||||
commentCount: 2,
|
||||
favoriteCount: 30,
|
||||
liked: false,
|
||||
favorited: false,
|
||||
);
|
||||
final playback = FakePlaybackRepository(
|
||||
statsByEpisode: {'signal-1': stats},
|
||||
);
|
||||
final logic = _logic(playback: playback);
|
||||
logic.attach(
|
||||
PlayerPage(drama: _drama('signal'), initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(
|
||||
factory: _RecordingFactory().create,
|
||||
coordinator: PlaybackCoordinator(),
|
||||
);
|
||||
await _drain(logic);
|
||||
expect(logic.episodeStats('signal-1')?.likeCount, 7);
|
||||
|
||||
logic.applyEpisodeLikeState('signal-1', liked: true, likeCount: 8);
|
||||
await logic.requestEpisodeSelection(1);
|
||||
await _drain(logic);
|
||||
await logic.requestEpisodeSelection(0);
|
||||
await _drain(logic);
|
||||
|
||||
expect(logic.episodeStats('signal-1')?.liked, isTrue);
|
||||
expect(logic.episodeStats('signal-1')?.likeCount, 8);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('should_keep_playback_when_episode_stats_fail', () async {
|
||||
final playback = FakePlaybackRepository(
|
||||
statsError: StateError('stats unavailable'),
|
||||
);
|
||||
final logic = _logic(playback: playback);
|
||||
logic.attach(
|
||||
PlayerPage(drama: _drama('signal'), initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(
|
||||
factory: _RecordingFactory().create,
|
||||
coordinator: PlaybackCoordinator(),
|
||||
);
|
||||
await _drain(logic);
|
||||
|
||||
expect(logic.session.active, isNotNull);
|
||||
expect(logic.ticketError, isNull);
|
||||
expect(logic.episodeStats('signal-1'), isNull);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
}
|
||||
|
||||
class _RecordingFactory {
|
||||
int live = 0;
|
||||
final List<_RecordingPlayer> created = [];
|
||||
|
||||
PlayerPort create(Episode episode) {
|
||||
live++;
|
||||
final player = _RecordingPlayer(onDispose: () => live--);
|
||||
created.add(player);
|
||||
return player;
|
||||
}
|
||||
}
|
||||
|
||||
class _RecordingPlayer implements PlayerPort {
|
||||
_RecordingPlayer({required this.onDispose});
|
||||
|
||||
final void Function() onDispose;
|
||||
bool disposed = false;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<void> play() async {}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
onDispose();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p
|
||||
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/episode_stats.dart';
|
||||
import 'package:fruitshorts/core/data/models/interaction.dart';
|
||||
import 'package:fruitshorts/core/data/models/play_ticket.dart';
|
||||
import 'package:fruitshorts/core/data/models/player_port.dart';
|
||||
@@ -229,7 +230,7 @@ void main() {
|
||||
expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_show_one_paused_overlay_when_entering_full_player', (
|
||||
testWidgets('should_autoplay_full_player_and_restore_paused_feed', (
|
||||
tester,
|
||||
) async {
|
||||
final factory = _RouteVideoFactory();
|
||||
@@ -245,7 +246,9 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_fullPlayerFinder(), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-play-toggle')), findsOneWidget);
|
||||
final fullLogic = _pageLogic(tester, _fullPlayerFinder());
|
||||
expect(fullLogic.session.userWantsPlay, isTrue);
|
||||
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
|
||||
expect(
|
||||
find.byKey(const Key('home-feed-center-play')).hitTestable(),
|
||||
findsNothing,
|
||||
@@ -361,7 +364,11 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('player-progress')), findsOneWidget);
|
||||
expect(find.text('00:00 / 01:00'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('player-progress-uninitialized-time')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text('00:00 / 01:00'), findsNothing);
|
||||
|
||||
// IgnorePointer 让加载中的播放暂停与上下滑穿透,滑动会关闭入口 loading。
|
||||
expect(fullLogic.session.userWantsPlay, isTrue);
|
||||
@@ -515,9 +522,81 @@ void main() {
|
||||
expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_pause_feed_when_auth_page_is_pushed_and_resume_on_pop', (
|
||||
testWidgets('should_auto_play_full_player_when_feed_is_paused', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpApp(tester, playerFactory: (_) => _RecordingPlayer());
|
||||
await tester.tap(find.byKey(const Key('tab-recommend')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final feedPage = find.byKey(const Key('recommend-feed-player'));
|
||||
final feedLogic = _pageLogic(tester, feedPage);
|
||||
await feedLogic.session.preloadSettled;
|
||||
feedLogic.session.setUserWantsPlay(false);
|
||||
await feedLogic.session.playbackSettled;
|
||||
await tester.pump();
|
||||
expect(feedLogic.session.userWantsPlay, isFalse);
|
||||
|
||||
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final fullLogic = _pageLogic(tester, _fullPlayerFinder());
|
||||
final fullPlayer = fullLogic.session.active! as _RecordingPlayer;
|
||||
expect(fullLogic.session, isNot(same(feedLogic.session)));
|
||||
expect(fullLogic.session.userWantsPlay, isTrue);
|
||||
expect(fullPlayer.playCalls, greaterThan(0));
|
||||
expect(fullPlayer.playing, isTrue);
|
||||
|
||||
await tester.tap(find.byKey(const Key('player-back')).hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
expect(feedLogic.session.userWantsPlay, isFalse);
|
||||
expect(feedLogic.session.activeSlotPlaying, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('should_only_show_progress_buffer_after_video_first_frame', (
|
||||
tester,
|
||||
) async {
|
||||
final factory = _RouteVideoFactory();
|
||||
await _pumpApp(tester, playerFactory: factory.create);
|
||||
await _openSeriesPlayer(tester);
|
||||
|
||||
final videoIndex = factory.episodes.lastIndexWhere(
|
||||
(episode) => episode.id == 'city-lights-demo-1',
|
||||
);
|
||||
final video = factory.created[videoIndex];
|
||||
expect(video.controller.value.isInitialized, isTrue);
|
||||
expect(find.byKey(const Key('player-entry-loading-overlay')), findsNothing);
|
||||
|
||||
video.controller.value = const VideoPlayerValue(
|
||||
duration: Duration(minutes: 1),
|
||||
size: Size(1280, 720),
|
||||
position: Duration(seconds: 5),
|
||||
isInitialized: true,
|
||||
isPlaying: true,
|
||||
isBuffering: true,
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-entry-loading-overlay')), findsNothing);
|
||||
expect(find.byKey(const Key('player-video-loading')), findsNothing);
|
||||
expect(find.byKey(const Key('player-ticket-loading')), findsNothing);
|
||||
|
||||
video.controller.value = const VideoPlayerValue(
|
||||
duration: Duration(minutes: 1),
|
||||
size: Size(1280, 720),
|
||||
position: Duration(seconds: 6),
|
||||
isInitialized: true,
|
||||
isPlaying: true,
|
||||
isBuffering: true,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_keep_feed_playing_when_guest_saves', (tester) async {
|
||||
final players = <_RecordingPlayer>[];
|
||||
await _pumpApp(
|
||||
tester,
|
||||
@@ -534,18 +613,23 @@ void main() {
|
||||
|
||||
await tester.tap(find.byKey(const Key('rail-save')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Sign In'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AuthPage), findsOneWidget);
|
||||
expect(player.playing, isFalse);
|
||||
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AuthPage), findsNothing);
|
||||
expect(player.playing, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('should_like_without_login_from_recommend_feed', (tester) async {
|
||||
final interactions = FakeInteractionRepository();
|
||||
await _pumpApp(tester, interactions: interactions);
|
||||
await tester.tap(find.byKey(const Key('tab-recommend')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('rail-like')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AuthPage), findsNothing);
|
||||
expect(interactions.likeTargets, ['EPISODE:the-signal-demo-1']);
|
||||
});
|
||||
|
||||
testWidgets('should_keep_video_surface_background_black', (tester) async {
|
||||
await _pumpApp(tester);
|
||||
await _openSeriesPlayer(tester);
|
||||
@@ -562,7 +646,7 @@ void main() {
|
||||
expect(find.byKey(const Key('player-fallback-scrim')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_gate_comments_before_opening_sheet_when_signed_out', (
|
||||
testWidgets('should_open_comments_without_login', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpApp(tester);
|
||||
@@ -571,8 +655,8 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('player-rail-comments')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sign in to continue'), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-comments-sheet')), findsNothing);
|
||||
expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget);
|
||||
expect(find.byType(AuthPage), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_open_feed_comments_when_signed_in', (tester) async {
|
||||
@@ -1791,6 +1875,12 @@ void main() {
|
||||
testWidgets('should_open_unlock_panel_with_honest_method_states', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.withData({
|
||||
'auth.accessToken': 'access',
|
||||
'auth.refreshToken': 'refresh',
|
||||
'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}',
|
||||
});
|
||||
final interactions = FakeInteractionRepository();
|
||||
await _pumpApp(
|
||||
tester,
|
||||
@@ -1824,6 +1914,36 @@ void main() {
|
||||
find.text('Available unlock methods vary by title.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
testWidgets('should_require_login_before_opening_unlock_flow', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpApp(
|
||||
tester,
|
||||
playback: FakePlaybackRepository(
|
||||
ticket: const PlayTicket(
|
||||
episodeId: 'city-lights-demo-1',
|
||||
accessible: false,
|
||||
accessType: PlayTicket.coinRequired,
|
||||
unlockCoin: 30,
|
||||
),
|
||||
),
|
||||
);
|
||||
await _openSeriesPlayer(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('player-open-unlock')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byKey(const Key('player-unlock-panel')), findsNothing);
|
||||
|
||||
final signIn = find.widgetWithText(FilledButton, 'Sign In');
|
||||
await tester.ensureVisible(signIn);
|
||||
await tester.tap(signIn);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byType(AuthPage), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2056,12 +2176,14 @@ class _RouteVideoPlayer extends VideoPlayerPort {
|
||||
|
||||
class _RecordingPlayer implements PlayerPort {
|
||||
bool playing = false;
|
||||
int playCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
playCalls++;
|
||||
playing = true;
|
||||
}
|
||||
|
||||
@@ -2076,11 +2198,15 @@ class _RecordingPlayer implements PlayerPort {
|
||||
|
||||
class _PendingPlaybackRepository implements PlaybackRepository {
|
||||
final Completer<PlayTicket> ticket = Completer<PlayTicket>();
|
||||
final Completer<EpisodeStats> stats = Completer<EpisodeStats>();
|
||||
|
||||
@override
|
||||
Future<PlayTicket> ticketFor(String episodeId, {bool forceRefresh = false}) =>
|
||||
ticket.future;
|
||||
|
||||
@override
|
||||
Future<EpisodeStats> episodeStats(String episodeId) => stats.future;
|
||||
|
||||
@override
|
||||
bool isFresh(String episodeId) => false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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/models/wallet.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/commerce_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||
import 'package:fruitshorts/core/data/services/wallet_controller.dart';
|
||||
import 'package:fruitshorts/pages/player/player_logic.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
|
||||
const _lockedDrama = Drama(
|
||||
id: 'locked',
|
||||
title: 'Locked',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/locked.png',
|
||||
episodes: [
|
||||
Episode(
|
||||
id: 'locked-1',
|
||||
number: 1,
|
||||
title: 'Locked episode',
|
||||
isFree: false,
|
||||
locked: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('should_not_request_ticket_for_guest_on_locked_episode', () async {
|
||||
final playback = FakePlaybackRepository();
|
||||
final logic = _logic(playback, signedIn: false);
|
||||
logic.attach(
|
||||
const PlayerPage(drama: _lockedDrama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(
|
||||
factory: (_) => _NoopPlayer(),
|
||||
coordinator: PlaybackCoordinator(),
|
||||
);
|
||||
await _drain(logic);
|
||||
|
||||
expect(playback.requests, isEmpty);
|
||||
expect(logic.session.isLocked, isTrue);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('should_request_ticket_after_user_is_signed_in', () async {
|
||||
final playback = FakePlaybackRepository(
|
||||
ticket: const PlayTicket(
|
||||
episodeId: 'locked-1',
|
||||
accessible: false,
|
||||
accessType: PlayTicket.coinRequired,
|
||||
unlockCoin: 30,
|
||||
),
|
||||
);
|
||||
final logic = _logic(playback, signedIn: true);
|
||||
logic.attach(
|
||||
const PlayerPage(drama: _lockedDrama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(
|
||||
factory: (_) => _NoopPlayer(),
|
||||
coordinator: PlaybackCoordinator(),
|
||||
);
|
||||
await _drain(logic);
|
||||
|
||||
expect(playback.requests, ['locked-1']);
|
||||
expect(logic.blockedEpisodeId, 'locked-1');
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('should_resume_unlock_and_play_for_same_episode_after_login', () async {
|
||||
var signedIn = false;
|
||||
final playback = FakePlaybackRepository(
|
||||
ticket: const PlayTicket(
|
||||
episodeId: 'locked-1',
|
||||
accessible: true,
|
||||
accessType: 'ENTITLED',
|
||||
playUrl: '/files/locked-1.mp4',
|
||||
),
|
||||
);
|
||||
final logic = _logic(
|
||||
playback,
|
||||
signedIn: false,
|
||||
isSignedIn: () => signedIn,
|
||||
wallet: WalletController(_UnlockCommerceRepository()),
|
||||
);
|
||||
logic.attach(
|
||||
const PlayerPage(drama: _lockedDrama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(
|
||||
factory: (_) => _NoopPlayer(),
|
||||
coordinator: PlaybackCoordinator(),
|
||||
);
|
||||
await _drain(logic);
|
||||
|
||||
expect(playback.requests, isEmpty);
|
||||
expect(logic.blockedEpisodeId, 'locked-1');
|
||||
|
||||
signedIn = true;
|
||||
final unlocked = await logic.unlockEpisode(0);
|
||||
await _drain(logic);
|
||||
|
||||
expect(unlocked, isTrue);
|
||||
expect(playback.requests, ['locked-1']);
|
||||
expect(logic.session.active, isNotNull);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
}
|
||||
|
||||
PlayerLogic _logic(
|
||||
PlaybackRepository playback, {
|
||||
bool signedIn = true,
|
||||
bool Function()? isSignedIn,
|
||||
WalletController? wallet,
|
||||
}) {
|
||||
return PlayerLogic(
|
||||
history: FakeWatchHistory(),
|
||||
playback: playback,
|
||||
wallet: wallet,
|
||||
pictureInPicture: AndroidPictureInPicture(
|
||||
channel: const MethodChannel('test/player_locked_auth_flow/pip'),
|
||||
),
|
||||
waitForFrame: () async {},
|
||||
isSignedIn: isSignedIn ?? () => signedIn,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _drain(PlayerLogic logic) async {
|
||||
for (var attempt = 0; attempt < 4; attempt++) {
|
||||
await pumpEventQueue();
|
||||
await logic.session.playbackSettled;
|
||||
await logic.session.preloadSettled;
|
||||
}
|
||||
}
|
||||
|
||||
class _NoopPlayer implements PlayerPort {
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<void> play() async {}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
class _UnlockCommerceRepository implements CommerceRepository {
|
||||
@override
|
||||
Future<WalletSnapshot> fetchWallet() async => const WalletSnapshot(
|
||||
wallet: CoinWallet(
|
||||
coinBalance: 100,
|
||||
totalRechargedCoin: 0,
|
||||
totalConsumedCoin: 0,
|
||||
totalRefundedCoin: 0,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<UnlockResult> unlock(String episodeId) async => const UnlockResult(
|
||||
unlocked: true,
|
||||
accessType: 'ENTITLED',
|
||||
coinCost: 30,
|
||||
coinBalance: 70,
|
||||
);
|
||||
}
|
||||
@@ -266,7 +266,7 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_open_auth_gate_without_liking_when_signed_out', (
|
||||
testWidgets('should_like_without_login_when_signed_out', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
@@ -279,8 +279,8 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('player-rail-like')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sign in to continue'), findsOneWidget);
|
||||
expect(interactions.likeTargets, isEmpty);
|
||||
expect(find.text('Sign in to continue'), findsNothing);
|
||||
expect(interactions.likeTargets, ['EPISODE:city-lights-demo-1']);
|
||||
});
|
||||
|
||||
testWidgets('should_show_saved_player_drama_in_favorites', (tester) async {
|
||||
|
||||
Reference in new issue
Block a user