Heads up before you read this: I vibe-coded a local monkey-patch to fix this for myself. I'm aware GitHub is getting flooded with AI slop and I don't want to waste your time with a useless issue. If the bug description or monkey patch I vibe-coded is useful, great. If neither are useful, please just close this so it doesn't waste any more time.
The following text is AI generated, but I fact checked it and it contains all information that was useful to me in understanding and fixing this bug.
What I saw
Stremio Settings → "Play in external player" only offers two options for me: Disabled and M3U playlist. With M3U playlist selected, clicking the external-player button on a stream:
- shows the toast "Stream opened in external player" with a green checkmark
- but no media player ever launches
- a file
playlist (N).m3u keeps appearing in ~/Downloads
So the green check is misleading — the UI thinks it succeeded, but the user gets nothing playing.
Environment
- Distro: Arch Linux (Hyprland / Wayland)
- Package:
stremio-linux-shell 1.0.0-beta.13-1 (AUR, CEF-based build)
- Upstream URL in pacman metadata: https://github.com/Stremio/stremio-linux-shell
- mpv installed and registered for video MIME types and
application/x-mpegurl / application/vnd.apple.mpegurl
I realize main has since been refactored from CEF to GTK4 + WebKit (#31), so this exact code path no longer exists upstream. I'm filing it anyway because:
- The released package most users on AUR/Arch are running is still the CEF tree.
- The underlying UX bug — "M3U mode emits a download, not a player launch" — likely has a sibling on the WebKit branch worth verifying. The web app sends a
data: M3U via download regardless of which native shell is wrapping it.
Where the bug actually lives (CEF tree)
Three handoff paths exist, none of them launch a player:
WebViewEvent::Open(url) (popup) → app.open_url(url) → xdg-desktop-portal OpenFileRequest::send_uri. For an http://127.0.0.1:1147x/... stream URL, the portal hands it to the default x-scheme-handler/http, i.e. the browser, not mpv.
IpcEvent::OpenExternal(url) → same app.open_url(url) portal path.
- M3U playlist mode never hits either of those. The web app issues a download of a base64
data:application/x-mpegurl;... blob. With no DownloadHandler installed on the CEF client, CEF's default behavior just saves it to ~/Downloads. The "opened in external player" toast fires from web-app code before any handoff actually happens.
I confirmed this empirically: instrumented both native handlers, clicked external-player, no log lines fired, but ~/Downloads/playlist (N).m3u appeared.
What my local monkey-patch does (CEF tree)
For my own use I patched the shell to:
- Add a CEF
DownloadHandler to the client. looks_like_playlist checks suggested name / item name / URL / MIME type for .m3u, .m3u8, mpegurl, x-mpegurl, vnd.apple.mpegurl.
- On match: redirect the file path to
$XDG_RUNTIME_DIR/stremio-external-player/playlist-<id>.m3u and let CEF complete the download there.
- On
on_download_updated with is_complete(), spawn /usr/bin/mpv -- <path> and reap the child in a detached thread so it doesn't zombie.
- Also rewire
WebViewEvent::Open and IpcEvent::OpenExternal to spawn mpv directly instead of going through the portal.
It works end-to-end on my system: real mpv window, HDR10 PQ output, E-AC3 passthrough, no Downloads spam.
This is not PR-ready — mpv path is hardcoded, no config, no opt-out, no Windows/Mac story, and obviously the whole CEF module path is gone on main. Treating it strictly as a reference for the bug class.
What might actually be worth doing upstream (WebKit tree)
I haven't built or tested the WebKit branch, so this is speculation, but if the symptom reproduces there:
- The web app currently emits a
data: M3U download for the "M3U playlist" external-player mode.
- WebKit's equivalent intercept point would be
webkit_web_view's download-started signal, similar idea: detect M3U MIME / extension, redirect destination, spawn the configured external player on completion.
- The popup / IPC
open_uri path (webview.connect_open_external → window.open_uri) probably also wants a configurable "external player" command instead of the default GTK URI launcher when the URI is a local stream.
Repro
- Install
stremio-linux-shell (AUR) or any current CEF build.
- Settings → Play in external player → M3U playlist.
- Pick any stream, click the external-player button.
- Observe: green checkmark + toast, no player launches, new
playlist (N).m3u in ~/Downloads.
Local monkey-patch diff (CEF tree, ~250 lines, reference only)
diff -ruN a/src/external_player.rs b/src/external_player.rs
--- a/src/external_player.rs
+++ b/src/external_player.rs
@@ -0,0 +1,16 @@
+use std::process::Command;
+
+pub fn launch_mpv(source: &str, target: &str) {
+ tracing::info!("Launching external mpv from {source}: {target}");
+
+ match Command::new("/usr/bin/mpv").arg("--").arg(target).spawn() {
+ Ok(mut child) => {
+ std::thread::spawn(move || {
+ let _ = child.wait();
+ });
+ }
+ Err(error) => {
+ tracing::error!("Failed to launch external mpv player: {error}");
+ }
+ }
+}
diff -ruN a/src/main.rs b/src/main.rs
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
mod app;
mod config;
mod constants;
+mod external_player;
mod instance;
mod ipc;
mod player;
@@ -13,6 +14,7 @@
use clap::Parser;
use config::Config;
use constants::{STARTUP_URL, URI_SCHEME};
+use external_player::launch_mpv;
use glutin::{display::GetGlDisplay, surface::GlSurface};
use instance::{Instance, InstanceEvent};
use ipc::{IpcEvent, IpcEventMpv};
@@ -230,7 +232,7 @@
app.set_cursor(cursor);
}
WebViewEvent::Open(url) => {
- futures::executor::block_on(app.open_url(url));
+ launch_mpv("WebViewEvent::Open", url.as_str());
}
WebViewEvent::Ipc(data) => ipc::parse_request(data, |event| match event {
IpcEvent::Init(id) => {
@@ -241,7 +243,7 @@
app.set_fullscreen(state);
}
IpcEvent::OpenExternal(url) => {
- futures::executor::block_on(app.open_url(url));
+ launch_mpv("IpcEvent::OpenExternal", &url);
}
IpcEvent::Quit => {
event_loop_proxy.send_event(UserEvent::Quit).ok();
diff -ruN a/src/webview/app/client/download_handler.rs b/src/webview/app/client/download_handler.rs
--- a/src/webview/app/client/download_handler.rs
+++ b/src/webview/app/client/download_handler.rs
@@ -0,0 +1,161 @@
+use std::{
+ collections::HashSet,
+ fs::create_dir_all,
+ path::{Path, PathBuf},
+ sync::{Mutex, OnceLock},
+};
+
+use crate::{cef_impl, external_player::launch_mpv};
+
+static LAUNCHED_DOWNLOADS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
+
+fn launched_downloads() -> &'static Mutex<HashSet<u32>> {
+ LAUNCHED_DOWNLOADS.get_or_init(|| Mutex::new(HashSet::new()))
+}
+
+fn playlist_dir() -> PathBuf {
+ std::env::var_os("XDG_RUNTIME_DIR")
+ .map(PathBuf::from)
+ .unwrap_or_else(std::env::temp_dir)
+ .join("stremio-external-player")
+}
+
+fn cef_userfree_to_string(value: &CefStringUserfree) -> String {
+ CefString::from(value).to_string()
+}
+
+fn looks_like_playlist(
+ download_item: Option<&mut DownloadItem>,
+ suggested_name: Option<&CefString>,
+) -> bool {
+ let suggested_name = suggested_name
+ .map(ToString::to_string)
+ .unwrap_or_default()
+ .to_lowercase();
+
+ let Some(download_item) = download_item else {
+ return suggested_name.ends_with(".m3u") || suggested_name.ends_with(".m3u8");
+ };
+
+ let mime_type = cef_userfree_to_string(&download_item.mime_type()).to_lowercase();
+ let item_name = cef_userfree_to_string(&download_item.suggested_file_name()).to_lowercase();
+ let url = cef_userfree_to_string(&download_item.url()).to_lowercase();
+
+ suggested_name.ends_with(".m3u")
+ || suggested_name.ends_with(".m3u8")
+ || item_name.ends_with(".m3u")
+ || item_name.ends_with(".m3u8")
+ || url.ends_with(".m3u")
+ || url.ends_with(".m3u8")
+ || mime_type.contains("mpegurl")
+ || mime_type.contains("x-mpegurl")
+ || mime_type.contains("vnd.apple.mpegurl")
+}
+
+fn playlist_path(download_item: &mut DownloadItem, suggested_name: Option<&CefString>) -> PathBuf {
+ let mut name = suggested_name
+ .map(ToString::to_string)
+ .filter(|name| !name.trim().is_empty())
+ .unwrap_or_else(|| cef_userfree_to_string(&download_item.suggested_file_name()));
+
+ if name.trim().is_empty() {
+ name = "playlist.m3u".to_string();
+ }
+
+ let safe_name = name
+ .chars()
+ .map(|ch| match ch {
+ '/' | '\\' | ':' | '\0' => '_',
+ _ => ch,
+ })
+ .collect::<String>();
+
+ let dir = playlist_dir();
+ if let Err(error) = create_dir_all(&dir) {
+ tracing::error!("Failed to create playlist dir {}: {error}", dir.display());
+ }
+
+ let id = download_item.id();
+ let path = Path::new(&safe_name);
+ let stem = path
+ .file_stem()
+ .and_then(|value| value.to_str())
+ .unwrap_or("playlist");
+ let extension = path
+ .extension()
+ .and_then(|value| value.to_str())
+ .unwrap_or("m3u");
+
+ dir.join(format!("{stem}-{id}.{extension}"))
+}
+
+cef_impl!(
+ prefix = "WebView",
+ name = DownloadHandler,
+ sys_type = cef_dll_sys::cef_download_handler_t,
+ {
+ fn can_download(
+ &self,
+ _browser: Option<&mut Browser>,
+ _url: Option<&CefString>,
+ _request_method: Option<&CefString>,
+ ) -> ::std::os::raw::c_int {
+ true.into()
+ }
+
+ fn on_before_download(
+ &self,
+ _browser: Option<&mut Browser>,
+ mut download_item: Option<&mut DownloadItem>,
+ suggested_name: Option<&CefString>,
+ callback: Option<&mut BeforeDownloadCallback>,
+ ) -> ::std::os::raw::c_int {
+ if looks_like_playlist(download_item.as_mut().map(|item| &mut **item), suggested_name)
+ && let (Some(download_item), Some(callback)) = (download_item, callback)
+ {
+ let path = playlist_path(download_item, suggested_name);
+ let path_string = path.to_string_lossy().to_string();
+ let cef_path = CefString::from(path_string.as_str());
+
+ callback.cont(Some(&cef_path), false.into());
+ return true.into();
+ }
+
+ false.into()
+ }
+
+ fn on_download_updated(
+ &self,
+ _browser: Option<&mut Browser>,
+ download_item: Option<&mut DownloadItem>,
+ _callback: Option<&mut DownloadItemCallback>,
+ ) {
+ let Some(download_item) = download_item else {
+ return;
+ };
+
+ if download_item.is_complete() == 0
+ || !looks_like_playlist(Some(&mut *download_item), None)
+ {
+ return;
+ }
+
+ let id = download_item.id();
+ let already_launched = launched_downloads()
+ .lock()
+ .map(|mut launched| !launched.insert(id))
+ .unwrap_or(true);
+
+ if already_launched {
+ return;
+ }
+
+ let path = cef_userfree_to_string(&download_item.full_path());
+ if path.trim().is_empty() {
+ return;
+ }
+
+ launch_mpv("DownloadHandler::on_download_updated", &path);
+ }
+ }
+);
diff -ruN a/src/webview/app/client/mod.rs b/src/webview/app/client/mod.rs
--- a/src/webview/app/client/mod.rs
+++ b/src/webview/app/client/mod.rs
@@ -1,3 +1,4 @@
+mod download_handler;
mod display_handler;
mod keyboard_handler;
mod lifespan_handler;
@@ -6,6 +7,7 @@
use std::os::raw::c_int;
+use download_handler::WebViewDownloadHandler;
use display_handler::WebViewDisplayHandler;
use lifespan_handler::WebViewLifeSpanHandler;
use load_handler::WebViewLoadHandler;
@@ -29,6 +31,10 @@
Some(WebViewDisplayHandler::new())
}
+ fn download_handler(&self) -> Option<DownloadHandler> {
+ Some(WebViewDownloadHandler::new())
+ }
+
fn render_handler(&self) -> Option<RenderHandler> {
Some(WebViewRenderHandler::new())
}
The following text is AI generated, but I fact checked it and it contains all information that was useful to me in understanding and fixing this bug.
What I saw
Stremio Settings → "Play in external player" only offers two options for me: Disabled and M3U playlist. With M3U playlist selected, clicking the external-player button on a stream:
playlist (N).m3ukeeps appearing in~/DownloadsSo the green check is misleading — the UI thinks it succeeded, but the user gets nothing playing.
Environment
stremio-linux-shell 1.0.0-beta.13-1(AUR, CEF-based build)application/x-mpegurl/application/vnd.apple.mpegurlI realize
mainhas since been refactored from CEF to GTK4 + WebKit (#31), so this exact code path no longer exists upstream. I'm filing it anyway because:data:M3U via download regardless of which native shell is wrapping it.Where the bug actually lives (CEF tree)
Three handoff paths exist, none of them launch a player:
WebViewEvent::Open(url)(popup) →app.open_url(url)→ xdg-desktop-portalOpenFileRequest::send_uri. For anhttp://127.0.0.1:1147x/...stream URL, the portal hands it to the defaultx-scheme-handler/http, i.e. the browser, not mpv.IpcEvent::OpenExternal(url)→ sameapp.open_url(url)portal path.data:application/x-mpegurl;...blob. With noDownloadHandlerinstalled on the CEF client, CEF's default behavior just saves it to~/Downloads. The "opened in external player" toast fires from web-app code before any handoff actually happens.I confirmed this empirically: instrumented both native handlers, clicked external-player, no log lines fired, but
~/Downloads/playlist (N).m3uappeared.What my local monkey-patch does (CEF tree)
For my own use I patched the shell to:
DownloadHandlerto the client.looks_like_playlistchecks suggested name / item name / URL / MIME type for.m3u,.m3u8,mpegurl,x-mpegurl,vnd.apple.mpegurl.$XDG_RUNTIME_DIR/stremio-external-player/playlist-<id>.m3uand let CEF complete the download there.on_download_updatedwithis_complete(), spawn/usr/bin/mpv -- <path>and reap the child in a detached thread so it doesn't zombie.WebViewEvent::OpenandIpcEvent::OpenExternalto spawn mpv directly instead of going through the portal.It works end-to-end on my system: real mpv window, HDR10 PQ output, E-AC3 passthrough, no Downloads spam.
This is not PR-ready —
mpvpath is hardcoded, no config, no opt-out, no Windows/Mac story, and obviously the whole CEF module path is gone onmain. Treating it strictly as a reference for the bug class.What might actually be worth doing upstream (WebKit tree)
I haven't built or tested the WebKit branch, so this is speculation, but if the symptom reproduces there:
data:M3U download for the "M3U playlist" external-player mode.webkit_web_view'sdownload-startedsignal, similar idea: detect M3U MIME / extension, redirect destination, spawn the configured external player on completion.open_uripath (webview.connect_open_external→window.open_uri) probably also wants a configurable "external player" command instead of the default GTK URI launcher when the URI is a local stream.Repro
stremio-linux-shell(AUR) or any current CEF build.playlist (N).m3uin~/Downloads.Local monkey-patch diff (CEF tree, ~250 lines, reference only)