11 Commits
50 changed files with 1603 additions and 245 deletions

No files matched your search

+14
View File
@@ -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(),
+55
View File
@@ -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,
);
}
}
+5 -3
View File
@@ -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;
}
+1
View File
@@ -134,6 +134,7 @@ class AuthApi {
'platform': platform,
'osVersion': osVersion,
},
skipAuthRetry: true,
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
+8
View File
@@ -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');
}
}
+16 -4
View File
@@ -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,
);
}
}
+17 -1
View File
@@ -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() {
+1 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
}
+10 -3
View File
@@ -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,
+2 -12
View File
@@ -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);
}
+2
View File
@@ -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,
);
}
+213 -5
View File
@@ -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) {
+86 -18
View File
@@ -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>(
+23 -24
View File
@@ -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) {
+41 -18
View File
@@ -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),
+7 -5
View File
@@ -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),
),
),
],
);
+1 -1
View File
@@ -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(
+2 -3
View File
@@ -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,
);
});
+20 -24
View File
@@ -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', (
+5 -1
View File
@@ -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 {
+1 -1
View File
@@ -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(
+5 -1
View File
@@ -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();
+5 -3
View File
@@ -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 -3
View File
@@ -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();
}
+1 -1
View File
@@ -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', (
+10 -2
View File
@@ -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,
+26 -12
View File
@@ -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;
+6 -16
View File
@@ -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>(
+27 -23
View File
@@ -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();
+40 -3
View File
@@ -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]),
+16 -2
View File
@@ -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);
+4 -1
View File
@@ -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(),
+118
View File
@@ -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 {
+301
View File
@@ -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();
}
}
+142 -16
View File
@@ -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,
);
}
+3 -3
View File
@@ -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 {