craft-native 0.0.181 → 0.0.183
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/android/templates/AndroidManifest.xml.template +3 -1
- package/dist/android/templates/CraftNativeServices.kt.template +41 -3
- package/dist/android/templates/MainActivityNative.kt.template +221 -3
- package/dist/api/mobile.d.ts +7 -0
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +10 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +10 -23
- package/dist/ios/templates/CraftHybrid.swift +6 -2
- package/dist/ios/templates/CraftNativeActions.swift +3 -1
- package/dist/ios/templates/CraftNativeScreen.swift +403 -9
- package/dist/ios/templates/CraftNativeServices.swift +137 -20
- package/dist/ios/templates/CraftNativeUI.swift +2 -1
- package/dist/mobile.js +1 -0
- package/package.json +1 -1
|
@@ -48,18 +48,16 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
|
|
|
48
48
|
|
|
49
49
|
/// Says `text`; `done` hears true once it was spoken, false when it was
|
|
50
50
|
/// stopped or interrupted. With `interrupt` false it waits its turn behind
|
|
51
|
-
/// what is being said.
|
|
52
|
-
|
|
51
|
+
/// what is being said. `pitch` is 0.5 to 2 (1 is the voice's own), and
|
|
52
|
+
/// `preDelay` is seconds of quiet before the words, after whatever was
|
|
53
|
+
/// said before them. Main thread.
|
|
54
|
+
func speak(_ text: String, language: String?, rate: Double?, pitch: Double? = nil, preDelay: Double? = nil, interrupt: Bool, done: @escaping (Bool) -> Void) {
|
|
53
55
|
pendingRelease?.cancel()
|
|
54
56
|
pendingRelease = nil
|
|
55
57
|
if interrupt, cancelPending() { retireSynthesizer() }
|
|
56
|
-
let utterance =
|
|
57
|
-
utterance.rate = Self.utteranceRate(rate ?? 1)
|
|
58
|
-
// An unknown language finds no voice, and a nil voice is the device's own.
|
|
59
|
-
if let language, !language.isEmpty, let voice = AVSpeechSynthesisVoice(language: language) {
|
|
60
|
-
utterance.voice = voice
|
|
61
|
-
}
|
|
58
|
+
let utterance = Self.utterance(text, language: language, rate: rate, pitch: pitch, preDelay: preDelay)
|
|
62
59
|
pending.append((utterance, done))
|
|
60
|
+
holdsSession = true
|
|
63
61
|
sessionQueue.async {
|
|
64
62
|
self.claimSession()
|
|
65
63
|
DispatchQueue.main.async {
|
|
@@ -70,6 +68,74 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
|
|
|
70
68
|
}
|
|
71
69
|
}
|
|
72
70
|
|
|
71
|
+
/// Whether speech has the audio session, or is about to: a sound or a
|
|
72
|
+
/// video starting meanwhile leaves the category to speech, which hands it
|
|
73
|
+
/// over when it lets go (`CraftNativeSounds.reclaimSessionIfPlaying`).
|
|
74
|
+
/// Main thread.
|
|
75
|
+
private(set) var holdsSession = false
|
|
76
|
+
|
|
77
|
+
/// One utterance, set up the same way for a native screen and the page
|
|
78
|
+
/// (CraftApp.swift): the rate on AVSpeechUtterance's scale, the pitch,
|
|
79
|
+
/// the quiet before it, and the best voice installed for the language.
|
|
80
|
+
static func utterance(_ text: String, language: String?, rate: Double?, pitch: Double?, preDelay: Double?) -> AVSpeechUtterance {
|
|
81
|
+
let utterance = AVSpeechUtterance(string: text)
|
|
82
|
+
utterance.rate = utteranceRate(rate ?? 1)
|
|
83
|
+
let wantedPitch = pitch.flatMap { $0.isFinite ? $0 : nil } ?? 1
|
|
84
|
+
utterance.pitchMultiplier = Float(min(max(wantedPitch, 0.5), 2))
|
|
85
|
+
let quiet = preDelay.flatMap { $0.isFinite ? $0 : nil } ?? 0
|
|
86
|
+
utterance.preUtteranceDelay = min(max(quiet, 0), 5)
|
|
87
|
+
// An unknown language finds no voice, and a nil voice is the device's own.
|
|
88
|
+
if let voice = bestVoice(for: language) { utterance.voice = voice }
|
|
89
|
+
return utterance
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private static var voices: [String: AVSpeechSynthesisVoice] = [:]
|
|
93
|
+
private static var voicesObserver: NSObjectProtocol?
|
|
94
|
+
|
|
95
|
+
/// The best voice on this phone for `language` (the device's own when
|
|
96
|
+
/// nil): a premium voice when one is installed, else an enhanced one,
|
|
97
|
+
/// else the compact voice iOS would pick. Among voices of one quality the
|
|
98
|
+
/// one sharing the default voice's name wins ("Anna (Enhanced)" over
|
|
99
|
+
/// another enhanced voice when Anna is the default), so a download in
|
|
100
|
+
/// Settings changes how a voice sounds, not who it is. Novelty voices and
|
|
101
|
+
/// the person's own Personal Voice are never picked. Remembered per
|
|
102
|
+
/// language until the installed voices change. Main thread.
|
|
103
|
+
static func bestVoice(for language: String?) -> AVSpeechSynthesisVoice? {
|
|
104
|
+
let wanted = (language?.isEmpty == false ? language! : AVSpeechSynthesisVoice.currentLanguageCode())
|
|
105
|
+
if let known = voices[wanted] { return known }
|
|
106
|
+
if voicesObserver == nil {
|
|
107
|
+
if #available(iOS 17.0, *) {
|
|
108
|
+
voicesObserver = NotificationCenter.default.addObserver(forName: AVSpeechSynthesizer.availableVoicesDidChangeNotification, object: nil, queue: .main) { _ in
|
|
109
|
+
CraftNativeSpeech.voices.removeAll()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
guard let standard = AVSpeechSynthesisVoice(language: wanted) else { return nil }
|
|
114
|
+
let name = Self.baseName(standard.name)
|
|
115
|
+
let candidates = AVSpeechSynthesisVoice.speechVoices().filter { voice in
|
|
116
|
+
guard voice.language == standard.language else { return false }
|
|
117
|
+
if #available(iOS 17.0, *) {
|
|
118
|
+
if voice.voiceTraits.contains(.isNoveltyVoice) || voice.voiceTraits.contains(.isPersonalVoice) { return false }
|
|
119
|
+
}
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
let best = candidates.max { lhs, rhs in
|
|
123
|
+
let left = (lhs.quality.rawValue, Self.baseName(lhs.name) == name ? 1 : 0, lhs.identifier == standard.identifier ? 1 : 0)
|
|
124
|
+
let right = (rhs.quality.rawValue, Self.baseName(rhs.name) == name ? 1 : 0, rhs.identifier == standard.identifier ? 1 : 0)
|
|
125
|
+
if left != right { return left < right }
|
|
126
|
+
// Stable between launches: the same voice every time.
|
|
127
|
+
return lhs.identifier > rhs.identifier
|
|
128
|
+
} ?? standard
|
|
129
|
+
let chosen = best.quality.rawValue >= standard.quality.rawValue ? best : standard
|
|
130
|
+
voices[wanted] = chosen
|
|
131
|
+
return chosen
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// "Anna (Enhanced)" and "Anna (Premium)" are both Anna.
|
|
135
|
+
private static func baseName(_ name: String) -> String {
|
|
136
|
+
name.components(separatedBy: " (").first ?? name
|
|
137
|
+
}
|
|
138
|
+
|
|
73
139
|
func stop() {
|
|
74
140
|
// When something was sounding, its didCancel hands the session back
|
|
75
141
|
// once the audio has stopped; deactivating before that fails as busy.
|
|
@@ -95,8 +161,13 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
|
|
|
95
161
|
synthesizer = fresh
|
|
96
162
|
}
|
|
97
163
|
|
|
98
|
-
/// The page's rate, where 1 is normal, on AVSpeechUtterance's scale
|
|
99
|
-
///
|
|
164
|
+
/// The page's rate, where 1 is normal, on AVSpeechUtterance's scale, for
|
|
165
|
+
/// a native screen and the page alike.
|
|
166
|
+
///
|
|
167
|
+
/// Apple's scale is not linear in speed: the default sits at 0.5 and the
|
|
168
|
+
/// maximum is far faster than twice that. So a slower rate scales the
|
|
169
|
+
/// default down in proportion, and a faster one climbs toward the maximum
|
|
170
|
+
/// at half the slope, which keeps 2 brisk but intelligible.
|
|
100
171
|
static func utteranceRate(_ rate: Double) -> Float {
|
|
101
172
|
let requested = Float(min(max(rate.isFinite ? rate : 1, 0.5), 2))
|
|
102
173
|
let normal = AVSpeechUtteranceDefaultSpeechRate
|
|
@@ -129,11 +200,25 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
|
|
|
129
200
|
guard let self else { return }
|
|
130
201
|
self.pendingRelease = nil
|
|
131
202
|
guard self.pending.isEmpty, !self.synthesizer.isSpeaking else { return }
|
|
203
|
+
let soundsPlaying = CraftNativeSounds.isPlaying
|
|
132
204
|
self.sessionQueue.async {
|
|
133
|
-
guard let previous = self.sessionToRestore else {
|
|
205
|
+
guard let previous = self.sessionToRestore else {
|
|
206
|
+
// The claim failed: there is nothing to hand back.
|
|
207
|
+
DispatchQueue.main.async {
|
|
208
|
+
guard self.pending.isEmpty else { return }
|
|
209
|
+
self.holdsSession = false
|
|
210
|
+
CraftNativeSounds.reclaimSessionIfPlaying(handedBack: nil)
|
|
211
|
+
}
|
|
212
|
+
return
|
|
213
|
+
}
|
|
134
214
|
let session = AVAudioSession.sharedInstance()
|
|
135
215
|
do {
|
|
136
|
-
|
|
216
|
+
// Deactivating ends the ducking. A video or a sound still
|
|
217
|
+
// playing keeps it busy; the category put back below
|
|
218
|
+
// carries no ducking, so the music comes back up anyway.
|
|
219
|
+
if !soundsPlaying {
|
|
220
|
+
try session.setActive(false, options: .notifyOthersOnDeactivation)
|
|
221
|
+
}
|
|
137
222
|
} catch {
|
|
138
223
|
// Busy: the last of the audio has not drained, or the app
|
|
139
224
|
// plays something of its own. Once more shortly covers the first.
|
|
@@ -144,7 +229,11 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
|
|
|
144
229
|
}
|
|
145
230
|
self.sessionToRestore = nil
|
|
146
231
|
try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
|
|
147
|
-
DispatchQueue.main.async {
|
|
232
|
+
DispatchQueue.main.async {
|
|
233
|
+
// A cue that came in meanwhile claims the session again.
|
|
234
|
+
if self.pending.isEmpty { self.holdsSession = false }
|
|
235
|
+
CraftNativeSounds.reclaimSessionIfPlaying(handedBack: previous)
|
|
236
|
+
}
|
|
148
237
|
}
|
|
149
238
|
}
|
|
150
239
|
pendingRelease = work
|
|
@@ -337,12 +426,26 @@ final class CraftNativeSounds {
|
|
|
337
426
|
RunLoop.main.add(timer, forMode: .common)
|
|
338
427
|
}
|
|
339
428
|
|
|
429
|
+
/// Whether a sound, a native screen's Web Audio or a video is playing.
|
|
430
|
+
static var isPlaying: Bool { playing > 0 }
|
|
431
|
+
/// A sound started while speech held the session: speech's category was
|
|
432
|
+
/// left alone (it plays too, ducking the music under the cue), and the
|
|
433
|
+
/// category to put back later is the one speech hands back.
|
|
434
|
+
private static var claimedUnderSpeech = false
|
|
435
|
+
|
|
340
436
|
/// Takes the session for one more playing sound. Native screens' Web
|
|
341
|
-
/// Audio (CraftWebAudio.swift)
|
|
342
|
-
/// session back to whichever of them still
|
|
437
|
+
/// Audio (CraftWebAudio.swift) and videos (CraftNativeScreen.swift) count
|
|
438
|
+
/// here too, so speech hands the session back to whichever of them still
|
|
439
|
+
/// plays. Main thread.
|
|
343
440
|
static func claimSession(respectSilentSwitch: Bool) {
|
|
344
441
|
playing += 1
|
|
345
|
-
|
|
442
|
+
// Playback outranks ambient: a video heard through the silent switch
|
|
443
|
+
// is not muted by a breathing exercise's rain starting after it.
|
|
444
|
+
category = respectSilentSwitch && (playing == 1 || category == .ambient) ? .ambient : .playback
|
|
445
|
+
if CraftNativeSpeech.shared.holdsSession {
|
|
446
|
+
if sessionToRestore == nil { claimedUnderSpeech = true }
|
|
447
|
+
return
|
|
448
|
+
}
|
|
346
449
|
let session = AVAudioSession.sharedInstance()
|
|
347
450
|
if sessionToRestore == nil { sessionToRestore = (session.category, session.mode, session.categoryOptions) }
|
|
348
451
|
do {
|
|
@@ -355,14 +458,28 @@ final class CraftNativeSounds {
|
|
|
355
458
|
|
|
356
459
|
static func releaseSession() {
|
|
357
460
|
playing = max(0, playing - 1)
|
|
358
|
-
guard playing == 0
|
|
461
|
+
guard playing == 0 else { return }
|
|
462
|
+
if claimedUnderSpeech {
|
|
463
|
+
// Speech still holds the session and puts its own back.
|
|
464
|
+
claimedUnderSpeech = false
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
guard let previous = sessionToRestore else { return }
|
|
359
468
|
sessionToRestore = nil
|
|
469
|
+
if CraftNativeSpeech.shared.holdsSession { return }
|
|
360
470
|
try? AVAudioSession.sharedInstance().setCategory(previous.0, mode: previous.1, options: previous.2)
|
|
361
471
|
}
|
|
362
472
|
|
|
363
|
-
/// Speech handed the session back with the category it found
|
|
364
|
-
///
|
|
365
|
-
|
|
473
|
+
/// Speech handed the session back with the category it found
|
|
474
|
+
/// (`handedBack`, nil when it never took it), which may predate a sound
|
|
475
|
+
/// started while it spoke: take it again for the sound. Main thread.
|
|
476
|
+
static func reclaimSessionIfPlaying(handedBack: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?) {
|
|
477
|
+
if claimedUnderSpeech {
|
|
478
|
+
claimedUnderSpeech = false
|
|
479
|
+
let session = AVAudioSession.sharedInstance()
|
|
480
|
+
sessionToRestore = handedBack ?? (session.category, session.mode, session.categoryOptions)
|
|
481
|
+
if playing > 0 { try? session.setActive(true) }
|
|
482
|
+
}
|
|
366
483
|
guard playing > 0 else { return }
|
|
367
484
|
try? AVAudioSession.sharedInstance().setCategory(category, mode: .default, options: [.mixWithOthers])
|
|
368
485
|
}
|
|
@@ -522,7 +522,8 @@ enum CraftStatusBar {
|
|
|
522
522
|
}
|
|
523
523
|
guard let root = CraftPresenter.keyWindow()?.rootViewController else { return false }
|
|
524
524
|
teach(root)
|
|
525
|
-
|
|
525
|
+
// In step with a screen's animated change (craft.animation.next) when one runs.
|
|
526
|
+
UIView.animate(withDuration: CraftNativeAnimationClock.duration ?? 0.2) { root.setNeedsStatusBarAppearanceUpdate() }
|
|
526
527
|
return true
|
|
527
528
|
}
|
|
528
529
|
|
package/dist/mobile.js
CHANGED
|
@@ -688,6 +688,7 @@ var speech = {
|
|
|
688
688
|
return new Promise((resolve) => {
|
|
689
689
|
const utterance = new Utterance(text);
|
|
690
690
|
utterance.rate = speechRate(options.rate);
|
|
691
|
+
utterance.pitch = speechRate(options.pitch);
|
|
691
692
|
if (options.language)
|
|
692
693
|
utterance.lang = options.language;
|
|
693
694
|
const settle = (spoken) => {
|