@swiftbrowser/swift 0.5.2 → 0.5.3

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.
@@ -11,7 +11,9 @@
11
11
  //
12
12
  // Of the views showing a player, the newest plays it; the others are paused
13
13
  // and muted, and the one that takes over seeks to where the player is. A
14
- // player shown nowhere plays nothing and keeps its position.
14
+ // player shown nowhere that plays with its sound on plays through a hidden
15
+ // element (`_AVPlayerBackground`, a background view of the runtime's), as
16
+ // iOS plays a player's sound with no layer; muted, it keeps its position.
15
17
 
16
18
  import Foundation
17
19
  import FoundationBridge
@@ -32,6 +34,9 @@ final class _AVPlayerState {
32
34
  var seekSerial = 0
33
35
  /// The views showing the player (`_AVPlayerVideo` surfaces), oldest first; the last plays.
34
36
  var surfaces: [ObjectIdentifier] = []
37
+ /// The hidden surface that plays the player's sound while no view shows
38
+ /// it; always the oldest, so a view that shows the player takes over.
39
+ var backgroundSurface: ObjectIdentifier?
35
40
  /// An `AVPlayerLooper` loops the item (the element's `loop`).
36
41
  var loops = false
37
42
  /// The shortest periodic observer interval below the page's own pace, in seconds.
@@ -255,6 +260,12 @@ open class AVPlayer: @unchecked Sendable {
255
260
  _setItem(item)
256
261
  }
257
262
 
263
+ deinit {
264
+ // A player that goes away stops, as on iOS; its identifier may be reused.
265
+ let owner = ObjectIdentifier(self)
266
+ if _BackgroundViews.shared.contains(owner) { _BackgroundViews.shared.set(owner, nil) }
267
+ }
268
+
258
269
  // MARK: Item
259
270
 
260
271
  open var currentItem: AVPlayerItem? { _state.item }
@@ -272,6 +283,7 @@ open class AVPlayer: @unchecked Sendable {
272
283
  // The new item plays from its own position.
273
284
  resync()
274
285
  if _state.rate != 0 { _state.timeControlStatus = .waitingToPlayAtSpecifiedRate }
286
+ updateBackground()
275
287
  }
276
288
 
277
289
  /// The item ended with `actionAtItemEnd == .advance`: a plain player has
@@ -319,6 +331,7 @@ open class AVPlayer: @unchecked Sendable {
319
331
  } else if !wasPlaying {
320
332
  setControlStatus(.waitingToPlayAtSpecifiedRate)
321
333
  }
334
+ updateBackground()
322
335
  FoundationBridge.NotificationCenter.default.post(name: AVPlayer.rateDidChangeNotification, object: self)
323
336
  }
324
337
 
@@ -335,13 +348,21 @@ open class AVPlayer: @unchecked Sendable {
335
348
 
336
349
  open var isMuted: Bool {
337
350
  get { _state.isMuted }
338
- set { if newValue != _state.isMuted { _state.isMuted = newValue } }
351
+ set {
352
+ guard newValue != _state.isMuted else { return }
353
+ _state.isMuted = newValue
354
+ updateBackground()
355
+ }
339
356
  }
340
357
 
341
358
  /// 0…1, applied on top of mute.
342
359
  open var volume: Float {
343
360
  get { _state.volume }
344
- set { if newValue != _state.volume { _state.volume = newValue } }
361
+ set {
362
+ guard newValue != _state.volume else { return }
363
+ _state.volume = newValue
364
+ updateBackground()
365
+ }
345
366
  }
346
367
 
347
368
  // MARK: Time
@@ -458,15 +479,22 @@ open class AVPlayer: @unchecked Sendable {
458
479
 
459
480
  // MARK: Views
460
481
 
461
- /// A view started showing the player: it plays it from here.
462
- func _attach(_ surface: ObjectIdentifier) {
482
+ /// A view started showing the player: it plays it from here. The hidden
483
+ /// surface (`background`) goes first, behind any view.
484
+ func _attach(_ surface: ObjectIdentifier, background: Bool = false) {
463
485
  let previous = _state.surfaces.last
464
486
  _state.surfaces.removeAll { $0 == surface }
465
- _state.surfaces.append(surface)
466
- if previous != surface {
487
+ if background {
488
+ _state.backgroundSurface = surface
489
+ _state.surfaces.insert(surface, at: 0)
490
+ } else {
491
+ _state.surfaces.append(surface)
492
+ }
493
+ if _state.surfaces.last != previous {
467
494
  freezeTime()
468
495
  resync()
469
496
  }
497
+ updateBackground()
470
498
  }
471
499
 
472
500
  /// A view stopped showing the player.
@@ -474,6 +502,8 @@ open class AVPlayer: @unchecked Sendable {
474
502
  guard let index = _state.surfaces.firstIndex(of: surface) else { return }
475
503
  let wasActive = index == _state.surfaces.count - 1
476
504
  _state.surfaces.remove(at: index)
505
+ if _state.backgroundSurface == surface { _state.backgroundSurface = nil }
506
+ defer { updateBackground() }
477
507
  guard wasActive else { return }
478
508
  freezeTime()
479
509
  for (_, completion) in pendingSeeks { completion(true) }
@@ -485,6 +515,22 @@ open class AVPlayer: @unchecked Sendable {
485
515
  }
486
516
  }
487
517
 
518
+ /// Whether the player needs its hidden surface: it plays with its sound
519
+ /// on and no view shows it.
520
+ private var needsBackground: Bool {
521
+ _state.item != nil && _state.rate > 0 && !_state.isMuted && _state.volume > 0
522
+ && !_state.surfaces.contains { $0 != _state.backgroundSurface }
523
+ }
524
+
525
+ /// Registers the hidden surface with the runtime while the player needs
526
+ /// it, and takes it away once it does not.
527
+ private func updateBackground() {
528
+ let owner = ObjectIdentifier(self)
529
+ let needed = needsBackground
530
+ guard needed != _BackgroundViews.shared.contains(owner) else { return }
531
+ _BackgroundViews.shared.set(owner, needed ? { [weak self] in self.map { AnyView(_AVPlayerBackground(player: $0)) } } : nil)
532
+ }
533
+
488
534
  /// The `video` element's props for one of the views showing the player
489
535
  /// (docs/ops-protocol.md, "Video and audio"). Read in the view's body, so
490
536
  /// the view draws again when any of it changes (the position does not).
@@ -64,6 +64,8 @@ struct _AVPlayerSurfaceView: View {
64
64
  let player: AVPlayer
65
65
  let gravity: AVLayerVideoGravity
66
66
  let controls: Bool
67
+ /// The hidden surface that plays the sound of a player no view shows.
68
+ var background = false
67
69
  @State private var surface = _AVPlayerSurface()
68
70
 
69
71
  var body: some View {
@@ -72,7 +74,7 @@ struct _AVPlayerSurfaceView: View {
72
74
  _VideoElement(props: player._videoProps(surface: id, gravity: gravity, controls: controls)) { [weak player] event in
73
75
  player?._event(event, from: id)
74
76
  }
75
- .onAppear { player._attach(id) }
77
+ .onAppear { player._attach(id, background: background) }
76
78
  .onDisappear { player._detach(id) }
77
79
  }
78
80
  }
@@ -115,3 +117,14 @@ open class AVPlayerLayer: CALayer, @unchecked Sendable {
115
117
  AnyView(_AVPlayerVideo(player: player, gravity: videoGravity, controls: _showsControls))
116
118
  }
117
119
  }
120
+
121
+ /// The hidden surface of a player that plays with its sound on while no view
122
+ /// shows it: the runtime keeps it as a background view (BackgroundViews.swift)
123
+ /// until a view shows the player, it pauses, or it is muted.
124
+ struct _AVPlayerBackground: View {
125
+ let player: AVPlayer
126
+
127
+ var body: some View {
128
+ _AVPlayerSurfaceView(player: player, gravity: .resizeAspect, controls: false, background: true)
129
+ }
130
+ }
@@ -68,8 +68,9 @@ public struct AccessibilityChildBehavior: Hashable, Sendable {
68
68
  }
69
69
 
70
70
  nonisolated extension View {
71
- /// The label is not drawn; headless event scripts can address the
72
- /// element by it (`tap:"Wrong"`, docs/ops-protocol.md).
71
+ /// The label is not drawn: it is the element's `aria-label` on the page,
72
+ /// and headless event scripts can address the element by it
73
+ /// (`tap:"Wrong"`, docs/ops-protocol.md).
73
74
  public func accessibilityLabel(_ label: Text) -> some View {
74
75
  _AccessibilityView(content: self, mark: _AccessibilityMark(label: label.content))
75
76
  }
@@ -152,8 +153,10 @@ nonisolated extension View {
152
153
  // MARK: - Labels and identifiers kept for event scripts
153
154
 
154
155
  /// An `accessibilityLabel` / `accessibilityIdentifier` carried by the
155
- /// elements a view produces. It is not sent to the renderer: headless event
156
- /// scripts resolve selectors against it (Sources/SwiftUI/Runtime/Replay.swift).
156
+ /// elements a view produces: headless event scripts resolve selectors against
157
+ /// it (Sources/SwiftUI/Runtime/Replay.swift). The label is also sent, as the
158
+ /// elements' `accessibilityLabel` prop, which the page makes their
159
+ /// `aria-label`; the identifier is not.
157
160
  struct _AccessibilityMark: Equatable {
158
161
  var label: String?
159
162
  var identifier: String?
@@ -0,0 +1,56 @@
1
+ // Views the page keeps without showing them, for what iOS does with no view
2
+ // on screen: an `AVPlayer` that no view shows still plays its sound, through
3
+ // a hidden `video` element (AVFoundation's PlayerView.swift). A framework
4
+ // registers a view here under an owner; every render draws the registered
5
+ // views after the app's own elements, at zero size, transparent and not
6
+ // hit-testable. The registry is observed, so a change renders again.
7
+
8
+ import Observation
9
+
10
+ @Observable
11
+ public final class _BackgroundViews: @unchecked Sendable {
12
+ public static let shared = _BackgroundViews()
13
+
14
+ struct Entry {
15
+ let id: ObjectIdentifier
16
+ /// The view; nil once its owner is gone.
17
+ let make: () -> AnyView?
18
+ }
19
+
20
+ private(set) var entries: [Entry] = []
21
+
22
+ init() {}
23
+
24
+ /// Registers the view `make` returns under `owner`, replacing what it had;
25
+ /// nil removes it. `make` should hold the owner weakly and return nil once
26
+ /// it is gone.
27
+ public func set(_ owner: ObjectIdentifier, _ make: (() -> AnyView?)?) {
28
+ var entries = self.entries.filter { $0.id != owner }
29
+ if let make { entries.append(Entry(id: owner, make: make)) }
30
+ self.entries = entries
31
+ }
32
+
33
+ /// Whether `owner` has a view registered.
34
+ public func contains(_ owner: ObjectIdentifier) -> Bool {
35
+ entries.contains { $0.id == owner }
36
+ }
37
+
38
+ /// Forgets everything (tests).
39
+ public func reset() {
40
+ entries = []
41
+ }
42
+ }
43
+
44
+ /// The root the runtime renders the registered views from.
45
+ struct _BackgroundView: View {
46
+ var body: some View {
47
+ let views = _BackgroundViews.shared.entries.compactMap { entry in entry.make().map { (id: entry.id, view: $0) } }
48
+ ForEach(views, id: \.id) { entry in
49
+ entry.view
50
+ .frame(width: 0, height: 0)
51
+ .opacity(0)
52
+ .allowsHitTesting(false)
53
+ .accessibilityHidden(true)
54
+ }
55
+ }
56
+ }
@@ -8,7 +8,8 @@ struct _Element {
8
8
  var key: AnyHashable?
9
9
  /// What makes this element a selectable or deletable `List` row (Phase 8).
10
10
  var row: _RowMark?
11
- /// `accessibilityLabel` / `accessibilityIdentifier`, for event scripts only.
11
+ /// `accessibilityLabel` / `accessibilityIdentifier`, for event scripts (the
12
+ /// label is in `props` too, Runtime.swift).
12
13
  var accessibility: _AccessibilityMark?
13
14
  /// `.layoutValue(key:value:)` above this element, by key type: what a
14
15
  /// custom layout's subview reads (CustomLayout.swift).
@@ -248,6 +248,8 @@ public final class _Runtime {
248
248
  private var root: _MountedNode?
249
249
  /// The off-screen element measuring views for `_ViewMeasurements` (Measurements.swift).
250
250
  private var measuringRoot: _MountedNode?
251
+ /// The views frameworks keep without showing them (BackgroundViews.swift).
252
+ private var backgroundRoot: _MountedNode?
251
253
  /// Set when measurements arrive: the next render re-runs every body, none skipped as unchanged.
252
254
  private var renderEverythingPass = false
253
255
  let reconciler = _Reconciler()
@@ -597,6 +599,7 @@ public final class _Runtime {
597
599
  rootView = view
598
600
  root = nil
599
601
  measuringRoot = nil
602
+ backgroundRoot = nil
600
603
  render()
601
604
  }
602
605
 
@@ -810,6 +813,12 @@ public final class _Runtime {
810
813
  measuringRoot, with: _MeasuringView(entries: measuring),
811
814
  context: _ReconcileContext(environment: baseEnvironment, path: _ViewPath(root: _MeasuringView.self)), force: true)
812
815
  measuringRoot = measuringNode
816
+ // Its body reads the registry: a change there renders it again.
817
+ let backgroundNode = reconcile(
818
+ backgroundRoot, with: _BackgroundView(),
819
+ context: _ReconcileContext(environment: baseEnvironment, path: _ViewPath(root: _BackgroundView.self)),
820
+ force: backgroundRoot == nil || force)
821
+ backgroundRoot = backgroundNode
813
822
  renderEverythingPass = false
814
823
  presentedSheets = []
815
824
  presentedAlerts = []
@@ -818,6 +827,7 @@ public final class _Runtime {
818
827
  // read while producing elements (rare) falls back to a full render.
819
828
  let tree = withObservationTracking {
820
829
  elements(of: node) + presentedSheets + presentedAlerts + (measuringNode.map { elements(of: $0) } ?? [])
830
+ + elements(of: backgroundNode)
821
831
  } onChange: { [weak self] in
822
832
  MainActor.assumeIsolated { self?.invalidate() }
823
833
  }
@@ -1711,6 +1721,17 @@ public final class _Runtime {
1711
1721
  let mark = providing._accessibility
1712
1722
  for index in produced.indices {
1713
1723
  produced[index].accessibility = (produced[index].accessibility ?? _AccessibilityMark()).merged(with: mark)
1724
+ // The label is the element's accessible name on the page too
1725
+ // (`aria-label`): an icon-only `Label` in a button is named by
1726
+ // its title. Content that shows the label as text is named by
1727
+ // it already. The innermost label wins; a changed element is a
1728
+ // new value, so the reconciler does not skip it as cached.
1729
+ if let label = mark.label, !label.isEmpty, case .object(var props) = produced[index].props,
1730
+ props["accessibilityLabel"] == nil, !_showsText(label, in: produced[index]) {
1731
+ props["accessibilityLabel"] = .string(label)
1732
+ produced[index].props = .object(props)
1733
+ produced[index].token = _Element.nextToken()
1734
+ }
1714
1735
  }
1715
1736
  }
1716
1737
  if traits.contains(.tabScreen), let tab = node.view as? _TabScreenView, !tab.isSelected {
@@ -1727,6 +1748,14 @@ public final class _Runtime {
1727
1748
  return produced
1728
1749
  }
1729
1750
 
1751
+ /// Whether `element` or an element under it is a `text` showing `text`.
1752
+ private func _showsText(_ text: String, in element: _Element) -> Bool {
1753
+ if element.kind == "text", case .object(let props) = element.props, case .string(let shown)? = props["text"], shown == text {
1754
+ return true
1755
+ }
1756
+ return element.children.contains { _showsText(text, in: $0) }
1757
+ }
1758
+
1730
1759
  // MARK: State snapshot (hot reload)
1731
1760
 
1732
1761
  /// The elements of a node's children, in order. The elements a keyed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/swift",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "The SwiftBrowser Swift package (the SwiftUI shim, Charts, SwiftData and FoundationBridge) as an npm package, so @swiftbrowser/cli builds apps against the exact version it ships with.",
5
5
  "repository": {
6
6
  "type": "git",