dragonflight/services/web-ui/public/recorders.html

969 lines
35 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wild Dragon - Recorders</title>
<link rel="stylesheet" href="/css/common.css">
<style>
.recorders-container {
display: flex;
flex-direction: column;
height: calc(100vh - 110px);
overflow: hidden;
}
.recorders-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.recorders-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
}
.recorders-header h1 {
margin: 0;
}
.recorders-controls {
display: flex;
gap: var(--spacing-md);
}
.recorders-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: var(--spacing-lg);
}
.recorder-card {
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: all var(--transition-normal);
display: flex;
flex-direction: column;
}
.recorder-card:hover {
border-color: var(--color-accent-primary);
box-shadow: 0 8px 24px rgba(233, 69, 96, 0.15);
transform: translateY(-2px);
}
.recorder-thumbnail {
position: relative;
width: 100%;
height: 200px;
background-color: var(--color-bg-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
opacity: 0.3;
overflow: hidden;
}
.recorder-thumbnail.recording {
background: linear-gradient(45deg, rgba(239, 68, 68, 0.2), rgba(233, 69, 96, 0.2));
}
.recorder-status-badge {
position: absolute;
top: var(--spacing-md);
left: var(--spacing-md);
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: 6px 12px;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
color: white;
}
.recorder-status-badge.recording {
background-color: rgba(239, 68, 68, 0.9);
animation: pulse 1s infinite;
}
.recorder-status-badge.stopped {
background-color: rgba(75, 85, 99, 0.9);
}
.recorder-status-badge.error {
background-color: rgba(239, 68, 68, 0.9);
}
.recording-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: currentColor;
animation: pulse 1s infinite;
}
.recorder-content {
padding: var(--spacing-md);
flex: 1;
display: flex;
flex-direction: column;
}
.recorder-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
}
.recorder-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
word-break: break-word;
}
.recorder-type-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 4px 10px;
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-text-secondary);
white-space: nowrap;
}
.recorder-type-badge.sdi {
border-color: var(--color-info);
color: var(--color-info);
}
.recorder-type-badge.srt {
border-color: var(--color-warning);
color: var(--color-warning);
}
.recorder-type-badge.rtmp {
border-color: var(--color-success);
color: var(--color-success);
}
.recorder-meta {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.recorder-meta-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.recorder-meta-label {
color: var(--color-text-tertiary);
font-size: 0.85rem;
}
.recorder-meta-value {
color: var(--color-text-primary);
font-weight: 500;
font-family: 'Courier New', monospace;
}
.recorder-duration {
font-size: 0.9rem;
color: var(--color-text-secondary);
font-family: 'Courier New', monospace;
}
.recorder-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: auto;
}
.recorder-actions button {
flex: 1;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
text-align: center;
color: var(--color-text-tertiary);
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: var(--spacing-md);
opacity: 0.3;
}
.empty-state-text {
color: var(--color-text-tertiary);
margin-bottom: var(--spacing-md);
}
/* Modal Styles */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
z-index: 1000;
}
.modal-overlay.active {
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
}
.modal-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text-primary);
}
.modal-close {
background: none;
border: none;
color: var(--color-text-secondary);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
color: var(--color-text-primary);
}
.modal-body {
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
padding: var(--spacing-lg);
border-top: 1px solid var(--color-border);
background-color: var(--color-bg-tertiary);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.form-label {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.form-input,
.form-select {
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-family: var(--font-family);
font-size: 0.95rem;
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: var(--color-accent-primary);
background-color: var(--color-bg-secondary);
}
.form-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%23b0b0b0' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right var(--spacing-sm) center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
}
.form-toggle {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.toggle-switch {
position: relative;
width: 50px;
height: 28px;
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 20px;
cursor: pointer;
transition: all var(--transition-fast);
}
.toggle-switch.on {
background-color: var(--color-accent-primary);
border-color: var(--color-accent-primary);
}
.toggle-switch::after {
content: '';
position: absolute;
width: 22px;
height: 22px;
background-color: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: left var(--transition-fast);
}
.toggle-switch.on::after {
left: 26px;
}
.conditional-fields {
display: none;
padding: var(--spacing-md);
background-color: var(--color-bg-tertiary);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
gap: var(--spacing-md);
flex-direction: column;
}
.conditional-fields.visible {
display: flex;
}
@media (max-width: 768px) {
.recorders-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="header">
<div class="header-logo">
<div class="header-logo-icon">D</div>
<span>WILD DRAGON</span>
</div>
<nav class="header-nav">
<div class="nav-item" data-page="assets">Assets</div>
<div class="nav-item" data-page="capture">Capture</div>
<div class="nav-item" data-page="upload">Upload</div>
<div class="nav-item active" data-page="recorders">Recorders</div>
</nav>
</header>
<!-- Main Content -->
<div class="main-content">
<div class="content-area">
<div class="recorders-container">
<div class="recorders-content">
<!-- Header -->
<div class="recorders-header">
<h1>Recorder Management</h1>
<div class="recorders-controls">
<button class="btn btn-primary" onclick="openCreateModal()">+ New Recorder</button>
<button class="btn btn-secondary" id="backBtn" onclick="navigateTo('assets')">← Back</button>
</div>
</div>
<!-- Recorders Grid -->
<div class="recorders-grid" id="recordersGrid">
<div class="empty-state">
<div class="empty-state-icon">🎬</div>
<div class="empty-state-text">No recorders configured</div>
<p style="color: var(--color-text-tertiary); font-size: 0.9rem;">Click "New Recorder" to create your first recorder instance</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Status Bar -->
<footer class="status-bar">
<div class="status-item">
<span class="status-indicator" id="statusIndicator"></span>
<span id="statusText">Loading recorders...</span>
</div>
<div class="status-item" id="recorderCountItem" style="display: none;">
<span id="recorderCount">0</span> recorder(s)
</div>
</footer>
</div>
<!-- Create Recorder Modal -->
<div class="modal-overlay" id="createModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">New Recorder</div>
<button class="modal-close" onclick="closeCreateModal()"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Recorder Name</label>
<input type="text" id="recorderName" class="form-input" placeholder="e.g., Studio A SDI">
</div>
<div class="form-group">
<label class="form-label">Source Type</label>
<select id="sourceType" class="form-select" onchange="updateSourceFields()">
<option value="">Select source type...</option>
<option value="sdi">SDI (DeckLink)</option>
<option value="srt">SRT (Streaming)</option>
<option value="rtmp">RTMP (Stream)</option>
</select>
</div>
<!-- Source Config Fields -->
<div id="sourceConfigFields" class="conditional-fields"></div>
<div class="form-group">
<label class="form-label">Recording Codec</label>
<select id="recordingCodec" class="form-select">
<option value="prores_hq">ProRes HQ</option>
<option value="prores_422">ProRes 422</option>
<option value="dnxhd_185">DNxHD 185</option>
<option value="dnxhr_hq">DNxHR HQ</option>
<option value="h264">H.264</option>
<option value="h265">H.265</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Resolution</label>
<select id="resolution" class="form-select">
<option value="native">Native</option>
<option value="4k">3840x2160 (4K)</option>
<option value="1080p">1920x1080 (Full HD)</option>
<option value="720p">1280x720 (HD)</option>
</select>
</div>
<div class="form-toggle">
<div>
<div class="form-label">Proxy Generation</div>
<div style="font-size: 0.8rem; color: var(--color-text-tertiary);">Generate lower-res proxy for faster editing</div>
</div>
<div class="toggle-switch" id="proxyToggle" onclick="toggleProxy()"></div>
</div>
<div id="proxyFields" class="conditional-fields">
<div class="form-group">
<label class="form-label">Proxy Codec</label>
<select id="proxyCodec" class="form-select">
<option value="h264">H.264</option>
<option value="h265">H.265</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Proxy Resolution</label>
<select id="proxyResolution" class="form-select">
<option value="1080p">1920x1080 (Full HD)</option>
<option value="720p">1280x720 (HD)</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Project</label>
<select id="projectSelect" class="form-select"></select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeCreateModal()">Cancel</button>
<button class="btn btn-primary" onclick="createRecorder()">Create</button>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script>
// ============================================================
// STATE MANAGEMENT
// ============================================================
let recorderState = {
recorders: [],
projects: [],
selectedProject: null,
proxyEnabled: false,
statusPollInterval: null,
};
// ============================================================
// INITIALIZATION
// ============================================================
document.addEventListener('DOMContentLoaded', async () => {
setupEventListeners();
await loadRecorders();
await loadProjects();
startStatusPolling();
updateStatusBar();
});
function setupEventListeners() {
document.querySelectorAll('[data-page]').forEach(el => {
el.addEventListener('click', (e) => navigateTo(e.target.dataset.page));
});
document.getElementById('createModal').addEventListener('click', (e) => {
if (e.target.id === 'createModal') closeCreateModal();
});
}
// ============================================================
// LOAD DATA
// ============================================================
async function loadRecorders() {
try {
const result = await getRecorders();
if (result.success) {
recorderState.recorders = result.data || [];
renderRecorders();
}
} catch (error) {
console.error('Failed to load recorders:', error);
}
}
async function loadProjects() {
try {
const result = await getProjects();
if (result.success) {
recorderState.projects = result.data || [];
renderProjectSelect();
}
} catch (error) {
console.error('Failed to load projects:', error);
}
}
function renderProjectSelect() {
const select = document.getElementById('projectSelect');
select.innerHTML = '';
recorderState.projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.name;
select.appendChild(option);
});
if (recorderState.projects.length > 0) {
recorderState.selectedProject = recorderState.projects[0].id;
select.value = recorderState.selectedProject;
}
}
// ============================================================
// RENDERING
// ============================================================
function renderRecorders() {
const grid = document.getElementById('recordersGrid');
if (recorderState.recorders.length === 0) {
grid.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🎬</div>
<div class="empty-state-text">No recorders configured</div>
<p style="color: var(--color-text-tertiary); font-size: 0.9rem;">Click "New Recorder" to create your first recorder instance</p>
</div>
`;
return;
}
grid.innerHTML = '';
recorderState.recorders.forEach(recorder => {
const card = createRecorderCard(recorder);
grid.appendChild(card);
});
updateStatusBar();
}
function createRecorderCard(recorder) {
const isRecording = recorder.status === 'recording';
const card = document.createElement('div');
card.className = 'recorder-card';
const sourceTypeLower = (recorder.source_type || '').toLowerCase();
const sourceTypeClass = ['sdi', 'srt', 'rtmp'].includes(sourceTypeLower) ? sourceTypeLower : 'sdi';
card.innerHTML = `
<div class="recorder-thumbnail ${isRecording ? 'recording' : ''}">
🎥
<div class="recorder-status-badge ${recorder.status || 'stopped'}">
${isRecording ? '<span class="recording-indicator"></span>' : ''}
${recorder.status === 'recording' ? 'Recording' :
recorder.status === 'stopped' ? 'Stopped' : 'Error'}
</div>
</div>
<div class="recorder-content">
<div class="recorder-header">
<div class="recorder-title">${recorder.name || 'Untitled Recorder'}</div>
<div class="recorder-type-badge ${sourceTypeClass}">${recorder.source_type || 'Unknown'}</div>
</div>
<div class="recorder-meta">
<div class="recorder-meta-item">
<span class="recorder-meta-label">Source</span>
<span class="recorder-meta-value">${recorder.source_config?.url || recorder.source_config?.device || '—'}</span>
</div>
<div class="recorder-meta-item">
<span class="recorder-meta-label">Codec</span>
<span class="recorder-meta-value">${recorder.recording_codec || '—'}</span>
</div>
<div class="recorder-meta-item">
<span class="recorder-meta-label">Resolution</span>
<span class="recorder-meta-value">${recorder.resolution || 'Native'}</span>
</div>
${isRecording ? `
<div class="recorder-meta-item">
<span class="recorder-meta-label">Duration</span>
<span class="recorder-duration" id="duration-${recorder.id}">00:00:00</span>
</div>
` : ''}
</div>
<div class="recorder-actions">
${isRecording ? `
<button class="btn btn-sm btn-secondary" onclick="stopRecorder('${recorder.id}')">⏹ Stop</button>
` : `
<button class="btn btn-sm btn-primary" onclick="startRecorder('${recorder.id}')">⏺ Start</button>
`}
<button class="btn btn-sm btn-secondary" onclick="deleteRecorder('${recorder.id}')">🗑 Delete</button>
</div>
</div>
`;
return card;
}
// ============================================================
// MODAL OPERATIONS
// ============================================================
function openCreateModal() {
document.getElementById('createModal').classList.add('active');
document.getElementById('recorderName').value = '';
document.getElementById('sourceType').value = '';
document.getElementById('recordingCodec').value = 'prores_hq';
document.getElementById('resolution').value = 'native';
document.getElementById('proxyToggle').classList.remove('on');
recorderState.proxyEnabled = false;
updateSourceFields();
}
function closeCreateModal() {
document.getElementById('createModal').classList.remove('active');
}
function updateSourceFields() {
const sourceType = document.getElementById('sourceType').value;
const fieldsContainer = document.getElementById('sourceConfigFields');
fieldsContainer.innerHTML = '';
if (sourceType === 'sdi') {
fieldsContainer.innerHTML = `
<div class="form-group">
<label class="form-label">Device</label>
<select id="sdiDevice" class="form-select">
<option value="">Select DeckLink device...</option>
<option value="decklink_0">DeckLink Card 1</option>
<option value="decklink_1">DeckLink Card 2</option>
</select>
</div>
`;
fieldsContainer.classList.add('visible');
} else if (sourceType === 'srt') {
fieldsContainer.innerHTML = `
<div class="form-group">
<label class="form-label">SRT URL</label>
<input type="text" id="srtUrl" class="form-input" placeholder="srt://host:port">
</div>
<div class="form-group">
<label class="form-label">Mode</label>
<select id="srtMode" class="form-select">
<option value="listener">Listener</option>
<option value="caller">Caller</option>
</select>
</div>
`;
fieldsContainer.classList.add('visible');
} else if (sourceType === 'rtmp') {
fieldsContainer.innerHTML = `
<div class="form-group">
<label class="form-label">RTMP URL</label>
<input type="text" id="rtmpUrl" class="form-input" placeholder="rtmp://host:port/app/stream">
</div>
`;
fieldsContainer.classList.add('visible');
} else {
fieldsContainer.classList.remove('visible');
}
}
function toggleProxy() {
const toggle = document.getElementById('proxyToggle');
const fields = document.getElementById('proxyFields');
recorderState.proxyEnabled = !recorderState.proxyEnabled;
toggle.classList.toggle('on');
if (recorderState.proxyEnabled) {
fields.classList.add('visible');
} else {
fields.classList.remove('visible');
}
}
// ============================================================
// RECORDER OPERATIONS
// ============================================================
async function createRecorder() {
const name = document.getElementById('recorderName').value.trim();
const sourceType = document.getElementById('sourceType').value;
if (!name || !sourceType) {
alert('Please fill in all required fields');
return;
}
let sourceConfig = {};
if (sourceType === 'sdi') {
sourceConfig.device = document.getElementById('sdiDevice').value;
} else if (sourceType === 'srt') {
sourceConfig.url = document.getElementById('srtUrl').value;
sourceConfig.mode = document.getElementById('srtMode').value;
} else if (sourceType === 'rtmp') {
sourceConfig.url = document.getElementById('rtmpUrl').value;
}
const data = {
name,
source_type: sourceType,
source_config: sourceConfig,
recording_codec: document.getElementById('recordingCodec').value,
resolution: document.getElementById('resolution').value,
project_id: document.getElementById('projectSelect').value,
};
if (recorderState.proxyEnabled) {
data.proxy_enabled = true;
data.proxy_codec = document.getElementById('proxyCodec').value;
data.proxy_resolution = document.getElementById('proxyResolution').value;
}
try {
const result = await createRecorder(data);
if (result.success) {
closeCreateModal();
await loadRecorders();
updateStatusBar('Recorder created');
} else {
alert('Failed to create recorder');
}
} catch (error) {
console.error('Failed to create recorder:', error);
alert('Error creating recorder');
}
}
async function startRecorder(id) {
try {
const result = await startRecorder(id);
if (result.success) {
const recorder = recorderState.recorders.find(r => r.id === id);
if (recorder) {
recorder.status = 'recording';
recorder.start_time = Date.now();
renderRecorders();
updateStatusBar('Recorder started');
}
}
} catch (error) {
console.error('Failed to start recorder:', error);
}
}
async function stopRecorder(id) {
try {
const result = await stopRecorder(id);
if (result.success) {
const recorder = recorderState.recorders.find(r => r.id === id);
if (recorder) {
recorder.status = 'stopped';
renderRecorders();
updateStatusBar('Recorder stopped');
}
}
} catch (error) {
console.error('Failed to stop recorder:', error);
}
}
async function deleteRecorder(id) {
if (!confirm('Are you sure you want to delete this recorder?')) return;
try {
const result = await deleteRecorder(id);
if (result.success) {
recorderState.recorders = recorderState.recorders.filter(r => r.id !== id);
renderRecorders();
updateStatusBar('Recorder deleted');
}
} catch (error) {
console.error('Failed to delete recorder:', error);
}
}
// ============================================================
// STATUS POLLING
// ============================================================
function startStatusPolling() {
if (recorderState.statusPollInterval) {
clearInterval(recorderState.statusPollInterval);
}
recorderState.statusPollInterval = setInterval(async () => {
for (const recorder of recorderState.recorders) {
if (recorder.status === 'recording') {
const result = await getRecorderStatus(recorder.id);
if (result.success) {
recorder.status = result.data.status;
if (result.data.duration) {
const durationEl = document.getElementById(`duration-${recorder.id}`);
if (durationEl) {
durationEl.textContent = formatDuration(result.data.duration);
}
}
}
}
}
}, 2000);
}
// ============================================================
// NAVIGATION
// ============================================================
function navigateTo(page) {
if (page === 'assets') {
window.location.href = '/index.html';
} else if (page === 'capture') {
window.location.href = '/capture.html';
} else if (page === 'upload') {
window.location.href = '/upload.html';
}
}
// ============================================================
// STATUS BAR
// ============================================================
function updateStatusBar() {
const indicator = document.getElementById('statusIndicator');
const text = document.getElementById('statusText');
const count = document.getElementById('recorderCountItem');
indicator.className = 'status-indicator';
text.textContent = 'Connected';
const recordingCount = recorderState.recorders.filter(r => r.status === 'recording').length;
if (recordingCount > 0) {
text.textContent = `${recordingCount} recorder(s) recording`;
}
if (recorderState.recorders.length > 0) {
count.style.display = 'flex';
document.getElementById('recorderCount').textContent = recorderState.recorders.length;
} else {
count.style.display = 'none';
}
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (recorderState.statusPollInterval) {
clearInterval(recorderState.statusPollInterval);
}
});
</script>
</body>
</html>