init commit
This commit is contained in:
729
templates/stream.html
Normal file
729
templates/stream.html
Normal file
@@ -0,0 +1,729 @@
|
||||
|
||||
<!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">
|
||||
<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 wsUrl = `ws://${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 {
|
||||
const data = JSON.parse(event.data);
|
||||
handleWebSocketMessage(data);
|
||||
} catch (error) {
|
||||
console.error('Error parsing 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
Reference in New Issue
Block a user