38078fe7ee
ci / rust (push) Successful in 1m54s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m2s
windows-host / package (push) Successful in 6m43s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m12s
ci / bench (push) Successful in 4m47s
apple / swift (push) Successful in 1m9s
android / android (push) Successful in 3m33s
deb / build-publish (push) Successful in 4m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
decky / build-publish (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
release / apple (push) Successful in 8m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m15s
flatpak / build-publish (push) Successful in 4m4s
apple / screenshots (push) Successful in 5m31s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m48s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m20s
A controller-driven, chrome-less library launcher for the Steam Deck flow
(the Decky plugin's "Open library on screen" + pinned games, 8470419):
`--browse host[:port]` opens a paired host's game library as a coverflow
over a drifting aurora — A streams the focused title (the id rides the
Hello), session end returns to the launcher, B quits back to Gaming Mode.
`--connect` gains `--launch <id>` for direct-to-game starts; `--mgmt`
overrides the library port. Scope is deliberately library-only: host
selection/settings stay in the touch UI, pairing stays in the plugin (no
dialog can map under gamescope — every state renders in-page).
- gamepad.rs menu mode: the worker holds the active pad open while idle
(WITHOUT the Valve HIDAPI drivers — Deck lizard mode survives) and
translates it through a pure MenuNav state machine: edge-triggered
buttons, held-state snapshot on entry/detach (the escape chord that ends
a stream can't ghost-fire in the menu), 380/160 ms stick/dpad repeat,
menu rumble ticks. Keyboard fallback (arrows/Enter/Esc) drives the same
handler — fully usable with no pad, no host (PUNKTFUNK_FAKE_LIBRARY).
- Coverflow: ±38° corridor-facing tilt under per-card perspective
(gsk rotate_3d), dense overlapping side shelves with paint-order
restacking (gtk::Fixed draws in child order), opaque card faces + a
darkening veil for the recede (translucency would bleed the stack
through). The strip lives in an External-policy ScrolledWindow because
a bare gtk::Fixed measures its TRANSFORMED children and inflates the
page min-width past the window.
- Spring-driven motion: semi-implicit Euler in ≤8 ms substeps (a raw
50 ms frame leaves the stiff recoil spring ringing at ω·dt ≈ 1.2 —
regression-tested), ζ≈0.85 cursor chase + ζ≈0.55 boundary wobble;
velocity carries across retargets so held-repeat scrolling glides.
- Shot scene `gamepad-library` (GTK animations force-disabled in shot mode
— nav transitions froze mid-slide in headless captures); shared poster
fetch extracted to library::spawn_art_fetch.
Verified here: 21 unit tests (MenuNav, cursor stepping, spring
convergence/stability), clippy -D warnings clean, screenshot scene
pixel-checked, --browse smoke runs (fake-library + unpaired) on the
headless session. On-Deck validation pending (virtual-pad input, lizard
mode, rumble via Steam Input, full Decky→browse→stream→launcher loop).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
456 lines
19 KiB
Rust
456 lines
19 KiB
Rust
//! The application shell: window, navigation, and top-level glue. The trust/pairing
|
|
//! dialogs live in `ui_trust`, session launch in `launch`, CLI entry paths in `cli`, the
|
|
//! hosts grid in `ui_hosts`.
|
|
|
|
use crate::trust::Settings;
|
|
use crate::ui_hosts::{ConnectRequest, HostsCallbacks, HostsUi};
|
|
use adw::prelude::*;
|
|
use gtk::{gdk, gio, glib};
|
|
use punktfunk_core::client::NativeClient;
|
|
use punktfunk_core::config::{CompositorPref, GamepadPref};
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
const APP_ID: &str = "io.unom.Punktfunk";
|
|
|
|
/// Custom styles on top of libadwaita for the host cards: status pills, presence pips,
|
|
/// the most-recent accent bar, dashed discovered cards. Colours come from the adwaita
|
|
/// named palette so dark mode just works.
|
|
const CSS: &str = "
|
|
.pf-host-card { padding: 16px; }
|
|
.pf-pill { font-size: 0.72em; font-weight: bold; padding: 2px 10px; border-radius: 999px;
|
|
color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); }
|
|
.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); }
|
|
.pf-pill.pf-accent { color: @accent_color; background: alpha(@accent_color, 0.15); }
|
|
.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
|
|
background: alpha(currentColor, 0.35); }
|
|
.pf-pip.pf-online { background: @success_color; }
|
|
.pf-recent { box-shadow: inset 3px 0 0 0 @accent_bg_color; }
|
|
.pf-discovered { border: 1px dashed alpha(currentColor, 0.35); }
|
|
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
|
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
|
|
.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); }
|
|
/* Gaming-Mode launches: gamescope displays the window fullscreen but never ACKs the
|
|
xdg_toplevel fullscreen state, so GTK keeps the floating-CSD styling — libadwaita's
|
|
rounded corners + shadow margin stay visible over the stream. Flatten them outright. */
|
|
window.pf-chromeless { border-radius: 0; box-shadow: none; }
|
|
/* The gamepad library launcher (`--browse`, ui_gamepad_library) — always-dark console
|
|
chrome over the aurora, independent of the desktop theme. */
|
|
.pf-gl-page { background: black; color: white; }
|
|
.pf-gl-host { font-size: 1.15em; font-weight: bold; color: rgba(255, 255, 255, 0.9); }
|
|
.pf-gl-chip { font-size: 0.8em; color: rgba(255, 255, 255, 0.7);
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
border-radius: 999px; padding: 4px 12px; }
|
|
/* Solid face, not glass: coverflow side cards OVERLAP — a translucent card would bleed
|
|
the stack through the one on top. */
|
|
.pf-gl-poster { border-radius: 16px; background: rgb(30, 30, 37);
|
|
border: 1px solid rgba(255, 255, 255, 0.07); }
|
|
.pf-gl-dim { background: black; border-radius: 16px; }
|
|
.pf-gl-detail-title { font-size: 1.7em; font-weight: bold; color: white; }
|
|
.pf-gl-detail-store { font-size: 0.75em; font-weight: 600; letter-spacing: 2px;
|
|
color: rgba(255, 255, 255, 0.5); }
|
|
.pf-gl-glyph { font-size: 0.85em; font-weight: bold; color: white;
|
|
background: rgba(255, 255, 255, 0.14);
|
|
border-radius: 999px; min-width: 26px; min-height: 26px; padding: 2px 8px; }
|
|
.pf-gl-hint { color: rgba(255, 255, 255, 0.85); }
|
|
.pf-gl-status { font-size: 0.85em; color: #ff938a; }
|
|
.pf-gl-error-title { font-size: 1.4em; font-weight: bold; color: white; }
|
|
";
|
|
|
|
pub struct App {
|
|
pub window: adw::ApplicationWindow,
|
|
pub nav: adw::NavigationView,
|
|
pub toasts: adw::ToastOverlay,
|
|
pub settings: Rc<RefCell<Settings>>,
|
|
pub identity: (String, String),
|
|
/// App-lifetime SDL gamepad service: Settings list + per-session capture/feedback.
|
|
pub gamepad: crate::gamepad::GamepadService,
|
|
/// One session at a time — ignore connects while one is starting/running.
|
|
pub busy: std::cell::Cell<bool>,
|
|
/// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts.
|
|
pub fullscreen: bool,
|
|
/// Quit when the session ends (Gaming-Mode `--connect` launch): the app IS the stream —
|
|
/// exiting ends the Steam "game" so the Deck returns to Gaming Mode instead of stranding
|
|
/// the user on the client's own hosts page.
|
|
pub quit_on_session_end: bool,
|
|
/// The hosts page handle (banner + per-card connecting spinner), set right after the
|
|
/// page is built — `None` only during construction.
|
|
pub hosts: RefCell<Option<Rc<HostsUi>>>,
|
|
/// The gamepad library launcher — `Some` only under `--browse`, where it replaces the
|
|
/// hosts page as the root (and session end returns here instead of quitting).
|
|
pub browse: RefCell<Option<Rc<crate::ui_gamepad_library::LauncherUi>>>,
|
|
}
|
|
|
|
impl App {
|
|
pub fn toast(&self, msg: &str) {
|
|
self.toasts.add_toast(adw::Toast::new(msg));
|
|
}
|
|
|
|
pub fn hosts_ui(&self) -> Option<Rc<HostsUi>> {
|
|
self.hosts.borrow().clone()
|
|
}
|
|
|
|
pub fn browse_ui(&self) -> Option<Rc<crate::ui_gamepad_library::LauncherUi>> {
|
|
self.browse.borrow().clone()
|
|
}
|
|
|
|
/// Surface a connect failure: the launcher in browse mode, else the hosts page banner
|
|
/// (toast fallback pre-build).
|
|
pub fn connect_error(&self, msg: &str) {
|
|
match (self.browse_ui(), self.hosts_ui()) {
|
|
(Some(l), _) => l.show_error(msg),
|
|
(_, Some(h)) => h.show_error(msg),
|
|
_ => self.toast(msg),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn run() -> glib::ExitCode {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
|
)
|
|
.init();
|
|
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
|
|
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
|
|
if let Some(pin) = crate::cli::arg_value("--pair") {
|
|
return crate::cli::headless_pair(&pin);
|
|
}
|
|
// Headless library fetch (no GTK window): `--library host[:mgmt_port] [--fp HEX]`.
|
|
if let Some(target) = crate::cli::arg_value("--library") {
|
|
return crate::cli::headless_library(&target);
|
|
}
|
|
let mut builder = adw::Application::builder().application_id(APP_ID);
|
|
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each
|
|
// launch its own primary instance instead of forwarding to a still-registered name.
|
|
if crate::cli::shot_scene().is_some() {
|
|
builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE);
|
|
}
|
|
let app = builder.build();
|
|
app.connect_activate(build_ui);
|
|
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
|
// keeps GApplication from rejecting unknown options.
|
|
app.run_with_args(&[] as &[&str])
|
|
}
|
|
|
|
fn build_ui(gtk_app: &adw::Application) {
|
|
let identity = match crate::trust::load_or_create_identity() {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
tracing::error!("client identity: {e:#}");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
load_css();
|
|
// Screenshot scenes must capture settled frames: kill every GTK/libadwaita animation
|
|
// (nav-push slides especially — a headless session may starve the frame clock and
|
|
// leave a transition frozen mid-flight in the capture).
|
|
if crate::cli::shot_scene().is_some() {
|
|
if let Some(s) = gtk::Settings::default() {
|
|
s.set_gtk_enable_animations(false);
|
|
}
|
|
}
|
|
|
|
let nav = adw::NavigationView::new();
|
|
let toasts = adw::ToastOverlay::new();
|
|
toasts.set_child(Some(&nav));
|
|
let window = adw::ApplicationWindow::builder()
|
|
.application(gtk_app)
|
|
.title("Punktfunk")
|
|
.default_width(1200)
|
|
.default_height(780)
|
|
.content(&toasts)
|
|
.build();
|
|
|
|
let fullscreen = crate::cli::fullscreen_mode();
|
|
if fullscreen {
|
|
// Chrome-less shell: no CSD rounding/shadow (see CSS — gamescope never ACKs the
|
|
// fullscreen state, so GTK would keep them), and ask for fullscreen up front.
|
|
window.add_css_class("pf-chromeless");
|
|
window.fullscreen();
|
|
}
|
|
|
|
let app = Rc::new(App {
|
|
window: window.clone(),
|
|
nav: nav.clone(),
|
|
toasts,
|
|
settings: Rc::new(RefCell::new(Settings::load())),
|
|
identity,
|
|
gamepad: crate::gamepad::GamepadService::start(),
|
|
busy: std::cell::Cell::new(false),
|
|
fullscreen,
|
|
// (`--browse` makes cli_connect_request None — browse mode returns to the
|
|
// launcher on session end instead of quitting.)
|
|
quit_on_session_end: fullscreen && crate::cli::cli_connect_request().is_some(),
|
|
hosts: RefCell::new(None),
|
|
browse: RefCell::new(None),
|
|
});
|
|
|
|
// Re-apply the persisted forwarded-controller pin (stable key; the service matches it
|
|
// whenever such a pad connects) — without this the pin silently resets to Automatic on
|
|
// every launch, and Automatic may resolve to a gyro-less pad (Steam's virtual gamepad).
|
|
{
|
|
let forward = app.settings.borrow().forward_pad.clone();
|
|
if !forward.is_empty() {
|
|
app.gamepad.set_pinned(Some(forward));
|
|
}
|
|
}
|
|
|
|
// Browse mode (`--browse host`): the app IS the gamepad library launcher — it becomes
|
|
// the ONE root page. No hosts page (whose construction starts the mDNS browse), no
|
|
// header-menu actions; `Settings::library_enabled` is deliberately ignored (the flag
|
|
// gates the desktop menu item — asking to browse IS the opt-in here).
|
|
if let Some((req, paired, mgmt_port)) = crate::cli::cli_browse_request() {
|
|
let launcher = crate::ui_gamepad_library::open(app.clone(), req, paired, mgmt_port);
|
|
nav.add(&launcher.page);
|
|
*app.browse.borrow_mut() = Some(launcher);
|
|
window.present();
|
|
return;
|
|
}
|
|
|
|
let hosts_ui = Rc::new(crate::ui_hosts::new(
|
|
app.settings.clone(),
|
|
HostsCallbacks {
|
|
on_connect: {
|
|
let app = app.clone();
|
|
Rc::new(move |req| crate::ui_trust::initiate_connect(app.clone(), req))
|
|
},
|
|
on_speed_test: {
|
|
let app = app.clone();
|
|
Rc::new(move |req| speed_test(app.clone(), req))
|
|
},
|
|
on_pair: {
|
|
let app = app.clone();
|
|
Rc::new(move |req| {
|
|
if !app.busy.get() {
|
|
crate::ui_trust::pin_dialog(app.clone(), req);
|
|
}
|
|
})
|
|
},
|
|
on_library: {
|
|
let app = app.clone();
|
|
Rc::new(move |req| crate::ui_library::open(app.clone(), req))
|
|
},
|
|
},
|
|
));
|
|
*app.hosts.borrow_mut() = Some(hosts_ui.clone());
|
|
install_actions(&app, &hosts_ui);
|
|
nav.add(&hosts_ui.page);
|
|
window.present();
|
|
|
|
// CI screenshot mode: render one scripted, host-free scene and signal readiness
|
|
// (clients/linux/tools/screenshots.sh). Mutually exclusive with a real connect.
|
|
if let Some(scene) = crate::cli::shot_scene() {
|
|
crate::cli::run_shot(app, &scene);
|
|
return;
|
|
}
|
|
|
|
if let Some(req) = crate::cli::cli_connect_request() {
|
|
crate::ui_trust::initiate_connect(app, req);
|
|
}
|
|
}
|
|
|
|
fn load_css() {
|
|
let provider = gtk::CssProvider::new();
|
|
provider.load_from_string(CSS);
|
|
if let Some(display) = gdk::Display::default() {
|
|
gtk::style_context_add_provider_for_display(
|
|
&display,
|
|
&provider,
|
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Window actions behind the hosts page's header: the primary (hamburger) menu entries
|
|
/// plus the "+" add-host button and the empty state's call to action.
|
|
fn install_actions(app: &Rc<App>, hosts: &Rc<HostsUi>) {
|
|
let add = |name: &str, f: Box<dyn Fn()>| {
|
|
let action = gio::SimpleAction::new(name, None);
|
|
action.connect_activate(move |_, _| f());
|
|
app.window.add_action(&action);
|
|
};
|
|
{
|
|
let app = app.clone();
|
|
add(
|
|
"preferences",
|
|
Box::new(move || {
|
|
let refresh = {
|
|
let app = app.clone();
|
|
// The library toggle changes the saved cards' menu — re-render on close.
|
|
move || {
|
|
if let Some(h) = app.hosts_ui() {
|
|
h.refresh();
|
|
}
|
|
}
|
|
};
|
|
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad, refresh)
|
|
}),
|
|
);
|
|
}
|
|
{
|
|
let window = app.window.clone();
|
|
add(
|
|
"shortcuts",
|
|
Box::new(move || shortcuts_window(&window).present()),
|
|
);
|
|
}
|
|
{
|
|
let window = app.window.clone();
|
|
add(
|
|
"about",
|
|
Box::new(move || crate::ui_settings::show_about(&window)),
|
|
);
|
|
}
|
|
{
|
|
let hosts = hosts.clone();
|
|
add("add-host", Box::new(move || hosts.show_add_host()));
|
|
}
|
|
}
|
|
|
|
/// The Keyboard Shortcuts window (menu + the shortcuts scene). GtkShortcutsWindow is
|
|
/// builder-XML-first, so it's assembled from a snippet rather than widget calls.
|
|
pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow {
|
|
const UI: &str = r#"
|
|
<interface>
|
|
<object class="GtkShortcutsWindow" id="shortcuts">
|
|
<property name="modal">1</property>
|
|
<child>
|
|
<object class="GtkShortcutsSection">
|
|
<child>
|
|
<object class="GtkShortcutsGroup">
|
|
<property name="title">Stream</property>
|
|
<child>
|
|
<object class="GtkShortcutsShortcut">
|
|
<property name="title">Toggle fullscreen</property>
|
|
<property name="accelerator">F11</property>
|
|
</object>
|
|
</child>
|
|
<child>
|
|
<object class="GtkShortcutsShortcut">
|
|
<property name="title">Release captured input (click the stream to capture)</property>
|
|
<property name="accelerator"><Control><Alt><Shift>q</property>
|
|
</object>
|
|
</child>
|
|
<child>
|
|
<object class="GtkShortcutsShortcut">
|
|
<property name="title">Disconnect</property>
|
|
<property name="accelerator"><Control><Alt><Shift>d</property>
|
|
</object>
|
|
</child>
|
|
<child>
|
|
<object class="GtkShortcutsShortcut">
|
|
<property name="title">Toggle statistics overlay</property>
|
|
<property name="accelerator"><Control><Alt><Shift>s</property>
|
|
</object>
|
|
</child>
|
|
</object>
|
|
</child>
|
|
</object>
|
|
</child>
|
|
</object>
|
|
</interface>
|
|
"#;
|
|
let builder = gtk::Builder::from_string(UI);
|
|
let window: gtk::ShortcutsWindow = builder
|
|
.object("shortcuts")
|
|
.expect("shortcuts window in builder XML");
|
|
window.set_transient_for(Some(parent));
|
|
window
|
|
}
|
|
|
|
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
|
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
|
|
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
|
|
fn speed_test(app: Rc<App>, req: ConnectRequest) {
|
|
if app.busy.replace(true) {
|
|
return;
|
|
}
|
|
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
|
|
let status = gtk::Label::new(Some("Connecting…"));
|
|
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
|
|
dialog.set_extra_child(Some(&status));
|
|
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
|
|
dialog.set_response_enabled("apply", false);
|
|
dialog.set_close_response("close");
|
|
dialog.present(Some(&app.window));
|
|
|
|
let (tx, rx) =
|
|
async_channel::bounded::<Result<punktfunk_core::client::ProbeOutcome, String>>(1);
|
|
let identity = app.identity.clone();
|
|
let (host, port) = (req.addr.clone(), req.port);
|
|
std::thread::spawn(move || {
|
|
let result = (|| {
|
|
let c = NativeClient::connect(
|
|
&host,
|
|
port,
|
|
punktfunk_core::config::Mode {
|
|
width: 1280,
|
|
height: 720,
|
|
refresh_hz: 60,
|
|
},
|
|
CompositorPref::Auto,
|
|
GamepadPref::Auto,
|
|
0, // bitrate_kbps (host default)
|
|
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
|
2, // audio_channels: speed-test probe, stereo
|
|
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
|
|
0, // preferred_codec: no preference for a speed-test probe
|
|
None, // launch: speed-test probe connect, no game
|
|
pin,
|
|
Some(identity),
|
|
std::time::Duration::from_secs(15),
|
|
)
|
|
.map_err(|e| format!("connect: {e:?}"))?;
|
|
c.request_probe(3_000_000, 2_000)
|
|
.map_err(|e| format!("probe: {e:?}"))?;
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
|
loop {
|
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
|
let r = c.probe_result();
|
|
if r.done {
|
|
// Let the last UDP shards land before tearing down.
|
|
std::thread::sleep(std::time::Duration::from_millis(400));
|
|
return Ok(c.probe_result());
|
|
}
|
|
if std::time::Instant::now() > deadline {
|
|
return Err("probe timed out".to_string());
|
|
}
|
|
}
|
|
})();
|
|
let _ = tx.send_blocking(result);
|
|
});
|
|
|
|
glib::spawn_future_local(async move {
|
|
let outcome = rx.recv().await;
|
|
app.busy.set(false);
|
|
match outcome {
|
|
Ok(Ok(r)) => {
|
|
let mbps = f64::from(r.throughput_kbps) / 1000.0;
|
|
let recommended_kbps = r.throughput_kbps / 10 * 7;
|
|
status.set_text(&format!(
|
|
"{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s",
|
|
r.loss_pct,
|
|
f64::from(recommended_kbps) / 1000.0,
|
|
));
|
|
dialog.set_response_enabled("apply", true);
|
|
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
|
|
let settings = app.settings.clone();
|
|
let toasts = app.toasts.clone();
|
|
dialog.connect_response(Some("apply"), move |_, _| {
|
|
let mut s = settings.borrow_mut();
|
|
s.bitrate_kbps = recommended_kbps;
|
|
s.save();
|
|
toasts.add_toast(adw::Toast::new(&format!(
|
|
"Bitrate set to {:.0} Mbit/s",
|
|
f64::from(recommended_kbps) / 1000.0
|
|
)));
|
|
});
|
|
}
|
|
Ok(Err(msg)) => status.set_text(&msg),
|
|
Err(_) => {}
|
|
}
|
|
});
|
|
}
|