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 {