|
|
@@ -7,6 +7,8 @@
|
|
7
|
7
|
|
|
8
|
8
|
import Cocoa
|
|
9
|
9
|
import QuartzCore
|
|
|
10
|
+import AVFoundation
|
|
|
11
|
+import AVKit
|
|
10
|
12
|
import WebKit
|
|
11
|
13
|
import AuthenticationServices
|
|
12
|
14
|
import StoreKit
|
|
|
@@ -267,6 +269,20 @@ final class ViewController: NSViewController {
|
|
267
|
269
|
private var zoomJoinModeViews: [ZoomJoinMode: NSView] = [:]
|
|
268
|
270
|
private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
|
|
269
|
271
|
private var aiCompanionAudioURLByView = [ObjectIdentifier: URL]()
|
|
|
272
|
+ private var aiCompanionAudioStatusLabelByView = [ObjectIdentifier: NSTextField]()
|
|
|
273
|
+ private var aiCompanionAudioPlayer: AVPlayer?
|
|
|
274
|
+ private var aiCompanionCurrentlyPlayingURL: URL?
|
|
|
275
|
+ private weak var aiCompanionCurrentlyPlayingButton: NSButton?
|
|
|
276
|
+ private var aiCompanionPlaybackEndObserver: NSObjectProtocol?
|
|
|
277
|
+ private var aiCompanionPlaybackFailedObserver: NSObjectProtocol?
|
|
|
278
|
+ private var aiCompanionTimeControlObserver: NSKeyValueObservation?
|
|
|
279
|
+ private var aiCompanionAudioRequestID = UUID()
|
|
|
280
|
+ private var aiCompanionNoProgressWorkItem: DispatchWorkItem?
|
|
|
281
|
+
|
|
|
282
|
+ private let aiCompanionSpeechSynthesizer = AVSpeechSynthesizer()
|
|
|
283
|
+ private var aiCompanionIsUsingSpeech = false
|
|
|
284
|
+ private var aiCompanionSpeechTextByView = [ObjectIdentifier: String]()
|
|
|
285
|
+ private var aiCompanionCurrentlySpeakingButtonId: ObjectIdentifier?
|
|
270
|
286
|
private var paywallWindow: NSWindow?
|
|
271
|
287
|
private weak var paywallOverlayView: NSView?
|
|
272
|
288
|
private let paywallContentWidth: CGFloat = 520
|
|
|
@@ -450,6 +466,7 @@ final class ViewController: NSViewController {
|
|
450
|
466
|
|
|
451
|
467
|
override func viewDidLoad() {
|
|
452
|
468
|
super.viewDidLoad()
|
|
|
469
|
+ aiCompanionSpeechSynthesizer.delegate = self
|
|
453
|
470
|
// Sync toggle + palette with current macOS appearance on launch.
|
|
454
|
471
|
darkModeEnabled = systemPrefersDarkMode()
|
|
455
|
472
|
palette = Palette(isDarkMode: darkModeEnabled)
|
|
|
@@ -1941,6 +1958,26 @@ private extension ViewController {
|
|
1941
|
1958
|
}
|
|
1942
|
1959
|
|
|
1943
|
1960
|
private func makeAiCompanionPageContent() -> NSView {
|
|
|
1961
|
+ // Reset per-card mappings so stale buttons/labels from previous page builds don't linger.
|
|
|
1962
|
+ aiCompanionAudioPlayer?.pause()
|
|
|
1963
|
+ aiCompanionAudioPlayer = nil
|
|
|
1964
|
+ aiCompanionCurrentlyPlayingURL = nil
|
|
|
1965
|
+ aiCompanionCurrentlyPlayingButton = nil
|
|
|
1966
|
+ aiCompanionTimeControlObserver = nil
|
|
|
1967
|
+ aiCompanionNoProgressWorkItem?.cancel()
|
|
|
1968
|
+ aiCompanionNoProgressWorkItem = nil
|
|
|
1969
|
+
|
|
|
1970
|
+ aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
|
|
|
1971
|
+ aiCompanionIsUsingSpeech = false
|
|
|
1972
|
+ aiCompanionCurrentlySpeakingButtonId = nil
|
|
|
1973
|
+ if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
|
|
|
1974
|
+ if let token = aiCompanionPlaybackFailedObserver { NotificationCenter.default.removeObserver(token) }
|
|
|
1975
|
+ aiCompanionPlaybackEndObserver = nil
|
|
|
1976
|
+ aiCompanionPlaybackFailedObserver = nil
|
|
|
1977
|
+ aiCompanionAudioURLByView.removeAll()
|
|
|
1978
|
+ aiCompanionAudioStatusLabelByView.removeAll()
|
|
|
1979
|
+ aiCompanionSpeechTextByView.removeAll()
|
|
|
1980
|
+
|
|
1944
|
1981
|
let panel = NSView()
|
|
1945
|
1982
|
panel.translatesAutoresizingMaskIntoConstraints = false
|
|
1946
|
1983
|
panel.userInterfaceLayoutDirection = .leftToRight
|
|
|
@@ -2060,16 +2097,30 @@ private extension ViewController {
|
|
2060
|
2097
|
if let audioURL = URL(string: audioLink) {
|
|
2061
|
2098
|
aiCompanionAudioURLByView[ObjectIdentifier(audioButton)] = audioURL
|
|
2062
|
2099
|
}
|
|
|
2100
|
+ let trimmedSubtitle = meeting.subtitle?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
2101
|
+ let speechText = {
|
|
|
2102
|
+ let base = "Ended meeting: \(meeting.title)."
|
|
|
2103
|
+ guard let trimmedSubtitle, trimmedSubtitle.isEmpty == false else { return base }
|
|
|
2104
|
+ return "\(base) \(trimmedSubtitle)."
|
|
|
2105
|
+ }()
|
|
|
2106
|
+ aiCompanionSpeechTextByView[ObjectIdentifier(audioButton)] = speechText
|
|
2063
|
2107
|
|
|
2064
|
2108
|
let audioURLLabel = textLabel(audioLink, font: typography.fieldLabel, color: palette.textMuted)
|
|
2065
|
2109
|
audioURLLabel.alignment = .left
|
|
2066
|
2110
|
audioURLLabel.maximumNumberOfLines = 2
|
|
2067
|
2111
|
audioURLLabel.lineBreakMode = .byTruncatingTail
|
|
2068
|
2112
|
|
|
|
2113
|
+ let audioStatusLabel = textLabel("Not playing", font: typography.fieldLabel, color: palette.textMuted)
|
|
|
2114
|
+ audioStatusLabel.alignment = .left
|
|
|
2115
|
+ audioStatusLabel.maximumNumberOfLines = 1
|
|
|
2116
|
+ audioStatusLabel.lineBreakMode = .byTruncatingTail
|
|
|
2117
|
+ aiCompanionAudioStatusLabelByView[ObjectIdentifier(audioButton)] = audioStatusLabel
|
|
|
2118
|
+
|
|
2069
|
2119
|
stack.addArrangedSubview(title)
|
|
2070
|
2120
|
stack.addArrangedSubview(dateLabel)
|
|
2071
|
2121
|
stack.addArrangedSubview(audioButton)
|
|
2072
|
2122
|
stack.addArrangedSubview(audioURLLabel)
|
|
|
2123
|
+ stack.addArrangedSubview(audioStatusLabel)
|
|
2073
|
2124
|
|
|
2074
|
2125
|
card.addSubview(stack)
|
|
2075
|
2126
|
NSLayoutConstraint.activate([
|
|
|
@@ -2083,8 +2134,268 @@ private extension ViewController {
|
|
2083
|
2134
|
}
|
|
2084
|
2135
|
|
|
2085
|
2136
|
@objc private func aiCompanionAudioTapped(_ sender: NSButton) {
|
|
2086
|
|
- guard let url = aiCompanionAudioURLByView[ObjectIdentifier(sender)] else { return }
|
|
2087
|
|
- NSWorkspace.shared.open(url)
|
|
|
2137
|
+ let senderId = ObjectIdentifier(sender)
|
|
|
2138
|
+ guard let url = aiCompanionAudioURLByView[senderId] else { return }
|
|
|
2139
|
+
|
|
|
2140
|
+ // Toggle play/pause if the same card is tapped.
|
|
|
2141
|
+ if aiCompanionCurrentlyPlayingURL == url {
|
|
|
2142
|
+ if aiCompanionIsUsingSpeech {
|
|
|
2143
|
+ if aiCompanionSpeechSynthesizer.isSpeaking {
|
|
|
2144
|
+ aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
|
|
|
2145
|
+ aiCompanionIsUsingSpeech = false
|
|
|
2146
|
+ sender.title = "Play Audio"
|
|
|
2147
|
+ aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Stopped"
|
|
|
2148
|
+ } else {
|
|
|
2149
|
+ aiCompanionStartSpeech(forButtonId: senderId)
|
|
|
2150
|
+ sender.title = "Pause Audio"
|
|
|
2151
|
+ aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Speaking..."
|
|
|
2152
|
+ }
|
|
|
2153
|
+ return
|
|
|
2154
|
+ } else if let player = aiCompanionAudioPlayer {
|
|
|
2155
|
+ if player.rate != 0 {
|
|
|
2156
|
+ player.pause()
|
|
|
2157
|
+ sender.title = "Play Audio"
|
|
|
2158
|
+ aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Paused"
|
|
|
2159
|
+ } else {
|
|
|
2160
|
+ player.play()
|
|
|
2161
|
+ sender.title = "Pause Audio"
|
|
|
2162
|
+ aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
|
|
|
2163
|
+ }
|
|
|
2164
|
+ return
|
|
|
2165
|
+ }
|
|
|
2166
|
+ }
|
|
|
2167
|
+
|
|
|
2168
|
+ // Stop any previous playback and remove observers.
|
|
|
2169
|
+ aiCompanionAudioPlayer?.pause()
|
|
|
2170
|
+ aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
|
|
|
2171
|
+ aiCompanionIsUsingSpeech = false
|
|
|
2172
|
+ aiCompanionNoProgressWorkItem?.cancel()
|
|
|
2173
|
+ aiCompanionNoProgressWorkItem = nil
|
|
|
2174
|
+ aiCompanionCurrentlySpeakingButtonId = nil
|
|
|
2175
|
+ aiCompanionTimeControlObserver = nil
|
|
|
2176
|
+ if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
|
|
|
2177
|
+ if let token = aiCompanionPlaybackFailedObserver { NotificationCenter.default.removeObserver(token) }
|
|
|
2178
|
+ aiCompanionPlaybackEndObserver = nil
|
|
|
2179
|
+ aiCompanionPlaybackFailedObserver = nil
|
|
|
2180
|
+
|
|
|
2181
|
+ // Revert previous playing UI (if any).
|
|
|
2182
|
+ if let previousButton = aiCompanionCurrentlyPlayingButton {
|
|
|
2183
|
+ let previousId = ObjectIdentifier(previousButton)
|
|
|
2184
|
+ previousButton.title = "Play Audio"
|
|
|
2185
|
+ aiCompanionAudioStatusLabelByView[previousId]?.stringValue = "Not playing"
|
|
|
2186
|
+ }
|
|
|
2187
|
+
|
|
|
2188
|
+ aiCompanionCurrentlyPlayingURL = url
|
|
|
2189
|
+ aiCompanionCurrentlyPlayingButton = sender
|
|
|
2190
|
+ sender.title = "Pause Audio"
|
|
|
2191
|
+ aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Checking audio..."
|
|
|
2192
|
+
|
|
|
2193
|
+ aiCompanionAudioRequestID = UUID()
|
|
|
2194
|
+ let requestID = aiCompanionAudioRequestID
|
|
|
2195
|
+
|
|
|
2196
|
+ let urlToCheck = url
|
|
|
2197
|
+ let senderButton = sender
|
|
|
2198
|
+
|
|
|
2199
|
+ var request = URLRequest(url: urlToCheck)
|
|
|
2200
|
+ request.setValue("bytes=0-0", forHTTPHeaderField: "Range") // lightweight probe
|
|
|
2201
|
+ request.timeoutInterval = 10
|
|
|
2202
|
+
|
|
|
2203
|
+ URLSession.shared.dataTask(with: request) { [weak self] _, response, error in
|
|
|
2204
|
+ guard let self else { return }
|
|
|
2205
|
+
|
|
|
2206
|
+ DispatchQueue.main.async {
|
|
|
2207
|
+ guard self.aiCompanionAudioRequestID == requestID else { return } // stale tap
|
|
|
2208
|
+
|
|
|
2209
|
+ if let error {
|
|
|
2210
|
+ senderButton.title = "Play Audio"
|
|
|
2211
|
+ self.aiCompanionAudioTimeControlObserverResetForFailure()
|
|
|
2212
|
+ self.aiCompanionStartSpeech(forButtonId: senderId)
|
|
|
2213
|
+ return
|
|
|
2214
|
+ }
|
|
|
2215
|
+
|
|
|
2216
|
+ guard let http = response as? HTTPURLResponse else {
|
|
|
2217
|
+ senderButton.title = "Play Audio"
|
|
|
2218
|
+ self.aiCompanionAudioTimeControlObserverResetForFailure()
|
|
|
2219
|
+ self.aiCompanionStartSpeech(forButtonId: senderId)
|
|
|
2220
|
+ return
|
|
|
2221
|
+ }
|
|
|
2222
|
+
|
|
|
2223
|
+ let mime = http.mimeType?.lowercased() ?? ""
|
|
|
2224
|
+ let okStatus = (200...299).contains(http.statusCode) || http.statusCode == 206
|
|
|
2225
|
+ guard okStatus else {
|
|
|
2226
|
+ senderButton.title = "Play Audio"
|
|
|
2227
|
+ self.aiCompanionAudioTimeControlObserverResetForFailure()
|
|
|
2228
|
+ self.aiCompanionStartSpeech(forButtonId: senderId)
|
|
|
2229
|
+ return
|
|
|
2230
|
+ }
|
|
|
2231
|
+
|
|
|
2232
|
+ if !mime.isEmpty && mime.hasPrefix("audio/") == false {
|
|
|
2233
|
+ senderButton.title = "Play Audio"
|
|
|
2234
|
+ self.aiCompanionAudioTimeControlObserverResetForFailure()
|
|
|
2235
|
+ self.aiCompanionStartSpeech(forButtonId: senderId)
|
|
|
2236
|
+ return
|
|
|
2237
|
+ }
|
|
|
2238
|
+
|
|
|
2239
|
+ self.aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Loading..."
|
|
|
2240
|
+
|
|
|
2241
|
+ let player = AVPlayer(url: urlToCheck)
|
|
|
2242
|
+ player.volume = 1.0
|
|
|
2243
|
+ self.aiCompanionAudioPlayer = player
|
|
|
2244
|
+
|
|
|
2245
|
+ if let item = player.currentItem {
|
|
|
2246
|
+ self.aiCompanionPlaybackEndObserver = NotificationCenter.default.addObserver(
|
|
|
2247
|
+ forName: .AVPlayerItemDidPlayToEndTime,
|
|
|
2248
|
+ object: item,
|
|
|
2249
|
+ queue: .main
|
|
|
2250
|
+ ) { [weak self] _ in
|
|
|
2251
|
+ self?.aiCompanionAudioDidFinish()
|
|
|
2252
|
+ }
|
|
|
2253
|
+
|
|
|
2254
|
+ self.aiCompanionPlaybackFailedObserver = NotificationCenter.default.addObserver(
|
|
|
2255
|
+ forName: .AVPlayerItemFailedToPlayToEndTime,
|
|
|
2256
|
+ object: item,
|
|
|
2257
|
+ queue: .main
|
|
|
2258
|
+ ) { [weak self] notification in
|
|
|
2259
|
+ self?.aiCompanionAudioDidFail(notification: notification)
|
|
|
2260
|
+ }
|
|
|
2261
|
+ }
|
|
|
2262
|
+
|
|
|
2263
|
+ // Update the UI only when playback actually starts (helps diagnose "playing but no audio").
|
|
|
2264
|
+ self.aiCompanionTimeControlObserver = player.observe(\.timeControlStatus, options: [.initial, .new]) { [weak self] p, _ in
|
|
|
2265
|
+ guard let self else { return }
|
|
|
2266
|
+ guard self.aiCompanionCurrentlyPlayingURL == urlToCheck else { return }
|
|
|
2267
|
+
|
|
|
2268
|
+ switch p.timeControlStatus {
|
|
|
2269
|
+ case .playing:
|
|
|
2270
|
+ self.aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
|
|
|
2271
|
+ case .waitingToPlayAtSpecifiedRate:
|
|
|
2272
|
+ self.aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Buffering..."
|
|
|
2273
|
+ case .paused:
|
|
|
2274
|
+ self.aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Paused"
|
|
|
2275
|
+ @unknown default:
|
|
|
2276
|
+ self.aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
|
|
|
2277
|
+ }
|
|
|
2278
|
+ }
|
|
|
2279
|
+
|
|
|
2280
|
+ // If the remote file is silent/unplayable, AVPlayer can still report "playing".
|
|
|
2281
|
+ // After a short grace period, fall back to text-to-speech so you can always hear something.
|
|
|
2282
|
+ let startSeconds = player.currentTime().seconds
|
|
|
2283
|
+ player.play()
|
|
|
2284
|
+ let playerRef = player
|
|
|
2285
|
+ let urlRef = urlToCheck
|
|
|
2286
|
+
|
|
|
2287
|
+ self.aiCompanionNoProgressWorkItem?.cancel()
|
|
|
2288
|
+ let workItem = DispatchWorkItem { [weak self] in
|
|
|
2289
|
+ guard let self else { return }
|
|
|
2290
|
+ guard self.aiCompanionAudioPlayer === playerRef else { return }
|
|
|
2291
|
+ guard self.aiCompanionCurrentlyPlayingURL == urlRef else { return }
|
|
|
2292
|
+ guard self.aiCompanionIsUsingSpeech == false else { return }
|
|
|
2293
|
+
|
|
|
2294
|
+ let nowSeconds = playerRef.currentTime().seconds
|
|
|
2295
|
+ let progressed = startSeconds.isFinite && nowSeconds.isFinite && (nowSeconds - startSeconds) > 0.5
|
|
|
2296
|
+ let actuallyPlaying = playerRef.timeControlStatus == .playing
|
|
|
2297
|
+
|
|
|
2298
|
+ if actuallyPlaying == false || progressed == false {
|
|
|
2299
|
+ self.aiCompanionStartSpeech(forButtonId: senderId)
|
|
|
2300
|
+ }
|
|
|
2301
|
+ }
|
|
|
2302
|
+ self.aiCompanionNoProgressWorkItem = workItem
|
|
|
2303
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: workItem)
|
|
|
2304
|
+ }
|
|
|
2305
|
+ }.resume()
|
|
|
2306
|
+ }
|
|
|
2307
|
+
|
|
|
2308
|
+ private func aiCompanionAudioTimeControlObserverResetForFailure() {
|
|
|
2309
|
+ aiCompanionAudioPlayer?.pause()
|
|
|
2310
|
+ aiCompanionAudioPlayer = nil
|
|
|
2311
|
+ aiCompanionTimeControlObserver = nil
|
|
|
2312
|
+
|
|
|
2313
|
+ if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
|
|
|
2314
|
+ if let token = aiCompanionPlaybackFailedObserver { NotificationCenter.default.removeObserver(token) }
|
|
|
2315
|
+ aiCompanionPlaybackEndObserver = nil
|
|
|
2316
|
+ aiCompanionPlaybackFailedObserver = nil
|
|
|
2317
|
+ }
|
|
|
2318
|
+
|
|
|
2319
|
+ private func aiCompanionStartSpeech(forButtonId buttonId: ObjectIdentifier) {
|
|
|
2320
|
+ // Stop any remote audio playback first.
|
|
|
2321
|
+ aiCompanionAudioPlayer?.pause()
|
|
|
2322
|
+ aiCompanionAudioPlayer = nil
|
|
|
2323
|
+ aiCompanionTimeControlObserver = nil
|
|
|
2324
|
+ if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
|
|
|
2325
|
+ if let token = aiCompanionPlaybackFailedObserver { NotificationCenter.default.removeObserver(token) }
|
|
|
2326
|
+ aiCompanionPlaybackEndObserver = nil
|
|
|
2327
|
+ aiCompanionPlaybackFailedObserver = nil
|
|
|
2328
|
+
|
|
|
2329
|
+ aiCompanionNoProgressWorkItem?.cancel()
|
|
|
2330
|
+ aiCompanionNoProgressWorkItem = nil
|
|
|
2331
|
+
|
|
|
2332
|
+ aiCompanionIsUsingSpeech = true
|
|
|
2333
|
+ aiCompanionCurrentlySpeakingButtonId = buttonId
|
|
|
2334
|
+
|
|
|
2335
|
+ if let button = aiCompanionCurrentlyPlayingButton, ObjectIdentifier(button) == buttonId {
|
|
|
2336
|
+ button.title = "Pause Audio"
|
|
|
2337
|
+ }
|
|
|
2338
|
+
|
|
|
2339
|
+ aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = "Speaking..."
|
|
|
2340
|
+
|
|
|
2341
|
+ let text = aiCompanionSpeechTextByView[buttonId] ?? "Ended meeting."
|
|
|
2342
|
+ let utterance = AVSpeechUtterance(string: text)
|
|
|
2343
|
+ utterance.rate = 0.5
|
|
|
2344
|
+ utterance.volume = 1.0
|
|
|
2345
|
+
|
|
|
2346
|
+ aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
|
|
|
2347
|
+ aiCompanionSpeechSynthesizer.speak(utterance)
|
|
|
2348
|
+ }
|
|
|
2349
|
+
|
|
|
2350
|
+ private func aiCompanionAudioDidFinish() {
|
|
|
2351
|
+ guard let button = aiCompanionCurrentlyPlayingButton else { return }
|
|
|
2352
|
+ let buttonId = ObjectIdentifier(button)
|
|
|
2353
|
+
|
|
|
2354
|
+ aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
|
|
|
2355
|
+ aiCompanionIsUsingSpeech = false
|
|
|
2356
|
+ aiCompanionCurrentlySpeakingButtonId = nil
|
|
|
2357
|
+
|
|
|
2358
|
+ button.title = "Play Audio"
|
|
|
2359
|
+ aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = "Finished"
|
|
|
2360
|
+
|
|
|
2361
|
+ aiCompanionAudioPlayer?.pause()
|
|
|
2362
|
+ aiCompanionAudioPlayer = nil
|
|
|
2363
|
+ aiCompanionCurrentlyPlayingURL = nil
|
|
|
2364
|
+ aiCompanionCurrentlyPlayingButton = nil
|
|
|
2365
|
+
|
|
|
2366
|
+ if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
|
|
|
2367
|
+ if let token = aiCompanionPlaybackFailedObserver { NotificationCenter.default.removeObserver(token) }
|
|
|
2368
|
+ aiCompanionPlaybackEndObserver = nil
|
|
|
2369
|
+ aiCompanionPlaybackFailedObserver = nil
|
|
|
2370
|
+ aiCompanionTimeControlObserver = nil
|
|
|
2371
|
+ }
|
|
|
2372
|
+
|
|
|
2373
|
+ private func aiCompanionAudioDidFail(notification: Notification) {
|
|
|
2374
|
+ guard let button = aiCompanionCurrentlyPlayingButton else { return }
|
|
|
2375
|
+ let buttonId = ObjectIdentifier(button)
|
|
|
2376
|
+
|
|
|
2377
|
+ aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
|
|
|
2378
|
+ aiCompanionIsUsingSpeech = false
|
|
|
2379
|
+ aiCompanionCurrentlySpeakingButtonId = nil
|
|
|
2380
|
+
|
|
|
2381
|
+ button.title = "Play Audio"
|
|
|
2382
|
+
|
|
|
2383
|
+ var message = "Failed to play audio"
|
|
|
2384
|
+ if let item = notification.object as? AVPlayerItem, let error = item.error {
|
|
|
2385
|
+ message = "Failed: \(error.localizedDescription)"
|
|
|
2386
|
+ }
|
|
|
2387
|
+ aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = message
|
|
|
2388
|
+
|
|
|
2389
|
+ aiCompanionAudioPlayer?.pause()
|
|
|
2390
|
+ aiCompanionAudioPlayer = nil
|
|
|
2391
|
+ aiCompanionCurrentlyPlayingURL = nil
|
|
|
2392
|
+ aiCompanionCurrentlyPlayingButton = nil
|
|
|
2393
|
+
|
|
|
2394
|
+ if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
|
|
|
2395
|
+ if let token = aiCompanionPlaybackFailedObserver { NotificationCenter.default.removeObserver(token) }
|
|
|
2396
|
+ aiCompanionPlaybackEndObserver = nil
|
|
|
2397
|
+ aiCompanionPlaybackFailedObserver = nil
|
|
|
2398
|
+ aiCompanionTimeControlObserver = nil
|
|
2088
|
2399
|
}
|
|
2089
|
2400
|
|
|
2090
|
2401
|
private func mockAudioURLString(for meeting: ScheduledMeeting) -> String {
|
|
|
@@ -4784,6 +5095,40 @@ extension ViewController: NSTextFieldDelegate {
|
|
4784
|
5095
|
}
|
|
4785
|
5096
|
}
|
|
4786
|
5097
|
|
|
|
5098
|
+extension ViewController: AVSpeechSynthesizerDelegate {
|
|
|
5099
|
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
|
|
5100
|
+ DispatchQueue.main.async { [weak self] in
|
|
|
5101
|
+ guard let self else { return }
|
|
|
5102
|
+ guard self.aiCompanionSpeechSynthesizer === synthesizer else { return }
|
|
|
5103
|
+ guard let buttonId = self.aiCompanionCurrentlySpeakingButtonId else { return }
|
|
|
5104
|
+
|
|
|
5105
|
+ self.aiCompanionIsUsingSpeech = false
|
|
|
5106
|
+ self.aiCompanionCurrentlySpeakingButtonId = nil
|
|
|
5107
|
+
|
|
|
5108
|
+ if let button = self.aiCompanionCurrentlyPlayingButton, ObjectIdentifier(button) == buttonId {
|
|
|
5109
|
+ button.title = "Play Audio"
|
|
|
5110
|
+ }
|
|
|
5111
|
+ self.aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = "Finished"
|
|
|
5112
|
+ }
|
|
|
5113
|
+ }
|
|
|
5114
|
+
|
|
|
5115
|
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
|
|
5116
|
+ DispatchQueue.main.async { [weak self] in
|
|
|
5117
|
+ guard let self else { return }
|
|
|
5118
|
+ guard self.aiCompanionSpeechSynthesizer === synthesizer else { return }
|
|
|
5119
|
+ guard let buttonId = self.aiCompanionCurrentlySpeakingButtonId else { return }
|
|
|
5120
|
+
|
|
|
5121
|
+ self.aiCompanionIsUsingSpeech = false
|
|
|
5122
|
+ self.aiCompanionCurrentlySpeakingButtonId = nil
|
|
|
5123
|
+
|
|
|
5124
|
+ if let button = self.aiCompanionCurrentlyPlayingButton, ObjectIdentifier(button) == buttonId {
|
|
|
5125
|
+ button.title = "Play Audio"
|
|
|
5126
|
+ }
|
|
|
5127
|
+ self.aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = "Stopped"
|
|
|
5128
|
+ }
|
|
|
5129
|
+ }
|
|
|
5130
|
+}
|
|
|
5131
|
+
|
|
4787
|
5132
|
extension ViewController: NSWindowDelegate {
|
|
4788
|
5133
|
func windowWillClose(_ notification: Notification) {
|
|
4789
|
5134
|
guard let closingWindow = notification.object as? NSWindow else { return }
|