Compare commits
8 Commits
0412f4487f
...
d5fd89fc9a
| Author | SHA1 | Date | |
|---|---|---|---|
| d5fd89fc9a | |||
| 0a614eba6d | |||
| e979c4151d | |||
| e6b9401848 | |||
| d14000887c | |||
| 7adf1bbcad | |||
| a94fc92647 | |||
| b0643d6082 |
@@ -3,6 +3,7 @@
|
||||
"input_channel": 1,
|
||||
"output_device": 13,
|
||||
"caller_channel": 3,
|
||||
"live_caller_channel": 9,
|
||||
"music_channel": 5,
|
||||
"sfx_channel": 7,
|
||||
"phone_filter": false
|
||||
|
||||
@@ -20,6 +20,7 @@ class Settings(BaseSettings):
|
||||
signalwire_space: str = os.getenv("SIGNALWIRE_SPACE", "")
|
||||
signalwire_token: str = os.getenv("SIGNALWIRE_TOKEN", "")
|
||||
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
|
||||
signalwire_stream_url: str = os.getenv("SIGNALWIRE_STREAM_URL", "")
|
||||
|
||||
# LLM Settings
|
||||
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
||||
@@ -28,7 +29,7 @@ class Settings(BaseSettings):
|
||||
ollama_host: str = "http://localhost:11434"
|
||||
|
||||
# TTS Settings
|
||||
tts_provider: str = "kokoro" # "kokoro", "elevenlabs", "vits", or "bark"
|
||||
tts_provider: str = "inworld" # "kokoro", "elevenlabs", "inworld", "vits", or "bark"
|
||||
|
||||
# Audio Settings
|
||||
sample_rate: int = 24000
|
||||
|
||||
@@ -40,19 +40,47 @@ app.add_middleware(
|
||||
# Base caller info (name, voice) - backgrounds generated dynamically per session
|
||||
import random
|
||||
|
||||
MALE_NAMES = [
|
||||
"Tony", "Rick", "Dennis", "Earl", "Marcus", "Keith", "Darnell", "Wayne",
|
||||
"Greg", "Andre", "Ray", "Jerome", "Hector", "Travis", "Vince", "Leon",
|
||||
"Dale", "Frank", "Terrence", "Bobby", "Cliff", "Nate", "Reggie", "Carl",
|
||||
]
|
||||
|
||||
FEMALE_NAMES = [
|
||||
"Jasmine", "Megan", "Tanya", "Carla", "Brenda", "Sheila", "Denise", "Tamika",
|
||||
"Lorraine", "Crystal", "Angie", "Renee", "Monique", "Gina", "Patrice", "Deb",
|
||||
"Shonda", "Marlene", "Yolanda", "Stacy", "Jackie", "Carmen", "Rita", "Val",
|
||||
]
|
||||
|
||||
CALLER_BASES = {
|
||||
"1": {"name": "Tony", "voice": "VR6AewLTigWG4xSOukaG", "gender": "male", "age_range": (35, 55)},
|
||||
"2": {"name": "Jasmine", "voice": "jBpfuIE2acCO8z3wKNLl", "gender": "female", "age_range": (25, 38)},
|
||||
"3": {"name": "Rick", "voice": "TxGEqnHWrfWFTfGW9XjX", "gender": "male", "age_range": (40, 58)},
|
||||
"4": {"name": "Megan", "voice": "EXAVITQu4vr4xnSDxMaL", "gender": "female", "age_range": (24, 35)},
|
||||
"5": {"name": "Dennis", "voice": "pNInz6obpgDQGcFmaJgB", "gender": "male", "age_range": (32, 48)},
|
||||
"6": {"name": "Tanya", "voice": "21m00Tcm4TlvDq8ikWAM", "gender": "female", "age_range": (30, 45)},
|
||||
"7": {"name": "Earl", "voice": "ODq5zmih8GrVes37Dizd", "gender": "male", "age_range": (58, 72)},
|
||||
"8": {"name": "Carla", "voice": "XB0fDUnXU5powFXDhCwa", "gender": "female", "age_range": (38, 52)},
|
||||
"9": {"name": "Marcus", "voice": "IKne3meq5aSn9XLyUdCD", "gender": "male", "age_range": (24, 34)},
|
||||
"0": {"name": "Brenda", "voice": "pFZP5JQG7iQjIQuC4Bku", "gender": "female", "age_range": (45, 60)},
|
||||
"1": {"voice": "VR6AewLTigWG4xSOukaG", "gender": "male", "age_range": (35, 55)},
|
||||
"2": {"voice": "jBpfuIE2acCO8z3wKNLl", "gender": "female", "age_range": (25, 38)},
|
||||
"3": {"voice": "TxGEqnHWrfWFTfGW9XjX", "gender": "male", "age_range": (40, 58)},
|
||||
"4": {"voice": "EXAVITQu4vr4xnSDxMaL", "gender": "female", "age_range": (24, 35)},
|
||||
"5": {"voice": "pNInz6obpgDQGcFmaJgB", "gender": "male", "age_range": (32, 48)},
|
||||
"6": {"voice": "21m00Tcm4TlvDq8ikWAM", "gender": "female", "age_range": (30, 45)},
|
||||
"7": {"voice": "ODq5zmih8GrVes37Dizd", "gender": "male", "age_range": (58, 72)},
|
||||
"8": {"voice": "XB0fDUnXU5powFXDhCwa", "gender": "female", "age_range": (38, 52)},
|
||||
"9": {"voice": "IKne3meq5aSn9XLyUdCD", "gender": "male", "age_range": (24, 34)},
|
||||
"0": {"voice": "pFZP5JQG7iQjIQuC4Bku", "gender": "female", "age_range": (45, 60)},
|
||||
}
|
||||
|
||||
|
||||
def _randomize_caller_names():
|
||||
"""Assign random names to callers, unique per gender."""
|
||||
males = random.sample(MALE_NAMES, sum(1 for c in CALLER_BASES.values() if c["gender"] == "male"))
|
||||
females = random.sample(FEMALE_NAMES, sum(1 for c in CALLER_BASES.values() if c["gender"] == "female"))
|
||||
mi, fi = 0, 0
|
||||
for base in CALLER_BASES.values():
|
||||
if base["gender"] == "male":
|
||||
base["name"] = males[mi]
|
||||
mi += 1
|
||||
else:
|
||||
base["name"] = females[fi]
|
||||
fi += 1
|
||||
|
||||
_randomize_caller_names() # Initial assignment
|
||||
|
||||
# Background components for dynamic generation
|
||||
JOBS_MALE = [
|
||||
"runs a small HVAC business", "works as a long-haul trucker", "is a high school football coach",
|
||||
@@ -431,14 +459,17 @@ class Session:
|
||||
if self._research_task and not self._research_task.done():
|
||||
self._research_task.cancel()
|
||||
self._research_task = None
|
||||
_randomize_caller_names()
|
||||
self.id = str(uuid.uuid4())[:8]
|
||||
print(f"[Session] Reset - new session ID: {self.id}")
|
||||
names = [CALLER_BASES[k]["name"] for k in sorted(CALLER_BASES.keys())]
|
||||
print(f"[Session] Reset - new session ID: {self.id}, callers: {', '.join(names)}")
|
||||
|
||||
|
||||
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 ---
|
||||
@@ -521,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")
|
||||
@@ -531,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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="woman">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!</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
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
|
||||
|
||||
@@ -202,7 +202,17 @@ class AudioService:
|
||||
time.sleep(0.05)
|
||||
|
||||
if not self._recorded_audio:
|
||||
print(f"Recording stopped: NO audio chunks captured (piggyback={self._host_stream is not None})")
|
||||
piggyback = self._host_stream is not None
|
||||
# Check what other streams might be active
|
||||
active_streams = []
|
||||
if self._music_stream:
|
||||
active_streams.append("music")
|
||||
if self._live_caller_stream:
|
||||
active_streams.append("live_caller")
|
||||
if self._host_stream:
|
||||
active_streams.append("host")
|
||||
streams_info = f", active_streams=[{','.join(active_streams)}]" if active_streams else ""
|
||||
print(f"Recording stopped: NO audio chunks captured (piggyback={piggyback}, device={self.input_device}, ch={self.input_channel}{streams_info})")
|
||||
return b""
|
||||
|
||||
# Combine all chunks
|
||||
@@ -228,17 +238,28 @@ class AudioService:
|
||||
device_sr = int(device_info['default_samplerate'])
|
||||
record_channel = min(self.input_channel, max_channels) - 1
|
||||
|
||||
if max_channels == 0:
|
||||
print(f"Recording error: device {self.input_device} has no input channels")
|
||||
self._recording = False
|
||||
return
|
||||
|
||||
# Store device sample rate for later resampling
|
||||
self._record_device_sr = device_sr
|
||||
|
||||
print(f"Recording from device {self.input_device} ch {self.input_channel} @ {device_sr}Hz")
|
||||
stream_ready = threading.Event()
|
||||
callback_count = [0]
|
||||
|
||||
def callback(indata, frames, time_info, status):
|
||||
if status:
|
||||
print(f"Record status: {status}")
|
||||
callback_count[0] += 1
|
||||
if not stream_ready.is_set():
|
||||
stream_ready.set()
|
||||
if self._recording:
|
||||
self._recorded_audio.append(indata[:, record_channel].copy())
|
||||
|
||||
print(f"Recording: opening stream on device {self.input_device} ch {self.input_channel} @ {device_sr}Hz ({max_channels} ch)")
|
||||
|
||||
with sd.InputStream(
|
||||
device=self.input_device,
|
||||
channels=max_channels,
|
||||
@@ -247,11 +268,19 @@ class AudioService:
|
||||
callback=callback,
|
||||
blocksize=1024
|
||||
):
|
||||
# Wait for stream to actually start capturing
|
||||
if not stream_ready.wait(timeout=1.0):
|
||||
print(f"Recording WARNING: stream opened but callback not firing after 1s")
|
||||
|
||||
while self._recording:
|
||||
time.sleep(0.05)
|
||||
|
||||
print(f"Recording: stream closed, {callback_count[0]} callbacks fired, {len(self._recorded_audio)} chunks captured")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Recording error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._recording = False
|
||||
|
||||
# --- Caller TTS Playback ---
|
||||
@@ -463,10 +492,10 @@ class AudioService:
|
||||
record_channel = min(self.input_channel, max_channels) - 1
|
||||
step = max(1, int(device_sr / 16000))
|
||||
|
||||
# Buffer host mic to send ~60ms chunks instead of tiny 21ms ones
|
||||
# Buffer host mic to send ~100ms chunks (reduces WebSocket frame rate)
|
||||
host_accum = []
|
||||
host_accum_samples = [0]
|
||||
send_threshold = 960 # 60ms at 16kHz
|
||||
send_threshold = 1600 # 100ms at 16kHz
|
||||
|
||||
def callback(indata, frames, time_info, status):
|
||||
# Capture for push-to-talk recording if active
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Phone caller queue and audio stream service"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import numpy as np
|
||||
@@ -20,7 +22,20 @@ class CallerService:
|
||||
self._lock = threading.Lock()
|
||||
self._websockets: dict[str, any] = {} # caller_id -> WebSocket
|
||||
self._call_sids: dict[str, str] = {} # caller_id -> SignalWire callSid
|
||||
self.streaming_tts: bool = False # True while TTS audio is being streamed
|
||||
self._stream_sids: dict[str, str] = {} # caller_id -> SignalWire streamSid
|
||||
self._send_locks: dict[str, asyncio.Lock] = {} # per-caller send lock
|
||||
self._streaming_tts: set[str] = set() # caller_ids currently receiving TTS
|
||||
|
||||
def _get_send_lock(self, caller_id: str) -> asyncio.Lock:
|
||||
if caller_id not in self._send_locks:
|
||||
self._send_locks[caller_id] = asyncio.Lock()
|
||||
return self._send_locks[caller_id]
|
||||
|
||||
def is_streaming_tts(self, caller_id: str) -> bool:
|
||||
return caller_id in self._streaming_tts
|
||||
|
||||
def is_streaming_tts_any(self) -> bool:
|
||||
return len(self._streaming_tts) > 0
|
||||
|
||||
def add_to_queue(self, caller_id: str, phone: str):
|
||||
with self._lock:
|
||||
@@ -94,6 +109,8 @@ class CallerService:
|
||||
print(f"[Caller] {call_info['phone']} hung up — channel {call_info['channel']} released")
|
||||
self._websockets.pop(caller_id, None)
|
||||
self._call_sids.pop(caller_id, None)
|
||||
self._stream_sids.pop(caller_id, None)
|
||||
self._send_locks.pop(caller_id, None)
|
||||
|
||||
def reset(self):
|
||||
with self._lock:
|
||||
@@ -105,6 +122,9 @@ class CallerService:
|
||||
self._caller_counter = 0
|
||||
self._websockets.clear()
|
||||
self._call_sids.clear()
|
||||
self._stream_sids.clear()
|
||||
self._send_locks.clear()
|
||||
self._streaming_tts.clear()
|
||||
print("[Caller] Service reset")
|
||||
|
||||
def register_websocket(self, caller_id: str, websocket):
|
||||
@@ -119,29 +139,34 @@ class CallerService:
|
||||
"""Send small audio chunk to caller via SignalWire WebSocket.
|
||||
Encodes L16 PCM as base64 JSON per SignalWire protocol.
|
||||
"""
|
||||
if caller_id in self._streaming_tts:
|
||||
return # Don't send host audio during TTS streaming
|
||||
|
||||
ws = self._websockets.get(caller_id)
|
||||
if not ws:
|
||||
return
|
||||
|
||||
try:
|
||||
import base64
|
||||
if sample_rate != 16000:
|
||||
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
ratio = 16000 / sample_rate
|
||||
out_len = int(len(audio) * ratio)
|
||||
indices = (np.arange(out_len) / ratio).astype(int)
|
||||
indices = np.clip(indices, 0, len(audio) - 1)
|
||||
audio = audio[indices]
|
||||
pcm_data = (audio * 32767).astype(np.int16).tobytes()
|
||||
lock = self._get_send_lock(caller_id)
|
||||
async with lock:
|
||||
try:
|
||||
if sample_rate != 16000:
|
||||
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
ratio = 16000 / sample_rate
|
||||
out_len = int(len(audio) * ratio)
|
||||
indices = (np.arange(out_len) / ratio).astype(int)
|
||||
indices = np.clip(indices, 0, len(audio) - 1)
|
||||
audio = audio[indices]
|
||||
pcm_data = (audio * 32767).astype(np.int16).tobytes()
|
||||
|
||||
payload = base64.b64encode(pcm_data).decode('ascii')
|
||||
import json
|
||||
await ws.send_text(json.dumps({
|
||||
"event": "media",
|
||||
"media": {"payload": payload}
|
||||
}))
|
||||
except Exception as e:
|
||||
print(f"[Caller] Failed to send audio: {e}")
|
||||
payload = base64.b64encode(pcm_data).decode('ascii')
|
||||
stream_sid = self._stream_sids.get(caller_id, "")
|
||||
await ws.send_text(json.dumps({
|
||||
"event": "media",
|
||||
"streamSid": stream_sid,
|
||||
"media": {"payload": payload}
|
||||
}))
|
||||
except Exception as e:
|
||||
print(f"[Caller] Failed to send audio: {e}")
|
||||
|
||||
async def stream_audio_to_caller(self, caller_id: str, pcm_data: bytes, sample_rate: int):
|
||||
"""Stream large audio (TTS) to caller in real-time chunks via SignalWire WebSocket."""
|
||||
@@ -149,10 +174,10 @@ class CallerService:
|
||||
if not ws:
|
||||
return
|
||||
|
||||
self.streaming_tts = True
|
||||
lock = self._get_send_lock(caller_id)
|
||||
self._streaming_tts.add(caller_id)
|
||||
chunks_sent = 0
|
||||
try:
|
||||
import base64
|
||||
import json
|
||||
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
if sample_rate != 16000:
|
||||
ratio = 16000 / sample_rate
|
||||
@@ -161,23 +186,40 @@ class CallerService:
|
||||
indices = np.clip(indices, 0, len(audio) - 1)
|
||||
audio = audio[indices]
|
||||
|
||||
total_chunks = (len(audio) + 959) // 960
|
||||
duration_s = len(audio) / 16000
|
||||
print(f"[Caller] TTS stream starting: {duration_s:.1f}s audio, {total_chunks} chunks")
|
||||
|
||||
chunk_samples = 960
|
||||
chunk_duration = chunk_samples / 16000 # 60ms per chunk
|
||||
|
||||
for i in range(0, len(audio), chunk_samples):
|
||||
if caller_id not in self._websockets:
|
||||
print(f"[Caller] TTS stream aborted: caller {caller_id} disconnected at chunk {chunks_sent}/{total_chunks}")
|
||||
break
|
||||
t0 = time.time()
|
||||
chunk = audio[i:i + chunk_samples]
|
||||
pcm_chunk = (chunk * 32767).astype(np.int16).tobytes()
|
||||
payload = base64.b64encode(pcm_chunk).decode('ascii')
|
||||
await ws.send_text(json.dumps({
|
||||
"event": "media",
|
||||
"media": {"payload": payload}
|
||||
}))
|
||||
await asyncio.sleep(0.055)
|
||||
stream_sid = self._stream_sids.get(caller_id, "")
|
||||
async with lock:
|
||||
await ws.send_text(json.dumps({
|
||||
"event": "media",
|
||||
"streamSid": stream_sid,
|
||||
"media": {"payload": payload}
|
||||
}))
|
||||
chunks_sent += 1
|
||||
# Sleep to match real-time playback rate
|
||||
elapsed = time.time() - t0
|
||||
sleep_time = max(0, chunk_duration - elapsed)
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
print(f"[Caller] TTS stream finished: {chunks_sent}/{total_chunks} chunks sent")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Caller] Failed to stream audio: {e}")
|
||||
print(f"[Caller] TTS stream failed at chunk {chunks_sent}: {e}")
|
||||
finally:
|
||||
self.streaming_tts = False
|
||||
self._streaming_tts.discard(caller_id)
|
||||
|
||||
def register_call_sid(self, caller_id: str, call_sid: str):
|
||||
"""Track SignalWire callSid for a caller"""
|
||||
@@ -190,3 +232,11 @@ class CallerService:
|
||||
def unregister_call_sid(self, caller_id: str):
|
||||
"""Remove callSid tracking"""
|
||||
self._call_sids.pop(caller_id, None)
|
||||
|
||||
def register_stream_sid(self, caller_id: str, stream_sid: str):
|
||||
"""Track SignalWire streamSid for a caller"""
|
||||
self._stream_sids[caller_id] = stream_sid
|
||||
|
||||
def unregister_stream_sid(self, caller_id: str):
|
||||
"""Remove streamSid tracking"""
|
||||
self._stream_sids.pop(caller_id, None)
|
||||
|
||||
@@ -7,10 +7,10 @@ from ..config import settings
|
||||
|
||||
# Available OpenRouter models
|
||||
OPENROUTER_MODELS = [
|
||||
"anthropic/claude-3-haiku",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/gpt-4o",
|
||||
"anthropic/claude-3-haiku",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"google/gemini-flash-1.5",
|
||||
"google/gemini-pro-1.5",
|
||||
"meta-llama/llama-3.1-8b-instruct",
|
||||
@@ -114,7 +114,7 @@ class LLMService:
|
||||
"""Call OpenRouter API with retry"""
|
||||
for attempt in range(2): # Try twice
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
@@ -124,12 +124,16 @@ class LLMService:
|
||||
json={
|
||||
"model": self.openrouter_model,
|
||||
"messages": messages,
|
||||
"max_tokens": 100,
|
||||
"max_tokens": 150,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
if not content or not content.strip():
|
||||
print(f"OpenRouter returned empty response")
|
||||
return ""
|
||||
return content
|
||||
except (httpx.TimeoutException, httpx.ReadTimeout):
|
||||
print(f"OpenRouter timeout (attempt {attempt + 1})")
|
||||
if attempt == 0:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Radio Show</title>
|
||||
<title>Luke at The Roost</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<h1>AI Radio Show</h1>
|
||||
<h1>Luke at The Roost</h1>
|
||||
<div class="header-buttons">
|
||||
<button id="on-air-btn" class="on-air-btn off">OFF AIR</button>
|
||||
<button id="new-session-btn" class="new-session-btn">New Session</button>
|
||||
<button id="settings-btn">Settings</button>
|
||||
</div>
|
||||
@@ -53,7 +54,7 @@
|
||||
|
||||
<!-- Call Queue -->
|
||||
<section class="queue-section">
|
||||
<h2>Incoming Calls</h2>
|
||||
<h2>Incoming Calls <span style="font-size:0.6em;font-weight:normal;color:var(--text-muted);">(208) 439-5853</span></h2>
|
||||
<div id="call-queue" class="call-queue">
|
||||
<div class="queue-empty">No callers waiting</div>
|
||||
</div>
|
||||
@@ -207,6 +208,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/app.js?v=13"></script>
|
||||
<script src="/js/app.js?v=15"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -16,6 +16,21 @@ let tracks = [];
|
||||
let sounds = [];
|
||||
|
||||
|
||||
// --- Safe JSON parsing ---
|
||||
async function safeFetch(url, options = {}) {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let detail = text;
|
||||
try { detail = JSON.parse(text).detail || text; } catch {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!text) return {};
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
|
||||
// --- Init ---
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
console.log('AI Radio Show initializing...');
|
||||
@@ -42,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);
|
||||
@@ -108,6 +141,15 @@ function initEventListeners() {
|
||||
log('Real caller disconnected');
|
||||
});
|
||||
|
||||
// AI caller hangup (small button in AI caller panel)
|
||||
document.getElementById('hangup-ai-btn')?.addEventListener('click', hangup);
|
||||
|
||||
// AI respond button (manual trigger)
|
||||
document.getElementById('ai-respond-btn')?.addEventListener('click', triggerAiRespond);
|
||||
|
||||
// Start conversation update polling
|
||||
startConversationPolling();
|
||||
|
||||
// AI respond mode toggle
|
||||
document.getElementById('mode-manual')?.addEventListener('click', () => {
|
||||
document.getElementById('mode-manual')?.classList.add('active');
|
||||
@@ -123,7 +165,6 @@ function initEventListeners() {
|
||||
document.getElementById('mode-auto')?.addEventListener('click', () => {
|
||||
document.getElementById('mode-auto')?.classList.add('active');
|
||||
document.getElementById('mode-manual')?.classList.remove('active');
|
||||
document.getElementById('ai-respond-btn')?.classList.add('hidden');
|
||||
fetch('/api/session/ai-mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -362,6 +403,7 @@ async function newSession() {
|
||||
}
|
||||
|
||||
await fetch('/api/session/reset', { method: 'POST' });
|
||||
conversationSince = 0;
|
||||
|
||||
// Hide caller background
|
||||
const bgEl = document.getElementById('caller-background');
|
||||
@@ -434,8 +476,7 @@ async function stopRecording() {
|
||||
|
||||
try {
|
||||
// Stop recording and get transcription
|
||||
const res = await fetch('/api/record/stop', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
const data = await safeFetch('/api/record/stop', { method: 'POST' });
|
||||
|
||||
if (!data.text) {
|
||||
log('(No speech detected)');
|
||||
@@ -449,12 +490,11 @@ async function stopRecording() {
|
||||
// Chat
|
||||
showStatus(`${currentCaller.name} is thinking...`);
|
||||
|
||||
const chatRes = await fetch('/api/chat', {
|
||||
const chatData = await safeFetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: data.text })
|
||||
});
|
||||
const chatData = await chatRes.json();
|
||||
|
||||
addMessage(chatData.caller, chatData.text);
|
||||
|
||||
@@ -462,7 +502,7 @@ async function stopRecording() {
|
||||
if (chatData.text && chatData.text.trim()) {
|
||||
showStatus(`${currentCaller.name} is speaking...`);
|
||||
|
||||
await fetch('/api/tts', {
|
||||
await safeFetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -496,12 +536,11 @@ async function sendTypedMessage() {
|
||||
try {
|
||||
showStatus(`${currentCaller.name} is thinking...`);
|
||||
|
||||
const chatRes = await fetch('/api/chat', {
|
||||
const chatData = await safeFetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
const chatData = await chatRes.json();
|
||||
|
||||
addMessage(chatData.caller, chatData.text);
|
||||
|
||||
@@ -509,7 +548,7 @@ async function sendTypedMessage() {
|
||||
if (chatData.text && chatData.text.trim()) {
|
||||
showStatus(`${currentCaller.name} is speaking...`);
|
||||
|
||||
await fetch('/api/tts', {
|
||||
await safeFetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -538,6 +577,8 @@ async function loadMusic() {
|
||||
|
||||
const select = document.getElementById('track-select');
|
||||
if (!select) return;
|
||||
|
||||
const previousValue = select.value;
|
||||
select.innerHTML = '';
|
||||
|
||||
tracks.forEach((track, i) => {
|
||||
@@ -546,6 +587,12 @@ async function loadMusic() {
|
||||
option.textContent = track.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
// Restore previous selection if it still exists
|
||||
if (previousValue && [...select.options].some(o => o.value === previousValue)) {
|
||||
select.value = previousValue;
|
||||
}
|
||||
|
||||
console.log('Loaded', tracks.length, 'tracks');
|
||||
} catch (err) {
|
||||
console.error('loadMusic error:', err);
|
||||
@@ -554,6 +601,7 @@ async function loadMusic() {
|
||||
|
||||
|
||||
async function playMusic() {
|
||||
await loadMusic();
|
||||
const select = document.getElementById('track-select');
|
||||
const track = select?.value;
|
||||
if (!track) return;
|
||||
@@ -742,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');
|
||||
@@ -894,6 +948,19 @@ async function takeCall(callerId) {
|
||||
if (data.status === 'on_air') {
|
||||
showRealCaller(data.caller);
|
||||
log(`${data.caller.phone} is on air — Channel ${data.caller.channel}`);
|
||||
|
||||
// Auto-select an AI caller if none is active
|
||||
if (!currentCaller) {
|
||||
const callerBtns = document.querySelectorAll('.caller-btn');
|
||||
if (callerBtns.length > 0) {
|
||||
const randomIdx = Math.floor(Math.random() * callerBtns.length);
|
||||
const btn = callerBtns[randomIdx];
|
||||
const key = btn.dataset.key;
|
||||
const name = btn.textContent;
|
||||
log(`Auto-selecting ${name} as AI caller`);
|
||||
await startCall(key, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log('Failed to take call: ' + err.message);
|
||||
@@ -962,6 +1029,66 @@ function hideRealCaller() {
|
||||
}
|
||||
|
||||
|
||||
// --- AI Respond (manual trigger) ---
|
||||
async function triggerAiRespond() {
|
||||
if (!currentCaller) {
|
||||
log('No AI caller active — click a caller first');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('ai-respond-btn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Thinking...'; }
|
||||
showStatus(`${currentCaller.name} is thinking...`);
|
||||
|
||||
try {
|
||||
const data = await safeFetch('/api/ai-respond', { method: 'POST' });
|
||||
if (data.text) {
|
||||
addMessage(data.caller, data.text);
|
||||
showStatus(`${data.caller} is speaking...`);
|
||||
const duration = data.text.length * 60;
|
||||
setTimeout(hideStatus, Math.min(duration, 15000));
|
||||
}
|
||||
} catch (err) {
|
||||
log('AI respond error: ' + err.message);
|
||||
}
|
||||
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Let them respond'; }
|
||||
}
|
||||
|
||||
|
||||
// --- Conversation Update Polling ---
|
||||
let conversationSince = 0;
|
||||
|
||||
function startConversationPolling() {
|
||||
setInterval(fetchConversationUpdates, 1000);
|
||||
}
|
||||
|
||||
async function fetchConversationUpdates() {
|
||||
try {
|
||||
const res = await fetch(`/api/conversation/updates?since=${conversationSince}`);
|
||||
const data = await res.json();
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
for (const msg of data.messages) {
|
||||
conversationSince = msg.id + 1;
|
||||
if (msg.type === 'caller_disconnected') {
|
||||
hideRealCaller();
|
||||
log(`${msg.phone} disconnected (${msg.reason})`);
|
||||
} else if (msg.type === 'chat') {
|
||||
addMessage(msg.sender, msg.text);
|
||||
} else if (msg.type === 'ai_status') {
|
||||
showStatus(msg.text);
|
||||
} else if (msg.type === 'ai_done') {
|
||||
hideStatus();
|
||||
} else if (msg.type === 'caller_queued') {
|
||||
// Queue poll will pick this up, just ensure it refreshes
|
||||
fetchQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
|
||||
async function stopServer() {
|
||||
if (!confirm('Stop the server? You will need to restart it manually.')) return;
|
||||
|
||||
|
||||
12
functions/feed.js
Normal file
12
functions/feed.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export async function onRequest() {
|
||||
const feedUrl = 'https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml';
|
||||
const res = await fetch(feedUrl);
|
||||
const xml = await res.text();
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -155,11 +155,12 @@ Respond with ONLY valid JSON, no markdown or explanation."""
|
||||
return metadata
|
||||
|
||||
|
||||
def create_episode(audio_path: str, metadata: dict, duration: int) -> dict:
|
||||
def create_episode(audio_path: str, metadata: dict, episode_number: int) -> dict:
|
||||
"""Create episode on Castopod."""
|
||||
print("[3/5] Creating episode on Castopod...")
|
||||
|
||||
headers = get_auth_header()
|
||||
slug = re.sub(r'[^a-z0-9]+', '-', metadata["title"].lower()).strip('-')
|
||||
|
||||
# Upload audio and create episode
|
||||
with open(audio_path, "rb") as f:
|
||||
@@ -168,21 +169,25 @@ def create_episode(audio_path: str, metadata: dict, duration: int) -> dict:
|
||||
}
|
||||
data = {
|
||||
"title": metadata["title"],
|
||||
"description_markdown": metadata["description"],
|
||||
"slug": slug,
|
||||
"description": metadata["description"],
|
||||
"parental_advisory": "explicit",
|
||||
"type": "full",
|
||||
"created_by": "1"
|
||||
"podcast_id": str(PODCAST_ID),
|
||||
"created_by": "1",
|
||||
"updated_by": "1",
|
||||
"episode_number": str(episode_number),
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{CASTOPOD_URL}/api/rest/v1/podcasts/{PODCAST_ID}/episodes",
|
||||
f"{CASTOPOD_URL}/api/rest/v1/episodes",
|
||||
headers=headers,
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
print(f"Error creating episode: {response.text}")
|
||||
print(f"Error creating episode: {response.status_code} {response.text}")
|
||||
sys.exit(1)
|
||||
|
||||
episode = response.json()
|
||||
@@ -312,7 +317,7 @@ def get_next_episode_number() -> int:
|
||||
headers = get_auth_header()
|
||||
|
||||
response = requests.get(
|
||||
f"{CASTOPOD_URL}/api/rest/v1/podcasts/{PODCAST_ID}/episodes",
|
||||
f"{CASTOPOD_URL}/api/rest/v1/episodes",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
@@ -323,7 +328,12 @@ def get_next_episode_number() -> int:
|
||||
if not episodes:
|
||||
return 1
|
||||
|
||||
max_num = max(ep.get("number", 0) for ep in episodes)
|
||||
# Filter to our podcast
|
||||
our_episodes = [ep for ep in episodes if ep.get("podcast_id") == PODCAST_ID]
|
||||
if not our_episodes:
|
||||
return 1
|
||||
|
||||
max_num = max(ep.get("number", 0) or 0 for ep in our_episodes)
|
||||
return max_num + 1
|
||||
|
||||
|
||||
@@ -373,7 +383,7 @@ def main():
|
||||
return
|
||||
|
||||
# Step 3: Create episode
|
||||
episode = create_episode(str(audio_path), metadata, transcript["duration"])
|
||||
episode = create_episode(str(audio_path), metadata, episode_number)
|
||||
|
||||
# Step 4: Publish
|
||||
episode = publish_episode(episode["id"])
|
||||
|
||||
45
run.sh
45
run.sh
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
# AI Radio Show - Server Runner with restart support
|
||||
|
||||
PORT=8000
|
||||
LOG_FILE="/tmp/ai-radio-show.log"
|
||||
RESTART_FLAG="/tmp/ai-radio-show.restart"
|
||||
STOP_FLAG="/tmp/ai-radio-show.stop"
|
||||
@@ -13,16 +14,46 @@ source venv/bin/activate
|
||||
# Cleanup old flags
|
||||
rm -f "$RESTART_FLAG" "$STOP_FLAG"
|
||||
|
||||
# Check if port is already in use
|
||||
if lsof -i ":$PORT" -sTCP:LISTEN -t >/dev/null 2>&1; then
|
||||
EXISTING_PID=$(lsof -i ":$PORT" -sTCP:LISTEN -t 2>/dev/null | head -1)
|
||||
echo "ERROR: Port $PORT is already in use by PID $EXISTING_PID"
|
||||
echo "Run: kill $EXISTING_PID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
kill_server() {
|
||||
local pid=$1
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
return
|
||||
fi
|
||||
kill "$pid" 2>/dev/null
|
||||
# Wait up to 5 seconds for graceful shutdown
|
||||
for i in $(seq 1 10); do
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
return
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
# Force kill if still alive
|
||||
echo "[$(date)] Server didn't stop gracefully, force killing..." | tee -a "$LOG_FILE"
|
||||
kill -9 "$pid" 2>/dev/null
|
||||
wait "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
echo "AI Radio Show Server Runner"
|
||||
echo "Log file: $LOG_FILE"
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
while true; do
|
||||
echo "[$(date)] Starting server..." | tee -a "$LOG_FILE"
|
||||
# Handle Ctrl+C
|
||||
trap 'echo ""; echo "[$(date)] Interrupted" | tee -a "$LOG_FILE"; kill_server $SERVER_PID; exit 0' INT TERM
|
||||
|
||||
# Start uvicorn with output to both console and log file
|
||||
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 2>&1 | tee -a "$LOG_FILE" &
|
||||
while true; do
|
||||
echo "[$(date)] Starting server on port $PORT..." | tee -a "$LOG_FILE"
|
||||
|
||||
# Start uvicorn directly (not through tee pipe so we get the real PID)
|
||||
python -m uvicorn backend.main:app --host 0.0.0.0 --port $PORT >> "$LOG_FILE" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to exit or restart signal
|
||||
@@ -30,8 +61,7 @@ while true; do
|
||||
if [ -f "$RESTART_FLAG" ]; then
|
||||
echo "[$(date)] Restart requested..." | tee -a "$LOG_FILE"
|
||||
rm -f "$RESTART_FLAG"
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
wait $SERVER_PID 2>/dev/null
|
||||
kill_server $SERVER_PID
|
||||
sleep 1
|
||||
break
|
||||
fi
|
||||
@@ -39,8 +69,7 @@ while true; do
|
||||
if [ -f "$STOP_FLAG" ]; then
|
||||
echo "[$(date)] Stop requested..." | tee -a "$LOG_FILE"
|
||||
rm -f "$STOP_FLAG"
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
wait $SERVER_PID 2>/dev/null
|
||||
kill_server $SERVER_PID
|
||||
echo "[$(date)] Server stopped." | tee -a "$LOG_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
411
website/css/style.css
Normal file
411
website/css/style.css
Normal file
@@ -0,0 +1,411 @@
|
||||
:root {
|
||||
--bg: #1a1209;
|
||||
--bg-light: #2a2015;
|
||||
--accent: #e8791d;
|
||||
--accent-hover: #f59a4a;
|
||||
--accent-red: #cc2222;
|
||||
--text: #f5f0e5;
|
||||
--text-muted: #9a8b78;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Banner */
|
||||
.banner {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
padding: 3rem 1.5rem 2rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.cover-art {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 32px rgba(232, 121, 29, 0.35);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.hero-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.15rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.phone {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.phone-number {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 800;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.02em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.phone-digits {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.phone-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Subscribe buttons */
|
||||
.subscribe-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.subscribe-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 50px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.subscribe-btn:hover {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-1px);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.subscribe-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-spotify { background: #1DB954; }
|
||||
.btn-youtube { background: #FF0000; }
|
||||
.btn-apple { background: #A033FF; }
|
||||
.btn-rss { background: #f26522; }
|
||||
|
||||
/* Episodes */
|
||||
.episodes-section {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 8rem;
|
||||
}
|
||||
|
||||
.episodes-section h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.episodes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.episode-card {
|
||||
background: var(--bg-light);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.episode-card:hover {
|
||||
background: #352a1c;
|
||||
}
|
||||
|
||||
.episode-play-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.episode-play-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.episode-play-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.episode-play-btn.playing svg {
|
||||
/* pause icon swap handled by JS */
|
||||
}
|
||||
|
||||
.episode-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.episode-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.episode-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.episode-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.episodes-loading {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.episodes-error {
|
||||
text-align: center;
|
||||
color: var(--accent);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
/* Sticky Player */
|
||||
.sticky-player {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #150f06;
|
||||
border-top: 1px solid #333;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sticky-player.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.player-inner {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.player-play-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.player-play-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.player-play-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.player-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.player-progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.player-progress {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: #333;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
width: 0%;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.player-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 10rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px solid #2a2015;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.footer-contact {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.footer-contact a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.footer-contact a:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Desktop */
|
||||
@media (min-width: 768px) {
|
||||
.hero {
|
||||
padding: 4rem 2rem 2.5rem;
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
.hero-info {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cover-art {
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.subscribe-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.episodes-section {
|
||||
padding: 2rem 2rem 8rem;
|
||||
}
|
||||
}
|
||||
BIN
website/images/banner.png
Normal file
BIN
website/images/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
112
website/index.html
Normal file
112
website/index.html
Normal file
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Luke at the Roost — The ONLY radio show where Luke gives AI life advice</title>
|
||||
<meta name="description" content="The ONLY radio show where Luke gives AI life advice. Call in: 208-439-LUKE">
|
||||
|
||||
<!-- OG / Social -->
|
||||
<meta property="og:title" content="Luke at the Roost">
|
||||
<meta property="og:description" content="The ONLY radio show where Luke gives AI life advice. Call in: 208-439-LUKE">
|
||||
<meta property="og:image" content="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=2">
|
||||
<meta property="og:url" content="https://lukeattheroost.com">
|
||||
<meta property="og:type" content="website">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Luke at the Roost">
|
||||
<meta name="twitter:description" content="The ONLY radio show where Luke gives AI life advice. Call in: 208-439-LUKE">
|
||||
<meta name="twitter:image" content="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=2">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath d='M32 4c-2 0-4 2-4 5 0 1 .3 2 .8 3C26 13 24 16 24 20c0 2 .5 4 1.5 5.5L22 28c-2 1-4 3-5 6l-3 10c-.5 2 .5 3 2 3h4l1-4 2 4h6l-1-6 3 6h6l-1-6 3 6h4c1.5 0 2.5-1 2-3l-3-10c-1-3-3-5-5-6l-3.5-2.5C35.5 24 36 22 36 20c0-4-2-7-4.8-8 .5-1 .8-2 .8-3 0-3-2-5-4-5z' fill='%23e8791d'/%3E%3Ccircle cx='30' cy='17' r='1.5' fill='%231a1209'/%3E%3Cpath d='M36 15c1-1 3-1 4 0s1 3 0 4' fill='none' stroke='%23cc2222' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M28 22c2 1 4 1 6 0' fill='none' stroke='%23e8791d' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Banner -->
|
||||
<div class="banner">
|
||||
<img src="images/banner.png" alt="Luke at the Roost — ON AIR" class="banner-img">
|
||||
</div>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<div class="hero-inner">
|
||||
<img
|
||||
class="cover-art"
|
||||
src="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=2"
|
||||
alt="Luke at the Roost cover art"
|
||||
>
|
||||
<div class="hero-info">
|
||||
<h1>Luke at the Roost</h1>
|
||||
<p class="tagline">The ONLY radio show where Luke gives AI life advice.</p>
|
||||
<div class="phone">
|
||||
<span class="phone-label">Call in live</span>
|
||||
<span class="phone-number">208-439-LUKE</span>
|
||||
<span class="phone-digits">(208-439-5853)</span>
|
||||
</div>
|
||||
<div class="subscribe-row">
|
||||
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="subscribe-btn btn-spotify">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>
|
||||
Spotify
|
||||
</a>
|
||||
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="subscribe-btn btn-youtube">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
YouTube
|
||||
</a>
|
||||
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="subscribe-btn btn-apple">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.34 0A5.328 5.328 0 0 0 0 5.34v13.32A5.328 5.328 0 0 0 5.34 24h13.32A5.328 5.328 0 0 0 24 18.66V5.34A5.328 5.328 0 0 0 18.66 0zm6.525 2.568c2.336 0 4.448.902 4.448 3.545 0 1.497-.89 2.67-1.916 3.545-.663.566-.795 .84-.795 1.347 0 .6.397 1.173.894 1.722 1.417 1.564 1.96 2.853 1.96 4.448 0 3.063-2.673 4.257-5.165 4.257-.315 0-.658-.02-.994-.063-1.523-.195-2.86-.9-3.632-.9-.82 0-1.98.623-3.377.87A5.715 5.715 0 0 1 3.15 21.4c-1.27 0-2.1-.96-2.1-2.663 0-1.2.6-2.7 1.845-4.29.63-.81 1.62-1.83 2.91-2.31-.06-.6-.09-1.14-.09-1.62 0-4.28 2.76-7.95 6.15-7.95z"/></svg>
|
||||
Apple
|
||||
</a>
|
||||
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener" class="subscribe-btn btn-rss">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248S0 22.546 0 20.752s1.456-3.248 3.252-3.248 3.251 1.454 3.251 3.248zM.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594C14.524 16.21 7.849 9.636.002 9.473zM.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604C23.834 10.952 13.054.166.006 0z"/></svg>
|
||||
RSS
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Episodes -->
|
||||
<section class="episodes-section">
|
||||
<h2>Episodes</h2>
|
||||
<div class="episodes-list" id="episodes-list">
|
||||
<div class="episodes-loading">Loading episodes...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="footer-links">
|
||||
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
|
||||
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
|
||||
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
|
||||
</div>
|
||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
||||
<p>© 2026 Luke at the Roost</p>
|
||||
</footer>
|
||||
|
||||
<!-- Sticky Audio Player -->
|
||||
<div class="sticky-player" id="sticky-player">
|
||||
<div class="player-inner">
|
||||
<button class="player-play-btn" id="player-play-btn" aria-label="Play/Pause">
|
||||
<svg class="icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
<svg class="icon-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
</button>
|
||||
<div class="player-info">
|
||||
<div class="player-title" id="player-title">—</div>
|
||||
<div class="player-progress-row">
|
||||
<div class="player-progress" id="player-progress">
|
||||
<div class="player-progress-fill" id="player-progress-fill"></div>
|
||||
</div>
|
||||
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio id="audio-element" preload="none"></audio>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
227
website/js/app.js
Normal file
227
website/js/app.js
Normal file
@@ -0,0 +1,227 @@
|
||||
const FEED_URL = '/feed';
|
||||
|
||||
const audio = document.getElementById('audio-element');
|
||||
const stickyPlayer = document.getElementById('sticky-player');
|
||||
const playerPlayBtn = document.getElementById('player-play-btn');
|
||||
const playerTitle = document.getElementById('player-title');
|
||||
const playerProgress = document.getElementById('player-progress');
|
||||
const playerProgressFill = document.getElementById('player-progress-fill');
|
||||
const playerTime = document.getElementById('player-time');
|
||||
const episodesList = document.getElementById('episodes-list');
|
||||
|
||||
let currentEpisodeCard = null;
|
||||
|
||||
// Format seconds to M:SS or H:MM:SS
|
||||
function formatTime(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const s = Math.floor(seconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Format duration from itunes:duration (could be seconds or HH:MM:SS)
|
||||
function parseDuration(raw) {
|
||||
if (!raw) return '';
|
||||
if (raw.includes(':')) {
|
||||
const parts = raw.split(':').map(Number);
|
||||
let totalSec = 0;
|
||||
if (parts.length === 3) totalSec = parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
else if (parts.length === 2) totalSec = parts[0] * 60 + parts[1];
|
||||
return `${Math.round(totalSec / 60)} min`;
|
||||
}
|
||||
const sec = parseInt(raw, 10);
|
||||
if (isNaN(sec)) return '';
|
||||
return `${Math.round(sec / 60)} min`;
|
||||
}
|
||||
|
||||
// Format date nicely
|
||||
function formatDate(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
// Strip HTML tags and truncate
|
||||
function truncate(html, maxLen) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html || '';
|
||||
const text = div.textContent || '';
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen).trimEnd() + '...';
|
||||
}
|
||||
|
||||
// SVG icons
|
||||
const playSVG = '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>';
|
||||
const pauseSVG = '<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>';
|
||||
|
||||
// Fetch with timeout
|
||||
function fetchWithTimeout(url, ms = 8000) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), ms);
|
||||
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
|
||||
}
|
||||
|
||||
// Fetch and parse RSS feed
|
||||
async function fetchEpisodes() {
|
||||
let xml;
|
||||
const maxRetries = 2;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const res = await fetchWithTimeout(FEED_URL);
|
||||
if (!res.ok) throw new Error('Fetch failed');
|
||||
xml = await res.text();
|
||||
if (xml.includes('<item>')) break;
|
||||
throw new Error('Invalid response');
|
||||
} catch (err) {
|
||||
if (attempt === maxRetries) {
|
||||
episodesList.innerHTML = '<div class="episodes-error">Unable to load episodes. <a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank">View RSS feed</a></div>';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, 'text/xml');
|
||||
const items = doc.querySelectorAll('item');
|
||||
|
||||
if (items.length === 0) {
|
||||
episodesList.innerHTML = '<div class="episodes-error">No episodes found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const episodes = Array.from(items).map((item, i) => {
|
||||
const title = item.querySelector('title')?.textContent || 'Untitled';
|
||||
const description = item.querySelector('description')?.textContent || '';
|
||||
const enclosure = item.querySelector('enclosure');
|
||||
const audioUrl = enclosure?.getAttribute('url') || '';
|
||||
const pubDate = item.querySelector('pubDate')?.textContent || '';
|
||||
const duration = item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'duration')[0]?.textContent || '';
|
||||
const episodeNum = item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'episode')[0]?.textContent || '';
|
||||
const link = item.querySelector('link')?.textContent || '';
|
||||
|
||||
return { title, description, audioUrl, pubDate, duration, episodeNum, link };
|
||||
});
|
||||
|
||||
renderEpisodes(episodes);
|
||||
}
|
||||
|
||||
function renderEpisodes(episodes) {
|
||||
episodesList.innerHTML = '';
|
||||
|
||||
episodes.forEach((ep) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'episode-card';
|
||||
|
||||
const epLabel = ep.episodeNum ? `Ep ${ep.episodeNum}` : '';
|
||||
const dateStr = ep.pubDate ? formatDate(ep.pubDate) : '';
|
||||
const durStr = parseDuration(ep.duration);
|
||||
|
||||
const metaParts = [epLabel, dateStr, durStr].filter(Boolean).join(' · ');
|
||||
|
||||
card.innerHTML = `
|
||||
<button class="episode-play-btn" aria-label="Play ${ep.title}" data-url="${ep.audioUrl}" data-title="${ep.title.replace(/"/g, '"')}">
|
||||
${playSVG}
|
||||
</button>
|
||||
<div class="episode-info">
|
||||
<div class="episode-meta">${metaParts}</div>
|
||||
<div class="episode-title">${ep.title}</div>
|
||||
<div class="episode-desc">${truncate(ep.description, 150)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const btn = card.querySelector('.episode-play-btn');
|
||||
btn.addEventListener('click', () => playEpisode(ep.audioUrl, ep.title, card, btn));
|
||||
|
||||
episodesList.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function playEpisode(url, title, card, btn) {
|
||||
if (!url) return;
|
||||
|
||||
// If clicking the same episode that's playing, toggle play/pause
|
||||
if (audio.src === url || audio.src === encodeURI(url)) {
|
||||
if (audio.paused) {
|
||||
audio.play();
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset previous card button icon
|
||||
if (currentEpisodeCard) {
|
||||
const prevBtn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||
if (prevBtn) {
|
||||
prevBtn.innerHTML = playSVG;
|
||||
prevBtn.classList.remove('playing');
|
||||
}
|
||||
}
|
||||
|
||||
currentEpisodeCard = card;
|
||||
audio.src = url;
|
||||
audio.play();
|
||||
|
||||
playerTitle.textContent = title;
|
||||
stickyPlayer.classList.add('active');
|
||||
}
|
||||
|
||||
// Sync UI with audio state
|
||||
audio.addEventListener('play', () => {
|
||||
updatePlayIcons(true);
|
||||
});
|
||||
|
||||
audio.addEventListener('pause', () => {
|
||||
updatePlayIcons(false);
|
||||
});
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
if (audio.duration) {
|
||||
const pct = (audio.currentTime / audio.duration) * 100;
|
||||
playerProgressFill.style.width = pct + '%';
|
||||
playerTime.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
updatePlayIcons(false);
|
||||
});
|
||||
|
||||
function updatePlayIcons(playing) {
|
||||
// Sticky player icons
|
||||
const iconPlay = playerPlayBtn.querySelector('.icon-play');
|
||||
const iconPause = playerPlayBtn.querySelector('.icon-pause');
|
||||
if (iconPlay) iconPlay.style.display = playing ? 'none' : 'block';
|
||||
if (iconPause) iconPause.style.display = playing ? 'block' : 'none';
|
||||
|
||||
// Episode card icon
|
||||
if (currentEpisodeCard) {
|
||||
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||
if (btn) {
|
||||
btn.innerHTML = playing ? pauseSVG : playSVG;
|
||||
btn.classList.toggle('playing', playing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sticky player play/pause button
|
||||
playerPlayBtn.addEventListener('click', () => {
|
||||
if (audio.src) {
|
||||
if (audio.paused) audio.play();
|
||||
else audio.pause();
|
||||
}
|
||||
});
|
||||
|
||||
// Progress bar seeking
|
||||
playerProgress.addEventListener('click', (e) => {
|
||||
if (audio.duration) {
|
||||
const rect = playerProgress.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
audio.currentTime = pct * audio.duration;
|
||||
}
|
||||
});
|
||||
|
||||
// Init
|
||||
fetchEpisodes();
|
||||
Reference in New Issue
Block a user