craft-native 0.0.355 → 0.0.359

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.
@@ -192,10 +192,12 @@ enum CraftNativeFiles {
192
192
  do {
193
193
  let url = try newFile(named: name)
194
194
  guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.jpeg.identifier as CFString, 1, nil) else {
195
+ try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
195
196
  return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved"))
196
197
  }
197
198
  CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary)
198
199
  guard CGImageDestinationFinalize(destination) else {
200
+ try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
199
201
  return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved"))
200
202
  }
201
203
  return .success(describe(url, type: "image/jpeg", extra: ["width": image.width, "height": image.height]))
@@ -731,11 +733,11 @@ enum CraftNativeMedia {
731
733
  lock.lock()
732
734
  defer { lock.unlock() }
733
735
  if let flight, inFlight[address]?.id != flight {
734
- return .failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))
736
+ return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
735
737
  }
736
738
  // A background transfer from before the cache was cleared (sign-out).
737
739
  if let stillWanted, !stillWanted() {
738
- return .failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))
740
+ return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
739
741
  }
740
742
  let staging = root.appendingPathComponent(UUID().uuidString + ".part")
741
743
  defer { try? fm.removeItem(at: staging) }
@@ -784,7 +786,7 @@ enum CraftNativeMedia {
784
786
  catch { failure = error }
785
787
  }
786
788
  lock.unlock()
787
- for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))) }
789
+ for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))) }
788
790
  if let failure { throw failure }
789
791
  return removed
790
792
  }
@@ -1443,6 +1443,16 @@ enum CraftBasemap: String {
1443
1443
  static let outdoorCredit = "Topography: Esri, USGS, NOAA, OpenStreetMap contributors"
1444
1444
  }
1445
1445
 
1446
+ private final class CraftMarkerPhotoRedirects: NSObject, URLSessionTaskDelegate {
1447
+ static let session = URLSession(configuration: .ephemeral, delegate: CraftMarkerPhotoRedirects(), delegateQueue: nil)
1448
+
1449
+ func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
1450
+ newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
1451
+ let url = request.url
1452
+ completionHandler(url?.scheme?.lowercased() == "https" && url?.user == nil && url?.password == nil ? request : nil)
1453
+ }
1454
+ }
1455
+
1446
1456
  private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1447
1457
  private var annotationsByID: [String: MKPointAnnotation] = [:]
1448
1458
  private var overlaysByID: [String: MKPolyline] = [:]
@@ -1467,6 +1477,26 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1467
1477
  private var edgePadding = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
1468
1478
  private var markerBearings: [String: Double] = [:]
1469
1479
  private var markerPinColors: [String: UIColor] = [:]
1480
+ private struct MarkerPhoto: Equatable {
1481
+ let url: URL
1482
+ let size: CGFloat
1483
+ let circle: Bool
1484
+
1485
+ init?(_ props: [String: Any]) {
1486
+ guard let raw = props["image"] as? String, let url = URL(string: raw),
1487
+ url.scheme?.lowercased() == "https", let host = url.host, !host.isEmpty,
1488
+ url.user == nil, url.password == nil else { return nil }
1489
+ self.url = url
1490
+ // Current STX releases preserve unknown HTML/binding spellings.
1491
+ let value = CraftNativeMapView.numeric(props["imageSize"] ?? props["imagesize"] ?? props["image-size"]) ?? 30
1492
+ size = CGFloat(value.isFinite ? min(96, max(16, value)) : 30)
1493
+ circle = (props["imageShape"] ?? props["imageshape"] ?? props["image-shape"]) as? String != "square"
1494
+ }
1495
+ }
1496
+ private var markerPhotos: [String: MarkerPhoto] = [:]
1497
+ private var markerPhotoImages: [String: UIImage] = [:]
1498
+ private var markerPhotoTasks: [String: URLSessionDataTask] = [:]
1499
+ private var markerPhotoRequests: [String: UUID] = [:]
1470
1500
  private var tileOverlay: MKTileOverlay?
1471
1501
  private var tileTemplate: String?
1472
1502
  private var tileReplacesMap = true
@@ -1552,6 +1582,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1552
1582
  edgePadding = UIEdgeInsets(top: inset("top"), left: inset("left"), bottom: inset("bottom"), right: inset("right"))
1553
1583
  var nextBearings: [String: Double] = [:]
1554
1584
  var nextPinColors: [String: UIColor] = [:]
1585
+ var nextPhotos: [String: MarkerPhoto] = [:]
1555
1586
  // Showing the user dot must not trigger a permission prompt from a
1556
1587
  // renderer pass. The app's existing location capability owns that
1557
1588
  // prompt; a map only opts in after authorization is already granted.
@@ -1580,6 +1611,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1580
1611
  guard let id = (overlay["id"] as? CustomStringConvertible)?.description,
1581
1612
  let point = points.first else { continue }
1582
1613
  nextMarkers[id] = (point, overlay["title"] as? String)
1614
+ nextPhotos[id] = MarkerPhoto(overlay)
1583
1615
  if let color = overlay["pinColor"] as? String { nextPinColors[id] = Self.color(color) }
1584
1616
  if let bearing = Self.numeric(overlay["bearing"]), bearing.isFinite {
1585
1617
  nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
@@ -1598,6 +1630,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1598
1630
  if type == "mapmarker" || type == "marker" {
1599
1631
  guard let point = points.first else { continue }
1600
1632
  nextMarkers[id] = (point, childProps["title"] as? String)
1633
+ nextPhotos[id] = MarkerPhoto(childProps)
1601
1634
  if let color = childProps["pinColor"] as? String { nextPinColors[id] = Self.color(color) }
1602
1635
  if let bearing = Self.numeric(childProps["bearing"]), bearing.isFinite {
1603
1636
  nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
@@ -1626,6 +1659,12 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1626
1659
 
1627
1660
  markerBearings = nextBearings
1628
1661
  markerPinColors = nextPinColors
1662
+ for id in markerPhotos.keys where markerPhotos[id] != nextPhotos[id] {
1663
+ markerPhotoTasks.removeValue(forKey: id)?.cancel()
1664
+ markerPhotoRequests.removeValue(forKey: id)
1665
+ markerPhotoImages.removeValue(forKey: id)
1666
+ }
1667
+ markerPhotos = nextPhotos
1629
1668
  let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
1630
1669
  removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
1631
1670
  for (id, value) in nextMarkers {
@@ -1642,6 +1681,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1642
1681
  }
1643
1682
  }
1644
1683
  markerHandlers = nextHandlers
1684
+ for (id, photo) in markerPhotos where markerPhotoRequests[id] == nil {
1685
+ loadMarkerPhoto(photo, id: id)
1686
+ }
1645
1687
 
1646
1688
  let staleOverlays = overlaysByID.keys.filter { nextPolylines[$0] == nil }
1647
1689
  for id in staleOverlays {
@@ -1773,6 +1815,11 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1773
1815
  markerHandlers.removeAll()
1774
1816
  markerBearings.removeAll()
1775
1817
  markerPinColors.removeAll()
1818
+ markerPhotoTasks.values.forEach { $0.cancel() }
1819
+ markerPhotoTasks.removeAll()
1820
+ markerPhotoRequests.removeAll()
1821
+ markerPhotoImages.removeAll()
1822
+ markerPhotos.removeAll()
1776
1823
  removeAnnotations(Array(annotationsByID.values))
1777
1824
  annotationsByID.removeAll()
1778
1825
  removeOverlays(Array(overlaysByID.values))
@@ -1890,7 +1937,15 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1890
1937
  }
1891
1938
 
1892
1939
  private func configureMarkerView(_ view: MKAnnotationView, id: String) {
1893
- if let bearing = markerBearings[id] {
1940
+ if let photo = markerPhotoImages[id] {
1941
+ view.image = photo
1942
+ view.transform = .identity
1943
+ view.displayPriority = .required
1944
+ view.collisionMode = markerPhotos[id]?.circle == true ? .circle : .rectangle
1945
+ if #available(iOS 14.0, *) { view.zPriority = .max }
1946
+ view.isAccessibilityElement = true
1947
+ view.accessibilityTraits = .button
1948
+ } else if let bearing = markerBearings[id] {
1894
1949
  let color = (markerPinColors[id] ?? .systemGreen).resolvedColor(with: traitCollection)
1895
1950
  view.tintColor = color
1896
1951
  view.image = Self.markerImage(color: color, direction: true)
@@ -1919,6 +1974,45 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1919
1974
  }
1920
1975
  }
1921
1976
 
1977
+ private func loadMarkerPhoto(_ photo: MarkerPhoto, id: String) {
1978
+ let request = UUID()
1979
+ markerPhotoRequests[id] = request
1980
+ let scale = window?.screen.scale ?? UIScreen.main.scale
1981
+ let finish: (Data?) -> Void = { [weak self] data in
1982
+ let pixels = CGSize(width: photo.size * scale, height: photo.size * scale)
1983
+ let decoded = data.flatMap { $0.count <= 8 * 1024 * 1024 ? CraftNativeScreenController.decodeImage($0, fitting: (pixels, scale), fill: true) : nil }
1984
+ DispatchQueue.main.async { [weak self] in
1985
+ guard let self, self.markerPhotoRequests[id] == request else { return }
1986
+ self.markerPhotoTasks.removeValue(forKey: id)
1987
+ guard let decoded, decoded.size.width > 0, decoded.size.height > 0 else { return }
1988
+ let size = CGSize(width: photo.size, height: photo.size)
1989
+ let format = UIGraphicsImageRendererFormat()
1990
+ format.scale = scale
1991
+ self.markerPhotoImages[id] = UIGraphicsImageRenderer(size: size, format: format).image { _ in
1992
+ let bounds = CGRect(origin: .zero, size: size)
1993
+ if photo.circle { UIBezierPath(ovalIn: bounds).addClip() }
1994
+ let factor = max(photo.size / decoded.size.width, photo.size / decoded.size.height)
1995
+ let drawn = CGSize(width: decoded.size.width * factor, height: decoded.size.height * factor)
1996
+ decoded.draw(in: CGRect(x: (photo.size - drawn.width) / 2, y: (photo.size - drawn.height) / 2,
1997
+ width: drawn.width, height: drawn.height))
1998
+ }.withRenderingMode(.alwaysOriginal)
1999
+ if let annotation = self.annotationsByID[id], let view = self.view(for: annotation) {
2000
+ self.configureMarkerView(view, id: id)
2001
+ }
2002
+ }
2003
+ }
2004
+ if let local = CraftNativeMedia.local(photo.url.absoluteString) {
2005
+ DispatchQueue.global(qos: .userInitiated).async { finish(try? Data(contentsOf: local)) }
2006
+ } else {
2007
+ let task = CraftMarkerPhotoRedirects.session.dataTask(with: photo.url) { data, response, _ in
2008
+ let response = response as? HTTPURLResponse
2009
+ finish(response?.statusCode == 200 && response?.url?.scheme?.lowercased() == "https" ? data : nil)
2010
+ }
2011
+ markerPhotoTasks[id] = task
2012
+ task.resume()
2013
+ }
2014
+ }
2015
+
1922
2016
  private static let markerImages: NSCache<NSString, UIImage> = {
1923
2017
  let cache = NSCache<NSString, UIImage>()
1924
2018
  cache.countLimit = 64
@@ -4343,6 +4437,36 @@ final class CraftMapTapDelegate: NSObject, UIGestureRecognizerDelegate {
4343
4437
  }
4344
4438
  }
4345
4439
 
4440
+ /// A pressable wrapper must yield to an actionable descendant. Without this
4441
+ /// ordering UIKit may let the ancestor's tap recognizer win the same touch,
4442
+ /// so a nested Button never receives its physical action. A descendant
4443
+ /// control or tap recognizer is the source of the event; STX handles the
4444
+ /// logical bubbling from there.
4445
+ final class CraftPressGestureDelegate: NSObject, UIGestureRecognizerDelegate {
4446
+ static let shared = CraftPressGestureDelegate()
4447
+
4448
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
4449
+ guard let parent = gestureRecognizer.view,
4450
+ let child = otherGestureRecognizer.view,
4451
+ child !== parent else { return false }
4452
+ return child.isDescendant(of: parent)
4453
+ }
4454
+
4455
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
4456
+ guard let container = gestureRecognizer.view else { return true }
4457
+ let hitView = container.hitTest(touch.location(in: container), with: nil)
4458
+ guard var candidate = hitView ?? touch.view else { return true }
4459
+ while candidate !== container {
4460
+ if candidate is UIControl { return false }
4461
+ if candidate.gestureRecognizers?.contains(where: { $0 !== gestureRecognizer && $0 is UITapGestureRecognizer }) == true {
4462
+ return false
4463
+ }
4464
+ candidate = candidate.superview ?? container
4465
+ }
4466
+ return true
4467
+ }
4468
+ }
4469
+
4346
4470
  /// The delegate of a Pressable's press-in/out feedback. That recognizer is
4347
4471
  /// a long press of no duration, so it begins the moment a finger lands; on its
4348
4472
  /// own it would claim the touch, and the tap that is the Pressable's onPress
@@ -6538,15 +6662,25 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6538
6662
  let snapshotGet: @convention(block) (String) -> String? = { name in
6539
6663
  CraftSnapshots.read(name)
6540
6664
  }
6541
- let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
6542
- CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
6543
- CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
6665
+ // JavaScriptCore retains these outer host blocks. A weak capture only
6666
+ // on the nested completion still makes the host block retain self,
6667
+ // forming controller -> JSContext -> host block -> controller.
6668
+ let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { [weak self] name, value, done in
6669
+ CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { [weak self] ok in
6670
+ CraftNativeScriptThread.run {
6671
+ // A writer can finish after UIKit has disposed this
6672
+ // screen. Resolve the pending promise as a cancellation,
6673
+ // never as a successful write into a dead runtime.
6674
+ done.call(withArguments: [self?.runtimeDisposed != false ? false : ok])
6675
+ }
6544
6676
  }
6545
6677
  }
6546
- let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { keep, done in
6678
+ let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { [weak self] keep, done in
6547
6679
  let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
6548
- CraftSnapshots.clear(keeping: Set(names)) { ok in
6549
- CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
6680
+ CraftSnapshots.clear(keeping: Set(names)) { [weak self] ok in
6681
+ CraftNativeScriptThread.run {
6682
+ done.call(withArguments: [self?.runtimeDisposed != false ? false : ok])
6683
+ }
6550
6684
  }
6551
6685
  }
6552
6686
  let config = self.config
@@ -7915,20 +8049,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7915
8049
  screenRequestCancellers[requestToken] = { [weak self] in self?.sounds.stop(id: id, fadeOut: 0) }
7916
8050
  sounds.play(id: id, url: url, options: args.count > 2 ? args[2] as? [String: Any] ?? [:] : [:], answer: reply)
7917
8051
  case ("Audio", "pause"):
7918
- sounds.pause(id: id)
7919
- reply(.success(true))
8052
+ reply(.success(sounds.pause(id: id)))
7920
8053
  case ("Audio", "resume"):
7921
- sounds.resume(id: id)
7922
- reply(.success(true))
8054
+ reply(.success(sounds.resume(id: id)))
7923
8055
  case ("Audio", "setVolume"):
7924
- sounds.setVolume(id: id, volume: number(1), fade: number(2, "fade") / 1000)
7925
- reply(.success(true))
8056
+ reply(.success(sounds.setVolume(id: id, volume: number(1), fade: number(2, "fade") / 1000)))
7926
8057
  case ("Audio", "stop"):
7927
- sounds.stop(id: id, fadeOut: number(1, "fadeOut") / 1000)
7928
- reply(.success(true))
8058
+ reply(.success(sounds.stop(id: id, fadeOut: number(1, "fadeOut") / 1000)))
7929
8059
  case ("Audio", "stopAll"):
7930
- sounds.stopAll(fadeOut: number(0, "fadeOut") / 1000)
7931
- reply(.success(true))
8060
+ reply(.success(sounds.stopAll(fadeOut: number(0, "fadeOut") / 1000)))
7932
8061
  default:
7933
8062
  return false
7934
8063
  }
@@ -9339,6 +9468,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9339
9468
  let actionable = handler != nil || linkTargets[id] != nil
9340
9469
  if actionable, tapRecognizers[id] == nil {
9341
9470
  let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
9471
+ recognizer.cancelsTouchesInView = false
9472
+ recognizer.delegate = CraftPressGestureDelegate.shared
9342
9473
  tapRecognizers[id] = recognizer
9343
9474
  view.addGestureRecognizer(recognizer)
9344
9475
  } else if !actionable, let recognizer = tapRecognizers.removeValue(forKey: id) {
@@ -10111,6 +10242,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10111
10242
  // Decoded at the size it is drawn, when the style fixes that size.
10112
10243
  let pixels = Self.displayPixels(of: view)
10113
10244
  let fill = view.contentMode == .scaleAspectFill
10245
+ // media.keep owns private/offline copies keyed by the authored URL.
10246
+ // Read before the decoded/network cache, without retaining private
10247
+ // pixels there after the app clears its media on sign-out.
10248
+ if let local = CraftNativeMedia.local(uri) {
10249
+ DispatchQueue.global(qos: .userInitiated).async { [weak self, weak view] in
10250
+ let decoded = (try? Data(contentsOf: local)).flatMap { Self.decodeImage($0, fitting: pixels, fill: fill) }
10251
+ DispatchQueue.main.async { [weak self, weak view] in
10252
+ guard let self, let view, self.imageSources[ObjectIdentifier(view)] == requestKey else { return }
10253
+ guard let image = decoded, CraftNativeMedia.local(uri) == local else {
10254
+ self.imageFailure(view, uri: uri, message: "Kept image is unavailable or invalid")
10255
+ return
10256
+ }
10257
+ view.image = self.imageForDisplay(image, view: view)
10258
+ self.imageDidChange(view)
10259
+ self.emitImageEvent(view, handler: self.imageLoadHandlers[ObjectIdentifier(view)])
10260
+ self.emitImageEvent(view, handler: self.imageLoadEndHandlers[ObjectIdentifier(view)])
10261
+ }
10262
+ }
10263
+ return
10264
+ }
10114
10265
  let cacheKey = pixels.map { "\(requestKey)@\(Int($0.size.width))x\(Int($0.size.height))\(fill ? "f" : "")" } ?? requestKey
10115
10266
  // Shown before: on screen in this same frame, already decoded.
10116
10267
  if let decoded = Self.decodedImages.object(forKey: cacheKey as NSString) {
@@ -10239,7 +10390,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10239
10390
  let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
10240
10391
  // UIPickerView exposes adjustable component wheels. Making the
10241
10392
  // picker itself a leaf hides those wheels and their selected values.
10242
- let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl"].contains(type)
10393
+ let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl", "Modal", "Drawer"].contains(type)
10243
10394
  || ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
10244
10395
  || controlledVideo
10245
10396
  view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
@@ -279,6 +279,7 @@ final class CraftNativeSounds {
279
279
  /// The volume asked for; a fade in or out moves toward or away from it.
280
280
  var volume: Float
281
281
  var paused = false
282
+ var interrupted = false
282
283
 
283
284
  init(player: AVQueuePlayer, volume: Float) {
284
285
  self.player = player
@@ -306,9 +307,44 @@ final class CraftNativeSounds {
306
307
  private static var category: AVAudioSession.Category = .ambient
307
308
 
308
309
  private var sounds: [String: Sound] = [:]
310
+ private var interruptionObserver: NSObjectProtocol?
309
311
  /// `{ id, event: "ended" | "error", message? }` to the screen's script.
310
312
  var emit: (([String: Any]) -> Void)?
311
313
 
314
+ init() {
315
+ interruptionObserver = NotificationCenter.default.addObserver(
316
+ forName: AVAudioSession.interruptionNotification,
317
+ object: nil,
318
+ queue: .main,
319
+ ) { [weak self] note in
320
+ guard let self else { return }
321
+ let type = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? NSNumber)?.uintValue
322
+ if type == AVAudioSession.InterruptionType.began.rawValue {
323
+ self.sounds.values.forEach { sound in
324
+ guard !sound.paused else { return }
325
+ sound.interrupted = true
326
+ sound.player.pause()
327
+ }
328
+ return
329
+ }
330
+ guard type == AVAudioSession.InterruptionType.ended.rawValue else { return }
331
+ let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? NSNumber)?.uintValue ?? 0
332
+ let shouldResume = options & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0
333
+ self.sounds.values.forEach { sound in
334
+ guard sound.interrupted else { return }
335
+ sound.interrupted = false
336
+ // Without permission to auto-resume, keep a resumable paused
337
+ // handle. A later unrelated interruption must not restart it.
338
+ sound.paused = !shouldResume
339
+ if shouldResume { sound.player.play() }
340
+ }
341
+ }
342
+ }
343
+
344
+ deinit {
345
+ if let interruptionObserver { NotificationCenter.default.removeObserver(interruptionObserver) }
346
+ }
347
+
312
348
  /// Starts `url`. `answer` hears once it plays (or could not be loaded).
313
349
  func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
314
350
  stop(id: id, fadeOut: 0)
@@ -362,40 +398,52 @@ final class CraftNativeSounds {
362
398
  if fadeIn > 0 { ramp(sound, to: volume, over: fadeIn) }
363
399
  }
364
400
 
365
- func pause(id: String) {
366
- guard let sound = sounds[id] else { return }
401
+ @discardableResult
402
+ func pause(id: String) -> Bool {
403
+ guard let sound = sounds[id] else { return false }
367
404
  sound.paused = true
405
+ sound.interrupted = false
368
406
  sound.fade?.invalidate()
369
407
  sound.player.pause()
408
+ return true
370
409
  }
371
410
 
372
- func resume(id: String) {
373
- guard let sound = sounds[id], sound.paused else { return }
411
+ @discardableResult
412
+ func resume(id: String) -> Bool {
413
+ guard let sound = sounds[id], sound.paused else { return false }
374
414
  sound.paused = false
415
+ sound.interrupted = false
375
416
  sound.player.volume = sound.volume
376
417
  sound.player.play()
418
+ return true
377
419
  }
378
420
 
379
421
  /// Changes the volume, over `fade` seconds when given.
380
- func setVolume(id: String, volume: Double, fade: Double) {
381
- guard let sound = sounds[id] else { return }
422
+ @discardableResult
423
+ func setVolume(id: String, volume: Double, fade: Double) -> Bool {
424
+ guard let sound = sounds[id] else { return false }
382
425
  sound.volume = Float(min(max(volume, 0), 1))
383
426
  if fade > 0, !sound.paused { ramp(sound, to: sound.volume, over: fade) }
384
427
  else { sound.fade?.invalidate(); sound.player.volume = sound.volume }
428
+ return true
385
429
  }
386
430
 
387
431
  /// Stops the sound, fading it out over `fadeOut` seconds first.
388
- func stop(id: String, fadeOut: Double) {
389
- guard let sound = sounds[id] else { return }
390
- guard fadeOut > 0, !sound.paused else { remove(id); return }
432
+ @discardableResult
433
+ func stop(id: String, fadeOut: Double) -> Bool {
434
+ guard let sound = sounds[id] else { return false }
435
+ guard fadeOut > 0, !sound.paused else { remove(id); return true }
391
436
  ramp(sound, to: 0, over: fadeOut) { [weak self, weak sound] in
392
437
  guard let self, let sound, self.sounds[id] === sound else { return }
393
438
  self.remove(id)
394
439
  }
440
+ return true
395
441
  }
396
442
 
397
- func stopAll(fadeOut: Double) {
443
+ @discardableResult
444
+ func stopAll(fadeOut: Double) -> Bool {
398
445
  for id in Array(sounds.keys) { stop(id: id, fadeOut: fadeOut) }
446
+ return true
399
447
  }
400
448
 
401
449
  /// The screen closed: everything stops at once.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.355",
3
+ "version": "0.0.359",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",