@limrun/ui 0.4.0-rc.1 → 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/dist/components/remote-control.d.ts +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +369 -325
- package/index.html +4 -3
- package/package.json +1 -1
- package/src/assets/android_boot.gif +0 -0
- package/src/assets/android_boot.webp +0 -0
- package/src/assets/iphone16pro_black_bg.webp +0 -0
- package/src/assets/iphone16pro_black_landscape.webp +0 -0
- package/src/assets/iphone16pro_black_landscape_bg.webp +0 -0
- package/src/assets/pixel9_black_landscape.webp +0 -0
- package/src/components/remote-control.css +26 -12
- package/src/components/remote-control.tsx +136 -34
- package/src/demo.tsx +2 -2
package/index.html
CHANGED
|
@@ -133,8 +133,8 @@
|
|
|
133
133
|
|
|
134
134
|
.preview-item {
|
|
135
135
|
flex: 1;
|
|
136
|
-
|
|
137
|
-
|
|
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:
|
|
148
|
+
height: 100%;
|
|
149
|
+
width: 100%;
|
|
149
150
|
background: #f9fafb;
|
|
150
151
|
border-radius: 12px;
|
|
151
152
|
overflow: hidden;
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,21 +1,36 @@
|
|
|
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
|
-
|
|
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 {
|
|
@@ -27,14 +42,13 @@
|
|
|
27
42
|
background-color: black;
|
|
28
43
|
}
|
|
29
44
|
|
|
30
|
-
.rc-video-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
.rc-video-frameless {
|
|
46
|
+
position: relative;
|
|
47
|
+
height: 100%;
|
|
48
|
+
top: auto;
|
|
49
|
+
left: auto;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
|
-
.rc-video-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
height: 95.9%;
|
|
40
|
-
}
|
|
52
|
+
.rc-video-loading {
|
|
53
|
+
aspect-ratio: 9 / 19.5;
|
|
54
|
+
}
|
|
@@ -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/
|
|
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: { 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
|
+
|
|
87
108
|
// Device-specific configuration for frame sizing and video positioning
|
|
88
|
-
|
|
109
|
+
// Video position percentages are relative to the frame image dimensions
|
|
110
|
+
const deviceConfig: Record<DevicePlatform, DeviceConfig> = {
|
|
89
111
|
ios: {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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: { top: 1.61, left: 3.6, height: 96.78 },
|
|
122
|
+
landscape: { top: 3.9, left: 1.61, width: 96.78 },
|
|
123
|
+
},
|
|
94
124
|
},
|
|
95
125
|
android: {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
+
},
|
|
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,75 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
771
815
|
};
|
|
772
816
|
}, [url, token, propSessionId]);
|
|
773
817
|
|
|
774
|
-
//
|
|
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;
|
|
779
822
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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`;
|
|
785
860
|
}
|
|
861
|
+
|
|
862
|
+
setVideoStyle(newStyle);
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
866
|
+
updateVideoPosition();
|
|
786
867
|
});
|
|
787
868
|
|
|
869
|
+
resizeObserver.observe(frame);
|
|
788
870
|
resizeObserver.observe(video);
|
|
789
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
|
+
|
|
790
881
|
return () => {
|
|
791
882
|
resizeObserver.disconnect();
|
|
883
|
+
video.removeEventListener('loadedmetadata', updateVideoPosition);
|
|
884
|
+
frame.removeEventListener('load', updateVideoPosition);
|
|
792
885
|
};
|
|
793
|
-
}, [config]);
|
|
886
|
+
}, [config, showFrame]);
|
|
794
887
|
|
|
795
888
|
const handleVideoClick = () => {
|
|
796
889
|
if (videoRef.current) {
|
|
@@ -903,7 +996,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
903
996
|
return (
|
|
904
997
|
<div
|
|
905
998
|
className={clsx(
|
|
906
|
-
'rc-container',
|
|
999
|
+
'rc-container',
|
|
1000
|
+
isLandscape ? 'rc-container-landscape' : 'rc-container-portrait', // Use custom CSS class instead of Tailwind
|
|
907
1001
|
className,
|
|
908
1002
|
)}
|
|
909
1003
|
style={{ touchAction: 'none' }} // Keep touchAction none for the container
|
|
@@ -918,26 +1012,33 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
918
1012
|
onTouchEnd={handleInteraction}
|
|
919
1013
|
onTouchCancel={handleInteraction}
|
|
920
1014
|
>
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
+
)}
|
|
928
1024
|
<video
|
|
929
1025
|
ref={videoRef}
|
|
930
|
-
className={clsx(
|
|
931
|
-
|
|
932
|
-
|
|
1026
|
+
className={clsx(
|
|
1027
|
+
'rc-video',
|
|
1028
|
+
!showFrame && 'rc-video-frameless',
|
|
1029
|
+
!videoLoaded && 'rc-video-loading',
|
|
1030
|
+
)}
|
|
1031
|
+
style={{
|
|
1032
|
+
...videoStyle,
|
|
1033
|
+
...(config.loadingLogo
|
|
933
1034
|
? {
|
|
934
|
-
backgroundImage: `url(${config.loadingLogo})`,
|
|
1035
|
+
backgroundImage: `url("${config.loadingLogo}")`,
|
|
935
1036
|
backgroundRepeat: 'no-repeat',
|
|
936
1037
|
backgroundPosition: 'center',
|
|
937
|
-
backgroundSize:
|
|
1038
|
+
backgroundSize: config.loadingLogoSize,
|
|
938
1039
|
}
|
|
939
|
-
:
|
|
940
|
-
}
|
|
1040
|
+
: {}),
|
|
1041
|
+
}}
|
|
941
1042
|
autoPlay
|
|
942
1043
|
playsInline
|
|
943
1044
|
muted
|
|
@@ -945,6 +1046,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
945
1046
|
onKeyDown={handleKeyboard}
|
|
946
1047
|
onKeyUp={handleKeyboard}
|
|
947
1048
|
onClick={handleVideoClick}
|
|
1049
|
+
onLoadedMetadata={() => setVideoLoaded(true)}
|
|
948
1050
|
onFocus={() => {
|
|
949
1051
|
if (videoRef.current) {
|
|
950
1052
|
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/
|
|
7
|
-
const [token, setToken] = useState('
|
|
6
|
+
const [url, setUrl] = useState('wss://eu-hel1-5-staging.limrun.dev/v1/android_eustg_01kd8ayyfhfk8tq0a0ryap6tet/endpointWebSocket');
|
|
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);
|