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

644 lines
24 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wild Dragon - Capture Control</title>
<link rel="stylesheet" href="/css/common.css">
<style>
.capture-container {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
gap: var(--spacing-lg);
height: calc(100vh - 110px);
padding: var(--spacing-lg);
overflow: hidden;
}
.capture-panel {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.capture-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-lg);
}
.recording-display {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-lg);
padding: var(--spacing-xl);
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
min-height: 250px;
justify-content: center;
}
.recording-status {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-md);
}
.recording-indicator-large {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
transition: all var(--transition-normal);
}
.recording-indicator-large.idle {
background-color: rgba(16, 185, 129, 0.2);
color: var(--color-status-ready);
}
.recording-indicator-large.recording {
background-color: rgba(239, 68, 68, 0.2);
color: var(--color-status-error);
animation: pulse 1.5s infinite;
}
.recording-info {
text-align: center;
}
.recording-info-item {
margin-bottom: var(--spacing-sm);
}
.recording-info-label {
font-size: 0.8rem;
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.recording-info-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text-primary);
font-family: 'Courier New', monospace;
}
.control-buttons {
display: flex;
gap: var(--spacing-lg);
width: 100%;
justify-content: center;
}
.btn-record {
width: 120px;
height: 120px;
border-radius: 50%;
font-size: 1.2rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-normal);
}
.btn-record:hover {
transform: scale(1.05);
}
.btn-record:active:not(:disabled) {
transform: scale(0.95);
}
.btn-start {
background-color: var(--color-accent-primary);
color: white;
}
.btn-start:hover:not(:disabled) {
background-color: var(--color-accent-light);
box-shadow: 0 8px 24px rgba(233, 69, 96, 0.4);
}
.btn-stop {
background-color: var(--color-status-error);
color: white;
}
.btn-stop:hover:not(:disabled) {
background-color: #ef5555;
}
.recent-captures-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.capture-item {
padding: var(--spacing-md);
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.capture-item:hover {
border-color: var(--color-accent-primary);
background-color: var(--color-bg-hover);
}
.capture-item-name {
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--spacing-xs);
font-size: 0.95rem;
}
.capture-item-meta {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--color-text-tertiary);
}
@media (max-width: 1024px) {
.capture-container {
grid-template-columns: 1fr;
height: auto;
}
.recording-display {
min-height: 200px;
}
.btn-record {
width: 100px;
height: 100px;
}
}
.control-section {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
padding: var(--spacing-lg);
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
</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 active" data-page="capture">Capture</div>
<div class="nav-item" data-page="projects">Projects</div>
</nav>
</header>
<!-- Main Content -->
<div class="main-content">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-section">
<div class="sidebar-section-title">Capture Settings</div>
<div class="control-section">
<div class="form-group">
<label class="form-label">Project</label>
<select id="projectSelect" class="form-select"></select>
</div>
<div class="form-group">
<label class="form-label">Bin</label>
<select id="binSelect" class="form-select"></select>
</div>
<div class="form-group">
<label class="form-label">Clip Name</label>
<input type="text" id="clipNameInput" class="form-input" placeholder="e.g., Interview_John_01">
</div>
<div class="form-group">
<label class="form-label">Device</label>
<select id="deviceSelect" class="form-select"></select>
</div>
</div>
</div>
</aside>
<!-- Content Area -->
<div class="content-area">
<div class="content-main">
<div class="capture-container">
<!-- Left Panel -->
<div class="capture-panel">
<div class="control-section">
<div style="text-align: center;">
<h3 style="margin-bottom: var(--spacing-md);">Device Status</h3>
<div id="deviceStatus" style="color: var(--color-text-tertiary); font-size: 0.9rem;">
Loading devices...
</div>
</div>
</div>
</div>
<!-- Center Panel -->
<div class="capture-center">
<div class="recording-display">
<div class="recording-status">
<div class="recording-indicator-large idle" id="recordingIndicator"></div>
<div class="recording-info">
<div class="recording-info-item">
<div class="recording-info-label">Timecode</div>
<div class="recording-info-value" id="timecodeDisplay">00:00:00</div>
</div>
<div class="recording-info-item">
<div class="recording-info-label">Status</div>
<div class="recording-info-value" id="statusDisplay" style="color: var(--color-status-ready);">IDLE</div>
</div>
<div class="recording-info-item" id="clipNameDisplay" style="display: none;">
<div class="recording-info-label">Clip</div>
<div class="recording-info-value" id="clipNameText"></div>
</div>
</div>
</div>
</div>
<div class="control-buttons">
<button class="btn btn-record btn-start" id="startBtn" onclick="handleStartRecording()">
⏺ START
</button>
<button class="btn btn-record btn-stop" id="stopBtn" onclick="handleStopRecording()" disabled>
⏹ STOP
</button>
</div>
</div>
<!-- Right Panel -->
<div class="capture-panel">
<h3 style="margin-bottom: var(--spacing-md);">Recent Captures</h3>
<div class="recent-captures-list" id="recentCapturesList">
<div style="text-align: center; color: var(--color-text-tertiary); padding: var(--spacing-lg);">
No captures yet
</div>
</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">Initializing...</span>
</div>
<div class="status-item">
<span id="captureMode">Ready to capture</span>
</div>
</footer>
</div>
<script src="/js/api.js"></script>
<script>
// ============================================================
// STATE MANAGEMENT
// ============================================================
let captureState = {
isRecording: false,
currentDevice: null,
currentProject: null,
currentBin: null,
currentClipName: '',
projects: [],
bins: [],
devices: [],
recentCaptures: [],
recordingTime: 0,
recordingInterval: null,
sessionId: null,
};
// ============================================================
// INITIALIZATION
// ============================================================
document.addEventListener('DOMContentLoaded', async () => {
setupEventListeners();
await loadCaptureData();
updateStatusBar();
});
function setupEventListeners() {
// Navigation
document.querySelectorAll('[data-page]').forEach(el => {
el.addEventListener('click', (e) => navigateTo(e.target.dataset.page));
});
// Project selection
document.getElementById('projectSelect').addEventListener('change', (e) => {
captureState.currentProject = e.target.value;
loadBins();
});
// Bin selection
document.getElementById('binSelect').addEventListener('change', (e) => {
captureState.currentBin = e.target.value;
});
// Device selection
document.getElementById('deviceSelect').addEventListener('change', (e) => {
captureState.currentDevice = e.target.value;
});
// Clip name input
document.getElementById('clipNameInput').addEventListener('input', (e) => {
captureState.currentClipName = e.target.value;
});
}
async function loadCaptureData() {
try {
updateStatusBar('Loading capture devices...');
const [projectsRes, devicesRes, capturesRes] = await Promise.all([
getProjects(),
getCaptureDevices(),
getRecentCaptures(10),
]);
if (projectsRes.success) {
captureState.projects = projectsRes.data;
renderProjects();
if (captureState.projects.length > 0) {
captureState.currentProject = captureState.projects[0].id;
document.getElementById('projectSelect').value = captureState.currentProject;
await loadBins();
}
}
if (devicesRes.success) {
captureState.devices = devicesRes.data;
renderDevices();
}
if (capturesRes.success) {
captureState.recentCaptures = capturesRes.data;
renderRecentCaptures();
}
// Fetch current recording status
const statusRes = await getRecordingStatus();
if (statusRes.success && statusRes.data.recording) {
captureState.isRecording = true;
startTimecodeUpdate();
updateRecordingUI();
}
updateStatusBar();
} catch (error) {
console.error('Failed to load capture data:', error);
updateStatusBar('Error loading capture devices', true);
}
}
// ============================================================
// RENDERING
// ============================================================
function renderProjects() {
const select = document.getElementById('projectSelect');
select.innerHTML = '';
captureState.projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.name;
select.appendChild(option);
});
}
function renderDevices() {
const select = document.getElementById('deviceSelect');
const statusDiv = document.getElementById('deviceStatus');
select.innerHTML = '';
if (captureState.devices.length === 0) {
statusDiv.innerHTML = '<span style="color: var(--color-status-error);">No devices found</span>';
return;
}
statusDiv.innerHTML = `<span style="color: var(--color-status-ready);">${captureState.devices.length} device(s) available</span>`;
captureState.devices.forEach(device => {
const option = document.createElement('option');
option.value = device.id;
option.textContent = `${device.name} (${device.interface})`;
select.appendChild(option);
});
if (captureState.devices.length > 0) {
captureState.currentDevice = captureState.devices[0].id;
document.getElementById('deviceSelect').value = captureState.currentDevice;
}
}
async function loadBins() {
if (!captureState.currentProject) return;
const result = await getBins(captureState.currentProject);
if (result.success) {
captureState.bins = result.data;
renderBins();
}
}
function renderBins() {
const select = document.getElementById('binSelect');
select.innerHTML = '';
captureState.bins.forEach(bin => {
const option = document.createElement('option');
option.value = bin.id;
option.textContent = bin.name;
select.appendChild(option);
});
if (captureState.bins.length > 0) {
captureState.currentBin = captureState.bins[0].id;
document.getElementById('binSelect').value = captureState.currentBin;
}
}
function renderRecentCaptures() {
const container = document.getElementById('recentCapturesList');
if (captureState.recentCaptures.length === 0) {
container.innerHTML = '<div style="text-align: center; color: var(--color-text-tertiary); padding: var(--spacing-lg);">No captures yet</div>';
return;
}
container.innerHTML = '';
captureState.recentCaptures.slice(0, 10).forEach(capture => {
const item = document.createElement('div');
item.className = 'capture-item';
item.innerHTML = `
<div class="capture-item-name">${capture.clip_name || capture.name || 'Untitled'}</div>
<div class="capture-item-meta">
<span>${formatDuration(capture.duration || 0)}</span>
<span>${new Date(capture.created_at || capture.captured_at).toLocaleDateString()}</span>
</div>
`;
container.appendChild(item);
});
}
// ============================================================
// RECORDING CONTROL
// Named handleStartRecording / handleStopRecording to avoid
// shadowing the api.js startRecording / stopRecording functions.
// ============================================================
async function handleStartRecording() {
if (!captureState.currentProject || !captureState.currentDevice) {
alert('Please select project and device');
return;
}
if (!captureState.currentClipName) {
alert('Please enter a clip name');
return;
}
try {
updateStatusBar('Starting recording...');
const result = await startRecording(
parseInt(captureState.currentDevice, 10),
captureState.currentProject,
captureState.currentBin || null,
captureState.currentClipName
);
if (result.success) {
captureState.isRecording = true;
captureState.recordingTime = 0;
captureState.sessionId = result.data.session_id || null;
startTimecodeUpdate();
updateRecordingUI();
updateStatusBar();
} else {
updateStatusBar('Failed to start recording', true);
}
} catch (error) {
console.error('Recording error:', error);
updateStatusBar('Error starting recording', true);
}
}
async function handleStopRecording() {
try {
updateStatusBar('Stopping recording...');
const result = await stopRecording(captureState.sessionId);
if (result.success) {
captureState.isRecording = false;
captureState.sessionId = null;
clearInterval(captureState.recordingInterval);
updateRecordingUI();
loadCaptureData();
updateStatusBar('Recording saved');
} else {
updateStatusBar('Failed to stop recording', true);
}
} catch (error) {
console.error('Stop error:', error);
updateStatusBar('Error stopping recording', true);
}
}
function startTimecodeUpdate() {
if (captureState.recordingInterval) {
clearInterval(captureState.recordingInterval);
}
captureState.recordingInterval = setInterval(() => {
captureState.recordingTime++;
document.getElementById('timecodeDisplay').textContent = formatDuration(captureState.recordingTime);
}, 1000);
}
function updateRecordingUI() {
const indicator = document.getElementById('recordingIndicator');
const status = document.getElementById('statusDisplay');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const clipDisplay = document.getElementById('clipNameDisplay');
const clipText = document.getElementById('clipNameText');
if (captureState.isRecording) {
indicator.className = 'recording-indicator-large recording';
status.textContent = 'RECORDING';
status.style.color = 'var(--color-status-error)';
startBtn.disabled = true;
stopBtn.disabled = false;
clipDisplay.style.display = 'block';
clipText.textContent = captureState.currentClipName;
} else {
indicator.className = 'recording-indicator-large idle';
status.textContent = 'IDLE';
status.style.color = 'var(--color-status-ready)';
startBtn.disabled = false;
stopBtn.disabled = true;
clipDisplay.style.display = 'none';
}
}
// ============================================================
// NAVIGATION
// ============================================================
function navigateTo(page) {
if (page === 'assets') {
window.location.href = '/index.html';
} else if (page === 'projects') {
alert('Projects page coming soon');
}
}
// ============================================================
// STATUS BAR
// ============================================================
function updateStatusBar(message = '', isError = false) {
const indicator = document.getElementById('statusIndicator');
const text = document.getElementById('statusText');
if (message) {
text.textContent = message;
indicator.className = isError ? 'status-indicator disconnected' : 'status-indicator';
} else {
indicator.className = 'status-indicator';
text.textContent = 'Connected';
}
}
</script>
</body>
</html>