Initial commit: WebCB - Web-based CB radio app

This commit is contained in:
tomdebone
2026-05-13 21:41:54 +02:00
commit c3dc1d28de
8 changed files with 1768 additions and 0 deletions

BIN
client/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

BIN
client/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

523
client/index.html Normal file
View File

@@ -0,0 +1,523 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#0a0a0a">
<title>WebCB Funk</title>
<link rel="manifest" href="manifest.json">
<style>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Roboto+Mono:wght@400;700&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
body { font-family: 'Roboto Mono', monospace; background: #0a0a0a; color: #fff; min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 10px; }
h1 { font-family: 'Orbitron', sans-serif; text-align: center; padding: 15px; font-size: 1.5rem; color: #00ff88; text-shadow: 0 0 20px #00ff8866; letter-spacing: 4px; }
.radio-body { background: linear-gradient(145deg, #1a1a1a, #0d0d0d); border: 4px solid #333; border-radius: 20px; padding: 20px; width: 100%; max-width: 420px; box-shadow: 0 10px 40px rgba(0,0,0,0.8), inset 0 1px 0 rgba(255,255,255,0.05); }
.display-unit { background: #0a0f0a; border: 3px solid #2a2a2a; border-radius: 12px; padding: 20px; margin-bottom: 20px; position: relative; overflow: hidden; }
.display-unit::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 50%; background: linear-gradient(180deg, rgba(0,255,136,0.03) 0%, transparent 100%); pointer-events: none; }
.freq-display { font-family: 'Orbitron', sans-serif; font-size: 32px; font-weight: 900; color: #00ff88; text-align: center; text-shadow: 0 0 20px #00ff88, 0 0 40px #00ff8844; letter-spacing: 2px; }
.freq-display::after { content: ' MHz'; font-size: 14px; color: #00ff8888; }
.channel-info { display: flex; justify-content: space-between; margin-top: 15px; }
.channel-box { background: #111; border: 2px solid #333; border-radius: 8px; padding: 10px 15px; text-align: center; flex: 1; margin: 0 5px; }
.channel-box:first-child { margin-left: 0; }
.channel-box:last-child { margin-right: 0; }
.channel-box .label { font-size: 10px; color: #666; text-transform: uppercase; letter-spacing: 1px; }
.channel-box .value { font-family: 'Orbitron', sans-serif; font-size: 18px; font-weight: 700; color: #ffcc00; text-shadow: 0 0 10px #ffcc0066; margin-top: 5px; }
.channel-box .value.mode { color: #ff6666; }
.users-online { margin-top: 15px; padding: 10px; background: #0a0a0a; border-radius: 8px; min-height: 40px; }
.users-online .label { font-size: 10px; color: #444; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.users-online .names { display: flex; flex-wrap: wrap; gap: 6px; }
.user-badge { background: linear-gradient(145deg, #1a1a1a, #111); border: 1px solid #333; padding: 4px 10px; border-radius: 15px; font-size: 11px; color: #888; }
.user-badge.self { color: #ffcc00; border-color: #ffcc0066; }
.user-badge.sending { color: #00ff88; border-color: #00ff88; animation: glow 1s infinite; }
@keyframes glow { 0%, 100% { box-shadow: 0 0 5px #00ff88; } 50% { box-shadow: 0 0 15px #00ff88; } }
.meter-section { display: flex; align-items: center; gap: 15px; margin: 20px 0; padding: 15px; background: #111; border-radius: 10px; border: 1px solid #222; }
.meter-label { font-size: 10px; color: #444; text-transform: uppercase; writing-mode: vertical-rl; text-orientation: mixed; letter-spacing: 2px; }
.meter { flex: 1; height: 30px; background: #0a0a0a; border-radius: 5px; display: flex; gap: 3px; padding: 5px; align-items: flex-end; border: 1px solid #222; }
.meter-bar { flex: 1; background: #1a1a1a; border-radius: 2px; transition: all 0.1s; min-height: 3px; }
.meter-bar.active { background: #00ff88; box-shadow: 0 0 8px #00ff88; }
.meter-bar.active.s5 { background: #ffcc00; box-shadow: 0 0 8px #ffcc00; }
.meter-bar.active.s7 { background: #ff9900; box-shadow: 0 0 8px #ff9900; }
.meter-bar.active.s9 { background: #ff3333; box-shadow: 0 0 8px #ff3333; }
.controls-section { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; }
.control-knob { background: #111; border: 2px solid #222; border-radius: 10px; padding: 15px; }
.control-knob .label { font-size: 10px; color: #666; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; display: flex; justify-content: space-between; }
.control-knob .label span { color: #00ff88; }
.control-knob input[type="range"] { width: 100%; height: 8px; -webkit-appearance: none; background: #0a0a0a; border-radius: 4px; accent-color: #00ff88; }
.control-knob input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; background: linear-gradient(145deg, #333, #1a1a1a); border: 2px solid #00ff88; border-radius: 50%; cursor: pointer; box-shadow: 0 0 10px #00ff8844; }
.ptt-button { width: 100%; padding: 30px; font-family: 'Orbitron', sans-serif; font-size: 20px; font-weight: 700; text-transform: uppercase; letter-spacing: 3px; background: linear-gradient(145deg, #8b0000, #5a0000); border: 4px solid #cc0000; border-radius: 15px; color: #fff; cursor: pointer; transition: all 0.1s; box-shadow: 0 5px 20px rgba(139,0,0,0.5); position: relative; overflow: hidden; }
.ptt-button::before { content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; background: linear-gradient(45deg, transparent 40%, rgba(255,255,255,0.1) 50%, transparent 60%); transform: rotate(45deg); transition: all 0.5s; }
.ptt-button:active::before, .ptt-button.active::before { left: 100%; }
.ptt-button:active, .ptt-button.active { background: linear-gradient(145deg, #cc0000, #8b0000); box-shadow: 0 0 40px #ff000088; transform: scale(0.98); }
.ptt-button:disabled { background: #222; border-color: #333; color: #555; box-shadow: none; cursor: not-allowed; }
.settings-toggles { display: flex; gap: 15px; margin-top: 15px; }
.toggle-item { flex: 1; display: flex; align-items: center; justify-content: space-between; background: #111; padding: 12px 15px; border-radius: 10px; border: 1px solid #222; }
.toggle-item label { font-size: 11px; color: #666; }
.toggle-item .toggle { width: 40px; height: 22px; background: #1a1a1a; border-radius: 11px; position: relative; cursor: pointer; transition: background 0.2s; border: 1px solid #333; }
.toggle-item .toggle.active { background: #00ff8833; border-color: #00ff88; }
.toggle-item .toggle::after { content: ''; position: absolute; width: 16px; height: 16px; background: #444; border-radius: 50%; top: 2px; left: 2px; transition: all 0.2s; }
.toggle-item .toggle.active::after { left: 20px; background: #00ff88; }
.bottom-nav { display: flex; background: linear-gradient(180deg, #1a1a1a, #0d0d0d); border-top: 1px solid #333; position: fixed; bottom: 0; left: 0; right: 0; z-index: 100; }
.bottom-nav button { flex: 1; padding: 15px 10px; text-align: center; background: none; border: none; color: #555; font-size: 10px; cursor: pointer; transition: all 0.2s; }
.bottom-nav button span { display: block; font-size: 22px; margin-bottom: 5px; }
.bottom-nav button.active { color: #00ff88; }
.login-screen { text-align: center; padding: 40px 20px; }
.login-screen h2 { font-family: 'Orbitron', sans-serif; color: #00ff88; margin-bottom: 30px; font-size: 1.2rem; }
.login-screen input { width: 100%; max-width: 280px; padding: 15px; font-size: 16px; background: #111; border: 2px solid #333; border-radius: 10px; color: #00ff88; text-align: center; margin-bottom: 15px; }
.login-screen button { width: 100%; max-width: 280px; padding: 15px 30px; font-family: 'Orbitron', sans-serif; font-size: 14px; background: linear-gradient(145deg, #00aa66, #008844); border: 2px solid #00ff88; border-radius: 10px; color: #fff; cursor: pointer; text-transform: uppercase; letter-spacing: 2px; }
.login-screen button:active { background: #00cc77; }
.name-status { height: 20px; font-size: 12px; margin-bottom: 10px; color: #666; }
.name-status.available { color: #00ff88; }
.name-status.taken { color: #ff6666; }
.modus-info { text-align: center; font-size: 10px; color: #444; margin-top: 8px; }
.mode-btn { cursor: pointer; transition: all 0.2s; }
.mode-btn:hover { color: #fff !important; text-shadow: 0 0 10px currentColor; }
.slang-panel { margin-top: 15px; padding: 12px; background: #111; border-radius: 10px; border: 1px solid #222; }
.slang-panel h4 { color: #00ff88; font-size: 11px; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1px; }
.slang-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.slang-btn { background: #1a1a1a; border: 1px solid #333; padding: 8px 4px; border-radius: 6px; text-align: center; cursor: pointer; font-size: 11px; color: #888; transition: all 0.2s; }
.slang-btn:active { background: #222; color: #00ff88; border-color: #00ff88; }
.slang-btn .text { font-weight: bold; color: #ffcc00; font-size: 13px; }
.slang-btn .desc { font-size: 9px; color: #555; margin-top: 2px; }
.slang-display { margin-top: 10px; min-height: 24px; padding: 6px 12px; background: #0a1a0a; border-radius: 8px; font-size: 12px; color: #ffcc00; display: none; }
.slang-display.show { display: block; animation: slangFade 3s ease-out forwards; }
@keyframes slangFade { 0% { opacity: 1; } 70% { opacity: 1; } 100% { opacity: 0; } }
.slang-display .user { color: #00ff88; font-weight: bold; }
.slang-display .text { margin-left: 8px; }
.channels-screen { display: none; flex-direction: column; height: calc(100vh - 120px); }
.channels-screen.active { display: flex; }
.channels-header { font-family: 'Orbitron', sans-serif; color: #00ff88; text-align: center; padding: 15px; font-size: 14px; letter-spacing: 2px; }
.channels-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding: 15px; overflow-y: auto; flex: 1; }
.ch-btn { background: linear-gradient(145deg, #1a1a1a, #0d0d0d); border: 2px solid #333; border-radius: 10px; padding: 12px 5px; text-align: center; cursor: pointer; transition: all 0.2s; }
.ch-btn:active { transform: scale(0.95); }
.ch-btn.active { border-color: #00ff88; background: linear-gradient(145deg, #0a1a0f, #051a0a); box-shadow: 0 0 15px #00ff8833; }
.ch-btn .num { font-family: 'Orbitron', sans-serif; font-size: 16px; font-weight: 700; color: #ffcc00; }
.ch-btn.active .num { color: #00ff88; text-shadow: 0 0 10px #00ff88; }
.ch-btn .name { font-size: 8px; color: #444; margin-top: 4px; text-transform: uppercase; }
.ch-btn .users { font-size: 10px; color: #666; margin-top: 2px; }
.ch-btn.sending { border-color: #ff3333; }
.ch-btn.sending::before { content: '🔴 '; }
.history-screen { display: none; flex-direction: column; height: calc(100vh - 120px); }
.history-screen.active { display: flex; }
.history-header { font-family: 'Orbitron', sans-serif; color: #00ff88; text-align: center; padding: 15px; font-size: 14px; letter-spacing: 2px; }
.history-list { flex: 1; overflow-y: auto; padding: 15px; display: flex; flex-direction: column; gap: 8px; }
.history-item { background: #111; border-radius: 8px; padding: 12px; border-left: 3px solid #333; }
.history-item.join { border-left-color: #00ff88; }
.history-item.leave { border-left-color: #ff6666; }
.history-item .time { font-size: 10px; color: #444; }
.history-item .name { color: #00ff88; font-weight: bold; margin: 4px 0; }
.history-item .action { font-size: 12px; color: #888; }
.history-item .ch { color: #ffcc00; }
.over-flash { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); font-family: 'Orbitron', sans-serif; font-size: 48px; font-weight: 900; color: #ff3333; text-shadow: 0 0 30px #ff3333; opacity: 0; pointer-events: none; z-index: 200; }
.over-flash.show { animation: overFlash 1.5s ease-out; }
@keyframes overFlash { 0% { opacity: 1; transform: translate(-50%, -50%) scale(0.5); } 50% { opacity: 1; transform: translate(-50%, -50%) scale(1.2); } 100% { opacity: 0; transform: translate(-50%, -50%) scale(1); } }
.main-content { width: 100%; max-width: 420px; padding-bottom: 70px; }
.tab-content { display: none; }
.tab-content.active { display: block; }
@media (min-width: 500px) { body { padding: 20px; } .radio-body { margin: 20px 0; } }
</style>
</head>
<body>
<h1>WEB • CB</h1>
<div class="main-content">
<div class="login-screen" id="loginScreen">
<h2>Station Anmelden</h2>
<input type="text" id="usernameInput" placeholder="Rufname" maxlength="20" oninput="checkName()">
<div class="name-status" id="nameStatus"></div>
<button onclick="joinApp()" id="joinBtn">Aufschalten</button>
</div>
<div class="radio-body" id="radioBody" style="display:none;">
<div class="display-unit">
<div class="freq-display" id="freqDisplay">27.405</div>
<div class="channel-info">
<div class="channel-box">
<div class="label">Kanal</div>
<div class="value" id="channelDisplay">9</div>
</div>
<div class="channel-box">
<div class="label">Modus</div>
<div class="value mode" id="modeDisplay" onclick="toggleMode()">AM</div>
</div>
<div class="channel-box">
<div class="label">Name</div>
<div class="value" id="channelName" style="font-size:12px;">Notruf</div>
</div>
</div>
<div class="modus-info" id="modusInfo">AM = Standard · SSB = bessere Reichweite</div>
<div class="users-online">
<div class="label">Auf diesem Kanal</div>
<div class="names" id="userList"></div>
</div>
<div class="slang-display" id="slangDisplay"></div>
</div>
<div class="meter-section">
<div class="meter-label">S</div>
<div class="meter" id="sMeter">
<div class="meter-bar"></div><div class="meter-bar"></div><div class="meter-bar"></div><div class="meter-bar"></div><div class="meter-bar"></div>
<div class="meter-bar"></div><div class="meter-bar"></div><div class="meter-bar"></div><div class="meter-bar"></div><div class="meter-bar"></div>
</div>
</div>
<div class="controls-section">
<div class="control-knob">
<div class="label">Squelch <span id="squelchVal">50</span></div>
<input type="range" id="squelch" min="0" max="100" value="50">
</div>
<div class="control-knob">
<div class="label">Volume <span id="volumeVal">70</span></div>
<input type="range" id="volume" min="0" max="100" value="70">
</div>
</div>
<button class="ptt-button" id="pttBtn" disabled>PTT — Drücken zum Senden</button>
<div class="settings-toggles">
<div class="toggle-item">
<label>Roger Beep</label>
<div class="toggle active" id="autoRogerToggle" onclick="toggleAutoRoger()"></div>
</div>
<div class="toggle-item">
<label>Auto Over</label>
<div class="toggle active" id="autoOverToggle" onclick="toggleAutoOver()"></div>
</div>
</div>
<div class="slang-panel">
<h4>CB Slang</h4>
<div class="slang-grid">
<div class="slang-btn" onclick="sendSlang('CQ CQ')"><div class="text">CQ CQ</div><div class="desc">Allgemeinruf</div></div>
<div class="slang-btn" onclick="sendSlang('QRZ')"><div class="text">QRZ</div><div class="desc">Wer ruft?</div></div>
<div class="slang-btn" onclick="sendSlang('73')"><div class="text">73</div><div class="desc">Grüße</div></div>
<div class="slang-btn" onclick="sendSlang('55')"><div class="text">55</div><div class="desc">Gruß formell</div></div>
<div class="slang-btn" onclick="sendSlang('88')"><div class="text">88</div><div class="desc">Kuss</div></div>
<div class="slang-btn" onclick="sendSlang('Over')"><div class="text">Over</div><div class="desc">Sprung dran</div></div>
<div class="slang-btn" onclick="sendSlang('Copy')"><div class="text">Copy</div><div class="desc">Verstanden</div></div>
<div class="slang-btn" onclick="sendSlang('Stand by')"><div class="text">Stand by</div><div class="desc">Moment</div></div>
<div class="slang-btn" onclick="sendSlang('Clear')"><div class="text">Clear</div><div class="desc">Ausstieg</div></div>
</div>
</div>
</div>
<div class="channels-screen tab-content" id="channelsTab">
<div class="channels-header">KANÄLE</div>
<div class="channels-grid" id="channelGrid"></div>
</div>
<div class="history-screen tab-content" id="historyTab">
<div class="history-header">VERLAUF</div>
<div class="history-list" id="historyList"></div>
</div>
</div>
<div class="over-flash" id="overFlash">OVER</div>
<nav class="bottom-nav" id="bottomNav" style="display:none;">
<button class="active" onclick="showTab('radio')"><span>📻</span>Radio</button>
<button onclick="showTab('channels')"><span>📡</span>Kanäle</button>
<button onclick="showTab('history')"><span>📜</span>Verlauf</button>
</nav>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<script>
const socket = io(window.location.origin);
let audioContext, micStream, gainNode, squelchGain;
let isLoggedIn = false, isTransmitting = false, currentChannel = 9, mySocketId = '';
let channels = [], CHANNEL_NAMES = {}, autoRoger = true, autoOver = true;
let history = [], myName = '';
const CB_FREQS = [26.965, 26.975, 26.985, 27.005, 27.015, 27.025, 27.035, 27.055, 27.065, 27.075, 27.085, 27.105, 27.115, 27.125, 27.135, 27.155, 27.165, 27.175, 27.185, 27.205, 27.215, 27.225, 27.235, 27.255, 27.265, 27.275, 27.285, 27.305, 27.315, 27.325, 27.335, 27.355, 27.365, 27.375, 27.385, 27.405, 27.415, 27.425, 27.435, 27.455];
function checkName() {
const name = document.getElementById('usernameInput').value.trim();
const status = document.getElementById('nameStatus');
if (name.length < 2) {
status.textContent = '';
status.className = 'name-status';
return;
}
socket.emit('check_name', name, (res) => {
if (res.available) {
status.textContent = '✓ Name verfügbar';
status.className = 'name-status available';
} else {
status.textContent = res.suggested ? `⚠ Besetzt - Vorschlag: ${res.suggested}` : '⚠ Besetzt';
status.className = 'name-status taken';
}
});
}
function showTab(tab) {
document.querySelectorAll('.bottom-nav button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
if (tab === 'radio') {
document.getElementById('radioBody').style.display = 'block';
document.querySelector('.bottom-nav button:nth-child(1)').classList.add('active');
} else {
document.getElementById('radioBody').style.display = 'none';
if (tab === 'channels') {
document.getElementById('channelsTab').classList.add('active');
document.querySelector('.bottom-nav button:nth-child(2)').classList.add('active');
} else {
document.getElementById('historyTab').classList.add('active');
document.querySelector('.bottom-nav button:nth-child(3)').classList.add('active');
}
}
}
function toggleAutoRoger() { autoRoger = !autoRoger; document.getElementById('autoRogerToggle').classList.toggle('active', autoRoger); }
function toggleAutoOver() { autoOver = !autoOver; document.getElementById('autoOverToggle').classList.toggle('active', autoOver); }
function playRogerBeep() {
if (!audioContext) return;
const osc = audioContext.createOscillator(), g = audioContext.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(1200, audioContext.currentTime);
osc.frequency.setValueAtTime(800, audioContext.currentTime + 0.1);
g.gain.setValueAtTime(0.3, audioContext.currentTime);
g.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
osc.connect(g); g.connect(audioContext.destination);
osc.start(); osc.stop(audioContext.currentTime + 0.3);
}
let currentMode = 'AM';
function toggleMode() {
currentMode = currentMode === 'AM' ? 'SSB' : 'AM';
document.getElementById('modeDisplay').textContent = currentMode;
document.getElementById('modusInfo').textContent = currentMode === 'AM' ? 'AM = Standard · SSB = bessere Reichweite' : 'SSB = USB/LSB · schmale Bandbreite';
}
function sendSlang(text) {
if (!audioContext) return;
const osc = audioContext.createOscillator();
const g = audioContext.createGain();
osc.type = 'sine';
osc.frequency.value = 800;
g.gain.setValueAtTime(0.2, audioContext.currentTime);
g.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
osc.connect(g); g.connect(audioContext.destination);
osc.start(); osc.stop(audioContext.currentTime + 0.2);
if (currentChannel) {
socket.emit('slang', { channel: currentChannel, text, user: myName });
}
}
function flashOver() {
const el = document.getElementById('overFlash');
el.classList.remove('show');
void el.offsetWidth;
el.classList.add('show');
}
function addHistory(name, action, channel) {
history.unshift({ time: new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }), name, action, channel });
if (history.length > 50) history.pop();
document.getElementById('historyList').innerHTML = history.map(h => `<div class="history-item ${action.includes('Verlassen') ? 'leave' : 'join'}"><div class="time">${h.time}</div><div class="name">${h.name}</div><div class="action">${h.action} <span class="ch">CH ${h.channel}</span></div></div>`).join('');
}
async function joinApp() {
const name = document.getElementById('usernameInput').value.trim() || 'CB-' + Math.random().toString(36).substr(2, 4).toUpperCase();
myName = name;
socket.emit('join', { name, channel: currentChannel }, (res) => {
if (res && res.myName) myName = res.myName;
socket.emit('join_channel', { channel: currentChannel });
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('radioBody').style.display = 'block';
document.getElementById('bottomNav').style.display = 'flex';
document.getElementById('pttBtn').disabled = false;
isLoggedIn = true;
initAudio();
playRogerBeep();
addHistory(myName, 'Aufgeschaltet auf', currentChannel);
});
}
async function initAudio() {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
const bufferSize = 2 * audioContext.sampleRate;
const noiseBuffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate);
const output = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) output[i] = Math.random() * 2 - 1;
const noiseSource = audioContext.createBufferSource();
noiseSource.buffer = noiseBuffer; noiseSource.loop = true;
const noiseFilter = audioContext.createBiquadFilter();
noiseFilter.type = 'bandpass'; noiseFilter.frequency.value = 2500; noiseFilter.Q.value = 0.5;
noiseGain = audioContext.createGain(); noiseGain.gain.value = 0.1;
squelchGain = audioContext.createGain(); squelchGain.gain.value = 0.1;
gainNode = audioContext.createGain(); gainNode.gain.value = 0.7;
noiseSource.connect(noiseFilter); noiseFilter.connect(squelchGain); squelchGain.connect(gainNode); gainNode.connect(audioContext.destination);
noiseSource.start();
startSMeterAnimation();
document.getElementById('squelch').addEventListener('input', () => {
const v = document.getElementById('squelch').value;
document.getElementById('squelchVal').textContent = v;
squelchGain.gain.value = (100 - v) / 100 * 0.15;
});
document.getElementById('volume').addEventListener('input', () => {
const v = document.getElementById('volume').value;
document.getElementById('volumeVal').textContent = v;
if (gainNode) gainNode.gain.value = v / 100;
});
}
function startSMeterAnimation() {
const bars = document.querySelectorAll('.meter-bar');
function animate() {
if (!isTransmitting) {
const sq = document.getElementById('squelch').value;
const level = Math.random() * 0.15 + (100 - sq) / 100 * 0.25;
const active = Math.floor(level * 10);
bars.forEach((bar, i) => {
bar.className = 'meter-bar' + (i < active ? ' active' : '');
if (i < active && i >= 6) bar.classList.add('s5');
if (i < active && i >= 8) bar.classList.add('s7');
if (i < active && i >= 9) bar.classList.add('s9');
});
}
setTimeout(animate, 80);
}
animate();
}
function updateDisplay() {
document.getElementById('freqDisplay').textContent = CB_FREQS[currentChannel - 1].toFixed(3);
document.getElementById('channelDisplay').textContent = currentChannel;
document.getElementById('channelName').textContent = CHANNEL_NAMES[currentChannel] || '-';
const ch = channels[currentChannel - 1];
if (ch) {
document.getElementById('userList').innerHTML = ch.users.map(u =>
`<div class="user-badge ${u.isTransmitting ? 'sending' : ''} ${u.id === mySocketId ? 'self' : ''}">${u.name}</div>`
).join('');
}
}
async function startTransmission() {
if (!audioContext || isTransmitting) return;
isTransmitting = true;
socket.emit('transmitting', true);
document.getElementById('pttBtn').classList.add('active');
const bars = document.querySelectorAll('.meter-bar');
bars.forEach((bar, i) => {
bar.className = 'meter-bar active';
if (i >= 6 && i < 8) bar.classList.add('s5');
if (i >= 8) bar.classList.add('s7');
});
updateDisplay();
try {
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const micSource = audioContext.createMediaStreamSource(micStream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
micSource.connect(processor); processor.connect(gainNode);
processor.onaudioprocess = (e) => {
const input = e.inputBuffer.getChannelData(0);
const output = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) output[i] = Math.max(-1, Math.min(1, input[i] * 2)) * 32767;
socket.emit('audio', Array.from(output));
};
} catch (err) { console.log('Mic denied'); }
}
function stopTransmission() {
socket.emit('transmitting', false);
if (micStream) { micStream.getTracks().forEach(t => t.stop()); micStream = null; }
isTransmitting = false;
document.getElementById('pttBtn').classList.remove('active');
updateDisplay();
if (autoRoger) playRogerBeep();
if (autoOver) flashOver();
}
const pttBtn = document.getElementById('pttBtn');
pttBtn.addEventListener('mousedown', startTransmission);
pttBtn.addEventListener('mouseup', stopTransmission);
pttBtn.addEventListener('mouseleave', stopTransmission);
pttBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startTransmission(); }, { passive: false });
pttBtn.addEventListener('touchend', stopTransmission);
socket.on('connect', () => { mySocketId = socket.id; });
socket.on('slang', ({ text, user }) => {
const el = document.getElementById('slangDisplay');
el.innerHTML = `<span class="user">${user}:</span><span class="text">${text}</span>`;
el.classList.remove('show');
void el.offsetWidth;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 3000);
});
socket.on('audio', (data) => {
if (isTransmitting || !audioContext) return;
const buf = audioContext.createBuffer(1, data.length, audioContext.sampleRate);
const channel = buf.getChannelData(0);
for (let i = 0; i < data.length; i++) channel[i] = data[i] / 32767;
const source = audioContext.createBufferSource();
source.buffer = buf;
const g = audioContext.createGain();
g.gain.value = document.getElementById('volume').value / 100;
source.connect(g); g.connect(audioContext.destination);
source.start();
if (autoOver) flashOver();
});
socket.on('init', ({ channels: ch, CHANNEL_NAMES: cn }) => {
channels.length = 0; channels.push(...ch);
CHANNEL_NAMES = cn || {};
renderChannels(); updateDisplay();
});
socket.on('channel_update', ({ channels: ch }) => {
channels.length = 0; channels.push(...ch);
renderChannels(); updateDisplay();
});
socket.on('user_update', ({ name, channel, joined }) => {
addHistory(name, joined ? 'Aufgeschaltet auf' : 'Verlassen', channel);
});
function renderChannels() {
const grid = document.getElementById('channelGrid');
grid.innerHTML = channels.map(ch => {
const hasSending = ch.users.some(u => u.isTransmitting);
return `<div class="ch-btn ${ch.id === currentChannel ? 'active' : ''} ${hasSending ? 'sending' : ''}" onclick="changeChannel(${ch.id})">
<div class="num">${ch.id}</div>
<div class="name">${CHANNEL_NAMES[ch.id] || ''}</div>
<div class="users">${ch.users.length}👤</div>
</div>`;
}).join('');
}
function changeChannel(ch) {
if (!isLoggedIn) return;
socket.emit('leave_channel');
const old = currentChannel;
currentChannel = ch;
socket.emit('join_channel', { channel: ch });
addHistory(myName || 'Ich', `Kanalwechsel CH${old}→CH${ch}`, currentChannel);
updateDisplay(); renderChannels();
showTab('radio');
}
updateDisplay();
</script>
</body>
</html>

9
client/manifest.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "WebCB Funk",
"short_name": "WebCB",
"description": "CB Funk - online und cross-platform",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#00ff88"
}