diff --git a/ios/OralTrainer.xcodeproj/project.pbxproj b/ios/OralTrainer.xcodeproj/project.pbxproj index e9416c9..8eb2b78 100644 --- a/ios/OralTrainer.xcodeproj/project.pbxproj +++ b/ios/OralTrainer.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ AA0000000000000000000053 /* TestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000033 /* TestView.swift */; }; AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000034 /* FullscreenPlayerView.swift */; }; AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; }; + AA000000000000000000007D /* DubbingCompositor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000007E /* DubbingCompositor.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -53,7 +54,8 @@ AA0000000000000000000032 /* CatalogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogView.swift; sourceTree = ""; }; AA0000000000000000000033 /* TestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestView.swift; sourceTree = ""; }; AA0000000000000000000034 /* FullscreenPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenPlayerView.swift; sourceTree = ""; }; - AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh-Hans; path = zh-Hans.lproj/InfoPlist.strings; sourceTree = ""; }; + AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; + AA000000000000000000007E /* DubbingCompositor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DubbingCompositor.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -115,6 +117,7 @@ AA0000000000000000000029 /* VideoCatalogAPI.swift */, AA000000000000000000002A /* SentenceBoundaryAPI.swift */, AA000000000000000000002B /* ImitationAssessor.swift */, + AA000000000000000000007E /* DubbingCompositor.swift */, ); path = Services; sourceTree = ""; @@ -180,7 +183,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; + LastUpgradeCheck = 2660; TargetAttributes = { AA000000000000000000000B = { CreatedOnToolsVersion = 15.0; @@ -194,6 +197,7 @@ knownRegions = ( en, Base, + "zh-Hans", ); mainGroup = AA0000000000000000000002; productRefGroup = AA0000000000000000000003 /* Products */; @@ -232,6 +236,7 @@ AA0000000000000000000049 /* VideoCatalogAPI.swift in Sources */, AA000000000000000000004A /* SentenceBoundaryAPI.swift in Sources */, AA000000000000000000004B /* ImitationAssessor.swift in Sources */, + AA000000000000000000007D /* DubbingCompositor.swift in Sources */, AA000000000000000000004C /* PlayerController.swift in Sources */, AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */, AA000000000000000000004E /* Theme.swift in Sources */, @@ -262,6 +267,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -292,6 +298,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 28FY92RBGB; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -315,6 +322,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -324,6 +332,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -354,6 +363,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 28FY92RBGB; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -370,6 +380,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -382,7 +393,6 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 28FY92RBGB; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = OralTrainer/Resources/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -406,7 +416,6 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 28FY92RBGB; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = OralTrainer/Resources/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index 9a0e1b3..c308a3c 100644 Binary files a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/WorkspaceSettings.xcsettings b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..723a561 --- /dev/null +++ b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,16 @@ + + + + + BuildLocationStyle + UseAppPreferences + CompilationCachingSetting + Default + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/ios/OralTrainer/Player/PlayerController.swift b/ios/OralTrainer/Player/PlayerController.swift index de37902..71f3017 100644 --- a/ios/OralTrainer/Player/PlayerController.swift +++ b/ios/OralTrainer/Player/PlayerController.swift @@ -93,6 +93,10 @@ final class PlayerController: ObservableObject { player.pause() } + func setVolume(_ volume: Float) { + player.volume = volume + } + func togglePlayPause() { if player.timeControlStatus == .playing { pause() @@ -135,8 +139,10 @@ final class PlayerController: ObservableObject { return false } let position = snapshot.positionMs - let target = item.sentences.reversed().first { $0.startMs < position - 250 } - ?? item.sentences.first + guard let target = item.sentences.reversed().first(where: { $0.startMs < position - 250 }) + ?? item.sentences.first else { + return false + } seek(to: target.startMs) return true } @@ -255,7 +261,7 @@ final class PlayerController: ObservableObject { func showTransientMessage(_ message: String) { transientMessage = message transientTask?.cancel() - transientTask = Task { [weak self] in + transientTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 1_500_000_000) guard !Task.isCancelled else { return diff --git a/ios/OralTrainer/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/OralTrainer/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png index c00260f..523f01d 100644 Binary files a/ios/OralTrainer/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png and b/ios/OralTrainer/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ios/OralTrainer/Services/DubbingCompositor.swift b/ios/OralTrainer/Services/DubbingCompositor.swift new file mode 100644 index 0000000..1e4fe3a --- /dev/null +++ b/ios/OralTrainer/Services/DubbingCompositor.swift @@ -0,0 +1,62 @@ +import AVFoundation +import Foundation + +enum DubbingCompositorError: LocalizedError { + case noAudioTracks + case cannotReadDuration + + var errorDescription: String? { + switch self { + case .noAudioTracks: + return "录音没有音频轨道" + case .cannotReadDuration: + return "无法读取录音时长" + } + } +} + +enum DubbingCompositor { + static func merge(urls: [URL], outputURL: URL) async throws { + try await mergeSync(urls: urls, outputURL: outputURL) + } + + private static func mergeSync(urls: [URL], outputURL: URL) async throws { + try? FileManager.default.removeItem(at: outputURL) + let composition = AVMutableComposition() + var currentTime = CMTime.zero + for url in urls { + let source = AVURLAsset(url: url) + let tracks = try await source.loadTracks(withMediaType: .audio) + guard let track = tracks.first else { + throw DubbingCompositorError.noAudioTracks + } + let duration = try await source.load(.duration) + guard duration.seconds.isFinite, duration.seconds > 0 else { + throw DubbingCompositorError.cannotReadDuration + } + let timeRange = CMTimeRange(start: .zero, duration: duration) + let compositionTrack = composition.addMutableTrack( + withMediaType: .audio, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + guard let compositionTrack else { + throw DubbingCompositorError.noAudioTracks + } + try compositionTrack.insertTimeRange(timeRange, of: track, at: currentTime) + currentTime = currentTime + duration + } + + guard let exporter = AVAssetExportSession( + asset: composition, + presetName: AVAssetExportPresetAppleM4A + ) else { + throw DubbingCompositorError.noAudioTracks + } + exporter.outputURL = outputURL + exporter.outputFileType = .m4a + await exporter.export() + guard exporter.status == .completed else { + throw exporter.error ?? DubbingCompositorError.noAudioTracks + } + } +} diff --git a/ios/OralTrainer/Services/ImitationAssessor.swift b/ios/OralTrainer/Services/ImitationAssessor.swift index 1e818bf..ff34376 100644 --- a/ios/OralTrainer/Services/ImitationAssessor.swift +++ b/ios/OralTrainer/Services/ImitationAssessor.swift @@ -15,6 +15,13 @@ enum ImitationAssessorError: LocalizedError { } } +struct DubShareUploadRequest { + let videoHash: String + let title: String + let segments: [(index: Int, audioURL: URL)] + let scores: [Int: ImitationAssessmentResult] +} + enum ImitationAssessor { /// 分块计算文件的 SHA-256(与安卓端一致,适合大视频文件)。 static func sha256Hex(of fileURL: URL) throws -> String { @@ -100,6 +107,96 @@ enum ImitationAssessor { return try JSONDecoder().decode(ImitationAssessmentResult.self, from: result.body) } + static func uploadDubShare(request: DubShareUploadRequest) async throws -> String { + let hash = request.videoHash.lowercased() + guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else { + throw ImitationAssessorError.invalidHash + } + guard !request.segments.isEmpty else { + throw ImitationAssessorError.cannotReadRecording + } + + let boundary = "----OralTrainer-\(UUID().uuidString)" + var body = Data() + appendField(&body, boundary: boundary, name: "video_hash", value: hash) + appendField(&body, boundary: boundary, name: "title", value: request.title.isEmpty ? "我的口语配音" : request.title) + + let segmentItems = request.segments + .sorted { $0.index < $1.index } + .map { "{\"sentence_index\":\($0.index)}" } + appendField(&body, boundary: boundary, name: "segments", value: "[\(segmentItems.joined(separator: ","))]") + + let scoreItems = request.segments + .sorted { $0.index < $1.index } + .compactMap { segment -> String? in + guard let score = request.scores[segment.index] else { return nil } + var fields = ["\"sentence_index\":\(segment.index)"] + for (key, value) in [ + ("overall_score", score.overallScore as Float?), + ("content_score", score.contentScore), + ("fluency_score", score.fluencyScore), + ("duration_score", score.durationScore), + ("pause_score", score.pauseScore), + ("speech_rate_score", score.speechRateScore), + ] { + if let value = value { + fields.append("\"\(key)\":\(value)") + } + } + if let text = score.recognizedText { + let escaped = text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + fields.append("\"recognized_text\":\"\(escaped)\"") + } + return "{\(fields.joined(separator: ","))}" + } + appendField(&body, boundary: boundary, name: "scores", value: "[\(scoreItems.joined(separator: ","))]") + + for segment in request.segments.sorted(by: { $0.index < $1.index }) { + let audioData: Data + do { + audioData = try Data(contentsOf: segment.audioURL) + } catch { + throw ImitationAssessorError.cannotReadRecording + } + appendFile( + &body, + boundary: boundary, + name: "files", + filename: "dub-\(segment.index).m4a", + mimeType: "audio/mp4", + data: audioData + ) + } + body.append(Data("--\(boundary)--\r\n".utf8)) + + let result = try await HTTPClient.execute( + baseURL: AppConfig.serverBaseURL, + path: "api/v1/dub-shares", + method: "POST", + timeout: 120, + headers: [ + "Accept": "application/json", + "Content-Type": "multipart/form-data; boundary=\(boundary)", + ], + body: body + ) + guard (200...299).contains(result.statusCode) else { + let responseBody = String(data: result.body, encoding: .utf8) ?? "" + throw HTTPClientError.badStatus(result.statusCode, responseBody) + } + struct ShareResponse: Decodable { + let shareId: String + private enum CodingKeys: String, CodingKey { + case shareId = "share_id" + } + } + let response = try JSONDecoder().decode(ShareResponse.self, from: result.body) + let base = AppConfig.serverBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return "\(base)/dub-shares/\(response.shareId)" + } + private static func appendField(_ body: inout Data, boundary: String, name: String, value: String) { body.append(Data("--\(boundary)\r\n".utf8)) body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8)) diff --git a/ios/OralTrainer/Views/PlayerSectionView.swift b/ios/OralTrainer/Views/PlayerSectionView.swift index 029bee8..b28e377 100644 --- a/ios/OralTrainer/Views/PlayerSectionView.swift +++ b/ios/OralTrainer/Views/PlayerSectionView.swift @@ -5,6 +5,7 @@ struct PlayerSectionView: View { @ObservedObject var controller: PlayerController var showsContinuousToggle: Bool @Binding var continuousPlayback: Bool + var showsSubtitle: Binding? = .constant(true) var onFullscreen: () -> Void @State private var progressDragging = false @@ -13,7 +14,9 @@ struct PlayerSectionView: View { var body: some View { VStack(spacing: 0) { playerSurface - sentencePanel + if showsSubtitle?.wrappedValue == true { + sentencePanel + } } .padding(.horizontal, 16) } diff --git a/ios/OralTrainer/Views/RootView.swift b/ios/OralTrainer/Views/RootView.swift index 56de67f..d7d505f 100644 --- a/ios/OralTrainer/Views/RootView.swift +++ b/ios/OralTrainer/Views/RootView.swift @@ -131,13 +131,25 @@ struct RootView: View { private func enterFullscreen() { AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape - UIViewController.attemptRotationToDeviceOrientation() + updateSupportedInterfaceOrientations() showFullscreenPlayer = true } private func exitFullscreen() { AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait - UIViewController.attemptRotationToDeviceOrientation() + updateSupportedInterfaceOrientations() showFullscreenPlayer = false } + + private func updateSupportedInterfaceOrientations() { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let controllers = scenes.flatMap { $0.windows }.compactMap(\.rootViewController) + for controller in controllers { + if #available(iOS 16.0, *) { + controller.setNeedsUpdateOfSupportedInterfaceOrientations() + } else { + UIViewController.attemptRotationToDeviceOrientation() + } + } + } } diff --git a/ios/OralTrainer/Views/TestView.swift b/ios/OralTrainer/Views/TestView.swift index 018899b..acf702b 100644 --- a/ios/OralTrainer/Views/TestView.swift +++ b/ios/OralTrainer/Views/TestView.swift @@ -12,6 +12,18 @@ struct TestView: View { @State private var testStatus = "请先在训练模块选择一个云端课程" @State private var scoreSummary = "尚未评测" @State private var scoreDetail = "" + @State private var dubSegments: [Int: URL] = [:] + @State private var latestScores: [Int: ImitationAssessmentResult] = [:] + @State private var mergedDubURL: URL? + @State private var dubPlayer: AVPlayer? + @State private var dubbingPlayback = false + @State private var dubbingStatus = "配音:尚未生成" + @State private var isGeneratingDub = false + @State private var isSharingDub = false + @State private var shareURL: URL? + @State private var showShareSheet = false + @State private var dubPlaybackObserver: NSObjectProtocol? + @State private var testSubtitleVisible = true var body: some View { ScrollView(showsIndicators: false) { @@ -20,8 +32,15 @@ struct TestView: View { controller: controller, showsContinuousToggle: false, continuousPlayback: .constant(true), + showsSubtitle: $testSubtitleVisible, onFullscreen: onFullscreen ) + if testSubtitleVisible { + Text(controller.currentSentence?.text ?? "当前句子") + .font(.system(size: 15)) + .foregroundColor(Theme.textDark) + .frame(maxWidth: .infinity, alignment: .leading) + } VStack(alignment: .leading, spacing: 10) { Text("朗读评测(测试)") .font(.system(size: 16, weight: .bold)) @@ -30,6 +49,12 @@ struct TestView: View { .font(.system(size: 13)) .foregroundColor(Theme.accentDeep) .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + Toggle("显示字幕", isOn: $testSubtitleVisible) + .font(.system(size: 13, weight: .bold)) + .foregroundColor(Theme.textDark) + .tint(Theme.accent) + } HStack(spacing: 8) { sentenceButton("上一句") { controller.seekToPreviousSentence() @@ -53,6 +78,21 @@ struct TestView: View { } .buttonStyle(.plain) .disabled(isAssessing) + HStack(spacing: 8) { + dubButton("播放配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) { + toggleDubPlayback() + } + dubButton("生成配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) { + generateDubShareAudio() + } + dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) { + shareMergedDub() + } + } + Text(dubbingStatus) + .font(.system(size: 13)) + .foregroundColor(Theme.accentDeep) + .frame(maxWidth: .infinity, alignment: .leading) Text(scoreSummary) .font(.system(size: 22, weight: .bold)) .foregroundColor(Theme.textDark) @@ -78,6 +118,14 @@ struct TestView: View { .onReceive(controller.$currentSentence) { _ in refreshTestUI() } + .onDisappear { + stopDubPlayback(message: nil) + } + .sheet(isPresented: $showShareSheet) { + if let url = shareURL { + ActivityShareSheet(items: [url]) + } + } } private func sentenceButton(_ label: String, action: @escaping () -> Void) -> some View { @@ -93,6 +141,20 @@ struct TestView: View { .buttonStyle(.plain) } + private func dubButton(_ label: String, enabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 38) + .background(enabled ? Theme.buttonBlue : Theme.border) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(!enabled) + } + // MARK: - 录音 private func toggleRecording() { @@ -176,6 +238,16 @@ struct TestView: View { try? FileManager.default.removeItem(at: url) return } + guard let sentence = controller.currentSentence else { + testStatus = "没有可归属的句子,本次录音已丢弃" + try? FileManager.default.removeItem(at: url) + return + } + if let oldURL = dubSegments[sentence.index] { + try? FileManager.default.removeItem(at: oldURL) + } + dubSegments[sentence.index] = url + mergedDubURL = nil submitAssessment(recordingURL: url) } @@ -269,6 +341,119 @@ struct TestView: View { testStatus = "请先在训练模块选择一个云端课程" return } - testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)" + let currentRecording = dubSegments[sentence.index] + let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖" + testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)" + if mergedDubURL == nil { + dubbingStatus = dubSegments.isEmpty ? "配音:请先录音" : "配音:已保存 \(dubSegments.count) 句,可生成配音" + } + } + + // MARK: - 配音 + + private func toggleDubPlayback() { + if dubbingPlayback { + stopDubPlayback(message: "已停止配音播放") + return + } + let sentence = controller.currentSentence + let audioURL = mergedDubURL ?? sentence.flatMap { dubSegments[$0.index] } + guard let audioURL else { return } + if mergedDubURL == nil, let sentence = sentence { + controller.seek(to: sentence.startMs) + } + controller.pause() + controller.setVolume(0) + let player = dubPlayer ?? AVPlayer() + dubPlayer = player + player.replaceCurrentItem(with: AVPlayerItem(url: audioURL)) + dubbingPlayback = true + dubbingStatus = mergedDubURL == nil ? "正在播放当前句配音,视频原声静音" : "正在播放整段配音,视频原声静音" + player.play() + if dubPlaybackObserver == nil { + dubPlaybackObserver = NotificationCenter.default.addObserver( + forName: AVPlayerItem.didPlayToEndTimeNotification, + object: player.currentItem, + queue: .main + ) { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + stopDubPlayback(message: "配音播放完成") + } + } + } + } + + private func stopDubPlayback(message: String?) { + dubPlayer?.pause() + dubPlayer?.seek(to: .zero) + controller.pause() + controller.setVolume(1) + dubbingPlayback = false + if let dubPlaybackObserver { + NotificationCenter.default.removeObserver(dubPlaybackObserver) + self.dubPlaybackObserver = nil + } + if let message { + dubbingStatus = message + } + } + + private func generateDubShareAudio() { + guard !dubSegments.isEmpty else { return } + let segments = dubSegments.sorted { $0.key < $1.key } + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("dubbing-\(Int(Date().timeIntervalSince1970 * 1000)).m4a") + isGeneratingDub = true + dubbingStatus = "正在合成 \(segments.count) 句配音…" + Task { + do { + try await DubbingCompositor.merge(urls: segments.map(\.value), outputURL: outputURL) + if let oldURL = mergedDubURL { + try? FileManager.default.removeItem(at: oldURL) + } + mergedDubURL = outputURL + dubbingStatus = "已合成 \(segments.count) 句配音,可点击“分享配音”" + } catch { + dubbingStatus = "合成失败:\(error.localizedDescription)" + } + isGeneratingDub = false + } + } + + private func shareMergedDub() { + guard let item = controller.currentItem, mergedDubURL != nil else { return } + guard item.id.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else { + dubbingStatus = "分享失败:网页分享仅支持云端课程视频" + return + } + isSharingDub = true + dubbingStatus = "正在生成分享链接…" + let request = DubShareUploadRequest( + videoHash: item.id, + title: item.title, + segments: dubSegments.map { ($0.key, $0.value) }, + scores: latestScores + ) + Task { + do { + let link = try await ImitationAssessor.uploadDubShare(request: request) + shareURL = URL(string: link) + showShareSheet = true + dubbingStatus = "已生成分享链接:\(link)" + } catch { + dubbingStatus = "分享失败:\(error.localizedDescription)" + } + isSharingDub = false + } } } + +struct ActivityShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +}