diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt index 8bb68af..40191a8 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt @@ -434,6 +434,7 @@ class MainActivity : Activity() { explanationOverlay.show( sentence, controller.currentTrainingItem()?.title.orEmpty(), + controller.currentTrainingItem()?.id.orEmpty(), ) } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt index c31c493..8475beb 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt @@ -18,11 +18,12 @@ internal class SentenceExplanationOverlay( private val activity: Activity, private val api: SentenceExplanationApi, ) { + private val store = SentenceExplanationStore(activity) private var currentOverlay: View? = null private var currentRequest: CancellableExplanationRequest? = null private var requestGeneration = 0 - fun show(sentence: SentenceBoundary, courseTitle: String) { + fun show(sentence: SentenceBoundary, courseTitle: String, mediaId: String) { close() val generation = ++requestGeneration val root = FrameLayout(activity).apply { @@ -51,6 +52,31 @@ internal class SentenceExplanationOverlay( setTextColor(SampleTheme.textDark) textSize = 19f typeface = Typeface.DEFAULT_BOLD + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) + } + val saveHint = TextView(activity).apply { + text = HINT_UNSAVED + setTextColor(SampleTheme.textSubtle) + textSize = 12f + setPadding(6.dp, 0, 0, 0) + } + val saveButton = TextView(activity).apply { + text = "保存" + setTextColor(Color.WHITE) + textSize = 13f + typeface = Typeface.DEFAULT_BOLD + gravity = Gravity.CENTER + background = rounded(SampleTheme.button, 8f) + setPadding(12.dp, 6.dp, 12.dp, 6.dp) + isEnabled = false + alpha = 0.45f + } + val titleRow = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(title) + addView(saveButton) + addView(saveHint) } val subtitle = TextView(activity).apply { text = sentence.text?.takeIf { it.isNotBlank() } ?: courseTitle @@ -86,7 +112,7 @@ internal class SentenceExplanationOverlay( ).apply { topMargin = 16.dp } setOnClickListener { close() } } - card.addView(title) + card.addView(titleRow) card.addView(subtitle) card.addView(scroll) card.addView(close) @@ -94,6 +120,22 @@ internal class SentenceExplanationOverlay( addToWindow(root) currentOverlay = root + + val cached = store.load(mediaId, sentence.index) + if (!cached.isNullOrBlank()) { + message.text = cached + message.setTextColor(SampleTheme.textDark) + markSaved(saveButton, saveHint) + return + } + + var loadedExplanation: String? = null + saveButton.setOnClickListener { + val explanation = loadedExplanation ?: return@setOnClickListener + if (store.save(mediaId, sentence.index, explanation)) { + markSaved(saveButton, saveHint) + } + } currentRequest = api.explain( sentence.text.orEmpty(), courseTitle, @@ -101,14 +143,20 @@ internal class SentenceExplanationOverlay( sentence.language, object : SentenceExplanationCallback { override fun onSuccess(result: SentenceExplanationResult) { - if (generation == requestGeneration) message.text = result.explanation + if (generation != requestGeneration) return + loadedExplanation = result.explanation + message.text = result.explanation + message.setTextColor(SampleTheme.textDark) + saveButton.isEnabled = true + saveButton.alpha = 1f } override fun onError(error: Throwable) { - if (generation == requestGeneration) { - message.text = "讲解失败:${error.message ?: "请稍后重试"}" - message.setTextColor(SampleTheme.danger) - } + if (generation != requestGeneration) return + message.text = "讲解失败:${error.message ?: "请稍后重试"}" + message.setTextColor(SampleTheme.danger) + saveButton.isEnabled = false + saveButton.alpha = 0.45f } }, ) @@ -128,6 +176,13 @@ internal class SentenceExplanationOverlay( api.release() } + private fun markSaved(saveButton: TextView, saveHint: TextView) { + saveButton.text = "已保存" + saveButton.isEnabled = false + saveButton.alpha = 0.7f + saveHint.text = HINT_SAVED + } + private fun addToWindow(view: View) { (activity.window?.decorView as? ViewGroup)?.addView(view) } @@ -147,4 +202,9 @@ internal class SentenceExplanationOverlay( cornerRadius = radiusDp.dp.toFloat() strokeColor?.let { setStroke(1.dp, it) } } + + private companion object { + const val HINT_UNSAVED = "(下次直接看本地)" + const val HINT_SAVED = "(已保存本地)" + } } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationStore.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationStore.kt new file mode 100644 index 0000000..d6adfc6 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationStore.kt @@ -0,0 +1,39 @@ +package cn.learningpad.oraltrainer.sample + +import android.content.Context +import java.io.File +import java.util.Locale + +/** Persists sentence explanations under app filesDir for reuse without another API call. */ +internal class SentenceExplanationStore(context: Context) { + private val rootDir = File(context.applicationContext.filesDir, "sentence-explanations") + + fun load(mediaId: String, sentenceIndex: Int): String? { + val file = fileFor(mediaId, sentenceIndex) ?: return null + if (!file.isFile || file.length() <= 0L) return null + return file.readText() + } + + fun save(mediaId: String, sentenceIndex: Int, explanation: String): Boolean { + val text = explanation.trim() + if (text.isEmpty()) return false + val file = fileFor(mediaId, sentenceIndex) ?: return false + file.parentFile?.mkdirs() + file.writeText(text) + return file.isFile && file.length() > 0L + } + + private fun fileFor(mediaId: String, sentenceIndex: Int): File? { + val directoryName = sanitize(mediaId) ?: return null + return File(File(rootDir, directoryName), "$sentenceIndex.txt") + } + + companion object { + fun sanitize(mediaId: String): String? { + val filtered = mediaId.trim().lowercase(Locale.US) + .replace(Regex("[^a-z0-9._-]"), "_") + .trim('_') + return filtered.takeIf { it.isNotBlank() && it != "." && it != ".." } + } + } +} diff --git a/ios/OralTrainer.xcodeproj/project.pbxproj b/ios/OralTrainer.xcodeproj/project.pbxproj index 33d9a50..baf1854 100644 --- a/ios/OralTrainer.xcodeproj/project.pbxproj +++ b/ios/OralTrainer.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ AA0000000000000000000090 /* AuthAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000091 /* AuthAPI.swift */; }; AA00000000000000000000A1 /* UserHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A2 /* UserHistory.swift */; }; AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A4 /* TrainingHistoryStore.swift */; }; + AA00000000000000000000B0 /* SentenceExplanationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000B1 /* SentenceExplanationStore.swift */; }; AA0000000000000000000092 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000093 /* AuthView.swift */; }; AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; }; AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; }; @@ -41,6 +42,7 @@ AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = ""; }; AA00000000000000000000A2 /* UserHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHistory.swift; sourceTree = ""; }; AA00000000000000000000A4 /* TrainingHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingHistoryStore.swift; sourceTree = ""; }; + AA00000000000000000000B1 /* SentenceExplanationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceExplanationStore.swift; sourceTree = ""; }; AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; 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 = ""; }; @@ -131,6 +133,7 @@ AA000000000000000000002A /* SentenceBoundaryAPI.swift */, AA000000000000000000002B /* ImitationAssessor.swift */, AA00000000000000000000A4 /* TrainingHistoryStore.swift */, + AA00000000000000000000B1 /* SentenceExplanationStore.swift */, AA00000000000000000000A2 /* UserHistory.swift */, AA000000000000000000007E /* DubbingCompositor.swift */, AA0000000000000000000081 /* AudioRecorderSession.swift */, @@ -267,6 +270,7 @@ AA0000000000000000000090 /* AuthAPI.swift in Sources */, AA00000000000000000000A1 /* UserHistory.swift in Sources */, AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */, + AA00000000000000000000B0 /* SentenceExplanationStore.swift in Sources */, AA0000000000000000000092 /* AuthView.swift in Sources */, AA0000000000000000000053 /* TestView.swift in Sources */, AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */, diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/IDEFindNavigatorScopes.plist b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/IDEFindNavigatorScopes.plist new file mode 100644 index 0000000..5dd5da8 --- /dev/null +++ b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/IDEFindNavigatorScopes.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index 8f6a294..0ce9aee 100644 Binary files a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OralTrainer/Services/SentenceExplanationStore.swift b/ios/OralTrainer/Services/SentenceExplanationStore.swift new file mode 100644 index 0000000..30e4285 --- /dev/null +++ b/ios/OralTrainer/Services/SentenceExplanationStore.swift @@ -0,0 +1,47 @@ +import Foundation + +final class SentenceExplanationStore { + static let shared = SentenceExplanationStore() + + private let root: URL + + init() { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + root = documents.appendingPathComponent("SentenceExplanations", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + func load(mediaId: String, sentenceIndex: Int) -> String? { + let url = fileURL(mediaId: mediaId, sentenceIndex: sentenceIndex) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let text = try? String(contentsOf: url, encoding: .utf8) + let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + @discardableResult + func save(mediaId: String, sentenceIndex: Int, explanation: String) -> Bool { + let trimmed = explanation.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + let url = fileURL(mediaId: mediaId, sentenceIndex: sentenceIndex) + try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + do { + try trimmed.write(to: url, atomically: true, encoding: .utf8) + return true + } catch { + return false + } + } + + private func fileURL(mediaId: String, sentenceIndex: Int) -> URL { + let directory = root.appendingPathComponent(Self.sanitize(mediaId), isDirectory: true) + return directory.appendingPathComponent("\(sentenceIndex).txt") + } + + static func sanitize(_ mediaId: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + let filtered = mediaId.lowercased().unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } + let value = String(filtered).trimmingCharacters(in: CharacterSet(charactersIn: "_")) + return value.isEmpty || value == "." || value == ".." ? "unknown" : value + } +} diff --git a/ios/OralTrainer/Views/FullscreenPlayerView.swift b/ios/OralTrainer/Views/FullscreenPlayerView.swift index ec20e4e..6ed296e 100644 --- a/ios/OralTrainer/Views/FullscreenPlayerView.swift +++ b/ios/OralTrainer/Views/FullscreenPlayerView.swift @@ -97,6 +97,7 @@ struct FullscreenPlayerView: View { } .sheet(item: $explanationSentence) { sentence in SentenceExplanationSheet( + mediaId: controller.currentItem?.id ?? "", courseTitle: controller.currentItem?.title ?? "口语训练课程", sentence: sentence ) diff --git a/ios/OralTrainer/Views/PlayerSectionView.swift b/ios/OralTrainer/Views/PlayerSectionView.swift index 8f72914..3e702eb 100644 --- a/ios/OralTrainer/Views/PlayerSectionView.swift +++ b/ios/OralTrainer/Views/PlayerSectionView.swift @@ -22,6 +22,7 @@ struct PlayerSectionView: View { .padding(.horizontal, 16) .sheet(item: $explanationSentence) { sentence in SentenceExplanationSheet( + mediaId: controller.currentItem?.id ?? "", courseTitle: controller.currentItem?.title ?? "口语训练课程", sentence: sentence ) @@ -96,7 +97,7 @@ struct PlayerSectionView: View { Text(sentenceTextText) .font(.system(size: 15)) .foregroundColor(.white) - .lineLimit(2) + .lineLimit(4) Spacer() Button { guard let sentence = controller.currentSentence, !(sentence.text ?? "").isEmpty else { @@ -230,12 +231,14 @@ struct PlayerSectionView: View { } struct SentenceExplanationSheet: View { + let mediaId: String let courseTitle: String let sentence: SentenceBoundary @Environment(\.dismiss) private var dismiss @State private var explanation = "" @State private var errorMessage: String? + @State private var saved = false var body: some View { NavigationView { @@ -267,18 +270,35 @@ struct SentenceExplanationSheet: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button { - dismiss() - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 22, weight: .semibold)) + HStack(spacing: 6) { + Button(saved ? "已保存" : "保存") { + saveExplanation() + } + .disabled(saved || explanation.isEmpty || errorMessage != nil) + Text(saved ? "(已保存本地)" : "(下次直接看本地)") + .font(.system(size: 12)) .foregroundColor(Theme.textMuted) + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 22, weight: .semibold)) + .foregroundColor(Theme.textMuted) + } + .accessibilityLabel("关闭句子讲解") } - .accessibilityLabel("关闭句子讲解") } } } .task { + if let cached = SentenceExplanationStore.shared.load( + mediaId: mediaId, + sentenceIndex: sentence.index + ) { + explanation = cached + saved = true + return + } guard explanation.isEmpty, errorMessage == nil else { return } do { explanation = try await SentenceBoundaryAPI.explain( @@ -290,4 +310,13 @@ struct SentenceExplanationSheet: View { } } } + + private func saveExplanation() { + guard !saved, !explanation.isEmpty, errorMessage == nil else { return } + saved = SentenceExplanationStore.shared.save( + mediaId: mediaId, + sentenceIndex: sentence.index, + explanation: explanation + ) + } }