@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/dist/components/remote-control.d.ts +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +363 -328
- 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 +44 -12
- package/src/components/remote-control.tsx +134 -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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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/
|
|
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
|
-
|
|
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: { heightMultiplier: 0.9678 },
|
|
122
|
+
landscape: { widthMultiplier: 0.9678 },
|
|
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: { 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
|
-
//
|
|
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
|
-
|
|
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
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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',
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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(
|
|
931
|
-
|
|
932
|
-
|
|
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:
|
|
1036
|
+
backgroundSize: config.loadingLogoSize,
|
|
938
1037
|
}
|
|
939
|
-
:
|
|
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('
|
|
7
|
-
const [token, setToken] = useState('
|
|
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);
|