feat: rebuild the desktop interface in clipbin's style
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user