feat: Add dynamic server URL and deployment guide
This commit is contained in:
parent
2a9e80cc5a
commit
d489873060
8 changed files with 335 additions and 95 deletions
|
|
@ -58,9 +58,9 @@ app.whenReady().then(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// IPC Handlers
|
// IPC Handlers
|
||||||
ipcMain.handle('connect', async (_, { roomCode, displayName }) => {
|
ipcMain.handle('connect', async (_, { serverUrl, roomCode, displayName }) => {
|
||||||
if (networkManager) {
|
if (networkManager) {
|
||||||
return await networkManager.connect(roomCode, displayName);
|
return await networkManager.connect(serverUrl, roomCode, displayName);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -108,6 +108,13 @@ app.whenReady().then(() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stream Updates
|
||||||
|
ipcMain.on('update-stream', (_, { active, mediaType }) => {
|
||||||
|
if (networkManager) {
|
||||||
|
networkManager.updateStream(active, mediaType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
app.on('activate', function () {
|
app.on('activate', function () {
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,10 @@ import * as dgram from 'dgram';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
import { BrowserWindow } from 'electron';
|
import { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
// Constants - Configure SERVER_HOST for production
|
// Constants
|
||||||
const SERVER_HOST = process.env.MEET_SERVER_HOST || '127.0.0.1';
|
|
||||||
const SERVER_WS_URL = `ws://${SERVER_HOST}:5000/ws`;
|
|
||||||
const SERVER_UDP_HOST = SERVER_HOST;
|
|
||||||
const SERVER_UDP_PORT = 4000;
|
const SERVER_UDP_PORT = 4000;
|
||||||
|
|
||||||
// Packet Header Structure (22 bytes)
|
// Packet Header Structure (22 bytes)
|
||||||
// version: u8, media_type: u8, user_id: u32, sequence: u32, timestamp: u64, frag_idx: u8, frag_cnt: u8, flags: u16
|
|
||||||
const HEADER_SIZE = 22;
|
const HEADER_SIZE = 22;
|
||||||
|
|
||||||
export enum MediaType {
|
export enum MediaType {
|
||||||
|
|
@ -26,21 +22,33 @@ export class NetworkManager extends EventEmitter {
|
||||||
private roomCode: string = '';
|
private roomCode: string = '';
|
||||||
private videoSeq: number = 0;
|
private videoSeq: number = 0;
|
||||||
private audioSeq: number = 0;
|
private audioSeq: number = 0;
|
||||||
|
private screenSeq = 0;
|
||||||
private mainWindow: BrowserWindow;
|
private mainWindow: BrowserWindow;
|
||||||
|
private serverUdpHost: string = '127.0.0.1';
|
||||||
|
|
||||||
constructor(mainWindow: BrowserWindow) {
|
constructor(mainWindow: BrowserWindow) {
|
||||||
super();
|
super();
|
||||||
this.mainWindow = mainWindow;
|
this.mainWindow = mainWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(roomCode: string, displayName: string): Promise<any> {
|
async connect(serverUrl: string, roomCode: string, displayName: string): Promise<any> {
|
||||||
this.roomCode = roomCode; // Store for UDP handshake
|
this.roomCode = roomCode; // Store for UDP handshake
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.ws = new WebSocket(`${SERVER_WS_URL}?room=${roomCode}&name=${displayName}`);
|
// Determine Host and Protocol
|
||||||
|
let host = serverUrl.replace(/^wss?:\/\//, '').replace(/\/$/, '');
|
||||||
|
this.serverUdpHost = host.split(':')[0]; // Hostname only for UDP (strip port if present)
|
||||||
|
|
||||||
|
// Auto-detect protocol: localhost/IP uses ws://, domains use wss:// (HTTPS)
|
||||||
|
const isLocal = host.includes('localhost') || host.includes('127.0.0.1');
|
||||||
|
const protocol = isLocal ? 'ws' : 'wss';
|
||||||
|
const wsUrl = `${protocol}://${host}/ws`;
|
||||||
|
|
||||||
|
console.log(`[Network] Connecting to WS: ${wsUrl}, UDP Host: ${this.serverUdpHost}`);
|
||||||
|
|
||||||
|
this.ws = new WebSocket(`${wsUrl}?room=${roomCode}&name=${displayName}`);
|
||||||
|
|
||||||
this.ws.on('open', () => {
|
this.ws.on('open', () => {
|
||||||
console.log('WS Connected');
|
console.log('WS Connected');
|
||||||
// Send Join Message (serde adjacent tagging: type + data)
|
|
||||||
const joinMsg = {
|
const joinMsg = {
|
||||||
type: 'Join',
|
type: 'Join',
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -92,6 +100,9 @@ export class NetworkManager extends EventEmitter {
|
||||||
case 'ChatMessage':
|
case 'ChatMessage':
|
||||||
this.safeSend('chat-message', msg.data);
|
this.safeSend('chat-message', msg.data);
|
||||||
break;
|
break;
|
||||||
|
case 'UpdateStream':
|
||||||
|
this.safeSend('peer-stream-update', msg.data);
|
||||||
|
break;
|
||||||
case 'Error':
|
case 'Error':
|
||||||
console.error('WS Error Msg:', msg.data);
|
console.error('WS Error Msg:', msg.data);
|
||||||
reject(msg.data);
|
reject(msg.data);
|
||||||
|
|
@ -113,13 +124,29 @@ export class NetworkManager extends EventEmitter {
|
||||||
this.ws.send(JSON.stringify(chatMsg));
|
this.ws.send(JSON.stringify(chatMsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateStream(active: boolean, mediaType: MediaType) {
|
||||||
|
if (!this.ws) return;
|
||||||
|
const mediaTypeStr = mediaType === MediaType.Audio ? 'Audio'
|
||||||
|
: mediaType === MediaType.Video ? 'Video'
|
||||||
|
: 'Screen';
|
||||||
|
const msg = {
|
||||||
|
type: 'UpdateStream',
|
||||||
|
data: {
|
||||||
|
user_id: this.userId,
|
||||||
|
stream_id: 0,
|
||||||
|
active,
|
||||||
|
media_type: mediaTypeStr
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
|
||||||
setupUdp() {
|
setupUdp() {
|
||||||
this.udp = dgram.createSocket('udp4');
|
this.udp = dgram.createSocket('udp4');
|
||||||
|
|
||||||
this.udp.on('listening', () => {
|
this.udp.on('listening', () => {
|
||||||
const addr = this.udp?.address();
|
const addr = this.udp?.address();
|
||||||
console.log(`UDP Listening on ${addr?.port}`);
|
console.log(`UDP Listening on ${addr?.port}`);
|
||||||
// Send UDP handshake so server can associate our UDP address with room/user
|
|
||||||
this.sendHandshake();
|
this.sendHandshake();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -133,15 +160,8 @@ export class NetworkManager extends EventEmitter {
|
||||||
handleUdpMessage(msg: Buffer) {
|
handleUdpMessage(msg: Buffer) {
|
||||||
if (msg.length < HEADER_SIZE) return;
|
if (msg.length < HEADER_SIZE) return;
|
||||||
|
|
||||||
// Parse Header (Little Endian)
|
|
||||||
// const version = msg.readUInt8(0); // 1
|
|
||||||
const mediaType = msg.readUInt8(1);
|
const mediaType = msg.readUInt8(1);
|
||||||
const userId = msg.readUInt32LE(2);
|
const userId = msg.readUInt32LE(2);
|
||||||
// const seq = msg.readUInt32LE(6);
|
|
||||||
// const ts = msg.readBigUInt64LE(10);
|
|
||||||
// const fidx = msg.readUInt8(18);
|
|
||||||
// const fcnt = msg.readUInt8(19);
|
|
||||||
// const flags = msg.readUInt16LE(20);
|
|
||||||
|
|
||||||
const payload = msg.subarray(HEADER_SIZE);
|
const payload = msg.subarray(HEADER_SIZE);
|
||||||
const sequence = msg.readUInt32LE(6);
|
const sequence = msg.readUInt32LE(6);
|
||||||
|
|
@ -150,13 +170,6 @@ export class NetworkManager extends EventEmitter {
|
||||||
const fragCnt = msg.readUInt8(19);
|
const fragCnt = msg.readUInt8(19);
|
||||||
|
|
||||||
if (mediaType === MediaType.Audio) {
|
if (mediaType === MediaType.Audio) {
|
||||||
// Forward audio? Or decode here?
|
|
||||||
// Electron renderer can use Web Audio API?
|
|
||||||
// Or we decode in Main and send PCM?
|
|
||||||
// Better to send encoded Opus to Renderer and decode there with WASM (libopus)
|
|
||||||
// OR decode here with `opus-native` binding.
|
|
||||||
// For now, let's send payload to renderer via IPC.
|
|
||||||
// Note: IPC with high frequency audio packets might be laggy.
|
|
||||||
this.safeSend('audio-frame', { user_id: userId, data: payload });
|
this.safeSend('audio-frame', { user_id: userId, data: payload });
|
||||||
} else if (mediaType === MediaType.Video) {
|
} else if (mediaType === MediaType.Video) {
|
||||||
this.safeSend('video-frame', {
|
this.safeSend('video-frame', {
|
||||||
|
|
@ -203,7 +216,6 @@ export class NetworkManager extends EventEmitter {
|
||||||
const end = Math.min(start + MAX_PAYLOAD, buffer.length);
|
const end = Math.min(start + MAX_PAYLOAD, buffer.length);
|
||||||
const chunk = buffer.subarray(start, end);
|
const chunk = buffer.subarray(start, end);
|
||||||
|
|
||||||
// MediaType.Video = 1
|
|
||||||
const header = Buffer.alloc(HEADER_SIZE);
|
const header = Buffer.alloc(HEADER_SIZE);
|
||||||
header.writeUInt8(1, 0); // Version
|
header.writeUInt8(1, 0); // Version
|
||||||
header.writeUInt8(MediaType.Video, 1);
|
header.writeUInt8(MediaType.Video, 1);
|
||||||
|
|
@ -216,7 +228,7 @@ export class NetworkManager extends EventEmitter {
|
||||||
|
|
||||||
const packet = Buffer.concat([header, chunk]);
|
const packet = Buffer.concat([header, chunk]);
|
||||||
|
|
||||||
this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => {
|
this.udp.send(packet, SERVER_UDP_PORT, this.serverUdpHost, (err) => {
|
||||||
if (err) console.error('UDP Video Send Error', err);
|
if (err) console.error('UDP Video Send Error', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -225,7 +237,6 @@ export class NetworkManager extends EventEmitter {
|
||||||
sendAudioFrame(frame: Uint8Array) {
|
sendAudioFrame(frame: Uint8Array) {
|
||||||
if (!this.udp) return;
|
if (!this.udp) return;
|
||||||
|
|
||||||
// Construct Header (same format as video)
|
|
||||||
const header = Buffer.alloc(HEADER_SIZE);
|
const header = Buffer.alloc(HEADER_SIZE);
|
||||||
header.writeUInt8(1, 0); // Version
|
header.writeUInt8(1, 0); // Version
|
||||||
header.writeUInt8(MediaType.Audio, 1);
|
header.writeUInt8(MediaType.Audio, 1);
|
||||||
|
|
@ -238,18 +249,16 @@ export class NetworkManager extends EventEmitter {
|
||||||
|
|
||||||
const packet = Buffer.concat([header, Buffer.from(frame)]);
|
const packet = Buffer.concat([header, Buffer.from(frame)]);
|
||||||
|
|
||||||
this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => {
|
this.udp.send(packet, SERVER_UDP_PORT, this.serverUdpHost, (err) => {
|
||||||
if (err) console.error('UDP Audio Send Error', err);
|
if (err) console.error('UDP Audio Send Error', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private screenSeq = 0;
|
|
||||||
|
|
||||||
sendScreenFrame(frame: number[]) {
|
sendScreenFrame(frame: number[]) {
|
||||||
if (!this.udp || !this.userId) return;
|
if (!this.udp || !this.userId) return;
|
||||||
|
|
||||||
const buffer = Buffer.from(frame);
|
const buffer = Buffer.from(frame);
|
||||||
const MAX_PAYLOAD = 1400; // MTU friendly (~1500 total with headers)
|
const MAX_PAYLOAD = 1400;
|
||||||
const fragCount = Math.ceil(buffer.length / MAX_PAYLOAD);
|
const fragCount = Math.ceil(buffer.length / MAX_PAYLOAD);
|
||||||
const seq = this.screenSeq++;
|
const seq = this.screenSeq++;
|
||||||
const ts = BigInt(Date.now());
|
const ts = BigInt(Date.now());
|
||||||
|
|
@ -259,7 +268,6 @@ export class NetworkManager extends EventEmitter {
|
||||||
const end = Math.min(start + MAX_PAYLOAD, buffer.length);
|
const end = Math.min(start + MAX_PAYLOAD, buffer.length);
|
||||||
const chunk = buffer.subarray(start, end);
|
const chunk = buffer.subarray(start, end);
|
||||||
|
|
||||||
// MediaType.Screen = 2
|
|
||||||
const header = Buffer.alloc(HEADER_SIZE);
|
const header = Buffer.alloc(HEADER_SIZE);
|
||||||
header.writeUInt8(1, 0); // Version
|
header.writeUInt8(1, 0); // Version
|
||||||
header.writeUInt8(MediaType.Screen, 1);
|
header.writeUInt8(MediaType.Screen, 1);
|
||||||
|
|
@ -272,7 +280,7 @@ export class NetworkManager extends EventEmitter {
|
||||||
|
|
||||||
const packet = Buffer.concat([header, chunk]);
|
const packet = Buffer.concat([header, chunk]);
|
||||||
|
|
||||||
this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => {
|
this.udp.send(packet, SERVER_UDP_PORT, this.serverUdpHost, (err) => {
|
||||||
if (err) console.error('UDP Screen Send Error', err);
|
if (err) console.error('UDP Screen Send Error', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -284,18 +292,13 @@ export class NetworkManager extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server expects bincode-serialized Handshake { user_id: u32, room_code: String }
|
|
||||||
// bincode format for String: u64 length prefix (LE) + UTF-8 bytes
|
|
||||||
const roomCodeBytes = Buffer.from(this.roomCode, 'utf-8');
|
const roomCodeBytes = Buffer.from(this.roomCode, 'utf-8');
|
||||||
|
|
||||||
// Payload: | user_id (4 bytes LE) | room_code_len (8 bytes LE) | room_code (N bytes) |
|
|
||||||
const payloadLen = 4 + 8 + roomCodeBytes.length;
|
const payloadLen = 4 + 8 + roomCodeBytes.length;
|
||||||
const payload = Buffer.alloc(payloadLen);
|
const payload = Buffer.alloc(payloadLen);
|
||||||
payload.writeUInt32LE(this.userId, 0); // user_id
|
payload.writeUInt32LE(this.userId, 0); // user_id
|
||||||
payload.writeBigUInt64LE(BigInt(roomCodeBytes.length), 4); // string length
|
payload.writeBigUInt64LE(BigInt(roomCodeBytes.length), 4); // string length
|
||||||
roomCodeBytes.copy(payload, 12); // room_code
|
roomCodeBytes.copy(payload, 12); // room_code
|
||||||
|
|
||||||
// Construct header with MediaType.Command (3)
|
|
||||||
const header = Buffer.alloc(HEADER_SIZE);
|
const header = Buffer.alloc(HEADER_SIZE);
|
||||||
header.writeUInt8(1, 0); // Version
|
header.writeUInt8(1, 0); // Version
|
||||||
header.writeUInt8(3, 1); // MediaType.Command = 3
|
header.writeUInt8(3, 1); // MediaType.Command = 3
|
||||||
|
|
@ -309,7 +312,7 @@ export class NetworkManager extends EventEmitter {
|
||||||
const packet = Buffer.concat([header, payload]);
|
const packet = Buffer.concat([header, payload]);
|
||||||
|
|
||||||
console.log(`[UDP] Sending Handshake: userId=${this.userId}, room=${this.roomCode}, ${packet.length} bytes`);
|
console.log(`[UDP] Sending Handshake: userId=${this.userId}, room=${this.roomCode}, ${packet.length} bytes`);
|
||||||
this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => {
|
this.udp.send(packet, SERVER_UDP_PORT, this.serverUdpHost, (err) => {
|
||||||
if (err) console.error('UDP Handshake Send Error', err);
|
if (err) console.error('UDP Handshake Send Error', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Lobby } from "./components/Lobby";
|
||||||
import { Stage } from "./components/Stage";
|
import { Stage } from "./components/Stage";
|
||||||
import { ControlBar } from "./components/ControlBar";
|
import { ControlBar } from "./components/ControlBar";
|
||||||
import { ChatPanel } from "./components/ChatPanel";
|
import { ChatPanel } from "./components/ChatPanel";
|
||||||
|
import { NotificationToast } from "./components/NotificationToast";
|
||||||
import { PeerInfo, ChatMessage } from "./types";
|
import { PeerInfo, ChatMessage } from "./types";
|
||||||
|
|
||||||
const audioWorkletCode = `
|
const audioWorkletCode = `
|
||||||
|
|
@ -50,6 +51,7 @@ function App() {
|
||||||
// Chat State
|
// Chat State
|
||||||
const [chatOpen, setChatOpen] = useState(false);
|
const [chatOpen, setChatOpen] = useState(false);
|
||||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [chatNotifications, setChatNotifications] = useState<{ id: string; displayName: string; message: string; timestamp: number }[]>([]);
|
||||||
|
|
||||||
// Video Handling
|
// Video Handling
|
||||||
const localVideoRef = useRef<HTMLVideoElement>(null);
|
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
@ -95,6 +97,41 @@ function App() {
|
||||||
}
|
}
|
||||||
return newState;
|
return newState;
|
||||||
});
|
});
|
||||||
|
setPeerScreenUrls(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
if (newState[data.user_id]) {
|
||||||
|
URL.revokeObjectURL(newState[data.user_id]);
|
||||||
|
delete newState[data.user_id];
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Stream Signaling (MediaType: "Video" or "Screen" from Rust enum)
|
||||||
|
// @ts-ignore
|
||||||
|
const removeStreamUpdate = window.electron.ipcRenderer.on("peer-stream-update", (_, data) => {
|
||||||
|
const { user_id, active, media_type } = data;
|
||||||
|
if (!active) {
|
||||||
|
if (media_type === 'Video') {
|
||||||
|
setPeerVideoUrls(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
if (newState[user_id]) {
|
||||||
|
URL.revokeObjectURL(newState[user_id]);
|
||||||
|
delete newState[user_id];
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
} else if (media_type === 'Screen') {
|
||||||
|
setPeerScreenUrls(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
if (newState[user_id]) {
|
||||||
|
URL.revokeObjectURL(newState[user_id]);
|
||||||
|
delete newState[user_id];
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -124,6 +161,23 @@ function App() {
|
||||||
);
|
);
|
||||||
if (duplicate) return prev;
|
if (duplicate) return prev;
|
||||||
|
|
||||||
|
// Add notification
|
||||||
|
const notif = {
|
||||||
|
id: msg.id,
|
||||||
|
displayName: msg.displayName,
|
||||||
|
message: msg.message,
|
||||||
|
timestamp: msg.timestamp
|
||||||
|
};
|
||||||
|
setChatNotifications(notifs => {
|
||||||
|
if (notifs.find(n => n.id === notif.id)) return notifs;
|
||||||
|
return [...notifs, notif];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-dismiss after 5s
|
||||||
|
setTimeout(() => {
|
||||||
|
setChatNotifications(current => current.filter(n => n.id !== notif.id));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
return [...prev, msg];
|
return [...prev, msg];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -293,6 +347,7 @@ function App() {
|
||||||
return () => {
|
return () => {
|
||||||
removePeerJoined();
|
removePeerJoined();
|
||||||
removePeerLeft();
|
removePeerLeft();
|
||||||
|
removeStreamUpdate();
|
||||||
removeChatMessage();
|
removeChatMessage();
|
||||||
removeVideo();
|
removeVideo();
|
||||||
removeScreen();
|
removeScreen();
|
||||||
|
|
@ -355,10 +410,12 @@ function App() {
|
||||||
if (localVideoRef.current) {
|
if (localVideoRef.current) {
|
||||||
localVideoRef.current.srcObject = stream;
|
localVideoRef.current.srcObject = stream;
|
||||||
}
|
}
|
||||||
|
// Signal video ON
|
||||||
|
// @ts-ignore
|
||||||
|
window.electron.ipcRenderer.send('update-stream', { active: true, mediaType: 1 });
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error accessing camera:", err);
|
console.error("Camera access error:", err);
|
||||||
setVideoEnabled(false);
|
|
||||||
setError("Failed to access camera");
|
setError("Failed to access camera");
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -367,6 +424,9 @@ function App() {
|
||||||
stream.getTracks().forEach(track => track.stop());
|
stream.getTracks().forEach(track => track.stop());
|
||||||
localVideoRef.current.srcObject = null;
|
localVideoRef.current.srcObject = null;
|
||||||
}
|
}
|
||||||
|
// Signal video OFF
|
||||||
|
// @ts-ignore
|
||||||
|
if (connected) window.electron.ipcRenderer.send('update-stream', { active: false, mediaType: 1 });
|
||||||
}
|
}
|
||||||
}, [videoEnabled, connected]);
|
}, [videoEnabled, connected]);
|
||||||
|
|
||||||
|
|
@ -456,6 +516,10 @@ function App() {
|
||||||
|
|
||||||
screenVideo.onloadeddata = () => sendScreenFrame();
|
screenVideo.onloadeddata = () => sendScreenFrame();
|
||||||
|
|
||||||
|
// Signal screen ON
|
||||||
|
// @ts-ignore
|
||||||
|
window.electron.ipcRenderer.send('update-stream', { active: true, mediaType: 2 });
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Screen share error:', err);
|
console.error('Screen share error:', err);
|
||||||
setError('Failed to start screen sharing');
|
setError('Failed to start screen sharing');
|
||||||
|
|
@ -471,24 +535,17 @@ function App() {
|
||||||
URL.revokeObjectURL(localScreenUrl);
|
URL.revokeObjectURL(localScreenUrl);
|
||||||
setLocalScreenUrl(null);
|
setLocalScreenUrl(null);
|
||||||
}
|
}
|
||||||
|
// Signal screen OFF
|
||||||
|
// @ts-ignore
|
||||||
|
window.electron.ipcRenderer.send('update-stream', { active: false, mediaType: 2 });
|
||||||
addLog('Screen sharing stopped');
|
addLog('Screen sharing stopped');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
startScreenShare();
|
startScreenShare();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isActive = false;
|
isActive = false;
|
||||||
if (screenStreamRef.current) {
|
|
||||||
screenStreamRef.current.getTracks().forEach(t => t.stop());
|
|
||||||
screenStreamRef.current = null;
|
|
||||||
}
|
|
||||||
if (localScreenUrl) {
|
|
||||||
URL.revokeObjectURL(localScreenUrl);
|
|
||||||
setLocalScreenUrl(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [screenEnabled, connected, localScreenUrl]);
|
}, [screenEnabled, connected]);
|
||||||
|
|
||||||
// Audio Capture
|
// Audio Capture
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
|
@ -638,15 +695,15 @@ function App() {
|
||||||
};
|
};
|
||||||
}, [audioEnabled, connected, selectedAudioDevice]);
|
}, [audioEnabled, connected, selectedAudioDevice]);
|
||||||
|
|
||||||
async function handleJoin(roomCode: string, name: string, initialVideo: boolean, initialAudio: boolean) {
|
async function handleJoin(roomCode: string, name: string, serverUrl: string, initialVideo: boolean, initialAudio: boolean) {
|
||||||
if (!roomCode || !name) return;
|
if (!roomCode || !name || !serverUrl) return;
|
||||||
setDisplayName(name);
|
setDisplayName(name);
|
||||||
setVideoEnabled(initialVideo);
|
setVideoEnabled(initialVideo);
|
||||||
setAudioEnabled(initialAudio);
|
setAudioEnabled(initialAudio);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const result = await window.electron.ipcRenderer.invoke("connect", { roomCode, displayName: name });
|
const result = await window.electron.ipcRenderer.invoke("connect", { serverUrl, roomCode, displayName: name });
|
||||||
if (result) {
|
if (result) {
|
||||||
addLog(`Connected: Self=${result.self_id}, Peers=${result.peers.length}`);
|
addLog(`Connected: Self=${result.self_id}, Peers=${result.peers.length}`);
|
||||||
setSelfId(result.self_id);
|
setSelfId(result.self_id);
|
||||||
|
|
@ -735,6 +792,18 @@ function App() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Chat Notifications */}
|
||||||
|
{!chatOpen && (
|
||||||
|
<NotificationToast
|
||||||
|
notifications={chatNotifications}
|
||||||
|
onDismiss={(id) => setChatNotifications(prev => prev.filter(n => n.id !== id))}
|
||||||
|
onOpenChat={() => {
|
||||||
|
setChatOpen(true);
|
||||||
|
setChatNotifications([]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Hidden Canvas for capture */}
|
{/* Hidden Canvas for capture */}
|
||||||
<canvas ref={canvasRef} className="hidden" />
|
<canvas ref={canvasRef} className="hidden" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||||
import { Mic, MicOff, Camera, CameraOff } from 'lucide-react';
|
import { Mic, MicOff, Camera, CameraOff } from 'lucide-react';
|
||||||
|
|
||||||
interface LobbyProps {
|
interface LobbyProps {
|
||||||
onJoin: (room: string, name: string, video: boolean, audio: boolean) => void;
|
onJoin: (room: string, name: string, serverUrl: string, video: boolean, audio: boolean) => void;
|
||||||
initialName?: string;
|
initialName?: string;
|
||||||
initialRoom?: string;
|
initialRoom?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ interface LobbyProps {
|
||||||
export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps) {
|
export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps) {
|
||||||
const [roomCode, setRoomCode] = useState(initialRoom);
|
const [roomCode, setRoomCode] = useState(initialRoom);
|
||||||
const [displayName, setDisplayName] = useState(initialName);
|
const [displayName, setDisplayName] = useState(initialName);
|
||||||
|
const [serverUrl, setServerUrl] = useState('meet.srtk.in');
|
||||||
const [videoEnabled, setVideoEnabled] = useState(false);
|
const [videoEnabled, setVideoEnabled] = useState(false);
|
||||||
const [audioEnabled, setAudioEnabled] = useState(false);
|
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
@ -39,8 +40,8 @@ export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps
|
||||||
}, [videoEnabled]);
|
}, [videoEnabled]);
|
||||||
|
|
||||||
const handleJoin = () => {
|
const handleJoin = () => {
|
||||||
if (roomCode.trim() && displayName.trim()) {
|
if (roomCode.trim() && displayName.trim() && serverUrl.trim()) {
|
||||||
onJoin(roomCode, displayName, videoEnabled, audioEnabled);
|
onJoin(roomCode, displayName, serverUrl, videoEnabled, audioEnabled);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -92,6 +93,17 @@ export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps
|
||||||
<h1 className="text-2xl font-normal">Ready to join?</h1>
|
<h1 className="text-2xl font-normal">Ready to join?</h1>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-sm text-gray-400">Server URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={serverUrl}
|
||||||
|
onChange={e => setServerUrl(e.target.value)}
|
||||||
|
placeholder="meet.srtk.in"
|
||||||
|
className="bg-[#202124] border border-[#5f6368] rounded-md px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-[#8ab4f8]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-sm text-gray-400">Your name</label>
|
<label className="text-sm text-gray-400">Your name</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -117,7 +129,7 @@ export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleJoin}
|
onClick={handleJoin}
|
||||||
disabled={!roomCode || !displayName}
|
disabled={!roomCode || !displayName || !serverUrl}
|
||||||
className="mt-2 px-6 py-3 bg-[#8ab4f8] text-[#202124] font-medium rounded-full hover:bg-[#aecbfa] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="mt-2 px-6 py-3 bg-[#8ab4f8] text-[#202124] font-medium rounded-full hover:bg-[#aecbfa] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
Join now
|
Join now
|
||||||
|
|
|
||||||
59
src/renderer/src/components/NotificationToast.tsx
Normal file
59
src/renderer/src/components/NotificationToast.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { MessageCircle, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ChatNotification {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationToastProps {
|
||||||
|
notifications: ChatNotification[];
|
||||||
|
onDismiss: (id: string) => void;
|
||||||
|
onOpenChat: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationToast({ notifications, onDismiss, onOpenChat }: NotificationToastProps) {
|
||||||
|
if (notifications.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-24 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||||
|
{notifications.slice(0, 3).map((notif) => (
|
||||||
|
<div
|
||||||
|
key={notif.id}
|
||||||
|
className="bg-[#36393f] border border-[#5865F2] rounded-lg shadow-xl overflow-hidden animate-slide-in cursor-pointer"
|
||||||
|
onClick={onOpenChat}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 p-3">
|
||||||
|
<div className="bg-[#5865F2] rounded-full p-2 flex-shrink-0">
|
||||||
|
<MessageCircle size={16} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white font-medium text-sm truncate">
|
||||||
|
{notif.displayName}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-300 text-sm line-clamp-2">
|
||||||
|
{notif.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDismiss(notif.id);
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{notifications.length > 3 && (
|
||||||
|
<div className="text-center text-gray-400 text-sm">
|
||||||
|
+{notifications.length - 3} more messages
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { PeerInfo } from "../types";
|
import { PeerInfo } from "../types";
|
||||||
import { VideoTile } from "./VideoTile";
|
import { VideoTile } from "./VideoTile";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
interface StageProps {
|
interface StageProps {
|
||||||
selfId: number | null;
|
selfId: number | null;
|
||||||
|
|
@ -22,36 +23,85 @@ export function Stage({
|
||||||
localVideoRef,
|
localVideoRef,
|
||||||
videoEnabled = false
|
videoEnabled = false
|
||||||
}: StageProps) {
|
}: StageProps) {
|
||||||
|
// Track container dimensions for smart layout
|
||||||
|
const [containerSize, setContainerSize] = useState({ width: 800, height: 600 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateSize = () => {
|
||||||
|
const container = document.getElementById('stage-container');
|
||||||
|
if (container) {
|
||||||
|
setContainerSize({
|
||||||
|
width: container.clientWidth,
|
||||||
|
height: container.clientHeight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSize();
|
||||||
|
window.addEventListener('resize', updateSize);
|
||||||
|
return () => window.removeEventListener('resize', updateSize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Check if self is sharing screen
|
// Check if self is sharing screen
|
||||||
const isSelfSharing = !!localScreenUrl;
|
const isSelfSharing = !!localScreenUrl;
|
||||||
|
|
||||||
// Filter peers who are sharing screen
|
// Filter peers who are sharing screen
|
||||||
const peerScreens = peers.filter(p => !!peerScreenUrls[p.user_id]);
|
const peerScreens = peers.filter(p => !!peerScreenUrls[p.user_id]);
|
||||||
const participants = peers.filter(p => !peerScreenUrls[p.user_id]);
|
|
||||||
|
|
||||||
|
// All peers for webcam grid
|
||||||
|
const allParticipants = peers;
|
||||||
const showScreenLayer = isSelfSharing || peerScreens.length > 0;
|
const showScreenLayer = isSelfSharing || peerScreens.length > 0;
|
||||||
const totalParticipants = (selfId ? 1 : 0) + participants.length;
|
const totalParticipants = (selfId ? 1 : 0) + allParticipants.length;
|
||||||
|
|
||||||
// Calculate grid layout for webcams
|
// Smart layout: determine if we should use vertical or horizontal arrangement
|
||||||
let cols = 1;
|
const aspectRatio = containerSize.width / containerSize.height;
|
||||||
if (totalParticipants === 2) cols = 2;
|
const isVertical = aspectRatio < 1; // Taller than wide
|
||||||
else if (totalParticipants <= 4) cols = 2;
|
|
||||||
else if (totalParticipants <= 9) cols = 3;
|
// Calculate optimal grid layout based on aspect ratio and participant count
|
||||||
else cols = 4;
|
const getGridConfig = (count: number, isVertical: boolean) => {
|
||||||
|
if (count <= 1) {
|
||||||
|
return { cols: 1, rows: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVertical) {
|
||||||
|
// Vertical window: prefer fewer columns, more rows
|
||||||
|
if (count === 2) return { cols: 1, rows: 2 };
|
||||||
|
if (count <= 4) return { cols: 2, rows: 2 };
|
||||||
|
if (count <= 6) return { cols: 2, rows: 3 };
|
||||||
|
if (count <= 9) return { cols: 3, rows: 3 };
|
||||||
|
return { cols: 3, rows: Math.ceil(count / 3) };
|
||||||
|
} else {
|
||||||
|
// Horizontal window: prefer more columns
|
||||||
|
if (count === 2) return { cols: 2, rows: 1 };
|
||||||
|
if (count <= 4) return { cols: 2, rows: 2 };
|
||||||
|
if (count <= 6) return { cols: 3, rows: 2 };
|
||||||
|
if (count <= 9) return { cols: 3, rows: 3 };
|
||||||
|
return { cols: 4, rows: Math.ceil(count / 4) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridConfig = getGridConfig(totalParticipants, isVertical);
|
||||||
|
|
||||||
|
// Screen share layout direction
|
||||||
|
const screenLayoutClass = isVertical
|
||||||
|
? 'flex-col' // Stack screen above participants
|
||||||
|
: 'flex-row'; // Screen on left, participants on right
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 bg-[#202124] p-4 flex gap-4 overflow-hidden">
|
<div
|
||||||
|
id="stage-container"
|
||||||
|
className={`flex-1 bg-[#202124] p-4 flex ${screenLayoutClass} gap-4 overflow-hidden min-h-0`}
|
||||||
|
>
|
||||||
{/* Screen Share Layer */}
|
{/* Screen Share Layer */}
|
||||||
{showScreenLayer && (
|
{showScreenLayer && (
|
||||||
<div className="flex-[3] h-full flex flex-col gap-4">
|
<div className={`${isVertical ? 'h-[60%] w-full' : 'flex-[3] h-full'} flex flex-col gap-4 min-w-0`}>
|
||||||
{/* Local Screen Share */}
|
{/* Local Screen Share */}
|
||||||
{isSelfSharing && (
|
{isSelfSharing && (
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<VideoTile
|
<VideoTile
|
||||||
displayName={`${displayName} (Your Screen)`}
|
displayName={`${displayName} (Your Screen)`}
|
||||||
isSelf
|
|
||||||
videoEnabled={true}
|
|
||||||
videoSrc={localScreenUrl!}
|
videoSrc={localScreenUrl!}
|
||||||
|
videoEnabled={true}
|
||||||
isScreenShare={true}
|
isScreenShare={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -72,18 +122,23 @@ export function Stage({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Webcam Grid */}
|
{/* Webcam Grid */}
|
||||||
<div className={`${showScreenLayer ? 'flex-1 min-w-[300px]' : 'flex-1'} flex flex-col gap-3 h-full`}>
|
<div className={`${showScreenLayer ? (isVertical ? 'h-[40%] w-full' : 'w-[300px] flex-shrink-0') : 'flex-1'} h-full overflow-y-auto`}>
|
||||||
<div
|
<div
|
||||||
className={`grid gap-3 ${showScreenLayer ? 'grid-cols-1 overflow-y-auto pr-1' : ''}`}
|
className="grid gap-3 h-full w-full"
|
||||||
style={!showScreenLayer ? {
|
style={{
|
||||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
gridTemplateColumns: showScreenLayer
|
||||||
gridAutoRows: '1fr',
|
? '1fr'
|
||||||
height: '100%'
|
: `repeat(${gridConfig.cols}, 1fr)`,
|
||||||
} : {}}
|
gridTemplateRows: showScreenLayer
|
||||||
|
? 'auto'
|
||||||
|
: `repeat(${gridConfig.rows}, 1fr)`,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignContent: 'center'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Self Webcam */}
|
{/* Self Webcam */}
|
||||||
{selfId && (
|
{selfId && (
|
||||||
<div className={showScreenLayer ? "aspect-video" : "h-full"}>
|
<div className="aspect-video min-h-0">
|
||||||
<VideoTile
|
<VideoTile
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
isSelf
|
isSelf
|
||||||
|
|
@ -95,12 +150,12 @@ export function Stage({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Remote Webcam Peers */}
|
{/* Remote Webcam Peers */}
|
||||||
{participants.map(peer => (
|
{allParticipants.map(peer => (
|
||||||
<div key={peer.user_id} className={showScreenLayer ? "aspect-video" : "h-full"}>
|
<div key={peer.user_id} className="aspect-video min-h-0">
|
||||||
<VideoTile
|
<VideoTile
|
||||||
displayName={peer.display_name}
|
displayName={peer.display_name}
|
||||||
audioEnabled={true}
|
audioEnabled={true}
|
||||||
videoEnabled={true}
|
videoEnabled={!!peerVideoUrls[peer.user_id]}
|
||||||
videoSrc={peerVideoUrls[peer.user_id]}
|
videoSrc={peerVideoUrls[peer.user_id]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,28 +19,38 @@ export function VideoTile({
|
||||||
videoEnabled = false,
|
videoEnabled = false,
|
||||||
isScreenShare = false
|
isScreenShare = false
|
||||||
}: VideoTileProps) {
|
}: VideoTileProps) {
|
||||||
|
// For self with video ref, use video element bound to the ref
|
||||||
|
// For remote peers, videoSrc contains blob URL of JPEG frames - use img
|
||||||
|
const showSelfVideo = isSelf && videoEnabled && videoRef;
|
||||||
|
const showRemoteMedia = !isSelf && videoSrc;
|
||||||
|
const showPlaceholder = !showSelfVideo && !showRemoteMedia;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative bg-[#3C4043] rounded-lg overflow-hidden flex items-center justify-center h-full">
|
<div className="relative bg-[#3C4043] rounded-lg overflow-hidden flex items-center justify-center w-full h-full">
|
||||||
{/* Video / Image */}
|
{/* Self Video (webcam stream) */}
|
||||||
{videoEnabled && videoRef ? (
|
{showSelfVideo && (
|
||||||
<video
|
<video
|
||||||
ref={videoRef as any}
|
ref={videoRef as React.RefObject<HTMLVideoElement>}
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
muted={isSelf}
|
muted={true}
|
||||||
className={`absolute inset-0 w-full h-full ${isScreenShare ? 'object-contain' : 'object-cover transform scale-x-[-1]'}`}
|
className={`w-full h-full object-cover ${!isScreenShare ? 'scale-x-[-1]' : ''}`}
|
||||||
/>
|
/>
|
||||||
) : videoSrc ? (
|
)}
|
||||||
|
|
||||||
|
{/* Remote Video/Screen (JPEG blob URLs) */}
|
||||||
|
{showRemoteMedia && (
|
||||||
<img
|
<img
|
||||||
src={videoSrc}
|
src={videoSrc}
|
||||||
alt=""
|
alt={displayName}
|
||||||
className={`absolute inset-0 w-full h-full ${isScreenShare ? 'object-contain' : 'object-cover transform scale-x-[-1]'}`}
|
className={`w-full h-full ${isScreenShare ? 'object-contain bg-black' : 'object-cover'}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{/* Placeholder Avatar */}
|
||||||
|
{showPlaceholder && (
|
||||||
<div className="flex flex-col items-center gap-2 z-10">
|
<div className="flex flex-col items-center gap-2 z-10">
|
||||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl font-bold text-white ${isSelf ? 'bg-[#5f6368]' : 'bg-[#5865F2]'
|
<div className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl font-bold text-white ${isSelf ? 'bg-[#5f6368]' : 'bg-[#5865F2]'}`}>
|
||||||
}`}>
|
|
||||||
{displayName.charAt(0).toUpperCase()}
|
{displayName.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,28 @@ body,
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #80868b;
|
background: #80868b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notification slide-in animation */
|
||||||
|
@keyframes slide-in {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in {
|
||||||
|
animation: slide-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line clamp utility */
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue