diff --git a/crates/rkvm-web/Cargo.toml b/crates/rkvm-web/Cargo.toml index 330c147..51bccb1 100644 --- a/crates/rkvm-web/Cargo.toml +++ b/crates/rkvm-web/Cargo.toml @@ -67,6 +67,8 @@ web-sys = { version = "0.3", features = [ "HtmlSelectElement", "HtmlCanvasElement", "CanvasRenderingContext2d", + "Navigator", + "Node", ] } [profile.release] diff --git a/crates/rkvm-web/index.html b/crates/rkvm-web/index.html index 2910191..695fc41 100644 --- a/crates/rkvm-web/index.html +++ b/crates/rkvm-web/index.html @@ -67,6 +67,7 @@ .card button:not(:disabled):hover { background: var(--accent-hover); } .card button:not(:disabled):active { transform: translateY(1px); } .card button:disabled { opacity: .65; cursor: progress; } + .card .row { display: flex; gap: 8px; } .err { color: var(--danger); margin-top: 10px; min-height: 1em; font-size: 12.5px; } .tb-handle { position: fixed; top: 0; left: 50%; transform: translateX(-50%); z-index: 31; @@ -81,12 +82,16 @@ transition: transform .22s cubic-bezier(.2,.8,.2,1), opacity .18s; opacity: 0; z-index: 32; display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-radius: 14px; background: rgba(18, 21, 28, 0.82); border: 1px solid var(--border); - font-size: 12px; backdrop-filter: blur(16px) saturate(1.4); box-shadow: var(--shadow); } + font-size: 12px; backdrop-filter: blur(16px) saturate(1.4); box-shadow: var(--shadow); + max-width: calc(100vw - 16px); box-sizing: border-box; flex-wrap: wrap; + justify-content: center; row-gap: 4px; } .toolbar.open { transform: translateX(-50%) translateY(0); opacity: 1; } .toolbar button { display: inline-flex; align-items: center; justify-content: center; min-width: 30px; height: 28px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r1); background: transparent; color: var(--text); cursor: pointer; - font: 500 12px var(--font); transition: background .12s, border-color .12s, color .12s, transform .05s; } + font: 500 12px var(--font); white-space: nowrap; + transition: background .12s, border-color .12s, color .12s, transform .05s; } + .toolbar button:disabled { opacity: .4; cursor: default; } .toolbar button:hover { background: var(--surface-3); } .toolbar button:active { transform: translateY(1px); } .toolbar button.active { background: var(--accent); border-color: var(--accent); color: #fff; } @@ -103,12 +108,15 @@ .tb-leds span.on { color: #07120c; background: var(--success); border-color: var(--success); box-shadow: 0 0 10px -1px var(--success); } .tb-err { color: var(--danger); max-width: 220px; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; } + white-space: nowrap; cursor: pointer; } + .tb-err:empty { display: none; } .nopw-banner { position: fixed; top: 8px; left: 8px; z-index: 30; max-width: min(460px, calc(100vw - 16px)); display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border-radius: var(--r1); background: rgba(120, 22, 32, 0.92); border: 1px solid var(--danger); color: #fff; font: 500 12.5px/1.4 var(--font); box-shadow: var(--shadow); } + /* The open toolbar sits at the top; move the warning out of its way. */ + .nopw-banner.low { top: auto; bottom: 8px; } .nopw-banner button { flex: none; background: none; border: 0; color: #fff; cursor: pointer; font-size: 12px; padding: 0 2px; opacity: .8; } .nopw-banner button:hover { opacity: 1; } diff --git a/crates/rkvm-web/src/app.rs b/crates/rkvm-web/src/app.rs index 1af1be5..b3fdd18 100644 --- a/crates/rkvm-web/src/app.rs +++ b/crates/rkvm-web/src/app.rs @@ -213,7 +213,11 @@ pub fn App() -> impl IntoView { let adjust_quality = move |delta: f64| { let Some(client) = rpc_store.get_value() else { return }; - let target = (quality.get_untracked() + delta).clamp(0.1, 1.0); + let current = quality.get_untracked(); + let target = ((current + delta).clamp(0.1, 1.0) * 10.0).round() / 10.0; + if (target - current).abs() < 0.01 { + return; + } spawn_local(async move { match client.call("setStreamQualityFactor", json!({ "factor": target })).await { Ok(_) => quality.set(target), @@ -272,11 +276,11 @@ pub fn App() -> impl IntoView { }; let go_fullscreen = move |_| { - if let Some(el) = - web_sys::window().and_then(|w| w.document()).and_then(|d| d.document_element()) - { - let _ = el.request_fullscreen(); - } + spawn_local(async move { + if let Err(e) = crate::util::toggle_fullscreen().await { + error.set(Some(e)); + } + }); }; let do_ocr = move |_| { @@ -290,6 +294,34 @@ pub fn App() -> impl IntoView { }); }; + let ocr_copy_msg = RwSignal::new(String::new()); + let copy_ocr = move |_| { + let text = ocr_text.get_untracked().unwrap_or_default(); + spawn_local(async move { + ocr_copy_msg.set(match crate::util::copy_text(&text).await { + Ok(()) => "Copied".to_string(), + Err(e) => e, + }); + }); + }; + + // Toolbar errors are transient: clear each one after a few seconds unless a + // newer error replaced it. Errors shown on the Connect card stay put. + let error_timer = StoredValue::new_local(None::); + Effect::new(move |_| { + let Some(msg) = error.get() else { return }; + if conn_state.get_untracked() != "connected" { + return; + } + error_timer.set_value(Some(Timeout::new(6000, move || { + if error.get_untracked().as_deref() == Some(msg.as_str()) + && conn_state.get_untracked() == "connected" + { + error.set(None); + } + }))); + }); + let hide_timer = StoredValue::new_local(None::); let cancel_hide = move |_| hide_timer.set_value(None); let schedule_hide = move |_| { @@ -344,9 +376,21 @@ pub fn App() -> impl IntoView { {move || if mouse_relative.get() { "Mouse: Rel" } else { "Mouse: Abs" }} - + {move || format!("{:.0}%", quality.get() * 100.0)} - + - {move || error.get().unwrap_or_default()} + + {move || error.get().unwrap_or_default()} + - diff --git a/crates/rkvm-web/src/util.rs b/crates/rkvm-web/src/util.rs index 9fbd47c..733e855 100644 --- a/crates/rkvm-web/src/util.rs +++ b/crates/rkvm-web/src/util.rs @@ -1,8 +1,12 @@ use std::rc::Rc; +use js_sys::{Array, Function, Promise, Reflect}; use leptos::prelude::*; use leptos::task::spawn_local; use serde_json::Value; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::HtmlTextAreaElement; use crate::rpc::RpcClient; @@ -29,3 +33,75 @@ pub fn json_string_list(v: &Value) -> Vec { .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) .unwrap_or_default() } + +fn call_method(target: &JsValue, name: &str, args: &Array) -> Result { + let f = Reflect::get(target, &JsValue::from_str(name))?.dyn_into::()?; + f.apply(target, args) +} + +fn js_message(e: &JsValue) -> String { + e.dyn_ref::() + .map(|e| String::from(e.message())) + .or_else(|| e.as_string()) + .unwrap_or_else(|| format!("{e:?}")) +} + +/// Toggles page fullscreen. `requestFullscreen` returns a promise that +/// browsers reject (permissions, iframes, missing user gesture); surface that +/// instead of leaving an unhandled rejection. +pub async fn toggle_fullscreen() -> Result<(), String> { + let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let doc_js: &JsValue = doc.as_ref(); + let active = Reflect::get(doc_js, &JsValue::from_str("fullscreenElement")) + .map(|v| !v.is_null() && !v.is_undefined()) + .unwrap_or(false); + let result = if active { + call_method(doc_js, "exitFullscreen", &Array::new()) + } else { + let el = doc.document_element().ok_or("no document element")?; + call_method(el.as_ref(), "requestFullscreen", &Array::new()) + }; + let value = result.map_err(|e| format!("Fullscreen unavailable: {}", js_message(&e)))?; + if let Ok(promise) = value.dyn_into::() { + JsFuture::from(promise) + .await + .map_err(|e| format!("Fullscreen blocked by the browser: {}", js_message(&e)))?; + } + Ok(()) +} + +/// Copies text to the clipboard. The async Clipboard API only exists in secure +/// contexts, and the device is usually opened over plain http, so fall back to +/// a temporary textarea and `execCommand("copy")`. +pub async fn copy_text(text: &str) -> Result<(), String> { + let window = web_sys::window().ok_or("no window")?; + let clipboard = Reflect::get(window.navigator().as_ref(), &JsValue::from_str("clipboard")) + .unwrap_or(JsValue::UNDEFINED); + if clipboard.is_object() + && let Ok(p) = call_method(&clipboard, "writeText", &Array::of1(&JsValue::from_str(text))) + && let Ok(promise) = p.dyn_into::() + && JsFuture::from(promise).await.is_ok() + { + return Ok(()); + } + let doc = window.document().ok_or("no document")?; + let body = doc.body().ok_or("no body")?; + let area = doc + .create_element("textarea") + .map_err(|e| js_message(&e))? + .dyn_into::() + .map_err(|_| "textarea cast failed")?; + area.set_value(text); + area.set_attribute("style", "position:fixed;opacity:0").map_err(|e| js_message(&e))?; + body.append_child(&area).map_err(|e| js_message(&e))?; + area.select(); + let copied = call_method(doc.as_ref(), "execCommand", &Array::of1(&JsValue::from_str("copy"))) + .map(|v| v.as_bool().unwrap_or(false)) + .unwrap_or(false); + area.remove(); + if copied { + Ok(()) + } else { + Err("Copy failed: select the text and copy it manually".to_string()) + } +}