optimize the appicon of iOS

This commit is contained in:
2026-08-26 17:14:59 +08:00
parent 76b0332035
commit bb677ea8cf
11 changed files with 406 additions and 11 deletions

View File

@@ -28,6 +28,7 @@
AA0000000000000000000053 /* TestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000033 /* TestView.swift */; }; AA0000000000000000000053 /* TestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000033 /* TestView.swift */; };
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000034 /* FullscreenPlayerView.swift */; }; AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000034 /* FullscreenPlayerView.swift */; };
AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -53,7 +54,8 @@
AA0000000000000000000032 /* CatalogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -115,6 +117,7 @@
AA0000000000000000000029 /* VideoCatalogAPI.swift */, AA0000000000000000000029 /* VideoCatalogAPI.swift */,
AA000000000000000000002A /* SentenceBoundaryAPI.swift */, AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
AA000000000000000000002B /* ImitationAssessor.swift */, AA000000000000000000002B /* ImitationAssessor.swift */,
AA000000000000000000007E /* DubbingCompositor.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -180,7 +183,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500; LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500; LastUpgradeCheck = 2660;
TargetAttributes = { TargetAttributes = {
AA000000000000000000000B = { AA000000000000000000000B = {
CreatedOnToolsVersion = 15.0; CreatedOnToolsVersion = 15.0;
@@ -194,6 +197,7 @@
knownRegions = ( knownRegions = (
en, en,
Base, Base,
"zh-Hans",
); );
mainGroup = AA0000000000000000000002; mainGroup = AA0000000000000000000002;
productRefGroup = AA0000000000000000000003 /* Products */; productRefGroup = AA0000000000000000000003 /* Products */;
@@ -232,6 +236,7 @@
AA0000000000000000000049 /* VideoCatalogAPI.swift in Sources */, AA0000000000000000000049 /* VideoCatalogAPI.swift in Sources */,
AA000000000000000000004A /* SentenceBoundaryAPI.swift in Sources */, AA000000000000000000004A /* SentenceBoundaryAPI.swift in Sources */,
AA000000000000000000004B /* ImitationAssessor.swift in Sources */, AA000000000000000000004B /* ImitationAssessor.swift in Sources */,
AA000000000000000000007D /* DubbingCompositor.swift in Sources */,
AA000000000000000000004C /* PlayerController.swift in Sources */, AA000000000000000000004C /* PlayerController.swift in Sources */,
AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */, AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */,
AA000000000000000000004E /* Theme.swift in Sources */, AA000000000000000000004E /* Theme.swift in Sources */,
@@ -262,6 +267,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -292,6 +298,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 28FY92RBGB;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -315,6 +322,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@@ -324,6 +332,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -354,6 +363,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 28FY92RBGB;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -370,6 +380,7 @@
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
@@ -382,7 +393,6 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 28FY92RBGB;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = OralTrainer/Resources/Info.plist; INFOPLIST_FILE = OralTrainer/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
@@ -406,7 +416,6 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 28FY92RBGB;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = OralTrainer/Resources/Info.plist; INFOPLIST_FILE = OralTrainer/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;

View File

@@ -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>

View File

@@ -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>

View File

@@ -93,6 +93,10 @@ final class PlayerController: ObservableObject {
player.pause() player.pause()
} }
func setVolume(_ volume: Float) {
player.volume = volume
}
func togglePlayPause() { func togglePlayPause() {
if player.timeControlStatus == .playing { if player.timeControlStatus == .playing {
pause() pause()
@@ -135,8 +139,10 @@ final class PlayerController: ObservableObject {
return false return false
} }
let position = snapshot.positionMs let position = snapshot.positionMs
let target = item.sentences.reversed().first { $0.startMs < position - 250 } guard let target = item.sentences.reversed().first(where: { $0.startMs < position - 250 })
?? item.sentences.first ?? item.sentences.first else {
return false
}
seek(to: target.startMs) seek(to: target.startMs)
return true return true
} }
@@ -255,7 +261,7 @@ final class PlayerController: ObservableObject {
func showTransientMessage(_ message: String) { func showTransientMessage(_ message: String) {
transientMessage = message transientMessage = message
transientTask?.cancel() transientTask?.cancel()
transientTask = Task { [weak self] in transientTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 1_500_000_000) try? await Task.sleep(nanoseconds: 1_500_000_000)
guard !Task.isCancelled else { guard !Task.isCancelled else {
return return

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 KiB

After

Width:  |  Height:  |  Size: 801 KiB

View 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
}
}
}

View File

@@ -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 { enum ImitationAssessor {
/// SHA-256 /// SHA-256
static func sha256Hex(of fileURL: URL) throws -> String { static func sha256Hex(of fileURL: URL) throws -> String {
@@ -100,6 +107,96 @@ enum ImitationAssessor {
return try JSONDecoder().decode(ImitationAssessmentResult.self, from: result.body) 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) { private static func appendField(_ body: inout Data, boundary: String, name: String, value: String) {
body.append(Data("--\(boundary)\r\n".utf8)) body.append(Data("--\(boundary)\r\n".utf8))
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8)) body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8))

View File

@@ -5,6 +5,7 @@ struct PlayerSectionView: View {
@ObservedObject var controller: PlayerController @ObservedObject var controller: PlayerController
var showsContinuousToggle: Bool var showsContinuousToggle: Bool
@Binding var continuousPlayback: Bool @Binding var continuousPlayback: Bool
var showsSubtitle: Binding<Bool>? = .constant(true)
var onFullscreen: () -> Void var onFullscreen: () -> Void
@State private var progressDragging = false @State private var progressDragging = false
@@ -13,8 +14,10 @@ struct PlayerSectionView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
playerSurface playerSurface
if showsSubtitle?.wrappedValue == true {
sentencePanel sentencePanel
} }
}
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }

View File

@@ -131,13 +131,25 @@ struct RootView: View {
private func enterFullscreen() { private func enterFullscreen() {
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
UIViewController.attemptRotationToDeviceOrientation() updateSupportedInterfaceOrientations()
showFullscreenPlayer = true showFullscreenPlayer = true
} }
private func exitFullscreen() { private func exitFullscreen() {
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
UIViewController.attemptRotationToDeviceOrientation() updateSupportedInterfaceOrientations()
showFullscreenPlayer = false 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()
}
}
}
} }

View File

@@ -12,6 +12,18 @@ struct TestView: View {
@State private var testStatus = "请先在训练模块选择一个云端课程" @State private var testStatus = "请先在训练模块选择一个云端课程"
@State private var scoreSummary = "尚未评测" @State private var scoreSummary = "尚未评测"
@State private var scoreDetail = "" @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 { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
@@ -20,8 +32,15 @@ struct TestView: View {
controller: controller, controller: controller,
showsContinuousToggle: false, showsContinuousToggle: false,
continuousPlayback: .constant(true), continuousPlayback: .constant(true),
showsSubtitle: $testSubtitleVisible,
onFullscreen: onFullscreen 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) { VStack(alignment: .leading, spacing: 10) {
Text("朗读评测(测试)") Text("朗读评测(测试)")
.font(.system(size: 16, weight: .bold)) .font(.system(size: 16, weight: .bold))
@@ -30,6 +49,12 @@ struct TestView: View {
.font(.system(size: 13)) .font(.system(size: 13))
.foregroundColor(Theme.accentDeep) .foregroundColor(Theme.accentDeep)
.fixedSize(horizontal: false, vertical: true) .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) { HStack(spacing: 8) {
sentenceButton("上一句") { sentenceButton("上一句") {
controller.seekToPreviousSentence() controller.seekToPreviousSentence()
@@ -53,6 +78,21 @@ struct TestView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(isAssessing) .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) Text(scoreSummary)
.font(.system(size: 22, weight: .bold)) .font(.system(size: 22, weight: .bold))
.foregroundColor(Theme.textDark) .foregroundColor(Theme.textDark)
@@ -78,6 +118,14 @@ struct TestView: View {
.onReceive(controller.$currentSentence) { _ in .onReceive(controller.$currentSentence) { _ in
refreshTestUI() 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 { private func sentenceButton(_ label: String, action: @escaping () -> Void) -> some View {
@@ -93,6 +141,20 @@ struct TestView: View {
.buttonStyle(.plain) .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: - // MARK: -
private func toggleRecording() { private func toggleRecording() {
@@ -176,6 +238,16 @@ struct TestView: View {
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
return 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) submitAssessment(recordingURL: url)
} }
@@ -269,6 +341,119 @@ struct TestView: View {
testStatus = "请先在训练模块选择一个云端课程" testStatus = "请先在训练模块选择一个云端课程"
return 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) {}
}