@swiftbrowser/native 0.17.6 → 0.17.8

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.
Files changed (81) hide show
  1. package/README.md +101 -2
  2. package/dist/blob.d.ts +60 -0
  3. package/dist/blob.d.ts.map +1 -0
  4. package/dist/blob.js +153 -0
  5. package/dist/blob.js.map +1 -0
  6. package/dist/bridge.d.ts +47 -1
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +11 -3
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +26 -2
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +49 -0
  14. package/dist/hostRequests.d.ts.map +1 -0
  15. package/dist/hostRequests.js +134 -0
  16. package/dist/hostRequests.js.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/matchMedia.d.ts +43 -0
  22. package/dist/matchMedia.d.ts.map +1 -0
  23. package/dist/matchMedia.js +63 -0
  24. package/dist/matchMedia.js.map +1 -0
  25. package/dist/mediaSession.d.ts +59 -0
  26. package/dist/mediaSession.d.ts.map +1 -0
  27. package/dist/mediaSession.js +119 -0
  28. package/dist/mediaSession.js.map +1 -0
  29. package/dist/notifications.d.ts +108 -0
  30. package/dist/notifications.d.ts.map +1 -0
  31. package/dist/notifications.js +251 -0
  32. package/dist/notifications.js.map +1 -0
  33. package/dist/run.d.ts.map +1 -1
  34. package/dist/run.js +9 -1
  35. package/dist/run.js.map +1 -1
  36. package/dist/runtime.d.ts +3 -1
  37. package/dist/runtime.d.ts.map +1 -1
  38. package/dist/runtime.js +72 -3
  39. package/dist/runtime.js.map +1 -1
  40. package/dist/secureStorage.d.ts +20 -0
  41. package/dist/secureStorage.d.ts.map +1 -0
  42. package/dist/secureStorage.js +28 -0
  43. package/dist/secureStorage.js.map +1 -0
  44. package/dist/subtle.d.ts +38 -0
  45. package/dist/subtle.d.ts.map +1 -0
  46. package/dist/subtle.js +72 -0
  47. package/dist/subtle.js.map +1 -0
  48. package/dist/webSocket.d.ts +73 -0
  49. package/dist/webSocket.d.ts.map +1 -0
  50. package/dist/webSocket.js +145 -0
  51. package/dist/webSocket.js.map +1 -0
  52. package/package.json +3 -3
  53. package/src/blob.ts +186 -0
  54. package/src/bridge.ts +57 -1
  55. package/src/fetch.ts +29 -3
  56. package/src/hostRequests.ts +148 -0
  57. package/src/index.ts +8 -0
  58. package/src/matchMedia.ts +79 -0
  59. package/src/mediaSession.ts +163 -0
  60. package/src/notifications.ts +317 -0
  61. package/src/run.ts +10 -1
  62. package/src/runtime.ts +73 -3
  63. package/src/secureStorage.ts +39 -0
  64. package/src/subtle.ts +87 -0
  65. package/src/webSocket.ts +175 -0
  66. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +180 -0
  67. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +437 -0
  68. package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
  69. package/swift/Sources/SwiftBrowserNative/Host/NowPlayingBridge.swift +190 -0
  70. package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
  71. package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +3 -0
  72. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
  73. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +26 -0
  74. package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
  75. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +2 -0
  76. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +22 -9
  77. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +8 -5
  78. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +304 -0
  79. package/swift/Sources/SwiftBrowserProtocol/Props.swift +43 -0
  80. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +12 -2
  81. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -0,0 +1,118 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ #if canImport(FoundationBridge)
5
+ import FoundationBridge
6
+ #endif
7
+
8
+ /// The app's `WebSocket`s (packages/native/src/webSocket.ts) on
9
+ /// `URLSessionWebSocketTask`: `wsOpen` starts one, which is open once its
10
+ /// first ping is answered (the handshake is done by then), `wsSend` sends
11
+ /// text or bytes, `wsClose` closes it. What a socket receives, and how it
12
+ /// ends, goes to the app as `__sbWebSocketEvent(id, json)`: `open`,
13
+ /// `message` (`text` or base64 `binary`), `error` and `close` (`code`,
14
+ /// `reason`, `wasClean`). Against the shim (the round trip) the task is the
15
+ /// shim's, over the page's WebSocket.
16
+ @MainActor
17
+ final class WebSocketBridge {
18
+ private let session: URLSession
19
+ private let send: (Int, JSONValue) -> Void
20
+ private var tasks: [Int: URLSessionWebSocketTask] = [:]
21
+
22
+ /// `send` hands an event for socket `id` to the app.
23
+ init(session: URLSession, send: @escaping (Int, JSONValue) -> Void) {
24
+ self.session = session
25
+ self.send = send
26
+ }
27
+
28
+ func open(id: Int, url string: String, protocols: [String]) {
29
+ guard let url = URL(string: string) else {
30
+ emit(id, ["type": .string("error"), "message": .string("invalid URL")])
31
+ emit(id, ["type": .string("close"), "code": .number(1006), "reason": .string(""), "wasClean": .bool(false)])
32
+ return
33
+ }
34
+ let task = protocols.isEmpty ? session.webSocketTask(with: url) : session.webSocketTask(with: url, protocols: protocols)
35
+ tasks[id] = task
36
+ task.resume()
37
+ task.sendPing { [weak self] error in
38
+ Task { @MainActor [weak self] in
39
+ guard let self, self.tasks[id] === task else { return }
40
+ if let error {
41
+ self.fail(id: id, task: task, error: error)
42
+ return
43
+ }
44
+ self.emit(id, ["type": .string("open"), "protocol": .string("")])
45
+ self.receive(id: id, task: task)
46
+ }
47
+ }
48
+ }
49
+
50
+ /// `{ text }` or `{ binary }` (base64).
51
+ func send(id: Int, json: String) {
52
+ guard let task = tasks[id], let fields = (try? JSONValue.parse(json))?.object else { return }
53
+ let message: URLSessionWebSocketTask.Message
54
+ if let text = fields["text"]?.string {
55
+ message = .string(text)
56
+ } else if let encoded = fields["binary"]?.string, let data = Data(base64Encoded: encoded) {
57
+ message = .data(data)
58
+ } else {
59
+ return
60
+ }
61
+ task.send(message) { [weak self] error in
62
+ guard let error else { return }
63
+ Task { @MainActor [weak self] in
64
+ guard let self, self.tasks[id] === task else { return }
65
+ self.fail(id: id, task: task, error: error)
66
+ }
67
+ }
68
+ }
69
+
70
+ func close(id: Int, code: Int, reason: String) {
71
+ guard let task = tasks.removeValue(forKey: id) else { return }
72
+ task.cancel(with: URLSessionWebSocketTask.CloseCode(rawValue: code) ?? .normalClosure, reason: reason.isEmpty ? nil : Data(reason.utf8))
73
+ emit(id, ["type": .string("close"), "code": .number(Double(code)), "reason": .string(reason), "wasClean": .bool(true)])
74
+ }
75
+
76
+ /// Ends every socket; the app that owned the bridge is going away.
77
+ func invalidate() {
78
+ for task in tasks.values { task.cancel(with: .goingAway, reason: nil) }
79
+ tasks.removeAll()
80
+ }
81
+
82
+ private func receive(id: Int, task: URLSessionWebSocketTask) {
83
+ task.receive { [weak self] result in
84
+ Task { @MainActor [weak self] in
85
+ guard let self, self.tasks[id] === task else { return }
86
+ switch result {
87
+ case .success(let message):
88
+ switch message {
89
+ case .string(let text): self.emit(id, ["type": .string("message"), "text": .string(text)])
90
+ case .data(let data): self.emit(id, ["type": .string("message"), "binary": .string(data.base64EncodedString())])
91
+ @unknown default: break
92
+ }
93
+ self.receive(id: id, task: task)
94
+ case .failure(let error):
95
+ self.fail(id: id, task: task, error: error)
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ /// The socket ended: a close the server sent (its code and reason), else an error and an unclean close.
102
+ private func fail(id: Int, task: URLSessionWebSocketTask, error: any Error) {
103
+ tasks[id] = nil
104
+ let code = task.closeCode
105
+ if code != .invalid {
106
+ let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? ""
107
+ emit(id, ["type": .string("close"), "code": .number(Double(code.rawValue)), "reason": .string(reason), "wasClean": .bool(true)])
108
+ } else {
109
+ emit(id, ["type": .string("error"), "message": .string(error.localizedDescription)])
110
+ emit(id, ["type": .string("close"), "code": .number(1006), "reason": .string(""), "wasClean": .bool(false)])
111
+ }
112
+ }
113
+
114
+ private func emit(_ id: Int, _ fields: JSONObject) {
115
+ send(id, .object(fields))
116
+ }
117
+ }
118
+ #endif
@@ -61,9 +61,35 @@ public final class NativeApp {
61
61
  #if canImport(JavaScriptCore)
62
62
  (host as? JavaScriptCoreHost)?.onError = { [weak self] error in self?.error = error.description }
63
63
  #endif
64
+ #if canImport(UserNotifications)
65
+ NotificationRouter.shared.installIfApp()
66
+ #endif
64
67
  bridge = HostBridge(host: host, tree: tree)
65
68
  }
66
69
 
70
+ #if canImport(UserNotifications)
71
+ // MARK: Remote notifications
72
+ //
73
+ // What the app delegate is handed (NativeAppDelegate.swift, or the app's
74
+ // own delegate calling these), for `registerForPush` and `onNotification`
75
+ // in `@swiftbrowser/native` (NotificationBridge.swift).
76
+
77
+ /// The device token: `registerForPush` resolves with it, as hex.
78
+ public static func didRegisterForRemoteNotifications(deviceToken: Data) {
79
+ NotificationRouter.shared.registered(token: deviceToken)
80
+ }
81
+
82
+ /// No token: `registerForPush` rejects with the error.
83
+ public static func didFailToRegisterForRemoteNotifications(error: any Error) {
84
+ NotificationRouter.shared.registrationFailed("\(error)")
85
+ }
86
+
87
+ /// A push delivered to the app delegate (a silent one): a `received` event.
88
+ public static func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) {
89
+ NotificationRouter.shared.received(remote: userInfo)
90
+ }
91
+ #endif
92
+
67
93
  /// Evaluates the app's bundle (what `@swiftbrowser/native` built: it calls
68
94
  /// `run(<App/>)` at the end, which renders the first pass into the tree
69
95
  /// before this returns). The environment, if known, is sent right after.
@@ -0,0 +1,36 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit) && canImport(UserNotifications)
2
+ import Foundation
3
+ import UIKit
4
+ import UserNotifications
5
+ #if canImport(FoundationBridge)
6
+ import FoundationBridge
7
+ #endif
8
+
9
+ /// The app delegate a native app adopts for remote notifications, through
10
+ /// `@UIApplicationDelegateAdaptor(NativeAppDelegate.self)` in its App file
11
+ /// (the project `native init` writes does): it hands the device token, a
12
+ /// failure to get one and the pushes delivered to the app to `NativeApp`,
13
+ /// for `registerForPush` and `onNotification` in `@swiftbrowser/native`
14
+ /// (NotificationBridge.swift). An app with a delegate of its own calls the
15
+ /// same `NativeApp` entry points from it.
16
+ open class NativeAppDelegate: UIResponder, UIApplicationDelegate {
17
+ open func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
18
+ // Before launching finishes, so a tap that launched the app is reported.
19
+ NotificationRouter.shared.install()
20
+ return true
21
+ }
22
+
23
+ open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
24
+ NativeApp.didRegisterForRemoteNotifications(deviceToken: deviceToken)
25
+ }
26
+
27
+ open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
28
+ NativeApp.didFailToRegisterForRemoteNotifications(error: error)
29
+ }
30
+
31
+ open func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
32
+ NativeApp.didReceiveRemoteNotification(userInfo: userInfo)
33
+ return .newData
34
+ }
35
+ }
36
+ #endif
@@ -82,6 +82,8 @@ struct ElementView: View {
82
82
  case "toolbar", "searchfield":
83
83
  // Taken by the enclosing screen; in place they draw nothing.
84
84
  return AnyView(EmptyView())
85
+ // Media
86
+ case "video": return AnyView(VideoElement(node: node))
85
87
  // Shapes and colors
86
88
  case "color": return AnyView(ColorElement(node: node))
87
89
  case "shape": return ShapeElement(node: node).reportingSize(of: node)
@@ -71,9 +71,20 @@ struct ToggleElement: View {
71
71
  }
72
72
  }
73
73
 
74
- /// `textfield`: a controlled field. Each keystroke sends `text`; the app's
75
- /// state is the truth the getter reads back, with no frame in between
76
- /// because the dispatch is synchronous.
74
+ /// The `text` event for a write to a field's binding, or nil when the
75
+ /// field's text already is `value`. SwiftUI writes a text binding on a focus
76
+ /// and a commit with the text it has, not only on a keystroke; the page's
77
+ /// renderer sends `text` only for input, and an app that takes every `text`
78
+ /// as an edit (Amindia's Discard button, appearing on a tap into a field)
79
+ /// must hear only changes natively too.
80
+ func textChange(of node: ElementNode, to value: String) -> RendererEvent? {
81
+ (node.props.string("text") ?? "") == value ? nil : .text(node.id, value)
82
+ }
83
+
84
+ /// `textfield`: a controlled field. Each keystroke sends `text` (a write of
85
+ /// the text the field has sends nothing, `textChange`); the app's state is
86
+ /// the truth the getter reads back, with no frame in between because the
87
+ /// dispatch is synchronous.
77
88
  struct TextFieldElement: View {
78
89
  let node: ElementNode
79
90
  @Environment(\.nativeApp) private var app
@@ -83,7 +94,7 @@ struct TextFieldElement: View {
83
94
  let props = TextFieldProps(props: node.props)
84
95
  let text = Binding(
85
96
  get: { TextFieldProps(props: node.props).text },
86
- set: { app?.dispatch(.text(node.id, $0)) }
97
+ set: { if let event = textChange(of: node, to: $0) { app?.dispatch(event) } }
87
98
  )
88
99
  let field: AnyView = {
89
100
  if props.secure { return AnyView(SecureField(props.placeholder, text: text)) }
@@ -133,7 +144,7 @@ struct TextEditorElement: View {
133
144
  var body: some View {
134
145
  TextEditor(text: Binding(
135
146
  get: { node.props.string("text") ?? "" },
136
- set: { app?.dispatch(.text(node.id, $0)) }
147
+ set: { if let event = textChange(of: node, to: $0) { app?.dispatch(event) } }
137
148
  ))
138
149
  }
139
150
  }
@@ -179,16 +190,18 @@ struct SliderElement: View {
179
190
  set: { app?.dispatch(.slide(node.id, $0)) }
180
191
  )
181
192
  let range = props.min...max(props.min, props.max)
193
+ // The drag's start and end, with the value then (`onEditingChanged`).
194
+ let editing = { (editing: Bool) -> Void in app?.dispatch(.slide(node.id, SliderProps(props: node.props).value, editing: editing)) }
182
195
  if node.children.isEmpty {
183
196
  if let step = props.step, step > 0 {
184
- Slider(value: value, in: range, step: step)
197
+ Slider(value: value, in: range, step: step, onEditingChanged: editing)
185
198
  } else {
186
- Slider(value: value, in: range)
199
+ Slider(value: value, in: range, onEditingChanged: editing)
187
200
  }
188
201
  } else if let step = props.step, step > 0 {
189
- Slider(value: value, in: range, step: step) { ChildrenView(node: node) }
202
+ Slider(value: value, in: range, step: step, label: { ChildrenView(node: node) }, onEditingChanged: editing)
190
203
  } else {
191
- Slider(value: value, in: range) { ChildrenView(node: node) }
204
+ Slider(value: value, in: range, label: { ChildrenView(node: node) }, onEditingChanged: editing)
192
205
  }
193
206
  }
194
207
  }
@@ -204,8 +204,11 @@ struct ScreenToolbar: ToolbarContent {
204
204
  }
205
205
 
206
206
  /// One `toolbar` element: a `ToolbarSpacer` for a `spacer` (iOS 26; a
207
- /// `Spacer` item before it), else an item with the element's children,
208
- /// without the bar's shared glass when `sharedBackground` is `hidden` (iOS 26).
207
+ /// `Spacer` item before it), else the element's children as items of its
208
+ /// placement (a `ToolbarItemGroup`: the protocol's `toolbar` holds the
209
+ /// items of one run, as the page lays them out side by side; in one
210
+ /// `ToolbarItem` iOS 26 shows only the first), without the bar's shared
211
+ /// glass when `sharedBackground` is `hidden` (iOS 26).
209
212
  struct ToolbarElement: ToolbarContent {
210
213
  let node: ElementNode
211
214
 
@@ -220,12 +223,12 @@ struct ToolbarElement: ToolbarContent {
220
223
  }
221
224
  } else if props.sharedBackground == "hidden" {
222
225
  if #available(iOS 26, macOS 26, *) {
223
- ToolbarItem(placement: placement) { ChildrenView(node: node) }.sharedBackgroundVisibility(.hidden)
226
+ ToolbarItemGroup(placement: placement) { ChildrenView(node: node) }.sharedBackgroundVisibility(.hidden)
224
227
  } else {
225
- ToolbarItem(placement: placement) { ChildrenView(node: node) }
228
+ ToolbarItemGroup(placement: placement) { ChildrenView(node: node) }
226
229
  }
227
230
  } else {
228
- ToolbarItem(placement: placement) { ChildrenView(node: node) }
231
+ ToolbarItemGroup(placement: placement) { ChildrenView(node: node) }
229
232
  }
230
233
  }
231
234
  }
@@ -0,0 +1,304 @@
1
+ #if canImport(SwiftUI) && canImport(AVFoundation)
2
+ import AVFoundation
3
+ import AVKit
4
+ import Foundation
5
+ import SwiftBrowserProtocol
6
+ import SwiftBrowserTree
7
+ import SwiftUI
8
+ #if canImport(FoundationBridge)
9
+ import FoundationBridge
10
+ #endif
11
+ #if canImport(UIKit)
12
+ import UIKit
13
+ #endif
14
+
15
+ /// `video` (docs/ops-protocol.md, "Video and audio"): an `AVPlayer` over the
16
+ /// element's URL, drawn by AVKit's `VideoPlayer` with the system's controls
17
+ /// (`controls`), else by a bare player layer at the element's `gravity`.
18
+ /// The props drive the player (the item, mute, volume, rate, play and
19
+ /// pause, each `seek` serial once) and the player reports back with the
20
+ /// `video` events the page sends: `ready` with the duration and size,
21
+ /// `time` while playing (every `report` seconds), `status`, `seeked`,
22
+ /// `ended`, `error`, and `play` / `pause` when the viewer uses the
23
+ /// controls. An element at no size plays its audio with nothing on
24
+ /// screen, as the page's hidden element does for a player shown nowhere.
25
+ ///
26
+ /// Built against the shim too (the round trip): the player there is the
27
+ /// shim's, which plays in the page; what iOS alone has (watching the
28
+ /// player's own state for the viewer's play and pause) is gated.
29
+ struct VideoElement: View {
30
+ let node: ElementNode
31
+ @Environment(\.nativeApp) private var app
32
+ @State private var controller = VideoController()
33
+
34
+ var body: some View {
35
+ let props = VideoProps(props: node.props)
36
+ content(props)
37
+ .background(Color.black)
38
+ .onAppear {
39
+ controller.attach(id: node.id, app: app)
40
+ controller.apply(props)
41
+ }
42
+ .onChange(of: props) { _, now in controller.apply(now) }
43
+ .onDisappear { controller.detach() }
44
+ }
45
+
46
+ @ViewBuilder private func content(_ props: VideoProps) -> some View {
47
+ // A finite proposal is taken as is; an axis left open follows the
48
+ // video's aspect ratio (16:9 before the metadata), as on the page.
49
+ let ratio = (props.width ?? 0) > 0 && (props.height ?? 0) > 0 ? props.width! / props.height! : 16.0 / 9.0
50
+ if props.controls {
51
+ VideoPlayer(player: controller.player).aspectRatio(ratio, contentMode: .fit)
52
+ } else {
53
+ PlayerLayerView(player: controller.player, gravity: props.gravity).aspectRatio(ratio, contentMode: props.gravity == "resizeAspect" ? .fit : .fill)
54
+ }
55
+ }
56
+ }
57
+
58
+ /// A bare `AVPlayerLayer` (no controls) at the element's gravity.
59
+ #if canImport(UIKit)
60
+ private struct PlayerLayerView: UIViewRepresentable {
61
+ let player: AVPlayer
62
+ let gravity: String
63
+
64
+ final class LayerView: UIView {
65
+ override class var layerClass: AnyClass { AVPlayerLayer.self }
66
+ var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
67
+ }
68
+
69
+ func makeUIView(context: Context) -> LayerView {
70
+ let view = LayerView()
71
+ view.playerLayer.player = player
72
+ view.playerLayer.videoGravity = videoGravity
73
+ return view
74
+ }
75
+
76
+ func updateUIView(_ view: LayerView, context: Context) {
77
+ if view.playerLayer.player !== player { view.playerLayer.player = player }
78
+ if view.playerLayer.videoGravity != videoGravity { view.playerLayer.videoGravity = videoGravity }
79
+ }
80
+
81
+ private var videoGravity: AVLayerVideoGravity {
82
+ switch gravity {
83
+ case "resizeAspectFill": .resizeAspectFill
84
+ case "resize": .resize
85
+ default: .resizeAspect
86
+ }
87
+ }
88
+ }
89
+ #else
90
+ /// macOS: AVKit's player view, which has no controls option; the protocol's `controls` is for iOS and the page.
91
+ private struct PlayerLayerView: View {
92
+ let player: AVPlayer
93
+ let gravity: String
94
+ var body: some View { VideoPlayer(player: player) }
95
+ }
96
+ #endif
97
+
98
+ /// The player behind one `video` element, kept across the view's renders:
99
+ /// applies each change of props and reports the player's events.
100
+ @MainActor
101
+ final class VideoController {
102
+ let player = AVPlayer()
103
+ private var id = 0
104
+ private weak var app: NativeApp?
105
+ private var applied: VideoProps?
106
+ private var url: String?
107
+ private var timeObserver: Any?
108
+ private var notifications: [Any] = []
109
+ private var loading: Task<Void, Never>?
110
+ private var ready = false
111
+ private var ended = false
112
+ #if !canImport(FoundationBridge)
113
+ private var stateObservation: NSKeyValueObservation?
114
+ #endif
115
+
116
+ func attach(id: Int, app: NativeApp?) {
117
+ self.id = id
118
+ self.app = app
119
+ }
120
+
121
+ /// Carries the props out, in the page's order: the item, mute and volume
122
+ /// (before any play), rate, a new seek, then play or pause.
123
+ func apply(_ props: VideoProps) {
124
+ if props.url != url { replace(props.url) }
125
+ if player.isMuted != props.muted { player.isMuted = props.muted }
126
+ let volume = Float(props.volume)
127
+ if player.volume != volume { player.volume = volume }
128
+ if props.report != applied?.report { observeTime(every: props.report ?? 0.25) }
129
+ if props.seekSerial != applied?.seekSerial {
130
+ seek(to: props.seekTime, serial: props.seekSerial)
131
+ }
132
+ let wasPlaying = applied?.playing ?? false
133
+ if props.playing, props.url != nil {
134
+ let rate = Float(props.rate > 0 ? props.rate : 1)
135
+ if player.rate != rate {
136
+ ended = false
137
+ player.rate = rate
138
+ }
139
+ if !wasPlaying { report("status", ["status": .string(ready ? "playing" : "waiting"), "time": .number(time)]) }
140
+ } else if player.rate != 0 {
141
+ player.pause()
142
+ if wasPlaying { report("status", ["status": .string("paused"), "time": .number(time)]) }
143
+ }
144
+ applied = props
145
+ }
146
+
147
+ func detach() {
148
+ loading?.cancel()
149
+ loading = nil
150
+ player.pause()
151
+ unobserve()
152
+ if let timeObserver {
153
+ player.removeTimeObserver(timeObserver)
154
+ self.timeObserver = nil
155
+ }
156
+ applied = nil
157
+ }
158
+
159
+ private var time: Double {
160
+ let seconds = player.currentTime().seconds
161
+ return seconds.isFinite ? seconds : 0
162
+ }
163
+
164
+ /// A new URL: a new item, watched for its end and its failure, probed for its metadata.
165
+ private func replace(_ url: String?) {
166
+ loading?.cancel()
167
+ unobserve()
168
+ self.url = url
169
+ ready = false
170
+ ended = false
171
+ guard let url, let parsed = URL(string: url) else {
172
+ player.replaceCurrentItem(with: nil)
173
+ if let url { report("error", ["url": .string(url), "code": .number(4), "message": .string("not a URL: \(url)")]) }
174
+ return
175
+ }
176
+ let item = AVPlayerItem(url: parsed)
177
+ player.replaceCurrentItem(with: item)
178
+ let center = NotificationCenter.default
179
+ notifications.append(center.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main) { [weak self] _ in
180
+ Task { @MainActor in self?.playedToEnd(url) }
181
+ })
182
+ notifications.append(center.addObserver(forName: .AVPlayerItemFailedToPlayToEndTime, object: item, queue: .main) { [weak self] notification in
183
+ let message = (notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error)?.localizedDescription ?? "playback failed"
184
+ Task { @MainActor in self?.report("error", ["url": .string(url), "code": .number(2), "message": .string(message)]) }
185
+ })
186
+ notifications.append(center.addObserver(forName: .AVPlayerItemPlaybackStalled, object: item, queue: .main) { [weak self] _ in
187
+ Task { @MainActor in
188
+ guard let self else { return }
189
+ self.report("status", ["status": .string("waiting"), "time": .number(self.time)])
190
+ }
191
+ })
192
+ #if !canImport(FoundationBridge)
193
+ // The viewer's own play and pause (the system's controls), as changes the props did not ask for.
194
+ stateObservation = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
195
+ let status = player.timeControlStatus
196
+ Task { @MainActor in self?.playerChanged(to: status, url: url) }
197
+ }
198
+ #endif
199
+ loading = Task { [weak self] in
200
+ await self?.probe(item, url: url)
201
+ }
202
+ }
203
+
204
+ #if !canImport(FoundationBridge)
205
+ /// The player's state changed: the viewer's play or pause when the props
206
+ /// asked for the opposite (a pause at the item's end is the end, not the
207
+ /// viewer), else playback starting as asked.
208
+ private func playerChanged(to status: AVPlayer.TimeControlStatus, url: String) {
209
+ guard let applied, self.url == url else { return }
210
+ switch status {
211
+ case .playing where !applied.playing:
212
+ report("play", ["time": .number(time)])
213
+ case .paused where applied.playing && !ended && ready && !atEnd:
214
+ report("pause", ["time": .number(time)])
215
+ case .playing where applied.playing:
216
+ report("status", ["status": .string("playing"), "time": .number(time)])
217
+ default:
218
+ break
219
+ }
220
+ }
221
+
222
+ /// Whether the item is at (or within a frame of) its end.
223
+ private var atEnd: Bool {
224
+ guard let duration = player.currentItem?.duration, duration.isNumeric else { return false }
225
+ return time >= duration.seconds - 0.05
226
+ }
227
+ #endif
228
+
229
+ /// `ready`: the duration and the video's size, once the asset says; `error` when it cannot be read.
230
+ private func probe(_ item: AVPlayerItem, url: String) async {
231
+ let asset = item.asset
232
+ do {
233
+ let duration = try await asset.load(.duration)
234
+ var width = 0.0
235
+ var height = 0.0
236
+ if let track = try await asset.loadTracks(withMediaType: .video).first {
237
+ let size = try await track.load(.naturalSize)
238
+ width = size.width
239
+ height = size.height
240
+ }
241
+ guard !Task.isCancelled, self.url == url else { return }
242
+ ready = true
243
+ let seconds = duration.seconds
244
+ report("ready", [
245
+ "url": .string(url),
246
+ "duration": seconds.isFinite && duration.isNumeric ? .number(seconds) : .null,
247
+ "width": .number(width),
248
+ "height": .number(height),
249
+ "time": .number(time),
250
+ ])
251
+ if applied?.playing == true { report("status", ["status": .string("playing"), "time": .number(time)]) }
252
+ } catch {
253
+ guard !Task.isCancelled, self.url == url else { return }
254
+ report("error", ["url": .string(url), "code": .number(4), "message": .string(error.localizedDescription)])
255
+ }
256
+ }
257
+
258
+ private func seek(to seconds: Double, serial: Int) {
259
+ ended = false
260
+ player.seek(to: CMTime(seconds: seconds, preferredTimescale: 600)) { [weak self] finished in
261
+ guard finished else { return }
262
+ Task { @MainActor in
263
+ guard let self else { return }
264
+ self.report("seeked", ["serial": .number(Double(serial)), "time": .number(self.time)])
265
+ }
266
+ }
267
+ }
268
+
269
+ private func playedToEnd(_ url: String) {
270
+ if applied?.loop == true {
271
+ // The item plays again from its start, as a looper would.
272
+ player.seek(to: .zero)
273
+ if applied?.playing == true { player.rate = Float(applied?.rate ?? 1) }
274
+ } else {
275
+ ended = true
276
+ }
277
+ report("ended", ["url": .string(url), "time": .number(time)])
278
+ }
279
+
280
+ private func observeTime(every interval: Double) {
281
+ if let timeObserver { player.removeTimeObserver(timeObserver) }
282
+ let step = max(0.05, interval)
283
+ timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: step, preferredTimescale: 600), queue: .main) { [weak self] time in
284
+ Task { @MainActor in
285
+ guard let self, self.applied?.playing == true else { return }
286
+ let seconds = time.seconds
287
+ self.report("time", ["time": .number(seconds.isFinite ? seconds : 0)])
288
+ }
289
+ }
290
+ }
291
+
292
+ private func unobserve() {
293
+ for token in notifications { NotificationCenter.default.removeObserver(token) }
294
+ notifications = []
295
+ #if !canImport(FoundationBridge)
296
+ stateObservation = nil
297
+ #endif
298
+ }
299
+
300
+ private func report(_ event: String, _ fields: JSONObject) {
301
+ app?.dispatch(.video(id, event, fields))
302
+ }
303
+ }
304
+ #endif
@@ -385,6 +385,49 @@ public struct StepperProps: Equatable, Sendable {
385
385
  }
386
386
  }
387
387
 
388
+ /// `video` (docs/ops-protocol.md, "Video and audio"): a view of an `AVPlayer`, as the app works its props out.
389
+ public struct VideoProps: Equatable, Sendable {
390
+ /// The media URL; nil while there is none.
391
+ public var url: String?
392
+ /// Whether the player plays here (only its newest view plays it).
393
+ public var playing: Bool
394
+ public var muted: Bool
395
+ /// 0…1.
396
+ public var volume: Double
397
+ /// The playback rate (the default rate while paused).
398
+ public var rate: Double
399
+ public var loop: Bool
400
+ /// `resizeAspect`, `resizeAspectFill` or `resize`.
401
+ public var gravity: String
402
+ /// The system's playback controls.
403
+ public var controls: Bool
404
+ /// Seconds; seek once per serial, reporting `seeked` with it.
405
+ public var seekTime: Double
406
+ public var seekSerial: Int
407
+ /// Report the position this often (seconds) while playing.
408
+ public var report: Double?
409
+ /// The video's pixel size once known: the aspect ratio of an axis left open.
410
+ public var width: Double?
411
+ public var height: Double?
412
+
413
+ public init(props: JSONObject) {
414
+ url = props.string("url")
415
+ playing = props.bool("playing") ?? false
416
+ muted = props.bool("muted") ?? false
417
+ volume = props.double("volume") ?? 1
418
+ rate = props.double("rate") ?? 1
419
+ loop = props.bool("loop") ?? false
420
+ gravity = props.string("gravity") ?? "resizeAspect"
421
+ controls = props.bool("controls") ?? true
422
+ let seek = props.object("seek")
423
+ seekTime = seek?.double("time") ?? 0
424
+ seekSerial = seek?.int("serial") ?? 0
425
+ report = props.double("report")
426
+ width = props.double("width")
427
+ height = props.double("height")
428
+ }
429
+ }
430
+
388
431
  public struct SliderProps: Equatable, Sendable {
389
432
  public var value: Double
390
433
  public var min: Double
@@ -57,8 +57,18 @@ public struct RendererEvent: Equatable, Sendable {
57
57
  }
58
58
  /// A `stepper` half was tapped (`value` is ±1).
59
59
  public static func step(_ id: Int, _ delta: Int) -> RendererEvent { with("step", id: id, ["value": .number(Double(delta))]) }
60
- /// A `slider` moved.
61
- public static func slide(_ id: Int, _ value: Double) -> RendererEvent { with("slide", id: id, ["value": .number(value)]) }
60
+ /// A `slider` moved; `editing` true with a drag's first value and false with its last (`onEditingChanged`), nil between.
61
+ public static func slide(_ id: Int, _ value: Double, editing: Bool? = nil) -> RendererEvent {
62
+ var fields: JSONObject = ["value": .number(value)]
63
+ if let editing { fields["editing"] = .bool(editing) }
64
+ return with("slide", id: id, fields)
65
+ }
66
+ /// A `video` element's playback (docs/ops-protocol.md, "`video` events"): `event` names it (`ready`, `time`, `status`, `seeked`, `play`, `pause`, `volume`, `rate`, `ended`, `error`), `fields` are its own.
67
+ public static func video(_ id: Int, _ event: String, _ fields: JSONObject = [:]) -> RendererEvent {
68
+ var all = fields
69
+ all["event"] = .string(event)
70
+ return with("video", id: id, all)
71
+ }
62
72
  /// A `datepicker` changed; `seconds` since 1970.
63
73
  public static func date(_ id: Int, seconds: Double) -> RendererEvent { with("date", id: id, ["value": .number(seconds)]) }
64
74
  /// A `textfield` with `focused` gained or lost focus.