diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index 6ad7d3e..a3823b2 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -7,19 +7,19 @@ saving files, or sending video; those tasks do not run inside the compositor. ## Record from the top bar -1. Click the screen-recording icon in the top bar, then **Record display**. +1. Click the screen-recording icon in the top bar to open the display picker. 2. Choose a monitor in the native picker and click **Share**. 3. The icon changes to a stop square and shows elapsed time. Click it to stop. -The recorder finishes the video before reporting it saved. Open the recording -menu again to see its saved path. Videos are saved as VP8 WebM files under +The recorder finishes the video before sending a notification with its saved +path. Videos are saved as VP8 WebM files under `Videos/Ferese` (using your configured XDG Videos folder). Audio is off. The icon uses the same theme accent, hover effect, and corner radius as the other bar controls. Encoding runs in a separate native `ferese-record` process using GStreamer. The shell remains responsive while a file is recorded or finalized. If saving -fails, an error appears in the recording menu; an unfinished `.webm.part` file +fails, an error appears in the notification center; an unfinished `.webm.part` file may remain at the path shown in the error. ## Start sharing diff --git a/portal/assets/arrow.svg b/portal/assets/arrow.svg new file mode 100644 index 0000000..1b5a28e --- /dev/null +++ b/portal/assets/arrow.svg @@ -0,0 +1 @@ + diff --git a/portal/assets/check.svg b/portal/assets/check.svg new file mode 100644 index 0000000..0d77fa1 --- /dev/null +++ b/portal/assets/check.svg @@ -0,0 +1 @@ + diff --git a/portal/assets/display.svg b/portal/assets/display.svg new file mode 100644 index 0000000..41cc956 --- /dev/null +++ b/portal/assets/display.svg @@ -0,0 +1 @@ + diff --git a/portal/assets/stop.svg b/portal/assets/stop.svg new file mode 100644 index 0000000..a2a7347 --- /dev/null +++ b/portal/assets/stop.svg @@ -0,0 +1 @@ + diff --git a/portal/src/picker.rs b/portal/src/picker.rs index 607c443..c56a8ff 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -2,8 +2,8 @@ use crate::capture::Source; use cosmic::{ ApplicationExt, Element, app::{Core, Settings, Task}, - iced::Length, - widget::{button, column, container, row, text}, + iced::{Alignment, Length}, + widget::{button, column, container, icon, row, scrollable, text}, }; use serde::{Deserialize, Serialize}; use std::io::{Read, Write}; @@ -41,9 +41,10 @@ pub fn run() -> Result<(), Box> { } let (theme, font) = appearance(); + let height = 156. + (prompt.sources.len().min(4) as f32 * 56.); cosmic::app::run::( Settings::default() - .size(cosmic::iced::Size::new(480., 440.)) + .size(cosmic::iced::Size::new(400., height)) .theme(theme) .default_font(font) .default_text_size(14.) @@ -68,7 +69,9 @@ impl cosmic::Application for Picker { &mut self.core } - fn init(core: Core, prompt: Prompt) -> (Self, Task) { + fn init(mut core: Core, prompt: Prompt) -> (Self, Task) { + core.window.show_headerbar = false; + core.window.border_padding = Some(0); // No preselected source: clicking Share must be a deliberate choice. let mut app = Self { core, @@ -121,73 +124,116 @@ impl cosmic::Application for Picker { fn view(&self) -> Element<'_, Message> { let app = if self.prompt.app.is_empty() { - "An application" + "this application" } else { &self.prompt.app }; - let mut content = column([]) - .spacing(16) - .push( - text(if self.prompt.indicator { - "Your screen is being shared" - } else { - "Share your screen" - }) - .size(24), - ) - .push(text(if self.prompt.indicator { - format!("{app} can see the displays below.") - } else { - format!("{app} wants to see your screen. Choose what to share.") - })); - + let mut content = column([]).width(Length::Fill).spacing(10).push( + row![ + glyph(include_bytes!("../assets/display.svg"), 24), + column![ + text(if self.prompt.indicator { + "Screen sharing" + } else { + "Choose a display" + }) + .size(18), + text(if self.prompt.indicator { + format!("Shared with {app}") + } else { + format!("Share with {app}") + }) + .size(12), + ] + .spacing(2) + .width(Length::Fill), + ] + .spacing(12) + .align_y(Alignment::Center), + ); + let mut sources = column([]).spacing(6); for (index, source) in self.prompt.sources.iter().enumerate() { - let label = format!( - "{}{} — {} × {}", - if self.selected.contains(&index) { - "✓ " - } else { - "" - }, - source.name, - source.width, - source.height - ); - + let selected = self.selected.contains(&index); + let mut entry = row![ + glyph(include_bytes!("../assets/display.svg"), 24), + column![ + text(&source.name).size(14), + text(format!("{} × {}", source.width, source.height)).size(12) + ] + .spacing(3) + .width(Length::Fill), + ] + .spacing(12) + .align_y(Alignment::Center); + if selected || self.prompt.indicator { + entry = entry.push(glyph(include_bytes!("../assets/check.svg"), 20)); + } if self.prompt.indicator { - content = content.push(text(label)); + sources = sources.push(container(entry).padding(8).width(Length::Fill)); } else { - content = content.push( - button::text(label) + sources = sources.push( + button::custom(entry) + .class(if selected { + cosmic::theme::Button::Suggested + } else { + cosmic::theme::Button::Standard + }) + .padding(8) .width(Length::Fill) .on_press(Message::Select(index)), ); } } - - content = content.push(text( - "Everything visible on a shared display, including notifications, may be seen.", - )); - + content = content.push(scrollable(sources).height(Length::Fixed( + (self.prompt.sources.len().min(4) * 56) as f32, + ))); + content = content.push( + text("Everything on this display, including notifications, is visible.") + .size(12) + .width(Length::Fill), + ); if self.prompt.indicator { - content = content.push(button::destructive("Stop sharing").on_press(Message::Cancel)); + content = content.push( + button::custom( + row![ + glyph(include_bytes!("../assets/stop.svg"), 16), + text("Stop sharing") + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .class(cosmic::theme::Button::Destructive) + .padding([6, 16]) + .on_press(Message::Cancel), + ); } else { - let mut share = button::suggested("Share"); + let mut share = button::custom( + row![ + text("Share"), + glyph(include_bytes!("../assets/arrow.svg"), 16) + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .class(cosmic::theme::Button::Suggested) + .padding([6, 16]); if !self.selected.is_empty() { share = share.on_press(Message::Share); } - content = content.push( - row([]) - .spacing(12) - .push(button::text("Cancel").on_press(Message::Cancel)) - .push(share), - ); + content = content + .push(row![button::text("Cancel").on_press(Message::Cancel), share].spacing(8)); } - - container(content).padding(24).width(Length::Fill).into() + container(content).padding(14).width(Length::Fill).into() } } +fn glyph(source: &'static [u8], size: u16) -> icon::Icon { + icon::from_svg_bytes(source) + .symbolic(true) + .icon() + .size(size) +} + fn appearance() -> (cosmic::Theme, cosmic::font::Font) { let document = ferese_config::config_path() .and_then(|p| std::fs::read_to_string(p).ok()) diff --git a/shell/ferese-shell/assets/icons/status/record-cancel.svg b/shell/ferese-shell/assets/icons/status/record-cancel.svg new file mode 100644 index 0000000..15b3d66 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/record-cancel.svg @@ -0,0 +1 @@ + diff --git a/shell/ferese-shell/assets/icons/status/record-saving.svg b/shell/ferese-shell/assets/icons/status/record-saving.svg new file mode 100644 index 0000000..79d7dcc --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/record-saving.svg @@ -0,0 +1 @@ + diff --git a/shell/ferese-shell/assets/icons/status/record-stop.svg b/shell/ferese-shell/assets/icons/status/record-stop.svg index 015bfe7..fe298ff 100644 --- a/shell/ferese-shell/assets/icons/status/record-stop.svg +++ b/shell/ferese-shell/assets/icons/status/record-stop.svg @@ -1,5 +1,4 @@ - - - + + diff --git a/shell/ferese-shell/src/recording.rs b/shell/ferese-shell/src/recording.rs index 9801aa6..23182bd 100644 --- a/shell/ferese-shell/src/recording.rs +++ b/shell/ferese-shell/src/recording.rs @@ -36,6 +36,15 @@ pub struct Recorder { updates: Option>, } impl Recorder { + pub fn label(&self) -> &'static str { + match self.state { + State::Selecting => "Cancel display selection", + State::Recording(_) => "Stop recording", + State::Saving => "Saving recording", + _ => "Record a display", + } + } + pub fn busy(&self) -> bool { self.updates.is_some() } @@ -87,11 +96,15 @@ impl Recorder { let _ = send.send(Event::Exited); }); } - Err(error) => self.state = State::Error(format!("Cannot start recorder: {error}")), + Err(error) => { + let message = format!("Cannot start recorder: {error}"); + notify("Recording unavailable", message.clone()); + self.state = State::Error(message); + } } } pub fn stop(&mut self) { - if self.busy() { + if self.busy() && !matches!(self.state, State::Saving) { // EOF requests EOS/finalization. Never terminate the encoder abruptly. self.control.take(); self.state = State::Saving; @@ -134,8 +147,47 @@ impl Recorder { } } if done { + match &self.state { + State::Saved(path) => notify("Recording saved", path.display().to_string()), + State::Error(message) => notify("Recording failed", message.clone()), + _ => (), + } self.control.take(); self.updates.take(); } } } + +// Use the normal notification center for results, without an extra recording menu. +fn notify(title: &'static str, body: String) { + std::thread::spawn(move || { + let result = (|| -> zbus::Result<()> { + let connection = zbus::blocking::connection::Builder::session()? + .method_timeout(std::time::Duration::from_secs(3)) + .build()?; + let proxy = zbus::blocking::Proxy::new( + &connection, + "org.freedesktop.Notifications", + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + )?; + let _: u32 = proxy.call( + "Notify", + &( + "Ferese", + 0u32, + "ferese", + title, + body, + Vec::::new(), + std::collections::HashMap::::new(), + -1i32, + ), + )?; + Ok(()) + })(); + if let Err(error) = result { + eprintln!("ferese-shell: recording notification: {error}"); + } + }); +} diff --git a/shell/ferese-shell/src/status_ui.rs b/shell/ferese-shell/src/status_ui.rs index 400ce79..5dad404 100644 --- a/shell/ferese-shell/src/status_ui.rs +++ b/shell/ferese-shell/src/status_ui.rs @@ -257,15 +257,26 @@ impl FereseShell { if !kind.available(&self.status) { continue; } - let (source, enabled) = if kind == Menu::Recording && self.recorder.busy() { - ( - include_bytes!("../assets/icons/status/record-stop.svg").as_slice(), - true, - ) + let (source, enabled) = if kind == Menu::Recording { + let source: &'static [u8] = match self.recorder.state { + recording::State::Selecting => { + include_bytes!("../assets/icons/status/record-cancel.svg") + } + recording::State::Recording(_) => { + include_bytes!("../assets/icons/status/record-stop.svg") + } + recording::State::Saving => { + include_bytes!("../assets/icons/status/record-saving.svg") + } + _ => include_bytes!("../assets/icons/status/record.svg"), + }; + (source, true) } else { status_icon(kind, &self.status) }; - let selected = self.menu.as_ref().is_some_and(|m| m.kind == kind); + let selected = self.menu.as_ref().is_some_and(|m| m.kind == kind) + || (kind == Menu::Recording + && matches!(self.recorder.state, recording::State::Recording(_))); let foreground = color(if selected { theme.accent } else if enabled { @@ -326,7 +337,9 @@ impl FereseShell { let recording_busy = kind == Menu::Recording && self.recorder.busy(); let control = button::custom(content) .name(if recording_busy { - "Stop screen recording".into() + self.recorder.label().into() + } else if kind == Menu::Recording { + "Record a display".into() } else { status_label(kind, &self.status) }) @@ -336,8 +349,12 @@ impl FereseShell { ]) .height(metrics.control_height) .on_press_with_rectangle(move |offset, bounds| { - if recording_busy { - return cosmic::Action::App(Message::StopRecording); + if kind == Menu::Recording { + return cosmic::Action::App(if recording_busy { + Message::StopRecording + } else { + Message::StartRecording + }); } cosmic::Action::App(Message::OpenMenu( kind, @@ -434,42 +451,6 @@ impl FereseShell { ] .spacing(8), ); - } else if kind == Menu::Recording { - use recording::State; - let description = match &self.recorder.state { - State::Idle => { - "Record a display. Video is saved to Videos/Ferese as WebM. Audio is off." - .to_owned() - } - State::Selecting => "Choose a display in the sharing dialog.".to_owned(), - State::Recording(_) => format!( - "Recording {} · audio off", - self.recorder.elapsed().unwrap_or_default() - ), - State::Saving => "Finishing your recording…".to_owned(), - State::Saved(path) => format!("Saved to {}", path.display()), - State::Error(message) => message.clone(), - }; - rows = rows.push(text(description).size(13).class(theme::Text::Color(muted))); - if !self.recorder.busy() { - rows = rows.push(menu_button( - "Record display", - Message::StartRecording, - primary, - p, - )); - } else if !matches!(self.recorder.state, State::Saving) { - rows = rows.push(menu_button( - if matches!(self.recorder.state, State::Selecting) { - "Cancel" - } else { - "Stop and save" - }, - Message::StopRecording, - primary, - p, - )); - } } else { let combined = menu.kind == Menu::System; if combined {