确认删除

确认删除下面的内容?删除后所有访客都将无法再获取它。

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 小时后过期,请勿上传敏感资料。