optimize the appicon of iOS
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
AA0000000000000000000033 /* TestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestView.swift; sourceTree = "<group>"; };
|
||||
AA0000000000000000000034 /* FullscreenPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenPlayerView.swift; sourceTree = "<group>"; };
|
||||
AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh-Hans; path = zh-Hans.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
|
||||
AA000000000000000000007E /* DubbingCompositor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DubbingCompositor.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildLocationStyle</key>
|
||||
<string>UseAppPreferences</string>
|
||||
<key>CompilationCachingSetting</key>
|
||||
<string>Default</string>
|
||||
<key>CustomBuildLocationType</key>
|
||||
<string>RelativeToDerivedData</string>
|
||||
<key>DerivedDataLocationStyle</key>
|
||||
<string>Default</string>
|
||||
<key>ShowSharedSchemesAutomaticallyEnabled</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 675 KiB After Width: | Height: | Size: 801 KiB |
62
ios/OralTrainer/Services/DubbingCompositor.swift
Normal file
62
ios/OralTrainer/Services/DubbingCompositor.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -5,6 +5,7 @@ struct PlayerSectionView: View {
|
||||
@ObservedObject var controller: PlayerController
|
||||
var showsContinuousToggle: Bool
|
||||
@Binding var continuousPlayback: Bool
|
||||
var showsSubtitle: Binding<Bool>? = .constant(true)
|
||||
var onFullscreen: () -> Void
|
||||
|
||||
@State private var progressDragging = false
|
||||
@@ -13,8 +14,10 @@ struct PlayerSectionView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
playerSurface
|
||||
if showsSubtitle?.wrappedValue == true {
|
||||
sentencePanel
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user