Files
LoveLiveMusicPlayerKMP/iosApp/iosApp/HostBridge.swift
T

192 lines
7.5 KiB
Swift

import Network
import UIKit
/// Bridges Swift host events to Kotlin (via UserDefaults + notifications).
/// Full bidirectional callback requires exporting Kotlin handlers through the ComposeApp framework.
final class HostBridge {
static let shared = HostBridge()
private var localNetworkBrowser: NWBrowser?
private var localNetworkTimeout: DispatchWorkItem?
private var qrScanner: QrScannerViewController?
/// iOS 没有显式的本地网络权限 API;开始 Bonjour 浏览会在首次进入传输页时触发系统授权。
func requestLocalNetworkPermission() {
guard localNetworkBrowser == nil else { return }
let parameters = NWParameters()
parameters.includePeerToPeer = true
let browser = NWBrowser(
for: .bonjour(type: "_llmp._tcp", domain: nil),
using: parameters
)
localNetworkBrowser = browser
browser.stateUpdateHandler = { [weak self] state in
switch state {
case .ready, .failed:
self?.stopLocalNetworkPermissionRequest()
default:
break
}
}
browser.start(queue: .main)
let timeout = DispatchWorkItem { [weak self] in
self?.stopLocalNetworkPermissionRequest()
}
localNetworkTimeout = timeout
DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: timeout)
}
private func stopLocalNetworkPermissionRequest() {
localNetworkTimeout?.cancel()
localNetworkTimeout = nil
localNetworkBrowser?.stateUpdateHandler = nil
localNetworkBrowser?.cancel()
localNetworkBrowser = nil
}
func handleDeepLink(_ url: String) {
UserDefaults(suiteName: widgetGroupId)?.set(url, forKey: "pendingDeepLink")
NotificationCenter.default.post(
name: Notification.Name("llmpDeepLink"),
object: nil,
userInfo: ["url": url]
)
}
func handleWidgetAction(from userInfo: [AnyHashable: Any]?) {
guard let url = userInfo?["url"] as? String else { return }
if url.contains("toggle_play") {
NotificationCenter.default.post(name: Notification.Name("llmpHostTogglePlay"), object: nil)
} else if url.contains("previous") {
NotificationCenter.default.post(name: Notification.Name("llmpHostPrevious"), object: nil)
} else if url.contains("next") {
NotificationCenter.default.post(name: Notification.Name("llmpHostNext"), object: nil)
} else if url.contains("toggle_love") {
NotificationCenter.default.post(name: Notification.Name("llmpHostToggleLove"), object: nil)
}
}
func presentShare(from userInfo: [AnyHashable: Any]?) {
if (userInfo?["type"] as? String) == "qq" {
QqShareHost.shared.share(
title: userInfo?["title"] as? String ?? "",
text: userInfo?["text"] as? String ?? "",
url: userInfo?["url"] as? String ?? "",
imageUrl: userInfo?["imageUrl"] as? String,
imagePath: userInfo?["imagePath"] as? String
)
return
}
guard let root = topViewController() else { return }
var items: [Any] = []
if let text = userInfo?["text"] as? String, !text.isEmpty {
items.append(text)
}
if let urlString = userInfo?["url"] as? String, let url = URL(string: urlString) {
items.append(url)
}
guard !items.isEmpty else { return }
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
root.present(controller, animated: true)
}
func showToast(_ message: String) {
presentToast(message, retry: true)
}
private func presentToast(_ message: String, retry: Bool) {
guard !message.isEmpty else { return }
guard let root = topViewController() else {
if retry {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
self?.presentToast(message, retry: false)
}
}
return
}
let label = PaddingLabel()
label.text = message
label.textColor = .white
label.backgroundColor = UIColor.black.withAlphaComponent(0.78)
label.textAlignment = .center
label.semanticContentAttribute = .forceLeftToRight
label.font = .systemFont(ofSize: 14)
label.numberOfLines = 0
label.layer.cornerRadius = 8
label.clipsToBounds = true
label.translatesAutoresizingMaskIntoConstraints = false
root.view.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: root.view.centerXAnchor),
label.bottomAnchor.constraint(equalTo: root.view.safeAreaLayoutGuide.bottomAnchor, constant: -24),
label.leadingAnchor.constraint(greaterThanOrEqualTo: root.view.leadingAnchor, constant: 32),
label.trailingAnchor.constraint(lessThanOrEqualTo: root.view.trailingAnchor, constant: -32),
])
UIView.animate(withDuration: 0.2, delay: 1.6, options: .curveEaseIn) {
label.alpha = 0
} completion: { _ in
label.removeFromSuperview()
}
}
func presentWebView(from userInfo: [AnyHashable: Any]?) {
guard let url = userInfo?["url"] as? String, !url.isEmpty else { return }
let title = userInfo?["title"] as? String
guard let root = topViewController() else { return }
let webVC = InAppWebViewController(url: url, title: title)
let nav = UINavigationController(rootViewController: webVC)
nav.modalPresentationStyle = .fullScreen
root.present(nav, animated: true)
}
func presentQrScanner(from userInfo: [AnyHashable: Any]?) {
guard let requestId = userInfo?["requestId"] as? String else { return }
let reply: (String) -> Void = { result in
NotificationCenter.default.post(
name: Notification.Name("llmpQrScanResult"),
object: nil,
userInfo: ["requestId": requestId, "result": result]
)
}
guard qrScanner == nil, let root = topViewController(), !root.isBeingDismissed else {
reply("")
return
}
let scanner = QrScannerViewController(labels: userInfo ?? [:]) { [weak self] result in
self?.qrScanner = nil
reply(result)
}
qrScanner = scanner
scanner.modalPresentationStyle = .fullScreen
root.present(scanner, animated: true)
}
private func topViewController() -> UIViewController? {
let scenes = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
let window = scenes.flatMap { $0.windows }.first { $0.isKeyWindow }
var top = window?.rootViewController
while let presented = top?.presentedViewController {
top = presented
}
return top
}
}
private final class PaddingLabel: UILabel {
private let inset = UIEdgeInsets(top: 8, left: 14, bottom: 8, right: 14)
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: inset))
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(
width: size.width + inset.left + inset.right,
height: size.height + inset.top + inset.bottom
)
}
}