feat(player): 保持屏幕常亮并自动恢复播放

This commit is contained in:
Zhou Weidong committed 2026-10-22 10:17:43 +08:00
1 parent 5bf10221a8
commit a25c500371
7 files changed
+273 -1

No files matched your search

@@ -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")
@@ -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 指示器尺寸均通过;
全量静态分析零告警,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,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UIApplication.shared.isIdleTimerDisabled = true
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
@@ -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<void> get playbackSettled => _operations;
Future<void> get preloadSettled => _preloadTask;
/// 当前页面可见、无浮层遮挡且存在可用播放器时可安全恢复。
bool get canResumeAfterBackground =>
!_closed && _routeVisible && !_overlayOpen && active != null;
bool get _visible => !_closed && _appResumed && _routeVisible;
bool get _mayPlay => _visible && !_overlayOpen && _userWantsPlay;
+36 -1
View File
@@ -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<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
void dispose() {
if (_disposed) return;
+166
View File
@@ -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');