Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aaa5bbd60 | ||
|
|
5649e4c3d4 | ||
|
|
9f7301305e | ||
|
|
63191b4692 | ||
|
|
0f777b19f2 |
No files matched your search
@@ -33,10 +33,15 @@ class PlaybackPositionCache {
|
||||
PlaybackPositionCache({
|
||||
DateTime Function()? now,
|
||||
this.maxAge = _defaultMaxAge,
|
||||
this.maxEntries = defaultMaxEntries,
|
||||
}) : _now = now ?? DateTime.now;
|
||||
|
||||
/// 最多保留的集数:长会话连看几十部剧也不至于让 Map 无限增长。
|
||||
static const int defaultMaxEntries = 100;
|
||||
|
||||
final DateTime Function() _now;
|
||||
final Duration maxAge;
|
||||
final int maxEntries;
|
||||
final Map<String, CachedPlaybackPosition> _positions = {};
|
||||
|
||||
/// 保存最近有效播放位置;零位置不会覆盖已有记录。
|
||||
@@ -57,6 +62,24 @@ class PlaybackPositionCache {
|
||||
duration: duration,
|
||||
updatedAt: _now(),
|
||||
);
|
||||
_evictOverflow();
|
||||
}
|
||||
|
||||
/// 超出上限时按写入时间淘汰最旧的一条,避免长会话内存只增不减。
|
||||
void _evictOverflow() {
|
||||
while (maxEntries > 0 && _positions.length > maxEntries) {
|
||||
String? oldestKey;
|
||||
DateTime? oldestAt;
|
||||
for (final entry in _positions.entries) {
|
||||
final at = entry.value.updatedAt;
|
||||
if (oldestAt == null || at.isBefore(oldestAt)) {
|
||||
oldestAt = at;
|
||||
oldestKey = entry.key;
|
||||
}
|
||||
}
|
||||
if (oldestKey == null) return;
|
||||
_positions.remove(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取同剧同集且未过期的最近位置。
|
||||
|
||||
@@ -8,7 +8,8 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
const int spriteThumbnailDecodeMaxDimension = 2048;
|
||||
/// 雪碧图解码上限:擦洗预览只占很小一块区域,2048 会让单张位图膨胀到 16MB。
|
||||
const int spriteThumbnailDecodeMaxDimension = 1024;
|
||||
|
||||
/// VTT 资源拒绝访问,服务层据此清缓存并重试一次。
|
||||
class SpriteThumbnailForbiddenException implements Exception {
|
||||
|
||||
@@ -227,14 +227,17 @@ class WatchRecordsController extends PagedListController<WatchRecord> {
|
||||
);
|
||||
return detail;
|
||||
} catch (failure, stackTrace) {
|
||||
debugPrint(
|
||||
'[WatchRecordsController] detail failed dramaId=$dramaId '
|
||||
'${failure.runtimeType}: $failure',
|
||||
);
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
||||
);
|
||||
// 弱网时详情会成批失败,release 下不需要逐条打堆栈。
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[WatchRecordsController] detail failed dramaId=$dramaId '
|
||||
'${failure.runtimeType}: $failure',
|
||||
);
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
||||
);
|
||||
}
|
||||
if (isDisposed) return null;
|
||||
final current = _recordIndex(dramaId);
|
||||
if (current < 0) return null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/count_format.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const int _columns = 2;
|
||||
@@ -218,16 +219,28 @@ class _DiscoverCoverImage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final url = AppConfig.mediaUrl(path);
|
||||
if (url.isEmpty) return _placeholder();
|
||||
return Image.network(
|
||||
url,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return _placeholder();
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 卡片只有半屏宽,按原图解码一张竖版海报就是数 MB,按布局宽度限一下。
|
||||
final decodeWidth = constraints.hasBoundedWidth
|
||||
? resolveImageDecodePixels(
|
||||
constraints.maxWidth,
|
||||
MediaQuery.devicePixelRatioOf(context),
|
||||
)
|
||||
: null;
|
||||
return Image.network(
|
||||
url,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: decodeWidth,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return _placeholder();
|
||||
},
|
||||
errorBuilder: (_, _, _) => _placeholder(),
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, _, _) => _placeholder(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,10 +69,14 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
history: context.read<WatchHistoryRepository>(),
|
||||
catalog: context.read<CatalogRepository>(),
|
||||
);
|
||||
unawaited(_history.load());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(widget.favoriteController.load());
|
||||
});
|
||||
// IndexedStack 启动时就会建好本页:不可见时先不拉数据,等切到本 tab 再加载,
|
||||
// 免得冷启动和首页 feed 抢网络。
|
||||
if (widget.visible) {
|
||||
unawaited(_history.load());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(widget.favoriteController.load());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onAuthChanged() {
|
||||
@@ -107,6 +111,13 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!oldWidget.visible && widget.visible) {
|
||||
unawaited(_history.load());
|
||||
// 启动时本页不可见,收藏没在这里拉过;首次可见时补齐(已有数据就不重复请求)。
|
||||
// 放在帧后,避免构建期间触发 controller 通知。
|
||||
if (widget.favoriteController.cards.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(widget.favoriteController.load());
|
||||
});
|
||||
}
|
||||
}
|
||||
if (oldWidget.historyRequest == widget.historyRequest) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
|
||||
@@ -161,24 +161,18 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
}
|
||||
|
||||
Widget _body(BuildContext context) {
|
||||
final pages = <Widget>[
|
||||
_recommend(context),
|
||||
_recommendDiscover(context),
|
||||
FollowingPage(
|
||||
final builders = <WidgetBuilder>[
|
||||
_recommend,
|
||||
_recommendDiscover,
|
||||
(context) => FollowingPage(
|
||||
onGoHome: () => logic.select(0),
|
||||
favoriteController: context.read<FavoriteController>(),
|
||||
historyRequest: logic.followingHistoryRequest,
|
||||
visible: logic.tab == 2,
|
||||
),
|
||||
_my(context),
|
||||
_my,
|
||||
];
|
||||
return IndexedStack(
|
||||
index: logic.tab,
|
||||
children: [
|
||||
for (var index = 0; index < pages.length; index++)
|
||||
TickerMode(enabled: logic.tab == index, child: pages[index]),
|
||||
],
|
||||
);
|
||||
return _LazyTabStack(index: logic.tab, builders: builders);
|
||||
}
|
||||
|
||||
Widget _recommend(BuildContext context) {
|
||||
@@ -248,3 +242,42 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
logic.select(tab);
|
||||
}
|
||||
}
|
||||
|
||||
/// 四个根 tab 的容器:只有被访问过的 tab 才真正构建并常驻。
|
||||
///
|
||||
/// 冷启动时只建推荐页,其余 tab 等第一次切过去再建,省掉三棵子树和它们各自的请求。
|
||||
class _LazyTabStack extends StatefulWidget {
|
||||
const _LazyTabStack({required this.index, required this.builders});
|
||||
|
||||
final int index;
|
||||
final List<WidgetBuilder> builders;
|
||||
|
||||
@override
|
||||
State<_LazyTabStack> createState() => _LazyTabStackState();
|
||||
}
|
||||
|
||||
class _LazyTabStackState extends State<_LazyTabStack> {
|
||||
late final Set<int> _visited = {widget.index};
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _LazyTabStack oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_visited.add(widget.index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IndexedStack(
|
||||
index: widget.index,
|
||||
children: [
|
||||
for (var index = 0; index < widget.builders.length; index++)
|
||||
TickerMode(
|
||||
enabled: widget.index == index,
|
||||
child: _visited.contains(index)
|
||||
? widget.builders[index](context)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,9 @@ class PlayerLogic extends ChangeNotifier {
|
||||
ResumePoint? resumePoint;
|
||||
bool resumeLoaded = false;
|
||||
|
||||
/// 续看点加载中的请求:进页与首次准备播放会同时触发,复用同一次请求。
|
||||
Future<void>? _resumePointLoad;
|
||||
|
||||
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
|
||||
bool ticketLoading = false;
|
||||
String? ticketError;
|
||||
@@ -512,7 +515,15 @@ class PlayerLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
|
||||
Future<void> loadResumePoint() async {
|
||||
Future<void> loadResumePoint() {
|
||||
final inFlight = _resumePointLoad;
|
||||
if (inFlight != null) return inFlight;
|
||||
return _resumePointLoad = _loadResumePoint().whenComplete(() {
|
||||
_resumePointLoad = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadResumePoint() async {
|
||||
final entry = queue.entryAt(initialIndex);
|
||||
final cached = positionCache.read(
|
||||
entry.episode.id,
|
||||
@@ -1281,9 +1292,12 @@ class PlayerLogic extends ChangeNotifier {
|
||||
await _waitUntilFrameReady(player.controller);
|
||||
return;
|
||||
}
|
||||
await pendingWrite;
|
||||
// 上一条进度写盘与续看点拉取互不依赖,并行等,少一个网络往返的首帧延迟。
|
||||
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
||||
if (resumePoint == null) await loadResumePoint();
|
||||
await Future.wait([
|
||||
pendingWrite,
|
||||
if (!resumeLoaded) loadResumePoint(),
|
||||
]);
|
||||
if (resumePositionFor(episode.id) == null) {
|
||||
final dramaId = _dramaIdForEpisode(episode.id);
|
||||
final cached = dramaId == null
|
||||
|
||||
@@ -61,6 +61,17 @@ const double _playerLoadingSize = 24;
|
||||
const double _playerLoadingStrokeWidth = 2;
|
||||
const double _feedRefreshTopGap = 56;
|
||||
|
||||
/// 相邻集海报预缓存宽度:列表卡片只有百来 dp,按原图解码会占数 MB。
|
||||
const int _posterPreviewWidth = 360;
|
||||
|
||||
/// 画中画还原封面接近全屏,保留更高分辨率但不必按原图。
|
||||
const int _posterPipWidth = 720;
|
||||
|
||||
/// 预缓存用低分辨率 provider;与 RemoteImage 一样限制解码尺寸,避免整张原图进缓存。
|
||||
ImageProvider _posterProvider(String url, {required int width}) {
|
||||
return ResizeImage(NetworkImage(url), width: width, allowUpscaling: false);
|
||||
}
|
||||
|
||||
class PlayerPageHandoff {
|
||||
PlayerPageHandoff({required this.sourceUserWantsPlay});
|
||||
|
||||
@@ -178,7 +189,9 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
_thumbnailService = PlaybackThumbnailService(
|
||||
sourceResolver: _resolveThumbnailSource,
|
||||
);
|
||||
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8)
|
||||
// 雪碧图每张解码后是整块位图,保留 3 部剧足够擦洗回退,8 部在低端机
|
||||
// 容易把图片缓存顶满甚至触发 OOM。
|
||||
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 3)
|
||||
..addListener(_handleSpriteFrameChanged);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
logic.start(
|
||||
@@ -507,9 +520,10 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
final imageUrl = AppConfig.mediaUrl(posterUrl);
|
||||
if (_precachedPosterUrls.add(imageUrl)) {
|
||||
unawaited(
|
||||
precacheImage(NetworkImage(imageUrl), context).catchError((
|
||||
Object _,
|
||||
) {
|
||||
precacheImage(
|
||||
_posterProvider(imageUrl, width: _posterPreviewWidth),
|
||||
context,
|
||||
).catchError((Object _) {
|
||||
_precachedPosterUrls.remove(imageUrl);
|
||||
}),
|
||||
);
|
||||
@@ -728,7 +742,15 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
if (logic.pip) {
|
||||
final poster = logic.trackedEpisode?.posterUrl?.trim();
|
||||
if (poster != null && poster.isNotEmpty) {
|
||||
unawaited(precacheImage(NetworkImage(poster), context));
|
||||
unawaited(
|
||||
precacheImage(
|
||||
_posterProvider(
|
||||
AppConfig.mediaUrl(poster),
|
||||
width: _posterPipWidth,
|
||||
),
|
||||
context,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Stack(
|
||||
|
||||
+1
-1
@@ -245,7 +245,7 @@ packages:
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||
|
||||
@@ -45,6 +45,8 @@ dependencies:
|
||||
sdk: flutter
|
||||
path_provider: 2.1.5
|
||||
html: ^0.15.6
|
||||
# 播放量等数字的本地化格式(NumberFormat);版本由 flutter_localizations 钉住
|
||||
intl: any
|
||||
# 画中画:Android 原生 PiP,OnLeavePiP 支持退后台自动进小窗
|
||||
floating: ^6.0.0
|
||||
# 运行时权限:systemAlertWindow(「显示在其他应用上层」)
|
||||
|
||||
@@ -23,6 +23,28 @@ import '../support/fake_interaction_repositories.dart';
|
||||
import '../support/widget_player.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_defer_watch_history_until_following_tab_is_opened', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final catalog = FakeCatalogRepository();
|
||||
final history = _IdentityWatchHistoryRepository(
|
||||
device: [_historyProgress('the-signal-demo', 'Device history')],
|
||||
account: const [],
|
||||
);
|
||||
await tester.pumpWidget(_app(catalog: catalog, history: history));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 首页默认停在推荐 tab:追剧页在启动时还没被构建,不该提前拉观看历史。
|
||||
expect(history.recentCalls, 0);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-follow')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(history.recentCalls, greaterThan(0));
|
||||
});
|
||||
|
||||
testWidgets('should_render_following_tabs_grid_and_edit_actions', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -89,6 +89,25 @@ void main() {
|
||||
expect(cache.latestForDrama('drama-a')?.episodeId, 'episode-2');
|
||||
});
|
||||
|
||||
test('should_evict_oldest_entry_when_capacity_exceeded', () {
|
||||
var now = DateTime(2026, 10, 22, 12);
|
||||
final cache = PlaybackPositionCache(now: () => now, maxEntries: 2);
|
||||
|
||||
for (final episodeId in ['episode-1', 'episode-2', 'episode-3']) {
|
||||
cache.save(
|
||||
episodeId: episodeId,
|
||||
dramaId: 'drama-a',
|
||||
position: const Duration(seconds: 30),
|
||||
duration: const Duration(minutes: 3),
|
||||
);
|
||||
now = now.add(const Duration(minutes: 1));
|
||||
}
|
||||
|
||||
expect(cache.read('episode-1', dramaId: 'drama-a'), isNull);
|
||||
expect(cache.read('episode-2', dramaId: 'drama-a'), isNotNull);
|
||||
expect(cache.read('episode-3', dramaId: 'drama-a'), isNotNull);
|
||||
});
|
||||
|
||||
test('should_reject_expired_cached_position', () {
|
||||
var now = DateTime(2026, 10, 22, 12);
|
||||
final cache = PlaybackPositionCache(now: () => now);
|
||||
|
||||
@@ -158,6 +158,18 @@ void main() {
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_share_one_request_when_resume_point_loads_concurrently', () async {
|
||||
final history = FakeWatchHistory()..saved = const Duration(seconds: 20);
|
||||
final logic = _logic(history, drama: _drama('signal'));
|
||||
|
||||
// 进页与首次准备播放会同时要续看点,只应发一次请求。
|
||||
await Future.wait([logic.loadResumePoint(), logic.loadResumePoint()]);
|
||||
|
||||
expect(logic.resumeLoaded, isTrue);
|
||||
expect(history.reads, ['signal']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_not_seek_when_position_is_zero', () async {
|
||||
final history = FakeWatchHistory()
|
||||
..records['signal'] = const WatchProgress(
|
||||
|
||||
Reference in new issue
Block a user