craft-native 0.0.164 → 0.0.167
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 +1 -1
- package/dist/android/templates/MainActivityNative.kt.template +416 -36
- package/dist/api/mobile.d.ts +25 -1
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +2 -2
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +1 -0
- package/dist/ios/src/index.js +138 -88
- package/dist/ios/src/source-audit.d.ts +8 -0
- package/dist/ios/templates/CraftApp.swift +102 -13
- package/dist/ios/templates/CraftHealth.swift +214 -39
- package/dist/ios/templates/CraftHeartRate.swift +34 -4
- package/dist/ios/templates/CraftHybrid.swift +140 -17
- package/dist/ios/templates/CraftNativeScreen.swift +338 -61
- package/dist/ios/templates/CraftNativeServices.swift +76 -23
- package/dist/mobile.js +29 -0
- package/package.json +1 -1
|
@@ -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
|
|
package/dist/mobile.js
CHANGED
|
@@ -1369,6 +1369,12 @@ var pushNotifications = {
|
|
|
1369
1369
|
return onCraftEvent("craftNotificationReceived", (detail) => callback(detail));
|
|
1370
1370
|
}
|
|
1371
1371
|
};
|
|
1372
|
+
function healthHeartRateSamples(detail) {
|
|
1373
|
+
const samples = detail?.samples;
|
|
1374
|
+
if (!Array.isArray(samples))
|
|
1375
|
+
return [];
|
|
1376
|
+
return samples.filter((sample) => typeof sample?.bpm === "number" && Number.isFinite(sample.bpm) && typeof sample.startDate === "number" && typeof sample.endDate === "number");
|
|
1377
|
+
}
|
|
1372
1378
|
var health = {
|
|
1373
1379
|
supports(method) {
|
|
1374
1380
|
return typeof getCraftRoot()?.health?.[method] === "function";
|
|
@@ -1432,6 +1438,29 @@ var health = {
|
|
|
1432
1438
|
if (!craft?.health?.getMindfulSessions)
|
|
1433
1439
|
throw new Error("Reading mindful sessions is unavailable");
|
|
1434
1440
|
return craft.health.getMindfulSessions(options);
|
|
1441
|
+
},
|
|
1442
|
+
async startHeartRateUpdates(options = {}) {
|
|
1443
|
+
const craft = getCraftRoot();
|
|
1444
|
+
if (!craft?.health?.startHeartRateUpdates)
|
|
1445
|
+
throw new Error("Heart-rate updates are unavailable");
|
|
1446
|
+
return Boolean(await craft.health.startHeartRateUpdates(options));
|
|
1447
|
+
},
|
|
1448
|
+
async stopHeartRateUpdates() {
|
|
1449
|
+
const craft = getCraftRoot();
|
|
1450
|
+
if (!craft?.health?.stopHeartRateUpdates)
|
|
1451
|
+
throw new Error("Heart-rate updates are unavailable");
|
|
1452
|
+
return Boolean(await craft.health.stopHeartRateUpdates());
|
|
1453
|
+
},
|
|
1454
|
+
onHeartRate(callback) {
|
|
1455
|
+
if (typeof window === "undefined" || typeof getCraftRoot()?.health?.startHeartRateUpdates !== "function")
|
|
1456
|
+
return () => {};
|
|
1457
|
+
const listener = (event) => {
|
|
1458
|
+
const samples = healthHeartRateSamples(event.detail);
|
|
1459
|
+
if (samples.length)
|
|
1460
|
+
callback(samples);
|
|
1461
|
+
};
|
|
1462
|
+
window.addEventListener("craftHealthHeartRate", listener);
|
|
1463
|
+
return () => window.removeEventListener("craftHealthHeartRate", listener);
|
|
1435
1464
|
}
|
|
1436
1465
|
};
|
|
1437
1466
|
async function updateLiveActivity(idOrState, state) {
|