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.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 项全过。
|
||||||
|
- 未启停服务,未操作真机。
|
||||||
@@ -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` 已还原,
|
||||||
|
未启停服务,未操作真机。
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in new issue
Block a user