@limrun/ui 0.3.2 → 0.4.0-rc.1

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,179 @@
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: 500px;
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: 700px;
149
+ background: #f9fafb;
150
+ border-radius: 12px;
151
+ overflow: hidden;
152
+ }
153
+
154
+ .info-box {
155
+ background: #fef3c7;
156
+ border: 2px solid #fbbf24;
157
+ border-radius: 8px;
158
+ padding: 15px;
159
+ margin-bottom: 20px;
160
+ }
161
+
162
+ .info-box h4 {
163
+ color: #92400e;
164
+ margin-bottom: 8px;
165
+ font-size: 0.95rem;
166
+ }
167
+
168
+ .info-box p {
169
+ color: #78350f;
170
+ font-size: 0.9rem;
171
+ line-height: 1.5;
172
+ }
173
+ </style>
174
+ </head>
175
+ <body>
176
+ <div id="root"></div>
177
+ <script type="module" src="/src/demo.tsx"></script>
178
+ </body>
179
+ </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.1",
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
@@ -1,60 +1,40 @@
1
1
  .rc-container {
2
2
  position: relative;
3
- display: flex;
3
+ display: inline-block;
4
4
  height: 100%;
5
- align-items: center;
6
- justify-content: center;
5
+ box-sizing: border-box;
7
6
  isolation: isolate;
8
- contain: layout style;
9
7
 
10
- background-color: rgba(0, 0, 0, 0.05);
11
8
  touch-action: none;
12
-
13
- --rc-spinner-color: #3b82f6;
14
- --rc-text-muted: #6b7280;
15
9
  }
16
10
 
17
- .rc-video {
11
+ .rc-phone-frame {
12
+ position: relative;
13
+ z-index: 20;
14
+ display: block;
18
15
  height: 100%;
19
- width: 100%;
20
- max-height: 100%;
21
- max-width: 100%;
22
- object-fit: contain;
23
- outline: none;
24
16
  pointer-events: none;
25
- cursor: none;
17
+ user-select: none;
18
+ border-radius: 16.8% / 8.7%;
26
19
  }
27
20
 
28
- .rc-placeholder-wrapper {
21
+ .rc-video {
29
22
  position: absolute;
30
- inset: 0;
31
- display: flex;
32
- flex-direction: column;
33
- align-items: center;
34
- justify-content: center;
35
- text-align: center;
36
- }
37
-
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;
23
+ width: auto;
24
+ outline: none;
25
+ pointer-events: none;
26
+ cursor: none;
27
+ background-color: black;
44
28
  }
45
29
 
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;
30
+ .rc-video-ios {
31
+ top: 1.6%;
32
+ left: 3.9%;
33
+ height: 96.76%;
54
34
  }
55
35
 
56
- @keyframes rc-spin {
57
- to {
58
- transform: rotate(360deg);
59
- }
36
+ .rc-video-android {
37
+ top: 2.3%;
38
+ left: 4.5%;
39
+ height: 95.9%;
60
40
  }
@@ -1,8 +1,12 @@
1
- import React, { useEffect, useRef, useState, useMemo, forwardRef, useImperativeHandle } from 'react';
1
+ import React, { useEffect, useRef, useMemo, 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.webp';
8
+ import pixelFrameImage from '../assets/pixel9_black.webp';
9
+ import appleLogoSvg from '../assets/Apple_logo_white.svg';
6
10
  import {
7
11
  createTouchControlMessage,
8
12
  createInjectKeycodeMessage,
@@ -70,6 +74,32 @@ const debugWarn = (...args: any[]) => {
70
74
  }
71
75
  };
72
76
 
77
+ type DevicePlatform = 'ios' | 'android';
78
+
79
+ const detectPlatform = (url: string): DevicePlatform => {
80
+ if (url.includes('/android_')) {
81
+ return 'android';
82
+ }
83
+ // Default to iOS if no Android pattern is found
84
+ return 'ios';
85
+ };
86
+
87
+ // Device-specific configuration for frame sizing and video positioning
88
+ const deviceConfig = {
89
+ ios: {
90
+ frameImage: iphoneFrameImage,
91
+ frameWidthMultiplier: 1.0841,
92
+ videoBorderRadiusMultiplier: 0.157,
93
+ loadingLogo: appleLogoSvg,
94
+ },
95
+ android: {
96
+ frameImage: pixelFrameImage,
97
+ frameWidthMultiplier: 1.107,
98
+ videoBorderRadiusMultiplier: 0.137,
99
+ loadingLogo: null,
100
+ },
101
+ };
102
+
73
103
  function getAndroidKeycodeAndMeta(event: React.KeyboardEvent): { keycode: number; metaState: number } | null {
74
104
  const code = event.code;
75
105
  const keycode = codeMap[code];
@@ -103,10 +133,10 @@ function getAndroidKeycodeAndMeta(event: React.KeyboardEvent): { keycode: number
103
133
  export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>(
104
134
  ({ className, url, token, sessionId: propSessionId, openUrl }: RemoteControlProps, ref) => {
105
135
  const videoRef = useRef<HTMLVideoElement>(null);
136
+ const frameRef = useRef<HTMLImageElement>(null);
106
137
  const wsRef = useRef<WebSocket | null>(null);
107
138
  const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
108
139
  const dataChannelRef = useRef<RTCDataChannel | null>(null);
109
- const [isConnected, setIsConnected] = useState<boolean>(false);
110
140
  const keepAliveIntervalRef = useRef<number | undefined>(undefined);
111
141
  const pendingScreenshotResolversRef = useRef<
112
142
  Map<string, (value: ScreenshotData | PromiseLike<ScreenshotData>) => void>
@@ -124,6 +154,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
124
154
  [propSessionId],
125
155
  );
126
156
 
157
+ const platform = useMemo(() => detectPlatform(url), [url]);
158
+ const config = deviceConfig[platform];
159
+
127
160
  const updateStatus = (message: string) => {
128
161
  // Use the wrapper for conditional logging
129
162
  debugLog(message);
@@ -569,7 +602,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
569
602
  // Set up connection state monitoring
570
603
  peerConnectionRef.current.onconnectionstatechange = () => {
571
604
  updateStatus('Connection state: ' + peerConnectionRef.current?.connectionState);
572
- setIsConnected(peerConnectionRef.current?.connectionState === 'connected');
573
605
  };
574
606
 
575
607
  peerConnectionRef.current.oniceconnectionstatechange = () => {
@@ -716,7 +748,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
716
748
  dataChannelRef.current.close();
717
749
  dataChannelRef.current = null;
718
750
  }
719
- setIsConnected(false);
720
751
  updateStatus('Stopped');
721
752
  };
722
753
 
@@ -740,6 +771,27 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
740
771
  };
741
772
  }, [url, token, propSessionId]);
742
773
 
774
+ // Resize phone frame and video border-radius relative to video size
775
+ useEffect(() => {
776
+ const video = videoRef.current;
777
+ const frame = frameRef.current;
778
+ if (!video || !frame) return;
779
+
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`;
785
+ }
786
+ });
787
+
788
+ resizeObserver.observe(video);
789
+
790
+ return () => {
791
+ resizeObserver.disconnect();
792
+ };
793
+ }, [config]);
794
+
743
795
  const handleVideoClick = () => {
744
796
  if (videoRef.current) {
745
797
  videoRef.current.focus();
@@ -866,14 +918,30 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
866
918
  onTouchEnd={handleInteraction}
867
919
  onTouchCancel={handleInteraction}
868
920
  >
921
+ <img
922
+ ref={frameRef}
923
+ src={config.frameImage}
924
+ alt=""
925
+ className="rc-phone-frame"
926
+ draggable={false}
927
+ />
869
928
  <video
870
929
  ref={videoRef}
871
- className="rc-video" // Use custom CSS class
930
+ className={clsx('rc-video', platform === 'ios' ? 'rc-video-ios' : 'rc-video-android')}
931
+ style={
932
+ config.loadingLogo
933
+ ? {
934
+ backgroundImage: `url(${config.loadingLogo})`,
935
+ backgroundRepeat: 'no-repeat',
936
+ backgroundPosition: 'center',
937
+ backgroundSize: '20%',
938
+ }
939
+ : undefined
940
+ }
872
941
  autoPlay
873
942
  playsInline
874
943
  muted
875
- tabIndex={0} // Make it focusable
876
- style={{ outline: 'none', pointerEvents: 'none' }}
944
+ tabIndex={0}
877
945
  onKeyDown={handleKeyboard}
878
946
  onKeyUp={handleKeyboard}
879
947
  onClick={handleVideoClick}
@@ -888,12 +956,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
888
956
  }
889
957
  }}
890
958
  />
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
959
  </div>
898
960
  );
899
961
  },
package/src/demo.tsx ADDED
@@ -0,0 +1,192 @@
1
+ import { useState, useRef } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { RemoteControl, RemoteControlHandle } from './components/remote-control';
4
+
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');
8
+ const [platform, setPlatform] = useState<'ios' | 'android'>('ios');
9
+ const [isConnected, setIsConnected] = useState(false);
10
+ const [key, setKey] = useState(0);
11
+ const [showDebugInfo, setShowDebugInfo] = useState(false);
12
+
13
+ const remoteControlRef = useRef<RemoteControlHandle>(null);
14
+
15
+ const handleConnect = () => {
16
+ if (url) {
17
+ setIsConnected(true);
18
+ // Force remount by changing key
19
+ setKey(prev => prev + 1);
20
+ }
21
+ };
22
+
23
+ const handleDisconnect = () => {
24
+ setIsConnected(false);
25
+ setKey(prev => prev + 1);
26
+ };
27
+
28
+ const handleScreenshot = async () => {
29
+ if (remoteControlRef.current) {
30
+ try {
31
+ const screenshot = await remoteControlRef.current.screenshot();
32
+ // Open screenshot in new window
33
+ const win = window.open();
34
+ if (win) {
35
+ win.document.write(`<img src="${screenshot.dataUri}" style="max-width: 100%;" />`);
36
+ }
37
+ } catch (error) {
38
+ console.error('Screenshot failed:', error);
39
+ alert('Screenshot failed: ' + (error as Error).message);
40
+ }
41
+ }
42
+ };
43
+
44
+ return (
45
+ <>
46
+ <div className="header">
47
+ <h1>📱 RemoteControl Component Demo</h1>
48
+ <p>Test the iOS device frame and remote control features</p>
49
+ </div>
50
+
51
+ <div className="demo-container">
52
+ <div className="info-box">
53
+ <h4>â„šī¸ How to Use:</h4>
54
+ <p>
55
+ Enter your WebSocket URL and authentication token below, select iOS or Android platform,
56
+ then click Connect to see the remote control in action. The iOS platform will display
57
+ a realistic iPhone frame around the stream.
58
+ </p>
59
+ <p style={{ marginTop: '10px', fontWeight: 600 }}>
60
+ ✨ iOS Feature: Touches can start from the bottom bezel area (below the screen, near the
61
+ home indicator) to enable authentic iOS swipe-up gestures for going home or switching apps!
62
+ </p>
63
+ </div>
64
+
65
+ <div className="controls">
66
+ <div className="control-group">
67
+ <label htmlFor="url">WebSocket URL</label>
68
+ <input
69
+ id="url"
70
+ type="text"
71
+ value={url}
72
+ onChange={(e) => setUrl(e.target.value)}
73
+ placeholder="wss://your-instance.limrun.com/control"
74
+ disabled={isConnected}
75
+ />
76
+ </div>
77
+
78
+ <div className="control-group">
79
+ <label htmlFor="token">Authentication Token</label>
80
+ <input
81
+ id="token"
82
+ type="password"
83
+ value={token}
84
+ onChange={(e) => setToken(e.target.value)}
85
+ placeholder="Enter your token"
86
+ disabled={isConnected}
87
+ />
88
+ </div>
89
+
90
+ <div className="control-group">
91
+ <label htmlFor="platform">Platform</label>
92
+ <select
93
+ id="platform"
94
+ value={platform}
95
+ onChange={(e) => setPlatform(e.target.value as 'ios' | 'android')}
96
+ disabled={isConnected}
97
+ >
98
+ <option value="ios">iOS (with frame)</option>
99
+ <option value="android">Android (no frame)</option>
100
+ </select>
101
+ </div>
102
+
103
+ <div className="control-group">
104
+ <label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
105
+ <input
106
+ type="checkbox"
107
+ checked={showDebugInfo}
108
+ onChange={(e) => setShowDebugInfo(e.target.checked)}
109
+ style={{ cursor: 'pointer' }}
110
+ />
111
+ Show iOS Extended Touch Area Info
112
+ </label>
113
+ </div>
114
+
115
+ <div className="button-group">
116
+ {!isConnected ? (
117
+ <button
118
+ className="primary"
119
+ onClick={handleConnect}
120
+ disabled={!url}
121
+ >
122
+ Connect
123
+ </button>
124
+ ) : (
125
+ <>
126
+ <button className="secondary" onClick={handleDisconnect}>
127
+ Disconnect
128
+ </button>
129
+ <button className="primary" onClick={handleScreenshot}>
130
+ Take Screenshot
131
+ </button>
132
+ </>
133
+ )}
134
+ </div>
135
+ </div>
136
+
137
+ {showDebugInfo && platform === 'ios' && (
138
+ <div style={{
139
+ background: '#e0f2fe',
140
+ border: '2px solid #0284c7',
141
+ borderRadius: '8px',
142
+ padding: '15px',
143
+ marginBottom: '20px'
144
+ }}>
145
+ <h4 style={{ color: '#0c4a6e', marginBottom: '8px', fontSize: '0.95rem' }}>
146
+ 🔧 iOS Extended Touch Area
147
+ </h4>
148
+ <p style={{ color: '#075985', fontSize: '0.9rem', lineHeight: '1.5', margin: 0 }}>
149
+ The iOS frame includes a <strong>60-pixel extended touch area</strong> below the visible screen.
150
+ This area (where the home indicator is located) can receive touch events and sends coordinates
151
+ beyond the screen bounds (y &gt; screenHeight), allowing iOS to properly detect gestures that
152
+ start from outside the screen - just like on a real iPhone. Try starting a swipe gesture from
153
+ the home indicator area!
154
+ </p>
155
+ </div>
156
+ )}
157
+
158
+ {isConnected ? (
159
+ <div className="device-preview">
160
+ <div className="preview-item">
161
+ <h3>{platform === 'ios' ? '📱 iOS with Frame' : '🤖 Android (No Frame)'}</h3>
162
+ <div className="device-wrapper">
163
+ <RemoteControl
164
+ key={key}
165
+ ref={remoteControlRef}
166
+ url={url}
167
+ token={token}
168
+ />
169
+ </div>
170
+ </div>
171
+ </div>
172
+ ) : (
173
+ <div style={{
174
+ textAlign: 'center',
175
+ padding: '60px 20px',
176
+ color: '#9ca3af',
177
+ fontSize: '1.1rem'
178
+ }}>
179
+ Enter your connection details above and click Connect to start
180
+ </div>
181
+ )}
182
+ </div>
183
+ </>
184
+ );
185
+ }
186
+
187
+ // Mount the demo app
188
+ const container = document.getElementById('root');
189
+ if (container) {
190
+ const root = createRoot(container);
191
+ root.render(<Demo />);
192
+ }