This commit is contained in:
Flatlogic Bot 2025-11-07 22:57:22 +00:00
parent 3ddc00e28e
commit 1496e8cc1d
6 changed files with 320 additions and 69 deletions

View File

@ -1,18 +1,2 @@
DirectoryIndex index.php index.html php_value upload_max_filesize 64M
Options -Indexes php_value post_max_size 64M
Options -MultiViews
RewriteEngine On
# 0) Serve existing files/directories as-is
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# 1) Internal map: /page or /page/ -> /page.php (if such PHP file exists)
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+?)/?$ $1.php [L]
# 2) Optional: strip trailing slash for non-directories (keeps .php links working)
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.+)/$ $1 [R=301,L]

View File

@ -3,8 +3,6 @@ document.addEventListener('DOMContentLoaded', function () {
const sceneListEl = document.querySelector('.scene-list'); const sceneListEl = document.querySelector('.scene-list');
const previewPanel = document.getElementById('preview-panel'); const previewPanel = document.getElementById('preview-panel');
const programPanel = document.getElementById('program-panel'); const programPanel = document.getElementById('program-panel');
const previewContent = previewPanel.querySelector('.scene-content');
const programContent = programPanel.querySelector('.scene-content');
const cutButton = document.getElementById('cut-button'); const cutButton = document.getElementById('cut-button');
// Modal Elements // Modal Elements
@ -19,14 +17,37 @@ document.addEventListener('DOMContentLoaded', function () {
// Source settings elements // Source settings elements
const colorSettings = document.getElementById('source-color-group'); const colorSettings = document.getElementById('source-color-group');
const imageSettings = document.getElementById('source-image-group'); const imageSettings = document.getElementById('source-image-group');
const videoSettings = document.getElementById('source-video-group');
const cameraSettings = document.getElementById('source-camera-group');
const sceneColorInput = document.getElementById('scene-color-input'); const sceneColorInput = document.getElementById('scene-color-input');
const sceneImageUrlInput = document.getElementById('scene-image-url-input'); const sceneImageFileInput = document.getElementById('scene-image-file-input');
const sceneVideoFileInput = document.getElementById('scene-video-file-input');
// Camera specific elements
const cameraPermissionPrompt = document.getElementById('camera-permission-prompt');
const grantCameraPermissionBtn = document.getElementById('grant-camera-permission-btn');
const cameraDeviceSelection = document.getElementById('camera-device-selection');
const sceneCameraDeviceInput = document.getElementById('scene-camera-device-input');
// Data Store // Data Store
let scenes = []; let scenes = [];
let activePreviewSceneId = null; let activePreviewSceneId = null;
let activeProgramSceneId = null; let activeProgramSceneId = null;
let sceneIdCounter = 0; let sceneIdCounter = 0;
let activeStreams = {}; // To hold references to active MediaStream objects
// --- STREAM MANAGEMENT ---
function stopStream(panel) {
const panelId = panel.id;
if (activeStreams[panelId]) {
activeStreams[panelId].getTracks().forEach(track => track.stop());
delete activeStreams[panelId];
}
const videoEl = panel.querySelector('video');
if (videoEl) {
videoEl.remove();
}
}
// --- DATA MANAGEMENT --- // --- DATA MANAGEMENT ---
function addScene(name, type, value) { function addScene(name, type, value) {
@ -46,6 +67,9 @@ document.addEventListener('DOMContentLoaded', function () {
if (activePreviewSceneId === id) { if (activePreviewSceneId === id) {
clearPreview(); clearPreview();
} }
if (activeProgramSceneId === id) {
clearProgram();
}
renderSceneList(); renderSceneList();
} }
@ -72,16 +96,17 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
} }
function updatePreview(sceneId) { async function updatePreview(sceneId) {
const scene = getScene(sceneId); const scene = getScene(sceneId);
if (!scene) return; if (!scene) return;
activePreviewSceneId = scene.id; activePreviewSceneId = scene.id;
// Clear previous styles stopStream(previewPanel);
previewPanel.innerHTML = '<span class="panel-label">PREVIEW</span><div class="scene-content"></div>';
previewPanel.style.backgroundColor = '#000'; previewPanel.style.backgroundColor = '#000';
previewPanel.style.backgroundImage = 'none'; previewPanel.style.backgroundImage = 'none';
previewContent.textContent = scene.name; previewPanel.querySelector('.scene-content').textContent = scene.name;
switch (scene.type) { switch (scene.type) {
case 'color': case 'color':
@ -90,39 +115,132 @@ document.addEventListener('DOMContentLoaded', function () {
case 'image': case 'image':
previewPanel.style.backgroundImage = `url('${scene.value}')`; previewPanel.style.backgroundImage = `url('${scene.value}')`;
break; break;
case 'video':
case 'camera':
const video = document.createElement('video');
video.muted = true;
video.loop = scene.type === 'video';
video.autoplay = true;
video.style.width = '100%';
video.style.height = '100%';
video.style.objectFit = 'cover';
if (scene.type === 'video') {
video.src = scene.value;
} else { // camera
try {
// A deviceId of '' can happen if the initial scene is a camera
// before permission is granted. We should ask for default camera.
const constraints = scene.value
? { video: { deviceId: { exact: scene.value } } }
: { video: true };
const stream = await navigator.mediaDevices.getUserMedia(constraints);
video.srcObject = stream;
activeStreams[previewPanel.id] = stream;
} catch (err) {
console.error("Error accessing camera:", err);
previewPanel.querySelector('.scene-content').textContent = 'Camera Error!';
previewPanel.style.backgroundColor = 'red';
return;
}
}
previewPanel.querySelector('.scene-content').appendChild(video);
break;
default: default:
previewPanel.style.backgroundColor = '#000'; previewPanel.style.backgroundColor = '#000';
break; break;
} }
renderSceneList(); // Re-render to show active state renderSceneList();
} }
function clearPreview() { function clearPreview() {
stopStream(previewPanel);
activePreviewSceneId = null; activePreviewSceneId = null;
previewContent.textContent = 'Select a Scene'; previewPanel.innerHTML = '<span class="panel-label">PREVIEW</span><div class="scene-content">Select a Scene</div>';
previewPanel.style.backgroundColor = '#000'; previewPanel.style.backgroundColor = '#000';
previewPanel.style.backgroundImage = 'none'; previewPanel.style.backgroundImage = 'none';
renderSceneList(); renderSceneList();
} }
function clearProgram() {
stopStream(programPanel);
activeProgramSceneId = null;
programPanel.innerHTML = '<span class="panel-label">PROGRAM (ON AIR)</span><div class="scene-content"></div>';
programPanel.style.backgroundColor = '#440000';
programPanel.style.backgroundImage = 'none';
}
function getIconForSceneType(type) { function getIconForSceneType(type) {
switch (type) { switch (type) {
case 'color': return '<i class="bi bi-palette-fill"></i>'; case 'color': return '<i class="bi bi-palette-fill"></i>';
case 'image': return '<i class="bi bi-image-fill"></i>'; case 'image': return '<i class="bi bi-image-fill"></i>';
case 'video': return '<i class="bi bi-film"></i>'; case 'video': return '<i class="bi bi-film"></i>';
case 'camera': return '<i class="bi bi-camera-video-fill"></i>';
default: return '<i class="bi bi-question-circle-fill"></i>'; default: return '<i class="bi bi-question-circle-fill"></i>';
} }
} }
// --- CAMERA & DEVICE MANAGEMENT ---
async function populateCameraDevices() {
// First, check for a secure context (HTTPS), which is required for getUserMedia.
if (!window.isSecureContext) {
console.error("Camera access requires a secure context (HTTPS).");
cameraPermissionPrompt.innerHTML = '<p class="text-danger">Camera access is only available over a secure HTTPS connection. Please ensure you are using an https:// URL.</p>';
return;
}
try {
// Request permission first
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
// Stop the tracks immediately, we only needed it for permission
stream.getTracks().forEach(track => track.stop());
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(device => device.kind === 'videoinput');
sceneCameraDeviceInput.innerHTML = '';
if (videoDevices.length === 0) {
// This case is unlikely if getUserMedia succeeded, but good to have.
cameraPermissionPrompt.innerHTML = '<p class="text-warning">No camera devices were found.</p>';
return;
}
videoDevices.forEach(device => {
const option = document.createElement('option');
option.value = device.deviceId;
option.textContent = device.label || `Camera ${sceneCameraDeviceInput.length + 1}`;
sceneCameraDeviceInput.appendChild(option);
});
// Switch UI
cameraPermissionPrompt.style.display = 'none';
cameraDeviceSelection.style.display = 'block';
} catch (err) {
console.error("Could not get camera permissions or list devices:", err);
let errorMessage = 'An unexpected error occurred while trying to access the camera.';
if (err.name === 'NotAllowedError') {
errorMessage = 'Camera permission was denied. You need to grant permission in your browser\'s site settings to use this feature.';
} else if (err.name === 'NotFoundError') {
errorMessage = 'No camera was found on your device. Please connect a camera and try again.';
} else if (err.name === 'NotReadableError') {
errorMessage = 'The camera is currently in use by another application or a hardware error occurred.';
}
cameraPermissionPrompt.innerHTML = `<p class="text-danger">${errorMessage}</p>`;
}
}
// --- EVENT LISTENERS --- // --- EVENT LISTENERS ---
// Scene list interactions (select, remove)
sceneListEl.addEventListener('click', (e) => { sceneListEl.addEventListener('click', (e) => {
const target = e.target; const target = e.target;
const sceneItem = target.closest('.list-group-item'); const sceneItem = target.closest('.list-group-item');
if (!sceneItem) return; if (!sceneItem) return;
const sceneId = parseInt(sceneItem.dataset.sceneId); const sceneId = parseInt(sceneItem.dataset.sceneId, 10);
if (target.classList.contains('remove-scene-btn')) { if (target.classList.contains('remove-scene-btn')) {
removeScene(sceneId); removeScene(sceneId);
@ -131,22 +249,41 @@ document.addEventListener('DOMContentLoaded', function () {
} }
}); });
// Transition button
cutButton.addEventListener('click', () => { cutButton.addEventListener('click', () => {
if (activePreviewSceneId === null) return; if (activePreviewSceneId === null) return;
const previewScene = getScene(activePreviewSceneId); const previewScene = getScene(activePreviewSceneId);
activeProgramSceneId = previewScene.id; activeProgramSceneId = previewScene.id;
programContent.textContent = previewScene.name; stopStream(programPanel);
// Clone the content and style
programPanel.innerHTML = previewPanel.innerHTML;
programPanel.style.backgroundColor = previewPanel.style.backgroundColor; programPanel.style.backgroundColor = previewPanel.style.backgroundColor;
programPanel.style.backgroundImage = previewPanel.style.backgroundImage; programPanel.style.backgroundImage = previewPanel.style.backgroundImage;
const previewVideo = previewPanel.querySelector('video');
if (previewVideo && previewVideo.srcObject) { // It's a stream
// Move the stream from preview to program
activeStreams[programPanel.id] = activeStreams[previewPanel.id];
delete activeStreams[previewPanel.id];
}
programPanel.querySelector('.panel-label').textContent = 'PROGRAM (ON AIR)';
const programVideo = programPanel.querySelector('video');
if(programVideo) {
programVideo.muted = false; // Unmute in program
}
clearPreview(); clearPreview();
}); });
// Modal interactions function showModal() {
function showModal() { modal.style.display = 'flex'; sceneNameInput.focus(); } modal.style.display = 'flex';
sceneNameInput.focus();
// Reset and update visibility on open
sceneTypeSelect.dispatchEvent(new Event('change'));
}
function hideModal() { modal.style.display = 'none'; } function hideModal() { modal.style.display = 'none'; }
addSceneBtn.addEventListener('click', showModal); addSceneBtn.addEventListener('click', showModal);
@ -157,9 +294,24 @@ document.addEventListener('DOMContentLoaded', function () {
const type = e.target.value; const type = e.target.value;
colorSettings.style.display = type === 'color' ? 'block' : 'none'; colorSettings.style.display = type === 'color' ? 'block' : 'none';
imageSettings.style.display = type === 'image' ? 'block' : 'none'; imageSettings.style.display = type === 'image' ? 'block' : 'none';
videoSettings.style.display = type === 'video' ? 'block' : 'none';
cameraSettings.style.display = type === 'camera' ? 'block' : 'none';
if (type === 'camera') {
// Reset to initial state
cameraPermissionPrompt.style.display = 'block';
cameraDeviceSelection.style.display = 'none';
// Check if devices are already populated
if (sceneCameraDeviceInput.options.length > 0) {
cameraPermissionPrompt.style.display = 'none';
cameraDeviceSelection.style.display = 'block';
}
}
}); });
modalSaveBtn.addEventListener('click', () => { grantCameraPermissionBtn.addEventListener('click', populateCameraDevices);
async function saveScene() {
const name = sceneNameInput.value.trim(); const name = sceneNameInput.value.trim();
if (!name) { if (!name) {
alert('Please enter a scene name.'); alert('Please enter a scene name.');
@ -167,49 +319,83 @@ document.addEventListener('DOMContentLoaded', function () {
} }
const type = sceneTypeSelect.value; const type = sceneTypeSelect.value;
let value; let value;
let file = null;
if (type === 'color') { try {
value = sceneColorInput.value; if (type === 'color') {
} else if (type === 'image') { value = sceneColorInput.value;
value = sceneImageUrlInput.value.trim(); } else if (type === 'camera') {
if (!value) { value = sceneCameraDeviceInput.value;
alert('Please enter an image URL.'); if (!value) {
return; alert('Please select a camera device.');
return;
}
} else {
if (type === 'image') {
file = sceneImageFileInput.files[0];
if (!file) { alert('Please select an image file.'); return; }
} else if (type === 'video') {
file = sceneVideoFileInput.files[0];
if (!file) { alert('Please select a video file.'); return; }
}
modalSaveBtn.disabled = true;
modalSaveBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Uploading...';
const formData = new FormData();
formData.append('file', file);
const response = await fetch('upload.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
value = result.filePath;
} else {
throw new Error(result.error || 'Upload failed');
}
} }
const newScene = addScene(name, type, value);
updatePreview(newScene.id);
// Reset form and hide modal
sceneNameInput.value = '';
sceneColorInput.value = '#1e90ff';
sceneImageFileInput.value = '';
sceneVideoFileInput.value = '';
// Don't clear camera list, just reset selection
sceneTypeSelect.value = 'color';
colorSettings.style.display = 'block';
imageSettings.style.display = 'none';
videoSettings.style.display = 'none';
cameraSettings.style.display = 'none';
hideModal();
} catch (error) {
alert('Error: ' + error.message);
} finally {
modalSaveBtn.disabled = false;
modalSaveBtn.textContent = 'Save Scene';
} }
}
const newScene = addScene(name, type, value); modalSaveBtn.addEventListener('click', saveScene);
updatePreview(newScene.id);
// Reset form and hide modal
sceneNameInput.value = '';
sceneColorInput.value = '#1e90ff';
sceneImageUrlInput.value = '';
sceneTypeSelect.value = 'color';
colorSettings.style.display = 'block';
imageSettings.style.display = 'none';
hideModal();
});
// --- INITIALIZATION --- // --- INITIALIZATION ---
function initialize() { function initialize() {
// Add some default scenes // Set a default scene that doesn't require permissions on load
addScene('Main Camera', 'color', '#003366');
addScene('Starting Soon Screen', 'image', 'https://images.pexels.com/photos/1762851/pexels-photo-1762851.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1'); addScene('Starting Soon Screen', 'image', 'https://images.pexels.com/photos/1762851/pexels-photo-1762851.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1');
addScene('Main Camera', 'camera', ''); // Default camera, will ask for permission on use
addScene('Promo Video', 'video', 'https://assets.mixkit.co/videos/preview/mixkit-spinning-around-the-earth-29351-large.mp4');
addScene('Screen Share', 'color', '#006633'); addScene('Screen Share', 'color', '#006633');
// Set initial program scene for display const firstScene = getScene(0);
const firstScene = scenes[0];
if (firstScene) { if (firstScene) {
activeProgramSceneId = firstScene.id; updatePreview(firstScene.id);
programContent.textContent = firstScene.name;
programPanel.style.backgroundColor = firstScene.value;
programPanel.style.backgroundImage = 'none';
}
// Select the second scene for preview initially
if (scenes.length > 1) {
updatePreview(scenes[1].id);
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

View File

@ -95,7 +95,8 @@
<select class="form-select" id="scene-type-select"> <select class="form-select" id="scene-type-select">
<option value="color">Solid Color</option> <option value="color">Solid Color</option>
<option value="image">Image</option> <option value="image">Image</option>
<option value="video" disabled>Video (soon)</option> <option value="video">Video</option>
<option value="camera">Camera</option>
</select> </select>
</div> </div>
@ -105,10 +106,25 @@
</div> </div>
<div id="source-image-group" class="mb-3 source-settings" style="display: none;"> <div id="source-image-group" class="mb-3 source-settings" style="display: none;">
<label for="scene-image-url-input" class="form-label">Image URL</label> <label for="scene-image-file-input" class="form-label">Image File</label>
<input type="url" class="form-control" id="scene-image-url-input" placeholder="https://example.com/image.jpg"> <input type="file" class="form-control" id="scene-image-file-input" accept="image/*">
</div>
<div id="source-video-group" class="mb-3 source-settings" style="display: none;">
<label for="scene-video-file-input" class="form-label">Video File</label>
<input type="file" class="form-control" id="scene-video-file-input" accept="video/*">
</div>
<div id="source-camera-group" class="mb-3 source-settings" style="display: none;">
<div id="camera-permission-prompt" class="text-center">
<p>Please grant camera permission to proceed.</p>
<button type="button" id="grant-camera-permission-btn" class="btn btn-primary">Grant Permission</button>
</div>
<div id="camera-device-selection" style="display: none;">
<label for="scene-camera-device-input" class="form-label">Camera Device</label>
<select class="form-select" id="scene-camera-device-input"></select>
</div>
</div> </div>
<!-- Other source type options will go here -->
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

65
upload.php Normal file
View File

@ -0,0 +1,65 @@
<?php
header('Content-Type: application/json');
$response = ['success' => false, 'error' => 'An unknown error occurred.'];
if (isset($_FILES['file'])) {
$file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
$response['error'] = 'File upload error: ' . $file['error'];
} else {
$fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$fileMime = mime_content_type($file['tmp_name']);
$targetDir = '';
$allowedTypes = [];
$maxSize = 0;
$prefix = '';
// Determine settings based on file type
if (strpos($fileMime, 'image/') === 0) {
$targetDir = 'assets/uploads/images/';
$allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
$maxSize = 5 * 1024 * 1024; // 5 MB
$prefix = 'img_';
} elseif (strpos($fileMime, 'video/') === 0) {
$targetDir = 'assets/uploads/videos/';
$allowedTypes = ['mp4', 'webm', 'mov', 'ogv'];
$maxSize = 50 * 1024 * 1024; // 50 MB
$prefix = 'vid_';
} else {
$response['error'] = 'Unsupported file type: ' . $fileMime;
}
if ($targetDir) {
if (!is_dir($targetDir)) {
mkdir($targetDir, 0775, true);
}
if (!in_array($fileExt, $allowedTypes)) {
$response['error'] = 'Invalid file extension. Allowed: ' . implode(', ', $allowedTypes);
} elseif ($file['size'] > $maxSize) {
$response['error'] = 'File is too large. Maximum size is ' . ($maxSize / 1024 / 1024) . ' MB.';
} else {
$fileName = preg_replace("/[^a-zA-Z0-9-_\.]/", "", basename($file['name']));
$uniqueName = $prefix . uniqid('', true) . '.' . $fileExt;
$targetPath = $targetDir . $uniqueName;
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
$response = [
'success' => true,
'filePath' => $targetPath
];
} else {
$response['error'] = 'Failed to move uploaded file.';
}
}
}
}
} else {
$response['error'] = 'No file uploaded.';
}
echo json_encode($response);
?>