浏览代码

Ai companion: improve ended-meeting audio playback

Use an in-app AVPlayer with better status handling, plus a text-to-speech fallback when the audio URL can't produce audible output.

Made-with: Cursor
huzaifahayat12 1 月之前
父节点
当前提交
83466e38b9
共有 1 个文件被更改,包括 347 次插入2 次删除
  1. 347 2
      meetings_app/ViewController.swift

+ 347 - 2
meetings_app/ViewController.swift

@@ -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 }