I set out to build the app that would save the world. Embedding YouTube turned out to be the easy 10%.

작성자

카테고리:

← 피드로
DEV Community · Joe Lin · 2026-08-12 개발(SW)

I set out to build the app that would save the world. Reader, it does not save the world. What it actually does is sit in a small always-on-top window on your desktop, play a video (or read an article out loud) while you pretend to be deep in a spreadsheet, and vanish completely the second you hit a hotkey because your boss just walked past your desk. Grandiose delusions of world-saving aside, here’s the part of that pitch that actually mattered: this was originally supposed to ship as a free Chrome extension first, with a paid desktop version following later only if anyone cared. That’s not what happened.

Chrome’s extension commands API only lets you register a couple of fixed shortcuts, and the user has to go rebind them from a separate chrome://extensions/shortcuts settings page, not from inside your own UI — dead on arrival for something whose entire premise is “the hide-everything key has to feel instant and yours.” And getting a real always-on-top, frameless, click-through floating window to behave consistently inside an extension’s sandbox turned out to need a lot more rework than I’d budgeted for. So the free extension is still on the shelf getting redesigned, and the paid Windows desktop app — Tauri, Rust backend, real OS-level global hotkeys — ended up finished and shipped to the Microsoft Store first instead.

Once that decision was made, I figured the actual video feature would be the fast part: grab YouTube’s IFrame Player API, drop it in a frameless window, ship it before lunch. That’s not remotely how it went either. The floating-window shell and hotkeys came together fast. What ate most of the development time had nothing to do with UI: reliably telling apart “the video is buffering” from “YouTube just silently blocked this and nothing is ever coming.”

The part that went as expected: crop instead of scale

Since it’s a small floating window, I wanted the video to fill the whole frame edge-to-edge rather than sit inside black bars — closer to how a phone lets you pinch-zoom a video than how a normal embedded player behaves. An <iframe> doesn’t support object-fit: cover the way a real <video> element does, so the actual video area has to be deliberately oversized and then shifted with plain positioning:

function iframeW() {
  return Math.round(clip.clientWidth * zoomPct / 100);
}

function applyCropPos() {
  const w = iframeW();
  const h = Math.round((w * 9) / 16);
  const extra = clip.clientHeight - h;
  const bottom = extra > 0 ? Math.round(extra / 2) : yOff;
  f.style.cssText =
    `position:absolute; bottom:${bottom}px; left:calc(50% + ${xOff}px); transform:translateX(-50%);` +
    `width:${w}px; height:${h}px; border:none;`;
}

Enter fullscreen mode Exit fullscreen mode

The iframe is always forced to a true 16:9 box computed from the window’s width, not the video’s — then whatever doesn’t fit vertically just hangs off the top/bottom of the visible clip area, and dragging or scrolling only ever nudges xOff/yOff, which get clamped so you can’t drag the video away from the window entirely:

function clampCrop() {
  const w = iframeW();
  const h = Math.round((w * 9) / 16);
  const minY = -(Math.max(0, h - clip.clientHeight));
  yOff = Math.max(minY, Math.min(0, yOff)); // never scroll past either edge
  const exX = Math.max(0, (w - clip.clientWidth) / 2) + 80; // small overshoot allowance either side
  xOff = Math.max(-exX, Math.min(exX, xOff));
}

Enter fullscreen mode Exit fullscreen mode

This part behaved exactly like I expected going in: fiddly math, but nothing surprising.

The part I didn’t expect: proving the black box isn’t actually broken

Here’s the failure mode that took the longest to nail down. Every so often, autoplay just… doesn’t. The player loads, the frame renders, and nothing plays — no error, no exception, no event fires to tell my code anything went wrong. From the outside, a genuinely stuck video and a video that’s still buffering on a slow connection look identical: a static frame sitting there doing nothing.

What’s actually happening, most of the time, is that YouTube served an interstitial instead of the video — a “confirm you’re not a bot” style challenge, sometimes triggered by embedding from an unfamiliar app context. There’s no JS callback for “the embed you requested got swapped for a captcha screen.” So the app has three independent ways of guessing at it, from most to least trustworthy:

Mechanism 1 (Rust side, primary): read the actual API response. YouTube’s embedded player talks to an internal endpoint (/youtubei/v1/player) that returns a playabilityStatus field, and the Rust backend intercepts that network response directly through the WebView2 APIs to inspect it — the same kind of interpretation logic the open-source yt-dlp project uses to distinguish a genuine bot-check/CAPTCHA state from things that look similar but aren’t, like an age-restriction notice (which needs a completely different response — that one isn’t “stuck,” it’s working as intended, just gated):

fn is_bot_check_reason(playability: &Value) -> bool {
    let reason = playability.get("reason").and_then(Value::as_str).unwrap_or("").to_lowercase();
    let status = playability.get("status").and_then(Value::as_str).unwrap_or("").to_lowercase();

    let is_age_gate = playability.get("desktopLegacyAgeGateReason").is_some()
        || AGE_GATE_REASONS.iter().any(|k| reason.contains(k) || status.contains(k));
    if is_age_gate { return false; } // age-gated is a different problem, not "stuck"

    let has_captcha_signal = playability.pointer("/errorScreen/playerCaptchaViewModel").is_some();
    has_captcha_signal || reason.contains("this helps protect our community")
}

Enter fullscreen mode Exit fullscreen mode

Mechanism 2 (fallback): ask the WebView what text is actually on screen. Using the Chrome DevTools Protocol (DOM.getDocument with pierce: true to see through the cross-origin iframe), the Rust side pulls the rendered text every couple of seconds and checks it against a short list of phrases. This one only exists because mechanism 1, however solid it feels, is still inference from an internal API response format that isn’t a public contract — if YouTube reshapes that response, this is the safety net.

Mechanism 3 (last resort, in the JS layer): a plain timeout. If neither of the above caught anything and 12 seconds have passed since autoplay was supposed to start with still no PLAYING state, assume it’s stuck anyway:

function armStuckTimer() {
  stuckTimer = setTimeout(() => { if (!everPlayed) showBlockOverlay(); }, STUCK_TIMEOUT_MS);
  let pollsLeft = Math.ceil(STUCK_TIMEOUT_MS / DOM_POLL_INTERVAL_MS);
  domPollTimer = setInterval(async () => {
    if (everPlayed || --pollsLeft <= 0) { clearInterval(domPollTimer); return; }
    const found = await invoke("check_bot_check_dom");
    if (found && !everPlayed) showBlockOverlay();
  }, DOM_POLL_INTERVAL_MS);
}

Enter fullscreen mode Exit fullscreen mode

That pollsLeft countdown matters more than it looks: mechanism 2 is a DevTools Protocol round-trip into the WebView asking it to walk its entire DOM tree, which isn’t free to run forever. It only polls for the same window that the plain timeout is already counting down — once the video actually starts playing, or the timeout budget runs out, the interval clears itself instead of quietly polling for the rest of the video’s runtime.

When any mechanism fires, the app shows an overlay with two honest options: reload the player, or sign in with your Google account first (some of these walls go away once the embed is running with a logged-in session) and then reload. It’s not a magic fix — it’s just not leaving you staring at a silently frozen black rectangle with no idea what happened or what to try next.

The smaller wrinkle: letting people escape to YouTube’s own controls

Crop mode looks great for casual watching, but it hides YouTube’s real control bar, so there’s a toggle to temporarily switch to the official player chrome when someone needs subtitles, quality settings, or the actual seek bar:

async function toggleNativeMode() {
  savedSize = { w: window.innerWidth, h: window.innerHeight };
  const videoW = Math.max(window.innerWidth, NATIVE_MODE_MIN_WIDTH);
  const videoH = Math.round(videoW * 9 / 16);
  clip.style.cssText = `position:absolute; top:${TOP_STRIP_HEIGHT}px; left:0; width:${videoW}px; height:${videoH}px;`;
  await win.setSize(new LogicalSize(videoW, TOP_STRIP_HEIGHT + videoH + BOTTOM_STRIP_HEIGHT));
}

Enter fullscreen mode Exit fullscreen mode

The mildly interesting part is how the layout avoids fighting itself: the window resizes taller, the video area gets pushed down below a fixed-height strip at the top and above one at the bottom, and the drag handle / close button / gear icon — which were already pinned to the window’s edges by CSS, not positioned relative to the video — just naturally land in that newly opened space without needing to be recalculated at all. Toggle it off again and savedSize restores the exact width/height it had before, so flipping back and forth doesn’t slowly drift the window to a different size each time.

Where the guessing still isn’t perfect

None of the three detection mechanisms are bulletproof, and I don’t pretend they are:

  • The DOM-text fallback only checks a short list of English phrases. I’ve genuinely never seen the block screen render in another language, so I have nothing to test the keyword list against — it’s a backstop for when the primary mechanism misses, not a verified multilingual solution.
  • The 12-second timeout is a number I landed on by feel, not measurement. On a slow or congested connection, a video that’s simply still buffering can occasionally trip the same overlay as a real block — the timeout can’t actually distinguish “stuck because blocked” from “stuck because your Wi-Fi is bad” if the other two mechanisms haven’t already caught it.
  • The crop position (pan and zoom) lives in memory only — close the floating window and it’s gone, back to centered next time, even for the same video.

There’s also a read-aloud mode in the same floating-window shell — paste in an article or a novel excerpt and it reads it back to you sentence by sentence, which turned out to have its own gremlin (the browser’s speech engine can silently stop mid-sentence with no error at all), but that’s a story for another post. The video mode was where the real fight was.

I ended up shipping the whole thing as Yarn Thread – Pro — the always-on-top window described above, hotkey and all. It hasn’t saved the world yet. It has gotten me through a lot of boring afternoons.

Get it

  • Yarn Thread – Pro — Microsoft Store (language switches inside the app’s own settings, no separate store pages per language)

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다