@limrun/ui 0.4.0-rc.1 → 0.4.0-rc.11

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 CHANGED
@@ -133,8 +133,8 @@
133
133
 
134
134
  .preview-item {
135
135
  flex: 1;
136
- min-width: 300px;
137
- max-width: 500px;
136
+ height: '20vh';
137
+ width: '20vh';
138
138
  }
139
139
 
140
140
  .preview-item h3 {
@@ -145,7 +145,8 @@
145
145
  }
146
146
 
147
147
  .device-wrapper {
148
- height: 700px;
148
+ height: 100%;
149
+ width: 100%;
149
150
  background: #f9fafb;
150
151
  border-radius: 12px;
151
152
  overflow: hidden;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limrun/ui",
3
- "version": "0.4.0-rc.1",
3
+ "version": "0.4.0-rc.11",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
Binary file
Binary file
@@ -1,40 +1,72 @@
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;
14
21
  display: block;
15
- height: 100%;
16
22
  pointer-events: none;
17
23
  user-select: none;
18
- border-radius: 16.8% / 8.7%;
24
+ }
25
+
26
+ .rc-phone-frame-portrait {
27
+ height: 100%;
28
+ max-width: 100%;
29
+ }
30
+
31
+ .rc-phone-frame-landscape {
32
+ width: 100%;
33
+ max-height: 100%;
19
34
  }
20
35
 
21
36
  .rc-video {
22
37
  position: absolute;
23
- width: auto;
38
+ /* Size is controlled by inline style; defaults are for frameless mode */
39
+ width: 100%;
40
+ height: 100%;
24
41
  outline: none;
25
42
  pointer-events: none;
26
43
  cursor: none;
27
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;
28
54
  }
29
55
 
30
- .rc-video-ios {
31
- top: 1.6%;
32
- left: 3.9%;
33
- height: 96.76%;
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
- .rc-video-android {
37
- top: 2.3%;
38
- left: 4.5%;
39
- height: 95.9%;
62
+ .rc-video-frameless {
63
+ position: relative;
64
+ height: 100%;
65
+ top: auto;
66
+ left: auto;
67
+ width: 100%;
40
68
  }
69
+
70
+ .rc-video-loading {
71
+ aspect-ratio: 9 / 19.5;
72
+ }
@@ -1,12 +1,15 @@
1
- import React, { useEffect, useRef, 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
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
+ import pixelFrameImageLandscape from '../assets/pixel9_black_landscape.webp';
10
+ import iphoneFrameImageLandscape from '../assets/iphone16pro_black_landscape_bg.webp';
9
11
  import appleLogoSvg from '../assets/Apple_logo_white.svg';
12
+ import androidBootImage from '../assets/android_boot.webp';
10
13
  import {
11
14
  createTouchControlMessage,
12
15
  createInjectKeycodeMessage,
@@ -41,6 +44,10 @@ interface RemoteControlProps {
41
44
  //
42
45
  // If not provided, the component will not open any URL.
43
46
  openUrl?: string;
47
+
48
+ // showFrame controls whether to display the device frame
49
+ // around the video. Defaults to true.
50
+ showFrame?: boolean;
44
51
  }
45
52
 
46
53
  interface ScreenshotData {
@@ -84,19 +91,50 @@ const detectPlatform = (url: string): DevicePlatform => {
84
91
  return 'ios';
85
92
  };
86
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
+
87
108
  // Device-specific configuration for frame sizing and video positioning
88
- const deviceConfig = {
109
+ // Video position percentages are relative to the frame image dimensions
110
+ const deviceConfig: Record<DevicePlatform, DeviceConfig> = {
89
111
  ios: {
90
- frameImage: iphoneFrameImage,
91
- frameWidthMultiplier: 1.0841,
92
- videoBorderRadiusMultiplier: 0.157,
112
+ frame: {
113
+ image: iphoneFrameImage,
114
+ imageLandscape: iphoneFrameImageLandscape,
115
+ },
116
+ videoBorderRadiusMultiplier: 0.15,
93
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
+ },
94
124
  },
95
125
  android: {
96
- frameImage: pixelFrameImage,
97
- frameWidthMultiplier: 1.107,
98
- videoBorderRadiusMultiplier: 0.137,
99
- loadingLogo: null,
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: { heightMultiplier: 0.967 },
136
+ landscape: { widthMultiplier: 0.962 },
137
+ },
100
138
  },
101
139
  };
102
140
 
@@ -131,9 +169,12 @@ function getAndroidKeycodeAndMeta(event: React.KeyboardEvent): { keycode: number
131
169
  }
132
170
 
133
171
  export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>(
134
- ({ className, url, token, sessionId: propSessionId, openUrl }: RemoteControlProps, ref) => {
172
+ ({ className, url, token, sessionId: propSessionId, openUrl, showFrame = true }: RemoteControlProps, ref) => {
135
173
  const videoRef = useRef<HTMLVideoElement>(null);
136
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>({});
137
178
  const wsRef = useRef<WebSocket | null>(null);
138
179
  const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
139
180
  const dataChannelRef = useRef<RTCDataChannel | null>(null);
@@ -752,6 +793,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
752
793
  };
753
794
 
754
795
  useEffect(() => {
796
+ // Reset video loaded state when connection params change
797
+ setVideoLoaded(false);
798
+
755
799
  // Start connection when component mounts
756
800
  start();
757
801
 
@@ -771,26 +815,72 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
771
815
  };
772
816
  }, [url, token, propSessionId]);
773
817
 
774
- // Resize phone frame and video border-radius relative to video size
818
+ // Calculate video position and border-radius based on frame dimensions
775
819
  useEffect(() => {
776
820
  const video = videoRef.current;
777
821
  const frame = frameRef.current;
778
- if (!video || !frame) return;
822
+
823
+ if (!video) return;
824
+
825
+ // If no frame, no positioning needed
826
+ if (!showFrame || !frame) {
827
+ setVideoStyle({});
828
+ return;
829
+ }
779
830
 
780
- const resizeObserver = new ResizeObserver((entries) => {
781
- for (const entry of entries) {
782
- const videoWidth = entry.contentRect.width;
783
- frame.style.width = `${videoWidth * config.frameWidthMultiplier}px`;
784
- video.style.borderRadius = `${videoWidth * config.videoBorderRadiusMultiplier}px`;
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';
785
851
  }
852
+ newStyle.borderRadius = `${landscape ? frameHeight * config.videoBorderRadiusMultiplier : frameWidth * config.videoBorderRadiusMultiplier}px`;
853
+ setVideoStyle(newStyle);
854
+ };
855
+
856
+ const resizeObserver = new ResizeObserver(() => {
857
+ updateVideoPosition();
786
858
  });
787
859
 
860
+ resizeObserver.observe(frame);
788
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();
789
876
 
790
877
  return () => {
791
878
  resizeObserver.disconnect();
879
+ video.removeEventListener('loadedmetadata', updateVideoPosition);
880
+ video.removeEventListener('resize', updateVideoPosition);
881
+ frame.removeEventListener('load', updateVideoPosition);
792
882
  };
793
- }, [config]);
883
+ }, [config, showFrame]);
794
884
 
795
885
  const handleVideoClick = () => {
796
886
  if (videoRef.current) {
@@ -903,7 +993,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
903
993
  return (
904
994
  <div
905
995
  className={clsx(
906
- '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
907
998
  className,
908
999
  )}
909
1000
  style={{ touchAction: 'none' }} // Keep touchAction none for the container
@@ -918,26 +1009,34 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
918
1009
  onTouchEnd={handleInteraction}
919
1010
  onTouchCancel={handleInteraction}
920
1011
  >
921
- <img
922
- ref={frameRef}
923
- src={config.frameImage}
924
- alt=""
925
- className="rc-phone-frame"
926
- draggable={false}
927
- />
1012
+ {showFrame && (
1013
+ <img
1014
+ ref={frameRef}
1015
+ src={isLandscape ? config.frame.imageLandscape : config.frame.image}
1016
+ alt=""
1017
+ className={clsx('rc-phone-frame', isLandscape ? 'rc-phone-frame-landscape' : 'rc-phone-frame-portrait')}
1018
+ draggable={false}
1019
+ />
1020
+ )}
928
1021
  <video
929
1022
  ref={videoRef}
930
- className={clsx('rc-video', platform === 'ios' ? 'rc-video-ios' : 'rc-video-android')}
931
- style={
932
- config.loadingLogo
1023
+ className={clsx(
1024
+ 'rc-video',
1025
+ !showFrame && 'rc-video-frameless',
1026
+ showFrame && platform === 'ios' && 'rc-video-ios-stretch',
1027
+ !videoLoaded && 'rc-video-loading',
1028
+ )}
1029
+ style={{
1030
+ ...videoStyle,
1031
+ ...(config.loadingLogo
933
1032
  ? {
934
- backgroundImage: `url(${config.loadingLogo})`,
1033
+ backgroundImage: `url("${config.loadingLogo}")`,
935
1034
  backgroundRepeat: 'no-repeat',
936
1035
  backgroundPosition: 'center',
937
- backgroundSize: '20%',
1036
+ backgroundSize: config.loadingLogoSize,
938
1037
  }
939
- : undefined
940
- }
1038
+ : {}),
1039
+ }}
941
1040
  autoPlay
942
1041
  playsInline
943
1042
  muted
@@ -945,6 +1044,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
945
1044
  onKeyDown={handleKeyboard}
946
1045
  onKeyUp={handleKeyboard}
947
1046
  onClick={handleVideoClick}
1047
+ onLoadedMetadata={() => setVideoLoaded(true)}
948
1048
  onFocus={() => {
949
1049
  if (videoRef.current) {
950
1050
  videoRef.current.style.outline = 'none';
package/src/demo.tsx CHANGED
@@ -3,8 +3,8 @@ import { createRoot } from 'react-dom/client';
3
3
  import { RemoteControl, RemoteControlHandle } from './components/remote-control';
4
4
 
5
5
  function Demo() {
6
- const [url, setUrl] = useState('wss://eu-hel1-5-staging.limrun.dev/v1/organizations/org_01k3v8bvxvecfvscdhvcp3rga1/ios.limrun.com/v1/instances/ios_eustg_01kd5hvbcmf7krej28t927cskb/endpointWebSocket');
7
- const [token, setToken] = useState('lim_c8a8f9a45008ee01441fbd01f41d5f1b04eac8c4fc3f20de');
6
+ const [url, setUrl] = useState('ws://localhost:8833/signaling');
7
+ const [token, setToken] = useState('lim_44530d73085dc139b2ebe1a07658bd11af2ec422ddd2ceb7');
8
8
  const [platform, setPlatform] = useState<'ios' | 'android'>('ios');
9
9
  const [isConnected, setIsConnected] = useState(false);
10
10
  const [key, setKey] = useState(0);