From 8ba52e0f03b7b056b250630360f3a68a9a9e4053 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 16 Jan 2026 18:06:59 -0700 Subject: [PATCH 1/9] refactor(release): exclude merge commits and support scoped commits in changelog --- scripts/release.sh | 123 +++++++++++++++++++++++---------------------- 1 file changed, 62 insertions(+), 61 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 9b70bd3..12b042c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -12,11 +12,11 @@ CHANGELOG_FILE="CHANGELOG.md" STATE_FILE=".release_state" # Centralized commit filter - commits matching these patterns will be excluded from changelog -EXCLUDED_COMMIT_PATTERNS="^ci\(release_sh\):" +EXCLUDED_COMMIT_PATTERNS="^ci\(release_sh\):|^Merge pull request|^Merge branch" if [ ! -f "$PROJECT_FILE" ]; then - echo -e "${RED}Error: $PROJECT_FILE not found!${NC}" - exit 1 + echo -e "${RED}Error: $PROJECT_FILE not found!${NC}" + exit 1 fi # 1. Update Build Version (Always increments) @@ -29,13 +29,13 @@ sed -i '' "s/let buildVersionString = \"$CURRENT_BUILD\"/let buildVersionString # 2. Update Short Version (Only if argument provided) if [ -n "$1" ]; then - NEW_VERSION="$1" - echo "Updating short version to: $NEW_VERSION" - sed -i '' "s/let shortVersionString = \".*\"/let shortVersionString = \"$NEW_VERSION\"/" "$PROJECT_FILE" + NEW_VERSION="$1" + echo "Updating short version to: $NEW_VERSION" + sed -i '' "s/let shortVersionString = \".*\"/let shortVersionString = \"$NEW_VERSION\"/" "$PROJECT_FILE" else - # Read existing short version - NEW_VERSION=$(grep 'let shortVersionString' "$PROJECT_FILE" | sed -E 's/.*"([^"]+)".*/\1/') - echo "No version specified. Keeping existing short version: $NEW_VERSION" + # Read existing short version + NEW_VERSION=$(grep 'let shortVersionString' "$PROJECT_FILE" | sed -E 's/.*"([^"]+)".*/\1/') + echo "No version specified. Keeping existing short version: $NEW_VERSION" fi # 3. Generate Changelog @@ -47,18 +47,18 @@ HEADER="## [$FULL_VERSION] - $DATE" # Determine commit range if [ -f "$STATE_FILE" ]; then - LAST_COMMIT=$(cat "$STATE_FILE") - echo "Found last release commit: $LAST_COMMIT" - # Check if the commit actually exists - if git cat-file -e "$LAST_COMMIT" 2>/dev/null; then - COMMITS=$(git log --pretty=format:"%s" "$LAST_COMMIT..HEAD" | grep -vE "$EXCLUDED_COMMIT_PATTERNS") - else - echo -e "${RED}Warning: Last commit $LAST_COMMIT not found. Defaulting to last 10 commits.${NC}" - COMMITS=$(git log -n 10 --pretty=format:"%s" | grep -vE "$EXCLUDED_COMMIT_PATTERNS") - fi + LAST_COMMIT=$(cat "$STATE_FILE") + echo "Found last release commit: $LAST_COMMIT" + # Check if the commit actually exists + if git cat-file -e "$LAST_COMMIT" 2>/dev/null; then + COMMITS=$(git log --no-merges --pretty=format:"%s" "$LAST_COMMIT..HEAD" | grep -vE "$EXCLUDED_COMMIT_PATTERNS") + else + echo -e "${RED}Warning: Last commit $LAST_COMMIT not found. Defaulting to last 10 commits.${NC}" + COMMITS=$(git log --no-merges -n 10 --pretty=format:"%s" | grep -vE "$EXCLUDED_COMMIT_PATTERNS") + fi else - echo "No previous release state found. Defaulting to last 10 commits." - COMMITS=$(git log -n 10 --pretty=format:"%s" | grep -vE "$EXCLUDED_COMMIT_PATTERNS") + echo "No previous release state found. Defaulting to last 10 commits." + COMMITS=$(git log --no-merges -n 10 --pretty=format:"%s" | grep -vE "$EXCLUDED_COMMIT_PATTERNS") fi # Initialize category arrays @@ -70,81 +70,82 @@ OTHER="" # Categorize commits while IFS= read -r commit; do - if [ -z "$commit" ]; then - continue - fi - - # Extract prefix (everything before the first colon) - prefix=$(echo "$commit" | grep -oE "^[a-zA-Z]+:" | tr -d ':' | tr '[:upper:]' '[:lower:]') - - # Remove prefix from message for cleaner output - message=$(echo "$commit" | sed 's/^[a-zA-Z]*: *//') - - case "$prefix" in - feat|feature) - FEATURES="${FEATURES}- ${message}"$'\n' - ;; - fix|bugfix|bug) - FIXES="${FIXES}- ${message}"$'\n' - ;; - refactor|perf|improve|enhancement) - IMPROVEMENTS="${IMPROVEMENTS}- ${message}"$'\n' - ;; - chore|build|ci|style|docs|test) - CHORES="${CHORES}- ${message}"$'\n' - ;; - *) - # If no recognized prefix, include the full commit message - OTHER="${OTHER}- ${commit}"$'\n' - ;; - esac -done <<< "$COMMITS" + if [ -z "$commit" ]; then + continue + fi + + # Extract prefix (handles scoped commits like feat(ui):) + full_prefix=$(echo "$commit" | grep -oE "^[a-zA-Z]+(\([^\)]+\))?:" | tr '[:upper:]' '[:lower:]') + prefix=$(echo "$full_prefix" | sed -E 's/^([a-zA-Z]+).*/\1/') + + # Remove prefix from message for cleaner output + message=$(echo "$commit" | sed -E 's/^[a-zA-Z]+(\([^\)]+\))?: *//') + + case "$prefix" in + feat | feature) + FEATURES="${FEATURES}- ${message}"$'\n' + ;; + fix | bugfix | bug) + FIXES="${FIXES}- ${message}"$'\n' + ;; + refactor | perf | improve | enhancement) + IMPROVEMENTS="${IMPROVEMENTS}- ${message}"$'\n' + ;; + chore | build | ci | style | docs | test) + CHORES="${CHORES}- ${message}"$'\n' + ;; + *) + # If no recognized prefix, include the full commit message + OTHER="${OTHER}- ${commit}"$'\n' + ;; + esac +done <<<"$COMMITS" # Build changelog entry NEW_ENTRY="$HEADER"$'\n' if [ -n "$FEATURES" ]; then - NEW_ENTRY="${NEW_ENTRY}"$'\n'"### Features"$'\n'"${FEATURES}" + NEW_ENTRY="${NEW_ENTRY}"$'\n'"### Features"$'\n'"${FEATURES}" fi if [ -n "$FIXES" ]; then - NEW_ENTRY="${NEW_ENTRY}"$'\n'"### Bug Fixes"$'\n'"${FIXES}" + NEW_ENTRY="${NEW_ENTRY}"$'\n'"### Bug Fixes"$'\n'"${FIXES}" fi if [ -n "$IMPROVEMENTS" ]; then - NEW_ENTRY="${NEW_ENTRY}"$'\n'"### Improvements"$'\n'"${IMPROVEMENTS}" + NEW_ENTRY="${NEW_ENTRY}"$'\n'"### Improvements"$'\n'"${IMPROVEMENTS}" fi if [ -n "$CHORES" ]; then - NEW_ENTRY="${NEW_ENTRY}"$'\n'"### Chores"$'\n'"${CHORES}" + NEW_ENTRY="${NEW_ENTRY}"$'\n'"### Chores"$'\n'"${CHORES}" fi if [ -n "$OTHER" ]; then - NEW_ENTRY="${NEW_ENTRY}"$'\n'"### Other"$'\n'"${OTHER}" + NEW_ENTRY="${NEW_ENTRY}"$'\n'"### Other"$'\n'"${OTHER}" fi # Handle case where there are no commits if [ -z "$FEATURES" ] && [ -z "$FIXES" ] && [ -z "$IMPROVEMENTS" ] && [ -z "$CHORES" ] && [ -z "$OTHER" ]; then - NEW_ENTRY="${NEW_ENTRY}"$'\n'"- No significant changes."$'\n' + NEW_ENTRY="${NEW_ENTRY}"$'\n'"- No significant changes."$'\n' fi NEW_ENTRY="${NEW_ENTRY}"$'\n' # Prepend to CHANGELOG.md if [ -f "$CHANGELOG_FILE" ]; then - # Create a temp file - echo "$NEW_ENTRY" | cat - "$CHANGELOG_FILE" > temp_changelog && mv temp_changelog "$CHANGELOG_FILE" + # Create a temp file + echo "$NEW_ENTRY" | cat - "$CHANGELOG_FILE" >temp_changelog && mv temp_changelog "$CHANGELOG_FILE" else - echo "# Changelog" > "$CHANGELOG_FILE" - echo "" >> "$CHANGELOG_FILE" - echo "$NEW_ENTRY" >> "$CHANGELOG_FILE" + echo "# Changelog" >"$CHANGELOG_FILE" + echo "" >>"$CHANGELOG_FILE" + echo "$NEW_ENTRY" >>"$CHANGELOG_FILE" fi echo -e "${GREEN}Changelog updated.${NC}" # 4. Update State File CURRENT_HEAD=$(git rev-parse HEAD) -echo "$CURRENT_HEAD" > "$STATE_FILE" +echo "$CURRENT_HEAD" >"$STATE_FILE" echo "Updated release state to $CURRENT_HEAD" # 5. Commit changes automatically From 11ce628a680c77873616c93e3af2fdc0e9fe65ff Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 16 Jan 2026 23:25:22 -0700 Subject: [PATCH 2/9] ci(convert): convert videos --- scripts/convert_to_avplayer.sh | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100755 scripts/convert_to_avplayer.sh diff --git a/scripts/convert_to_avplayer.sh b/scripts/convert_to_avplayer.sh new file mode 100755 index 0000000..ec3d694 --- /dev/null +++ b/scripts/convert_to_avplayer.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# ============================================================================== +# AVPlayer Video Converter Script +# Converts video files to H.264/AAC MP4 format for maximum compatibility. +# ============================================================================== + +if ! command -v ffmpeg &>/dev/null; then + echo "❌ Error: ffmpeg is not installed. Please install it first (e.g., 'brew install ffmpeg')." + exit 1 +fi + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " + echo "Example: $0 ./my_videos" + exit 1 +fi + +SOURCE_DIR="$1" +OUTPUT_DIR="${SOURCE_DIR}/output" + +if [ ! -d "$SOURCE_DIR" ]; then + echo "❌ Error: Source directory '$SOURCE_DIR' does not exist." + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" + +echo "🚀 Starting conversion..." +echo "📂 Source: $SOURCE_DIR" +echo "📂 Output: $OUTPUT_DIR" +echo "--------------------------------------------------" + +# Process files +find "$SOURCE_DIR" -maxdepth 1 -type f \( -iname "*.mp4" -o -iname "*.mkv" -o -iname "*.mov" -o -iname "*.avi" -o -iname "*.wmv" -o -iname "*.flv" -o -iname "*.webm" -o -iname "*.m4v" \) -print0 | while IFS= read -r -d '' file; do + filename=$(basename -- "$file") + filename_no_ext="${filename%.*}" + output_file="$OUTPUT_DIR/${filename_no_ext}.mp4" + + echo "🎬 Processing: $filename" + + ffmpeg -nostdin -i "file:$file" \ + -c:v libx264 -profile:v high -level:v 4.1 -pix_fmt yuv420p \ + -preset medium -crf 23 \ + -c:a aac -b:a 128k \ + -movflags +faststart \ + -hide_banner -loglevel error -stats \ + -y "$output_file" + + if [ $? -eq 0 ]; then + echo "✅ Successfully converted to: $output_file" + else + echo "❌ Failed to convert: $filename" + fi + echo "--------------------------------------------------" +done + +echo "🎉 All done! Converted files are in: $OUTPUT_DIR" From b598caecf91a809e19cb6310c30584795e52986a Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Jan 2026 00:39:43 -0700 Subject: [PATCH 3/9] feat(vocabulary): implement VocabularyService and refactor SubtitleView for better state management --- ABPlayer/Sources/ABPlayerApp.swift | 4 + .../Sources/Services/VocabularyService.swift | 120 ++++++++++++++++++ .../Sources/Utils/AttributedStringCache.swift | 45 +++++++ ABPlayer/Sources/Views/SubtitleView.swift | 96 ++------------ 4 files changed, 182 insertions(+), 83 deletions(-) create mode 100644 ABPlayer/Sources/Services/VocabularyService.swift create mode 100644 ABPlayer/Sources/Utils/AttributedStringCache.swift diff --git a/ABPlayer/Sources/ABPlayerApp.swift b/ABPlayer/Sources/ABPlayerApp.swift index db0fbfd..e586c17 100644 --- a/ABPlayer/Sources/ABPlayerApp.swift +++ b/ABPlayer/Sources/ABPlayerApp.swift @@ -36,6 +36,7 @@ struct ABPlayerApp: App { private let sessionTracker = SessionTracker() private let transcriptionManager = TranscriptionManager() private let transcriptionSettings = TranscriptionSettings() + private let vocabularyService: VocabularyService private let queueManager: TranscriptionQueueManager private let updater = SparkleUpdater() @@ -77,6 +78,8 @@ struct ABPlayerApp: App { modelContainer = try ModelContainer(for: schema, configurations: modelConfiguration) modelContainer.mainContext.autosaveEnabled = true + vocabularyService = VocabularyService(modelContext: modelContainer.mainContext) + queueManager = TranscriptionQueueManager( transcriptionManager: transcriptionManager, settings: transcriptionSettings @@ -153,6 +156,7 @@ struct ABPlayerApp: App { .environment(transcriptionManager) .environment(transcriptionSettings) .environment(queueManager) + .environment(vocabularyService) } .defaultSize(width: 1600, height: 900) .windowResizability(.contentSize) diff --git a/ABPlayer/Sources/Services/VocabularyService.swift b/ABPlayer/Sources/Services/VocabularyService.swift new file mode 100644 index 0000000..7ff260b --- /dev/null +++ b/ABPlayer/Sources/Services/VocabularyService.swift @@ -0,0 +1,120 @@ +import Foundation +import Observation +import SwiftData + +/// Service responsible for managing vocabulary operations +/// Provides centralized vocabulary lookup, CRUD operations, and cache management +@Observable +@MainActor +final class VocabularyService { + private let modelContext: ModelContext + + /// Internal cache mapping normalized words to Vocabulary entities + private var vocabularyMap: [String: Vocabulary] = [:] + + /// Version counter for cache invalidation - incremented whenever vocabulary data changes + /// Views can observe this to trigger re-renders when vocabulary updates + private(set) var version = 0 + + init(modelContext: ModelContext) { + self.modelContext = modelContext + refreshVocabularyMap() + } + + // MARK: - Public API + + /// Normalize a word for vocabulary lookup (lowercase, trim punctuation) + func normalize(_ word: String) -> String { + word.lowercased().trimmingCharacters(in: .punctuationCharacters) + } + + /// Find vocabulary entry for a word + /// - Parameter word: The word to look up (will be normalized internally) + /// - Returns: Vocabulary entry if found, nil otherwise + func findVocabulary(for word: String) -> Vocabulary? { + let normalized = normalize(word) + return vocabularyMap[normalized] + } + + /// Get difficulty level for a word (nil if not in vocabulary or level is 0) + /// - Parameter word: The word to check + /// - Returns: Difficulty level (1-3) or nil + func difficultyLevel(for word: String) -> Int? { + guard let vocab = findVocabulary(for: word), vocab.difficultyLevel > 0 else { + return nil + } + return vocab.difficultyLevel + } + + /// Increment forgot count for a word (creates new entry if not exists) + /// - Parameter word: The word to mark as forgotten + func incrementForgotCount(for word: String) { + let normalized = normalize(word) + if let vocab = vocabularyMap[normalized] { + vocab.forgotCount += 1 + } else { + let newVocab = Vocabulary(word: normalized, forgotCount: 1) + modelContext.insert(newVocab) + vocabularyMap[normalized] = newVocab + } + version += 1 + } + + /// Increment remembered count for a word (only if already in vocabulary) + /// - Parameter word: The word to mark as remembered + func incrementRememberedCount(for word: String) { + let normalized = normalize(word) + // Only increment if word exists - you can't "remember" a word you never "forgot" + if let vocab = vocabularyMap[normalized] { + vocab.rememberedCount += 1 + version += 1 + } + } + + /// Remove vocabulary entry for a word + /// - Parameter word: The word to remove + func removeVocabulary(for word: String) { + let normalized = normalize(word) + if let vocab = vocabularyMap[normalized] { + modelContext.delete(vocab) + vocabularyMap.removeValue(forKey: normalized) + version += 1 + } + } + + /// Get forgot count for a word (0 if not in vocabulary) + /// - Parameter word: The word to check + /// - Returns: Number of times the word was forgotten + func forgotCount(for word: String) -> Int { + findVocabulary(for: word)?.forgotCount ?? 0 + } + + /// Get remembered count for a word (0 if not in vocabulary) + /// - Parameter word: The word to check + /// - Returns: Number of times the word was remembered + func rememberedCount(for word: String) -> Int { + findVocabulary(for: word)?.rememberedCount ?? 0 + } + + /// Get creation date for a word (nil if not in vocabulary) + /// - Parameter word: The word to check + /// - Returns: Date when the word was first added to vocabulary + func createdAt(for word: String) -> Date? { + findVocabulary(for: word)?.createdAt + } + + // MARK: - Internal Cache Management + + /// Refresh the internal vocabulary map from ModelContext + /// Should be called when external changes to vocabulary data occur + func refreshVocabularyMap() { + let descriptor = FetchDescriptor() + let vocabularies = (try? modelContext.fetch(descriptor)) ?? [] + + vocabularyMap = Dictionary( + vocabularies.map { ($0.word, $0) }, + uniquingKeysWith: { first, _ in first } + ) + version += 1 + } +} diff --git a/ABPlayer/Sources/Utils/AttributedStringCache.swift b/ABPlayer/Sources/Utils/AttributedStringCache.swift new file mode 100644 index 0000000..5ca7a77 --- /dev/null +++ b/ABPlayer/Sources/Utils/AttributedStringCache.swift @@ -0,0 +1,45 @@ +import AppKit +import Foundation +import Observation + +@Observable +@MainActor +final class AttributedStringCache { + struct CacheKey: Hashable { + let cueID: UUID + let fontSize: Double + let defaultTextColor: NSColor + let vocabularyVersion: Int + + func hash(into hasher: inout Hasher) { + hasher.combine(cueID) + hasher.combine(fontSize) + hasher.combine(vocabularyVersion) + } + + static func == (lhs: CacheKey, rhs: CacheKey) -> Bool { + lhs.cueID == rhs.cueID && + lhs.fontSize == rhs.fontSize && + lhs.defaultTextColor == rhs.defaultTextColor && + lhs.vocabularyVersion == rhs.vocabularyVersion + } + } + + private var cache: [CacheKey: NSAttributedString] = [:] + + func get(for key: CacheKey) -> NSAttributedString? { + cache[key] + } + + func set(_ value: NSAttributedString, for key: CacheKey) { + cache[key] = value + } + + func invalidate(for cueID: UUID) { + cache = cache.filter { $0.key.cueID != cueID } + } + + func invalidateAll() { + cache.removeAll() + } +} diff --git a/ABPlayer/Sources/Views/SubtitleView.swift b/ABPlayer/Sources/Views/SubtitleView.swift index 03b49ef..2994f7e 100644 --- a/ABPlayer/Sources/Views/SubtitleView.swift +++ b/ABPlayer/Sources/Views/SubtitleView.swift @@ -5,28 +5,19 @@ import SwiftUI /// Displays synchronized subtitles with current playback position highlighted struct SubtitleView: View { @Environment(AudioPlayerManager.self) private var playerManager - @Query private var vocabularies: [Vocabulary] + @Environment(VocabularyService.self) private var vocabularyService let cues: [SubtitleCue] - /// Binding to expose countdown seconds to parent (nil when not paused) @Binding var countdownSeconds: Int? - /// Font size for subtitle text let fontSize: Double - /// Duration for resume countdown in seconds private static let pauseDuration = 3 @State private var currentCueID: UUID? - /// Indicates user is manually scrolling; pauses auto-scroll and highlight tracking @State private var isUserScrolling = false - /// Task to handle countdown and resume tracking @State private var scrollResumeTask: Task? - /// Currently selected word info (cueID, wordIndex) - lifted to parent for cross-row dismiss @State private var selectedWord: (cueID: UUID, wordIndex: Int)? - /// Tracks if playback was playing before word interaction (for cross-row dismiss) @State private var wasPlayingBeforeWordInteraction = false - @State private var vocabularyMap: [String: Vocabulary] = [:] - @State private var vocabularyVersion = 0 @State private var tappedCueID: UUID? var body: some View { @@ -40,8 +31,6 @@ struct SubtitleView: View { isActive: cue.id == currentCueID, isScrolling: isUserScrolling, fontSize: fontSize, - vocabularyMap: vocabularyMap, - vocabularyVersion: vocabularyVersion, selectedWordIndex: selectedWord?.cueID == cue.id ? selectedWord?.wordIndex : nil, onWordSelected: { wordIndex in handleWordSelection(wordIndex: wordIndex, cueID: cue.id) @@ -99,18 +88,6 @@ struct SubtitleView: View { .task { await trackCurrentCue() } - .onAppear { - updateVocabularyMap() - } - .onChange(of: vocabularies) { _, _ in - updateVocabularyMap() - } - } - - private func updateVocabularyMap() { - vocabularyMap = Dictionary( - vocabularies.map { ($0.word, $0) }, uniquingKeysWith: { first, _ in first }) - vocabularyVersion += 1 } private func handleWordSelection(wordIndex: Int?, cueID: UUID) { @@ -127,12 +104,10 @@ struct SubtitleView: View { } } - /// Only hides the popover without resuming playback private func hidePopover() { selectedWord = nil } - /// Hides the popover and resumes playback if it was paused by word click private func dismissWord() { guard selectedWord != nil else { return } selectedWord = nil @@ -226,14 +201,12 @@ struct SubtitleView: View { // MARK: - Cue Row private struct SubtitleCueRow: View { - @Environment(\.modelContext) private var modelContext + @Environment(VocabularyService.self) private var vocabularyService let cue: SubtitleCue let isActive: Bool let isScrolling: Bool let fontSize: Double - let vocabularyMap: [String: Vocabulary] - let vocabularyVersion: Int let selectedWordIndex: Int? let onWordSelected: (Int?) -> Void let onHidePopover: () -> Void @@ -251,8 +224,6 @@ private struct SubtitleCueRow: View { isActive: Bool, isScrolling: Bool, fontSize: Double, - vocabularyMap: [String: Vocabulary], - vocabularyVersion: Int, selectedWordIndex: Int?, onWordSelected: @escaping (Int?) -> Void, onHidePopover: @escaping () -> Void, @@ -262,8 +233,6 @@ private struct SubtitleCueRow: View { self.isActive = isActive self.isScrolling = isScrolling self.fontSize = fontSize - self.vocabularyMap = vocabularyMap - self.vocabularyVersion = vocabularyVersion self.selectedWordIndex = selectedWordIndex self.onWordSelected = onWordSelected self.onHidePopover = onHidePopover @@ -272,63 +241,24 @@ private struct SubtitleCueRow: View { } - /// Normalize a word for vocabulary lookup (lowercase, trim punctuation) - private func normalize(_ word: String) -> String { - word.lowercased().trimmingCharacters(in: .punctuationCharacters) - } - - /// Find vocabulary entry for a word - private func findVocabulary(for word: String) -> Vocabulary? { - let normalized = normalize(word) - return vocabularyMap[normalized] - } - /// Get difficulty level for a word (nil if not in vocabulary or level is 0) private func difficultyLevel(for word: String) -> Int? { - guard let vocab = findVocabulary(for: word), vocab.difficultyLevel > 0 else { - return nil - } - return vocab.difficultyLevel - } - - /// Increment forgot count for a word (creates new entry if not exists) - private func incrementForgotCount(for word: String) { - if let vocab = findVocabulary(for: word) { - vocab.forgotCount += 1 - } else { - let newVocab = Vocabulary(word: normalize(word), forgotCount: 1) - modelContext.insert(newVocab) - } - } - - /// Increment remembered count for a word (only if already in vocabulary) - private func incrementRememberedCount(for word: String) { - // Only increment if word exists - you can't "remember" a word you never "forgot" - if let vocab = findVocabulary(for: word) { - vocab.rememberedCount += 1 - } - } - - /// Remove vocabulary entry for a word - private func removeVocabulary(for word: String) { - if let vocab = findVocabulary(for: word) { - modelContext.delete(vocab) - } + vocabularyService.difficultyLevel(for: word) } /// Get forgot count for a word (0 if not in vocabulary) private func forgotCount(for word: String) -> Int { - findVocabulary(for: word)?.forgotCount ?? 0 + vocabularyService.forgotCount(for: word) } /// Get remembered count for a word (0 if not in vocabulary) private func rememberedCount(for word: String) -> Int { - findVocabulary(for: word)?.rememberedCount ?? 0 + vocabularyService.rememberedCount(for: word) } /// Get creation date for a word (nil if not in vocabulary) private func createdAt(for word: String) -> Date? { - findVocabulary(for: word)?.createdAt + vocabularyService.createdAt(for: word) } @@ -351,7 +281,7 @@ private struct SubtitleCueRow: View { defaultTextColor: isActive ? NSColor(Color.primary) : NSColor(Color.secondary), selectedWordIndex: selectedWordIndex, difficultyLevelProvider: { difficultyLevel(for: $0) }, - vocabularyVersion: vocabularyVersion, + vocabularyVersion: vocabularyService.version, onWordSelected: { index in isWordInteracting = true onWordSelected(selectedWordIndex == index ? nil : index) @@ -364,13 +294,13 @@ private struct SubtitleCueRow: View { onWordSelected(nil) }, onForgot: { word in - incrementForgotCount(for: word) + vocabularyService.incrementForgotCount(for: word) }, onRemembered: { word in - incrementRememberedCount(for: word) + vocabularyService.incrementRememberedCount(for: word) }, onRemove: { word in - removeVocabulary(for: word) + vocabularyService.removeVocabulary(for: word) }, onWordRectChanged: { rect in if popoverSourceRect != rect { @@ -409,9 +339,9 @@ private struct SubtitleCueRow: View { WordMenuView( word: words[selectedIndex], onDismiss: { onWordSelected(nil) }, - onForgot: { incrementForgotCount(for: $0) }, - onRemembered: { incrementRememberedCount(for: $0) }, - onRemove: { removeVocabulary(for: $0) }, + onForgot: { vocabularyService.incrementForgotCount(for: $0) }, + onRemembered: { vocabularyService.incrementRememberedCount(for: $0) }, + onRemove: { vocabularyService.removeVocabulary(for: $0) }, forgotCount: forgotCount(for: words[selectedIndex]), rememberedCount: rememberedCount(for: words[selectedIndex]), createdAt: createdAt(for: words[selectedIndex]) From 17e485609f769d2e95ee81aeece4a43bffd501cd Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Jan 2026 00:59:42 -0700 Subject: [PATCH 4/9] refactor(SubtitleView): extract logic to SubtitleViewModel and modularize sub-components --- .../Components/CountdownRingView.swift | 39 + .../InteractiveAttributedTextView.swift | 513 +++++++++ .../Subtitle/Components/WordMenuView.swift | 86 ++ .../Subtitle/Models/WordVocabularyData.swift | 59 + .../Views/Subtitle/SubtitleCueRow.swift | 209 ++++ .../Views/Subtitle/SubtitleViewModel.swift | 156 +++ ABPlayer/Sources/Views/SubtitleView.swift | 1021 +---------------- 7 files changed, 1094 insertions(+), 989 deletions(-) create mode 100644 ABPlayer/Sources/Views/Subtitle/Components/CountdownRingView.swift create mode 100644 ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift create mode 100644 ABPlayer/Sources/Views/Subtitle/Components/WordMenuView.swift create mode 100644 ABPlayer/Sources/Views/Subtitle/Models/WordVocabularyData.swift create mode 100644 ABPlayer/Sources/Views/Subtitle/SubtitleCueRow.swift create mode 100644 ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift diff --git a/ABPlayer/Sources/Views/Subtitle/Components/CountdownRingView.swift b/ABPlayer/Sources/Views/Subtitle/Components/CountdownRingView.swift new file mode 100644 index 0000000..2db8ea6 --- /dev/null +++ b/ABPlayer/Sources/Views/Subtitle/Components/CountdownRingView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct CountdownRingView: View { + let countdown: Int + let total: Int + + private var progress: Double { + guard total > 0 else { return 0 } + return Double(countdown) / Double(total) + } + + var body: some View { + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.2), lineWidth: 3) + + Circle() + .trim(from: 0, to: progress) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 3, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 1), value: progress) + + Text("\(countdown)") + .font(.system(.caption, design: .rounded, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.primary) + } + .frame(width: 32, height: 32) + .padding(6) + .background { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(.ultraThinMaterial) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + } + } +} diff --git a/ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift b/ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift new file mode 100644 index 0000000..41383bf --- /dev/null +++ b/ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift @@ -0,0 +1,513 @@ +import SwiftUI +import AppKit +import OSLog + +struct InteractiveAttributedTextView: NSViewRepresentable { + let cueID: UUID + let isScrolling: Bool + let words: [String] + let fontSize: Double + var defaultTextColor: NSColor = .labelColor + let selectedWordIndex: Int? + let difficultyLevelProvider: (String) -> Int? + let vocabularyVersion: Int + let onWordSelected: (Int) -> Void + let onDismiss: () -> Void + let onForgot: (String) -> Void + let onRemembered: (String) -> Void + let onRemove: (String) -> Void + let onWordRectChanged: (CGRect?) -> Void + let onHeightChanged: (CGFloat) -> Void + let forgotCount: (String) -> Int + let rememberedCount: (String) -> Int + let createdAt: (String) -> Date? + + func makeNSView(context: Context) -> InteractiveNSTextView { + let textView = InteractiveNSTextView() + textView.isEditable = false + textView.isSelectable = false + textView.backgroundColor = .clear + textView.textContainerInset = NSSize(width: 2, height: 1) + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.coordinator = context.coordinator + + textView.setContentHuggingPriority(.defaultHigh, for: .vertical) + textView.setContentCompressionResistancePriority(.required, for: .vertical) + + return textView + } + + func updateNSView(_ textView: InteractiveNSTextView, context: Context) { + let isFirstRender = context.coordinator.cachedAttributedString == nil + let needsContentUpdate = isFirstRender || + context.coordinator.cueID != cueID || + context.coordinator.fontSize != fontSize || + context.coordinator.defaultTextColor != defaultTextColor || + context.coordinator.words != words || + context.coordinator.vocabularyVersion != vocabularyVersion + + let needsSelectionUpdate = context.coordinator.selectedWordIndex != selectedWordIndex + + context.coordinator.difficultyLevelProvider = difficultyLevelProvider + context.coordinator.vocabularyVersion = vocabularyVersion + context.coordinator.onWordSelected = onWordSelected + context.coordinator.onDismiss = onDismiss + context.coordinator.onForgot = onForgot + context.coordinator.onRemembered = onRemembered + context.coordinator.onRemove = onRemove + context.coordinator.onWordRectChanged = onWordRectChanged + context.coordinator.forgotCount = forgotCount + context.coordinator.rememberedCount = rememberedCount + context.coordinator.createdAt = createdAt + context.coordinator.isScrolling = isScrolling + + if needsContentUpdate { + if context.coordinator.vocabularyVersion != vocabularyVersion { + context.coordinator.cachedAttributedString = nil + } + context.coordinator.cachedSize = nil + context.coordinator.updateState( + cueID: cueID, + words: words, + selectedWordIndex: selectedWordIndex, + fontSize: fontSize, + defaultTextColor: defaultTextColor + ) + textView.textStorage?.setAttributedString(context.coordinator.buildAttributedString()) + textView.invalidateIntrinsicContentSize() + } + + if needsSelectionUpdate { + textView.updateHoverState( + oldHoveredIndex: context.coordinator.lastHoveredIndex, + newHoveredIndex: textView.hoveredWordIndex, + oldSelectedIndex: context.coordinator.lastSelectedIndex, + newSelectedIndex: selectedWordIndex + ) + context.coordinator.selectedWordIndex = selectedWordIndex + context.coordinator.lastSelectedIndex = selectedWordIndex + context.coordinator.updateSelectedRect(in: textView) + } else if !isScrolling { + if textView.hoveredWordIndex != context.coordinator.lastHoveredIndex { + textView.updateHoverState( + oldHoveredIndex: context.coordinator.lastHoveredIndex, + newHoveredIndex: textView.hoveredWordIndex, + oldSelectedIndex: nil, + newSelectedIndex: nil + ) + context.coordinator.lastHoveredIndex = textView.hoveredWordIndex + } + } else if isScrolling { + if context.coordinator.lastHoveredIndex != nil { + textView.updateHoverState( + oldHoveredIndex: context.coordinator.lastHoveredIndex, + newHoveredIndex: nil, + oldSelectedIndex: nil, + newSelectedIndex: nil + ) + context.coordinator.lastHoveredIndex = nil + textView.hoveredWordIndex = nil + } + } + } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView: InteractiveNSTextView, context: Context) -> CGSize? { + guard let layoutManager = nsView.layoutManager, + let textContainer = nsView.textContainer else { + return nil + } + + let width = proposal.width ?? 400 + + if let cachedWidth = context.coordinator.cachedWidth, + let cachedSize = context.coordinator.cachedSize, + abs(cachedWidth - width) < 1.0 { + Logger.ui.debug("hit cache \(words.first ?? "nil") w:\(cachedSize.width), h:\(cachedSize.height)") + return cachedSize + } + + textContainer.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) + + layoutManager.ensureLayout(for: textContainer) + let usedRect = layoutManager.usedRect(for: textContainer) + if usedRect.isEmpty { + return nil + } + + let inset = nsView.textContainerInset + let height = usedRect.height + inset.height * 2 + + let size = CGSize(width: width, height: height) + if width.isNormal && height.isNormal { + context.coordinator.cachedWidth = width + context.coordinator.cachedSize = size + } + + DispatchQueue.main.async { + onHeightChanged(height) + } + + Logger.ui.debug("\(words.first ?? "nil") w:\(width), h:\(height)") + + return size + } + + func makeCoordinator() -> Coordinator { + Coordinator( + cueID: cueID, + words: words, + selectedWordIndex: selectedWordIndex, + fontSize: fontSize, + defaultTextColor: defaultTextColor, + difficultyLevelProvider: difficultyLevelProvider, + vocabularyVersion: vocabularyVersion, + onWordSelected: onWordSelected, + onDismiss: onDismiss, + onForgot: onForgot, + onRemembered: onRemembered, + onRemove: onRemove, + onWordRectChanged: onWordRectChanged, + forgotCount: forgotCount, + rememberedCount: rememberedCount, + createdAt: createdAt + ) + } + + class Coordinator: NSObject { + var cueID: UUID + var words: [String] + var selectedWordIndex: Int? + var fontSize: Double + var defaultTextColor: NSColor + var difficultyLevelProvider: (String) -> Int? + var vocabularyVersion: Int + var onWordSelected: (Int) -> Void + var onDismiss: () -> Void + var onForgot: (String) -> Void + var onRemembered: (String) -> Void + var onRemove: (String) -> Void + var onWordRectChanged: (CGRect?) -> Void + var forgotCount: (String) -> Int + var rememberedCount: (String) -> Int + var createdAt: (String) -> Date? + var isScrolling = false + + var cachedAttributedString: NSAttributedString? + var cachedVocabularyVersion: Int = 0 + var cachedDefaultTextColor: NSColor? + var lastSelectedIndex: Int? + var lastHoveredIndex: Int? + var wordRanges: [NSRange] = [] + var wordFrames: [CGRect] = [] + var cachedWidth: CGFloat? + var cachedSize: CGSize? + + init( + cueID: UUID, + words: [String], + selectedWordIndex: Int?, + fontSize: Double, + defaultTextColor: NSColor, + difficultyLevelProvider: @escaping (String) -> Int?, + vocabularyVersion: Int, + onWordSelected: @escaping (Int) -> Void, + onDismiss: @escaping () -> Void, + onForgot: @escaping (String) -> Void, + onRemembered: @escaping (String) -> Void, + onRemove: @escaping (String) -> Void, + onWordRectChanged: @escaping (CGRect?) -> Void, + forgotCount: @escaping (String) -> Int, + rememberedCount: @escaping (String) -> Int, + createdAt: @escaping (String) -> Date? + ) { + self.cueID = cueID + self.words = words + self.selectedWordIndex = selectedWordIndex + self.fontSize = fontSize + self.defaultTextColor = defaultTextColor + self.difficultyLevelProvider = difficultyLevelProvider + self.vocabularyVersion = vocabularyVersion + self.onWordSelected = onWordSelected + self.onDismiss = onDismiss + self.onForgot = onForgot + self.onRemembered = onRemembered + self.onRemove = onRemove + self.onWordRectChanged = onWordRectChanged + self.forgotCount = forgotCount + self.rememberedCount = rememberedCount + self.createdAt = createdAt + } + + func updateState( + cueID: UUID, + words: [String], + selectedWordIndex: Int?, + fontSize: Double, + defaultTextColor: NSColor + ) { + self.cueID = cueID + self.words = words + self.selectedWordIndex = selectedWordIndex + self.fontSize = fontSize + self.defaultTextColor = defaultTextColor + } + + func buildAttributedString() -> NSAttributedString { + if let cached = cachedAttributedString, + !wordRanges.isEmpty, + cached.string.split(separator: " ").count == words.count, + cachedVocabularyVersion == vocabularyVersion, + cachedDefaultTextColor == defaultTextColor { + return cached + } + + cachedDefaultTextColor = defaultTextColor + cachedVocabularyVersion = vocabularyVersion + wordRanges.removeAll(keepingCapacity: true) + wordFrames.removeAll(keepingCapacity: true) + let result = NSMutableAttributedString() + let font = NSFont.systemFont(ofSize: fontSize) + + for (index, word) in words.enumerated() { + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: baseColorForWord(word), + NSAttributedString.Key("wordIndex"): index + ] + + let startLocation = result.length + let wordString = NSAttributedString(string: word, attributes: attributes) + result.append(wordString) + let endLocation = result.length + + wordRanges.append(NSRange(location: startLocation, length: endLocation - startLocation)) + + if index < words.count - 1 { + result.append(NSAttributedString(string: " ", attributes: [.font: font])) + } + } + + cachedAttributedString = result + return result + } + + func cacheWordFrames(in textView: NSTextView) { + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { return } + + wordFrames.removeAll(keepingCapacity: true) + + for range in wordRanges { + let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + rect.origin.x += textView.textContainerInset.width + rect.origin.y += textView.textContainerInset.height + wordFrames.append(rect) + } + } + + func baseColorForWord(_ word: String) -> NSColor { + guard let level = difficultyLevelProvider(word), level > 0 else { + return defaultTextColor + } + switch level { + case 1: return .systemGreen + case 2: return .systemYellow + default: return .systemRed + } + } + + @MainActor + func updateSelectedRect(in textView: NSTextView) { + Task { @MainActor in + guard let index = selectedWordIndex, + index < wordRanges.count, + let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { + onWordRectChanged(nil) + return + } + + let range = wordRanges[index] + let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + rect.origin.x += textView.textContainerInset.width + rect.origin.y += textView.textContainerInset.height + onWordRectChanged(rect) + } + } + + @MainActor + func handleClick(at point: NSPoint, in textView: NSTextView) { + guard let wordIndex = findWordIndex(at: point, in: textView) else { + onDismiss() + return + } + onWordSelected(wordIndex) + } + + @MainActor + func handleMouseMoved(at point: NSPoint, in textView: NSTextView) -> Int? { + if isScrolling { return nil } + return findWordIndex(at: point, in: textView) + } + + @MainActor + private func findWordIndex(at point: NSPoint, in textView: NSTextView) -> Int? { + let containerInset = textView.textContainerInset + + let hoverAreaFrame = textView.bounds.insetBy( + dx: containerInset.width * 2, + dy: containerInset.height * 2 + ) + + guard hoverAreaFrame.contains(point) else { + return nil + } + + if !wordFrames.isEmpty && wordFrames.count == wordRanges.count { + for (index, frame) in wordFrames.enumerated() { + if frame.contains(point) { + return index + } + } + return nil + } + + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer, + let textStorage = textView.textStorage else { return nil } + + let characterIndex = layoutManager.characterIndex( + for: point, + in: textContainer, + fractionOfDistanceBetweenInsertionPoints: nil + ) + + guard characterIndex < textStorage.length else { return nil } + + return textStorage.attribute(NSAttributedString.Key("wordIndex"), at: characterIndex, effectiveRange: nil) as? Int + } + } +} + +class InteractiveNSTextView: NSTextView { + weak var coordinator: InteractiveAttributedTextView.Coordinator? + private var trackingArea: NSTrackingArea? + var hoveredWordIndex: Int? + weak var popoverViewController: NSViewController? + + override var firstBaselineOffsetFromTop: CGFloat { + guard let layoutManager = layoutManager, + let textContainer = textContainer, + let textStorage = textStorage, + textStorage.length > 0 else { + return textContainerInset.height + } + + let glyphRange = layoutManager.glyphRange(for: textContainer) + guard glyphRange.length > 0 else { + return textContainerInset.height + } + + let firstLineFragmentRect = layoutManager.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil) + Logger.ui.debug("firstLineFragmentRect: \(String(describing: firstLineFragmentRect))") + let firstLineBaselineOffset = layoutManager.typesetter.baselineOffset( + in: layoutManager, + glyphIndex: 0 + ) + + return textContainerInset.height + firstLineFragmentRect.origin.y + firstLineBaselineOffset + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + coordinator?.cacheWordFrames(in: self) + coordinator?.updateSelectedRect(in: self) + + if let trackingArea = trackingArea { + removeTrackingArea(trackingArea) + } + + let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow] + trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) + addTrackingArea(trackingArea!) + } + + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + coordinator?.handleClick(at: point, in: self) + } + + override func mouseMoved(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + let newHoveredWordIndex = coordinator?.handleMouseMoved(at: point, in: self) + + if newHoveredWordIndex != hoveredWordIndex { + let oldIndex = hoveredWordIndex + hoveredWordIndex = newHoveredWordIndex + updateHoverState( + oldHoveredIndex: oldIndex, + newHoveredIndex: newHoveredWordIndex, + oldSelectedIndex: coordinator?.lastSelectedIndex, + newSelectedIndex: coordinator?.lastSelectedIndex + ) + } + } + + override func mouseExited(with event: NSEvent) { + if hoveredWordIndex != nil { + let oldIndex = hoveredWordIndex + hoveredWordIndex = nil + updateHoverState( + oldHoveredIndex: oldIndex, + newHoveredIndex: nil, + oldSelectedIndex: coordinator?.lastSelectedIndex, + newSelectedIndex: coordinator?.lastSelectedIndex + ) + } + } + + func updateHoverState(oldHoveredIndex: Int?, newHoveredIndex: Int?, oldSelectedIndex: Int?, newSelectedIndex: Int?) { + guard let textStorage = textStorage, let coordinator = coordinator else { return } + + var indicesToUpdate = [oldHoveredIndex, newHoveredIndex].compactMap { $0 } + + if let oldIndex = oldSelectedIndex, oldSelectedIndex != newSelectedIndex { + indicesToUpdate.append(oldIndex) + } + if let newIndex = newSelectedIndex, oldSelectedIndex != newSelectedIndex { + indicesToUpdate.append(newIndex) + } + + let uniqueIndices = Array(Set(indicesToUpdate)) + if uniqueIndices.isEmpty { return } + + textStorage.beginEditing() + + for wordIndex in uniqueIndices { + guard wordIndex < coordinator.wordRanges.count else { continue } + let range = coordinator.wordRanges[wordIndex] + + let isHovered = wordIndex == newHoveredIndex + let isSelected = wordIndex == newSelectedIndex + let isHighlighted = isHovered || isSelected + + let word = coordinator.words[wordIndex] + let baseColor = coordinator.baseColorForWord(word) + let foregroundColor = isHighlighted ? NSColor.controlAccentColor : baseColor + let backgroundColor = isHighlighted ? NSColor.controlAccentColor.withAlphaComponent(0.15) : .clear + + textStorage.addAttribute(.foregroundColor, value: foregroundColor, range: range) + textStorage.addAttribute(.backgroundColor, value: backgroundColor, range: range) + } + + textStorage.endEditing() + } +} diff --git a/ABPlayer/Sources/Views/Subtitle/Components/WordMenuView.swift b/ABPlayer/Sources/Views/Subtitle/Components/WordMenuView.swift new file mode 100644 index 0000000..5208f46 --- /dev/null +++ b/ABPlayer/Sources/Views/Subtitle/Components/WordMenuView.swift @@ -0,0 +1,86 @@ +import SwiftUI +import AppKit + +struct WordMenuView: View { + let word: String + let onDismiss: () -> Void + let onForgot: (String) -> Void + let onRemembered: (String) -> Void + let onRemove: (String) -> Void + let forgotCount: Int + let rememberedCount: Int + let createdAt: Date? + + private var canRemember: Bool { + guard forgotCount > 0, let createdAt = createdAt else { return false } + return Date().timeIntervalSince(createdAt) >= 12 * 3600 + } + + private var cleanedWord: String { + word.lowercased().trimmingCharacters(in: .punctuationCharacters) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Group { + MenuButton(label: "Copy", systemImage: "doc.on.doc") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(cleanedWord, forType: .string) + onDismiss() + } + + MenuButton( + label: "Forgot" + (forgotCount > 0 ? " (\(forgotCount))" : ""), + systemImage: "xmark.circle" + ) { + onForgot(cleanedWord) + onDismiss() + } + + if canRemember { + MenuButton( + label: "Remember" + (rememberedCount > 0 ? " (\(rememberedCount))" : ""), + systemImage: "checkmark.circle" + ) { + onRemembered(cleanedWord) + onDismiss() + } + } + + if forgotCount > 0 || rememberedCount > 0 { + MenuButton(label: "Remove", systemImage: "trash") { + onRemove(cleanedWord) + onDismiss() + } + } + } + .padding(4) + } + .frame(minWidth: 160) + } +} + +struct MenuButton: View { + let label: String + let systemImage: String + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Label(label, systemImage: systemImage) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isHovered ? Color.accentColor : Color.clear) + ) + .foregroundStyle(isHovered ? .white : .primary) + .onHover { isHovered = $0 } + } +} diff --git a/ABPlayer/Sources/Views/Subtitle/Models/WordVocabularyData.swift b/ABPlayer/Sources/Views/Subtitle/Models/WordVocabularyData.swift new file mode 100644 index 0000000..1291c46 --- /dev/null +++ b/ABPlayer/Sources/Views/Subtitle/Models/WordVocabularyData.swift @@ -0,0 +1,59 @@ +import Foundation +import AppKit + +/// Value type representing vocabulary data for a word +/// Decouples views from VocabularyService implementation +struct WordVocabularyData { + let word: String + let difficultyLevel: Int? + let forgotCount: Int + let rememberedCount: Int + let createdAt: Date? + + /// Whether the word can be marked as "remembered" + /// Requires at least one "forgot" count and 12 hours since creation + var canRemember: Bool { + guard forgotCount > 0, let date = createdAt else { return false } + return Date().timeIntervalSince(date) >= 12 * 3600 + } + + /// Display color based on difficulty level + var displayColor: NSColor { + guard let level = difficultyLevel, level > 0 else { + return .labelColor + } + switch level { + case 1: return .systemGreen + case 2: return .systemYellow + default: return .systemRed + } + } + + /// Cleaned word text (lowercase, no punctuation) + var cleanedWord: String { + word.lowercased().trimmingCharacters(in: .punctuationCharacters) + } + + /// Whether this word has any vocabulary tracking data + var hasVocabularyData: Bool { + forgotCount > 0 || rememberedCount > 0 + } + + /// Create vocabulary data from VocabularyService + static func from( + word: String, + difficultyLevel: (String) -> Int?, + forgotCount: (String) -> Int, + rememberedCount: (String) -> Int, + createdAt: (String) -> Date? + ) -> WordVocabularyData { + let cleaned = word.lowercased().trimmingCharacters(in: .punctuationCharacters) + return WordVocabularyData( + word: word, + difficultyLevel: difficultyLevel(cleaned), + forgotCount: forgotCount(cleaned), + rememberedCount: rememberedCount(cleaned), + createdAt: createdAt(cleaned) + ) + } +} diff --git a/ABPlayer/Sources/Views/Subtitle/SubtitleCueRow.swift b/ABPlayer/Sources/Views/Subtitle/SubtitleCueRow.swift new file mode 100644 index 0000000..1ad70bf --- /dev/null +++ b/ABPlayer/Sources/Views/Subtitle/SubtitleCueRow.swift @@ -0,0 +1,209 @@ +import SwiftUI +import AppKit + +struct SubtitleCueRow: View { + @Environment(VocabularyService.self) private var vocabularyService + + let cue: SubtitleCue + let isActive: Bool + let isScrolling: Bool + let fontSize: Double + let selectedWordIndex: Int? + let onWordSelected: (Int?) -> Void + let onHidePopover: () -> Void + let onTap: () -> Void + + @State private var isHovered = false + @State private var popoverSourceRect: CGRect? + @State private var isWordInteracting = false + @State private var contentHeight: CGFloat = 0 + + private let words: [String] + + init( + cue: SubtitleCue, + isActive: Bool, + isScrolling: Bool, + fontSize: Double, + selectedWordIndex: Int?, + onWordSelected: @escaping (Int?) -> Void, + onHidePopover: @escaping () -> Void, + onTap: @escaping () -> Void + ) { + self.cue = cue + self.isActive = isActive + self.isScrolling = isScrolling + self.fontSize = fontSize + self.selectedWordIndex = selectedWordIndex + self.onWordSelected = onWordSelected + self.onHidePopover = onHidePopover + self.onTap = onTap + self.words = cue.text.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + } + + private func difficultyLevel(for word: String) -> Int? { + vocabularyService.difficultyLevel(for: word) + } + + private func forgotCount(for word: String) -> Int { + vocabularyService.forgotCount(for: word) + } + + private func rememberedCount(for word: String) -> Int { + vocabularyService.rememberedCount(for: word) + } + + private func createdAt(for word: String) -> Date? { + vocabularyService.createdAt(for: word) + } + + var body: some View { + GeometryReader { geometry in + let availableWidth = geometry.size.width + let textWidth = availableWidth - 52 - 12 + + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(timeString(from: cue.startTime)) + .font(.system(size: max(11, fontSize - 4), design: .monospaced)) + .foregroundStyle(isActive ? Color.primary : Color.secondary) + .frame(width: 52, alignment: .trailing) + + InteractiveAttributedTextView( + cueID: cue.id, + isScrolling: isScrolling, + words: words, + fontSize: fontSize, + defaultTextColor: isActive ? NSColor(Color.primary) : NSColor(Color.secondary), + selectedWordIndex: selectedWordIndex, + difficultyLevelProvider: { difficultyLevel(for: $0) }, + vocabularyVersion: vocabularyService.version, + onWordSelected: { index in + isWordInteracting = true + onWordSelected(selectedWordIndex == index ? nil : index) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 100_000_000) + isWordInteracting = false + } + }, + onDismiss: { + onWordSelected(nil) + }, + onForgot: { word in + vocabularyService.incrementForgotCount(for: word) + }, + onRemembered: { word in + vocabularyService.incrementRememberedCount(for: word) + }, + onRemove: { word in + vocabularyService.removeVocabulary(for: word) + }, + onWordRectChanged: { rect in + if popoverSourceRect != rect { + popoverSourceRect = rect + } + }, + onHeightChanged: { height in + if contentHeight != height { + contentHeight = height + } + }, + forgotCount: { forgotCount(for: $0) }, + rememberedCount: { rememberedCount(for: $0) }, + createdAt: { createdAt(for: $0) } + ) + .alignmentGuide(.firstTextBaseline) { context in + let font = NSFont.systemFont(ofSize: fontSize) + let lineHeight = font.ascender + font.leading + return lineHeight + } + .frame(width: textWidth, alignment: .leading) + .popover( + isPresented: Binding( + get: { popoverSourceRect != nil }, + set: { + if !$0 { + popoverSourceRect = nil + onWordSelected(nil) + } + } + ), + attachmentAnchor: .rect(.rect(popoverSourceRect ?? .zero)), + arrowEdge: .bottom + ) { + if let selectedIndex = selectedWordIndex, selectedIndex < words.count { + WordMenuView( + word: words[selectedIndex], + onDismiss: { onWordSelected(nil) }, + onForgot: { vocabularyService.incrementForgotCount(for: $0) }, + onRemembered: { vocabularyService.incrementRememberedCount(for: $0) }, + onRemove: { vocabularyService.removeVocabulary(for: $0) }, + forgotCount: forgotCount(for: words[selectedIndex]), + rememberedCount: rememberedCount(for: words[selectedIndex]), + createdAt: createdAt(for: words[selectedIndex]) + ) + } + } + .onDisappear { + onHidePopover() + } + } + } + .frame(height: max(contentHeight, 23), alignment: .center) + .padding(.vertical, 8) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(backgroundColor) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(isActive ? Color.accentColor.opacity(0.5) : Color.clear, lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { + guard !isWordInteracting else { return } + + if selectedWordIndex == nil { + onTap() + } else { + onWordSelected(selectedWordIndex) + } + } + .onHover { hovering in + guard !isScrolling else { + if isHovered { isHovered = false } + return + } + withAnimation(.easeInOut(duration: 0.15)) { + isHovered = hovering + } + } + .onChange(of: isScrolling) { _, isScrolling in + if isScrolling { + isHovered = false + } + } + .onChange(of: isActive) { _, newValue in + if !newValue { + onWordSelected(nil) + } + } + .animation(.easeInOut(duration: 0.2), value: isActive) + } + + private var backgroundColor: Color { + if isActive { + return Color.accentColor.opacity(0.12) + } else if isHovered && !isScrolling { + return Color.primary.opacity(0.04) + } else { + return Color.clear + } + } + + private func timeString(from value: Double) -> String { + guard value.isFinite, value >= 0 else { return "0:00" } + let totalSeconds = Int(value.rounded()) + return String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60) + } +} diff --git a/ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift b/ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift new file mode 100644 index 0000000..8b37cbb --- /dev/null +++ b/ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift @@ -0,0 +1,156 @@ +import Foundation +import SwiftUI + +@Observable +class SubtitleViewModel { + enum ScrollState: Equatable { + case autoScrolling + case userScrolling(countdown: Int) + + var isUserScrolling: Bool { + if case .userScrolling = self { return true } + return false + } + + var countdown: Int? { + if case .userScrolling(let value) = self { return value } + return nil + } + } + + enum WordSelectionState: Equatable { + case none + case selected(cueID: UUID, wordIndex: Int) + + var selectedWord: (cueID: UUID, wordIndex: Int)? { + if case .selected(let cueID, let wordIndex) = self { + return (cueID, wordIndex) + } + return nil + } + } + + private(set) var currentCueID: UUID? + private(set) var scrollState: ScrollState = .autoScrolling + private(set) var wordSelection: WordSelectionState = .none + private(set) var tappedCueID: UUID? + + private var wasPlayingBeforeSelection = false + private var scrollResumeTask: Task? + private static let pauseDuration = 3 + + @MainActor + func handleUserScroll() { + scrollResumeTask?.cancel() + scrollState = .userScrolling(countdown: Self.pauseDuration) + + scrollResumeTask = Task { @MainActor in + for remaining in (0.. 0 ? .userScrolling(countdown: remaining) : .autoScrolling + } + scrollState = .autoScrolling + } + } + + func cancelScrollResume() { + scrollResumeTask?.cancel() + scrollResumeTask = nil + scrollState = .autoScrolling + } + + func handleWordSelection(wordIndex: Int?, cueID: UUID, isPlaying: Bool, onPause: () -> Void) { + if let wordIndex { + if wordSelection == .none { + wasPlayingBeforeSelection = isPlaying + if isPlaying { + onPause() + } + } + wordSelection = .selected(cueID: cueID, wordIndex: wordIndex) + } else { + dismissWord(onPlay: onPause) + } + } + + func hidePopover() { + wordSelection = .none + } + + func dismissWord(onPlay: () -> Void) { + guard wordSelection != .none else { return } + wordSelection = .none + if wasPlayingBeforeSelection { + onPlay() + wasPlayingBeforeSelection = false + } + } + + func handleCueTap(cueID: UUID, onSeek: (Double) -> Void, cueStartTime: Double) { + tappedCueID = cueID + onSeek(cueStartTime) + cancelScrollResume() + } + + func updateCurrentCue(time: Double, cues: [SubtitleCue]) { + guard !scrollState.isUserScrolling else { return } + let activeCue = findActiveCue(at: time, in: cues) + if activeCue?.id != currentCueID { + currentCueID = activeCue?.id + } + } + + func reset() { + scrollResumeTask?.cancel() + scrollResumeTask = nil + scrollState = .autoScrolling + currentCueID = nil + wordSelection = .none + } + + @MainActor + func trackPlayback(timeProvider: @escaping @MainActor () -> Double, cues: [SubtitleCue]) async { + let epsilon: Double = 0.001 + + while !Task.isCancelled { + if !scrollState.isUserScrolling { + let currentTime = timeProvider() + let activeCue = findActiveCue(at: currentTime, in: cues, epsilon: epsilon) + + if activeCue?.id != currentCueID { + currentCueID = activeCue?.id + } + } + + try? await Task.sleep(for: .milliseconds(100)) + } + } + + private func findActiveCue(at time: Double, in cues: [SubtitleCue], epsilon: Double = 0.001) -> SubtitleCue? { + guard !cues.isEmpty else { return nil } + + var low = 0 + var high = cues.count - 1 + var result: Int? = nil + + while low <= high { + let mid = (low + high) / 2 + if cues[mid].startTime <= time + epsilon { + result = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + + if let index = result { + let cue = cues[index] + if time >= cue.startTime - epsilon && time < cue.endTime { + return cue + } + } + + return nil + } +} diff --git a/ABPlayer/Sources/Views/SubtitleView.swift b/ABPlayer/Sources/Views/SubtitleView.swift index 2994f7e..2944b69 100644 --- a/ABPlayer/Sources/Views/SubtitleView.swift +++ b/ABPlayer/Sources/Views/SubtitleView.swift @@ -2,7 +2,6 @@ import OSLog import SwiftData import SwiftUI -/// Displays synchronized subtitles with current playback position highlighted struct SubtitleView: View { @Environment(AudioPlayerManager.self) private var playerManager @Environment(VocabularyService.self) private var vocabularyService @@ -11,14 +10,7 @@ struct SubtitleView: View { @Binding var countdownSeconds: Int? let fontSize: Double - private static let pauseDuration = 3 - - @State private var currentCueID: UUID? - @State private var isUserScrolling = false - @State private var scrollResumeTask: Task? - @State private var selectedWord: (cueID: UUID, wordIndex: Int)? - @State private var wasPlayingBeforeWordInteraction = false - @State private var tappedCueID: UUID? + @State private var viewModel = SubtitleViewModel() var body: some View { ZStack(alignment: .topTrailing) { @@ -28,20 +20,29 @@ struct SubtitleView: View { ForEach(cues) { cue in SubtitleCueRow( cue: cue, - isActive: cue.id == currentCueID, - isScrolling: isUserScrolling, + isActive: cue.id == viewModel.currentCueID, + isScrolling: viewModel.scrollState.isUserScrolling, fontSize: fontSize, - selectedWordIndex: selectedWord?.cueID == cue.id ? selectedWord?.wordIndex : nil, + selectedWordIndex: viewModel.wordSelection.selectedWord?.cueID == cue.id + ? viewModel.wordSelection.selectedWord?.wordIndex + : nil, onWordSelected: { wordIndex in - handleWordSelection(wordIndex: wordIndex, cueID: cue.id) + viewModel.handleWordSelection( + wordIndex: wordIndex, + cueID: cue.id, + isPlaying: playerManager.isPlaying, + onPause: { playerManager.pause() } + ) }, onHidePopover: { - hidePopover() + viewModel.hidePopover() }, onTap: { - tappedCueID = cue.id - playerManager.seek(to: cue.startTime) - cancelScrollResume() + viewModel.handleCueTap( + cueID: cue.id, + onSeek: { playerManager.seek(to: $0) }, + cueStartTime: cue.startTime + ) } ) .id(cue.id) @@ -54,455 +55,50 @@ struct SubtitleView: View { .onScrollPhaseChange { _, newPhase in handleScrollPhaseChange(newPhase) } - .onChange(of: currentCueID) { _, newValue in - guard !isUserScrolling, let id = newValue else { return } + .onChange(of: viewModel.currentCueID) { _, newValue in + guard !viewModel.scrollState.isUserScrolling, let id = newValue else { return } withAnimation(.easeInOut(duration: 0.3)) { proxy.scrollTo(id, anchor: .center) } } - .onChange(of: tappedCueID) { _, newValue in + .onChange(of: viewModel.tappedCueID) { _, newValue in guard let id = newValue else { return } withAnimation(.easeInOut(duration: 0.3)) { proxy.scrollTo(id, anchor: .center) } } .onChange(of: cues) { _, _ in - scrollResumeTask?.cancel() - scrollResumeTask = nil - isUserScrolling = false - currentCueID = nil + viewModel.reset() countdownSeconds = nil - selectedWord = nil } } VStack(alignment: .trailing, spacing: 8) { - if let countdown = countdownSeconds { - CountdownRingView(countdown: countdown, total: Self.pauseDuration) + if let countdown = viewModel.scrollState.countdown { + CountdownRingView(countdown: countdown, total: 3) .transition(.scale.combined(with: .opacity)) } } .padding(12) } - .animation(.easeInOut(duration: 0.2), value: countdownSeconds != nil) + .animation(.easeInOut(duration: 0.2), value: viewModel.scrollState.countdown != nil) .task { - await trackCurrentCue() + await viewModel.trackPlayback( + timeProvider: { @MainActor in playerManager.currentTime }, + cues: cues + ) } - } - - private func handleWordSelection(wordIndex: Int?, cueID: UUID) { - if let wordIndex { - if selectedWord == nil { - wasPlayingBeforeWordInteraction = playerManager.isPlaying - if playerManager.isPlaying { - playerManager.pause() - } - } - selectedWord = (cueID, wordIndex) - } else { - dismissWord() - } - } - - private func hidePopover() { - selectedWord = nil - } - - private func dismissWord() { - guard selectedWord != nil else { return } - selectedWord = nil - if wasPlayingBeforeWordInteraction { - playerManager.play() - wasPlayingBeforeWordInteraction = false + .onChange(of: viewModel.scrollState.countdown) { _, newValue in + countdownSeconds = newValue } } private func handleScrollPhaseChange(_ phase: ScrollPhase) { guard case .interacting = phase else { return } - - scrollResumeTask?.cancel() - isUserScrolling = true - countdownSeconds = Self.pauseDuration - - scrollResumeTask = Task { - for remaining in (0.. 0 ? remaining : nil - } - isUserScrolling = false - } - } - - private func cancelScrollResume() { - scrollResumeTask?.cancel() - scrollResumeTask = nil - isUserScrolling = false - countdownSeconds = nil - } - - private func trackCurrentCue() async { - // Small epsilon for floating-point comparison to avoid precision issues at boundaries - let epsilon: Double = 0.001 - - while !Task.isCancelled { - if !isUserScrolling { - let currentTime = playerManager.currentTime - let activeCue = findActiveCue(at: currentTime, epsilon: epsilon) - - if activeCue?.id != currentCueID { - await MainActor.run { - currentCueID = activeCue?.id - } - } - } - - try? await Task.sleep(for: .milliseconds(100)) - } - } - - /// Uses binary search to find the cue containing the given time - /// - Parameters: - /// - time: The current playback time - /// - epsilon: Small tolerance for floating-point comparison - /// - Returns: The active cue, or nil if none contains the time - private func findActiveCue(at time: Double, epsilon: Double) -> SubtitleCue? { - guard !cues.isEmpty else { return nil } - - // Binary search to find the cue whose startTime is <= time - var low = 0 - var high = cues.count - 1 - var result: Int? = nil - - while low <= high { - let mid = (low + high) / 2 - if cues[mid].startTime <= time + epsilon { - result = mid - low = mid + 1 - } else { - high = mid - 1 - } - } - - // Verify the found cue actually contains the current time - if let index = result { - let cue = cues[index] - // Use epsilon to handle boundary precision: time should be >= startTime - epsilon and < endTime + epsilon - // But prefer strict < endTime to avoid overlapping with next cue - if time >= cue.startTime - epsilon && time < cue.endTime { - return cue - } - } - - return nil - } -} - -// MARK: - Cue Row - -private struct SubtitleCueRow: View { - @Environment(VocabularyService.self) private var vocabularyService - - let cue: SubtitleCue - let isActive: Bool - let isScrolling: Bool - let fontSize: Double - let selectedWordIndex: Int? - let onWordSelected: (Int?) -> Void - let onHidePopover: () -> Void - let onTap: () -> Void - - @State private var isHovered = false - @State private var popoverSourceRect: CGRect? - @State private var isWordInteracting = false - @State private var contentHeight: CGFloat = 0 - - private let words: [String] - - init( - cue: SubtitleCue, - isActive: Bool, - isScrolling: Bool, - fontSize: Double, - selectedWordIndex: Int?, - onWordSelected: @escaping (Int?) -> Void, - onHidePopover: @escaping () -> Void, - onTap: @escaping () -> Void - ) { - self.cue = cue - self.isActive = isActive - self.isScrolling = isScrolling - self.fontSize = fontSize - self.selectedWordIndex = selectedWordIndex - self.onWordSelected = onWordSelected - self.onHidePopover = onHidePopover - self.onTap = onTap - self.words = cue.text.split(separator: " ", omittingEmptySubsequences: true).map(String.init) - } - - - /// Get difficulty level for a word (nil if not in vocabulary or level is 0) - private func difficultyLevel(for word: String) -> Int? { - vocabularyService.difficultyLevel(for: word) - } - - /// Get forgot count for a word (0 if not in vocabulary) - private func forgotCount(for word: String) -> Int { - vocabularyService.forgotCount(for: word) - } - - /// Get remembered count for a word (0 if not in vocabulary) - private func rememberedCount(for word: String) -> Int { - vocabularyService.rememberedCount(for: word) - } - - /// Get creation date for a word (nil if not in vocabulary) - private func createdAt(for word: String) -> Date? { - vocabularyService.createdAt(for: word) - } - - - var body: some View { - GeometryReader { geometry in - let availableWidth = geometry.size.width - let textWidth = availableWidth - 52 - 12 - - HStack(alignment: .firstTextBaseline, spacing: 12) { - Text(timeString(from: cue.startTime)) - .font(.system(size: max(11, fontSize - 4), design: .monospaced)) - .foregroundStyle(isActive ? Color.primary : Color.secondary) - .frame(width: 52, alignment: .trailing) - - InteractiveAttributedTextView( - cueID: cue.id, - isScrolling: isScrolling, - words: words, - fontSize: fontSize, - defaultTextColor: isActive ? NSColor(Color.primary) : NSColor(Color.secondary), - selectedWordIndex: selectedWordIndex, - difficultyLevelProvider: { difficultyLevel(for: $0) }, - vocabularyVersion: vocabularyService.version, - onWordSelected: { index in - isWordInteracting = true - onWordSelected(selectedWordIndex == index ? nil : index) - Task { @MainActor in - try? await Task.sleep(nanoseconds: 100_000_000) - isWordInteracting = false - } - }, - onDismiss: { - onWordSelected(nil) - }, - onForgot: { word in - vocabularyService.incrementForgotCount(for: word) - }, - onRemembered: { word in - vocabularyService.incrementRememberedCount(for: word) - }, - onRemove: { word in - vocabularyService.removeVocabulary(for: word) - }, - onWordRectChanged: { rect in - if popoverSourceRect != rect { - popoverSourceRect = rect - } - }, - onHeightChanged: { height in - if contentHeight != height { - contentHeight = height - } - }, - forgotCount: { forgotCount(for: $0) }, - rememberedCount: { rememberedCount(for: $0) }, - createdAt: { createdAt(for: $0) } - ) - .alignmentGuide(.firstTextBaseline) { context in - let font = NSFont.systemFont(ofSize: fontSize) - let lineHeight = font.ascender + font.leading - return lineHeight - } - .frame(width: textWidth, alignment: .leading) - .popover( - isPresented: Binding( - get: { popoverSourceRect != nil }, - set: { - if !$0 { - popoverSourceRect = nil - onWordSelected(nil) - } - } - ), - attachmentAnchor: .rect(.rect(popoverSourceRect ?? .zero)), - arrowEdge: .bottom - ) { - if let selectedIndex = selectedWordIndex, selectedIndex < words.count { - WordMenuView( - word: words[selectedIndex], - onDismiss: { onWordSelected(nil) }, - onForgot: { vocabularyService.incrementForgotCount(for: $0) }, - onRemembered: { vocabularyService.incrementRememberedCount(for: $0) }, - onRemove: { vocabularyService.removeVocabulary(for: $0) }, - forgotCount: forgotCount(for: words[selectedIndex]), - rememberedCount: rememberedCount(for: words[selectedIndex]), - createdAt: createdAt(for: words[selectedIndex]) - ) - } - } - .onDisappear { - onHidePopover() - } - } - } - .frame(height: max(contentHeight, 23), alignment: .center) - .padding(.vertical, 8) - .padding(.horizontal, 8) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(backgroundColor) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(isActive ? Color.accentColor.opacity(0.5) : Color.clear, lineWidth: 1) - ) - .contentShape(Rectangle()) - .onTapGesture { - guard !isWordInteracting else { return } - - if selectedWordIndex == nil { - onTap() - } else { - onWordSelected(selectedWordIndex) - } - } - .onHover { hovering in - guard !isScrolling else { - if isHovered { isHovered = false } - return - } - withAnimation(.easeInOut(duration: 0.15)) { - isHovered = hovering - } - } - .onChange(of: isScrolling) { _, isScrolling in - if isScrolling { - isHovered = false - } - } - .onChange(of: isActive) { _, newValue in - if !newValue { - onWordSelected(nil) - } - } - .animation(.easeInOut(duration: 0.2), value: isActive) - } - - private var backgroundColor: Color { - if isActive { - return Color.accentColor.opacity(0.12) - } else if isHovered && !isScrolling { - return Color.primary.opacity(0.04) - } else { - return Color.clear - } - } - - private func timeString(from value: Double) -> String { - guard value.isFinite, value >= 0 else { return "0:00" } - let totalSeconds = Int(value.rounded()) - return String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60) - } -} - -// MARK: - Word Menu - -private struct WordMenuView: View { - let word: String - let onDismiss: () -> Void - let onForgot: (String) -> Void - let onRemembered: (String) -> Void - let onRemove: (String) -> Void - let forgotCount: Int - let rememberedCount: Int - let createdAt: Date? - - private var canRemember: Bool { - guard forgotCount > 0, let createdAt = createdAt else { return false } - // Must be at least 12 hours since creation - return Date().timeIntervalSince(createdAt) >= 12 * 3600 - } - - private var cleanedWord: String { - word.lowercased().trimmingCharacters(in: .punctuationCharacters) - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - Group { - MenuButton(label: "Copy", systemImage: "doc.on.doc") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(cleanedWord, forType: .string) - onDismiss() - } - - MenuButton( - label: "Forgot" + (forgotCount > 0 ? " (\(forgotCount))" : ""), - systemImage: "xmark.circle" - ) { - onForgot(cleanedWord) - onDismiss() - } - - if canRemember { - MenuButton( - label: "Remember" + (rememberedCount > 0 ? " (\(rememberedCount))" : ""), - systemImage: "checkmark.circle" - ) { - onRemembered(cleanedWord) - onDismiss() - } - } - - if forgotCount > 0 || rememberedCount > 0 { - // add a menu item to remove the word from the vocabulary - MenuButton(label: "Remove", systemImage: "trash") { - onRemove(cleanedWord) - onDismiss() - } - } - } - .padding(4) - } - .frame(minWidth: 160) - } -} - -private struct MenuButton: View { - let label: String - let systemImage: String - let action: () -> Void - - @State private var isHovered = false - - var body: some View { - Button(action: action) { - Label(label, systemImage: systemImage) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(isHovered ? Color.accentColor : Color.clear) - ) - .foregroundStyle(isHovered ? .white : .primary) - .onHover { isHovered = $0 } + viewModel.handleUserScroll() } } -// MARK: - Empty State - struct SubtitleEmptyView: View { var body: some View { ContentUnavailableView( @@ -512,556 +108,3 @@ struct SubtitleEmptyView: View { ) } } - -// MARK: - Interactive Attributed Text View - -private struct InteractiveAttributedTextView: NSViewRepresentable { - let cueID: UUID - let isScrolling: Bool - let words: [String] - let fontSize: Double - var defaultTextColor: NSColor = .labelColor - let selectedWordIndex: Int? - let difficultyLevelProvider: (String) -> Int? - let vocabularyVersion: Int - let onWordSelected: (Int) -> Void - let onDismiss: () -> Void - let onForgot: (String) -> Void - let onRemembered: (String) -> Void - let onRemove: (String) -> Void - let onWordRectChanged: (CGRect?) -> Void - let onHeightChanged: (CGFloat) -> Void - let forgotCount: (String) -> Int - let rememberedCount: (String) -> Int - let createdAt: (String) -> Date? - - func makeNSView(context: Context) -> InteractiveNSTextView { - let textView = InteractiveNSTextView() - textView.isEditable = false - textView.isSelectable = false - textView.backgroundColor = .clear - textView.textContainerInset = NSSize(width: 2, height: 1) - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.heightTracksTextView = false - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = false - textView.autoresizingMask = [.width] - textView.coordinator = context.coordinator - - textView.setContentHuggingPriority(.defaultHigh, for: .vertical) - textView.setContentCompressionResistancePriority(.required, for: .vertical) - - return textView - } - - func updateNSView(_ textView: InteractiveNSTextView, context: Context) { - let isFirstRender = context.coordinator.cachedAttributedString == nil - let needsContentUpdate = isFirstRender || - context.coordinator.cueID != cueID || - context.coordinator.fontSize != fontSize || - context.coordinator.defaultTextColor != defaultTextColor || - context.coordinator.words != words || - context.coordinator.vocabularyVersion != vocabularyVersion - - let needsSelectionUpdate = context.coordinator.selectedWordIndex != selectedWordIndex - - context.coordinator.difficultyLevelProvider = difficultyLevelProvider - context.coordinator.vocabularyVersion = vocabularyVersion - context.coordinator.onWordSelected = onWordSelected - context.coordinator.onDismiss = onDismiss - context.coordinator.onForgot = onForgot - context.coordinator.onRemembered = onRemembered - context.coordinator.onRemove = onRemove - context.coordinator.onWordRectChanged = onWordRectChanged - context.coordinator.forgotCount = forgotCount - context.coordinator.rememberedCount = rememberedCount - context.coordinator.createdAt = createdAt - context.coordinator.isScrolling = isScrolling - - if needsContentUpdate { - if context.coordinator.vocabularyVersion != vocabularyVersion { - context.coordinator.cachedAttributedString = nil - } - context.coordinator.cachedSize = nil - context.coordinator.updateState( - cueID: cueID, - words: words, - selectedWordIndex: selectedWordIndex, - fontSize: fontSize, - defaultTextColor: defaultTextColor - ) - textView.textStorage?.setAttributedString(context.coordinator.buildAttributedString()) - textView.invalidateIntrinsicContentSize() - } - - if needsSelectionUpdate { - textView.updateHoverState( - oldHoveredIndex: context.coordinator.lastHoveredIndex, - newHoveredIndex: textView.hoveredWordIndex, - oldSelectedIndex: context.coordinator.lastSelectedIndex, - newSelectedIndex: selectedWordIndex - ) - context.coordinator.selectedWordIndex = selectedWordIndex - context.coordinator.lastSelectedIndex = selectedWordIndex - context.coordinator.updateSelectedRect(in: textView) - } else if !isScrolling { - if textView.hoveredWordIndex != context.coordinator.lastHoveredIndex { - textView.updateHoverState( - oldHoveredIndex: context.coordinator.lastHoveredIndex, - newHoveredIndex: textView.hoveredWordIndex, - oldSelectedIndex: nil, - newSelectedIndex: nil - ) - context.coordinator.lastHoveredIndex = textView.hoveredWordIndex - } - } else if isScrolling { - if context.coordinator.lastHoveredIndex != nil { - textView.updateHoverState( - oldHoveredIndex: context.coordinator.lastHoveredIndex, - newHoveredIndex: nil, - oldSelectedIndex: nil, - newSelectedIndex: nil - ) - context.coordinator.lastHoveredIndex = nil - textView.hoveredWordIndex = nil - } - } - } - - func sizeThatFits(_ proposal: ProposedViewSize, nsView: InteractiveNSTextView, context: Context) -> CGSize? { - guard let layoutManager = nsView.layoutManager, - let textContainer = nsView.textContainer else { - return nil - } - - let width = proposal.width ?? 400 - - if let cachedWidth = context.coordinator.cachedWidth, - let cachedSize = context.coordinator.cachedSize, - abs(cachedWidth - width) < 1.0 { - Logger.ui.debug("hit cache \(words.first ?? "nil") w:\(cachedSize.width), h:\(cachedSize.height)") - return cachedSize - } - - textContainer.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) - - layoutManager.ensureLayout(for: textContainer) - let usedRect = layoutManager.usedRect(for: textContainer) - if usedRect.isEmpty { - return nil - } - - let inset = nsView.textContainerInset - let height = usedRect.height + inset.height * 2 - - let size = CGSize(width: width, height: height) - if width.isNormal && height.isNormal { - context.coordinator.cachedWidth = width - context.coordinator.cachedSize = size - } - - DispatchQueue.main.async { - onHeightChanged(height) - } - - Logger.ui.debug("\(words.first ?? "nil") w:\(width), h:\(height)") - - return size - } - - func makeCoordinator() -> Coordinator { - Coordinator( - cueID: cueID, - words: words, - selectedWordIndex: selectedWordIndex, - fontSize: fontSize, - defaultTextColor: defaultTextColor, - difficultyLevelProvider: difficultyLevelProvider, - vocabularyVersion: vocabularyVersion, - onWordSelected: onWordSelected, - onDismiss: onDismiss, - onForgot: onForgot, - onRemembered: onRemembered, - onRemove: onRemove, - onWordRectChanged: onWordRectChanged, - forgotCount: forgotCount, - rememberedCount: rememberedCount, - createdAt: createdAt - ) - } - - class Coordinator: NSObject { - var cueID: UUID - var words: [String] - var selectedWordIndex: Int? - var fontSize: Double - var defaultTextColor: NSColor - var difficultyLevelProvider: (String) -> Int? - var vocabularyVersion: Int - var onWordSelected: (Int) -> Void - var onDismiss: () -> Void - var onForgot: (String) -> Void - var onRemembered: (String) -> Void - var onRemove: (String) -> Void - var onWordRectChanged: (CGRect?) -> Void - var forgotCount: (String) -> Int - var rememberedCount: (String) -> Int - var createdAt: (String) -> Date? - var isScrolling = false - - var cachedAttributedString: NSAttributedString? - var cachedVocabularyVersion: Int = 0 - var cachedDefaultTextColor: NSColor? - var lastSelectedIndex: Int? - var lastHoveredIndex: Int? - var wordRanges: [NSRange] = [] - var wordFrames: [CGRect] = [] - var cachedWidth: CGFloat? - var cachedSize: CGSize? - - init( - cueID: UUID, - words: [String], - selectedWordIndex: Int?, - fontSize: Double, - defaultTextColor: NSColor, - difficultyLevelProvider: @escaping (String) -> Int?, - vocabularyVersion: Int, - onWordSelected: @escaping (Int) -> Void, - onDismiss: @escaping () -> Void, - onForgot: @escaping (String) -> Void, - onRemembered: @escaping (String) -> Void, - onRemove: @escaping (String) -> Void, - onWordRectChanged: @escaping (CGRect?) -> Void, - forgotCount: @escaping (String) -> Int, - rememberedCount: @escaping (String) -> Int, - createdAt: @escaping (String) -> Date? - ) { - self.cueID = cueID - self.words = words - self.selectedWordIndex = selectedWordIndex - self.fontSize = fontSize - self.defaultTextColor = defaultTextColor - self.difficultyLevelProvider = difficultyLevelProvider - self.vocabularyVersion = vocabularyVersion - self.onWordSelected = onWordSelected - self.onDismiss = onDismiss - self.onForgot = onForgot - self.onRemembered = onRemembered - self.onRemove = onRemove - self.onWordRectChanged = onWordRectChanged - self.forgotCount = forgotCount - self.rememberedCount = rememberedCount - self.createdAt = createdAt - } - - func updateState( - cueID: UUID, - words: [String], - selectedWordIndex: Int?, - fontSize: Double, - defaultTextColor: NSColor - ) { - self.cueID = cueID - self.words = words - self.selectedWordIndex = selectedWordIndex - self.fontSize = fontSize - self.defaultTextColor = defaultTextColor - } - - func buildAttributedString() -> NSAttributedString { - if let cached = cachedAttributedString, - !wordRanges.isEmpty, - cached.string.split(separator: " ").count == words.count, - cachedVocabularyVersion == vocabularyVersion, - cachedDefaultTextColor == defaultTextColor { - return cached - } - - cachedDefaultTextColor = defaultTextColor - cachedVocabularyVersion = vocabularyVersion - wordRanges.removeAll(keepingCapacity: true) - wordFrames.removeAll(keepingCapacity: true) - let result = NSMutableAttributedString() - let font = NSFont.systemFont(ofSize: fontSize) - - for (index, word) in words.enumerated() { - let attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: baseColorForWord(word), - NSAttributedString.Key("wordIndex"): index - ] - - let startLocation = result.length - let wordString = NSAttributedString(string: word, attributes: attributes) - result.append(wordString) - let endLocation = result.length - - wordRanges.append(NSRange(location: startLocation, length: endLocation - startLocation)) - - if index < words.count - 1 { - result.append(NSAttributedString(string: " ", attributes: [.font: font])) - } - } - - cachedAttributedString = result - return result - } - - func cacheWordFrames(in textView: NSTextView) { - guard let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer else { return } - - wordFrames.removeAll(keepingCapacity: true) - - for range in wordRanges { - let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) - var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) - rect.origin.x += textView.textContainerInset.width - rect.origin.y += textView.textContainerInset.height - wordFrames.append(rect) - } - } - - func baseColorForWord(_ word: String) -> NSColor { - guard let level = difficultyLevelProvider(word), level > 0 else { - return defaultTextColor - } - switch level { - case 1: return .systemGreen - case 2: return .systemYellow - default: return .systemRed - } - } - - @MainActor - func updateSelectedRect(in textView: NSTextView) { - Task { @MainActor in - guard let index = selectedWordIndex, - index < wordRanges.count, - let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer else { - onWordRectChanged(nil) - return - } - - let range = wordRanges[index] - let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) - var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) - rect.origin.x += textView.textContainerInset.width - rect.origin.y += textView.textContainerInset.height - onWordRectChanged(rect) - } - } - - @MainActor - func handleClick(at point: NSPoint, in textView: NSTextView) { - guard let wordIndex = findWordIndex(at: point, in: textView) else { - onDismiss() - return - } - onWordSelected(wordIndex) - } - - @MainActor - func handleMouseMoved(at point: NSPoint, in textView: NSTextView) -> Int? { - if isScrolling { return nil } - return findWordIndex(at: point, in: textView) - } - - @MainActor - private func findWordIndex(at point: NSPoint, in textView: NSTextView) -> Int? { - let containerInset = textView.textContainerInset - - let hoverAreaFrame = textView.bounds.insetBy( - dx: containerInset.width * 2, - dy: containerInset.height * 2 - ) - - guard hoverAreaFrame.contains(point) else { - return nil - } - - if !wordFrames.isEmpty && wordFrames.count == wordRanges.count { - for (index, frame) in wordFrames.enumerated() { - if frame.contains(point) { - return index - } - } - return nil - } - - guard let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer, - let textStorage = textView.textStorage else { return nil } - - let characterIndex = layoutManager.characterIndex( - for: point, - in: textContainer, - fractionOfDistanceBetweenInsertionPoints: nil - ) - - guard characterIndex < textStorage.length else { return nil } - - return textStorage.attribute(NSAttributedString.Key("wordIndex"), at: characterIndex, effectiveRange: nil) as? Int - } - } -} - -private class InteractiveNSTextView: NSTextView { - weak var coordinator: InteractiveAttributedTextView.Coordinator? - private var trackingArea: NSTrackingArea? - var hoveredWordIndex: Int? - weak var popoverViewController: NSViewController? - - override var firstBaselineOffsetFromTop: CGFloat { - guard let layoutManager = layoutManager, - let textContainer = textContainer, - let textStorage = textStorage, - textStorage.length > 0 else { - return textContainerInset.height - } - - let glyphRange = layoutManager.glyphRange(for: textContainer) - guard glyphRange.length > 0 else { - return textContainerInset.height - } - - let firstLineFragmentRect = layoutManager.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil) - Logger.ui.debug("firstLineFragmentRect: \(String(describing: firstLineFragmentRect))") - let firstLineBaselineOffset = layoutManager.typesetter.baselineOffset( - in: layoutManager, - glyphIndex: 0 - ) - - return textContainerInset.height + firstLineFragmentRect.origin.y + firstLineBaselineOffset - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - - coordinator?.cacheWordFrames(in: self) - coordinator?.updateSelectedRect(in: self) - - if let trackingArea = trackingArea { - removeTrackingArea(trackingArea) - } - - let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow] - trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) - addTrackingArea(trackingArea!) - } - - override func mouseDown(with event: NSEvent) { - let point = convert(event.locationInWindow, from: nil) - coordinator?.handleClick(at: point, in: self) - } - - override func mouseMoved(with event: NSEvent) { - let point = convert(event.locationInWindow, from: nil) - let newHoveredWordIndex = coordinator?.handleMouseMoved(at: point, in: self) - - if newHoveredWordIndex != hoveredWordIndex { - let oldIndex = hoveredWordIndex - hoveredWordIndex = newHoveredWordIndex - updateHoverState( - oldHoveredIndex: oldIndex, - newHoveredIndex: newHoveredWordIndex, - oldSelectedIndex: coordinator?.lastSelectedIndex, - newSelectedIndex: coordinator?.lastSelectedIndex - ) - } - } - - override func mouseExited(with event: NSEvent) { - if hoveredWordIndex != nil { - let oldIndex = hoveredWordIndex - hoveredWordIndex = nil - updateHoverState( - oldHoveredIndex: oldIndex, - newHoveredIndex: nil, - oldSelectedIndex: coordinator?.lastSelectedIndex, - newSelectedIndex: coordinator?.lastSelectedIndex - ) - } - } - - func updateHoverState(oldHoveredIndex: Int?, newHoveredIndex: Int?, oldSelectedIndex: Int?, newSelectedIndex: Int?) { - guard let textStorage = textStorage, let coordinator = coordinator else { return } - - var indicesToUpdate = [oldHoveredIndex, newHoveredIndex].compactMap { $0 } - - if let oldIndex = oldSelectedIndex, oldSelectedIndex != newSelectedIndex { - indicesToUpdate.append(oldIndex) - } - if let newIndex = newSelectedIndex, oldSelectedIndex != newSelectedIndex { - indicesToUpdate.append(newIndex) - } - - let uniqueIndices = Array(Set(indicesToUpdate)) - if uniqueIndices.isEmpty { return } - - textStorage.beginEditing() - - for wordIndex in uniqueIndices { - guard wordIndex < coordinator.wordRanges.count else { continue } - let range = coordinator.wordRanges[wordIndex] - - let isHovered = wordIndex == newHoveredIndex - let isSelected = wordIndex == newSelectedIndex - let isHighlighted = isHovered || isSelected - - let word = coordinator.words[wordIndex] - let baseColor = coordinator.baseColorForWord(word) - let foregroundColor = isHighlighted ? NSColor.controlAccentColor : baseColor - let backgroundColor = isHighlighted ? NSColor.controlAccentColor.withAlphaComponent(0.15) : .clear - - textStorage.addAttribute(.foregroundColor, value: foregroundColor, range: range) - textStorage.addAttribute(.backgroundColor, value: backgroundColor, range: range) - } - - textStorage.endEditing() - } -} - -// MARK: - Countdown Ring View - -/// Circular countdown indicator with progress ring -private struct CountdownRingView: View { - let countdown: Int - let total: Int - - private var progress: Double { - guard total > 0 else { return 0 } - return Double(countdown) / Double(total) - } - - var body: some View { - ZStack { - Circle() - .stroke(Color.secondary.opacity(0.2), lineWidth: 3) - - Circle() - .trim(from: 0, to: progress) - .stroke( - Color.accentColor, - style: StrokeStyle(lineWidth: 3, lineCap: .round) - ) - .rotationEffect(.degrees(-90)) - .animation(.linear(duration: 1), value: progress) - - Text("\(countdown)") - .font(.system(.caption, design: .rounded, weight: .semibold)) - .monospacedDigit() - .foregroundStyle(.primary) - } - .frame(width: 32, height: 32) - .padding(6) - .background { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(.ultraThinMaterial) - .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) - } - } -} From 55852e15d0c340dd8c2d32c18159918cd15c5026 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Jan 2026 01:12:00 -0700 Subject: [PATCH 5/9] refactor(subtitle): extract layout and string building logic into dedicated utilities and add tests --- .../InteractiveAttributedTextView.swift | 121 ++----- .../Utilities/AttributedStringBuilder.swift | 56 ++++ .../Utilities/WordLayoutManager.swift | 92 ++++++ ABPlayer/Tests/SubtitleViewModelTests.swift | 312 ++++++++++++++++++ 4 files changed, 485 insertions(+), 96 deletions(-) create mode 100644 ABPlayer/Sources/Views/Subtitle/Utilities/AttributedStringBuilder.swift create mode 100644 ABPlayer/Sources/Views/Subtitle/Utilities/WordLayoutManager.swift create mode 100644 ABPlayer/Tests/SubtitleViewModelTests.swift diff --git a/ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift b/ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift index 41383bf..2991809 100644 --- a/ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift +++ b/ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift @@ -203,9 +203,17 @@ struct InteractiveAttributedTextView: NSViewRepresentable { var lastSelectedIndex: Int? var lastHoveredIndex: Int? var wordRanges: [NSRange] = [] - var wordFrames: [CGRect] = [] var cachedWidth: CGFloat? var cachedSize: CGSize? + + private var layoutManager = WordLayoutManager() + private var stringBuilder: AttributedStringBuilder { + AttributedStringBuilder( + fontSize: fontSize, + defaultTextColor: defaultTextColor, + difficultyLevelProvider: difficultyLevelProvider + ) + } init( cueID: UUID, @@ -268,83 +276,41 @@ struct InteractiveAttributedTextView: NSViewRepresentable { cachedDefaultTextColor = defaultTextColor cachedVocabularyVersion = vocabularyVersion - wordRanges.removeAll(keepingCapacity: true) - wordFrames.removeAll(keepingCapacity: true) - let result = NSMutableAttributedString() - let font = NSFont.systemFont(ofSize: fontSize) - - for (index, word) in words.enumerated() { - let attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: baseColorForWord(word), - NSAttributedString.Key("wordIndex"): index - ] - - let startLocation = result.length - let wordString = NSAttributedString(string: word, attributes: attributes) - result.append(wordString) - let endLocation = result.length - - wordRanges.append(NSRange(location: startLocation, length: endLocation - startLocation)) - - if index < words.count - 1 { - result.append(NSAttributedString(string: " ", attributes: [.font: font])) - } - } - cachedAttributedString = result - return result + let result = stringBuilder.build(words: words) + wordRanges = result.wordRanges + cachedAttributedString = result.attributedString + + return result.attributedString } func cacheWordFrames(in textView: NSTextView) { - guard let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer else { return } - - wordFrames.removeAll(keepingCapacity: true) - - for range in wordRanges { - let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) - var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) - rect.origin.x += textView.textContainerInset.width - rect.origin.y += textView.textContainerInset.height - wordFrames.append(rect) - } + layoutManager.cacheWordFrames(wordRanges: wordRanges, in: textView) } func baseColorForWord(_ word: String) -> NSColor { - guard let level = difficultyLevelProvider(word), level > 0 else { - return defaultTextColor - } - switch level { - case 1: return .systemGreen - case 2: return .systemYellow - default: return .systemRed - } + stringBuilder.colorForWord(word) } @MainActor func updateSelectedRect(in textView: NSTextView) { Task { @MainActor in - guard let index = selectedWordIndex, - index < wordRanges.count, - let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer else { + guard let index = selectedWordIndex else { onWordRectChanged(nil) return } - - let range = wordRanges[index] - let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) - var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) - rect.origin.x += textView.textContainerInset.width - rect.origin.y += textView.textContainerInset.height - onWordRectChanged(rect) + + if let rect = layoutManager.boundingRect(forWordAt: index, wordRanges: wordRanges, in: textView) { + onWordRectChanged(rect) + } else { + onWordRectChanged(nil) + } } } @MainActor func handleClick(at point: NSPoint, in textView: NSTextView) { - guard let wordIndex = findWordIndex(at: point, in: textView) else { + guard let wordIndex = layoutManager.findWordIndex(at: point, in: textView, wordRanges: wordRanges) else { onDismiss() return } @@ -354,44 +320,7 @@ struct InteractiveAttributedTextView: NSViewRepresentable { @MainActor func handleMouseMoved(at point: NSPoint, in textView: NSTextView) -> Int? { if isScrolling { return nil } - return findWordIndex(at: point, in: textView) - } - - @MainActor - private func findWordIndex(at point: NSPoint, in textView: NSTextView) -> Int? { - let containerInset = textView.textContainerInset - - let hoverAreaFrame = textView.bounds.insetBy( - dx: containerInset.width * 2, - dy: containerInset.height * 2 - ) - - guard hoverAreaFrame.contains(point) else { - return nil - } - - if !wordFrames.isEmpty && wordFrames.count == wordRanges.count { - for (index, frame) in wordFrames.enumerated() { - if frame.contains(point) { - return index - } - } - return nil - } - - guard let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer, - let textStorage = textView.textStorage else { return nil } - - let characterIndex = layoutManager.characterIndex( - for: point, - in: textContainer, - fractionOfDistanceBetweenInsertionPoints: nil - ) - - guard characterIndex < textStorage.length else { return nil } - - return textStorage.attribute(NSAttributedString.Key("wordIndex"), at: characterIndex, effectiveRange: nil) as? Int + return layoutManager.findWordIndex(at: point, in: textView, wordRanges: wordRanges) } } } diff --git a/ABPlayer/Sources/Views/Subtitle/Utilities/AttributedStringBuilder.swift b/ABPlayer/Sources/Views/Subtitle/Utilities/AttributedStringBuilder.swift new file mode 100644 index 0000000..09cc851 --- /dev/null +++ b/ABPlayer/Sources/Views/Subtitle/Utilities/AttributedStringBuilder.swift @@ -0,0 +1,56 @@ +import AppKit +import Foundation + +/// Builds attributed strings for interactive subtitle text +/// Handles word-based formatting with vocabulary difficulty colors +struct AttributedStringBuilder { + let fontSize: Double + let defaultTextColor: NSColor + let difficultyLevelProvider: (String) -> Int? + + /// Result containing the attributed string and word ranges + struct Result { + let attributedString: NSAttributedString + let wordRanges: [NSRange] + } + + /// Build attributed string from words with color coding + func build(words: [String]) -> Result { + var wordRanges: [NSRange] = [] + let result = NSMutableAttributedString() + let font = NSFont.systemFont(ofSize: fontSize) + + for (index, word) in words.enumerated() { + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: colorForWord(word), + NSAttributedString.Key("wordIndex"): index + ] + + let startLocation = result.length + let wordString = NSAttributedString(string: word, attributes: attributes) + result.append(wordString) + let endLocation = result.length + + wordRanges.append(NSRange(location: startLocation, length: endLocation - startLocation)) + + if index < words.count - 1 { + result.append(NSAttributedString(string: " ", attributes: [.font: font])) + } + } + + return Result(attributedString: result, wordRanges: wordRanges) + } + + /// Get color for word based on difficulty level + func colorForWord(_ word: String) -> NSColor { + guard let level = difficultyLevelProvider(word), level > 0 else { + return defaultTextColor + } + switch level { + case 1: return .systemGreen + case 2: return .systemYellow + default: return .systemRed + } + } +} diff --git a/ABPlayer/Sources/Views/Subtitle/Utilities/WordLayoutManager.swift b/ABPlayer/Sources/Views/Subtitle/Utilities/WordLayoutManager.swift new file mode 100644 index 0000000..4e463af --- /dev/null +++ b/ABPlayer/Sources/Views/Subtitle/Utilities/WordLayoutManager.swift @@ -0,0 +1,92 @@ +import AppKit +import Foundation + +/// Manages word frame caching and hit detection for interactive text +class WordLayoutManager { + private(set) var wordFrames: [CGRect] = [] + + /// Cache word bounding rectangles for hover detection + func cacheWordFrames( + wordRanges: [NSRange], + in textView: NSTextView + ) { + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { return } + + wordFrames.removeAll(keepingCapacity: true) + + for range in wordRanges { + let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + rect.origin.x += textView.textContainerInset.width + rect.origin.y += textView.textContainerInset.height + wordFrames.append(rect) + } + } + + /// Find word index at given point using cached frames or layout manager + func findWordIndex( + at point: NSPoint, + in textView: NSTextView, + wordRanges: [NSRange] + ) -> Int? { + let containerInset = textView.textContainerInset + + let hoverAreaFrame = textView.bounds.insetBy( + dx: containerInset.width * 2, + dy: containerInset.height * 2 + ) + + guard hoverAreaFrame.contains(point) else { + return nil + } + + if !wordFrames.isEmpty && wordFrames.count == wordRanges.count { + for (index, frame) in wordFrames.enumerated() { + if frame.contains(point) { + return index + } + } + return nil + } + + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer, + let textStorage = textView.textStorage else { return nil } + + let characterIndex = layoutManager.characterIndex( + for: point, + in: textContainer, + fractionOfDistanceBetweenInsertionPoints: nil + ) + + guard characterIndex < textStorage.length else { return nil } + + return textStorage.attribute(NSAttributedString.Key("wordIndex"), at: characterIndex, effectiveRange: nil) as? Int + } + + /// Get bounding rect for word at index + func boundingRect( + forWordAt index: Int, + wordRanges: [NSRange], + in textView: NSTextView + ) -> CGRect? { + guard index < wordRanges.count, + let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { + return nil + } + + let range = wordRanges[index] + let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + rect.origin.x += textView.textContainerInset.width + rect.origin.y += textView.textContainerInset.height + return rect + } + + /// Clear cached frames + func clearCache() { + wordFrames.removeAll() + } +} diff --git a/ABPlayer/Tests/SubtitleViewModelTests.swift b/ABPlayer/Tests/SubtitleViewModelTests.swift new file mode 100644 index 0000000..a5a6caf --- /dev/null +++ b/ABPlayer/Tests/SubtitleViewModelTests.swift @@ -0,0 +1,312 @@ +import AppKit +import Foundation +import Testing + +@testable import ABPlayer + +struct AttributedStringBuilderTests { + + @Test + func testBuildBasicAttributedString() { + let builder = AttributedStringBuilder( + fontSize: 16.0, + defaultTextColor: .labelColor, + difficultyLevelProvider: { _ in nil } + ) + + let words = ["Hello", "world", "test"] + let result = builder.build(words: words) + + #expect(result.wordRanges.count == 3) + #expect(result.attributedString.string == "Hello world test") + } + + @Test + func testWordRangesCalculation() { + let builder = AttributedStringBuilder( + fontSize: 16.0, + defaultTextColor: .labelColor, + difficultyLevelProvider: { _ in nil } + ) + + let words = ["Hello", "world"] + let result = builder.build(words: words) + + #expect(result.wordRanges[0].location == 0) + #expect(result.wordRanges[0].length == 5) + #expect(result.wordRanges[1].location == 6) + #expect(result.wordRanges[1].length == 5) + } + + @Test + func testColorForWordWithoutDifficulty() { + let builder = AttributedStringBuilder( + fontSize: 16.0, + defaultTextColor: .labelColor, + difficultyLevelProvider: { _ in nil } + ) + + let color = builder.colorForWord("test") + #expect(color == .labelColor) + } + + @Test + func testColorForWordWithDifficultyLevel1() { + let builder = AttributedStringBuilder( + fontSize: 16.0, + defaultTextColor: .labelColor, + difficultyLevelProvider: { word in + word == "easy" ? 1 : nil + } + ) + + let color = builder.colorForWord("easy") + #expect(color == .systemGreen) + } + + @Test + func testColorForWordWithDifficultyLevel2() { + let builder = AttributedStringBuilder( + fontSize: 16.0, + defaultTextColor: .labelColor, + difficultyLevelProvider: { word in + word == "medium" ? 2 : nil + } + ) + + let color = builder.colorForWord("medium") + #expect(color == .systemYellow) + } + + @Test + func testColorForWordWithDifficultyLevel3() { + let builder = AttributedStringBuilder( + fontSize: 16.0, + defaultTextColor: .labelColor, + difficultyLevelProvider: { word in + word == "hard" ? 3 : nil + } + ) + + let color = builder.colorForWord("hard") + #expect(color == .systemRed) + } + + @Test + func testEmptyWordsArray() { + let builder = AttributedStringBuilder( + fontSize: 16.0, + defaultTextColor: .labelColor, + difficultyLevelProvider: { _ in nil } + ) + + let result = builder.build(words: []) + #expect(result.wordRanges.isEmpty) + #expect(result.attributedString.string.isEmpty) + } + + @Test + func testSingleWord() { + let builder = AttributedStringBuilder( + fontSize: 16.0, + defaultTextColor: .labelColor, + difficultyLevelProvider: { _ in nil } + ) + + let result = builder.build(words: ["Hello"]) + #expect(result.wordRanges.count == 1) + #expect(result.attributedString.string == "Hello") + } + + @Test + func testFontAttributeApplied() { + let fontSize: Double = 20.0 + let builder = AttributedStringBuilder( + fontSize: fontSize, + defaultTextColor: .labelColor, + difficultyLevelProvider: { _ in nil } + ) + + let result = builder.build(words: ["Test"]) + let attributes = result.attributedString.attributes(at: 0, effectiveRange: nil) + + if let font = attributes[.font] as? NSFont { + #expect(abs(font.pointSize - fontSize) < 0.01) + } else { + Issue.record("Font attribute not found") + } + } + + @Test + func testWordIndexAttribute() { + let builder = AttributedStringBuilder( + fontSize: 16.0, + defaultTextColor: .labelColor, + difficultyLevelProvider: { _ in nil } + ) + + let result = builder.build(words: ["First", "Second"]) + + let firstAttributes = result.attributedString.attributes(at: 0, effectiveRange: nil) + let secondAttributes = result.attributedString.attributes(at: 6, effectiveRange: nil) + + #expect(firstAttributes[NSAttributedString.Key("wordIndex")] as? Int == 0) + #expect(secondAttributes[NSAttributedString.Key("wordIndex")] as? Int == 1) + } +} + +struct SubtitleViewModelTests { + + @Test + @MainActor + func testInitialState() { + let viewModel = SubtitleViewModel() + + #expect(viewModel.currentCueID == nil) + #expect(viewModel.scrollState == .autoScrolling) + #expect(viewModel.wordSelection == .none) + } + + @Test + @MainActor + func testHandleUserScroll() async { + let viewModel = SubtitleViewModel() + + viewModel.handleUserScroll() + + #expect(viewModel.scrollState.isUserScrolling) + if case .userScrolling(let countdown) = viewModel.scrollState { + #expect(countdown == 3) + } else { + Issue.record("Expected userScrolling state") + } + } + + @Test + @MainActor + func testCancelScrollResume() { + let viewModel = SubtitleViewModel() + + viewModel.handleUserScroll() + viewModel.cancelScrollResume() + + #expect(viewModel.scrollState == .autoScrolling) + } + + @Test + @MainActor + func testHandleWordSelection() { + let viewModel = SubtitleViewModel() + let cueID = UUID() + var pauseCalled = false + + viewModel.handleWordSelection( + wordIndex: 0, + cueID: cueID, + isPlaying: true, + onPause: { pauseCalled = true } + ) + + #expect(pauseCalled) + if case .selected(let selectedCueID, let wordIndex) = viewModel.wordSelection { + #expect(selectedCueID == cueID) + #expect(wordIndex == 0) + } else { + Issue.record("Expected selected state") + } + } + + @Test + @MainActor + func testDismissWord() { + let viewModel = SubtitleViewModel() + let cueID = UUID() + var playCalled = false + + viewModel.handleWordSelection( + wordIndex: 0, + cueID: cueID, + isPlaying: true, + onPause: {} + ) + + viewModel.dismissWord(onPlay: { playCalled = true }) + + #expect(playCalled) + #expect(viewModel.wordSelection == .none) + } + + @Test + @MainActor + func testFindActiveCueWithBinarySearch() { + let viewModel = SubtitleViewModel() + + let cues = [ + SubtitleCue(startTime: 0.0, endTime: 2.0, text: "First"), + SubtitleCue(startTime: 2.0, endTime: 4.0, text: "Second"), + SubtitleCue(startTime: 4.0, endTime: 6.0, text: "Third"), + SubtitleCue(startTime: 6.0, endTime: 8.0, text: "Fourth") + ] + + viewModel.updateCurrentCue(time: 2.5, cues: cues) + #expect(viewModel.currentCueID == cues[1].id) + + viewModel.updateCurrentCue(time: 5.0, cues: cues) + #expect(viewModel.currentCueID == cues[2].id) + } + + @Test + @MainActor + func testUpdateCurrentCueDoesNotUpdateDuringUserScroll() { + let viewModel = SubtitleViewModel() + + let cues = [ + SubtitleCue(startTime: 0.0, endTime: 2.0, text: "First"), + SubtitleCue(startTime: 2.0, endTime: 4.0, text: "Second") + ] + + viewModel.updateCurrentCue(time: 1.0, cues: cues) + let firstCueID = viewModel.currentCueID + + viewModel.handleUserScroll() + viewModel.updateCurrentCue(time: 3.0, cues: cues) + + #expect(viewModel.currentCueID == firstCueID) + } + + @Test + @MainActor + func testReset() { + let viewModel = SubtitleViewModel() + let cueID = UUID() + + viewModel.handleUserScroll() + viewModel.handleWordSelection(wordIndex: 0, cueID: cueID, isPlaying: false, onPause: {}) + + viewModel.reset() + + #expect(viewModel.scrollState == .autoScrolling) + #expect(viewModel.currentCueID == nil) + #expect(viewModel.wordSelection == .none) + } + + @Test + @MainActor + func testHandleCueTap() { + let viewModel = SubtitleViewModel() + let cueID = UUID() + var seekTime: Double? + + viewModel.handleUserScroll() + + viewModel.handleCueTap( + cueID: cueID, + onSeek: { seekTime = $0 }, + cueStartTime: 10.0 + ) + + #expect(viewModel.tappedCueID == cueID) + #expect(seekTime == 10.0) + #expect(viewModel.scrollState == .autoScrolling) + } +} From df8cb5d554a614ef696d6f6cc5f4e5402d9bb8c9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Jan 2026 01:36:58 -0700 Subject: [PATCH 6/9] refactor(subtitle): improve robustness with logging, assertions, and async-stream countdown --- .../Views/Subtitle/SubtitleViewModel.swift | 77 +++++++++++-- .../Utilities/AttributedStringBuilder.swift | 7 +- .../Utilities/WordLayoutManager.swift | 14 ++- ABPlayer/Tests/SubtitleViewModelTests.swift | 101 ++++++++++++++++++ 4 files changed, 191 insertions(+), 8 deletions(-) diff --git a/ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift b/ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift index 8b37cbb..69883a0 100644 --- a/ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift +++ b/ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift @@ -1,8 +1,10 @@ import Foundation +import OSLog import SwiftUI @Observable class SubtitleViewModel { + private static let logger = Logger(subsystem: "com.abplayer", category: "SubtitleViewModel") enum ScrollState: Equatable { case autoScrolling case userScrolling(countdown: Int) @@ -45,12 +47,38 @@ class SubtitleViewModel { scrollState = .userScrolling(countdown: Self.pauseDuration) scrollResumeTask = Task { @MainActor in - for remaining in (0.. 0 ? .userScrolling(countdown: remaining) : .autoScrolling + do { + for await remaining in countdown(from: Self.pauseDuration) { + guard !Task.isCancelled else { + Self.logger.debug("Countdown task cancelled") + return + } + scrollState = remaining > 0 ? .userScrolling(countdown: remaining) : .autoScrolling + } + scrollState = .autoScrolling + Self.logger.debug("Countdown completed, resumed auto-scrolling") + } catch { + Self.logger.error("Countdown error: \(error.localizedDescription)") + scrollState = .autoScrolling + } + } + } + + private func countdown(from seconds: Int) -> AsyncStream { + AsyncStream { continuation in + Task { + for i in (0.. Void) { if let wordIndex { + assert(wordIndex >= 0, "Word index must be non-negative") + if wordSelection == .none { wasPlayingBeforeSelection = isPlaying if isPlaying { @@ -69,6 +99,7 @@ class SubtitleViewModel { } } wordSelection = .selected(cueID: cueID, wordIndex: wordIndex) + Self.logger.debug("Selected word at index \(wordIndex) in cue \(cueID)") } else { dismissWord(onPlay: onPause) } @@ -88,12 +119,19 @@ class SubtitleViewModel { } func handleCueTap(cueID: UUID, onSeek: (Double) -> Void, cueStartTime: Double) { + assert(cueStartTime >= 0, "Cue start time must be non-negative") + assert(cueStartTime.isFinite, "Cue start time must be finite") + tappedCueID = cueID onSeek(cueStartTime) cancelScrollResume() + Self.logger.debug("Tapped cue at time \(cueStartTime)") } func updateCurrentCue(time: Double, cues: [SubtitleCue]) { + assert(time >= 0, "Time must be non-negative") + assert(time.isFinite, "Time must be finite") + guard !scrollState.isUserScrolling else { return } let activeCue = findActiveCue(at: time, in: cues) if activeCue?.id != currentCueID { @@ -113,21 +151,47 @@ class SubtitleViewModel { func trackPlayback(timeProvider: @escaping @MainActor () -> Double, cues: [SubtitleCue]) async { let epsilon: Double = 0.001 + guard !cues.isEmpty else { + Self.logger.warning("trackPlayback called with empty cues array") + return + } + + Self.logger.debug("Started tracking playback for \(cues.count) cues") + while !Task.isCancelled { if !scrollState.isUserScrolling { let currentTime = timeProvider() + + guard currentTime.isFinite && currentTime >= 0 else { + Self.logger.error("Invalid time from provider: \(currentTime)") + continue + } + let activeCue = findActiveCue(at: currentTime, in: cues, epsilon: epsilon) if activeCue?.id != currentCueID { currentCueID = activeCue?.id + if let cue = activeCue { + Self.logger.debug("Active cue changed: \(cue.text.prefix(30))...") + } } } - try? await Task.sleep(for: .milliseconds(100)) + do { + try await Task.sleep(for: .milliseconds(100)) + } catch { + Self.logger.debug("Playback tracking cancelled: \(error.localizedDescription)") + break + } } + + Self.logger.debug("Stopped tracking playback") } private func findActiveCue(at time: Double, in cues: [SubtitleCue], epsilon: Double = 0.001) -> SubtitleCue? { + assert(epsilon > 0, "Epsilon must be positive") + assert(time.isFinite, "Time must be finite") + guard !cues.isEmpty else { return nil } var low = 0 @@ -145,6 +209,7 @@ class SubtitleViewModel { } if let index = result { + assert(index >= 0 && index < cues.count, "Binary search produced invalid index") let cue = cues[index] if time >= cue.startTime - epsilon && time < cue.endTime { return cue diff --git a/ABPlayer/Sources/Views/Subtitle/Utilities/AttributedStringBuilder.swift b/ABPlayer/Sources/Views/Subtitle/Utilities/AttributedStringBuilder.swift index 09cc851..b65716e 100644 --- a/ABPlayer/Sources/Views/Subtitle/Utilities/AttributedStringBuilder.swift +++ b/ABPlayer/Sources/Views/Subtitle/Utilities/AttributedStringBuilder.swift @@ -21,6 +21,8 @@ struct AttributedStringBuilder { let font = NSFont.systemFont(ofSize: fontSize) for (index, word) in words.enumerated() { + assert(!word.isEmpty, "Words should not be empty") + let attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: colorForWord(word), @@ -32,13 +34,16 @@ struct AttributedStringBuilder { result.append(wordString) let endLocation = result.length - wordRanges.append(NSRange(location: startLocation, length: endLocation - startLocation)) + let range = NSRange(location: startLocation, length: endLocation - startLocation) + assert(range.length > 0, "Word range should have positive length") + wordRanges.append(range) if index < words.count - 1 { result.append(NSAttributedString(string: " ", attributes: [.font: font])) } } + assert(wordRanges.count == words.count, "Word ranges count must match words count") return Result(attributedString: result, wordRanges: wordRanges) } diff --git a/ABPlayer/Sources/Views/Subtitle/Utilities/WordLayoutManager.swift b/ABPlayer/Sources/Views/Subtitle/Utilities/WordLayoutManager.swift index 4e463af..e3fbd10 100644 --- a/ABPlayer/Sources/Views/Subtitle/Utilities/WordLayoutManager.swift +++ b/ABPlayer/Sources/Views/Subtitle/Utilities/WordLayoutManager.swift @@ -42,8 +42,11 @@ class WordLayoutManager { } if !wordFrames.isEmpty && wordFrames.count == wordRanges.count { + assert(wordFrames.count == wordRanges.count, "Cached frames count must match word ranges") + for (index, frame) in wordFrames.enumerated() { if frame.contains(point) { + assert(index < wordRanges.count, "Found index must be within word ranges") return index } } @@ -62,7 +65,11 @@ class WordLayoutManager { guard characterIndex < textStorage.length else { return nil } - return textStorage.attribute(NSAttributedString.Key("wordIndex"), at: characterIndex, effectiveRange: nil) as? Int + let wordIndex = textStorage.attribute(NSAttributedString.Key("wordIndex"), at: characterIndex, effectiveRange: nil) as? Int + if let wordIndex { + assert(wordIndex >= 0 && wordIndex < wordRanges.count, "Word index from attribute must be valid") + } + return wordIndex } /// Get bounding rect for word at index @@ -71,6 +78,9 @@ class WordLayoutManager { wordRanges: [NSRange], in textView: NSTextView ) -> CGRect? { + assert(index >= 0, "Word index must be non-negative") + assert(index < wordRanges.count, "Word index must be within range") + guard index < wordRanges.count, let layoutManager = textView.layoutManager, let textContainer = textView.textContainer else { @@ -82,6 +92,8 @@ class WordLayoutManager { var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) rect.origin.x += textView.textContainerInset.width rect.origin.y += textView.textContainerInset.height + + assert(rect.width >= 0 && rect.height >= 0, "Bounding rect must have non-negative dimensions") return rect } diff --git a/ABPlayer/Tests/SubtitleViewModelTests.swift b/ABPlayer/Tests/SubtitleViewModelTests.swift index a5a6caf..1b43146 100644 --- a/ABPlayer/Tests/SubtitleViewModelTests.swift +++ b/ABPlayer/Tests/SubtitleViewModelTests.swift @@ -309,4 +309,105 @@ struct SubtitleViewModelTests { #expect(seekTime == 10.0) #expect(viewModel.scrollState == .autoScrolling) } + + @Test + @MainActor + func testTrackPlaybackWithInvalidTime() async { + let viewModel = SubtitleViewModel() + let cues = [ + SubtitleCue(startTime: 0.0, endTime: 2.0, text: "First") + ] + + var callCount = 0 + let trackingTask = Task { + await viewModel.trackPlayback( + timeProvider: { + callCount += 1 + if callCount < 3 { + return Double.nan + } else { + return 1.0 + } + }, + cues: cues + ) + } + + try? await Task.sleep(for: .milliseconds(500)) + trackingTask.cancel() + + #expect(callCount > 0) + } + + @Test + @MainActor + func testTrackPlaybackWithEmptyCues() async { + let viewModel = SubtitleViewModel() + + let trackingTask = Task { + await viewModel.trackPlayback( + timeProvider: { 0.0 }, + cues: [] + ) + } + + try? await Task.sleep(for: .milliseconds(100)) + trackingTask.cancel() + + #expect(viewModel.currentCueID == nil) + } + + @Test + @MainActor + func testCountdownAsyncStream() async { + let viewModel = SubtitleViewModel() + + viewModel.handleUserScroll() + + if case .userScrolling(let countdown) = viewModel.scrollState { + #expect(countdown == 3) + } else { + Issue.record("Expected userScrolling state") + } + + try? await Task.sleep(for: .seconds(1.2)) + + if case .userScrolling(let countdown) = viewModel.scrollState { + #expect(countdown < 3) + } + } + + @Test + func testAttributedStringBuilderWithEmptyWords() { + let builder = AttributedStringBuilder( + fontSize: 16.0, + defaultTextColor: .labelColor, + difficultyLevelProvider: { _ in nil } + ) + + let result = builder.build(words: []) + + #expect(result.wordRanges.isEmpty) + #expect(result.attributedString.string.isEmpty) + } + + @Test + @MainActor + func testWordLayoutManagerBoundingRectWithMissingTextContainer() { + let layoutManager = WordLayoutManager() + + let mockTextView = NSTextView(frame: .zero) + mockTextView.layoutManager?.removeTextContainer(at: 0) + + let wordRanges: [NSRange] = [NSRange(location: 0, length: 5)] + + let rect = layoutManager.boundingRect( + forWordAt: 0, + wordRanges: wordRanges, + in: mockTextView + ) + + #expect(rect == nil) + } } + From e57fabb833a6aa85d763ff7692c787cbe5aaa04d Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Jan 2026 01:50:03 -0700 Subject: [PATCH 7/9] fix(project): resolve compiler warnings, concurrency issues, and asset naming conflicts --- .../Contents.json | 0 .../Views/Components/FileRowView.swift | 2 +- .../InteractiveAttributedTextView.swift | 3 ++- .../Views/Subtitle/SubtitleViewModel.swift | 19 +++++++------------ .../Utilities/WordLayoutManager.swift | 1 + Project.swift | 2 ++ 6 files changed, 13 insertions(+), 14 deletions(-) rename ABPlayer/Resources/Colors.xcassets/{accent.colorset => appAccent.colorset}/Contents.json (100%) diff --git a/ABPlayer/Resources/Colors.xcassets/accent.colorset/Contents.json b/ABPlayer/Resources/Colors.xcassets/appAccent.colorset/Contents.json similarity index 100% rename from ABPlayer/Resources/Colors.xcassets/accent.colorset/Contents.json rename to ABPlayer/Resources/Colors.xcassets/appAccent.colorset/Contents.json diff --git a/ABPlayer/Sources/Views/Components/FileRowView.swift b/ABPlayer/Sources/Views/Components/FileRowView.swift index c1edeef..5787bbf 100644 --- a/ABPlayer/Sources/Views/Components/FileRowView.swift +++ b/ABPlayer/Sources/Views/Components/FileRowView.swift @@ -16,7 +16,7 @@ struct FileRowView: View { var body: some View { ZStack(alignment: .leading) { - Color.asset.accent + Color.asset.appAccent .frame(width: 3) .scaleEffect(y: isSelected ? 1 : 0, anchor: .center) .opacity(isSelected ? 1 : 0) diff --git a/ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift b/ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift index 2991809..e023678 100644 --- a/ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift +++ b/ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift @@ -178,7 +178,8 @@ struct InteractiveAttributedTextView: NSViewRepresentable { ) } - class Coordinator: NSObject { + @MainActor + class Coordinator: NSObject { var cueID: UUID var words: [String] var selectedWordIndex: Int? diff --git a/ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift b/ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift index 69883a0..c258b67 100644 --- a/ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift +++ b/ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift @@ -47,20 +47,15 @@ class SubtitleViewModel { scrollState = .userScrolling(countdown: Self.pauseDuration) scrollResumeTask = Task { @MainActor in - do { - for await remaining in countdown(from: Self.pauseDuration) { - guard !Task.isCancelled else { - Self.logger.debug("Countdown task cancelled") - return - } - scrollState = remaining > 0 ? .userScrolling(countdown: remaining) : .autoScrolling + for await remaining in countdown(from: Self.pauseDuration) { + guard !Task.isCancelled else { + Self.logger.debug("Countdown task cancelled") + return } - scrollState = .autoScrolling - Self.logger.debug("Countdown completed, resumed auto-scrolling") - } catch { - Self.logger.error("Countdown error: \(error.localizedDescription)") - scrollState = .autoScrolling + scrollState = remaining > 0 ? .userScrolling(countdown: remaining) : .autoScrolling } + scrollState = .autoScrolling + Self.logger.debug("Countdown completed, resumed auto-scrolling") } } diff --git a/ABPlayer/Sources/Views/Subtitle/Utilities/WordLayoutManager.swift b/ABPlayer/Sources/Views/Subtitle/Utilities/WordLayoutManager.swift index e3fbd10..c4e3b33 100644 --- a/ABPlayer/Sources/Views/Subtitle/Utilities/WordLayoutManager.swift +++ b/ABPlayer/Sources/Views/Subtitle/Utilities/WordLayoutManager.swift @@ -2,6 +2,7 @@ import AppKit import Foundation /// Manages word frame caching and hit detection for interactive text +@MainActor class WordLayoutManager { private(set) var wordFrames: [CGRect] = [] diff --git a/Project.swift b/Project.swift index 13ecdec..4ad76f6 100644 --- a/Project.swift +++ b/Project.swift @@ -34,6 +34,7 @@ let project = Project( "ABPlayer/Resources", ], dependencies: [ + .sdk(name: "AppIntents", type: .framework, status: .optional), .external(name: "Sentry"), .external(name: "WhisperKit"), .external(name: "KeyboardShortcuts"), @@ -61,6 +62,7 @@ let project = Project( "ABPlayer/Resources", ], dependencies: [ + .sdk(name: "AppIntents", type: .framework, status: .optional), .external(name: "Sentry"), .external(name: "WhisperKit"), .external(name: "KeyboardShortcuts"), From 4af78a51ece95ed6fcec464382df5ff22b38c3dc Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Jan 2026 01:57:28 -0700 Subject: [PATCH 8/9] docs(subtitle): add architecture diagram for subtitle system --- Docs/SubtitleArchitecture.canvas | 307 +++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 Docs/SubtitleArchitecture.canvas diff --git a/Docs/SubtitleArchitecture.canvas b/Docs/SubtitleArchitecture.canvas new file mode 100644 index 0000000..1248914 --- /dev/null +++ b/Docs/SubtitleArchitecture.canvas @@ -0,0 +1,307 @@ +{ + "nodes": [ + { + "id": "group-ui", + "type": "group", + "x": -60, + "y": -40, + "width": 920, + "height": 520, + "label": "UI Layer (SwiftUI + AppKit Wrapper)", + "color": "4" + }, + { + "id": "subtitle-view", + "type": "text", + "x": 0, + "y": 30, + "width": 400, + "height": 260, + "color": "4", + "text": "**SubtitleView**\n`ABPlayer/Sources/Views/SubtitleView.swift`\n\n- `@Environment(AudioPlayerManager)`\n- `@Environment(VocabularyService)`\n- `@State private var viewModel = SubtitleViewModel()`\n- Renders `ForEach(cues) -> SubtitleCueRow`\n- `.task { await viewModel.trackPlayback(timeProvider:, cues:) }`\n- `onChange(currentCueID|tappedCueID)` -> `ScrollViewReader.scrollTo()`\n- `onScrollPhaseChange(.interacting)` -> `viewModel.handleUserScroll()`" + }, + { + "id": "subtitle-cue-row", + "type": "text", + "x": 440, + "y": 30, + "width": 380, + "height": 260, + "color": "4", + "text": "**SubtitleCueRow**\n`ABPlayer/Sources/Views/Subtitle/SubtitleCueRow.swift`\n\n- Input: `SubtitleCue`, `isActive`, `isScrolling`, `selectedWordIndex`\n- Tap: `onTapGesture` -> `onTap()` (cue jump)\n- Word selection: forwards `onWordSelected(index?)`\n- Queries + mutates `VocabularyService` (difficulty / forgot / remembered / remove)\n- Hosts popover `WordMenuView`" + }, + { + "id": "interactive-attributed-text-view", + "type": "text", + "x": 440, + "y": 310, + "width": 380, + "height": 150, + "color": "4", + "text": "**InteractiveAttributedTextView** (NSViewRepresentable)\n`ABPlayer/Sources/Views/Subtitle/Components/InteractiveAttributedTextView.swift`\n\n- AppKit text rendering + per-word hit-testing\n- Uses `Coordinator` to cache attributed string + selection/hover ranges\n- Takes providers + callbacks from `SubtitleCueRow`\n- Receives `vocabularyVersion` to re-render highlights" + }, + { + "id": "group-logic", + "type": "group", + "x": -60, + "y": 520, + "width": 920, + "height": 380, + "label": "Logic Layer (ViewModel / State Machine)", + "color": "6" + }, + { + "id": "subtitle-view-model", + "type": "text", + "x": 0, + "y": 590, + "width": 820, + "height": 280, + "color": "6", + "text": "**SubtitleViewModel** (`@Observable`)\n`ABPlayer/Sources/Views/Subtitle/SubtitleViewModel.swift`\n\nState:\n- `currentCueID: UUID?`\n- `tappedCueID: UUID?`\n- `scrollState: ScrollState` (`autoScrolling` | `userScrolling(countdown:)`)\n- `wordSelection: WordSelectionState` (`none` | `selected(cueID, wordIndex)`)\n\nCore methods:\n- `trackPlayback(timeProvider:, cues:)` (loop; 100ms sleep)\n- `findActiveCue(at:in:)` (binary search)\n- `handleCueTap(cueID:onSeek:cueStartTime:)`\n- `handleUserScroll()` (starts countdown task)\n- `handleWordSelection(wordIndex:cueID:isPlaying:onPause:)`\n- `reset()`" + }, + { + "id": "group-data", + "type": "group", + "x": 900, + "y": -40, + "width": 780, + "height": 940, + "label": "Data Layer (SwiftData Model + Parsing)", + "color": "3" + }, + { + "id": "subtitle-parser", + "type": "text", + "x": 940, + "y": 30, + "width": 340, + "height": 200, + "color": "3", + "text": "**SubtitleParser**\n`ABPlayer/Sources/Models/SubtitleFile.swift`\n\n- `detectFormat(from:)` -> `srt|vtt|unknown`\n- `parse(from:)` -> `[SubtitleCue]`\n- `parseSRT(_:)` / `parseVTT(_:)`\n- `writeSRT(cues:to:)`" + }, + { + "id": "subtitle-cue", + "type": "text", + "x": 1320, + "y": 30, + "width": 320, + "height": 200, + "color": "3", + "text": "**SubtitleCue** (value type)\n`ABPlayer/Sources/Models/SubtitleFile.swift`\n\n- `id: UUID`\n- `startTime: Double`\n- `endTime: Double`\n- `text: String`\n\nUsed by UI + ViewModel for:\n- rendering\n- binary search for active cue" + }, + { + "id": "subtitle-file-model", + "type": "text", + "x": 940, + "y": 260, + "width": 700, + "height": 220, + "color": "3", + "text": "**SubtitleFile** (SwiftData `@Model`)\n`ABPlayer/Sources/Models/SubtitleFile.swift`\n\n- Metadata: `id`, `displayName`, `bookmarkData`, `createdAt`, `audioFile` relationship\n- Cache: `cachedCuesData` (`@Attribute(.externalStorage)`) stores JSON\n- Computed: `cues: [SubtitleCue]` (encode/decode JSON)\n\nUpstream loaders may set `subtitleFile.cues = try SubtitleParser.parse(url)`" + }, + { + "id": "group-services", + "type": "group", + "x": -60, + "y": 920, + "width": 1740, + "height": 360, + "label": "Services (Injected via @Environment)", + "color": "5" + }, + { + "id": "audio-player-manager", + "type": "text", + "x": 0, + "y": 990, + "width": 820, + "height": 240, + "color": "5", + "text": "**AudioPlayerManager** (`@Observable`, `@MainActor`)\n`ABPlayer/Sources/Services/AudioPlayerManager.swift`\n\nProvides:\n- `currentTime: Double`\n- `isPlaying: Bool`\n- Controls: `play()`, `pause()`, `seek(to:)`, `load(...)`\n\nUsed by subtitle UI for:\n- time source (highlight + auto-scroll)\n- tap-to-seek\n- pause during word selection" + }, + { + "id": "vocabulary-service", + "type": "text", + "x": 860, + "y": 990, + "width": 760, + "height": 240, + "color": "5", + "text": "**VocabularyService** (`@Observable`, `@MainActor`)\n`ABPlayer/Sources/Services/VocabularyService.swift`\n\n- Lookup: `difficultyLevel(for:)`, counts, `createdAt(for:)`\n- Mutations: `incrementForgotCount`, `incrementRememberedCount`, `removeVocabulary`\n- `version` increments for UI invalidation\n\nUsed by subtitle row/text to color + update vocabulary stats." + }, + { + "id": "legend", + "type": "text", + "x": 1680, + "y": -40, + "width": 360, + "height": 240, + "text": "**Legend**\n\nArrow labels indicate relationship type:\n- **Data Flow**: produces / consumes data\n- **Observation**: view reacts to state changes\n- **Ownership**: stored reference / lifecycle\n- **Callbacks**: closures passed across layers" + } + ], + "edges": [ + { + "id": "e-parser-produces-cues", + "fromNode": "subtitle-parser", + "fromSide": "right", + "toNode": "subtitle-cue", + "toSide": "left", + "toEnd": "arrow", + "label": "Data Flow: parse() produces [SubtitleCue]" + }, + { + "id": "e-file-contains-cues", + "fromNode": "subtitle-file-model", + "fromSide": "right", + "toNode": "subtitle-cue", + "toSide": "bottom", + "toEnd": "arrow", + "label": "Data: cached cues JSON -> [SubtitleCue]" + }, + { + "id": "e-cues-to-subtitle-view", + "fromNode": "subtitle-cue", + "fromSide": "left", + "toNode": "subtitle-view", + "toSide": "right", + "toEnd": "arrow", + "label": "Data Flow: `cues: [SubtitleCue]` input" + }, + { + "id": "e-subtitle-view-owns-viewmodel", + "fromNode": "subtitle-view", + "fromSide": "bottom", + "toNode": "subtitle-view-model", + "toSide": "top", + "toEnd": "arrow", + "label": "Ownership: `@State SubtitleViewModel()`" + }, + { + "id": "e-subtitle-view-renders-row", + "fromNode": "subtitle-view", + "fromSide": "right", + "toNode": "subtitle-cue-row", + "toSide": "left", + "toEnd": "arrow", + "label": "UI: ForEach(cues) -> SubtitleCueRow" + }, + { + "id": "e-row-renders-interactive-text", + "fromNode": "subtitle-cue-row", + "fromSide": "bottom", + "toNode": "interactive-attributed-text-view", + "toSide": "top", + "toEnd": "arrow", + "label": "UI: word-level rendering + callbacks" + }, + { + "id": "e-subtitle-view-observes-viewmodel", + "fromNode": "subtitle-view-model", + "fromSide": "left", + "toNode": "subtitle-view", + "toSide": "bottom", + "toEnd": "arrow", + "label": "Observation: currentCueID / tappedCueID / scrollState" + }, + { + "id": "e-viewmodel-consumes-cues", + "fromNode": "subtitle-cue", + "fromSide": "bottom", + "toNode": "subtitle-view-model", + "toSide": "right", + "toEnd": "arrow", + "label": "Logic: binary search findActiveCue(in cues)" + }, + { + "id": "e-audio-time-to-trackPlayback", + "fromNode": "audio-player-manager", + "fromSide": "top", + "toNode": "subtitle-view-model", + "toSide": "bottom", + "toEnd": "arrow", + "label": "Playback Loop: timeProvider -> currentTime" + }, + { + "id": "e-trackPlayback-to-currentCueID", + "fromNode": "subtitle-view-model", + "fromSide": "top", + "toNode": "subtitle-view", + "toSide": "left", + "toEnd": "arrow", + "label": "Playback Loop: currentCueID -> highlight + auto-scroll" + }, + { + "id": "e-row-tap-to-handleCueTap", + "fromNode": "subtitle-cue-row", + "fromSide": "left", + "toNode": "subtitle-view-model", + "toSide": "right", + "toEnd": "arrow", + "label": "User Interaction: tap -> handleCueTap()" + }, + { + "id": "e-handleCueTap-seeks-audio", + "fromNode": "subtitle-view-model", + "fromSide": "bottom", + "toNode": "audio-player-manager", + "toSide": "bottom", + "toEnd": "arrow", + "label": "User Interaction: onSeek closure -> seek(to:)" + }, + { + "id": "e-word-selection-to-viewmodel", + "fromNode": "interactive-attributed-text-view", + "fromSide": "left", + "toNode": "subtitle-view-model", + "toSide": "right", + "toEnd": "arrow", + "label": "Callbacks: onWordSelected -> handleWordSelection()" + }, + { + "id": "e-word-selection-pauses-audio", + "fromNode": "subtitle-view-model", + "fromSide": "bottom", + "toNode": "audio-player-manager", + "toSide": "top", + "toEnd": "arrow", + "label": "Callbacks: onPause/onPlay -> pause()/play()" + }, + { + "id": "e-row-uses-vocab", + "fromNode": "subtitle-cue-row", + "fromSide": "right", + "toNode": "vocabulary-service", + "toSide": "left", + "toEnd": "arrow", + "label": "Vocabulary: queries + increment/remove" + }, + { + "id": "e-vocab-version-invalidates-text", + "fromNode": "vocabulary-service", + "fromSide": "top", + "toNode": "interactive-attributed-text-view", + "toSide": "right", + "toEnd": "arrow", + "label": "Observation: `version` -> re-render highlights" + }, + { + "id": "e-subtitle-view-environment-audio", + "fromNode": "subtitle-view", + "fromSide": "bottom", + "toNode": "audio-player-manager", + "toSide": "left", + "toEnd": "arrow", + "label": "DI: `@Environment(AudioPlayerManager)`" + }, + { + "id": "e-subtitle-view-environment-vocab", + "fromNode": "subtitle-view", + "fromSide": "right", + "toNode": "vocabulary-service", + "toSide": "top", + "toEnd": "arrow", + "label": "DI: `@Environment(VocabularyService)`" + } + ] +} \ No newline at end of file From 2339ecacc6886dee54e963a395be022c43e31e92 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Jan 2026 02:46:06 -0700 Subject: [PATCH 9/9] ci(release_sh): 0.2.9-53 --- .release_state | 2 +- CHANGELOG.md | 19 +++++++++++++++++++ Project.swift | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.release_state b/.release_state index 6941e64..bdd94ef 100644 --- a/.release_state +++ b/.release_state @@ -1 +1 @@ -1a888e49e97919ce3646d6ae42b4e9ad2cde9f07 +4af78a51ece95ed6fcec464382df5ff22b38c3dc diff --git a/CHANGELOG.md b/CHANGELOG.md index f510678..24f3e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## [0.2.9.53] - 2026-01-17 + +### Features +- implement VocabularyService and refactor SubtitleView for better state management + +### Bug Fixes +- resolve compiler warnings, concurrency issues, and asset naming conflicts + +### Improvements +- improve robustness with logging, assertions, and async-stream countdown +- extract layout and string building logic into dedicated utilities and add tests +- extract logic to SubtitleViewModel and modularize sub-components +- exclude merge commits and support scoped commits in changelog + +### Chores +- add architecture diagram for subtitle system +- convert videos + + ## [0.2.9.52] - 2026-01-16 ### Other diff --git a/Project.swift b/Project.swift index 4ad76f6..a8d27e5 100644 --- a/Project.swift +++ b/Project.swift @@ -1,6 +1,6 @@ import ProjectDescription -let buildVersionString = "52" +let buildVersionString = "53" let shortVersionString = "0.2.9" let project = Project( name: "ABPlayer",