@limrun/ui 0.3.2 → 0.4.0-rc.10

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/index.html ADDED
@@ -0,0 +1,180 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>RemoteControl Demo - @limrun/ui</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ min-height: 100vh;
18
+ padding: 20px;
19
+ }
20
+
21
+ #root {
22
+ display: flex;
23
+ flex-direction: column;
24
+ gap: 20px;
25
+ max-width: 1400px;
26
+ margin: 0 auto;
27
+ }
28
+
29
+ .header {
30
+ text-align: center;
31
+ color: white;
32
+ padding: 20px;
33
+ }
34
+
35
+ .header h1 {
36
+ font-size: 2.5rem;
37
+ margin-bottom: 10px;
38
+ text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
39
+ }
40
+
41
+ .header p {
42
+ font-size: 1.1rem;
43
+ opacity: 0.9;
44
+ }
45
+
46
+ .demo-container {
47
+ background: white;
48
+ border-radius: 20px;
49
+ padding: 30px;
50
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
51
+ }
52
+
53
+ .controls {
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 15px;
57
+ margin-bottom: 30px;
58
+ }
59
+
60
+ .control-group {
61
+ display: flex;
62
+ flex-direction: column;
63
+ gap: 8px;
64
+ }
65
+
66
+ .control-group label {
67
+ font-weight: 600;
68
+ font-size: 0.9rem;
69
+ color: #374151;
70
+ }
71
+
72
+ .control-group input,
73
+ .control-group select {
74
+ padding: 10px 15px;
75
+ border: 2px solid #e5e7eb;
76
+ border-radius: 8px;
77
+ font-size: 0.95rem;
78
+ transition: border-color 0.2s;
79
+ }
80
+
81
+ .control-group input:focus,
82
+ .control-group select:focus {
83
+ outline: none;
84
+ border-color: #667eea;
85
+ }
86
+
87
+ .button-group {
88
+ display: flex;
89
+ gap: 10px;
90
+ }
91
+
92
+ button {
93
+ padding: 12px 24px;
94
+ border: none;
95
+ border-radius: 8px;
96
+ font-size: 1rem;
97
+ font-weight: 600;
98
+ cursor: pointer;
99
+ transition: all 0.2s;
100
+ }
101
+
102
+ button.primary {
103
+ background: #667eea;
104
+ color: white;
105
+ }
106
+
107
+ button.primary:hover {
108
+ background: #5568d3;
109
+ transform: translateY(-1px);
110
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
111
+ }
112
+
113
+ button.secondary {
114
+ background: #e5e7eb;
115
+ color: #374151;
116
+ }
117
+
118
+ button.secondary:hover {
119
+ background: #d1d5db;
120
+ }
121
+
122
+ button:disabled {
123
+ opacity: 0.5;
124
+ cursor: not-allowed;
125
+ }
126
+
127
+ .device-preview {
128
+ display: flex;
129
+ gap: 30px;
130
+ justify-content: center;
131
+ flex-wrap: wrap;
132
+ }
133
+
134
+ .preview-item {
135
+ flex: 1;
136
+ height: '20vh';
137
+ width: '20vh';
138
+ }
139
+
140
+ .preview-item h3 {
141
+ text-align: center;
142
+ margin-bottom: 15px;
143
+ font-size: 1.2rem;
144
+ color: #374151;
145
+ }
146
+
147
+ .device-wrapper {
148
+ height: 100%;
149
+ width: 100%;
150
+ background: #f9fafb;
151
+ border-radius: 12px;
152
+ overflow: hidden;
153
+ }
154
+
155
+ .info-box {
156
+ background: #fef3c7;
157
+ border: 2px solid #fbbf24;
158
+ border-radius: 8px;
159
+ padding: 15px;
160
+ margin-bottom: 20px;
161
+ }
162
+
163
+ .info-box h4 {
164
+ color: #92400e;
165
+ margin-bottom: 8px;
166
+ font-size: 0.95rem;
167
+ }
168
+
169
+ .info-box p {
170
+ color: #78350f;
171
+ font-size: 0.9rem;
172
+ line-height: 1.5;
173
+ }
174
+ </style>
175
+ </head>
176
+ <body>
177
+ <div id="root"></div>
178
+ <script type="module" src="/src/demo.tsx"></script>
179
+ </body>
180
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limrun/ui",
3
- "version": "0.3.2",
3
+ "version": "0.4.0-rc.10",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="842.32007" height="1000.0001">
3
+ <path fill="#fff" d="M824.66636 779.30363c-15.12299 34.93724-33.02368 67.09674-53.7638 96.66374-28.27076 40.3074-51.4182 68.2078-69.25717 83.7012-27.65347 25.4313-57.2822 38.4556-89.00964 39.1963-22.77708 0-50.24539-6.4813-82.21973-19.629-32.07926-13.0861-61.55985-19.5673-88.51583-19.5673-28.27075 0-58.59083 6.4812-91.02193 19.5673-32.48053 13.1477-58.64639 19.9994-78.65196 20.6784-30.42501 1.29623-60.75123-12.0985-91.02193-40.2457-19.32039-16.8514-43.48632-45.7394-72.43607-86.6641-31.060778-43.7024-56.597041-94.37983-76.602609-152.15586C10.740416 658.44309 0 598.01283 0 539.50845c0-67.01648 14.481044-124.8172 43.486336-173.25401C66.28194 327.34823 96.60818 296.6578 134.5638 274.1276c37.95566-22.53016 78.96676-34.01129 123.1321-34.74585 24.16591 0 55.85633 7.47508 95.23784 22.166 39.27042 14.74029 64.48571 22.21538 75.54091 22.21538 8.26518 0 36.27668-8.7405 83.7629-26.16587 44.90607-16.16001 82.80614-22.85118 113.85458-20.21546 84.13326 6.78992 147.34122 39.95559 189.37699 99.70686-75.24463 45.59122-112.46573 109.4473-111.72502 191.36456.67899 63.8067 23.82643 116.90384 69.31888 159.06309 20.61664 19.56727 43.64066 34.69027 69.2571 45.4307-5.55531 16.11062-11.41933 31.54225-17.65372 46.35662zM631.70926 20.0057c0 50.01141-18.27108 96.70693-54.6897 139.92782-43.94932 51.38118-97.10817 81.07162-154.75459 76.38659-.73454-5.99983-1.16045-12.31444-1.16045-18.95003 0-48.01091 20.9006-99.39207 58.01678-141.40314 18.53027-21.27094 42.09746-38.95744 70.67685-53.0663C578.3158 9.00229 605.2903 1.31621 630.65988 0c.74076 6.68575 1.04938 13.37191 1.04938 20.00505z"/>
4
+ </svg>
Binary file
Binary file
Binary file
@@ -1,60 +1,54 @@
1
1
  .rc-container {
2
2
  position: relative;
3
- display: flex;
4
- height: 100%;
5
- align-items: center;
6
- justify-content: center;
3
+ display: inline-block;
4
+ box-sizing: border-box;
7
5
  isolation: isolate;
8
- contain: layout style;
9
6
 
10
- background-color: rgba(0, 0, 0, 0.05);
11
7
  touch-action: none;
12
-
13
- --rc-spinner-color: #3b82f6;
14
- --rc-text-muted: #6b7280;
15
8
  }
16
9
 
17
- .rc-video {
10
+ .rc-container-portrait {
18
11
  height: 100%;
12
+ }
13
+
14
+ .rc-container-landscape {
19
15
  width: 100%;
20
- max-height: 100%;
21
- max-width: 100%;
22
- object-fit: contain;
23
- outline: none;
16
+ }
17
+
18
+ .rc-phone-frame {
19
+ position: relative;
20
+ z-index: 20;
21
+ display: block;
24
22
  pointer-events: none;
25
- cursor: none;
23
+ user-select: none;
26
24
  }
27
25
 
28
- .rc-placeholder-wrapper {
29
- position: absolute;
30
- inset: 0;
31
- display: flex;
32
- flex-direction: column;
33
- align-items: center;
34
- justify-content: center;
35
- text-align: center;
26
+ .rc-phone-frame-portrait {
27
+ height: 100%;
28
+ max-width: 100%;
36
29
  }
37
30
 
38
- .rc-placeholder-content {
39
- color: var(--rc-text-muted);
40
- font-size: 0.875rem;
41
- line-height: 1.25rem;
42
- margin: 0;
43
- font-family: inherit;
31
+ .rc-phone-frame-landscape {
32
+ width: 100%;
33
+ max-height: 100%;
44
34
  }
45
35
 
46
- .rc-spinner {
47
- width: 32px;
48
- height: 32px;
49
- border: 2px solid transparent;
50
- border-top-color: var(--rc-spinner-color);
51
- border-radius: 50%;
52
- margin: 0 auto 8px;
53
- animation: rc-spin 1s linear infinite;
36
+ .rc-video {
37
+ position: absolute;
38
+ width: auto;
39
+ outline: none;
40
+ pointer-events: none;
41
+ cursor: none;
42
+ background-color: black;
54
43
  }
55
44
 
56
- @keyframes rc-spin {
57
- to {
58
- transform: rotate(360deg);
59
- }
45
+ .rc-video-frameless {
46
+ position: relative;
47
+ height: 100%;
48
+ top: auto;
49
+ left: auto;
60
50
  }
51
+
52
+ .rc-video-loading {
53
+ aspect-ratio: 9 / 19.5;
54
+ }
@@ -1,8 +1,15 @@
1
- import React, { useEffect, useRef, useState, useMemo, forwardRef, useImperativeHandle } from 'react';
1
+ import React, { useEffect, useRef, useMemo, useState, forwardRef, useImperativeHandle } from 'react';
2
2
  import { clsx } from 'clsx';
3
3
  import './remote-control.css';
4
4
 
5
5
  import { ANDROID_KEYS, AMOTION_EVENT, codeMap } from '../core/constants';
6
+
7
+ import iphoneFrameImage from '../assets/iphone16pro_black_bg.webp';
8
+ import pixelFrameImage from '../assets/pixel9_black.webp';
9
+ import pixelFrameImageLandscape from '../assets/pixel9_black_landscape.webp';
10
+ import iphoneFrameImageLandscape from '../assets/iphone16pro_black_landscape_bg.webp';
11
+ import appleLogoSvg from '../assets/Apple_logo_white.svg';
12
+ import androidBootImage from '../assets/android_boot.webp';
6
13
  import {
7
14
  createTouchControlMessage,
8
15
  createInjectKeycodeMessage,
@@ -37,6 +44,10 @@ interface RemoteControlProps {
37
44
  //
38
45
  // If not provided, the component will not open any URL.
39
46
  openUrl?: string;
47
+
48
+ // showFrame controls whether to display the device frame
49
+ // around the video. Defaults to true.
50
+ showFrame?: boolean;
40
51
  }
41
52
 
42
53
  interface ScreenshotData {
@@ -70,6 +81,63 @@ const debugWarn = (...args: any[]) => {
70
81
  }
71
82
  };
72
83
 
84
+ type DevicePlatform = 'ios' | 'android';
85
+
86
+ const detectPlatform = (url: string): DevicePlatform => {
87
+ if (url.includes('/android_')) {
88
+ return 'android';
89
+ }
90
+ // Default to iOS if no Android pattern is found
91
+ return 'ios';
92
+ };
93
+
94
+ type DeviceConfig = {
95
+ videoBorderRadiusMultiplier: number;
96
+ loadingLogo: string;
97
+ loadingLogoSize: string;
98
+ videoPosition: {
99
+ portrait: { top: number; left: number; height: number; };
100
+ landscape: { top: number; left: number; width: number; };
101
+ };
102
+ frame: {
103
+ image: string;
104
+ imageLandscape: string;
105
+ }
106
+ }
107
+
108
+ // Device-specific configuration for frame sizing and video positioning
109
+ // Video position percentages are relative to the frame image dimensions
110
+ const deviceConfig: Record<DevicePlatform, DeviceConfig> = {
111
+ ios: {
112
+ frame: {
113
+ image: iphoneFrameImage,
114
+ imageLandscape: iphoneFrameImageLandscape,
115
+ },
116
+ videoBorderRadiusMultiplier: 0.15,
117
+ loadingLogo: appleLogoSvg,
118
+ loadingLogoSize: '20%',
119
+ // Video position as percentage of frame dimensions
120
+ videoPosition: {
121
+ portrait: { top: 1.61, left: 3.6, height: 96.78 },
122
+ landscape: { top: 3.9, left: 1.61, width: 96.78 },
123
+ },
124
+ },
125
+ android: {
126
+ frame: {
127
+ image: pixelFrameImage,
128
+ imageLandscape: pixelFrameImageLandscape,
129
+ },
130
+ videoBorderRadiusMultiplier: 0.13,
131
+ loadingLogo: androidBootImage,
132
+ loadingLogoSize: '40%',
133
+ // Video position as percentage of frame dimensions
134
+ videoPosition: {
135
+ portrait: { top: 2.1, left: 4.5, height: 96.2 },
136
+ landscape: { top: 5, left: 2.25, width: 95.9 },
137
+ },
138
+ },
139
+ };
140
+
73
141
  function getAndroidKeycodeAndMeta(event: React.KeyboardEvent): { keycode: number; metaState: number } | null {
74
142
  const code = event.code;
75
143
  const keycode = codeMap[code];
@@ -101,12 +169,15 @@ function getAndroidKeycodeAndMeta(event: React.KeyboardEvent): { keycode: number
101
169
  }
102
170
 
103
171
  export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>(
104
- ({ className, url, token, sessionId: propSessionId, openUrl }: RemoteControlProps, ref) => {
172
+ ({ className, url, token, sessionId: propSessionId, openUrl, showFrame = true }: RemoteControlProps, ref) => {
105
173
  const videoRef = useRef<HTMLVideoElement>(null);
174
+ const frameRef = useRef<HTMLImageElement>(null);
175
+ const [videoLoaded, setVideoLoaded] = useState(false);
176
+ const [isLandscape, setIsLandscape] = useState(false);
177
+ const [videoStyle, setVideoStyle] = useState<React.CSSProperties>({});
106
178
  const wsRef = useRef<WebSocket | null>(null);
107
179
  const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
108
180
  const dataChannelRef = useRef<RTCDataChannel | null>(null);
109
- const [isConnected, setIsConnected] = useState<boolean>(false);
110
181
  const keepAliveIntervalRef = useRef<number | undefined>(undefined);
111
182
  const pendingScreenshotResolversRef = useRef<
112
183
  Map<string, (value: ScreenshotData | PromiseLike<ScreenshotData>) => void>
@@ -124,6 +195,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
124
195
  [propSessionId],
125
196
  );
126
197
 
198
+ const platform = useMemo(() => detectPlatform(url), [url]);
199
+ const config = deviceConfig[platform];
200
+
127
201
  const updateStatus = (message: string) => {
128
202
  // Use the wrapper for conditional logging
129
203
  debugLog(message);
@@ -569,7 +643,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
569
643
  // Set up connection state monitoring
570
644
  peerConnectionRef.current.onconnectionstatechange = () => {
571
645
  updateStatus('Connection state: ' + peerConnectionRef.current?.connectionState);
572
- setIsConnected(peerConnectionRef.current?.connectionState === 'connected');
573
646
  };
574
647
 
575
648
  peerConnectionRef.current.oniceconnectionstatechange = () => {
@@ -716,11 +789,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
716
789
  dataChannelRef.current.close();
717
790
  dataChannelRef.current = null;
718
791
  }
719
- setIsConnected(false);
720
792
  updateStatus('Stopped');
721
793
  };
722
794
 
723
795
  useEffect(() => {
796
+ // Reset video loaded state when connection params change
797
+ setVideoLoaded(false);
798
+
724
799
  // Start connection when component mounts
725
800
  start();
726
801
 
@@ -740,6 +815,76 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
740
815
  };
741
816
  }, [url, token, propSessionId]);
742
817
 
818
+ // Calculate video position and border-radius based on frame dimensions
819
+ useEffect(() => {
820
+ const video = videoRef.current;
821
+ const frame = frameRef.current;
822
+
823
+ if (!video) return;
824
+
825
+ // If no frame, no positioning needed
826
+ if (!showFrame || !frame) {
827
+ setVideoStyle({});
828
+ return;
829
+ }
830
+
831
+ const updateVideoPosition = () => {
832
+ const frameWidth = frame.clientWidth;
833
+ const frameHeight = frame.clientHeight;
834
+
835
+ if (frameWidth === 0 || frameHeight === 0) return;
836
+
837
+ // Determine landscape based on video's intrinsic dimensions
838
+ const landscape = video.videoWidth > video.videoHeight;
839
+ setIsLandscape(landscape);
840
+
841
+ const pos = landscape ? config.videoPosition.landscape : config.videoPosition.portrait;
842
+
843
+ // Calculate position in pixels based on frame dimensions
844
+ const topPx = (pos.top / 100) * frameHeight;
845
+ const leftPx = (pos.left / 100) * frameWidth;
846
+
847
+ let newStyle: React.CSSProperties = {
848
+ top: `${topPx}px`,
849
+ left: `${leftPx}px`,
850
+ };
851
+
852
+ if ('height' in pos) {
853
+ const heightPx = (pos.height / 100) * frameHeight;
854
+ newStyle.height = `${heightPx}px`;
855
+ newStyle.borderRadius = `${frameWidth * config.videoBorderRadiusMultiplier}px`;
856
+ } else if ('width' in pos) {
857
+ const widthPx = (pos.width / 100) * frameWidth;
858
+ newStyle.width = `${widthPx}px`;
859
+ newStyle.borderRadius = `${frameHeight * config.videoBorderRadiusMultiplier}px`;
860
+ }
861
+
862
+ setVideoStyle(newStyle);
863
+ };
864
+
865
+ const resizeObserver = new ResizeObserver(() => {
866
+ updateVideoPosition();
867
+ });
868
+
869
+ resizeObserver.observe(frame);
870
+ resizeObserver.observe(video);
871
+
872
+ // Also update when the frame image loads
873
+ frame.addEventListener('load', updateVideoPosition);
874
+
875
+ // Update when video metadata loads (to get correct intrinsic dimensions)
876
+ video.addEventListener('loadedmetadata', updateVideoPosition);
877
+
878
+ // Initial calculation
879
+ updateVideoPosition();
880
+
881
+ return () => {
882
+ resizeObserver.disconnect();
883
+ video.removeEventListener('loadedmetadata', updateVideoPosition);
884
+ frame.removeEventListener('load', updateVideoPosition);
885
+ };
886
+ }, [config, showFrame]);
887
+
743
888
  const handleVideoClick = () => {
744
889
  if (videoRef.current) {
745
890
  videoRef.current.focus();
@@ -851,7 +996,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
851
996
  return (
852
997
  <div
853
998
  className={clsx(
854
- 'rc-container', // Use custom CSS class instead of Tailwind
999
+ 'rc-container',
1000
+ isLandscape ? 'rc-container-landscape' : 'rc-container-portrait', // Use custom CSS class instead of Tailwind
855
1001
  className,
856
1002
  )}
857
1003
  style={{ touchAction: 'none' }} // Keep touchAction none for the container
@@ -866,17 +1012,41 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
866
1012
  onTouchEnd={handleInteraction}
867
1013
  onTouchCancel={handleInteraction}
868
1014
  >
1015
+ {showFrame && (
1016
+ <img
1017
+ ref={frameRef}
1018
+ src={isLandscape ? config.frame.imageLandscape : config.frame.image}
1019
+ alt=""
1020
+ className={clsx('rc-phone-frame', isLandscape ? 'rc-phone-frame-landscape' : 'rc-phone-frame-portrait')}
1021
+ draggable={false}
1022
+ />
1023
+ )}
869
1024
  <video
870
1025
  ref={videoRef}
871
- className="rc-video" // Use custom CSS class
1026
+ className={clsx(
1027
+ 'rc-video',
1028
+ !showFrame && 'rc-video-frameless',
1029
+ !videoLoaded && 'rc-video-loading',
1030
+ )}
1031
+ style={{
1032
+ ...videoStyle,
1033
+ ...(config.loadingLogo
1034
+ ? {
1035
+ backgroundImage: `url("${config.loadingLogo}")`,
1036
+ backgroundRepeat: 'no-repeat',
1037
+ backgroundPosition: 'center',
1038
+ backgroundSize: config.loadingLogoSize,
1039
+ }
1040
+ : {}),
1041
+ }}
872
1042
  autoPlay
873
1043
  playsInline
874
1044
  muted
875
- tabIndex={0} // Make it focusable
876
- style={{ outline: 'none', pointerEvents: 'none' }}
1045
+ tabIndex={0}
877
1046
  onKeyDown={handleKeyboard}
878
1047
  onKeyUp={handleKeyboard}
879
1048
  onClick={handleVideoClick}
1049
+ onLoadedMetadata={() => setVideoLoaded(true)}
880
1050
  onFocus={() => {
881
1051
  if (videoRef.current) {
882
1052
  videoRef.current.style.outline = 'none';
@@ -888,12 +1058,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
888
1058
  }
889
1059
  }}
890
1060
  />
891
- {!isConnected && (
892
- <div className="rc-placeholder-wrapper">
893
- <div className="rc-spinner"></div>
894
- <p className="rc-placeholder-content">Connecting...</p>
895
- </div>
896
- )}
897
1061
  </div>
898
1062
  );
899
1063
  },