added ios module

This commit is contained in:
2026-08-17 20:13:28 +08:00
parent a8031e2e3b
commit 7c81976b2b
27 changed files with 2767 additions and 0 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ sentence_api/data/oral_trainer.sqlite3*
sentence_api/data/v/
sentence_api/data/work/
sentence_api/data/attempts/
android/sample-app/release/

View File

@@ -122,6 +122,12 @@ SDK 基于 AndroidX Media3 / ExoPlayer支持在线视频流、本地缓存、
详见 [android/README.md](android/README.md)。
## iOS App
`ios/` 子工程是与安卓端对齐的 iOS 移动端SwiftUI + AVPlayer
在线课程播放、按句跳转、变速、横屏全屏、本地视频导入,以及麦克风录音
朗读评测,接口与安卓端完全一致。详见 [ios/README.md](ios/README.md)。
## 口语训练视频服务
`sentence_api/` 已扩展为完整的视频服务:支持超过 1 GB 的流式上传、MOSS 自动

View File

@@ -0,0 +1,433 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; };
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; };
AA0000000000000000000044 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000024 /* Config.swift */; };
AA0000000000000000000045 /* TrainingModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000025 /* TrainingModels.swift */; };
AA0000000000000000000046 /* AssessmentModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000026 /* AssessmentModels.swift */; };
AA0000000000000000000047 /* PlaybackModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000027 /* PlaybackModels.swift */; };
AA0000000000000000000048 /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000028 /* HTTPClient.swift */; };
AA0000000000000000000049 /* VideoCatalogAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000029 /* VideoCatalogAPI.swift */; };
AA000000000000000000004A /* SentenceBoundaryAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000002A /* SentenceBoundaryAPI.swift */; };
AA000000000000000000004B /* ImitationAssessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000002B /* ImitationAssessor.swift */; };
AA000000000000000000004C /* PlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000002C /* PlayerController.swift */; };
AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000002D /* PlayerSurfaceView.swift */; };
AA000000000000000000004E /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000002E /* Theme.swift */; };
AA000000000000000000004F /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000002F /* RootView.swift */; };
AA0000000000000000000050 /* TrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000030 /* TrainingView.swift */; };
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000031 /* PlayerSectionView.swift */; };
AA0000000000000000000052 /* CatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000032 /* CatalogView.swift */; };
AA0000000000000000000053 /* TestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000033 /* TestView.swift */; };
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000034 /* FullscreenPlayerView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AA0000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
AA0000000000000000000022 /* OralTrainerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OralTrainerApp.swift; sourceTree = "<group>"; };
AA0000000000000000000023 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
AA0000000000000000000024 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
AA0000000000000000000025 /* TrainingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingModels.swift; sourceTree = "<group>"; };
AA0000000000000000000026 /* AssessmentModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssessmentModels.swift; sourceTree = "<group>"; };
AA0000000000000000000027 /* PlaybackModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackModels.swift; sourceTree = "<group>"; };
AA0000000000000000000028 /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = "<group>"; };
AA0000000000000000000029 /* VideoCatalogAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCatalogAPI.swift; sourceTree = "<group>"; };
AA000000000000000000002A /* SentenceBoundaryAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBoundaryAPI.swift; sourceTree = "<group>"; };
AA000000000000000000002B /* ImitationAssessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImitationAssessor.swift; sourceTree = "<group>"; };
AA000000000000000000002C /* PlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerController.swift; sourceTree = "<group>"; };
AA000000000000000000002D /* PlayerSurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSurfaceView.swift; sourceTree = "<group>"; };
AA000000000000000000002E /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
AA000000000000000000002F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
AA0000000000000000000030 /* TrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingView.swift; sourceTree = "<group>"; };
AA0000000000000000000031 /* PlayerSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSectionView.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>"; };
AA0000000000000000000034 /* FullscreenPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenPlayerView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
AA000000000000000000000E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
AA0000000000000000000002 = {
isa = PBXGroup;
children = (
AA0000000000000000000004 /* OralTrainer */,
AA0000000000000000000003 /* Products */,
);
sourceTree = "<group>";
};
AA0000000000000000000003 /* Products */ = {
isa = PBXGroup;
children = (
AA000000000000000000000A /* OralTrainer.app */,
);
name = Products;
sourceTree = "<group>";
};
AA0000000000000000000004 /* OralTrainer */ = {
isa = PBXGroup;
children = (
AA0000000000000000000005 /* Models */,
AA0000000000000000000006 /* Services */,
AA0000000000000000000007 /* Player */,
AA0000000000000000000008 /* Views */,
AA0000000000000000000009 /* Resources */,
AA0000000000000000000022 /* OralTrainerApp.swift */,
AA0000000000000000000023 /* AppDelegate.swift */,
AA0000000000000000000024 /* Config.swift */,
);
path = OralTrainer;
sourceTree = "<group>";
};
AA0000000000000000000005 /* Models */ = {
isa = PBXGroup;
children = (
AA0000000000000000000025 /* TrainingModels.swift */,
AA0000000000000000000026 /* AssessmentModels.swift */,
AA0000000000000000000027 /* PlaybackModels.swift */,
);
path = Models;
sourceTree = "<group>";
};
AA0000000000000000000006 /* Services */ = {
isa = PBXGroup;
children = (
AA0000000000000000000028 /* HTTPClient.swift */,
AA0000000000000000000029 /* VideoCatalogAPI.swift */,
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
AA000000000000000000002B /* ImitationAssessor.swift */,
);
path = Services;
sourceTree = "<group>";
};
AA0000000000000000000007 /* Player */ = {
isa = PBXGroup;
children = (
AA000000000000000000002C /* PlayerController.swift */,
AA000000000000000000002D /* PlayerSurfaceView.swift */,
);
path = Player;
sourceTree = "<group>";
};
AA0000000000000000000008 /* Views */ = {
isa = PBXGroup;
children = (
AA000000000000000000002E /* Theme.swift */,
AA000000000000000000002F /* RootView.swift */,
AA0000000000000000000030 /* TrainingView.swift */,
AA0000000000000000000031 /* PlayerSectionView.swift */,
AA0000000000000000000032 /* CatalogView.swift */,
AA0000000000000000000033 /* TestView.swift */,
AA0000000000000000000034 /* FullscreenPlayerView.swift */,
);
path = Views;
sourceTree = "<group>";
};
AA0000000000000000000009 /* Resources */ = {
isa = PBXGroup;
children = (
AA0000000000000000000020 /* Assets.xcassets */,
AA000000000000000000001F /* Info.plist */,
);
path = Resources;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
AA000000000000000000000B /* OralTrainer */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "OralTrainer" */;
buildPhases = (
AA000000000000000000000C /* Sources */,
AA000000000000000000000E /* Frameworks */,
AA000000000000000000000D /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = OralTrainer;
productName = OralTrainer;
productReference = AA000000000000000000000A /* OralTrainer.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
AA0000000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
AA000000000000000000000B = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "OralTrainer" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = AA0000000000000000000002;
productRefGroup = AA0000000000000000000003 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
AA000000000000000000000B /* OralTrainer */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
AA000000000000000000000D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA0000000000000000000040 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
AA000000000000000000000C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */,
AA0000000000000000000043 /* AppDelegate.swift in Sources */,
AA0000000000000000000044 /* Config.swift in Sources */,
AA0000000000000000000045 /* TrainingModels.swift in Sources */,
AA0000000000000000000046 /* AssessmentModels.swift in Sources */,
AA0000000000000000000047 /* PlaybackModels.swift in Sources */,
AA0000000000000000000048 /* HTTPClient.swift in Sources */,
AA0000000000000000000049 /* VideoCatalogAPI.swift in Sources */,
AA000000000000000000004A /* SentenceBoundaryAPI.swift in Sources */,
AA000000000000000000004B /* ImitationAssessor.swift in Sources */,
AA000000000000000000004C /* PlayerController.swift in Sources */,
AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */,
AA000000000000000000004E /* Theme.swift in Sources */,
AA000000000000000000004F /* RootView.swift in Sources */,
AA0000000000000000000050 /* TrainingView.swift in Sources */,
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
AA0000000000000000000052 /* CatalogView.swift in Sources */,
AA0000000000000000000053 /* TestView.swift in Sources */,
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
AA0000000000000000000012 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
AA0000000000000000000013 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
AA0000000000000000000014 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = OralTrainer/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.learningpad.oraltrainer.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA0000000000000000000015 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = OralTrainer/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.learningpad.oraltrainer.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
AA0000000000000000000010 /* Build configuration list for PBXProject "OralTrainer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000012 /* Debug */,
AA0000000000000000000013 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "OralTrainer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000014 /* Debug */,
AA0000000000000000000015 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = AA0000000000000000000001 /* Project object */;
}

View File

@@ -0,0 +1,12 @@
import UIKit
final class AppDelegate: NSObject, UIApplicationDelegate {
static var orientationLock: UIInterfaceOrientationMask = .portrait
func application(
_ application: UIApplication,
supportedInterfaceOrientationsFor window: UIWindow?
) -> UIInterfaceOrientationMask {
AppDelegate.orientationLock
}
}

View File

@@ -0,0 +1,20 @@
import Foundation
enum AppConfig {
/// IP
/// / http://<IP>:<> http://192.168.1.100:80
static let serverBaseURL = "https://videoservice.d1kt.cn"
/// sentence_api/.env CLIENT_API_KEY
static let assessmentAPIKey = "fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220"
/// HTTPS TLS HTTP/
static let allowHTTPFallback = true
static let userAgent = "OralTrainerKit/0.1.0 (iOS)"
static let connectTimeout: TimeInterval = 15
static let readTimeout: TimeInterval = 30
static let assessmentTimeout: TimeInterval = 180
static let sha256Pattern = #"^[0-9a-fA-F]{64}$"#
}

View File

@@ -0,0 +1,98 @@
import Foundation
struct ImitationAssessmentRequest {
let mediaId: String
let sentence: SentenceBoundary
let recordingURL: URL
let locale: String?
}
struct TextSubstitution: Decodable {
let expected: String
let actual: String
}
struct ImitationAssessmentResult: Decodable {
let attemptId: String?
let scoringVersion: String?
let overallScore: Float
let passed: Bool?
let passScore: Float?
let contentScore: Float?
let completenessScore: Float?
let fluencyScore: Float?
let pronunciationScore: Float?
let prosodyScore: Float?
let durationScore: Float?
let pauseScore: Float?
let speechRateScore: Float?
let referenceText: String?
let recognizedText: String?
let referenceDurationMs: Int64?
let referenceSpeechDurationMs: Int64?
let studentRecordingDurationMs: Int64?
let studentSpeechDurationMs: Int64?
let durationRatio: Float?
let missingTokens: [String]
let extraTokens: [String]
let substitutions: [TextSubstitution]
let feedback: String?
let details: [String: String]
private enum CodingKeys: String, CodingKey {
case attemptId = "attempt_id"
case scoringVersion = "scoring_version"
case overallScore = "overall_score"
case passed
case passScore = "pass_score"
case contentScore = "content_score"
case completenessScore = "completeness_score"
case fluencyScore = "fluency_score"
case pronunciationScore = "pronunciation_score"
case prosodyScore = "prosody_score"
case durationScore = "duration_score"
case pauseScore = "pause_score"
case speechRateScore = "speech_rate_score"
case referenceText = "reference_text"
case recognizedText = "recognized_text"
case referenceDurationMs = "reference_duration_ms"
case referenceSpeechDurationMs = "reference_speech_duration_ms"
case studentRecordingDurationMs = "student_recording_duration_ms"
case studentSpeechDurationMs = "student_speech_duration_ms"
case durationRatio = "duration_ratio"
case missingTokens = "missing_tokens"
case extraTokens = "extra_tokens"
case substitutions
case feedback
case details
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
attemptId = try container.decodeIfPresent(String.self, forKey: .attemptId)
scoringVersion = try container.decodeIfPresent(String.self, forKey: .scoringVersion)
overallScore = try container.decodeIfPresent(Float.self, forKey: .overallScore) ?? 0
passed = try container.decodeIfPresent(Bool.self, forKey: .passed)
passScore = try container.decodeIfPresent(Float.self, forKey: .passScore)
contentScore = try container.decodeIfPresent(Float.self, forKey: .contentScore)
completenessScore = try container.decodeIfPresent(Float.self, forKey: .completenessScore)
fluencyScore = try container.decodeIfPresent(Float.self, forKey: .fluencyScore)
pronunciationScore = try container.decodeIfPresent(Float.self, forKey: .pronunciationScore)
prosodyScore = try container.decodeIfPresent(Float.self, forKey: .prosodyScore)
durationScore = try container.decodeIfPresent(Float.self, forKey: .durationScore)
pauseScore = try container.decodeIfPresent(Float.self, forKey: .pauseScore)
speechRateScore = try container.decodeIfPresent(Float.self, forKey: .speechRateScore)
referenceText = try container.decodeIfPresent(String.self, forKey: .referenceText)
recognizedText = try container.decodeIfPresent(String.self, forKey: .recognizedText)
referenceDurationMs = try container.decodeIfPresent(Int64.self, forKey: .referenceDurationMs)
referenceSpeechDurationMs = try container.decodeIfPresent(Int64.self, forKey: .referenceSpeechDurationMs)
studentRecordingDurationMs = try container.decodeIfPresent(Int64.self, forKey: .studentRecordingDurationMs)
studentSpeechDurationMs = try container.decodeIfPresent(Int64.self, forKey: .studentSpeechDurationMs)
durationRatio = try container.decodeIfPresent(Float.self, forKey: .durationRatio)
missingTokens = try container.decodeIfPresent([String].self, forKey: .missingTokens) ?? []
extraTokens = try container.decodeIfPresent([String].self, forKey: .extraTokens) ?? []
substitutions = try container.decodeIfPresent([TextSubstitution].self, forKey: .substitutions) ?? []
feedback = try container.decodeIfPresent(String.self, forKey: .feedback)
details = try container.decodeIfPresent([String: String].self, forKey: .details) ?? [:]
}
}

View File

@@ -0,0 +1,58 @@
import Foundation
enum PlaybackState: String {
case idle
case buffering
case ready
case ended
}
struct PlaybackSnapshot: Equatable {
var mediaId: String?
var positionMs: Int64 = 0
var durationMs: Int64 = -1
var bufferedPositionMs: Int64 = 0
var isPlaying = false
var state: PlaybackState = .idle
var speed: Float = 1
var sentenceIndex: Int?
}
struct PlayerConfig {
var sentenceMode = true
var defaultSeekStepMs: Int64 = 10_000
var autoPlay = false
var continuousPlayback = true
var minPlaybackSpeed: Float = 0.5
var maxPlaybackSpeed: Float = 2.0
}
enum LoopMode {
case off
case one
case all
}
enum SwipeAction {
case none
case rewind
case forward
case previousSentence
case nextSentence
case previousSentenceOrRewind
case nextSentenceOrForward
}
enum GestureKind {
case singleTap
case swipeLeft
case swipeRight
case longPressSpeed
}
enum BoundaryState: Equatable {
case idle
case loading(isLocal: Bool)
case loaded(count: Int)
case failed(message: String)
}

View File

@@ -0,0 +1,95 @@
import Foundation
struct SentenceBoundary: Identifiable, Equatable, Decodable {
let index: Int
let startMs: Int64
let endMs: Int64
let text: String?
let language: String?
let referenceSpeechDurationMs: Int64?
var id: Int { index }
private enum CodingKeys: String, CodingKey {
case index, text, language
case startMs = "start_ms"
case endMs = "end_ms"
case referenceSpeechDurationMs = "reference_speech_duration_ms"
}
}
struct SentenceBoundaryDocument: Decodable {
let videoHash: String
let durationMs: Int64
let algorithmVersion: String
let sentences: [SentenceBoundary]
private enum CodingKeys: String, CodingKey {
case videoHash = "video_hash"
case durationMs = "duration_ms"
case algorithmVersion = "algorithm_version"
case sentences
}
}
struct TrainingVideoSummary: Identifiable, Decodable {
let videoHash: String
let title: String
let streamUrl: String
let durationMs: Int64?
let sizeBytes: Int64
let language: String?
let sentenceCount: Int
let status: String
var id: String { videoHash }
private enum CodingKeys: String, CodingKey {
case title, language, status
case videoHash = "video_hash"
case streamUrl = "stream_url"
case durationMs = "duration_ms"
case sizeBytes = "size_bytes"
case sentenceCount = "sentence_count"
}
/// http 使 http
func resolvedStreamURL(baseURL: String) -> URL? {
guard let base = URL(string: baseURL) else { return nil }
var absolute = URL(string: streamUrl, relativeTo: base)?.absoluteString ?? streamUrl
if base.scheme == "http" {
absolute = absolute.replacingOccurrences(
of: "https://",
with: "http://",
options: [.anchored]
)
}
return URL(string: absolute)
}
}
struct TrainingMediaItem: Identifiable, Equatable {
let id: String
let title: String
let url: URL
let sentences: [SentenceBoundary]
let isLocalFile: Bool
init(
id: String,
title: String,
url: URL,
sentences: [SentenceBoundary] = [],
isLocalFile: Bool = false
) {
self.id = id
self.title = title
self.url = url
self.sentences = sentences
self.isLocalFile = isLocalFile
}
static func == (lhs: TrainingMediaItem, rhs: TrainingMediaItem) -> Bool {
lhs.id == rhs.id
}
}

View File

@@ -0,0 +1,12 @@
import SwiftUI
@main
struct OralTrainerApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
RootView()
}
}
}

View File

@@ -0,0 +1,427 @@
import AVFoundation
import Combine
import Foundation
/// OralTrainerController
/// 线KVOUI 线
final class PlayerController: ObservableObject {
@Published private(set) var snapshot = PlaybackSnapshot()
@Published private(set) var currentItem: TrainingMediaItem?
@Published private(set) var currentSentence: SentenceBoundary?
@Published var boundaryState: BoundaryState = .idle
@Published var transientMessage: String?
let player = AVPlayer()
var config = PlayerConfig()
var loopMode: LoopMode = .all
private var mediaItem: TrainingMediaItem?
private var lastSentenceIndex: Int?
private var stopAtSentenceEnd = false
private var stopAtSentenceEndIndex: Int?
private var speedBeforeLongPress: Float?
private var transientTask: Task<Void, Never>?
private var timeObserver: Any?
private var endObserver: NSObjectProtocol?
private var itemStatusObserver: NSKeyValueObservation?
private var timeControlObserver: NSKeyValueObservation?
init() {
configureAudioSession()
player.automaticallyWaitsToMinimizeStalling = true
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
queue: .main
) { [weak self] _ in
self?.tick()
}
endObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.handleEnded()
}
timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in
DispatchQueue.main.async {
self?.tick()
}
}
tick()
}
deinit {
if let timeObserver = timeObserver {
player.removeTimeObserver(timeObserver)
}
if let endObserver = endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
itemStatusObserver?.invalidate()
timeControlObserver?.invalidate()
}
// MARK: -
func load(_ item: TrainingMediaItem) {
lastSentenceIndex = nil
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
mediaItem = item
currentItem = item
boundaryState = .idle
let playerItem = AVPlayerItem(url: item.url)
attachItemObservers(to: playerItem)
player.replaceCurrentItem(with: playerItem)
if config.autoPlay {
player.play()
}
tick()
}
// MARK: -
func play() {
player.play()
}
func pause() {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
player.pause()
}
func togglePlayPause() {
if player.timeControlStatus == .playing {
pause()
} else {
play()
}
}
func stop() {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
player.pause()
player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
}
func seek(to positionMs: Int64) {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600)
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
}
func seekRelative(_ deltaMs: Int64) {
seek(to: snapshot.positionMs + deltaMs)
}
func rewind() {
seekRelative(-max(0, config.defaultSeekStepMs))
}
func fastForward() {
seekRelative(max(0, config.defaultSeekStepMs))
}
// MARK: -
@discardableResult
func seekToPreviousSentence() -> Bool {
guard let item = mediaItem, !item.sentences.isEmpty else {
return false
}
let position = snapshot.positionMs
let target = item.sentences.reversed().first { $0.startMs < position - 250 }
?? item.sentences.first
seek(to: target.startMs)
return true
}
@discardableResult
func seekToNextSentence() -> Bool {
guard let item = mediaItem, !item.sentences.isEmpty else {
return false
}
let position = snapshot.positionMs
guard let target = item.sentences.first(where: { $0.startMs > position + 150 }) else {
return false
}
seek(to: target.startMs)
return true
}
func previousSentenceOrRewind() {
if !config.sentenceMode || !seekToPreviousSentence() {
rewind()
}
}
func nextSentenceOrForward() {
if !config.sentenceMode || !seekToNextSentence() {
fastForward()
}
}
///
func playCurrentSentenceAndStop() {
guard let sentence = currentSentence else {
return
}
let time = CMTime(seconds: Double(sentence.startMs) / 1000, preferredTimescale: 600)
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
stopAtSentenceEnd = true
stopAtSentenceEndIndex = sentence.index
play()
}
// MARK: -
func setContinuousPlayback(_ enabled: Bool) {
config.continuousPlayback = enabled
}
func setSentenceMode(_ enabled: Bool) {
config.sentenceMode = enabled
}
func setLoopMode(_ mode: LoopMode) {
loopMode = mode
}
func setPlaybackSpeed(_ speed: Float) {
let clamped = min(config.maxPlaybackSpeed, max(config.minPlaybackSpeed, speed))
if player.timeControlStatus == .playing {
player.rate = clamped
} else {
player.defaultRate = clamped
}
tick()
}
// MARK: - GestureControlsConfig
func beginLongPressSpeed(atX x: CGFloat, width: CGFloat) {
speedBeforeLongPress = snapshot.speed
setPlaybackSpeed(speedForTouchX(x, width: width))
showTransientMessage("变速播放中")
}
func updateLongPressSpeed(atX x: CGFloat, width: CGFloat) {
setPlaybackSpeed(speedForTouchX(x, width: width))
}
func endLongPressSpeed() {
if let speed = speedBeforeLongPress {
setPlaybackSpeed(speed)
}
speedBeforeLongPress = nil
}
private func speedForTouchX(_ x: CGFloat, width: CGFloat) -> Float {
guard width > 0 else {
return 1
}
let center = width / 2
let distanceRatio = abs(x - center) / center
let farFromCenter = distanceRatio >= 0.5
if x < center {
return farFromCenter ? 0.5 : 0.75
}
return farFromCenter ? 2.0 : 1.5
}
// MARK: -
func handleGesture(_ kind: GestureKind) {
switch kind {
case .singleTap:
togglePlayPause()
showTransientMessage("播放状态已切换")
case .swipeLeft:
previousSentenceOrRewind()
showTransientMessage("已跳到上一句")
case .swipeRight:
nextSentenceOrForward()
showTransientMessage("已跳到下一句")
case .longPressSpeed:
break
}
}
func showTransientMessage(_ message: String) {
transientMessage = message
transientTask?.cancel()
transientTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 1_500_000_000)
guard !Task.isCancelled else {
return
}
self?.transientMessage = nil
}
}
// MARK: -
private func tick() {
guard let item = player.currentItem else {
snapshot = PlaybackSnapshot(mediaId: mediaItem?.id, state: .idle, speed: currentSpeed())
return
}
let position = currentPositionMs()
enforceSentenceBoundary(positionMs: position)
let finalPosition = currentPositionMs()
let sentence = sentence(at: finalPosition)
if sentence?.index != lastSentenceIndex {
lastSentenceIndex = sentence?.index
}
let duration = durationMs(of: item)
let state = playbackState(of: item, positionMs: finalPosition, durationMs: duration)
snapshot = PlaybackSnapshot(
mediaId: mediaItem?.id,
positionMs: finalPosition,
durationMs: duration,
bufferedPositionMs: bufferedPositionMs(of: item),
isPlaying: player.timeControlStatus == .playing,
state: state,
speed: currentSpeed(),
sentenceIndex: sentence?.index
)
currentSentence = sentence
}
private func currentPositionMs() -> Int64 {
let seconds = player.currentTime().seconds
guard seconds.isFinite else {
return 0
}
return max(0, Int64(seconds * 1000))
}
private func durationMs(of item: AVPlayerItem) -> Int64 {
let seconds = item.duration.seconds
guard seconds.isFinite, seconds > 0 else {
return -1
}
return Int64(seconds * 1000)
}
private func bufferedPositionMs(of item: AVPlayerItem) -> Int64 {
guard let range = item.loadedTimeRanges.last?.timeRangeValue else {
return 0
}
let seconds = range.end.seconds
guard seconds.isFinite else {
return 0
}
return max(0, Int64(seconds * 1000))
}
private func currentSpeed() -> Float {
let rate = player.rate
return rate == 0 ? player.defaultRate : rate
}
private func playbackState(of item: AVPlayerItem, positionMs: Int64, durationMs: Int64) -> PlaybackState {
if item.status == .failed {
return .idle
}
switch player.timeControlStatus {
case .waitingToPlayAtSpecifiedRate:
return .buffering
case .playing:
return .ready
case .paused:
if durationMs > 0, positionMs >= durationMs {
return .ended
}
return .ready
@unknown default:
return .idle
}
}
private func sentence(at positionMs: Int64) -> SentenceBoundary? {
guard let item = mediaItem else {
return nil
}
return item.sentences.last { sentence in
positionMs >= sentence.startMs && positionMs < sentence.endMs
}
}
/// playCurrentSentenceAndStop
private func enforceSentenceBoundary(positionMs: Int64) {
guard config.sentenceMode,
player.timeControlStatus == .playing,
let item = mediaItem,
!item.sentences.isEmpty else {
return
}
if stopAtSentenceEnd {
guard let index = stopAtSentenceEndIndex,
let sentence = item.sentences.first(where: { $0.index == index }) else {
return
}
if positionMs >= sentence.endMs {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
player.pause()
seekRaw(sentence.startMs)
}
return
}
if config.continuousPlayback {
return
}
guard let anchorIndex = lastSentenceIndex,
let anchor = item.sentences.first(where: { $0.index == anchorIndex }) else {
return
}
if positionMs >= anchor.endMs {
seekRaw(anchor.startMs)
}
}
private func seekRaw(_ positionMs: Int64) {
let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600)
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
}
private func handleEnded() {
guard loopMode != .off else {
tick()
return
}
player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
player.play()
tick()
}
// MARK: -
private func attachItemObservers(to item: AVPlayerItem) {
itemStatusObserver?.invalidate()
itemStatusObserver = item.observe(\.status, options: [.new]) { [weak self] item, _ in
DispatchQueue.main.async {
guard let self = self else {
return
}
if item.status == .failed {
let message = item.error?.localizedDescription ?? "未知错误"
self.showTransientMessage("播放失败:\(message)")
}
self.tick()
}
}
}
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, mode: .moviePlayback)
try? session.setActive(true)
}
}

View File

@@ -0,0 +1,106 @@
import AVFoundation
import SwiftUI
import UIKit
/// OralTrainerPlayerView
/// - /
/// - / / 退退 /
/// -
struct PlayerSurfaceView: UIViewRepresentable {
let controller: PlayerController
func makeUIView(context: Context) -> VideoSurfaceView {
let view = VideoSurfaceView()
view.playerLayer.player = controller.player
view.controller = controller
return view
}
func updateUIView(_ uiView: VideoSurfaceView, context: Context) {
uiView.playerLayer.player = controller.player
}
}
final class VideoSurfaceView: UIView {
weak var controller: PlayerController?
override class var layerClass: AnyClass {
AVPlayerLayer.self
}
var playerLayer: AVPlayerLayer {
layer as! AVPlayerLayer
}
private let minSwipeDistance: CGFloat = 48
private let minSwipeVelocity: CGFloat = 160
private let longPressTimeout: TimeInterval = 0.5
private var tapRecognizer: UITapGestureRecognizer!
private var panRecognizer: UIPanGestureRecognizer!
private var longPressRecognizer: UILongPressGestureRecognizer!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
playerLayer.videoGravity = .resizeAspect
tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
panRecognizer.minimumNumberOfTouches = 1
panRecognizer.maximumNumberOfTouches = 1
longPressRecognizer = UILongPressGestureRecognizer(
target: self,
action: #selector(handleLongPress)
)
longPressRecognizer.minimumPressDuration = longPressTimeout
tapRecognizer.require(toFail: panRecognizer)
tapRecognizer.require(toFail: longPressRecognizer)
panRecognizer.require(toFail: longPressRecognizer)
addGestureRecognizer(tapRecognizer)
addGestureRecognizer(panRecognizer)
addGestureRecognizer(longPressRecognizer)
isUserInteractionEnabled = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func handleTap() {
controller?.handleGesture(.singleTap)
}
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
guard recognizer.state == .ended || recognizer.state == .cancelled else {
return
}
let translation = recognizer.translation(in: self)
let velocity = recognizer.velocity(in: self)
let dx = translation.x
let dy = translation.y
guard abs(dx) >= abs(dy),
abs(dx) >= minSwipeDistance,
abs(velocity.x) >= minSwipeVelocity else {
return
}
controller?.handleGesture(dx > 0 ? .swipeRight : .swipeLeft)
}
@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
let location = recognizer.location(in: self)
switch recognizer.state {
case .began:
controller?.beginLongPressSpeed(atX: location.x, width: bounds.width)
case .changed:
controller?.updateLongPressSpeed(atX: location.x, width: bounds.width)
case .ended, .cancelled:
controller?.endLongPressSpeed()
default:
break
}
}
}

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.518",
"green" : "0.722",
"red" : "0.082"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,50 @@
<?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>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<key>CFBundleDisplayName</key>
<string>跟读虫</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>需要麦克风权限才能进行录音评测</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,133 @@
import Foundation
enum HTTPClientError: LocalizedError {
case invalidURL
case badStatus(Int, String)
var errorDescription: String? {
switch self {
case .invalidURL:
return "无效的服务地址"
case .badStatus(let code, let body):
return "HTTP \(code): \(body)"
}
}
}
struct HTTPResult {
let statusCode: Int
let body: Data
let effectiveBaseURL: String
}
enum HTTPClient {
static func getJSON<T: Decodable>(
_ type: T.Type,
baseURL: String,
path: String,
timeout: TimeInterval
) async throws -> (value: T, effectiveBaseURL: String) {
let result = try await execute(
baseURL: baseURL,
path: path,
method: "GET",
timeout: timeout,
headers: ["Accept": "application/json"]
)
guard (200...299).contains(result.statusCode) else {
let body = String(data: result.body, encoding: .utf8) ?? ""
throw HTTPClientError.badStatus(result.statusCode, body)
}
let value = try JSONDecoder().decode(T.self, from: result.body)
return (value, result.effectiveBaseURL)
}
static func execute(
baseURL: String,
path: String,
method: String,
timeout: TimeInterval,
headers: [String: String] = [:],
body: Data? = nil
) async throws -> HTTPResult {
let trimmedBase = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let base = URL(string: trimmedBase), base.scheme != nil, base.host != nil else {
throw HTTPClientError.invalidURL
}
let endpoint = URL(string: path, relativeTo: base) ?? base
do {
return try await perform(
url: endpoint,
method: method,
timeout: timeout,
headers: headers,
body: body,
effectiveBaseURL: trimmedBase
)
} catch {
// HTTPS TLS HTTP
guard AppConfig.allowHTTPFallback,
endpoint.scheme == "https",
isTLSError(error) else {
throw error
}
var baseComponents = URLComponents(string: trimmedBase)
baseComponents?.scheme = "http"
guard let httpBase = baseComponents?.string,
let httpURL = URL(string: path, relativeTo: URL(string: httpBase)) else {
throw error
}
return try await perform(
url: httpURL,
method: method,
timeout: timeout,
headers: headers,
body: body,
effectiveBaseURL: httpBase
)
}
}
private static func perform(
url: URL,
method: String,
timeout: TimeInterval,
headers: [String: String],
body: Data?,
effectiveBaseURL: String
) async throws -> HTTPResult {
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = timeout
request.setValue(AppConfig.userAgent, forHTTPHeaderField: "User-Agent")
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
request.httpBody = body
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw HTTPClientError.invalidURL
}
return HTTPResult(statusCode: http.statusCode, body: data, effectiveBaseURL: effectiveBaseURL)
}
private static func isTLSError(_ error: Error) -> Bool {
let nsError = error as NSError
let tlsCodes: [Int] = [
NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid,
NSURLErrorClientCertificateRejected,
NSURLErrorClientCertificateRequired,
NSURLErrorCannotLoadFromNetwork,
]
if tlsCodes.contains(nsError.code) {
return true
}
let message = (nsError.localizedDescription + " " + (nsError.localizedFailureReason ?? ""))
.lowercased()
return message.contains("ssl") || message.contains("tls") || message.contains("certificate")
}
}

View File

@@ -0,0 +1,124 @@
import CryptoKit
import Foundation
enum ImitationAssessorError: LocalizedError {
case invalidHash
case cannotReadRecording
var errorDescription: String? {
switch self {
case .invalidHash:
return "视频 id 必须是 64 位 SHA-256 十六进制字符串"
case .cannotReadRecording:
return "无法读取录音文件"
}
}
}
enum ImitationAssessor {
/// SHA-256
static func sha256Hex(of fileURL: URL) throws -> String {
let handle: FileHandle
do {
handle = try FileHandle(forReadingFrom: fileURL)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
defer { try? handle.close() }
var hasher = SHA256()
while true {
guard let data = try? handle.read(upToCount: 1 << 20), !data.isEmpty else {
break
}
hasher.update(data: data)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
/// SHA-256
static func copyAndHash(from source: URL, to destination: URL) throws -> String {
let input: FileHandle
do {
input = try FileHandle(forReadingFrom: source)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
defer { try? input.close() }
try? FileManager.default.removeItem(at: destination)
FileManager.default.createFile(atPath: destination.path, contents: nil)
let output = try FileHandle(forWritingTo: destination)
defer { try? output.close() }
var hasher = SHA256()
while true {
guard let data = try input.read(upToCount: 1 << 20), !data.isEmpty else {
break
}
hasher.update(data: data)
try output.write(contentsOf: data)
}
try output.synchronize()
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
/// MOSS multipart/form-data
static func assess(request: ImitationAssessmentRequest) async throws -> ImitationAssessmentResult {
let hash = request.mediaId.lowercased()
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
throw ImitationAssessorError.invalidHash
}
let path = "api/v1/videos/\(hash)/sentences/\(request.sentence.index)/assessments"
let boundary = "----OralTrainer-\(UUID().uuidString)"
var body = Data()
appendField(&body, boundary: boundary, name: "language", value: request.locale ?? request.sentence.language ?? "")
let recordingData: Data
do {
recordingData = try Data(contentsOf: request.recordingURL)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
let filename = request.recordingURL.lastPathComponent
let mimeType = request.recordingURL.pathExtension.lowercased() == "wav" ? "audio/wav" : "audio/m4a"
appendFile(&body, boundary: boundary, name: "audio", filename: filename, mimeType: mimeType, data: recordingData)
body.append(Data("--\(boundary)--\r\n".utf8))
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: path,
method: "POST",
timeout: AppConfig.assessmentTimeout,
headers: [
"Accept": "application/json",
"Content-Type": "multipart/form-data; boundary=\(boundary)",
"X-Client-Key": AppConfig.assessmentAPIKey,
],
body: body
)
guard (200...299).contains(result.statusCode) else {
let responseBody = String(data: result.body, encoding: .utf8) ?? ""
throw HTTPClientError.badStatus(result.statusCode, responseBody)
}
return try JSONDecoder().decode(ImitationAssessmentResult.self, from: result.body)
}
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))
body.append(Data(value.utf8))
body.append(Data("\r\n".utf8))
}
private static func appendFile(
_ body: inout Data,
boundary: String,
name: String,
filename: String,
mimeType: String,
data: Data
) {
body.append(Data("--\(boundary)\r\n".utf8))
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".utf8))
body.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
body.append(data)
body.append(Data("\r\n".utf8))
}
}

View File

@@ -0,0 +1,34 @@
import Foundation
enum SentenceBoundaryAPIError: LocalizedError {
case invalidHash
case hashMismatch
var errorDescription: String? {
switch self {
case .invalidHash:
return "video_hash 必须是 64 位 SHA-256 十六进制字符串"
case .hashMismatch:
return "服务端返回的句边界哈希与请求不一致"
}
}
}
enum SentenceBoundaryAPI {
static func fetch(videoHash: String) async throws -> SentenceBoundaryDocument {
let hash = videoHash.lowercased()
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
throw SentenceBoundaryAPIError.invalidHash
}
let result = try await HTTPClient.getJSON(
SentenceBoundaryDocument.self,
baseURL: AppConfig.serverBaseURL,
path: "api/v1/videos/\(hash)/sentence-boundaries",
timeout: AppConfig.readTimeout
)
guard result.value.videoHash.lowercased() == hash else {
throw SentenceBoundaryAPIError.hashMismatch
}
return result.value
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
struct VideoCatalogResponse: Decodable {
let videos: [TrainingVideoSummary]
}
enum VideoCatalogAPI {
static func fetch() async throws -> (videos: [TrainingVideoSummary], baseURL: String) {
let result = try await HTTPClient.getJSON(
VideoCatalogResponse.self,
baseURL: AppConfig.serverBaseURL,
path: "api/v1/videos",
timeout: AppConfig.readTimeout
)
let ready = result.value.videos.filter { $0.status == "ready" }
return (ready, result.effectiveBaseURL)
}
}

View File

@@ -0,0 +1,184 @@
import SwiftUI
@MainActor
final class CatalogViewModel: ObservableObject {
@Published private(set) var videos: [TrainingVideoSummary] = []
@Published private(set) var statusText = "正在同步"
@Published private(set) var activeVideoHash: String?
weak var controller: PlayerController?
private var baseURL = AppConfig.serverBaseURL
private var hasLoaded = false
func loadIfNeeded() async {
guard !hasLoaded else {
return
}
hasLoaded = true
await load()
}
func load() async {
statusText = "正在同步"
do {
let (fetched, base) = try await VideoCatalogAPI.fetch()
baseURL = base
videos = fetched
statusText = fetched.isEmpty ? "暂无云端课程" : "\(fetched.count) 个云端课程"
} catch {
statusText = "云端暂不可用:\(error.localizedDescription)"
}
}
/// loadRemoteVideo()
func select(_ video: TrainingVideoSummary) {
guard let controller = controller else {
return
}
activeVideoHash = video.videoHash
guard let url = video.resolvedStreamURL(baseURL: baseURL) else {
controller.boundaryState = .failed(message: "无法解析视频流地址")
return
}
let loadingItem = TrainingMediaItem(id: video.videoHash, title: video.title, url: url)
controller.load(loadingItem)
controller.boundaryState = .loading(isLocal: false)
controller.showTransientMessage("正在加载云端课程...")
Task {
do {
let document = try await SentenceBoundaryAPI.fetch(videoHash: video.videoHash)
guard activeVideoHash == video.videoHash else {
return
}
let item = TrainingMediaItem(
id: video.videoHash,
title: video.title,
url: url,
sentences: document.sentences
)
controller.load(item)
controller.boundaryState = .loaded(count: document.sentences.count)
controller.showTransientMessage("课程已就绪:\(document.sentences.count)")
} catch {
guard activeVideoHash == video.videoHash else {
return
}
controller.boundaryState = .failed(message: error.localizedDescription)
controller.showTransientMessage("句子边界加载失败:\(error.localizedDescription)")
}
}
}
/// onActivityResult SHA-256
func importVideo(at url: URL) async {
guard let controller = controller else {
return
}
let didAccess = url.startAccessingSecurityScopedResource()
defer {
if didAccess {
url.stopAccessingSecurityScopedResource()
}
}
controller.showTransientMessage("正在获取本地视频的句子边界...")
do {
let fileName = url.lastPathComponent
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let tempURL = cacheDir.appendingPathComponent("import-\(UUID().uuidString)-\(fileName)")
let hash = try ImitationAssessor.copyAndHash(from: url, to: tempURL)
let item = TrainingMediaItem(
id: hash,
title: fileName,
url: tempURL,
isLocalFile: true
)
activeVideoHash = hash
controller.load(item)
controller.boundaryState = .loading(isLocal: true)
do {
let document = try await SentenceBoundaryAPI.fetch(videoHash: hash)
guard activeVideoHash == hash else {
return
}
let loadedItem = TrainingMediaItem(
id: hash,
title: fileName,
url: tempURL,
sentences: document.sentences,
isLocalFile: true
)
controller.load(loadedItem)
controller.boundaryState = .loaded(count: document.sentences.count)
controller.showTransientMessage("句子边界已加载:\(document.sentences.count)")
} catch {
guard activeVideoHash == hash else {
return
}
controller.boundaryState = .failed(message: error.localizedDescription)
controller.showTransientMessage("句子边界获取失败,使用默认快退/快进:\(error.localizedDescription)")
}
} catch {
controller.showTransientMessage("本地视频导入失败:\(error.localizedDescription)")
}
}
}
struct CatalogView: View {
@ObservedObject var vm: CatalogViewModel
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 0) {
Text("课程")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
Spacer()
Text(vm.statusText)
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.lineLimit(2)
.multilineTextAlignment(.trailing)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(vm.videos) { video in
catalogCard(video)
}
}
.padding(.vertical, 2)
}
}
.padding(.horizontal, 16)
.task {
await vm.loadIfNeeded()
}
}
private func catalogCard(_ video: TrainingVideoSummary) -> some View {
let selected = vm.activeVideoHash == video.videoHash
return Button {
vm.select(video)
} label: {
VStack(alignment: .leading, spacing: 6) {
Text(video.title)
.font(.system(size: 15, weight: .bold))
.foregroundColor(selected ? Theme.textDark : .white)
.lineLimit(2)
.multilineTextAlignment(.leading)
Text("\(video.sentenceCount) 句 · \(Theme.formatTime(video.durationMs ?? -1))")
.font(.system(size: 12))
.foregroundColor(selected ? Theme.accentDeep : Theme.textMuted)
}
.padding(.horizontal, 14)
.frame(width: 218, height: 84, alignment: .leading)
.background(selected ? Theme.selected : Theme.surface)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(selected ? Theme.accent : Theme.border, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,136 @@
import SwiftUI
/// + + 退
struct FullscreenPlayerView: View {
@ObservedObject var controller: PlayerController
let onExit: () -> Void
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
PlayerSurfaceView(controller: controller)
.ignoresSafeArea()
VStack {
Spacer()
VStack(alignment: .leading, spacing: 6) {
Text(controller.currentItem?.title ?? "未选择课程")
.font(.system(size: 17, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
Text(sentenceMetaText)
.font(.system(size: 13))
.foregroundColor(Theme.textOverlay)
Text(sentenceTextText)
.font(.system(size: 15))
.foregroundColor(.white)
.lineLimit(2)
progressRow
Text(statusText)
.font(.system(size: 13))
.foregroundColor(Theme.textOverlay)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.overlay)
}
VStack {
Spacer()
HStack {
Spacer()
Button(action: onExit) {
Image(systemName: "arrow.down.right.and.arrow.up.left")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
.frame(width: 44, height: 44)
.background(Theme.overlay)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
}
.padding(16)
}
}
@State private var progressDragging = false
@State private var dragPositionMs: Int64 = 0
private var progressRow: some View {
let duration = controller.snapshot.durationMs
return HStack(spacing: 8) {
Text(Theme.formatTime(controller.snapshot.positionMs))
.font(.system(size: 13))
.foregroundColor(Theme.textOverlay)
Slider(
value: progressBinding,
in: 0...max(1, Double(duration)),
onEditingChanged: { editing in
if editing {
progressDragging = true
} else {
progressDragging = false
controller.seek(to: dragPositionMs)
}
}
)
.tint(Theme.accentLight)
Text(Theme.formatTime(controller.snapshot.durationMs))
.font(.system(size: 13))
.foregroundColor(Theme.textOverlay)
}
}
private var progressBinding: Binding<Double> {
Binding(
get: {
if progressDragging {
return Double(dragPositionMs)
}
return Double(controller.snapshot.positionMs)
},
set: { newValue in
dragPositionMs = Int64(newValue)
}
)
}
private var sentenceMetaText: String {
switch controller.boundaryState {
case .loading(let isLocal):
return isLocal ? "句子边界分析中" : "句子边界加载中"
case .failed:
return "暂无句子边界"
case .idle, .loaded:
guard let sentence = controller.currentSentence else {
return "暂无句子边界"
}
let count = controller.currentItem?.sentences.count ?? 0
let countText = count > 0 ? " / \(count)" : ""
return "\(sentence.index + 1)\(countText)\(Theme.formatTime(sentence.startMs))-\(Theme.formatTime(sentence.endMs))"
}
}
private var sentenceTextText: String {
switch controller.boundaryState {
case .loading(let isLocal):
return isLocal ? "正在匹配服务端句子边界" : (controller.currentItem?.title ?? "加载中")
case .failed:
return controller.currentItem?.title ?? "未选择课程"
case .idle, .loaded:
guard let sentence = controller.currentSentence else {
return controller.currentItem?.title ?? "未选择课程"
}
if let text = sentence.text, !text.isEmpty {
return text
}
return "当前句子"
}
}
private var statusText: String {
if let message = controller.transientMessage {
return message
}
return controller.playbackStatusText(controller.snapshot)
}
}

View File

@@ -0,0 +1,194 @@
import SwiftUI
/// + + /
struct PlayerSectionView: View {
@ObservedObject var controller: PlayerController
var showsContinuousToggle: Bool
@Binding var continuousPlayback: Bool
var onFullscreen: () -> Void
@State private var progressDragging = false
@State private var dragPositionMs: Int64 = 0
var body: some View {
VStack(spacing: 0) {
playerSurface
sentencePanel
}
.padding(.horizontal, 16)
}
private var playerSurface: some View {
ZStack {
PlayerSurfaceView(controller: controller)
.aspectRatio(16.0 / 9.0, contentMode: .fit)
.frame(maxWidth: .infinity)
VStack {
HStack(spacing: 8) {
overlayPill(timeText)
Spacer()
Button(action: cycleSpeed) {
overlayPill(Theme.formatSpeed(controller.snapshot.speed))
}
.buttonStyle(.plain)
}
Spacer()
}
.padding(12)
VStack {
Spacer()
HStack {
Spacer()
Button(action: onFullscreen) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
.frame(width: 44, height: 44)
.background(Theme.overlay)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
}
.padding(12)
}
.background(Color.black)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Theme.border, lineWidth: 1)
)
.padding(.bottom, 10)
}
private var sentencePanel: some View {
VStack(alignment: .leading, spacing: 6) {
Text(controller.currentItem?.title ?? "未选择课程")
.font(.system(size: 17, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
if showsContinuousToggle {
HStack(spacing: 0) {
Text("连续播放")
.font(.system(size: 13, weight: .bold))
.foregroundColor(Theme.textSubtle)
Spacer()
Toggle("", isOn: $continuousPlayback)
.labelsHidden()
.tint(Theme.accent)
}
.padding(.vertical, 2)
}
Text(sentenceMetaText)
.font(.system(size: 13))
.foregroundColor(Theme.textSubtle)
Text(sentenceTextText)
.font(.system(size: 15))
.foregroundColor(.white)
.lineLimit(2)
progressRow
Text(statusText)
.font(.system(size: 13))
.foregroundColor(Theme.textSubtle)
}
.padding(.bottom, 10)
}
private var progressRow: some View {
let duration = controller.snapshot.durationMs
return HStack(spacing: 8) {
Slider(
value: progressBinding,
in: 0...max(1, Double(duration)),
onEditingChanged: { editing in
if editing {
progressDragging = true
} else {
progressDragging = false
controller.seek(to: dragPositionMs)
}
}
)
.tint(Theme.accent)
}
}
private var progressBinding: Binding<Double> {
Binding(
get: {
if progressDragging {
return Double(dragPositionMs)
}
return Double(controller.snapshot.positionMs)
},
set: { newValue in
dragPositionMs = Int64(newValue)
}
)
}
private var timeText: String {
let snapshot = controller.snapshot
return "\(Theme.formatTime(snapshot.positionMs)) / \(Theme.formatTime(snapshot.durationMs))"
}
private var sentenceMetaText: String {
switch controller.boundaryState {
case .loading(let isLocal):
return isLocal ? "句子边界分析中" : "句子边界加载中"
case .failed:
return "暂无句子边界"
case .idle, .loaded:
guard let sentence = controller.currentSentence else {
return "暂无句子边界"
}
let count = controller.currentItem?.sentences.count ?? 0
let countText = count > 0 ? " / \(count)" : ""
return "\(sentence.index + 1)\(countText)\(Theme.formatTime(sentence.startMs))-\(Theme.formatTime(sentence.endMs))"
}
}
private var sentenceTextText: String {
switch controller.boundaryState {
case .loading(let isLocal):
if isLocal {
return "正在匹配服务端句子边界"
}
return controller.currentItem?.title ?? "加载中"
case .failed:
return controller.currentItem?.title ?? "未选择课程"
case .idle, .loaded:
guard let sentence = controller.currentSentence else {
return controller.currentItem?.title ?? "未选择课程"
}
if let text = sentence.text, !text.isEmpty {
return text
}
return "当前句子"
}
}
private var statusText: String {
if let message = controller.transientMessage {
return message
}
return controller.playbackStatusText(controller.snapshot)
}
private func overlayPill(_ text: String) -> some View {
Text(text)
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Theme.overlay)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func cycleSpeed() {
let current = controller.snapshot.speed
let cycle: [Float] = [1.0, 1.25, 1.5, 1.75, 2.0]
let next = cycle.first { $0 > current + 0.001 } ?? cycle.first ?? 1.0
controller.setPlaybackSpeed(next)
}
}

View File

@@ -0,0 +1,143 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers
struct RootView: View {
@StateObject private var controller = PlayerController()
@StateObject private var catalogVM = CatalogViewModel()
@State private var activeModule: Module = .train
@State private var continuousPlayback = false
@State private var showImporter = false
@State private var showFullscreenPlayer = false
enum Module {
case train
case test
}
var body: some View {
ZStack {
Theme.background.ignoresSafeArea()
VStack(spacing: 0) {
header
moduleTabs
if activeModule == .train {
TrainingView(
controller: controller,
catalogVM: catalogVM,
continuousPlayback: $continuousPlayback,
onFullscreen: enterFullscreen
)
} else {
TestView(controller: controller, onFullscreen: enterFullscreen)
}
}
}
.preferredColorScheme(.dark)
.onAppear {
catalogVM.controller = controller
controller.setContinuousPlayback(continuousPlayback)
}
.onChange(of: activeModule) { module in
if module == .train {
controller.setContinuousPlayback(continuousPlayback)
} else {
controller.setContinuousPlayback(true)
}
}
.fileImporter(
isPresented: $showImporter,
allowedContentTypes: [.movie],
allowsMultipleSelection: false
) { result in
if case .success(let urls) = result, let url = urls.first {
Task {
await catalogVM.importVideo(at: url)
}
}
}
.fullScreenCover(isPresented: $showFullscreenPlayer) {
FullscreenPlayerView(controller: controller) {
exitFullscreen()
}
.onAppear(perform: enterFullscreen)
}
}
private var header: some View {
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 2) {
Text("跟读虫")
.font(.system(size: 32, weight: .bold))
.foregroundColor(.white)
Text("外语跟读训练神器")
.font(.system(size: 15))
.foregroundColor(Theme.textMuted)
}
Spacer()
headerButton("导入") {
showImporter = true
}
headerButton("刷新") {
Task {
await catalogVM.load()
}
}
}
.padding(.horizontal, 18)
.padding(.top, 8)
.padding(.bottom, 10)
}
private func headerButton(_ label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 14)
.frame(height: 40)
.background(Theme.buttonBlue)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
private var moduleTabs: some View {
HStack(spacing: 0) {
moduleTab("训练", module: .train)
moduleTab("测试", module: .test)
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
private func moduleTab(_ label: String, module: Module) -> some View {
let selected = activeModule == module
return Button {
activeModule = module
} label: {
Text(label)
.font(.system(size: 15, weight: .bold))
.foregroundColor(selected ? .white : Theme.textMuted)
.frame(maxWidth: .infinity)
.frame(height: 38)
.background(selected ? Theme.buttonBlue : Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.padding(2)
}
private func enterFullscreen() {
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
UIViewController.attemptRotationToDeviceOrientation()
showFullscreenPlayer = true
}
private func exitFullscreen() {
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
UIViewController.attemptRotationToDeviceOrientation()
showFullscreenPlayer = false
}
}

View File

@@ -0,0 +1,274 @@
import AVFoundation
import SwiftUI
struct TestView: View {
@ObservedObject var controller: PlayerController
let onFullscreen: () -> Void
@State private var recorder: AVAudioRecorder?
@State private var recordingURL: URL?
@State private var isRecording = false
@State private var isAssessing = false
@State private var testStatus = "请先在训练模块选择一个云端课程"
@State private var scoreSummary = "尚未评测"
@State private var scoreDetail = ""
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
PlayerSectionView(
controller: controller,
showsContinuousToggle: false,
continuousPlayback: .constant(true),
onFullscreen: onFullscreen
)
VStack(alignment: .leading, spacing: 10) {
Text("朗读评测(测试)")
.font(.system(size: 16, weight: .bold))
.foregroundColor(Theme.textDark)
Text(testStatus)
.font(.system(size: 13))
.foregroundColor(Theme.accentDeep)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
sentenceButton("上一句") {
controller.seekToPreviousSentence()
}
sentenceButton("播放原句") {
controller.playCurrentSentenceAndStop()
controller.showTransientMessage("正在播放当前句子,播放完自动停止")
}
sentenceButton("下一句") {
controller.seekToNextSentence()
}
}
Button(action: toggleRecording) {
Text(isRecording ? "停止并评测" : "开始录音")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(isRecording ? Theme.buttonBlue : Theme.accent)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(isAssessing)
Text(scoreSummary)
.font(.system(size: 22, weight: .bold))
.foregroundColor(Theme.textDark)
Text(scoreDetail)
.font(.system(size: 13))
.foregroundColor(Theme.textSubtle)
.lineSpacing(4)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(18)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Theme.lightBorder, lineWidth: 1)
)
.padding(16)
}
}
.onReceive(controller.$currentItem) { _ in
refreshTestUI()
}
.onReceive(controller.$currentSentence) { _ in
refreshTestUI()
}
}
private func sentenceButton(_ label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 40)
.background(Theme.buttonBlue)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
// MARK: -
private func toggleRecording() {
if isRecording {
stopRecordingAndAssess()
} else {
startRecording()
}
}
private func startRecording() {
controller.pause()
requestMicrophonePermission { granted in
guard granted else {
testStatus = "需要麦克风权限才能进行录音评测"
return
}
beginRecording()
}
}
private func requestMicrophonePermission(completion: @escaping (Bool) -> Void) {
if #available(iOS 17.0, *) {
AVAudioApplication.requestRecordPermission { granted in
DispatchQueue.main.async {
completion(granted)
}
}
} else {
AVAudioSession.sharedInstance().requestRecordPermission { granted in
DispatchQueue.main.async {
completion(granted)
}
}
}
}
private func beginRecording() {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("attempt-\(Int(Date().timeIntervalSince1970 * 1000)).m4a")
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 96_000,
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
]
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
try session.setActive(true)
let newRecorder = try AVAudioRecorder(url: fileURL, settings: settings)
guard newRecorder.record() else {
testStatus = "录音启动失败:无法开始录制"
return
}
recorder = newRecorder
recordingURL = fileURL
isRecording = true
testStatus = "正在录音…读完当前句子后点击“停止并评测”"
} catch {
testStatus = "录音启动失败:\(error.localizedDescription)"
}
}
private func stopRecordingAndAssess() {
guard let currentRecorder = recorder else {
return
}
let duration = currentRecorder.currentTime
currentRecorder.stop()
restorePlaybackAudioSession()
recorder = nil
isRecording = false
guard let url = recordingURL else {
return
}
recordingURL = nil
if duration < 1.0 {
testStatus = "录音太短或无法保存"
try? FileManager.default.removeItem(at: url)
return
}
submitAssessment(recordingURL: url)
}
private func restorePlaybackAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, mode: .moviePlayback)
try? session.setActive(true)
}
// MARK: -
private func submitAssessment(recordingURL url: URL) {
guard let item = controller.currentItem, let sentence = controller.currentSentence else {
testStatus = "没有正在学习的句子,无法评测"
return
}
guard item.id.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
testStatus = "仅云端课程支持朗读评测(本地视频请先上传到服务器)"
return
}
guard !AppConfig.assessmentAPIKey.isEmpty,
!AppConfig.assessmentAPIKey.hasPrefix("replace-") else {
testStatus = "未配置评分密钥:请在 Config.swift 填入服务器 .env 的 CLIENT_API_KEY"
return
}
testStatus = "正在评测第 \(sentence.index + 1) 句,请稍候…"
scoreSummary = "评测中…"
scoreDetail = ""
isAssessing = true
let request = ImitationAssessmentRequest(
mediaId: item.id,
sentence: sentence,
recordingURL: url,
locale: sentence.language?.isEmpty == false ? sentence.language : nil
)
Task {
do {
let result = try await ImitationAssessor.assess(request: request)
applyAssessmentResult(result)
} catch {
testStatus = "评测失败:\(error.localizedDescription)"
scoreSummary = "评测失败"
}
isAssessing = false
try? FileManager.default.removeItem(at: url)
}
}
private func applyAssessmentResult(_ result: ImitationAssessmentResult) {
testStatus = result.passed == true ? "已通过" : "未通过"
scoreSummary = String(format: "总分 %.1f", result.overallScore)
var lines: [String] = []
if let value = result.contentScore {
lines.append(String(format: "内容分 %.1f", value))
}
if let value = result.fluencyScore {
lines.append(String(format: "流畅度 %.1f", value))
}
if let value = result.durationScore {
lines.append(String(format: "时长分 %.1f", value))
}
if let value = result.pauseScore {
lines.append(String(format: "停顿分 %.1f", value))
}
if let value = result.speechRateScore {
lines.append(String(format: "语速分 %.1f", value))
}
if let text = result.referenceText, !text.isEmpty {
lines.append("参考:\(text)")
}
if let text = result.recognizedText, !text.isEmpty {
lines.append("识别:\(text)")
}
if !result.missingTokens.isEmpty {
lines.append("漏读:\(result.missingTokens.joined(separator: ""))")
}
if !result.extraTokens.isEmpty {
lines.append("多读:\(result.extraTokens.joined(separator: ""))")
}
if let feedback = result.feedback, !feedback.isEmpty {
lines.append(feedback)
}
scoreDetail = lines.joined(separator: "\n")
}
private func refreshTestUI() {
guard !isRecording, !isAssessing else {
return
}
guard let item = controller.currentItem, let sentence = controller.currentSentence else {
testStatus = "请先在训练模块选择一个云端课程"
return
}
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)"
}
}

View File

@@ -0,0 +1,77 @@
import SwiftUI
enum Theme {
static let background = Color(hex: 0x0C0F12)
static let surface = Color(hex: 0x1C2228)
static let border = Color(hex: 0x323B43)
static let buttonBlue = Color(hex: 0x2563EB)
static let accent = Color(hex: 0x15B884)
static let accentDeep = Color(hex: 0x077656)
static let accentLight = Color(hex: 0x5EEAB6)
static let selected = Color(hex: 0xE5F8F0)
static let progressTrack = Color(hex: 0xE0E7EB)
static let progressTrackDark = Color(hex: 0x373E46)
static let lightBorder = Color(hex: 0xDAE2E8)
static let overlay = Color(red: 9.0 / 255.0, green: 12.0 / 255.0, blue: 16.0 / 255.0, opacity: 0.7)
static let textDark = Color(hex: 0x12181F)
static let textMuted = Color(hex: 0x97A2AE)
static let textSubtle = Color(hex: 0x58626C)
static let textOverlay = Color(hex: 0xA3ACB6)
static func formatTime(_ ms: Int64) -> String {
if ms < 0 {
return "--:--"
}
let totalSeconds = ms / 1000
let hours = Int(totalSeconds / 3600)
let minutes = Int((totalSeconds % 3600) / 60)
let seconds = Int(totalSeconds % 60)
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%02d:%02d", minutes, seconds)
}
static func formatSpeed(_ speed: Float) -> String {
var value = String(format: "%.2f", speed)
while value.hasSuffix("0") {
value.removeLast()
}
if value.hasSuffix(".") {
value.removeLast()
}
return "\(value)x"
}
}
extension Color {
init(hex: UInt32) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255.0,
green: Double((hex >> 8) & 0xFF) / 255.0,
blue: Double(hex & 0xFF) / 255.0,
opacity: 1
)
}
}
extension PlayerController {
/// playbackStatus()
func playbackStatusText(_ snapshot: PlaybackSnapshot) -> String {
let state: String
if snapshot.isPlaying {
state = "播放中"
} else if snapshot.state == .buffering {
state = "缓冲中"
} else if snapshot.state == .ended {
state = "已结束"
} else {
state = "已暂停"
}
if let index = snapshot.sentenceIndex {
return "\(state) · 第 \(index + 1)"
}
return state
}
}

View File

@@ -0,0 +1,31 @@
import SwiftUI
struct TrainingView: View {
@ObservedObject var controller: PlayerController
@ObservedObject var catalogVM: CatalogViewModel
@Binding var continuousPlayback: Bool
let onFullscreen: () -> Void
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
PlayerSectionView(
controller: controller,
showsContinuousToggle: true,
continuousPlayback: $continuousPlayback,
onFullscreen: onFullscreen
)
CatalogView(vm: catalogVM)
.padding(.bottom, 20)
}
}
.onChange(of: continuousPlayback) { enabled in
controller.setContinuousPlayback(enabled)
controller.showTransientMessage(
enabled
? "已开启连续播放,将按句子顺序连续播放"
: "已关闭连续播放,将循环播放当前句子"
)
}
}
}

62
ios/README.md Normal file
View File

@@ -0,0 +1,62 @@
# Oral Trainer iOS App跟读虫 iOS 版)
iOS 端与 [android/](../android/README.md) 示例 App 功能对齐的移动端:
- 在线视频目录(`GET /api/v1/videos`,仅展示 `ready` 课程,自动解析相对流地址)
- AVPlayer 在线播放 + 本地缓存导入;单击播放/暂停
- 左划 / 右划:上一句 / 下一句(无句边界时回退为 10 秒快退 / 快进)
- 长按播放区按触摸点水平位置变速左慢右快0.5x - 2.0x),松手恢复原速
- 按句跳转:句边界来自 `GET /api/v1/videos/{sha256}/sentence-boundaries`
- 连续播放开关:关闭时循环播放当前句子,开启时按句子顺序连续播放
- 进度条拖动定位、播放状态 / 句号 / 变速显示
- 横屏全屏播放(半透明句面板,与安卓端横屏布局一致)
- 本地视频导入:复制到缓存并计算 SHA-256自动匹配服务端句边界
- 朗读评测麦克风录音AAC 16kHz上传
`POST /api/v1/videos/{sha256}/sentences/{index}/assessments`
展示内容 / 流畅度 / 时长 / 停顿 / 语速等分数与漏读、多读、反馈
- HTTPS 因 TLS 问题失败时自动降级重试 HTTP与安卓端 `allowHttpFallback` 一致)
## 目录结构
```
ios/
├── OralTrainer.xcodeproj # Xcode 工程
└── OralTrainer/
├── Config.swift # 服务端地址与评测密钥(改这里)
├── Models/ # 课程 / 句边界 / 评测数据模型
├── Services/ # HTTP 客户端、课程目录、句边界、评测上传
├── Player/ # 播放控制器AVPlayer+ 手势播放区
└── Views/ # SwiftUI 界面(训练 / 测试 / 全屏)
```
## 构建与运行
1. 需要 macOS + Xcode16 或更新版本iOS 部署目标 16.0。
2. 打开 `ios/OralTrainer.xcodeproj`,在 Signing 里选择你的开发者团队。
3. 选一台 iPhone / iPad 或模拟器运行;真机评测录音需要麦克风权限
(首次点击“开始录音”时会自动请求)。
4. 本机没有 Xcode 时,也可在另一台装有 Xcode 的 Mac 上打开本目录构建。
## 配置
- 服务端地址:改 `OralTrainer/Config.swift` 里的 `serverBaseURL`
(内网可填 `http://<服务器IP>:<端口>`)。
- 评测密钥:改 `assessmentAPIKey`,填服务器 `sentence_api/.env` 里的
`CLIENT_API_KEY`;安卓端示例 App 用的是同一个密钥。
- 生产环境建议保持 HTTPS`Info.plist` 中的 `NSAllowsArbitraryLoads`
与安卓端的明文 HTTP 开关一样,仅为内网/私有部署提供便利。
## 与安卓端的差异说明
- 播放内核为系统 AVPlayer对应安卓 Media3/ExoPlayer支持 MP4/MOV/M4V
及 HLSMKV/WEBM 需先转码,与 iOS 系统播放器能力一致。
- 横屏全屏点播放区右下角的旋转按钮进入iPhone 会自动锁定横屏,
退出后恢复竖屏iPad 允许全部方向。
- 速度胶囊支持点击循环 1x → 1.25x → 1.5x → 1.75x → 2.0x
(安卓端只有长按变速,这里作为 iOS 便捷补充)。
## 常见问题
- “云端暂不可用:…”:检查手机能否访问服务端、地址是否填对、网关证书是否有效。
- 本地视频评测提示“仅云端课程支持朗读评测”:本地文件需先通过管理端上传到服务器。
- 录音提示失败:在 设置 > 隐私 > 麦克风 中允许本 App 使用麦克风。