feat(player): 接入双端画中画播放
This commit is contained in:
1 parent
5346be40a9
commit
43e60e29ec
10 files changed
+1015
-29
No files matched your search
@@ -8,7 +8,9 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.stormg.lumadrama"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
// permission_handler_android 14 的 AAR 元数据要求依赖方 compileSdk >= 37,
|
||||
// 固定为 37(比 Flutter 默认的 36 高一档,只影响编译期 API 可见性)。
|
||||
compileSdk = 37
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission
|
||||
android:name="android.permission.INTERNET"/>
|
||||
<!-- 画中画自动触发依赖的「显示在其他应用上层」权限(部分 ROM 用它管控 PiP) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<application
|
||||
android:label="FruitShorts"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -284,6 +284,7 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FruitPip"
|
||||
private const val thumbnailMaxWidth = 240
|
||||
private const val thumbnailJpegQuality = 70
|
||||
private const val thumbnailTimeoutMs = 1500L
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
|
||||
private let thumbnailHTTPHeaderFieldsKey = "AVURLAssetHTTPHeaderFieldsKey"
|
||||
|
||||
@@ -35,6 +36,318 @@ final class RunnerFlutterViewController: FlutterViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS 影子播放器:独立 AVPlayer + AVPlayerLayer 专供画中画窗口。
|
||||
/// Flutter 的 video_player 走纹理渲染,没有 AVPlayerLayer,无法直接创建
|
||||
/// AVPictureInPictureController;小窗的画面与声音由影子接管,退出时把
|
||||
/// 影子进度回传给 Dart,由 Flutter 播放器 seek 对齐后继续。
|
||||
final class PipShadowCoordinator: NSObject, AVPictureInPictureControllerDelegate {
|
||||
static let shared = PipShadowCoordinator()
|
||||
|
||||
private var player: AVPlayer?
|
||||
private var playerLayer: AVPlayerLayer?
|
||||
private var controller: AVPictureInPictureController?
|
||||
private var endObserver: NSObjectProtocol?
|
||||
private var possibleObservation: NSKeyValueObservation?
|
||||
/// 画中画还原过渡层:原生窗口级红晕渐变,在系统还原动画开始时立即盖上,
|
||||
/// 避免回前台瞬间 Flutter 表面未恢复显示黑帧;视频出帧后由 Dart 通知淡出。
|
||||
private var restoreOverlay: UIView?
|
||||
/// 系统要求 playerLayer 位于真实的 view 层级中才会判定画中画可用,
|
||||
/// 因此用 1x1 透明宿主 view 承载,而不是直接挂在 window.layer 上。
|
||||
private var hostView: UIView?
|
||||
/// 影子图层就绪稍晚于创建,置位后由 KVO 自动拉起画中画。
|
||||
private var pendingStart = false
|
||||
private var startRetryWorkItems: [DispatchWorkItem] = []
|
||||
|
||||
weak var channel: FlutterMethodChannel?
|
||||
|
||||
/// active 变化回调;停止时把影子最终进度(毫秒)一并提供。
|
||||
var onPipChanged: ((Bool, Int64) -> Void)?
|
||||
/// 影子在画中画里播完当前集。
|
||||
var onPlaybackEnded: (() -> Void)?
|
||||
|
||||
static var isSupported: Bool {
|
||||
AVPictureInPictureController.isPictureInPictureSupported()
|
||||
}
|
||||
|
||||
private var keyWindow: UIWindow? {
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap { $0.windows }
|
||||
.first { $0.isKeyWindow }
|
||||
}
|
||||
|
||||
/// 首次创建影子:1x1 图层挂在 keyWindow 的宿主 view 里(肉眼不可见),静音待命。
|
||||
/// [autoEnter] 为 true 时退后台由系统自动拉起画中画(需影子在播,见 armAutoEnter)。
|
||||
func prepare(urlString: String, positionMs: Int64, autoEnter: Bool) {
|
||||
teardown()
|
||||
log("prepare begin supported=\(PipShadowCoordinator.isSupported) autoEnter=\(autoEnter)")
|
||||
guard Self.isSupported else { return }
|
||||
guard let url = URL(string: urlString) else {
|
||||
log("prepare failed bad url \(urlString)")
|
||||
return
|
||||
}
|
||||
guard let window = keyWindow else {
|
||||
log("prepare failed no key window")
|
||||
return
|
||||
}
|
||||
let item = AVPlayerItem(url: url)
|
||||
let p = AVPlayer(playerItem: item)
|
||||
p.isMuted = true
|
||||
p.actionAtItemEnd = .pause
|
||||
let layer = AVPlayerLayer()
|
||||
layer.player = p
|
||||
let host = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
|
||||
host.isUserInteractionEnabled = false
|
||||
host.backgroundColor = .clear
|
||||
window.addSubview(host)
|
||||
layer.frame = host.bounds
|
||||
host.layer.addSublayer(layer)
|
||||
hostView = host
|
||||
guard let c = AVPictureInPictureController(playerLayer: layer) else {
|
||||
p.pause()
|
||||
log("prepare failed no pip controller")
|
||||
return
|
||||
}
|
||||
c.canStartPictureInPictureAutomaticallyFromInline = autoEnter
|
||||
c.delegate = self
|
||||
player = p
|
||||
playerLayer = layer
|
||||
controller = c
|
||||
// 静音起播让 item 完成首帧解码,isPictureInPicturePossible 才会变 true。
|
||||
p.play()
|
||||
possibleObservation = c.observe(
|
||||
\.isPictureInPicturePossible,
|
||||
options: [.new, .initial]
|
||||
) { [weak self] controller, change in
|
||||
let value = change.newValue ?? controller.isPictureInPicturePossible
|
||||
self?.log("possible changed to \(value)")
|
||||
guard let self, self.pendingStart, controller.isPictureInPicturePossible else { return }
|
||||
self.pendingStart = false
|
||||
// 刚翻 true 的同一 runloop 里调用会被系统静默拒绝,推到下一拍再进。
|
||||
DispatchQueue.main.async {
|
||||
guard self.controller === controller, controller.isPictureInPicturePossible else {
|
||||
self.log("start via kvo aborted (shadow replaced)")
|
||||
return
|
||||
}
|
||||
self.log("start via kvo")
|
||||
controller.startPictureInPicture()
|
||||
}
|
||||
}
|
||||
log("shadow ready, seeking to \(positionMs)ms")
|
||||
if positionMs > 0 {
|
||||
p.seek(
|
||||
to: CMTime(value: positionMs, timescale: 1000),
|
||||
toleranceBefore: .zero,
|
||||
toleranceAfter: .zero
|
||||
)
|
||||
}
|
||||
registerEndObserver(for: item)
|
||||
activateAudioSession()
|
||||
}
|
||||
|
||||
/// 退出 App 前(inactive)把影子对齐到当前进度并静音起播:
|
||||
/// auto PiP 只在「player 正在播放 + 图层离屏」时由系统触发。
|
||||
func armAutoEnter(positionMs: Int64) {
|
||||
guard let player, let controller else {
|
||||
log("arm failed, shadow missing")
|
||||
return
|
||||
}
|
||||
guard controller.canStartPictureInPictureAutomaticallyFromInline else {
|
||||
log("arm failed, autoEnter disabled on shadow")
|
||||
return
|
||||
}
|
||||
log("arm auto enter at \(positionMs)ms")
|
||||
if positionMs > 0 {
|
||||
player.seek(
|
||||
to: CMTime(value: positionMs, timescale: 1000),
|
||||
toleranceBefore: CMTime(value: 800, timescale: 1000),
|
||||
toleranceAfter: CMTime(value: 800, timescale: 1000)
|
||||
)
|
||||
}
|
||||
player.play()
|
||||
}
|
||||
|
||||
/// 回前台后取消待命:影子暂停回静音,等待下一次退后台。
|
||||
func disarmAutoEnter() {
|
||||
guard let player else { return }
|
||||
log("disarm auto enter")
|
||||
player.pause()
|
||||
player.isMuted = true
|
||||
}
|
||||
|
||||
/// 进入画中画:图层就绪则立即进入,否则 KVO/延时重试在就绪后自动进入。
|
||||
/// 仅当影子从未创建成功(prepare 失败)时返回 false。
|
||||
func start() -> Bool {
|
||||
guard let controller else {
|
||||
log("start failed, shadow not created")
|
||||
return false
|
||||
}
|
||||
log("start requested possible=\(controller.isPictureInPicturePossible)")
|
||||
pendingStart = true
|
||||
if controller.isPictureInPicturePossible {
|
||||
pendingStart = false
|
||||
controller.startPictureInPicture()
|
||||
}
|
||||
scheduleStartRetries()
|
||||
return true
|
||||
}
|
||||
|
||||
/// KVO 偶发不触发或流加载较慢时兜底:就绪后再试两次。
|
||||
private func scheduleStartRetries() {
|
||||
startRetryWorkItems.forEach { $0.cancel() }
|
||||
startRetryWorkItems.removeAll()
|
||||
for delay in [1.0, 2.5] {
|
||||
let item = DispatchWorkItem { [weak self] in
|
||||
guard
|
||||
let self,
|
||||
self.pendingStart,
|
||||
let controller = self.controller,
|
||||
controller.isPictureInPicturePossible
|
||||
else { return }
|
||||
self.log("start via retry after \(delay)s")
|
||||
self.pendingStart = false
|
||||
controller.startPictureInPicture()
|
||||
}
|
||||
startRetryWorkItems.append(item)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item)
|
||||
}
|
||||
}
|
||||
|
||||
/// 画中画内换集:只换 AVPlayerItem,不动 player/layer,窗口不中断。
|
||||
func replaceItem(urlString: String, positionMs: Int64) {
|
||||
guard let player, let url = URL(string: urlString) else { return }
|
||||
let item = AVPlayerItem(url: url)
|
||||
player.replaceCurrentItem(with: item)
|
||||
registerEndObserver(for: item)
|
||||
if positionMs > 0 {
|
||||
player.seek(
|
||||
to: CMTime(value: positionMs, timescale: 1000),
|
||||
toleranceBefore: .zero,
|
||||
toleranceAfter: .zero
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 进入画中画后解除静音并起播(此时 Flutter 播放器已被暂停)。
|
||||
func unmuteAndPlay() {
|
||||
player?.isMuted = false
|
||||
player?.play()
|
||||
}
|
||||
|
||||
func positionMs() -> Int64 {
|
||||
Int64(max(0, player?.currentTime().seconds ?? 0) * 1000)
|
||||
}
|
||||
|
||||
func stopPip() {
|
||||
controller?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
func teardown() {
|
||||
startRetryWorkItems.forEach { $0.cancel() }
|
||||
startRetryWorkItems.removeAll()
|
||||
possibleObservation?.invalidate()
|
||||
possibleObservation = nil
|
||||
pendingStart = false
|
||||
if let endObserver {
|
||||
NotificationCenter.default.removeObserver(endObserver)
|
||||
}
|
||||
endObserver = nil
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
playerLayer?.removeFromSuperlayer()
|
||||
playerLayer = nil
|
||||
hostView?.removeFromSuperview()
|
||||
hostView = nil
|
||||
controller?.delegate = nil
|
||||
controller = nil
|
||||
player = nil
|
||||
}
|
||||
|
||||
/// 诊断日志:NSLog 之外同步转发到 Dart,便于在 flutter run 控制台观察。
|
||||
private func log(_ message: String) {
|
||||
NSLog("pip: %@", message)
|
||||
channel?.invokeMethod("pipDebug", arguments: message)
|
||||
}
|
||||
|
||||
private func registerEndObserver(for item: AVPlayerItem) {
|
||||
if let endObserver {
|
||||
NotificationCenter.default.removeObserver(endObserver)
|
||||
}
|
||||
endObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.log("item did play to end")
|
||||
self?.onPlaybackEnded?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 画中画的声音走影子播放器,需要 playback 类别的音频会话保活。
|
||||
private func activateAudioSession() {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
// 会话激活失败只影响后台出声,不阻断画中画。
|
||||
}
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStartPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
log("will start")
|
||||
pendingStart = false
|
||||
onPipChanged?(true, 0)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
log("will stop, showing restore overlay")
|
||||
showRestoreOverlay()
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
log("did stop")
|
||||
showRestoreOverlay()
|
||||
onPipChanged?(false, positionMs())
|
||||
}
|
||||
|
||||
/// 盖原生过渡层(幂等):主题红晕渐变,不依赖 Flutter 渲染。
|
||||
func showRestoreOverlay() {
|
||||
guard let window = keyWindow else { return }
|
||||
restoreOverlay?.removeFromSuperview()
|
||||
let overlay = UIView(frame: window.bounds)
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
let gradient = CAGradientLayer()
|
||||
gradient.frame = overlay.bounds
|
||||
gradient.colors = [
|
||||
UIColor(red: 0.298, green: 0.086, blue: 0.102, alpha: 1).cgColor,
|
||||
UIColor(red: 0.118, green: 0.043, blue: 0.047, alpha: 1).cgColor,
|
||||
UIColor(red: 0.063, green: 0.063, blue: 0.071, alpha: 1).cgColor,
|
||||
]
|
||||
gradient.locations = [0, 0.22, 0.62]
|
||||
gradient.startPoint = CGPoint(x: 0.5, y: 0)
|
||||
gradient.endPoint = CGPoint(x: 0.5, y: 1)
|
||||
overlay.layer.addSublayer(gradient)
|
||||
window.addSubview(overlay)
|
||||
restoreOverlay = overlay
|
||||
}
|
||||
|
||||
/// 淡出并移除过渡层(视频首帧已确认后由 Dart 调用)。
|
||||
func hideRestoreOverlay() {
|
||||
guard let overlay = restoreOverlay else { return }
|
||||
restoreOverlay = nil
|
||||
UIView.animate(
|
||||
withDuration: 0.25,
|
||||
animations: { overlay.alpha = 0 }
|
||||
) { _ in overlay.removeFromSuperview() }
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
private let wirelessDataPrompt = WirelessDataPrompt()
|
||||
@@ -64,6 +377,7 @@ final class RunnerFlutterViewController: FlutterViewController {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
registerWirelessDataChannel(engineBridge)
|
||||
registerDeviceIdChannel(engineBridge)
|
||||
registerPipChannel(engineBridge)
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "com.stormg.lumadrama/share",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
@@ -145,6 +459,91 @@ final class RunnerFlutterViewController: FlutterViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS 画中画:影子播放器通道,与 Android 共用同一 channel 名。
|
||||
private func registerPipChannel(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "com.stormg.lumadrama/pip",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
)
|
||||
let pip = PipShadowCoordinator.shared
|
||||
pip.channel = channel
|
||||
channel.setMethodCallHandler { call, result in
|
||||
switch call.method {
|
||||
case "isAvailable":
|
||||
result(PipShadowCoordinator.isSupported)
|
||||
case "enter":
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let url = args["url"] as? String else {
|
||||
result(false)
|
||||
return
|
||||
}
|
||||
let positionMs = (args["positionMs"] as? NSNumber)?.int64Value ?? 0
|
||||
pip.prepare(urlString: url, positionMs: positionMs, autoEnter: false)
|
||||
// 等一拍让 AVPlayerLayer 完成挂载再请求进入画中画。
|
||||
DispatchQueue.main.async {
|
||||
result(pip.start())
|
||||
}
|
||||
case "prepareShadow":
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let url = args["url"] as? String else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
pip.prepare(
|
||||
urlString: url,
|
||||
positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0,
|
||||
autoEnter: (args["autoEnter"] as? NSNumber)?.boolValue ?? false
|
||||
)
|
||||
result(nil)
|
||||
case "armAutoEnter":
|
||||
pip.armAutoEnter(
|
||||
positionMs: (call.arguments as? [String: Any])?["positionMs"] as? Int64 ?? 0
|
||||
)
|
||||
result(nil)
|
||||
case "showRestoreOverlay":
|
||||
pip.showRestoreOverlay()
|
||||
result(nil)
|
||||
case "hideRestoreOverlay":
|
||||
pip.hideRestoreOverlay()
|
||||
result(nil)
|
||||
case "disarmAutoEnter":
|
||||
pip.disarmAutoEnter()
|
||||
result(nil)
|
||||
case "unmute":
|
||||
pip.unmuteAndPlay()
|
||||
result(nil)
|
||||
case "replaceItem":
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let url = args["url"] as? String else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
pip.replaceItem(
|
||||
urlString: url,
|
||||
positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0
|
||||
)
|
||||
result(nil)
|
||||
case "stop":
|
||||
pip.stopPip()
|
||||
result(nil)
|
||||
case "teardown":
|
||||
pip.teardown()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
pip.onPipChanged = { active, positionMs in
|
||||
channel.invokeMethod(
|
||||
"onPipChanged",
|
||||
arguments: ["active": active, "positionMs": positionMs]
|
||||
)
|
||||
}
|
||||
pip.onPlaybackEnded = {
|
||||
channel.invokeMethod("onPipPlaybackEnded", arguments: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。
|
||||
/// 同通道顺带提供设备区域(ISO-2),注册时作为服务端 IP 解析失败后的兜底。
|
||||
private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
|
||||
@@ -87,6 +87,10 @@
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -48,6 +48,10 @@ dependencies:
|
||||
sdk: flutter
|
||||
path_provider: 2.1.5
|
||||
html: ^0.15.6
|
||||
# 画中画:Android 原生 PiP,OnLeavePiP 支持退后台自动进小窗
|
||||
floating: ^6.0.0
|
||||
# 运行时权限:systemAlertWindow(「显示在其他应用上层」)
|
||||
permission_handler: ^13.0.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -86,6 +90,7 @@ flutter:
|
||||
- assets/icon/splash_logo.png
|
||||
- assets/icon/splash_logo_android12.png
|
||||
- assets/icon/rail/
|
||||
- assets/auth/
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
|
||||
Reference in new issue
Block a user