@namiml/expo-sdk 3.4.0-dev.202605060437

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.
Files changed (60) hide show
  1. package/dist/index.cjs +4000 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.d.ts +151 -0
  4. package/dist/index.mjs +3966 -0
  5. package/dist/index.mjs.map +1 -0
  6. package/nami-expo-nami-iap.tgz +0 -0
  7. package/package.json +92 -0
  8. package/src/adapters/expo-device.adapter.ts +106 -0
  9. package/src/adapters/expo-purchase.adapter.ts +79 -0
  10. package/src/adapters/expo-storage.adapter.ts +92 -0
  11. package/src/adapters/expo-ui.adapter.ts +57 -0
  12. package/src/adapters/index.ts +33 -0
  13. package/src/amazon-kepler.d.ts +7 -0
  14. package/src/components/NamiView.tsx +1006 -0
  15. package/src/components/PaywallScreen.tsx +245 -0
  16. package/src/components/TemplateRenderer.tsx +243 -0
  17. package/src/components/containers/NamiBackgroundContainer.tsx +103 -0
  18. package/src/components/containers/NamiCarousel.tsx +217 -0
  19. package/src/components/containers/NamiCollapseContainer.tsx +116 -0
  20. package/src/components/containers/NamiContainer.tsx +315 -0
  21. package/src/components/containers/NamiContentContainer.tsx +140 -0
  22. package/src/components/containers/NamiFooter.tsx +35 -0
  23. package/src/components/containers/NamiHeader.tsx +45 -0
  24. package/src/components/containers/NamiProductContainer.tsx +248 -0
  25. package/src/components/containers/NamiRepeatingGrid.tsx +81 -0
  26. package/src/components/containers/NamiResponsiveGrid.tsx +75 -0
  27. package/src/components/containers/NamiStack.tsx +69 -0
  28. package/src/components/elements/NamiButton.tsx +285 -0
  29. package/src/components/elements/NamiCountdownTimer.tsx +123 -0
  30. package/src/components/elements/NamiImage.tsx +177 -0
  31. package/src/components/elements/NamiPlayPauseButton.tsx +93 -0
  32. package/src/components/elements/NamiProgressBar.tsx +90 -0
  33. package/src/components/elements/NamiProgressIndicator.tsx +41 -0
  34. package/src/components/elements/NamiQRCode.tsx +51 -0
  35. package/src/components/elements/NamiRadioButton.tsx +62 -0
  36. package/src/components/elements/NamiSegmentPicker.tsx +67 -0
  37. package/src/components/elements/NamiSegmentPickerItem.tsx +184 -0
  38. package/src/components/elements/NamiSpacer.tsx +23 -0
  39. package/src/components/elements/NamiSymbol.tsx +104 -0
  40. package/src/components/elements/NamiText.tsx +311 -0
  41. package/src/components/elements/NamiToggleButton.tsx +102 -0
  42. package/src/components/elements/NamiToggleSwitch.tsx +64 -0
  43. package/src/components/elements/NamiVideo.kepler.tsx +638 -0
  44. package/src/components/elements/NamiVideo.tsx +133 -0
  45. package/src/components/elements/NamiVolumeButton.tsx +93 -0
  46. package/src/context/FocusContext.tsx +169 -0
  47. package/src/context/PaywallContext.tsx +343 -0
  48. package/src/global.d.ts +5 -0
  49. package/src/index.ts +62 -0
  50. package/src/nami.ts +24 -0
  51. package/src/react-native-qrcode-svg.d.ts +4 -0
  52. package/src/utils/actionHandler.ts +281 -0
  53. package/src/utils/fonts.ts +359 -0
  54. package/src/utils/iconMap.ts +67 -0
  55. package/src/utils/impression.ts +39 -0
  56. package/src/utils/rendering.ts +197 -0
  57. package/src/utils/smartText.ts +148 -0
  58. package/src/utils/styles.ts +668 -0
  59. package/src/utils/tvFocus.ts +31 -0
  60. package/src/utils/videoControls.ts +49 -0
@@ -0,0 +1,285 @@
1
+ import React, { useCallback, useMemo, useRef } from 'react';
2
+ import { Pressable, ActivityIndicator, StyleSheet, View, Linking as RNLinking } from 'react-native';
3
+ import { useFirstFocusReadyContext, usePaywallContext } from '../../context/PaywallContext';
4
+ import { applyStyles, parseColor, childSpacingStyle, extractPrefixedStyles, focusedStyleOverrides, parseSize } from '../../utils/styles';
5
+ import {
6
+ handleAction,
7
+ shouldHoist,
8
+ ACTION_DEEP_LINK,
9
+ ACTION_BUY_SKU,
10
+ ACTION_SELECT_SKU,
11
+ } from '../../utils/actionHandler';
12
+ import { buildSmartTextReplacements, interpolateSmartText } from '../../utils/smartText';
13
+ import { TemplateRenderer } from '../TemplateRenderer';
14
+ import type { TComponent } from '@namiml/sdk-core';
15
+ import { NamiReservedActions, SHOULD_SHOW_LOADING_INDICATOR } from '@namiml/sdk-core';
16
+ import { FocusedStyleProvider, useFocusableState, useFocusEnabled, useRegisterPreferredFocus } from '../../context/FocusContext';
17
+ import { expandRenderableComponents } from '../../utils/rendering';
18
+ import { useTVPreferredFocus } from '../../utils/tvFocus';
19
+
20
+ interface Props {
21
+ component: any;
22
+ scaleFactor: number;
23
+ onClose?: () => void;
24
+ parentDirection?: string;
25
+ }
26
+
27
+ function createFlowButtonHandle(component: any) {
28
+ return {
29
+ ...component,
30
+ firstTextValue(): string | undefined {
31
+ const { components = [] } = component ?? {};
32
+ if (!Array.isArray(components)) return undefined;
33
+
34
+ for (const child of components) {
35
+ if (child?.component === 'text' && typeof child?.text === 'string') {
36
+ return child.text;
37
+ }
38
+ }
39
+
40
+ return undefined;
41
+ },
42
+ };
43
+ }
44
+
45
+ function openComponentUrl(url?: string) {
46
+ if (!url) return;
47
+
48
+ void RNLinking.canOpenURL(url)
49
+ .then((canOpen) => {
50
+ if (canOpen) {
51
+ return RNLinking.openURL(url);
52
+ }
53
+ return undefined;
54
+ })
55
+ .catch(() => {});
56
+ }
57
+
58
+ function withDisabledChildStyles(child: TComponent, disabled: boolean): TComponent {
59
+ if (!disabled || !child || typeof child !== 'object') {
60
+ return child;
61
+ }
62
+
63
+ const disabledStyles = extractPrefixedStyles(child as Record<string, any>, 'disabled');
64
+ const safeDisabledStyles = Object.entries(disabledStyles).reduce((result, [key, value]) => {
65
+ if (value !== null && value !== undefined) {
66
+ result[key] = value;
67
+ }
68
+ return result;
69
+ }, {} as Record<string, any>);
70
+
71
+ return { ...child, ...safeDisabledStyles } as TComponent;
72
+ }
73
+
74
+ export const NamiButton: React.FC<Props> = ({ component, scaleFactor, onClose, parentDirection }) => {
75
+ const ctx = usePaywallContext();
76
+ const focusReadyCtx = useFirstFocusReadyContext();
77
+ const focusEnabled = useFocusEnabled();
78
+ const pressableRef = useRef<any>(null);
79
+ const { isFocused, onFocus, onBlur } = useFocusableState(
80
+ Boolean(component.focused),
81
+ focusEnabled,
82
+ );
83
+ useTVPreferredFocus(
84
+ pressableRef,
85
+ focusEnabled && Boolean(component.focused),
86
+ );
87
+ useRegisterPreferredFocus(
88
+ pressableRef.current,
89
+ focusEnabled && Boolean(component.focused),
90
+ );
91
+ const smartTextSku = component?.smartTextSku ?? component?.sku;
92
+ const resolvedChildren = useMemo(
93
+ () => expandRenderableComponents(ctx, component.components),
94
+ [ctx, component.components],
95
+ );
96
+
97
+ const disabled = useMemo(() => {
98
+ if (component.disabled === true) return true;
99
+ if (!component.disabled) return false;
100
+ const replacements = buildSmartTextReplacements(ctx.state, ctx.flow, smartTextSku);
101
+ const resolved = interpolateSmartText(String(component.disabled), replacements);
102
+ return resolved === true || resolved === 'true';
103
+ }, [component.disabled, smartTextSku, ctx.state, ctx.flow]);
104
+
105
+ const sku = useMemo(() => {
106
+ if (!component.sku) return undefined;
107
+ const items = ctx.state.skuItems ?? [];
108
+ return items.find((s: any) => s.sku_ref_id === component.sku?.sku_ref_id) ?? component.sku;
109
+ }, [component.sku, ctx.state.skuItems]);
110
+
111
+ const onPress = useCallback(() => {
112
+ if (disabled || !ctx.state.userInteractionEnabled) return;
113
+ const onTap = component.onTap;
114
+ const url = component.url;
115
+ const flowButton = createFlowButtonHandle(component);
116
+
117
+ if (url && onTap?.function !== ACTION_DEEP_LINK) {
118
+ openComponentUrl(url);
119
+ return;
120
+ }
121
+
122
+ const currentGroupId = onTap?.parameters?.partialState?.currentGroupId;
123
+ if (currentGroupId) {
124
+ ctx.setCurrentGroupData(String(currentGroupId), '');
125
+ }
126
+
127
+ if (ctx.flow && component.id) {
128
+ let actionId = component.id;
129
+ if (onTap?.function === ACTION_BUY_SKU || onTap?.function === ACTION_SELECT_SKU) {
130
+ actionId = NamiReservedActions.BUY_SKU;
131
+ }
132
+
133
+ if (ctx.flow.currentStepHasHoistedPrimaryActions(actionId)) {
134
+ ctx.flow.triggerActions(actionId, flowButton, { sku });
135
+ return;
136
+ }
137
+
138
+ if (shouldHoist(onTap)) {
139
+ ctx.flow.triggerBeforeActions(component.id, flowButton, { sku });
140
+ handleAction({
141
+ onTap,
142
+ sku,
143
+ ctx,
144
+ onClose,
145
+ componentChange: {
146
+ id: component.id,
147
+ name: component.title ?? component.screenreaderText,
148
+ },
149
+ });
150
+ ctx.flow.triggerAfterActions(component.id, flowButton, { sku });
151
+ return;
152
+ }
153
+ }
154
+
155
+ if (onTap) {
156
+ handleAction({
157
+ onTap,
158
+ sku,
159
+ ctx,
160
+ onClose,
161
+ componentChange: {
162
+ id: component.id,
163
+ name: component.title ?? component.screenreaderText,
164
+ },
165
+ });
166
+ }
167
+ }, [component, sku, ctx, disabled, onClose]);
168
+
169
+ const handleFocus = useCallback(() => {
170
+ onFocus();
171
+ const paywallId = ctx.state.selectedPaywall?.id ?? 'unknown';
172
+ const page = ctx.state.currentPage ?? 'unknown';
173
+ const formFactor = ctx.state.formFactor;
174
+ focusReadyCtx.notifyFirstFocusReady(paywallId, page, formFactor);
175
+ }, [onFocus, focusReadyCtx, ctx.state.selectedPaywall?.id, ctx.state.currentPage, ctx.state.formFactor]);
176
+
177
+ const { backgroundColor, borderOverride } = useMemo(() => {
178
+ const focused = isFocused;
179
+ const fill = focused ? (component.focusedFillColor ?? component.focusedFillColorFallback) : component.fillColor;
180
+ const fallback = focused ? component.focusedFillColorFallback : component.fillColorFallback;
181
+ const textColor = focused ? parseColor(component.focusedFontColor) ?? parseColor(component.fontColor) : parseColor(component.fontColor);
182
+ const bgSource = fill ?? fallback;
183
+ const bgColor = parseColor(bgSource) ?? parseColor(fallback);
184
+ return {
185
+ color: textColor,
186
+ backgroundColor: bgColor,
187
+ borderOverride: focused ? focusedStyleOverrides(component, scaleFactor) : {},
188
+ };
189
+ }, [component, scaleFactor, isFocused]);
190
+
191
+ const containerStyle = useMemo(() => {
192
+ const disabledStyles = extractPrefixedStyles(component, 'disabled');
193
+ const base = applyStyles(disabled ? { ...component, ...disabledStyles } : component, scaleFactor, isFocused, parentDirection);
194
+ if (disabled) {
195
+ const disabledBg = parseColor(disabledStyles.fillColor ?? component.disabledFillColor);
196
+ if (disabledBg) base.backgroundColor = disabledBg;
197
+ base.opacity = 0.6;
198
+ }
199
+
200
+ if (base.borderRadius != null) {
201
+ base.overflow = 'hidden';
202
+ }
203
+ return base;
204
+ }, [component, scaleFactor, disabled, backgroundColor, borderOverride, parentDirection, isFocused ]);
205
+
206
+ const showLoading =
207
+ SHOULD_SHOW_LOADING_INDICATOR &&
208
+ ctx.state.purchaseInProgress &&
209
+ component.sku?.id === (ctx.state as any).productBeingPurchased?.id;
210
+ const renderedChildren = useMemo(() => resolvedChildren.map((child: TComponent, i: number) => {
211
+ const resolvedChild = withDisabledChildStyles(child, disabled);
212
+ const buttonDirection = component.direction ?? "vertical";
213
+ const spacingStyle =
214
+ i === 0
215
+ ? {}
216
+ : childSpacingStyle(
217
+ i,
218
+ { spacing: component.spacing, direction: buttonDirection },
219
+ scaleFactor,
220
+ );
221
+ const hasSpacing = Object.keys(spacingStyle).length > 0;
222
+
223
+ const childRawWidth =
224
+ (resolvedChild as any).width ?? (resolvedChild as any).fixedWidth;
225
+ const needsFlexShare =
226
+ buttonDirection === "horizontal" && childRawWidth == null;
227
+
228
+ const childNode = (
229
+ <TemplateRenderer
230
+ key={resolvedChild.id ?? i}
231
+ component={resolvedChild}
232
+ scaleFactor={scaleFactor}
233
+ onClose={onClose}
234
+ parentDirection={buttonDirection}
235
+ />
236
+ );
237
+
238
+ if (hasSpacing || needsFlexShare) {
239
+ return (
240
+ <View
241
+ key={resolvedChild.id ?? i}
242
+ style={[
243
+ needsFlexShare ? styles.flexShare : null,
244
+ hasSpacing ? spacingStyle : null,
245
+ ]}
246
+ >
247
+ {childNode}
248
+ </View>
249
+ );
250
+ }
251
+
252
+ return childNode;
253
+ }), [resolvedChildren, component.direction, component.spacing, scaleFactor, onClose, disabled]);
254
+
255
+ return (
256
+ <Pressable
257
+ ref={pressableRef}
258
+ onPress={onPress}
259
+ disabled={disabled}
260
+ onFocus={handleFocus}
261
+ onBlur={onBlur}
262
+ accessibilityRole="button"
263
+ accessibilityLabel={component.screenreaderText || component.title || ''}
264
+ accessibilityHint={component.screenreaderHint ?? undefined}
265
+ accessibilityState={{ disabled, busy: showLoading }}
266
+ hasTVPreferredFocus={focusEnabled && Boolean(component.focused)}
267
+ style={({ pressed }) => [containerStyle, pressed && styles.pressed]}
268
+ >
269
+ {showLoading ? (
270
+ <ActivityIndicator
271
+ color={parseColor(component.loadingIndicatorColor) ?? "#fff"}
272
+ />
273
+ ) : (
274
+ <FocusedStyleProvider focused={isFocused}>
275
+ {renderedChildren}
276
+ </FocusedStyleProvider>
277
+ )}
278
+ </Pressable>
279
+ );
280
+ };
281
+
282
+ const styles = StyleSheet.create({
283
+ pressed: { opacity: 0.7 },
284
+ flexShare: { flex: 1 },
285
+ });
@@ -0,0 +1,123 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { View, Text } from 'react-native';
3
+ import { usePaywallContext } from '../../context/PaywallContext';
4
+ import { applyStyles, parseColor, parseSize } from '../../utils/styles';
5
+ import { NamiEventEmitter, PAYWALL_ACTION_EVENT, NamiPaywallAction } from '@namiml/sdk-core';
6
+ import type { TCountdownTimerTextComponent } from '@namiml/sdk-core';
7
+
8
+ interface Props {
9
+ component: TCountdownTimerTextComponent;
10
+ scaleFactor: number;
11
+ parentDirection?: string;
12
+ }
13
+
14
+ export const NamiCountdownTimer: React.FC<Props> = ({ component, scaleFactor, parentDirection }) => {
15
+ const ctx = usePaywallContext();
16
+ const timerId = component.id ?? 'default';
17
+ const mode = component.mode ?? 'duration';
18
+
19
+ const computeInitialSeconds = (): number => {
20
+ const saved = ctx.getTimerState(timerId);
21
+ if (saved) return Math.max(0, saved.remainingSeconds);
22
+
23
+ if (mode === 'targetDateTime' && component.targetDateTime) {
24
+ const target = new Date(component.targetDateTime).getTime();
25
+ const diff = Math.floor((target - Date.now()) / 1000);
26
+ return Math.max(0, diff);
27
+ }
28
+ return component.durationSeconds ?? 60;
29
+ };
30
+
31
+ const [remaining, setRemaining] = useState(computeInitialSeconds);
32
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
33
+
34
+ useEffect(() => {
35
+ if (remaining <= 0) {
36
+ if (intervalRef.current) clearInterval(intervalRef.current);
37
+ const saved = ctx.getTimerState(timerId);
38
+ if (!saved?.hasEmittedCompletion) {
39
+ const eventData = ctx.getPaywallActionEventData();
40
+ NamiEventEmitter.getInstance().emit(PAYWALL_ACTION_EVENT, {
41
+ ...eventData,
42
+ action: NamiPaywallAction.COUNTDOWN_TIMER_COMPLETED,
43
+ });
44
+ ctx.setTimerState(timerId, 0, Date.now(), true);
45
+ }
46
+ return;
47
+ }
48
+
49
+ intervalRef.current = setInterval(() => {
50
+ setRemaining(prev => {
51
+ const next = prev - 1;
52
+ ctx.setTimerState(timerId, next, Date.now(), false);
53
+ return next;
54
+ });
55
+ }, 1000);
56
+
57
+ return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
58
+ }, [remaining <= 0]);
59
+
60
+ // Handle showWhenCompleted
61
+ if (remaining <= 0 && component.showWhenCompleted === 'hidden') return null;
62
+
63
+ const containerStyle = applyStyles(component, scaleFactor, false, parentDirection);
64
+
65
+ const units = component.units ?? { hours: true, minutes: true, seconds: true };
66
+ const labels = component.labels;
67
+ const zeroPad = component.zeroPad !== false;
68
+ const separator = component.separator ?? ':';
69
+ const isHorizontal = (component.direction ?? 'horizontal') === 'horizontal';
70
+ const labelPosition = component.labelPosition ?? 'none';
71
+
72
+ const hrs = Math.floor(remaining / 3600);
73
+ const mins = Math.floor((remaining % 3600) / 60);
74
+ const secs = remaining % 60;
75
+
76
+ const parts: { value: number; label?: string }[] = [];
77
+ if (units.days) {
78
+ const days = Math.floor(remaining / 86400);
79
+ parts.push({ value: days, label: labels?.days });
80
+ }
81
+ if (units.hours) parts.push({ value: hrs, label: labels?.hours });
82
+ if (units.minutes) parts.push({ value: mins, label: labels?.minutes });
83
+ if (units.seconds) parts.push({ value: secs, label: labels?.seconds });
84
+
85
+ const valueFontStyle = {
86
+ fontSize: parseSize(component.fontSize, scaleFactor) ?? 24,
87
+ color: parseColor(component.fontColor) ?? '#000',
88
+ fontFamily: component.fontName,
89
+ includeFontPadding: false,
90
+ };
91
+
92
+ const labelFontStyle = {
93
+ fontSize: parseSize(component.labelFontSize, scaleFactor) ?? 12,
94
+ color: parseColor(component.labelFontColor) ?? '#666',
95
+ fontFamily: component.labelFontName,
96
+ marginTop: labelPosition === 'bottom' || labelPosition === 'below' ? 2 : 0,
97
+ marginBottom: labelPosition === 'top' || labelPosition === 'above' ? 2 : 0,
98
+ includeFontPadding: false,
99
+ };
100
+
101
+ return (
102
+ <View style={[containerStyle, { flexDirection: isHorizontal ? 'row' : 'column', alignItems: 'center' }]}>
103
+ {parts.map((part, i) => (
104
+ <View key={i} style={{ flexDirection: isHorizontal ? 'row' : 'column', alignItems: 'center' }}>
105
+ {i > 0 && isHorizontal && (
106
+ <Text style={valueFontStyle} allowFontScaling={false}>{separator}</Text>
107
+ )}
108
+ <View style={{ alignItems: 'center' }}>
109
+ {labelPosition === 'top' || labelPosition === 'above' ? (
110
+ <Text style={labelFontStyle} allowFontScaling={false}>{part.label}</Text>
111
+ ) : null}
112
+ <Text style={valueFontStyle} allowFontScaling={false}>
113
+ {zeroPad ? String(part.value).padStart(2, '0') : String(part.value)}
114
+ </Text>
115
+ {labelPosition === 'bottom' || labelPosition === 'below' ? (
116
+ <Text style={labelFontStyle} allowFontScaling={false}>{part.label}</Text>
117
+ ) : null}
118
+ </View>
119
+ </View>
120
+ ))}
121
+ </View>
122
+ );
123
+ };
@@ -0,0 +1,177 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { Image as RNImage, View } from 'react-native';
3
+ import { applyStyles, parseSizeOrPercent } from '../../utils/styles';
4
+ import { usePaywallContext } from '../../context/PaywallContext';
5
+ import { buildSmartTextReplacements, interpolateSmartText } from '../../utils/smartText';
6
+
7
+ // Prefer expo-image when available; fall back to react-native Image
8
+ let ImageComponent: typeof RNImage;
9
+ let supportsContentPosition = false;
10
+ let supportsContentFit = false;
11
+ try {
12
+ const expoImage = require('expo-image');
13
+ ImageComponent = expoImage.Image;
14
+ supportsContentPosition = true;
15
+ supportsContentFit = true;
16
+ } catch {
17
+ ImageComponent = RNImage;
18
+ }
19
+
20
+ interface Props {
21
+ component: any;
22
+ scaleFactor: number;
23
+ parentDirection?: string;
24
+ }
25
+
26
+ const aspectRatioCache = new Map<string, number>();
27
+
28
+ export const NamiImage: React.FC<Props> = ({ component, scaleFactor, parentDirection }) => {
29
+ const ctx = usePaywallContext();
30
+ const [sourceAspectRatio, setSourceAspectRatio] = useState<number | null>(() => {
31
+ const raw = component?.url ?? component?.imageUrl;
32
+ if (!raw || typeof raw !== 'string') {
33
+ return null;
34
+ }
35
+
36
+ return aspectRatioCache.get(raw) ?? null;
37
+ });
38
+ const smartTextSku = component?.smartTextSku ?? component?.sku;
39
+
40
+ const url = useMemo(() => {
41
+ const raw = component.url ?? component.imageUrl ?? '';
42
+ const replacements = buildSmartTextReplacements(ctx.state, ctx.flow, smartTextSku);
43
+ const resolved = interpolateSmartText(raw, replacements);
44
+ return resolved == null ? '' : String(resolved);
45
+ }, [component.url, component.imageUrl, smartTextSku, ctx.state, ctx.flow]);
46
+
47
+ if (!url) return null;
48
+
49
+ const rawW = component.width ?? component.fixedWidth;
50
+ const rawH = component.height ?? component.fixedHeight;
51
+ const h = parseSizeOrPercent(rawH ?? '100%', scaleFactor);
52
+ const w = rawW === 'fitContent' && h ? h : parseSizeOrPercent(rawW ?? '100%', scaleFactor);
53
+ const hasPercentWidth = typeof rawW === 'string' && rawW.trim().endsWith('%');
54
+ const numericHeight = typeof h === 'number' ? h : null;
55
+ const prefersEdgeAlignment =
56
+ component.alignment === 'left'
57
+ || component.alignment === 'leading'
58
+ || component.alignment === 'right'
59
+ || component.alignment === 'trailing';
60
+ const shouldUseNativeEdgeFit =
61
+ component.imageCropping === 'fit'
62
+ && hasPercentWidth
63
+ && numericHeight != null
64
+ && prefersEdgeAlignment;
65
+ // On Vega/TV targets, contentPosition alone can still visually center a contained image
66
+ // inside a full-width slot. For left/right aligned fit images, render them at measured
67
+ // fitted width and let the parent container anchor them to the edge.
68
+ const containerStyle: any = applyStyles(component, scaleFactor, false, parentDirection);
69
+ const horizontalAlignment =
70
+ component.alignment === 'left' || component.alignment === 'leading'
71
+ ? 'flex-start'
72
+ : component.alignment === 'right' || component.alignment === 'trailing'
73
+ ? 'flex-end'
74
+ : 'center';
75
+ const contentPosition =
76
+ component.alignment === 'left' || component.alignment === 'leading'
77
+ ? { left: 0, top: '50%' as const }
78
+ : component.alignment === 'right' || component.alignment === 'trailing'
79
+ ? { right: 0, top: '50%' as const }
80
+ : 'center';
81
+ containerStyle.alignItems = horizontalAlignment;
82
+ const imageStyle: any = {
83
+ height: h ?? '100%',
84
+ flexShrink: typeof rawW === 'number' ? 0 : 1,
85
+ marginRight: component.rightMargin ?? component.spacing ?? 0,
86
+ maxWidth: '100%',
87
+ alignSelf: horizontalAlignment,
88
+ width:
89
+ shouldUseNativeEdgeFit && sourceAspectRatio != null
90
+ ? numericHeight! * sourceAspectRatio
91
+ : (w ?? '100%'),
92
+ };
93
+ const contentFit = component.imageCropping === 'fit' ? 'contain' : 'cover';
94
+ const edgeFitReady = !shouldUseNativeEdgeFit || sourceAspectRatio != null;
95
+
96
+ useEffect(() => {
97
+ if (!shouldUseNativeEdgeFit || !url) {
98
+ return;
99
+ }
100
+
101
+ const cachedRatio = aspectRatioCache.get(url);
102
+ if (cachedRatio != null) {
103
+ setSourceAspectRatio((prev) => (prev === cachedRatio ? prev : cachedRatio));
104
+ return;
105
+ }
106
+
107
+ let cancelled = false;
108
+ RNImage.getSize(
109
+ url,
110
+ (width, height) => {
111
+ if (cancelled || !height) return;
112
+ const next = width / height;
113
+ if (!Number.isFinite(next) || next <= 0) return;
114
+ aspectRatioCache.set(url, next);
115
+ setSourceAspectRatio((prev) => (prev === next ? prev : next));
116
+ },
117
+ () => {
118
+ // Fall back to onLoad when size prefetch is unavailable on the target runtime.
119
+ },
120
+ );
121
+
122
+ return () => {
123
+ cancelled = true;
124
+ };
125
+ }, [shouldUseNativeEdgeFit, url]);
126
+
127
+ const applyLoadedSource = useCallback((source: any) => {
128
+ if (!shouldUseNativeEdgeFit) {
129
+ return;
130
+ }
131
+
132
+ const width = source?.width;
133
+ const height = source?.height;
134
+ if (typeof width !== 'number' || typeof height !== 'number' || height <= 0) return;
135
+ const next = width / height;
136
+ if (!Number.isFinite(next) || next <= 0) return;
137
+ aspectRatioCache.set(url, next);
138
+ setSourceAspectRatio((prev) => (prev === next ? prev : next));
139
+ }, [shouldUseNativeEdgeFit, url]);
140
+
141
+ const onNativeImageLoad = useCallback((event: any) => {
142
+ applyLoadedSource(event?.nativeEvent?.source);
143
+ }, [applyLoadedSource]);
144
+
145
+ const onExpoImageLoad = useCallback((event: any) => {
146
+ applyLoadedSource(event?.source);
147
+ }, [applyLoadedSource]);
148
+
149
+ return (
150
+ <View style={containerStyle} pointerEvents="none">
151
+ {shouldUseNativeEdgeFit ? (
152
+ <RNImage
153
+ source={{ uri: url }}
154
+ style={[imageStyle, !edgeFitReady ? styles.hidden : null]}
155
+ blurRadius={component.blur}
156
+ onLoad={shouldUseNativeEdgeFit ? onNativeImageLoad : undefined}
157
+ resizeMode={contentFit}
158
+ />
159
+ ) : (
160
+ <ImageComponent
161
+ source={{ uri: url }}
162
+ style={imageStyle}
163
+ blurRadius={component.blur}
164
+ onLoad={shouldUseNativeEdgeFit ? onExpoImageLoad : undefined}
165
+ {...(supportsContentPosition ? { contentPosition } : {})}
166
+ {...(supportsContentFit ? { contentFit } : { resizeMode: contentFit })}
167
+ />
168
+ )}
169
+ </View>
170
+ );
171
+ };
172
+
173
+ const styles = {
174
+ hidden: {
175
+ opacity: 0,
176
+ },
177
+ };
@@ -0,0 +1,93 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Pressable, StyleSheet, View } from 'react-native';
3
+ import { applyStyles, childSpacingStyle, focusedStyleOverrides } from '../../utils/styles';
4
+ import { TemplateRenderer } from '../TemplateRenderer';
5
+ import { usePaywallContext } from '../../context/PaywallContext';
6
+ import { handleAction } from '../../utils/actionHandler';
7
+ import { getVideoControlState, subscribeVideoControls } from '../../utils/videoControls';
8
+ import type { TComponent } from '@namiml/sdk-core';
9
+ import { FocusedStyleProvider, useFocusableState, useFocusEnabled, useRegisterPreferredFocus } from '../../context/FocusContext';
10
+ import { useTVPreferredFocus } from '../../utils/tvFocus';
11
+
12
+ interface Props {
13
+ component: any;
14
+ scaleFactor: number;
15
+ onClose?: () => void;
16
+ parentDirection?: string;
17
+ }
18
+
19
+ export const NamiPlayPauseButton: React.FC<Props> = ({
20
+ component,
21
+ scaleFactor,
22
+ onClose,
23
+ parentDirection,
24
+ }) => {
25
+ const ctx = usePaywallContext();
26
+ const focusEnabled = useFocusEnabled();
27
+ const pressableRef = useRef<any>(null);
28
+ const { isFocused, onFocus, onBlur } = useFocusableState(
29
+ Boolean(component.focused),
30
+ focusEnabled,
31
+ );
32
+ useTVPreferredFocus(
33
+ pressableRef,
34
+ focusEnabled && Boolean(component.focused),
35
+ );
36
+ useRegisterPreferredFocus(
37
+ pressableRef.current,
38
+ focusEnabled && Boolean(component.focused),
39
+ );
40
+ const [playing, setPlaying] = useState(getVideoControlState().playing);
41
+
42
+ useEffect(() => subscribeVideoControls((next) => setPlaying(next.playing)), []);
43
+
44
+ const containerStyle = useMemo(() => {
45
+ const base = applyStyles(component, scaleFactor, false, parentDirection);
46
+ if (isFocused) {
47
+ Object.assign(base, focusedStyleOverrides(component, scaleFactor));
48
+ }
49
+ return base;
50
+ }, [component, scaleFactor, parentDirection, isFocused]);
51
+
52
+ const onPress = () => {
53
+ handleAction({
54
+ onTap: playing ? component.playingOnTap : component.pausedOnTap,
55
+ ctx,
56
+ onClose,
57
+ });
58
+ };
59
+
60
+ const direction = component.direction ?? 'horizontal';
61
+ const children = (playing ? component.playingComponents : component.pausedComponents) ?? [];
62
+
63
+ return (
64
+ <Pressable
65
+ ref={pressableRef}
66
+ onPress={onPress}
67
+ onFocus={onFocus}
68
+ onBlur={onBlur}
69
+ hasTVPreferredFocus={focusEnabled && Boolean(component.focused)}
70
+ style={({ pressed }) => [containerStyle, pressed ? styles.pressed : null]}
71
+ >
72
+ <FocusedStyleProvider focused={isFocused}>
73
+ {children.map((child: TComponent, i: number) => (
74
+ <View
75
+ key={child.id ?? i}
76
+ style={i === 0 ? null : childSpacingStyle(i, { spacing: component.spacing, direction }, scaleFactor)}
77
+ >
78
+ <TemplateRenderer
79
+ component={child}
80
+ scaleFactor={scaleFactor}
81
+ onClose={onClose}
82
+ parentDirection={direction}
83
+ />
84
+ </View>
85
+ ))}
86
+ </FocusedStyleProvider>
87
+ </Pressable>
88
+ );
89
+ };
90
+
91
+ const styles = StyleSheet.create({
92
+ pressed: { opacity: 0.7 },
93
+ });