openvidu-js-screen-share: Migrated to livekit
This commit is contained in:
parent
dc6e9cfa5c
commit
65b241520a
@ -1,186 +1,149 @@
|
||||
// OpenVidu global variables
|
||||
var OVCamera;
|
||||
var OVScreen
|
||||
var sessionCamera;
|
||||
var sessionScreen
|
||||
|
||||
// User name and session name global variables
|
||||
var LivekitClient = window.LivekitClient;
|
||||
var room;
|
||||
var myUserName;
|
||||
var mySessionId;
|
||||
var screensharing = false;
|
||||
|
||||
var myRoomName;
|
||||
var isScreenShared = false;
|
||||
var screenSharePublication;
|
||||
|
||||
/* OPENVIDU METHODS */
|
||||
|
||||
function joinSession() {
|
||||
function joinRoom() {
|
||||
myRoomName = document.getElementById('roomName').value;
|
||||
myUserName = document.getElementById('userName').value;
|
||||
|
||||
mySessionId = document.getElementById("sessionId").value;
|
||||
myUserName = document.getElementById("userName").value;
|
||||
// --- 1) Get a Room object ---
|
||||
|
||||
// --- 1) Create two OpenVidu objects.
|
||||
room = new LivekitClient.Room();
|
||||
|
||||
// 'OVCamera' will handle Camera operations.
|
||||
// 'OVScreen' will handle screen sharing operations
|
||||
OVCamera = new OpenVidu();
|
||||
OVScreen = new OpenVidu();
|
||||
// --- 2) Specify the actions when events take place in the room ---
|
||||
|
||||
// --- 2) Init two OpenVidu Session Objects ---
|
||||
|
||||
// 'sessionCamera' will handle camera operations
|
||||
// 'sessionScreen' will handle screen sharing operations
|
||||
sessionCamera = OVCamera.initSession();
|
||||
sessionScreen = OVScreen.initSession();
|
||||
|
||||
// --- 3) Specify the actions when events of type 'streamCreated' take
|
||||
// --- place in the session. The reason why we're using two different objects
|
||||
// --- is to handle diferently the subscribers when it is of 'CAMERA' type, or 'SCREEN' type ---
|
||||
|
||||
// ------- 3.1) Handle subscribers of 'CAMERA' type
|
||||
sessionCamera.on('streamCreated', event => {
|
||||
if (event.stream.typeOfVideo == "CAMERA") {
|
||||
// Subscribe to the Stream to receive it. HTML video will be appended to element with 'container-cameras' id
|
||||
var subscriber = sessionCamera.subscribe(event.stream, 'container-cameras');
|
||||
// When the HTML video has been appended to DOM...
|
||||
subscriber.on('videoElementCreated', event => {
|
||||
// Add a new <p> element for the user's nickname just below its video
|
||||
appendUserData(event.element, subscriber.stream.connection);
|
||||
});
|
||||
// On every new Track received...
|
||||
room.on(
|
||||
LivekitClient.RoomEvent.TrackSubscribed,
|
||||
(track, publication, participant) => {
|
||||
const element = track.attach();
|
||||
element.id = track.sid;
|
||||
element.className = 'removable';
|
||||
document.getElementById('video-container').appendChild(element);
|
||||
if (track.kind === 'video' || track.kind === 'screen') {
|
||||
appendUserData(element, participant.identity);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// ------- 3.2) Handle subscribers of 'Screen' type
|
||||
sessionScreen.on('streamCreated', event => {
|
||||
if (event.stream.typeOfVideo == "SCREEN") {
|
||||
// Subscribe to the Stream to receive it. HTML video will be appended to element with 'container-screens' id
|
||||
var subscriberScreen = sessionScreen.subscribe(event.stream, 'container-screens');
|
||||
// When the HTML video has been appended to DOM...
|
||||
subscriberScreen.on('videoElementCreated', event => {
|
||||
// Add a new <p> element for the user's nickname just below its video
|
||||
appendUserData(event.element, subscriberScreen.stream.connection);
|
||||
});
|
||||
// On every new Track destroyed...
|
||||
room.on(
|
||||
LivekitClient.RoomEvent.TrackUnsubscribed,
|
||||
(track, publication, participant) => {
|
||||
track.detach();
|
||||
document.getElementById(track.sid)?.remove();
|
||||
if (track.kind === 'video' || track.kind === 'screen') {
|
||||
removeUserData(participant);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// On every Stream destroyed...
|
||||
sessionCamera.on('streamDestroyed', event => {
|
||||
// Delete the HTML element with the user's nickname. HTML videos are automatically removed from DOM
|
||||
removeUserData(event.stream.connection);
|
||||
});
|
||||
// --- 3) Connect to the room with a valid access token ---
|
||||
|
||||
// On every asynchronous exception...
|
||||
sessionCamera.on('exception', (exception) => {
|
||||
console.warn(exception);
|
||||
});
|
||||
// Get a token from the application backend
|
||||
getToken(myRoomName, myUserName).then((token) => {
|
||||
const livekitUrl = getLivekitUrlFromMetadata(token);
|
||||
|
||||
|
||||
// --- 4) Connect to the session with two different tokens: one for the camera and other for the screen ---
|
||||
|
||||
// --- 4.1) Get the token for the 'sessionCamera' object
|
||||
getToken(mySessionId).then(token => {
|
||||
|
||||
// First param is the token got from the OpenVidu deployment. Second param can be retrieved by every user on event
|
||||
// 'streamCreated' (property Stream.connection.data), and will be appended to DOM as the user's nickname
|
||||
sessionCamera.connect(token, { clientData: myUserName })
|
||||
// First param is the LiveKit server URL. Second param is the access token
|
||||
room
|
||||
.connect(livekitUrl, token)
|
||||
.then(() => {
|
||||
// --- 4) Set page layout for active call ---
|
||||
|
||||
// --- 5) Set page layout for active call ---
|
||||
|
||||
document.getElementById('session-title').innerText = mySessionId;
|
||||
document.getElementById('room-title').innerText = myRoomName;
|
||||
document.getElementById('join').style.display = 'none';
|
||||
document.getElementById('session').style.display = 'block';
|
||||
document.getElementById('room').style.display = 'block';
|
||||
|
||||
// --- 6) Get your own camera stream with the desired properties ---
|
||||
// --- 5) Publish your local tracks ---
|
||||
|
||||
var publisher = OVCamera.initPublisher('container-cameras', {
|
||||
audioSource: undefined, // The source of audio. If undefined default microphone
|
||||
videoSource: undefined, // The source of video. If undefined default webcam
|
||||
publishAudio: true, // Whether you want to start publishing with your audio unmuted or not
|
||||
publishVideo: true, // Whether you want to start publishing with your video enabled or not
|
||||
resolution: '640x480', // The resolution of your video
|
||||
frameRate: 30, // The frame rate of your video
|
||||
insertMode: 'APPEND', // How the video is inserted in the target element 'container-cameras'
|
||||
mirror: false // Whether to mirror your local video or not
|
||||
room.localParticipant.setMicrophoneEnabled(true);
|
||||
room.localParticipant.setCameraEnabled(true).then((publication) => {
|
||||
const element = publication.track.attach();
|
||||
document.getElementById('video-container').appendChild(element);
|
||||
initMainVideo(element, myUserName);
|
||||
appendUserData(element, myUserName);
|
||||
element.className = 'removable';
|
||||
});
|
||||
|
||||
// --- 7) Specify the actions when events take place in our publisher ---
|
||||
|
||||
// When our HTML video has been added to DOM...
|
||||
publisher.on('videoElementCreated', function (event) {
|
||||
initMainVideo(event.element, myUserName);
|
||||
appendUserData(event.element, myUserName);
|
||||
event.element['muted'] = true;
|
||||
});
|
||||
|
||||
// --- 8) Publish your stream ---
|
||||
sessionCamera.publish(publisher);
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('There was an error connecting to the session:', error.code, error.message);
|
||||
.catch((error) => {
|
||||
console.log(
|
||||
'There was an error connecting to the room:',
|
||||
error.code,
|
||||
error.message
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- 4.2) Get the token for the 'sessionScreen' object
|
||||
getToken(mySessionId).then((tokenScreen) => {
|
||||
// Create a token for screen share
|
||||
sessionScreen.connect(tokenScreen, { clientData: myUserName }).then(() => {
|
||||
document.getElementById('buttonScreenShare').style.visibility = 'visible';
|
||||
console.log("Session screen connected");
|
||||
}).catch((error => {
|
||||
console.warn('There was an error connecting to the session for screen share:', error.code, error.message);
|
||||
}));;
|
||||
});
|
||||
}
|
||||
|
||||
// --- 9) Function to be called when the 'Screen share' button is clicked
|
||||
function publishScreenShare() {
|
||||
// --- 9.1) To create a publisherScreen set the property 'videoSource' to 'screen'
|
||||
var publisherScreen = OVScreen.initPublisher("container-screens", { videoSource: "screen" });
|
||||
function leaveRoom() {
|
||||
// --- 6) Leave the room by calling 'disconnect' method over the Room object ---
|
||||
|
||||
// --- 9.2) Publish the screen share stream only after the user grants permission to the browser
|
||||
publisherScreen.once('accessAllowed', (event) => {
|
||||
document.getElementById('buttonScreenShare').style.visibility = 'hidden';
|
||||
screensharing = true;
|
||||
// If the user closes the shared window or stops sharing it, unpublish the stream
|
||||
publisherScreen.stream.getMediaStream().getVideoTracks()[0].addEventListener('ended', () => {
|
||||
console.log('User pressed the "Stop sharing" button');
|
||||
sessionScreen.unpublish(publisherScreen);
|
||||
document.getElementById('buttonScreenShare').style.visibility = 'visible';
|
||||
screensharing = false;
|
||||
});
|
||||
sessionScreen.publish(publisherScreen);
|
||||
});
|
||||
|
||||
publisherScreen.on('videoElementCreated', function (event) {
|
||||
appendUserData(event.element, sessionScreen.connection);
|
||||
event.element['muted'] = true;
|
||||
});
|
||||
|
||||
publisherScreen.once('accessDenied', (event) => {
|
||||
console.error('Screen Share: Access Denied');
|
||||
});
|
||||
}
|
||||
|
||||
function leaveSession() {
|
||||
|
||||
// --- 10) Leave the session by calling 'disconnect' method over the Session object ---
|
||||
sessionScreen.disconnect();
|
||||
sessionCamera.disconnect();
|
||||
room.disconnect();
|
||||
|
||||
// Removing all HTML elements with user's nicknames.
|
||||
// HTML videos are automatically removed when leaving a Session
|
||||
// HTML videos are automatically removed when leaving a Room
|
||||
removeAllUserData();
|
||||
|
||||
// Back to 'Join session' page
|
||||
// Back to 'Join room' page
|
||||
document.getElementById('join').style.display = 'block';
|
||||
document.getElementById('session').style.display = 'none';
|
||||
// Restore default screensharing value to false
|
||||
screensharing = false;
|
||||
document.getElementById('room').style.display = 'none';
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
async function toggleScreenShare() {
|
||||
console.log('Toggling screen share');
|
||||
const enabled = !isScreenShared;
|
||||
|
||||
if (enabled) {
|
||||
// Enable screen sharing
|
||||
screenSharePublication = await room.localParticipant?.setScreenShareEnabled(enabled);
|
||||
|
||||
if (screenSharePublication) {
|
||||
console.log('Screen sharing enabled', screenSharePublication);
|
||||
isScreenShared = enabled;
|
||||
|
||||
// Attach the screen share track to the video container
|
||||
const element = screenSharePublication.track.attach();
|
||||
element.id = screenSharePublication.trackSid;
|
||||
element.className = 'removable';
|
||||
document.getElementById('video-container').appendChild(element);
|
||||
|
||||
// Add user data for the screen share
|
||||
appendUserData(element, `${myUserName}_SCREEN`);
|
||||
|
||||
// Listen for the 'ended' event to handle screen sharing stop
|
||||
screenSharePublication.addListener('ended', async () => {
|
||||
console.debug('Clicked native stop button. Stopping screen sharing');
|
||||
await stopScreenSharing();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Disable screen sharing
|
||||
await stopScreenSharing();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function stopScreenSharing() {
|
||||
isScreenShared = false;
|
||||
await room.localParticipant?.setScreenShareEnabled(false);
|
||||
const trackSid = screenSharePublication?.trackSid;
|
||||
|
||||
if (trackSid) {
|
||||
document.getElementById(trackSid)?.remove();
|
||||
removeUserData({ identity: `${myUserName}_SCREEN` });
|
||||
}
|
||||
screenSharePublication = undefined;
|
||||
}
|
||||
|
||||
window.onbeforeunload = function () {
|
||||
if (sessionCamera) sessionCamera.disconnect();
|
||||
if (sessionScreen) sessionScreen.disconnect();
|
||||
if (room) room.disconnect();
|
||||
};
|
||||
|
||||
/* APPLICATION SPECIFIC METHODS */
|
||||
@ -190,39 +153,29 @@ window.addEventListener('load', function () {
|
||||
});
|
||||
|
||||
function generateParticipantInfo() {
|
||||
document.getElementById("sessionId").value = "SessionScreenA";
|
||||
document.getElementById("userName").value = "Participant" + Math.floor(Math.random() * 100);
|
||||
document.getElementById('roomName').value = 'RoomA';
|
||||
document.getElementById('userName').value =
|
||||
'Participant' + Math.floor(Math.random() * 100);
|
||||
}
|
||||
|
||||
function appendUserData(videoElement, connection) {
|
||||
var userData;
|
||||
var nodeId;
|
||||
if (typeof connection === "string") {
|
||||
userData = connection;
|
||||
nodeId = connection;
|
||||
} else {
|
||||
userData = JSON.parse(connection.data).clientData;
|
||||
nodeId = connection.connectionId;
|
||||
}
|
||||
function appendUserData(videoElement, participantIdentity) {
|
||||
var dataNode = document.createElement('div');
|
||||
dataNode.className = "data-node";
|
||||
dataNode.id = "data-" + nodeId;
|
||||
dataNode.innerHTML = "<p>" + userData + "</p>";
|
||||
dataNode.className = 'removable';
|
||||
dataNode.id = 'data-' + participantIdentity;
|
||||
dataNode.innerHTML = '<p>' + participantIdentity + '</p>';
|
||||
videoElement.parentNode.insertBefore(dataNode, videoElement.nextSibling);
|
||||
addClickListener(videoElement, userData);
|
||||
addClickListener(videoElement, participantIdentity);
|
||||
}
|
||||
|
||||
function removeUserData(connection) {
|
||||
var dataNodeToRemove = document.getElementById("data-" + connection.connectionId);
|
||||
if (dataNodeToRemove) {
|
||||
dataNodeToRemove.parentNode.removeChild(dataNodeToRemove);
|
||||
}
|
||||
function removeUserData(participant) {
|
||||
var dataNode = document.getElementById('data-' + participant.identity);
|
||||
dataNode?.parentNode.removeChild(dataNode);
|
||||
}
|
||||
|
||||
function removeAllUserData() {
|
||||
var nicknameElements = document.getElementsByClassName('data-node');
|
||||
while (nicknameElements[0]) {
|
||||
nicknameElements[0].parentNode.removeChild(nicknameElements[0]);
|
||||
var elementsToRemove = document.getElementsByClassName('removable');
|
||||
while (elementsToRemove[0]) {
|
||||
elementsToRemove[0].parentNode.removeChild(elementsToRemove[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,66 +183,75 @@ function addClickListener(videoElement, userData) {
|
||||
videoElement.addEventListener('click', function () {
|
||||
var mainVideo = $('#main-video video').get(0);
|
||||
if (mainVideo.srcObject !== videoElement.srcObject) {
|
||||
$('#main-video').fadeOut("fast", () => {
|
||||
$('#main-video').fadeOut('fast', () => {
|
||||
$('#main-video p').html(userData);
|
||||
mainVideo.srcObject = videoElement.srcObject;
|
||||
$('#main-video').fadeIn("fast");
|
||||
$('#main-video').fadeIn('fast');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initMainVideo(videoElement, userData) {
|
||||
document.querySelector('#main-video video').srcObject = videoElement.srcObject;
|
||||
document.querySelector('#main-video video').srcObject =
|
||||
videoElement.srcObject;
|
||||
document.querySelector('#main-video p').innerHTML = userData;
|
||||
document.querySelector('#main-video video')['muted'] = true;
|
||||
}
|
||||
|
||||
function getLivekitUrlFromMetadata(token) {
|
||||
if (!token) throw new Error('Trying to get metadata from an empty token');
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
window
|
||||
.atob(base64)
|
||||
.split('')
|
||||
.map((c) => {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
})
|
||||
.join('')
|
||||
);
|
||||
|
||||
const payload = JSON.parse(jsonPayload);
|
||||
if (!payload?.metadata) throw new Error('Token does not contain metadata');
|
||||
const metadata = JSON.parse(payload.metadata);
|
||||
return metadata.livekitUrl;
|
||||
} catch (error) {
|
||||
throw new Error('Error decoding and parsing token: ' + error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* --------------------------------------------
|
||||
* GETTING A TOKEN FROM YOUR APPLICATION SERVER
|
||||
* --------------------------------------------
|
||||
* The methods below request the creation of a Session and a Token to
|
||||
* The methods below request the creation of a Token to
|
||||
* your application server. This keeps your OpenVidu deployment secure.
|
||||
*
|
||||
*
|
||||
* In this sample code, there is no user control at all. Anybody could
|
||||
* access your application server endpoints! In a real production
|
||||
* environment, your application server must identify the user to allow
|
||||
* access to the endpoints.
|
||||
*
|
||||
* Visit https://docs.openvidu.io/en/stable/application-server to learn
|
||||
* more about the integration of OpenVidu in your application server.
|
||||
*
|
||||
*/
|
||||
|
||||
var APPLICATION_SERVER_URL = "http://localhost:5000/";
|
||||
var APPLICATION_SERVER_URL = 'http://localhost:5000/';
|
||||
|
||||
function getToken(mySessionId) {
|
||||
return createSession(mySessionId).then(sessionId => createToken(sessionId));
|
||||
}
|
||||
|
||||
function createSession(sessionId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: APPLICATION_SERVER_URL + "api/sessions",
|
||||
data: JSON.stringify({ customSessionId: sessionId }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
success: response => resolve(response), // The sessionId
|
||||
error: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createToken(sessionId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: APPLICATION_SERVER_URL + 'api/sessions/' + sessionId + '/connections',
|
||||
data: JSON.stringify({}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
success: (response) => resolve(response), // The token
|
||||
error: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
function getToken(roomName, participantName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: APPLICATION_SERVER_URL + 'token',
|
||||
data: JSON.stringify({
|
||||
roomName,
|
||||
participantName,
|
||||
permissions: {},
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
success: (token) => resolve(token),
|
||||
error: (error) => reject(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
<!-- Bootstrap -->
|
||||
|
||||
<link rel="stylesheet" href="style.css" type="text/css" media="screen">
|
||||
<script src="openvidu-browser-2.27.0.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</head>
|
||||
|
||||
@ -34,15 +34,15 @@
|
||||
<div id="join">
|
||||
<div id="img-div"><img src="resources/images/openvidu_grey_bg_transp_cropped.png" /></div>
|
||||
<div id="join-dialog" class="jumbotron vertical-center">
|
||||
<h1>Join a video session</h1>
|
||||
<form class="form-group" onsubmit="joinSession(); return false">
|
||||
<h1>Join a video room</h1>
|
||||
<form class="form-group" onsubmit="joinRoom(); return false">
|
||||
<p>
|
||||
<label>Participant</label>
|
||||
<input class="form-control" type="text" id="userName" required>
|
||||
</p>
|
||||
<p>
|
||||
<label>Session</label>
|
||||
<input class="form-control" type="text" id="sessionId" required>
|
||||
<label>Room</label>
|
||||
<input class="form-control" type="text" id="roomName" required>
|
||||
</p>
|
||||
<p class="text-center">
|
||||
<input class="btn btn-lg btn-success" type="submit" name="commit" value="Join!">
|
||||
@ -51,23 +51,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="session" style="display: none;">
|
||||
<div id="session-header">
|
||||
<h1 id="session-title"></h1>
|
||||
<input class="btn btn-large" type="button" id="buttonScreenShare" onmouseup="publishScreenShare()" value="Screen share" style="visibility: hidden;">
|
||||
<input class="btn btn-large btn-danger" type="button" id="buttonLeaveSession" onmouseup="leaveSession()" value="Leave session">
|
||||
<div id="room" style="display: none;">
|
||||
<div id="room-header">
|
||||
<h1 id="room-title"></h1>
|
||||
<input class="btn btn-large" type="button" id="buttonScreenShare" onmouseup="toggleScreenShare()" value="Screen share">
|
||||
<input class="btn btn-large btn-danger" type="button" id="buttonLeaveRoom" onmouseup="leaveRoom()" value="Leave room">
|
||||
</div>
|
||||
<div id="main-video" class="col-md-6"><p></p><video autoplay playsinline="true"></video></div>
|
||||
<div class="col-md-6">
|
||||
<div class="row panel panel-default">
|
||||
<div class="panel-heading">User cameras</div>
|
||||
<div class="panel-body" id="container-cameras" ></div>
|
||||
</div>
|
||||
<div class="row panel panel-default">
|
||||
<div class="panel-heading">User Screens</div>
|
||||
<div class="panel-body" id="container-screens" ></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="video-container" class="col-md-6"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -158,15 +158,15 @@ a:hover .demo-logo {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
#session-header {
|
||||
#room-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#session-title {
|
||||
#room-title {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#buttonLeaveSession {
|
||||
#buttonLeaveRoom {
|
||||
float: right;
|
||||
margin-top: 20px;
|
||||
}
|
||||
@ -242,7 +242,7 @@ video {
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
#session img {
|
||||
#room img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: inline-block;
|
||||
@ -250,7 +250,7 @@ video {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
#session #container-cameras img {
|
||||
#room #container-cameras img {
|
||||
position: relative;
|
||||
float: left;
|
||||
width: 50%;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user