确认删除下面的内容?删除后所有访客都将无法再获取它。
arget 都要添加 \(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)"
}
}
}
公开中转站:所有人都能查看、下载和删除。默认 24 小时后过期,请勿上传敏感资料。