Resolve conflicts: accept server split, apply hint reorder to terminal.ts, add JSX pragma to phone.tsx Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
/** @jsxImportSource hono/jsx */
|
||
import { define, stylesToCSS } from '@because/forge'
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Forge components
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const Page = define('Page', {
|
||
fontFamily: '-apple-system, system-ui, sans-serif',
|
||
background: '#111',
|
||
color: '#fff',
|
||
height: '100dvh',
|
||
width: '100vw',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
overflow: 'hidden',
|
||
userSelect: 'none',
|
||
WebkitUserSelect: 'none',
|
||
position: 'relative',
|
||
touchAction: 'manipulation',
|
||
})
|
||
|
||
const Screen = define('Screen', {
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
flex: 1,
|
||
width: '100%',
|
||
padding: 20,
|
||
})
|
||
|
||
const Title = define('Title', {
|
||
fontSize: 18,
|
||
color: '#888',
|
||
textAlign: 'center',
|
||
lineHeight: 1.4,
|
||
})
|
||
|
||
const GuessDisplay = define('GuessDisplay', {
|
||
fontSize: 96,
|
||
fontWeight: 'bold',
|
||
fontFamily: 'monospace',
|
||
lineHeight: 1,
|
||
margin: '24px 0',
|
||
})
|
||
|
||
const Controls = define('Controls', {
|
||
display: 'flex',
|
||
gap: 12,
|
||
marginBottom: 24,
|
||
})
|
||
|
||
const StepBtn = define('StepBtn', {
|
||
base: 'button',
|
||
fontSize: 24,
|
||
fontWeight: 'bold',
|
||
width: 64,
|
||
height: 64,
|
||
border: 'none',
|
||
borderRadius: 12,
|
||
background: '#333',
|
||
color: '#fff',
|
||
cursor: 'pointer',
|
||
WebkitTapHighlightColor: 'transparent',
|
||
touchAction: 'manipulation',
|
||
states: { ':active': { background: '#555' } },
|
||
})
|
||
|
||
const GuessBtn = define('GuessBtn', {
|
||
base: 'button',
|
||
fontSize: 24,
|
||
fontWeight: 'bold',
|
||
padding: '16px 48px',
|
||
border: 'none',
|
||
borderRadius: 16,
|
||
background: '#22c55e',
|
||
color: '#fff',
|
||
cursor: 'pointer',
|
||
WebkitTapHighlightColor: 'transparent',
|
||
touchAction: 'manipulation',
|
||
states: { ':active': { background: '#16a34a' } },
|
||
})
|
||
|
||
const HintText = define('HintText', {
|
||
fontSize: 36,
|
||
fontWeight: 'bold',
|
||
marginTop: 32,
|
||
minHeight: 50,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
transition: 'opacity 0.15s ease',
|
||
})
|
||
|
||
const VictoryEmoji = define('VictoryEmoji', {
|
||
fontSize: 64,
|
||
marginBottom: 16,
|
||
})
|
||
|
||
const VictoryText = define('VictoryText', {
|
||
fontSize: 22,
|
||
lineHeight: 1.5,
|
||
textAlign: 'center',
|
||
padding: '0 20px',
|
||
maxWidth: 380,
|
||
})
|
||
|
||
const StatusBar = define('StatusBar', {
|
||
position: 'fixed',
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
padding: 8,
|
||
fontSize: 12,
|
||
color: '#555',
|
||
textAlign: 'center',
|
||
})
|
||
|
||
const VolumeWarning = define('VolumeWarning', {
|
||
fontSize: 14,
|
||
color: '#f59e0b',
|
||
textAlign: 'center',
|
||
marginTop: 16,
|
||
padding: '8px 16px',
|
||
background: 'rgba(245, 158, 11, 0.1)',
|
||
borderRadius: 8,
|
||
border: '1px solid rgba(245, 158, 11, 0.3)',
|
||
})
|
||
|
||
const ConnectBtn = define('ConnectBtn', {
|
||
base: 'button',
|
||
fontSize: 28,
|
||
fontWeight: 'bold',
|
||
padding: '20px 48px',
|
||
border: 'none',
|
||
borderRadius: 16,
|
||
background: '#2563eb',
|
||
color: '#fff',
|
||
cursor: 'pointer',
|
||
WebkitTapHighlightColor: 'transparent',
|
||
touchAction: 'manipulation',
|
||
states: { ':active': { background: '#1d4ed8' } },
|
||
})
|
||
|
||
const ConnectStatus = define('ConnectStatus', {
|
||
fontSize: 18,
|
||
color: '#888',
|
||
marginTop: 24,
|
||
textAlign: 'center',
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Client-side code — real functions, serialized into <script>
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/* eslint-disable */
|
||
// @ts-nocheck — this function is serialized and runs in the browser, not on the server
|
||
function clientMain() {
|
||
var currentGuess = 50
|
||
var playing = false
|
||
var busy = false
|
||
var gg, ggInstance, audioContext
|
||
var pendingResolve
|
||
|
||
var connectScreen = document.getElementById('connect-screen')
|
||
var connectStatus = document.getElementById('connect-status')
|
||
var guessDisplay = document.getElementById('guess-display')
|
||
var hintArea = document.getElementById('hint-area')
|
||
var statusEl = document.getElementById('status')
|
||
var guessBtn = document.getElementById('guess-btn')
|
||
var gameScreen = document.getElementById('game-screen')
|
||
var victoryScreen = document.getElementById('victory-screen')
|
||
var victoryText = document.getElementById('victory-text')
|
||
|
||
function bypassSilentMode() {
|
||
if ('audioSession' in navigator) navigator.audioSession.type = 'play-and-record'
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise(function (resolve) { setTimeout(resolve, ms) })
|
||
}
|
||
|
||
function playChirp(text) {
|
||
var rawBytes = gg.encode(
|
||
ggInstance, text,
|
||
gg.ProtocolId.GGWAVE_PROTOCOL_AUDIBLE_FAST, 50
|
||
)
|
||
var bytesCopy = new Uint8Array(rawBytes.length)
|
||
bytesCopy.set(new Uint8Array(rawBytes.buffer, rawBytes.byteOffset, rawBytes.length))
|
||
var floats = new Float32Array(bytesCopy.buffer)
|
||
|
||
var buf = audioContext.createBuffer(1, floats.length, audioContext.sampleRate)
|
||
buf.getChannelData(0).set(floats)
|
||
var source = audioContext.createBufferSource()
|
||
source.buffer = buf
|
||
source.connect(audioContext.destination)
|
||
|
||
return new Promise(function (resolve) { source.onended = resolve; source.start() })
|
||
}
|
||
|
||
async function sendAndWait(text, timeoutMs) {
|
||
playing = true
|
||
if (audioContext.state === 'suspended') await audioContext.resume()
|
||
await playChirp(text)
|
||
await sleep(500)
|
||
playing = false
|
||
|
||
return new Promise(function (resolve) {
|
||
var timer
|
||
if (timeoutMs) {
|
||
timer = setTimeout(function () {
|
||
pendingResolve = undefined
|
||
resolve(undefined)
|
||
}, timeoutMs)
|
||
}
|
||
pendingResolve = function (msg) {
|
||
if (timer) clearTimeout(timer)
|
||
pendingResolve = undefined
|
||
resolve(msg)
|
||
}
|
||
})
|
||
}
|
||
|
||
function startListening(stream) {
|
||
var source = audioContext.createMediaStreamSource(stream)
|
||
var processor = audioContext.createScriptProcessor(4096, 1, 1)
|
||
var samplesPerFrame = gg.getDefaultParameters().samplesPerFrame
|
||
var buffer = new Float32Array(0)
|
||
|
||
processor.onaudioprocess = function (e) {
|
||
if (playing) return
|
||
|
||
var input = e.inputBuffer.getChannelData(0)
|
||
var newBuf = new Float32Array(buffer.length + input.length)
|
||
newBuf.set(buffer)
|
||
newBuf.set(input, buffer.length)
|
||
buffer = newBuf
|
||
|
||
while (buffer.length >= samplesPerFrame) {
|
||
var frame = buffer.slice(0, samplesPerFrame)
|
||
buffer = buffer.slice(samplesPerFrame)
|
||
|
||
var bytes = new Uint8Array(frame.buffer, frame.byteOffset, frame.byteLength)
|
||
var result = gg.decode(ggInstance, bytes)
|
||
if (result && result.length > 0) {
|
||
var text = Array.from(result)
|
||
.map(function (b) { return String.fromCharCode(b & 0xff) })
|
||
.join('')
|
||
if (pendingResolve) pendingResolve(text)
|
||
}
|
||
}
|
||
}
|
||
|
||
source.connect(processor)
|
||
processor.connect(audioContext.destination)
|
||
}
|
||
|
||
async function connect() {
|
||
try {
|
||
// AudioContext MUST be created synchronously in gesture handler (iOS)
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000 })
|
||
var src = audioContext.createBufferSource()
|
||
src.buffer = audioContext.createBuffer(1, 1, 48000)
|
||
src.connect(audioContext.destination)
|
||
src.start()
|
||
bypassSilentMode()
|
||
|
||
connectStatus.textContent = 'Initializing audio (' + audioContext.sampleRate + ' Hz)...'
|
||
gg = await ggwave_factory()
|
||
gg.disableLog()
|
||
var ggParams = gg.getDefaultParameters()
|
||
ggParams.sampleRateInp = audioContext.sampleRate
|
||
ggParams.sampleRateOut = audioContext.sampleRate
|
||
ggParams.sampleRate = audioContext.sampleRate
|
||
ggInstance = gg.init(ggParams)
|
||
|
||
connectStatus.textContent = 'Requesting microphone...'
|
||
var stream = await navigator.mediaDevices.getUserMedia({
|
||
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
|
||
})
|
||
startListening(stream)
|
||
|
||
connectStatus.textContent = 'Connecting via audio...'
|
||
var response = await sendAndWait('HELLO', 8000)
|
||
|
||
if (response === 'HEY BUDDY') {
|
||
connectScreen.style.display = 'none'
|
||
gameScreen.style.display = 'flex'
|
||
statusEl.textContent = 'Ready'
|
||
} else {
|
||
connectStatus.textContent = 'No response heard. Make sure the server is running and volume is up!'
|
||
connectStatus.style.color = '#ef4444'
|
||
}
|
||
} catch (err) {
|
||
connectStatus.textContent = err.message || 'Failed to connect'
|
||
connectStatus.style.color = '#ef4444'
|
||
}
|
||
}
|
||
|
||
async function sendGuess() {
|
||
if (busy || !gg) return
|
||
busy = true
|
||
guessBtn.textContent = 'Sending...'
|
||
guessBtn.style.background = '#666'
|
||
hintArea.textContent = ''
|
||
hintArea.style.opacity = '0'
|
||
|
||
statusEl.textContent = 'Listening for response...'
|
||
guessBtn.textContent = 'Listening...'
|
||
var response = await sendAndWait(String(currentGuess), 8000)
|
||
|
||
if (response === 'Higher!' || response === 'Lower!') {
|
||
hintArea.style.opacity = '0'
|
||
hintArea.textContent = response
|
||
hintArea.style.color = response === 'Higher!' ? '#f59e0b' : '#3b82f6'
|
||
void hintArea.offsetWidth
|
||
hintArea.style.opacity = '1'
|
||
} else if (response === 'Correct!') {
|
||
gameScreen.style.display = 'none'
|
||
victoryScreen.style.display = 'flex'
|
||
victoryText.textContent = 'You found ' + currentGuess + '!'
|
||
statusEl.textContent = ''
|
||
} else {
|
||
hintArea.textContent = 'No response heard. Volume up on both devices!'
|
||
hintArea.style.color = '#ef4444'
|
||
hintArea.style.opacity = '1'
|
||
}
|
||
|
||
busy = false
|
||
guessBtn.textContent = 'Guess!'
|
||
guessBtn.style.background = '#22c55e'
|
||
statusEl.textContent = 'Ready'
|
||
}
|
||
|
||
function adjust(delta) {
|
||
if (busy) return
|
||
currentGuess = Math.max(1, Math.min(100, currentGuess + delta))
|
||
guessDisplay.textContent = currentGuess
|
||
hintArea.textContent = ''
|
||
hintArea.style.opacity = '0'
|
||
}
|
||
|
||
window.connect = connect
|
||
window.adjust = function (delta) { adjust(delta) }
|
||
window.sendGuess = function () { sendGuess() }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Phone page component
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function PhonePage() {
|
||
return (
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||
<title>Corey's Screechy Audio Demo</title>
|
||
<link rel="stylesheet" href="/styles.css" />
|
||
<script src="/ggwave.js">{""}</script>
|
||
</head>
|
||
<body style="margin:0;overflow:hidden;touch-action:manipulation">
|
||
<Page>
|
||
<Screen id="connect-screen">
|
||
<Title>Corey's Screechy Audio Demo</Title>
|
||
<VolumeWarning>🔊 Turn your volume up on both devices!</VolumeWarning>
|
||
<div style="margin-top:32px">
|
||
<ConnectBtn onclick="connect()">Connect</ConnectBtn>
|
||
</div>
|
||
<ConnectStatus id="connect-status" />
|
||
</Screen>
|
||
|
||
<Screen id="game-screen" style="display:none">
|
||
<Title>Guess the Number<br />1 – 100</Title>
|
||
<GuessDisplay id="guess-display">50</GuessDisplay>
|
||
<Controls>
|
||
<StepBtn onclick="adjust(-10)">-10</StepBtn>
|
||
<StepBtn onclick="adjust(-1)">-1</StepBtn>
|
||
<StepBtn onclick="adjust(1)">+1</StepBtn>
|
||
<StepBtn onclick="adjust(10)">+10</StepBtn>
|
||
</Controls>
|
||
<GuessBtn id="guess-btn" onclick="sendGuess()">Guess!</GuessBtn>
|
||
<HintText id="hint-area" />
|
||
</Screen>
|
||
|
||
<Screen id="victory-screen" style="display:none">
|
||
<VictoryEmoji>🎉</VictoryEmoji>
|
||
<VictoryText id="victory-text" />
|
||
</Screen>
|
||
|
||
<StatusBar id="status" />
|
||
</Page>
|
||
<script dangerouslySetInnerHTML={{ __html: `(${clientMain})()` }} />
|
||
</body>
|
||
</html>
|
||
)
|
||
}
|
||
|
||
export { stylesToCSS }
|