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 diff --git a/src/app/globals.css b/src/app/globals.css index 62e474d..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,49 +68,125 @@ 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; + gap: 12px; + padding: 10px 14px; + transition: border-color 250ms ease, box-shadow 250ms ease, background 250ms 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 { - 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 { @@ -170,6 +246,8 @@ body { opacity: 0.6; } +/* ---- Inputs ---- */ + .text-input { width: 100%; border: 1px solid var(--line); @@ -185,6 +263,8 @@ body { outline-offset: 2px; } +/* ---- Buttons ---- */ + .action-btn, .ghost-btn { border: 0; @@ -192,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 { @@ -202,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); @@ -209,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, @@ -229,6 +314,8 @@ body { justify-content: center; } +/* ---- Status pill ---- */ + .status-pill { margin: 0; justify-self: start; @@ -240,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 { @@ -263,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; @@ -276,7 +371,7 @@ body { } .speaker-grill { - height: 34px; + height: 30px; border-radius: 8px; background: repeating-linear-gradient( @@ -297,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; @@ -314,7 +412,7 @@ body { } .ptt-button { - width: min(190px, 70vw); + width: min(170px, 65vw); aspect-ratio: 1 / 1; justify-self: center; border: 0; @@ -337,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); @@ -362,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; } @@ -379,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 { @@ -408,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 { @@ -425,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, @@ -459,6 +613,7 @@ body { .warn-line { color: #f0a56a; + font-size: 0.82rem; } .error-line { @@ -466,6 +621,8 @@ body { font-weight: 600; } +/* ---- Animations ---- */ + @keyframes rise { from { opacity: 0; @@ -498,9 +655,11 @@ body { } } +/* ---- Responsive ---- */ + @media (max-width: 640px) { .ptt-button { - width: min(170px, 74vw); + width: min(155px, 60vw); font-size: 0.78rem; } @@ -511,8 +670,8 @@ body { @media (max-width: 420px) { .app-shell { - gap: 12px; - padding: 12px 12px 16px; + gap: 10px; + padding: 10px 10px 14px; } .retro-panel { @@ -520,7 +679,7 @@ body { } .hero { - gap: 12px; + gap: 10px; } .talkie-face { @@ -528,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 77b354d..e9591b6 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,13 +749,17 @@ export default function RoomPage() { return (
-
0.08 ? "rx-live" : ""}`}> +
0.08 ? "rx-live" : ""}`}> TALKY -
- - = 2 ? "on" : ""}`} /> + CH-{roomId} +
+
+ {Array.from({ length: MAX_PARTICIPANTS }).map((_, i) => ( + + ))} +
+ {isTransmitting ? "TX" : "RX"}
- {isTransmitting ? "TX" : "RX"}
@@ -841,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" && (