cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

feat: client-side stream stats + codec in wire protocol

dami etoile committed a690554
Wire protocol:
- Header expanded 13 → 14 bytes: added codec byte (0=h264, 1=h265, 2=av1)
- FramePayload now includes codec field
- Client reads codec from wire, passes to frontend via frame event

Frontend:
- Codec tracked from frame payload, not host-codec event (which only
  worked on the host's own frontend, not the client)
- New # stream section in info panel (shown when connected as client):
  codec received, dropped frames, total frames received
- Dropped frames counted: decoder not ready, decode exceptions
- # encoder and # host performance sections hidden when connected
- Sections swap on connect/disconnect via updatePanelVisibility()
commit
a69055418c4455959a6abd253d15dea95bdd60ce
parent
2bd6aee835dd1f0482ef90efa2651fd3c820717d
author
dami etoile <dami.etoile@gmail.com>
2 files changed, +94 -28
Msrc-tauri/src/commands.rs33+++++++++--
Msrc/ui/index.html89+++++++++++++++++++++++-------
M src-tauri/src/commands.rs +26 -7
@@ -43,6 +43,22 @@ const N0_RELAY: &str = "https://usw1-1.relay.n0.iroh.link./";
4343 const KEYRING_SERVICE: &str = "keyhome";4444 const KEYRING_ENTRY: &str = "host-identity";4545 46+fn codec_to_byte(codec: &str) -> u8 {47+ match codec {48+ "h265" => 1,49+ "av1" => 2,50+ _ => 0,51+ }52+}53+54+fn byte_to_codec(b: u8) -> &'static str {55+ match b {56+ 1 => "h265",57+ 2 => "av1",58+ _ => "h264",59+ }60+}61+4662 // ─── Keyring Persistence ─────────────────────────────────────────────────────4763 4864 fn store_identity_in_keyring(secret: &[u8; 32]) -> anyhow::Result<()> {
@@ -1118,7 +1134,7 @@ async fn stream_frames_ffmpeg(
11181134 let frame_ms = now.duration_since(last_frame_time).as_secs_f64() * 1000.0;11191135 last_frame_time = now;11201136 1121- // Header: width(4) + height(4) + size(4) + is_keyframe(1) = 13 bytes1137+ // Header: width(4) + height(4) + size(4) + keyframe(1) + codec(1) = 14 bytes11221138 let header = [11231139 (width as u32).to_be_bytes(),11241140 (height as u32).to_be_bytes(),
@@ -1126,9 +1142,10 @@ async fn stream_frames_ffmpeg(
11261142 ]11271143 .concat();11281144 let kf_byte = if is_keyframe { 1u8 } else { 0u8 };1145+ let codec_byte = codec_to_byte(&config.codec);11291146 11301147 send.write_all(&header).await?;1131- send.write_all(&[kf_byte]).await?;1148+ send.write_all(&[kf_byte, codec_byte]).await?;11321149 send.write_all(frame_data).await?;11331150 11341151 frame_count += 1;
@@ -1218,7 +1235,7 @@ async fn stream_frames_xcap(
12181235 let encode_ms = encode_start.elapsed().as_secs_f64() * 1000.0;12191236 let h264_size = h264_data.len();12201237 1221- // Header: width(4) + height(4) + size(4) + is_keyframe(1) = 13 bytes1238+ // Header: width(4) + height(4) + size(4) + keyframe(1) + codec(1) = 14 bytes12221239 let header = [12231240 (w as u32).to_be_bytes(),12241241 (h as u32).to_be_bytes(),
@@ -1228,7 +1245,7 @@ async fn stream_frames_xcap(
12281245 let kf_byte = if is_keyframe { 1u8 } else { 0u8 };12291246 12301247 send.write_all(&header).await?;1231- send.write_all(&[kf_byte]).await?;1248+ send.write_all(&[kf_byte, 0u8]).await?; // xcap path is always h26412321249 send.write_all(&h264_data).await?;12331250 12341251 frame_count += 1;
@@ -1362,6 +1379,7 @@ pub struct FramePayload {
13621379 pub height: u32,13631380 pub data: String,13641381 pub keyframe: bool,1382+ pub codec: String,13651383 }13661384 13671385 #[derive(Serialize)]
@@ -1484,7 +1502,7 @@ pub async fn iroh_client_connect(
14841502 };14851503 14861504 loop {1487- let mut header = [0u8; 13];1505+ let mut header = [0u8; 14];14881506 if frame_recv.read_exact(&mut header).await.is_err() {14891507 let _ = app.emit("frame-error", "Connection lost");14901508 break;
@@ -1495,6 +1513,7 @@ pub async fn iroh_client_connect(
14951513 let h264_len =14961514 u32::from_be_bytes([header[8], header[9], header[10], header[11]]) as usize;14971515 let is_keyframe = header[12] == 1;1516+ let codec = byte_to_codec(header[13]).to_string();14981517 14991518 let mut h264_buf = vec![0u8; h264_len];15001519 if frame_recv.read_exact(&mut h264_buf).await.is_err() {
@@ -1507,7 +1526,7 @@ pub async fn iroh_client_connect(
15071526 let b64 = base64::engine::general_purpose::STANDARD.encode(&h264_buf);15081527 let _ = app.emit(15091528 "frame",1510- FramePayload { width: w, height: h, data: b64, keyframe: is_keyframe },1529+ FramePayload { width: w, height: h, data: b64, keyframe: is_keyframe, codec: codec.clone() },15111530 );15121531 15131532 frame_count += 1;
@@ -1541,7 +1560,7 @@ pub async fn iroh_client_connect(
15411560 let b64 = base64::engine::general_purpose::STANDARD.encode(&jpeg_buf);15421561 let _ = app.emit(15431562 "frame",1544- FramePayload { width: yw as u32, height: yh as u32, data: b64, keyframe: is_keyframe },1563+ FramePayload { width: yw as u32, height: yh as u32, data: b64, keyframe: is_keyframe, codec: codec.clone() },15451564 );15461565 15471566 frame_count += 1;
M src/ui/index.html +68 -21
@@ -518,7 +518,7 @@
518518 <div class="info-row"><span class="info-label">pin retries</span><span class="info-value" id="fido-pin">—</span></div>519519 <button class="link-btn" style="margin-top: 8px;" onclick="scanFido()">rescan</button>520520 </div>521- <div class="panel-section">521+ <div class="panel-section" id="encoder-section">522522 <h3># encoder</h3>523523 <div class="info-row">524524 <span class="info-label">codec</span>
@@ -555,14 +555,20 @@
555555 <div class="info-row"><span class="info-label">available</span><span class="info-value" id="enc-available">—</span></div>556556 <button class="link-btn" style="margin-top: 8px;" onclick="saveEncoderConfig()">apply</button>557557 </div>558- <div class="panel-section">559- <h3># performance</h3>558+ <div class="panel-section" id="host-perf-section">559+ <h3># host performance</h3>560560 <div class="info-row"><span class="info-label">frame time</span><span class="info-value" id="perf-encode">— ms</span></div>561561 <div class="info-row"><span class="info-label">encoder</span><span class="info-value" id="perf-capture">—</span></div>562562 <div class="info-row"><span class="info-label">frame size</span><span class="info-value" id="perf-size">— KB</span></div>563563 <div class="info-row"><span class="info-label">fps</span><span class="info-value" id="perf-fps">—</span></div>564564 <canvas id="encode-spark" width="260" height="48" style="width:100%;height:48px;margin-top:8px;display:block;"></canvas>565565 </div>566+ <div class="panel-section" id="stream-section" style="display:none;">567+ <h3># stream</h3>568+ <div class="info-row"><span class="info-label">codec</span><span class="info-value" id="stream-codec">—</span></div>569+ <div class="info-row"><span class="info-label">dropped</span><span class="info-value" id="stream-dropped">0</span></div>570+ <div class="info-row"><span class="info-label">received</span><span class="info-value" id="stream-received">0</span></div>571+ </div>566572 <div class="panel-section">567573 <h3># registration</h3>568574 <div class="info-row"><span class="info-label">status</span><span class="info-value" id="reg-status">—</span></div>
@@ -582,6 +588,17 @@
582588 const invoke = (...args) => window.__TAURI__.core.invoke(...args);583589 const listen = (...args) => window.__TAURI__.event.listen(...args);584590 591+ function updatePanelVisibility() {592+ const encSection = document.getElementById('encoder-section');593+ const hostPerfSection = document.getElementById('host-perf-section');594+ const streamSection = document.getElementById('stream-section');595+ // Show encoder config + host perf when hosting (or idle)596+ // Show stream stats when connected as client597+ encSection.style.display = connected ? 'none' : '';598+ hostPerfSection.style.display = connected ? 'none' : '';599+ streamSection.style.display = connected ? '' : 'none';600+ }601+585602 // ─── Status ──────────────────────────────────────────────────────────586603 function setStatus(state, text) {587604 const line = document.getElementById('status-line');
@@ -717,6 +734,7 @@
717734 if (result.running) {718735 hosting = true;719736 setStatus('ok', 'hosting');737+ updatePanelVisibility();720738 document.getElementById('node-id-text').textContent =721739 result.node_id.substring(0, 16) + '...';722740 // Show "stop hosting" disconnect button, hide main action
@@ -748,6 +766,7 @@
748766 } catch (e) { console.error(e); }749767 hosting = false;750768 setStatus('', 'idle');769+ updatePanelVisibility();751770 document.getElementById('action-main').style.display = '';752771 document.getElementById('action-main').textContent = registered ? 'host' : 'register';753772 document.getElementById('action-unreg').style.display = registered ? '' : 'none';
@@ -786,6 +805,9 @@
786805 if (result.connected) {787806 connected = true;788807 setStatus('ok', 'connected');808+ droppedFrames = 0;809+ receivedFrames = 0;810+ updatePanelVisibility();789811 document.getElementById('node-id-text').textContent =790812 result.host_node_id.substring(0, 16) + '...';791813 document.getElementById('frame-canvas').style.display = 'block';
@@ -816,6 +838,7 @@
816838 connected = false;817839 controlMode = false;818840 updateControlUI();841+ updatePanelVisibility();819842 setStatus('', 'idle');820843 document.getElementById('frame-canvas').style.display = 'none';821844 document.getElementById('placeholder').classList.remove('hidden');
@@ -984,7 +1007,9 @@
9841007 // WebCodecs expects length-prefixed NALs + description record.9851008 // AV1 uses OBU format directly.9861009 987- let activeCodec = 'h264'; // updated from host-codec event1010+ let activeCodec = 'h264'; // updated from frame payload1011+ let droppedFrames = 0;1012+ let receivedFrames = 0;9881013 9891014 function parseAnnexBNals(data) {9901015 const nals = [];
@@ -1233,24 +1258,32 @@
12331258 const canvas = document.getElementById('frame-canvas');12341259 const ctx = canvas.getContext('2d');12351260 1236- listen('host-codec', (event) => {1237- activeCodec = event.payload || 'h264';1238- console.log('host codec:', activeCodec);1239- // Reset decoder — new codec needs new config1240- decoderConfigured = false;1241- if (videoDecoder) {1242- try { videoDecoder.close(); } catch(_) {}1243- videoDecoder = null;1244- }1245- });1261+ function updateStreamStats() {1262+ document.getElementById('stream-received').textContent = receivedFrames;1263+ document.getElementById('stream-dropped').textContent = droppedFrames;1264+ document.getElementById('stream-codec').textContent = activeCodec;1265+ }12461266 12471267 listen('frame', (event) => {1248- const { width, height, data } = event.payload;1268+ const { width, height, data, codec } = event.payload;12491269 frameWidth = width;12501270 frameHeight = height;12511271 if (canvas.width !== width) canvas.width = width;12521272 if (canvas.height !== height) canvas.height = height;12531273 1274+ // Update codec from payload — reset decoder if codec changed1275+ if (codec && codec !== activeCodec) {1276+ activeCodec = codec;1277+ decoderConfigured = false;1278+ if (videoDecoder) {1279+ try { videoDecoder.close(); } catch(_) {}1280+ videoDecoder = null;1281+ }1282+ console.log('codec changed:', activeCodec);1283+ }1284+1285+ receivedFrames++;1286+12541287 if (hasWebCodecs) {12551288 const raw = Uint8Array.from(atob(data), c => c.charCodeAt(0));12561289
@@ -1268,7 +1301,11 @@
12681301 }12691302 }12701303 1271- if (!decoderConfigured || !videoDecoder) return;1304+ if (!decoderConfigured || !videoDecoder) {1305+ droppedFrames++;1306+ updateStreamStats();1307+ return;1308+ }12721309 12731310 // Filter out TD and sequence header OBUs — already in description12741311 const frameObus = obus.filter(o => o.type !== 2 && o.type !== 12);
@@ -1285,7 +1322,7 @@
12851322 timestamp: performance.now() * 1000,12861323 data: chunkData,12871324 });1288- try { videoDecoder.decode(chunk); } catch (e) { console.warn('av1 decode failed:', e); }1325+ try { videoDecoder.decode(chunk); } catch (e) { droppedFrames++; console.warn('av1 decode failed:', e); }12891326 }12901327 12911328 } else if (activeCodec === 'h265') {
@@ -1307,7 +1344,11 @@
13071344 }13081345 }13091346 1310- if (!decoderConfigured || !videoDecoder) return;1347+ if (!decoderConfigured || !videoDecoder) {1348+ droppedFrames++;1349+ updateStreamStats();1350+ return;1351+ }13111352 13121353 // Filter out VPS/SPS/PPS/AUD — already in description13131354 const sliceNals = nals.filter(nal => {
@@ -1321,7 +1362,7 @@
13211362 timestamp: performance.now() * 1000,13221363 data: lpData,13231364 });1324- try { videoDecoder.decode(chunk); } catch (e) { console.warn('hevc decode failed:', e); }1365+ try { videoDecoder.decode(chunk); } catch (e) { droppedFrames++; console.warn('hevc decode failed:', e); }13251366 }13261367 13271368 } else {
@@ -1342,7 +1383,11 @@
13421383 }13431384 }13441385 1345- if (!decoderConfigured || !videoDecoder) return;1386+ if (!decoderConfigured || !videoDecoder) {1387+ droppedFrames++;1388+ updateStreamStats();1389+ return;1390+ }13461391 13471392 // Filter out SPS/PPS/AUD — already in description13481393 const sliceNals = nals.filter(nal => {
@@ -1356,7 +1401,7 @@
13561401 timestamp: performance.now() * 1000,13571402 data: lpData,13581403 });1359- try { videoDecoder.decode(chunk); } catch (e) { console.warn('h264 decode failed:', e); }1404+ try { videoDecoder.decode(chunk); } catch (e) { droppedFrames++; console.warn('h264 decode failed:', e); }13601405 }13611406 }13621407 } else {
@@ -1371,6 +1416,8 @@
13711416 };13721417 img.src = url;13731418 }1419+1420+ updateStreamStats();13741421 });13751422 13761423 listen('frame-stats', (event) => {