Why I built it

Samsung Internet has a small button that hands a web video to an external player. Firefox for Android doesn’t. I wanted that one-tap experience, as close as possible to the Samsung Browser one, so I built a Firefox extension for it: Video2Player.

What it does

Platform

It’s Android-only, because it relies on Android’s intent system. It has no purpose on desktop Firefox.

A tip

It works best with an adblocker. Without one, ad videos on a page get detected too, and the extension can offer to open an ad in your player instead of the video you wanted. The same goes for apps like JustPlayer, which will happily open whatever URL they’re given.

The hard part — blob URLs

The obvious plan is to read video.src and pass it to VLC. That fails on a large share of the web.

Most modern streaming sites play video through MediaSource Extensions (MSE) and HLS. The <video> tag then has a src like blob:https://example.com/1f3c…, a temporary reference that only exists inside that browser tab. VLC can’t open it. My first version detected the video, fired the intent, and nothing happened.

So the real job is recovering the actual stream URL (usually an .m3u8 or .mp4) from behind the blob.

How it’s built

The repo is a small Manifest V2 extension:

manifest.json   – Manifest V2, Android-only
content.js      – detection, URL recovery, intent builder, fallback UI
content.css     – pill button + fallback dialog styles
background.js   – re-injects the content script on a toolbar tap
icons/

The manifest

Firefox for Android is strict about manifests. I hit three validator warnings before it was clean, and the working version looks like this:

{
  "manifest_version": 2,
  "name": "Video2Player",
  "version": "1.1.0",
  "browser_specific_settings": {
    "gecko": {
      "id": "video2player@firefox-android",
      "strict_min_version": "142.0",
      "data_collection_permissions": { "required": ["none"] }
    },
    "gecko_android": { "strict_min_version": "142.0" }
  },
  "permissions": ["activeTab", "storage", "clipboardWrite"],
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content.js"],
    "css": ["content.css"],
    "run_at": "document_idle",
    "all_frames": false
  }]
}

Detecting videos

A MutationObserver watches for new <video> elements, with a throttle so busy pages don’t get hammered. A couple of delayed re-scans catch players that attach their source late, for example after an ad or after the user presses play. Anything under 100px is ignored, which filters out tracking pixels and hover previews.

Recovering the real URL

When video.src is a blob, the script tries several strategies, from most specific to least:

function getVideoSrc(video) {
  // 1. A real URL already on <video>, <source>, or data-* attributes
  const direct = candidates.find(u => u && !u.startsWith("blob:"));
  if (direct) return direct;

  // 2. Site-specific extraction, for sites that expose URLs in inline JS
  const site = extractKnownSiteUrl();
  if (site) return site;

  // 3. Best-effort regex scan of inline <script> text
  const generic = extractGenericStreamUrl();
  if (generic) return generic;

  // 4. Last resort: the blob (triggers the fallback dialog)
  return candidates.find(u => u.startsWith("blob:")) ?? null;
}

Site-specific extraction. Some sites put their real stream URLs in an inline script, in calls like player.setVideoUrlHigh('https://…mp4'). A regex over the <script> text recovers them. It’s precise, but it only works for sites I’ve looked at, and right now that means xvideos.com.

Generic script scan. A regex finds https://….mp4 or ….m3u8 in inline scripts, skipping anything that looks like a thumbnail, preview, or .vtt subtitle file. It’s a heuristic and will sometimes miss.

The URL is re-resolved at tap time, not when the button is created. Many players only settle on the real source once playback has started.

Building the intent

Android launches apps from a browser through intent:// URIs. My first attempt used intent:<full-url>#Intent;…, which can break when a signed CDN URL contains ; or other awkward characters. The canonical form strips the scheme and passes it as a field:

function buildIntentUri(rawUrl, opts = {}) {
  const u = new URL(rawUrl);
  const scheme = u.protocol.replace(":", "");
  const rest = rawUrl.slice(u.protocol.length + 2); // strip "https://"

  const parts = [
    "action=android.intent.action.VIEW",
    `scheme=${scheme}`,
    "type=video/*",
  ];
  if (opts.package) parts.push(`package=${opts.package}`);
  parts.push("end");

  return `intent://${rest}#Intent;${parts.join(";")};`;
}

The main button doesn’t set a package, so Android decides. It opens whatever you’ve set as the default handler for video/*. If VLC is your default, you get VLC straight away with no picker. Only the explicit buttons in the fallback dialog pin a package.

Knowing when the handoff failed

A page can’t directly tell whether an intent launched anything. But when another app opens, the tab goes into the background and visibilitychange fires:

let handedOff = false;
const onHide = () => { if (document.hidden) handedOff = true; };
document.addEventListener("visibilitychange", onHide);

location.href = intentUri;

setTimeout(() => {
  document.removeEventListener("visibilitychange", onHide);
  if (!handedOff) showFallbackDialog(src);   // nothing opened
}, 1500);

If the tab is still visible after 1.5 seconds, the extension assumes the handoff failed and offers the manual options instead of leaving you with a dead button.

Limitations

What’s next: iframe players and network sniffing (experimental)

Many streaming and aggregator sites don’t host the player on their own page. They embed it from another domain in an iframe, often with a choice of mirror hosts. A content script that only runs in the top frame never sees that <video>. I’m working on two changes for this, and neither is in the repo yet:

There’s a Firefox catch here. Content scripts run in an isolated world, so patching window.fetch directly changes the content script’s own copy and not the page’s. The patch has to go through wrappedJSObject:

const pageWindow = window.wrappedJSObject;
const sniffed = [];

function record(url) {
  if (typeof url === "string" && /\.(m3u8|mp4)(\?|#|$)/i.test(url)
      && !sniffed.includes(url)) {
    sniffed.unshift(url);
    if (sniffed.length > 8) sniffed.length = 8;
  }
}

const origFetch = pageWindow.fetch;
pageWindow.fetch = exportFunction(function (...args) {
  try {
    const req = args[0];
    record(typeof req === "string" ? req : req && req.url);
  } catch (_) {}
  return origFetch.apply(this, args);
}, pageWindow);

An alternative that avoids the isolation problem is browser.webRequest in the background script, which sees every request in every frame and can pass the URLs to the content script. I haven’t tested either approach on a live device yet, so I’m treating this as experimental.

One limit stays either way. Hosts that decrypt and assemble video entirely in the browser, with Mega as the clearest example, never make a plain .m3u8 or .mp4 request, so there’s nothing to sniff.

Publishing notes

What I’d add next

If it breaks on a site you care about, open an issue on the repo: https://github.com/kiraitachi/Firefox-Video2player/issues.

References

Note: This is a work in progress. Changes and updates are tracked on GitHub (repo), not in this blog post.