feat: rebuild the desktop interface in clipbin's style
Rust checks / windows-cross-check (push) Successful in 38s
Rust checks / linux (push) Failing after 5m18s

Replaces the placeholder shell with a sidebar app in the web Clipbin's look:
a session library, a session timeline (footage, missing and cleaned-up spans,
bookmarks, a draggable clip selection exported on keyframes), clips and
settings. Geist Mono and Lucide icons are embedded. The core gains the
library model and formatting; sample data is opt-in for interface work, and
scripts/rig/screenshot-ui.sh captures each screen headlessly.
This commit is contained in:
2026-10-08 15:45:10 +02:00
parent 795118c200
commit e20b2680cc
49 changed files with 3161 additions and 131 deletions
+2
View File
@@ -1,7 +1,9 @@
//! Recorder settings and state shared with the desktop interface.
pub mod library;
mod settings;
mod status;
pub use library::Library;
pub use settings::{Settings, VideoCodec};
pub use status::RecordingStatus;
+394
View File
@@ -0,0 +1,394 @@
//! Recorded sessions, bookmarks and exported clips as the interface shows them.
//!
//! Persistence comes with the recorder integration; until then the library is
//! empty, or filled with `Library::sample()` for interface work.
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, PartialEq)]
pub struct Library {
pub sessions: Vec<Session>,
pub clips: Vec<Clip>,
pub storage: StorageUsage,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Session {
pub id: u64,
pub game: String,
pub started: SystemTime,
pub length: Duration,
/// The session is still recording.
pub active: bool,
/// The session timeline: where footage exists and where it does not.
pub spans: Vec<Span>,
pub bookmarks: Vec<Bookmark>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub start: Duration,
pub end: Duration,
pub kind: SpanKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpanKind {
Footage,
/// Capture was unavailable; nothing was recorded (SPEC: recovery must not
/// imply the interrupted period was captured).
Missing,
/// Removed by storage cleanup.
Removed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Bookmark {
pub at: Duration,
pub before: Duration,
pub after: Duration,
pub protected: bool,
}
impl Bookmark {
/// The protected range, clamped to the session.
pub fn range(&self, session_length: Duration) -> (Duration, Duration) {
(
self.at.saturating_sub(self.before),
(self.at + self.after).min(session_length),
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Clip {
pub id: u64,
pub game: String,
pub created: SystemTime,
pub length: Duration,
pub size_bytes: u64,
}
/// SPEC: the budget counts only unprotected session footage; protected
/// bookmarks and exported clips are shown separately.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StorageUsage {
pub budget_bytes: u64,
pub unprotected_bytes: u64,
pub protected_bytes: u64,
pub clip_bytes: u64,
}
impl Session {
pub fn protected_bookmarks(&self) -> usize {
self.bookmarks
.iter()
.filter(|bookmark| bookmark.protected)
.count()
}
/// Time with no footage because capture was unavailable.
pub fn missing_time(&self) -> Duration {
self.time_in(SpanKind::Missing)
}
/// Footage removed by storage cleanup.
pub fn removed_time(&self) -> Duration {
self.time_in(SpanKind::Removed)
}
fn time_in(&self, kind: SpanKind) -> Duration {
self.spans
.iter()
.filter(|span| span.kind == kind)
.map(|span| span.end.saturating_sub(span.start))
.sum()
}
}
/// Clip exports cut on keyframes: the requested range grows outward to the
/// nearest keyframes so none of the selection is lost.
pub fn export_range(
start: Duration,
end: Duration,
keyframe_interval: Duration,
session_length: Duration,
) -> (Duration, Duration) {
let interval = keyframe_interval.as_millis().max(1);
let floor =
|time: Duration| Duration::from_millis((time.as_millis() / interval * interval) as u64);
let ceil = |time: Duration| {
Duration::from_millis((time.as_millis().div_ceil(interval) * interval) as u64)
};
(floor(start), ceil(end).min(session_length))
}
/// `1:02:03` or `2:03`.
pub fn format_clock(time: Duration) -> String {
let seconds = time.as_secs();
let (hours, minutes, seconds) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
if hours > 0 {
format!("{hours}:{minutes:02}:{seconds:02}")
} else {
format!("{minutes}:{seconds:02}")
}
}
/// `1h 12m`, `12m`, `1m 34s`, or `45s`: seconds only for clip-sized lengths.
pub fn format_length(time: Duration) -> String {
let seconds = time.as_secs();
match (seconds / 3600, seconds / 60 % 60, seconds % 60) {
(0, 0, seconds) => format!("{seconds}s"),
(0, minutes @ 1..10, seconds @ 1..) => format!("{minutes}m {seconds:02}s"),
(0, minutes, _) => format!("{minutes}m"),
(hours, minutes, _) => format!("{hours}h {minutes:02}m"),
}
}
/// Sizes in decimal units, as storage is sold: `12.4 GB`.
pub fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = "B";
for next in UNITS {
if value < 1000.0 {
break;
}
value /= 1000.0;
unit = next;
}
if unit == "B" {
format!("{bytes} B")
} else {
format!("{value:.1} {unit}")
}
}
/// `just now`, `12 minutes ago`, `3 hours ago`, `2 days ago`.
pub fn format_ago(then: SystemTime, now: SystemTime) -> String {
let seconds = now.duration_since(then).unwrap_or_default().as_secs();
let plural =
|count: u64, unit: &str| format!("{count} {unit}{} ago", if count == 1 { "" } else { "s" });
match seconds {
0..60 => "just now".into(),
60..3600 => plural(seconds / 60, "minute"),
3600..86_400 => plural(seconds / 3600, "hour"),
_ => plural(seconds / 86_400, "day"),
}
}
impl Library {
pub fn empty(budget_bytes: u64) -> Self {
Self {
sessions: Vec::new(),
clips: Vec::new(),
storage: StorageUsage {
budget_bytes,
unprotected_bytes: 0,
protected_bytes: 0,
clip_bytes: 0,
},
}
}
pub fn session(&self, id: u64) -> Option<&Session> {
self.sessions.iter().find(|session| session.id == id)
}
/// Example data for working on the interface before recordings are
/// persisted. Never shown unless explicitly requested.
pub fn sample(now: SystemTime) -> Self {
let minutes = |m: u64| Duration::from_secs(m * 60);
let seconds = Duration::from_secs;
let ago = |m: u64| now - minutes(m);
let footage = |start: Duration, end: Duration| Span {
start,
end,
kind: SpanKind::Footage,
};
let bookmark = |at: Duration, protected| Bookmark {
at,
before: seconds(120),
after: seconds(30),
protected,
};
let session = |id, game: &str, started, length, active, spans, bookmarks| Session {
id,
game: game.into(),
started,
length,
active,
spans,
bookmarks,
};
Self {
sessions: vec![
session(
1,
"Counter-Strike 2",
ago(38),
minutes(38),
true,
vec![footage(Duration::ZERO, minutes(38))],
vec![bookmark(minutes(9), true), bookmark(minutes(31), true)],
),
session(
2,
"Deep Rock Galactic",
ago(26 * 60),
minutes(72),
false,
vec![
footage(Duration::ZERO, minutes(41)),
Span {
start: minutes(41),
end: minutes(43),
kind: SpanKind::Missing,
},
footage(minutes(43), minutes(72)),
],
vec![
bookmark(minutes(12), true),
bookmark(minutes(40), true),
bookmark(minutes(66), false),
],
),
session(
3,
"Hades II",
ago(2 * 24 * 60),
minutes(124),
false,
vec![
Span {
start: Duration::ZERO,
end: minutes(50),
kind: SpanKind::Removed,
},
footage(minutes(50), minutes(124)),
],
vec![bookmark(minutes(98), true)],
),
session(
4,
"Rocket League",
ago(4 * 24 * 60),
minutes(47),
false,
vec![footage(Duration::ZERO, minutes(47))],
Vec::new(),
),
session(
5,
"Elden Ring Nightreign",
ago(6 * 24 * 60),
minutes(96),
false,
vec![footage(Duration::ZERO, minutes(96))],
vec![bookmark(minutes(55), true), bookmark(minutes(88), true)],
),
],
clips: vec![
Clip {
id: 1,
game: "Counter-Strike 2".into(),
created: ago(12),
length: seconds(120),
size_bytes: 412_000_000,
},
Clip {
id: 2,
game: "Deep Rock Galactic".into(),
created: ago(25 * 60),
length: seconds(94),
size_bytes: 318_000_000,
},
Clip {
id: 3,
game: "Hades II".into(),
created: ago(2 * 24 * 60),
length: seconds(38),
size_bytes: 121_000_000,
},
],
storage: StorageUsage {
budget_bytes: 50_000_000_000,
unprotected_bytes: 31_600_000_000,
protected_bytes: 4_200_000_000,
clip_bytes: 851_000_000,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn export_ranges_grow_to_keyframes_and_stop_at_the_session_end() {
let ms = Duration::from_millis;
let second = Duration::from_secs(1);
assert_eq!(
export_range(ms(10_400), ms(20_100), second, ms(60_000)),
(ms(10_000), ms(21_000))
);
assert_eq!(
export_range(ms(10_000), ms(20_000), second, ms(60_000)),
(ms(10_000), ms(20_000))
);
assert_eq!(
export_range(ms(55_000), ms(59_500), second, ms(59_500)),
(ms(55_000), ms(59_500))
);
}
#[test]
fn bookmark_protection_is_clamped_to_the_session() {
let bookmark = Bookmark {
at: Duration::from_secs(60),
before: Duration::from_secs(120),
after: Duration::from_secs(30),
protected: true,
};
assert_eq!(
bookmark.range(Duration::from_secs(80)),
(Duration::ZERO, Duration::from_secs(80))
);
}
#[test]
fn formatting() {
assert_eq!(format_clock(Duration::from_secs(3723)), "1:02:03");
assert_eq!(format_clock(Duration::from_secs(123)), "2:03");
assert_eq!(format_length(Duration::from_secs(4320)), "1h 12m");
assert_eq!(format_length(Duration::from_secs(45)), "45s");
assert_eq!(format_length(Duration::from_secs(94)), "1m 34s");
assert_eq!(format_length(Duration::from_secs(120)), "2m");
assert_eq!(format_bytes(31_600_000_000), "31.6 GB");
assert_eq!(format_bytes(999), "999 B");
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
assert_eq!(
format_ago(now - Duration::from_secs(60), now),
"1 minute ago"
);
assert_eq!(
format_ago(now - Duration::from_secs(3 * 86_400), now),
"3 days ago"
);
}
#[test]
fn missing_and_removed_time_are_counted_separately() {
let library = Library::sample(SystemTime::now());
assert_eq!(
library.session(2).unwrap().missing_time(),
Duration::from_secs(120)
);
assert_eq!(
library.session(3).unwrap().removed_time(),
Duration::from_secs(50 * 60)
);
assert_eq!(library.session(3).unwrap().missing_time(), Duration::ZERO);
}
}