From 8f8776b42b1e66271bcb1b2c1d735cd2f04fdae4 Mon Sep 17 00:00:00 2001 From: Justin Scroggins Date: Mon, 2 Nov 2026 19:15:12 -0500 Subject: [PATCH] Discord's call stage: stream tiles, a focused stream, and a screen picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A share or camera used to show as a "X is sharing · Watch" pill, then a 16:9 box above the tiles, and whoever shared saw nothing of it. Now everyone sharing their screen or camera has a stream tile beside their own, "Watch stream" until it is watched. Clicking one watches it and fills the stage with it, the people in a strip under it, over it or hidden, with a full-screen button; clicking it again, or Escape, is the grid again. My own share is a tile too, drawn from what my call sends (or my seat, when my call's list lags), with Stop and a dimmed picture of what I picked. Share opens a picker of every screen and, on Windows, every window, each with a small picture (`mediad --screens`, off the main thread). The choice rides on `ShareSend` as an optional `screen` (left off the wire when empty, so older frames still read), and mediad captures that screen (GDI, per-monitor DPI) or window (PrintWindow). macOS and X11 still share the primary screen; a phone keeps the system's own picker. A click that redraws the stage removed the tile it focused, and Escape then found no binding, so the stage puts the keys back on the window's root. Co-Authored-By: Claude Opus 5.5 --- crates/chatd/src/adapters/tiny/live.rs | 2 +- crates/chatd/tests/e2e.rs | 6 +- crates/mediad/Cargo.toml | 5 +- crates/mediad/src/picture.rs | 5 + crates/mediad/src/picture/desktop.rs | 61 +- crates/mediad/src/picture/phone.rs | 8 + crates/mediad/src/picture/screens.rs | 334 +++++++++++ crates/mediad/src/serve.rs | 16 +- crates/mediad/tests/voice.rs | 6 +- crates/protocol/src/call.rs | 38 +- crates/tinyclient/assets/icons/maximize.svg | 18 + crates/tinyclient/assets/icons/minimize.svg | 18 + .../tinyclient/assets/icons/panel-bottom.svg | 16 + crates/tinyclient/src/gui/assets.rs | 3 + crates/tinyclient/src/gui/call/dock.rs | 2 +- .../tinyclient/src/gui/call/phone/controls.rs | 2 +- crates/tinyclient/src/gui/debug_launch.rs | 2 +- crates/tinyclient/src/gui/layout.rs | 2 + crates/tinyclient/src/gui/settings/mod.rs | 2 +- crates/tinyclient/src/gui/shot_demo_live.rs | 2 +- crates/tinyclient/src/gui/tiny/channel.rs | 533 ++++++++++++------ crates/tinyclient/src/gui/tiny/layer.rs | 10 +- crates/tinyclient/src/gui/tiny/mod.rs | 34 +- crates/tinyclient/src/gui/tiny/modals.rs | 1 + crates/tinyclient/src/gui/tiny/share.rs | 208 +++++++ crates/tinyclient/src/gui/tiny/stage.rs | 145 +++++ 26 files changed, 1226 insertions(+), 253 deletions(-) create mode 100644 crates/mediad/src/picture/screens.rs create mode 100644 crates/tinyclient/assets/icons/maximize.svg create mode 100644 crates/tinyclient/assets/icons/minimize.svg create mode 100644 crates/tinyclient/assets/icons/panel-bottom.svg create mode 100644 crates/tinyclient/src/gui/tiny/share.rs create mode 100644 crates/tinyclient/src/gui/tiny/stage.rs diff --git a/crates/chatd/src/adapters/tiny/live.rs b/crates/chatd/src/adapters/tiny/live.rs index a5149857..40b14023 100644 --- a/crates/chatd/src/adapters/tiny/live.rs +++ b/crates/chatd/src/adapters/tiny/live.rs @@ -1162,7 +1162,7 @@ fn heard_frame(ctx: &Context, saved: &Saved, live: &Live, seat: &mut Seat, pendi who.heard(frame); let reread = who.reread(&before); if user == seat.me && before.sharing && !who.sharing { - ctx.tell_media(room, MediaRequest::ShareSend { on: false }); + ctx.tell_media(room, MediaRequest::ShareSend { on: false, screen: None }); } if user == seat.me && before.camera && !who.camera { ctx.tell_media(room, MediaRequest::Camera { on: false }); diff --git a/crates/chatd/tests/e2e.rs b/crates/chatd/tests/e2e.rs index 41a7bc94..dba7c0b3 100644 --- a/crates/chatd/tests/e2e.rs +++ b/crates/chatd/tests/e2e.rs @@ -584,17 +584,17 @@ fn mediad_exits_when_chatd_is_killed_mid_call() { fn a_share_is_one_encode_and_one_subscribed_decode() { let chatd = Daemon::start(standup); - let no_call = chatd.ask(Request::Media(MediaRequest::ShareSend { on: true })); + let no_call = chatd.ask(Request::Media(MediaRequest::ShareSend { on: true, screen: None })); assert!(matches!(no_call, Response::Error { code: ErrorCode::NoCall, .. }), "{no_call:?}"); assert_eq!(chatd.mediad_pids(), Vec::::new()); chatd.ask(call_start()); let watch = |source: &str| Request::Media(MediaRequest::ShareWatch { source: Some(source.into()) }); let speaker = |source: &str| Request::Media(MediaRequest::Speaker { source: Some(source.into()) }); - chatd.ask(Request::Media(MediaRequest::ShareSend { on: true })); + chatd.ask(Request::Media(MediaRequest::ShareSend { on: true, screen: None })); chatd.ask(watch("alice")); chatd.ask(speaker("carol")); - chatd.ask(Request::Media(MediaRequest::ShareSend { on: true })); + chatd.ask(Request::Media(MediaRequest::ShareSend { on: true, screen: None })); chatd.ask(speaker("dave")); let last = chatd.ask(watch("bob")); diff --git a/crates/mediad/Cargo.toml b/crates/mediad/Cargo.toml index 74da164d..9c77b2ec 100644 --- a/crates/mediad/Cargo.toml +++ b/crates/mediad/Cargo.toml @@ -78,8 +78,9 @@ webpki-roots = "1" [target.'cfg(windows)'.dependencies] # MMCSS scheduling for the device callbacks and the call's tick, and the 1 ms system timer a -# call's 20 ms tick needs (`rt`). Already in the tree through cpal and mio. -windows-sys = { version = "0.61", features = ["Win32_Media", "Win32_System_Threading", "Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging"] } +# call's 20 ms tick needs (`rt`), and the screens and windows a share can send (`picture/screens`). +# Already in the tree through cpal and mio. +windows-sys = { version = "0.61", features = ["Win32_Media", "Win32_System_Threading", "Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Graphics_Dwm", "Win32_Storage_Xps", "Win32_UI_HiDpi", "Win32_UI_WindowsAndMessaging"] } # The first camera, through Media Foundation. Pure Rust bindings. A machine with no camera # says so, and the call stays a voice call. nokhwa = { version = "0.10.10", default-features = false, features = ["input-native"] } diff --git a/crates/mediad/src/picture.rs b/crates/mediad/src/picture.rs index 5fcbe870..cd91e838 100644 --- a/crates/mediad/src/picture.rs +++ b/crates/mediad/src/picture.rs @@ -19,6 +19,11 @@ pub const CODEC_LATER: &str = "OpenH264 is still downloading."; mod desktop; #[cfg(not(any(target_os = "android", target_os = "ios")))] pub use desktop::*; +/// What a share sends: the picker's list and the one chosen. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +mod screens; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub use screens::{choose_screen, screens}; pub mod convert; /// Built everywhere, so host tests drive it with `platform::fake`. pub mod phone; diff --git a/crates/mediad/src/picture/desktop.rs b/crates/mediad/src/picture/desktop.rs index 185c6738..d2a55c96 100644 --- a/crates/mediad/src/picture/desktop.rs +++ b/crates/mediad/src/picture/desktop.rs @@ -261,12 +261,21 @@ impl Camera { } } -/// The primary screen as RGBA or BGRA. The whole screen, which the share then fits. +/// The screen or window chosen for the share ([`super::choose_screen`]), else the primary +/// screen, as RGBA or BGRA. The whole of it, which the share then fits. pub fn capture_screen() -> Result { + capture_source(super::screens::chosen().as_deref()) +} + +/// One screen or window by its `--screens` id. Only Windows tells them apart; elsewhere every id +/// is the primary screen. +pub(super) fn capture_source(id: Option<&str>) -> Result { #[cfg(windows)] { - return capture_screen_windows(); + return super::screens::capture(id); } + #[cfg(not(windows))] + let _ = id; #[cfg(target_os = "macos")] { return capture_screen_macos(); @@ -281,54 +290,6 @@ pub fn capture_screen() -> Result { } } -#[cfg(windows)] -fn capture_screen_windows() -> Result { - use std::mem::size_of; - use windows_sys::Win32::Graphics::Gdi::{ - BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits, ReleaseDC, SelectObject, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, SRCCOPY, - }; - use windows_sys::Win32::UI::WindowsAndMessaging::{GetDesktopWindow, GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN}; - unsafe { - let width = GetSystemMetrics(SM_CXSCREEN); - let height = GetSystemMetrics(SM_CYSCREEN); - if width <= 0 || height <= 0 { - return Err("The screen has no size.".to_owned()); - } - let desktop = GetDesktopWindow(); - let dc = GetDC(desktop); - if dc.is_null() { - return Err("tinyclient can't record the screen.".to_owned()); - } - let memory = CreateCompatibleDC(dc); - let bitmap = CreateCompatibleBitmap(dc, width, height); - let previous = SelectObject(memory, bitmap as _); - let copied = BitBlt(memory, 0, 0, width, height, dc, 0, 0, SRCCOPY); - let mut header = BITMAPINFOHEADER { - biSize: size_of::() as u32, - biWidth: width, - biHeight: -height, - biPlanes: 1, - biBitCount: 32, - biCompression: BI_RGB, - biSizeImage: 0, - biXPelsPerMeter: 0, - biYPelsPerMeter: 0, - biClrUsed: 0, - biClrImportant: 0, - }; - let mut pixels = vec![0u8; (width * height * 4) as usize]; - let rows = GetDIBits(memory, bitmap, 0, height as u32, pixels.as_mut_ptr().cast(), (&mut header as *mut BITMAPINFOHEADER).cast(), DIB_RGB_COLORS); - SelectObject(memory, previous); - DeleteObject(bitmap as _); - DeleteDC(memory); - ReleaseDC(desktop, dc); - if copied == 0 || rows == 0 { - return Err("tinyclient can't record the screen.".to_owned()); - } - Ok(Rgba { width: width as u32, height: height as u32, pixels, bgra: true }) - } -} - #[cfg(target_os = "macos")] fn capture_screen_macos() -> Result { use core_graphics::display::CGDisplay; diff --git a/crates/mediad/src/picture/phone.rs b/crates/mediad/src/picture/phone.rs index f279795e..36d3d638 100644 --- a/crates/mediad/src/picture/phone.rs +++ b/crates/mediad/src/picture/phone.rs @@ -183,6 +183,14 @@ impl Incoming { } } +/// A phone's share is the system's own picker's: nothing here to choose. +pub fn choose_screen(_id: Option) {} + +/// A phone lists nothing: the system asks what to share. +pub fn screens(_folder: Option<&std::path::Path>) -> Vec { + Vec::new() +} + /// Whether the screen can be shared, before the button claims it: a platform that shares. pub fn probe_screen() -> Result<(), String> { platform::required().map(|_| ()) diff --git a/crates/mediad/src/picture/screens.rs b/crates/mediad/src/picture/screens.rs new file mode 100644 index 00000000..228cb289 --- /dev/null +++ b/crates/mediad/src/picture/screens.rs @@ -0,0 +1,334 @@ +//! What a share sends. The share picker lists every screen and, on Windows, every window a person +//! would call one (`--screens`), each with a small JPEG of it; the share then captures the one +//! chosen with `ShareSend`. Windows reads a screen with GDI from the desktop and a window with +//! `PrintWindow`, which draws it even when it is covered. macOS and X11 share the primary screen +//! alone, so they list just that. + +use super::{fit, Rgba}; +use protocol::{ScreenSource, SCREENS_LIMIT}; +use std::path::Path; +use std::sync::Mutex; + +/// The picker's pictures are fitted inside this. +const THUMB: (u32, u32) = (320, 180); +const THUMB_QUALITY: u8 = 70; + +/// The screen or window the share captures, by id. `None` is the primary screen. One call per +/// process, so one choice per process. +static CHOSEN: Mutex> = Mutex::new(None); + +/// What the share captures from its next frame on. +pub fn choose_screen(id: Option) { + *CHOSEN.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = id; +} + +pub(super) fn chosen() -> Option { + CHOSEN.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).clone() +} + +/// Every screen, then every window, at most [`SCREENS_LIMIT`]. With a folder, a picture of each +/// is written there, and the pictures of an earlier list are removed. Each list's files have new +/// names, so a window that caches images by path never shows an old one. +pub fn screens(folder: Option<&Path>) -> Vec { + let mut found = listed(); + found.truncate(SCREENS_LIMIT); + let Some(folder) = folder else { + return found; + }; + if std::fs::create_dir_all(folder).is_err() { + return found; + } + for entry in std::fs::read_dir(folder).into_iter().flatten().flatten() { + if entry.path().extension().is_some_and(|ext| ext == "jpg") { + let _ = std::fs::remove_file(entry.path()); + } + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|at| at.as_millis()).unwrap_or_default(); + for (n, source) in found.iter_mut().enumerate() { + let path = folder.join(format!("screen-{stamp}-{n}.jpg")); + match super::desktop::capture_source(Some(&source.id)).and_then(|grab| thumbnail(&grab, &path)) { + Ok(()) => source.thumbnail = path.to_string_lossy().into_owned(), + Err(err) => tracing::debug!("mediad: no picture of {}: {err}", source.name), + } + } + found +} + +/// A small JPEG of one capture, sampled to its nearest pixel: the picker draws it at this size. +fn thumbnail(grab: &Rgba, path: &Path) -> Result<(), String> { + let (width, height) = fit(grab.width, grab.height, THUMB.0, THUMB.1); + let rgb = shrink(grab, width, height); + let file = std::fs::File::create(path).map_err(|err| err.to_string())?; + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(std::io::BufWriter::new(file), THUMB_QUALITY); + encoder.encode(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|err| err.to_string()) +} + +/// RGB at `width` × `height`, each pixel the nearest of the capture's. +fn shrink(grab: &Rgba, width: u32, height: u32) -> Vec { + let mut rgb = Vec::with_capacity((width * height * 3) as usize); + for y in 0..height { + let sy = (y as u64 * grab.height as u64 / height.max(1) as u64) as u32; + for x in 0..width { + let sx = (x as u64 * grab.width as u64 / width.max(1) as u64) as u32; + let at = ((sy * grab.width + sx) * 4) as usize; + let pixel = grab.pixels.get(at..at + 3).unwrap_or(&[0, 0, 0]); + if grab.bgra { + rgb.extend_from_slice(&[pixel[2], pixel[1], pixel[0]]); + } else { + rgb.extend_from_slice(pixel); + } + } + } + rgb +} + +#[cfg(not(windows))] +fn listed() -> Vec { + vec![ScreenSource { id: "display:primary".to_owned(), name: "Your screen".to_owned(), primary: true, ..ScreenSource::default() }] +} + +#[cfg(windows)] +fn listed() -> Vec { + let _dpi = win::Physical::new(); + let mut found = win::displays() + .into_iter() + .enumerate() + .map(|(n, display)| ScreenSource { + id: format!("display:{}", display.device), + name: if display.primary { format!("Screen {} (main)", n + 1) } else { format!("Screen {}", n + 1) }, + primary: display.primary, + ..ScreenSource::default() + }) + .collect::>(); + found.extend(win::windows().into_iter().map(|(handle, title)| ScreenSource { id: format!("window:{handle}"), name: title, window: true, ..ScreenSource::default() })); + found +} + +/// One screen or window, by id; the primary screen when the id is `None` or names nothing that +/// is still there as a screen. A window that closed is said in words, so the share ends. +#[cfg(windows)] +pub(super) fn capture(id: Option<&str>) -> Result { + let _dpi = win::Physical::new(); + if let Some(handle) = id.and_then(|id| id.strip_prefix("window:")).and_then(|handle| handle.parse::().ok()) { + return win::capture_window(handle); + } + let displays = win::displays(); + let device = id.and_then(|id| id.strip_prefix("display:")); + let display = displays.iter().find(|display| Some(display.device.as_str()) == device).or_else(|| displays.iter().find(|display| display.primary)); + match display { + Some(display) => win::capture_rect(display.rect), + None => Err("The screen has no size.".to_owned()), + } +} + +#[cfg(windows)] +mod win { + use super::Rgba; + use std::mem::size_of; + use windows_sys::core::BOOL; + use windows_sys::Win32::Foundation::{HWND, LPARAM, RECT}; + use windows_sys::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_CLOAKED}; + use windows_sys::Win32::Graphics::Gdi::{ + BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, EnumDisplayMonitors, GetDC, GetDIBits, GetMonitorInfoW, ReleaseDC, SelectObject, BITMAPINFOHEADER, BI_RGB, + DIB_RGB_COLORS, HDC, HMONITOR, MONITORINFO, MONITORINFOEXW, SRCCOPY, + }; + use windows_sys::Win32::Storage::Xps::PrintWindow; + use windows_sys::Win32::UI::HiDpi::{SetThreadDpiAwarenessContext, DPI_AWARENESS_CONTEXT, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2}; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetWindow, GetWindowLongW, GetWindowRect, GetWindowTextLengthW, GetWindowTextW, IsIconic, IsWindow, IsWindowVisible, GWL_EXSTYLE, GW_OWNER, MONITORINFOF_PRIMARY, + PW_RENDERFULLCONTENT, WS_EX_TOOLWINDOW, + }; + + /// Real pixels while it lives, on this thread: a screen at 150 % is captured whole and + /// every rectangle is in the same units, whatever the process's own awareness is. + pub struct Physical(DPI_AWARENESS_CONTEXT); + + impl Physical { + pub fn new() -> Physical { + // SAFETY: sets this thread's awareness and returns the one before, put back on drop. + Physical(unsafe { SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) }) + } + } + + impl Drop for Physical { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the context Windows handed back above. + unsafe { SetThreadDpiAwarenessContext(self.0) }; + } + } + } + + pub struct Display { + pub device: String, + pub rect: RECT, + pub primary: bool, + } + + /// Every screen, in the order Windows enumerates them. + pub fn displays() -> Vec { + unsafe extern "system" fn each(monitor: HMONITOR, _: HDC, _: *mut RECT, data: LPARAM) -> BOOL { + // SAFETY: `data` is the Vec below, alive for the whole enumeration. + let found = unsafe { &mut *(data as *mut Vec) }; + let mut info: MONITORINFOEXW = unsafe { std::mem::zeroed() }; + info.monitorInfo.cbSize = size_of::() as u32; + // SAFETY: an EX struct with its size set, as GetMonitorInfoW reads it. + if unsafe { GetMonitorInfoW(monitor, (&raw mut info).cast::()) } != 0 { + let len = info.szDevice.iter().position(|&c| c == 0).unwrap_or(info.szDevice.len()); + found.push(Display { + device: String::from_utf16_lossy(&info.szDevice[..len]), + rect: info.monitorInfo.rcMonitor, + primary: info.monitorInfo.dwFlags & MONITORINFOF_PRIMARY != 0, + }); + } + 1 + } + let mut found: Vec = Vec::new(); + // SAFETY: the callback only writes the Vec it is handed. + unsafe { EnumDisplayMonitors(std::ptr::null_mut(), std::ptr::null(), Some(each), (&raw mut found) as LPARAM) }; + found + } + + /// The windows a person would pick from: shown, titled, not minimized, not a tool window or + /// another's dialog, and not hidden by the shell (a suspended Store app is "cloaked"). + pub fn windows() -> Vec<(isize, String)> { + unsafe extern "system" fn each(hwnd: HWND, data: LPARAM) -> BOOL { + // SAFETY: `data` is the Vec below, alive for the whole enumeration. + let found = unsafe { &mut *(data as *mut Vec<(isize, String)>) }; + if found.len() >= protocol::SCREENS_LIMIT { + return 0; + } + // SAFETY: each of these only reads the window Windows just handed over. + unsafe { + if IsWindowVisible(hwnd) == 0 || IsIconic(hwnd) != 0 || !GetWindow(hwnd, GW_OWNER).is_null() || GetWindowLongW(hwnd, GWL_EXSTYLE) as u32 & WS_EX_TOOLWINDOW != 0 { + return 1; + } + let mut cloaked: u32 = 0; + if DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED as u32, (&raw mut cloaked).cast(), size_of::() as u32) == 0 && cloaked != 0 { + return 1; + } + let len = GetWindowTextLengthW(hwnd); + if len <= 0 { + return 1; + } + let mut title = vec![0u16; len as usize + 1]; + let got = GetWindowTextW(hwnd, title.as_mut_ptr(), title.len() as i32); + let title = String::from_utf16_lossy(&title[..got.max(0) as usize]); + // The desktop itself is a titled window too. + if !title.trim().is_empty() && title != "Program Manager" { + found.push((hwnd as isize, title)); + } + } + 1 + } + let mut found: Vec<(isize, String)> = Vec::new(); + // SAFETY: the callback only writes the Vec it is handed. + unsafe { EnumWindows(Some(each), (&raw mut found) as LPARAM) }; + found + } + + /// One rectangle of the desktop, in physical pixels. + pub fn capture_rect(rect: RECT) -> Result { + let (width, height) = (rect.right - rect.left, rect.bottom - rect.top); + if width <= 0 || height <= 0 { + return Err("The screen has no size.".to_owned()); + } + // SAFETY: the desktop's DC, released below; the copy lands in a bitmap of that size. + unsafe { + let dc = GetDC(std::ptr::null_mut()); + if dc.is_null() { + return Err("tinyclient can't record the screen.".to_owned()); + } + let grabbed = grab(dc, width, height, |memory| BitBlt(memory, 0, 0, width, height, dc, rect.left, rect.top, SRCCOPY) != 0); + ReleaseDC(std::ptr::null_mut(), dc); + grabbed.ok_or_else(|| "tinyclient can't record the screen.".to_owned()) + } + } + + /// One window as it draws itself, even under others. + pub fn capture_window(handle: isize) -> Result { + let hwnd = handle as HWND; + const GONE: &str = "The window you shared was closed."; + // SAFETY: the handle is checked first; a window closing meanwhile only fails the calls. + unsafe { + if IsWindow(hwnd) == 0 { + return Err(GONE.to_owned()); + } + if IsIconic(hwnd) != 0 { + return Err("The window you shared is minimized. Restore it to keep sharing.".to_owned()); + } + let mut rect: RECT = std::mem::zeroed(); + if GetWindowRect(hwnd, &mut rect) == 0 { + return Err(GONE.to_owned()); + } + let (width, height) = (rect.right - rect.left, rect.bottom - rect.top); + if width <= 0 || height <= 0 { + return Err(GONE.to_owned()); + } + let dc = GetDC(std::ptr::null_mut()); + if dc.is_null() { + return Err("tinyclient can't record the window.".to_owned()); + } + let grabbed = grab(dc, width, height, |memory| PrintWindow(hwnd, memory, PW_RENDERFULLCONTENT) != 0); + ReleaseDC(std::ptr::null_mut(), dc); + grabbed.ok_or_else(|| "tinyclient can't record the window.".to_owned()) + } + } + + /// A bitmap of `width` × `height` compatible with `dc`, drawn by `draw`, read back as BGRA. + /// + /// # Safety + /// `dc` is a live DC. + unsafe fn grab(dc: HDC, width: i32, height: i32, draw: impl FnOnce(HDC) -> bool) -> Option { + unsafe { + let memory = CreateCompatibleDC(dc); + let bitmap = CreateCompatibleBitmap(dc, width, height); + let previous = SelectObject(memory, bitmap as _); + let drawn = draw(memory); + let mut header = BITMAPINFOHEADER { + biSize: size_of::() as u32, + biWidth: width, + biHeight: -height, + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB, + biSizeImage: 0, + biXPelsPerMeter: 0, + biYPelsPerMeter: 0, + biClrUsed: 0, + biClrImportant: 0, + }; + let mut pixels = vec![0u8; (width * height * 4) as usize]; + let rows = GetDIBits(memory, bitmap, 0, height as u32, pixels.as_mut_ptr().cast(), (&mut header as *mut BITMAPINFOHEADER).cast(), DIB_RGB_COLORS); + SelectObject(memory, previous); + DeleteObject(bitmap as _); + DeleteDC(memory); + (drawn && rows != 0).then(|| Rgba { width: width as u32, height: height as u32, pixels, bgra: true }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_thumbnail_keeps_the_shape_and_turns_bgra_round() { + // 4 × 2, blue-green-red-white across the top as BGRA. + let mut pixels = Vec::new(); + for _ in 0..2 { + pixels.extend_from_slice(&[255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 255, 255, 255, 0]); + } + let grab = Rgba { width: 4, height: 2, pixels, bgra: true }; + let rgb = shrink(&grab, 2, 1); + assert_eq!(rgb, vec![0, 0, 255, 255, 0, 0], "every other pixel, as RGB"); + } + + #[test] + fn the_choice_is_held_until_changed() { + choose_screen(Some("display:two".into())); + assert_eq!(chosen().as_deref(), Some("display:two")); + choose_screen(None); + assert_eq!(chosen(), None); + } +} diff --git a/crates/mediad/src/serve.rs b/crates/mediad/src/serve.rs index 85d81d6a..c1e58f44 100644 --- a/crates/mediad/src/serve.rs +++ b/crates/mediad/src/serve.rs @@ -78,7 +78,7 @@ impl Call { // on a joined voice call is refused in words. Turning one off is never refused. // Before a join there is no engine in the call and the bookkeeping below stands, // which is what chatd's own lifecycle tests drive. - MediaRequest::ShareSend { on: true } + MediaRequest::ShareSend { on: true, .. } | MediaRequest::ShareWatch { source: Some(_) } | MediaRequest::Speaker { source: Some(_) } | MediaRequest::Camera { on: true } @@ -86,7 +86,11 @@ impl Call { { return MediaResponse::Refused { detail: NO_VIDEO.to_owned() }; } - MediaRequest::ShareSend { on } => { + MediaRequest::ShareSend { on, screen } => { + // What the share captures, chosen before the probe so the probe tries that one. + if on { + picture::choose_screen(screen); + } if self.tiny.is_some() && on { if let Err(words) = picture::probe_screen() { return MediaResponse::Refused { detail: words }; @@ -402,6 +406,14 @@ pub fn run(args: &[String]) -> u8 { return 0; } // Who macOS would name in the microphone prompt. Touches no audio and no permission. + // The share picker's list: every screen and window, with a small picture of each in the + // folder named. + Some("--screens") => { + let folder = args.get(1).map(std::path::PathBuf::from); + let screens = picture::screens(folder.as_deref()); + out::print_line(&serde_json::to_string(&screens).unwrap_or_else(|_| "[]".to_owned())); + return 0; + } Some("--responsible") => { out::print_line(&permission::responsible().unwrap_or_default()); return 0; diff --git a/crates/mediad/tests/voice.rs b/crates/mediad/tests/voice.rs index d5483772..2a9e6f36 100644 --- a/crates/mediad/tests/voice.rs +++ b/crates/mediad/tests/voice.rs @@ -1397,14 +1397,14 @@ fn video_is_refused_in_words_on_a_voice_call_and_reports_no_stream() { let mut fake = Fake::new(); let mut mediad = Mediad::start("null", None); // Before a join there is no engine in the call and the request is only noted. - assert!(matches!(mediad.ask(MediaRequest::ShareSend { on: true }), MediaResponse::Streams { video } if video.len() == 1)); + assert!(matches!(mediad.ask(MediaRequest::ShareSend { on: true, screen: None }), MediaResponse::Streams { video } if video.len() == 1)); mediad.join(&fake.endpoint(), NO_DAVE); let mut peer = fake.accept(); fake.handshake(&mut peer, &[AES], 0, 30_000); mediad.connected(); assert_eq!(mediad.ask(MediaRequest::Streams), MediaResponse::Streams { video: vec![] }, "a voice call holds no encode"); for request in [ - MediaRequest::ShareSend { on: true }, + MediaRequest::ShareSend { on: true, screen: None }, MediaRequest::ShareWatch { source: Some(THEM.into()) }, MediaRequest::Speaker { source: Some(THEM.into()) }, MediaRequest::Camera { on: true }, @@ -1414,7 +1414,7 @@ fn video_is_refused_in_words_on_a_voice_call_and_reports_no_stream() { }; assert!(detail.contains("voice only"), "{detail}"); } - assert_eq!(mediad.ask(MediaRequest::ShareSend { on: false }), MediaResponse::Streams { video: vec![] }); + assert_eq!(mediad.ask(MediaRequest::ShareSend { on: false, screen: None }), MediaResponse::Streams { video: vec![] }); assert_eq!(mediad.hang_up().0.code(), Some(0)); } diff --git a/crates/protocol/src/call.rs b/crates/protocol/src/call.rs index 7e61efec..a036e89d 100644 --- a/crates/protocol/src/call.rs +++ b/crates/protocol/src/call.rs @@ -122,6 +122,26 @@ pub struct Participant { pub sharing: bool, } +/// The most screens and windows `mediad --screens` lists. +pub const SCREENS_LIMIT: usize = 24; + +/// One screen or window a share can send, as `mediad --screens` lists it for the share picker. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScreenSource { + /// What [`MediaRequest::ShareSend`] names it by: `display:` or `window:`. + pub id: String, + /// "Screen 1", or the window's title. + pub name: String, + /// A window, not a whole screen. + #[serde(default, skip_serializing_if = "is_false")] + pub window: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub primary: bool, + /// A small picture of it, as a file, when one could be taken. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub thumbnail: String, +} + /// One microphone or speaker the system lists. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct AudioDevice { @@ -352,7 +372,13 @@ pub struct TinyPicture { pub enum MediaRequest { Streams, /// Send our screen, or stop sending it. - ShareSend { on: bool }, + ShareSend { + on: bool, + /// Which screen or window, as `mediad --screens` lists it ([`ScreenSource::id`]). + /// `None` is the primary screen, as older shells asked for. + #[serde(default, skip_serializing_if = "Option::is_none")] + screen: Option, + }, /// Watch one participant's share. Replaces any share already being watched. ShareWatch { source: Option }, /// The active speaker changed. Replaces the previous speaker's decode. @@ -722,6 +748,16 @@ mod tests { assert_eq!(serde_json::from_str::(&json).unwrap(), on); } + #[test] + fn a_share_without_a_screen_is_the_frame_an_older_build_sends() { + let older: MediaRequest = serde_json::from_str(r#"{"media":"share_send","on":true}"#).unwrap(); + assert_eq!(older, MediaRequest::ShareSend { on: true, screen: None }); + assert_eq!(serde_json::to_string(&older).unwrap(), r#"{"media":"share_send","on":true}"#); + let chosen = MediaRequest::ShareSend { on: true, screen: Some("display:\\\\.\\DISPLAY2".into()) }; + let json = serde_json::to_string(&chosen).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), chosen); + } + #[test] fn a_blocked_call_round_trips_and_is_not_transport() { let security = CallSecurity { mode: SecurityMode::Blocked, unsupported: vec!["bea".into()], ..CallSecurity::default() }; diff --git a/crates/tinyclient/assets/icons/maximize.svg b/crates/tinyclient/assets/icons/maximize.svg new file mode 100644 index 00000000..e867ee07 --- /dev/null +++ b/crates/tinyclient/assets/icons/maximize.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/tinyclient/assets/icons/minimize.svg b/crates/tinyclient/assets/icons/minimize.svg new file mode 100644 index 00000000..e5eb7174 --- /dev/null +++ b/crates/tinyclient/assets/icons/minimize.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/tinyclient/assets/icons/panel-bottom.svg b/crates/tinyclient/assets/icons/panel-bottom.svg new file mode 100644 index 00000000..66a1aff4 --- /dev/null +++ b/crates/tinyclient/assets/icons/panel-bottom.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/crates/tinyclient/src/gui/assets.rs b/crates/tinyclient/src/gui/assets.rs index 4149d8bc..8dda14d9 100644 --- a/crates/tinyclient/src/gui/assets.rs +++ b/crates/tinyclient/src/gui/assets.rs @@ -80,6 +80,9 @@ assets!( "icons/mic.svg", "icons/minus.svg", "icons/monitor-up.svg", + "icons/maximize.svg", + "icons/minimize.svg", + "icons/panel-bottom.svg", "icons/panel-left.svg", "icons/paperclip.svg", "icons/pencil.svg", diff --git a/crates/tinyclient/src/gui/call/dock.rs b/crates/tinyclient/src/gui/call/dock.rs index ea56b24d..115de50e 100644 --- a/crates/tinyclient/src/gui/call/dock.rs +++ b/crates/tinyclient/src/gui/call/dock.rs @@ -917,7 +917,7 @@ impl Shell { share .tooltip(tip(if share_on { "Stop sharing" } else { "Share your screen" }, None)) .on_click(cx.listener(move |this, _, _, cx| { - this.call_ask(Request::Media(MediaRequest::ShareSend { on: !share_on }), cx) + this.call_ask(Request::Media(MediaRequest::ShareSend { on: !share_on, screen: None }), cx) })) }) }); diff --git a/crates/tinyclient/src/gui/call/phone/controls.rs b/crates/tinyclient/src/gui/call/phone/controls.rs index bcb521c8..61682ebe 100644 --- a/crates/tinyclient/src/gui/call/phone/controls.rs +++ b/crates/tinyclient/src/gui/call/phone/controls.rs @@ -134,7 +134,7 @@ impl Shell { if !live { Look::Unavailable } else if share_on { Look::On } else { Look::Rest }, SIZE, ) - .when(live, |button| button.on_click(cx.listener(move |this, _, _, cx| this.call_ask(Request::Media(MediaRequest::ShareSend { on: !share_on }), cx)))); + .when(live, |button| button.on_click(cx.listener(move |this, _, _, cx| this.call_ask(Request::Media(MediaRequest::ShareSend { on: !share_on, screen: None }), cx)))); vec![self.call_phone_row(vec![camera, flip, share, speaker], false), self.call_phone_row(vec![mute, deafen, more, end], false)] } else { vec![self.call_phone_row(vec![mute, speaker, deafen, more, end], false)] diff --git a/crates/tinyclient/src/gui/debug_launch.rs b/crates/tinyclient/src/gui/debug_launch.rs index 127c67b5..4053a1b6 100644 --- a/crates/tinyclient/src/gui/debug_launch.rs +++ b/crates/tinyclient/src/gui/debug_launch.rs @@ -159,7 +159,7 @@ impl Shell { } if launch.share { say!("tinyclient: debug launch: sharing the screen"); - self.call_ask(Request::Media(MediaRequest::ShareSend { on: true }), cx); + self.call_ask(Request::Media(MediaRequest::ShareSend { on: true, screen: None }), cx); } } Stage::Watching(asked) => { diff --git a/crates/tinyclient/src/gui/layout.rs b/crates/tinyclient/src/gui/layout.rs index c5744c93..64780986 100644 --- a/crates/tinyclient/src/gui/layout.rs +++ b/crates/tinyclient/src/gui/layout.rs @@ -383,6 +383,8 @@ impl Shell { self.close_switcher(window, cx); } else if self.layout_state.menu.is_some() { self.layout_state.menu = None; + } else if self.tiny_unfull(window, cx) { + return true; } else if self.tiny_escape(cx) { return true; } else if self.layout_state.floating { diff --git a/crates/tinyclient/src/gui/settings/mod.rs b/crates/tinyclient/src/gui/settings/mod.rs index 7859e140..7e2583cd 100644 --- a/crates/tinyclient/src/gui/settings/mod.rs +++ b/crates/tinyclient/src/gui/settings/mod.rs @@ -1068,7 +1068,7 @@ impl SettingsWindow { } /// `TINYCLIENT_MEDIAD`, or the `mediad` beside this window, as chatd finds it. -fn mediad_path() -> std::path::PathBuf { +pub(in crate::gui) fn mediad_path() -> std::path::PathBuf { if let Some(path) = std::env::var_os("TINYCLIENT_MEDIAD") { return path.into(); } diff --git a/crates/tinyclient/src/gui/shot_demo_live.rs b/crates/tinyclient/src/gui/shot_demo_live.rs index 421e1f0d..086b4c04 100644 --- a/crates/tinyclient/src/gui/shot_demo_live.rs +++ b/crates/tinyclient/src/gui/shot_demo_live.rs @@ -216,7 +216,7 @@ pub fn answer(request: &Request) -> Option { }), Request::CallTalk { held, .. } => on_call(&|call| call.speaking = *held && !call.muted), Request::Media(MediaRequest::Camera { on }) => on_call(&|call| stream(call, Content::Camera, *on)), - Request::Media(MediaRequest::ShareSend { on }) => on_call(&|call| stream(call, Content::Share, *on)), + Request::Media(MediaRequest::ShareSend { on, .. }) => on_call(&|call| stream(call, Content::Share, *on)), Request::Media(_) => Response::Ok, _ => return None, }) diff --git a/crates/tinyclient/src/gui/tiny/channel.rs b/crates/tinyclient/src/gui/tiny/channel.rs index 601523d2..111e3331 100644 --- a/crates/tinyclient/src/gui/tiny/channel.rs +++ b/crates/tinyclient/src/gui/tiny/channel.rs @@ -1,26 +1,59 @@ //! One voice channel in the pane: its topic, how full it is, push-to-talk and bitrate in the top //! bar, then the stage, one tile per person in the call, ringed while they speak and labelled -//! with what is true of them ("Server muted"). Click a tile to focus it, drawn large; hover it -//! for its volume. Right-click a tile for the person menu. Over the stage, while my call here is -//! connecting, reconnecting or has failed, a banner says so with the time and what to do. A -//! picture being watched sits above the stage. Under the stage, Join, or while I am in it the -//! call's own controls — mute, deafen, share, camera, and Leave — with "You're muted", -//! push-to-talk, and a watch strip above them. A channel nobody is in says so, with Join and -//! which devices it will use. +//! with what is true of them ("Server muted"), and beside each one sharing their screen or camera +//! a stream tile, "Watch stream" until it is watched (`stage`). Click a person to focus them, +//! drawn large; hover them for their volume, right-click them for the person menu. Click a stream +//! to watch it filling the stage, the people in a strip under it, over it or hidden, and full +//! screen; click it again, or Escape, for the grid. My own share is a tile too, from what my call +//! sends, with Stop. Over the stage, while my call here is connecting, reconnecting or has +//! failed, a banner says so with the time and what to do. Under the stage, Join, or while I am in +//! it the call's own controls — mute, deafen, share, camera, and Leave — with "You're muted" and +//! push-to-talk above them. A channel nobody is in says so, with Join and which devices it will +//! use. +use super::stage::{self, Focus, Sending, Stream}; use super::{join_blocked, model, Float, Page}; use crate::gui::call::health::{self, Grade}; use crate::gui::components::{button_disabled, button_with_icon, glyph, tip, Avatar, ButtonKind}; use crate::gui::state::Shell; use crate::gui::theme::{self, Type}; -use gpui::{div, img, prelude::*, px, AnyElement, Context, Div, MouseButton, MouseDownEvent, ObjectFit, SharedString, Stateful}; -use protocol::{Call, CallPicture, CallState, Content, Direction, MediaRequest, PictureKind, Request, TinyRoom, TinySeat}; +use gpui::{div, img, prelude::*, px, AnyElement, Context, Div, MouseButton, MouseDownEvent, ObjectFit, RenderImage, SharedString, Stateful, Window}; +use protocol::{Call, CallState, Content, Direction, MediaRequest, Request, TinyRoom, TinySeat}; +use std::sync::Arc; const TILE_W: f32 = 196.; const TILE_H: f32 = 148.; /// A focused tile: the one person drawn large. const FOCUS_W: f32 = 420.; const FOCUS_H: f32 = 300.; +/// A tile in the strip under a focused stream. +const STRIP_W: f32 = 150.; +const STRIP_H: f32 = 96.; + +/// How large a tile is drawn. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Size { + Grid, + Big, + Strip, +} + +impl Size { + /// A person's tile: width, height and avatar. + fn person(self) -> (f32, f32, f32) { + match self { + Size::Grid => (TILE_W, TILE_H, 64.), + Size::Big => (FOCUS_W, FOCUS_H, 112.), + Size::Strip => (STRIP_W, STRIP_H, 36.), + } + } + + /// A stream's tile: as tall as a person's, 16:9. + fn stream(self) -> (f32, f32) { + let (_, h, _) = self.person(); + (h * 16. / 9., h) + } +} /// Said under the call while echo cancellation is switched off in Settings → Voice. const HEADPHONES: &str = "Echo cancellation is off: wear headphones, or others hear themselves."; @@ -111,27 +144,52 @@ fn sending(call: &Call, content: Content) -> bool { } /// A decode of this person was asked for, or their picture is on screen. -fn watching(call: &Call, user_id: &str) -> bool { +pub(super) fn watching(call: &Call, user_id: &str) -> bool { call.video.iter().any(|stream| stream.direction == Direction::Decode && stream.content == Content::Share && stream.source == user_id) || call.picture.as_ref().is_some_and(|picture| !picture.path.is_empty() && picture.user_id == user_id) } -/// Who the watch strip offers: someone else's share, else someone else's camera. A room with -/// video turned off offers neither, and our own seat is never it. -struct Shown { - user_id: String, - share: bool, +/// What my call sends, when it is up. +fn sending_now(call: Option<&Call>) -> Option { + call.filter(|call| call.state == CallState::Joined).map(|call| Sending { share: sending(call, Content::Share), camera: sending(call, Content::Camera) }) } -fn pictured(room: &TinyRoom, me: &str) -> Option { - if !room.video_enabled { - return None; - } - let others: Vec<&TinySeat> = room.participants.iter().filter(|seat| seat.user_id != me).collect(); - if let Some(seat) = others.iter().find(|seat| seat.sharing) { - return Some(Shown { user_id: seat.user_id.clone(), share: true }); - } - others.iter().find(|seat| seat.camera).map(|seat| Shown { user_id: seat.user_id.clone(), share: false }) +/// A round button over a stream: its own click, never the stream's. +fn stream_button(id: &'static str, icon: &'static str, words: &'static str) -> Stateful
{ + div() + .id(id) + .flex_none() + .size(px(32.)) + .rounded_full() + .bg(theme::overlay()) + .border_1() + .border_color(theme::overlay_edge()) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .tab_index(0) + .hover(|style| style.bg(theme::selected())) + .focus_visible(|style| style.shadow(theme::focus_ring())) + .tooltip(tip(words, None)) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(glyph(icon, 15., theme::text())) +} + +/// The red LIVE mark and whose stream it is, at a stream's foot. +fn caption(words: String, small: bool) -> Div { + div() + .absolute() + .bottom(px(if small { 6. } else { 10. })) + .left(px(if small { 6. } else { 10. })) + .max_w(px(if small { 140. } else { 360. })) + .flex() + .items_center() + .gap(px(6.)) + .child(chip("LIVE", theme::on_accent(), theme::danger_fill()).font_weight(theme::SEMIBOLD)) + .when(!small, |row| { + row.child(div().min_w_0().truncate().h(px(20.)).px(px(6.)).rounded(px(theme::R_CHIP)).bg(theme::overlay()).flex().items_center().type_micro().text_color(theme::text()).child(words)) + }) } impl Shell { @@ -178,9 +236,8 @@ impl Shell { self.tiny_stage(page, room, call.as_ref(), cx) }; let up = call.as_ref().is_some_and(|call| !matches!(call.state, CallState::Failed { .. })); - let picture = call.as_ref().and_then(|call| self.tiny_picture(page, room, call, cx)); let bar = if room.participants.is_empty() && !mine && !up { None } else { Some(self.tiny_call_bar(page, room, mine || up, call.as_ref(), cx)) }; - div().size_full().flex().flex_col().children(banner).children(picture).child(stage).children(bar).into_any_element() + div().size_full().flex().flex_col().children(banner).child(stage).children(bar).into_any_element() } /// Over the stage while my call here is not simply up: connecting or reconnecting with how @@ -290,130 +347,30 @@ impl Shell { ) } - /// The one picture being watched: 16:9 above the stage, with who it is and Stop watching. - /// My own share is never drawn here. - fn tiny_picture(&self, page: &Page, room: &TinyRoom, call: &Call, cx: &mut Context) -> Option
{ - let picture: &CallPicture = call.picture.as_ref().filter(|picture| !picture.path.is_empty() && picture.user_id != page.me)?; - let name = model::name_by_id(page.community, &picture.user_id); - let kind = match picture.kind { - PictureKind::Share => "screen", - PictureKind::Camera => "camera", - }; - let caption = format!("{name}'s {kind} · {}", room.name); - let frame = self.call_state.feed.shown().map(|frame| img(frame.clone()).size_full().object_fit(ObjectFit::Contain)); - Some( - div().flex_none().px(px(20.)).pt(px(16.)).child( - div() - .w_full() - .rounded(px(12.)) - .bg(gpui::rgb(0x000000)) - .border_1() - .border_color(theme::hairline()) - .overflow_hidden() - .flex() - .flex_col() - .child(div().w_full().aspect_ratio(16. / 9.).max_h(px(420.)).min_h(px(160.)).p(px(6.)).children(frame)) - .child( - div() - .min_h(px(32.)) - .px(px(10.)) - .py(px(6.)) - .flex() - .flex_wrap() - .items_center() - .gap(px(8.)) - .bg(theme::raised()) - .child(Avatar::named(&name, 18.).build()) - .child(div().flex_1().min_w(px(120.)).whitespace_normal().line_clamp(2).type_small().text_color(theme::text()).child(caption)) - .child( - div() - .id("tiny-picture-stop") - .flex_none() - .h(px(24.)) - .px(px(8.)) - .rounded(px(6.)) - .flex() - .items_center() - .gap(px(4.)) - .cursor_pointer() - .tab_index(0) - .hover(|style| style.bg(theme::hover())) - .focus_visible(|style| style.shadow(theme::focus_ring())) - .tooltip(tip("Stop watching", None)) - .on_click(cx.listener(|this, _, _, cx| this.call_ask(Request::Media(MediaRequest::ShareWatch { source: None }), cx))) - .child(glyph("icons/x.svg", 12., theme::text_soft())) - .child(div().type_micro().text_color(theme::text_soft()).child("Stop watching")), - ), - ), - ), - ) - } - - /// The strip over the controls: my share, with Stop, or someone else's picture, with Watch. - fn tiny_watch(&self, page: &Page, room: &TinyRoom, call: &Call, cx: &mut Context) -> Option
{ - let (words, action, ours, ask) = if sending(call, Content::Share) { - ("You're sharing".to_owned(), "Stop", true, Request::Media(MediaRequest::ShareSend { on: false })) - } else { - let shown = pictured(room, page.me)?; - let name = model::name_by_id(page.community, &shown.user_id); - let words = if shown.share { format!("{name} is sharing") } else { format!("{name}'s camera is on") }; - let on = watching(call, &shown.user_id); - let source = (!on).then_some(shown.user_id); - (words, if on { "Stop watching" } else { "Watch" }, false, Request::Media(MediaRequest::ShareWatch { source })) - }; - Some( - div() - .min_h(px(32.)) - .max_w_full() - .px(px(12.)) - .py(px(4.)) - .rounded(px(16.)) - .bg(if ours { theme::accent_soft() } else { theme::overlay() }) - .border_1() - .border_color(if ours { theme::accent() } else { theme::overlay_edge() }) - .flex() - .flex_wrap() - .justify_center() - .items_center() - .gap(px(10.)) - .child(div().whitespace_normal().line_clamp(2).type_small().text_color(theme::text()).child(words)) - .child( - div() - .id("tiny-watch") - .h(px(22.)) - .px(px(10.)) - .rounded_full() - .bg(theme::selected()) - .flex() - .items_center() - .cursor_pointer() - .tab_index(0) - .hover(|style| style.bg(theme::pressed())) - .focus_visible(|style| style.shadow(theme::focus_ring())) - .on_click(cx.listener(move |this, _, _, cx| this.call_ask(ask.clone(), cx))) - .child(div().type_micro().font_weight(theme::SEMIBOLD).text_color(theme::text()).child(action)), - ), - ) - } - - /// The tiles: wrapping and centred, or one person large over a row of the rest. + /// The tiles: wrapping and centred, each stream beside its owner; or one person large over + /// a row of the rest; or one stream filling the stage. fn tiny_stage(&self, page: &Page, room: &TinyRoom, call: Option<&Call>, cx: &mut Context) -> AnyElement { - let focus = self.tiny_state.focus.as_deref().filter(|id| room.participants.iter().any(|seat| seat.user_id == *id)); - let base = div().id("tiny-stage").flex_1().min_h_0().overflow_y_scroll().p(px(24.)); + let streams = stage::streams(room, page.me, sending_now(call)); + let focus = stage::still(self.tiny_state.focus.as_ref(), room, &streams); + let base = div().id("tiny-stage").flex_1().min_h_0(); match focus { None => base + .overflow_y_scroll() + .p(px(24.)) .flex() .flex_wrap() .content_center() .items_center() .justify_center() .gap(px(12.)) - .children(room.participants.iter().map(|seat| self.tiny_tile(page, seat, call, false, cx))) + .children(self.tiny_tiles(page, room, &streams, call, None, Size::Grid, cx)) .into_any_element(), - Some(focus) => { - let big = room.participants.iter().find(|seat| seat.user_id == focus).map(|seat| self.tiny_tile(page, seat, call, true, cx)); - let rest = room.participants.iter().filter(|seat| seat.user_id != focus).map(|seat| self.tiny_tile(page, seat, call, false, cx)); - base.flex() + Some(Focus::Person(focus)) => { + let big = room.participants.iter().find(|seat| seat.user_id == *focus).map(|seat| self.tiny_tile(page, seat, call, Size::Big, cx)); + let rest = self.tiny_tiles(page, room, &streams, call, Some(&Focus::Person(focus.clone())), Size::Grid, cx); + base.overflow_y_scroll() + .p(px(24.)) + .flex() .flex_col() .items_center() .justify_center() @@ -422,10 +379,253 @@ impl Shell { .child(div().flex().flex_wrap().justify_center().gap(px(12.)).children(rest)) .into_any_element() } + Some(Focus::Stream(user_id)) => { + let Some(stream) = streams.iter().find(|stream| stream.user_id == *user_id) else { + return base.into_any_element(); + }; + let strip = self.tiny_state.strip; + let view = self.tiny_stream_view(page, stream, call, false, cx); + let row = div() + .id("tiny-strip") + .max_w_full() + .overflow_x_scroll() + .flex() + .gap(px(8.)) + .children(self.tiny_tiles(page, room, &streams, call, Some(&Focus::Stream(user_id.clone())), Size::Strip, cx)); + let base = base.p(px(16.)).flex().flex_col().gap(px(12.)); + if strip.hidden { + base.child(view).into_any_element() + } else if strip.over { + base.child( + div() + .relative() + .flex_1() + .min_h_0() + .flex() + .child(view) + .child(div().absolute().bottom(px(12.)).left_0().right_0().px(px(12.)).flex().justify_center().child(row.p(px(6.)).rounded(px(16.)).bg(theme::overlay()))), + ) + .into_any_element() + } else { + base.child(view).child(div().flex_none().flex().justify_center().child(row)).into_any_element() + } + } + } + } + + /// Everyone's tile, each followed by their stream's, leaving out the one focused. + #[allow(clippy::too_many_arguments)] + fn tiny_tiles(&self, page: &Page, room: &TinyRoom, streams: &[Stream], call: Option<&Call>, except: Option<&Focus>, size: Size, cx: &mut Context) -> Vec { + let shown = |focus: Focus| except != Some(&focus); + let mut tiles = Vec::new(); + for seat in &room.participants { + if shown(Focus::Person(seat.user_id.clone())) { + tiles.push(self.tiny_tile(page, seat, call, size, cx)); + } + if let Some(stream) = streams.iter().find(|stream| stream.user_id == seat.user_id).filter(|stream| shown(Focus::Stream(stream.user_id.clone()))) { + tiles.push(self.tiny_stream_tile(page, stream, call, size, cx)); + } + } + // My stream before the room lists my seat. + for stream in streams.iter().filter(|stream| !room.participants.iter().any(|seat| seat.user_id == stream.user_id)) { + if shown(Focus::Stream(stream.user_id.clone())) { + tiles.push(self.tiny_stream_tile(page, stream, call, size, cx)); + } + } + tiles + } + + /// The frame on screen, when it is this person's. + fn tiny_frame(&self, call: Option<&Call>, user_id: &str) -> Option> { + call?.picture.as_ref().filter(|picture| !picture.path.is_empty() && picture.user_id == user_id)?; + self.call_state.feed.shown().cloned() + } + + /// What a stream shows: its picture; mine, which is never sent back to me, said in words with + /// Stop; anyone else's, "Watch stream" until its first picture arrives. + fn tiny_stream_body(&self, page: &Page, stream: &Stream, call: Option<&Call>, size: Size, cx: &mut Context) -> AnyElement { + if let Some(frame) = self.tiny_frame(call, &stream.user_id) { + return img(frame).size_full().object_fit(ObjectFit::Contain).into_any_element(); + } + let small = size == Size::Strip; + let large = size == Size::Big; + let body = div().size_full().relative().flex().flex_col().items_center().justify_center().gap(px(if small { 4. } else { 10. })).p(px(8.)); + if stream.mine { + let thumbnail = self.tiny_state.shared.as_ref().filter(|_| stream.share).map(|screen| screen.thumbnail.clone()).filter(|path| !path.is_empty()); + let words = if stream.share { "You're sharing your screen" } else { "Your camera is on" }; + let stop = if stream.share { + button_with_icon("tiny-stream-stop", ButtonKind::Danger, "icons/monitor-off.svg", "Stop sharing").on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.tiny_stop_share(cx); + })) + } else { + button_with_icon("tiny-stream-camera-off", ButtonKind::Danger, "icons/video-off.svg", "Turn camera off").on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.call_ask(Request::Media(MediaRequest::Camera { on: false }), cx); + })) + }; + let line = match &self.tiny_state.shared { + Some(screen) if stream.share => format!("Everyone in the call can watch {}. It isn't played back to you, so you don't see a mirror of your own screen.", screen.name), + _ => "Everyone in the call can watch it.".to_owned(), + }; + return body + .children(thumbnail.map(|path| img(std::path::PathBuf::from(path)).absolute().top_0().left_0().size_full().object_fit(ObjectFit::Cover).opacity(0.3))) + // A grid tile has room for the words and Stop, not the icon as well. + .when(small || large, |body| body.child(glyph(if stream.share { "icons/monitor-up.svg" } else { "icons/video.svg" }, if small { 18. } else { 36. }, theme::accent_text()))) + .when(!small, |body| body.child(div().text_center().whitespace_normal().type_ui().font_weight(theme::SEMIBOLD).text_color(theme::text()).child(words))) + .when(large, |body| body.child(div().max_w(px(420.)).text_center().whitespace_normal().type_small().text_color(theme::text_soft()).child(line))) + .when(!small, |body| body.child(stop.h(px(28.)).on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()))) + .into_any_element(); + } + let name = model::name_by_id(page.community, &stream.user_id); + let asked = call.is_some_and(|call| watching(call, &stream.user_id)); + let pill = div() + .h(px(if small { 22. } else { 30. })) + .px(px(if small { 8. } else { 14. })) + .rounded_full() + .bg(if asked { theme::overlay() } else { theme::accent() }) + .flex() + .items_center() + .gap(px(6.)) + .when(!asked && !small, |pill| pill.child(glyph("icons/play.svg", 12., theme::on_accent()))) + .child( + div() + .map(|words| if small { words.type_micro() } else { words.type_small() }) + .font_weight(theme::SEMIBOLD) + .text_color(if asked { theme::text_soft() } else { theme::on_accent() }) + .child(if asked { "Connecting…" } else { "Watch stream" }), + ); + body.when(!small, |body| body.child(Avatar::new(&page.key(&stream.user_id), &name, if large { 72. } else { 40. }).image_at(page.picture(&stream.user_id)).build())) + .child(pill) + .into_any_element() + } + + /// A stream in the grid or the strip: click it to watch it filling the stage. + fn tiny_stream_tile(&self, page: &Page, stream: &Stream, call: Option<&Call>, size: Size, cx: &mut Context) -> AnyElement { + let (w, h) = size.stream(); + let small = size == Size::Strip; + let name = model::name_by_id(page.community, &stream.user_id); + let what = if stream.share { "screen" } else { "camera" }; + let live = self.tiny_frame(call, &stream.user_id).is_some(); + let user_id = stream.user_id.clone(); + let mine = stream.mine; + div() + .id(SharedString::from(format!("tiny-stream-{}", stream.user_id))) + .relative() + .flex_none() + .w(px(w)) + .h(px(h)) + .rounded(px(theme::R_CARD + 4.)) + .bg(gpui::rgb(0x0b0b0d)) + .border_2() + .border_color(if live { theme::border_strong() } else { theme::hairline() }) + .overflow_hidden() + .cursor_pointer() + .tab_index(0) + .focus_visible(|style| style.shadow(theme::focus_ring())) + .tooltip(tip(if mine || live { "Click to focus".to_owned() } else { format!("Watch {name}'s {what}") }, None)) + .on_click(cx.listener(move |this, _, window, cx| this.tiny_open_stream(&user_id, mine, window, cx))) + .child(self.tiny_stream_body(page, stream, call, size, cx)) + .child(caption(if mine { format!("Your {what}") } else { format!("{name}'s {what}") }, small)) + .into_any_element() + } + + /// The focused stream, filling the stage (or, `full`, the window): its picture, whose it is, + /// and the buttons for the people, where they go, full screen, and stopping. A click on it + /// is the grid again. + fn tiny_stream_view(&self, page: &Page, stream: &Stream, call: Option<&Call>, full: bool, cx: &mut Context) -> Stateful
{ + let strip = self.tiny_state.strip; + let name = model::name_by_id(page.community, &stream.user_id); + let what = if stream.share { "screen" } else { "camera" }; + let user_id = stream.user_id.clone(); + let mine = stream.mine; + let mut tools = div().absolute().top(px(10.)).right(px(10.)).flex().gap(px(6.)); + if full { + tools = tools.child(stream_button("tiny-stream-unfull", "icons/minimize.svg", "Exit full screen (Esc)").on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.tiny_unfull(window, cx); + }))); + } else { + tools = tools + .child(stream_button("tiny-stream-people", "icons/users.svg", if strip.hidden { "Show participants" } else { "Hide participants" }).on_click(cx.listener( + move |this, _, window, cx| { + cx.stop_propagation(); + this.tiny_strip(stage::Strip { hidden: !strip.hidden, ..strip }, window, cx); + }, + ))) + .when(!strip.hidden, |tools| { + tools.child( + stream_button("tiny-stream-over", "icons/panel-bottom.svg", if strip.over { "Participants under the stream" } else { "Participants over the stream" }) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.tiny_strip(stage::Strip { over: !strip.over, ..strip }, window, cx); + })), + ) + }) + .child(stream_button("tiny-stream-full", "icons/maximize.svg", "Full screen").on_click(cx.listener(|this, _, window: &mut Window, cx| { + cx.stop_propagation(); + this.tiny_full(window, cx); + }))); } + if !mine { + tools = tools.child(stream_button("tiny-stream-unwatch", "icons/x.svg", "Stop watching").on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.tiny_unfull(window, cx); + this.tiny_stop_watching(window, cx); + }))); + } + div() + .id(if full { "tiny-stream-whole" } else { "tiny-stream-focused" }) + .relative() + .flex_1() + .min_h_0() + .w_full() + .when(full, |view| view.size_full()) + .when(!full, |view| view.rounded(px(12.)).border_1().border_color(theme::hairline())) + .bg(gpui::rgb(0x000000)) + .overflow_hidden() + .when(!full, |view| { + view.cursor_pointer() + .tooltip(tip("Click to see everyone again (Esc)", None)) + .on_click(cx.listener(move |this, _, window, cx| this.tiny_open_stream(&user_id, mine, window, cx))) + }) + .child(self.tiny_stream_body(page, stream, call, Size::Big, cx)) + .child(caption(if mine { format!("Your {what}") } else { format!("{name}'s {what}") }, false)) + .child(tools) } - fn tiny_tile(&self, page: &Page, seat: &TinySeat, call: Option<&Call>, big: bool, cx: &mut Context) -> AnyElement { + /// Over the whole window while the focused stream has it. + pub(super) fn tiny_full_stage(&self, page: &Page, cx: &mut Context) -> Option { + self.tiny_state.full?; + let room = page.community.rooms.iter().find(|room| Some(&room.id) == self.tiny_state.channel.as_ref())?; + let call = self.tiny_call_here(room); + let streams = stage::streams(room, page.me, sending_now(call.as_ref())); + let stream = match stage::still(self.tiny_state.focus.as_ref(), room, &streams) { + Some(Focus::Stream(user_id)) => streams.iter().find(|stream| stream.user_id == *user_id), + _ => None, + }; + let inside: AnyElement = match stream { + Some(stream) => self.tiny_stream_view(page, stream, call.as_ref(), true, cx).into_any_element(), + // The stream ended while it had the window. + None => div() + .size_full() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(12.)) + .child(div().type_title().text_color(theme::text()).child("The stream ended")) + .child(button_with_icon("tiny-stream-ended", ButtonKind::Secondary, "icons/minimize.svg", "Exit full screen").h(px(32.)).on_click(cx.listener(|this, _, window, cx| { + this.tiny_unfull(window, cx); + }))) + .into_any_element(), + }; + Some(div().id("tiny-full").absolute().top_0().left_0().size_full().bg(gpui::rgb(0x000000)).occlude().flex().child(inside).into_any_element()) + } + + fn tiny_tile(&self, page: &Page, seat: &TinySeat, call: Option<&Call>, size: Size, cx: &mut Context) -> AnyElement { + let big = size == Size::Big; + let small = size == Size::Strip; let me = seat.user_id == page.me; let seat = as_heard(seat, me, call); let seat = &seat; @@ -438,14 +638,14 @@ impl Shell { if local_mute && !me { glyphs.push(("icons/volume-x.svg", theme::warning(), "Muted for you")); } - let focused = self.tiny_state.focus.as_deref() == Some(seat.user_id.as_str()); + let focused = self.tiny_state.focus.as_ref() == Some(&Focus::Person(seat.user_id.clone())); let group: SharedString = format!("tiny-tile-{}", seat.user_id).into(); - let (w, h, avatar) = if big { (FOCUS_W, FOCUS_H, 112.) } else { (TILE_W, TILE_H, 64.) }; + let (w, h, avatar) = size.person(); let user_id = seat.user_id.clone(); let focus_id = seat.user_id.clone(); let menu_id = seat.user_id.clone(); // My own link, in my own tile: what everyone else hears me through. - let link = (me && call.is_some_and(|call| call.state == CallState::Joined)).then(|| self.tiny_health()).flatten().map(|health| { + let link = (me && !small && call.is_some_and(|call| call.state == CallState::Joined)).then(|| self.tiny_health()).flatten().map(|health| { let grade = health.grade(); let words = match health.rtt_ms { Some(ms) if grade != Grade::Unknown => format!("{} · {ms} ms", grade.words()), @@ -537,6 +737,7 @@ impl Shell { .id(SharedString::from(format!("tiny-tile-{}", seat.user_id))) .group(group) .relative() + .flex_none() .w(px(w)) .h(px(h)) .rounded(px(theme::R_CARD + 4.)) @@ -554,12 +755,12 @@ impl Shell { .flex_col() .items_center() .justify_center() - .gap(px(8.)) + .gap(px(if small { 4. } else { 8. })) .cursor_pointer() .tab_index(0) .focus_visible(|style| style.shadow(theme::focus_ring())) .tooltip(tip(if focused { "Click to stop focusing" } else { "Click to focus. Right-click for more" }, None)) - .on_click(cx.listener(move |this, _, _, cx| this.tiny_toggle_focus(&focus_id, cx))) + .on_click(cx.listener(move |this, _, window, cx| this.tiny_toggle_focus(&focus_id, window, cx))) .on_mouse_down( MouseButton::Right, cx.listener(move |this, event: &MouseDownEvent, _, cx| this.tiny_open_float(Float::Person { user_id: user_id.clone(), at: event.position }, cx)), @@ -571,19 +772,20 @@ impl Shell { .flex() .items_center() .gap(px(6.)) - .child(div().min_w_0().truncate().map(|name| if big { name.type_title() } else { name.type_ui() }).font_weight(theme::SEMIBOLD).text_color(color).child(name)) - .when(me, |row| row.child(chip("You", theme::text_soft(), theme::hover()))), + .child(div().min_w_0().truncate().map(|name| if big { name.type_title() } else if small { name.type_small() } else { name.type_ui() }).font_weight(theme::SEMIBOLD).text_color(color).child(name)) + .when(me && !small, |row| row.child(chip("You", theme::text_soft(), theme::hover()))), ) - .child(state_row) + .when(!small, |tile| tile.child(state_row)) .children(link) .child(div().absolute().top(px(8.)).right(px(8.)).flex().gap(px(4.)).children(glyphs.into_iter().map(|(icon, color, _)| glyph(icon, 14., color)))) - .child(actions) + .when(!small, |tile| tile.child(actions)) .into_any_element() } /// A click on a tile: that person large, or everyone alike again. - fn tiny_toggle_focus(&mut self, user_id: &str, cx: &mut Context) { - self.tiny_state.focus = if self.tiny_state.focus.as_deref() == Some(user_id) { None } else { Some(user_id.to_owned()) }; + fn tiny_toggle_focus(&mut self, user_id: &str, window: &mut Window, cx: &mut Context) { + self.tiny_keep_keys(window, cx); + self.tiny_state.focus = stage::toggle(self.tiny_state.focus.as_ref(), Focus::Person(user_id.to_owned())); cx.notify(); } @@ -798,14 +1000,13 @@ impl Shell { let share = pictures.then(|| { bar_button("tiny-bar-share", "icons/monitor-up.svg", if share_on { Look::On } else { Look::Rest }) .tooltip(tip(if share_on { "Stop sharing" } else { "Share your screen" }, None)) - .on_click(cx.listener(move |this, _, _, cx| this.call_ask(Request::Media(MediaRequest::ShareSend { on: !share_on }), cx))) + .on_click(cx.listener(move |this, _, window, cx| if share_on { this.tiny_stop_share(cx) } else { this.tiny_share(window, cx) })) }); let camera = pictures.then(|| { bar_button("tiny-bar-camera", "icons/video.svg", if camera_on { Look::On } else { Look::Rest }) .tooltip(tip(if camera_on { "Turn camera off" } else { "Turn camera on" }, None)) .on_click(cx.listener(move |this, _, _, cx| this.call_ask(Request::Media(MediaRequest::Camera { on: !camera_on }), cx))) }); - let watch = call.filter(|_| pictures).and_then(|call| self.tiny_watch(page, room, call, cx)); let refused = self.call_refused().map(str::to_owned).or_else(|| call.and_then(|call| call.device_notice.clone())); let health = call.filter(|call| call.state == CallState::Joined).and_then(|_| self.tiny_health()).map(|health| { let grade = health.grade(); @@ -831,7 +1032,6 @@ impl Shell { }); let no_sound = !page.audio && !page.community.media.is_empty(); bar.children(self.tiny_call_pills(room, call, held, cx)) - .children(watch) .child( div() .max_w_full() @@ -915,29 +1115,6 @@ mod tests { assert_eq!(as_heard(&seat("robin"), true, Some(&connecting)), seat("robin"), "only a call that is up speaks for me"); } - #[test] - fn a_share_is_offered_ahead_of_a_camera_and_never_our_own() { - let room = TinyRoom { - participants: vec![ - TinySeat { user_id: "me".into(), sharing: true, camera: true, ..TinySeat::default() }, - TinySeat { user_id: "ada".into(), camera: true, ..TinySeat::default() }, - TinySeat { user_id: "bev".into(), sharing: true, ..TinySeat::default() }, - ], - ..TinyRoom::new("lounge", "Lounge") - }; - let shown = pictured(&room, "me").expect("bev's share"); - assert_eq!(shown.user_id, "bev"); - assert!(shown.share); - let cameras = TinyRoom { - participants: vec![TinySeat { user_id: "ada".into(), camera: true, ..TinySeat::default() }], - ..TinyRoom::new("lounge", "Lounge") - }; - let shown = pictured(&cameras, "me").expect("ada's camera"); - assert_eq!((shown.user_id.as_str(), shown.share), ("ada", false)); - let quiet = TinyRoom { video_enabled: false, ..cameras }; - assert!(pictured(&quiet, "me").is_none(), "a room with video off offers nothing"); - } - #[test] fn sending_is_our_encode_and_watching_is_that_person() { let mut call = joined(false, false, false); diff --git a/crates/tinyclient/src/gui/tiny/layer.rs b/crates/tinyclient/src/gui/tiny/layer.rs index 35efed89..f7a42828 100644 --- a/crates/tinyclient/src/gui/tiny/layer.rs +++ b/crates/tinyclient/src/gui/tiny/layer.rs @@ -1,7 +1,7 @@ //! Everything the server page floats over the window, drawn from the window's root so it sits -//! above every pane: Server Settings, a menu with its submenu, the hover card, the connection -//! numbers, and a modal. A menu has a catcher behind it that closes it on a click anywhere else, -//! and that also carries a volume drag that wanders off the slider. +//! above every pane: a stream in full screen, Server Settings, a menu with its submenu, the hover +//! card, the connection numbers, and a modal. A menu has a catcher behind it that closes it on a +//! click anywhere else, and that also carries a volume drag that wanders off the slider. use super::members::MEMBERS_WIDTH; use super::panel::PANEL; @@ -15,11 +15,13 @@ use gpui::{div, prelude::*, px, AnyElement, Context, MouseButton, MouseMoveEvent impl Shell { pub(in crate::gui) fn tiny_layer(&self, window: &mut Window, cx: &mut Context) -> Option { let state = &self.tiny_state; - if state.float.is_none() && state.modal.is_none() && state.settings.is_none() && state.card.is_none() && !state.setup.prompt { + if state.float.is_none() && state.modal.is_none() && state.settings.is_none() && state.card.is_none() && !state.setup.prompt && state.full.is_none() { return None; } let page = self.tiny_page_facts()?; let mut layer = div().absolute().top_0().left_0().size_full(); + // A stream with the whole window, under any menu or modal. + layer = layer.children(self.tiny_full_stage(&page, cx)); if state.settings.is_some() { layer = layer.child(self.tiny_settings_overlay(&page, window, cx)); } diff --git a/crates/tinyclient/src/gui/tiny/mod.rs b/crates/tinyclient/src/gui/tiny/mod.rs index f9a397d6..f59dc396 100644 --- a/crates/tinyclient/src/gui/tiny/mod.rs +++ b/crates/tinyclient/src/gui/tiny/mod.rs @@ -31,6 +31,8 @@ mod pane; mod panel; pub(super) mod settings; mod setup; +mod share; +mod stage; use super::input::{InputEvent, InputKey, TextInput}; use super::sidebar::Mode; @@ -99,6 +101,8 @@ pub(super) enum Modal { /// Ownership to this member, the server's name typed first. Transfer(String), SignOutEverywhere, + /// What to share in a call: a screen or a window. + Share, } /// The invite being made. @@ -163,8 +167,21 @@ pub struct State { /// The channel on show. None is the server's home. channel: Option, - /// The person drawn large on the stage, pinned by a click on their tile. - focus: Option, + /// The person drawn large, or the stream filling the stage, pinned by a click on its tile. + focus: Option, + /// Where the people go while a stream fills the stage. + strip: stage::Strip, + /// The focused stream has the whole window. True when the window was made full screen for + /// it, and so goes back when it ends. + full: Option, + /// The share picker's screens and windows, `None` while `mediad --screens` looks. + screens: Option>, + /// The picker shows windows, not screens. + screen_windows: bool, + /// The one picked in the picker, by id. + screen_pick: Option, + /// What my share sends, as the picker listed it, for my own stream's tile. + shared: Option, /// Categories folded, by key ([`model::LOOSE`] for the rooms in none). folded: HashSet, members_open: bool, @@ -259,6 +276,12 @@ impl State { call_error: None, channel: None, focus: None, + strip: stage::Strip::default(), + full: None, + screens: None, + screen_windows: false, + screen_pick: None, + shared: None, folded: HashSet::new(), members_open: true, float: None, @@ -1011,7 +1034,7 @@ impl Shell { /// The member hover card is not a page, so back passes it by. False when none was up. pub(in crate::gui) fn tiny_back(&mut self, cx: &mut Context) -> bool { let state = &self.tiny_state; - if state.sub.is_none() && state.float.is_none() && state.modal.is_none() && state.settings.is_none() { + if state.sub.is_none() && state.float.is_none() && state.modal.is_none() && state.settings.is_none() && state.focus.is_none() { return false; } self.tiny_escape(cx) @@ -1032,6 +1055,9 @@ impl Shell { self.tiny_close_settings(cx); } else if self.tiny_state.card.is_some() { self.tiny_state.card = None; + } else if self.tiny_state.focus.is_some() && self.tiny_state.channel.is_some() && matches!(self.sidebar_state.mode, Mode::Network(Network::Tiny)) { + // A focused tile is the grid again. + self.tiny_state.focus = None; } else { return false; } @@ -1429,7 +1455,7 @@ impl Shell { } if let Some(ix) = flag("--tiny-focus").and_then(|n| n.parse::().ok()) { let room = self.tiny_state.channel.as_ref().and_then(|id| community.rooms.iter().find(|room| room.id == *id)); - self.tiny_state.focus = room.and_then(|room| room.participants.get(ix)).map(|seat| seat.user_id.clone()); + self.tiny_state.focus = room.and_then(|room| room.participants.get(ix)).map(|seat| stage::Focus::Person(seat.user_id.clone())); } match flag("--tiny-menu").as_deref() { Some("header") => self.tiny_open_float(Float::Header, cx), diff --git a/crates/tinyclient/src/gui/tiny/modals.rs b/crates/tinyclient/src/gui/tiny/modals.rs index 10ff63b8..3f98330c 100644 --- a/crates/tinyclient/src/gui/tiny/modals.rs +++ b/crates/tinyclient/src/gui/tiny/modals.rs @@ -70,6 +70,7 @@ impl Shell { pub(super) fn tiny_modal(&self, page: &Page, modal_on_show: &Modal, window: &Window, cx: &mut Context) -> Div { match modal_on_show { + Modal::Share => self.tiny_share_modal(cx), Modal::Invite => self.invite_modal(page, window, cx), Modal::Code { paste, origin, terms, role } => self.code_modal(page, paste, origin, terms, role, cx), Modal::Kick(user_id) => { diff --git a/crates/tinyclient/src/gui/tiny/share.rs b/crates/tinyclient/src/gui/tiny/share.rs new file mode 100644 index 00000000..e863753e --- /dev/null +++ b/crates/tinyclient/src/gui/tiny/share.rs @@ -0,0 +1,208 @@ +//! Sharing and watching in a Tiny call. Share opens a picker of every screen and window, each with +//! a small picture of it (`mediad --screens`, read off the main thread), and shares the one picked; +//! a phone's share has the system's own picker, so it shares at once. A click on someone's stream +//! watches it and fills the stage with it, and the stage can have the whole window, until Escape. + +use super::channel::watching; +use super::stage::{self, Focus}; +use super::Modal; +use crate::gui::components::{button, button_disabled, glyph, modal, segment, segmented, ButtonKind}; +use crate::gui::state::Shell; +use crate::gui::theme::{self, Type}; +use gpui::{div, img, prelude::*, px, Context, Div, ObjectFit, SharedString, Window}; +use protocol::{MediaRequest, Request, ScreenSource, SCREENS_LIMIT}; + +/// A picture in the picker, 16:9. +const THUMB_W: f32 = 184.; +const THUMB_H: f32 = 104.; + +impl Shell { + /// Share pressed while not sharing: the picker. + pub(super) fn tiny_share(&mut self, window: &mut Window, cx: &mut Context) { + if crate::gui::phone::active() { + self.call_ask(Request::Media(MediaRequest::ShareSend { on: true, screen: None }), cx); + return; + } + self.tiny_state.screens = None; + self.tiny_state.screen_pick = None; + self.tiny_state.screen_windows = false; + self.tiny_open_modal(Modal::Share, window, cx); + self.off_thread(cx, read_screens, |this, screens, cx| { + this.tiny_state.screen_pick = screens.iter().find(|screen| screen.primary).or(screens.first()).map(|screen| screen.id.clone()); + this.tiny_state.screens = Some(screens); + cx.notify(); + }); + } + + /// Shares what the picker has picked; the primary screen when it listed nothing. + fn tiny_share_picked(&mut self, cx: &mut Context) { + let picked = self.tiny_state.screen_pick.as_ref().and_then(|id| self.tiny_state.screens.iter().flatten().find(|screen| screen.id == *id)).cloned(); + let screen = picked.as_ref().map(|screen| screen.id.clone()); + self.tiny_state.shared = picked; + self.close_modal(cx); + self.call_ask(Request::Media(MediaRequest::ShareSend { on: true, screen }), cx); + } + + pub(super) fn tiny_stop_share(&mut self, cx: &mut Context) { + self.tiny_state.shared = None; + self.call_ask(Request::Media(MediaRequest::ShareSend { on: false, screen: None }), cx); + } + + /// A click on a stream: watched, and filling the stage; when it already fills it, the grid. + pub(super) fn tiny_open_stream(&mut self, user_id: &str, mine: bool, window: &mut Window, cx: &mut Context) { + self.tiny_keep_keys(window, cx); + let focus = stage::toggle(self.tiny_state.focus.as_ref(), Focus::Stream(user_id.to_owned())); + if focus.is_some() && !mine && !self.tiny_call().is_some_and(|call| watching(&call, user_id)) { + self.call_ask(Request::Media(MediaRequest::ShareWatch { source: Some(user_id.to_owned()) }), cx); + } + self.tiny_state.focus = focus; + cx.notify(); + } + + pub(super) fn tiny_stop_watching(&mut self, window: &mut Window, cx: &mut Context) { + self.tiny_keep_keys(window, cx); + if matches!(self.tiny_state.focus, Some(Focus::Stream(_))) { + self.tiny_state.focus = None; + } + self.call_ask(Request::Media(MediaRequest::ShareWatch { source: None }), cx); + } + + /// The people under the focused stream, over it, or gone. + pub(super) fn tiny_strip(&mut self, strip: stage::Strip, window: &mut Window, cx: &mut Context) { + self.tiny_keep_keys(window, cx); + self.tiny_state.strip = strip; + cx.notify(); + } + + /// The focused stream has the whole window, which goes full screen if it was not. + pub(super) fn tiny_full(&mut self, window: &mut Window, cx: &mut Context) { + if self.tiny_state.full.is_some() { + return; + } + self.tiny_keep_keys(window, cx); + let took = !window.is_fullscreen(); + if took { + window.toggle_fullscreen(); + } + self.tiny_state.full = Some(took); + cx.notify(); + } + + /// Out of the stream's full screen, and the window back as it was. False when it was not. + pub(in crate::gui) fn tiny_unfull(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(took) = self.tiny_state.full.take() else { + return false; + }; + self.tiny_keep_keys(window, cx); + if took && window.is_fullscreen() { + window.toggle_fullscreen(); + } + cx.notify(); + true + } + + /// The stage's keys back on the window's root. A click that redraws the stage takes away the + /// tile or button it focused, and Escape then finds no binding. + pub(super) fn tiny_keep_keys(&self, window: &mut Window, cx: &mut Context) { + window.focus(&self.focus, cx); + } + + /// The picker: screens or windows, a picture and a name each, Cancel and Share. + pub(super) fn tiny_share_modal(&self, cx: &mut Context) -> Div { + let state = &self.tiny_state; + let has_windows = state.screens.iter().flatten().any(|screen| screen.window); + let windows = state.screen_windows && has_windows; + let tabs = has_windows.then(|| { + segmented() + .mt(px(14.)) + .child(segment("tiny-share-screens", "Screens", !windows).on_click(cx.listener(|this, _, _, cx| { + this.tiny_state.screen_windows = false; + cx.notify(); + }))) + .child(segment("tiny-share-windows", "Windows", windows).on_click(cx.listener(|this, _, _, cx| { + this.tiny_state.screen_windows = true; + cx.notify(); + }))) + }); + let body = match &state.screens { + None => div().h(px(THUMB_H + 40.)).flex().items_center().justify_center().type_ui().text_color(theme::text_soft()).child("Looking for screens and windows…").into_any_element(), + Some(screens) if screens.is_empty() => { + div().mt(px(12.)).whitespace_normal().type_ui().text_color(theme::text_soft()).child("Your whole screen is shared. Everyone in the call can watch it.").into_any_element() + } + Some(screens) => div() + .id("tiny-share-list") + .mt(px(12.)) + .max_h(px(2. * (THUMB_H + 48.) + 12.)) + .overflow_y_scroll() + .flex() + .flex_wrap() + .gap(px(12.)) + .children(screens.iter().filter(|screen| screen.window == windows).map(|screen| self.tiny_share_card(screen, cx))) + .into_any_element(), + }; + let ready = state.screens.is_some(); + let go = if ready && !self.tiny_state.busy { + button("tiny-share-go", ButtonKind::Primary, "Share").h(px(32.)).on_click(cx.listener(|this, _, _, cx| this.tiny_share_picked(cx))).into_any_element() + } else { + button_disabled("Share").h(px(32.)).into_any_element() + }; + modal("Share your screen") + // Three cards a row, with room for the list's scroll bar. + .w(px(3. * (THUMB_W + 16.) + 2. * 12. + 40. + 16.)) + .child(div().mt(px(6.)).whitespace_normal().type_ui().text_color(theme::text_soft()).child("Pick what everyone in the call can watch.")) + .children(tabs) + .child(body) + .child( + div() + .mt(px(20.)) + .flex() + .justify_end() + .gap(px(8.)) + .child(button("tiny-share-cancel", ButtonKind::Secondary, "Cancel").h(px(32.)).on_click(cx.listener(|this, _, _, cx| this.close_modal(cx)))) + .child(go), + ) + } + + fn tiny_share_card(&self, screen: &ScreenSource, cx: &mut Context) -> gpui::Stateful
{ + let picked = self.tiny_state.screen_pick.as_deref() == Some(screen.id.as_str()); + let id = screen.id.clone(); + let picture = if screen.thumbnail.is_empty() { + glyph(if screen.window { "icons/square.svg" } else { "icons/monitor-up.svg" }, 28., theme::text_faint()).into_any_element() + } else { + img(std::path::PathBuf::from(&screen.thumbnail)).size_full().object_fit(ObjectFit::Contain).into_any_element() + }; + div() + .id(SharedString::from(format!("tiny-share-{}", screen.id))) + .w(px(THUMB_W + 16.)) + .p(px(6.)) + .rounded(px(10.)) + .border_2() + .border_color(if picked { theme::accent() } else { theme::hairline() }) + .when(picked, |card| card.bg(theme::accent_wash())) + .flex() + .flex_col() + .gap(px(6.)) + .cursor_pointer() + .tab_index(0) + .hover(|style| style.bg(theme::hover())) + .focus_visible(|style| style.shadow(theme::focus_ring())) + .on_click(cx.listener(move |this, event: &gpui::ClickEvent, _, cx| { + this.tiny_state.screen_pick = Some(id.clone()); + // A double click shares at once. + if event.click_count() > 1 { + this.tiny_share_picked(cx); + } + cx.notify(); + })) + .child(div().w(px(THUMB_W)).h(px(THUMB_H)).rounded(px(6.)).bg(gpui::rgb(0x000000)).overflow_hidden().flex().items_center().justify_center().child(picture)) + .child(div().min_w_0().truncate().type_small().text_color(if picked { theme::text() } else { theme::text_soft() }).child(screen.name.clone())) + } +} + +/// `mediad --screens`, with its pictures in the support folder. Empty when mediad could not say, +/// and the share is then the primary screen. +fn read_screens() -> Vec { + let folder = protocol::support_dir().join("screens"); + let stdout = protocol::media_host::output(protocol::launch::no_console(&mut std::process::Command::new(crate::gui::settings::mediad_path())).arg("--screens").arg(&folder).stderr(std::process::Stdio::null())).unwrap_or_default(); + serde_json::from_slice::>(&stdout).unwrap_or_default().into_iter().take(SCREENS_LIMIT).collect() +} diff --git a/crates/tinyclient/src/gui/tiny/stage.rs b/crates/tinyclient/src/gui/tiny/stage.rs new file mode 100644 index 00000000..9c379025 --- /dev/null +++ b/crates/tinyclient/src/gui/tiny/stage.rs @@ -0,0 +1,145 @@ +//! What the call stage holds, as Discord lays it out: a grid of everyone in the call and of every +//! stream (someone's screen, else their camera), each stream a tile of its own beside its owner's. +//! Clicking a stream watches it and fills the stage with it, the people in a strip under it, over +//! it, or hidden; clicking it again, or Escape, is the grid again. Clicking a person draws them +//! large over a row of the rest. Pure, so the choices are tested without a window. + +use protocol::{TinyRoom, TinySeat}; + +/// What fills the stage instead of the grid. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Focus { + /// One person drawn large over a row of the rest. + Person(String), + /// One person's stream filling the stage. + Stream(String), +} + +/// Where the people go while a stream is focused. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct Strip { + /// The stream has the whole stage. + pub hidden: bool, + /// The strip is laid over the stream's foot instead of under it. + pub over: bool, +} + +/// One stream on the stage. A person has one: their screen when they share it, else their +/// camera, which is what watching them shows (the server's one video slot carries one picture). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Stream { + pub user_id: String, + pub share: bool, + pub mine: bool, +} + +/// What my own call here is sending, which it knows before the server's seat says so. `None` +/// when my call is not up here. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct Sending { + pub share: bool, + pub camera: bool, +} + +/// Every stream in the room, in seat order. A room with video turned off has none. +pub(super) fn streams(room: &TinyRoom, me: &str, sending: Option) -> Vec { + if !room.video_enabled { + return Vec::new(); + } + let of = |seat: &TinySeat| { + let mine = seat.user_id == me; + // Mine is either: my call knows first, and the seat holds when my call's own list lags. + let (share, camera) = match sending.filter(|_| mine) { + Some(sending) => (sending.share || seat.sharing, sending.camera || seat.camera), + None => (seat.sharing, seat.camera), + }; + (share || camera).then(|| Stream { user_id: seat.user_id.clone(), share, mine }) + }; + let mut found: Vec = room.participants.iter().filter_map(of).collect(); + // My share is drawn from my call even before my seat is in the room's list. + if let Some(sending) = sending.filter(|sending| (sending.share || sending.camera) && !room.participants.iter().any(|seat| seat.user_id == me)) { + found.push(Stream { user_id: me.to_owned(), share: sending.share, mine: true }); + } + found +} + +/// A click on a tile: that tile focused, or the grid again when it already was. +pub(super) fn toggle(focus: Option<&Focus>, clicked: Focus) -> Option { + (focus != Some(&clicked)).then_some(clicked) +} + +/// The focus as it can still be drawn: a person who left, or a stream that ended, is the grid. +pub(super) fn still<'a>(focus: Option<&'a Focus>, room: &TinyRoom, streams: &[Stream]) -> Option<&'a Focus> { + focus.filter(|focus| match focus { + Focus::Person(user_id) => room.participants.iter().any(|seat| seat.user_id == *user_id), + Focus::Stream(user_id) => streams.iter().any(|stream| stream.user_id == *user_id), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn room(seats: Vec) -> TinyRoom { + TinyRoom { participants: seats, ..TinyRoom::new("lounge", "Lounge") } + } + + fn seat(user_id: &str, sharing: bool, camera: bool) -> TinySeat { + TinySeat { user_id: user_id.into(), sharing, camera, ..TinySeat::default() } + } + + #[test] + fn everyone_sharing_or_on_camera_has_a_stream_and_a_share_wins() { + let room = room(vec![seat("ada", false, true), seat("bev", false, false), seat("cal", true, true)]); + let found = streams(&room, "me", None); + assert_eq!(found, vec![ + Stream { user_id: "ada".into(), share: false, mine: false }, + Stream { user_id: "cal".into(), share: true, mine: false }, + ]); + let quiet = TinyRoom { video_enabled: false, ..room }; + assert!(streams(&quiet, "me", None).is_empty(), "a room with video off has no streams"); + } + + #[test] + fn my_stream_is_drawn_from_my_call_before_the_server_says_so() { + // The server has not echoed my share yet: my call already sends it. + let room = room(vec![seat("me", false, false), seat("ada", false, false)]); + let sharing = Some(Sending { share: true, camera: false }); + assert_eq!(streams(&room, "me", sharing), vec![Stream { user_id: "me".into(), share: true, mine: true }]); + // My seat says I share while my call's list has not caught up: still drawn. + let seated = room_with_my_share(); + assert_eq!(streams(&seated, "me", Some(Sending::default())), vec![Stream { user_id: "me".into(), share: true, mine: true }]); + // No call of mine here: the seat is all there is to go on. + assert_eq!(streams(&seated, "me", None), vec![Stream { user_id: "me".into(), share: true, mine: true }]); + // My seat not in the list yet. + assert_eq!(streams(&room_empty(), "me", sharing).len(), 1); + } + + fn room_with_my_share() -> TinyRoom { + room(vec![seat("me", true, false)]) + } + + fn room_empty() -> TinyRoom { + room(Vec::new()) + } + + #[test] + fn a_click_focuses_and_a_second_click_is_the_grid_again() { + let ada = Focus::Stream("ada".into()); + assert_eq!(toggle(None, ada.clone()), Some(ada.clone())); + assert_eq!(toggle(Some(&ada), ada.clone()), None); + let person = Focus::Person("ada".into()); + assert_eq!(toggle(Some(&ada), person.clone()), Some(person), "a person and their stream are two tiles"); + } + + #[test] + fn a_stream_that_ends_or_a_person_who_leaves_unfocuses() { + let room = room(vec![seat("ada", true, false), seat("bev", false, false)]); + let found = streams(&room, "me", None); + let ada = Focus::Stream("ada".into()); + assert_eq!(still(Some(&ada), &room, &found), Some(&ada)); + assert_eq!(still(Some(&Focus::Stream("bev".into())), &room, &found), None, "bev shares nothing"); + assert_eq!(still(Some(&Focus::Person("bev".into())), &room, &found), Some(&Focus::Person("bev".into()))); + assert_eq!(still(Some(&Focus::Person("cal".into())), &room, &found), None, "cal is not in the call"); + } +}