@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/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`, `Image` (image.ts, on a bridge with `probeImage`) with the
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
- define('navigator', {
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) { ChildrenView(node: node) }
191
+ Slider(value: value, in: range, step: step, label: { ChildrenView(node: node) }, onEditingChanged: editing)
190
192
  } else {
191
- Slider(value: value, in: range) { ChildrenView(node: node) }
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 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