feat(player): 切集刷新互动计数统计
This commit is contained in:
1 parent
37353dac15
commit
188811fde6
11 files changed
+694
-20
No files matched your search
@@ -60,6 +60,20 @@ class Episode {
|
|||||||
posterUrl: posterUrl ?? this.posterUrl,
|
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) {
|
factory Episode.fromJson(Map<String, dynamic> json) {
|
||||||
return Episode(
|
return Episode(
|
||||||
id: (json['id'] ?? '').toString(),
|
id: (json['id'] ?? '').toString(),
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 播放页轻量互动计数快照,不包含播放地址或票据信息。
|
||||||
|
class EpisodeStats {
|
||||||
|
const EpisodeStats({
|
||||||
|
required this.episodeId,
|
||||||
|
required this.dramaId,
|
||||||
|
required this.likeCount,
|
||||||
|
required this.commentCount,
|
||||||
|
required this.favoriteCount,
|
||||||
|
required this.liked,
|
||||||
|
required this.favorited,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String episodeId;
|
||||||
|
final String dramaId;
|
||||||
|
final int likeCount;
|
||||||
|
final int commentCount;
|
||||||
|
final int favoriteCount;
|
||||||
|
final bool liked;
|
||||||
|
final bool favorited;
|
||||||
|
|
||||||
|
EpisodeStats copyWith({
|
||||||
|
String? dramaId,
|
||||||
|
int? likeCount,
|
||||||
|
int? commentCount,
|
||||||
|
int? favoriteCount,
|
||||||
|
bool? liked,
|
||||||
|
bool? favorited,
|
||||||
|
}) {
|
||||||
|
return EpisodeStats(
|
||||||
|
episodeId: episodeId,
|
||||||
|
dramaId: dramaId ?? this.dramaId,
|
||||||
|
likeCount: likeCount ?? this.likeCount,
|
||||||
|
commentCount: commentCount ?? this.commentCount,
|
||||||
|
favoriteCount: favoriteCount ?? this.favoriteCount,
|
||||||
|
liked: liked ?? this.liked,
|
||||||
|
favorited: favorited ?? this.favorited,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory EpisodeStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EpisodeStats(
|
||||||
|
episodeId: jsonToId(json['episodeId']),
|
||||||
|
dramaId: jsonToId(json['dramaId']),
|
||||||
|
likeCount: jsonToInt(json['likeCount']),
|
||||||
|
commentCount: jsonToInt(json['commentCount']),
|
||||||
|
favoriteCount: jsonToInt(json['favoriteCount']),
|
||||||
|
liked: json['liked'] == true,
|
||||||
|
favorited: json['favorited'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/episode_stats.dart';
|
||||||
import '../models/play_ticket.dart';
|
import '../models/play_ticket.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
@@ -17,4 +18,11 @@ class PlaybackApi {
|
|||||||
if (data is Map<String, dynamic>) return PlayTicket.fromJson(data);
|
if (data is Map<String, dynamic>) return PlayTicket.fromJson(data);
|
||||||
throw const ApiException(-1, '返回数据格式异常');
|
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
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/episode_stats.dart';
|
||||||
import '../models/play_ticket.dart';
|
import '../models/play_ticket.dart';
|
||||||
import '../remote/playback_api.dart';
|
import '../remote/playback_api.dart';
|
||||||
|
|
||||||
@@ -10,6 +11,9 @@ import '../remote/playback_api.dart';
|
|||||||
abstract interface class PlaybackRepository {
|
abstract interface class PlaybackRepository {
|
||||||
Future<PlayTicket> ticketFor(String episodeId, {bool forceRefresh = false});
|
Future<PlayTicket> ticketFor(String episodeId, {bool forceRefresh = false});
|
||||||
|
|
||||||
|
/// 读取互动计数快照;与 [ticketFor] 分离,避免为了刷新计数重新签发地址。
|
||||||
|
Future<EpisodeStats> episodeStats(String episodeId);
|
||||||
|
|
||||||
/// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。
|
/// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。
|
||||||
bool isFresh(String episodeId);
|
bool isFresh(String episodeId);
|
||||||
}
|
}
|
||||||
@@ -42,6 +46,10 @@ class HttpPlaybackRepository implements PlaybackRepository {
|
|||||||
return ticket;
|
return ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<EpisodeStats> episodeStats(String episodeId) =>
|
||||||
|
_api.episodeStats(episodeId);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool isFresh(String episodeId) {
|
bool isFresh(String episodeId) {
|
||||||
final cached = _cache[episodeId];
|
final cached = _cache[episodeId];
|
||||||
@@ -65,11 +73,22 @@ class _CachedTicket {
|
|||||||
|
|
||||||
/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。
|
/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。
|
||||||
class FakePlaybackRepository implements PlaybackRepository {
|
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;
|
PlayTicket? ticket;
|
||||||
Object? error;
|
Object? error;
|
||||||
|
EpisodeStats? stats;
|
||||||
|
Object? statsError;
|
||||||
|
final Map<String, EpisodeStats> statsByEpisode;
|
||||||
final List<String> requests = [];
|
final List<String> requests = [];
|
||||||
|
final List<String> statsRequests = [];
|
||||||
final List<bool> forced = [];
|
final List<bool> forced = [];
|
||||||
|
|
||||||
/// 模拟"票据是否还有效":置 false 可验证回前台会重新取。
|
/// 模拟"票据是否还有效":置 false 可验证回前台会重新取。
|
||||||
@@ -95,4 +114,13 @@ class FakePlaybackRepository implements PlaybackRepository {
|
|||||||
qualities: const ['AUTO', '720P', '1080P'],
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,17 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
|
|
||||||
bool isSaved(String dramaId) => _saved.contains(dramaId);
|
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() {
|
void notify() {
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,24 @@ class InteractionController extends ChangeNotifier {
|
|||||||
/// 该集服务端最新点赞数。
|
/// 该集服务端最新点赞数。
|
||||||
int? episodeLikeCount(String episodeId) => _episodeLikeCounts[episodeId];
|
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) =>
|
Future<OperationResult<LikeToggleResult>> toggleDramaLike(String dramaId) =>
|
||||||
_toggleLike(
|
_toggleLike(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:flutter/scheduler.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.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/play_ticket.dart';
|
||||||
import '../../core/data/models/watch_progress.dart';
|
import '../../core/data/models/watch_progress.dart';
|
||||||
import '../../core/data/models/player_port.dart';
|
import '../../core/data/models/player_port.dart';
|
||||||
@@ -51,9 +52,11 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
this.downloads,
|
this.downloads,
|
||||||
PlaybackPositionCache? positionCache,
|
PlaybackPositionCache? positionCache,
|
||||||
Future<void> Function()? waitForFrame,
|
Future<void> Function()? waitForFrame,
|
||||||
|
DateTime Function()? clock,
|
||||||
}) : positionCache = positionCache ?? PlaybackPositionCache(),
|
}) : positionCache = positionCache ?? PlaybackPositionCache(),
|
||||||
_waitForFrame =
|
_waitForFrame =
|
||||||
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame);
|
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame),
|
||||||
|
_clock = clock ?? DateTime.now;
|
||||||
|
|
||||||
final WatchHistoryRepository history;
|
final WatchHistoryRepository history;
|
||||||
final PlaybackRepository playback;
|
final PlaybackRepository playback;
|
||||||
@@ -62,6 +65,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
final DownloadController? downloads;
|
final DownloadController? downloads;
|
||||||
final PlaybackPositionCache positionCache;
|
final PlaybackPositionCache positionCache;
|
||||||
final Future<void> Function() _waitForFrame;
|
final Future<void> Function() _waitForFrame;
|
||||||
|
final DateTime Function() _clock;
|
||||||
|
|
||||||
late PageController pages;
|
late PageController pages;
|
||||||
late PlaybackQueue queue;
|
late PlaybackQueue queue;
|
||||||
@@ -100,11 +104,19 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
bool muted = false;
|
bool muted = false;
|
||||||
double playbackSpeed = 1.0;
|
double playbackSpeed = 1.0;
|
||||||
bool stoppedAtFeedEnd = false;
|
bool stoppedAtFeedEnd = false;
|
||||||
|
Duration episodeStatsTtl = const Duration(seconds: 60);
|
||||||
|
ValueChanged<EpisodeStats>? onEpisodeStatsUpdated;
|
||||||
VideoPlayerPort? scrubbingPlayer;
|
VideoPlayerPort? scrubbingPlayer;
|
||||||
bool pip = false;
|
bool pip = false;
|
||||||
bool pipPreparing = false;
|
bool pipPreparing = false;
|
||||||
final Map<String, int> pickedEpisode = {};
|
final Map<String, int> pickedEpisode = {};
|
||||||
final Set<String> _unlockedEpisodeIds = {};
|
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 与失败文案;空表示没有进行中的解锁。
|
/// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。
|
||||||
String? unlockingEpisodeId;
|
String? unlockingEpisodeId;
|
||||||
@@ -168,6 +180,10 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EpisodeStats? episodeStats(String episodeId) => _episodeStats[episodeId];
|
||||||
|
|
||||||
|
int? dramaFavoriteCount(String dramaId) => _dramaFavoriteCounts[dramaId];
|
||||||
|
|
||||||
void notify() {
|
void notify() {
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -536,6 +552,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) =>
|
String _fileUrl(String path) =>
|
||||||
path.startsWith('file:') ? path : Uri.file(path).toString();
|
path.startsWith('file:') ? path : Uri.file(path).toString();
|
||||||
|
|
||||||
@@ -1076,7 +1265,10 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
!isMounted()) {
|
!isMounted()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ready) return;
|
if (!ready) {
|
||||||
|
unawaited(refreshEpisodeStats(index));
|
||||||
|
return;
|
||||||
|
}
|
||||||
await _selectVisible(index, userWantsPlay: userWantsPlay);
|
await _selectVisible(index, userWantsPlay: userWantsPlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,13 +1279,16 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 按可见队列选择一集,并预热上一集与下一集。
|
/// 按可见队列选择一集,并预热上一集与下一集。
|
||||||
Future<void> _selectVisible(int index, {bool? userWantsPlay}) {
|
Future<void> _selectVisible(int index, {bool? userWantsPlay}) async {
|
||||||
return session.select(
|
await session.select(
|
||||||
index,
|
index,
|
||||||
preloadIndex: nextVisibleIndex(index),
|
preloadIndex: nextVisibleIndex(index),
|
||||||
previousIndex: previousVisibleIndex(index),
|
previousIndex: previousVisibleIndex(index),
|
||||||
userWantsPlay: userWantsPlay,
|
userWantsPlay: userWantsPlay,
|
||||||
);
|
);
|
||||||
|
if (!_disposed && session.currentIndex == index) {
|
||||||
|
unawaited(refreshEpisodeStats(index));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int? nextVisibleIndex(int index) {
|
int? nextVisibleIndex(int index) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../../core/config/app_config.dart';
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
|
import '../../core/data/models/episode_stats.dart';
|
||||||
import '../../core/data/repositories/comment_repository.dart';
|
import '../../core/data/repositories/comment_repository.dart';
|
||||||
import '../../core/data/services/catalog_controller.dart';
|
import '../../core/data/services/catalog_controller.dart';
|
||||||
import '../../core/data/services/favorite_controller.dart';
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
@@ -153,8 +154,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
PlayerHeartOverlayController();
|
PlayerHeartOverlayController();
|
||||||
final Set<String> _precachedPosterUrls = {};
|
final Set<String> _precachedPosterUrls = {};
|
||||||
|
|
||||||
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
/// 评论接口返回的最新数量;统计快照优先,失败时保留这里的旧值。
|
||||||
final Map<String, int> _favoriteDelta = {};
|
|
||||||
final Map<String, int> _commentCounts = {};
|
final Map<String, int> _commentCounts = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -169,6 +169,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
wallet: widget.wallet,
|
wallet: widget.wallet,
|
||||||
downloads: widget.downloads,
|
downloads: widget.downloads,
|
||||||
);
|
);
|
||||||
|
logic.onEpisodeStatsUpdated = _applyEpisodeStats;
|
||||||
logic.attach(widget, isMounted: () => mounted);
|
logic.attach(widget, isMounted: () => mounted);
|
||||||
_thumbnailService = PlaybackThumbnailService(
|
_thumbnailService = PlaybackThumbnailService(
|
||||||
sourceResolver: _resolveThumbnailSource,
|
sourceResolver: _resolveThumbnailSource,
|
||||||
@@ -550,14 +551,39 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。
|
void _applyEpisodeStats(EpisodeStats stats) {
|
||||||
void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) {
|
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(() {
|
setState(() {
|
||||||
_favoriteDelta[dramaId] =
|
_commentCounts[stats.episodeId] = stats.commentCount;
|
||||||
(_favoriteDelta[dramaId] ?? 0) + (nowSaved ? 1 : -1);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 收藏状态确认后同步右侧剧级计数。
|
||||||
|
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(
|
Future<void> _openComments(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Drama drama,
|
Drama drama,
|
||||||
@@ -573,6 +599,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
try {
|
try {
|
||||||
final count = await comments.count(drama.id, episodeId: episode.id);
|
final count = await comments.count(drama.id, episodeId: episode.id);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
logic.applyEpisodeCommentCount(episode.id, count);
|
||||||
setState(() => _commentCounts[episode.id] = count);
|
setState(() => _commentCounts[episode.id] = count);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 评论面板已经关闭,计数刷新失败时保留旧值即可。
|
// 评论面板已经关闭,计数刷新失败时保留旧值即可。
|
||||||
@@ -1159,10 +1186,12 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
liked: interactions.isEpisodeLiked(episode.id),
|
liked: interactions.isEpisodeLiked(episode.id),
|
||||||
likeCount:
|
likeCount:
|
||||||
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
|
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
|
||||||
favoriteCount: drama.favoriteCount == null
|
favoriteCount:
|
||||||
? null
|
logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount,
|
||||||
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
|
commentCount:
|
||||||
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
|
logic.episodeStats(episode.id)?.commentCount ??
|
||||||
|
_commentCounts[episode.id] ??
|
||||||
|
episode.commentCount,
|
||||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||||
onLike: () async {
|
onLike: () async {
|
||||||
await _toggleLike(context, interactions, episode);
|
await _toggleLike(context, interactions, episode);
|
||||||
@@ -1265,10 +1294,12 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
liked: interactions.isEpisodeLiked(episode.id),
|
liked: interactions.isEpisodeLiked(episode.id),
|
||||||
likeCount:
|
likeCount:
|
||||||
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
|
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
|
||||||
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
|
commentCount:
|
||||||
favoriteCount: drama.favoriteCount == null
|
logic.episodeStats(episode.id)?.commentCount ??
|
||||||
? null
|
_commentCounts[episode.id] ??
|
||||||
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
|
episode.commentCount,
|
||||||
|
favoriteCount:
|
||||||
|
logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount,
|
||||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||||
playing: logic.session.userWantsPlay,
|
playing: logic.session.userWantsPlay,
|
||||||
playbackSpeed: _playbackSpeed,
|
playbackSpeed: _playbackSpeed,
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ extension _PlayerViewActions on _PlayerPageState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
|
final likeCount = interactions.episodeLikeCount(episode.id);
|
||||||
|
if (likeCount != null) {
|
||||||
|
logic.applyEpisodeLikeState(
|
||||||
|
episode.id,
|
||||||
|
liked: interactions.isEpisodeLiked(episode.id),
|
||||||
|
likeCount: likeCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
_refreshLikeState();
|
_refreshLikeState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,7 +179,7 @@ extension _PlayerViewActions on _PlayerPageState {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
final nowSaved = favorites.isSaved(drama.id);
|
final nowSaved = favorites.isSaved(drama.id);
|
||||||
if (nowSaved != wasSaved) {
|
if (nowSaved != wasSaved) {
|
||||||
_applyFavoriteDelta(drama.id, nowSaved: nowSaved);
|
_applyFavoriteDelta(drama, nowSaved: nowSaved);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/episode_stats.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/player_port.dart';
|
||||||
|
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||||
|
import 'package:fruitshorts/core/data/remote/playback_api.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/player_logic.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||||
|
|
||||||
|
import '../support/fake_http_adapter.dart';
|
||||||
|
import '../support/fake_watch_history.dart';
|
||||||
|
|
||||||
|
Drama _drama(String id) => Drama(
|
||||||
|
id: id,
|
||||||
|
title: id,
|
||||||
|
synopsis: 'synopsis',
|
||||||
|
posterUrl: '/files/poster.png',
|
||||||
|
episodes: [
|
||||||
|
for (var number = 1; number <= 3; number++)
|
||||||
|
Episode(
|
||||||
|
id: '$id-$number',
|
||||||
|
number: number,
|
||||||
|
title: 'Episode $number',
|
||||||
|
isFree: true,
|
||||||
|
videoUrl: 'https://example.test/$id-$number.mp4',
|
||||||
|
likeCount: 1,
|
||||||
|
commentCount: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
AndroidPictureInPicture _pip() => AndroidPictureInPicture(
|
||||||
|
channel: const MethodChannel('test/player_episode_stats/pip'),
|
||||||
|
);
|
||||||
|
|
||||||
|
PlayerLogic _logic({
|
||||||
|
required PlaybackRepository playback,
|
||||||
|
DateTime Function()? clock,
|
||||||
|
}) => PlayerLogic(
|
||||||
|
history: FakeWatchHistory(),
|
||||||
|
playback: playback,
|
||||||
|
pictureInPicture: _pip(),
|
||||||
|
waitForFrame: () async {},
|
||||||
|
clock: clock,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _drain(PlayerLogic logic) async {
|
||||||
|
for (var attempt = 0; attempt < 4; attempt++) {
|
||||||
|
await pumpEventQueue();
|
||||||
|
await logic.session.playbackSettled;
|
||||||
|
await logic.session.preloadSettled;
|
||||||
|
}
|
||||||
|
await pumpEventQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test(
|
||||||
|
'should_refresh_episode_stats_on_up_and_down_swipes_without_reopening_play',
|
||||||
|
() async {
|
||||||
|
var now = DateTime(2026, 10, 26, 12);
|
||||||
|
final adapter = FakeHttpAdapter((options) async {
|
||||||
|
final path = options.uri.path;
|
||||||
|
if (path.endsWith('/play')) {
|
||||||
|
final episodeId = path.split('/')[3];
|
||||||
|
return FakeHttpResponse(
|
||||||
|
jsonEncode({
|
||||||
|
'code': 0,
|
||||||
|
'message': 'ok',
|
||||||
|
'data': {
|
||||||
|
'episodeId': episodeId,
|
||||||
|
'accessible': true,
|
||||||
|
'accessType': 'FREE',
|
||||||
|
'playUrl': '/files/$episodeId.mp4',
|
||||||
|
'qualities': ['AUTO'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (path.endsWith('/stats')) {
|
||||||
|
final episodeId = path.split('/')[3];
|
||||||
|
return FakeHttpResponse(
|
||||||
|
jsonEncode({
|
||||||
|
'code': 0,
|
||||||
|
'message': 'ok',
|
||||||
|
'data': {
|
||||||
|
'episodeId': episodeId,
|
||||||
|
'dramaId': 'signal',
|
||||||
|
'likeCount': episodeId == 'signal-1' ? '11' : '22',
|
||||||
|
'commentCount': episodeId == 'signal-1' ? '3' : '4',
|
||||||
|
'favoriteCount': '30',
|
||||||
|
'liked': episodeId == 'signal-1',
|
||||||
|
'favorited': false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw StateError('unexpected request $path');
|
||||||
|
});
|
||||||
|
final client = ApiClient(
|
||||||
|
baseUrl: 'http://127.0.0.1:8081',
|
||||||
|
adapter: adapter,
|
||||||
|
);
|
||||||
|
final playback = HttpPlaybackRepository(
|
||||||
|
PlaybackApi(client),
|
||||||
|
clock: () => now,
|
||||||
|
);
|
||||||
|
final logs = <String>[];
|
||||||
|
final previousDebugPrint = debugPrint;
|
||||||
|
debugPrint = (message, {wrapWidth}) {
|
||||||
|
if (message == null) return;
|
||||||
|
logs.add(message);
|
||||||
|
if (message.contains('[player] phase=episode-stats-refresh')) {
|
||||||
|
previousDebugPrint(message, wrapWidth: wrapWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
addTearDown(() {
|
||||||
|
debugPrint = previousDebugPrint;
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
final factory = _RecordingFactory();
|
||||||
|
final logic = _logic(
|
||||||
|
playback: playback,
|
||||||
|
clock: () => now,
|
||||||
|
);
|
||||||
|
logic.attach(
|
||||||
|
PlayerPage(drama: _drama('signal'), initialIndex: 0),
|
||||||
|
isMounted: () => true,
|
||||||
|
);
|
||||||
|
logic.start(
|
||||||
|
factory: factory.create,
|
||||||
|
coordinator: PlaybackCoordinator(),
|
||||||
|
);
|
||||||
|
await _drain(logic);
|
||||||
|
|
||||||
|
await logic.requestEpisodeSelection(1);
|
||||||
|
await _drain(logic);
|
||||||
|
final playRequestsBeforeBack = adapter.requests
|
||||||
|
.where((request) => request.uri.path.endsWith('/play'))
|
||||||
|
.length;
|
||||||
|
final statsRequestsBeforeBack = adapter.requests
|
||||||
|
.where((request) => request.uri.path.endsWith('/stats'))
|
||||||
|
.length;
|
||||||
|
|
||||||
|
await logic.requestEpisodeSelection(0);
|
||||||
|
await _drain(logic);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
adapter.requests.where((request) => request.uri.path.endsWith('/play')),
|
||||||
|
hasLength(playRequestsBeforeBack),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
adapter.requests.where(
|
||||||
|
(request) => request.uri.path.endsWith('/stats'),
|
||||||
|
),
|
||||||
|
hasLength(statsRequestsBeforeBack),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
logs,
|
||||||
|
contains(
|
||||||
|
'[player] phase=episode-stats-refresh episodeId=signal-1 cached=1',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(logic.episodeStats('signal-1')?.likeCount, 11);
|
||||||
|
|
||||||
|
now = now.add(const Duration(seconds: 61));
|
||||||
|
await logic.requestEpisodeSelection(1);
|
||||||
|
await _drain(logic);
|
||||||
|
await logic.requestEpisodeSelection(0);
|
||||||
|
await _drain(logic);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
adapter.requests.where(
|
||||||
|
(request) => request.uri.path.endsWith('/stats'),
|
||||||
|
),
|
||||||
|
hasLength(statsRequestsBeforeBack + 2),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
adapter.requests.where((request) => request.uri.path.endsWith('/play')),
|
||||||
|
hasLength(playRequestsBeforeBack),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
logs.where(
|
||||||
|
(line) => line.contains(
|
||||||
|
'episode-stats-refresh episodeId=signal-1 cached=0',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
hasLength(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
logic.dispose();
|
||||||
|
await _drain(logic);
|
||||||
|
expect(factory.live, 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('should_keep_local_like_count_when_switching_back', () async {
|
||||||
|
final stats = EpisodeStats(
|
||||||
|
episodeId: 'signal-1',
|
||||||
|
dramaId: 'signal',
|
||||||
|
likeCount: 7,
|
||||||
|
commentCount: 2,
|
||||||
|
favoriteCount: 30,
|
||||||
|
liked: false,
|
||||||
|
favorited: false,
|
||||||
|
);
|
||||||
|
final playback = FakePlaybackRepository(
|
||||||
|
statsByEpisode: {'signal-1': stats},
|
||||||
|
);
|
||||||
|
final logic = _logic(playback: playback);
|
||||||
|
logic.attach(
|
||||||
|
PlayerPage(drama: _drama('signal'), initialIndex: 0),
|
||||||
|
isMounted: () => true,
|
||||||
|
);
|
||||||
|
logic.start(
|
||||||
|
factory: _RecordingFactory().create,
|
||||||
|
coordinator: PlaybackCoordinator(),
|
||||||
|
);
|
||||||
|
await _drain(logic);
|
||||||
|
expect(logic.episodeStats('signal-1')?.likeCount, 7);
|
||||||
|
|
||||||
|
logic.applyEpisodeLikeState('signal-1', liked: true, likeCount: 8);
|
||||||
|
await logic.requestEpisodeSelection(1);
|
||||||
|
await _drain(logic);
|
||||||
|
await logic.requestEpisodeSelection(0);
|
||||||
|
await _drain(logic);
|
||||||
|
|
||||||
|
expect(logic.episodeStats('signal-1')?.liked, isTrue);
|
||||||
|
expect(logic.episodeStats('signal-1')?.likeCount, 8);
|
||||||
|
|
||||||
|
logic.dispose();
|
||||||
|
await _drain(logic);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_keep_playback_when_episode_stats_fail', () async {
|
||||||
|
final playback = FakePlaybackRepository(
|
||||||
|
statsError: StateError('stats unavailable'),
|
||||||
|
);
|
||||||
|
final logic = _logic(playback: playback);
|
||||||
|
logic.attach(
|
||||||
|
PlayerPage(drama: _drama('signal'), initialIndex: 0),
|
||||||
|
isMounted: () => true,
|
||||||
|
);
|
||||||
|
logic.start(
|
||||||
|
factory: _RecordingFactory().create,
|
||||||
|
coordinator: PlaybackCoordinator(),
|
||||||
|
);
|
||||||
|
await _drain(logic);
|
||||||
|
|
||||||
|
expect(logic.session.active, isNotNull);
|
||||||
|
expect(logic.ticketError, isNull);
|
||||||
|
expect(logic.episodeStats('signal-1'), isNull);
|
||||||
|
|
||||||
|
logic.dispose();
|
||||||
|
await _drain(logic);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecordingFactory {
|
||||||
|
int live = 0;
|
||||||
|
final List<_RecordingPlayer> created = [];
|
||||||
|
|
||||||
|
PlayerPort create(Episode episode) {
|
||||||
|
live++;
|
||||||
|
final player = _RecordingPlayer(onDispose: () => live--);
|
||||||
|
created.add(player);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecordingPlayer implements PlayerPort {
|
||||||
|
_RecordingPlayer({required this.onDispose});
|
||||||
|
|
||||||
|
final void Function() onDispose;
|
||||||
|
bool disposed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> play() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> pause() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() async {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
onDispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p
|
|||||||
|
|
||||||
import 'package:fruitshorts/app.dart';
|
import 'package:fruitshorts/app.dart';
|
||||||
import 'package:fruitshorts/core/data/models/drama.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/interaction.dart';
|
||||||
import 'package:fruitshorts/core/data/models/play_ticket.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/player_port.dart';
|
||||||
@@ -2158,11 +2159,15 @@ class _RecordingPlayer implements PlayerPort {
|
|||||||
|
|
||||||
class _PendingPlaybackRepository implements PlaybackRepository {
|
class _PendingPlaybackRepository implements PlaybackRepository {
|
||||||
final Completer<PlayTicket> ticket = Completer<PlayTicket>();
|
final Completer<PlayTicket> ticket = Completer<PlayTicket>();
|
||||||
|
final Completer<EpisodeStats> stats = Completer<EpisodeStats>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PlayTicket> ticketFor(String episodeId, {bool forceRefresh = false}) =>
|
Future<PlayTicket> ticketFor(String episodeId, {bool forceRefresh = false}) =>
|
||||||
ticket.future;
|
ticket.future;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<EpisodeStats> episodeStats(String episodeId) => stats.future;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool isFresh(String episodeId) => false;
|
bool isFresh(String episodeId) => false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user