craft-native 0.0.166 → 0.0.168
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js
CHANGED
|
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3303
3303
|
`);
|
|
3304
3304
|
}
|
|
3305
3305
|
// package.json
|
|
3306
|
-
var version = "0.0.
|
|
3306
|
+
var version = "0.0.168";
|
|
3307
3307
|
|
|
3308
3308
|
// bin/cli.ts
|
|
3309
3309
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -1712,14 +1712,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1712
1712
|
// Keep awake
|
|
1713
1713
|
private var isKeepingAwake = false
|
|
1714
1714
|
|
|
1715
|
-
// Speech synthesis.
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1715
|
+
// Speech synthesis. Each utterance not finished yet is held beside the
|
|
1716
|
+
// call waiting on it. A cue that interrupts one still sounding speaks
|
|
1717
|
+
// on a fresh synthesizer: AVSpeechSynthesizer can drop an utterance
|
|
1718
|
+
// handed to it straight after `stopSpeaking(at: .immediate)`, which
|
|
1719
|
+
// lost the "2" of a "3, 2, 1" countdown. The stopped one is kept in
|
|
1720
|
+
// `retiredSpeechSynthesizers` until it reports the cancel.
|
|
1718
1721
|
private lazy var speechSynthesizer: AVSpeechSynthesizer = {
|
|
1719
1722
|
let synthesizer = AVSpeechSynthesizer()
|
|
1720
1723
|
synthesizer.delegate = self
|
|
1721
1724
|
return synthesizer
|
|
1722
1725
|
}()
|
|
1726
|
+
private var retiredSpeechSynthesizers: [AVSpeechSynthesizer] = []
|
|
1727
|
+
/// The session is handed back this long after the last word, so a
|
|
1728
|
+
/// countdown ducks the music once rather than between every number.
|
|
1729
|
+
private static let speechReleaseAfterSilence: TimeInterval = 1.5
|
|
1730
|
+
private var pendingSpeechRelease: DispatchWorkItem?
|
|
1723
1731
|
private var pendingUtterances: [(utterance: AVSpeechUtterance, callbackId: String?)] = []
|
|
1724
1732
|
/// What the session was before speech claimed it; nil while speech
|
|
1725
1733
|
/// does not hold it.
|
|
@@ -6715,8 +6723,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6715
6723
|
|
|
6716
6724
|
private func speak(_ text: String, body: [String: Any], callbackId: String?) {
|
|
6717
6725
|
DispatchQueue.main.async {
|
|
6718
|
-
|
|
6719
|
-
|
|
6726
|
+
self.pendingSpeechRelease?.cancel()
|
|
6727
|
+
self.pendingSpeechRelease = nil
|
|
6728
|
+
if body["interrupt"] as? Bool ?? true, self.cancelPendingSpeech() {
|
|
6729
|
+
self.retiredSpeechSynthesizers.append(self.speechSynthesizer)
|
|
6730
|
+
let fresh = AVSpeechSynthesizer()
|
|
6731
|
+
fresh.delegate = self
|
|
6732
|
+
self.speechSynthesizer = fresh
|
|
6720
6733
|
}
|
|
6721
6734
|
|
|
6722
6735
|
let utterance = AVSpeechUtterance(string: text)
|
|
@@ -6818,8 +6831,20 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6818
6831
|
/// Checked on the main thread, where the queue of utterances lives, and
|
|
6819
6832
|
/// done on the speech queue after any setup already asked for, so a
|
|
6820
6833
|
/// cue that arrives meanwhile reclaims the session after this.
|
|
6821
|
-
private func releaseAudioSessionIfSilent(retry: Bool = true) {
|
|
6834
|
+
private func releaseAudioSessionIfSilent(retry: Bool = true, after delay: TimeInterval = Coordinator.speechReleaseAfterSilence) {
|
|
6822
6835
|
guard pendingUtterances.isEmpty else { return }
|
|
6836
|
+
pendingSpeechRelease?.cancel()
|
|
6837
|
+
let work = DispatchWorkItem { [weak self] in
|
|
6838
|
+
guard let self else { return }
|
|
6839
|
+
self.pendingSpeechRelease = nil
|
|
6840
|
+
guard self.pendingUtterances.isEmpty, !self.speechSynthesizer.isSpeaking else { return }
|
|
6841
|
+
self.deactivateSpeechAudioSession(retry: retry)
|
|
6842
|
+
}
|
|
6843
|
+
pendingSpeechRelease = work
|
|
6844
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
|
6845
|
+
}
|
|
6846
|
+
|
|
6847
|
+
private func deactivateSpeechAudioSession(retry: Bool) {
|
|
6823
6848
|
speechAudioQueue.async {
|
|
6824
6849
|
guard let previous = self.speechAudioSessionToRestore else { return }
|
|
6825
6850
|
let session = AVAudioSession.sharedInstance()
|
|
@@ -6832,8 +6857,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6832
6857
|
// right, and the ducking still ends when the music app
|
|
6833
6858
|
// hears nothing more.
|
|
6834
6859
|
if retry {
|
|
6835
|
-
DispatchQueue.main.
|
|
6836
|
-
self.releaseAudioSessionIfSilent(retry: false)
|
|
6860
|
+
DispatchQueue.main.async {
|
|
6861
|
+
self.releaseAudioSessionIfSilent(retry: false, after: 0.5)
|
|
6837
6862
|
}
|
|
6838
6863
|
return
|
|
6839
6864
|
}
|
|
@@ -6844,11 +6869,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6844
6869
|
}
|
|
6845
6870
|
}
|
|
6846
6871
|
|
|
6847
|
-
fileprivate func speechDidEnd(_ utterance: AVSpeechUtterance, spoken: Bool) {
|
|
6872
|
+
fileprivate func speechDidEnd(_ synthesizer: AVSpeechSynthesizer, _ utterance: AVSpeechUtterance, spoken: Bool) {
|
|
6848
6873
|
if let index = pendingUtterances.firstIndex(where: { $0.utterance === utterance }) {
|
|
6849
6874
|
let entry = pendingUtterances.remove(at: index)
|
|
6850
6875
|
resolveCallback(entry.callbackId, result: spoken)
|
|
6851
6876
|
}
|
|
6877
|
+
if synthesizer !== speechSynthesizer, !synthesizer.isSpeaking {
|
|
6878
|
+
retiredSpeechSynthesizers.removeAll { $0 === synthesizer }
|
|
6879
|
+
}
|
|
6852
6880
|
releaseAudioSessionIfSilent()
|
|
6853
6881
|
}
|
|
6854
6882
|
|
|
@@ -8480,11 +8508,11 @@ extension CraftWebView.Coordinator: WKUIDelegate {
|
|
|
8480
8508
|
|
|
8481
8509
|
extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
|
|
8482
8510
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
|
8483
|
-
DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }
|
|
8511
|
+
DispatchQueue.main.async { self.speechDidEnd(synthesizer, utterance, spoken: true) }
|
|
8484
8512
|
}
|
|
8485
8513
|
|
|
8486
8514
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
|
8487
|
-
DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: false) }
|
|
8515
|
+
DispatchQueue.main.async { self.speechDidEnd(synthesizer, utterance, spoken: false) }
|
|
8488
8516
|
}
|
|
8489
8517
|
}
|
|
8490
8518
|
|
|
@@ -2694,7 +2694,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2694
2694
|
}
|
|
2695
2695
|
|
|
2696
2696
|
private func refreshTraitDefaults() {
|
|
2697
|
-
view.backgroundColor = config.resolvedBackgroundColor
|
|
2697
|
+
view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
|
|
2698
2698
|
if let document = mutationDocument.node("root") {
|
|
2699
2699
|
renderCommitted(document)
|
|
2700
2700
|
} else {
|
|
@@ -2895,8 +2895,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2895
2895
|
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
2896
2896
|
/// `craft.secureStorage.set` wrote.
|
|
2897
2897
|
/// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
|
|
2898
|
-
/// tabBarHidden, contentSwipeBack, tabSwipeEnabled, rightButtons })`;
|
|
2899
|
-
/// tap is the event `navButton`.
|
|
2898
|
+
/// tabBarHidden, contentSwipeBack, tabSwipeEnabled, backgroundColor, rightButtons })`;
|
|
2899
|
+
/// a right button's tap is the event `navButton`.
|
|
2900
2900
|
///
|
|
2901
2901
|
/// A runtime that assigns `craft.storage` (or the others) afterwards keeps
|
|
2902
2902
|
/// these: each namespace is a property whose setter adds them back to the
|
|
@@ -3148,6 +3148,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3148
3148
|
/// own horizontal pager or chart gesture.
|
|
3149
3149
|
private(set) var tabSwipeEnabled = true
|
|
3150
3150
|
private var prefersLargeTitle: Bool?
|
|
3151
|
+
/// The screen's own colour (`backgroundColor`), under the status bar and
|
|
3152
|
+
/// the home indicator too, where its content never reaches: a black
|
|
3153
|
+
/// full-screen timer is black edge to edge. nil is the app's.
|
|
3154
|
+
private var screenBackgroundColor: UIColor?
|
|
3151
3155
|
|
|
3152
3156
|
/// Whether this screen asked for a bar of its own through `setOptions`: a
|
|
3153
3157
|
/// title, a large title or buttons, and not `hidden`. A tab's root screen
|
|
@@ -3188,6 +3192,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3188
3192
|
if let tabSwipeEnabled = options["tabSwipeEnabled"] as? Bool {
|
|
3189
3193
|
self.tabSwipeEnabled = tabSwipeEnabled
|
|
3190
3194
|
}
|
|
3195
|
+
if options.keys.contains("backgroundColor") {
|
|
3196
|
+
screenBackgroundColor = color(options["backgroundColor"])
|
|
3197
|
+
view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
|
|
3198
|
+
}
|
|
3191
3199
|
if let hidden = options["hidden"] as? Bool {
|
|
3192
3200
|
navigationBarHidden = hidden
|
|
3193
3201
|
if navigationController?.topViewController === self {
|
|
@@ -3345,12 +3353,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3345
3353
|
}
|
|
3346
3354
|
return nil
|
|
3347
3355
|
}
|
|
3356
|
+
/// The scroll view the screen starts with, when nothing sits above it:
|
|
3357
|
+
/// the root's first child, its first, and so on, none padded on top.
|
|
3358
|
+
func leading(_ node: RenderedNode) -> UIScrollView? {
|
|
3359
|
+
if let scroll = node.view as? CraftNativeScrollView { return scroll.isVertical && scroll.isScrollEnabled ? scroll : nil }
|
|
3360
|
+
if let list = node.view as? CraftNativeFlatList {
|
|
3361
|
+
return list.isScrollEnabled && (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal ? list : nil
|
|
3362
|
+
}
|
|
3363
|
+
guard paddingInsets(node.style).top == 0,
|
|
3364
|
+
let first = node.children.first(where: { $0.type != "Modal" && !$0.view.isHidden }) else { return nil }
|
|
3365
|
+
return leading(first)
|
|
3366
|
+
}
|
|
3348
3367
|
let scroll = find(renderedRoot)
|
|
3349
3368
|
// Under a large title the screen runs edge to edge, as UIKit's own
|
|
3350
3369
|
// screens do: the scroll view starts under the bar and insets its
|
|
3351
3370
|
// content by it, so the title collapses and pull to refresh draws
|
|
3352
|
-
// in the bar's space.
|
|
3353
|
-
|
|
3371
|
+
// in the bar's space. So does a screen that starts with its scroll
|
|
3372
|
+
// view: its content scrolls up under the bar behind iOS's scroll edge
|
|
3373
|
+
// effect, rather than over the bar's back button. Other screens stay
|
|
3374
|
+
// inside the safe area.
|
|
3375
|
+
let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leading(renderedRoot))
|
|
3354
3376
|
if rootTopToView?.isActive != edgeToEdge {
|
|
3355
3377
|
rootTopToSafeArea?.isActive = !edgeToEdge
|
|
3356
3378
|
rootTopToView?.isActive = edgeToEdge
|
|
@@ -4924,6 +4946,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4924
4946
|
)
|
|
4925
4947
|
// A label paints its background inside its own bounds, so a rounded
|
|
4926
4948
|
// chip of text clips to its corners.
|
|
4949
|
+
// A scroll view does not clip either: its content runs on under the
|
|
4950
|
+
// floating tab bar and the home indicator, as iOS 26's own screens'
|
|
4951
|
+
// does. At the top, a screen that starts with one runs edge to edge
|
|
4952
|
+
// (trackContentScrollView), so nothing scrolls over the bar.
|
|
4927
4953
|
view.clipsToBounds = style["overflow"] as? String == "hidden"
|
|
4928
4954
|
|| (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
|
|
4929
4955
|
view.layer.masksToBounds = view.clipsToBounds
|
|
@@ -13,12 +13,32 @@ import UserNotifications
|
|
|
13
13
|
/// `craft.speech` on a native screen: AVSpeechSynthesizer, set up the way the
|
|
14
14
|
/// page's own speech is (CraftApp.swift). The music ducks rather than stops,
|
|
15
15
|
/// the silent switch does not mute a cue, and the session is handed back once
|
|
16
|
-
/// nothing
|
|
16
|
+
/// nothing has been said for a moment.
|
|
17
|
+
///
|
|
18
|
+
/// Three things a workout's countdown ("3", "2", "1", a second apart) needs:
|
|
19
|
+
/// - An interrupting cue is never lost. AVSpeechSynthesizer can drop an
|
|
20
|
+
/// utterance handed to it straight after `stopSpeaking(at: .immediate)`,
|
|
21
|
+
/// so a cue that cuts another off speaks on a fresh synthesizer while the
|
|
22
|
+
/// stopped one winds down.
|
|
23
|
+
/// - The session stays claimed for `releaseAfterSilence` after the last word,
|
|
24
|
+
/// so the music ducks once for a countdown instead of swelling back up
|
|
25
|
+
/// between every number, and the next number does not wait on the audio
|
|
26
|
+
/// hardware again.
|
|
27
|
+
/// - Activating and deactivating the session blocks until the hardware
|
|
28
|
+
/// answers, so it happens on a queue of its own, never the main thread the
|
|
29
|
+
/// screens' JavaScript and their timers run on.
|
|
17
30
|
final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
|
|
18
31
|
static let shared = CraftNativeSpeech()
|
|
32
|
+
/// How long the session is kept after the last word.
|
|
33
|
+
static let releaseAfterSilence: TimeInterval = 1.5
|
|
19
34
|
|
|
20
|
-
private
|
|
35
|
+
private var synthesizer = AVSpeechSynthesizer()
|
|
36
|
+
/// Synthesizers stopped by an interruption, kept until they report it.
|
|
37
|
+
private var retired: [AVSpeechSynthesizer] = []
|
|
21
38
|
private var pending: [(utterance: AVSpeechUtterance, done: (Bool) -> Void)] = []
|
|
39
|
+
private var pendingRelease: DispatchWorkItem?
|
|
40
|
+
/// The audio session's work, in order. `sessionToRestore` is only touched here.
|
|
41
|
+
private let sessionQueue = DispatchQueue(label: "dev.craft.native.speech-session")
|
|
22
42
|
private var sessionToRestore: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?
|
|
23
43
|
|
|
24
44
|
private override init() {
|
|
@@ -27,9 +47,12 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
|
|
|
27
47
|
}
|
|
28
48
|
|
|
29
49
|
/// Says `text`; `done` hears true once it was spoken, false when it was
|
|
30
|
-
/// stopped or interrupted.
|
|
50
|
+
/// stopped or interrupted. With `interrupt` false it waits its turn behind
|
|
51
|
+
/// what is being said. Main thread.
|
|
31
52
|
func speak(_ text: String, language: String?, rate: Double?, interrupt: Bool, done: @escaping (Bool) -> Void) {
|
|
32
|
-
|
|
53
|
+
pendingRelease?.cancel()
|
|
54
|
+
pendingRelease = nil
|
|
55
|
+
if interrupt, cancelPending() { retireSynthesizer() }
|
|
33
56
|
let utterance = AVSpeechUtterance(string: text)
|
|
34
57
|
utterance.rate = Self.utteranceRate(rate ?? 1)
|
|
35
58
|
// An unknown language finds no voice, and a nil voice is the device's own.
|
|
@@ -37,8 +60,14 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
|
|
|
37
60
|
utterance.voice = voice
|
|
38
61
|
}
|
|
39
62
|
pending.append((utterance, done))
|
|
40
|
-
|
|
41
|
-
|
|
63
|
+
sessionQueue.async {
|
|
64
|
+
self.claimSession()
|
|
65
|
+
DispatchQueue.main.async {
|
|
66
|
+
// Stopped while the session was being set up: say nothing.
|
|
67
|
+
guard self.pending.contains(where: { $0.utterance === utterance }) else { return }
|
|
68
|
+
self.synthesizer.speak(utterance)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
42
71
|
}
|
|
43
72
|
|
|
44
73
|
func stop() {
|
|
@@ -58,6 +87,14 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
|
|
|
58
87
|
return sounding
|
|
59
88
|
}
|
|
60
89
|
|
|
90
|
+
/// The stopped synthesizer winds down on its own; the next cue gets a new one.
|
|
91
|
+
private func retireSynthesizer() {
|
|
92
|
+
retired.append(synthesizer)
|
|
93
|
+
let fresh = AVSpeechSynthesizer()
|
|
94
|
+
fresh.delegate = self
|
|
95
|
+
synthesizer = fresh
|
|
96
|
+
}
|
|
97
|
+
|
|
61
98
|
/// The page's rate, where 1 is normal, on AVSpeechUtterance's scale: the
|
|
62
99
|
/// same mapping as the page's speech, so a cue sounds alike in both.
|
|
63
100
|
static func utteranceRate(_ rate: Double) -> Float {
|
|
@@ -69,6 +106,7 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
|
|
|
69
106
|
return min(max(mapped, AVSpeechUtteranceMinimumSpeechRate), AVSpeechUtteranceMaximumSpeechRate)
|
|
70
107
|
}
|
|
71
108
|
|
|
109
|
+
/// Session queue.
|
|
72
110
|
private func claimSession() {
|
|
73
111
|
guard sessionToRestore == nil else { return }
|
|
74
112
|
let session = AVAudioSession.sharedInstance()
|
|
@@ -83,37 +121,52 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
|
|
|
83
121
|
}
|
|
84
122
|
}
|
|
85
123
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
124
|
+
/// Hand the session back once nothing has been said for a moment. Main thread.
|
|
125
|
+
private func releaseSessionIfSilent(retry: Bool = true, after delay: TimeInterval = CraftNativeSpeech.releaseAfterSilence) {
|
|
126
|
+
guard pending.isEmpty else { return }
|
|
127
|
+
pendingRelease?.cancel()
|
|
128
|
+
let work = DispatchWorkItem { [weak self] in
|
|
129
|
+
guard let self else { return }
|
|
130
|
+
self.pendingRelease = nil
|
|
131
|
+
guard self.pending.isEmpty, !self.synthesizer.isSpeaking else { return }
|
|
132
|
+
self.sessionQueue.async {
|
|
133
|
+
guard let previous = self.sessionToRestore else { return }
|
|
134
|
+
let session = AVAudioSession.sharedInstance()
|
|
135
|
+
do {
|
|
136
|
+
try session.setActive(false, options: .notifyOthersOnDeactivation)
|
|
137
|
+
} catch {
|
|
138
|
+
// Busy: the last of the audio has not drained, or the app
|
|
139
|
+
// plays something of its own. Once more shortly covers the first.
|
|
140
|
+
if retry {
|
|
141
|
+
DispatchQueue.main.async { self.releaseSessionIfSilent(retry: false, after: 0.5) }
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
self.sessionToRestore = nil
|
|
146
|
+
try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
|
|
147
|
+
DispatchQueue.main.async { CraftNativeSounds.reclaimSessionIfPlaying() }
|
|
97
148
|
}
|
|
98
149
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
CraftNativeSounds.reclaimSessionIfPlaying()
|
|
150
|
+
pendingRelease = work
|
|
151
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
|
102
152
|
}
|
|
103
153
|
|
|
104
|
-
private func finished(_ utterance: AVSpeechUtterance, spoken: Bool) {
|
|
154
|
+
private func finished(_ synthesizer: AVSpeechSynthesizer, _ utterance: AVSpeechUtterance, spoken: Bool) {
|
|
105
155
|
if let index = pending.firstIndex(where: { $0.utterance === utterance }) {
|
|
106
156
|
pending.remove(at: index).done(spoken)
|
|
107
157
|
}
|
|
158
|
+
if synthesizer !== self.synthesizer, !synthesizer.isSpeaking {
|
|
159
|
+
retired.removeAll { $0 === synthesizer }
|
|
160
|
+
}
|
|
108
161
|
releaseSessionIfSilent()
|
|
109
162
|
}
|
|
110
163
|
|
|
111
164
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
|
112
|
-
DispatchQueue.main.async { self.finished(utterance, spoken: true) }
|
|
165
|
+
DispatchQueue.main.async { self.finished(synthesizer, utterance, spoken: true) }
|
|
113
166
|
}
|
|
114
167
|
|
|
115
168
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
|
116
|
-
DispatchQueue.main.async { self.finished(utterance, spoken: false) }
|
|
169
|
+
DispatchQueue.main.async { self.finished(synthesizer, utterance, spoken: false) }
|
|
117
170
|
}
|
|
118
171
|
}
|
|
119
172
|
|