Add BorderBreaker aspect ratio controls

This commit is contained in:
Kelvin Acheampong 2025-11-22 22:00:44 +00:00
parent 81fa5c902d
commit 2e66df3ad7
9 changed files with 919 additions and 504 deletions

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
engine.log
engine.err
/target
/stremio*.exe
/libmpv-2.dll

View file

@ -1,16 +1,16 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "Stremio"
#define MyAppName "Stremio BorderBreaker"
#define MyAppExeName "stremio-shell-ng.exe"
#define MyAppExeLocation SourcePath + "..\target\x86_64-pc-windows-msvc\release\" + MyAppExeName
#define MyAppVersion() GetVersionComponents(MyAppExeLocation, Local[0], Local[1], Local[2], Local[3]), \
Str(Local[0]) + "." + Str(Local[1]) + "." + Str(Local[2])
#define MyAppPublisher "Smart Code OOD"
#define MyAppPublisher "BorderBreaker"
#define MyAppCopyright "Copyright © " + GetDateTimeString('yyyy', '', '') + " " + MyAppPublisher
#define MyAppURL "https://www.stremio.com/"
#define MyAppGoodbyeURL "https://www.strem.io/goodbye"
#define MyAppURL "https://stremio-borderbreaker.local/"
#define MyAppGoodbyeURL MyAppURL
#define AssocTorrentExt ".torrent"
#define AssocTorrentKey StringChange(MyAppName, " ", "") + AssocTorrentExt
#define AssocTorrentDesc "Bittorrent seed file"
@ -21,7 +21,7 @@
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{DD3870DA-AF3C-4C73-B010-72944AB610C6}
AppId={{5C3D0D2C-5B0D-4567-90A6-5D21C4EF8B52}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
@ -114,9 +114,10 @@ Name: "finnish"; MessagesFile: "compiler:Languages\Finnish.isl"
Name: "french"; MessagesFile: "compiler:Languages\French.isl"
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
Name: "hebrew"; MessagesFile: "compiler:Languages\Hebrew.isl"
Name: "icelandic"; MessagesFile: "compiler:Languages\Icelandic.isl"
Name: "hungarian"; MessagesFile: "compiler:Languages\Hungarian.isl"
Name: "italian"; MessagesFile: "compiler:Languages\Italian.isl"
Name: "japanese"; MessagesFile: "compiler:Languages\Japanese.isl"
Name: "korean"; MessagesFile: "compiler:Languages\Korean.isl"
Name: "norwegian"; MessagesFile: "compiler:Languages\Norwegian.isl"
Name: "polish"; MessagesFile: "compiler:Languages\Polish.isl"
Name: "portuguese"; MessagesFile: "compiler:Languages\Portuguese.isl"
@ -124,6 +125,8 @@ Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
Name: "slovak"; MessagesFile: "compiler:Languages\Slovak.isl"
Name: "slovenian"; MessagesFile: "compiler:Languages\Slovenian.isl"
Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl"
Name: "swedish"; MessagesFile: "compiler:Languages\Swedish.isl"
Name: "tamil"; MessagesFile: "compiler:Languages\Tamil.isl"
Name: "turkish"; MessagesFile: "compiler:Languages\Turkish.isl"
Name: "ukrainian"; MessagesFile: "compiler:Languages\Ukrainian.isl"
@ -142,7 +145,6 @@ finnish.RemoveDataFolder=Poistetaanko kaikki tiedot ja asetukset?
french.RemoveDataFolder=Supprimer toutes les données et la configuration ?
german.RemoveDataFolder=Alle Daten und Konfiguration entfernen?
hebrew.RemoveDataFolder=Remove all data and configuration?
icelandic.RemoveDataFolder=Fjarlægja öll gögn og stillingar?
italian.RemoveDataFolder=Rimuovere tutti i dati e la configurazione?
japanese.RemoveDataFolder=すべてのデータと構成を削除しますか?
norwegian.RemoveDataFolder=Vil du fjerne all data og konfigurasjon?

View file

@ -1,7 +1,7 @@
use native_windows_derive::NwgUi;
use native_windows_gui as nwg;
use rand::Rng;
use serde_json;
use serde_json::{self, json};
use std::{
cell::RefCell,
io::Read,
@ -16,6 +16,7 @@ use url::Url;
use winapi::um::{winbase::CREATE_BREAKAWAY_FROM_JOB, winuser::WS_EX_TOPMOST};
use crate::stremio_app::{
aspect_ratio::AspectController,
constants::{APP_NAME, UPDATE_ENDPOINT, UPDATE_INTERVAL, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH},
ipc::{RPCRequest, RPCResponse},
splash::SplashImage,
@ -84,6 +85,11 @@ pub struct MainWindow {
#[nwg_control]
#[nwg_events(OnNotice: [Self::on_focus_notice] )]
pub focus_notice: nwg::Notice,
#[nwg_control]
#[nwg_events(OnNotice: [Self::on_aspect_toggle_notice] )]
pub aspect_toggle_notice: nwg::Notice,
pub aspect_controller: RefCell<AspectController>,
pub aspect_player_tx: RefCell<Option<flume::Sender<String>>>,
}
impl MainWindow {
@ -135,13 +141,16 @@ impl MainWindow {
self.window.set_visible(!self.start_hidden);
self.tray.tray_show_hide.set_checked(!self.start_hidden);
let player_channel = self.player.channel.borrow();
let (player_tx, player_rx) = player_channel
.as_ref()
.expect("Cannont obtain communication channel for the Player");
let player_tx = player_tx.clone();
let player_rx = player_rx.clone();
{
*self.aspect_player_tx.borrow_mut() = Some(player_tx.clone());
self.aspect_controller.borrow().apply_current(&player_tx);
}
let web_channel = self.webview.channel.borrow();
let (web_tx, web_rx) = web_channel
@ -242,6 +251,7 @@ impl MainWindow {
let hide_splash_sender = self.hide_splash_notice.sender();
let focus_sender = self.focus_notice.sender();
let autoupdater_setup_mutex = self.autoupdater_setup_file.clone();
let aspect_toggle_sender_thread = self.aspect_toggle_notice.sender();
thread::spawn(move || loop {
if let Some(msg) = web_rx
.recv()
@ -296,6 +306,9 @@ impl MainWindow {
Some("win-focus") => {
focus_sender.notice();
}
Some("borderbreaker-cycle") => {
aspect_toggle_sender_thread.notice();
}
Some("autoupdater-notif-clicked") => {
// We've shown the "Update Available" notification
// and the user clicked on "Restart And Update"
@ -421,4 +434,32 @@ impl MainWindow {
self.tray.tray_show_hide.set_checked(self.window.visible());
self.transmit_window_visibility_change();
}
fn on_aspect_toggle_notice(&self) {
let player_tx_opt = self.aspect_player_tx.borrow().clone();
if let Some(player_tx) = player_tx_opt {
let mut controller = self.aspect_controller.borrow_mut();
controller.cycle();
controller.apply_current(&player_tx);
let label = format!(
"Aspect: {}",
controller
.current_mode()
.overlay_label(controller.display_ratio())
);
drop(controller);
self.broadcast_aspect_overlay(&label);
}
}
fn broadcast_aspect_overlay(&self, text: &str) {
if let Ok(web_channel) = self.webview.channel.try_borrow() {
if let Some((web_tx, _)) = web_channel.as_ref() {
let payload = json!({
"__borderbreakerOverlay": text,
})
.to_string();
let _ = web_tx.send(payload);
}
}
}
}

View file

@ -0,0 +1,289 @@
use std::{
env,
fs::{self, File},
io::{Read, Write},
path::{Path, PathBuf},
};
use flume::Sender;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use crate::stremio_app::{
stremio_player::{
BoolProp, FpProp, InMsg, InMsgArgs, InMsgFn, PropKey, PropVal, StrProp,
},
window_helper,
};
static CONFIG_DIR: Lazy<PathBuf> = Lazy::new(|| {
env::var("APPDATA")
.map(PathBuf::from)
.unwrap_or_else(|_| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
.join("StremioBorderBreaker")
});
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum AspectMode {
AutoDetect,
FillCrop,
FitToScreen,
Ratio16x9,
Ratio4x3,
Ratio1x1,
Ratio21x9,
Ratio32x9,
Cinema,
}
impl AspectMode {
pub fn display_name(self) -> &'static str {
match self {
AspectMode::AutoDetect => "Auto",
AspectMode::FillCrop => "Fill (Crop)",
AspectMode::FitToScreen => "Fit to Screen",
AspectMode::Ratio16x9 => "16:9",
AspectMode::Ratio4x3 => "4:3",
AspectMode::Ratio1x1 => "1:1",
AspectMode::Ratio21x9 => "21:9 Ultrawide",
AspectMode::Ratio32x9 => "32:9 Super Ultrawide",
AspectMode::Cinema => "Cinema",
}
}
pub fn overlay_label(self, display_ratio: f32) -> String {
match self {
AspectMode::AutoDetect => {
format!("Auto ({:.2}:1)", display_ratio.max(0.01))
}
mode => mode.display_name().to_string(),
}
}
}
struct AspectSpec {
aspect_override: Option<f64>,
keep_aspect: bool,
panscan: f64,
video_unscaled: Option<&'static str>,
}
impl AspectMode {
fn spec(self, display_ratio: f32) -> AspectSpec {
match self {
AspectMode::AutoDetect => AspectSpec {
aspect_override: Some(display_ratio.max(0.1) as f64),
keep_aspect: true,
panscan: 0.0,
video_unscaled: Some("no"),
},
AspectMode::FillCrop => AspectSpec {
aspect_override: None,
keep_aspect: true,
panscan: 1.0,
video_unscaled: Some("no"),
},
AspectMode::FitToScreen => AspectSpec {
aspect_override: None,
keep_aspect: true,
panscan: 0.0,
video_unscaled: Some("no"),
},
AspectMode::Ratio16x9 => AspectSpec::ratio(16.0 / 9.0),
AspectMode::Ratio4x3 => AspectSpec::ratio(4.0 / 3.0),
AspectMode::Ratio1x1 => AspectSpec::ratio(1.0),
AspectMode::Ratio21x9 => AspectSpec::ratio(21.0 / 9.0),
AspectMode::Ratio32x9 => AspectSpec::ratio(32.0 / 9.0),
AspectMode::Cinema => AspectSpec::ratio(2.39),
}
}
}
impl AspectSpec {
fn ratio(value: f64) -> Self {
AspectSpec {
aspect_override: Some(value),
keep_aspect: true,
panscan: 0.0,
video_unscaled: Some("no"),
}
}
}
#[derive(Serialize, Deserialize)]
struct AspectConfig {
mode: AspectMode,
}
pub struct AspectController {
config_path: PathBuf,
order: Vec<AspectMode>,
current_index: usize,
display_ratio: f32,
}
impl AspectController {
pub fn new() -> Self {
Self::with_paths(CONFIG_DIR.join("aspect.json"), window_helper::primary_monitor_ratio())
}
fn with_paths(config_path: PathBuf, display_ratio: f32) -> Self {
let order = vec![
AspectMode::AutoDetect,
AspectMode::FillCrop,
AspectMode::FitToScreen,
AspectMode::Ratio16x9,
AspectMode::Ratio4x3,
AspectMode::Ratio1x1,
AspectMode::Ratio21x9,
AspectMode::Ratio32x9,
AspectMode::Cinema,
];
let saved_mode = Self::read_config(&config_path).map(|c| c.mode);
let current_index = saved_mode
.and_then(|mode| order.iter().position(|m| m == &mode))
.unwrap_or(0);
AspectController {
config_path,
order,
current_index,
display_ratio,
}
}
pub fn current_mode(&self) -> AspectMode {
self.order[self.current_index]
}
pub fn cycle(&mut self) -> AspectMode {
self.current_index = (self.current_index + 1) % self.order.len();
self.persist();
self.current_mode()
}
pub fn apply_current(&self, player_tx: &Sender<String>) {
self.apply_mode(player_tx, self.current_mode());
}
pub fn apply_mode(&self, player_tx: &Sender<String>, mode: AspectMode) {
let spec = mode.spec(self.display_ratio);
if let Some(ratio) = spec.aspect_override {
send_fp_prop(player_tx, FpProp::VideoAspectOverride, ratio);
} else {
send_fp_prop(player_tx, FpProp::VideoAspectOverride, 0.0);
}
send_bool_prop(player_tx, BoolProp::Keepaspect, spec.keep_aspect);
send_fp_prop(player_tx, FpProp::Panscan, spec.panscan);
if let Some(value) = spec.video_unscaled {
send_str_prop(player_tx, StrProp::VideoUnscaled, value);
}
}
pub fn display_ratio(&self) -> f32 {
self.display_ratio
}
fn persist(&self) {
if let Some(parent) = self.config_path.parent() {
let _ = fs::create_dir_all(parent);
}
let config = AspectConfig {
mode: self.current_mode(),
};
if let Ok(data) = serde_json::to_vec(&config) {
if let Ok(mut file) = File::create(&self.config_path) {
let _ = file.write_all(&data);
}
}
}
fn read_config(path: &Path) -> Option<AspectConfig> {
let mut file = File::open(path).ok()?;
let mut buf = Vec::new();
file.read_to_end(&mut buf).ok()?;
serde_json::from_slice(&buf).ok()
}
}
impl Default for AspectController {
fn default() -> Self {
Self::new()
}
}
fn send_fp_prop(player_tx: &Sender<String>, prop: FpProp, value: f64) {
let msg = InMsg(
InMsgFn::MpvSetProp,
InMsgArgs::StProp(PropKey::Fp(prop), PropVal::Num(value)),
);
if let Ok(serialized) = serde_json::to_string(&msg) {
let _ = player_tx.send(serialized);
}
}
fn send_bool_prop(player_tx: &Sender<String>, prop: BoolProp, value: bool) {
let msg = InMsg(
InMsgFn::MpvSetProp,
InMsgArgs::StProp(PropKey::Bool(prop), PropVal::Bool(value)),
);
if let Ok(serialized) = serde_json::to_string(&msg) {
let _ = player_tx.send(serialized);
}
}
fn send_str_prop(player_tx: &Sender<String>, prop: StrProp, value: &str) {
let msg = InMsg(
InMsgFn::MpvSetProp,
InMsgArgs::StProp(PropKey::Str(prop), PropVal::Str(value.to_string())),
);
if let Ok(serialized) = serde_json::to_string(&msg) {
let _ = player_tx.send(serialized);
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::{distributions::Alphanumeric, Rng};
use std::fs;
fn temp_config_path() -> PathBuf {
let mut name: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
name.push_str(".json");
env::temp_dir().join(name)
}
#[test]
fn cycles_modes_and_persists() {
let path = temp_config_path();
let mut controller = AspectController::with_paths(path.clone(), 2.33);
assert_eq!(controller.current_mode(), AspectMode::AutoDetect);
controller.cycle();
assert_eq!(controller.current_mode(), AspectMode::FillCrop);
controller.cycle();
assert_eq!(controller.current_mode(), AspectMode::FitToScreen);
// ensure persisted
let loaded = AspectController::with_paths(path.clone(), 2.33);
assert_eq!(loaded.current_mode(), AspectMode::FitToScreen);
let _ = fs::remove_file(path);
}
#[test]
fn overlay_labels_match_modes() {
let ratio = 21.0 / 9.0;
assert_eq!(
AspectMode::AutoDetect.overlay_label(ratio),
format!("Auto ({:.2}:1)", ratio)
);
assert_eq!(
AspectMode::Ratio21x9.overlay_label(ratio),
"21:9 Ultrawide"
);
assert_eq!(AspectMode::Cinema.overlay_label(ratio), "Cinema");
}
}

View file

@ -1,5 +1,6 @@
pub mod app;
pub use app::MainWindow;
pub mod aspect_ratio;
pub mod ipc;
pub mod stremio_player;
pub mod stremio_server;

View file

@ -138,6 +138,7 @@ pub enum BoolProp {
PausedForCache,
Seeking,
EofReached,
Keepaspect,
}
stringable!(BoolProp);
// Int
@ -164,6 +165,9 @@ pub enum FpProp {
CacheBufferingState,
SubPos,
Speed,
VideoAspectOverride,
VideoZoom,
Panscan,
}
stringable!(FpProp);
// Str
@ -186,6 +190,7 @@ pub enum StrProp {
TrackList,
VideoParams,
Vo,
VideoUnscaled,
}
stringable!(StrProp);

View file

@ -2,8 +2,8 @@ pub mod player;
pub use player::Player;
pub mod communication;
pub use communication::{
CmdVal, InMsg, InMsgArgs, InMsgFn, PlayerEnded, PlayerEvent, PlayerProprChange, PlayerResponse,
PropKey, PropVal,
BoolProp, CmdVal, FpProp, InMsg, InMsgArgs, InMsgFn, PlayerEnded, PlayerEvent, PlayerProprChange,
PlayerResponse, PropKey, PropVal, StrProp,
};
#[cfg(test)]
mod communication_tests;

View file

@ -166,8 +166,72 @@ impl PartialUi for WebView {
try{console.log('Shell JS injected');if(window.self === window.top) {
window.qt={webChannelTransport:{send:window.chrome.webview.postMessage}};
window.chrome.webview.addEventListener('message',ev=>window.qt.webChannelTransport.onmessage(ev));
window.chrome.webview.addEventListener('message',ev=>{
try{
const data = typeof ev.data === "string" ? JSON.parse(ev.data) : ev.data;
if(data && data.__borderbreakerOverlay){
if(window.__bbShowOverlay){ window.__bbShowOverlay(data.__borderbreakerOverlay); }
return;
}
}catch(err){}
window.qt.webChannelTransport.onmessage(ev);
});
}}catch(e){}
try{
if(!document.getElementById('bb-overlay-style')){
const style = document.createElement('style');
style.id = 'bb-overlay-style';
style.textContent = `
#bbOverlayToast {
position: fixed;
top: 28px;
left: 50%;
transform: translateX(-50%) translateY(-8px);
padding: 10px 20px;
border-radius: 10px;
background: rgba(13, 15, 23, 0.92);
color: #fff;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.2px;
box-shadow: 0 12px 35px rgba(0,0,0,0.4);
z-index: 99999;
opacity: 0;
pointer-events: none;
transition: opacity .18s ease, transform .18s ease;
font-family: 'Segoe UI', 'Inter', sans-serif;
}
#bbOverlayToast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
`;
document.head.appendChild(style);
const toast = document.createElement('div');
toast.id = 'bbOverlayToast';
document.body.appendChild(toast);
}
window.__bbShowOverlay = (text) => {
const toast = document.getElementById('bbOverlayToast');
if(!toast) return;
toast.textContent = text;
toast.classList.add('visible');
clearTimeout(window.__bbOverlayTimer);
window.__bbOverlayTimer = setTimeout(() => toast.classList.remove('visible'), 1700);
};
}catch(e){}
try{
window.addEventListener('keydown', (e) => {
const tag = (e.target && e.target.tagName || "").toUpperCase();
if(e.ctrlKey || e.altKey || e.metaKey) return;
if(tag === 'INPUT' || tag === 'TEXTAREA') return;
if(e.target && e.target.isContentEditable) return;
if((e.key || "").toLowerCase() === "b") {
e.preventDefault();
window.chrome.webview.postMessage('{"id":1,"args":["borderbreaker-cycle"]}');
}
}, true);
}catch(e){}
"##, |_| Ok(())).expect("Cannot add script to webview");
Ok(())
}).expect("Cannot add content loading");
@ -177,16 +241,17 @@ impl PartialUi for WebView {
controller
.move_focus(webview2::MoveFocusReason::Programmatic)
.ok();
controller.add_accelerator_key_pressed(move |_, e| {
// Block F7, Ctrl+F, and Ctrl+G
let k = e.get_virtual_key()?;
if k == VK_F7 as u32 || k == 70 & 0x7F || k == 71 & 0x7F {
e.put_handled(true)
} else {
Ok(())
}
})
.unwrap();
controller
.add_accelerator_key_pressed(move |_, e| {
// Block F7, Ctrl+F, and Ctrl+G
let k = e.get_virtual_key()?;
if k == VK_F7 as u32 || k == 70 & 0x7F || k == 71 & 0x7F {
e.put_handled(true)
} else {
Ok(())
}
})
.unwrap();
controller_clone
.set(controller)

View file

@ -154,3 +154,13 @@ impl WindowStyle {
}
}
}
pub fn primary_monitor_ratio() -> f32 {
let width = unsafe { GetSystemMetrics(SM_CXSCREEN) } as f32;
let height = unsafe { GetSystemMetrics(SM_CYSCREEN) } as f32;
if height <= f32::EPSILON {
0.0
} else {
width / height
}
}