import Flutter import UIKit import QGVAPlayer import Darwin.Mach public class VapFlutterView: NSObject, FlutterPlatformView { private let _view: UIView private let channel: FlutterMethodChannel private var vapView: QGVAPWrapView? private var repeatCount: Int = 0 private var playResult: FlutterResult? private var vapTagContents: [String: [String: Any]] = [:] init( context: CGRect, params: [String: Any]?, messenger: FlutterBinaryMessenger, id: Int64 ) { _view = UIView(frame: context) channel = FlutterMethodChannel(name: "vap_view_\(id)", binaryMessenger: messenger) super.init() // Initialize VAP view with proper configuration vapView = QGVAPWrapView(frame: context) vapView?.center = _view.center // Set scaleType from params (matching Kotlin logic) if let scaleType = params?["scaleType"] as? String { switch scaleType { case "fitCenter": vapView?.contentMode = .aspectFit break case "centerCrop": vapView?.contentMode = .aspectFill break case "fitXY": vapView?.contentMode = .scaleToFill break default: vapView?.contentMode = .aspectFit break } } else { // Default scale type (matching Kotlin's default) vapView?.contentMode = .aspectFit } // Configure background color to prevent visual glitches vapView?.backgroundColor = UIColor.clear _view.backgroundColor = UIColor.clear channel.setMethodCallHandler(onMethodCall) // Add vapView to container if let vapView = vapView { vapView.translatesAutoresizingMaskIntoConstraints = false _view.addSubview(vapView) // Set up auto layout constraints NSLayoutConstraint.activate([ vapView.topAnchor.constraint(equalTo: _view.topAnchor), vapView.leadingAnchor.constraint(equalTo: _view.leadingAnchor), vapView.trailingAnchor.constraint(equalTo: _view.trailingAnchor), vapView.bottomAnchor.constraint(equalTo: _view.bottomAnchor) ]) } // Initial playback if filePath or assetName is provided (matching Kotlin) if let filePath = params?["filePath"] as? String { playFile(filePath) } if let assetName = params?["assetName"] as? String { playAsset(assetName) } if let loop = params?["loop"] as? Int { setLoop(loop) } if let mute = params?["mute"] as? Bool { setMute(mute) } // Store tag contents as Maps if let tagContents = params?["vapTagContents"] as? [String: [String: Any]] { for (tag, content) in tagContents { vapTagContents[tag] = content NSLog("Stored tag content for tag: \(tag), contentType: \(content["contentType"] ?? "unknown")") } } } public func view() -> UIView { return _view } private func reset() { // Ensure cleanup happens on main thread DispatchQueue.main.async { [weak self] in guard let self = self else { return } clearVapTagContents() // Stop VAP playback and clean up resources self.vapView?.stopHWDMP4() // Give a moment for VideoToolbox to clean up DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { // Remove all subviews self._view.subviews.forEach { $0.removeFromSuperview() } // Clear the VAP view reference self.vapView = nil // Force garbage collection hint if #available(iOS 13.0, *) { // Modern iOS versions handle this automatically } else { // For older iOS versions, hint at memory cleanup DispatchQueue.global(qos: .background).async { // Trigger background cleanup } } } } } private func dispose() { reset() channel.setMethodCallHandler(nil) } private func onMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "dispose": dispose() result(nil) case "playFile": if let args = call.arguments as? [String: Any], let filePath = args["filePath"] as? String { playFile(filePath, result) } case "playAsset": if let args = call.arguments as? [String: Any], let assetName = args["assetName"] as? String { playAsset(assetName, result) } case "stop": stop() result(nil) case "setLoop": if let args = call.arguments as? [String: Any], let loop = args["loop"] as? Int { setLoop(loop) } else { setLoop(0) } result(nil) case "setMute": if let args = call.arguments as? [String: Any], let mute = args["mute"] as? Bool { setMute(mute) } else { setMute(false) } result(nil) case "setScaleType": if let args = call.arguments as? [String: Any], let scaleType = args["scaleType"] as? String { setScaleType(scaleType) } result(nil) case "setVapTagContent": if let args = call.arguments as? [String: Any], let tag = args["tag"] as? String, let contentMap = args["content"] as? [String: Any] { setVapTagContent(tag: tag, contentMap: contentMap) } result(nil) case "setVapTagContents": if let args = call.arguments as? [String: Any], let contents = args["contents"] as? [String: [String: Any]] { setVapTagContents(contents) } result(nil) case "getVapTagContent": if let args = call.arguments as? [String: Any], let tag = args["tag"] as? String { result(getVapTagContent(tag: tag)) } else { result(nil) } case "getAllVapTagContents": result(vapTagContents) case "clearVapTagContents": clearVapTagContents() result(nil) default: result(FlutterMethodNotImplemented) } } private func playFile(_ filePath: String, _ result: FlutterResult? = nil) { // Ensure we're on the main thread for UI operations self.playResult = result DispatchQueue.main.async { [weak self] in guard let self = self, let vapView = self.vapView else { return } // Stop any existing playback first vapView.stopHWDMP4() // Configure VAP player with better error handling do { guard FileManager.default.fileExists(atPath: filePath) else { self.sendFailedEvent(errorCode: -1, errorType: "FILE_NOT_FOUND", errorMsg: "VAP file not found: \(filePath)") return } // Check file size (VAP files shouldn't be too large for memory) let fileAttributes = try FileManager.default.attributesOfItem(atPath: filePath) if let fileSize = fileAttributes[.size] as? NSNumber { let fileSizeInMB = fileSize.doubleValue / (1024 * 1024) // Reject extremely large files that will definitely cause issues if fileSizeInMB > 100 { self.sendFailedEvent(errorCode: -1006, errorType: "FILE_TOO_LARGE", errorMsg: "VAP file too large (\(fileSizeInMB) MB), maximum size is 100MB") playError(-1006, "FILE_TOO_LARGE", "VAP file too large (\(fileSizeInMB) MB), maximum size is 100MB") return } } // Start playback with delegate vapView.playHWDMP4(filePath, repeatCount: self.repeatCount, delegate: self) } catch { self.sendFailedEvent(errorCode: -2, errorType: "FILE_PLAYBACK_ERROR", errorMsg: "Failed to play VAP file: \(error.localizedDescription)") } } } private func playAsset(_ assetName: String, _ result: FlutterResult? = nil) { let key = FlutterDartProject.lookupKey(forAsset: assetName) if let bundlePath = Bundle.main.path(forResource: key, ofType: nil) { playFile(bundlePath) } else { sendFailedEvent(errorCode: -1, errorType: "FILE_NOT_FOUND", errorMsg: "Asset not found: \(assetName)") } } private func stop() { vapView?.stopHWDMP4() DispatchQueue.main.async { [weak self] in self?.channel.invokeMethod("onVideoDestroy", arguments: nil) } } private func setLoop(_ loop: Int) { repeatCount = loop } private func setMute(_ mute: Bool) { vapView?.setMute(mute) } private func setScaleType(_ scaleType: String) { switch scaleType { case "fitCenter": vapView?.contentMode = .aspectFit break case "centerCrop": vapView?.contentMode = .aspectFill break case "fitXY": vapView?.contentMode = .scaleToFill break default: vapView?.contentMode = .aspectFit break } } // MARK: - VAP Tag Content Management private func setVapTagContent(tag: String, contentMap: [String: Any]) { vapTagContents[tag] = contentMap } private func setVapTagContents(_ contents: [String: [String: Any]]) { vapTagContents.merge(contents) { (_, new) in new } } private func getVapTagContent(tag: String) -> String? { guard let contentMap = vapTagContents[tag], let contentValue = contentMap["contentValue"] as? String else { return nil } return contentValue } private func clearVapTagContents() { vapTagContents.removeAll() } // MARK: - Event Handling private func sendFailedEvent(errorCode: Int, errorType: String, errorMsg: String?) { let args: [String: Any?] = [ "errorCode": errorCode, "errorType": errorType, "errorMsg": errorMsg ] DispatchQueue.main.async { [weak self] in self?.channel.invokeMethod("onFailed", arguments: args) } } private func sendVideoCompleteEvent() { DispatchQueue.main.async { [weak self] in self?.channel.invokeMethod("onVideoComplete", arguments: nil) } } private func sendVideoRenderEvent(frameIndex: Int, config: [String: Any]?) { let args: [String: Any?] = [ "frameIndex": frameIndex, "config": config ] DispatchQueue.main.async { [weak self] in self?.channel.invokeMethod("onVideoRender", arguments: args) } } private func sendVideoConfigReadyEvent(config: [String: Any]) { let args = ["config": config] DispatchQueue.main.async { [weak self] in self?.channel.invokeMethod("onVideoConfigReady", arguments: args) } } // MARK: - Objective-C 兼容性桥接方法 @objc public func vapWrap_viewDidFinishPlayMP4(_ totalFrameCount: Int, view: UIView) { // 注意:参数 `view` 是普通的 UIView,不是 QGVAPWrapView // 我们直接调用已有方法,并使用 self.vapView 作为参数 if let vapView = self.vapView { self.viewDidFinishPlayMP4(totalFrameCount, view: vapView) } } @objc public func vapWrap_viewDidStartPlayMP4(_ totalFrameCount: Int, view: UIView) { // 你原来的 viewDidStartPlayMP4 方法只接受一个 QGVAPWrapView 参数 // 忽略 totalFrameCount 参数,只传递 vapView if let vapView = self.vapView { self.viewDidStartPlayMP4(vapView) } } @objc public func vapWrap_viewDidStopPlayMP4(_ frameIndex: Int, view: UIView) { if let vapView = self.vapView { self.viewDidStopPlayMP4(frameIndex, view: vapView) } } @objc public func vapWrap_onVAPStopWithError(_ error: NSError?, view: UIView) { // viewDidFailPlayMP4 方法只需要 error 参数 self.viewDidFailPlayMP4(error ?? NSError(domain: "VAP", code: -1, userInfo: nil)) } } // MARK: - HWDMP4PlayDelegate extension VapFlutterView: VAPWrapViewDelegate { func shouldStartPlayMP4(_ container: QGVAPWrapView, config: QGVAPConfigModel) -> Bool { var animConfigMap: [String: Any] = [:] animConfigMap["width"] = config.info.size.width animConfigMap["height"] = config.info.size.height animConfigMap["fps"] = config.info.fps animConfigMap["totalFrames"] = config.info.framesCount animConfigMap["videoHeight"] = config.info.videoSize.height animConfigMap["videoWidth"] = config.info.videoSize.width animConfigMap["isMix"] = config.info.isMerged animConfigMap["orien"] = config.info.targetOrientaion.rawValue animConfigMap["alphaPointRect"] = [ "x": config.info.alphaAreaRect.minX, "y": config.info.alphaAreaRect.minY, "w": config.info.alphaAreaRect.maxX, "h": config.info.alphaAreaRect.maxY ] animConfigMap["rgbPointRect"] = [ "x": config.info.rgbAreaRect.minX, "y": config.info.rgbAreaRect.minY, "w": config.info.rgbAreaRect.maxX, "h": config.info.rgbAreaRect.maxY ] animConfigMap["version"] = config.info.version sendVideoConfigReadyEvent(config: animConfigMap) return true } func viewDidStartPlayMP4(_ container: QGVAPWrapView) { DispatchQueue.main.async { [weak self] in self?.channel.invokeMethod("onVideoStart", arguments: nil) } } func viewDidPlayMP4AtFrame(_ frame: QGMP4AnimatedImageFrame, view container: QGVAPWrapView) { let args: [String: Any] = ["frameIndex": frame.index] DispatchQueue.main.async { [weak self] in self?.channel.invokeMethod("onVideoRender", arguments: args) } } func viewDidStopPlayMP4(_ lastFrameIndex: Int, view container: QGVAPWrapView) { playSuccess() DispatchQueue.main.async { [weak self] in self?.channel.invokeMethod("onVideoDestroy", arguments: nil) } } func viewDidFinishPlayMP4(_ totalFrameCount: Int, view container: QGVAPWrapView) { playSuccess() sendVideoCompleteEvent() } func viewDidFailPlayMP4(_ error: NSError) { // Handle specific VideoToolbox errors var errorMsg = error.localizedDescription var errorCode = error.code // Check for common VideoToolbox errors if error.domain == "com.apple.videotoolbox" || error.domain.contains("VT") { switch error.code { case -12909: // kVTVideoDecoderBadDataErr errorMsg = "Invalid or corrupted video data. Please check your VAP file encoding." errorCode = -1001 case -12911: // kVTVideoDecoderMalfunctionErr errorMsg = "Video decoder malfunction. Try restarting the app." errorCode = -1002 case -12912: // kVTVideoDecoderNotAvailableNowErr errorMsg = "Video decoder not available. Device may be under memory pressure." errorCode = -1003 case -12913: // kVTInvalidSessionErr errorMsg = "Invalid video session. Please try playing the file again." errorCode = -1004 default: errorMsg = "VideoToolbox error: \(error.localizedDescription)" errorCode = -1000 } } playError(errorCode, "VIDEO_PLAYBACK_ERROR", errorMsg) sendFailedEvent(errorCode: errorCode, errorType:"VIDEO_PLAYBACK_ERROR", errorMsg: errorMsg) } func playError(_ code:Int, _ errorType:String, _ errorMsg:String){ if(playResult != nil){ playResult?(FlutterError(code: String(code),message: errorMsg,details: ["errorType": errorType])) playResult = nil } } func playSuccess(){ if(playResult != nil){ playResult?(nil) playResult = nil } } // MARK: - Resource Management Delegate Methods public func vapWrapview_content(forVapTag tag: String, resource info: QGVAPSourceInfo) -> String { guard let contentMap = vapTagContents[tag], let contentValue = contentMap["contentValue"] as? String else { return tag } // If the resource type is text, return the content value if info.type == .text || info.type == .textStr { return contentValue } // For other resource types (images, etc.), return the tag return tag } public func vapWrapView_loadVapImage(withURL urlStr: String, context: [AnyHashable : Any], completion completionBlock: @escaping VAPImageCompletionBlock) { print("URLNYA : " + urlStr + " INI VAPIMAGECONTENT : " + String(describing: vapTagContents) + "\n") // First check if we have content for this tag in vapTagContents if let contentMap = vapTagContents[urlStr], let contentValue = contentMap["contentValue"] as? String, let contentType = contentMap["contentType"] as? String { handleVapTagContent(content: contentValue, contentType: contentType, tag: urlStr, completion: completionBlock) return } // Fallback to original URL loading if no tag content is found guard let url = URL(string: urlStr) else { completionBlock(nil, NSError(domain: "VAPImageLoader", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL and no tag content found"]), urlStr) return } // Simple URLSession implementation - replace with your preferred image loading library URLSession.shared.dataTask(with: url) { data, response, error in DispatchQueue.main.async { if let error = error { completionBlock(nil, error as NSError, urlStr) return } guard let data = data, let image = UIImage(data: data) else { completionBlock(nil, NSError(domain: "VAPImageLoader", code: -2, userInfo: [NSLocalizedDescriptionKey: "Failed to create image from data"]), urlStr) return } completionBlock(image, nil, urlStr) } }.resume() } func loadVapImageWithURL(_ urlStr: String, context: [String: Any], completion: @escaping VAPImageCompletionBlock) { // First check if we have content for this tag in vapTagContents if let contentMap = vapTagContents[urlStr], let contentValue = contentMap["contentValue"] as? String, let contentType = contentMap["contentType"] as? String { handleVapTagContent(content: contentValue, contentType: contentType, tag: urlStr, completion: completion) return } // Fallback to original URL loading if no tag content is found guard let url = URL(string: urlStr) else { completion(nil, NSError(domain: "VAPImageLoader", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL and no tag content found"]), urlStr) return } // Simple URLSession implementation - replace with your preferred image loading library URLSession.shared.dataTask(with: url) { data, response, error in DispatchQueue.main.async { if let error = error { completion(nil, error as NSError, urlStr) return } guard let data = data, let image = UIImage(data: data) else { completion(nil, NSError(domain: "VAPImageLoader", code: -2, userInfo: [NSLocalizedDescriptionKey: "Failed to create image from data"]), urlStr) return } completion(image, nil, urlStr) } }.resume() } private func handleVapTagContent(content: String, contentType: String, tag: String, completion: @escaping VAPImageCompletionBlock) { NSLog("Processing tag: \(tag), contentType: \(contentType)") switch contentType { case "text": handleTextContent(content: content, tag: tag, completion: completion) case "image_base64": handleImageBase64Content(content: content, tag: tag, completion: completion) case "image_file": handleImageFileContent(content: content, tag: tag, completion: completion) case "image_asset": handleImageAssetContent(content: content, tag: tag, completion: completion) case "image_url": handleImageUrlContent(content: content, tag: tag, completion: completion) default: NSLog("Unsupported content type: \(contentType) for tag: \(tag)") completion(nil, NSError(domain: "VAPImageLoader", code: -4, userInfo: [NSLocalizedDescriptionKey: "Unsupported content type: \(contentType)"]), tag) } } // MARK: - Content Type Handlers private func handleTextContent(content: String, tag: String, completion: @escaping VAPImageCompletionBlock) { // Text content is not handled by image loading, skip NSLog("Text content type for tag: \(tag), skipping image loading") completion(nil, nil, tag) } private func handleImageBase64Content(content: String, tag: String, completion: @escaping VAPImageCompletionBlock) { DispatchQueue.global(qos: .userInitiated).async { var base64String = content // Remove data URL prefix if present (e.g., "data:image/png;base64,") if content.hasPrefix("data:image/") { if let commaRange = content.range(of: ",") { base64String = String(content[commaRange.upperBound...]) } } else if content.hasPrefix("base64:") { base64String = String(content.dropFirst(7)) // Remove "base64:" prefix } guard let imageData = Data(base64Encoded: base64String), let image = UIImage(data: imageData) else { DispatchQueue.main.async { completion(nil, NSError(domain: "VAPImageLoader", code: -3, userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 image for tag: \(tag)"]), tag) } return } DispatchQueue.main.async { NSLog("Successfully decoded base64 image for tag: \(tag)") completion(image, nil, tag) } } } private func handleImageFileContent(content: String, tag: String, completion: @escaping VAPImageCompletionBlock) { DispatchQueue.global(qos: .userInitiated).async { var fullPath = content // Handle different file path types if content.hasPrefix("/") { // Absolute path fullPath = content } else if content.hasPrefix("file://") { // File URL fullPath = String(content.dropFirst(7)) } else { // Relative path - check in Documents directory let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] let documentFilePath = documentsPath + "/" + content if FileManager.default.fileExists(atPath: documentFilePath) { fullPath = documentFilePath } else { // Use as-is and let it fail if invalid fullPath = content } } guard FileManager.default.fileExists(atPath: fullPath) else { DispatchQueue.main.async { completion(nil, NSError(domain: "VAPImageLoader", code: -4, userInfo: [NSLocalizedDescriptionKey: "File not found: \(fullPath) for tag: \(tag)"]), tag) } return } guard let image = UIImage(contentsOfFile: fullPath) else { DispatchQueue.main.async { completion(nil, NSError(domain: "VAPImageLoader", code: -5, userInfo: [NSLocalizedDescriptionKey: "Failed to load image from file: \(fullPath) for tag: \(tag)"]), tag) } return } DispatchQueue.main.async { NSLog("Successfully loaded file image for tag: \(tag) from: \(fullPath)") completion(image, nil, tag) } } } private func handleImageAssetContent(content: String, tag: String, completion: @escaping VAPImageCompletionBlock) { DispatchQueue.global(qos: .userInitiated).async { // Try as Flutter asset first let assetKey = FlutterDartProject.lookupKey(forAsset: content) var fullPath: String? if let assetPath = Bundle.main.path(forResource: assetKey, ofType: nil) { fullPath = assetPath } else if let bundlePath = Bundle.main.path(forResource: content, ofType: nil) { // Try as direct bundle resource fullPath = bundlePath } guard let validPath = fullPath, FileManager.default.fileExists(atPath: validPath) else { DispatchQueue.main.async { completion(nil, NSError(domain: "VAPImageLoader", code: -4, userInfo: [NSLocalizedDescriptionKey: "Asset not found: \(content) for tag: \(tag)"]), tag) } return } guard let image = UIImage(contentsOfFile: validPath) else { DispatchQueue.main.async { completion(nil, NSError(domain: "VAPImageLoader", code: -5, userInfo: [NSLocalizedDescriptionKey: "Failed to load asset image: \(content) for tag: \(tag)"]), tag) } return } DispatchQueue.main.async { NSLog("Successfully loaded asset image for tag: \(tag) from: \(validPath)") completion(image, nil, tag) } } } private func handleImageUrlContent(content: String, tag: String, completion: @escaping VAPImageCompletionBlock) { guard let url = URL(string: content) else { completion(nil, NSError(domain: "VAPImageLoader", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL: \(content) for tag: \(tag)"]), tag) return } NSLog("Loading image from URL: \(content) for tag: \(tag)") URLSession.shared.dataTask(with: url) { data, response, error in DispatchQueue.main.async { if let error = error { NSLog("Failed to load image from URL: \(content) for tag: \(tag), error: \(error.localizedDescription)") completion(nil, error as NSError, tag) return } guard let data = data, let image = UIImage(data: data) else { completion(nil, NSError(domain: "VAPImageLoader", code: -2, userInfo: [NSLocalizedDescriptionKey: "Failed to create image from URL data: \(content) for tag: \(tag)"]), tag) return } NSLog("Successfully loaded image from URL: \(content) for tag: \(tag)") completion(image, nil, tag) } }.resume() } }