craft-native 0.0.113 → 0.0.118
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/src/index.js +6 -0
- package/dist/android/templates/AndroidManifest.xml.template +8 -0
- package/dist/android/templates/CraftBridge.kt.template +285 -1
- package/dist/android/templates/CraftNativeFlatList.kt.template +181 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +335 -0
- package/dist/android/templates/MainActivityNative.kt.template +493 -42
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/mobile.d.ts +29 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +9 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -1
- package/dist/ios/src/index.js +2 -0
- package/dist/ios/templates/CraftApp.swift +221 -0
- package/dist/ios/templates/CraftNativeFlatList.swift +323 -0
- package/dist/ios/templates/CraftNativeMutation.swift +365 -0
- package/dist/ios/templates/CraftNativeScreen.swift +460 -31
- package/dist/mobile.js +58 -0
- package/dist/types.d.ts +81 -1
- package/package.json +1 -1
|
@@ -58,6 +58,7 @@ dependencies {
|
|
|
58
58
|
implementation("androidx.core:core-ktx:1.12.0")
|
|
59
59
|
implementation("androidx.appcompat:appcompat:1.6.1")
|
|
60
60
|
implementation("androidx.fragment:fragment-ktx:1.6.2")
|
|
61
|
+
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
|
61
62
|
implementation("androidx.webkit:webkit:1.12.1")
|
|
62
63
|
{{JAVASCRIPT_ENGINE_DEPENDENCY}}
|
|
63
64
|
implementation("com.google.android.material:material:1.11.0")
|
package/dist/api/mobile.d.ts
CHANGED
|
@@ -687,6 +687,34 @@ export declare const keepAwake: {
|
|
|
687
687
|
enable(): Promise<void>;
|
|
688
688
|
disable(): Promise<void>;
|
|
689
689
|
};
|
|
690
|
+
export interface SpeechOptions {
|
|
691
|
+
/** Speaking rate, 0.5 to 2. 1 is the voice's normal pace. */
|
|
692
|
+
rate?: number;
|
|
693
|
+
/** BCP-47 language tag, such as 'en-US'. Defaults to the device's language. */
|
|
694
|
+
language?: string;
|
|
695
|
+
/** Stop whatever is being said first. Defaults to true; false queues behind it. */
|
|
696
|
+
interrupt?: boolean;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Short spoken cues: "Rest, 15 seconds. Up next: Dead Bug".
|
|
700
|
+
*
|
|
701
|
+
* Inside a Craft app this is native text-to-speech, which ducks the person's
|
|
702
|
+
* music rather than stopping it and, on iOS, plays with the silent switch on.
|
|
703
|
+
* In a browser it falls back to the Web Speech API, which does neither, and
|
|
704
|
+
* where there is no speech at all it answers `false` rather than throwing, so
|
|
705
|
+
* a cue never breaks the flow around it.
|
|
706
|
+
*/
|
|
707
|
+
export declare const speech: {
|
|
708
|
+
isAvailable(): boolean;
|
|
709
|
+
/**
|
|
710
|
+
* Speak `text`. Resolves when the utterance ends: `true` when it was
|
|
711
|
+
* spoken, `false` when it was stopped, interrupted or could not be spoken.
|
|
712
|
+
* Rejects with code `INVALID_ARGUMENT` when `text` is empty.
|
|
713
|
+
*/
|
|
714
|
+
speak(text: string, options?: SpeechOptions): Promise<boolean>;
|
|
715
|
+
/** Stop speaking at once. Every pending `speak()` resolves `false`. */
|
|
716
|
+
stop(): Promise<void>;
|
|
717
|
+
};
|
|
690
718
|
export declare const deepLinks: {
|
|
691
719
|
getInitialURL(): Promise<string | null>;
|
|
692
720
|
/**
|
|
@@ -852,6 +880,7 @@ declare const mobile: {
|
|
|
852
880
|
lifecycle: typeof lifecycle;
|
|
853
881
|
notifications: typeof notifications;
|
|
854
882
|
keepAwake: typeof keepAwake;
|
|
883
|
+
speech: typeof speech;
|
|
855
884
|
deepLinks: typeof deepLinks;
|
|
856
885
|
network: typeof network;
|
|
857
886
|
appReview: typeof appReview;
|
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.118";
|
|
3307
3307
|
|
|
3308
3308
|
// bin/cli.ts
|
|
3309
3309
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
|
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
310
310
|
`);
|
|
311
311
|
}
|
|
312
312
|
// package.json
|
|
313
|
-
var version = "0.0.
|
|
313
|
+
var version = "0.0.118";
|
|
314
314
|
|
|
315
315
|
// src/sdk-version.ts
|
|
316
316
|
var SDK_VERSION = version;
|
|
@@ -3649,6 +3649,13 @@ var _watchRecursiveWarned = false;
|
|
|
3649
3649
|
async function watch(path, callback, options = {}) {
|
|
3650
3650
|
validatePath(path);
|
|
3651
3651
|
const recursive = options.recursive ?? true;
|
|
3652
|
+
const native2 = getCraftFs();
|
|
3653
|
+
if (native2?.watch) {
|
|
3654
|
+
const handle = await native2.watch(path, (event) => callback(event.type, event.path), { recursive });
|
|
3655
|
+
return () => {
|
|
3656
|
+
handle.unwatch();
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3652
3659
|
if (typeof window !== "undefined" && window.craft) {
|
|
3653
3660
|
const watchId = typeof globalThis.crypto !== "undefined" && "randomUUID" in globalThis.crypto ? globalThis.crypto.randomUUID() : `${Date.now()}_${performance.now()}`;
|
|
3654
3661
|
await callBridge("fs.watch", { path, watchId, recursive });
|
|
@@ -5277,6 +5284,7 @@ var notifications = {
|
|
|
5277
5284
|
}
|
|
5278
5285
|
}
|
|
5279
5286
|
};
|
|
5287
|
+
var speakingOnTheWeb = new Set;
|
|
5280
5288
|
function detectPlatform2() {
|
|
5281
5289
|
if (typeof window === "undefined")
|
|
5282
5290
|
return "linux";
|
package/dist/index.d.cts
CHANGED
|
@@ -81,4 +81,4 @@ export declare function show(html: string, options?: WindowOptions): Promise<voi
|
|
|
81
81
|
* Quick helper to load a URL
|
|
82
82
|
*/
|
|
83
83
|
export declare function loadURL(url: string, options?: WindowOptions): Promise<void>;
|
|
84
|
-
export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
|
|
84
|
+
export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -81,4 +81,4 @@ export declare function show(html: string, options?: WindowOptions): Promise<voi
|
|
|
81
81
|
* Quick helper to load a URL
|
|
82
82
|
*/
|
|
83
83
|
export declare function loadURL(url: string, options?: WindowOptions): Promise<void>;
|
|
84
|
-
export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
|
|
84
|
+
export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
58
58
|
`);
|
|
59
59
|
}
|
|
60
60
|
// package.json
|
|
61
|
-
var version = "0.0.
|
|
61
|
+
var version = "0.0.118";
|
|
62
62
|
|
|
63
63
|
// src/sdk-version.ts
|
|
64
64
|
var SDK_VERSION = version;
|
|
@@ -3409,6 +3409,13 @@ var _watchRecursiveWarned = false;
|
|
|
3409
3409
|
async function watch(path, callback, options = {}) {
|
|
3410
3410
|
validatePath(path);
|
|
3411
3411
|
const recursive = options.recursive ?? true;
|
|
3412
|
+
const native2 = getCraftFs();
|
|
3413
|
+
if (native2?.watch) {
|
|
3414
|
+
const handle = await native2.watch(path, (event) => callback(event.type, event.path), { recursive });
|
|
3415
|
+
return () => {
|
|
3416
|
+
handle.unwatch();
|
|
3417
|
+
};
|
|
3418
|
+
}
|
|
3412
3419
|
if (typeof window !== "undefined" && window.craft) {
|
|
3413
3420
|
const watchId = typeof globalThis.crypto !== "undefined" && "randomUUID" in globalThis.crypto ? globalThis.crypto.randomUUID() : `${Date.now()}_${performance.now()}`;
|
|
3414
3421
|
await callBridge("fs.watch", { path, watchId, recursive });
|
|
@@ -5037,6 +5044,7 @@ var notifications = {
|
|
|
5037
5044
|
}
|
|
5038
5045
|
}
|
|
5039
5046
|
};
|
|
5047
|
+
var speakingOnTheWeb = new Set;
|
|
5040
5048
|
function detectPlatform2() {
|
|
5041
5049
|
if (typeof window === "undefined")
|
|
5042
5050
|
return "linux";
|
package/dist/ios/src/index.js
CHANGED
|
@@ -406,7 +406,9 @@ async function init(options) {
|
|
|
406
406
|
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
407
407
|
writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
|
|
408
408
|
cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
|
|
409
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
|
|
409
410
|
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
411
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
|
|
410
412
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
411
413
|
const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
|
|
412
414
|
<true/>
|
|
@@ -722,6 +722,24 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
722
722
|
// Keep awake
|
|
723
723
|
private var isKeepingAwake = false
|
|
724
724
|
|
|
725
|
+
// Speech synthesis. One synthesizer for the life of the web view, so a
|
|
726
|
+
// cue can interrupt the one before it; each utterance it has not
|
|
727
|
+
// finished yet is held beside the call waiting on it.
|
|
728
|
+
private lazy var speechSynthesizer: AVSpeechSynthesizer = {
|
|
729
|
+
let synthesizer = AVSpeechSynthesizer()
|
|
730
|
+
synthesizer.delegate = self
|
|
731
|
+
return synthesizer
|
|
732
|
+
}()
|
|
733
|
+
private var pendingUtterances: [(utterance: AVSpeechUtterance, callbackId: String?)] = []
|
|
734
|
+
/// What the session was before speech claimed it; nil while speech
|
|
735
|
+
/// does not hold it.
|
|
736
|
+
private var speechAudioSessionToRestore: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?
|
|
737
|
+
/// Activating and deactivating the audio session blocks until the
|
|
738
|
+
/// audio hardware answers, which iOS flags as a hang risk on the main
|
|
739
|
+
/// thread. They run here instead, in order, and the session state
|
|
740
|
+
/// above is only touched here.
|
|
741
|
+
private let speechAudioQueue = DispatchQueue(label: "craft.speech.audio")
|
|
742
|
+
|
|
725
743
|
// Orientation lock
|
|
726
744
|
private var lockedOrientation: UIInterfaceOrientationMask?
|
|
727
745
|
|
|
@@ -1058,6 +1076,16 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1058
1076
|
} else {
|
|
1059
1077
|
rejectCallback(callbackId, error: "setFlashlight needs true or false", code: "INVALID_ARGUMENT")
|
|
1060
1078
|
}
|
|
1079
|
+
// Speech synthesis. Ungated, like the flashlight: speaking needs no
|
|
1080
|
+
// permission and no entitlement.
|
|
1081
|
+
case "speak":
|
|
1082
|
+
if let text = body["text"] as? String, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
1083
|
+
speak(text, body: body, callbackId: callbackId)
|
|
1084
|
+
} else {
|
|
1085
|
+
rejectCallback(callbackId, error: "speak needs some text to say", code: "INVALID_ARGUMENT")
|
|
1086
|
+
}
|
|
1087
|
+
case "stopSpeaking":
|
|
1088
|
+
stopSpeaking(callbackId: callbackId)
|
|
1061
1089
|
// Vibrate pattern
|
|
1062
1090
|
case "vibrate":
|
|
1063
1091
|
if let pattern = body["pattern"] as? [Int] {
|
|
@@ -1850,6 +1878,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1850
1878
|
orientationLock: \(config.enableOrientationLock),
|
|
1851
1879
|
deepLinks: \(config.enableDeepLinks),
|
|
1852
1880
|
flashlight: true,
|
|
1881
|
+
speech: true,
|
|
1853
1882
|
network: true,
|
|
1854
1883
|
deviceInfo: true,
|
|
1855
1884
|
badge: true,
|
|
@@ -2788,6 +2817,37 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2788
2817
|
});
|
|
2789
2818
|
},
|
|
2790
2819
|
|
|
2820
|
+
// Speech synthesis. speak() settles when the utterance ends:
|
|
2821
|
+
// true when it was spoken, false when something stopped it.
|
|
2822
|
+
// No timeout, because a long sentence at a slow rate can
|
|
2823
|
+
// outlast any deadline that would be fair to a short one.
|
|
2824
|
+
speech: {
|
|
2825
|
+
speak: function(text, options) {
|
|
2826
|
+
var self = window.craft;
|
|
2827
|
+
var id = 'cb_' + (++self._callbackId);
|
|
2828
|
+
options = options || {};
|
|
2829
|
+
window.webkit.messageHandlers.craft.postMessage({
|
|
2830
|
+
action: 'speak',
|
|
2831
|
+
text: text == null ? '' : String(text),
|
|
2832
|
+
rate: typeof options.rate === 'number' ? options.rate : null,
|
|
2833
|
+
language: typeof options.language === 'string' ? options.language : null,
|
|
2834
|
+
interrupt: options.interrupt !== false,
|
|
2835
|
+
callbackId: id
|
|
2836
|
+
});
|
|
2837
|
+
return new Promise(function(resolve, reject) {
|
|
2838
|
+
self._callbacks[id] = {resolve: resolve, reject: reject};
|
|
2839
|
+
});
|
|
2840
|
+
},
|
|
2841
|
+
stop: function() {
|
|
2842
|
+
var self = window.craft;
|
|
2843
|
+
var id = 'cb_' + (++self._callbackId);
|
|
2844
|
+
window.webkit.messageHandlers.craft.postMessage({action: 'stopSpeaking', callbackId: id});
|
|
2845
|
+
return new Promise(function(resolve, reject) {
|
|
2846
|
+
self._callbacks[id] = {resolve: resolve, reject: reject};
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
},
|
|
2850
|
+
|
|
2791
2851
|
// Orientation Lock
|
|
2792
2852
|
lockOrientation: function(orientation) {
|
|
2793
2853
|
var self = this;
|
|
@@ -4762,6 +4822,153 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4762
4822
|
}
|
|
4763
4823
|
}
|
|
4764
4824
|
|
|
4825
|
+
// MARK: - Speech Synthesis
|
|
4826
|
+
//
|
|
4827
|
+
// Native rather than the page's own `speechSynthesis`, which in a
|
|
4828
|
+
// WKWebView plays through the default soloAmbient session: it stops
|
|
4829
|
+
// the person's music and the silent switch mutes it. A workout cue has
|
|
4830
|
+
// to do neither, so the session is set up here for as long as there is
|
|
4831
|
+
// something to say, and handed back when there is not.
|
|
4832
|
+
|
|
4833
|
+
private func speak(_ text: String, body: [String: Any], callbackId: String?) {
|
|
4834
|
+
DispatchQueue.main.async {
|
|
4835
|
+
if body["interrupt"] as? Bool ?? true {
|
|
4836
|
+
self.cancelPendingSpeech()
|
|
4837
|
+
}
|
|
4838
|
+
|
|
4839
|
+
let utterance = AVSpeechUtterance(string: text)
|
|
4840
|
+
utterance.rate = Self.utteranceRate(body["rate"] as? Double ?? 1)
|
|
4841
|
+
// An unknown language finds no voice, and a nil voice is the
|
|
4842
|
+
// device's own, which is the default anyway.
|
|
4843
|
+
if let language = body["language"] as? String, !language.isEmpty,
|
|
4844
|
+
let voice = AVSpeechSynthesisVoice(language: language) {
|
|
4845
|
+
utterance.voice = voice
|
|
4846
|
+
}
|
|
4847
|
+
|
|
4848
|
+
self.pendingUtterances.append((utterance, callbackId))
|
|
4849
|
+
self.claimAudioSessionForSpeech {
|
|
4850
|
+
// Stopped while the session was being set up: say nothing.
|
|
4851
|
+
guard self.pendingUtterances.contains(where: { $0.utterance === utterance }) else { return }
|
|
4852
|
+
self.speechSynthesizer.speak(utterance)
|
|
4853
|
+
}
|
|
4854
|
+
}
|
|
4855
|
+
}
|
|
4856
|
+
|
|
4857
|
+
private func stopSpeaking(callbackId: String?) {
|
|
4858
|
+
DispatchQueue.main.async {
|
|
4859
|
+
// When something was playing, its didCancel releases the
|
|
4860
|
+
// session once the audio has actually stopped; deactivating
|
|
4861
|
+
// before that fails as busy and leaves the music ducked.
|
|
4862
|
+
if !self.cancelPendingSpeech() { self.releaseAudioSessionIfSilent() }
|
|
4863
|
+
self.resolveCallback(callbackId, result: true)
|
|
4864
|
+
}
|
|
4865
|
+
}
|
|
4866
|
+
|
|
4867
|
+
/// Stop what is being said and settle every call waiting on it, false.
|
|
4868
|
+
/// Returns whether the synthesizer was making a sound.
|
|
4869
|
+
///
|
|
4870
|
+
/// Settled here rather than in `didCancel`, because the delegate hears
|
|
4871
|
+
/// about the utterance that was playing and nothing about the ones
|
|
4872
|
+
/// queued behind it, whose calls would otherwise never settle.
|
|
4873
|
+
@discardableResult
|
|
4874
|
+
private func cancelPendingSpeech() -> Bool {
|
|
4875
|
+
let waiting = pendingUtterances
|
|
4876
|
+
pendingUtterances.removeAll()
|
|
4877
|
+
let synthesizer = speechSynthesizer
|
|
4878
|
+
let wasSounding = synthesizer.isSpeaking || synthesizer.isPaused
|
|
4879
|
+
if wasSounding { synthesizer.stopSpeaking(at: .immediate) }
|
|
4880
|
+
for entry in waiting {
|
|
4881
|
+
resolveCallback(entry.callbackId, result: false)
|
|
4882
|
+
}
|
|
4883
|
+
return wasSounding
|
|
4884
|
+
}
|
|
4885
|
+
|
|
4886
|
+
/// The page's rate, where 1 is normal, on AVSpeechUtterance's scale.
|
|
4887
|
+
///
|
|
4888
|
+
/// Apple's scale is not linear in speed: the default sits at 0.5 and
|
|
4889
|
+
/// the maximum is far faster than twice that. So a slower rate scales
|
|
4890
|
+
/// the default down in proportion, and a faster one climbs toward the
|
|
4891
|
+
/// maximum at half the slope, which keeps 2 brisk but intelligible.
|
|
4892
|
+
private static func utteranceRate(_ rate: Double) -> Float {
|
|
4893
|
+
let requested = Float(min(max(rate.isFinite ? rate : 1, 0.5), 2))
|
|
4894
|
+
let normal = AVSpeechUtteranceDefaultSpeechRate
|
|
4895
|
+
let mapped = requested <= 1
|
|
4896
|
+
? normal * requested
|
|
4897
|
+
: normal + (AVSpeechUtteranceMaximumSpeechRate - normal) * (requested - 1) / 2
|
|
4898
|
+
return min(max(mapped, AVSpeechUtteranceMinimumSpeechRate), AVSpeechUtteranceMaximumSpeechRate)
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
/// Duck the music rather than stop it, and play through the silent
|
|
4902
|
+
/// switch.
|
|
4903
|
+
///
|
|
4904
|
+
/// `.playback` is what the switch does not mute, `.duckOthers` lowers
|
|
4905
|
+
/// the music under the cue, and the spoken-audio option pauses a
|
|
4906
|
+
/// podcast rather than talking over it. Left alone while recognition
|
|
4907
|
+
/// or a recording holds the session: switching it to playback would
|
|
4908
|
+
/// cut their microphone off mid-take.
|
|
4909
|
+
///
|
|
4910
|
+
/// Set up on the speech queue, then `start` runs on the main thread.
|
|
4911
|
+
private func claimAudioSessionForSpeech(then start: @escaping () -> Void) {
|
|
4912
|
+
let microphoneBusy = audioEngine.isRunning || (audioRecorder?.isRecording ?? false)
|
|
4913
|
+
speechAudioQueue.async {
|
|
4914
|
+
if self.speechAudioSessionToRestore == nil && !microphoneBusy {
|
|
4915
|
+
let session = AVAudioSession.sharedInstance()
|
|
4916
|
+
let previous = (session.category, session.mode, session.categoryOptions)
|
|
4917
|
+
do {
|
|
4918
|
+
try session.setCategory(.playback, mode: .voicePrompt, options: [.duckOthers, .interruptSpokenAudioAndMixWithOthers])
|
|
4919
|
+
try session.setActive(true)
|
|
4920
|
+
self.speechAudioSessionToRestore = previous
|
|
4921
|
+
} catch {
|
|
4922
|
+
// Still speak, through whatever session the app has: a
|
|
4923
|
+
// cue that does not duck beats one that never plays.
|
|
4924
|
+
print("[Craft] Speech audio session failed: \(error.localizedDescription)")
|
|
4925
|
+
}
|
|
4926
|
+
}
|
|
4927
|
+
DispatchQueue.main.async(execute: start)
|
|
4928
|
+
}
|
|
4929
|
+
}
|
|
4930
|
+
|
|
4931
|
+
/// Hand the audio back once nothing is left to say, so the music
|
|
4932
|
+
/// returns to full volume instead of staying ducked, and put back the
|
|
4933
|
+
/// category the app had, so the page's own media plays as before.
|
|
4934
|
+
///
|
|
4935
|
+
/// Checked on the main thread, where the queue of utterances lives, and
|
|
4936
|
+
/// done on the speech queue after any setup already asked for, so a
|
|
4937
|
+
/// cue that arrives meanwhile reclaims the session after this.
|
|
4938
|
+
private func releaseAudioSessionIfSilent(retry: Bool = true) {
|
|
4939
|
+
guard pendingUtterances.isEmpty else { return }
|
|
4940
|
+
speechAudioQueue.async {
|
|
4941
|
+
guard let previous = self.speechAudioSessionToRestore else { return }
|
|
4942
|
+
let session = AVAudioSession.sharedInstance()
|
|
4943
|
+
do {
|
|
4944
|
+
try session.setActive(false, options: .notifyOthersOnDeactivation)
|
|
4945
|
+
} catch {
|
|
4946
|
+
// Busy: the synthesizer's audio has not quite drained, or
|
|
4947
|
+
// the page has its own media playing. Once more shortly
|
|
4948
|
+
// covers the first; for the second, staying active is
|
|
4949
|
+
// right, and the ducking still ends when the music app
|
|
4950
|
+
// hears nothing more.
|
|
4951
|
+
if retry {
|
|
4952
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
4953
|
+
self.releaseAudioSessionIfSilent(retry: false)
|
|
4954
|
+
}
|
|
4955
|
+
return
|
|
4956
|
+
}
|
|
4957
|
+
print("[Craft] Speech audio session release failed: \(error.localizedDescription)")
|
|
4958
|
+
}
|
|
4959
|
+
self.speechAudioSessionToRestore = nil
|
|
4960
|
+
try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
|
|
4961
|
+
}
|
|
4962
|
+
}
|
|
4963
|
+
|
|
4964
|
+
fileprivate func speechDidEnd(_ utterance: AVSpeechUtterance, spoken: Bool) {
|
|
4965
|
+
if let index = pendingUtterances.firstIndex(where: { $0.utterance === utterance }) {
|
|
4966
|
+
let entry = pendingUtterances.remove(at: index)
|
|
4967
|
+
resolveCallback(entry.callbackId, result: spoken)
|
|
4968
|
+
}
|
|
4969
|
+
releaseAudioSessionIfSilent()
|
|
4970
|
+
}
|
|
4971
|
+
|
|
4765
4972
|
// MARK: - Orientation Lock
|
|
4766
4973
|
private func lockOrientation(_ orientation: String, callbackId: String?) {
|
|
4767
4974
|
var mask: UIInterfaceOrientationMask = .all
|
|
@@ -6698,6 +6905,20 @@ extension URL {
|
|
|
6698
6905
|
}
|
|
6699
6906
|
|
|
6700
6907
|
// MARK: - Contact Picker Delegate
|
|
6908
|
+
// MARK: - AVSpeechSynthesizerDelegate
|
|
6909
|
+
//
|
|
6910
|
+
// The end of an utterance is what `craft.speech.speak()` waits for. Hopped to
|
|
6911
|
+
// main because every piece of speech state is touched there and nowhere else.
|
|
6912
|
+
extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
|
|
6913
|
+
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
|
6914
|
+
DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }
|
|
6915
|
+
}
|
|
6916
|
+
|
|
6917
|
+
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
|
6918
|
+
DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: false) }
|
|
6919
|
+
}
|
|
6920
|
+
}
|
|
6921
|
+
|
|
6701
6922
|
extension CraftWebView.Coordinator: CNContactPickerDelegate {
|
|
6702
6923
|
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
|
|
6703
6924
|
let contactData = formatContact(contact)
|