merge: 合并屏幕常亮与自动续播
This commit is contained in:
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.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 项全过。
|
||||
- 未启停服务,未操作真机。
|
||||
@@ -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` 已还原,
|
||||
未启停服务,未操作真机。
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user