add services/web-ui/public/capture.html
This commit is contained in:
parent
b716a6e6d7
commit
28be46403a
1 changed files with 638 additions and 0 deletions
638
services/web-ui/public/capture.html
Normal file
638
services/web-ui/public/capture.html
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
<!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="startRecording()">
|
||||
⏺ START
|
||||
</button>
|
||||
<button class="btn btn-record btn-stop" id="stopBtn" onclick="stopRecording()" 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,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 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 || 'Untitled'}</div>
|
||||
<div class="capture-item-meta">
|
||||
<span>${formatDuration(capture.duration || 0)}</span>
|
||||
<span>${new Date(capture.captured_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RECORDING CONTROL
|
||||
// ============================================================
|
||||
|
||||
async function startRecording() {
|
||||
if (!captureState.currentProject || !captureState.currentBin || !captureState.currentDevice) {
|
||||
alert('Please select project, bin, and device');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!captureState.currentClipName) {
|
||||
alert('Please enter a clip name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
updateStatusBar('Starting recording...');
|
||||
const result = await startRecording(
|
||||
captureState.currentDevice,
|
||||
captureState.currentProject,
|
||||
captureState.currentBin,
|
||||
captureState.currentClipName
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
captureState.isRecording = true;
|
||||
captureState.recordingTime = 0;
|
||||
startTimecodeUpdate();
|
||||
updateRecordingUI();
|
||||
updateStatusBar();
|
||||
} else {
|
||||
updateStatusBar('Failed to start recording', true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Recording error:', error);
|
||||
updateStatusBar('Error starting recording', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
try {
|
||||
updateStatusBar('Stopping recording...');
|
||||
const result = await stopRecording();
|
||||
|
||||
if (result.success) {
|
||||
captureState.isRecording = false;
|
||||
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>
|
||||
Loading…
Reference in a new issue