From 4b653b3b20f07ce6d31cf0e3b9b121ed9bb9677b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 03:44:15 +0000 Subject: [PATCH 1/3] Fix remote audio playback, add TX/RX topbar indicators, reduce SFX volume - Fix audio: remove conflicting ontrack handler in attachPeerListeners that overwrote createAudioPeerConnection's handler, preventing onRemoteStream from firing. Move remoteAudioMixRef setup into onRemoteStream callback. - Fix audio: replace createMediaElementSource (which hijacks Audio element output) with createMediaStreamSource for remote level metering, so Audio elements play through default output without interference. - Fix audio: add AudioContext.resume() on PTT press to handle suspended context from autoplay policy. Retry playing blocked peer audio elements on user gesture. - Fix bug: old remote meter RAF used meterRafRef instead of remoteRafRef, causing local/remote meter interference. - UI: add tx-live class on topbar when transmitting (purple glow), keep rx-live for remote audio detection (cyan glow), with smooth transitions. - Reduce SFX volumes: PTT sounds 0.65->0.25, background loop 0.2->0.08. https://claude.ai/code/session_012j7QvKgfRo15QnH17W8vYo --- src/app/globals.css | 16 +++- src/app/r/[roomId]/page.tsx | 148 +++++++++++++++--------------------- 2 files changed, 76 insertions(+), 88 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 62e474d..2af33a9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -86,13 +86,23 @@ body { display: flex; align-items: center; gap: 16px; + transition: border-color 200ms ease, box-shadow 200ms ease, background 200ms ease; +} + +.topbar.tx-live { + border-color: rgba(141, 75, 255, 0.7); + background: rgba(141, 75, 255, 0.08); + box-shadow: + inset 0 0 20px rgba(141, 75, 255, 0.2), + 0 0 20px rgba(141, 75, 255, 0.3); } .topbar.rx-live { - border-color: rgba(18, 213, 255, 0.6); + border-color: rgba(18, 213, 255, 0.7); + background: rgba(18, 213, 255, 0.06); box-shadow: - inset 0 0 18px rgba(18, 213, 255, 0.18), - 0 0 18px rgba(18, 213, 255, 0.25); + inset 0 0 20px rgba(18, 213, 255, 0.2), + 0 0 20px rgba(18, 213, 255, 0.3); } .brand-mark { diff --git a/src/app/r/[roomId]/page.tsx b/src/app/r/[roomId]/page.tsx index 77b354d..42e96ab 100644 --- a/src/app/r/[roomId]/page.tsx +++ b/src/app/r/[roomId]/page.tsx @@ -53,7 +53,7 @@ export default function RoomPage() { const meterRafRef = useRef(null); const remoteRafRef = useRef(null); const remoteAnalyserRef = useRef(null); - const remoteSourceRef = useRef(null); + const remoteSourceRef = useRef(null); const pttDownAudioRef = useRef(null); const pttUpAudioRef = useRef(null); const backgroundLoopRef = useRef(null); @@ -96,16 +96,16 @@ export default function RoomPage() { const ensureSfx = useCallback(() => { if (!pttDownAudioRef.current) { pttDownAudioRef.current = new Audio("/sfx/start.m4a"); - pttDownAudioRef.current.volume = 0.65; + pttDownAudioRef.current.volume = 0.25; } if (!pttUpAudioRef.current) { pttUpAudioRef.current = new Audio("/sfx/end.flac"); - pttUpAudioRef.current.volume = 0.65; + pttUpAudioRef.current.volume = 0.25; } if (!backgroundLoopRef.current) { const loop = new Audio("/sfx/crispsound.wav"); loop.loop = true; - loop.volume = 0.2; + loop.volume = 0.08; backgroundLoopRef.current = loop; } }, []); @@ -195,33 +195,6 @@ export default function RoomPage() { }); }; - pc.ontrack = (event) => { - const [stream] = event.streams; - if (!stream) { - return; - } - - let audio = peerAudioRef.current.get(peerId); - if (!audio) { - audio = new Audio(); - audio.autoplay = true; - (audio as HTMLAudioElement & { playsInline?: boolean }).playsInline = true; - peerAudioRef.current.set(peerId, audio); - } - - if (!remoteAudioMixRef.current) { - remoteAudioMixRef.current = audio; - } - audio.srcObject = stream; - void audio.play().catch(() => { - // Autoplay can be blocked; ignore. - }); - }; - - pc.onconnectionstatechange = () => { - setConnectionState(pc.connectionState); - }; - // Use only a single where() to avoid composite index requirements. const signalsQuery = query(signalsCollectionRef(roomId), where("to", "==", clientIdNow)); const candidatesQuery = query(candidatesCollectionRef(roomId), where("to", "==", clientIdNow)); @@ -336,10 +309,54 @@ export default function RoomPage() { peerAudioRef.current.set(peerId, audio); } + if (!remoteAudioMixRef.current) { + remoteAudioMixRef.current = audio; + } audio.srcObject = stream; void audio.play().catch(() => { - // Autoplay can be blocked. + // Autoplay can be blocked; will retry on user gesture. }); + + // Set up remote audio analysis for level metering via MediaStreamSource + // (does not interfere with the Audio element playback unlike createMediaElementSource) + const ctx = audioContextRef.current; + if (ctx && !remoteSourceRef.current) { + if (ctx.state === "suspended") { + void ctx.resume(); + } + if (!remoteAnalyserRef.current) { + const analyser = ctx.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = 0.7; + remoteAnalyserRef.current = analyser; + } + try { + const source = ctx.createMediaStreamSource(stream); + source.connect(remoteAnalyserRef.current); + remoteSourceRef.current = source; + } catch { + // Ignore if source creation fails. + } + + // Start remote level metering RAF + if (!remoteRafRef.current && remoteAnalyserRef.current) { + const analyser = remoteAnalyserRef.current; + const data = new Uint8Array(analyser.frequencyBinCount); + const tick = () => { + analyser.getByteTimeDomainData(data); + let sum = 0; + for (let i = 0; i < data.length; i += 1) { + const v = (data[i] - 128) / 128; + sum += v * v; + } + const rms = Math.sqrt(sum / data.length); + const next = Math.min(1, Math.max(0, rms * 2.4)); + setRemoteLevel(next); + remoteRafRef.current = window.requestAnimationFrame(tick); + }; + remoteRafRef.current = window.requestAnimationFrame(tick); + } + } }, onConnectionStateChange: (state) => { setConnectionState(state); @@ -385,6 +402,19 @@ export default function RoomPage() { setIsHolding(true); ensureSfx(); playSfx(pttDownAudioRef); + + // Resume AudioContext if suspended (autoplay policy) + if (audioContextRef.current?.state === "suspended") { + void audioContextRef.current.resume(); + } + + // Retry playing any remote audio elements blocked by autoplay + for (const [, audio] of peerAudioRef.current) { + if (audio.paused && audio.srcObject) { + void audio.play().catch(() => {}); + } + } + void startTalking(); }, [ensureSfx, joinState, playSfx, startTalking]); @@ -497,58 +527,6 @@ export default function RoomPage() { }; }, [micReady]); - useEffect(() => { - const audio = remoteAudioMixRef.current; - if (!audio) { - return; - } - - if (!audioContextRef.current) { - audioContextRef.current = new AudioContext(); - } - - const context = audioContextRef.current; - if (!remoteAnalyserRef.current) { - const analyser = context.createAnalyser(); - analyser.fftSize = 512; - analyser.smoothingTimeConstant = 0.7; - remoteAnalyserRef.current = analyser; - } - - if (!remoteSourceRef.current) { - const source = context.createMediaElementSource(audio); - source.connect(remoteAnalyserRef.current); - remoteAnalyserRef.current.connect(context.destination); - remoteSourceRef.current = source; - } - - const analyser = remoteAnalyserRef.current; - const data = new Uint8Array(analyser.frequencyBinCount); - - const tick = () => { - analyser.getByteTimeDomainData(data); - let sum = 0; - for (let i = 0; i < data.length; i += 1) { - const v = (data[i] - 128) / 128; - sum += v * v; - } - const rms = Math.sqrt(sum / data.length); - const next = Math.min(1, Math.max(0, rms * 2.4)); - setRemoteLevel(next); - meterRafRef.current = window.requestAnimationFrame(tick); - }; - - remoteRafRef.current = window.requestAnimationFrame(tick); - - return () => { - // Keep analyser/source for reuse; only stop the RAF. - if (remoteRafRef.current !== null) { - window.cancelAnimationFrame(remoteRafRef.current); - remoteRafRef.current = null; - } - }; - }, [participants.length]); - useEffect(() => { if (!clientId) { return; @@ -771,7 +749,7 @@ export default function RoomPage() { return (
-
0.08 ? "rx-live" : ""}`}> +
0.08 ? "rx-live" : ""}`}> TALKY
From 63c3b573800737006e5fa985f7b39bcaa1caa91a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 03:45:08 +0000 Subject: [PATCH 2/3] Add tsconfig.tsbuildinfo to .gitignore https://claude.ai/code/session_012j7QvKgfRo15QnH17W8vYo --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ed37ba6..e8ab622 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* - +tsconfig.tsbuildinfo From 6a67e427a27c057e1e7eddc34354cf00ae7a6a6c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 03:55:08 +0000 Subject: [PATCH 3/3] Refonte UI retro-futuriste minimaliste - Ecran CRT: ajout scanlines horizontales subtiles + ligne de balayage animee qui descend lentement sur les ecrans .screen - Topbar: brand TALKY en cyan lumineux avec glow, badge frequence CH-{PIN}, 4 slots de participants (dots), regroupement a droite - Metres audio: double metre TX/RX avec labels, le metre RX montre le niveau du signal entrant en cyan, TX en violet->cyan quand actif - Stats: ligne inline epuree (2/4 | PIN 1234) au lieu du grid redondant - Status pill: animation pulse sur les etats "Connexion" / "En attente" - Boutons: meilleurs hover states avec glow violet - Page accueil: texte et structure alignes avec le nouveau style - Responsive: tailles reduites, hero scrollable, meilleur mobile - Transitions fluides sur tous les elements interactifs https://claude.ai/code/session_012j7QvKgfRo15QnH17W8vYo --- src/app/globals.css | 252 ++++++++++++++++++++++++++++-------- src/app/page.tsx | 6 +- src/app/r/[roomId]/page.tsx | 61 ++++++--- 3 files changed, 245 insertions(+), 74 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 2af33a9..af4b1e1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -41,8 +41,8 @@ body { min-height: 100vh; display: grid; grid-template-rows: auto 1fr; - gap: 18px; - padding: 18px clamp(14px, 4vw, 36px) 28px; + gap: 14px; + padding: 14px clamp(14px, 4vw, 36px) 20px; max-height: 100vh; overflow: hidden; } @@ -68,25 +68,64 @@ body { border: 1px solid rgba(141, 75, 255, 0.3); border-radius: 14px; padding: 12px 14px; - background: rgba(12, 8, 24, 0.9); + background: rgba(12, 8, 24, 0.92); box-shadow: inset 0 0 18px rgba(6, 4, 12, 0.85), 0 10px 20px rgba(6, 4, 12, 0.6); } +/* ---- Screen (CRT-like display) ---- */ + .screen { + position: relative; border: 1px solid rgba(18, 213, 255, 0.35); border-radius: 10px; padding: 12px; background: radial-gradient(circle at top, rgba(18, 213, 255, 0.12), transparent 65%); box-shadow: inset 0 0 16px rgba(18, 213, 255, 0.08); + overflow: hidden; } +.screen::after { + content: ""; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.04) 2px, + rgba(0, 0, 0, 0.04) 4px + ); + pointer-events: none; + border-radius: inherit; +} + +.screen::before { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent 10%, rgba(18, 213, 255, 0.12) 50%, transparent 90%); + animation: scanline 6s linear infinite; + pointer-events: none; + z-index: 1; +} + +@keyframes scanline { + 0% { top: 0; } + 100% { top: 100%; } +} + +/* ---- Top bar ---- */ + .topbar { display: flex; align-items: center; - gap: 16px; - transition: border-color 200ms ease, box-shadow 200ms ease, background 200ms ease; + gap: 12px; + padding: 10px 14px; + transition: border-color 250ms ease, box-shadow 250ms ease, background 250ms ease; } .topbar.tx-live { @@ -106,21 +145,48 @@ body { } .brand-mark { - font-size: 0.7rem; + font-family: var(--font-display), "Courier New", monospace; + font-size: 0.6rem; font-weight: 700; letter-spacing: 0.35em; + color: var(--accent); + text-shadow: 0 0 12px rgba(18, 213, 255, 0.5); +} + +.freq-badge { + font-size: 0.65rem; + letter-spacing: 0.12em; color: var(--ink-soft); + opacity: 0.7; +} + +.topbar-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 12px; } .topbar-sub { - font-size: 0.75rem; - color: rgba(164, 131, 221, 0.7); + font-size: 0.65rem; + letter-spacing: 0.12em; + color: rgba(164, 131, 221, 0.5); } +/* ---- Layout ---- */ + .hero { display: grid; - gap: 16px; + gap: 14px; max-width: 520px; + align-content: start; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; +} + +.hero::-webkit-scrollbar { + display: none; } .hero-actions { @@ -180,6 +246,8 @@ body { opacity: 0.6; } +/* ---- Inputs ---- */ + .text-input { width: 100%; border: 1px solid var(--line); @@ -195,6 +263,8 @@ body { outline-offset: 2px; } +/* ---- Buttons ---- */ + .action-btn, .ghost-btn { border: 0; @@ -202,7 +272,7 @@ body { font: inherit; font-weight: 700; cursor: pointer; - transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease; + transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease, border-color 160ms ease; } .action-btn { @@ -212,6 +282,11 @@ body { box-shadow: 0 10px 20px rgba(91, 33, 182, 0.4); } +.action-btn:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(91, 33, 182, 0.5), 0 0 16px rgba(141, 75, 255, 0.3); +} + .ghost-btn { padding: 9px 12px; color: var(--ink); @@ -219,9 +294,9 @@ body { border: 1px solid var(--line); } -.action-btn:hover, .ghost-btn:hover { transform: translateY(-1px); + border-color: rgba(141, 75, 255, 0.4); } .action-btn:disabled, @@ -239,6 +314,8 @@ body { justify-content: center; } +/* ---- Status pill ---- */ + .status-pill { margin: 0; justify-self: start; @@ -250,12 +327,13 @@ body { color: var(--ink); background: rgba(24, 15, 40, 0.9); border: 1px solid var(--line); + transition: background 300ms ease, border-color 300ms ease; } .status-connexion, -.status-en-attente, -.status-reconnexion { +.status-en-attente { background: rgba(24, 15, 40, 0.9); + animation: status-pulse 2s ease-in-out infinite; } .status-connecte { @@ -273,6 +351,13 @@ body { border-color: rgba(255, 107, 107, 0.4); } +@keyframes status-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +/* ---- Talkie face ---- */ + .talkie-face { display: grid; gap: 10px; @@ -286,7 +371,7 @@ body { } .speaker-grill { - height: 34px; + height: 30px; border-radius: 8px; background: repeating-linear-gradient( @@ -307,16 +392,19 @@ body { } .knob { - padding: 4px 8px; + padding: 3px 7px; border-radius: 999px; border: 1px solid rgba(141, 75, 255, 0.25); background: rgba(12, 8, 22, 0.8); - font-size: 0.6rem; + font-size: 0.55rem; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-soft); + transition: border-color 200ms ease; } +/* ---- PTT area ---- */ + .ptt-stack { display: grid; gap: 10px; @@ -324,7 +412,7 @@ body { } .ptt-button { - width: min(190px, 70vw); + width: min(170px, 65vw); aspect-ratio: 1 / 1; justify-self: center; border: 0; @@ -347,19 +435,48 @@ body { } .ptt-button:active:not(:disabled) { - transform: scale(0.98); + transform: scale(0.97); } .ptt-button.live { animation: pulse-live 1.1s ease-in-out infinite; } +/* ---- Audio meters ---- */ + +.meters-group { + display: grid; + gap: 5px; + width: 100%; + max-width: 210px; +} + +.meter-row { + display: flex; + align-items: center; + gap: 8px; +} + +.meter-label { + font-size: 0.5rem; + font-weight: 700; + letter-spacing: 0.15em; + color: var(--ink-soft); + width: 16px; + text-align: right; + flex-shrink: 0; +} + +.meter-label.rx { + color: var(--accent); +} + .audio-meter { position: relative; - width: 170px; - height: 18px; - border-radius: 10px; - border: 1px solid rgba(141, 75, 255, 0.35); + flex: 1; + height: 14px; + border-radius: 8px; + border: 1px solid rgba(141, 75, 255, 0.3); background: rgba(12, 8, 22, 0.85); overflow: hidden; box-shadow: inset 0 0 8px rgba(6, 4, 12, 0.9); @@ -372,7 +489,7 @@ body { width: 100%; transform-origin: left center; transform: scaleX(var(--level)); - background: linear-gradient(90deg, rgba(110, 231, 255, 0.5), rgba(167, 139, 250, 0.6)); + background: linear-gradient(90deg, rgba(141, 75, 255, 0.5), rgba(167, 139, 250, 0.6)); transition: transform 80ms linear; } @@ -389,27 +506,36 @@ body { inset: 2px; display: grid; grid-template-columns: repeat(12, 1fr); - gap: 3px; - opacity: 0.75; + gap: 2px; + opacity: 0.7; pointer-events: none; } .audio-bar { background: rgba(30, 22, 48, 0.9); - border-radius: 3px; - box-shadow: inset 0 0 8px rgba(6, 4, 12, 0.9); + border-radius: 2px; + box-shadow: inset 0 0 6px rgba(6, 4, 12, 0.9); } .audio-meter.live .audio-meter-fill { - box-shadow: 0 0 10px rgba(110, 231, 255, 0.7); + background: linear-gradient(90deg, rgba(141, 75, 255, 0.6), rgba(18, 213, 255, 0.6)); + box-shadow: 0 0 8px rgba(18, 213, 255, 0.5); +} + +.audio-meter.rx-active .audio-meter-fill { + background: linear-gradient(90deg, rgba(18, 213, 255, 0.45), rgba(110, 231, 255, 0.55)); + box-shadow: 0 0 8px rgba(18, 213, 255, 0.5); } +/* ---- Indicators ---- */ + .device-led { - width: 10px; - height: 10px; + width: 8px; + height: 8px; border-radius: 999px; - background: rgba(164, 131, 221, 0.6); - box-shadow: 0 0 10px rgba(141, 75, 255, 0.6); + background: rgba(164, 131, 221, 0.5); + box-shadow: 0 0 8px rgba(141, 75, 255, 0.4); + transition: background 200ms ease, box-shadow 200ms ease; } .device-led.live { @@ -418,15 +544,16 @@ body { } .device-state { - margin-left: auto; - font-size: 0.7rem; + font-size: 0.65rem; letter-spacing: 0.2em; font-weight: 700; - color: rgba(164, 131, 221, 0.75); + color: rgba(164, 131, 221, 0.6); + transition: color 200ms ease, text-shadow 200ms ease; } .device-state.live { - color: rgba(18, 213, 255, 0.85); + color: var(--accent); + text-shadow: 0 0 10px rgba(18, 213, 255, 0.6); } .connection-dots { @@ -435,26 +562,43 @@ body { gap: 4px; padding: 3px 6px; border-radius: 999px; - border: 1px solid rgba(141, 75, 255, 0.25); + border: 1px solid rgba(141, 75, 255, 0.2); background: rgba(14, 9, 24, 0.8); } .connection-dot { - width: 6px; - height: 6px; + width: 5px; + height: 5px; border-radius: 999px; - background: rgba(164, 131, 221, 0.25); + background: rgba(164, 131, 221, 0.2); box-shadow: inset 0 0 3px rgba(6, 4, 12, 0.7); + transition: background 300ms ease, box-shadow 300ms ease; } .connection-dot.on { background: rgba(18, 213, 255, 0.85); - box-shadow: 0 0 10px rgba(18, 213, 255, 0.6); + box-shadow: 0 0 8px rgba(18, 213, 255, 0.6); } -.stats-grid { - display: grid; - gap: 6px; +/* ---- Stats ---- */ + +.stats-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.stat-item { + font-size: 0.72rem; + color: var(--ink-soft); + letter-spacing: 0.06em; +} + +.stat-sep { + width: 1px; + height: 12px; + background: rgba(141, 75, 255, 0.25); } .speaker-line, @@ -469,6 +613,7 @@ body { .warn-line { color: #f0a56a; + font-size: 0.82rem; } .error-line { @@ -476,6 +621,8 @@ body { font-weight: 600; } +/* ---- Animations ---- */ + @keyframes rise { from { opacity: 0; @@ -508,9 +655,11 @@ body { } } +/* ---- Responsive ---- */ + @media (max-width: 640px) { .ptt-button { - width: min(170px, 74vw); + width: min(155px, 60vw); font-size: 0.78rem; } @@ -521,8 +670,8 @@ body { @media (max-width: 420px) { .app-shell { - gap: 12px; - padding: 12px 12px 16px; + gap: 10px; + padding: 10px 10px 14px; } .retro-panel { @@ -530,7 +679,7 @@ body { } .hero { - gap: 12px; + gap: 10px; } .talkie-face { @@ -538,13 +687,12 @@ body { } .ptt-button { - width: min(150px, 70vw); + width: min(140px, 58vw); font-size: 0.72rem; } - .audio-meter { - width: 140px; - height: 14px; + .meters-group { + max-width: 180px; } .text-input { diff --git a/src/app/page.tsx b/src/app/page.tsx index 5fdd360..fc4b682 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -64,14 +64,14 @@ export default function HomePage() {
TALKY - RETRO LINK MODE + v1.0
-

TALKIE

+

SIGNAL

Talky

-

P2P mesh, PIN local 4 chiffres.

+

Talkie-walkie P2P, PIN a 4 chiffres.

diff --git a/src/app/r/[roomId]/page.tsx b/src/app/r/[roomId]/page.tsx index 42e96ab..e9591b6 100644 --- a/src/app/r/[roomId]/page.tsx +++ b/src/app/r/[roomId]/page.tsx @@ -751,11 +751,15 @@ export default function RoomPage() {
0.08 ? "rx-live" : ""}`}> TALKY -
- - = 2 ? "on" : ""}`} /> + CH-{roomId} +
+
+ {Array.from({ length: MAX_PARTICIPANTS }).map((_, i) => ( + + ))} +
+ {isTransmitting ? "TX" : "RX"}
- {isTransmitting ? "TX" : "RX"}
@@ -819,26 +823,45 @@ export default function RoomPage() { > {isTransmitting ? "TX..." : "PTT"} -
- - -
-
-

Parole libre

-

Participants: {participants.length}/{MAX_PARTICIPANTS}

-

PIN: {roomId}

-

Connectes: {participants.length}

+
+ {participants.length}/{MAX_PARTICIPANTS} + + PIN {roomId}
{participants.length <= 1 && joinState === "ready" && (