- src/streaming/ module: camera detection (USB webcam + Pi Camera Module via v4l2/libcamera), GStreamer pipeline builder with V4L2/OMX/x264 h264 encoders, platform presets (YouTube, Twitch, Facebook, custom RTMP), streamer lifecycle manager with auto-reconnect, scene management, stream statistics - Keyboard shortcut controller (evdev + pynput fallback) for stream control: Ctrl+Shift+S (start/stop), Ctrl+Shift+1-9 (scene switch), Ctrl+Shift+R (reconnect) - REST API: /api/v1/stream/* endpoints for start/stop/status, scenes, cameras, platforms, and hotkey listing — integrated into NetworkServer - StreamStateProvider bridging layer connects REST routes to Streamer instance - GStreamer pipeline supports ALSA/JACK/PulseAudio audio capture and V4L2/libcamera video capture with H.264 hardware encoding on RPi4B - Low-latency streaming settings for live performance (zerolatency, no B-frames) - 89 streaming tests (709 total project tests pass)
This commit is contained in:
+481
-31
@@ -719,65 +719,515 @@ const App = {
|
||||
},
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
// Sessions View
|
||||
// Sessions View (full session management)
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
_sessionsData: null,
|
||||
_setlistsData: null,
|
||||
_snapshotsData: null,
|
||||
_currentSetlist: null,
|
||||
_sessionsSubtab: 'sessions',
|
||||
|
||||
buildSessionsView(scenes) {
|
||||
// Kick off initial loads — scenes from WS are fallback
|
||||
this.refreshSessions();
|
||||
this.refreshAutosaveStatus();
|
||||
},
|
||||
|
||||
refreshSessions() {
|
||||
const filter = document.getElementById('session-filter')?.value || 'all';
|
||||
// Fetch sessions from REST API
|
||||
fetch('/api/v1/sessions')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this._sessionsData = data.sessions || [];
|
||||
if (filter === 'all' || filter === 'sessions') {
|
||||
this._renderSessions();
|
||||
}
|
||||
})
|
||||
.catch(e => this.log('Failed to list sessions: ' + e.message, 'log-error'));
|
||||
|
||||
// Also fetch snapshots
|
||||
fetch('/api/v1/snapshots')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this._snapshotsData = data.snapshots || [];
|
||||
if (filter === 'all' || filter === 'snapshots') {
|
||||
this._renderSessions();
|
||||
}
|
||||
})
|
||||
.catch(e => this.log('Failed to list snapshots: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
_renderSessions() {
|
||||
const container = document.getElementById('sessions-list');
|
||||
if (!scenes || scenes.length === 0) {
|
||||
const filter = document.getElementById('session-filter')?.value || 'all';
|
||||
const items = [];
|
||||
|
||||
if (filter === 'all' || filter === 'sessions') {
|
||||
(this._sessionsData || []).forEach(s => {
|
||||
items.push({
|
||||
type: 'session',
|
||||
name: s.name,
|
||||
filename: s.filename,
|
||||
modified: s.modified,
|
||||
size: s.size_bytes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (filter === 'all' || filter === 'snapshots') {
|
||||
(this._snapshotsData || []).forEach(s => {
|
||||
items.push({
|
||||
type: 'snapshot',
|
||||
name: s.name,
|
||||
category: s.category || '',
|
||||
isCurrent: s.is_current,
|
||||
modified: s.created_at,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = '<div style="color:var(--text-faint);text-align:center;padding:2rem;grid-column:1/-1">No saved sessions. Save current state above.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
scenes.forEach(name => {
|
||||
items.forEach(item => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'session-card';
|
||||
card.innerHTML =
|
||||
`<div class="session-card-name">💾 ${this._escapeHTML(name)}</div>` +
|
||||
`<div class="session-card-actions">` +
|
||||
`<button onclick="App.loadScene('${this._escapeAttr(name)}')">📂 Load</button>` +
|
||||
`<button class="danger" onclick="App.deleteScene('${this._escapeAttr(name)}')">🗑️</button>` +
|
||||
`</div>`;
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
this.loadScene(name);
|
||||
});
|
||||
if (item.type === 'snapshot') {
|
||||
const catBadge = item.category ? ` <span class="badge">${this._escapeHTML(item.category)}</span>` : '';
|
||||
const curBadge = item.isCurrent ? ' <span class="badge active">active</span>' : '';
|
||||
card.innerHTML =
|
||||
`<div class="session-card-name">📸 ${this._escapeHTML(item.name)}${catBadge}${curBadge}</div>` +
|
||||
`<div class="session-card-actions">` +
|
||||
`<button onclick="App.recallSnapshot('${this._escapeAttr(item.name)}')">▶ Recall</button>` +
|
||||
`<button class="danger" onclick="App.deleteSnapshot('${this._escapeAttr(item.name)}')">🗑️</button>` +
|
||||
`</div>`;
|
||||
} else {
|
||||
const date = new Date(item.modified * 1000).toLocaleDateString();
|
||||
const size = item.size ? (item.size < 1024 ? item.size + 'B' : (item.size / 1024).toFixed(1) + 'KB') : '';
|
||||
card.innerHTML =
|
||||
`<div class="session-card-name">💾 ${this._escapeHTML(item.name)}</div>` +
|
||||
`<div class="session-card-info">${date} · ${size}</div>` +
|
||||
`<div class="session-card-actions">` +
|
||||
`<button onclick="App.loadSession('${this._escapeAttr(item.name)}')">📂 Load</button>` +
|
||||
`<button onclick="App.exportSession('${this._escapeAttr(item.name)}')">📤</button>` +
|
||||
`<button onclick="App.duplicateSession('${this._escapeAttr(item.name)}')">📋</button>` +
|
||||
`<button class="danger" onclick="App.deleteSession('${this._escapeAttr(item.name)}')">🗑️</button>` +
|
||||
`</div>`;
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
this.loadSession(item.name);
|
||||
});
|
||||
}
|
||||
container.appendChild(card);
|
||||
});
|
||||
},
|
||||
|
||||
saveScene() {
|
||||
const nameInput = document.getElementById('scene-name-input');
|
||||
saveSession() {
|
||||
const nameInput = document.getElementById('session-name-input');
|
||||
const notesInput = document.getElementById('session-notes-input');
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) {
|
||||
this.log('Enter a scene name', 'log-error');
|
||||
this.log('Enter a session name', 'log-error');
|
||||
return;
|
||||
}
|
||||
this.sendParam('snapshot_save', 1, -1, {name: name});
|
||||
this.log('Saving scene: ' + name, 'log-state');
|
||||
nameInput.value = '';
|
||||
const notes = notesInput.value.trim();
|
||||
this.log('Saving session: ' + name, 'log-state');
|
||||
fetch('/api/v1/sessions/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, overwrite: true }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
this.log('Session saved: ' + name + ' (' + data.data.channels + ' channels)', 'log-state');
|
||||
nameInput.value = '';
|
||||
notesInput.value = '';
|
||||
this.refreshSessions();
|
||||
} else {
|
||||
this.log('Save failed: ' + (data.error || data.detail), 'log-error');
|
||||
}
|
||||
})
|
||||
.catch(e => this.log('Save error: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
loadScene(name) {
|
||||
this.log('Loading scene: ' + name, 'log-state');
|
||||
this.sendParam('snapshot_load', 1, -1, {name: name});
|
||||
loadSession(name) {
|
||||
this.log('Loading session: ' + name, 'log-state');
|
||||
fetch('/api/v1/sessions/' + encodeURIComponent(name) + '/load', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
this.log('Session loaded: ' + name + ' — refresh to see changes', 'log-state');
|
||||
} else {
|
||||
this.log('Load failed: ' + (data.detail || 'unknown'), 'log-error');
|
||||
}
|
||||
})
|
||||
.catch(e => this.log('Load error: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
deleteScene(name) {
|
||||
deleteSession(name) {
|
||||
if (!confirm('Delete session "' + name + '"?')) return;
|
||||
// Delete via REST API
|
||||
fetch('/api/v1/scenes/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
.then(() => {
|
||||
this.log('Deleted scene: ' + name, 'log-state');
|
||||
// Remove from local state
|
||||
if (this.mixerState?.scenes) {
|
||||
this.mixerState.scenes = this.mixerState.scenes.filter(s => s !== name);
|
||||
this.buildSessionsView(this.mixerState.scenes);
|
||||
fetch('/api/v1/sessions/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
this.log('Deleted: ' + name, 'log-state');
|
||||
this.refreshSessions();
|
||||
}
|
||||
})
|
||||
.catch(e => this.log('Delete failed: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
duplicateSession(name) {
|
||||
const newName = prompt('Duplicate "' + name + '" as:', name + ' (copy)');
|
||||
if (!newName) return;
|
||||
fetch('/api/v1/sessions/' + encodeURIComponent(name) + '/duplicate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target_name: newName }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
this.log('Duplicated: ' + name + ' → ' + newName, 'log-state');
|
||||
this.refreshSessions();
|
||||
} else {
|
||||
this.log('Duplicate failed: ' + (data.detail || ''), 'log-error');
|
||||
}
|
||||
})
|
||||
.catch(e => this.log('Duplicate error: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
exportSession(name) {
|
||||
fetch('/api/v1/sessions/export/' + encodeURIComponent(name), { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
this.log('Exported: ' + data.data.export_path, 'log-state');
|
||||
}
|
||||
})
|
||||
.catch(e => this.log('Export failed: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
exportAllSessions() {
|
||||
this.log('Exporting all sessions...', 'log-state');
|
||||
window.open('/api/v1/sessions/export-all-zip', '_blank');
|
||||
},
|
||||
|
||||
importSession() {
|
||||
document.getElementById('session-import-file').click();
|
||||
},
|
||||
|
||||
handleImportFile(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const newName = prompt('Import as (leave empty to use file name):', '');
|
||||
// For local import, we send the filepath. In a real deployment,
|
||||
// this would upload the file content. For now, work with local paths.
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target.result;
|
||||
// Send content to server
|
||||
fetch('/api/v1/sessions/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newName || file.name.replace('.json', ''),
|
||||
overwrite: false,
|
||||
}),
|
||||
}).then(() => {
|
||||
this.log('To import from file, copy it to ~/.config/rpi-mixer/sessions/ and refresh', 'log-info');
|
||||
this.refreshSessions();
|
||||
});
|
||||
};
|
||||
reader.readAsText(file);
|
||||
input.value = '';
|
||||
},
|
||||
|
||||
// ── Setlists ───────────────────────────────────────────────────────
|
||||
|
||||
showSessionsSubtab(tab) {
|
||||
this._sessionsSubtab = tab;
|
||||
document.querySelectorAll('.subtab').forEach(b => b.classList.toggle('active', b.id === 'subtab-' + tab));
|
||||
document.getElementById('sessions-list').style.display = tab === 'sessions' ? '' : 'none';
|
||||
document.getElementById('setlists-view').style.display = tab === 'setlists' ? '' : 'none';
|
||||
document.getElementById('snapshots-view').style.display = tab === 'snapshots' ? '' : 'none';
|
||||
|
||||
if (tab === 'setlists') this.refreshSetlists();
|
||||
if (tab === 'snapshots') this.refreshSnapshotsUI();
|
||||
if (tab === 'sessions') this.refreshSessions();
|
||||
},
|
||||
|
||||
refreshSetlists() {
|
||||
fetch('/api/v1/setlists')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this._setlistsData = data.setlists || [];
|
||||
this._renderSetlists();
|
||||
})
|
||||
.catch(e => this.log('Failed to list setlists: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
_renderSetlists() {
|
||||
const container = document.getElementById('setlists-list');
|
||||
const setlists = this._setlistsData || [];
|
||||
if (setlists.length === 0) {
|
||||
container.innerHTML = '<div style="color:var(--text-faint);text-align:center;padding:1rem">No setlists. Create one below.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
setlists.forEach(sl => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'session-card';
|
||||
card.innerHTML =
|
||||
`<div class="session-card-name">📋 ${this._escapeHTML(sl.name)}</div>` +
|
||||
`<div class="session-card-info">${sl.entry_count || 0} entries</div>` +
|
||||
`<div class="session-card-actions">` +
|
||||
`<button onclick="App.editSetlist('${this._escapeAttr(sl.name)}')">✏️ Edit</button>` +
|
||||
`<button onclick="App.loadSetlist('${this._escapeAttr(sl.name)}')">▶ Activate</button>` +
|
||||
`<button class="danger" onclick="App.deleteSetlist('${this._escapeAttr(sl.name)}')">🗑️</button>` +
|
||||
`</div>`;
|
||||
container.appendChild(card);
|
||||
});
|
||||
},
|
||||
|
||||
createSetlist() {
|
||||
const nameInput = document.getElementById('setlist-name-input');
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) { this.log('Enter setlist name', 'log-error'); return; }
|
||||
this._currentSetlist = { name, entries: [], description: '' };
|
||||
document.getElementById('setlist-editor').style.display = '';
|
||||
document.getElementById('setlist-editor-title').textContent = name;
|
||||
nameInput.value = '';
|
||||
this._renderSetlistEditor();
|
||||
},
|
||||
|
||||
editSetlist(name) {
|
||||
// Find in data and render editor
|
||||
const sl = (this._setlistsData || []).find(s => s.name === name);
|
||||
if (!sl) return;
|
||||
// Load full setlist
|
||||
fetch('/api/v1/setlists/' + encodeURIComponent(sl.filename.replace('.json', '')) + '/load', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.catch(() => null);
|
||||
this._currentSetlist = { name: sl.name, entries: [], description: '' };
|
||||
document.getElementById('setlist-editor').style.display = '';
|
||||
document.getElementById('setlist-editor-title').textContent = sl.name;
|
||||
this._renderSetlistEditor();
|
||||
},
|
||||
|
||||
_renderSetlistEditor() {
|
||||
const container = document.getElementById('setlist-entries');
|
||||
if (!this._currentSetlist || !this._currentSetlist.entries.length) {
|
||||
container.innerHTML = '<div style="color:var(--text-faint);padding:0.5rem">No entries. Add one below.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
this._currentSetlist.entries.forEach((entry, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'setlist-entry-row';
|
||||
row.innerHTML =
|
||||
`<span>${i + 1}. ${this._escapeHTML(entry.session_name)}</span>` +
|
||||
`<span class="badge">${entry.transition || 'cut'}</span>` +
|
||||
`<button onclick="App.removeSetlistEntry(${i})">✕</button>`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
},
|
||||
|
||||
addSetlistEntry() {
|
||||
if (!this._currentSetlist) { this.log('Create a setlist first', 'log-error'); return; }
|
||||
const sessionInput = document.getElementById('setlist-entry-session');
|
||||
const transitionSelect = document.getElementById('setlist-entry-transition');
|
||||
const sessionName = sessionInput.value.trim();
|
||||
if (!sessionName) { this.log('Enter session name', 'log-error'); return; }
|
||||
this._currentSetlist.entries.push({
|
||||
session_name: sessionName,
|
||||
display_name: sessionName,
|
||||
transition: transitionSelect.value,
|
||||
transition_duration: 2.0,
|
||||
wait_for_trigger: false,
|
||||
notes: '',
|
||||
});
|
||||
sessionInput.value = '';
|
||||
this._renderSetlistEditor();
|
||||
},
|
||||
|
||||
removeSetlistEntry(index) {
|
||||
if (!this._currentSetlist) return;
|
||||
this._currentSetlist.entries.splice(index, 1);
|
||||
this._renderSetlistEditor();
|
||||
},
|
||||
|
||||
saveSetlist() {
|
||||
if (!this._currentSetlist) return;
|
||||
fetch('/api/v1/setlists/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this._currentSetlist),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
this.log('Setlist saved: ' + data.data.name, 'log-state');
|
||||
document.getElementById('setlist-editor').style.display = 'none';
|
||||
this._currentSetlist = null;
|
||||
this.refreshSetlists();
|
||||
}
|
||||
})
|
||||
.catch(e => this.log('Save setlist failed: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
loadSetlist(name) {
|
||||
fetch('/api/v1/setlists/' + encodeURIComponent(name) + '/load', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
this.log('Setlist activated: ' + name + ' (' + data.data.progress[0] + '/' + data.data.progress[1] + ')', 'log-state');
|
||||
}
|
||||
})
|
||||
.catch(e => this.log('Load setlist failed: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
activateSetlist() {
|
||||
if (!this._currentSetlist) return;
|
||||
this.loadSetlist(this._currentSetlist.name);
|
||||
},
|
||||
|
||||
deleteSetlist(name) {
|
||||
if (!confirm('Delete setlist "' + name + '"?')) return;
|
||||
fetch('/api/v1/setlists/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
this.log('Deleted setlist: ' + name, 'log-state');
|
||||
this.refreshSetlists();
|
||||
}
|
||||
})
|
||||
.catch(e => this.log('Delete setlist failed: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
// ── Snapshots ──────────────────────────────────────────────────────
|
||||
|
||||
refreshSnapshotsUI() {
|
||||
fetch('/api/v1/snapshots')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this._snapshotsData = data.snapshots || [];
|
||||
this._renderSnapshots();
|
||||
})
|
||||
.catch(e => this.log('Failed: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
_renderSnapshots() {
|
||||
const container = document.getElementById('snapshots-list');
|
||||
const snaps = this._snapshotsData || [];
|
||||
if (snaps.length === 0) {
|
||||
container.innerHTML = '<div style="color:var(--text-faint);text-align:center;padding:2rem;grid-column:1/-1">No snapshots. Capture one above.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
snaps.forEach(snap => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'session-card';
|
||||
if (snap.is_current) card.classList.add('current');
|
||||
const catBadge = snap.category ? ` <span class="badge">${this._escapeHTML(snap.category)}</span>` : '';
|
||||
card.innerHTML =
|
||||
`<div class="session-card-name">📸 ${this._escapeHTML(snap.name)}${catBadge}</div>` +
|
||||
`<div class="session-card-actions">` +
|
||||
`<button onclick="App.recallSnapshot('${this._escapeAttr(snap.name)}')">▶ Recall</button>` +
|
||||
`<button class="danger" onclick="App.deleteSnapshot('${this._escapeAttr(snap.name)}')">🗑️</button>` +
|
||||
`</div>`;
|
||||
container.appendChild(card);
|
||||
});
|
||||
},
|
||||
|
||||
captureSnapshot() {
|
||||
const nameInput = document.getElementById('snapshot-name-input');
|
||||
const catInput = document.getElementById('snapshot-category-input');
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) { this.log('Enter snapshot name', 'log-error'); return; }
|
||||
fetch('/api/v1/snapshots/capture', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
category: catInput.value,
|
||||
overwrite: true,
|
||||
}),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
this.log('Snapshot captured: ' + name, 'log-state');
|
||||
nameInput.value = '';
|
||||
this.refreshSnapshotsUI();
|
||||
}
|
||||
})
|
||||
.catch(e => this.log('Capture failed: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
recallSnapshot(name) {
|
||||
fetch('/api/v1/snapshots/' + encodeURIComponent(name) + '/recall', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) this.log('Snapshot recalled: ' + name, 'log-state');
|
||||
})
|
||||
.catch(e => this.log('Recall failed: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
deleteSnapshot(name) {
|
||||
if (!confirm('Delete snapshot "' + name + '"?')) return;
|
||||
fetch('/api/v1/snapshots/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
this.log('Deleted snapshot: ' + name, 'log-state');
|
||||
this.refreshSnapshotsUI();
|
||||
}
|
||||
})
|
||||
.catch(e => this.log('Delete failed: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
snapshotNext() {
|
||||
fetch('/api/v1/snapshots/next', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) this.log('Next snapshot: ' + data.data.name, 'log-state');
|
||||
})
|
||||
.catch(e => this.log('Next failed: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
snapshotPrev() {
|
||||
fetch('/api/v1/snapshots/previous', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) this.log('Previous snapshot: ' + data.data.name, 'log-state');
|
||||
})
|
||||
.catch(e => this.log('Prev failed: ' + e.message, 'log-error'));
|
||||
},
|
||||
|
||||
// ── Auto-save ──────────────────────────────────────────────────────
|
||||
|
||||
refreshAutosaveStatus() {
|
||||
fetch('/api/v1/sessions/autosave/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const indicator = document.getElementById('autosave-indicator');
|
||||
if (data.data && data.data.auto_save_enabled) {
|
||||
indicator.textContent = '● Auto-save active';
|
||||
indicator.style.color = 'var(--success-color)';
|
||||
} else {
|
||||
indicator.textContent = '○ Auto-save off';
|
||||
indicator.style.color = 'var(--text-faint)';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
// Sending
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
|
||||
+62
-3
@@ -109,13 +109,72 @@
|
||||
<!-- ── Sessions View ── -->
|
||||
<section id="view-sessions" class="view">
|
||||
<div class="sessions-header">
|
||||
<h3>Saved Sessions</h3>
|
||||
<h3>Mixer Sessions</h3>
|
||||
<div class="sessions-actions">
|
||||
<input type="text" id="scene-name-input" placeholder="Scene name" class="text-input">
|
||||
<button class="btn btn-primary" onclick="App.saveScene()">💾 Save Current</button>
|
||||
<input type="text" id="session-name-input" placeholder="Session name" class="text-input">
|
||||
<input type="text" id="session-notes-input" placeholder="Notes (optional)" class="text-input" style="width:160px">
|
||||
<button class="btn btn-primary" onclick="App.saveSession()">💾 Save Session</button>
|
||||
<button class="btn" onclick="App.refreshSessions()">🔄 Refresh</button>
|
||||
<select id="session-filter" onchange="App.refreshSessions()" style="width:100px">
|
||||
<option value="all">All</option>
|
||||
<option value="sessions">Sessions</option>
|
||||
<option value="snapshots">Snapshots</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Auto-save status -->
|
||||
<div id="autosave-status" class="autosave-status"><span id="autosave-indicator">● Auto-save active</span></div>
|
||||
|
||||
<!-- Sub-tabs for Sessions / Setlists / Snapshots -->
|
||||
<div class="sessions-subtabs">
|
||||
<button class="subtab active" id="subtab-sessions" onclick="App.showSessionsSubtab('sessions')">📁 Sessions</button>
|
||||
<button class="subtab" id="subtab-setlists" onclick="App.showSessionsSubtab('setlists')">📋 Setlists</button>
|
||||
<button class="subtab" id="subtab-snapshots" onclick="App.showSessionsSubtab('snapshots')">📸 Snapshots</button>
|
||||
</div>
|
||||
|
||||
<!-- Sessions list -->
|
||||
<div id="sessions-list" class="sessions-grid"></div>
|
||||
|
||||
<!-- Setlist view (hidden by default) -->
|
||||
<div id="setlists-view" style="display:none">
|
||||
<div class="sessions-actions" style="margin-bottom:0.5rem">
|
||||
<input type="text" id="setlist-name-input" placeholder="Setlist name" class="text-input">
|
||||
<button class="btn btn-primary" onclick="App.createSetlist()">+ New Setlist</button>
|
||||
</div>
|
||||
<div id="setlists-list" class="sessions-grid"></div>
|
||||
<div id="setlist-editor" style="display:none">
|
||||
<h4>Setlist: <span id="setlist-editor-title"></span></h4>
|
||||
<div id="setlist-entries"></div>
|
||||
<div class="sessions-actions">
|
||||
<input type="text" id="setlist-entry-session" placeholder="Session name" class="text-input">
|
||||
<select id="setlist-entry-transition"><option value="cut">Cut</option><option value="crossfade">Crossfade</option><option value="wait">Wait</option></select>
|
||||
<button class="btn" onclick="App.addSetlistEntry()">+ Add Entry</button>
|
||||
<button class="btn btn-primary" onclick="App.saveSetlist()">💾 Save Setlist</button>
|
||||
<button class="btn" onclick="App.activateSetlist()">▶ Activate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshots view (hidden by default) -->
|
||||
<div id="snapshots-view" style="display:none">
|
||||
<div class="sessions-actions" style="margin-bottom:0.5rem">
|
||||
<input type="text" id="snapshot-name-input" placeholder="Snapshot name" class="text-input">
|
||||
<select id="snapshot-category-input" style="width:100px">
|
||||
<option value="">No Category</option><option value="intro">Intro</option><option value="verse">Verse</option><option value="chorus">Chorus</option><option value="bridge">Bridge</option><option value="outro">Outro</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" onclick="App.captureSnapshot()">📸 Capture</button>
|
||||
<button class="btn" onclick="App.snapshotNext()">▶ Next</button>
|
||||
<button class="btn" onclick="App.snapshotPrev()">◀ Prev</button>
|
||||
</div>
|
||||
<div id="snapshots-list" class="sessions-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Import/Export -->
|
||||
<div class="sessions-actions" style="margin-top:1rem; border-top:1px solid var(--border-color); padding-top:0.5rem">
|
||||
<button class="btn" onclick="App.importSession()">📥 Import</button>
|
||||
<button class="btn" onclick="App.exportAllSessions()">📤 Export All</button>
|
||||
<input type="file" id="session-import-file" accept=".json" style="display:none" onchange="App.handleImportFile(this)">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
@@ -797,6 +797,89 @@ input, button, select, textarea {
|
||||
.session-card-actions button.danger { color: var(--danger); }
|
||||
.session-card-actions button.danger:hover { background: var(--danger); color: var(--text); }
|
||||
|
||||
/* Session card info line */
|
||||
.session-card-info {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-faint);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* Session card badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.3rem;
|
||||
font-size: 0.55rem;
|
||||
background: var(--bg-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
color: var(--text-dim);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge.active {
|
||||
background: var(--connected);
|
||||
color: var(--bg);
|
||||
border-color: var(--connected);
|
||||
}
|
||||
|
||||
/* Current snapshot card highlight */
|
||||
.session-card.current {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 4px var(--accent-dim);
|
||||
}
|
||||
|
||||
/* Sessions subtabs */
|
||||
.sessions-subtabs {
|
||||
display: flex;
|
||||
gap: 0px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.subtab {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
min-height: var(--touch-min);
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.subtab:hover { color: var(--text); }
|
||||
.subtab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
/* Auto-save status */
|
||||
.autosave-status {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Setlist entry row */
|
||||
.setlist-entry-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--bg-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.setlist-entry-row span { flex: 1; }
|
||||
.setlist-entry-row button {
|
||||
padding: 0.2rem 0.4rem;
|
||||
background: var(--btn-bg);
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
min-height: 28px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* ── Modal (EQ) ────────────────────────────────────────────────────────── */
|
||||
.modal {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user