@craft-native/ios 0.0.115 → 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/README.md CHANGED
@@ -439,6 +439,20 @@ await window.craft.restorePurchases();
439
439
  window.craft.setKeepAwake(true); // Prevent screen dimming
440
440
  window.craft.setKeepAwake(false); // Allow screen dimming
441
441
 
442
+ // Speech: short spoken cues that duck the person's music instead of stopping
443
+ // it, and play with the silent switch on. Always available (no config flag).
444
+ // Resolves when the utterance ends: true if spoken, false if stopped or
445
+ // interrupted. Rejects with INVALID_ARGUMENT for empty text.
446
+ const spoken = await window.craft.speech.speak('Rest, 15 seconds. Up next: Dead Bug', {
447
+ rate: 1, // 0.5 to 2, 1 = normal
448
+ language: 'en-US', // BCP-47; defaults to the device's language
449
+ interrupt: true // default: stop what is being said first; false queues
450
+ });
451
+ await window.craft.speech.stop(); // pending speak() calls resolve false
452
+ // Or `import { speech } from 'craft-native/mobile'`: the same calls, falling
453
+ // back to the Web Speech API in a browser and resolving false where neither
454
+ // exists. speech.isAvailable() says which.
455
+
442
456
  // Orientation Lock
443
457
  window.craft.lockOrientation('portrait'); // Lock to portrait
444
458
  window.craft.lockOrientation('landscape'); // Lock to landscape
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.115",
3
+ "version": "0.0.118",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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)