added ios module
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ sentence_api/data/oral_trainer.sqlite3*
|
|||||||
sentence_api/data/v/
|
sentence_api/data/v/
|
||||||
sentence_api/data/work/
|
sentence_api/data/work/
|
||||||
sentence_api/data/attempts/
|
sentence_api/data/attempts/
|
||||||
|
android/sample-app/release/
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ SDK 基于 AndroidX Media3 / ExoPlayer,支持在线视频流、本地缓存、
|
|||||||
|
|
||||||
详见 [android/README.md](android/README.md)。
|
详见 [android/README.md](android/README.md)。
|
||||||
|
|
||||||
|
## iOS App
|
||||||
|
|
||||||
|
`ios/` 子工程是与安卓端对齐的 iOS 移动端(SwiftUI + AVPlayer):
|
||||||
|
在线课程播放、按句跳转、变速、横屏全屏、本地视频导入,以及麦克风录音
|
||||||
|
朗读评测,接口与安卓端完全一致。详见 [ios/README.md](ios/README.md)。
|
||||||
|
|
||||||
## 口语训练视频服务
|
## 口语训练视频服务
|
||||||
|
|
||||||
`sentence_api/` 已扩展为完整的视频服务:支持超过 1 GB 的流式上传、MOSS 自动
|
`sentence_api/` 已扩展为完整的视频服务:支持超过 1 GB 的流式上传、MOSS 自动
|
||||||
|
|||||||
433
ios/OralTrainer.xcodeproj/project.pbxproj
Normal file
433
ios/OralTrainer.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
12
ios/OralTrainer/AppDelegate.swift
Normal file
12
ios/OralTrainer/AppDelegate.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
20
ios/OralTrainer/Config.swift
Normal file
20
ios/OralTrainer/Config.swift
Normal 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}$"#
|
||||||
|
}
|
||||||
98
ios/OralTrainer/Models/AssessmentModels.swift
Normal file
98
ios/OralTrainer/Models/AssessmentModels.swift
Normal 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) ?? [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
58
ios/OralTrainer/Models/PlaybackModels.swift
Normal file
58
ios/OralTrainer/Models/PlaybackModels.swift
Normal 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)
|
||||||
|
}
|
||||||
95
ios/OralTrainer/Models/TrainingModels.swift
Normal file
95
ios/OralTrainer/Models/TrainingModels.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
12
ios/OralTrainer/OralTrainerApp.swift
Normal file
12
ios/OralTrainer/OralTrainerApp.swift
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct OralTrainerApp: App {
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
427
ios/OralTrainer/Player/PlayerController.swift
Normal file
427
ios/OralTrainer/Player/PlayerController.swift
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 与安卓端 OralTrainerController 对齐的播放控制器。
|
||||||
|
/// 所有属性变更都发生在主线程(时间观察器、KVO、UI 回调均为主线程)。
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
ios/OralTrainer/Player/PlayerSurfaceView.swift
Normal file
106
ios/OralTrainer/Player/PlayerSurfaceView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
ios/OralTrainer/Resources/Assets.xcassets/Contents.json
Normal file
6
ios/OralTrainer/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
50
ios/OralTrainer/Resources/Info.plist
Normal file
50
ios/OralTrainer/Resources/Info.plist
Normal 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>
|
||||||
133
ios/OralTrainer/Services/HTTPClient.swift
Normal file
133
ios/OralTrainer/Services/HTTPClient.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
124
ios/OralTrainer/Services/ImitationAssessor.swift
Normal file
124
ios/OralTrainer/Services/ImitationAssessor.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
34
ios/OralTrainer/Services/SentenceBoundaryAPI.swift
Normal file
34
ios/OralTrainer/Services/SentenceBoundaryAPI.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
18
ios/OralTrainer/Services/VideoCatalogAPI.swift
Normal file
18
ios/OralTrainer/Services/VideoCatalogAPI.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
184
ios/OralTrainer/Views/CatalogView.swift
Normal file
184
ios/OralTrainer/Views/CatalogView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
136
ios/OralTrainer/Views/FullscreenPlayerView.swift
Normal file
136
ios/OralTrainer/Views/FullscreenPlayerView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
194
ios/OralTrainer/Views/PlayerSectionView.swift
Normal file
194
ios/OralTrainer/Views/PlayerSectionView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
143
ios/OralTrainer/Views/RootView.swift
Normal file
143
ios/OralTrainer/Views/RootView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
274
ios/OralTrainer/Views/TestView.swift
Normal file
274
ios/OralTrainer/Views/TestView.swift
Normal 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) 句)"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
ios/OralTrainer/Views/Theme.swift
Normal file
77
ios/OralTrainer/Views/Theme.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ios/OralTrainer/Views/TrainingView.swift
Normal file
31
ios/OralTrainer/Views/TrainingView.swift
Normal 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
62
ios/README.md
Normal 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 + Xcode(16 或更新版本),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
|
||||||
|
及 HLS;MKV/WEBM 需先转码,与 iOS 系统播放器能力一致。
|
||||||
|
- 横屏全屏:点播放区右下角的旋转按钮进入;iPhone 会自动锁定横屏,
|
||||||
|
退出后恢复竖屏;iPad 允许全部方向。
|
||||||
|
- 速度胶囊支持点击循环 1x → 1.25x → 1.5x → 1.75x → 2.0x
|
||||||
|
(安卓端只有长按变速,这里作为 iOS 便捷补充)。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
- “云端暂不可用:…”:检查手机能否访问服务端、地址是否填对、网关证书是否有效。
|
||||||
|
- 本地视频评测提示“仅云端课程支持朗读评测”:本地文件需先通过管理端上传到服务器。
|
||||||
|
- 录音提示失败:在 设置 > 隐私 > 麦克风 中允许本 App 使用麦克风。
|
||||||
Reference in New Issue
Block a user