first commit

This commit is contained in:
Stefan Hacker
2026-01-29 20:31:37 +01:00
commit 0b09765013
30 changed files with 6865 additions and 0 deletions
+443
View File
@@ -0,0 +1,443 @@
// ESP32 SIP Phone - Web Interface
(function() {
'use strict';
// State
let currentTab = 'status';
let statusUpdateInterval = null;
// DOM Elements
const elements = {
// Status bar
wifiStatus: document.getElementById('wifi-status'),
sipStatus: document.getElementById('sip-status'),
audioStatus: document.getElementById('audio-status'),
// Call info
callState: document.getElementById('call-state'),
callRemote: document.getElementById('call-remote'),
callDuration: document.getElementById('call-duration'),
callButtons: document.getElementById('call-buttons'),
btnAnswer: document.getElementById('btn-answer'),
btnReject: document.getElementById('btn-reject'),
btnHangup: document.getElementById('btn-hangup'),
// Audio
audioSource: document.getElementById('audio-source'),
usbConnected: document.getElementById('usb-connected'),
btConnected: document.getElementById('bt-connected'),
volumeSlider: document.getElementById('volume-slider'),
volumeValue: document.getElementById('volume-value'),
muteCheckbox: document.getElementById('mute-checkbox'),
// WiFi
wifiForm: document.getElementById('wifi-form'),
wifiSsid: document.getElementById('wifi-ssid'),
wifiPassword: document.getElementById('wifi-password'),
btnScanWifi: document.getElementById('btn-scan-wifi'),
wifiScanResults: document.getElementById('wifi-scan-results'),
staticIpConfig: document.getElementById('static-ip-config'),
// SIP
sipForm: document.getElementById('sip-form'),
sipServer: document.getElementById('sip-server'),
sipPort: document.getElementById('sip-port'),
sipUsername: document.getElementById('sip-username'),
sipPassword: document.getElementById('sip-password'),
sipDisplayName: document.getElementById('sip-display-name'),
// Bluetooth
btnScanBt: document.getElementById('btn-scan-bt'),
btPairedDevices: document.getElementById('bt-paired-devices'),
btFoundDevices: document.getElementById('bt-found-devices'),
// System
btnReboot: document.getElementById('btn-reboot'),
btnFactoryReset: document.getElementById('btn-factory-reset')
};
// API Functions
async function apiGet(endpoint) {
try {
const response = await fetch('/api/' + endpoint);
return await response.json();
} catch (error) {
console.error('API Error:', error);
return null;
}
}
async function apiPost(endpoint, data = {}) {
try {
const response = await fetch('/api/' + endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return await response.json();
} catch (error) {
console.error('API Error:', error);
return null;
}
}
// Status Updates
async function updateStatus() {
const status = await apiGet('status');
if (!status) return;
// WiFi
if (status.wifi) {
const wifiState = status.wifi.state === 'connected' ? 'Verbunden' :
status.wifi.state === 'hotspot' ? 'Hotspot' : 'Getrennt';
elements.wifiStatus.textContent = 'WiFi: ' + wifiState;
elements.wifiStatus.className = 'status-item ' +
(status.wifi.state === 'connected' ? 'status-connected' : 'status-disconnected');
}
// SIP
if (status.sip) {
const sipState = status.sip.state === 'registered' ? 'Registriert' :
status.sip.state === 'registering' ? 'Verbinde...' : 'Nicht registriert';
elements.sipStatus.textContent = 'SIP: ' + sipState;
elements.sipStatus.className = 'status-item ' +
(status.sip.state === 'registered' ? 'status-connected' :
status.sip.state === 'registering' ? 'status-pending' : 'status-disconnected');
}
// Audio
if (status.audio) {
const source = status.audio.source === 'usb' ? 'USB' :
status.audio.source === 'bluetooth' ? 'Bluetooth' : 'Keine';
elements.audioStatus.textContent = 'Audio: ' + source;
elements.audioSource.textContent = source;
elements.usbConnected.textContent = status.audio.usb_connected ? 'Verbunden' : 'Nicht verbunden';
elements.btConnected.textContent = status.audio.bt_connected ? 'Verbunden' : 'Nicht verbunden';
elements.volumeSlider.value = status.audio.volume;
elements.volumeValue.textContent = status.audio.volume + '%';
elements.muteCheckbox.checked = status.audio.muted;
}
// Call
if (status.call) {
updateCallUI(status.call);
}
}
function updateCallUI(call) {
const states = {
'idle': 'Kein aktiver Anruf',
'incoming': 'Eingehender Anruf',
'outgoing': 'Ausgehender Anruf',
'ringing': 'Klingelt...',
'connected': 'Verbunden'
};
elements.callState.textContent = states[call.state] || call.state;
if (call.state !== 'idle') {
elements.callRemote.textContent = (call.name || '') + ' ' + (call.remote || '');
if (call.state === 'connected' && call.duration !== undefined) {
const mins = Math.floor(call.duration / 60);
const secs = call.duration % 60;
elements.callDuration.textContent = mins + ':' + (secs < 10 ? '0' : '') + secs;
} else {
elements.callDuration.textContent = '';
}
elements.callButtons.classList.remove('hidden');
// Show appropriate buttons
elements.btnAnswer.classList.toggle('hidden', call.state !== 'incoming');
elements.btnReject.classList.toggle('hidden', call.state !== 'incoming');
elements.btnHangup.classList.toggle('hidden', call.state === 'idle' || call.state === 'incoming');
} else {
elements.callRemote.textContent = '';
elements.callDuration.textContent = '';
elements.callButtons.classList.add('hidden');
}
}
// Tab Navigation
function setupTabs() {
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
if (tab === currentTab) return;
// Update nav
document.querySelector('.nav-btn.active').classList.remove('active');
btn.classList.add('active');
// Update content
document.querySelector('.tab-content.active').classList.remove('active');
document.getElementById('tab-' + tab).classList.add('active');
currentTab = tab;
// Load tab-specific data
if (tab === 'wifi') loadWifiConfig();
if (tab === 'sip') loadSipConfig();
if (tab === 'bluetooth') loadBluetoothDevices();
});
});
}
// WiFi
async function loadWifiConfig() {
const config = await apiGet('wifi/config');
if (!config) return;
elements.wifiSsid.value = config.ssid || '';
elements.wifiPassword.value = '';
const ipMode = config.ip_mode || 'dhcp';
document.querySelector('input[name="ip-mode"][value="' + ipMode + '"]').checked = true;
elements.staticIpConfig.classList.toggle('hidden', ipMode !== 'static');
if (ipMode === 'static') {
document.getElementById('static-ip').value = config.static_ip || '';
document.getElementById('gateway').value = config.gateway || '';
document.getElementById('netmask').value = config.netmask || '';
document.getElementById('dns').value = config.dns || '';
}
}
async function scanWifi() {
elements.btnScanWifi.disabled = true;
elements.btnScanWifi.textContent = 'Scanne...';
elements.wifiScanResults.classList.remove('hidden');
elements.wifiScanResults.innerHTML = '<p class="no-devices">Scanne...</p>';
const networks = await apiGet('wifi/scan');
elements.btnScanWifi.disabled = false;
elements.btnScanWifi.textContent = 'Scannen';
if (!networks || networks.length === 0) {
elements.wifiScanResults.innerHTML = '<p class="no-devices">Keine Netzwerke gefunden</p>';
return;
}
elements.wifiScanResults.innerHTML = networks.map(net =>
'<div class="scan-item" data-ssid="' + escapeHtml(net.ssid) + '">' +
'<span>' + escapeHtml(net.ssid) + (net.secure ? ' 🔒' : '') + '</span>' +
'<span class="scan-rssi">' + net.rssi + ' dBm</span>' +
'</div>'
).join('');
elements.wifiScanResults.querySelectorAll('.scan-item').forEach(item => {
item.addEventListener('click', () => {
elements.wifiSsid.value = item.dataset.ssid;
elements.wifiScanResults.classList.add('hidden');
elements.wifiPassword.focus();
});
});
}
async function saveWifiConfig(e) {
e.preventDefault();
const ipMode = document.querySelector('input[name="ip-mode"]:checked').value;
const data = {
ssid: elements.wifiSsid.value,
password: elements.wifiPassword.value,
ip_mode: ipMode
};
if (ipMode === 'static') {
data.static_ip = document.getElementById('static-ip').value;
data.gateway = document.getElementById('gateway').value;
data.netmask = document.getElementById('netmask').value;
data.dns = document.getElementById('dns').value;
}
const result = await apiPost('wifi/config', data);
if (result && result.success) {
alert('WiFi-Konfiguration gespeichert. Verbinde...');
} else {
alert('Fehler: ' + (result?.error || 'Unbekannt'));
}
}
// SIP
async function loadSipConfig() {
const config = await apiGet('sip/config');
if (!config) return;
elements.sipServer.value = config.server || '';
elements.sipPort.value = config.port || 5060;
elements.sipUsername.value = config.username || '';
elements.sipPassword.value = '';
elements.sipDisplayName.value = config.display_name || '';
}
async function saveSipConfig(e) {
e.preventDefault();
const data = {
server: elements.sipServer.value,
port: parseInt(elements.sipPort.value) || 5060,
username: elements.sipUsername.value,
password: elements.sipPassword.value,
display_name: elements.sipDisplayName.value
};
const result = await apiPost('sip/config', data);
if (result && result.success) {
alert('SIP-Konfiguration gespeichert. Registriere...');
} else {
alert('Fehler: ' + (result?.error || 'Unbekannt'));
}
}
// Bluetooth
async function loadBluetoothDevices() {
const devices = await apiGet('bluetooth/devices');
if (!devices || devices.length === 0) {
elements.btPairedDevices.innerHTML = '<p class="no-devices">Keine Gerate gepaart</p>';
} else {
elements.btPairedDevices.innerHTML = devices.map(dev =>
'<div class="device-item">' +
'<div class="device-info">' +
'<div class="device-name">' + escapeHtml(dev.name || 'Unbekannt') + '</div>' +
'<div class="device-address">' + escapeHtml(dev.address) + '</div>' +
'</div>' +
'<div class="device-actions">' +
'<button class="btn btn-secondary btn-connect" data-address="' + escapeHtml(dev.address) + '">Verbinden</button>' +
'<button class="btn btn-danger btn-unpair" data-address="' + escapeHtml(dev.address) + '">Entfernen</button>' +
'</div>' +
'</div>'
).join('');
elements.btPairedDevices.querySelectorAll('.btn-connect').forEach(btn => {
btn.addEventListener('click', () => connectBluetooth(btn.dataset.address));
});
elements.btPairedDevices.querySelectorAll('.btn-unpair').forEach(btn => {
btn.addEventListener('click', () => unpairBluetooth(btn.dataset.address));
});
}
}
async function scanBluetooth() {
elements.btnScanBt.disabled = true;
elements.btnScanBt.textContent = 'Suche...';
elements.btFoundDevices.innerHTML = '<p class="no-devices">Suche Gerate...</p>';
await apiPost('bluetooth/scan');
// Wait for scan to complete
setTimeout(async () => {
elements.btnScanBt.disabled = false;
elements.btnScanBt.textContent = 'Gerate suchen';
// In a full implementation, we'd poll for discovered devices
elements.btFoundDevices.innerHTML = '<p class="no-devices">Suche abgeschlossen. Gerate werden automatisch gepaart wenn sie in den Pairing-Modus gehen.</p>';
}, 10000);
}
async function connectBluetooth(address) {
const result = await apiPost('bluetooth/connect', { address });
if (result && result.success) {
alert('Verbinde...');
} else {
alert('Fehler: ' + (result?.error || 'Unbekannt'));
}
}
async function unpairBluetooth(address) {
if (!confirm('Gerat wirklich entfernen?')) return;
const result = await apiPost('bluetooth/unpair', { address });
if (result && result.success) {
loadBluetoothDevices();
} else {
alert('Fehler: ' + (result?.error || 'Unbekannt'));
}
}
// Call Actions
async function answerCall() {
await apiPost('call/answer');
}
async function rejectCall() {
await apiPost('call/reject');
}
async function hangupCall() {
await apiPost('call/hangup');
}
// System
async function reboot() {
if (!confirm('System wirklich neustarten?')) return;
await apiPost('system/reboot');
alert('System startet neu...');
}
async function factoryReset() {
if (!confirm('ACHTUNG: Alle Einstellungen werden geloscht! Fortfahren?')) return;
if (!confirm('Wirklich alle Einstellungen loschen?')) return;
await apiPost('system/factory-reset');
alert('Werksreset durchgefuhrt. System startet neu...');
}
// Utilities
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Event Listeners
function setupEventListeners() {
// Call buttons
elements.btnAnswer.addEventListener('click', answerCall);
elements.btnReject.addEventListener('click', rejectCall);
elements.btnHangup.addEventListener('click', hangupCall);
// Volume
elements.volumeSlider.addEventListener('input', () => {
elements.volumeValue.textContent = elements.volumeSlider.value + '%';
});
// WiFi
elements.wifiForm.addEventListener('submit', saveWifiConfig);
elements.btnScanWifi.addEventListener('click', scanWifi);
document.querySelectorAll('input[name="ip-mode"]').forEach(radio => {
radio.addEventListener('change', (e) => {
elements.staticIpConfig.classList.toggle('hidden', e.target.value !== 'static');
});
});
// SIP
elements.sipForm.addEventListener('submit', saveSipConfig);
// Bluetooth
elements.btnScanBt.addEventListener('click', scanBluetooth);
// System
elements.btnReboot.addEventListener('click', reboot);
elements.btnFactoryReset.addEventListener('click', factoryReset);
}
// Initialize
function init() {
setupTabs();
setupEventListeners();
updateStatus();
// Update status every 2 seconds
statusUpdateInterval = setInterval(updateStatus, 2000);
}
// Start
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+183
View File
@@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 SIP Phone</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1>ESP32 SIP Phone</h1>
<div id="status-bar">
<span id="wifi-status" class="status-item">WiFi: --</span>
<span id="sip-status" class="status-item">SIP: --</span>
<span id="audio-status" class="status-item">Audio: --</span>
</div>
</header>
<nav>
<button class="nav-btn active" data-tab="status">Status</button>
<button class="nav-btn" data-tab="wifi">WLAN</button>
<button class="nav-btn" data-tab="sip">SIP</button>
<button class="nav-btn" data-tab="bluetooth">Bluetooth</button>
<button class="nav-btn" data-tab="system">System</button>
</nav>
<main>
<!-- Status Tab -->
<section id="tab-status" class="tab-content active">
<div class="card">
<h2>Anrufstatus</h2>
<div id="call-info">
<p id="call-state">Kein aktiver Anruf</p>
<p id="call-remote"></p>
<p id="call-duration"></p>
</div>
<div id="call-buttons" class="button-group hidden">
<button id="btn-answer" class="btn btn-success">Annehmen</button>
<button id="btn-reject" class="btn btn-danger">Ablehnen</button>
<button id="btn-hangup" class="btn btn-danger">Auflegen</button>
</div>
</div>
<div class="card">
<h2>Audio</h2>
<p>Aktive Quelle: <strong id="audio-source">--</strong></p>
<p>USB Headset: <span id="usb-connected">--</span></p>
<p>Bluetooth Headset: <span id="bt-connected">--</span></p>
<div class="volume-control">
<label>Lautstarke:</label>
<input type="range" id="volume-slider" min="0" max="100" value="80">
<span id="volume-value">80%</span>
</div>
<label class="checkbox-label">
<input type="checkbox" id="mute-checkbox"> Stummschalten
</label>
</div>
</section>
<!-- WiFi Tab -->
<section id="tab-wifi" class="tab-content">
<div class="card">
<h2>WLAN-Konfiguration</h2>
<form id="wifi-form">
<div class="form-group">
<label for="wifi-ssid">SSID:</label>
<div class="input-with-button">
<input type="text" id="wifi-ssid" required>
<button type="button" id="btn-scan-wifi" class="btn btn-secondary">Scannen</button>
</div>
</div>
<div id="wifi-scan-results" class="scan-results hidden"></div>
<div class="form-group">
<label for="wifi-password">Passwort:</label>
<input type="password" id="wifi-password">
</div>
<div class="form-group">
<label>IP-Konfiguration:</label>
<div class="radio-group">
<label><input type="radio" name="ip-mode" value="dhcp" checked> DHCP</label>
<label><input type="radio" name="ip-mode" value="static"> Statisch</label>
</div>
</div>
<div id="static-ip-config" class="hidden">
<div class="form-group">
<label for="static-ip">IP-Adresse:</label>
<input type="text" id="static-ip" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label for="gateway">Gateway:</label>
<input type="text" id="gateway" placeholder="192.168.1.1">
</div>
<div class="form-group">
<label for="netmask">Netzmaske:</label>
<input type="text" id="netmask" placeholder="255.255.255.0">
</div>
<div class="form-group">
<label for="dns">DNS:</label>
<input type="text" id="dns" placeholder="8.8.8.8">
</div>
</div>
<button type="submit" class="btn btn-primary">Speichern</button>
</form>
</div>
</section>
<!-- SIP Tab -->
<section id="tab-sip" class="tab-content">
<div class="card">
<h2>SIP-Konfiguration</h2>
<form id="sip-form">
<div class="form-group">
<label for="sip-server">Server:</label>
<input type="text" id="sip-server" placeholder="pbx.example.com" required>
</div>
<div class="form-group">
<label for="sip-port">Port:</label>
<input type="number" id="sip-port" value="5060">
</div>
<div class="form-group">
<label for="sip-username">Benutzername:</label>
<input type="text" id="sip-username" required>
</div>
<div class="form-group">
<label for="sip-password">Passwort:</label>
<input type="password" id="sip-password">
</div>
<div class="form-group">
<label for="sip-display-name">Anzeigename:</label>
<input type="text" id="sip-display-name" placeholder="Max Mustermann">
</div>
<button type="submit" class="btn btn-primary">Speichern</button>
</form>
</div>
</section>
<!-- Bluetooth Tab -->
<section id="tab-bluetooth" class="tab-content">
<div class="card">
<h2>Bluetooth Headsets</h2>
<p class="hint">USB-Headsets haben Vorrang vor Bluetooth.</p>
<button id="btn-scan-bt" class="btn btn-secondary">Geräte suchen</button>
<h3>Gepaarte Geräte</h3>
<div id="bt-paired-devices" class="device-list">
<p class="no-devices">Keine Geräte gepaart</p>
</div>
<h3>Gefundene Geräte</h3>
<div id="bt-found-devices" class="device-list">
<p class="no-devices">Starte Suche...</p>
</div>
</div>
</section>
<!-- System Tab -->
<section id="tab-system" class="tab-content">
<div class="card">
<h2>System</h2>
<div class="button-group">
<button id="btn-reboot" class="btn btn-secondary">Neustart</button>
<button id="btn-factory-reset" class="btn btn-danger">Werksreset</button>
</div>
</div>
<div class="card">
<h2>Info</h2>
<p>ESP32-S3 Bluetooth SIP Client</p>
<p>Hotspot: ESP32-SIP-Phone</p>
<p>Standard-IP: 192.168.4.1</p>
</div>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
+374
View File
@@ -0,0 +1,374 @@
/* ESP32 SIP Phone - Web Interface Styles */
:root {
--primary: #2196F3;
--primary-dark: #1976D2;
--success: #4CAF50;
--danger: #f44336;
--warning: #FF9800;
--bg: #f5f5f5;
--card-bg: #ffffff;
--text: #333333;
--text-light: #666666;
--border: #e0e0e0;
--shadow: 0 2px 4px rgba(0,0,0,0.1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
header {
background: var(--primary);
color: white;
padding: 1rem;
text-align: center;
}
header h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
#status-bar {
display: flex;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
font-size: 0.85rem;
}
.status-item {
background: rgba(255,255,255,0.2);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
}
nav {
display: flex;
background: var(--card-bg);
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.nav-btn {
flex: 1;
padding: 0.75rem 1rem;
border: none;
background: transparent;
color: var(--text-light);
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
white-space: nowrap;
}
.nav-btn:hover {
background: var(--bg);
}
.nav-btn.active {
color: var(--primary);
border-bottom: 2px solid var(--primary);
font-weight: 500;
}
main {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.card {
background: var(--card-bg);
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 1rem;
box-shadow: var(--shadow);
}
.card h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
color: var(--primary);
}
.card h3 {
font-size: 1rem;
margin: 1rem 0 0.5rem;
color: var(--text);
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
font-size: 0.9rem;
}
input[type="text"],
input[type="password"],
input[type="number"] {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: var(--primary);
}
.input-with-button {
display: flex;
gap: 0.5rem;
}
.input-with-button input {
flex: 1;
}
.radio-group {
display: flex;
gap: 1.5rem;
}
.radio-group label {
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: normal;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
/* Buttons */
.btn {
display: inline-block;
padding: 0.5rem 1.25rem;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-secondary {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--border);
}
.btn-success {
background: var(--success);
color: white;
}
.btn-danger {
background: var(--danger);
color: white;
}
.button-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Volume Control */
.volume-control {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1rem 0;
}
.volume-control input[type="range"] {
flex: 1;
}
/* Device List */
.device-list {
border: 1px solid var(--border);
border-radius: 4px;
margin-top: 0.5rem;
}
.device-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
}
.device-item:last-child {
border-bottom: none;
}
.device-info {
flex: 1;
}
.device-name {
font-weight: 500;
}
.device-address {
font-size: 0.8rem;
color: var(--text-light);
font-family: monospace;
}
.device-actions {
display: flex;
gap: 0.5rem;
}
.device-actions .btn {
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}
.no-devices {
padding: 1rem;
text-align: center;
color: var(--text-light);
font-style: italic;
}
/* Scan Results */
.scan-results {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 1rem;
}
.scan-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--border);
}
.scan-item:hover {
background: var(--bg);
}
.scan-item:last-child {
border-bottom: none;
}
.scan-rssi {
font-size: 0.8rem;
color: var(--text-light);
}
/* Call Info */
#call-info {
margin-bottom: 1rem;
}
#call-state {
font-size: 1.1rem;
font-weight: 500;
}
#call-remote {
font-size: 1.25rem;
margin: 0.5rem 0;
}
#call-duration {
color: var(--text-light);
}
/* Utilities */
.hidden {
display: none !important;
}
.hint {
font-size: 0.85rem;
color: var(--text-light);
margin-bottom: 1rem;
}
/* Status Colors */
.status-connected {
color: var(--success);
}
.status-disconnected {
color: var(--danger);
}
.status-pending {
color: var(--warning);
}
/* Responsive */
@media (max-width: 480px) {
header h1 {
font-size: 1.25rem;
}
.nav-btn {
padding: 0.5rem;
font-size: 0.8rem;
}
main {
padding: 0.5rem;
}
.card {
padding: 1rem;
}
}