Files
clipbin-desktop/crates/clipbin-desktop/src/screens/clips.rs
T
harry f1c31d6c90 feat: keep Clipbin recording in the tray
Add native Linux SNI and Windows notification-area trays, single-instance reopening, background startup and login entries. Preserve recording and discovery after close, with clean Quit and reachable no-tray fallbacks.

Track capture-source audio delivery independently of silent output tracks and warn without stopping video. Give clip metadata its own row and shape labels at final bounds to avoid GPUI 0.2.2 truncation cache and text-run bugs.

Validated with strict workspace checks, 57 tests, Windows core/OBS/probe cross-clippy, all ten capture scenarios, tray/session/clips/playback rigs and narrow/wide screenshots. Document runtime validation gaps.
2026-10-13 18:29:37 +02:00

217 lines
7.5 KiB
Rust

//! Exported clips: local MP4 files that never expire automatically.
use clipbin_core::library::{format_ago, format_bytes, format_length};
use gpui::{
AnyElement, ClickEvent, Context, FontWeight, IntoElement, Pixels, canvas, div, prelude::*, px,
};
use crate::{
app::ClipbinApp,
components::{ButtonStyle, button, chip, count, empty_state, page_header},
screens::{corner, thumbnail},
theme,
};
pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
let clips = &app.library.clips;
let total: u64 = clips.iter().map(|clip| clip.size_bytes).sum();
let header = page_header(
"Clips",
if clips.is_empty() {
"Clips you save are kept until you delete them.".to_owned()
} else {
format!(
"{} · {} · kept until you delete them",
count(clips.len(), "clip"),
format_bytes(total)
)
},
vec![
button(
"open-clips-folder",
ButtonStyle::Secondary,
Some("folder-open"),
Some("Open folder"),
)
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.service.open_clip(None);
cx.notify();
}))
.into_any_element(),
],
);
let failed_actions = (app.failed_exports > 0).then(|| {
div()
.flex()
.items_center()
.gap_2()
.child(format!(
"{} failed exports · source footage kept",
app.failed_exports
))
.child(
button("retry-exports", ButtonStyle::Secondary, None, Some("Retry")).on_click(
cx.listener(|app, _: &ClickEvent, _, cx| {
app.service.retry_exports();
cx.notify();
}),
),
)
.child(
button(
"release-exports",
ButtonStyle::Ghost,
None,
Some("Release protection"),
)
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.service.release_exports();
cx.notify();
})),
)
});
if clips.is_empty() {
return div()
.flex()
.flex_col()
.gap_6()
.child(header)
.children(failed_actions)
.child(empty_state(
"scissors",
"No clips yet",
"Press the clip hotkey in game to save the last two minutes, or select a range on a session's timeline.",
))
.into_any_element();
}
let now = app.now();
let cards = clips.iter().map(|clip| {
let id = clip.id;
div()
.flex()
.flex_col()
.min_w_0()
.gap_3()
.child(
thumbnail(&clip.game, app.playback.clip_thumbnails.get(&clip.id)).child(
corner()
.bottom_2p5()
.right_2p5()
.child(chip(format_length(clip.length))),
),
)
.child(
div()
.flex()
.flex_col()
.min_w_0()
.gap_2()
.px_1()
.child(
div()
.flex()
.flex_col()
.min_w_0()
.gap_0p5()
.child(
div()
.text_base()
.font_weight(FontWeight::SEMIBOLD)
.text_color(theme::foreground())
.child(label(clip.game.clone(), px(24.))),
)
.child(div().text_xs().text_color(theme::subtle()).child(label(
format!(
"{} · {}",
format_ago(clip.created, now),
format_bytes(clip.size_bytes)
),
px(18.),
))),
)
.child(
div()
.flex()
.items_center()
.justify_end()
.gap_2()
.flex_shrink_0()
.child(
button(("open-clip", id), ButtonStyle::Ghost, Some("play"), None)
.flex_shrink_0()
.rounded_full()
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
app.service.open_clip(Some(id));
cx.notify();
})),
)
.child(
button(
("delete-clip", id),
ButtonStyle::Ghost,
None,
Some("Delete"),
)
.flex_shrink_0()
.on_click(cx.listener(
move |app, _: &ClickEvent, _, cx| {
app.service.delete_clip(id);
cx.notify();
},
)),
),
),
)
});
div()
.flex()
.flex_col()
.gap_6()
.child(header)
.children(failed_actions)
.child(
div()
.grid()
.grid_cols(3)
.gap_x_4()
.gap_y_8()
.children(cards),
)
.into_any_element()
}
// GPUI 0.2.2 caches nowrap widths and mutates text runs during remeasurement.
// Shape once at the final card bounds to retain both ellipsis and font styling.
fn label(text: String, height: Pixels) -> impl IntoElement {
canvas(
move |bounds, window, cx| {
let style = window.text_style();
let font_size = style.font_size.to_pixels(window.rem_size());
let line_height = style
.line_height
.to_pixels(font_size.into(), window.rem_size());
let text: gpui::SharedString = text.replace(['\n', '\r'], " ").into();
let mut runs = vec![style.to_run(text.len())];
let text = cx
.text_system()
.line_wrapper(style.font(), font_size)
.truncate_line(text, bounds.size.width, "…", &mut runs);
(
window
.text_system()
.shape_line(text, font_size, &runs, None),
line_height,
)
},
|bounds, (line, line_height), window, cx| {
let _ = line.paint(bounds.origin, line_height, window, cx);
},
)
.w_full()
.h(height)
.overflow_hidden()
}