From 7c81976b2b9d2e9141c475b3c689c61ccdc9cad3 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Mon, 17 Aug 2026 20:13:28 +0800 Subject: [PATCH] added ios module --- .gitignore | 1 + README.md | 6 + ios/OralTrainer.xcodeproj/project.pbxproj | 433 ++++++++++++++++++ ios/OralTrainer/AppDelegate.swift | 12 + ios/OralTrainer/Config.swift | 20 + ios/OralTrainer/Models/AssessmentModels.swift | 98 ++++ ios/OralTrainer/Models/PlaybackModels.swift | 58 +++ ios/OralTrainer/Models/TrainingModels.swift | 95 ++++ ios/OralTrainer/OralTrainerApp.swift | 12 + ios/OralTrainer/Player/PlayerController.swift | 427 +++++++++++++++++ .../Player/PlayerSurfaceView.swift | 106 +++++ .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/Contents.json | 13 + .../Resources/Assets.xcassets/Contents.json | 6 + ios/OralTrainer/Resources/Info.plist | 50 ++ ios/OralTrainer/Services/HTTPClient.swift | 133 ++++++ .../Services/ImitationAssessor.swift | 124 +++++ .../Services/SentenceBoundaryAPI.swift | 34 ++ .../Services/VideoCatalogAPI.swift | 18 + ios/OralTrainer/Views/CatalogView.swift | 184 ++++++++ .../Views/FullscreenPlayerView.swift | 136 ++++++ ios/OralTrainer/Views/PlayerSectionView.swift | 194 ++++++++ ios/OralTrainer/Views/RootView.swift | 143 ++++++ ios/OralTrainer/Views/TestView.swift | 274 +++++++++++ ios/OralTrainer/Views/Theme.swift | 77 ++++ ios/OralTrainer/Views/TrainingView.swift | 31 ++ ios/README.md | 62 +++ 27 files changed, 2767 insertions(+) create mode 100644 ios/OralTrainer.xcodeproj/project.pbxproj create mode 100644 ios/OralTrainer/AppDelegate.swift create mode 100644 ios/OralTrainer/Config.swift create mode 100644 ios/OralTrainer/Models/AssessmentModels.swift create mode 100644 ios/OralTrainer/Models/PlaybackModels.swift create mode 100644 ios/OralTrainer/Models/TrainingModels.swift create mode 100644 ios/OralTrainer/OralTrainerApp.swift create mode 100644 ios/OralTrainer/Player/PlayerController.swift create mode 100644 ios/OralTrainer/Player/PlayerSurfaceView.swift create mode 100644 ios/OralTrainer/Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/OralTrainer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/OralTrainer/Resources/Assets.xcassets/Contents.json create mode 100644 ios/OralTrainer/Resources/Info.plist create mode 100644 ios/OralTrainer/Services/HTTPClient.swift create mode 100644 ios/OralTrainer/Services/ImitationAssessor.swift create mode 100644 ios/OralTrainer/Services/SentenceBoundaryAPI.swift create mode 100644 ios/OralTrainer/Services/VideoCatalogAPI.swift create mode 100644 ios/OralTrainer/Views/CatalogView.swift create mode 100644 ios/OralTrainer/Views/FullscreenPlayerView.swift create mode 100644 ios/OralTrainer/Views/PlayerSectionView.swift create mode 100644 ios/OralTrainer/Views/RootView.swift create mode 100644 ios/OralTrainer/Views/TestView.swift create mode 100644 ios/OralTrainer/Views/Theme.swift create mode 100644 ios/OralTrainer/Views/TrainingView.swift create mode 100644 ios/README.md diff --git a/.gitignore b/.gitignore index 0f8e986..105392b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ sentence_api/data/oral_trainer.sqlite3* sentence_api/data/v/ sentence_api/data/work/ sentence_api/data/attempts/ +android/sample-app/release/ diff --git a/README.md b/README.md index 90a7e4b..8f98ceb 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,12 @@ SDK 基于 AndroidX Media3 / ExoPlayer,支持在线视频流、本地缓存、 详见 [android/README.md](android/README.md)。 +## iOS App + +`ios/` 子工程是与安卓端对齐的 iOS 移动端(SwiftUI + AVPlayer): +在线课程播放、按句跳转、变速、横屏全屏、本地视频导入,以及麦克风录音 +朗读评测,接口与安卓端完全一致。详见 [ios/README.md](ios/README.md)。 + ## 口语训练视频服务 `sentence_api/` 已扩展为完整的视频服务:支持超过 1 GB 的流式上传、MOSS 自动 diff --git a/ios/OralTrainer.xcodeproj/project.pbxproj b/ios/OralTrainer.xcodeproj/project.pbxproj new file mode 100644 index 0000000..497dd62 --- /dev/null +++ b/ios/OralTrainer.xcodeproj/project.pbxproj @@ -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 = ""; }; + AA0000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AA0000000000000000000022 /* OralTrainerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OralTrainerApp.swift; sourceTree = ""; }; + AA0000000000000000000023 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AA0000000000000000000024 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; + AA0000000000000000000025 /* TrainingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingModels.swift; sourceTree = ""; }; + AA0000000000000000000026 /* AssessmentModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssessmentModels.swift; sourceTree = ""; }; + AA0000000000000000000027 /* PlaybackModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackModels.swift; sourceTree = ""; }; + AA0000000000000000000028 /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; + AA0000000000000000000029 /* VideoCatalogAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCatalogAPI.swift; sourceTree = ""; }; + AA000000000000000000002A /* SentenceBoundaryAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBoundaryAPI.swift; sourceTree = ""; }; + AA000000000000000000002B /* ImitationAssessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImitationAssessor.swift; sourceTree = ""; }; + AA000000000000000000002C /* PlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerController.swift; sourceTree = ""; }; + AA000000000000000000002D /* PlayerSurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSurfaceView.swift; sourceTree = ""; }; + AA000000000000000000002E /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + AA000000000000000000002F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + AA0000000000000000000030 /* TrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingView.swift; sourceTree = ""; }; + AA0000000000000000000031 /* PlayerSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSectionView.swift; sourceTree = ""; }; + AA0000000000000000000032 /* CatalogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogView.swift; sourceTree = ""; }; + AA0000000000000000000033 /* TestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestView.swift; sourceTree = ""; }; + AA0000000000000000000034 /* FullscreenPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenPlayerView.swift; sourceTree = ""; }; +/* 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 = ""; + }; + AA0000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + AA000000000000000000000A /* OralTrainer.app */, + ); + name = Products; + sourceTree = ""; + }; + 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 = ""; + }; + AA0000000000000000000005 /* Models */ = { + isa = PBXGroup; + children = ( + AA0000000000000000000025 /* TrainingModels.swift */, + AA0000000000000000000026 /* AssessmentModels.swift */, + AA0000000000000000000027 /* PlaybackModels.swift */, + ); + path = Models; + sourceTree = ""; + }; + AA0000000000000000000006 /* Services */ = { + isa = PBXGroup; + children = ( + AA0000000000000000000028 /* HTTPClient.swift */, + AA0000000000000000000029 /* VideoCatalogAPI.swift */, + AA000000000000000000002A /* SentenceBoundaryAPI.swift */, + AA000000000000000000002B /* ImitationAssessor.swift */, + ); + path = Services; + sourceTree = ""; + }; + AA0000000000000000000007 /* Player */ = { + isa = PBXGroup; + children = ( + AA000000000000000000002C /* PlayerController.swift */, + AA000000000000000000002D /* PlayerSurfaceView.swift */, + ); + path = Player; + sourceTree = ""; + }; + 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 = ""; + }; + AA0000000000000000000009 /* Resources */ = { + isa = PBXGroup; + children = ( + AA0000000000000000000020 /* Assets.xcassets */, + AA000000000000000000001F /* Info.plist */, + ); + path = Resources; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/ios/OralTrainer/AppDelegate.swift b/ios/OralTrainer/AppDelegate.swift new file mode 100644 index 0000000..5efd84d --- /dev/null +++ b/ios/OralTrainer/AppDelegate.swift @@ -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 + } +} diff --git a/ios/OralTrainer/Config.swift b/ios/OralTrainer/Config.swift new file mode 100644 index 0000000..48e4c07 --- /dev/null +++ b/ios/OralTrainer/Config.swift @@ -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}$"# +} diff --git a/ios/OralTrainer/Models/AssessmentModels.swift b/ios/OralTrainer/Models/AssessmentModels.swift new file mode 100644 index 0000000..58453bf --- /dev/null +++ b/ios/OralTrainer/Models/AssessmentModels.swift @@ -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) ?? [:] + } +} diff --git a/ios/OralTrainer/Models/PlaybackModels.swift b/ios/OralTrainer/Models/PlaybackModels.swift new file mode 100644 index 0000000..9cd634a --- /dev/null +++ b/ios/OralTrainer/Models/PlaybackModels.swift @@ -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) +} diff --git a/ios/OralTrainer/Models/TrainingModels.swift b/ios/OralTrainer/Models/TrainingModels.swift new file mode 100644 index 0000000..7a5d0c5 --- /dev/null +++ b/ios/OralTrainer/Models/TrainingModels.swift @@ -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 + } +} diff --git a/ios/OralTrainer/OralTrainerApp.swift b/ios/OralTrainer/OralTrainerApp.swift new file mode 100644 index 0000000..c123ad6 --- /dev/null +++ b/ios/OralTrainer/OralTrainerApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct OralTrainerApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + WindowGroup { + RootView() + } + } +} diff --git a/ios/OralTrainer/Player/PlayerController.swift b/ios/OralTrainer/Player/PlayerController.swift new file mode 100644 index 0000000..de37902 --- /dev/null +++ b/ios/OralTrainer/Player/PlayerController.swift @@ -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? + + 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) + } +} diff --git a/ios/OralTrainer/Player/PlayerSurfaceView.swift b/ios/OralTrainer/Player/PlayerSurfaceView.swift new file mode 100644 index 0000000..2bd0167 --- /dev/null +++ b/ios/OralTrainer/Player/PlayerSurfaceView.swift @@ -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 + } + } +} diff --git a/ios/OralTrainer/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/OralTrainer/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..f24633d --- /dev/null +++ b/ios/OralTrainer/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -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 + } +} diff --git a/ios/OralTrainer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/OralTrainer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/ios/OralTrainer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/OralTrainer/Resources/Assets.xcassets/Contents.json b/ios/OralTrainer/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/OralTrainer/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/OralTrainer/Resources/Info.plist b/ios/OralTrainer/Resources/Info.plist new file mode 100644 index 0000000..8578fad --- /dev/null +++ b/ios/OralTrainer/Resources/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleDisplayName + 跟读虫 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSMicrophoneUsageDescription + 需要麦克风权限才能进行录音评测 + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/OralTrainer/Services/HTTPClient.swift b/ios/OralTrainer/Services/HTTPClient.swift new file mode 100644 index 0000000..9801fbb --- /dev/null +++ b/ios/OralTrainer/Services/HTTPClient.swift @@ -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( + _ 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") + } +} diff --git a/ios/OralTrainer/Services/ImitationAssessor.swift b/ios/OralTrainer/Services/ImitationAssessor.swift new file mode 100644 index 0000000..1e818bf --- /dev/null +++ b/ios/OralTrainer/Services/ImitationAssessor.swift @@ -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)) + } +} diff --git a/ios/OralTrainer/Services/SentenceBoundaryAPI.swift b/ios/OralTrainer/Services/SentenceBoundaryAPI.swift new file mode 100644 index 0000000..e3bba32 --- /dev/null +++ b/ios/OralTrainer/Services/SentenceBoundaryAPI.swift @@ -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 + } +} diff --git a/ios/OralTrainer/Services/VideoCatalogAPI.swift b/ios/OralTrainer/Services/VideoCatalogAPI.swift new file mode 100644 index 0000000..0c347f1 --- /dev/null +++ b/ios/OralTrainer/Services/VideoCatalogAPI.swift @@ -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) + } +} diff --git a/ios/OralTrainer/Views/CatalogView.swift b/ios/OralTrainer/Views/CatalogView.swift new file mode 100644 index 0000000..8bbf0e5 --- /dev/null +++ b/ios/OralTrainer/Views/CatalogView.swift @@ -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) + } +} diff --git a/ios/OralTrainer/Views/FullscreenPlayerView.swift b/ios/OralTrainer/Views/FullscreenPlayerView.swift new file mode 100644 index 0000000..6a90a1a --- /dev/null +++ b/ios/OralTrainer/Views/FullscreenPlayerView.swift @@ -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 { + 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) + } +} diff --git a/ios/OralTrainer/Views/PlayerSectionView.swift b/ios/OralTrainer/Views/PlayerSectionView.swift new file mode 100644 index 0000000..029bee8 --- /dev/null +++ b/ios/OralTrainer/Views/PlayerSectionView.swift @@ -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 { + 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) + } +} diff --git a/ios/OralTrainer/Views/RootView.swift b/ios/OralTrainer/Views/RootView.swift new file mode 100644 index 0000000..329cde7 --- /dev/null +++ b/ios/OralTrainer/Views/RootView.swift @@ -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 + } +} diff --git a/ios/OralTrainer/Views/TestView.swift b/ios/OralTrainer/Views/TestView.swift new file mode 100644 index 0000000..018899b --- /dev/null +++ b/ios/OralTrainer/Views/TestView.swift @@ -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) 句)" + } +} diff --git a/ios/OralTrainer/Views/Theme.swift b/ios/OralTrainer/Views/Theme.swift new file mode 100644 index 0000000..14c2649 --- /dev/null +++ b/ios/OralTrainer/Views/Theme.swift @@ -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 + } +} diff --git a/ios/OralTrainer/Views/TrainingView.swift b/ios/OralTrainer/Views/TrainingView.swift new file mode 100644 index 0000000..c6d9f56 --- /dev/null +++ b/ios/OralTrainer/Views/TrainingView.swift @@ -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 + ? "已开启连续播放,将按句子顺序连续播放" + : "已关闭连续播放,将循环播放当前句子" + ) + } + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..ad14ed0 --- /dev/null +++ b/ios/README.md @@ -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 使用麦克风。