@octane-xplat/video 0.0.0 → 0.7.0

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/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
- "name": "@octane-xplat/video",
3
- "version": "0.0.0",
4
- "description": "Stub reserving the name; real releases publish from CI via trusted publishing.",
5
- "publishConfig": {"access": "public"}
6
- }
2
+ "name": "@octane-xplat/video",
3
+ "version": "0.7.0",
4
+ "description": "Embedded video leaf for Octane xplat — web <video> + @nstudio/nativescript-exoplayer (ExoPlayer / AVPlayerViewController) with self-drawn chrome",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/octane-xplat/octane-xplat.git",
8
+ "directory": "packages/video"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "types",
13
+ "src"
14
+ ],
15
+ "type": "module",
16
+ "exports": {
17
+ ".": {
18
+ "macos": {
19
+ "types": "./types/index.macos.d.ts",
20
+ "default": "./src/index.macos.ts"
21
+ },
22
+ "types": "./types/index.d.ts",
23
+ "web": "./dist/web/index.js",
24
+ "native": "./dist/native/index.js",
25
+ "default": "./dist/native/index.js"
26
+ }
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "dependencies": {
32
+ "@nstudio/nativescript-exoplayer": "6.2.0"
33
+ },
34
+ "devDependencies": {
35
+ "@nativescript-community/octane": "0.2.1",
36
+ "@nativescript/core": "9.1.2",
37
+ "@octane-xplat/ui": "0.7.0",
38
+ "@octanejs/vite-plugin": "0.1.61",
39
+ "octane": "0.6.3",
40
+ "typescript": "5.9.3",
41
+ "vite": "8.3.0"
42
+ },
43
+ "peerDependencies": {
44
+ "@nativescript-community/octane": ">=0.2.1 <1",
45
+ "@nativescript/core": ">=9.1.0 <10",
46
+ "@octane-xplat/ui": ">=0.6.0 <1",
47
+ "octane": ">=0.6.3 <1"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@nativescript-community/octane": {
51
+ "optional": true
52
+ },
53
+ "@nativescript/core": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "scripts": {
58
+ "build": "vite build && vite build --mode native && tsc -p tsconfig.types.json && node ../../scripts/check-native-dist.mjs"
59
+ }
60
+ }
@@ -0,0 +1,7 @@
1
+ /** @jsxImportSource @xplat/macos/renderer */
2
+ import type { VideoProps } from './props'
3
+
4
+ /** Explicit placeholder until the AppKit host has a video surface. */
5
+ export function Video(_props: VideoProps) @{
6
+ <label className="vx-unsupported">Video playback is unsupported by the AppKit host</label>
7
+ }
package/src/Video.tsrx ADDED
@@ -0,0 +1,162 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { useCallback, useEffect, useRef, useState } from 'octane';
3
+ import { registerElement } from '@nativescript-community/octane';
4
+ import type { Attributes } from '@nativescript-community/octane/intrinsics';
5
+ import { Video as NSVideo, VideoFill } from '@nstudio/nativescript-exoplayer';
6
+ import type { VideoHandle, VideoProps } from './props';
7
+ import { cx } from './cx.mobile';
8
+ import { applyEscapeProps, nativeAccessibilityRole, nativeAccessibilityState } from './escape-props';
9
+ import { layoutChildProps } from './layout-child';
10
+ import { VideoChrome } from './video-chrome.tsrx';
11
+
12
+ declare module '@nativescript-community/octane/intrinsics' {
13
+ interface NativeScriptElements {
14
+ xplatvideo: Attributes<typeof NSVideo>;
15
+ }
16
+ }
17
+
18
+ // @nstudio/nativescript-exoplayer — AVPlayerViewController on iOS,
19
+ // ExoPlayer2 on Android. It is a required peer: this leaf sits in the
20
+ // shared native barrel, so every native app must declare the plugin for
21
+ // the /ns/m dev bridge to resolve it under the app root.
22
+ registerElement('xplatvideo', NSVideo);
23
+
24
+ const FILLS: Record<string, any> = {
25
+ contain: VideoFill.aspect,
26
+ cover: VideoFill.aspectFill,
27
+ fill: VideoFill.fill,
28
+ };
29
+
30
+ /** Video — surface-hosted: the plugin supplies the engine surface
31
+ * (`controls={false}` strips AVPlayerViewController/PlayerView chrome) and
32
+ * the shared `VideoChrome` draws the transport on top. Times are ms —
33
+ * the plugin's native unit on both platforms. One-time wiring (escape
34
+ * props, `src`) lands in effects: the driver doesn't diff props, and
35
+ * `src` must arrive after the flags (the plugin opens the clip when the
36
+ * src property lands). The plugin emits `finished` even while looping —
37
+ * `onEnded` suppresses it under `loop` to match the web leaf. */
38
+ export function Video(props: VideoProps) @{
39
+ const host = useRef<any>(null);
40
+ const [playing, setPlaying] = useState(props.playing ?? props.autoPlay ?? false);
41
+ const [started, setStarted] = useState(false);
42
+ const [position, setPosition] = useState(0);
43
+ const [duration, setDuration] = useState(0);
44
+ const [internalMuted, setInternalMuted] = useState(false);
45
+ const muted = props.muted ?? internalMuted;
46
+ const appliedSrc = useRef<string | undefined>(undefined);
47
+
48
+ // Stable identity — an inline closure re-fires ref(null)/ref(v) per
49
+ // render, which would bounce host.current around the effects below.
50
+ const bindHost = useCallback((v: any) => {
51
+ host.current = v;
52
+ }, []);
53
+
54
+ const drive = (v: any, next: boolean) => {
55
+ if (!v) return;
56
+ if (next) v.play();
57
+ else v.pause();
58
+ };
59
+
60
+ // src + escape props land in effects: the driver doesn't diff props, and
61
+ // `src` must arrive after the flags (the plugin opens the clip when the
62
+ // src property lands).
63
+ useEffect(() => {
64
+ const v = host.current;
65
+ if (!v) return;
66
+ applyEscapeProps(v, props);
67
+ props.bind?.(handle(v));
68
+ }, []);
69
+
70
+ useEffect(() => {
71
+ const v = host.current;
72
+ if (!v || props.src === appliedSrc.current) return;
73
+ appliedSrc.current = props.src;
74
+ v.src = props.src;
75
+ }, [props.src]);
76
+
77
+ // Controlled `playing` writes — play state is a method call, not a prop.
78
+ useEffect(() => {
79
+ if (props.playing === undefined) return;
80
+ setPlaying(props.playing);
81
+ drive(host.current, props.playing);
82
+ }, [props.playing]);
83
+
84
+ const togglePlay = () => {
85
+ const next = !playing;
86
+ setPlaying(next);
87
+ drive(host.current, next);
88
+ props.onPlayingChange?.(next);
89
+ };
90
+
91
+ const handle = (v: any): VideoHandle => ({
92
+ play: () => v.play(),
93
+ pause: () => v.pause(),
94
+ seekTo: (ms: number) => v.seekToTime(ms / 1000),
95
+ currentTime: () => v.getCurrentTime(),
96
+ duration: () => v.getDuration(),
97
+ native: v,
98
+ });
99
+
100
+ <gridlayout
101
+ id={props.id}
102
+ rows="*"
103
+ columns="*"
104
+ className={cx('vx-stack', 'vx-video', props.className)}
105
+ style={props.style}
106
+ {...layoutChildProps(props)}
107
+ accessible={props.accessible}
108
+ accessibilityLabel={props.accessibilityLabel}
109
+ accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
110
+ accessibilityHint={props.accessibilityHint}
111
+ accessibilityValue={props.accessibilityValue}
112
+ accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
113
+ accessibilityLiveRegion={props.accessibilityLiveRegion as any}
114
+ >
115
+ <xplatvideo
116
+ ref={bindHost}
117
+ autoplay={props.playing ?? props.autoPlay ?? false}
118
+ controls={false}
119
+ loop={props.loop}
120
+ muted={muted}
121
+ fill={FILLS[props.fit ?? 'contain']}
122
+ observeCurrentTime={props.controls !== false}
123
+ onFinished={() => {
124
+ if (!props.loop) {
125
+ setPlaying(false);
126
+ props.onEnded?.();
127
+ }
128
+ }}
129
+ onPlaybackReady={() => {
130
+ const v = host.current;
131
+ const ms = v?.getDuration?.() ?? 0;
132
+ setDuration(ms);
133
+ props.onReady?.({ duration: ms });
134
+ }}
135
+ onPlaybackStart={() => setStarted(true)}
136
+ onCurrentTimeUpdated={(e: any) => setPosition(e.position ?? 0)}
137
+ />
138
+ @if (props.controls !== false) {
139
+ <VideoChrome
140
+ poster={props.poster}
141
+ started={started}
142
+ playing={playing}
143
+ position={position}
144
+ duration={duration}
145
+ muted={muted}
146
+ onTogglePlay={togglePlay}
147
+ onToggleMute={() => {
148
+ const next = !muted;
149
+ setInternalMuted(next);
150
+ if (host.current) host.current.muted = next;
151
+ props.onMutedChange?.(next);
152
+ }}
153
+ onSeek={(ms: number) => {
154
+ if (host.current) {
155
+ host.current.seekToTime(ms / 1000);
156
+ setPosition(ms);
157
+ }
158
+ }}
159
+ />
160
+ }
161
+ </gridlayout>
162
+ }
@@ -0,0 +1,147 @@
1
+ import { useEffect, useRef, useState } from 'octane';
2
+ import type { VideoHandle, VideoProps } from './props';
3
+ import { applyEscapeProps } from './escape-props';
4
+ import { layoutChildProps } from './layout-child.web';
5
+ import { VideoChrome } from './video-chrome.web.tsrx';
6
+
7
+ const MEDIA_ERR: Record<number, string> = {
8
+ 1: 'aborted',
9
+ 2: 'network',
10
+ 3: 'decode',
11
+ 4: 'source-not-supported',
12
+ };
13
+
14
+ /** Video — chrome-reset surface + self-drawn transport. The `<video>`
15
+ * element runs chrome-free (`controls` never set); the transport is the
16
+ * shared `VideoChrome` overlay, identical source on every target. Times
17
+ * are milliseconds — converted from the element's seconds at the
18
+ * boundary. */
19
+ export function Video(props: VideoProps) @{
20
+ const el = useRef<HTMLVideoElement | null>(null);
21
+ const [playing, setPlaying] = useState(props.playing ?? props.autoPlay ?? false);
22
+ const [started, setStarted] = useState(false);
23
+ const [position, setPosition] = useState(0);
24
+ const [duration, setDuration] = useState(0);
25
+ const [internalMuted, setInternalMuted] = useState(false);
26
+ const muted = props.muted ?? internalMuted;
27
+
28
+ const drive = (v: HTMLVideoElement | null, next: boolean) => {
29
+ if (!v) return;
30
+ if (next) {
31
+ // Autoplay-with-sound rejection parks the leaf back in paused.
32
+ v.play()?.catch(() => setPlaying(false));
33
+ } else {
34
+ v.pause();
35
+ }
36
+ };
37
+
38
+ // Controlled `playing` writes land imperatively — the DOM renderer
39
+ // diffs props, but play state is a method call, not an attribute.
40
+ useEffect(() => {
41
+ if (props.playing === undefined) return;
42
+ setPlaying(props.playing);
43
+ drive(el.current, props.playing);
44
+ }, [props.playing]);
45
+
46
+ // Uncontrolled autoplay start + controlled-prop mute echo.
47
+ useEffect(() => {
48
+ const v = el.current;
49
+ if (!v) return;
50
+ v.muted = muted;
51
+ if (props.autoPlay || props.playing) {
52
+ v.play()?.catch(() => setPlaying(false));
53
+ }
54
+ }, [muted]);
55
+
56
+ const togglePlay = () => {
57
+ const next = !playing;
58
+ setPlaying(next);
59
+ drive(el.current, next);
60
+ props.onPlayingChange?.(next);
61
+ };
62
+
63
+ const handle = (v: HTMLVideoElement): VideoHandle => ({
64
+ play: () => v.play(),
65
+ pause: () => v.pause(),
66
+ seekTo: (ms: number) => {
67
+ v.currentTime = ms / 1000;
68
+ },
69
+ currentTime: () => v.currentTime * 1000,
70
+ duration: () => (Number.isFinite(v.duration) ? v.duration * 1000 : 0),
71
+ native: v,
72
+ });
73
+
74
+ <div
75
+ id={props.id}
76
+ className={['vx-stack', 'vx-video', props.className]}
77
+ {...layoutChildProps(props, props.style)}
78
+ role={props.accessibilityRole}
79
+ aria-label={props.accessibilityLabel}
80
+ aria-hidden={props.accessible === false ? true : undefined}
81
+ aria-description={props.accessibilityHint}
82
+ aria-valuetext={props.accessibilityValue}
83
+ aria-disabled={props.accessibilityState?.disabled}
84
+ aria-selected={props.accessibilityState?.selected}
85
+ aria-checked={props.accessibilityState?.checked}
86
+ aria-live={props.accessibilityLiveRegion === 'none' ? 'off' : props.accessibilityLiveRegion}
87
+ >
88
+ <video
89
+ ref={(v: HTMLVideoElement | null) => {
90
+ el.current = v;
91
+ if (!v) return;
92
+ v.muted = muted;
93
+ applyEscapeProps(v, props);
94
+ props.bind?.(handle(v));
95
+ }}
96
+ className="vx-video-surface"
97
+ src={props.src}
98
+ poster={props.poster}
99
+ muted={muted}
100
+ loop={props.loop}
101
+ playsInline={true}
102
+ preload="metadata"
103
+ style={{ objectFit: props.fit ?? 'contain' }}
104
+ onPlay={() => setPlaying(true)}
105
+ onPause={() => setPlaying(false)}
106
+ onPlaying={() => setStarted(true)}
107
+ onLoadedMetadata={(e: any) => {
108
+ const ms = e.currentTarget.duration * 1000;
109
+ setDuration(Number.isFinite(ms) ? ms : 0);
110
+ props.onReady?.({ duration: Number.isFinite(ms) ? ms : 0 });
111
+ }}
112
+ onTimeUpdate={(e: any) => setPosition(e.currentTarget.currentTime * 1000)}
113
+ onEnded={() => {
114
+ if (props.loop) return;
115
+ setPlaying(false);
116
+ props.onEnded?.();
117
+ }}
118
+ onError={(e: any) => {
119
+ const code = e.currentTarget?.error?.code ?? 0;
120
+ props.onError?.({ message: MEDIA_ERR[code] ?? 'unknown' });
121
+ }}
122
+ />
123
+ @if (props.controls !== false) {
124
+ <VideoChrome
125
+ poster={props.poster}
126
+ started={started}
127
+ playing={playing}
128
+ position={position}
129
+ duration={duration}
130
+ muted={muted}
131
+ onTogglePlay={togglePlay}
132
+ onToggleMute={() => {
133
+ const next = !muted;
134
+ setInternalMuted(next);
135
+ if (el.current) el.current.muted = next;
136
+ props.onMutedChange?.(next);
137
+ }}
138
+ onSeek={(ms: number) => {
139
+ if (el.current) {
140
+ el.current.currentTime = ms / 1000;
141
+ setPosition(ms);
142
+ }
143
+ }}
144
+ />
145
+ }
146
+ </div>
147
+ }
@@ -0,0 +1,8 @@
1
+ /** NS `className` splits on ' ' only, and the universal driver applies the
2
+ * prop via `String(value)` — an array would arrive comma-joined and match
3
+ * no class at all. Native leaves normalize through cx() before handing
4
+ * className to an intrinsic. (The DOM renderer already flattens arrays,
5
+ * so web leaves don't need this.) */
6
+ export function cx(...parts: any[]): string {
7
+ return parts.flat(Infinity).filter(Boolean).join(' ')
8
+ }
@@ -0,0 +1,65 @@
1
+ import { isAndroid, isIOS } from '@nativescript/core'
2
+ import type { Role } from './props'
3
+
4
+ /** Apply only the active device's escape bag after the primitive's own props. */
5
+ export function applyEscapeProps(view: any, props: { ios?: any; android?: any }): void {
6
+ if (!view) {
7
+ return
8
+ }
9
+
10
+ const bag = isIOS ? props.ios : isAndroid ? props.android : undefined
11
+ if (bag) {
12
+ Object.assign(view, bag)
13
+ }
14
+ }
15
+
16
+ const NATIVE_ACCESSIBILITY_ROLES: Record<Role, string> = {
17
+ button: 'button',
18
+ link: 'link',
19
+ search: 'search',
20
+ image: 'image',
21
+ heading: 'header',
22
+ adjustable: 'adjustable',
23
+ summary: 'summary',
24
+ text: 'text',
25
+ none: 'none',
26
+ progressbar: 'progressBar',
27
+ checkbox: 'checkbox',
28
+ switch: 'switch',
29
+ radio: 'radioButton',
30
+ spinbutton: 'spinButton',
31
+ // NativeScript has no tab role; a tab is still an actionable button and
32
+ // its selected state is carried separately by accessibilityState.
33
+ tab: 'button',
34
+ }
35
+
36
+ /** Translate the shared/ARIA spelling to NativeScript's narrower role enum. */
37
+ export function nativeAccessibilityRole(role?: Role): string | undefined {
38
+ return role ? NATIVE_ACCESSIBILITY_ROLES[role] : undefined
39
+ }
40
+
41
+ /** NativeScript's AccessibilityState is a single enum value, unlike the
42
+ * web's independent ARIA booleans. Priority follows the strongest state. */
43
+ export function nativeAccessibilityState(state?: {
44
+ disabled?: boolean
45
+ selected?: boolean
46
+ checked?: boolean
47
+ }): string | undefined {
48
+ if (state?.disabled) {
49
+ return 'disabled'
50
+ }
51
+
52
+ if (state?.selected) {
53
+ return 'selected'
54
+ }
55
+
56
+ if (state?.checked === true) {
57
+ return 'checked'
58
+ }
59
+
60
+ if (state?.checked === false) {
61
+ return 'unchecked'
62
+ }
63
+
64
+ return undefined
65
+ }
@@ -0,0 +1,25 @@
1
+ /** Apply DOM escape props after the primitive's own props. Dash-named keys
2
+ * are attributes; other keys use the DOM property where available. */
3
+ export function applyEscapeProps(element: any, props: { web?: any }): void {
4
+ if (!element || !props.web) {
5
+ return
6
+ }
7
+
8
+ for (const [key, value] of Object.entries(props.web)) {
9
+ if (key.includes('-')) {
10
+ if (value == null || value === false) {
11
+ element.removeAttribute(key)
12
+ } else {
13
+ element.setAttribute(key, value === true ? '' : String(value))
14
+ }
15
+ } else if (key in element) {
16
+ try {
17
+ element[key] = value
18
+ } catch {
19
+ element.setAttribute(key, String(value))
20
+ }
21
+ } else if (value != null) {
22
+ element.setAttribute(key, String(value))
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,2 @@
1
+ export { Video } from './Video.macos.tsrx'
2
+ export type { VideoEvent, VideoFit, VideoHandle, VideoProps } from './props'
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { Video } from './Video.tsrx'
2
+ export type { VideoEvent, VideoFit, VideoHandle, VideoProps } from './props'
@@ -0,0 +1,2 @@
1
+ export { Video } from './Video.web.tsrx'
2
+ export type { VideoEvent, VideoFit, VideoHandle, VideoProps } from './props'
@@ -0,0 +1,57 @@
1
+ import type { LayoutChildProps, FlexContainerProps } from './props'
2
+
3
+ // start/end shorthand → flex-start/flex-end.
4
+ const FLEX_JUSTIFY: Record<string, string> = { start: 'flex-start', end: 'flex-end' }
5
+ const FLEX_ALIGN: Record<string, string> = { start: 'flex-start', end: 'flex-end' }
6
+
7
+ /** NativeScript reads these attributes from each child view during parent
8
+ * layout. Values are forwarded with NativeScript's own property names.
9
+ * The flex-container props (justifyContent/alignItems/flexWrap/gap) land on
10
+ * the host flexboxlayout itself — its own attributes, not child metadata. */
11
+ export function layoutChildProps(props: LayoutChildProps & Partial<FlexContainerProps>) {
12
+ const result: Record<string, any> = {}
13
+ for (const key of [
14
+ 'row',
15
+ 'col',
16
+ 'rowSpan',
17
+ 'colSpan',
18
+ 'dock',
19
+ 'left',
20
+ 'top',
21
+ 'flexGrow',
22
+ 'flexShrink',
23
+ 'alignSelf',
24
+ 'order',
25
+ ] as const) {
26
+ if (props[key] !== undefined) {
27
+ result[key] = props[key]
28
+ }
29
+ }
30
+
31
+ if (props.justifyContent !== undefined) {
32
+ result.justifyContent = FLEX_JUSTIFY[props.justifyContent] ?? props.justifyContent
33
+ }
34
+
35
+ if (props.alignItems !== undefined) {
36
+ result.alignItems = FLEX_ALIGN[props.alignItems] ?? props.alignItems
37
+ }
38
+
39
+ if (props.flexWrap !== undefined) {
40
+ result.flexWrap =
41
+ props.flexWrap === true ? 'wrap' : props.flexWrap === false ? 'nowrap' : props.flexWrap
42
+ }
43
+
44
+ if (props.gap !== undefined) {
45
+ result.gap = props.gap
46
+ }
47
+
48
+ if (props.rowGap !== undefined) {
49
+ result.rowGap = props.rowGap
50
+ }
51
+
52
+ if (props.columnGap !== undefined) {
53
+ result.columnGap = props.columnGap
54
+ }
55
+
56
+ return result
57
+ }
@@ -0,0 +1,108 @@
1
+ import type { LayoutChildProps, FlexContainerProps } from './props'
2
+
3
+ /** The shared contract is "numbers are dips" (RN semantics), but octane's DOM
4
+ * renderer keeps React's unitless list — lineHeight is unitless there, so
5
+ * `style={{lineHeight: 40}}` would render a 40× multiplier instead of 40px.
6
+ * lineHeight is the one unitless-in-React prop that is a length in the
7
+ * shared vocabulary; force the px suffix. */
8
+ export function normStyle(style: any) {
9
+ if (style && typeof style.lineHeight === 'number') {
10
+ return { ...style, lineHeight: style.lineHeight + 'px' }
11
+ }
12
+
13
+ return style
14
+ }
15
+
16
+ // start/end shorthand → flex-start/flex-end (valid in both engines).
17
+ const FLEX_JUSTIFY: Record<string, string> = {
18
+ start: 'flex-start',
19
+ end: 'flex-end',
20
+ }
21
+
22
+ const FLEX_ALIGN: Record<string, string> = {
23
+ start: 'flex-start',
24
+ end: 'flex-end',
25
+ }
26
+
27
+ /** Convert parent-layout metadata to CSS on the web leaf. Native's `dock`
28
+ * attribute has no CSS equivalent and is ignored on web. Grid indices are
29
+ * zero-based in the shared API, matching NativeScript. */
30
+ export function layoutChildProps(
31
+ props: LayoutChildProps & Partial<FlexContainerProps> & { style?: any },
32
+ baseStyle = props.style,
33
+ ) {
34
+ const style = { ...normStyle(baseStyle) }
35
+ if (props.row !== undefined) {
36
+ style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`
37
+ } else if (props.rowSpan !== undefined) {
38
+ style.gridRow = `span ${props.rowSpan}`
39
+ }
40
+
41
+ if (props.col !== undefined) {
42
+ style.gridColumn = `${props.col + 1} / span ${props.colSpan ?? 1}`
43
+ } else if (props.colSpan !== undefined) {
44
+ style.gridColumn = `span ${props.colSpan}`
45
+ }
46
+
47
+ if (props.left !== undefined) {
48
+ style.left = props.left
49
+ }
50
+
51
+ if (props.top !== undefined) {
52
+ style.top = props.top
53
+ }
54
+
55
+ if (props.flexGrow !== undefined) {
56
+ style.flexGrow = props.flexGrow
57
+ }
58
+
59
+ if (props.flexShrink !== undefined) {
60
+ style.flexShrink = props.flexShrink
61
+ }
62
+
63
+ if (props.alignSelf !== undefined) {
64
+ style.alignSelf = props.alignSelf
65
+ }
66
+
67
+ if (props.order !== undefined) {
68
+ style.order = props.order
69
+ }
70
+
71
+ // Flex-container props (View/Row/Pressable hosts) — RN vocabulary on the
72
+ // element style. Numbers become px via octane's style normalization.
73
+ if (props.justifyContent !== undefined) {
74
+ style.justifyContent = FLEX_JUSTIFY[props.justifyContent] ?? props.justifyContent
75
+ }
76
+
77
+ if (props.alignItems !== undefined) {
78
+ style.alignItems = FLEX_ALIGN[props.alignItems] ?? props.alignItems
79
+ }
80
+
81
+ if (props.flexWrap !== undefined) {
82
+ style.flexWrap =
83
+ props.flexWrap === true ? 'wrap' : props.flexWrap === false ? 'nowrap' : props.flexWrap
84
+ }
85
+
86
+ if (props.gap !== undefined) {
87
+ style.gap = props.gap
88
+ }
89
+
90
+ if (props.rowGap !== undefined) {
91
+ style.rowGap = props.rowGap
92
+ }
93
+
94
+ if (props.columnGap !== undefined) {
95
+ style.columnGap = props.columnGap
96
+ }
97
+
98
+ // The shared contract is "numbers are dips" (RN semantics), but octane's
99
+ // DOM renderer keeps React's unitless list — lineHeight is unitless there,
100
+ // so `style={{lineHeight: 40}}` would render a 40× multiplier instead of
101
+ // 40px. lineHeight is the one unitless-in-React prop that is a length in
102
+ // the shared vocabulary; force the px suffix.
103
+ if (typeof style.lineHeight === 'number') {
104
+ style.lineHeight = style.lineHeight + 'px'
105
+ }
106
+
107
+ return { style }
108
+ }