From 6c2f77926b7a484b77676223b52184ca08f8dca7 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 19:26:46 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(auth):=20=E7=99=BB=E5=BD=95=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E4=B8=8E=E6=89=8B=E6=9C=BA=E5=8F=B7=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=94=AE=E7=9B=98=E9=81=BF=E8=AE=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 31 +++++++++ lib/core/platform/keyboard_inset.dart | 21 ++++++ lib/pages/auth/auth_view.dart | 77 +++++++++++++++++++++- lib/pages/auth/phone_auth_sheet.dart | 93 ++++++++++++++++++++++++--- test/auth/auth_page_test.dart | 86 +++++++++++++++++++++++++ test/auth/phone_auth_sheet_test.dart | 33 ++++++++++ 6 files changed, 332 insertions(+), 9 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 04af67f..890e1f0 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -92,3 +92,34 @@ Running Gradle task 'assembleDebug'... 25.8s 解读:静态分析零告警,555 项全量测试通过,debug APK 构建成功; `pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 + +## 2026-10-21 K98 修复登录与手机号登录键盘遮挡 + +- 已完整读取 K98 `docs/plans/INBOX.md`。 +- 已从最新 `origin/develop` 创建 `fix/k98-auth-keyboard`。 +- 计划见 FruitServer `docs/plans/2026-10-21-K98-auth-keyboard.md`。 +- 将统一 viewInsets/channel 有效键盘高度,并给登录页与手机号弹窗增加 + 焦点/键盘变化后的字段滚动校正。 +- 已新增 `effectiveKeyboardInset`,兼容 iOS 实时 viewInsets 与 Android + `adjustNothing` 下的 MethodChannel 键盘高度。 +- 登录/注册页已增加 ScrollController、notifier 监听和邮箱/密码焦点校正; + 手机号弹窗已同样接入手机号/验证码框滚动校正。 +- 已补 iOS 登录/注册、Android channel、手机号验证码框键盘避让测试。 + +门禁原始输出: + +```text +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:52 +559: All tests passed! + +$ flutter build apk --debug +Running Gradle task 'assembleDebug'... 26.2s +✓ Built build/app/outputs/flutter-apk/app-debug.apk +``` + +解读:静态分析零告警,559 项全量测试通过,debug APK 构建成功; +`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 diff --git a/lib/core/platform/keyboard_inset.dart b/lib/core/platform/keyboard_inset.dart index dfa0783..7e77c3a 100644 --- a/lib/core/platform/keyboard_inset.dart +++ b/lib/core/platform/keyboard_inset.dart @@ -1,8 +1,29 @@ // Author: fruit.com.cn +import 'dart:math' as math; + import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +/// iOS 使用实时 viewInsets;Android adjustNothing 下取 MethodChannel 最大值。 +double effectiveKeyboardInset( + BuildContext context, + KeyboardInsetNotifier notifier, { + double maxRatio = 0.6, +}) { + final media = MediaQuery.of(context); + final viewInset = media.viewInsets.bottom; + final channelInset = + !kIsWeb && defaultTargetPlatform == TargetPlatform.android + ? notifier.value / media.devicePixelRatio + : 0.0; + final inset = math.max(viewInset, channelInset); + final maxInset = media.size.height * maxRatio; + if (!inset.isFinite || inset > maxInset) return 0; + return math.max(0, inset); +} + /// Provides the Android IME height even when `adjustNothing` keeps /// `MediaQuery.viewInsets` at zero. class KeyboardInsetNotifier extends ChangeNotifier { diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 03d8505..59b38b2 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../../core/data/remote/api_client.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/platform/haptics.dart'; +import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; @@ -101,6 +102,9 @@ class _AuthPageState extends State { late final TextEditingController _passwordController; late final FocusNode _emailFocus; late final FocusNode _passwordFocus; + late final ScrollController _scrollController; + final _keyboardInset = KeyboardInsetNotifier.instance; + double _lastKeyboardInset = 0; bool _obscurePassword = true; bool _emailEntryOpen = false; @@ -112,17 +116,87 @@ class _AuthPageState extends State { _passwordController = TextEditingController(); _emailFocus = FocusNode(debugLabel: 'auth-email-focus'); _passwordFocus = FocusNode(debugLabel: 'auth-password-focus'); + _scrollController = ScrollController(); + _emailFocus.addListener(_onFocusChanged); + _passwordFocus.addListener(_onFocusChanged); + _keyboardInset.addListener(_onKeyboardInsetChanged); + unawaited(_keyboardInset.start()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final inset = effectiveKeyboardInset(context, _keyboardInset); + if ((inset - _lastKeyboardInset).abs() >= 1) { + _lastKeyboardInset = inset; + _scheduleRevealFocusedField(); + } } @override void dispose() { + _keyboardInset.removeListener(_onKeyboardInsetChanged); + _emailFocus.removeListener(_onFocusChanged); + _passwordFocus.removeListener(_onFocusChanged); _emailController.dispose(); _passwordController.dispose(); _emailFocus.dispose(); _passwordFocus.dispose(); + _scrollController.dispose(); super.dispose(); } + void _onKeyboardInsetChanged() { + if (!mounted) return; + setState(() {}); + _scheduleRevealFocusedField(); + } + + void _onFocusChanged() { + if (_emailFocus.hasFocus || _passwordFocus.hasFocus) { + _scheduleRevealFocusedField(); + } + } + + void _scheduleRevealFocusedField() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _revealFocusedField(); + }); + } + + void _revealFocusedField() { + if (!_scrollController.hasClients) return; + final focus = _passwordFocus.hasFocus + ? _passwordFocus + : _emailFocus.hasFocus + ? _emailFocus + : null; + final fieldContext = focus?.context; + final renderObject = fieldContext?.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) return; + final keyboardInset = effectiveKeyboardInset(context, _keyboardInset); + if (keyboardInset <= 0) return; + final fieldBottom = renderObject + .localToGlobal(Offset(0, renderObject.size.height)) + .dy; + final visibleBottom = + MediaQuery.sizeOf(context).height - keyboardInset - 24; + final overlap = fieldBottom - visibleBottom; + if (overlap <= 0) return; + final position = _scrollController.position; + final target = (position.pixels + overlap) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + if (target <= position.pixels + 0.5) return; + unawaited( + _scrollController.animateTo( + target, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ), + ); + } + String _errorText(AppLocalizations l10n, AuthFieldError error) { switch (error) { case AuthFieldError.emailRequired: @@ -246,12 +320,13 @@ class _AuthPageState extends State { : _topContentPadding; final bottom = _bottomContentPadding + - MediaQuery.viewInsetsOf(context).bottom; + effectiveKeyboardInset(context, _keyboardInset); final available = constraints.maxHeight - top - bottom; final minHeight = available > 0 ? available : 0.0; return AutofillGroup( child: SingleChildScrollView( key: const Key('auth-scroll'), + controller: _scrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: EdgeInsets.fromLTRB( diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart index 541e830..c37f6c7 100644 --- a/lib/pages/auth/phone_auth_sheet.dart +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import '../../core/data/remote/api_client.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/platform/haptics.dart'; +import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; @@ -46,7 +47,10 @@ class _PhoneAuthSheetState extends State { late final TextEditingController _code; late final FocusNode _phoneFocus; late final FocusNode _codeFocus; + late final ScrollController _scrollController; + final _keyboardInset = KeyboardInsetNotifier.instance; Timer? _countdown; + double _lastKeyboardInset = 0; bool _signUp = false; bool _sending = false; bool _submitting = false; @@ -61,18 +65,88 @@ class _PhoneAuthSheetState extends State { _code = TextEditingController(); _phoneFocus = FocusNode(debugLabel: 'phone-auth-number'); _codeFocus = FocusNode(debugLabel: 'phone-auth-code'); + _scrollController = ScrollController(); + _phoneFocus.addListener(_onFocusChanged); + _codeFocus.addListener(_onFocusChanged); + _keyboardInset.addListener(_onKeyboardInsetChanged); + unawaited(_keyboardInset.start()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final inset = effectiveKeyboardInset(context, _keyboardInset); + if ((inset - _lastKeyboardInset).abs() >= 1) { + _lastKeyboardInset = inset; + _scheduleRevealFocusedField(); + } } @override void dispose() { + _keyboardInset.removeListener(_onKeyboardInsetChanged); + _phoneFocus.removeListener(_onFocusChanged); + _codeFocus.removeListener(_onFocusChanged); _countdown?.cancel(); _phone.dispose(); _code.dispose(); _phoneFocus.dispose(); _codeFocus.dispose(); + _scrollController.dispose(); super.dispose(); } + void _onKeyboardInsetChanged() { + if (!mounted) return; + setState(() {}); + _scheduleRevealFocusedField(); + } + + void _onFocusChanged() { + if (_phoneFocus.hasFocus || _codeFocus.hasFocus) { + _scheduleRevealFocusedField(); + } + } + + void _scheduleRevealFocusedField() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _revealFocusedField(); + }); + } + + void _revealFocusedField() { + if (!_scrollController.hasClients) return; + final focus = _codeFocus.hasFocus + ? _codeFocus + : _phoneFocus.hasFocus + ? _phoneFocus + : null; + final fieldContext = focus?.context; + final renderObject = fieldContext?.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) return; + final keyboardInset = effectiveKeyboardInset(context, _keyboardInset); + if (keyboardInset <= 0) return; + final fieldBottom = renderObject + .localToGlobal(Offset(0, renderObject.size.height)) + .dy; + final visibleBottom = + MediaQuery.sizeOf(context).height - keyboardInset - 24; + final overlap = fieldBottom - visibleBottom; + if (overlap <= 0) return; + final position = _scrollController.position; + final target = (position.pixels + overlap) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + if (target <= position.pixels + 0.5) return; + unawaited( + _scrollController.animateTo( + target, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ), + ); + } + String get _target => _phone.text.replaceAll(RegExp(r'[\s-]'), ''); bool get _phoneValid => _target.length >= _phoneMinLength; @@ -99,9 +173,8 @@ class _PhoneAuthSheetState extends State { if (!mounted) return; _startCountdown(); setState(() => _error = null); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); _codeFocus.requestFocus(); } on ApiException catch (error) { if (mounted) setState(() => _error = error.message); @@ -166,16 +239,19 @@ class _PhoneAuthSheetState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final bottom = MediaQuery.viewInsetsOf(context).bottom; + final bottom = effectiveKeyboardInset(context, _keyboardInset); return Padding( padding: EdgeInsets.only(bottom: bottom), child: Container( key: const Key('phone-auth-sheet'), decoration: const BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(_sheetRadius), + ), ), child: SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.fromLTRB(22, 10, 22, 26), child: Column( mainAxisSize: MainAxisSize.min, @@ -278,9 +354,10 @@ class _PhoneAuthSheetState extends State { onSubmitted: (_) => _submit(), onChanged: (_) => setState(() => _error = null), style: const TextStyle(color: AppColors.onSurface, fontSize: 15), - decoration: _decoration(l10n.authCodeLabel, l10n.authCodeHint).copyWith( - suffixIcon: _sendButton(l10n), - ), + decoration: _decoration( + l10n.authCodeLabel, + l10n.authCodeHint, + ).copyWith(suffixIcon: _sendButton(l10n)), ), ); diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index 3a5851c..1e02e3c 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; @@ -263,6 +265,90 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets('should_keep_password_above_ios_keyboard', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + + await pumpAuthPage(tester); + await tester.tap(find.byKey(const Key('auth-password'))); + await tester.pumpAndSettle(); + + _expectAboveKeyboard(tester, const Key('auth-password'), 300); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('should_keep_signup_password_above_ios_keyboard', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + + await pumpAuthPage(tester, signUp: true); + await tester.tap(find.byKey(const Key('auth-password'))); + await tester.pumpAndSettle(); + + _expectAboveKeyboard(tester, const Key('auth-password'), 300); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('should_keep_password_above_android_channel_keyboard', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const keyboardChannel = MethodChannel('com.stormg.lumadrama/keyboard'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + keyboardChannel, + (call) async => call.method == 'get' ? 300 : null, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + keyboardChannel, + null, + ); + }); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.viewInsets = const FakeViewPadding(); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + + await pumpAuthPage(tester); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + keyboardChannel.name, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('insetsChanged', 300), + ), + (_) {}, + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('auth-password'))); + await tester.pumpAndSettle(); + + _expectAboveKeyboard(tester, const Key('auth-password'), 300); + debugDefaultTargetPlatformOverride = null; + }); +} + +void _expectAboveKeyboard(WidgetTester tester, Key fieldKey, double inset) { + final screenHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect( + tester.getBottomLeft(find.byKey(fieldKey)).dy, + lessThanOrEqualTo(screenHeight - inset), + ); } class _PendingAuthApi extends AuthApi { diff --git a/test/auth/phone_auth_sheet_test.dart b/test/auth/phone_auth_sheet_test.dart index 9656a31..036b286 100644 --- a/test/auth/phone_auth_sheet_test.dart +++ b/test/auth/phone_auth_sheet_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; @@ -97,6 +98,38 @@ void main() { expect(find.text('Enter a valid phone number'), findsOneWidget); expect(adapter.requests, isEmpty); }); + + testWidgets('should_keep_code_above_ios_keyboard', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ), + ); + final auth = AuthController( + AuthApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter)), + AuthSessionStore(), + ); + await _pumpHost(tester, auth); + await tester.tap(find.byKey(const Key('open-phone-sheet'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('auth-phone-code'))); + await tester.pumpAndSettle(); + + expect( + tester.getBottomLeft(find.byKey(const Key('auth-phone-code'))).dy, + lessThanOrEqualTo(844 - 300), + ); + debugDefaultTargetPlatformOverride = null; + }); } Future _pumpHost(WidgetTester tester, AuthController auth) { From 8b53adc2beb7eb1f417d7d02fdf6d95a842c4106 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 19:33:22 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(library):=20=E6=94=B6=E8=97=8F?= =?UTF-8?q?=E4=B8=8E=E8=A7=82=E7=9C=8B=E8=AE=B0=E5=BD=95=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E6=92=AD=E6=94=BE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 26 +++++++++ lib/pages/favorites/favorites_page.dart | 6 +- .../watch_records/watch_records_page.dart | 15 +++-- test/library/favorites_page_test.dart | 47 ++++++++++++++++ test/watch/watch_records_page_test.dart | 56 +++++++++++++++++++ 5 files changed, 140 insertions(+), 10 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 890e1f0..a766d09 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -123,3 +123,29 @@ Running Gradle task 'assembleDebug'... 26.2s 解读:静态分析零告警,559 项全量测试通过,debug APK 构建成功; `pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 + +## 2026-10-21 K98.1 收藏与观看记录直接进入播放页 + +- 已从最新 `origin/develop` 创建 `fix/k98.1-play-from-lists`。 +- 收藏卡片已改为调用 `playDramaFromCatalog`,不再进入详情页。 +- 观看记录卡片已按 `progress.episodeId` 直达播放页对应集;episodeId 为空时从第 1 集起播。 +- 详情未加载完成的观看记录仍保持原禁用、加载和重试行为。 +- 已补收藏及观看记录直达 PlayerPage 的路由断言。 + +门禁原始输出: + +```text +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +01:09 +561: All tests passed! + +$ flutter build apk --debug +Running Gradle task 'assembleDebug'... 28.7s +✓ Built build/app/outputs/flutter-apk/app-debug.apk +``` + +解读:静态分析零告警,561 项全量测试通过,debug APK 构建成功; +`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 2676569..3f68c21 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -19,7 +19,7 @@ import '../../core/widgets/empty_state.dart'; import '../../core/widgets/remote_image.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; -import '../drama_detail/drama_detail_view.dart'; +import '../player/open_player.dart'; import 'favorites_binding.dart'; const double _pageHorizontal = 14; @@ -194,9 +194,7 @@ class _FavoritesBody extends StatelessWidget { } Future _open(BuildContext context, Drama drama) async { - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), - ); + await playDramaFromCatalog(context, drama); } Future _remove(BuildContext context, String dramaId) async { diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index 157c48b..5daa2b8 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_record.dart'; import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; @@ -19,7 +19,7 @@ import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; -import '../drama_detail/drama_detail_view.dart'; +import '../player/open_player.dart'; import 'watch_records_binding.dart'; import 'widgets/watch_record_card.dart'; @@ -138,7 +138,7 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { record: record, onRetry: () => controller.retryDetail(record.dramaId), onRemove: () => _remove(record.dramaId), - onOpen: record.drama == null ? null : () => _open(record.drama!), + onOpen: record.drama == null ? null : () => _open(record), ), ], ], @@ -191,9 +191,12 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { ); } - Future _open(Drama drama) async { - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), + Future _open(WatchRecord record) async { + final episodeId = record.progress.episodeId; + await playDramaFromCatalog( + context, + record.drama!, + episodeId: episodeId.isEmpty ? null : episodeId, ); } diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index b286d81..2091e85 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -2,10 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.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/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; @@ -14,13 +17,20 @@ import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/core/widgets/empty_state.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; import 'package:fruitshorts/pages/favorites/favorites_page.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:provider/provider.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'; import '../support/fake_http_adapter.dart'; +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; Drama _card(String id) => Drama( id: id, @@ -152,6 +162,43 @@ void main() { expect(repository.listCalls, 0); }, ); + + testWidgets('should_open_player_when_favorite_card_is_tapped', ( + tester, + ) async { + await _auth(signedIn: true); + final catalog = FakeCatalogRepository(); + final drama = catalog.dramas.first; + + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository( + saved: {drama.id}, + cards: [drama], + ), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-watchlist'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(Key('favorite-card-${drama.id}'))); + await tester.pumpAndSettle(); + + expect(find.byType(PlayerPage), findsOneWidget); + expect(find.byType(DramaDetailPage), findsNothing); + }); } Future _auth({required bool signedIn}) async { diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index 90bd1a0..4d4cca0 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/feed_page.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; @@ -9,6 +10,8 @@ import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; 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/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; @@ -17,14 +20,20 @@ import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/core/widgets/empty_state.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/watch_records/watch_records_page.dart'; import 'package:provider/provider.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'; import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; import '../support/fake_http_adapter.dart'; import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; void main() { setUp(() { @@ -180,6 +189,53 @@ void main() { expect(find.text('账号不存在,请先注册'), findsOneWidget); expect(find.byKey(const Key('watch-records-retry')), findsOneWidget); }); + + testWidgets('should_open_player_at_recorded_episode_when_card_is_tapped', ( + tester, + ) async { + await _auth(signedIn: true); + final catalog = FakeCatalogRepository(); + final drama = catalog.dramas.first; + final episode = drama.episodes[2]; + final history = InMemoryWatchHistoryRepository( + seed: [ + WatchProgress( + dramaId: drama.id, + episodeId: episode.id, + position: const Duration(seconds: 12), + duration: const Duration(seconds: 60), + updatedAt: DateTime(2026, 10, 17, 12), + ), + ], + ); + + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: history, + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-history'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(Key('watch-record-${drama.id}'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.initialIndex, 2); + expect(find.byType(DramaDetailPage), findsNothing); + }); } WatchProgress _progress(String dramaId) => WatchProgress( From d227ac69fb95b21728ad325c108907a8fc96e57e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 19:45:38 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(home):=20=E4=BF=AE=E5=A4=8D=20feed=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=A7=A6=E5=8F=91=E7=9A=84=20build=20?= =?UTF-8?q?=E6=9C=9F=20setState?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 26 ++++++++++++ lib/pages/home/home_logic.dart | 14 ++++++- lib/pages/player/player_logic.dart | 30 ++++++++++++-- lib/pages/player/player_view.dart | 16 +++++--- test/home/home_logic_test.dart | 35 +++++++++++++++++ test/player/player_logic_test.dart | 63 ++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 10 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index a766d09..9a8bd65 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -149,3 +149,29 @@ Running Gradle task 'assembleDebug'... 28.7s 解读:静态分析零告警,561 项全量测试通过,debug APK 构建成功; `pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 + +## 2026-10-21 K98.2 修复 feed 更新触发 build 期通知 + +- 已从最新 `origin/develop` 创建 `fix/k98.2-feed-build-notify`。 +- PlayerPage 的 feed 更新改为帧后调度,同一帧多次变化继续由 PlayerLogic 合并。 +- PlayerLogic 在非 idle 调度阶段延后 feed 通知与 PageController 跳页,并给延迟通知去重。 +- HomeLogic.onDramaChanged 在 build/layout/paint 阶段改为帧后通知。 +- 已补 build 阶段 updateFeed、最终 PageController 位置及 HomeLogic 延后通知回归测试。 + +门禁原始输出: + +```text +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:55 +563: All tests passed! + +$ flutter build apk --debug +Running Gradle task 'assembleDebug'... 26.4s +✓ Built build/app/outputs/flutter-apk/app-debug.apk +``` + +解读:静态分析零告警,563 项全量测试通过,debug APK 构建成功; +`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index ae4310f..826749e 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import '../../core/data/models/drama.dart'; import '../discover/discover_types.dart'; @@ -22,6 +23,7 @@ class HomeLogic extends ChangeNotifier { bool drawerOpen = false; bool detailOpen = false; bool videoImmersive = false; + bool _dramaNotifyScheduled = false; bool _disposed = false; void notify() { @@ -120,7 +122,17 @@ class HomeLogic extends ChangeNotifier { void onDramaChanged(Drama drama) { currentDrama = drama; - notify(); + if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) { + notify(); + return; + } + if (_dramaNotifyScheduled || _disposed) return; + _dramaNotifyScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _dramaNotifyScheduled = false; + if (!_disposed) notify(); + }); + SchedulerBinding.instance.scheduleFrame(); } void onImmersiveChanged(bool value) { diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 463f180..debe265 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; @@ -108,6 +109,8 @@ class PlayerLogic extends ChangeNotifier { bool _latestResetToFirst = false; bool _feedUpdateRunning = false; bool _hasPendingFeed = false; + bool _deferredFeedNotifyScheduled = false; + int _feedJumpSerial = 0; Future _feedUpdateFuture = Future.value(); /// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化, @@ -259,7 +262,7 @@ class PlayerLogic extends ChangeNotifier { if (dramas.isNotEmpty) pageDrama = dramas.first; if (nextQueue.length == 0) { session.setRouteVisible(false); - notify(); + _notifyAfterFrameIfNeeded(); return; } final keepIndex = @@ -275,7 +278,7 @@ class PlayerLogic extends ChangeNotifier { preloadIndex: nextVisibleIndex(keepIndex), ); _jumpToFeedPage(keepIndex); - notify(); + _notifyAfterFrameIfNeeded(); return; } await _replaceSession( @@ -303,7 +306,7 @@ class PlayerLogic extends ChangeNotifier { final nextSession = _createSession(factory, coordinator); session = nextSession; nextSession.setRouteVisible(active && visibleIndices.isNotEmpty); - notify(); + _notifyAfterFrameIfNeeded(); await previous.close(); if (revision != feedRevision || !identical(session, nextSession)) return; _jumpToFeedPage(targetIndex); @@ -312,13 +315,32 @@ class PlayerLogic extends ChangeNotifier { void _jumpToFeedPage(int index) { if (!_started) return; - if (pages.hasClients) { + final serial = ++_feedJumpSerial; + if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle && + pages.hasClients) { pages.jumpToPage(index); return; } WidgetsBinding.instance.addPostFrameCallback((_) { + if (_disposed || serial != _feedJumpSerial || !_started) return; if (pages.hasClients) pages.jumpToPage(index); }); + SchedulerBinding.instance.scheduleFrame(); + } + + void _notifyAfterFrameIfNeeded() { + if (_disposed) return; + if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) { + notify(); + return; + } + if (_deferredFeedNotifyScheduled) return; + _deferredFeedNotifyScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _deferredFeedNotifyScheduled = false; + if (!_disposed) notify(); + }); + SchedulerBinding.instance.scheduleFrame(); } /// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。 diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 5ed0578..9f23d17 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -139,11 +139,17 @@ class _PlayerPageState extends State if (widget.homeMode && widget.feedDramas != null && (feedChanged || channelChanged)) { - logic.updateFeed( - widget.feedDramas!, - keepDramaId: channelChanged ? null : logic.currentDramaId, - resetToFirst: channelChanged, - ); + final feedDramas = widget.feedDramas!; + final keepDramaId = channelChanged ? null : logic.currentDramaId; + final resetToFirst = channelChanged; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + logic.updateFeed( + feedDramas, + keepDramaId: keepDramaId, + resetToFirst: resetToFirst, + ); + }); } logic.onPageUpdated(oldWidget); } diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index f9c2427..c23a029 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/pages/discover/discover_types.dart'; @@ -121,6 +122,40 @@ void main() { logic.dispose(); }); + testWidgets('should_defer_drama_notification_when_changed_during_build', ( + tester, + ) async { + final logic = HomeLogic(dramas); + var notifications = 0; + var immediateNotifications = -1; + var changed = false; + logic.addListener(() => notifications++); + + await tester.pumpWidget( + MaterialApp( + home: ListenableBuilder( + listenable: logic, + builder: (context, _) { + if (!changed) { + changed = true; + logic.onDramaChanged(dramas[1]); + immediateNotifications = notifications; + } + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(immediateNotifications, 0); + expect(tester.takeException(), isNull); + expect(logic.currentDrama.id, 'city'); + await tester.pump(); + expect(notifications, 1); + expect(tester.takeException(), isNull); + logic.dispose(); + }); + test('should_map_onto_root_tabs_when_my_page_actions_fire', () { final logic = HomeLogic(dramas); diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 3abc840..dea2fcf 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -236,6 +236,7 @@ void main() { logic.dispose(); await _drain(logic); }); + }); group('history writes', () { @@ -618,6 +619,68 @@ void main() { logic.dispose(); await _drain(logic); }); + + testWidgets( + 'should_defer_feed_update_side_effects_when_called_during_build', + (tester) async { + final factory = _RecordingFactory(); + final initial = [_drama('a'), _drama('b'), _drama('c')]; + final latest = [_drama('d'), _drama('c'), _drama('a')]; + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage( + drama: initial.first, + initialIndex: 0, + homeMode: true, + feedDramas: initial, + ), + isMounted: () => true, + ); + await tester.runAsync(() async { + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + }); + expect(logic.currentDramaId, 'a'); + expect(logic.session.active, isNotNull); + var updateScheduled = false; + + await tester.pumpWidget( + MaterialApp( + home: ListenableBuilder( + listenable: logic, + builder: (context, _) { + if (!updateScheduled) { + updateScheduled = true; + logic.updateFeed(latest, keepDramaId: 'a'); + } + return Scaffold( + body: PageView.builder( + controller: logic.pages, + itemCount: logic.visibleIndices.length, + itemBuilder: (_, index) => + ColoredBox(color: Colors.black, key: ValueKey(index)), + ), + ); + }, + ), + ), + ); + expect(tester.takeException(), isNull); + expect(logic.pages.hasClients, isTrue); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(logic.queue.entryAt(2).drama.id, 'a'); + expect(logic.pages.page, closeTo(2, 0.01)); + logic.dispose(); + }, + ); }); group('lifecycle', () { From f3ec12e3c3baaf0ead4841501b2bdce44ef99a64 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 20:00:15 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E9=A2=91=E9=81=93=E6=97=B6=E5=A4=8D=E7=94=A8=E5=B7=B2?= =?UTF-8?q?=E9=87=8A=E6=94=BE=E7=9A=84=E8=A7=86=E9=A2=91=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 28 +++++ lib/core/data/services/playback_session.dart | 28 ++++- lib/pages/player/player_logic.dart | 8 +- test/playback/playback_session_test.dart | 55 ++++++++- test/playback/progress_throttle_test.dart | 1 + test/player/player_logic_test.dart | 115 +++++++++++++++++++ 6 files changed, 229 insertions(+), 6 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 9a8bd65..2122f93 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -175,3 +175,31 @@ Running Gradle task 'assembleDebug'... 26.4s 解读:静态分析零告警,563 项全量测试通过,debug APK 构建成功; `pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 + +## 2026-10-21 K98.3 修复频道切换复用已释放视频控制器 + +- 已从最新 `origin/develop` 创建 `fix/k98.3-disposed-video-controller`。 +- `PlaybackSession.active` 与 `preparedPlayerAt` 不再暴露 closed/retiring slot; + `_retire` 先清空活动引用并通知,再等待界面帧边界后暂停和释放播放器。 +- `PlayerLogic._replaceSession` 切换 session 后等待 `endOfFrame`,并再次校验 + revision 后才关闭旧 session;其它 session 回收路径同样使用该帧边界。 +- 已补 retiring player 脱钩单测,以及真实 `PlayerVideoSurface` 经 feed 切换、 + 连续 pump 后不出现 disposed controller 异常的回归测试。 + +门禁原始输出: + +```text +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:58 +565: All tests passed! + +$ flutter build apk --debug +Running Gradle task 'assembleDebug'... 35.7s +✓ Built build/app/outputs/flutter-apk/app-debug.apk +``` + +解读:静态分析零告警,565 项全量测试通过,debug APK 构建成功; +`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 6127fa9..d0c58ea 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -49,6 +49,7 @@ class PlaybackSession extends ChangeNotifier { this.prepare, this.activate, PlaybackCoordinator? coordinator, + this.waitForFrame, }) : _coordinator = coordinator ?? PlaybackCoordinator(); final List episodes; @@ -60,6 +61,7 @@ class PlaybackSession extends ChangeNotifier { /// Runs only when a slot becomes current (e.g. progress listeners). final PlayerPreparation? activate; + final Future Function()? waitForFrame; final PlaybackCoordinator _coordinator; final Set<_PlayerSlot> _live = {}; _PlayerSlot? _active; @@ -80,8 +82,16 @@ class PlaybackSession extends ChangeNotifier { int get currentIndex => _currentIndex; Episode get currentEpisode => episodes[_currentIndex]; - PlayerPort? get active => - _active?.index == _currentIndex ? _active?.player : null; + PlayerPort? get active { + final slot = _active; + if (_closed || + slot == null || + slot.retiring || + slot.index != _currentIndex) { + return null; + } + return slot.player; + } bool get loading => _loading; bool get userWantsPlay => _userWantsPlay; bool get canAutoAdvance => _mayPlay && !_loading && active != null; @@ -99,8 +109,15 @@ class PlaybackSession extends ChangeNotifier { /// Exposes only prepared slots so the adjacent page can mount its video texture /// during a swipe without starting audio or registering history listeners. PlayerPort? preparedPlayerAt(int index) { + if (_closed) return null; for (final slot in [_active, _next]) { - if (slot?.index == index && slot!.prepared) return slot.player; + if (slot == null || + slot.retiring || + slot.index != index || + !slot.prepared) { + continue; + } + return slot.player; } return null; } @@ -162,6 +179,11 @@ class PlaybackSession extends ChangeNotifier { Future _retire(_PlayerSlot? slot) async { if (slot == null || !_live.contains(slot)) return; slot.retiring = true; + final cleared = _active == slot || _next == slot; + if (_active == slot) _active = null; + if (_next == slot) _next = null; + if (cleared) _notify(); + await waitForFrame?.call(); await slot.ready; try { await slot.player.pause(); diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index debe265..288023f 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -40,13 +40,16 @@ class PlayerLogic extends ChangeNotifier { required this.pictureInPicture, this.wallet, this.downloads, - }); + Future Function()? waitForFrame, + }) : _waitForFrame = + waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame); final WatchHistoryRepository history; final PlaybackRepository playback; final AndroidPictureInPicture pictureInPicture; final WalletController? wallet; final DownloadController? downloads; + final Future Function() _waitForFrame; late PageController pages; late PlaybackQueue queue; @@ -220,6 +223,7 @@ class PlayerLogic extends ChangeNotifier { prepare: preparePlayer, activate: activatePlayer, coordinator: coordinator, + waitForFrame: _waitForFrame, ); } @@ -307,6 +311,8 @@ class PlayerLogic extends ChangeNotifier { session = nextSession; nextSession.setRouteVisible(active && visibleIndices.isNotEmpty); _notifyAfterFrameIfNeeded(); + await _waitForFrame(); + if (revision != feedRevision || !identical(session, nextSession)) return; await previous.close(); if (revision != feedRevision || !identical(session, nextSession)) return; _jumpToFeedPage(targetIndex); diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart index 336d3b6..d9905c2 100644 --- a/test/playback/playback_session_test.dart +++ b/test/playback/playback_session_test.dart @@ -123,6 +123,38 @@ void main() { await session.close(); }); + test( + 'should_detach_retiring_player_before_disposal_finishes', + () async { + final disposeStarted = Completer(); + final disposeGate = Completer(); + final factory = FakeFactory( + firstDisposeStarted: disposeStarted, + firstDisposeGate: disposeGate, + ); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + ); + await session.select(0); + final retiring = factory.created.single; + + final switching = session.select(1); + await disposeStarted.future; + + expect(retiring.disposed, isFalse); + expect(session.active, isNull); + expect(session.preparedPlayerAt(0), isNull); + + disposeGate.complete(); + await switching; + expect(retiring.disposed, isTrue); + expect(session.active, isA()); + await session.close(); + }, + ); + test('should_keep_user_pause_when_reselecting_the_same_episode', () async { final factory = FakeFactory(); final session = PlaybackSession( @@ -164,8 +196,14 @@ void main() { } class FakeFactory { - FakeFactory({this.holdFirstInitialize = false}); + FakeFactory({ + this.holdFirstInitialize = false, + this.firstDisposeStarted, + this.firstDisposeGate, + }); final bool holdFirstInitialize; + final Completer? firstDisposeStarted; + final Completer? firstDisposeGate; final List created = []; final Completer firstCreated = Completer(); int live = 0; @@ -176,6 +214,8 @@ class FakeFactory { if (live > peakLive) peakLive = live; final player = FakePlayer( holdInitialize: holdFirstInitialize && created.isEmpty, + disposeStarted: created.isEmpty ? firstDisposeStarted : null, + disposeGate: created.isEmpty ? firstDisposeGate : null, onDispose: () => live--, ); created.add(player); @@ -185,9 +225,16 @@ class FakeFactory { } class FakePlayer implements PlayerPort { - FakePlayer({required this.holdInitialize, required this.onDispose}); + FakePlayer({ + required this.holdInitialize, + required this.onDispose, + this.disposeStarted, + this.disposeGate, + }); final bool holdInitialize; final void Function() onDispose; + final Completer? disposeStarted; + final Completer? disposeGate; final Completer _initializeGate = Completer(); bool initialized = false; bool disposed = false; @@ -211,6 +258,10 @@ class FakePlayer implements PlayerPort { @override Future dispose() async { if (disposed) return; + if (disposeStarted != null && !disposeStarted!.isCompleted) { + disposeStarted!.complete(); + } + await disposeGate?.future; disposed = true; onDispose(); } diff --git a/test/playback/progress_throttle_test.dart b/test/playback/progress_throttle_test.dart index 90b3697..7bfb62a 100644 --- a/test/playback/progress_throttle_test.dart +++ b/test/playback/progress_throttle_test.dart @@ -37,6 +37,7 @@ PlayerLogic _logic(FakeWatchHistory history, {Drama? drama}) { pictureInPicture: AndroidPictureInPicture( channel: const MethodChannel('test/progress/pip'), ), + waitForFrame: () async {}, ); if (drama != null) { logic.attach( diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index dea2fcf..c04bc59 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/video_player.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'; @@ -11,8 +12,10 @@ import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; import 'package:fruitshorts/core/data/services/download_controller.dart'; import 'package:fruitshorts/core/data/services/download_storage.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/core/data/services/video_player_port.dart'; import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; +import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; import '../support/fake_watch_history.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; @@ -45,10 +48,12 @@ AndroidPictureInPicture _pip() => PlayerLogic _logic( FakeWatchHistory history, { PlaybackRepository? playback, + Future Function()? waitForFrame, }) => PlayerLogic( history: history, playback: playback ?? FakePlaybackRepository(), pictureInPicture: _pip(), + waitForFrame: waitForFrame ?? () async {}, ); /// Drains the session's serialized work so a test never ends mid-disposal. @@ -681,6 +686,49 @@ void main() { logic.dispose(); }, ); + + testWidgets( + 'should_unmount_old_controller_before_disposing_it_when_feed_changes', + (tester) async { + final factory = _VideoRecordingFactory(); + final initial = [_drama('old')]; + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage( + drama: initial.first, + initialIndex: 0, + homeMode: true, + feedDramas: initial, + ), + isMounted: () => true, + ); + await tester.runAsync(() async { + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + }); + final oldPlayer = factory.created.single; + await tester.pumpWidget( + MaterialApp(home: _SessionVideoSurface(logic: logic)), + ); + await tester.pump(); + expect(find.byType(VideoPlayer), findsOneWidget); + + logic.updateFeed([_drama('new')], resetToFirst: true); + for (var frame = 0; frame < 5; frame++) { + await tester.pump(); + await tester.runAsync(pumpEventQueue); + } + + expect(tester.takeException(), isNull); + expect(oldPlayer.disposed, isTrue); + expect(factory.created.length, greaterThanOrEqualTo(2)); + logic.dispose(); + await tester.pump(); + }, + ); }); group('lifecycle', () { @@ -889,3 +937,70 @@ class _RecordingPlayer implements PlayerPort { onDispose(); } } + +class _SessionVideoSurface extends StatelessWidget { + const _SessionVideoSurface({required this.logic}); + + final PlayerLogic logic; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: logic, + builder: (context, _) => AnimatedBuilder( + animation: logic.session, + builder: (context, _) { + final port = logic.session.active; + final video = port is VideoPlayerPort ? port : null; + return Scaffold( + body: PlayerVideoSurface( + controller: video?.controller, + videoUrl: video?.controller.dataSource ?? '', + theater: false, + onTap: () {}, + ), + ); + }, + ), + ); + } +} + +class _VideoRecordingFactory { + final List<_VideoRecordingPlayer> created = []; + + PlayerPort create(Episode episode) { + final player = _VideoRecordingPlayer(episode); + created.add(player); + return player; + } +} + +class _VideoRecordingPlayer extends VideoPlayerPort { + _VideoRecordingPlayer(super.episode); + + bool disposed = false; + + @override + Future initialize() async { + controller.value = const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration(seconds: 1), + isInitialized: true, + ); + } + + @override + Future play() async {} + + @override + Future pause() async {} + + @override + Future dispose() async { + if (disposed) return; + disposed = true; + await controller.dispose(); + } +}