@swiftbrowser/swift 0.5.1 → 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.
- package/Sources/AVFoundation/Player.swift +53 -7
- package/Sources/AVFoundation/PlayerView.swift +14 -1
- package/Sources/SwiftUI/API/Accessibility.swift +7 -4
- package/Sources/SwiftUI/Runtime/BackgroundViews.swift +56 -0
- package/Sources/SwiftUI/Runtime/Reconciler.swift +2 -1
- package/Sources/SwiftUI/Runtime/Runtime.swift +29 -0
- package/package.json +1 -1
|
@@ -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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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
|
|
72
|
-
/// element by it
|
|
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
|
|
156
|
-
///
|
|
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
|
|
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.
|
|
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",
|