Compare commits

...

17 commits

Author SHA1 Message Date
Владимир Борисов
b644519919
Merge pull request #60 from Stremio/fix/window-state-titlebar-color
Some checks failed
Continuous integration / test (push) Has been cancelled
Fix: Window state and title bar color
2026-05-13 17:15:35 +03:00
Timothy Z.
d7df369616 create gradient using colorref
Some checks failed
Continuous integration / test (push) Has been cancelled
2026-05-13 15:56:28 +02:00
Timothy Z.
dd12f3a855 Merge remote-tracking branch 'origin/main' into fix/window-state-titlebar-color
# Conflicts:
#	src/stremio_app/window_helper.rs
2026-05-13 15:54:43 +02:00
Timothy Z.
4b310e9afd correct color to match normal baground 2026-05-13 15:47:38 +02:00
Timothy Z.
49dcebab4e update to match secondary bg 2026-05-13 15:46:09 +02:00
Timothy Z.
271382c752 update to use correct color 2026-05-13 15:43:17 +02:00
Владимир Борисов
59fe6b77a0
Merge pull request #42 from Stremio/fix/correctly-close-fullscreen-esc-shortcut
Some checks are pending
Continuous integration / test (push) Waiting to run
Shortcuts: Fix esc shortcut to only close fullscreen
2026-05-12 16:34:20 +03:00
Timothy Z.
a5f0f62d02 fix: esc shortcut close fullscreen
Some checks failed
Continuous integration / test (push) Has been cancelled
2026-05-12 15:29:34 +02:00
Владимир Борисов
5632cd7dc4
Merge pull request #63 from Stremio/claude/fix/server-reader-error-break
Some checks are pending
Continuous integration / test (push) Waiting to run
Fix server reader IO error loop
2026-05-12 14:34:29 +03:00
Владимир Борисов
a6026fb568
Merge pull request #61 from Stremio/claude/fix/pipe-forward-fallthrough
Fix peer-death command drop on single-instance forwarding
2026-05-12 14:29:24 +03:00
Владимир Борисов
b8b045b286
Merge pull request #43 from Aztup/feat/mpv-vf-filter
Player: allow setting mpv vf property for RTX Auto HDR / VSR support
2026-05-12 14:25:06 +03:00
Владимир Борисов
b7c0fd4f20
Merge pull request #75 from Stremio/claude/feat/play-external
Player: launch external player via play-external IPC
2026-05-12 13:35:26 +03:00
Timothy Z.
4c92d6658b fix: break server reader threads on read error
Some checks failed
Continuous integration / test (push) Has been cancelled
2026-05-12 10:30:36 +02:00
Timothy Z.
60ccf06a29 fix: fall through to self-launch when peer forward fails
Some checks failed
Continuous integration / test (push) Has been cancelled
2026-05-12 10:14:16 +02:00
Timothy Z.
b06ce07077 fix: window state and title bar color
Some checks failed
Continuous integration / test (push) Has been cancelled
2026-05-11 17:52:30 +02:00
Timothy Z.
368a0062ed Player: launch external player via play-external IPC
Some checks failed
Continuous integration / test (push) Has been cancelled
2026-05-11 01:35:53 +03:00
Aztup
0d519db7b4 Add Vf to communication.rs 2026-05-02 15:45:22 +02:00
8 changed files with 362 additions and 30 deletions

View file

@ -13,6 +13,7 @@ native-windows-gui = { git = "https://github.com/Stremio/native-windows-gui", fe
] } ] }
native-windows-derive = "1" native-windows-derive = "1"
winapi = { version = "0.3.9", features = [ winapi = { version = "0.3.9", features = [
"dwmapi",
"libloaderapi", "libloaderapi",
"handleapi", "handleapi",
"jobapi2", "jobapi2",

View file

@ -88,8 +88,15 @@ fn main() {
commands_path.push_str(&username()); commands_path.push_str(&username());
let socket_path = Path::new(&commands_path); let socket_path = Path::new(&commands_path);
if let Ok(mut stream) = PipeClient::connect(socket_path) { if let Ok(mut stream) = PipeClient::connect(socket_path) {
stream.write_all(command.as_bytes()).ok(); let forwarded = stream
exit(0); .write_all(command.as_bytes())
.and_then(|_| stream.flush())
.is_ok();
drop(stream);
if forwarded {
exit(0);
}
eprintln!("Failed to forward command to existing Stremio instance; launching new instance");
} }
// END IPC // END IPC

View file

@ -24,6 +24,7 @@ use crate::stremio_app::{
systray::SystemTray, systray::SystemTray,
updater, updater,
window_helper::WindowStyle, window_helper::WindowStyle,
window_settings::WindowSettings,
PipeServer, PipeServer,
}; };
@ -41,6 +42,7 @@ pub struct MainWindow {
pub force_update: bool, pub force_update: bool,
pub release_candidate: bool, pub release_candidate: bool,
pub autoupdater_setup_file: Arc<Mutex<Option<PathBuf>>>, pub autoupdater_setup_file: Arc<Mutex<Option<PathBuf>>>,
pub requested_fullscreen: Arc<Mutex<Option<bool>>>,
pub saved_window_style: RefCell<WindowStyle>, pub saved_window_style: RefCell<WindowStyle>,
#[nwg_resource] #[nwg_resource]
pub embed: nwg::EmbedResource, pub embed: nwg::EmbedResource,
@ -53,14 +55,15 @@ pub struct MainWindow {
OnPaint: [Self::on_paint], OnPaint: [Self::on_paint],
OnMinMaxInfo: [Self::on_min_max(SELF, EVT_DATA)], OnMinMaxInfo: [Self::on_min_max(SELF, EVT_DATA)],
OnWindowMinimize: [Self::transmit_window_state_change], OnWindowMinimize: [Self::transmit_window_state_change],
OnWindowMaximize: [Self::transmit_window_state_change], OnWindowMaximize: [Self::on_window_state_changed],
OnWindowFocus: [Self::transmit_window_state_change], OnWindowFocus: [Self::transmit_window_state_change],
OnResizeEnd: [Self::save_window_settings],
)] )]
pub window: nwg::Window, pub window: nwg::Window,
#[nwg_partial(parent: window)] #[nwg_partial(parent: window)]
#[nwg_events( #[nwg_events(
(tray, MousePressLeftUp): [Self::on_show], (tray, MousePressLeftUp): [Self::on_show],
(tray_exit, OnMenuItemSelected): [nwg::stop_thread_dispatch()], (tray_exit, OnMenuItemSelected): [Self::on_exit],
(tray_show_hide, OnMenuItemSelected): [Self::on_show_hide], (tray_show_hide, OnMenuItemSelected): [Self::on_show_hide],
(tray_topmost, OnMenuItemSelected): [Self::on_toggle_topmost], (tray_topmost, OnMenuItemSelected): [Self::on_toggle_topmost],
)] )]
@ -130,7 +133,13 @@ impl MainWindow {
self.webview.dev_tools.set(self.dev_tools).ok(); self.webview.dev_tools.set(self.dev_tools).ok();
if let Some(hwnd) = self.window.handle.hwnd() { if let Some(hwnd) = self.window.handle.hwnd() {
if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() { if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() {
saved_style.center_window(hwnd, WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT); saved_style.set_title_bar_color(hwnd);
if let Some(window_settings) = WindowSettings::load() {
saved_style
.restore_window_placement(hwnd, window_settings.to_window_placement());
} else {
saved_style.center_window(hwnd, WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT);
}
} }
} }
@ -247,6 +256,7 @@ impl MainWindow {
let hide_splash_sender = self.hide_splash_notice.sender(); let hide_splash_sender = self.hide_splash_notice.sender();
let focus_sender = self.focus_notice.sender(); let focus_sender = self.focus_notice.sender();
let autoupdater_setup_mutex = self.autoupdater_setup_file.clone(); let autoupdater_setup_mutex = self.autoupdater_setup_file.clone();
let requested_fullscreen = self.requested_fullscreen.clone();
thread::spawn(move || loop { thread::spawn(move || loop {
if let Some(msg) = web_rx if let Some(msg) = web_rx
.recv() .recv()
@ -258,7 +268,16 @@ impl MainWindow {
None if msg.is_handshake() => { None if msg.is_handshake() => {
web_tx_web.send(RPCResponse::get_handshake()).ok(); web_tx_web.send(RPCResponse::get_handshake()).ok();
} }
Some("win-set-visibility") => toggle_fullscreen_sender.notice(), Some("win-set-visibility") => {
if let Some(fullscreen) = msg
.get_params()
.and_then(|params| params.get("fullscreen"))
.and_then(|value| value.as_bool())
{
*requested_fullscreen.lock().unwrap() = Some(fullscreen);
toggle_fullscreen_sender.notice();
}
}
Some("quit") => quit_sender.notice(), Some("quit") => quit_sender.notice(),
Some("app-ready") => { Some("app-ready") => {
hide_splash_sender.notice(); hide_splash_sender.notice();
@ -298,6 +317,52 @@ impl MainWindow {
} }
} }
} }
Some("play-external") => {
if let Some(arg) = msg.get_params() {
let arg = arg.as_str().unwrap_or("");
let arg_lc = arg.to_lowercase();
const ALLOWED_SCHEMES: &[&str] = &["mpv://", "vlc://", "potplayer://"];
let allowed = ALLOWED_SCHEMES.iter().any(|s| arg_lc.starts_with(s));
if !arg.is_empty() && allowed {
if let Some(stream_url) =
arg_lc.starts_with("mpv://").then(|| &arg[6..])
{
// `--` ends mpv's option parsing; the stream URL can't smuggle flags.
let mpv_paths: Vec<String> = vec![
std::env::var("ProgramFiles")
.ok()
.map(|v| format!("{v}\\mpv\\mpv.exe")),
std::env::var("ProgramFiles(x86)")
.ok()
.map(|v| format!("{v}\\mpv\\mpv.exe")),
std::env::var("LOCALAPPDATA")
.ok()
.map(|v| format!("{v}\\Programs\\mpv\\mpv.exe")),
std::env::var("LOCALAPPDATA")
.ok()
.map(|v| format!("{v}\\mpv\\mpv.exe")),
Some("mpv.exe".to_string()),
]
.into_iter()
.flatten()
.collect();
for path in &mpv_paths {
if Command::new(path)
.arg("--")
.arg(stream_url)
.creation_flags(CREATE_BREAKAWAY_FROM_JOB)
.spawn()
.is_ok()
{
break;
}
}
} else {
open::that(arg).ok();
}
}
}
}
Some("win-focus") => { Some("win-focus") => {
focus_sender.notice(); focus_sender.notice();
} }
@ -360,10 +425,35 @@ impl MainWindow {
self.webview.fit_to_window(self.window.handle.hwnd()); self.webview.fit_to_window(self.window.handle.hwnd());
} }
} }
fn on_window_state_changed(&self) {
self.save_window_settings();
self.transmit_window_state_change();
}
fn save_window_settings(&self) {
if self
.saved_window_style
.try_borrow()
.map(|style| style.full_screen)
.unwrap_or(false)
{
return;
}
if let Some(hwnd) = self.window.handle.hwnd() {
if let Err(err) = WindowSettings::save(hwnd) {
eprintln!("Cannot save window settings: {err}");
}
}
}
fn on_toggle_fullscreen_notice(&self) { fn on_toggle_fullscreen_notice(&self) {
if let Some(hwnd) = self.window.handle.hwnd() { if let Some(hwnd) = self.window.handle.hwnd() {
if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() { if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() {
saved_style.toggle_full_screen(hwnd); let target = self
.requested_fullscreen
.lock()
.unwrap()
.take()
.unwrap_or(!saved_style.full_screen);
saved_style.set_full_screen(hwnd, target);
self.tray.tray_topmost.set_enabled(!saved_style.full_screen); self.tray.tray_topmost.set_enabled(!saved_style.full_screen);
self.tray self.tray
.tray_topmost .tray_topmost
@ -422,8 +512,13 @@ impl MainWindow {
if let nwg::EventData::OnWindowClose(data) = data { if let nwg::EventData::OnWindowClose(data) = data {
data.close(false); data.close(false);
} }
self.save_window_settings();
self.window.set_visible(false); self.window.set_visible(false);
self.tray.tray_show_hide.set_checked(self.window.visible()); self.tray.tray_show_hide.set_checked(self.window.visible());
self.transmit_window_visibility_change(); self.transmit_window_visibility_change();
} }
fn on_exit(&self) {
self.save_window_settings();
nwg::stop_thread_dispatch();
}
} }

View file

@ -9,6 +9,7 @@ pub mod named_pipe;
pub mod splash; pub mod splash;
pub mod systray; pub mod systray;
pub mod window_helper; pub mod window_helper;
pub mod window_settings;
pub use named_pipe::{PipeClient, PipeServer}; pub use named_pipe::{PipeClient, PipeServer};
pub mod constants; pub mod constants;
pub mod updater; pub mod updater;

View file

@ -203,6 +203,7 @@ pub enum StrProp {
SubBorderColor, SubBorderColor,
SubColor, SubColor,
TrackList, TrackList,
Vf,
VideoParams, VideoParams,
Vo, Vo,
} }

View file

@ -85,10 +85,14 @@ impl StremioServer {
let http_endpoint = String::new(); let http_endpoint = String::new();
loop { loop {
let mut buffer = [0; SRV_BUFFER_SIZE]; let mut buffer = [0; SRV_BUFFER_SIZE];
let on = stdout.read(&mut buffer[..]).unwrap_or(!0); let on = match stdout.read(&mut buffer[..]) {
if on > buffer.len() { Ok(0) => break,
continue; Ok(n) => n,
} Err(err) => {
eprintln!("server stdout read error: {err}");
break;
}
};
std::io::stdout().write_all(&buffer).ok(); std::io::stdout().write_all(&buffer).ok();
let string_data = String::from_utf8_lossy(&buffer[..on]); let string_data = String::from_utf8_lossy(&buffer[..on]);
{ {
@ -116,10 +120,6 @@ impl StremioServer {
.collect::<Vec<&str>>() .collect::<Vec<&str>>()
.join("\n"); .join("\n");
}; };
if on == 0 {
// Server terminated
break;
}
} }
}); });
@ -128,10 +128,14 @@ impl StremioServer {
let err_thread = thread::spawn(move || { let err_thread = thread::spawn(move || {
let mut buffer = [0; SRV_BUFFER_SIZE]; let mut buffer = [0; SRV_BUFFER_SIZE];
loop { loop {
let en = stderr.read(&mut buffer[..]).unwrap_or(!0); let en = match stderr.read(&mut buffer[..]) {
if en > buffer.len() { Ok(0) => break,
continue; Ok(n) => n,
} Err(err) => {
eprintln!("server stderr read error: {err}");
break;
}
};
std::io::stderr().write_all(&buffer).ok(); std::io::stderr().write_all(&buffer).ok();
let string_data = String::from_utf8_lossy(&buffer[..en]); let string_data = String::from_utf8_lossy(&buffer[..en]);
// eprint!("{:?}", &buffer); // eprint!("{:?}", &buffer);
@ -148,10 +152,6 @@ impl StremioServer {
.collect::<Vec<&str>>() .collect::<Vec<&str>>()
.join("\n"); .join("\n");
}; };
if en == 0 {
// Server terminated
break;
}
} }
}); });
out_thread.join().ok(); out_thread.join().ok();

View file

@ -1,13 +1,26 @@
use std::{cmp, mem}; use std::{cmp, mem};
use winapi::ctypes::c_void;
use winapi::shared::minwindef::DWORD;
use winapi::shared::windef::HWND; use winapi::shared::windef::HWND;
use winapi::um::dwmapi::DwmSetWindowAttribute;
use winapi::um::winuser::{ use winapi::um::winuser::{
GetForegroundWindow, GetMonitorInfoA, GetSystemMetrics, GetWindowLongA, GetWindowRect, GetForegroundWindow, GetMonitorInfoA, GetSystemMetrics, GetWindowLongA, GetWindowRect,
IsIconic, IsZoomed, MonitorFromWindow, SetForegroundWindow, SetWindowLongA, SetWindowPos, IsIconic, IsZoomed, MonitorFromWindow, SetForegroundWindow, SetWindowLongA, SetWindowPlacement,
GWL_EXSTYLE, GWL_STYLE, HWND_NOTOPMOST, HWND_TOPMOST, MONITORINFO, MONITOR_DEFAULTTONEAREST, SetWindowPos, GWL_EXSTYLE, GWL_STYLE, HWND_NOTOPMOST, HWND_TOPMOST, MONITORINFO,
SM_CXSCREEN, SM_CYSCREEN, SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOSIZE, WS_CAPTION, MONITOR_DEFAULTTONEAREST, SM_CXSCREEN, SM_CYSCREEN, SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOSIZE,
WS_EX_CLIENTEDGE, WS_EX_DLGMODALFRAME, WS_EX_STATICEDGE, WS_EX_TOPMOST, WS_EX_WINDOWEDGE, WINDOWPLACEMENT, WS_CAPTION, WS_EX_CLIENTEDGE, WS_EX_DLGMODALFRAME, WS_EX_STATICEDGE,
WS_THICKFRAME, WS_EX_TOPMOST, WS_EX_WINDOWEDGE, WS_THICKFRAME,
}; };
const DWMWA_CAPTION_COLOR: DWORD = 35;
const DWMWA_TEXT_COLOR: DWORD = 36;
const STREMIO_CAPTION_COLOR: DWORD = colorref(0x15, 0x12, 0x2b);
const WHITE_TEXT_COLOR: DWORD = colorref(0xff, 0xff, 0xff);
const fn colorref(red: DWORD, green: DWORD, blue: DWORD) -> DWORD {
red | (green << 8) | (blue << 16)
}
// https://doc.qt.io/qt-5/qt.html#WindowState-enum // https://doc.qt.io/qt-5/qt.html#WindowState-enum
bitflags! { bitflags! {
struct WindowState: u8 { struct WindowState: u8 {
@ -71,8 +84,41 @@ impl WindowStyle {
self.pos = ((monitor_w - self.size.0) / 2, (monitor_h - self.size.1) / 2); self.pos = ((monitor_w - self.size.0) / 2, (monitor_h - self.size.1) / 2);
self.show_window_at(hwnd, HWND_NOTOPMOST); self.show_window_at(hwnd, HWND_NOTOPMOST);
} }
pub fn toggle_full_screen(&mut self, hwnd: HWND) { pub fn restore_window_placement(&mut self, hwnd: HWND, placement: WINDOWPLACEMENT) {
if self.full_screen { self.pos = (
placement.rcNormalPosition.left,
placement.rcNormalPosition.top,
);
self.size = (
placement.rcNormalPosition.right - placement.rcNormalPosition.left,
placement.rcNormalPosition.bottom - placement.rcNormalPosition.top,
);
unsafe {
SetWindowPlacement(hwnd, &placement);
}
}
pub fn set_title_bar_color(&self, hwnd: HWND) {
unsafe {
DwmSetWindowAttribute(
hwnd,
DWMWA_CAPTION_COLOR,
&STREMIO_CAPTION_COLOR as *const _ as *const c_void,
mem::size_of_val(&STREMIO_CAPTION_COLOR) as DWORD,
);
DwmSetWindowAttribute(
hwnd,
DWMWA_TEXT_COLOR,
&WHITE_TEXT_COLOR as *const _ as *const c_void,
mem::size_of_val(&WHITE_TEXT_COLOR) as DWORD,
);
}
}
pub fn set_full_screen(&mut self, hwnd: HWND, full_screen: bool) {
if self.full_screen == full_screen {
return;
}
if !full_screen {
let topmost = if self.ex_style as u32 & WS_EX_TOPMOST == WS_EX_TOPMOST { let topmost = if self.ex_style as u32 & WS_EX_TOPMOST == WS_EX_TOPMOST {
HWND_TOPMOST HWND_TOPMOST
} else { } else {

View file

@ -0,0 +1,181 @@
use serde::{Deserialize, Serialize};
use std::{env, fs, io, path::PathBuf};
use winapi::shared::windef::{HWND, POINT, RECT};
use winapi::um::winuser::{
GetWindowPlacement, IsIconic, SW_SHOWMAXIMIZED, SW_SHOWNORMAL, WINDOWPLACEMENT,
};
const WINDOW_SETTINGS_FILE: &str = "window-state.json";
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct WindowSettings {
show_cmd: u32,
min_position: Point,
max_position: Point,
normal_position: Rect,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct Point {
x: i32,
y: i32,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct Rect {
left: i32,
top: i32,
right: i32,
bottom: i32,
}
impl WindowSettings {
pub fn load() -> Option<Self> {
fs::read_to_string(settings_path())
.ok()
.and_then(|settings| serde_json::from_str(&settings).ok())
}
pub fn save(hwnd: HWND) -> io::Result<()> {
let Some(settings) = Self::from_window(hwnd) else {
return Ok(());
};
let path = settings_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&settings).map_err(io::Error::other)?;
fs::write(path, json)
}
pub fn to_window_placement(&self) -> WINDOWPLACEMENT {
let mut placement = WINDOWPLACEMENT {
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
flags: 0,
showCmd: self.show_cmd,
ptMinPosition: self.min_position.clone().into(),
ptMaxPosition: self.max_position.clone().into(),
rcNormalPosition: self.normal_position.clone().into(),
};
if !is_restorable_size(&placement.rcNormalPosition) {
placement.showCmd = SW_SHOWNORMAL as u32;
}
placement
}
fn from_window(hwnd: HWND) -> Option<Self> {
if unsafe { IsIconic(hwnd) } != 0 {
return None;
}
let mut placement = WINDOWPLACEMENT {
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
flags: 0,
showCmd: 0,
ptMinPosition: POINT { x: 0, y: 0 },
ptMaxPosition: POINT { x: 0, y: 0 },
rcNormalPosition: RECT {
left: 0,
top: 0,
right: 0,
bottom: 0,
},
};
if unsafe { GetWindowPlacement(hwnd, &mut placement) } == 0 {
return None;
}
if !is_restorable_size(&placement.rcNormalPosition) {
return None;
}
Some(WindowSettings {
show_cmd: if placement.showCmd == SW_SHOWMAXIMIZED as u32 {
SW_SHOWMAXIMIZED as u32
} else {
SW_SHOWNORMAL as u32
},
min_position: placement.ptMinPosition.into(),
max_position: placement.ptMaxPosition.into(),
normal_position: placement.rcNormalPosition.into(),
})
}
}
fn settings_path() -> PathBuf {
env::var_os("APPDATA")
.map(PathBuf::from)
.unwrap_or_else(env::temp_dir)
.join("Stremio")
.join(WINDOW_SETTINGS_FILE)
}
fn is_restorable_size(rect: &RECT) -> bool {
rect.right > rect.left && rect.bottom > rect.top
}
impl From<POINT> for Point {
fn from(point: POINT) -> Self {
Point {
x: point.x,
y: point.y,
}
}
}
impl From<Point> for POINT {
fn from(point: Point) -> Self {
POINT {
x: point.x,
y: point.y,
}
}
}
impl From<RECT> for Rect {
fn from(rect: RECT) -> Self {
Rect {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
}
}
}
impl From<Rect> for RECT {
fn from(rect: Rect) -> Self {
RECT {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
}
}
}
#[cfg(test)]
mod tests {
use super::is_restorable_size;
use winapi::shared::windef::RECT;
#[test]
fn rejects_empty_window_rect() {
assert!(!is_restorable_size(&RECT {
left: 10,
top: 10,
right: 10,
bottom: 20,
}));
}
#[test]
fn accepts_non_empty_window_rect() {
assert!(is_restorable_size(&RECT {
left: 10,
top: 10,
right: 20,
bottom: 20,
}));
}
}