Created
August 30, 2026 17:36
-
-
Save aldoyh/af7c14f069d452c33a590cb0ef375543 to your computer and use it in GitHub Desktop.
Wave Surfer Audio Visualizer Studio
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en" class="dark"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>Wave Surfer Audio Visualizer Studio Pro</title> | |
| <!-- Fonts --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet"> | |
| <!-- Icons (Phosphor) for clean, geometric look similar to reference --> | |
| <script src="https://unpkg.com/@phosphor-icons/web"></script> | |
| <!-- HLS for Radio Stream --> | |
| <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script> | |
| <!-- Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script> | |
| tailwind.config = { | |
| darkMode: 'class', | |
| theme: { | |
| extend: { | |
| fontFamily: { | |
| sans: ['Outfit', 'sans-serif'], | |
| }, | |
| colors: { | |
| cosmic: { | |
| base: '#050014', | |
| glow: '#2a0a4a' | |
| } | |
| }, | |
| backgroundImage: { | |
| 'orb-gradient': 'linear-gradient(160deg, #3b28cc 0%, #d414c9 100%)', | |
| 'active-glow': 'linear-gradient(135deg, #4f46e5, #ec4899)' | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| <style> | |
| body { | |
| background-color: #050014; | |
| color: #ffffff; | |
| overflow: hidden; | |
| touch-action: none; /* Prevent scroll on touch */ | |
| } | |
| /* Deep Space Starry Background */ | |
| .stars-bg { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100vw; | |
| height: 100vh; | |
| z-index: 0; | |
| background-image: | |
| radial-gradient(circle at 50% 50%, rgba(42, 10, 74, 0.4) 0%, transparent 60%), | |
| url("data:image/svg+xml,%3Csvg width='200' height='200' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='40' cy='20' r='1' fill='%23ffffff' fill-opacity='0.2'/%3E%3Ccircle cx='150' cy='80' r='1.5' fill='%23ffffff' fill-opacity='0.4'/%3E%3Ccircle cx='90' cy='160' r='0.8' fill='%23ffffff' fill-opacity='0.3'/%3E%3Ccircle cx='180' cy='190' r='1.2' fill='%23ffffff' fill-opacity='0.15'/%3E%3Ccircle cx='10' cy='110' r='1' fill='%23ffffff' fill-opacity='0.25'/%3E%3C/svg%3E"); | |
| background-size: cover, 150px 150px; | |
| pointer-events: none; | |
| } | |
| /* Visualizer Canvas - Rendered behind UI */ | |
| #visualizer-canvas { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100vw; | |
| height: 100vh; | |
| z-index: 5; | |
| pointer-events: none; | |
| /* Slight blur for neon dispersion effect */ | |
| filter: drop-shadow(0 0 10px rgba(212, 20, 201, 0.3)); | |
| } | |
| /* Central Power Orb */ | |
| .power-orb-wrapper { | |
| position: relative; | |
| width: 280px; | |
| height: 280px; | |
| border-radius: 50%; | |
| /* Outer thin ring */ | |
| border: 1px solid rgba(255, 255, 255, 0.15); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 20; | |
| } | |
| .power-orb { | |
| width: 250px; | |
| height: 250px; | |
| border-radius: 50%; | |
| background: var(--tw-gradient-stops); | |
| background-image: linear-gradient(160deg, #3b28cc 0%, #d414c9 100%); | |
| box-shadow: | |
| 0 0 80px rgba(212, 20, 201, 0.3), | |
| inset 0 -10px 30px rgba(0, 0, 0, 0.2), | |
| inset 0 10px 30px rgba(255, 255, 255, 0.2); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), box-shadow 0.3s ease; | |
| } | |
| .power-orb:active { | |
| transform: scale(0.95); | |
| } | |
| .power-orb.is-playing { | |
| box-shadow: | |
| 0 0 120px rgba(212, 20, 201, 0.6), | |
| inset 0 -10px 30px rgba(0, 0, 0, 0.2), | |
| inset 0 10px 30px rgba(255, 255, 255, 0.4); | |
| animation: pulseOrb 2s infinite alternate; | |
| } | |
| @keyframes pulseOrb { | |
| 0% { transform: scale(1); } | |
| 100% { transform: scale(1.02); } | |
| } | |
| .power-icon { | |
| font-size: 80px; | |
| color: #ffffff; | |
| font-weight: 300; | |
| filter: drop-shadow(0 0 10px rgba(255,255,255,0.5)); | |
| transition: text-shadow 0.3s ease; | |
| } | |
| .power-orb.is-playing .power-icon { | |
| text-shadow: 0 0 20px #fff, 0 0 40px #d414c9; | |
| } | |
| /* Orbital Nodes (Top Arc) */ | |
| .orbital-arc { | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 1.25rem; | |
| margin-bottom: 2rem; | |
| z-index: 20; | |
| } | |
| .arc-node { | |
| width: 46px; | |
| height: 46px; | |
| border-radius: 50%; | |
| border: 1px solid rgba(255, 255, 255, 0.3); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| color: #ffffff; | |
| cursor: pointer; | |
| background: rgba(0, 0, 0, 0.2); | |
| transition: all 0.3s ease; | |
| position: relative; | |
| } | |
| .arc-node i { | |
| font-size: 20px; | |
| } | |
| .arc-node:hover { | |
| border-color: rgba(255, 255, 255, 0.6); | |
| background: rgba(255, 255, 255, 0.1); | |
| } | |
| .arc-node.active { | |
| background: linear-gradient(135deg, #4f46e5, #ec4899); | |
| border-color: transparent; | |
| box-shadow: 0 0 20px rgba(236, 72, 153, 0.5); | |
| transform: scale(1.15) translateY(-5px); | |
| } | |
| /* The little dot below the active node */ | |
| .arc-node.active::after { | |
| content: ''; | |
| position: absolute; | |
| bottom: -12px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 4px; | |
| height: 4px; | |
| background-color: #fff; | |
| border-radius: 50%; | |
| box-shadow: 0 0 5px #fff; | |
| } | |
| /* Top Header Notch Indicator */ | |
| .notch-indicator { | |
| position: absolute; | |
| top: -12px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 0; | |
| height: 0; | |
| border-left: 6px solid transparent; | |
| border-right: 6px solid transparent; | |
| border-bottom: 6px solid rgba(255, 255, 255, 0.2); | |
| } | |
| /* Bottom Dock */ | |
| .bottom-dock { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 1.5rem; | |
| z-index: 20; | |
| margin-bottom: 2rem; | |
| } | |
| .dock-btn { | |
| width: 50px; | |
| height: 50px; | |
| border-radius: 50%; | |
| border: 1px solid rgba(255, 255, 255, 0.3); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| color: #ffffff; | |
| cursor: pointer; | |
| background: transparent; | |
| transition: all 0.3s ease; | |
| font-size: 18px; | |
| position: relative; | |
| } | |
| .dock-btn:hover { | |
| border-color: rgba(255, 255, 255, 0.8); | |
| } | |
| .dock-btn.active { | |
| background: linear-gradient(135deg, #3b28cc, #d414c9); | |
| border-color: transparent; | |
| box-shadow: 0 0 20px rgba(212, 20, 201, 0.4); | |
| } | |
| .dock-btn.active::after { | |
| content: ''; | |
| position: absolute; | |
| bottom: -15px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 5px; | |
| height: 5px; | |
| background-color: #fff; | |
| border-radius: 50%; | |
| box-shadow: 0 0 8px #fff; | |
| } | |
| </style> | |
| </head> | |
| <body class="h-screen w-screen flex flex-col items-center justify-between select-none"> | |
| <!-- Background Layers --> | |
| <div class="stars-bg"></div> | |
| <canvas id="visualizer-canvas"></canvas> | |
| <!-- Top Header Title --> | |
| <header class="w-full text-center pt-8 z-20 flex flex-col items-center"> | |
| <h1 class="text-xl font-semibold tracking-wide text-gray-200">Wave Surfer Pro</h1> | |
| <p id="stream-status" class="text-xs text-gray-400 mt-1 uppercase tracking-widest">Radio 93.3 FM Live</p> | |
| </header> | |
| <!-- Main UI Container --> | |
| <main class="flex-1 flex flex-col items-center justify-center w-full z-20"> | |
| <!-- Orbital Arc (Mode Selectors) --> | |
| <div class="orbital-arc"> | |
| <!-- Music (Particles) --> | |
| <div class="arc-node" data-mode="particles" title="Particles"> | |
| <i class="ph ph-music-notes"></i> | |
| </div> | |
| <!-- Sunrise (Cline) --> | |
| <div class="arc-node" data-mode="cline" title="Cline Symmetrical"> | |
| <i class="ph ph-sun-horizon"></i> | |
| </div> | |
| <!-- Bulb (Bars - Default) --> | |
| <div class="arc-node active" data-mode="bars" title="Radial Bars"> | |
| <i class="ph ph-lightbulb"></i> | |
| </div> | |
| <!-- Moon (Pulse) --> | |
| <div class="arc-node" data-mode="pulse" title="Pulse Core"> | |
| <i class="ph ph-moon-stars"></i> | |
| </div> | |
| <!-- Candle (Fire/Nebula) --> | |
| <div class="arc-node" data-mode="nebula" title="Nebula Flow"> | |
| <i class="ph ph-campfire"></i> | |
| </div> | |
| </div> | |
| <!-- Central Power Orb --> | |
| <div class="power-orb-wrapper" id="orb-wrapper"> | |
| <div class="notch-indicator"></div> | |
| <div class="power-orb" id="btn-power"> | |
| <i class="ph ph-power power-icon"></i> | |
| </div> | |
| </div> | |
| </main> | |
| <!-- Bottom Dock --> | |
| <footer class="w-full flex justify-center pb-12 z-20"> | |
| <div class="bottom-dock"> | |
| <!-- Primary Preset --> | |
| <div class="dock-btn active" title="Theme: Cosmic"> | |
| <i class="ph ph-squares-four"></i> | |
| </div> | |
| <!-- Numbered Presets (Placeholders for UI Match) --> | |
| <div class="dock-btn font-light" title="Theme: Neon">1</div> | |
| <div class="dock-btn font-light" title="Theme: Amber">2</div> | |
| <div class="dock-btn font-light" title="Theme: Mono">3</div> | |
| </div> | |
| </footer> | |
| <!-- Native Audio Element (Hidden) --> | |
| <audio id="audio-engine" crossorigin="anonymous" preload="auto" class="hidden"></audio> | |
| <script> | |
| // System Config | |
| const RADIO_STREAM_URL = "https://5c7b683162943.streamlock.net/live/ngrp:radio-93-3_all/chunklist_w984020168_b981072.m3u8"; | |
| // Audio & Engine State | |
| let audioCtx, analyser, sourceNode, gainNode, hls; | |
| let isPlaying = false; | |
| let currentMode = 'bars'; // Enforced default mode as requested | |
| let isEngineInitialized = false; | |
| // Data Buffers for Smoothing Physics | |
| let bufferLength; | |
| let dataArray; | |
| let smoothedArray; // Temporal easing | |
| let peaksArray; // Physics-based peak caps | |
| let velocities; // Gravity acceleration for caps | |
| // Canvas Setup | |
| const canvas = document.getElementById('visualizer-canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const orbWrapper = document.getElementById('orb-wrapper'); | |
| function resizeCanvas() { | |
| canvas.width = window.innerWidth; | |
| canvas.height = window.innerHeight; | |
| } | |
| window.addEventListener('resize', resizeCanvas); | |
| resizeCanvas(); | |
| // UI Elements | |
| const audioElement = document.getElementById('audio-engine'); | |
| const btnPower = document.getElementById('btn-power'); | |
| const statusText = document.getElementById('stream-status'); | |
| const modeNodes = document.querySelectorAll('.arc-node'); | |
| function initAudioEngine() { | |
| if (isEngineInitialized) return; | |
| const AudioContext = window.AudioContext || window.webkitAudioContext; | |
| audioCtx = new AudioContext(); | |
| analyser = audioCtx.createAnalyser(); | |
| // 512 for a good balance of bar thickness and detail | |
| analyser.fftSize = 512; | |
| // Native smoothing set low because we use custom physics math | |
| analyser.smoothingTimeConstant = 0.4; | |
| bufferLength = analyser.frequencyBinCount; | |
| dataArray = new Uint8Array(bufferLength); | |
| // Initialize physics arrays | |
| smoothedArray = new Float32Array(bufferLength); | |
| peaksArray = new Float32Array(bufferLength); | |
| velocities = new Float32Array(bufferLength); | |
| gainNode = audioCtx.createGain(); | |
| gainNode.gain.value = 1.0; | |
| // Setup HLS Stream | |
| if (Hls.isSupported()) { | |
| hls = new Hls({ lowLatencyMode: true }); | |
| hls.loadSource(RADIO_STREAM_URL); | |
| hls.attachMedia(audioElement); | |
| hls.on(Hls.Events.MANIFEST_PARSED, () => { | |
| connectMediaElement(); | |
| }); | |
| } else if (audioElement.canPlayType('application/vnd.apple.mpegurl')) { | |
| audioElement.src = RADIO_STREAM_URL; | |
| audioElement.addEventListener('loadedmetadata', () => { | |
| connectMediaElement(); | |
| }); | |
| } | |
| isEngineInitialized = true; | |
| } | |
| function connectMediaElement() { | |
| if (!sourceNode && audioCtx) { | |
| sourceNode = audioCtx.createMediaElementSource(audioElement); | |
| sourceNode.connect(gainNode); | |
| gainNode.connect(analyser); | |
| analyser.connect(audioCtx.destination); | |
| } | |
| } | |
| async function togglePlayback() { | |
| if (!isEngineInitialized) { | |
| initAudioEngine(); | |
| } | |
| if (audioCtx.state === 'suspended') { | |
| await audioCtx.resume(); | |
| } | |
| if (isPlaying) { | |
| audioElement.pause(); | |
| setPlayingState(false); | |
| } else { | |
| try { | |
| await audioElement.play(); | |
| setPlayingState(true); | |
| } catch (e) { | |
| console.error("Playback failed:", e); | |
| statusText.textContent = "Playback Blocked. Tap again."; | |
| } | |
| } | |
| } | |
| function setPlayingState(state) { | |
| isPlaying = state; | |
| if (state) { | |
| btnPower.classList.add('is-playing'); | |
| statusText.textContent = "Radio 93.3 FM Live - Playing"; | |
| statusText.classList.add('text-pink-400'); | |
| } else { | |
| btnPower.classList.remove('is-playing'); | |
| statusText.textContent = "Radio 93.3 FM Live - Paused"; | |
| statusText.classList.remove('text-pink-400'); | |
| } | |
| } | |
| function renderLoop() { | |
| requestAnimationFrame(renderLoop); | |
| // Clear canvas entirely each frame for crisp rendering | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| if (!analyser || !isPlaying) return; | |
| // Get live audio data | |
| analyser.getByteFrequencyData(dataArray); | |
| // Calculate center of the screen based on the Orb's exact position | |
| const rect = orbWrapper.getBoundingClientRect(); | |
| const cx = rect.left + rect.width / 2; | |
| const cy = rect.top + rect.height / 2; | |
| const baseRadius = (rect.width / 2) + 15; // Start drawing just outside the orb ring | |
| ctx.lineCap = 'round'; | |
| if (currentMode === 'bars') drawRadialBars(cx, cy, baseRadius); | |
| else if (currentMode === 'cline') drawSymmetricalCline(cx, cy); | |
| else if (currentMode === 'particles') drawParticles(cx, cy, baseRadius); | |
| else if (currentMode === 'pulse') drawPulseCore(cx, cy, baseRadius); | |
| else if (currentMode === 'nebula') drawRadialBars(cx, cy, baseRadius, true); // Variant | |
| } | |
| // SMOOTHED RADIAL BARS MODE (DEFAULT) | |
| function drawRadialBars(cx, cy, baseRadius, isNebula = false) { | |
| const barCount = 90; // Number of radial bars | |
| const step = Math.floor(bufferLength / barCount); | |
| for (let i = 0; i < barCount; i++) { | |
| // Focus on lower and mid frequencies, skipping extreme highs | |
| const dataIndex = Math.floor(i * step * 0.8); | |
| const targetValue = dataArray[dataIndex] / 255; | |
| // 1. Math Interpolation for Buttery Smoothness (Easing) | |
| smoothedArray[i] += (targetValue - smoothedArray[i]) * 0.2; | |
| const value = smoothedArray[i]; | |
| // 2. Physics-based peak gravity caps | |
| if (value >= peaksArray[i]) { | |
| peaksArray[i] = value; | |
| velocities[i] = 0; // Reset velocity on hit | |
| } else { | |
| velocities[i] += 0.002; // Gravity acceleration | |
| peaksArray[i] -= velocities[i]; // Apply falling velocity | |
| if (peaksArray[i] < 0) peaksArray[i] = 0; | |
| } | |
| // Calculate angle (Start from top, go clockwise) | |
| const angle = (i / barCount) * Math.PI * 2 - (Math.PI / 2); | |
| // Calculate bar length based on screen size | |
| const maxBarLength = Math.min(canvas.width, canvas.height) * 0.3; | |
| const barLength = value * maxBarLength; | |
| const x1 = cx + Math.cos(angle) * baseRadius; | |
| const y1 = cy + Math.sin(angle) * baseRadius; | |
| const x2 = cx + Math.cos(angle) * (baseRadius + barLength); | |
| const y2 = cy + Math.sin(angle) * (baseRadius + barLength); | |
| // Gradient for the bars | |
| const gradient = ctx.createLinearGradient(x1, y1, x2, y2); | |
| gradient.addColorStop(0, '#3b28cc'); // Deep Blue | |
| gradient.addColorStop(0.5, '#9333ea'); // Purple | |
| gradient.addColorStop(1, '#d414c9'); // Pink/Magenta | |
| // Draw Main Bar | |
| ctx.beginPath(); | |
| ctx.moveTo(x1, y1); | |
| ctx.lineTo(x2, y2); | |
| ctx.lineWidth = 4; | |
| ctx.strokeStyle = isNebula ? 'rgba(212, 20, 201, 0.4)' : gradient; | |
| ctx.stroke(); | |
| if (!isNebula) { | |
| // Draw Physics Cap (Falling Dot) at the peak | |
| const capRadius = baseRadius + (peaksArray[i] * maxBarLength) + 6; | |
| const cx2 = cx + Math.cos(angle) * capRadius; | |
| const cy2 = cy + Math.sin(angle) * capRadius; | |
| ctx.beginPath(); | |
| ctx.arc(cx2, cy2, 2.5, 0, Math.PI * 2); | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.shadowBlur = 8; | |
| ctx.shadowColor = '#d414c9'; | |
| ctx.fill(); | |
| ctx.shadowBlur = 0; // Reset | |
| } | |
| } | |
| } | |
| function drawSymmetricalCline(cx, cy) { | |
| ctx.lineWidth = 3; | |
| ctx.shadowBlur = 15; | |
| ctx.shadowColor = '#d414c9'; | |
| const renderWidth = canvas.width * 0.8; | |
| const startX = (canvas.width - renderWidth) / 2; | |
| const sliceWidth = renderWidth / bufferLength; | |
| const grad = ctx.createLinearGradient(startX, 0, startX + renderWidth, 0); | |
| grad.addColorStop(0, '#3b28cc'); | |
| grad.addColorStop(0.5, '#d414c9'); | |
| grad.addColorStop(1, '#3b28cc'); | |
| ctx.strokeStyle = grad; | |
| // Top mirrored half | |
| ctx.beginPath(); | |
| let x = startX; | |
| for(let i = 0; i < bufferLength; i++) { | |
| smoothedArray[i] += ((dataArray[i]/255) - smoothedArray[i]) * 0.2; | |
| const v = smoothedArray[i] * 150; // Amplitude | |
| if(i === 0) ctx.moveTo(x, cy - v); else ctx.lineTo(x, cy - v); | |
| x += sliceWidth; | |
| } | |
| ctx.stroke(); | |
| // Bottom mirrored half | |
| ctx.beginPath(); | |
| x = startX; | |
| for(let i = 0; i < bufferLength; i++) { | |
| const v = smoothedArray[i] * 150; | |
| if(i === 0) ctx.moveTo(x, cy + v); else ctx.lineTo(x, cy + v); | |
| x += sliceWidth; | |
| } | |
| ctx.stroke(); | |
| ctx.shadowBlur = 0; | |
| } | |
| function drawParticles(cx, cy, baseRadius) { | |
| const count = 80; | |
| const step = Math.floor(bufferLength / count); | |
| for(let i=0; i<count; i++) { | |
| smoothedArray[i] += ((dataArray[i*step]/255) - smoothedArray[i]) * 0.1; | |
| const val = smoothedArray[i]; | |
| if(val < 0.05) continue; | |
| const angle = (i/count) * Math.PI * 2 + (Date.now()*0.0002); | |
| const distance = baseRadius + (val * 250); | |
| const px = cx + Math.cos(angle) * distance; | |
| const py = cy + Math.sin(angle) * distance; | |
| ctx.beginPath(); | |
| ctx.arc(px, py, val * 6 + 1, 0, Math.PI*2); | |
| ctx.fillStyle = i % 2 === 0 ? '#d414c9' : '#3b28cc'; | |
| ctx.shadowBlur = 10; | |
| ctx.shadowColor = ctx.fillStyle; | |
| ctx.fill(); | |
| } | |
| ctx.shadowBlur = 0; | |
| } | |
| function drawPulseCore(cx, cy, baseRadius) { | |
| let sum = 0; | |
| for(let i=0; i<20; i++) sum += dataArray[i]; | |
| const avgBass = (sum / 20) / 255; | |
| const radius = baseRadius + (avgBass * 100); | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, radius, 0, Math.PI*2); | |
| ctx.lineWidth = 2 + (avgBass * 15); | |
| ctx.strokeStyle = `rgba(212, 20, 201, ${0.4 + avgBass})`; | |
| ctx.shadowBlur = 40 * avgBass; | |
| ctx.shadowColor = '#d414c9'; | |
| ctx.stroke(); | |
| ctx.shadowBlur = 0; | |
| } | |
| // Event Listeners | |
| btnPower.addEventListener('click', togglePlayback); | |
| modeNodes.forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| modeNodes.forEach(b => b.classList.remove('active')); | |
| const target = e.currentTarget; | |
| target.classList.add('active'); | |
| currentMode = target.dataset.mode; | |
| }); | |
| }); | |
| // Start animation loop | |
| requestAnimationFrame(renderLoop); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment