Pocket YouTube: Streaming YouTube to a PSP over a USB Cable
The PSP's radio can't reach the modern web, so the network moved to the other end of the cable: a Mac companion runs yt-dlp and ffmpeg, and the handheld plays a ring buffer that happens to be a file — search, CJK titles, seek, pause, 44.1 kHz audio and all. Inside: a stream container you can ls, 256 colors impersonating 720p at 12 fps, and the three bugs only real silicon would show — a GPU race photographed by its own raster line, a leaked audio channel, and a build that shipped last week's code with a green checkmark.
Written by Yifeng "Evan" Wang
Pocket YouTube on a real PSP — playing, of all things, a documentary about the PSP. Un-mute for the part a screenshot can't prove: the 44.1 kHz audio is coming out of the handheld too.
The Sony PSP has WiFi. It is 802.11b, it tops out around 100 KB/s on a good day, and its idea of TLS predates the certificates, the ciphers, and frankly the internet that YouTube lives on. No amount of software on the device will make that radio speak to a 2026 CDN.
But look at the machine sitting next to it. During development, a PSP is tethered to a laptop anyway — PSPLINK mounts a directory of your machine as the device's host0: drive over USB 2.0. That cable moves about a megabyte per second of file I/O. A megabyte per second is not much by any modern standard, and it is also, if you are careful, exactly enough to stream video.
So that became the project: YouTube on the PSP, where the network is a USB cable. Search with an on-screen keyboard, browse real results — thumbnails, Chinese titles, view counts — pick one, and watch it, with sound, with pause and seek, on 2004 hardware. A Mac companion process owns everything the PSP cannot do (DNS, TLS, yt-dlp, H.264), and the handheld owns everything it can: a 60 Hz UI, a texture, and an audio ring. If you are new here, the device side is PocketJS — our runtime that runs real Solid JSX on the PSP — and this app is one more entry in its growing family of proofs, merged as #113.
This post is the whole story: a stream container you can ls, a 256-color video plane, an audio thread with no allocator, and the three bugs — a GPU race, a leaked hardware channel, a build system that lied — that only a real device would ever have shown us.
Split the app at the network boundary
The design rule is the same one every project in this family obeys: the device never parses the world, it only consumes what a build step — here, a live one — has already chewed. Pocket Figma froze a 22 MB design file into tile pyramids; Pocket YouTube does the same thing to a video, except the "build step" is a Mac process running while you watch.
The mailbox deserves one sentence of respect. It is two append-only JSON-lines files — the device writes {"t":"search","id":2,"q":"psp"} into out.jsonl, the Mac appends {"t":"results","id":2,...} to in.jsonl — and it generalizes the transport our time-travel debugger already proved out. Every reply echoes the request id, so the app routes responses without ordering assumptions; every bulk payload (a thumbnail row, the stream itself) travels as a side file named in the message. Request/response over tail -f, effectively. It is not glamorous. It is inspectable with cat, testable with a canned driver, and survives either side restarting — the device detects a truncated mailbox and rewinds its read offset.
Search results are worth a look before we get to video, because they solve a problem PocketJS cannot solve on-device: arbitrary text. The PSP build bakes a font atlas of exactly the glyphs the app's source mentions — search results can name any Unicode codepoint in existence. So the Mac renders each result as one 512×64 CLUT8 image: thumbnail, duration badge, title in any script, channel, view count, all typeset with opentype.js and shipped as a side file. The device shows a texture; it never meets a glyph it doesn't know.

Host-rendered rows on the device: titles in any script the PSP's atlas could never bake (CJK included), a duration chip on each thumbnail, rounded corners masked into the pixels — because the GE's scissor is rectangular and cannot round anything.
A video stream you can ls
There is no socket to stream over, so the stream is a file with the shape of a ring buffer — a format we call .pkst. The Mac writes it in place, forever; the PSP polls its 96-byte header and chases the tail. The whole thing, for any length of video, is exactly 1,058,144 bytes:
This is the part of the design I would defend in any architecture review: there is no protocol state machine because the file is the state. The PSP crashes? Reconnect and read the header. The Mac restarts? It truncates the mailbox, the device notices its read offset is past EOF and rewinds. You want to debug the stream? Point a 30-line script at the file and check that latestSeq grows at 12 per second — we did exactly that, from a laptop, to prove a "broken" resume was actually working (the bug was elsewhere; more below).
256 colors, twelve times a second
Now the graphics part, and a short detour for anyone who has never met a fixed-function GPU.
The PSP's GPU — the GE — has no shaders and no YUV path we can feed from a file, but it has excellent support for palettized textures: a texture where each texel is one byte, an index into a 256-entry color table (a CLUT) that the hardware dereferences while sampling. For video over a skinny pipe this is a gift. An RGB frame at 512×128 would be 196 KB; as indices it is 64 KB plus a 1 KB palette — and the decode on the device is nothing at all, because the GPU does the lookup in silicon.
The catch is that 256 colors is not many for a movie frame, and this is where the Mac earns its keep, per frame, at 12 fps:
- Median-cut quantization picks the 256 colors that partition this frame's actual pixel population — and the palette entries are the true means of each partition, not the centers of the boxes. That last clause matters: we first shipped box centers, and the dither (below) amplified the systematic bias into visible speckle on flat areas.
- Serpentine Floyd–Steinberg dithering distributes each pixel's rounding error onto its unvisited neighbors, alternating scan direction per row. This is why 256 colors can impersonate a sunset: your eye integrates the error pattern back into the gradient.
- The palette rides inside the frame. Scene cuts change everything; a global palette would smear. Every slot is self-contained — which will come back to haunt us in the GPU-race section, because two frames of the same scene can carry wildly different palettes: median cut is deterministic, but the order boxes split in — and therefore which index means which color — reshuffles with tiny input changes.
The plane itself is 512×128 texels stretched to the 480×272 screen — the GE wants power-of-two dimensions, and this is the sweet spot: horizontally it is nearly 1:1 (each texel 0.94 screen pixels wide, where sharpness actually lives), vertically it is a 2.13× stretch that bilinear filtering hides in motion. The source is YouTube's 720p progressive stream when available, Lanczos-downscaled — so every texel is earned.
Anamorphic texels have one sharp edge, and we cut ourselves on it. If you letterbox a 16:9 video into the texture's 4:1 box — the obvious ffmpeg one-liner — you get this:

The bug, preserved: aspect-ratio math done in texture space instead of screen space. The frame is "correctly" letterboxed into 512×128 — and the anamorphic stretch then squeezes it into a 213-pixel strip.
The letterbox has to be computed in screen space — where will these pixels land after the stretch? — and mapped back into texels. Fifteen lines of arithmetic, one honest screenshot of the failure, and a lesson that generalizes: when your texture and your screen disagree about pixel shape, every "obvious" size computation is wrong in exactly one coordinate system.
The 26-kilobyte tick
Streaming on the device is one function, videoTick(), called once per 60 Hz frame on the main thread — the same thread that runs your JSX. It is allowed at most 26 KB of file I/O per tick, and the entire real-time behavior of the app falls out of how that budget is spent:
Note what is absent: threads fighting over the cable, a streaming heuristic, adaptive anything. The reader chases latestSeq; if USB stalls, it presents the last good frame and catches up by skipping to the tail, not by replaying the past. The 60 Hz UI never hitches, because the pump cannot exceed its budget by construction. When we doubled the plane to 512-wide, the entire "will it keep up?" question reduced to the arithmetic in that diagram — and one measurement on hardware: play a video, sample the HUD clock twice over ten wall seconds, confirm it advanced ten seconds. It did.
Audio is a thread with no allocator
Audio on the PSP is beautifully primitive: you reserve a hardware channel, and a thread hands the kernel 1,024-frame PCM blocks; each call blocks until the hardware drains. Our audio thread is ~40 lines of no_std Rust around a single-producer single-consumer ring in plain RAM — videoTick pushes source-rate PCM in from the file, the thread pulls blocks out. No allocator, no locks; two atomic cursors with acquire/release ordering.
It fought us anyway, three times, in escalating order of subtlety:
- The channel leak. Releasing the hardware channel from the audio thread itself failed persistently — release reports "busy" while the final blocks drain, and (we believe, though the firmware isn't saying) it also cares which thread asks. The failure was silent, and the symptom was maddening: audio worked for exactly one video per boot. Every later
reservefailed against the leaked channel. The fix is a rule worth engraving: the thread that reserves the channel releases the channel, with retries across the drain, and the worker thread only signals and self-deletes. - The sizzle. With the leak fixed, playback carried a constant fizz under the music. Two suspects were executed together: the PSP's SRC (sample-rate-converter) channel — the "hardware will resample your 22.05 kHz" path, a known quirk pit on real units — and a missing data-cache writeback on the output buffer. We moved to a normal channel at the PSP's native 44.1 kHz, doing the 2× upsample ourselves (linear interpolation, carrying the last frame across block boundaries), and flush the dcache before every submit, because the hardware DMAs the buffer and cached lines are your problem on this machine. There is no memory-coherent bus fairy in 2004.
- The clock. There is no A/V sync algorithm. Audio joins one chunk behind the writer's tail, video chases the newest slot, and both rings are shallow enough (0.7 s and 5.9 s) that they cannot drift apart meaningfully before the next seek or pause resets them both. Sync by construction, not by correction.
That SIGSTOP pause trick from the ring diagram also had a beautiful failure worth confessing: resume originally sent SIGCONT — and the picture leapt forward by the length of the pause, because ffmpeg's -re real-time pacer keeps counting wall clock while the process is frozen, then sprints to catch up. Resume is now "seek to where you stopped." The OS was technically doing exactly what we asked.
The race you can only lose on real silicon
Here is the best bug of the project. When we doubled the plane to 512×128, playback grew flickering full-screen noise — except for a clean band at the top of the screen. Colors, not tearing: random confetti, changing every frame, over an otherwise perfectly advancing video. In the emulator: nothing. In the deterministic sim: nothing. Only the hardware.
The clean band was the confession. PocketJS's render loop is pipelined the way every PSP engine's is:
The fix costs nothing: videoTick now only stages and validates a frame; the actual texture write happens in vid::present(), called by the render loop in the gap between sceGuSync (GE provably idle) and kicking the next list. One staged frame, at most one 60 Hz tick of extra latency on a 12 fps stream, and the race is not "unlikely" — it is structurally impossible.
I want to be honest about how it was found, because the method matters more than the fix. We could not see the flicker (this device is driven from a terminal, an ocean of abstraction away from its screen). The report was human: "constant noise, but the top ~50 pixels are clean." Fifty screen pixels is 24 texel rows. From there it is not debugging, it is geometry — what writes a texture from the top and had time to finish 24 rows? The previous frame's rasterizer. Users make excellent oscilloscopes if you take their words literally.
The build that lied
One more war story, and the most embarrassing one, because the bug wasn't in the product — it was in our belief system.
Halfway through hardware bring-up, fixes stopped fixing. Audio repairs that were provably correct changed nothing on the device. The eventual strings one-liner is now burned into the project's memory: the PRX on the device did not contain the new code. Our build.rs embedded the app bundle via include_str! — but declared no rerun-if-changed for it, so a JS-only rebuild relinked a fresh executable around a stale embedded bundle. The build reported success. The deploy reported success. The device ran last week.
It got better: the stale embed had been masking a compile error in one of the "shipped" fixes. We had verified, on hardware, with screenshots, code that had never compiled.
Two lines of cargo:rerun-if-changed fixed the mechanism. The lesson fixed the methodology: process evidence lies; artifact evidence doesn't. Every hardware verdict since starts with strings pocketjs-psp.prx | grep <a literal only the new code contains>. Trust the binary, not the build log.
What the framework paid back
Everything above is systems plumbing. Here is why doing it inside PocketJS was the point.
Text entry became a framework capability, not app furniture. This project needed a real keyboard, so the PSP's on-screen keyboard got promoted into @pocketjs/framework/osk: an LVGL-style variable-width key grid — three layers, 40 keys in the letters layer — with the editing session (buffer, caret) in a controller and input adapters per platform: d-pad spatial navigation on PSP, front-panel touch on Vita, the virtual cursor wherever it's enabled. While open it is modal — it pushes a focus scope and a button-handler block, so an app cannot freeze itself behind an invisible keyboard by forgetting a guard (we know, because an earlier app-local keyboard did exactly that). Adopting it in an app is one createOsk() and one <Osk/>:
const osk = createOsk({ value: query, setValue: setQuery, onCommit: () => search() });
onButtonPress(BTN.TRIANGLE, () => osk.open());
// …
<Osk osk={osk} /> // docked, modal, themed; d-pad / touch / cursor all wired
The system OSK — the same component any PocketJS app now gets. The layout math that renders these variable-width keys is the same math the d-pad navigation and the touch hit-testing consume, so they can never disagree.
The sim typed on this keyboard before the keyboard existed on hardware. PocketJS is deterministic to the byte, so the app's test suite is nine scripted journeys: boot a world, feed it a canned host driver, press virtual buttons, assert on the component tree and the command stream. The keyboard journeys don't even hard-code button sequences — they run BFS over the actual key layout to derive the d-pad path to each letter, so a layout tweak re-derives every test. One journey types by touch, end to end, on a PSP app, in CI, in about four seconds.
The device became scriptable the way a browser is. The same DevTools channel that powers time travel gave this project its hands and eyes: replay tapes inject button masks, screenshots come back over the cable, the component tree answers queries — while a human also holds the device. Ten of the eleven bugs this project logged were found and verified through that loop, from a terminal. The one that wasn't — the flicker — was found by a human eye and localized through it. This whole journey was "filmed" that way, no hands involved:

One search-to-playback journey, every frame the device's own framebuffer: a replay tape presses the buttons, the DevTools channel takes the pictures, the same USB cable carries both — and the video stream.
The scorecard for the whole feature: 8 new host ops (the mailbox and the video plane), one no_std ring-parser module shared by test and target, 9 sim journeys, 12 host-pipeline tests, 11 keyboard-geometry tests, 77 Rust core tests — and a bundle that is still, in the end, one Solid component tree that any web developer could read.
The numbers
- 1 USB cable — no WiFi, no sockets, no network stack on the device
- 1,058,144 bytes — one preallocated ring file per stream, any video length
- 512×128 CLUT8 plane at 12 fps, quantized per-frame from YouTube's 720p stream
- ~0.87 MB/s steady state on the cable, under a 26 KB per-tick budget with 1.8× headroom
- 44.1 kHz stereo out of a 40-line audio thread, 2× software upsample, zero allocations
- 60 Hz UI throughout — search, scroll, on-screen keyboard, HUD, never blocked by the stream
- 11 bugs found on real hardware; 0 found by the emulator; the sim caught everything it structurally could
- 1 framework keyboard now shared by every PocketJS app on PSP and Vita
What's next
The honest ceiling of this design is the pipe: raw palettized frames cost what they cost. But the PSP has a hardware H.264 decoder — the Media Engine — sitting one undocumented interface away. Move decode onto the device and the cable carries compressed video instead of pixels: an order of magnitude less bandwidth, which buys 480×272 at full frame rate. That is the next mountain, and the streaming architecture above was deliberately built so that only the payload of the ring changes when we climb it.
And because the host pipeline is just "ffmpeg into a ring file," there is a one-afternoon spin-off we keep grinning about: point it at screen capture instead of a YouTube URL, and the PSP becomes a wired second monitor. A 2004 handheld as a Mac status display, over the same cable it charges from.
The device never parses the world. It just plays whatever the world writes into one small, honest file.
Pocket YouTube is open source at pocket-stack/pocket-youtube, host service included — a PSP, a USB cable, and bun run serve away. Follow @pocket_js for what the Media Engine says back.