@limrun/ui 0.4.0-rc.8 → 0.4.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,6 @@
1
1
  {
2
2
  "name": "@limrun/ui",
3
- "version": "0.4.0-rc.8",
3
+ "version": "0.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
Binary file
Binary file
@@ -1,13 +1,20 @@
1
1
  .rc-container {
2
2
  position: relative;
3
3
  display: inline-block;
4
- height: 100%;
5
4
  box-sizing: border-box;
6
5
  isolation: isolate;
7
6
 
8
7
  touch-action: none;
9
8
  }
10
9
 
10
+ .rc-container-portrait {
11
+ height: 100%;
12
+ }
13
+
14
+ .rc-container-landscape {
15
+ width: 100%;
16
+ }
17
+
11
18
  .rc-phone-frame {
12
19
  position: relative;
13
20
  z-index: 20;
@@ -18,19 +25,38 @@
18
25
 
19
26
  .rc-phone-frame-portrait {
20
27
  height: 100%;
28
+ max-width: 100%;
21
29
  }
22
30
 
23
31
  .rc-phone-frame-landscape {
24
32
  width: 100%;
33
+ max-height: 100%;
25
34
  }
26
35
 
27
36
  .rc-video {
28
37
  position: absolute;
29
- width: auto;
38
+ /* Size is controlled by inline style; defaults are for frameless mode */
39
+ width: 100%;
40
+ height: 100%;
30
41
  outline: none;
31
42
  pointer-events: none;
32
43
  cursor: none;
33
44
  background-color: black;
45
+ object-fit: contain;
46
+ object-position: center center;
47
+ inset: 0;
48
+
49
+ top: 50%;
50
+ left: 50%;
51
+ transform: translate(-50%, -50%);
52
+ right: auto;
53
+ bottom: auto;
54
+ }
55
+
56
+ .rc-video-ios-stretch {
57
+ /* iOS-only: intentionally stretch to fill the target screen box */
58
+ object-fit: fill;
59
+ z-index: 30;
34
60
  }
35
61
 
36
62
  .rc-video-frameless {
@@ -38,32 +64,9 @@
38
64
  height: 100%;
39
65
  top: auto;
40
66
  left: auto;
67
+ width: 100%;
41
68
  }
42
69
 
43
70
  .rc-video-loading {
44
71
  aspect-ratio: 9 / 19.5;
45
- }
46
-
47
- .rc-video-ios-portrait {
48
- top: 1.61%;
49
- left: 3.9%;
50
- height: 96.78%;
51
- }
52
-
53
- .rc-video-ios-landscape {
54
- top: 3.9%;
55
- left: 1.61%;
56
- width: 96.78%;
57
- }
58
-
59
- .rc-video-android-portrait {
60
- top: 2.25%;
61
- left: 4.5%;
62
- height: 95.9%;
63
- }
64
-
65
- .rc-video-android-landscape {
66
- left: 2.25%;
67
- top: 4.5%;
68
- width: 95.9%;
69
72
  }
@@ -4,11 +4,12 @@ import './remote-control.css';
4
4
 
5
5
  import { ANDROID_KEYS, AMOTION_EVENT, codeMap } from '../core/constants';
6
6
 
7
- import iphoneFrameImage from '../assets/iphone16pro_black.webp';
7
+ import iphoneFrameImage from '../assets/iphone16pro_black_bg.webp';
8
8
  import pixelFrameImage from '../assets/pixel9_black.webp';
9
9
  import pixelFrameImageLandscape from '../assets/pixel9_black_landscape.webp';
10
- import iphoneFrameImageLandscape from '../assets/iphone16pro_black_landscape.webp';
10
+ import iphoneFrameImageLandscape from '../assets/iphone16pro_black_landscape_bg.webp';
11
11
  import appleLogoSvg from '../assets/Apple_logo_white.svg';
12
+ import androidBootImage from '../assets/android_boot.webp';
12
13
  import {
13
14
  createTouchControlMessage,
14
15
  createInjectKeycodeMessage,
@@ -90,21 +91,50 @@ const detectPlatform = (url: string): DevicePlatform => {
90
91
  return 'ios';
91
92
  };
92
93
 
94
+ type DeviceConfig = {
95
+ videoBorderRadiusMultiplier: number;
96
+ loadingLogo: string;
97
+ loadingLogoSize: string;
98
+ videoPosition: {
99
+ portrait: { heightMultiplier?: number; widthMultiplier?: number; };
100
+ landscape: { heightMultiplier?: number; widthMultiplier?: number; };
101
+ };
102
+ frame: {
103
+ image: string;
104
+ imageLandscape: string;
105
+ }
106
+ }
107
+
93
108
  // Device-specific configuration for frame sizing and video positioning
94
- const deviceConfig = {
109
+ // Video position percentages are relative to the frame image dimensions
110
+ const deviceConfig: Record<DevicePlatform, DeviceConfig> = {
95
111
  ios: {
96
- frameImage: iphoneFrameImage,
97
- frameImageLandscape: iphoneFrameImageLandscape,
98
- frameWidthMultiplier: 1.0841,
112
+ frame: {
113
+ image: iphoneFrameImage,
114
+ imageLandscape: iphoneFrameImageLandscape,
115
+ },
99
116
  videoBorderRadiusMultiplier: 0.15,
100
117
  loadingLogo: appleLogoSvg,
118
+ loadingLogoSize: '20%',
119
+ // Video position as percentage of frame dimensions
120
+ videoPosition: {
121
+ portrait: { heightMultiplier: 0.9678 },
122
+ landscape: { widthMultiplier: 0.9678 },
123
+ },
101
124
  },
102
125
  android: {
103
- frameImage: pixelFrameImage,
104
- frameImageLandscape: pixelFrameImageLandscape,
105
- frameWidthMultiplier: 1.107,
126
+ frame: {
127
+ image: pixelFrameImage,
128
+ imageLandscape: pixelFrameImageLandscape,
129
+ },
106
130
  videoBorderRadiusMultiplier: 0.13,
107
- loadingLogo: undefined,
131
+ loadingLogo: androidBootImage,
132
+ loadingLogoSize: '40%',
133
+ // Video position as percentage of frame dimensions
134
+ videoPosition: {
135
+ portrait: { heightMultiplier: 0.967 },
136
+ landscape: { widthMultiplier: 0.962 },
137
+ },
108
138
  },
109
139
  };
110
140
 
@@ -144,6 +174,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
144
174
  const frameRef = useRef<HTMLImageElement>(null);
145
175
  const [videoLoaded, setVideoLoaded] = useState(false);
146
176
  const [isLandscape, setIsLandscape] = useState(false);
177
+ const [videoStyle, setVideoStyle] = useState<React.CSSProperties>({});
147
178
  const wsRef = useRef<WebSocket | null>(null);
148
179
  const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
149
180
  const dataChannelRef = useRef<RTCDataChannel | null>(null);
@@ -784,33 +815,70 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
784
815
  };
785
816
  }, [url, token, propSessionId]);
786
817
 
787
- // Resize phone frame and video border-radius relative to video size
818
+ // Calculate video position and border-radius based on frame dimensions
788
819
  useEffect(() => {
789
820
  const video = videoRef.current;
790
821
  const frame = frameRef.current;
791
- if (!video || !frame || !showFrame) return;
792
-
793
- const resizeObserver = new ResizeObserver((entries) => {
794
- for (const entry of entries) {
795
- const landscape = entry.contentRect.width > entry.contentRect.height;
796
- setIsLandscape(landscape);
797
- const videoWidth = entry.contentRect.width;
798
- const videoHeight = entry.contentRect.height;
799
- const originalFrameWidth = frame.clientWidth;
800
- const newFrameWidth = videoWidth * config.frameWidthMultiplier;
801
- // The video is too small up until the first frame is visible, so we need to make sure we don't scale the frame down too much.
802
- if (!landscape && newFrameWidth > 0.9*originalFrameWidth) {
803
- // This is mostly for iOS where the video size is slightly inconsistent.
804
- frame.style.width = `${videoWidth * config.frameWidthMultiplier}px`;
805
- }
806
- video.style.borderRadius = `${(entry.contentRect.width > entry.contentRect.height ? videoHeight : videoWidth) * config.videoBorderRadiusMultiplier}px`;
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
+ let newStyle: React.CSSProperties = {};
843
+ if (pos.heightMultiplier) {
844
+ newStyle.height = `${frameHeight * pos.heightMultiplier}px`;
845
+ // Let the other dimension follow the video stream's intrinsic aspect ratio.
846
+ newStyle.width = 'auto';
847
+ } else if (pos.widthMultiplier) {
848
+ newStyle.width = `${frameWidth * pos.widthMultiplier}px`;
849
+ // Let the other dimension follow the video stream's intrinsic aspect ratio.
850
+ newStyle.height = 'auto';
807
851
  }
852
+ newStyle.borderRadius = `${landscape ? frameHeight * config.videoBorderRadiusMultiplier : frameWidth * config.videoBorderRadiusMultiplier}px`;
853
+ setVideoStyle(newStyle);
854
+ };
855
+
856
+ const resizeObserver = new ResizeObserver(() => {
857
+ updateVideoPosition();
808
858
  });
809
859
 
860
+ resizeObserver.observe(frame);
810
861
  resizeObserver.observe(video);
862
+
863
+ // Also update when the frame image loads
864
+ frame.addEventListener('load', updateVideoPosition);
865
+
866
+ // Update when video metadata loads (to get correct intrinsic dimensions)
867
+ video.addEventListener('loadedmetadata', updateVideoPosition);
868
+
869
+ // IMPORTANT: When the WebRTC stream changes orientation, the intrinsic video size
870
+ // (videoWidth/videoHeight) can change without re-firing 'loadedmetadata'.
871
+ // The <video> element emits 'resize' in that case.
872
+ video.addEventListener('resize', updateVideoPosition);
873
+
874
+ // Initial calculation
875
+ updateVideoPosition();
811
876
 
812
877
  return () => {
813
878
  resizeObserver.disconnect();
879
+ video.removeEventListener('loadedmetadata', updateVideoPosition);
880
+ video.removeEventListener('resize', updateVideoPosition);
881
+ frame.removeEventListener('load', updateVideoPosition);
814
882
  };
815
883
  }, [config, showFrame]);
816
884
 
@@ -925,7 +993,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
925
993
  return (
926
994
  <div
927
995
  className={clsx(
928
- 'rc-container', // Use custom CSS class instead of Tailwind
996
+ 'rc-container',
997
+ isLandscape ? 'rc-container-landscape' : 'rc-container-portrait', // Use custom CSS class instead of Tailwind
929
998
  className,
930
999
  )}
931
1000
  style={{ touchAction: 'none' }} // Keep touchAction none for the container
@@ -943,7 +1012,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
943
1012
  {showFrame && (
944
1013
  <img
945
1014
  ref={frameRef}
946
- src={isLandscape ? config.frameImageLandscape : config.frameImage}
1015
+ src={isLandscape ? config.frame.imageLandscape : config.frame.image}
947
1016
  alt=""
948
1017
  className={clsx('rc-phone-frame', isLandscape ? 'rc-phone-frame-landscape' : 'rc-phone-frame-portrait')}
949
1018
  draggable={false}
@@ -953,19 +1022,21 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
953
1022
  ref={videoRef}
954
1023
  className={clsx(
955
1024
  'rc-video',
956
- showFrame ? (platform === 'ios' ? (isLandscape ? 'rc-video-ios-landscape' : 'rc-video-ios-portrait') : isLandscape ? 'rc-video-android-landscape' : 'rc-video-android-portrait') : 'rc-video-frameless',
1025
+ !showFrame && 'rc-video-frameless',
1026
+ showFrame && platform === 'ios' && 'rc-video-ios-stretch',
957
1027
  !videoLoaded && 'rc-video-loading',
958
1028
  )}
959
- style={
960
- config.loadingLogo
1029
+ style={{
1030
+ ...videoStyle,
1031
+ ...(config.loadingLogo
961
1032
  ? {
962
1033
  backgroundImage: `url("${config.loadingLogo}")`,
963
1034
  backgroundRepeat: 'no-repeat',
964
1035
  backgroundPosition: 'center',
965
- backgroundSize: '20%',
1036
+ backgroundSize: config.loadingLogoSize,
966
1037
  }
967
- : {}
968
- }
1038
+ : {}),
1039
+ }}
969
1040
  autoPlay
970
1041
  playsInline
971
1042
  muted
package/dist/demo.d.ts DELETED
@@ -1 +0,0 @@
1
- export {};
package/index.html DELETED
@@ -1,181 +0,0 @@
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
- min-width: 300px;
137
- max-width: 800px;
138
- max-height: 800px;
139
- }
140
-
141
- .preview-item h3 {
142
- text-align: center;
143
- margin-bottom: 15px;
144
- font-size: 1.2rem;
145
- color: #374151;
146
- }
147
-
148
- .device-wrapper {
149
- height: 100%;
150
- width: 100%;
151
- background: #f9fafb;
152
- border-radius: 12px;
153
- overflow: hidden;
154
- }
155
-
156
- .info-box {
157
- background: #fef3c7;
158
- border: 2px solid #fbbf24;
159
- border-radius: 8px;
160
- padding: 15px;
161
- margin-bottom: 20px;
162
- }
163
-
164
- .info-box h4 {
165
- color: #92400e;
166
- margin-bottom: 8px;
167
- font-size: 0.95rem;
168
- }
169
-
170
- .info-box p {
171
- color: #78350f;
172
- font-size: 0.9rem;
173
- line-height: 1.5;
174
- }
175
- </style>
176
- </head>
177
- <body>
178
- <div id="root"></div>
179
- <script type="module" src="/src/demo.tsx"></script>
180
- </body>
181
- </html>