@sigx/lynx-video 0.4.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Andreas Ekdahl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @sigx/lynx-video
2
+
3
+ Native video player component for sigx-lynx. iOS uses `AVPlayer` + `AVPlayerLayer`; Android uses `androidx.media3` (`ExoPlayer` + `PlayerView`).
4
+
5
+ Registers a `<video-player>` JSX intrinsic that participates in Lynx's layout tree. The typed `<VideoPlayer>` wrapper is the recommended entry point.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @sigx/lynx-video
11
+ ```
12
+
13
+ `sigx prebuild` auto-discovers the package, registers the `<video-player>` UI element on both platforms, and pulls in the `media3` Gradle deps on Android.
14
+
15
+ ## Usage
16
+
17
+ ```tsx
18
+ import { VideoPlayer } from '@sigx/lynx-video';
19
+
20
+ function ClipScreen() {
21
+ return () => (
22
+ <VideoPlayer
23
+ src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
24
+ autoplay
25
+ controls
26
+ resizeMode="contain"
27
+ onLoad={(e) => console.log('loaded', e.detail.durationMs)}
28
+ onEnd={() => console.log('done')}
29
+ onError={(e) => console.warn(e.detail.message)}
30
+ style={{ width: '100%', aspectRatio: 16 / 9 }}
31
+ />
32
+ );
33
+ }
34
+ ```
35
+
36
+ ## API
37
+
38
+ ### `<VideoPlayer>` props
39
+
40
+ | Prop | Type | Notes |
41
+ | ------------- | ------------------------------------- | ------------------------------------------------------ |
42
+ | `src` | `string` | URL or `file://` URI. Setting reloads the player. |
43
+ | `poster` | `string?` | Image to display before the first frame. |
44
+ | `autoplay` | `boolean?` | Begin playback as soon as the asset is ready. |
45
+ | `playing` | `boolean?` | Drive play/pause declaratively. Re-renders flip state. |
46
+ | `loop` | `boolean?` | Restart automatically at end-of-clip. |
47
+ | `muted` | `boolean?` | Mute audio output. |
48
+ | `volume` | `number?` | 0..1. Independent of `muted`. |
49
+ | `controls` | `boolean?` | Show platform-default playback controls. |
50
+ | `resizeMode` | `'contain' \| 'cover' \| 'stretch'` | Default `'contain'`. |
51
+ | `onLoad` | `(e) => void` | `detail: { durationMs, width, height }` |
52
+ | `onEnd` | `(e) => void` | Playback reached end of clip. |
53
+ | `onError` | `(e) => void` | `detail: { message }` |
54
+ | `onTimeUpdate`| `(e) => void` | ~4×/sec. `detail: { positionMs }` |
55
+
56
+ ## Gotchas
57
+
58
+ - **Imperative methods** (`seek(s)`, `getStatus()`) are tracked as a v2 follow-up — they need Lynx's `UIMethodInvoker` surface, which isn't wired through sigx-lynx yet (same blocker that `WebView.goBack` and `Map.animateToRegion` are waiting on). For now, drive the player declaratively via `playing` / `src` props.
59
+ - **App Transport Security (iOS)** — playing an `http://` (non-HTTPS) URL requires an `NSAppTransportSecurity` exception in your app's `Info.plist`. The package itself does not relax ATS.
60
+ - **Android media3 versions** — pinned to `1.4.1`. If your app already depends on a different `media3` version, align it via Gradle resolution to avoid duplicate-class errors.
61
+ - **AudioSession (iOS)** — when playing audio-bearing video, this component sets `AVAudioSession` to `.playback`. Apps that also use `@sigx/lynx-audio` get a separate session ref-count.
@@ -0,0 +1,19 @@
1
+ package com.sigx.video
2
+
3
+ import com.lynx.tasm.behavior.Behavior
4
+ import com.lynx.tasm.behavior.LynxContext
5
+ import com.lynx.tasm.behavior.ui.LynxUI
6
+
7
+ /**
8
+ * Registers the `<video-player>` JSX tag with Lynx's UI registry.
9
+ *
10
+ * Discovered by the autolinker via `signalx-module.json`'s `android.behaviors`
11
+ * field; the generated `GeneratedBehaviors.attachAll(builder)` calls
12
+ * `builder.addBehavior(VideoPlayerBehavior())` for every `LynxViewBuilder`
13
+ * in the app (production + dev-client path).
14
+ */
15
+ class VideoPlayerBehavior : Behavior("video-player") {
16
+ override fun createUI(context: LynxContext): LynxUI<*> {
17
+ return VideoPlayerUI(context)
18
+ }
19
+ }
@@ -0,0 +1,273 @@
1
+ package com.sigx.video
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import androidx.annotation.OptIn
8
+ import androidx.media3.common.MediaItem
9
+ import androidx.media3.common.PlaybackException
10
+ import androidx.media3.common.Player
11
+ import androidx.media3.common.VideoSize
12
+ import androidx.media3.common.util.UnstableApi
13
+ import androidx.media3.exoplayer.ExoPlayer
14
+ import androidx.media3.ui.AspectRatioFrameLayout
15
+ import androidx.media3.ui.PlayerView
16
+ import com.lynx.tasm.behavior.LynxContext
17
+ import com.lynx.tasm.behavior.LynxProp
18
+ import com.lynx.tasm.behavior.ui.LynxUI
19
+ import com.lynx.tasm.event.LynxDetailEvent
20
+
21
+ /**
22
+ * Native UI for the `<video-player>` JSX element on Android.
23
+ *
24
+ * Backed by `androidx.media3` (`ExoPlayer` + `PlayerView`). The PlayerView
25
+ * is added directly to Lynx's view tree and decoded frames render inside it.
26
+ *
27
+ * Prop / event surface (v1):
28
+ * - `src` → URL or `file://` URI
29
+ * - `poster` → image displayed before the first frame (best-effort)
30
+ * - `autoplay` → start as soon as the asset is ready
31
+ * - `playing` → declarative play/pause toggle
32
+ * - `loop` → restart at end-of-clip
33
+ * - `muted` → mute audio
34
+ * - `volume` → 0..1
35
+ * - `controls` → show PlayerView's built-in transport controls
36
+ * - `resize-mode` → contain | cover | stretch
37
+ * - `bindload` → asset metadata ready
38
+ * - `bindend` → playback finished
39
+ * - `binderror` → load / playback failure
40
+ * - `bindtimeupdate` → ~4×/sec position broadcast
41
+ *
42
+ * Imperative methods (`seek`, `getStatus`) are tracked as a v2 follow-up
43
+ * — same UIMethodInvoker blocker as `WebView`/`Map`. Drive playback
44
+ * declaratively via the `playing` / `src` props.
45
+ */
46
+ @OptIn(UnstableApi::class)
47
+ class VideoPlayerUI(context: LynxContext) : LynxUI<PlayerView>(context) {
48
+
49
+ companion object {
50
+ private const val TIME_UPDATE_INTERVAL_MS = 250L
51
+ }
52
+
53
+ private var exoPlayer: ExoPlayer? = null
54
+ private val handler = Handler(Looper.getMainLooper())
55
+ private var didEmitLoad = false
56
+
57
+ // Pending prop values applied either at createView() time or once the
58
+ // player transitions to STATE_READY (autoplay, playing, muted, volume).
59
+ private var pendingSrc: String? = null
60
+ private var pendingAutoplay = false
61
+ private var pendingPlaying: Boolean? = null
62
+ private var pendingLoop = false
63
+ private var pendingMuted = false
64
+ private var pendingVolume = 1f
65
+ private var pendingControls = false
66
+ private var pendingResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
67
+
68
+ private val timeUpdateRunnable = object : Runnable {
69
+ override fun run() {
70
+ // Self-rescheduling loop, but only while actively playing —
71
+ // otherwise we'd keep waking the main thread every 250 ms
72
+ // forever while the user is on a paused / ended clip.
73
+ // `onIsPlayingChanged` re-arms us when playback resumes.
74
+ val player = exoPlayer ?: return
75
+ if (!player.isPlaying) return
76
+ fireEvent("timeupdate", mapOf("positionMs" to player.currentPosition.coerceAtLeast(0L)))
77
+ handler.postDelayed(this, TIME_UPDATE_INTERVAL_MS)
78
+ }
79
+ }
80
+
81
+ override fun createView(context: Context): PlayerView {
82
+ val view = PlayerView(context)
83
+ view.useController = pendingControls
84
+ view.resizeMode = pendingResizeMode
85
+
86
+ val player = ExoPlayer.Builder(context).build()
87
+ player.volume = if (pendingMuted) 0f else pendingVolume
88
+ player.repeatMode = if (pendingLoop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
89
+ player.addListener(playerListener)
90
+ view.player = player
91
+ exoPlayer = player
92
+
93
+ pendingSrc?.let { loadSource(it) }
94
+ return view
95
+ }
96
+
97
+ override fun onDetach() {
98
+ super.onDetach()
99
+ handler.removeCallbacks(timeUpdateRunnable)
100
+ runCatching { exoPlayer?.release() }
101
+ exoPlayer = null
102
+ }
103
+
104
+ private val playerListener = object : Player.Listener {
105
+ override fun onPlaybackStateChanged(state: Int) {
106
+ when (state) {
107
+ Player.STATE_READY -> {
108
+ if (!didEmitLoad) {
109
+ didEmitLoad = true
110
+ val player = exoPlayer ?: return
111
+ val duration = if (player.duration == androidx.media3.common.C.TIME_UNSET) 0L
112
+ else player.duration.coerceAtLeast(0L)
113
+ // Video size may not be reported synchronously on
114
+ // every codec — `onVideoSizeChanged` follows up.
115
+ val size = player.videoSize
116
+ fireEvent(
117
+ "load",
118
+ mapOf(
119
+ "durationMs" to duration,
120
+ "width" to size.width,
121
+ "height" to size.height,
122
+ ),
123
+ )
124
+ applyQueuedPlayState()
125
+ }
126
+ }
127
+ Player.STATE_ENDED -> {
128
+ // ExoPlayer.REPEAT_MODE_ONE handles looping natively, so
129
+ // reaching STATE_ENDED means we truly finished.
130
+ fireEvent("end", emptyMap())
131
+ }
132
+ else -> {}
133
+ }
134
+ }
135
+
136
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
137
+ handler.removeCallbacks(timeUpdateRunnable)
138
+ if (isPlaying) {
139
+ handler.postDelayed(timeUpdateRunnable, TIME_UPDATE_INTERVAL_MS)
140
+ }
141
+ }
142
+
143
+ override fun onPlayerError(error: PlaybackException) {
144
+ fireEvent("error", mapOf("message" to (error.message ?: "Playback error")))
145
+ }
146
+
147
+ override fun onVideoSizeChanged(videoSize: VideoSize) {
148
+ // The initial bindload may have reported width/height=0 if the
149
+ // codec hadn't decoded a frame yet. We don't re-fire bindload
150
+ // here — apps that need the post-decode size can observe the
151
+ // element's measured layout. (Lynx fires `bindlayoutchange`
152
+ // through LynxCommonAttributes for that.)
153
+ }
154
+ }
155
+
156
+ // ── Prop setters ─────────────────────────────────────────────────────
157
+
158
+ @LynxProp(name = "src")
159
+ fun setSrc(value: String?) {
160
+ if (value.isNullOrEmpty()) {
161
+ // Clearing the prop must stop and unload the current item —
162
+ // otherwise a re-render that drops `src` would keep the
163
+ // previous clip playing. We stop the timer loop and clear
164
+ // any pending media item.
165
+ pendingSrc = null
166
+ didEmitLoad = false
167
+ handler.removeCallbacks(timeUpdateRunnable)
168
+ exoPlayer?.let { player ->
169
+ try { player.stop() } catch (_: Throwable) {}
170
+ try { player.clearMediaItems() } catch (_: Throwable) {}
171
+ }
172
+ return
173
+ }
174
+ pendingSrc = value
175
+ if (exoPlayer != null) loadSource(value)
176
+ }
177
+
178
+ @LynxProp(name = "poster")
179
+ fun setPoster(value: String?) {
180
+ // Best-effort: when a poster URL is provided we set it as the
181
+ // PlayerView's defaultArtwork via the artwork display flag once we
182
+ // get a frame. v1 keeps this minimal — apps that need a poster
183
+ // render an <image> sibling instead and toggle visibility on
184
+ // `bindload`.
185
+ }
186
+
187
+ @LynxProp(name = "autoplay")
188
+ fun setAutoplay(value: Boolean) {
189
+ pendingAutoplay = value
190
+ val player = exoPlayer ?: return
191
+ if (value && player.playbackState == Player.STATE_READY) {
192
+ player.playWhenReady = true
193
+ player.play()
194
+ }
195
+ }
196
+
197
+ @LynxProp(name = "playing")
198
+ fun setPlaying(value: Boolean) {
199
+ pendingPlaying = value
200
+ val player = exoPlayer ?: return
201
+ if (player.playbackState == Player.STATE_READY) {
202
+ if (value) player.play() else player.pause()
203
+ }
204
+ }
205
+
206
+ @LynxProp(name = "loop")
207
+ fun setLoop(value: Boolean) {
208
+ pendingLoop = value
209
+ exoPlayer?.repeatMode = if (value) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
210
+ }
211
+
212
+ @LynxProp(name = "muted")
213
+ fun setMuted(value: Boolean) {
214
+ pendingMuted = value
215
+ exoPlayer?.volume = if (value) 0f else pendingVolume
216
+ }
217
+
218
+ @LynxProp(name = "volume")
219
+ fun setVolume(value: Double) {
220
+ val v = value.coerceIn(0.0, 1.0).toFloat()
221
+ pendingVolume = v
222
+ if (!pendingMuted) exoPlayer?.volume = v
223
+ }
224
+
225
+ @LynxProp(name = "controls")
226
+ fun setControls(value: Boolean) {
227
+ pendingControls = value
228
+ mView.useController = value
229
+ }
230
+
231
+ @LynxProp(name = "resize-mode")
232
+ fun setResizeMode(value: String?) {
233
+ val mode = when (value) {
234
+ "cover" -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
235
+ "stretch" -> AspectRatioFrameLayout.RESIZE_MODE_FILL
236
+ else -> AspectRatioFrameLayout.RESIZE_MODE_FIT
237
+ }
238
+ pendingResizeMode = mode
239
+ mView.resizeMode = mode
240
+ }
241
+
242
+ // ── Internals ────────────────────────────────────────────────────────
243
+
244
+ private fun loadSource(source: String) {
245
+ val player = exoPlayer ?: return
246
+ didEmitLoad = false
247
+ val uri = if (source.startsWith("/")) Uri.fromFile(java.io.File(source))
248
+ else Uri.parse(source)
249
+ val item = MediaItem.fromUri(uri)
250
+ player.setMediaItem(item)
251
+ player.prepare()
252
+ // playWhenReady stays governed by autoplay/playing props applied
253
+ // after STATE_READY in `applyQueuedPlayState`.
254
+ player.playWhenReady = false
255
+ }
256
+
257
+ private fun applyQueuedPlayState() {
258
+ val player = exoPlayer ?: return
259
+ val explicit = pendingPlaying
260
+ when {
261
+ explicit == true -> player.play()
262
+ explicit == false -> player.pause()
263
+ pendingAutoplay -> player.play()
264
+ else -> player.pause()
265
+ }
266
+ }
267
+
268
+ private fun fireEvent(name: String, params: Map<String, Any?>) {
269
+ val event = LynxDetailEvent(sign, name)
270
+ for ((k, v) in params) event.addDetail(k, v)
271
+ lynxContext.eventEmitter.sendCustomEvent(event)
272
+ }
273
+ }
@@ -0,0 +1,29 @@
1
+ import { type Define } from '@sigx/lynx';
2
+ import './jsx-augment.js';
3
+ import type { VideoEndEvent, VideoErrorEvent, VideoLoadEvent, VideoResizeMode, VideoTimeUpdateEvent } from './jsx-augment.js';
4
+ export type VideoPlayerProps = Define.Prop<'src', string, false> & Define.Prop<'poster', string, false> & Define.Prop<'autoplay', boolean, false> & Define.Prop<'playing', boolean, false> & Define.Prop<'loop', boolean, false> & Define.Prop<'muted', boolean, false> & Define.Prop<'volume', number, false> & Define.Prop<'controls', boolean, false> & Define.Prop<'resizeMode', VideoResizeMode, false> & Define.Prop<'class', string, false> & Define.Prop<'style', string | Record<string, string | number>, false> & Define.Prop<'onLoad', (e: VideoLoadEvent) => void, false> & Define.Prop<'onEnd', (e: VideoEndEvent) => void, false> & Define.Prop<'onError', (e: VideoErrorEvent) => void, false> & Define.Prop<'onTimeUpdate', (e: VideoTimeUpdateEvent) => void, false>;
5
+ /**
6
+ * Native video player.
7
+ *
8
+ * On iOS this wraps an `AVPlayer` inside an `AVPlayerLayer`; on Android,
9
+ * `androidx.media3.exoplayer.ExoPlayer` inside a `PlayerView`. The element
10
+ * participates in Lynx's layout tree like any other view — give it a
11
+ * `width` / `height` (or `aspectRatio`) and it draws decoded frames there.
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * <VideoPlayer
16
+ * src="https://example.com/clip.mp4"
17
+ * autoplay
18
+ * controls
19
+ * onLoad={(e) => console.log('dur', e.detail.durationMs)}
20
+ * onEnd={() => console.log('done')}
21
+ * style={{ width: '100%', aspectRatio: 16 / 9 }}
22
+ * />
23
+ * ```
24
+ *
25
+ * @remarks
26
+ * Imperative methods (`seek`, `getStatus`) are not yet implemented — see the
27
+ * package README. v1 is declarative-only via the `playing` / `src` props.
28
+ */
29
+ export declare const VideoPlayer: import("@sigx/runtime-core").ComponentFactory<VideoPlayerProps, void, {}>;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
2
+ import { component } from '@sigx/lynx';
3
+ import './jsx-augment.js';
4
+ /**
5
+ * Native video player.
6
+ *
7
+ * On iOS this wraps an `AVPlayer` inside an `AVPlayerLayer`; on Android,
8
+ * `androidx.media3.exoplayer.ExoPlayer` inside a `PlayerView`. The element
9
+ * participates in Lynx's layout tree like any other view — give it a
10
+ * `width` / `height` (or `aspectRatio`) and it draws decoded frames there.
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * <VideoPlayer
15
+ * src="https://example.com/clip.mp4"
16
+ * autoplay
17
+ * controls
18
+ * onLoad={(e) => console.log('dur', e.detail.durationMs)}
19
+ * onEnd={() => console.log('done')}
20
+ * style={{ width: '100%', aspectRatio: 16 / 9 }}
21
+ * />
22
+ * ```
23
+ *
24
+ * @remarks
25
+ * Imperative methods (`seek`, `getStatus`) are not yet implemented — see the
26
+ * package README. v1 is declarative-only via the `playing` / `src` props.
27
+ */
28
+ export const VideoPlayer = component(({ props }) => {
29
+ return () => (_jsx("video-player", { src: props.src, poster: props.poster, autoplay: props.autoplay, playing: props.playing, loop: props.loop, muted: props.muted, volume: props.volume, controls: props.controls, "resize-mode": props.resizeMode, class: props.class, style: props.style, bindload: props.onLoad, bindend: props.onEnd, binderror: props.onError, bindtimeupdate: props.onTimeUpdate }));
30
+ });
@@ -0,0 +1,4 @@
1
+ import './jsx-augment.js';
2
+ export { VideoPlayer } from './VideoPlayer.js';
3
+ export type { VideoPlayerProps } from './VideoPlayer.js';
4
+ export type { VideoPlayerAttributes, VideoResizeMode, VideoLoadEvent, VideoLoadEventDetail, VideoEndEvent, VideoEndEventDetail, VideoErrorEvent, VideoErrorEventDetail, VideoTimeUpdateEvent, VideoTimeUpdateEventDetail, } from './jsx-augment.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import './jsx-augment.js';
2
+ export { VideoPlayer } from './VideoPlayer.js';
@@ -0,0 +1,96 @@
1
+ /**
2
+ * JSX intrinsic type augmentation for `<video-player>`.
3
+ *
4
+ * Importing this module registers `'video-player'` as a valid JSX intrinsic
5
+ * with the prop + event surface implemented by `VideoPlayerUI` (iOS) and
6
+ * `VideoPlayerUI.kt` (Android). Pulled in automatically by
7
+ * `@sigx/lynx-video`'s entry point so consumers do not need to import it
8
+ * directly.
9
+ *
10
+ * Element availability requires `sigx prebuild` to have run after adding
11
+ * this package as a dependency — the autolinker emits the `LynxConfig`
12
+ * registration (iOS) and `Behavior` attachment (Android) that bind the tag
13
+ * to the native UI class.
14
+ */
15
+ import type { LynxCommonAttributes, LynxEventHandler } from '@sigx/lynx-runtime';
16
+ export interface VideoLoadEventDetail {
17
+ /** Total duration in milliseconds. 0 if unknown (live streams). */
18
+ durationMs: number;
19
+ /** Natural width in pixels. */
20
+ width: number;
21
+ /** Natural height in pixels. */
22
+ height: number;
23
+ [k: string]: unknown;
24
+ }
25
+ export interface VideoLoadEvent {
26
+ type: 'load';
27
+ detail: VideoLoadEventDetail;
28
+ }
29
+ export interface VideoEndEventDetail {
30
+ [k: string]: unknown;
31
+ }
32
+ export interface VideoEndEvent {
33
+ type: 'end';
34
+ detail: VideoEndEventDetail;
35
+ }
36
+ export interface VideoErrorEventDetail {
37
+ message: string;
38
+ [k: string]: unknown;
39
+ }
40
+ export interface VideoErrorEvent {
41
+ type: 'error';
42
+ detail: VideoErrorEventDetail;
43
+ }
44
+ export interface VideoTimeUpdateEventDetail {
45
+ /** Current playback position in milliseconds. */
46
+ positionMs: number;
47
+ [k: string]: unknown;
48
+ }
49
+ export interface VideoTimeUpdateEvent {
50
+ type: 'timeupdate';
51
+ detail: VideoTimeUpdateEventDetail;
52
+ }
53
+ export type VideoResizeMode = 'contain' | 'cover' | 'stretch';
54
+ export interface VideoPlayerAttributes extends LynxCommonAttributes {
55
+ /** URL or `file://` URI of the asset to play. */
56
+ src?: string;
57
+ /** Image displayed before the first frame is decoded. */
58
+ poster?: string;
59
+ /** Begin playback as soon as the asset is ready. Default: false. */
60
+ autoplay?: boolean;
61
+ /**
62
+ * Drive play/pause declaratively. `true` resumes, `false` pauses. Apps
63
+ * that prefer imperative control can flip a signal that drives this prop.
64
+ */
65
+ playing?: boolean;
66
+ /** Restart automatically at end-of-clip. Default: false. */
67
+ loop?: boolean;
68
+ /** Mute audio output, independent of `volume`. Default: false. */
69
+ muted?: boolean;
70
+ /** Output volume 0..1. Default: 1. */
71
+ volume?: number;
72
+ /** Show platform-default playback controls overlay. Default: false. */
73
+ controls?: boolean;
74
+ /** How the video frame fits inside the element box. Default: `'contain'`. */
75
+ 'resize-mode'?: VideoResizeMode;
76
+ /** Fires once asset metadata is available (duration, dimensions). */
77
+ bindload?: LynxEventHandler<VideoLoadEvent>;
78
+ /** Fires when playback reaches the end of the clip (not when looping). */
79
+ bindend?: LynxEventHandler<VideoEndEvent>;
80
+ /** Fires on a non-recoverable load or playback error. */
81
+ binderror?: LynxEventHandler<VideoErrorEvent>;
82
+ /**
83
+ * Fires ~4×/sec while playing with the current position. Use sparingly —
84
+ * this crosses the bridge per call. For high-frequency animations,
85
+ * subscribe to the asset duration once and animate locally instead.
86
+ */
87
+ bindtimeupdate?: LynxEventHandler<VideoTimeUpdateEvent>;
88
+ }
89
+ declare global {
90
+ namespace JSX {
91
+ interface IntrinsicElements {
92
+ 'video-player': VideoPlayerAttributes;
93
+ }
94
+ }
95
+ }
96
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,387 @@
1
+ import Foundation
2
+ import UIKit
3
+ import AVFoundation
4
+ import Lynx
5
+
6
+ /// Native UI for the `<video-player>` JSX element.
7
+ ///
8
+ /// Registered via the autolinker — `signalx-module.json`'s `ios.uiComponents`
9
+ /// produces `config.registerUI(VideoPlayerUI.self, withName: "video-player")`
10
+ /// in the generated `GeneratedComponentRegistry.swift`.
11
+ ///
12
+ /// Prop / event surface (v1):
13
+ /// - `src` → URL (URL-loaded) or `file://` (local file)
14
+ /// - `poster` → image displayed before the first frame
15
+ /// - `autoplay` → start as soon as the asset is ready
16
+ /// - `playing` → declarative play/pause toggle
17
+ /// - `loop` → restart at end-of-clip
18
+ /// - `muted` → mute audio
19
+ /// - `volume` → 0..1
20
+ /// - `controls` → show platform-default controls overlay
21
+ /// - `resize-mode` → contain | cover | stretch
22
+ /// - `bindload` → asset metadata ready
23
+ /// - `bindend` → playback finished
24
+ /// - `binderror` → load / playback failure
25
+ /// - `bindtimeupdate` → ~4×/sec position broadcast
26
+ ///
27
+ /// Imperative methods (`seek`, `getStatus`) are tracked as a v2 follow-up
28
+ /// — they need the Lynx UIMethodInvoker surface that isn't wired through
29
+ /// sigx-lynx yet (same blocker as `WebView.goBack` / `Map.animateToRegion`).
30
+ @objc public class VideoPlayerUI: LynxUI<UIView> {
31
+
32
+ private var player: AVPlayer?
33
+ private var playerLayer: AVPlayerLayer?
34
+ private var posterView: UIImageView?
35
+ private var controlsView: UIView?
36
+ private var timeObserver: Any?
37
+ private var statusObservation: NSKeyValueObservation?
38
+ private var didReachEndObserver: NSObjectProtocol?
39
+
40
+ // Pending prop values applied after createView() and after the asset
41
+ // reaches `.readyToPlay` (volume, muted, autoplay/playing).
42
+ private var pendingAutoplay = false
43
+ private var pendingPlaying: Bool?
44
+ private var pendingLoop = false
45
+ private var pendingMuted = false
46
+ private var pendingVolume: Float = 1.0
47
+ private var pendingResizeMode: AVLayerVideoGravity = .resizeAspect
48
+ private var pendingControls = false
49
+ private var pendingPosterURL: URL?
50
+ private var didEmitLoad = false
51
+
52
+ // MARK: - LynxUI overrides
53
+
54
+ public override func createView() -> UIView? {
55
+ let container = UIView(frame: .zero)
56
+ container.backgroundColor = .black
57
+ container.clipsToBounds = true
58
+ return container
59
+ }
60
+
61
+ public override func layoutDidFinished() {
62
+ super.layoutDidFinished()
63
+ guard let container = view else { return }
64
+ playerLayer?.frame = container.bounds
65
+ posterView?.frame = container.bounds
66
+ controlsView?.frame = container.bounds
67
+ }
68
+
69
+ deinit {
70
+ teardownPlayer()
71
+ }
72
+
73
+ // MARK: - Prop setters
74
+
75
+ @objc public func setSrc(_ value: NSString?, requestReset: Bool) {
76
+ let raw = (value as String?) ?? ""
77
+ if raw.isEmpty {
78
+ // Clearing the prop must actually stop and release the current
79
+ // asset — otherwise a re-render that drops `src` would leave
80
+ // the previous clip playing.
81
+ teardownPlayer()
82
+ didEmitLoad = false
83
+ return
84
+ }
85
+ guard let url = resolveURL(raw) else {
86
+ teardownPlayer()
87
+ didEmitLoad = false
88
+ fireEvent("error", params: ["message": "Invalid src: \(raw)"])
89
+ return
90
+ }
91
+ loadAsset(url: url)
92
+ }
93
+
94
+ @objc(__lynx_prop_config__src)
95
+ public class func __lynxPropConfigSrc() -> [String] {
96
+ return ["src", "setSrc:requestReset:", "NSString *"]
97
+ }
98
+
99
+ @objc public func setPoster(_ value: NSString?, requestReset: Bool) {
100
+ guard let raw = value as String?, !raw.isEmpty, let url = resolveURL(raw) else {
101
+ posterView?.removeFromSuperview()
102
+ posterView = nil
103
+ return
104
+ }
105
+ pendingPosterURL = url
106
+ loadPoster(url: url)
107
+ }
108
+
109
+ @objc(__lynx_prop_config__poster)
110
+ public class func __lynxPropConfigPoster() -> [String] {
111
+ return ["poster", "setPoster:requestReset:", "NSString *"]
112
+ }
113
+
114
+ @objc public func setAutoplay(_ value: Bool, requestReset: Bool) {
115
+ pendingAutoplay = value
116
+ if value, let player = player, player.currentItem?.status == .readyToPlay {
117
+ player.play()
118
+ }
119
+ }
120
+
121
+ @objc(__lynx_prop_config__autoplay)
122
+ public class func __lynxPropConfigAutoplay() -> [String] {
123
+ return ["autoplay", "setAutoplay:requestReset:", "BOOL"]
124
+ }
125
+
126
+ @objc public func setPlaying(_ value: Bool, requestReset: Bool) {
127
+ pendingPlaying = value
128
+ guard let player = player else { return }
129
+ if player.currentItem?.status == .readyToPlay {
130
+ if value { player.play() } else { player.pause() }
131
+ }
132
+ }
133
+
134
+ @objc(__lynx_prop_config__playing)
135
+ public class func __lynxPropConfigPlaying() -> [String] {
136
+ return ["playing", "setPlaying:requestReset:", "BOOL"]
137
+ }
138
+
139
+ @objc public func setLoop(_ value: Bool, requestReset: Bool) {
140
+ pendingLoop = value
141
+ }
142
+
143
+ @objc(__lynx_prop_config__loop)
144
+ public class func __lynxPropConfigLoop() -> [String] {
145
+ return ["loop", "setLoop:requestReset:", "BOOL"]
146
+ }
147
+
148
+ @objc public func setMuted(_ value: Bool, requestReset: Bool) {
149
+ pendingMuted = value
150
+ player?.isMuted = value
151
+ }
152
+
153
+ @objc(__lynx_prop_config__muted)
154
+ public class func __lynxPropConfigMuted() -> [String] {
155
+ return ["muted", "setMuted:requestReset:", "BOOL"]
156
+ }
157
+
158
+ @objc public func setVolume(_ value: NSNumber?, requestReset: Bool) {
159
+ let v = Float(max(0, min(1, (value?.doubleValue ?? 1.0))))
160
+ pendingVolume = v
161
+ player?.volume = v
162
+ }
163
+
164
+ @objc(__lynx_prop_config__volume)
165
+ public class func __lynxPropConfigVolume() -> [String] {
166
+ return ["volume", "setVolume:requestReset:", "NSNumber *"]
167
+ }
168
+
169
+ @objc public func setControls(_ value: Bool, requestReset: Bool) {
170
+ pendingControls = value
171
+ // Native-controls UI is intentionally minimal in v1 — the iOS
172
+ // AVPlayerViewController would conflict with Lynx's view tree
173
+ // (it owns its own VC). We render a simple tap-to-play overlay
174
+ // instead. Apps that want full transport controls today can
175
+ // build their own overlay using the `playing` prop.
176
+ if value {
177
+ attachTapToggleOverlay()
178
+ } else {
179
+ controlsView?.removeFromSuperview()
180
+ controlsView = nil
181
+ }
182
+ }
183
+
184
+ @objc(__lynx_prop_config__controls)
185
+ public class func __lynxPropConfigControls() -> [String] {
186
+ return ["controls", "setControls:requestReset:", "BOOL"]
187
+ }
188
+
189
+ @objc public func setResizeMode(_ value: NSString?, requestReset: Bool) {
190
+ let mode = (value as String?) ?? "contain"
191
+ let gravity: AVLayerVideoGravity
192
+ switch mode {
193
+ case "cover": gravity = .resizeAspectFill
194
+ case "stretch": gravity = .resize
195
+ default: gravity = .resizeAspect
196
+ }
197
+ pendingResizeMode = gravity
198
+ playerLayer?.videoGravity = gravity
199
+ }
200
+
201
+ @objc(__lynx_prop_config__resize_mode)
202
+ public class func __lynxPropConfigResizeMode() -> [String] {
203
+ return ["resize-mode", "setResizeMode:requestReset:", "NSString *"]
204
+ }
205
+
206
+ // MARK: - Asset lifecycle
207
+
208
+ private func loadAsset(url: URL) {
209
+ teardownPlayer()
210
+
211
+ let item = AVPlayerItem(url: url)
212
+ let player = AVPlayer(playerItem: item)
213
+ player.isMuted = pendingMuted
214
+ player.volume = pendingVolume
215
+
216
+ let layer = AVPlayerLayer(player: player)
217
+ layer.videoGravity = pendingResizeMode
218
+ if let view = view {
219
+ layer.frame = view.bounds
220
+ view.layer.insertSublayer(layer, at: 0)
221
+ }
222
+
223
+ self.player = player
224
+ self.playerLayer = layer
225
+ self.didEmitLoad = false
226
+
227
+ // KVO on item.status — first time we hit `.readyToPlay`, fire
228
+ // `bindload` with the asset's duration + natural size and apply
229
+ // the queued autoplay/playing prop.
230
+ statusObservation = item.observe(\.status, options: [.new]) { [weak self] item, _ in
231
+ guard let self = self else { return }
232
+ switch item.status {
233
+ case .readyToPlay:
234
+ self.handleReadyToPlay(item: item)
235
+ case .failed:
236
+ let message = item.error?.localizedDescription ?? "Asset failed to load"
237
+ self.fireEvent("error", params: ["message": message])
238
+ default:
239
+ break
240
+ }
241
+ }
242
+
243
+ didReachEndObserver = NotificationCenter.default.addObserver(
244
+ forName: .AVPlayerItemDidPlayToEndTime,
245
+ object: item,
246
+ queue: .main,
247
+ ) { [weak self] _ in
248
+ self?.handleDidReachEnd()
249
+ }
250
+
251
+ timeObserver = player.addPeriodicTimeObserver(
252
+ forInterval: CMTime(seconds: 0.25, preferredTimescale: CMTimeScale(NSEC_PER_SEC)),
253
+ queue: .main,
254
+ ) { [weak self] time in
255
+ // Same NaN/Inf trap as duration on live streams — guard the
256
+ // position-observer cast too. `CMTimeGetSeconds` returns NaN
257
+ // for indefinite / unknown times, and `Int(.nan)` traps.
258
+ let positionMs = msFromSeconds(CMTimeGetSeconds(time))
259
+ self?.fireEvent("timeupdate", params: ["positionMs": positionMs])
260
+ }
261
+ }
262
+
263
+ private func handleReadyToPlay(item: AVPlayerItem) {
264
+ guard !didEmitLoad else { return }
265
+ didEmitLoad = true
266
+
267
+ // Live streams and assets with indeterminate duration return
268
+ // `CMTime.indefinite` whose `CMTimeGetSeconds` is `NaN` (and some
269
+ // edge cases produce `+Inf`). `Int(Double.nan)` traps, so funnel
270
+ // through a finite-check helper before reporting.
271
+ let durationMs = msFromSeconds(CMTimeGetSeconds(item.duration))
272
+ var width = 0
273
+ var height = 0
274
+ if let track = item.asset.tracks(withMediaType: .video).first {
275
+ let size = track.naturalSize.applying(track.preferredTransform)
276
+ width = Int(abs(size.width))
277
+ height = Int(abs(size.height))
278
+ }
279
+ fireEvent("load", params: [
280
+ "durationMs": durationMs,
281
+ "width": width,
282
+ "height": height,
283
+ ])
284
+
285
+ // Apply queued props. `playing` overrides `autoplay` if both set.
286
+ if let playing = pendingPlaying {
287
+ if playing { player?.play() } else { player?.pause() }
288
+ } else if pendingAutoplay {
289
+ player?.play()
290
+ }
291
+ // First frame painted — hide the poster.
292
+ posterView?.removeFromSuperview()
293
+ posterView = nil
294
+ }
295
+
296
+ private func handleDidReachEnd() {
297
+ if pendingLoop {
298
+ player?.seek(to: .zero)
299
+ player?.play()
300
+ return
301
+ }
302
+ fireEvent("end", params: [:])
303
+ }
304
+
305
+ private func teardownPlayer() {
306
+ if let observer = timeObserver { player?.removeTimeObserver(observer) }
307
+ timeObserver = nil
308
+ statusObservation?.invalidate()
309
+ statusObservation = nil
310
+ if let observer = didReachEndObserver {
311
+ NotificationCenter.default.removeObserver(observer)
312
+ }
313
+ didReachEndObserver = nil
314
+
315
+ player?.pause()
316
+ playerLayer?.removeFromSuperlayer()
317
+ player = nil
318
+ playerLayer = nil
319
+ }
320
+
321
+ private func loadPoster(url: URL) {
322
+ // Best-effort, async, no caching layer. Apps that need a CDN-aware
323
+ // image pipeline can roll their own and pass a `file://` poster.
324
+ URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
325
+ guard let self = self, let data = data, let image = UIImage(data: data) else { return }
326
+ DispatchQueue.main.async {
327
+ // Race guard — if `poster` was reset or replaced while this
328
+ // request was in flight, drop the stale image. Without
329
+ // this, a fast Settings flip from PosterA → PosterB could
330
+ // land PosterA on screen if its download completed second.
331
+ guard self.pendingPosterURL == url,
332
+ self.posterView == nil,
333
+ !self.didEmitLoad,
334
+ let container = self.view else { return }
335
+ let iv = UIImageView(image: image)
336
+ iv.frame = container.bounds
337
+ iv.contentMode = .scaleAspectFit
338
+ container.addSubview(iv)
339
+ self.posterView = iv
340
+ }
341
+ }.resume()
342
+ }
343
+
344
+ private func attachTapToggleOverlay() {
345
+ guard controlsView == nil, let container = view else { return }
346
+ let overlay = UIView(frame: container.bounds)
347
+ overlay.backgroundColor = UIColor.clear
348
+ let tap = UITapGestureRecognizer(target: self, action: #selector(togglePlayPause))
349
+ overlay.addGestureRecognizer(tap)
350
+ container.addSubview(overlay)
351
+ controlsView = overlay
352
+ }
353
+
354
+ @objc private func togglePlayPause() {
355
+ guard let player = player else { return }
356
+ if player.timeControlStatus == .playing {
357
+ player.pause()
358
+ } else {
359
+ player.play()
360
+ }
361
+ }
362
+
363
+ // MARK: - Helpers
364
+
365
+ private func resolveURL(_ source: String) -> URL? {
366
+ if source.hasPrefix("file://") || source.hasPrefix("http://") || source.hasPrefix("https://") {
367
+ return URL(string: source)
368
+ }
369
+ if source.hasPrefix("/") {
370
+ return URL(fileURLWithPath: source)
371
+ }
372
+ return URL(string: source)
373
+ }
374
+
375
+ fileprivate func fireEvent(_ name: String, params: [String: Any]) {
376
+ let event = LynxCustomEvent(name: name, targetSign: sign, params: params)
377
+ context?.eventEmitter?.sendCustomEvent(event)
378
+ }
379
+ }
380
+
381
+ /// Convert seconds (possibly `NaN` or `±Inf`) to clamped milliseconds.
382
+ /// `Int(Double.nan)` and `Int(.infinity)` trap, so live streams and
383
+ /// indeterminate durations would crash the app without this guard.
384
+ fileprivate func msFromSeconds(_ seconds: Double) -> Int {
385
+ guard seconds.isFinite, seconds > 0 else { return 0 }
386
+ return Int(seconds * 1000.0)
387
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@sigx/lynx-video",
3
+ "version": "0.4.1",
4
+ "description": "Native video player component for sigx-lynx (AVPlayer on iOS, ExoPlayer on Android).",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./signalx-module.json": "./signalx-module.json",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "ios",
20
+ "android",
21
+ "signalx-module.json",
22
+ "README.md"
23
+ ],
24
+ "peerDependencies": {
25
+ "@sigx/lynx": "^0.4.1"
26
+ },
27
+ "devDependencies": {
28
+ "@typescript/native-preview": "7.0.0-dev.20260521.1",
29
+ "typescript": "^6.0.3",
30
+ "@sigx/lynx": "^0.4.1"
31
+ },
32
+ "author": "Andreas Ekdahl",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/signalxjs/lynx.git",
37
+ "directory": "packages/lynx-video"
38
+ },
39
+ "homepage": "https://github.com/signalxjs/lynx/tree/main/packages/lynx-video",
40
+ "bugs": {
41
+ "url": "https://github.com/signalxjs/lynx/issues"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "keywords": [
47
+ "signalx",
48
+ "sigx",
49
+ "lynx",
50
+ "video",
51
+ "avplayer",
52
+ "exoplayer",
53
+ "ios",
54
+ "android"
55
+ ],
56
+ "scripts": {
57
+ "build": "node ../../scripts/clean.mjs dist && tsgo",
58
+ "dev": "tsgo --watch",
59
+ "clean": "node ../../scripts/clean.mjs dist .turbo"
60
+ }
61
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "Video",
3
+ "package": "@sigx/lynx-video",
4
+ "description": "Native video player component (AVPlayer / ExoPlayer)",
5
+ "platforms": ["android", "ios"],
6
+ "ios": {
7
+ "sourceDir": "ios",
8
+ "uiComponents": [
9
+ { "name": "video-player", "uiClass": "VideoPlayerUI" }
10
+ ]
11
+ },
12
+ "android": {
13
+ "sourceDir": "android",
14
+ "behaviors": [
15
+ { "name": "video-player", "behaviorClass": "com.sigx.video.VideoPlayerBehavior" }
16
+ ],
17
+ "dependencies": [
18
+ "androidx.media3:media3-exoplayer:1.4.1",
19
+ "androidx.media3:media3-ui:1.4.1"
20
+ ]
21
+ }
22
+ }