feat(player): 接入双端画中画播放
This commit is contained in:
1 parent
5346be40a9
commit
43e60e29ec
10 files changed
+1015
-29
No files matched your search
@@ -3,7 +3,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Native Android activity PiP; the Flutter playback session keeps the player.
|
||||
/// 原生活动级画中画桥:Android 是 Activity PiP(画面即 Flutter 渲染),
|
||||
/// iOS 是影子播放器接力(画面/声音来自原生 AVPlayer,退出时进度回传)。
|
||||
class AndroidPictureInPicture {
|
||||
AndroidPictureInPicture({MethodChannel? channel})
|
||||
: _channel = channel ?? const MethodChannel('com.stormg.lumadrama/pip');
|
||||
@@ -11,22 +12,147 @@ class AndroidPictureInPicture {
|
||||
final MethodChannel _channel;
|
||||
|
||||
bool get isPlatformSupported =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS);
|
||||
|
||||
void setStateListener(ValueChanged<bool>? listener) {
|
||||
_channel.setMethodCallHandler(
|
||||
listener == null
|
||||
? null
|
||||
: (call) async {
|
||||
if (call.method == 'onPipChanged') {
|
||||
listener(call.arguments == true);
|
||||
}
|
||||
},
|
||||
);
|
||||
void Function(bool active, int finalPositionMs)? _pipChangedListener;
|
||||
VoidCallback? _playbackEndedListener;
|
||||
|
||||
/// PiP 状态回调:iOS 停止时 [finalPositionMs] 为影子最终进度(毫秒)。
|
||||
void setStateListener(void Function(bool active, int finalPositionMs)? listener) {
|
||||
_pipChangedListener = listener;
|
||||
_refreshHandler();
|
||||
}
|
||||
|
||||
Future<bool> enter() async {
|
||||
/// iOS 画中画内影子播完当前集(Android 不会触发)。
|
||||
void setPlaybackEndedListener(VoidCallback? listener) {
|
||||
_playbackEndedListener = listener;
|
||||
_refreshHandler();
|
||||
}
|
||||
|
||||
void _refreshHandler() {
|
||||
if (_pipChangedListener == null && _playbackEndedListener == null) {
|
||||
_channel.setMethodCallHandler(null);
|
||||
return;
|
||||
}
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'pipDebug':
|
||||
debugPrint('pip[native]: ${call.arguments}');
|
||||
case 'onPipChanged':
|
||||
final listener = _pipChangedListener;
|
||||
if (listener == null) return;
|
||||
final args = call.arguments;
|
||||
if (args is Map) {
|
||||
listener(
|
||||
args['active'] == true,
|
||||
(args['positionMs'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
} else {
|
||||
// Android 只回传 bool。
|
||||
listener(args == true, 0);
|
||||
}
|
||||
case 'onPipPlaybackEnded':
|
||||
_playbackEndedListener?.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 进入画中画。Android 忽略片源参数;iOS 用 [url] 与 [positionMs]
|
||||
/// 初始化影子播放器(对齐到当前进度、静音待命)。
|
||||
Future<bool> enter({required String url, required int positionMs}) async {
|
||||
if (!isPlatformSupported) return false;
|
||||
return await _channel.invokeMethod<bool>('enter') ?? false;
|
||||
return await _channel.invokeMethod<bool>('enter', {
|
||||
'url': url,
|
||||
'positionMs': positionMs,
|
||||
}) ??
|
||||
false;
|
||||
}
|
||||
|
||||
/// 创建影子播放器但不起播(仅 iOS):
|
||||
/// 自动小窗待命形态——静音暂停,退后台由 [armAutoEnter] 拉起。
|
||||
Future<void> prepareShadow({
|
||||
required String url,
|
||||
required int positionMs,
|
||||
bool autoEnter = false,
|
||||
}) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('prepareShadow', {
|
||||
'url': url,
|
||||
'positionMs': positionMs,
|
||||
'autoEnter': autoEnter,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 退后台瞬间把影子对齐到当前进度并静音起播,触发系统自动画中画(仅 iOS)。
|
||||
Future<void> armAutoEnter(int positionMs) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('armAutoEnter', {
|
||||
'positionMs': positionMs,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 回前台后影子回待命(暂停、静音)(仅 iOS)。
|
||||
Future<void> disarmAutoEnter() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('disarmAutoEnter');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 显示原生还原过渡层(仅 iOS;还原动画开始时原生侧也会自动盖上)。
|
||||
Future<void> showRestoreOverlay() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('showRestoreOverlay');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 淡出原生还原过渡层(仅 iOS;视频首帧确认后调用)。
|
||||
Future<void> hideRestoreOverlay() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('hideRestoreOverlay');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 解除影子静音并起播(仅 iOS 有意义,进入 PiP 后声音交给影子)。
|
||||
Future<void> unmute() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('unmute');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// iOS 画中画内换集:影子只换播放项,窗口不中断。
|
||||
Future<void> replaceItem({required String url, required int positionMs}) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('replaceItem', {
|
||||
'url': url,
|
||||
'positionMs': positionMs,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 请求退出画中画(iOS;触发 onPipChanged(false) 后由 Dart 收尾)。
|
||||
Future<void> stop() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('stop');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 销毁影子播放器(仅 iOS)。
|
||||
Future<void> teardown() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('teardown');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:floating/floating.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/episode_stats.dart';
|
||||
@@ -44,6 +48,16 @@ typedef _PlaybackSnapshot = ({
|
||||
});
|
||||
|
||||
class PlayerLogic extends ChangeNotifier {
|
||||
/// Android PiP 管理器(floating 插件);负责退后台自动进小窗。
|
||||
static final Floating _floating = Floating();
|
||||
|
||||
/// 挂了自动小窗的播放器实例(引用计数):沉浸流与独立播放页可能
|
||||
/// 同时在树上,只有全部不需要时才真正注销 OnLeavePiP。
|
||||
static final Set<PlayerLogic> _pipOwners = <PlayerLogic>{};
|
||||
|
||||
/// OnLeavePiP 当前在原生侧的挂载状态(进程级,与 [_pipOwners] 对应)。
|
||||
static bool _pipNativeArmed = false;
|
||||
|
||||
PlayerLogic({
|
||||
required this.history,
|
||||
required this.playback,
|
||||
@@ -112,7 +126,31 @@ class PlayerLogic extends ChangeNotifier {
|
||||
VideoPlayerPort? scrubbingPlayer;
|
||||
bool pip = false;
|
||||
bool pipPreparing = false;
|
||||
final Map<String, int> pickedEpisode = {};
|
||||
bool _wasPlayingBeforeIosPip = false;
|
||||
Timer? _pipStartTimer;
|
||||
|
||||
/// iOS 自动小窗开关(对齐红果:开启后退 App 自动进入画中画)。
|
||||
static const _iosAutoPipPrefKey = 'player.ios.autoEnterPip';
|
||||
bool iosAutoPipEnabled = false;
|
||||
|
||||
/// 画中画还原信号:自增通知视频表面回退占位重新等首帧(防回前台黑屏)。
|
||||
int pipResumeToken = 0;
|
||||
|
||||
/// 画中画还原过渡期:为 true 时播放区盖当前集封面,等纹理出帧后淡出,
|
||||
/// 盖住回前台瞬间的黑纹理帧。700ms 后自动复位。
|
||||
bool pipRecovering = false;
|
||||
Timer? _pipRecoverTimer;
|
||||
|
||||
/// iOS 影子播放器当前承载的集 id(全局单例状态);用于避免重复换源。
|
||||
static String? iosShadowEpisodeId;
|
||||
|
||||
/// 当前设备是否为 iOS 真机环境(面板据此显示自动小窗开关)。
|
||||
bool get iosDevice => !kIsWeb && Platform.isIOS;
|
||||
/// PiP 状态是全局单通道回调,须广播给所有存活的 PlayerLogic:
|
||||
/// 推荐页每页一个 logic,若只保留最后一次注册的 handler,
|
||||
/// 相邻预加载页会盖掉正在播放页的注册,导致小窗 UI 不隐藏且无声音。
|
||||
static final Set<PlayerLogic> _pipSubscribers = <PlayerLogic>{};
|
||||
static final Map<String, int> pickedEpisode = {};
|
||||
final Set<String> _unlockedEpisodeIds = {};
|
||||
final Map<String, EpisodeStats> _episodeStats = {};
|
||||
final Map<String, DateTime> _episodeStatsFetchedAt = {};
|
||||
@@ -214,7 +252,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
_started = true;
|
||||
_sessionFactory = factory;
|
||||
_sessionCoordinator = coordinator;
|
||||
pictureInPicture.setStateListener(onPipChanged);
|
||||
_pipSubscribers.add(this);
|
||||
pictureInPicture.setStateListener(_dispatchPipChanged);
|
||||
pictureInPicture.setPlaybackEndedListener(_dispatchPipPlaybackEnded);
|
||||
unawaited(_loadIosAutoPipPref());
|
||||
queue = feedDramas == null
|
||||
? PlaybackQueue.series(pageDrama)
|
||||
: PlaybackQueue.home(feedDramas!);
|
||||
@@ -850,6 +891,11 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (pip || pipPreparing) {
|
||||
pip = false;
|
||||
pipPreparing = false;
|
||||
if (iosDevice) {
|
||||
// 部分回前台路径不回调 didStop:销毁影子、seek 刷纹理并恢复播放,
|
||||
// 否则停在 PiP 期间的暂停态表现为黑屏;自动小窗开着则重建待命。
|
||||
unawaited(_recoverFromIosPipFallback());
|
||||
}
|
||||
notify();
|
||||
notifyImmersive();
|
||||
}
|
||||
@@ -873,6 +919,26 @@ class PlayerLogic extends ChangeNotifier {
|
||||
state == AppLifecycleState.resumed || pip || pipPreparing,
|
||||
);
|
||||
if (state != AppLifecycleState.resumed) cancelScrub();
|
||||
// iOS 自动小窗:退后台瞬间把影子对齐到当前进度起播,系统据此自动进画中画;
|
||||
// 回前台取消待命。手动小窗的恢复由 onPipChanged(false) 处理。
|
||||
if (iosDevice && iosAutoPipEnabled && !pip) {
|
||||
final video = session.active is VideoPlayerPort
|
||||
? session.active as VideoPlayerPort
|
||||
: null;
|
||||
if (state == AppLifecycleState.inactive &&
|
||||
video != null &&
|
||||
session.userWantsPlay) {
|
||||
debugPrint('pip: lifecycle inactive -> arm auto enter');
|
||||
unawaited(
|
||||
pictureInPicture.armAutoEnter(
|
||||
video.controller.value.position.inMilliseconds,
|
||||
),
|
||||
);
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
debugPrint('pip: lifecycle resumed -> disarm auto enter');
|
||||
unawaited(pictureInPicture.disarmAutoEnter());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshTicketThenResume(
|
||||
@@ -904,7 +970,13 @@ class PlayerLogic extends ChangeNotifier {
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
pictureInPicture.setStateListener(null);
|
||||
_pipSubscribers.remove(this);
|
||||
_pipStartTimer?.cancel();
|
||||
if (_pipSubscribers.isEmpty) {
|
||||
pictureInPicture.setStateListener(null);
|
||||
pictureInPicture.setPlaybackEndedListener(null);
|
||||
}
|
||||
unawaited(syncPipAutoArm(false));
|
||||
unawaited(saveCurrentProgress());
|
||||
_detachTrackedPlayerListener();
|
||||
if (_started) {
|
||||
@@ -918,31 +990,117 @@ class PlayerLogic extends ChangeNotifier {
|
||||
|
||||
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
|
||||
|
||||
/// 按 [want] 挂/摘退后台自动小窗(引用计数):
|
||||
/// - 沉浸流:随所在 tab 的激活状态调用(active 时挂,切走时摘);
|
||||
/// - 独立播放页:进入时挂、销毁时摘;
|
||||
/// - 权限未授权时静默跳过(申请统一在首次点「画中画」时,见 [enterPip])。
|
||||
Future<void> syncPipAutoArm(bool want) async {
|
||||
if (!pictureInPicture.isPlatformSupported) return;
|
||||
// iOS 无「退后台自动小窗」(系统不开放 Activity 级自动进入),走手动入口。
|
||||
if (!kIsWeb && Platform.isIOS) return;
|
||||
try {
|
||||
if (want) {
|
||||
_pipOwners.add(this);
|
||||
} else {
|
||||
_pipOwners.remove(this);
|
||||
}
|
||||
final arm = _pipOwners.isNotEmpty;
|
||||
if (arm == _pipNativeArmed) return;
|
||||
if (arm) {
|
||||
if (!await _floating.isPipAvailable) return;
|
||||
if (!await Permission.systemAlertWindow.isGranted) return;
|
||||
await _floating.enable(const OnLeavePiP(aspectRatio: Rational(9, 16)));
|
||||
_pipNativeArmed = true;
|
||||
} else {
|
||||
_pipNativeArmed = false;
|
||||
await _floating.cancelOnLeavePiP();
|
||||
}
|
||||
} catch (_) {
|
||||
// 自动小窗挂载失败不影响正常播放。
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> enterPip(BuildContext context) async {
|
||||
if (pip || pipPreparing) return;
|
||||
if (session.active is! VideoPlayerPort) {
|
||||
if (pip || pipPreparing) {
|
||||
debugPrint('pip: tap ignored (pip=$pip preparing=$pipPreparing)');
|
||||
return;
|
||||
}
|
||||
final isIos = !kIsWeb && Platform.isIOS;
|
||||
debugPrint('pip: tap platform=${isIos ? "ios" : "android"}');
|
||||
final activeVideo = session.active is VideoPlayerPort
|
||||
? session.active as VideoPlayerPort
|
||||
: null;
|
||||
if (activeVideo == null) {
|
||||
debugPrint('pip: no active video port');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 首次点击「画中画」:申请「显示在其他应用上层」权限(特殊权限,
|
||||
// 会跳系统设置页);拒绝则视为设备无法使用画中画。仅 Android 需要。
|
||||
if (!isIos && pictureInPicture.isPlatformSupported) {
|
||||
try {
|
||||
if (!await Permission.systemAlertWindow.isGranted) {
|
||||
await Permission.systemAlertWindow.request();
|
||||
}
|
||||
if (await Permission.systemAlertWindow.isGranted) {
|
||||
_pipOwners.add(this);
|
||||
if (!_pipNativeArmed && await _floating.isPipAvailable) {
|
||||
await _floating.enable(
|
||||
const OnLeavePiP(aspectRatio: Rational(9, 16)),
|
||||
);
|
||||
_pipNativeArmed = true;
|
||||
}
|
||||
} else {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.pipUnavailable),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// 权限流程异常时按原有流程继续尝试直接进入。
|
||||
}
|
||||
}
|
||||
pipPreparing = true;
|
||||
notify();
|
||||
notifyImmersive();
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
final url = VideoPlayerPort.resolveUri(
|
||||
sessionEpisodes[session.currentIndex],
|
||||
).toString();
|
||||
final positionMs = activeVideo.controller.value.position.inMilliseconds;
|
||||
debugPrint('pip: invoke enter pos=$positionMs');
|
||||
bool entered = false;
|
||||
try {
|
||||
entered = await pictureInPicture.enter();
|
||||
} catch (_) {
|
||||
entered = await pictureInPicture.enter(url: url, positionMs: positionMs);
|
||||
} catch (e) {
|
||||
debugPrint('pip: enter threw $e');
|
||||
// Android reports unavailable for devices without PiP support.
|
||||
}
|
||||
debugPrint('pip: entered=$entered');
|
||||
if (!context.mounted) return;
|
||||
if (entered) {
|
||||
pip = true;
|
||||
pipPreparing = false;
|
||||
notify();
|
||||
notifyImmersive();
|
||||
session.setAppResumed(true);
|
||||
if (isIos) {
|
||||
// iOS 等影子流就绪后自动进入小窗(原生 pendingStart 兜底不会过期);
|
||||
// 超时仅复位按钮状态,避免长时间卡在「准备中」。
|
||||
_pipStartTimer?.cancel();
|
||||
_pipStartTimer = Timer(const Duration(seconds: 6), () {
|
||||
if (!isMounted() || pip || !pipPreparing) return;
|
||||
pipPreparing = false;
|
||||
notify();
|
||||
notifyImmersive();
|
||||
});
|
||||
} else {
|
||||
pip = true;
|
||||
pipPreparing = false;
|
||||
notify();
|
||||
notifyImmersive();
|
||||
session.setAppResumed(true);
|
||||
}
|
||||
} else {
|
||||
pipPreparing = false;
|
||||
notify();
|
||||
@@ -953,10 +1111,28 @@ class PlayerLogic extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void onPipChanged(bool enabled) {
|
||||
static void _dispatchPipChanged(bool enabled, int finalPositionMs) {
|
||||
for (final logic in List.of(_pipSubscribers)) {
|
||||
logic.onPipChanged(enabled, finalPositionMs: finalPositionMs);
|
||||
}
|
||||
}
|
||||
|
||||
static void _dispatchPipPlaybackEnded() {
|
||||
for (final logic in List.of(_pipSubscribers)) {
|
||||
logic._handleIosPipPlaybackEnded();
|
||||
}
|
||||
}
|
||||
|
||||
void onPipChanged(bool enabled, {int finalPositionMs = 0}) {
|
||||
if (!isMounted()) return;
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
_onIosPipChanged(enabled, finalPositionMs);
|
||||
return;
|
||||
}
|
||||
pip = enabled;
|
||||
pipPreparing = false;
|
||||
_pipStartTimer?.cancel();
|
||||
_pipStartTimer = null;
|
||||
notify();
|
||||
notifyImmersive();
|
||||
session.setAppResumed(
|
||||
@@ -965,6 +1141,94 @@ class PlayerLogic extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
/// iOS 影子接力:进入小窗后画面/声音交给原生影子播放器,
|
||||
/// Flutter 播放器暂停(避免双声);退出时按影子进度 seek 对齐恢复。
|
||||
void _onIosPipChanged(bool enabled, int finalPositionMs) {
|
||||
final video = session.active is VideoPlayerPort
|
||||
? session.active as VideoPlayerPort
|
||||
: null;
|
||||
pip = enabled;
|
||||
pipPreparing = false;
|
||||
_pipStartTimer?.cancel();
|
||||
_pipStartTimer = null;
|
||||
if (enabled) {
|
||||
_wasPlayingBeforeIosPip = session.userWantsPlay;
|
||||
session.setUserWantsPlay(false);
|
||||
if (video != null) unawaited(video.pause());
|
||||
unawaited(pictureInPicture.unmute());
|
||||
session.setAppResumed(true);
|
||||
} else {
|
||||
unawaited(pictureInPicture.teardown());
|
||||
PlayerLogic.iosShadowEpisodeId = null;
|
||||
pipResumeToken++;
|
||||
_beginPipRecover();
|
||||
if (video != null) {
|
||||
final target = finalPositionMs > 0
|
||||
? clampResumePosition(
|
||||
Duration(milliseconds: finalPositionMs),
|
||||
video.controller.value.duration,
|
||||
)
|
||||
: video.controller.value.position;
|
||||
// seek 同时兜住进度对齐与回前台纹理黑屏(强制 AVPlayer 重新出帧)。
|
||||
unawaited(video.seekTo(target));
|
||||
}
|
||||
session.setUserWantsPlay(_wasPlayingBeforeIosPip);
|
||||
session.setAppResumed(
|
||||
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed,
|
||||
);
|
||||
// 自动小窗开着时影子回待命,等待下次退后台。
|
||||
if (iosAutoPipEnabled) {
|
||||
final episode = trackedEpisode;
|
||||
if (episode != null) unawaited(_ensureIosShadowStandby(episode));
|
||||
}
|
||||
}
|
||||
notify();
|
||||
notifyImmersive();
|
||||
}
|
||||
|
||||
/// iOS 画中画里影子播完当前集:小窗内直接换下一集片源无缝连播(不翻页),
|
||||
/// 同时用无动画跳页把 App 内位置同步到新集,还原后正好停在新集;
|
||||
/// 没有下一集或下一集锁定时退出画中画回到 App。
|
||||
Future<void> _handleIosPipPlaybackEnded() async {
|
||||
if (!isMounted() || !pip || !(!kIsWeb && Platform.isIOS)) return;
|
||||
final index = session.currentIndex;
|
||||
final positionInVisible = visibleIndices.indexOf(index) + 1;
|
||||
if (positionInVisible <= 0 || positionInVisible >= visibleIndices.length) {
|
||||
await pictureInPicture.stop();
|
||||
return;
|
||||
}
|
||||
final nextIndex = visibleIndices[positionInVisible];
|
||||
if (nextIndex < 0 || nextIndex >= sessionEpisodes.length) {
|
||||
await pictureInPicture.stop();
|
||||
return;
|
||||
}
|
||||
if (isEpisodeLocked(sessionEpisodes[nextIndex])) {
|
||||
await pictureInPicture.stop();
|
||||
return;
|
||||
}
|
||||
final episode = sessionEpisodes[nextIndex];
|
||||
debugPrint('pip: auto next episode=${episode.id}');
|
||||
// 影子直接切下一集从头播,窗口不中断。
|
||||
try {
|
||||
await pictureInPicture.replaceItem(
|
||||
url: VideoPlayerPort.resolveUri(episode).toString(),
|
||||
positionMs: 0,
|
||||
);
|
||||
await pictureInPicture.unmute();
|
||||
PlayerLogic.iosShadowEpisodeId = episode.id;
|
||||
} catch (_) {
|
||||
await pictureInPicture.stop();
|
||||
return;
|
||||
}
|
||||
// 同步 App 内会话与页面位置(无动画),还原后停在当前连播到的集。
|
||||
final controller = pages;
|
||||
try {
|
||||
if (controller.hasClients) controller.jumpToPage(positionInVisible);
|
||||
} catch (_) {
|
||||
// 页面可能已离树,影子连播不受影响。
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
|
||||
if (player is! VideoPlayerPort) return;
|
||||
try {
|
||||
@@ -1020,6 +1284,121 @@ class PlayerLogic extends ChangeNotifier {
|
||||
_completionProgressSaved = false;
|
||||
lastProgressWrite = DateTime.now();
|
||||
player.controller.addListener(_handleTrackedPlayerChanged);
|
||||
// iOS:小窗内换集则影子直切新片源继续播;自动小窗开启时影子换源保持待命。
|
||||
// 影子已在该集(连播刚换过源)时跳过,避免重置回片头。
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
if (pip) {
|
||||
if (PlayerLogic.iosShadowEpisodeId != episode.id) {
|
||||
unawaited(_syncIosShadowToEpisode(episode, playWhenPip: true));
|
||||
}
|
||||
} else if (iosAutoPipEnabled) {
|
||||
unawaited(_ensureIosShadowStandby(episode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncIosShadowToEpisode(
|
||||
Episode episode, {
|
||||
required bool playWhenPip,
|
||||
}) async {
|
||||
debugPrint('pip: shadow sync episode=${episode.id} play=$playWhenPip');
|
||||
try {
|
||||
await pictureInPicture.replaceItem(
|
||||
url: VideoPlayerPort.resolveUri(episode).toString(),
|
||||
positionMs: 0,
|
||||
);
|
||||
PlayerLogic.iosShadowEpisodeId = episode.id;
|
||||
if (playWhenPip) await pictureInPicture.unmute();
|
||||
} catch (_) {
|
||||
// 影子切换失败时保持小窗停在旧集末帧,不阻断 Flutter 侧换集。
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取自动小窗开关;开启时影子待命由集激活钩子建立。
|
||||
Future<void> _loadIosAutoPipPref() async {
|
||||
if (!iosDevice) return;
|
||||
try {
|
||||
iosAutoPipEnabled =
|
||||
await SharedPreferencesAsync().getBool(_iosAutoPipPrefKey) ?? false;
|
||||
} catch (_) {
|
||||
iosAutoPipEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换自动小窗开关(更多面板):开启即建影子待命,关闭即销毁。
|
||||
Future<void> setIosAutoPip(bool enabled) async {
|
||||
debugPrint('pip: auto-pip switch -> $enabled');
|
||||
if (!iosDevice || iosAutoPipEnabled == enabled) return;
|
||||
iosAutoPipEnabled = enabled;
|
||||
try {
|
||||
await SharedPreferencesAsync().setBool(_iosAutoPipPrefKey, enabled);
|
||||
} catch (_) {
|
||||
// 偏好写失败只影响下次启动的记忆,本次会话照常生效。
|
||||
}
|
||||
if (enabled) {
|
||||
final index = session.currentIndex;
|
||||
if (index >= 0 && index < sessionEpisodes.length) {
|
||||
await _ensureIosShadowStandby(sessionEpisodes[index]);
|
||||
}
|
||||
} else {
|
||||
await pictureInPicture.teardown();
|
||||
PlayerLogic.iosShadowEpisodeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 还原过渡:盖封面直至视频真正出帧(onVideoVisible 时收起),兜底 900ms。
|
||||
void _beginPipRecover() {
|
||||
_pipRecoverTimer?.cancel();
|
||||
pipRecovering = true;
|
||||
_pipRecoverTimer = Timer(const Duration(milliseconds: 900), endPipRecover);
|
||||
}
|
||||
|
||||
/// 视频首帧确认后立即收起还原封面(由播放视图回调)。
|
||||
void endPipRecover() {
|
||||
_pipRecoverTimer?.cancel();
|
||||
if (pipRecovering) {
|
||||
pipRecovering = false;
|
||||
notify();
|
||||
}
|
||||
// 原生过渡层在还原动画开始时已盖上,这里等表面恢复后淡出。
|
||||
unawaited(pictureInPicture.hideRestoreOverlay());
|
||||
}
|
||||
|
||||
/// 回前台兜底恢复:销毁影子、seek 强制重新出帧并恢复播放,
|
||||
/// 修复部分还原路径不回调 didStop 导致的黑屏 + 暂停。
|
||||
Future<void> _recoverFromIosPipFallback() async {
|
||||
pipResumeToken++;
|
||||
_beginPipRecover();
|
||||
await pictureInPicture.teardown();
|
||||
PlayerLogic.iosShadowEpisodeId = null;
|
||||
final video = session.active is VideoPlayerPort
|
||||
? session.active as VideoPlayerPort
|
||||
: null;
|
||||
if (video != null) {
|
||||
await video.seekTo(video.controller.value.position);
|
||||
session.setUserWantsPlay(true);
|
||||
}
|
||||
session.setAppResumed(true);
|
||||
final episode = trackedEpisode;
|
||||
if (iosAutoPipEnabled && episode != null) {
|
||||
await _ensureIosShadowStandby(episode);
|
||||
}
|
||||
}
|
||||
|
||||
/// 影子待命形态:静音暂停在片头,退后台时由系统自动切画中画。
|
||||
Future<void> _ensureIosShadowStandby(Episode episode) async {
|
||||
if (!iosDevice || pip) return;
|
||||
try {
|
||||
debugPrint('pip: prepare shadow standby (autoEnter)');
|
||||
await pictureInPicture.prepareShadow(
|
||||
url: VideoPlayerPort.resolveUri(episode).toString(),
|
||||
positionMs: 0,
|
||||
autoEnter: true,
|
||||
);
|
||||
PlayerLogic.iosShadowEpisodeId = episode.id;
|
||||
} catch (e) {
|
||||
debugPrint('pip: prepare standby failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _detachTrackedPlayerListener() {
|
||||
|
||||
@@ -200,6 +200,8 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
);
|
||||
}
|
||||
_pageController = logic.pages;
|
||||
// 自动小窗:独立播放页进入即挂;沉浸流随所在 tab 的激活状态挂/摘。
|
||||
unawaited(logic.syncPipAutoArm(widget.embedded ? widget.active : true));
|
||||
if (!widget.embedded &&
|
||||
!logic.isEpisodeLocked(logic.session.currentEpisode)) {
|
||||
_loadingVisible = true;
|
||||
@@ -217,6 +219,10 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
void didUpdateWidget(covariant PlayerPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
logic.attach(widget, isMounted: () => mounted);
|
||||
// 沉浸流所在 tab 切换时同步自动小窗(激活挂、切走摘)。
|
||||
if (widget.embedded && oldWidget.active != widget.active) {
|
||||
unawaited(logic.syncPipAutoArm(widget.active));
|
||||
}
|
||||
if (oldWidget.scrubCancelRequest != widget.scrubCancelRequest) {
|
||||
_cancelFeedScrub();
|
||||
}
|
||||
@@ -703,6 +709,13 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
onRefresh: _refreshHomeFeed,
|
||||
child: pages,
|
||||
);
|
||||
// 进画中画时预加载当前集海报:还原过渡封面立即可显示,避免再黑一次。
|
||||
if (logic.pip) {
|
||||
final poster = logic.trackedEpisode?.posterUrl?.trim();
|
||||
if (poster != null && poster.isNotEmpty) {
|
||||
unawaited(precacheImage(NetworkImage(poster), context));
|
||||
}
|
||||
}
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
clipBehavior: Clip.none,
|
||||
@@ -711,7 +724,13 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
pageController: _pageController,
|
||||
slots: _renderSlots(indices),
|
||||
onCurrentVideoVisible: _handleCurrentVideoVisible,
|
||||
resumeSignal: logic.pipResumeToken,
|
||||
),
|
||||
if (logic.pipRecovering)
|
||||
Positioned.fill(
|
||||
key: const Key('player-pip-recover-cover'),
|
||||
child: _PipRecoverCover(episode: logic.trackedEpisode),
|
||||
),
|
||||
if (!widget.embedded && !_loadingRemoved)
|
||||
Center(
|
||||
child: IgnorePointer(
|
||||
@@ -804,6 +823,9 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
|
||||
void _handleCurrentVideoVisible(int? index) {
|
||||
if (!mounted || _loadingRemoved) return;
|
||||
// 收起还原封面要放在 early-return 之前:恢复时集未变,
|
||||
// _videoReadyIndex 已相等,只有这里能接到淡入完成的回调。
|
||||
logic.endPipRecover();
|
||||
final currentIndex = logic.session.currentIndex;
|
||||
if (index == null ||
|
||||
index != currentIndex ||
|
||||
@@ -1447,3 +1469,43 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 画中画还原过渡封面:盖住回前台瞬间的黑纹理帧,视频出帧后由
|
||||
/// [PlayerLogic.endPipRecover] 收起。背景用主题渐变,海报预加载命中前也有过渡观感。
|
||||
class _PipRecoverCover extends StatelessWidget {
|
||||
const _PipRecoverCover({required this.episode});
|
||||
|
||||
final Episode? episode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final poster = episode?.posterUrl?.trim();
|
||||
final thumbnail = episode?.thumbnailsUrl?.trim();
|
||||
final url = poster != null && poster.isNotEmpty
|
||||
? poster
|
||||
: (thumbnail != null && thumbnail.isNotEmpty ? thumbnail : null);
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.followingGlow,
|
||||
AppColors.followingGlowMid,
|
||||
Color(0xFF101012),
|
||||
],
|
||||
stops: [0, 0.22, 0.62],
|
||||
),
|
||||
),
|
||||
child: url == null
|
||||
? const SizedBox.shrink()
|
||||
: Image.network(
|
||||
url,
|
||||
key: const Key('player-pip-recover-cover-image'),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,14 @@ extension _PlayerViewActions on _PlayerPageState {
|
||||
onQualityChanged: (quality) => _switchQuality(context, l10n, quality),
|
||||
onClear: _hideChrome,
|
||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||
onPip: logic.pictureInPicture.isPlatformSupported
|
||||
// iOS 不显示手动「画中画」入口(走下方「退出后自动小窗」开关)。
|
||||
onPip: logic.pictureInPicture.isPlatformSupported && !logic.iosDevice
|
||||
? () => unawaited(logic.enterPip(context))
|
||||
: null,
|
||||
autoPipEnabled: logic.iosAutoPipEnabled,
|
||||
onAutoPipChanged: logic.iosDevice
|
||||
? (value) => unawaited(logic.setIosAutoPip(value))
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user