import Flutter import UIKit import AVFoundation import AVKit private let thumbnailHTTPHeaderFieldsKey = "AVURLAssetHTTPHeaderFieldsKey" private final class ThumbnailResponseGate { private let lock = NSLock() private var completed = false func send(_ result: @escaping FlutterResult, data: Data?) { lock.lock() if completed { lock.unlock() return } completed = true lock.unlock() DispatchQueue.main.async { result(data) } } } /// Keeps the system LaunchScreen as the only splash. Flutter would otherwise /// instantiate that storyboard again and the icon appears a second time. final class RunnerFlutterViewController: FlutterViewController { override func loadDefaultSplashScreenView() -> Bool { false } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black } } /// 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() private let thumbnailQueue = DispatchQueue( label: "com.stormg.lumadrama.thumbnail", qos: .userInitiated ) override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { UIApplication.shared.isIdleTimerDisabled = true return super.application(application, didFinishLaunchingWithOptions: launchOptions) } override func applicationDidBecomeActive(_ application: UIApplication) { super.applicationDidBecomeActive(application) requestWirelessDataPrompt() } func requestWirelessDataPrompt() { wirelessDataPrompt.requestSystemPrompt() } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) registerWirelessDataChannel(engineBridge) registerDeviceIdChannel(engineBridge) registerPipChannel(engineBridge) let channel = FlutterMethodChannel( name: "com.stormg.lumadrama/share", binaryMessenger: engineBridge.applicationRegistrar.messenger() ) channel.setMethodCallHandler { [weak self] call, result in guard call.method == "shareText", let arguments = call.arguments as? [String: Any], let text = arguments["text"] as? String, !text.isEmpty else { result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil)) return } DispatchQueue.main.async { guard let controller = self?.sharePresenter() else { result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil)) return } let activity = UIActivityViewController(activityItems: [text], applicationActivities: nil) if let popover = activity.popoverPresentationController { popover.sourceView = controller.view popover.sourceRect = CGRect( x: controller.view.bounds.midX, y: controller.view.bounds.midY, width: 0, height: 0 ) popover.permittedArrowDirections = [] } activity.completionWithItemsHandler = { _, _, _, _ in result(nil) } controller.present(activity, animated: true) } } let thumbnailChannel = FlutterMethodChannel( name: "com.stormg.lumadrama/thumbnail", binaryMessenger: engineBridge.applicationRegistrar.messenger() ) thumbnailChannel.setMethodCallHandler { [weak self] call, result in guard call.method == "frame" else { result(FlutterMethodNotImplemented) return } guard let self, let arguments = call.arguments as? [String: Any], let timeNumber = arguments["timeMs"] as? NSNumber else { result(nil) return } let localPath = arguments["localPath"] as? String ?? "" let remoteUrl = arguments["url"] as? String ?? "" let headers = arguments["headers"] as? [String: String] ?? [:] let assetUrl: URL? if !localPath.isEmpty { assetUrl = localPath.hasPrefix("file:") ? URL(string: localPath) : URL(fileURLWithPath: localPath) } else if !remoteUrl.isEmpty { assetUrl = URL(string: remoteUrl) } else { assetUrl = nil } guard let assetUrl else { result(nil) return } let options: [String: Any]? = headers.isEmpty ? nil : [thumbnailHTTPHeaderFieldsKey: headers] let asset = AVURLAsset(url: assetUrl, options: options) let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true generator.maximumSize = CGSize(width: 240, height: 426) generator.requestedTimeToleranceBefore = CMTime(seconds: 0.5, preferredTimescale: 600) generator.requestedTimeToleranceAfter = CMTime(seconds: 0.5, preferredTimescale: 600) let time = CMTime(value: timeNumber.int64Value, timescale: 1000) let gate = ThumbnailResponseGate() self.thumbnailQueue.async { do { let image = try generator.copyCGImage(at: time, actualTime: nil) let data = UIImage(cgImage: image).jpegData(compressionQuality: 0.7) gate.send(result, data: data) } catch { gate.send(result, data: nil) } } DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 1.5 ) { gate.send(result, data: nil) } } } /// 当前前台 Scene 的根控制器之上,最适合展示系统分享面板的控制器。 private func sharePresenter() -> UIViewController? { let keyWindow = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .first { $0.activationState == .foregroundActive }? .windows.first { $0.isKeyWindow } guard let rootController = keyWindow?.rootViewController else { return nil } let presenter = topPresentedController(from: rootController) guard presenter.viewIfLoaded?.window != nil else { return nil } return presenter } /// 解析已展示页面、导航栈和标签页中的当前可见控制器。 private func topPresentedController(from controller: UIViewController) -> UIViewController { if let presented = controller.presentedViewController { return topPresentedController(from: presented) } if let navigation = controller as? UINavigationController, let visible = navigation.visibleViewController { return topPresentedController(from: visible) } if let tabs = controller as? UITabBarController, let selected = tabs.selectedViewController { return topPresentedController(from: selected) } return controller } /// 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) { let channel = FlutterMethodChannel( name: "com.stormg.lumadrama/device", binaryMessenger: engineBridge.applicationRegistrar.messenger() ) channel.setMethodCallHandler { call, result in switch call.method { case "idfv": result(UIDevice.current.identifierForVendor?.uuidString) case "region": if #available(iOS 16.0, *) { result(Locale.current.region?.identifier) } else { result(Locale.current.regionCode) } default: result(FlutterMethodNotImplemented) } } } private func registerWirelessDataChannel(_ engineBridge: FlutterImplicitEngineBridge) { let channel = FlutterMethodChannel( name: WirelessDataPrompt.channelName, binaryMessenger: engineBridge.applicationRegistrar.messenger() ) channel.setMethodCallHandler { [weak self] call, result in guard call.method == WirelessDataPrompt.awaitDecisionMethod else { result(FlutterMethodNotImplemented) return } guard let self else { result(nil) return } self.wirelessDataPrompt.awaitDecision(result) } } }