Skip to content
Merged
2 changes: 1 addition & 1 deletion .release_state
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1a888e49e97919ce3646d6ae42b4e9ad2cde9f07
4af78a51ece95ed6fcec464382df5ff22b38c3dc
4 changes: 4 additions & 0 deletions ABPlayer/Sources/ABPlayerApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -153,6 +156,7 @@ struct ABPlayerApp: App {
.environment(transcriptionManager)
.environment(transcriptionSettings)
.environment(queueManager)
.environment(vocabularyService)
}
.defaultSize(width: 1600, height: 900)
.windowResizability(.contentSize)
Expand Down
120 changes: 120 additions & 0 deletions ABPlayer/Sources/Services/VocabularyService.swift
Original file line number Diff line number Diff line change
@@ -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<Vocabulary>()
let vocabularies = (try? modelContext.fetch(descriptor)) ?? []

vocabularyMap = Dictionary(
vocabularies.map { ($0.word, $0) },
uniquingKeysWith: { first, _ in first }
)
version += 1
}
}
45 changes: 45 additions & 0 deletions ABPlayer/Sources/Utils/AttributedStringCache.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
2 changes: 1 addition & 1 deletion ABPlayer/Sources/Views/Components/FileRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions ABPlayer/Sources/Views/Subtitle/Components/CountdownRingView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading