@swiftbrowser/native 0.17.6 → 0.17.7
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 +22 -0
- package/dist/bridge.d.ts +9 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- 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/runtime.d.ts +2 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +14 -3
- package/dist/runtime.js.map +1 -1
- package/package.json +3 -3
- package/src/bridge.ts +9 -1
- package/src/index.ts +1 -0
- package/src/mediaSession.ts +163 -0
- package/src/runtime.ts +13 -3
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +14 -0
- package/swift/Sources/SwiftBrowserNative/Host/NowPlayingBridge.swift +190 -0
- package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +2 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +2 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +6 -4
- 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
package/src/runtime.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* `atob`, `btoa`, `fetch` with `Headers`, `Request`, `Response`,
|
|
12
12
|
* `AbortController` and `AbortSignal`, `URL`, `URLSearchParams`, `crypto`
|
|
13
13
|
* (`getRandomValues`, `randomUUID`), `localStorage`, `structuredClone`,
|
|
14
|
-
* `navigator
|
|
14
|
+
* `navigator` (with `mediaSession` and `MediaMetadata` on a bridge with
|
|
15
|
+
* `mediaSession`, mediaSession.ts), `Image` (image.ts, on a bridge with `probeImage`) with the
|
|
15
16
|
* bridge's `__sbImageProbed`, `Worker` (worker.ts) with the bridge's
|
|
16
17
|
* `__sbWorkerMessage` and `__sbWorkerError`, `__sbOpenURL` (openURL.ts),
|
|
17
18
|
* and the bridge's callbacks `__sbTimerFired` and `__sbFetchResult`. In a
|
|
@@ -28,6 +29,7 @@ import { createFetch } from './fetch';
|
|
|
28
29
|
import { createImageGlobals } from './image';
|
|
29
30
|
import { SimpleURL, SimpleURLSearchParams } from './url';
|
|
30
31
|
import { installWorkerScope, NativeWorker } from './worker';
|
|
32
|
+
import { createMediaSession, NativeMediaMetadata } from './mediaSession';
|
|
31
33
|
import { receiveOpenURL } from './openURL';
|
|
32
34
|
|
|
33
35
|
type AnyGlobal = Record<string, unknown>;
|
|
@@ -207,14 +209,22 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
207
209
|
|
|
208
210
|
define('structuredClone', <T>(value: T): T => (value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T)));
|
|
209
211
|
|
|
210
|
-
|
|
212
|
+
const navigator: Record<string, unknown> = {
|
|
211
213
|
userAgent: 'SwiftBrowserNative',
|
|
212
214
|
language: native.locale().replace('_', '-'),
|
|
213
215
|
languages: [native.locale().replace('_', '-')],
|
|
214
216
|
onLine: true,
|
|
215
217
|
platform: 'iOS',
|
|
216
218
|
hardwareConcurrency: 1,
|
|
217
|
-
}
|
|
219
|
+
};
|
|
220
|
+
// What plays, for the lock screen and the media keys (mediaSession.ts); not in a worker.
|
|
221
|
+
const mediaSession = native.mediaSession && !native.workerPostMessage ? createMediaSession(native) : null;
|
|
222
|
+
if (mediaSession) navigator['mediaSession'] = mediaSession.session;
|
|
223
|
+
define('navigator', navigator);
|
|
224
|
+
if (mediaSession) {
|
|
225
|
+
define('MediaMetadata', NativeMediaMetadata);
|
|
226
|
+
define('__sbMediaSessionCommand', mediaSession.receive);
|
|
227
|
+
}
|
|
218
228
|
|
|
219
229
|
if (native.workerPostMessage) {
|
|
220
230
|
installed.push(...installWorkerScope(native, target));
|
|
@@ -28,6 +28,7 @@ import FoundationBridge // the shim's Foundation stand-ins (ShimAliases.swift)
|
|
|
28
28
|
/// | `openURL(url)` | the system opener |
|
|
29
29
|
/// | `locale()` | the viewer's locale identifier |
|
|
30
30
|
/// | `launchArguments()` | the process's arguments (`-screen list` from a scheme or `simctl launch`) |
|
|
31
|
+
/// | `mediaSession(update)` | `navigator.mediaSession`: the lock screen's now-playing info and the remote commands the app handles, on MediaPlayer (NowPlayingBridge.swift); a command the viewer gives comes to `__sbMediaSessionCommand(json)` |
|
|
31
32
|
/// | `workerStart(id, name, engine)`, `workerPost(id, text)`, `workerTerminate(id)` | workers (JavaScriptCore only): a `JSContext` on a thread (`"context"`, WorkerHost.swift) or a hidden web view with WebKit's JIT (`"webview"`, WebViewWorkerHost.swift); their messages come to `__sbWorkerMessage(id, text)`, their errors to `__sbWorkerError(id, message)` |
|
|
32
33
|
///
|
|
33
34
|
/// Everything JavaScript-facing runs on the main thread; work that is not
|
|
@@ -42,6 +43,9 @@ final class HostBridge {
|
|
|
42
43
|
#endif
|
|
43
44
|
private let logger = Logger(subsystem: "swiftbrowser", category: "js")
|
|
44
45
|
private let session = URLSession(configuration: .default)
|
|
46
|
+
#if canImport(MediaPlayer)
|
|
47
|
+
private var nowPlaying: NowPlayingBridge?
|
|
48
|
+
#endif
|
|
45
49
|
/// Keys of `localStorage` live under this prefix in `UserDefaults`.
|
|
46
50
|
static let storagePrefix = "swiftbrowser.storage."
|
|
47
51
|
|
|
@@ -192,6 +196,16 @@ final class HostBridge {
|
|
|
192
196
|
|
|
193
197
|
host.install("launchArguments") { _ in .array(ProcessInfo.processInfo.arguments.map(JSONValue.string)) }
|
|
194
198
|
|
|
199
|
+
#if canImport(MediaPlayer)
|
|
200
|
+
host.install("mediaSession") { [weak self] arguments in
|
|
201
|
+
guard let self, let update = arguments.first?.string else { return .string("mediaSession: expected a JSON string") }
|
|
202
|
+
if nowPlaying == nil {
|
|
203
|
+
nowPlaying = NowPlayingBridge { [weak self] command in self?.callIn("__sbMediaSessionCommand", [.string(command.json)]) }
|
|
204
|
+
}
|
|
205
|
+
return nowPlaying?.update(update).map(JSONValue.string)
|
|
206
|
+
}
|
|
207
|
+
#endif
|
|
208
|
+
|
|
195
209
|
#if canImport(JavaScriptCore)
|
|
196
210
|
host.install("workerStart") { [weak self] arguments in
|
|
197
211
|
guard let self, let id = arguments[safe: 0]?.int, let name = arguments[safe: 1]?.string else {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#if canImport(SwiftUI) && canImport(MediaPlayer)
|
|
2
|
+
import Foundation
|
|
3
|
+
import MediaPlayer
|
|
4
|
+
import SwiftBrowserProtocol
|
|
5
|
+
#if canImport(UIKit)
|
|
6
|
+
import UIKit
|
|
7
|
+
#elseif canImport(AppKit)
|
|
8
|
+
import AppKit
|
|
9
|
+
#endif
|
|
10
|
+
#if os(iOS) && canImport(AVFoundation)
|
|
11
|
+
import AVFoundation
|
|
12
|
+
#endif
|
|
13
|
+
#if canImport(FoundationBridge)
|
|
14
|
+
import FoundationBridge
|
|
15
|
+
#endif
|
|
16
|
+
|
|
17
|
+
/// The app's `navigator.mediaSession` (packages/native/src/mediaSession.ts)
|
|
18
|
+
/// on MediaPlayer: each `mediaSession(update)` sets `MPNowPlayingInfoCenter`
|
|
19
|
+
/// (the lock screen's title, artist, album and artwork, the position the
|
|
20
|
+
/// system extrapolates at the rate) and makes `MPRemoteCommandCenter`
|
|
21
|
+
/// listen for exactly the commands the app handles; a command the viewer
|
|
22
|
+
/// gives (a lock screen button, the media keys, the system's player
|
|
23
|
+
/// controls) goes back as `__sbMediaSessionCommand(json)`. The update is
|
|
24
|
+
/// the one the web renderer's `mediaSession` request carries
|
|
25
|
+
/// (docs/ops-protocol.md, "Now playing"), so an app writes the same code
|
|
26
|
+
/// for the page and the native app. Against the shim (the round trip) the
|
|
27
|
+
/// MediaPlayer here is the shim's, which hands the same to the page.
|
|
28
|
+
@MainActor
|
|
29
|
+
final class NowPlayingBridge {
|
|
30
|
+
private let send: (JSONValue) -> Void
|
|
31
|
+
/// The target added to each command listened for, by the protocol's name.
|
|
32
|
+
private var targets: [String: Any] = [:]
|
|
33
|
+
private var artworkURL: String?
|
|
34
|
+
private var artwork: MPMediaItemArtwork?
|
|
35
|
+
private var artworkLoad: Task<Void, Never>?
|
|
36
|
+
private var info: [String: Any]?
|
|
37
|
+
#if os(iOS)
|
|
38
|
+
private var sessionReady = false
|
|
39
|
+
#endif
|
|
40
|
+
|
|
41
|
+
/// `send` hands a received command to the app.
|
|
42
|
+
init(send: @escaping (JSONValue) -> Void) {
|
|
43
|
+
self.send = send
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Applies one update; the message of what was wrong with it, or nil.
|
|
47
|
+
func update(_ json: String) -> String? {
|
|
48
|
+
let parsed: JSONValue
|
|
49
|
+
do {
|
|
50
|
+
parsed = try JSONValue.parse(json)
|
|
51
|
+
} catch {
|
|
52
|
+
return "mediaSession: \(error)"
|
|
53
|
+
}
|
|
54
|
+
guard let fields = parsed.object else { return "mediaSession: expected an object" }
|
|
55
|
+
apply(fields)
|
|
56
|
+
return nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private func apply(_ fields: JSONObject) {
|
|
60
|
+
let center = MPNowPlayingInfoCenter.default()
|
|
61
|
+
if let metadata = fields.object("metadata") {
|
|
62
|
+
var next: [String: Any] = [:]
|
|
63
|
+
if let title = metadata.string("title") { next[MPMediaItemPropertyTitle] = title }
|
|
64
|
+
if let artist = metadata.string("artist") { next[MPMediaItemPropertyArtist] = artist }
|
|
65
|
+
if let album = metadata.string("album") { next[MPMediaItemPropertyAlbumTitle] = album }
|
|
66
|
+
if let position = fields.object("position") {
|
|
67
|
+
if let duration = position.double("duration") { next[MPMediaItemPropertyPlaybackDuration] = duration }
|
|
68
|
+
if let elapsed = position.double("position") { next[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsed }
|
|
69
|
+
if let rate = position.double("rate") { next[MPNowPlayingInfoPropertyPlaybackRate] = rate }
|
|
70
|
+
}
|
|
71
|
+
if let state = fields.string("playbackState"), fields.object("position") == nil {
|
|
72
|
+
// No position: the state alone says whether it plays, through the rate.
|
|
73
|
+
next[MPNowPlayingInfoPropertyPlaybackRate] = state == "playing" ? 1.0 : 0.0
|
|
74
|
+
}
|
|
75
|
+
info = next
|
|
76
|
+
loadArtwork(metadata.string("artwork"))
|
|
77
|
+
publish()
|
|
78
|
+
} else if fields["metadata"] == .null {
|
|
79
|
+
info = nil
|
|
80
|
+
artwork = nil
|
|
81
|
+
artworkURL = nil
|
|
82
|
+
artworkLoad?.cancel()
|
|
83
|
+
center.nowPlayingInfo = nil
|
|
84
|
+
}
|
|
85
|
+
if let state = fields.string("playbackState") {
|
|
86
|
+
#if os(macOS) || canImport(FoundationBridge)
|
|
87
|
+
center.playbackState = state == "playing" ? .playing : state == "paused" ? .paused : .unknown
|
|
88
|
+
#endif
|
|
89
|
+
#if os(iOS)
|
|
90
|
+
// The lock screen shows the app that plays through an active playback session.
|
|
91
|
+
if state == "playing" { activateAudioSession() }
|
|
92
|
+
#endif
|
|
93
|
+
}
|
|
94
|
+
if let commands = fields.array("commands") {
|
|
95
|
+
listen(for: Set(commands.compactMap(\.string)))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// The info with the artwork, as the center takes it whole.
|
|
100
|
+
private func publish() {
|
|
101
|
+
guard var next = info else { return }
|
|
102
|
+
if let artwork { next[MPMediaItemPropertyArtwork] = artwork }
|
|
103
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = next
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// The artwork at `url` (a `data:` URL at once, a remote one through a fetch), once per URL.
|
|
107
|
+
private func loadArtwork(_ url: String?) {
|
|
108
|
+
guard url != artworkURL else { return }
|
|
109
|
+
artworkURL = url
|
|
110
|
+
artwork = nil
|
|
111
|
+
artworkLoad?.cancel()
|
|
112
|
+
guard let url else { return }
|
|
113
|
+
if url.hasPrefix("data:") {
|
|
114
|
+
if let comma = url.firstIndex(of: ","), let data = Data(base64Encoded: String(url[url.index(after: comma)...])) {
|
|
115
|
+
artwork = Self.artwork(from: data)
|
|
116
|
+
}
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
guard let remote = URL(string: url) else { return }
|
|
120
|
+
artworkLoad = Task { [weak self] in
|
|
121
|
+
guard let (data, _) = try? await URLSession.shared.data(from: remote), !Task.isCancelled else { return }
|
|
122
|
+
guard let self, self.artworkURL == url else { return }
|
|
123
|
+
self.artwork = Self.artwork(from: data)
|
|
124
|
+
self.publish()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private static func artwork(from data: Data) -> MPMediaItemArtwork? {
|
|
129
|
+
#if canImport(UIKit)
|
|
130
|
+
guard let image = UIImage(data: data) else { return nil }
|
|
131
|
+
#elseif canImport(AppKit)
|
|
132
|
+
guard let image = NSImage(data: data) else { return nil }
|
|
133
|
+
#endif
|
|
134
|
+
return MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#if os(iOS)
|
|
138
|
+
private func activateAudioSession() {
|
|
139
|
+
guard !sessionReady else { return }
|
|
140
|
+
sessionReady = true
|
|
141
|
+
let session = AVAudioSession.sharedInstance()
|
|
142
|
+
try? session.setCategory(.playback, mode: .default)
|
|
143
|
+
try? session.setActive(true)
|
|
144
|
+
}
|
|
145
|
+
#endif
|
|
146
|
+
|
|
147
|
+
// MARK: Remote commands
|
|
148
|
+
|
|
149
|
+
/// The protocol's command names and the center's commands.
|
|
150
|
+
private var commands: [(name: String, command: MPRemoteCommand)] {
|
|
151
|
+
let center = MPRemoteCommandCenter.shared()
|
|
152
|
+
return [
|
|
153
|
+
("play", center.playCommand),
|
|
154
|
+
("pause", center.pauseCommand),
|
|
155
|
+
("stop", center.stopCommand),
|
|
156
|
+
("nextTrack", center.nextTrackCommand),
|
|
157
|
+
("previousTrack", center.previousTrackCommand),
|
|
158
|
+
("seekTo", center.changePlaybackPositionCommand),
|
|
159
|
+
("skipForward", center.skipForwardCommand),
|
|
160
|
+
("skipBackward", center.skipBackwardCommand),
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// Listens for exactly `wanted`: a target for each new one, none for the
|
|
165
|
+
/// dropped ones, and every other command disabled (the system enables them
|
|
166
|
+
/// all to begin with; the protocol says the others get no button).
|
|
167
|
+
private func listen(for wanted: Set<String>) {
|
|
168
|
+
for (name, command) in commands {
|
|
169
|
+
let listening = targets[name] != nil
|
|
170
|
+
if wanted.contains(name), !listening {
|
|
171
|
+
if let skip = command as? MPSkipIntervalCommand, skip.preferredIntervals.isEmpty { skip.preferredIntervals = [15] }
|
|
172
|
+
targets[name] = command.addTarget { [weak self] event in
|
|
173
|
+
var fields: JSONObject = ["command": .string(name)]
|
|
174
|
+
if let seek = event as? MPChangePlaybackPositionCommandEvent { fields["position"] = .number(seek.positionTime) }
|
|
175
|
+
if let skip = event as? MPSkipIntervalCommandEvent { fields["interval"] = .number(skip.interval) }
|
|
176
|
+
// Called on the main thread; a hop keeps the app's handler out of the system's callback.
|
|
177
|
+
DispatchQueue.main.async {
|
|
178
|
+
MainActor.assumeIsolated { self?.send(.object(fields)) }
|
|
179
|
+
}
|
|
180
|
+
return .success
|
|
181
|
+
}
|
|
182
|
+
} else if !wanted.contains(name), listening {
|
|
183
|
+
command.removeTarget(targets.removeValue(forKey: name))
|
|
184
|
+
}
|
|
185
|
+
let enabled = wanted.contains(name)
|
|
186
|
+
if command.isEnabled != enabled { command.isEnabled = enabled }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
#endif
|
|
@@ -24,4 +24,6 @@ public typealias HTTPURLResponse = FoundationBridge.HTTPURLResponse
|
|
|
24
24
|
public typealias URLCache = FoundationBridge.URLCache
|
|
25
25
|
public typealias CachedURLResponse = FoundationBridge.CachedURLResponse
|
|
26
26
|
public typealias DispatchQueue = FoundationBridge.DispatchQueue
|
|
27
|
+
// The shim's players post their notifications through the bridge's center.
|
|
28
|
+
public typealias NotificationCenter = FoundationBridge.NotificationCenter
|
|
27
29
|
#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)
|
|
@@ -179,16 +179,18 @@ struct SliderElement: View {
|
|
|
179
179
|
set: { app?.dispatch(.slide(node.id, $0)) }
|
|
180
180
|
)
|
|
181
181
|
let range = props.min...max(props.min, props.max)
|
|
182
|
+
// The drag's start and end, with the value then (`onEditingChanged`).
|
|
183
|
+
let editing = { (editing: Bool) -> Void in app?.dispatch(.slide(node.id, SliderProps(props: node.props).value, editing: editing)) }
|
|
182
184
|
if node.children.isEmpty {
|
|
183
185
|
if let step = props.step, step > 0 {
|
|
184
|
-
Slider(value: value, in: range, step: step)
|
|
186
|
+
Slider(value: value, in: range, step: step, onEditingChanged: editing)
|
|
185
187
|
} else {
|
|
186
|
-
Slider(value: value, in: range)
|
|
188
|
+
Slider(value: value, in: range, onEditingChanged: editing)
|
|
187
189
|
}
|
|
188
190
|
} else if let step = props.step, step > 0 {
|
|
189
|
-
Slider(value: value, in: range, step: step
|
|
191
|
+
Slider(value: value, in: range, step: step, label: { ChildrenView(node: node) }, onEditingChanged: editing)
|
|
190
192
|
} else {
|
|
191
|
-
Slider(value: value, in: range
|
|
193
|
+
Slider(value: value, in: range, label: { ChildrenView(node: node) }, onEditingChanged: editing)
|
|
192
194
|
}
|
|
193
195
|
}
|
|
194
196
|
}
|
|
@@ -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
|