首页
只读列表
发布 / 上传
发布文字 / 网址
粘贴
上传文件
手动刷新本页
内容列表(
2
条)
名称 / 内容
大小 / 剩余时间
操作
(2026-10-25)3xui配置教程1080.mov
25.7 MB
约 1432 分钟后过期
下载
删除
// LiveTranslator.swift (v8) // 主 App:整个替换 PiPFanyi 文件夹里的 LiveTranslator.swift //…
显示更多
收起
// LiveTranslator.swift (v8) // 主 App:整个替换 PiPFanyi 文件夹里的 LiveTranslator.swift // // 作用:每 0.2 秒看一次扩展写的识别结果。有新的一帧时: // 1. 先立刻把截图显示到悬浮窗,已经翻译过的句子马上贴上; // 2. 新句子翻译好后再刷新一次。 // 悬浮窗暂停时不读也不刷新,画面定格;点播放后接着处理扩展写的新画面。 // 翻译前先把同一段的几行合并成一整段(一句话跨几行也能整句翻译),译文贴回整段的位置。 // 翻译引擎两种: // · 本地版:苹果自带的 Translation,在手机上完成,不用联网;语言包要先在 App 里下载。 // · 在线版:百度通用文本翻译,要联网,填自己的 APP ID 和密钥。 import Foundation import Translation import NaturalLanguage import Combine import UIKit import CryptoKit /// 翻译引擎 enum TranslateEngine: String, CaseIterable, Identifiable { case local, baidu var id: String { rawValue } var title: String { switch self { case .local: return "本地(苹果)" case .baidu: return "在线(百度)" } } } @MainActor final class LiveTranslator: ObservableObject { static let shared = LiveTranslator() @Published var status = "等待开始屏幕广播" @Published var source: SourceLanguage { didSet { Shared.defaults?.set(source.rawValue, forKey: Shared.sourceLanguageKey) cache.removeAll() lastUpdatedAt = nil // 换语言后,下一次轮询重新翻译当前画面 lastModified = nil } } /// 翻译引擎:本地版 / 在线版 @Published var engine: TranslateEngine { didSet { UserDefaults.standard.set(engine.rawValue, forKey: "engine") cache.removeAll() // 换引擎后重新翻译当前画面 lastUpdatedAt = nil lastModified = nil } } /// 百度翻译开放平台的 APP ID 和密钥 @Published var baiduAppID: String { didSet { UserDefaults.standard.set(baiduAppID, forKey: "baiduAppID") } } @Published var baiduSecret: String { didSet { UserDefaults.standard.set(baiduSecret, forKey: "baiduSecret") } } let target = Locale.Language(identifier: "zh-Hans") /// 一帧画面:识别结果 + 当时的截图 private struct Job { let payload: LivePayload let frame: UIImage? } private var sessions: [String: TranslationSession] = [:] private var cache: [String: String] = [:] // 原文 → 译文,相同的句子不重复翻译 private var lastModified: Date? private var lastUpdatedAt: Double? private var pending: Job? private var isTranslating = false private var timer: Timer? private init() { let raw = Shared.defaults?.string(forKey: Shared.sourceLanguageKey) ?? "" source = SourceLanguage(rawValue: raw) ?? .auto engine = TranslateEngine(rawValue: UserDefaults.standard.string(forKey: "engine") ?? "") ?? .local baiduAppID = UserDefaults.standard.string(forKey: "baiduAppID") ?? "" baiduSecret = UserDefaults.standard.string(forKey: "baiduSecret") ?? "" } /// 没有正在翻译的画面、扩展也没有新写的画面没读:单次模式靠它判断能不能自动暂停 var isIdle: Bool { guard !isTranslating, pending == nil else { return false } if let url = Shared.payloadURL, let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), let modified = attributes[.modificationDate] as? Date, modified != lastModified { return false } return true } // MARK: - 开始轮询 func start() { guard timer == nil else { return } guard Shared.containerURL != nil else { status = "App Group 没有配置好:两个 Target 都要添加 \(Shared.appGroupID)" return } timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in Task { @MainActor in self?.poll() } } } /// 语言包下载完成后调用 func languageInstalled() { sessions.removeAll() lastUpdatedAt = nil lastModified = nil } /// 给界面显示语言包状态 func statusText(for language: SourceLanguage) async -> String { guard language != .auto else { return "自动识别:遇到的语言需要先下载语言包(选具体语言后可下载)" } let status = await LanguageAvailability().status( from: Locale.Language(identifier: language.rawValue), to: target) switch status { case .installed: return "语言包已下载,可以离线翻译" case .supported: return "语言包还没下载,点下面的按钮下载" case .unsupported: return "不支持把这个语言翻译成中文" @unknown default: return "语言包状态未知" } } // MARK: - 读取扩展的识别结果 private func poll() { guard !PiPManager.shared.isPaused else { return } // 暂停中:画面定格 guard let url = Shared.payloadURL, let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), let modified = attributes[.modificationDate] as? Date, modified != lastModified else { return } lastModified = modified guard let data = try? Data(contentsOf: url), let payload = try? JSONDecoder().decode(LivePayload.self, from: data) else { return } guard payload.running else { status = "屏幕广播已停止" lastUpdatedAt = nil pending = nil PiPManager.shared.render(message: "屏幕广播已停止") return } guard payload.updatedAt != lastUpdatedAt else { return } lastUpdatedAt = payload.updatedAt let frame = payload.frameName .flatMap { Shared.fileURL($0) } .flatMap { UIImage(contentsOfFile: $0.path) } if payload.items.isEmpty { pending = nil status = "广播中:画面里没有识别到外文" PiPManager.shared.render(frame: frame, items: [], banner: "画面里没有识别到外文") return } // 先立刻显示截图,已经翻译过的句子直接贴上 PiPManager.shared.render(frame: frame, items: overlayItems(for: payload), banner: nil) let blocks = Self.makeBlocks(payload.items) let needsTranslation = blocks.contains { cache[$0.text] == nil } guard needsTranslation else { pending = nil status = "广播中:共 \(blocks.count) 段" return } let job = Job(payload: payload, frame: frame) if isTranslating { pending = job // 正在翻译时,只保留最新的一帧 return } Task { await process(job) } } private func process(_ job: Job) async { isTranslating = true var next: Job? = job while let current = next { await translate(current) next = pending pending = nil } isTranslating = false } /// 已经有译文的段落,换成要贴到画面上的位置和文字 private func overlayItems(for payload: LivePayload) -> [OverlayItem] { Self.makeBlocks(payload.items).compactMap { block in guard let text = cache[block.text] else { return nil } return OverlayItem(rect: block.rect, text: text, lineHeight: block.lineHeight) } } // MARK: - 把一行一行的文字合并成段落 private struct Block { var lines: [LiveItem] var rect: CGRect var text: String { LiveTranslator.join(lines.map { $0.text }) } var lineHeight: CGFloat { CGFloat(lines.map { $0.h }.reduce(0, +) / Double(max(lines.count, 1))) } } private static func makeBlocks(_ items: [LiveItem]) -> [Block] { let sorted = items.sorted { ($0.y, $0.x) < ($1.y, $1.x) } var blocks: [Block] = [] for item in sorted { let rect = CGRect(x: item.x, y: item.y, width: item.w, height: item.h) if let index = blocks.lastIndex(where: { canJoin($0, item) }) { blocks[index].lines.append(item) blocks[index].rect = blocks[index].rect.union(rect) } else { blocks.append(Block(lines: [item], rect: rect)) } } return blocks } /// 这一行能不能接在这一段后面:字一样大、上下挨着、左右对得上、不是列表的新一项 private static func canJoin(_ block: Block, _ item: LiveItem) -> Bool { guard let last = block.lines.last else { return false } let ratio = item.h / max(last.h, 0.0001) guard ratio > 0.7 && ratio < 1.4 else { return false } // 字号差太多(标题和正文) let height = (last.h + item.h) / 2 let gap = item.y - (last.y + last.h) guard gap > -0.4 * height && gap < 0.8 * height else { return false } // 隔得太远是另一段 let overlap = min(last.x + last.w, item.x + item.w) - max(last.x, item.x) let leftAligned = abs(item.x - last.x) < 0.03 guard leftAligned || overlap > 0.6 * min(last.w, item.w) else { return false } // 不在同一栏 guard last.w > 0.6 * item.w else { return false } // 上一行很短:标题或段落结尾 guard !startsListItem(item.text) else { return false } // 1. / • / - 开头:列表新的一项 return true } private static func startsListItem(_ text: String) -> Bool { text.range(of: #"^(\d{1,3}[.)、]|[•·\-–—*▪●])\s?"#, options: .regularExpression) != nil } /// 把几行拼成一段:英文等中间加空格,日文、中文直接相连,行尾连字符断开的单词接回去 private static func join(_ lines: [String]) -> String { var result = "" for line in lines { guard !result.isEmpty else { result = line; continue } if result.hasSuffix("-"), let first = line.first, first.isLowercase { result.removeLast() result += line } else if isCJK(result.last) && isCJK(line.first) { result += line } else { result += " " + line } } return result } private static func isCJK(_ character: Character?) -> Bool { guard let scalar = character?.unicodeScalars.first else { return false } switch scalar.value { case 0x3040...0x30FF, 0x3400...0x4DBF, 0x4E00...0x9FFF, 0xFF00...0xFFEF: return true default: return false } } // MARK: - 翻译 private func translate(_ job: Job) async { let payload = job.payload let texts = Self.makeBlocks(payload.items).map { $0.text } do { if cache.count > 1000 { cache.removeAll() } let missing = Array(Set(texts.filter { cache[$0] == nil })) var engineName = "百度在线" switch engine { case .local: guard let code = sourceCode(for: texts) else { if payload.updatedAt == lastUpdatedAt { status = "广播中:认不出是哪种语言" PiPManager.shared.render(frame: job.frame, items: overlayItems(for: payload), banner: "认不出是哪种语言") } return } engineName = SourceLanguage(rawValue: code)?.title ?? code if !missing.isEmpty { try await translateLocally(missing, code: code) } case .baidu: if !missing.isEmpty { try await translateWithBaidu(missing) } } // 翻译期间屏幕已经变了或点了暂停,就不再显示这一帧(译文已经存进缓存,下一帧直接用) guard payload.updatedAt == lastUpdatedAt, !PiPManager.shared.isPaused else { return } status = "广播中:\(engineName) → 中文,共 \(texts.count) 段" PiPManager.shared.render(frame: job.frame, items: overlayItems(for: payload), banner: nil) } catch { guard payload.updatedAt == lastUpdatedAt, !PiPManager.shared.isPaused else { return } status = "翻译出错:\(error.localizedDescription)" PiPManager.shared.render(frame: job.frame, items: overlayItems(for: payload), banner: status) } } /// 本地版:苹果自带翻译,在手机上完成 private func translateLocally(_ texts: [String], code: String) async throws { let session = try await session(for: code) let requests = texts.enumerated().map { index, text in TranslationSession.Request(sourceText: text, clientIdentifier: String(index)) } let responses = try await session.translations(from: requests) for response in responses { if let id = response.clientIdentifier, let index = Int(id), index < texts.count { cache[texts[index]] = response.targetText } } } // MARK: - 在线版:百度通用文本翻译 /// 一次请求的字数上限(百度单次最多约 6000 字节,留点余量) private let baiduChunkLimit = 1500 private func translateWithBaidu(_ texts: [String]) async throws { let appID = baiduAppID.trimmingCharacters(in: .whitespacesAndNewlines) let secret = baiduSecret.trimmingCharacters(in: .whitespacesAndNewlines) guard !appID.isEmpty, !secret.isEmpty else { throw LiveError.baiduKeyMissing } // 每段一行,一次发多段;太长就分几次发 var chunks: [[String]] = [[]] var length = 0 for text in texts { if length + text.count > baiduChunkLimit, !(chunks.last?.isEmpty ?? true) { chunks.append([]) length = 0 } chunks[chunks.count - 1].append(text) length += text.count + 1 } for chunk in chunks where !chunk.isEmpty { let translated = try await baiduRequest(chunk, appID: appID, secret: secret) for (text, result) in zip(chunk, translated) { cache[text] = result } } } /// 发一次请求,按顺序返回每一段的译文 private func baiduRequest(_ texts: [String], appID: String, secret: String) async throws -> [String] { let q = texts.joined(separator: "\n") let salt = String(Int.random(in: 100_000...999_999_999)) let sign = Self.md5(appID + q + salt + secret) var request = URLRequest(url: URL(string: "https://fanyi-api.baidu.com/api/trans/vip/translate")!) request.httpMethod = "POST" request.timeoutInterval = 10 request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpBody = Self.formBody([ "q": q, "from": baiduFrom, "to": "zh", "appid": appID, "salt": salt, "sign": sign ]) let (data, _) = try await URLSession.shared.data(for: request) guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw LiveError.baidu("返回的数据看不懂") } if let code = json["error_code"].map({ "\($0)" }), code != "52000" { let message = json["error_msg"].map { "\($0)" } ?? "" throw LiveError.baidu("\(code) \(message)") } let results = (json["trans_result"] as? [[String: Any]] ?? []).compactMap { $0["dst"] as? String } // 正常情况下一段对应一条;条数对不上时,按原文对照 if results.count == texts.count { return results } var bySource: [String: String] = [:] for item in json["trans_result"] as? [[String: Any]] ?? [] { if let src = item["src"] as? String, let dst = item["dst"] as? String { bySource[src] = dst } } return texts.map { bySource[$0] ?? $0 } } /// 百度的语言代码和苹果的不完全一样 private var baiduFrom: String { switch source { case .auto: return "auto" case .en: return "en" case .ja: return "jp" case .ko: return "kor" case .fr: return "fra" case .de: return "de" case .es: return "spa" case .ru: return "ru" } } private static func md5(_ string: String) -> String { Insecure.MD5.hash(data: Data(string.utf8)).map { String(format: "%02x", $0) }.joined() } private static func formBody(_ fields: [String: String]) -> Data { var allowed = CharacterSet.alphanumerics allowed.insert(charactersIn: "-._~") let body = fields.map { key, value in key + "=" + (value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value) }.joined(separator: "&") return Data(body.utf8) } /// 原文语言:手动选的直接用;自动识别时按整段文字判断 private func sourceCode(for lines: [String]) -> String? { if source != .auto { return source.rawValue } let recognizer = NLLanguageRecognizer() recognizer.processString(lines.joined(separator: "\n")) guard let language = recognizer.dominantLanguage, language != .undetermined, language != .simplifiedChinese, language != .traditionalChinese else { return nil } return language.rawValue } private func session(for code: String) async throws -> TranslationSession { if let existing = sessions[code] { return existing } let language = Locale.Language(identifier: code) let name = SourceLanguage(rawValue: code)?.title ?? code switch await LanguageAvailability().status(from: language, to: target) { case .installed: let session = TranslationSession(installedSource: language, target: target) sessions[code] = session return session case .supported: throw LiveError.notInstalled(name) case .unsupported: throw LiveError.unsupported(name) @unknown default: throw LiveError.unsupported(name) } } } nonisolated enum LiveError: LocalizedError { case notInstalled(String) case unsupported(String) case baiduKeyMissing case baidu(String) var errorDescription: String? { switch self { case .notInstalled(let name): return "\(name)语言包还没下载,请回到 App 里下载" case .unsupported(let name): return "不支持把\(name)翻译成中文" case .baiduKeyMissing: return "还没填百度的 APP ID 和密钥,请回到 App 里填写" case .baidu(let message): return "百度翻译出错:\(message)" } } }
19.3 KB
约 1430 分钟后过期
复制
删除
公开中转站:所有人都能查看、下载和删除。默认 24 小时后过期,请勿上传敏感资料。