@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 +21 -0
- package/README.md +61 -0
- package/android/com/sigx/video/VideoPlayerBehavior.kt +19 -0
- package/android/com/sigx/video/VideoPlayerUI.kt +273 -0
- package/dist/VideoPlayer.d.ts +29 -0
- package/dist/VideoPlayer.js +30 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/jsx-augment.d.ts +96 -0
- package/dist/jsx-augment.js +1 -0
- package/ios/VideoPlayerUI.swift +387 -0
- package/package.json +61 -0
- package/signalx-module.json +22 -0
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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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,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
|
+
}
|