P6-R1: Audio/video streaming pipeline
Lint & Validate / lint (push) Has been cancelled

- 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:
2026-05-19 22:13:29 -04:00
parent d6480a35ed
commit cdf04bd8eb
21 changed files with 7119 additions and 34 deletions
+481 -31
View File
@@ -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
View File
@@ -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>
+83
View File
@@ -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;