Streamline recording controls and compact the display picker
No files matched your search
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 12h14m-6-6 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 200 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="m8 12 3 3 5-6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect x="8" y="10" width="44" height="32" rx="7" fill="none" stroke="currentColor" stroke-width="3"/><path d="M23 50h14M30 42v8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 271 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="3" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 136 B |
@@ -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<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
let (theme, font) = appearance();
|
||||
let height = 156. + (prompt.sources.len().min(4) as f32 * 56.);
|
||||
cosmic::app::run::<Picker>(
|
||||
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<Message>) {
|
||||
fn init(mut core: Core, prompt: Prompt) -> (Self, Task<Message>) {
|
||||
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())
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Cancel display selection"><circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/><path d="m23 23 14 14m0-14L23 37" fill="none" stroke="#3d7be6" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Saving recording"><circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/><g fill="#3d7be6"><circle cx="20" cy="30" r="3"/><circle cx="30" cy="30" r="3"/><circle cx="40" cy="30" r="3"/></g></svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -1,5 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Stop recording">
|
||||
<rect x="8" y="10" width="44" height="32" rx="7" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
<path d="M23 50h14M30 42v8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
|
||||
<rect x="22" y="18" width="16" height="16" rx="3" fill="#3d7be6"/>
|
||||
<circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
<rect x="20" y="20" width="20" height="20" rx="4" fill="#3d7be6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 380 B After Width: | Height: | Size: 258 B |
@@ -36,6 +36,15 @@ pub struct Recorder {
|
||||
updates: Option<Receiver<Event>>,
|
||||
}
|
||||
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::<String>::new(),
|
||||
std::collections::HashMap::<String, zbus::zvariant::OwnedValue>::new(),
|
||||
-1i32,
|
||||
),
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
eprintln!("ferese-shell: recording notification: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||