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 */; };
|
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;
|
||||||
|
|||||||
@@ -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()
|
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 |
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 {
|
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))
|
||||||
|
|||||||
@@ -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,7 +14,9 @@ struct PlayerSectionView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
playerSurface
|
playerSurface
|
||||||
sentencePanel
|
if showsSubtitle?.wrappedValue == true {
|
||||||
|
sentencePanel
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user