@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.
- package/README.md +101 -2
- package/dist/blob.d.ts +60 -0
- package/dist/blob.d.ts.map +1 -0
- package/dist/blob.js +153 -0
- package/dist/blob.js.map +1 -0
- package/dist/bridge.d.ts +47 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/fetch.d.ts +11 -3
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +26 -2
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +49 -0
- package/dist/hostRequests.d.ts.map +1 -0
- package/dist/hostRequests.js +134 -0
- package/dist/hostRequests.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/matchMedia.d.ts +43 -0
- package/dist/matchMedia.d.ts.map +1 -0
- package/dist/matchMedia.js +63 -0
- package/dist/matchMedia.js.map +1 -0
- package/dist/mediaSession.d.ts +59 -0
- package/dist/mediaSession.d.ts.map +1 -0
- package/dist/mediaSession.js +119 -0
- package/dist/mediaSession.js.map +1 -0
- package/dist/notifications.d.ts +108 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +251 -0
- package/dist/notifications.js.map +1 -0
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +9 -1
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +72 -3
- package/dist/runtime.js.map +1 -1
- package/dist/secureStorage.d.ts +20 -0
- package/dist/secureStorage.d.ts.map +1 -0
- package/dist/secureStorage.js +28 -0
- package/dist/secureStorage.js.map +1 -0
- package/dist/subtle.d.ts +38 -0
- package/dist/subtle.d.ts.map +1 -0
- package/dist/subtle.js +72 -0
- package/dist/subtle.js.map +1 -0
- package/dist/webSocket.d.ts +73 -0
- package/dist/webSocket.d.ts.map +1 -0
- package/dist/webSocket.js +145 -0
- package/dist/webSocket.js.map +1 -0
- package/package.json +3 -3
- package/src/blob.ts +186 -0
- package/src/bridge.ts +57 -1
- package/src/fetch.ts +29 -3
- package/src/hostRequests.ts +148 -0
- package/src/index.ts +8 -0
- package/src/matchMedia.ts +79 -0
- package/src/mediaSession.ts +163 -0
- package/src/notifications.ts +317 -0
- package/src/run.ts +10 -1
- package/src/runtime.ts +73 -3
- package/src/secureStorage.ts +39 -0
- package/src/subtle.ts +87 -0
- package/src/webSocket.ts +175 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +180 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +437 -0
- package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
- package/swift/Sources/SwiftBrowserNative/Host/NowPlayingBridge.swift +190 -0
- package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
- package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +3 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +26 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +2 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +22 -9
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +8 -5
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +304 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +43 -0
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +12 -2
- 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
|
-
/// `
|
|
75
|
-
///
|
|
76
|
-
///
|
|
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: {
|
|
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: {
|
|
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
|
|
202
|
+
Slider(value: value, in: range, step: step, label: { ChildrenView(node: node) }, onEditingChanged: editing)
|
|
190
203
|
} else {
|
|
191
|
-
Slider(value: value, in: range
|
|
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
|
|
208
|
-
///
|
|
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
|
-
|
|
226
|
+
ToolbarItemGroup(placement: placement) { ChildrenView(node: node) }.sharedBackgroundVisibility(.hidden)
|
|
224
227
|
} else {
|
|
225
|
-
|
|
228
|
+
ToolbarItemGroup(placement: placement) { ChildrenView(node: node) }
|
|
226
229
|
}
|
|
227
230
|
} else {
|
|
228
|
-
|
|
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
|
|
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.
|