@@ -6,7 +6,14 @@ import NaturalLanguage
66class AppDelegate : NSObject , NSApplicationDelegate {
77 var window : NSWindow ?
88 var eventMonitor : Any ?
9- var handledURL = false
9+ private var _handledURL = false
10+ private var isTransitioning = false
11+ private let lock = NSLock ( )
12+
13+ var handledURL : Bool {
14+ get { lock. lock ( ) ; defer { lock. unlock ( ) } ; return _handledURL }
15+ set { lock. lock ( ) ; defer { lock. unlock ( ) } ; _handledURL = newValue }
16+ }
1017
1118 func applicationWillFinishLaunching( _ notification: Notification ) {
1219 // Register URL handler early to catch URL events before applicationDidFinishLaunching
@@ -27,7 +34,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
2734 }
2835
2936 func applicationShouldTerminateAfterLastWindowClosed( _ sender: NSApplication ) -> Bool {
30- return true
37+ // Don't terminate if we're transitioning to a new window
38+ return !isTransitioning
3139 }
3240
3341 func applicationWillTerminate( _ notification: Notification ) {
@@ -75,6 +83,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
7583 }
7684
7785 func showDialog( text: String ? = nil ) {
86+ // Prevent app termination during window transition
87+ isTransitioning = true
88+ defer { isTransitioning = false }
89+
7890 // Clean up previous event monitor
7991 if let monitor = eventMonitor {
8092 NSEvent . removeMonitor ( monitor)
@@ -147,7 +159,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
147159
148160 // Position near mouse cursor
149161 let mouseLocation = NSEvent . mouseLocation
150- let screenFrame = NSScreen . main? . visibleFrame ?? . zero
162+ guard let screen = NSScreen . main ?? NSScreen . screens. first else {
163+ // Fallback: center on screen if no screen available
164+ panel. center ( )
165+ panel. makeKeyAndOrderFront ( nil )
166+ window = panel
167+ NSApplication . shared. activate ( ignoringOtherApps: true )
168+ return
169+ }
170+ let screenFrame = screen. visibleFrame
151171 var windowX = mouseLocation. x - estimatedWidth / 2
152172
153173 // Default: above mouse, fallback: below if near top edge
@@ -180,7 +200,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
180200 NSApplication . shared. activate ( ignoringOtherApps: true )
181201
182202 // Keyboard shortcuts
183- eventMonitor = NSEvent . addLocalMonitorForEvents ( matching: . keyDown) { event in
203+ eventMonitor = NSEvent . addLocalMonitorForEvents ( matching: . keyDown) { [ weak self] event in
204+ guard self != nil else { return event }
184205 // Escape to close
185206 if event. keyCode == 53 {
186207 NSApplication . shared. terminate ( nil )
@@ -231,19 +252,52 @@ func detectLanguage(_ text: String) -> String {
231252 return languageNames [ language] ?? language. rawValue. capitalized
232253}
233254
255+ // MARK: - Speech Manager
256+ class SpeechManager : NSObject , ObservableObject , NSSpeechSynthesizerDelegate {
257+ @Published var speakingOriginal = false
258+ @Published var speakingTranslation = false
259+
260+ private let synthesizer = NSSpeechSynthesizer ( )
261+
262+ override init ( ) {
263+ super. init ( )
264+ synthesizer. delegate = self
265+ }
266+
267+ func speak( _ text: String , isOriginal: Bool ) {
268+ if synthesizer. isSpeaking {
269+ synthesizer. stopSpeaking ( )
270+ speakingOriginal = false
271+ speakingTranslation = false
272+ return
273+ }
274+
275+ if isOriginal {
276+ speakingOriginal = true
277+ } else {
278+ speakingTranslation = true
279+ }
280+ synthesizer. startSpeaking ( text)
281+ }
282+
283+ func speechSynthesizer( _ sender: NSSpeechSynthesizer , didFinishSpeaking finishedSpeaking: Bool ) {
284+ DispatchQueue . main. async { [ weak self] in
285+ self ? . speakingOriginal = false
286+ self ? . speakingTranslation = false
287+ }
288+ }
289+ }
290+
234291// MARK: - Dialog View
235292struct DialogView : View {
236293 let text : String
237294 let onClose : ( ) -> Void
238295 @State private var copiedOriginal = false
239296 @State private var copiedTranslation = false
240- @State private var speakingOriginal = false
241- @State private var speakingTranslation = false
242297 @State private var showingHelp = false
298+ @StateObject private var speechManager = SpeechManager ( )
243299 @Environment ( \. colorScheme) private var colorScheme
244300
245- private let synthesizer = NSSpeechSynthesizer ( )
246-
247301 // Adaptive opacity based on color scheme
248302 private var originalCardOpacity : Double {
249303 colorScheme == . dark ? 0.15 : 0.06
@@ -285,10 +339,10 @@ struct DialogView: View {
285339 . foregroundStyle ( . secondary)
286340 Spacer ( )
287341 HStack ( spacing: 12 ) {
288- Button ( action: { speakText ( parts. original) } ) {
289- Image ( systemName: speakingOriginal ? " stop.fill " : " speaker.wave.2 " )
342+ Button ( action: { speechManager . speak ( parts. original, isOriginal : true ) } ) {
343+ Image ( systemName: speechManager . speakingOriginal ? " stop.fill " : " speaker.wave.2 " )
290344 . font ( . system( size: 12 ) )
291- . foregroundStyle ( speakingOriginal ? . orange : . secondary)
345+ . foregroundStyle ( speechManager . speakingOriginal ? . orange : . secondary)
292346 . frame ( width: 14 , height: 14 )
293347 }
294348 . buttonStyle ( . plain)
@@ -321,10 +375,10 @@ struct DialogView: View {
321375 . foregroundStyle ( . secondary)
322376 Spacer ( )
323377 HStack ( spacing: 12 ) {
324- Button ( action: { speakText ( parts. translation) } ) {
325- Image ( systemName: speakingTranslation ? " stop.fill " : " speaker.wave.2 " )
378+ Button ( action: { speechManager . speak ( parts. translation, isOriginal : false ) } ) {
379+ Image ( systemName: speechManager . speakingTranslation ? " stop.fill " : " speaker.wave.2 " )
326380 . font ( . system( size: 12 ) )
327- . foregroundStyle ( speakingTranslation ? . orange : . secondary)
381+ . foregroundStyle ( speechManager . speakingTranslation ? . orange : . secondary)
328382 . frame ( width: 14 , height: 14 )
329383 }
330384 . buttonStyle ( . plain)
@@ -361,10 +415,10 @@ struct DialogView: View {
361415 . foregroundStyle ( . secondary)
362416 Spacer ( )
363417 HStack ( spacing: 12 ) {
364- Button ( action: { speakText ( text) } ) {
365- Image ( systemName: speakingOriginal ? " stop.fill " : " speaker.wave.2 " )
418+ Button ( action: { speechManager . speak ( text, isOriginal : true ) } ) {
419+ Image ( systemName: speechManager . speakingOriginal ? " stop.fill " : " speaker.wave.2 " )
366420 . font ( . system( size: 12 ) )
367- . foregroundStyle ( speakingOriginal ? . orange : . secondary)
421+ . foregroundStyle ( speechManager . speakingOriginal ? . orange : . secondary)
368422 . frame ( width: 14 , height: 14 )
369423 }
370424 . buttonStyle ( . plain)
@@ -467,44 +521,6 @@ struct DialogView: View {
467521 . frame ( minWidth: 360 , minHeight: 160 )
468522 }
469523
470- private func speakText( _ textToSpeak: String ) {
471- // Determine if this is original or translation text
472- let isOriginal : Bool
473- if let parts = bilingualParts {
474- isOriginal = textToSpeak == parts. original
475- } else {
476- isOriginal = true // Single text mode - always treat as original
477- }
478-
479- // If already speaking this text, stop it
480- if synthesizer. isSpeaking {
481- synthesizer. stopSpeaking ( )
482- speakingOriginal = false
483- speakingTranslation = false
484- return // Just stop, don't restart
485- }
486-
487- // Start speaking
488- if isOriginal {
489- speakingOriginal = true
490- } else {
491- speakingTranslation = true
492- }
493-
494- synthesizer. startSpeaking ( textToSpeak)
495-
496- // Monitor when speech finishes
497- DispatchQueue . global ( ) . async {
498- while synthesizer. isSpeaking {
499- Thread . sleep ( forTimeInterval: 0.1 )
500- }
501- DispatchQueue . main. async {
502- speakingOriginal = false
503- speakingTranslation = false
504- }
505- }
506- }
507-
508524 private func copyText( _ text: String , isTranslation: Bool = false ) {
509525 NSPasteboard . general. clearContents ( )
510526 NSPasteboard . general. setString ( text, forType: . string)
0 commit comments