773 lines
29 KiB
HTML
773 lines
29 KiB
HTML
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Stream: {{ client.id }} - Video Streaming Server</title>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:root {
|
|
--primary: #667eea;
|
|
--secondary: #764ba2;
|
|
--success: #10b981;
|
|
--danger: #ef4444;
|
|
--warning: #f59e0b;
|
|
--info: #3b82f6;
|
|
--dark: #1f2937;
|
|
--light: #f9fafb;
|
|
--gray: #6b7280;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: #1a1a1a;
|
|
color: white;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Header */
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 15px 30px;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
}
|
|
|
|
.back-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: white;
|
|
text-decoration: none;
|
|
padding: 8px 16px;
|
|
background: rgba(255,255,255,0.1);
|
|
border-radius: 5px;
|
|
transition: background 0.3s;
|
|
}
|
|
|
|
.back-btn:hover {
|
|
background: rgba(255,255,255,0.2);
|
|
}
|
|
|
|
.client-info h2 {
|
|
font-size: 18px;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.client-info p {
|
|
font-size: 12px;
|
|
color: #aaa;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
|
|
.btn {
|
|
padding: 8px 16px;
|
|
border: none;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
transition: all 0.3s;
|
|
background: rgba(255,255,255,0.1);
|
|
color: white;
|
|
}
|
|
|
|
.btn:hover {
|
|
background: rgba(255,255,255,0.2);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: var(--danger);
|
|
}
|
|
|
|
/* Main Content */
|
|
.main-content {
|
|
display: flex;
|
|
height: calc(100vh - 70px);
|
|
}
|
|
|
|
/* Video Container */
|
|
.video-container {
|
|
flex: 3;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: #000;
|
|
position: relative;
|
|
}
|
|
|
|
.video-placeholder {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
color: #666;
|
|
}
|
|
|
|
.video-placeholder i {
|
|
font-size: 100px;
|
|
margin-bottom: 20px;
|
|
opacity: 0.3;
|
|
}
|
|
|
|
.video-placeholder h3 {
|
|
font-size: 24px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.video-placeholder p {
|
|
font-size: 14px;
|
|
text-align: center;
|
|
max-width: 400px;
|
|
}
|
|
|
|
.video-stats {
|
|
position: absolute;
|
|
bottom: 20px;
|
|
left: 20px;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
padding: 10px 15px;
|
|
border-radius: 5px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* Controls Panel */
|
|
.controls-panel {
|
|
flex: 1;
|
|
max-width: 300px;
|
|
background: rgba(30, 30, 30, 0.95);
|
|
padding: 20px;
|
|
overflow-y: auto;
|
|
border-left: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.controls-section {
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.controls-section h3 {
|
|
font-size: 16px;
|
|
margin-bottom: 15px;
|
|
color: #ddd;
|
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
padding-bottom: 10px;
|
|
}
|
|
|
|
.control-group {
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.control-group label {
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
color: #aaa;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.slider-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
input[type="range"] {
|
|
flex: 1;
|
|
height: 5px;
|
|
-webkit-appearance: none;
|
|
background: rgba(255,255,255,0.1);
|
|
border-radius: 5px;
|
|
outline: none;
|
|
}
|
|
|
|
input[type="range"]::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 15px;
|
|
height: 15px;
|
|
border-radius: 50%;
|
|
background: var(--primary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.slider-value {
|
|
min-width: 30px;
|
|
text-align: right;
|
|
font-size: 12px;
|
|
color: #ddd;
|
|
}
|
|
|
|
.btn-group {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.btn-control {
|
|
padding: 10px;
|
|
text-align: center;
|
|
}
|
|
|
|
.btn-control i {
|
|
font-size: 20px;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.btn-control span {
|
|
display: block;
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* Connection Info */
|
|
.info-box {
|
|
background: rgba(0, 0, 0, 0.3);
|
|
border-radius: 5px;
|
|
padding: 15px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.info-item {
|
|
margin-bottom: 8px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.info-item label {
|
|
display: inline-block;
|
|
width: 100px;
|
|
color: #aaa;
|
|
}
|
|
|
|
/* Status Indicator */
|
|
.status-indicator {
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
margin-right: 5px;
|
|
}
|
|
|
|
.status-connected {
|
|
background: var(--success);
|
|
box-shadow: 0 0 10px var(--success);
|
|
}
|
|
|
|
.status-disconnected {
|
|
background: var(--danger);
|
|
box-shadow: 0 0 10px var(--danger);
|
|
}
|
|
|
|
/* WebSocket Status */
|
|
.ws-status {
|
|
position: fixed;
|
|
bottom: 10px;
|
|
right: 10px;
|
|
padding: 5px 10px;
|
|
border-radius: 3px;
|
|
font-size: 11px;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
}
|
|
|
|
.ws-connected {
|
|
color: var(--success);
|
|
}
|
|
|
|
.ws-disconnected {
|
|
color: var(--danger);
|
|
}
|
|
|
|
/* Loading */
|
|
.loading {
|
|
text-align: center;
|
|
padding: 50px;
|
|
color: #666;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.main-content {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.controls-panel {
|
|
max-width: none;
|
|
height: 300px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Header -->
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<a href="/room/{{ client.room_id }}" class="back-btn">
|
|
<i class="fas fa-arrow-left"></i>
|
|
Back to Room
|
|
</a>
|
|
|
|
<div class="client-info">
|
|
<h2>Streaming: {{ client.id[:8] }}...</h2>
|
|
<p>
|
|
<span class="status-indicator status-connected"></span>
|
|
Connected from {{ client.ip_address }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="header-actions">
|
|
<button class="btn btn-danger" onclick="disconnectClient()">
|
|
<i class="fas fa-sign-out-alt"></i> Disconnect
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div class="main-content">
|
|
<!-- Video Container -->
|
|
<div class="video-container">
|
|
<!-- Canvas для отображения видеокадров -->
|
|
<canvas id="videoCanvas" style="width: 100%; height: auto; display: none; background: #000;"></canvas>
|
|
|
|
<div class="video-placeholder" id="videoPlaceholder">
|
|
<i class="fas fa-video"></i>
|
|
<h3>Waiting for Video Stream</h3>
|
|
<p>Video stream will appear here once the client starts streaming.</p>
|
|
<p>Use the controls on the right to adjust video settings.</p>
|
|
</div>
|
|
|
|
<div class="video-stats" id="videoStats" style="display: none;">
|
|
<div>Resolution: <span id="statsResolution">640x480</span></div>
|
|
<div>Frame Rate: <span id="statsFps">30</span> FPS</div>
|
|
<div>Quality: <span id="statsQuality">85</span>%</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controls Panel -->
|
|
<div class="controls-panel">
|
|
<!-- Connection Info -->
|
|
<div class="info-box">
|
|
<div class="info-item">
|
|
<label>Client ID:</label>
|
|
<span>{{ client.id }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>Room:</label>
|
|
<span>{{ room.name if room else 'Unknown' }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>Connected:</label>
|
|
<span>{{ client.connected_at }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>Streaming:</label>
|
|
<span id="streamingStatus">{{ "Yes" if client.is_streaming else "No" }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Video Controls -->
|
|
<div class="controls-section">
|
|
<h3>Video Controls</h3>
|
|
|
|
<div class="control-group">
|
|
<label>Quality</label>
|
|
<div class="slider-container">
|
|
<input type="range" id="qualitySlider" min="10" max="100" value="{{ client.video_settings.quality }}">
|
|
<span class="slider-value" id="qualityValue">{{ client.video_settings.quality }}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label>Brightness</label>
|
|
<div class="slider-container">
|
|
<input type="range" id="brightnessSlider" min="-100" max="100" value="0">
|
|
<span class="slider-value" id="brightnessValue">0</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label>Contrast</label>
|
|
<div class="slider-container">
|
|
<input type="range" id="contrastSlider" min="0.5" max="2" step="0.1" value="1">
|
|
<span class="slider-value" id="contrastValue">1.0</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="btn-group">
|
|
<button class="btn btn-control" onclick="sendCommand('rotate', {angle: 90})">
|
|
<i class="fas fa-redo"></i>
|
|
<span>Rotate 90°</span>
|
|
</button>
|
|
<button class="btn btn-control" onclick="sendCommand('rotate', {angle: 180})">
|
|
<i class="fas fa-sync-alt"></i>
|
|
<span>Rotate 180°</span>
|
|
</button>
|
|
<button class="btn btn-control" onclick="sendCommand('flip', {direction: 1})">
|
|
<i class="fas fa-arrows-alt-v"></i>
|
|
<span>Flip Vertical</span>
|
|
</button>
|
|
<button class="btn btn-control" onclick="sendCommand('flip', {direction: 0})">
|
|
<i class="fas fa-arrows-alt-h"></i>
|
|
<span>Flip Horizontal</span>
|
|
</button>
|
|
<button class="btn btn-control" onclick="sendCommand('grayscale', {})">
|
|
<i class="fas fa-adjust"></i>
|
|
<span>Grayscale</span>
|
|
</button>
|
|
<button class="btn btn-control" onclick="sendCommand('reset', {})">
|
|
<i class="fas fa-undo"></i>
|
|
<span>Reset All</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stream Information -->
|
|
<div class="controls-section">
|
|
<h3>Stream Information</h3>
|
|
<div class="info-box">
|
|
<p>This client is connected via WebSocket and streaming video data.</p>
|
|
<p>Use the controls above to adjust the video stream in real-time.</p>
|
|
<p>Changes are applied to the video processor on the server side.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- WebSocket Status -->
|
|
<div class="ws-status" id="wsStatus">
|
|
<span class="status-indicator status-disconnected"></span>
|
|
<span id="wsStatusText">Connecting...</span>
|
|
</div>
|
|
|
|
<script>
|
|
let ws = null;
|
|
let clientId = '{{ client.id }}';
|
|
let sessionId = getCookie('session_id');
|
|
let reconnectAttempts = 0;
|
|
const maxReconnectAttempts = 5;
|
|
|
|
function getCookie(name) {
|
|
const value = `; ${document.cookie}`;
|
|
const parts = value.split(`; ${name}=`);
|
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
|
return null;
|
|
}
|
|
|
|
// Initialize WebSocket connection
|
|
function connectWebSocket() {
|
|
if (!sessionId) {
|
|
console.error('No session ID found');
|
|
return;
|
|
}
|
|
|
|
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
const wsUrl = `${wsProto}://${window.location.hostname}:{{ server_port }}/ws/admin/${sessionId}`;
|
|
|
|
try {
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = function() {
|
|
console.log('WebSocket connected');
|
|
updateWsStatus(true);
|
|
reconnectAttempts = 0;
|
|
|
|
// Request to watch this client
|
|
sendCommand('watch_client', { client_id: clientId });
|
|
};
|
|
|
|
ws.onmessage = function(event) {
|
|
try {
|
|
// Проверяем, это бинарные данные (видеокадр) или текст (JSON)
|
|
if (event.data instanceof Blob) {
|
|
// Это видеокадр - отображаем на canvas
|
|
handleVideoFrame(event.data);
|
|
} else if (typeof event.data === 'string') {
|
|
// Это текстовое сообщение JSON
|
|
const data = JSON.parse(event.data);
|
|
handleWebSocketMessage(data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error processing WebSocket message:', error);
|
|
}
|
|
};
|
|
|
|
ws.onclose = function() {
|
|
console.log('WebSocket disconnected');
|
|
updateWsStatus(false);
|
|
|
|
// Try to reconnect
|
|
if (reconnectAttempts < maxReconnectAttempts) {
|
|
reconnectAttempts++;
|
|
setTimeout(connectWebSocket, 1000 * reconnectAttempts);
|
|
}
|
|
};
|
|
|
|
ws.onerror = function(error) {
|
|
console.error('WebSocket error:', error);
|
|
updateWsStatus(false);
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Error creating WebSocket:', error);
|
|
updateWsStatus(false);
|
|
}
|
|
}
|
|
|
|
// Update WebSocket status display
|
|
function updateWsStatus(connected) {
|
|
const wsStatus = document.getElementById('wsStatus');
|
|
const wsStatusText = document.getElementById('wsStatusText');
|
|
|
|
if (connected) {
|
|
wsStatus.className = 'ws-status';
|
|
wsStatusText.innerHTML = '<span class="status-indicator status-connected"></span> Connected';
|
|
} else {
|
|
wsStatus.className = 'ws-status';
|
|
wsStatusText.innerHTML = `<span class="status-indicator status-disconnected"></span> Disconnected (${reconnectAttempts}/${maxReconnectAttempts})`;
|
|
}
|
|
}
|
|
|
|
// Handle incoming WebSocket messages
|
|
function handleWebSocketMessage(data) {
|
|
console.log('Received:', data);
|
|
|
|
switch (data.type) {
|
|
case 'stream_started':
|
|
showNotification('Stream started successfully');
|
|
updateStreamingStatus(true);
|
|
break;
|
|
|
|
case 'stream_info':
|
|
showNotification(data.message);
|
|
break;
|
|
|
|
case 'control_response':
|
|
if (data.success) {
|
|
showNotification('Command executed successfully');
|
|
} else {
|
|
showNotification('Command failed', 'error');
|
|
}
|
|
break;
|
|
|
|
case 'stats_update':
|
|
// Update any stats if needed
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Send command to WebSocket
|
|
function sendCommand(type, data) {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
showNotification('WebSocket not connected', 'error');
|
|
return;
|
|
}
|
|
|
|
const command = {
|
|
type: 'control_client',
|
|
client_id: clientId,
|
|
command: {
|
|
type: type,
|
|
...data
|
|
}
|
|
};
|
|
|
|
ws.send(JSON.stringify(command));
|
|
console.log('Sent command:', command);
|
|
}
|
|
|
|
// Update streaming status
|
|
function updateStreamingStatus(streaming) {
|
|
document.getElementById('streamingStatus').textContent = streaming ? 'Yes' : 'No';
|
|
|
|
if (streaming) {
|
|
document.getElementById('videoStats').style.display = 'block';
|
|
document.getElementById('videoPlaceholder').innerHTML = `
|
|
<i class="fas fa-sync-alt fa-spin"></i>
|
|
<h3>Streaming Live Video</h3>
|
|
<p>Video is being streamed from the client.</p>
|
|
<p>Use controls to adjust the stream.</p>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// Show notification
|
|
function showNotification(message, type = 'success') {
|
|
// Create notification element
|
|
const notification = document.createElement('div');
|
|
notification.style.cssText = `
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
padding: 15px 20px;
|
|
background: ${type === 'error' ? '#ef4444' : '#10b981'};
|
|
color: white;
|
|
border-radius: 5px;
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
|
z-index: 10000;
|
|
animation: slideIn 0.3s ease-out;
|
|
`;
|
|
notification.textContent = message;
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
// Remove after 3 seconds
|
|
setTimeout(() => {
|
|
notification.style.animation = 'slideOut 0.3s ease-in';
|
|
setTimeout(() => {
|
|
document.body.removeChild(notification);
|
|
}, 300);
|
|
}, 3000);
|
|
}
|
|
|
|
// Disconnect client
|
|
async function disconnectClient() {
|
|
if (confirm('Are you sure you want to disconnect this client?')) {
|
|
try {
|
|
const response = await fetch(`/api/disconnect-client/${clientId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showNotification('Client disconnected successfully');
|
|
setTimeout(() => {
|
|
window.location.href = `/room/{{ client.room_id }}`;
|
|
}, 1000);
|
|
} else {
|
|
showNotification('Error: ' + (result.error || 'Failed to disconnect client'), 'error');
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error: ' + error.message, 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle video frame from admin WebSocket
|
|
function handleVideoFrame(blob) {
|
|
const canvas = document.getElementById('videoCanvas');
|
|
const placeholder = document.getElementById('videoPlaceholder');
|
|
|
|
// Show canvas, hide placeholder
|
|
if (canvas.style.display === 'none') {
|
|
canvas.style.display = 'block';
|
|
placeholder.style.display = 'none';
|
|
}
|
|
|
|
// Create image from blob and draw on canvas
|
|
const img = new Image();
|
|
img.onload = function() {
|
|
const ctx = canvas.getContext('2d');
|
|
// Set canvas size to match image
|
|
canvas.width = img.width;
|
|
canvas.height = img.height;
|
|
// Draw image
|
|
ctx.drawImage(img, 0, 0);
|
|
// Revoke the object URL to free memory
|
|
URL.revokeObjectURL(img.src);
|
|
};
|
|
|
|
img.onerror = function() {
|
|
console.error('Failed to load image from blob');
|
|
URL.revokeObjectURL(img.src);
|
|
};
|
|
|
|
// Create object URL from blob and load as image
|
|
img.src = URL.createObjectURL(blob);
|
|
}
|
|
|
|
// Initialize sliders
|
|
function initializeSliders() {
|
|
// Quality slider
|
|
const qualitySlider = document.getElementById('qualitySlider');
|
|
const qualityValue = document.getElementById('qualityValue');
|
|
|
|
qualitySlider.addEventListener('input', function() {
|
|
qualityValue.textContent = this.value + '%';
|
|
document.getElementById('statsQuality').textContent = this.value;
|
|
});
|
|
|
|
qualitySlider.addEventListener('change', function() {
|
|
sendCommand('adjust_quality', { quality: parseInt(this.value) });
|
|
});
|
|
|
|
// Brightness slider
|
|
const brightnessSlider = document.getElementById('brightnessSlider');
|
|
const brightnessValue = document.getElementById('brightnessValue');
|
|
|
|
brightnessSlider.addEventListener('input', function() {
|
|
brightnessValue.textContent = this.value;
|
|
});
|
|
|
|
brightnessSlider.addEventListener('change', function() {
|
|
sendCommand('brightness', { value: parseInt(this.value) });
|
|
});
|
|
|
|
// Contrast slider
|
|
const contrastSlider = document.getElementById('contrastSlider');
|
|
const contrastValue = document.getElementById('contrastValue');
|
|
|
|
contrastSlider.addEventListener('input', function() {
|
|
contrastValue.textContent = parseFloat(this.value).toFixed(1);
|
|
});
|
|
|
|
contrastSlider.addEventListener('change', function() {
|
|
sendCommand('contrast', { value: parseFloat(this.value) });
|
|
});
|
|
}
|
|
|
|
// Initialize
|
|
window.addEventListener('load', function() {
|
|
if (!sessionId) {
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
|
|
connectWebSocket();
|
|
initializeSliders();
|
|
|
|
// Add CSS animations
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
@keyframes slideIn {
|
|
from { transform: translateX(100%); opacity: 0; }
|
|
to { transform: translateX(0); opacity: 1; }
|
|
}
|
|
@keyframes slideOut {
|
|
from { transform: translateX(0); opacity: 1; }
|
|
to { transform: translateX(100%); opacity: 0; }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|