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
- Finds
<video>elements on any page, including ones added later by infinite scroll, single-page navigation, or players that attach late. - Shows a pill button styled to match Firefox’s colours, so it looks like part of the browser. It stays visible after its entrance animation.
- On tap, works out the real playable URL and sends it to Android as an intent. The OS opens your default video player, with no extra picker if you’ve set a default.
- If nothing seems to have happened, it shows a small fallback dialog with explicit VLC and MX Player buttons and a Copy Video URL option.
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
}]
}
data_collection_permissionsis now required for new Firefox extensions."none"declares that the extension collects nothing, which is true here.gecko_androidwith a matchingstrict_min_versionclears the “min version predates this key” warnings.
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
- Streaming and blob URLs. Many sites (most tube sites, Twitch, YouTube, Vimeo, and others) play through MSE or HLS, so the
<video>src is a temporary blob reference. External apps can’t open it. The extension has extraction logic for a few known sites plus a generic best-effort scan, but that isn’t guaranteed to work everywhere, and sites can change their player code at any time. - No forced player. The extension doesn’t pin playback to one app. It relies on your Android default for video files, and if you haven’t set one, Android may show its own chooser.
- Needs a real device to verify. Intent handling, defaults, and player compatibility only show up on an actual Android device with Firefox and a player installed.
- DRM and obfuscation. Encrypted media (EME) or heavily obfuscated players will likely not yield a usable URL.
- Not a downloader. It hands a URL to another app for playback. It doesn’t fetch, store, or convert video.
- Top frame only (for now). Version 1.1.0 only scans the main page, so videos inside cross-origin iframes aren’t seen yet. See the next section.
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:
- "all_frames": true injects the script into every frame, including cross-origin ones. Content scripts aren’t subject to the same-origin rules that stop page JavaScript from reading
iframe.contentDocument. Each frame then runs its own copy of the script, and the pill appears inside the embedded player’s frame. - Network sniffing records any
.m3u8or.mp4request the page makes, which is host-agnostic because an HLS player has to download its playlist at some point. It must run atdocument_start, before the player’s own scripts, or the requests are already gone.
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
- Firefox for Android only offers add-ons that declare Android compatibility, which the
gecko_androidblock does. - Declare data collection explicitly with
data_collection_permissions. - Test sideloading first: Settings → About Firefox → tap the logo five times → Install add-on from file.
What I’d add next
- Network sniffing and iframe support, done properly.
- Remember a preferred player between sessions.
- A quality picker when a page exposes several HLS renditions.
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.