@octane-xplat/video 0.0.0 → 0.7.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/dist/native/Video.tsrx.js +248 -0
- package/dist/native/cx.mobile.js +11 -0
- package/dist/native/escape-props.js +39 -0
- package/dist/native/index.js +2 -0
- package/dist/native/layout-child.js +38 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/native/video-chrome.tsrx.js +359 -0
- package/dist/web/Video.web.tsrx.js +400 -0
- package/dist/web/escape-props.web.js +17 -0
- package/dist/web/index.js +2 -0
- package/dist/web/layout-child.web.js +47 -0
- package/dist/web/octane-client-build.json +8 -0
- package/dist/web/video-chrome.web.tsrx.js +201 -0
- package/package.json +75 -5
- package/src/Video.macos.tsrx +7 -0
- package/src/Video.tsrx +162 -0
- package/src/Video.web.tsrx +147 -0
- package/src/cx.mobile.ts +8 -0
- package/src/escape-props.ts +65 -0
- package/src/escape-props.web.ts +25 -0
- package/src/index.macos.ts +2 -0
- package/src/index.ts +2 -0
- package/src/index.web.ts +2 -0
- package/src/layout-child.ts +57 -0
- package/src/layout-child.web.ts +108 -0
- package/src/props.ts +125 -0
- package/src/video-chrome.tsrx +117 -0
- package/src/video-chrome.web.tsrx +116 -0
- package/types/index.d.ts +5 -0
- package/types/index.macos.d.ts +4 -0
- package/types/props.d.ts +103 -0
package/package.json
CHANGED
|
@@ -1,6 +1,76 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@octane-xplat/video",
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"description": "Embedded video player for Octane xplat — web <video> + @nstudio/nativescript-exoplayer (ExoPlayer / AVPlayerViewController) with self-drawn chrome",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"octane",
|
|
7
|
+
"xplat",
|
|
8
|
+
"cross-platform",
|
|
9
|
+
"video",
|
|
10
|
+
"video-player",
|
|
11
|
+
"exoplayer",
|
|
12
|
+
"avplayer",
|
|
13
|
+
"media",
|
|
14
|
+
"nativescript",
|
|
15
|
+
"ios",
|
|
16
|
+
"android",
|
|
17
|
+
"web"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/video",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/octane-xplat/octane-xplat.git",
|
|
24
|
+
"directory": "packages/video"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"types",
|
|
29
|
+
"src"
|
|
30
|
+
],
|
|
31
|
+
"type": "module",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"macos": {
|
|
35
|
+
"types": "./types/index.macos.d.ts",
|
|
36
|
+
"default": "./src/index.macos.ts"
|
|
37
|
+
},
|
|
38
|
+
"types": "./types/index.d.ts",
|
|
39
|
+
"web": "./dist/web/index.js",
|
|
40
|
+
"native": "./dist/native/index.js",
|
|
41
|
+
"default": "./dist/native/index.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@nstudio/nativescript-exoplayer": "6.2.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@nativescript-community/octane": "0.2.1",
|
|
52
|
+
"@nativescript/core": "9.1.2",
|
|
53
|
+
"@octane-xplat/ui": "0.7.1",
|
|
54
|
+
"@octanejs/vite-plugin": "0.1.61",
|
|
55
|
+
"octane": "0.6.3",
|
|
56
|
+
"typescript": "5.9.3",
|
|
57
|
+
"vite": "8.3.0"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
61
|
+
"@nativescript/core": ">=9.1.0 <10",
|
|
62
|
+
"@octane-xplat/ui": ">=0.6.0 <1",
|
|
63
|
+
"octane": ">=0.6.3 <1"
|
|
64
|
+
},
|
|
65
|
+
"peerDependenciesMeta": {
|
|
66
|
+
"@nativescript-community/octane": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"@nativescript/core": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"build": "vite build && vite build --mode native && tsc -p tsconfig.types.json && node ../../scripts/check-native-dist.mjs"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -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
|
+
}
|
package/src/cx.mobile.ts
ADDED
|
@@ -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
|
+
}
|
package/src/index.ts
ADDED
package/src/index.web.ts
ADDED
|
@@ -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
|
+
}
|