diff --git a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt index 3fc470e..a558249 100644 --- a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt +++ b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt @@ -6,8 +6,10 @@ import android.content.Intent import android.app.PictureInPictureParams import android.content.res.Configuration import android.os.Build +import android.os.Bundle import android.util.Rational import android.content.pm.PackageManager +import android.view.WindowManager import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import io.flutter.embedding.android.FlutterActivity @@ -21,6 +23,11 @@ class MainActivity : FlutterActivity() { private var keyboardChannel: MethodChannel? = null private var imeInset = 0 + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) pipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/pip") diff --git a/docs/plans/2026-10-22-keep-awake-auto-resume.md b/docs/plans/2026-10-22-keep-awake-auto-resume.md new file mode 100644 index 0000000..b2781ed --- /dev/null +++ b/docs/plans/2026-10-22-keep-awake-auto-resume.md @@ -0,0 +1,31 @@ +# 屏幕常亮与前台自动续播 + +## 目标 + +- Android 与 iOS 前台保持屏幕常亮,不引入插件或依赖。 +- 用户主动暂停后切后台再回前台,在播放器与票据状态安全时自动续播。 +- 锁定、blocked、票据失败、错误态及浮层遮挡场景不得强制起播。 + +## 实施步骤 + +1. Android `MainActivity.onCreate` 添加 `FLAG_KEEP_SCREEN_ON`,保留现有渠道逻辑。 +2. iOS `AppDelegate` 设置 `UIApplication.shared.isIdleTimerDisabled = true`。 +3. 为 `PlaybackSession` 增加无浮层、路线可见且 active 播放器可用的恢复判断。 +4. 调整 `PlayerLogic.onLifecycle`,在现有 PiP 复位和过期票据刷新流程后安全设置播放意图。 +5. 补用户暂停恢复、锁定/票据失败不恢复、后台保存并暂停测试。 +6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。 + +## 风险 / 待确认 + +- 真机常亮和生命周期行为由 PM 验收,本次不启停服务、不操作设备。 +- PiP 继续沿用现有可见性策略,不额外修改后台播放行为。 + +## 执行结果 + +- Android 主 Activity 已添加 `FLAG_KEEP_SCREEN_ON`,未改动 Flutter 渠道与 PiP 逻辑。 +- iOS AppDelegate 已设置 `UIApplication.shared.isIdleTimerDisabled = true`。 +- 用户主动暂停后回前台可安全恢复;过期票据会等待重取完成后再恢复。 +- locked、blocked、票据错误、会话错误、解锁/浮层遮挡和 PiP 场景均不会强制续播。 +- 后台仍先保存进度并暂停。 +- `dart analyze lib test` 零告警,`flutter test --no-pub` 580 项全过。 +- 未启停服务,未操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 4c4cdbb..f014d7e 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -299,3 +299,30 @@ $ flutter test --no-pub 解读:加载高光、锁定静态轨道、拖动禁用和两个 loading 指示器尺寸均通过; 全量静态分析零告警,576 项测试全过。`pubspec.lock` 已还原,未启停服务, 未操作真机。 + +## 2026-10-22 屏幕常亮与前台自动续播 + +- 已从 `develop`(`5bf1022`)创建 `feature/keep-awake-auto-resume`。 +- Android 主 Activity 添加 `FLAG_KEEP_SCREEN_ON`,保留已有渠道、PiP 和键盘逻辑。 +- iOS AppDelegate 开启 `isIdleTimerDisabled`,前台保持常亮。 +- 用户主动暂停后回前台安全恢复;过期票据先完成重取再设置播放意图。 +- locked、blocked、票据错误、会话错误、解锁/浮层遮挡和 PiP 场景不会强制续播。 +- 后台仍立即保存进度并暂停,不做后台播放。 + +执行命令与原始输出: + +```text +$ flutter test --no-pub test/player/player_logic_test.dart +00:00 +35: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:50 +580: All tests passed! +``` + +解读:用户暂停恢复、过期票据重取、locked/ticketError/浮层阻断及后台保存暂停 +均通过;全量静态分析零告警,580 项测试全过。`pubspec.lock` 已还原, +未启停服务,未操作真机。 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 946f662..f94360c 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -20,6 +20,7 @@ final class RunnerFlutterViewController: FlutterViewController { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + UIApplication.shared.isIdleTimerDisabled = true return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index d0c58ea..ccb4526 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -92,6 +92,7 @@ class PlaybackSession extends ChangeNotifier { } return slot.player; } + bool get loading => _loading; bool get userWantsPlay => _userWantsPlay; bool get canAutoAdvance => _mayPlay && !_loading && active != null; @@ -103,6 +104,10 @@ class PlaybackSession extends ChangeNotifier { Future get playbackSettled => _operations; Future get preloadSettled => _preloadTask; + /// 当前页面可见、无浮层遮挡且存在可用播放器时可安全恢复。 + bool get canResumeAfterBackground => + !_closed && _routeVisible && !_overlayOpen && active != null; + bool get _visible => !_closed && _appResumed && _routeVisible; bool get _mayPlay => _visible && !_overlayOpen && _userWantsPlay; diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 288023f..0e3618d 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -512,6 +512,7 @@ class PlayerLogic extends ChangeNotifier { void onLifecycle(AppLifecycleState state) { if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress()); if (state == AppLifecycleState.resumed) { + final wasInPip = pip || pipPreparing; // 部分机型退出画中画不会回调 onPipChanged(false):回前台先兜底复位, // 否则 chromeHidden 恒为 true,播放浮层与首页底栏都会消失。 if (pip || pipPreparing) { @@ -523,8 +524,17 @@ class PlayerLogic extends ChangeNotifier { // 签名地址有有效期:回前台时若票据已过期,重新取一张再起播, // 否则会撞上 403(契约 §1.4) final episode = trackedEpisode; + final shouldAutoResume = !wasInPip && _canAutoResume(episode); if (episode != null && !playback.isFresh(episode.id)) { - unawaited(selectEpisode(session.currentIndex)); + unawaited( + _refreshTicketThenResume( + episode, + session.currentIndex, + shouldAutoResume, + ), + ); + } else if (shouldAutoResume) { + session.setUserWantsPlay(true); } } session.setAppResumed( @@ -533,6 +543,31 @@ class PlayerLogic extends ChangeNotifier { if (state != AppLifecycleState.resumed) cancelScrub(); } + Future _refreshTicketThenResume( + Episode episode, + int index, + bool shouldResume, + ) async { + await selectEpisode(index); + if (!shouldResume || + !isMounted() || + trackedEpisode?.id != episode.id || + !_canAutoResume(episode)) { + return; + } + session.setUserWantsPlay(true); + } + + bool _canAutoResume(Episode? episode) { + return episode != null && + ticketError == null && + blockedEpisodeId != episode.id && + unlockingEpisodeId == null && + !session.isLocked && + session.error == null && + session.canResumeAfterBackground; + } + @override void dispose() { if (_disposed) return; diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index c04bc59..f52251c 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -732,6 +732,172 @@ void main() { }); group('lifecycle', () { + test( + 'should_auto_resume_previously_paused_playback_when_app_returns', + () async { + final factory = _RecordingFactory(); + final playback = FakePlaybackRepository(fresh: false); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory(), playback: playback); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + final player = factory.created.first; + final requestsBeforeResume = playback.requests.length; + logic + ..trackedEpisode = drama.episodes.first + ..trackedDrama = drama; + + logic.session.setUserWantsPlay(false); + await _drain(logic); + expect(logic.session.userWantsPlay, isFalse); + expect(player.plays, 1); + + logic.onLifecycle(AppLifecycleState.paused); + await _drain(logic); + expect(logic.trackedEpisode, isNotNull); + expect(logic.ticketError, isNull); + expect(logic.blockedEpisodeId, isNull); + expect(logic.unlockingEpisodeId, isNull); + expect(logic.session.isLocked, isFalse); + expect(logic.session.error, isNull); + expect(logic.session.canResumeAfterBackground, isTrue); + logic.onLifecycle(AppLifecycleState.resumed); + await _drain(logic); + + expect(logic.session.userWantsPlay, isTrue); + expect(player.plays, 2); + expect(playback.requests.length, greaterThan(requestsBeforeResume)); + logic.dispose(); + await _drain(logic); + }, + ); + + test('should_not_auto_resume_locked_episode_when_app_returns', () async { + final factory = _RecordingFactory(); + final drama = _drama('locked', isFree: false); + final logic = _logic( + FakeWatchHistory(), + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'locked-1', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ), + ), + ); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + logic.trackedEpisode = drama.episodes.first; + logic.session.setUserWantsPlay(false); + + logic.onLifecycle(AppLifecycleState.paused); + await _drain(logic); + logic.onLifecycle(AppLifecycleState.resumed); + await _drain(logic); + + expect(logic.session.userWantsPlay, isFalse); + expect(factory.created, isEmpty); + logic.dispose(); + await _drain(logic); + }); + + test( + 'should_not_auto_resume_when_ticket_failure_or_overlay_is_active', + () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + final player = factory.created.first; + logic.session.setUserWantsPlay(false); + await _drain(logic); + + logic.ticketError = 'ticket failed'; + logic.onLifecycle(AppLifecycleState.paused); + await _drain(logic); + logic.onLifecycle(AppLifecycleState.resumed); + await _drain(logic); + expect(logic.session.userWantsPlay, isFalse); + expect(player.plays, 1); + + logic.ticketError = null; + logic.session.setOverlayOpen(true); + logic.onLifecycle(AppLifecycleState.paused); + await _drain(logic); + logic.onLifecycle(AppLifecycleState.resumed); + await _drain(logic); + expect(logic.session.userWantsPlay, isFalse); + expect(player.plays, 1); + + logic.session.setOverlayOpen(false); + logic.dispose(); + await _drain(logic); + }, + ); + + test( + 'should_save_progress_and_pause_when_app_enters_background', + () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final history = FakeWatchHistory(); + final logic = _logic(history); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + final player = factory.created.first; + final tracked = VideoPlayerPort(drama.episodes.first); + tracked.controller.value = const VideoPlayerValue( + duration: Duration(minutes: 3), + position: Duration(seconds: 5), + isInitialized: true, + ); + logic + ..trackedPlayer = tracked + ..trackedEpisode = drama.episodes.first + ..trackedDrama = drama; + + logic.onLifecycle(AppLifecycleState.paused); + await _drain(logic); + await logic.pendingWrite; + + expect(player.pauses, 1); + expect(history.writes, ['signal-1|5000|false']); + logic.dispose(); + await _drain(logic); + await tracked.dispose(); + }, + ); + test('should_pause_active_player_when_backgrounded_and_play_it_when_resumed', () async { final factory = _RecordingFactory(); final drama = _drama('signal');