diff --git a/backend/main.py b/backend/main.py
index eed813d..1d82b9e 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -469,6 +469,7 @@ session = Session()
caller_service = CallerService()
_ai_response_lock = asyncio.Lock() # Prevents concurrent AI responses
_session_epoch = 0 # Increments on hangup/call start — stale tasks check this
+_show_on_air = False # Controls whether phone calls are accepted or get off-air message
# --- News & Research Helpers ---
@@ -551,6 +552,21 @@ async def index():
return FileResponse(frontend_dir / "index.html")
+# --- On-Air Toggle ---
+
+@app.post("/api/on-air")
+async def set_on_air(state: dict):
+ """Toggle whether the show is on air (accepting phone calls)"""
+ global _show_on_air
+ _show_on_air = bool(state.get("on_air", False))
+ print(f"[Show] On-air: {_show_on_air}")
+ return {"on_air": _show_on_air}
+
+@app.get("/api/on-air")
+async def get_on_air():
+ return {"on_air": _show_on_air}
+
+
# --- SignalWire Endpoints ---
@app.post("/api/signalwire/voice")
@@ -561,6 +577,15 @@ async def signalwire_voice_webhook(request: Request):
call_sid = form.get("CallSid", "")
print(f"[SignalWire] Inbound call from {caller_phone} (CallSid: {call_sid})")
+ if not _show_on_air:
+ print(f"[SignalWire] Show is off air — playing off-air message for {caller_phone}")
+ xml = """
+
+ Luke at the Roost is off the air right now. Please call back during the show for your chance to talk to Luke. Thanks for calling!
+
+"""
+ return Response(content=xml, media_type="application/xml")
+
# Use dedicated stream URL (ngrok) if configured, otherwise derive from request
if settings.signalwire_stream_url:
stream_url = settings.signalwire_stream_url
diff --git a/frontend/css/style.css b/frontend/css/style.css
index bc2d783..ad79cc6 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -54,6 +54,27 @@ header button {
cursor: pointer;
}
+.on-air-btn {
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ transition: background 0.2s;
+}
+
+.on-air-btn.off {
+ background: #666 !important;
+}
+
+.on-air-btn.on {
+ background: #cc2222 !important;
+ animation: on-air-pulse 2s ease-in-out infinite;
+}
+
+@keyframes on-air-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
.new-session-btn {
background: var(--accent) !important;
}
diff --git a/frontend/index.html b/frontend/index.html
index 6531719..287e3f1 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -11,6 +11,7 @@
Luke at The Roost
diff --git a/frontend/js/app.js b/frontend/js/app.js
index e2a56a2..a20b275 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -57,6 +57,24 @@ function initEventListeners() {
// New Session
document.getElementById('new-session-btn')?.addEventListener('click', newSession);
+ // On-Air toggle
+ const onAirBtn = document.getElementById('on-air-btn');
+ if (onAirBtn) {
+ fetch('/api/on-air').then(r => r.json()).then(data => {
+ updateOnAirBtn(onAirBtn, data.on_air);
+ });
+ onAirBtn.addEventListener('click', async () => {
+ const isOn = onAirBtn.classList.contains('on');
+ const res = await safeFetch('/api/on-air', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ on_air: !isOn }),
+ });
+ updateOnAirBtn(onAirBtn, res.on_air);
+ log(res.on_air ? 'Show is ON AIR — accepting calls' : 'Show is OFF AIR — calls get off-air message');
+ });
+ }
+
// Server controls
document.getElementById('restart-server-btn')?.addEventListener('click', restartServer);
document.getElementById('stop-server-btn')?.addEventListener('click', stopServer);
@@ -772,6 +790,12 @@ function log(text) {
addMessage('System', text);
}
+function updateOnAirBtn(btn, isOn) {
+ btn.classList.toggle('on', isOn);
+ btn.classList.toggle('off', !isOn);
+ btn.textContent = isOn ? 'ON AIR' : 'OFF AIR';
+}
+
function showStatus(text) {
const status = document.getElementById('status');