merge: 合并屏幕常亮与自动续播

This commit is contained in:
Zhou Weidong committed 2026-10-22 10:17:53 +08:00
commit fe49c6d8b2
7 files changed
+273 -1

No files matched your search

@@ -6,8 +6,10 @@ import android.content.Intent
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.os.Bundle
import android.util.Rational import android.util.Rational
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.view.WindowManager
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
@@ -21,6 +23,11 @@ class MainActivity : FlutterActivity() {
private var keyboardChannel: MethodChannel? = null private var keyboardChannel: MethodChannel? = null
private var imeInset = 0 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) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
pipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/pip") pipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/pip")
@@ -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 项全过。
- 未启停服务,未操作真机。
+27
View File
@@ -299,3 +299,30 @@ $ flutter test --no-pub
解读:加载高光、锁定静态轨道、拖动禁用和两个 loading 指示器尺寸均通过; 解读:加载高光、锁定静态轨道、拖动禁用和两个 loading 指示器尺寸均通过;
全量静态分析零告警,576 项测试全过。`pubspec.lock` 已还原,未启停服务, 全量静态分析零告警,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` 已还原,
未启停服务,未操作真机。
+1
View File
@@ -20,6 +20,7 @@ final class RunnerFlutterViewController: FlutterViewController {
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
UIApplication.shared.isIdleTimerDisabled = true
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
@@ -92,6 +92,7 @@ class PlaybackSession extends ChangeNotifier {
} }
return slot.player; return slot.player;
} }
bool get loading => _loading; bool get loading => _loading;
bool get userWantsPlay => _userWantsPlay; bool get userWantsPlay => _userWantsPlay;
bool get canAutoAdvance => _mayPlay && !_loading && active != null; bool get canAutoAdvance => _mayPlay && !_loading && active != null;
@@ -103,6 +104,10 @@ class PlaybackSession extends ChangeNotifier {
Future<void> get playbackSettled => _operations; Future<void> get playbackSettled => _operations;
Future<void> get preloadSettled => _preloadTask; Future<void> get preloadSettled => _preloadTask;
/// 当前页面可见、无浮层遮挡且存在可用播放器时可安全恢复。
bool get canResumeAfterBackground =>
!_closed && _routeVisible && !_overlayOpen && active != null;
bool get _visible => !_closed && _appResumed && _routeVisible; bool get _visible => !_closed && _appResumed && _routeVisible;
bool get _mayPlay => _visible && !_overlayOpen && _userWantsPlay; bool get _mayPlay => _visible && !_overlayOpen && _userWantsPlay;
+36 -1
View File
@@ -512,6 +512,7 @@ class PlayerLogic extends ChangeNotifier {
void onLifecycle(AppLifecycleState state) { void onLifecycle(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress()); if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress());
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
final wasInPip = pip || pipPreparing;
// 部分机型退出画中画不会回调 onPipChanged(false):回前台先兜底复位, // 部分机型退出画中画不会回调 onPipChanged(false):回前台先兜底复位,
// 否则 chromeHidden 恒为 true,播放浮层与首页底栏都会消失。 // 否则 chromeHidden 恒为 true,播放浮层与首页底栏都会消失。
if (pip || pipPreparing) { if (pip || pipPreparing) {
@@ -523,8 +524,17 @@ class PlayerLogic extends ChangeNotifier {
// 签名地址有有效期:回前台时若票据已过期,重新取一张再起播, // 签名地址有有效期:回前台时若票据已过期,重新取一张再起播,
// 否则会撞上 403(契约 §1.4) // 否则会撞上 403(契约 §1.4)
final episode = trackedEpisode; final episode = trackedEpisode;
final shouldAutoResume = !wasInPip && _canAutoResume(episode);
if (episode != null && !playback.isFresh(episode.id)) { 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( session.setAppResumed(
@@ -533,6 +543,31 @@ class PlayerLogic extends ChangeNotifier {
if (state != AppLifecycleState.resumed) cancelScrub(); if (state != AppLifecycleState.resumed) cancelScrub();
} }
Future<void> _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 @override
void dispose() { void dispose() {
if (_disposed) return; if (_disposed) return;
+166
View File
@@ -732,6 +732,172 @@ void main() {
}); });
group('lifecycle', () { 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 { test('should_pause_active_player_when_backgrounded_and_play_it_when_resumed', () async {
final factory = _RecordingFactory(); final factory = _RecordingFactory();
final drama = _drama('signal'); final drama = _drama('signal');