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.
@@ -8,7 +8,7 @@ An open-source game recorder for Windows and Linux, inspired by Plays.tv. Rust a
|
|||||||
|
|
||||||
The recording foundation is being validated (see [docs/validation.md](docs/validation.md)). `clipbin-obs` records official OBS capture sources through one hardware encoder into keyframe-aligned segments and reports honest status; `clipbin-probe` exercises it. It passes the Linux headless rig and the hardware sign-off on CachyOS/Hyprland/RX 9070 XT; all Windows validation is still to do.
|
The recording foundation is being validated (see [docs/validation.md](docs/validation.md)). `clipbin-obs` records official OBS capture sources through one hardware encoder into keyframe-aligned segments and reports honest status; `clipbin-probe` exercises it. It passes the Linux headless rig and the hardware sign-off on CachyOS/Hyprland/RX 9070 XT; all Windows validation is still to do.
|
||||||
|
|
||||||
The desktop app is still the initial shell: it opens a GPUI window, displays the agreed recording defaults, and always reports **Not recording**. Steam detection, the recorder in the app, playback, clipping, bookmarks, persistence, global hotkeys, autostart, and background recording are not implemented yet.
|
The desktop interface follows the web Clipbin's look (zinc dark theme, purple accent, Geist Mono): a session library, a session timeline with bookmarks and a clip selection, clips, and settings. It is not yet connected to the recorder, so it shows empty states; `CLIPBIN_SAMPLE_LIBRARY=1` fills it with example sessions for interface work. Steam detection, the recorder in the app, playback, export, persistence, global hotkeys, autostart, and background recording are not implemented yet.
|
||||||
|
|
||||||
Windows is a first-class target. Every change must keep the Windows code paths building (`cargo clippy --target x86_64-pc-windows-msvc`) and list what still needs validating on Windows.
|
Windows is a first-class target. Every change must keep the Windows code paths building (`cargo clippy --target x86_64-pc-windows-msvc`) and list what still needs validating on Windows.
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ cargo test --locked --workspace
|
|||||||
cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
|
cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
Recording validation on Linux, headless: `scripts/validate/linux-rig.sh`.
|
Recording validation on Linux, headless: `scripts/validate/linux-rig.sh`. Interface screenshots, headless: `scripts/rig/screenshot-ui.sh` (screens: sessions, session, clips, settings, failed, empty).
|
||||||
|
|
||||||
The core can also be checked without native UI dependencies:
|
The core can also be checked without native UI dependencies:
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
//! Recorder settings and state shared with the desktop interface.
|
//! Recorder settings and state shared with the desktop interface.
|
||||||
|
|
||||||
|
pub mod library;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod status;
|
mod status;
|
||||||
|
|
||||||
|
pub use library::Library;
|
||||||
pub use settings::{Settings, VideoCodec};
|
pub use settings::{Settings, VideoCodec};
|
||||||
pub use status::RecordingStatus;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
ISC License
|
||||||
|
|
||||||
|
Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2023 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2025.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted, provided that the above
|
||||||
|
copyright notice and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||||
|
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||||
|
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The MIT License (MIT) (for portions derived from Feather)
|
||||||
|
|
||||||
|
Copyright (c) 2013-2023 Cole Bemis
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-app-window"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
|
<path d="M10 4v4" />
|
||||||
|
<path d="M2 8h20" />
|
||||||
|
<path d="M6 4v4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 411 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-arrow-left"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-bookmark"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 355 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-chevron-left"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-chevron-right"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-circle-dot"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<circle cx="12" cy="12" r="1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 361 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-clapperboard"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z" />
|
||||||
|
<path d="m6.2 5.3 3.1 3.9" />
|
||||||
|
<path d="m12.4 3.4 3.1 4" />
|
||||||
|
<path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 499 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-clock"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 6v6l4 2" />
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 348 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-download"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 15V3" />
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<path d="m7 10 5 5 5-5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 398 B |
@@ -0,0 +1,22 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-film"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
<path d="M7 3v18" />
|
||||||
|
<path d="M3 7.5h4" />
|
||||||
|
<path d="M3 12h18" />
|
||||||
|
<path d="M3 16.5h4" />
|
||||||
|
<path d="M17 3v18" />
|
||||||
|
<path d="M17 7.5h4" />
|
||||||
|
<path d="M17 16.5h4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 508 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-folder-open"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 484 B |
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-gamepad-2"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="6" x2="10" y1="11" y2="11" />
|
||||||
|
<line x1="8" x2="8" y1="9" y2="13" />
|
||||||
|
<line x1="15" x2="15.01" y1="12" y2="12" />
|
||||||
|
<line x1="18" x2="18.01" y1="10" y2="10" />
|
||||||
|
<path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 761 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-hard-drive"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="22" x2="2" y1="12" y2="12" />
|
||||||
|
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||||
|
<line x1="6" x2="6.01" y1="16" y2="16" />
|
||||||
|
<line x1="10" x2="10.01" y1="16" y2="16" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 544 B |
@@ -0,0 +1,23 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-keyboard"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M10 8h.01" />
|
||||||
|
<path d="M12 12h.01" />
|
||||||
|
<path d="M14 8h.01" />
|
||||||
|
<path d="M16 12h.01" />
|
||||||
|
<path d="M18 8h.01" />
|
||||||
|
<path d="M6 8h.01" />
|
||||||
|
<path d="M7 16h10" />
|
||||||
|
<path d="M8 12h.01" />
|
||||||
|
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 541 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-layout-grid"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||||
|
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||||
|
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||||
|
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 499 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-mic"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 19v3" />
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||||
|
<rect x="9" y="2" width="6" height="13" rx="3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 401 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-monitor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||||
|
<line x1="8" x2="16" y1="21" y2="21" />
|
||||||
|
<line x1="12" x2="12" y1="17" y2="21" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 425 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-pause"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="14" y="3" width="5" height="18" rx="1" />
|
||||||
|
<rect x="5" y="3" width="5" height="18" rx="1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 390 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-play"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 382 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-power"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 2v10" />
|
||||||
|
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 353 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-rotate-ccw"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||||
|
<path d="M3 3v5h5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-scissors"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="6" cy="6" r="3" />
|
||||||
|
<path d="M8.12 8.12 12 12" />
|
||||||
|
<path d="M20 4 8.12 15.88" />
|
||||||
|
<circle cx="6" cy="18" r="3" />
|
||||||
|
<path d="M14.8 14.8 20 20" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-settings"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 666 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-skip-back"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M17.971 4.285A2 2 0 0 1 21 6v12a2 2 0 0 1-3.029 1.715l-9.997-5.998a2 2 0 0 1-.003-3.432z" />
|
||||||
|
<path d="M3 20V4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-skip-forward"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 4v16" />
|
||||||
|
<path d="M6.029 4.285A2 2 0 0 0 3 6v12a2 2 0 0 0 3.029 1.715l9.997-5.998a2 2 0 0 0 .003-3.432z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 417 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-triangle-alert"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
||||||
|
<path d="M12 9v4" />
|
||||||
|
<path d="M12 17h.01" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 431 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-volume-2"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
|
||||||
|
<path d="M16 9a5 5 0 0 1 0 6" />
|
||||||
|
<path d="M19.364 18.364a9 9 0 0 0 0-12.728" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 556 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-x"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 333 B |
@@ -0,0 +1,466 @@
|
|||||||
|
//! The root view: navigation, the shared state each screen reads, and the
|
||||||
|
//! window frame (sidebar and main area).
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
cell::Cell,
|
||||||
|
rc::Rc,
|
||||||
|
time::{Duration, SystemTime},
|
||||||
|
};
|
||||||
|
|
||||||
|
use clipbin_core::{Library, RecordingStatus, Settings, library::format_bytes};
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, Bounds, ClickEvent, Context, FontWeight, IntoElement, Pixels, Render, SharedString,
|
||||||
|
Window, div, prelude::*, px,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
components::{icon, meter},
|
||||||
|
screens, theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Route {
|
||||||
|
Sessions,
|
||||||
|
Session(u64),
|
||||||
|
Clips,
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the recorder is doing, as the sidebar shows it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Recorder {
|
||||||
|
pub status: RecordingStatus,
|
||||||
|
pub game: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The open session's playhead and clip selection.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Editor {
|
||||||
|
pub playhead: Duration,
|
||||||
|
pub selection: (Duration, Duration),
|
||||||
|
pub playing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a press on the timeline is dragging.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Drag {
|
||||||
|
Playhead,
|
||||||
|
SelectionStart,
|
||||||
|
SelectionEnd,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ClipbinApp {
|
||||||
|
pub route: Route,
|
||||||
|
pub library: Library,
|
||||||
|
pub settings: Settings,
|
||||||
|
pub recorder: Recorder,
|
||||||
|
pub editor: Editor,
|
||||||
|
/// A short message for actions that are not connected yet.
|
||||||
|
pub notice: Option<SharedString>,
|
||||||
|
pub drag: Option<Drag>,
|
||||||
|
/// Where the timeline was last laid out, to turn pointer positions into times.
|
||||||
|
pub timeline_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipbinApp {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let settings = Settings::default();
|
||||||
|
// Development aid: CLIPBIN_SAMPLE_LIBRARY=1 fills the screens with
|
||||||
|
// example sessions; CLIPBIN_SAMPLE_STATUS picks the recorder state and
|
||||||
|
// CLIPBIN_ROUTE the first screen, for screenshots.
|
||||||
|
let sample = std::env::var_os("CLIPBIN_SAMPLE_LIBRARY").is_some();
|
||||||
|
let library = if sample {
|
||||||
|
Library::sample(SystemTime::now())
|
||||||
|
} else {
|
||||||
|
Library::empty(settings.unprotected_session_limit_bytes)
|
||||||
|
};
|
||||||
|
let recorder = match (sample, std::env::var("CLIPBIN_SAMPLE_STATUS").as_deref()) {
|
||||||
|
(_, Ok("failed")) => Recorder {
|
||||||
|
status: RecordingStatus::Failed {
|
||||||
|
reason: "The capture source stopped providing a picture.".into(),
|
||||||
|
},
|
||||||
|
game: Some("Counter-Strike 2".into()),
|
||||||
|
},
|
||||||
|
(_, Ok("waiting")) => Recorder {
|
||||||
|
status: RecordingStatus::WaitingForCapture,
|
||||||
|
game: Some("Counter-Strike 2".into()),
|
||||||
|
},
|
||||||
|
(true, _) => Recorder {
|
||||||
|
status: RecordingStatus::Recording,
|
||||||
|
game: Some("Counter-Strike 2".into()),
|
||||||
|
},
|
||||||
|
_ => Recorder {
|
||||||
|
status: RecordingStatus::Idle,
|
||||||
|
game: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut app = Self {
|
||||||
|
route: Route::Sessions,
|
||||||
|
library,
|
||||||
|
settings,
|
||||||
|
recorder,
|
||||||
|
editor: Editor {
|
||||||
|
playhead: Duration::ZERO,
|
||||||
|
selection: (Duration::ZERO, Duration::ZERO),
|
||||||
|
playing: false,
|
||||||
|
},
|
||||||
|
notice: None,
|
||||||
|
drag: None,
|
||||||
|
timeline_bounds: Rc::default(),
|
||||||
|
};
|
||||||
|
match std::env::var("CLIPBIN_ROUTE").as_deref() {
|
||||||
|
Ok("clips") => app.route = Route::Clips,
|
||||||
|
Ok("settings") => app.route = Route::Settings,
|
||||||
|
Ok(route) if route.starts_with("session") => {
|
||||||
|
let id = route.trim_start_matches("session").trim_start_matches(':');
|
||||||
|
if let Some(id) = id
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
.or(app.library.sessions.first().map(|s| s.id))
|
||||||
|
{
|
||||||
|
app.open_session(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn navigate(&mut self, route: Route, cx: &mut Context<Self>) {
|
||||||
|
self.notice = None;
|
||||||
|
match route {
|
||||||
|
Route::Session(id) => self.open_session(id),
|
||||||
|
route => self.route = route,
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a session at its first bookmark (or its latest footage), with the
|
||||||
|
/// default clip length selected up to that point.
|
||||||
|
fn open_session(&mut self, id: u64) {
|
||||||
|
let Some(session) = self.library.session(id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (playhead, selection) = match session.bookmarks.first() {
|
||||||
|
Some(bookmark) => (bookmark.at, bookmark.range(session.length)),
|
||||||
|
None => {
|
||||||
|
let end = session.length;
|
||||||
|
(end, (end.saturating_sub(self.settings.clip_duration), end))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.editor = Editor {
|
||||||
|
playhead,
|
||||||
|
selection,
|
||||||
|
playing: false,
|
||||||
|
};
|
||||||
|
self.route = Route::Session(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now(&self) -> SystemTime {
|
||||||
|
SystemTime::now()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let section = |route: Route| match (route, self.route) {
|
||||||
|
(Route::Sessions, Route::Session(_)) => true,
|
||||||
|
(route, current) => route == current,
|
||||||
|
};
|
||||||
|
let nav_item = |id: &'static str,
|
||||||
|
route: Route,
|
||||||
|
icon_name: &str,
|
||||||
|
label: &'static str,
|
||||||
|
count: Option<usize>| {
|
||||||
|
let active = section(route);
|
||||||
|
div()
|
||||||
|
.id(id)
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_3()
|
||||||
|
.h_9()
|
||||||
|
.px_3()
|
||||||
|
.rounded_md()
|
||||||
|
.text_sm()
|
||||||
|
.cursor_pointer()
|
||||||
|
.when(active, |item| {
|
||||||
|
item.bg(theme::raised())
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.font_weight(FontWeight::MEDIUM)
|
||||||
|
})
|
||||||
|
.when(!active, |item| {
|
||||||
|
item.text_color(theme::muted())
|
||||||
|
.hover(|style| style.bg(theme::hover()).text_color(theme::text()))
|
||||||
|
})
|
||||||
|
.child(icon(icon_name).text_color(if active {
|
||||||
|
theme::primary_light()
|
||||||
|
} else {
|
||||||
|
theme::subtle()
|
||||||
|
}))
|
||||||
|
.child(div().flex_1().child(label))
|
||||||
|
.children(count.map(|count| {
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme::subtle())
|
||||||
|
.child(count.to_string())
|
||||||
|
}))
|
||||||
|
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| this.navigate(route, cx)))
|
||||||
|
};
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.w(px(232.))
|
||||||
|
.h_full()
|
||||||
|
.p_3()
|
||||||
|
.gap_6()
|
||||||
|
.bg(theme::sidebar())
|
||||||
|
.border_r_1()
|
||||||
|
.border_color(theme::border())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2p5()
|
||||||
|
.px_2()
|
||||||
|
.pt_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.size_8()
|
||||||
|
.rounded_lg()
|
||||||
|
.bg(theme::primary())
|
||||||
|
.child(icon("clapperboard").text_color(theme::foreground())),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_base()
|
||||||
|
.font_weight(FontWeight::BOLD)
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.child("clipbin"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_0p5()
|
||||||
|
.child(nav_item(
|
||||||
|
"nav-sessions",
|
||||||
|
Route::Sessions,
|
||||||
|
"layout-grid",
|
||||||
|
"Sessions",
|
||||||
|
Some(self.library.sessions.len()),
|
||||||
|
))
|
||||||
|
.child(nav_item(
|
||||||
|
"nav-clips",
|
||||||
|
Route::Clips,
|
||||||
|
"film",
|
||||||
|
"Clips",
|
||||||
|
Some(self.library.clips.len()),
|
||||||
|
))
|
||||||
|
.child(nav_item(
|
||||||
|
"nav-settings",
|
||||||
|
Route::Settings,
|
||||||
|
"settings",
|
||||||
|
"Settings",
|
||||||
|
None,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(self.recorder_card())
|
||||||
|
.child(self.storage_card())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recorder_card(&self) -> impl IntoElement {
|
||||||
|
let (colour, detail): (gpui::Hsla, String) = match &self.recorder.status {
|
||||||
|
RecordingStatus::Recording => (
|
||||||
|
theme::destructive().into(),
|
||||||
|
self.recorder.game.clone().unwrap_or_default(),
|
||||||
|
),
|
||||||
|
RecordingStatus::Starting | RecordingStatus::WaitingForCapture => (
|
||||||
|
theme::warning().into(),
|
||||||
|
self.recorder.game.clone().unwrap_or_default(),
|
||||||
|
),
|
||||||
|
RecordingStatus::Failed { .. } => {
|
||||||
|
(theme::destructive().into(), "Needs attention".into())
|
||||||
|
}
|
||||||
|
RecordingStatus::Idle => (theme::faint().into(), "Waiting for a game".into()),
|
||||||
|
};
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_1()
|
||||||
|
.p_3()
|
||||||
|
.rounded_lg()
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme::border())
|
||||||
|
.bg(theme::card())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.child(div().size_2().rounded_full().bg(colour))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.font_weight(FontWeight::SEMIBOLD)
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.child(self.recorder.status.label()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.pl_4()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme::subtle())
|
||||||
|
.truncate()
|
||||||
|
.child(detail),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn storage_card(&self) -> impl IntoElement {
|
||||||
|
let storage = self.library.storage;
|
||||||
|
let budget = storage.budget_bytes.max(1) as f32;
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_2()
|
||||||
|
.px_1()
|
||||||
|
.pb_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_1p5()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme::muted())
|
||||||
|
.child(icon("hard-drive").size_3p5())
|
||||||
|
.child("Session footage"),
|
||||||
|
)
|
||||||
|
.child(meter(&[(
|
||||||
|
storage.unprotected_bytes as f32 / budget,
|
||||||
|
theme::primary().into(),
|
||||||
|
)]))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.text_xs()
|
||||||
|
.child(div().text_color(theme::text()).child(format!(
|
||||||
|
"{} of {}",
|
||||||
|
format_bytes(storage.unprotected_bytes),
|
||||||
|
format_bytes(storage.budget_bytes)
|
||||||
|
)))
|
||||||
|
.child(div().text_color(theme::faint()).child(format!(
|
||||||
|
"+ {} kept",
|
||||||
|
format_bytes(storage.protected_bytes + storage.clip_bytes)
|
||||||
|
))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure_banner(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||||
|
let RecordingStatus::Failed { reason } = &self.recorder.status else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_3()
|
||||||
|
.px_4()
|
||||||
|
.py_3()
|
||||||
|
.rounded_lg()
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme::destructive_alpha(0.35))
|
||||||
|
.bg(theme::destructive_alpha(0.08))
|
||||||
|
.child(icon("triangle-alert").text_color(theme::destructive()))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.font_weight(FontWeight::SEMIBOLD)
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.child("Recording stopped"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme::muted())
|
||||||
|
.child(SharedString::from(reason.clone())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
crate::components::button(
|
||||||
|
"select-source",
|
||||||
|
crate::components::ButtonStyle::Secondary,
|
||||||
|
Some("monitor"),
|
||||||
|
Some("Choose capture source"),
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||||
|
this.notice =
|
||||||
|
Some("Choosing a source arrives with the recorder integration.".into());
|
||||||
|
cx.notify();
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_any_element(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for ClipbinApp {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let screen = match self.route {
|
||||||
|
Route::Sessions => screens::sessions::render(self, cx),
|
||||||
|
Route::Session(id) => screens::editor::render(self, id, cx),
|
||||||
|
Route::Clips => screens::clips::render(self, cx),
|
||||||
|
Route::Settings => screens::settings::render(self, cx),
|
||||||
|
};
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.size_full()
|
||||||
|
.bg(theme::background())
|
||||||
|
.text_color(theme::text())
|
||||||
|
.font_family(theme::FONT)
|
||||||
|
.child(self.sidebar(cx))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id("main")
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_1()
|
||||||
|
.h_full()
|
||||||
|
.overflow_y_scroll()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_6()
|
||||||
|
.w_full()
|
||||||
|
.max_w(px(1400.))
|
||||||
|
.px_8()
|
||||||
|
.py_8()
|
||||||
|
.children(self.failure_banner(cx))
|
||||||
|
.children(self.notice.clone().map(|notice| {
|
||||||
|
div()
|
||||||
|
.px_4()
|
||||||
|
.py_2p5()
|
||||||
|
.rounded_lg()
|
||||||
|
.bg(theme::primary_alpha(0.1))
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme::primary_alpha(0.3))
|
||||||
|
.text_sm()
|
||||||
|
.text_color(theme::text())
|
||||||
|
.child(notice)
|
||||||
|
}))
|
||||||
|
.child(screen),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
//! Fonts and icons compiled into the binary, so the interface needs no files
|
||||||
|
//! beside the executable. Geist Mono is under the SIL OFL (assets/fonts/OFL.txt)
|
||||||
|
//! and the icons are Lucide, under ISC (assets/icons/LICENSE).
|
||||||
|
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
use gpui::{App, AssetSource, Result, SharedString};
|
||||||
|
|
||||||
|
macro_rules! embedded {
|
||||||
|
($($path:literal),* $(,)?) => {
|
||||||
|
&[$(($path, include_bytes!(concat!("../assets/", $path)))),*]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILES: &[(&str, &[u8])] = embedded![
|
||||||
|
"icons/app-window.svg",
|
||||||
|
"icons/arrow-left.svg",
|
||||||
|
"icons/bookmark.svg",
|
||||||
|
"icons/chevron-left.svg",
|
||||||
|
"icons/chevron-right.svg",
|
||||||
|
"icons/circle-dot.svg",
|
||||||
|
"icons/clapperboard.svg",
|
||||||
|
"icons/clock.svg",
|
||||||
|
"icons/download.svg",
|
||||||
|
"icons/film.svg",
|
||||||
|
"icons/folder-open.svg",
|
||||||
|
"icons/gamepad-2.svg",
|
||||||
|
"icons/hard-drive.svg",
|
||||||
|
"icons/keyboard.svg",
|
||||||
|
"icons/layout-grid.svg",
|
||||||
|
"icons/mic.svg",
|
||||||
|
"icons/monitor.svg",
|
||||||
|
"icons/pause.svg",
|
||||||
|
"icons/play.svg",
|
||||||
|
"icons/power.svg",
|
||||||
|
"icons/rotate-ccw.svg",
|
||||||
|
"icons/scissors.svg",
|
||||||
|
"icons/settings.svg",
|
||||||
|
"icons/skip-back.svg",
|
||||||
|
"icons/skip-forward.svg",
|
||||||
|
"icons/triangle-alert.svg",
|
||||||
|
"icons/volume-2.svg",
|
||||||
|
"icons/x.svg",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FONTS: &[&[u8]] = &[
|
||||||
|
include_bytes!("../assets/fonts/GeistMono-Regular.ttf"),
|
||||||
|
include_bytes!("../assets/fonts/GeistMono-Medium.ttf"),
|
||||||
|
include_bytes!("../assets/fonts/GeistMono-SemiBold.ttf"),
|
||||||
|
include_bytes!("../assets/fonts/GeistMono-Bold.ttf"),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct Assets;
|
||||||
|
|
||||||
|
impl AssetSource for Assets {
|
||||||
|
fn load(&self, path: &str) -> Result<Option<Cow<'static, [u8]>>> {
|
||||||
|
Ok(FILES
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| *name == path)
|
||||||
|
.map(|(_, bytes)| Cow::Borrowed(*bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||||
|
Ok(FILES
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, _)| name.starts_with(path))
|
||||||
|
.map(|(name, _)| SharedString::from(*name))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_fonts(cx: &App) -> Result<()> {
|
||||||
|
cx.text_system()
|
||||||
|
.add_fonts(FONTS.iter().map(|font| Cow::Borrowed(*font)).collect())
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
//! Small building blocks shared by the screens.
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, Div, ElementId, FontWeight, Hsla, IntoElement, ParentElement, SharedString,
|
||||||
|
Stateful, Styled, Svg, div, prelude::*, px, svg,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
pub fn icon(name: &str) -> Svg {
|
||||||
|
svg()
|
||||||
|
.path(SharedString::from(format!("icons/{name}.svg")))
|
||||||
|
.size_4()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_color(theme::muted())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gives a box a fixed width-to-height ratio, like CSS `aspect-ratio`.
|
||||||
|
pub fn aspect_ratio<E: Styled>(mut element: E, ratio: f32) -> E {
|
||||||
|
element.style().aspect_ratio = Some(ratio);
|
||||||
|
element
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ButtonStyle {
|
||||||
|
Primary,
|
||||||
|
Secondary,
|
||||||
|
Ghost,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clickable row with an optional icon and label.
|
||||||
|
pub fn button(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
style: ButtonStyle,
|
||||||
|
icon_name: Option<&str>,
|
||||||
|
label: Option<&str>,
|
||||||
|
) -> Stateful<Div> {
|
||||||
|
let (background, foreground, hover) = match style {
|
||||||
|
ButtonStyle::Primary => (
|
||||||
|
theme::primary().into(),
|
||||||
|
theme::foreground(),
|
||||||
|
theme::primary_alpha(0.85),
|
||||||
|
),
|
||||||
|
ButtonStyle::Secondary => (
|
||||||
|
Hsla::from(theme::raised()),
|
||||||
|
theme::text(),
|
||||||
|
Hsla::from(theme::border()),
|
||||||
|
),
|
||||||
|
ButtonStyle::Ghost => (
|
||||||
|
Hsla::transparent_black(),
|
||||||
|
theme::muted(),
|
||||||
|
Hsla::from(theme::hover()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let mut button = div()
|
||||||
|
.id(id)
|
||||||
|
.flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_2()
|
||||||
|
.h_8()
|
||||||
|
.rounded_md()
|
||||||
|
.bg(background)
|
||||||
|
.text_color(foreground)
|
||||||
|
.text_sm()
|
||||||
|
.font_weight(FontWeight::MEDIUM)
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(move |style| style.bg(hover));
|
||||||
|
button = match label {
|
||||||
|
Some(_) => button.px_3(),
|
||||||
|
None => button.w_8(),
|
||||||
|
};
|
||||||
|
if let Some(name) = icon_name {
|
||||||
|
button = button.child(icon(name).text_color(foreground));
|
||||||
|
}
|
||||||
|
if let Some(label) = label {
|
||||||
|
button = button.child(SharedString::from(label.to_owned()));
|
||||||
|
}
|
||||||
|
button
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small rounded label, as on video cards.
|
||||||
|
pub fn chip(text: impl Into<SharedString>) -> Div {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_1()
|
||||||
|
.px_1p5()
|
||||||
|
.py_0p5()
|
||||||
|
.rounded_sm()
|
||||||
|
.bg(theme::overlay())
|
||||||
|
.text_xs()
|
||||||
|
.font_weight(FontWeight::MEDIUM)
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.child(text.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page title with a muted description and actions on the right.
|
||||||
|
pub fn page_header(
|
||||||
|
title: impl Into<SharedString>,
|
||||||
|
description: impl Into<SharedString>,
|
||||||
|
actions: Vec<AnyElement>,
|
||||||
|
) -> Div {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_end()
|
||||||
|
.justify_between()
|
||||||
|
.gap_4()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_2xl()
|
||||||
|
.font_weight(FontWeight::BOLD)
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.child(title.into()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(theme::subtle())
|
||||||
|
.child(description.into()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(div().flex().gap_2().children(actions))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bordered panel with a small heading.
|
||||||
|
pub fn panel(title: impl Into<SharedString>) -> Div {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_3()
|
||||||
|
.p_4()
|
||||||
|
.rounded_lg()
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme::border())
|
||||||
|
.bg(theme::card())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_weight(FontWeight::SEMIBOLD)
|
||||||
|
.text_color(theme::subtle())
|
||||||
|
.child(title.into()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A label on the left and a value on the right.
|
||||||
|
pub fn row(label: impl Into<SharedString>, value: impl IntoElement) -> Div {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.gap_4()
|
||||||
|
.text_sm()
|
||||||
|
.child(div().text_color(theme::muted()).child(label.into()))
|
||||||
|
.child(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A centred message for a screen with nothing to show yet.
|
||||||
|
pub fn empty_state(
|
||||||
|
icon_name: &str,
|
||||||
|
title: impl Into<SharedString>,
|
||||||
|
description: impl Into<SharedString>,
|
||||||
|
) -> Div {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_1()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_3()
|
||||||
|
.py_24()
|
||||||
|
.rounded_xl()
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme::border())
|
||||||
|
.border_dashed()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.size_12()
|
||||||
|
.rounded_full()
|
||||||
|
.bg(theme::surface())
|
||||||
|
.child(icon(icon_name).size_5()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_base()
|
||||||
|
.font_weight(FontWeight::SEMIBOLD)
|
||||||
|
.text_color(theme::text())
|
||||||
|
.child(title.into()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.max_w(px(420.))
|
||||||
|
.text_center()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(theme::subtle())
|
||||||
|
.child(description.into()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thin horizontal progress bar made of coloured parts.
|
||||||
|
pub fn meter(parts: &[(f32, Hsla)]) -> Div {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.h_1p5()
|
||||||
|
.w_full()
|
||||||
|
.rounded_full()
|
||||||
|
.overflow_hidden()
|
||||||
|
.bg(theme::raised())
|
||||||
|
.children(parts.iter().map(|(fraction, colour)| {
|
||||||
|
div()
|
||||||
|
.h_full()
|
||||||
|
.w(gpui::relative(fraction.clamp(0.0, 1.0)))
|
||||||
|
.bg(*colour)
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -3,46 +3,53 @@ use gpui::{
|
|||||||
actions, px, size,
|
actions, px, size,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod view;
|
mod app;
|
||||||
|
mod assets;
|
||||||
|
mod components;
|
||||||
|
mod screens;
|
||||||
|
mod theme;
|
||||||
|
|
||||||
actions!(clipbin, [Quit]);
|
actions!(clipbin, [Quit]);
|
||||||
|
|
||||||
fn quit(_: &Quit, cx: &mut App) {
|
|
||||||
cx.quit();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
Application::new().run(|cx: &mut App| {
|
Application::new()
|
||||||
cx.on_action(quit);
|
.with_assets(assets::Assets)
|
||||||
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
|
.run(|cx: &mut App| {
|
||||||
|
if let Err(error) = assets::load_fonts(cx) {
|
||||||
// This initial shell has no background recorder yet.
|
eprintln!("Could not load the interface font: {error}");
|
||||||
cx.on_window_closed(|cx| {
|
|
||||||
if cx.windows().is_empty() {
|
|
||||||
cx.quit();
|
|
||||||
}
|
}
|
||||||
})
|
cx.on_action(|_: &Quit, cx| cx.quit());
|
||||||
.detach();
|
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
|
||||||
|
|
||||||
let bounds = Bounds::centered(None, size(px(1080.0), px(720.0)), cx);
|
// This shell has no background recorder yet.
|
||||||
let result = cx.open_window(
|
cx.on_window_closed(|cx| {
|
||||||
WindowOptions {
|
if cx.windows().is_empty() {
|
||||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
cx.quit();
|
||||||
titlebar: Some(TitlebarOptions {
|
}
|
||||||
title: Some("Clipbin".into()),
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx);
|
||||||
|
let result = cx.open_window(
|
||||||
|
WindowOptions {
|
||||||
|
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||||
|
window_min_size: Some(size(px(1040.), px(640.))),
|
||||||
|
titlebar: Some(TitlebarOptions {
|
||||||
|
title: Some("Clipbin".into()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
app_id: Some("dev.clipbin.Clipbin".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
},
|
||||||
..Default::default()
|
|_, cx| cx.new(|_| app::ClipbinApp::new()),
|
||||||
},
|
);
|
||||||
|_, cx| cx.new(|_| view::ClipbinView::default()),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(error) = result {
|
if let Err(error) = result {
|
||||||
eprintln!("Could not open Clipbin: {error}");
|
eprintln!("Could not open Clipbin: {error}");
|
||||||
cx.quit();
|
cx.quit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.activate(true);
|
cx.activate(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! 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, div, prelude::*};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::ClipbinApp,
|
||||||
|
components::{ButtonStyle, button, chip, empty_state, page_header},
|
||||||
|
screens::{corner, muted_line, 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!(
|
||||||
|
"{} clips · {} · kept until you delete them",
|
||||||
|
clips.len(),
|
||||||
|
format_bytes(total)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
vec![
|
||||||
|
button(
|
||||||
|
"open-clips-folder",
|
||||||
|
ButtonStyle::Secondary,
|
||||||
|
Some("folder-open"),
|
||||||
|
Some("Open folder"),
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
||||||
|
app.notice =
|
||||||
|
Some("Opening the clips folder comes with the recorder integration.".into());
|
||||||
|
cx.notify();
|
||||||
|
}))
|
||||||
|
.into_any_element(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if clips.is_empty() {
|
||||||
|
return div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_6()
|
||||||
|
.child(header)
|
||||||
|
.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| {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
thumbnail(&clip.game).child(
|
||||||
|
corner()
|
||||||
|
.bottom_2p5()
|
||||||
|
.right_2p5()
|
||||||
|
.child(chip(format_length(clip.length))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_start()
|
||||||
|
.gap_2()
|
||||||
|
.px_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_0p5()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_base()
|
||||||
|
.font_weight(FontWeight::SEMIBOLD)
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.truncate()
|
||||||
|
.child(clip.game.clone()),
|
||||||
|
)
|
||||||
|
.child(muted_line(format!(
|
||||||
|
"{} · {}",
|
||||||
|
format_ago(clip.created, now),
|
||||||
|
format_bytes(clip.size_bytes)
|
||||||
|
))),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
button(
|
||||||
|
("open-clip", clip.id),
|
||||||
|
ButtonStyle::Ghost,
|
||||||
|
Some("play"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.rounded_full()
|
||||||
|
.on_click(cx.listener(
|
||||||
|
|app, _: &ClickEvent, _, cx| {
|
||||||
|
app.notice = Some(
|
||||||
|
"Opening clips comes with the recorder integration.".into(),
|
||||||
|
);
|
||||||
|
cx.notify();
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_6()
|
||||||
|
.child(header)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.grid()
|
||||||
|
.grid_cols(3)
|
||||||
|
.gap_x_4()
|
||||||
|
.gap_y_8()
|
||||||
|
.children(cards),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
//! A session's timeline: preview, bookmarks, and choosing a clip to export.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clipbin_core::library::{
|
||||||
|
Session, SpanKind, export_range, format_ago, format_clock, format_length,
|
||||||
|
};
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, Bounds, ClickEvent, Context, FontWeight, Hsla, IntoElement, MouseButton,
|
||||||
|
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, canvas, div, pattern_slash,
|
||||||
|
prelude::*, px, relative,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::{ClipbinApp, Drag, Route},
|
||||||
|
components::{ButtonStyle, aspect_ratio, button, chip, icon, panel, row},
|
||||||
|
screens::backdrop,
|
||||||
|
theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Keyframes come every second (clipbin-obs records with keyint_sec = 1).
|
||||||
|
const KEYFRAME_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
/// Selection handles respond within this distance of the pointer.
|
||||||
|
const HANDLE_REACH: f32 = 10.;
|
||||||
|
|
||||||
|
pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
||||||
|
let Some(session) = app.library.session(id) else {
|
||||||
|
return div()
|
||||||
|
.child("This session no longer exists.")
|
||||||
|
.into_any_element();
|
||||||
|
};
|
||||||
|
let editor = app.editor;
|
||||||
|
let length = session.length;
|
||||||
|
let (start, end) = editor.selection;
|
||||||
|
|
||||||
|
let header = div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
button("back", ButtonStyle::Ghost, Some("arrow-left"), None).on_click(
|
||||||
|
cx.listener(|app, _: &ClickEvent, _, cx| app.navigate(Route::Sessions, cx)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.text_xl()
|
||||||
|
.font_weight(FontWeight::BOLD)
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.child(session.game.clone())
|
||||||
|
.when(session.active, |title| {
|
||||||
|
title.child(chip("REC").bg(theme::destructive()))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(div().text_xs().text_color(theme::subtle()).child(format!(
|
||||||
|
"{} · {}",
|
||||||
|
format_ago(session.started, app.now()),
|
||||||
|
format_length(length)
|
||||||
|
))),
|
||||||
|
);
|
||||||
|
|
||||||
|
let preview = aspect_ratio(div(), 16. / 9.)
|
||||||
|
.relative()
|
||||||
|
.w_full()
|
||||||
|
.overflow_hidden()
|
||||||
|
.rounded_lg()
|
||||||
|
.bg(gpui::black())
|
||||||
|
.child(backdrop(&session.game).absolute().size_full().opacity(0.55))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.size_full()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.size(px(56.))
|
||||||
|
.rounded_full()
|
||||||
|
.bg(theme::overlay())
|
||||||
|
.child(icon("play").size_6().text_color(theme::foreground())),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme::muted())
|
||||||
|
.child("Preview arrives with playback"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.top_3()
|
||||||
|
.right_3()
|
||||||
|
.child(chip(format_clock(editor.playhead))),
|
||||||
|
);
|
||||||
|
|
||||||
|
let previous_bookmark = session
|
||||||
|
.bookmarks
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|b| b.at < editor.playhead)
|
||||||
|
.map(|b| b.at);
|
||||||
|
let next_bookmark = session
|
||||||
|
.bookmarks
|
||||||
|
.iter()
|
||||||
|
.find(|b| b.at > editor.playhead)
|
||||||
|
.map(|b| b.at);
|
||||||
|
let transport = div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
button(
|
||||||
|
"play",
|
||||||
|
ButtonStyle::Secondary,
|
||||||
|
Some(if editor.playing { "pause" } else { "play" }),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
||||||
|
app.notice =
|
||||||
|
Some("Playback isn't connected yet; it comes with the decoder.".into());
|
||||||
|
cx.notify();
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
button(
|
||||||
|
"previous-bookmark",
|
||||||
|
ButtonStyle::Ghost,
|
||||||
|
Some("skip-back"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||||
|
if let Some(at) = previous_bookmark {
|
||||||
|
app.editor.playhead = at;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
button(
|
||||||
|
"next-bookmark",
|
||||||
|
ButtonStyle::Ghost,
|
||||||
|
Some("skip-forward"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||||
|
if let Some(at) = next_bookmark {
|
||||||
|
app.editor.playhead = at;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.ml_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.font_weight(FontWeight::MEDIUM)
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.child(format_clock(editor.playhead)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(theme::faint())
|
||||||
|
.child(format!("/ {}", format_clock(length))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_4()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme::subtle())
|
||||||
|
.child(legend(theme::footage().into(), "Footage"))
|
||||||
|
.child(legend(theme::warning().into(), "Bookmark"))
|
||||||
|
.child(legend(theme::primary().into(), "Clip"))
|
||||||
|
.child(legend(theme::destructive_alpha(0.6), "Missing"))
|
||||||
|
.child(legend(theme::faint().into(), "Removed")),
|
||||||
|
);
|
||||||
|
|
||||||
|
let left = div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_3()
|
||||||
|
.child(preview)
|
||||||
|
.child(transport)
|
||||||
|
.child(timeline(app, session, cx));
|
||||||
|
|
||||||
|
let (export_start, export_end) = export_range(start, end, KEYFRAME_INTERVAL, length);
|
||||||
|
let clip_panel = panel("CLIP")
|
||||||
|
.child(row("Start", value(format_clock(start))))
|
||||||
|
.child(row("End", value(format_clock(end))))
|
||||||
|
.child(row(
|
||||||
|
"Length",
|
||||||
|
value(format_length(end.saturating_sub(start))),
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_1()
|
||||||
|
.p_2p5()
|
||||||
|
.rounded_md()
|
||||||
|
.bg(theme::surface())
|
||||||
|
.text_xs()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(theme::muted())
|
||||||
|
.child("Exported on keyframes"),
|
||||||
|
)
|
||||||
|
.child(div().text_color(theme::text()).child(format!(
|
||||||
|
"{} – {} · {}",
|
||||||
|
format_clock(export_start),
|
||||||
|
format_clock(export_end),
|
||||||
|
format_length(export_end.saturating_sub(export_start))
|
||||||
|
))),
|
||||||
|
)
|
||||||
|
.child(row(
|
||||||
|
"Audio",
|
||||||
|
value(if app.settings.microphone_enabled {
|
||||||
|
"Game + mic, mixed".to_owned()
|
||||||
|
} else {
|
||||||
|
"Game audio".to_owned()
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
div().flex().gap_2().pt_1().child(
|
||||||
|
button(
|
||||||
|
"export",
|
||||||
|
ButtonStyle::Primary,
|
||||||
|
Some("scissors"),
|
||||||
|
Some("Export clip"),
|
||||||
|
)
|
||||||
|
.flex_1()
|
||||||
|
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
||||||
|
app.notice = Some(
|
||||||
|
"Exporting isn't connected yet; it comes with the recorder integration."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
cx.notify();
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let bookmarks_panel = panel(format!("BOOKMARKS · {}", session.bookmarks.len())).children(
|
||||||
|
if session.bookmarks.is_empty() {
|
||||||
|
vec![
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme::subtle())
|
||||||
|
.child("Press the bookmark hotkey while playing to keep a moment.")
|
||||||
|
.into_any_element(),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
session
|
||||||
|
.bookmarks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, bookmark)| {
|
||||||
|
let range = bookmark.range(length);
|
||||||
|
let selected = editor.selection == range;
|
||||||
|
let at = bookmark.at;
|
||||||
|
div()
|
||||||
|
.id(("bookmark", index))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2p5()
|
||||||
|
.px_2()
|
||||||
|
.py_1p5()
|
||||||
|
.rounded_md()
|
||||||
|
.cursor_pointer()
|
||||||
|
.when(selected, |item| item.bg(theme::primary_alpha(0.15)))
|
||||||
|
.hover(|style| style.bg(theme::hover()))
|
||||||
|
.child(icon("bookmark").size_3p5().text_color(theme::warning()))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(theme::text())
|
||||||
|
.child(format_clock(bookmark.at)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(if bookmark.protected {
|
||||||
|
theme::success()
|
||||||
|
} else {
|
||||||
|
theme::subtle()
|
||||||
|
})
|
||||||
|
.child(if bookmark.protected {
|
||||||
|
"Kept"
|
||||||
|
} else {
|
||||||
|
"Released"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||||
|
app.editor.playhead = at;
|
||||||
|
app.editor.selection = range;
|
||||||
|
cx.notify();
|
||||||
|
}))
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let session_panel = panel("SESSION")
|
||||||
|
.child(row("Length", value(format_length(length))))
|
||||||
|
.child(row(
|
||||||
|
"Not captured",
|
||||||
|
value(match session.missing_time() {
|
||||||
|
Duration::ZERO => "None".to_owned(),
|
||||||
|
missing => format_length(missing),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.child(row(
|
||||||
|
"Cleaned up",
|
||||||
|
value(match session.removed_time() {
|
||||||
|
Duration::ZERO => "None".to_owned(),
|
||||||
|
removed => format_length(removed),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.child(row(
|
||||||
|
"Kept by bookmarks",
|
||||||
|
value(format!(
|
||||||
|
"{} of {}",
|
||||||
|
session.protected_bookmarks(),
|
||||||
|
session.bookmarks.len()
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_5()
|
||||||
|
.child(header)
|
||||||
|
.child(
|
||||||
|
div().flex().items_start().gap_6().child(left).child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.w(px(300.))
|
||||||
|
.gap_4()
|
||||||
|
.child(clip_panel)
|
||||||
|
.child(bookmarks_panel)
|
||||||
|
.child(session_panel),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(text: String) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.font_weight(FontWeight::MEDIUM)
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.child(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legend(colour: Hsla, label: &'static str) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(div().size_2().rounded_sm().bg(colour))
|
||||||
|
.child(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fraction of the session at `time`.
|
||||||
|
fn fraction(time: Duration, length: Duration) -> f32 {
|
||||||
|
(time.as_secs_f32() / length.as_secs_f32().max(1.)).clamp(0., 1.)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn time_at(position: Point<Pixels>, bounds: Bounds<Pixels>, length: Duration) -> Duration {
|
||||||
|
let x = (position.x - bounds.left()) / bounds.size.width;
|
||||||
|
length.mul_f32(x.clamp(0., 1.))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session timeline: footage and gaps, protected bookmark ranges, the
|
||||||
|
/// clip selection with its handles, and the playhead. Click to seek; drag the
|
||||||
|
/// selection's edges to change the clip.
|
||||||
|
fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -> impl IntoElement {
|
||||||
|
let length = session.length;
|
||||||
|
let (start, end) = app.editor.selection;
|
||||||
|
let bounds = app.timeline_bounds.clone();
|
||||||
|
|
||||||
|
let track = div()
|
||||||
|
.absolute()
|
||||||
|
.left_0()
|
||||||
|
.right_0()
|
||||||
|
.top(px(30.))
|
||||||
|
.h(px(8.))
|
||||||
|
.flex()
|
||||||
|
.rounded_full()
|
||||||
|
.overflow_hidden()
|
||||||
|
.children(session.spans.iter().map(|span| {
|
||||||
|
let width = fraction(span.end, length) - fraction(span.start, length);
|
||||||
|
let piece = div().h_full().w(relative(width));
|
||||||
|
match span.kind {
|
||||||
|
SpanKind::Footage => piece.bg(theme::footage()),
|
||||||
|
SpanKind::Missing => piece.bg(pattern_slash(theme::destructive_alpha(0.7), 2., 4.)),
|
||||||
|
SpanKind::Removed => piece.bg(pattern_slash(Hsla::from(theme::faint()), 2., 4.)),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let protected = session
|
||||||
|
.bookmarks
|
||||||
|
.iter()
|
||||||
|
.filter(|b| b.protected)
|
||||||
|
.map(|bookmark| {
|
||||||
|
let (from, to) = bookmark.range(length);
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.top(px(26.))
|
||||||
|
.h_4()
|
||||||
|
.left(relative(fraction(from, length)))
|
||||||
|
.w(relative(fraction(to, length) - fraction(from, length)))
|
||||||
|
.rounded_sm()
|
||||||
|
.bg(Hsla::from(theme::warning()).opacity(0.12))
|
||||||
|
});
|
||||||
|
|
||||||
|
let markers = session.bookmarks.iter().map(|bookmark| {
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.top(px(4.))
|
||||||
|
.left(relative(fraction(bookmark.at, length)))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.items_center()
|
||||||
|
.ml(px(-6.))
|
||||||
|
.child(icon("bookmark").size_3().text_color(theme::warning()))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(1.))
|
||||||
|
.h(px(34.))
|
||||||
|
.bg(Hsla::from(theme::warning()).opacity(0.6)),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let handle = || div().w(px(4.)).h_5().rounded_sm().bg(theme::foreground());
|
||||||
|
let selection = div()
|
||||||
|
.absolute()
|
||||||
|
.top(px(22.))
|
||||||
|
.h_6()
|
||||||
|
.left(relative(fraction(start, length)))
|
||||||
|
.w(relative(fraction(end, length) - fraction(start, length)))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.rounded_md()
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme::primary())
|
||||||
|
.bg(theme::primary_alpha(0.35))
|
||||||
|
.child(handle().ml(px(-2.)))
|
||||||
|
.child(handle().mr(px(-2.)));
|
||||||
|
|
||||||
|
let playhead = div()
|
||||||
|
.absolute()
|
||||||
|
.top(px(14.))
|
||||||
|
.bottom(px(10.))
|
||||||
|
.left(relative(fraction(app.editor.playhead, length)))
|
||||||
|
.w(px(2.))
|
||||||
|
.ml(px(-1.))
|
||||||
|
.rounded_full()
|
||||||
|
.bg(theme::foreground());
|
||||||
|
|
||||||
|
let ticks = (0..=4).map(|quarter| {
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme::faint())
|
||||||
|
.child(format_clock(length.mul_f32(quarter as f32 / 4.)))
|
||||||
|
});
|
||||||
|
|
||||||
|
let on_down_bounds = bounds.clone();
|
||||||
|
let on_move_bounds = bounds.clone();
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id("timeline")
|
||||||
|
.relative()
|
||||||
|
.h(px(64.))
|
||||||
|
.px_3()
|
||||||
|
.rounded_lg()
|
||||||
|
.bg(theme::surface())
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme::border())
|
||||||
|
.cursor_pointer()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.relative()
|
||||||
|
.size_full()
|
||||||
|
.child(
|
||||||
|
canvas(move |area, _, _| bounds.set(Some(area)), |_, _, _, _| {})
|
||||||
|
.absolute()
|
||||||
|
.size_full(),
|
||||||
|
)
|
||||||
|
.child(track)
|
||||||
|
.children(protected)
|
||||||
|
.child(selection)
|
||||||
|
.children(markers)
|
||||||
|
.child(playhead),
|
||||||
|
)
|
||||||
|
.on_mouse_down(
|
||||||
|
MouseButton::Left,
|
||||||
|
cx.listener(move |app, event: &MouseDownEvent, _, cx| {
|
||||||
|
let Some(area) = on_down_bounds.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let time = time_at(event.position, area, length);
|
||||||
|
let (start, end) = app.editor.selection;
|
||||||
|
let reach = length.mul_f32(HANDLE_REACH / f32::from(area.size.width));
|
||||||
|
let near = |edge: Duration| time.abs_diff(edge) <= reach;
|
||||||
|
app.drag = Some(if near(start) {
|
||||||
|
Drag::SelectionStart
|
||||||
|
} else if near(end) {
|
||||||
|
Drag::SelectionEnd
|
||||||
|
} else {
|
||||||
|
app.editor.playhead = time;
|
||||||
|
Drag::Playhead
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.on_mouse_move(cx.listener(move |app, event: &MouseMoveEvent, _, cx| {
|
||||||
|
let (Some(drag), Some(area)) = (app.drag, on_move_bounds.get()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if event.pressed_button != Some(MouseButton::Left) {
|
||||||
|
app.drag = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let time = time_at(event.position, area, length);
|
||||||
|
let (start, end) = app.editor.selection;
|
||||||
|
let minimum = Duration::from_secs(1);
|
||||||
|
match drag {
|
||||||
|
Drag::Playhead => app.editor.playhead = time,
|
||||||
|
Drag::SelectionStart => {
|
||||||
|
app.editor.selection.0 = time.min(end.saturating_sub(minimum))
|
||||||
|
}
|
||||||
|
Drag::SelectionEnd => {
|
||||||
|
app.editor.selection.1 = time.max(start + minimum).min(length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
}))
|
||||||
|
.on_mouse_up(
|
||||||
|
MouseButton::Left,
|
||||||
|
cx.listener(|app, _: &MouseUpEvent, _, cx| {
|
||||||
|
app.drag = None;
|
||||||
|
cx.notify();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(div().flex().justify_between().px_3().children(ticks))
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
pub mod clips;
|
||||||
|
pub mod editor;
|
||||||
|
pub mod sessions;
|
||||||
|
pub mod settings;
|
||||||
|
|
||||||
|
use gpui::{Div, FontWeight, Hsla, div, hsla, linear_color_stop, linear_gradient, prelude::*};
|
||||||
|
|
||||||
|
use crate::{components::aspect_ratio, theme};
|
||||||
|
|
||||||
|
/// A 16:9 thumbnail. Until frames are decoded, each game gets its own colour
|
||||||
|
/// with its initials, so cards stay recognisable.
|
||||||
|
pub fn thumbnail(game: &str) -> Div {
|
||||||
|
let tint = game_tint(game);
|
||||||
|
let initials: String = game
|
||||||
|
.split_whitespace()
|
||||||
|
.filter_map(|word| word.chars().next())
|
||||||
|
.filter(|c| c.is_alphanumeric())
|
||||||
|
.take(2)
|
||||||
|
.collect();
|
||||||
|
aspect_ratio(backdrop(game), 16. / 9.)
|
||||||
|
.relative()
|
||||||
|
.w_full()
|
||||||
|
.overflow_hidden()
|
||||||
|
.rounded_xl()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.size_full()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.text_3xl()
|
||||||
|
.font_weight(FontWeight::BOLD)
|
||||||
|
.text_color(tint(0.75, 0.22))
|
||||||
|
.child(initials),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The game's colour as a gradient fill.
|
||||||
|
pub fn backdrop(game: &str) -> Div {
|
||||||
|
let tint = game_tint(game);
|
||||||
|
div().bg(linear_gradient(
|
||||||
|
135.,
|
||||||
|
linear_color_stop(tint(0.22, 1.), 0.),
|
||||||
|
linear_color_stop(tint(0.07, 1.), 1.),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn game_tint(game: &str) -> impl Fn(f32, f32) -> Hsla {
|
||||||
|
let hue = game
|
||||||
|
.bytes()
|
||||||
|
.fold(7u32, |hash, byte| hash.wrapping_mul(31) + byte as u32)
|
||||||
|
% 360;
|
||||||
|
move |lightness, alpha| hsla(hue as f32 / 360., 0.45, lightness, alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pads a thumbnail's overlay chips into its corners.
|
||||||
|
pub fn corner() -> Div {
|
||||||
|
div().absolute().flex().gap_1p5()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn muted_line(text: String) -> Div {
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme::subtle())
|
||||||
|
.truncate()
|
||||||
|
.child(text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! The session library: every recorded session, newest first.
|
||||||
|
|
||||||
|
use clipbin_core::library::{format_ago, format_bytes, format_length};
|
||||||
|
use gpui::{AnyElement, ClickEvent, Context, FontWeight, IntoElement, div, prelude::*};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::{ClipbinApp, Route},
|
||||||
|
components::{chip, empty_state, icon, page_header},
|
||||||
|
screens::{corner, muted_line, thumbnail},
|
||||||
|
theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
||||||
|
let library = &app.library;
|
||||||
|
let now = app.now();
|
||||||
|
let description = if library.sessions.is_empty() {
|
||||||
|
"Sessions start automatically when you launch a Steam game.".to_owned()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} sessions · {} of footage within your {} budget",
|
||||||
|
library.sessions.len(),
|
||||||
|
format_bytes(library.storage.unprotected_bytes),
|
||||||
|
format_bytes(library.storage.budget_bytes)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let header = page_header("Sessions", description, Vec::new());
|
||||||
|
|
||||||
|
if library.sessions.is_empty() {
|
||||||
|
return div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_6()
|
||||||
|
.child(header)
|
||||||
|
.child(empty_state(
|
||||||
|
"gamepad-2",
|
||||||
|
"No sessions yet",
|
||||||
|
"Launch a Steam game and Clipbin records it in the background. Sessions and bookmarked moments appear here.",
|
||||||
|
))
|
||||||
|
.into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
let cards =
|
||||||
|
library.sessions.iter().map(|session| {
|
||||||
|
let id = session.id;
|
||||||
|
let bookmarks = session.bookmarks.len();
|
||||||
|
let detail = format!(
|
||||||
|
"{} · {}{}",
|
||||||
|
format_ago(session.started, now),
|
||||||
|
format_length(session.length),
|
||||||
|
match bookmarks {
|
||||||
|
0 => String::new(),
|
||||||
|
1 => " · 1 bookmark".into(),
|
||||||
|
count => format!(" · {count} bookmarks"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
div()
|
||||||
|
.id(("session", id))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_3()
|
||||||
|
.cursor_pointer()
|
||||||
|
.group("card")
|
||||||
|
.child(
|
||||||
|
thumbnail(&session.game)
|
||||||
|
.border_1()
|
||||||
|
.border_color(gpui::transparent_black())
|
||||||
|
.group_hover("card", |style| {
|
||||||
|
style.border_color(theme::primary_alpha(0.6))
|
||||||
|
})
|
||||||
|
.when(session.active, |thumb| {
|
||||||
|
thumb.child(
|
||||||
|
corner().top_2p5().left_2p5().child(
|
||||||
|
chip("REC").bg(theme::destructive()).child(
|
||||||
|
div().size_1p5().rounded_full().bg(theme::foreground()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
corner()
|
||||||
|
.bottom_2p5()
|
||||||
|
.right_2p5()
|
||||||
|
.when(bookmarks > 0, |corner| {
|
||||||
|
corner.child(chip(bookmarks.to_string()).child(
|
||||||
|
icon("bookmark").size_3().text_color(theme::warning()),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.child(chip(format_length(session.length))),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
corner()
|
||||||
|
.bottom_2p5()
|
||||||
|
.left_2p5()
|
||||||
|
.children(gap_chip(session.missing_time(), "missing"))
|
||||||
|
.children(gap_chip(session.removed_time(), "cleaned up")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_0p5()
|
||||||
|
.px_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_base()
|
||||||
|
.font_weight(FontWeight::SEMIBOLD)
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.truncate()
|
||||||
|
.child(session.game.clone()),
|
||||||
|
)
|
||||||
|
.child(muted_line(detail)),
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||||
|
app.navigate(Route::Session(id), cx)
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_6()
|
||||||
|
.child(header)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.grid()
|
||||||
|
.grid_cols(3)
|
||||||
|
.gap_x_4()
|
||||||
|
.gap_y_8()
|
||||||
|
.children(cards),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gap_chip(time: std::time::Duration, what: &str) -> Option<gpui::Div> {
|
||||||
|
(time > std::time::Duration::ZERO)
|
||||||
|
.then(|| chip(format!("{} {what}", format_length(time))).text_color(theme::muted()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
//! Recording, audio, clip and storage settings. Changes apply to this run;
|
||||||
|
//! saving them comes with settings persistence.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clipbin_core::{
|
||||||
|
Settings, VideoCodec,
|
||||||
|
library::{format_bytes, format_length},
|
||||||
|
};
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, ClickEvent, Context, Div, ElementId, FontWeight, IntoElement, SharedString, div,
|
||||||
|
prelude::*, px,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::ClipbinApp,
|
||||||
|
components::{icon, page_header},
|
||||||
|
theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Update = fn(&mut Settings);
|
||||||
|
|
||||||
|
pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
||||||
|
let settings = &app.settings;
|
||||||
|
let seconds = Duration::from_secs;
|
||||||
|
|
||||||
|
let resolution = segmented(
|
||||||
|
"resolution",
|
||||||
|
cx,
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"Match window",
|
||||||
|
settings.resolution.is_none(),
|
||||||
|
|s: &mut Settings| s.resolution = None,
|
||||||
|
),
|
||||||
|
("1080p", settings.resolution == Some((1920, 1080)), |s| {
|
||||||
|
s.resolution = Some((1920, 1080))
|
||||||
|
}),
|
||||||
|
("1440p", settings.resolution == Some((2560, 1440)), |s| {
|
||||||
|
s.resolution = Some((2560, 1440))
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let frame_rate = segmented(
|
||||||
|
"fps",
|
||||||
|
cx,
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"30",
|
||||||
|
settings.frames_per_second == 30,
|
||||||
|
|s: &mut Settings| s.frames_per_second = 30,
|
||||||
|
),
|
||||||
|
("60", settings.frames_per_second == 60, |s| {
|
||||||
|
s.frames_per_second = 60
|
||||||
|
}),
|
||||||
|
("120", settings.frames_per_second == 120, |s| {
|
||||||
|
s.frames_per_second = 120
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let codec = segmented(
|
||||||
|
"codec",
|
||||||
|
cx,
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"H.264",
|
||||||
|
settings.codec == VideoCodec::H264,
|
||||||
|
|s: &mut Settings| s.codec = VideoCodec::H264,
|
||||||
|
),
|
||||||
|
("HEVC", settings.codec == VideoCodec::Hevc, |s| {
|
||||||
|
s.codec = VideoCodec::Hevc
|
||||||
|
}),
|
||||||
|
("AV1", settings.codec == VideoCodec::Av1, |s| {
|
||||||
|
s.codec = VideoCodec::Av1
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let clip_length = segmented(
|
||||||
|
"clip",
|
||||||
|
cx,
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"30s",
|
||||||
|
settings.clip_duration == seconds(30),
|
||||||
|
|s: &mut Settings| s.clip_duration = Duration::from_secs(30),
|
||||||
|
),
|
||||||
|
("1m", settings.clip_duration == seconds(60), |s| {
|
||||||
|
s.clip_duration = Duration::from_secs(60)
|
||||||
|
}),
|
||||||
|
("2m", settings.clip_duration == seconds(120), |s| {
|
||||||
|
s.clip_duration = Duration::from_secs(120)
|
||||||
|
}),
|
||||||
|
("5m", settings.clip_duration == seconds(300), |s| {
|
||||||
|
s.clip_duration = Duration::from_secs(300)
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let budget = segmented(
|
||||||
|
"budget",
|
||||||
|
cx,
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"25 GB",
|
||||||
|
settings.unprotected_session_limit_bytes == 25_000_000_000,
|
||||||
|
|s: &mut Settings| s.unprotected_session_limit_bytes = 25_000_000_000,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"50 GB",
|
||||||
|
settings.unprotected_session_limit_bytes == 50_000_000_000,
|
||||||
|
|s| s.unprotected_session_limit_bytes = 50_000_000_000,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"100 GB",
|
||||||
|
settings.unprotected_session_limit_bytes == 100_000_000_000,
|
||||||
|
|s| s.unprotected_session_limit_bytes = 100_000_000_000,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"200 GB",
|
||||||
|
settings.unprotected_session_limit_bytes == 200_000_000_000,
|
||||||
|
|s| s.unprotected_session_limit_bytes = 200_000_000_000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_6()
|
||||||
|
.max_w(px(820.))
|
||||||
|
.child(page_header(
|
||||||
|
"Settings",
|
||||||
|
"Changes apply now. Saving them between launches comes with settings storage.",
|
||||||
|
Vec::new(),
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
section("monitor", "Recording")
|
||||||
|
.child(setting("Resolution", "Match window records at the game's own size and orientation.", resolution))
|
||||||
|
.child(setting("Frame rate", "Frames per second.", frame_rate))
|
||||||
|
.child(setting("Video codec", "Hardware encoding only. Unsupported codecs are disabled once encoders are checked.", codec))
|
||||||
|
.child(setting(
|
||||||
|
"Start on login",
|
||||||
|
"Record games without opening Clipbin first.",
|
||||||
|
toggle("login", settings.start_on_login, |s| s.start_on_login = !s.start_on_login, cx),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
section("volume-2", "Audio")
|
||||||
|
.child(setting(
|
||||||
|
"Game audio",
|
||||||
|
"Everything your speakers play.",
|
||||||
|
toggle("system-audio", settings.system_audio_enabled, |s| s.system_audio_enabled = !s.system_audio_enabled, cx),
|
||||||
|
))
|
||||||
|
.child(setting(
|
||||||
|
"Microphone",
|
||||||
|
"Recorded on its own track, plus a mixed track for playback.",
|
||||||
|
toggle("microphone", settings.microphone_enabled, |s| s.microphone_enabled = !s.microphone_enabled, cx),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
section("bookmark", "Clips and bookmarks")
|
||||||
|
.child(setting("Clip hotkey saves", "How far back the clip hotkey reaches.", clip_length))
|
||||||
|
.child(setting(
|
||||||
|
"Bookmarks keep",
|
||||||
|
"Footage protected around each bookmark.",
|
||||||
|
value(format!(
|
||||||
|
"{} before, {} after",
|
||||||
|
format_length(settings.bookmark_before),
|
||||||
|
format_length(settings.bookmark_after)
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
.child(setting("Hotkeys", "Global hotkeys arrive with the hotkey integration.", value("Not set yet".into()))),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
section("hard-drive", "Storage")
|
||||||
|
.child(setting(
|
||||||
|
"Session footage budget",
|
||||||
|
"Oldest unprotected footage is removed beyond this. Bookmarks and clips don't count.",
|
||||||
|
budget,
|
||||||
|
))
|
||||||
|
.child(setting(
|
||||||
|
"In use",
|
||||||
|
"Session footage, then footage kept by bookmarks and clips.",
|
||||||
|
value(format!(
|
||||||
|
"{} · {} kept",
|
||||||
|
format_bytes(app.library.storage.unprotected_bytes),
|
||||||
|
format_bytes(app.library.storage.protected_bytes + app.library.storage.clip_bytes)
|
||||||
|
)),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn section(icon_name: &str, title: &'static str) -> Div {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.rounded_lg()
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme::border())
|
||||||
|
.bg(theme::card())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.px_5()
|
||||||
|
.py_3()
|
||||||
|
.child(icon(icon_name).text_color(theme::primary_light()))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.font_weight(FontWeight::SEMIBOLD)
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.child(title),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setting(title: &'static str, description: &'static str, control: impl IntoElement) -> Div {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_6()
|
||||||
|
.px_5()
|
||||||
|
.py_3p5()
|
||||||
|
.border_t_1()
|
||||||
|
.border_color(theme::border())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_1()
|
||||||
|
.gap_0p5()
|
||||||
|
.child(div().text_sm().text_color(theme::text()).child(title))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme::subtle())
|
||||||
|
.child(description),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(control)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(text: String) -> Div {
|
||||||
|
div().text_sm().text_color(theme::muted()).child(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segmented(
|
||||||
|
id: &'static str,
|
||||||
|
cx: &mut Context<ClipbinApp>,
|
||||||
|
options: &[(&'static str, bool, Update)],
|
||||||
|
) -> Div {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.p_0p5()
|
||||||
|
.rounded_md()
|
||||||
|
.bg(theme::surface())
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme::border())
|
||||||
|
.children(
|
||||||
|
options
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, (label, selected, update))| {
|
||||||
|
let update = *update;
|
||||||
|
div()
|
||||||
|
.id(ElementId::Name(SharedString::from(format!("{id}-{index}"))))
|
||||||
|
.px_3()
|
||||||
|
.py_1()
|
||||||
|
.rounded_sm()
|
||||||
|
.text_xs()
|
||||||
|
.cursor_pointer()
|
||||||
|
.when(*selected, |option| {
|
||||||
|
option
|
||||||
|
.bg(theme::primary())
|
||||||
|
.text_color(theme::foreground())
|
||||||
|
.font_weight(FontWeight::MEDIUM)
|
||||||
|
})
|
||||||
|
.when(!*selected, |option| {
|
||||||
|
option
|
||||||
|
.text_color(theme::muted())
|
||||||
|
.hover(|style| style.text_color(theme::foreground()))
|
||||||
|
})
|
||||||
|
.child(*label)
|
||||||
|
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||||
|
update(&mut app.settings);
|
||||||
|
cx.notify();
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle(
|
||||||
|
id: &'static str,
|
||||||
|
on: bool,
|
||||||
|
update: Update,
|
||||||
|
cx: &mut Context<ClipbinApp>,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.id(id)
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.w(px(36.))
|
||||||
|
.h(px(20.))
|
||||||
|
.p(px(2.))
|
||||||
|
.rounded_full()
|
||||||
|
.cursor_pointer()
|
||||||
|
.bg(if on {
|
||||||
|
theme::primary()
|
||||||
|
} else {
|
||||||
|
theme::raised()
|
||||||
|
})
|
||||||
|
.when(on, |track| track.justify_end())
|
||||||
|
.child(div().size_4().rounded_full().bg(theme::foreground()))
|
||||||
|
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||||
|
update(&mut app.settings);
|
||||||
|
cx.notify();
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//! Colours and type from clipbin's web interface (resources/css/app.css):
|
||||||
|
//! a zinc dark theme with a purple accent, set entirely in Geist Mono.
|
||||||
|
|
||||||
|
use gpui::{Hsla, Rgba, rgb, rgba};
|
||||||
|
|
||||||
|
pub const FONT: &str = "Geist Mono";
|
||||||
|
|
||||||
|
pub fn background() -> Rgba {
|
||||||
|
rgb(0x09090b)
|
||||||
|
}
|
||||||
|
pub fn sidebar() -> Rgba {
|
||||||
|
rgb(0x0c0c0f)
|
||||||
|
}
|
||||||
|
pub fn card() -> Rgba {
|
||||||
|
rgb(0x0f0f12)
|
||||||
|
}
|
||||||
|
pub fn surface() -> Rgba {
|
||||||
|
rgb(0x18181b)
|
||||||
|
}
|
||||||
|
pub fn raised() -> Rgba {
|
||||||
|
rgb(0x232326)
|
||||||
|
}
|
||||||
|
pub fn border() -> Rgba {
|
||||||
|
rgb(0x27272a)
|
||||||
|
}
|
||||||
|
pub fn footage() -> Rgba {
|
||||||
|
rgb(0x52525b)
|
||||||
|
}
|
||||||
|
pub fn foreground() -> Rgba {
|
||||||
|
rgb(0xfafafa)
|
||||||
|
}
|
||||||
|
pub fn text() -> Rgba {
|
||||||
|
rgb(0xe4e4e7)
|
||||||
|
}
|
||||||
|
pub fn muted() -> Rgba {
|
||||||
|
rgb(0xa1a1aa)
|
||||||
|
}
|
||||||
|
pub fn subtle() -> Rgba {
|
||||||
|
rgb(0x71717a)
|
||||||
|
}
|
||||||
|
pub fn faint() -> Rgba {
|
||||||
|
rgb(0x52525b)
|
||||||
|
}
|
||||||
|
pub fn primary() -> Rgba {
|
||||||
|
rgb(0x7d22d2)
|
||||||
|
}
|
||||||
|
pub fn primary_light() -> Rgba {
|
||||||
|
rgb(0xb57cf0)
|
||||||
|
}
|
||||||
|
pub fn primary_alpha(alpha: f32) -> Hsla {
|
||||||
|
let mut colour: Hsla = primary().into();
|
||||||
|
colour.a = alpha;
|
||||||
|
colour
|
||||||
|
}
|
||||||
|
pub fn destructive() -> Rgba {
|
||||||
|
rgb(0xef4444)
|
||||||
|
}
|
||||||
|
pub fn destructive_alpha(alpha: f32) -> Hsla {
|
||||||
|
let mut colour: Hsla = destructive().into();
|
||||||
|
colour.a = alpha;
|
||||||
|
colour
|
||||||
|
}
|
||||||
|
pub fn warning() -> Rgba {
|
||||||
|
rgb(0xfbbf24)
|
||||||
|
}
|
||||||
|
pub fn success() -> Rgba {
|
||||||
|
rgb(0x4ade80)
|
||||||
|
}
|
||||||
|
pub fn overlay() -> Rgba {
|
||||||
|
rgba(0x000000b3)
|
||||||
|
}
|
||||||
|
pub fn hover() -> Rgba {
|
||||||
|
rgba(0xffffff14)
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
use clipbin_core::{RecordingStatus, Settings};
|
|
||||||
use gpui::{Context, IntoElement, Render, Window, div, prelude::*, rgb};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct ClipbinView {
|
|
||||||
settings: Settings,
|
|
||||||
status: RecordingStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for ClipbinView {
|
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let settings = &self.settings;
|
|
||||||
|
|
||||||
div()
|
|
||||||
.size_full()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_6()
|
|
||||||
.p_8()
|
|
||||||
.bg(rgb(0x101318))
|
|
||||||
.text_color(rgb(0xe6e9ef))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.justify_between()
|
|
||||||
.items_center()
|
|
||||||
.child(div().text_3xl().child("Clipbin"))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.id("quit")
|
|
||||||
.px_4()
|
|
||||||
.py_2()
|
|
||||||
.rounded_md()
|
|
||||||
.bg(rgb(0x232a35))
|
|
||||||
.cursor_pointer()
|
|
||||||
.on_click(|_, _, cx| cx.quit())
|
|
||||||
.child("Quit"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_2()
|
|
||||||
.p_4()
|
|
||||||
.rounded_lg()
|
|
||||||
.border_1()
|
|
||||||
.border_color(rgb(0x343d4c))
|
|
||||||
.child(div().text_lg().child(self.status.label()))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(rgb(0xa9b2c2))
|
|
||||||
.child("Capture is not available in this build."),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.justify_center()
|
|
||||||
.items_center()
|
|
||||||
.gap_3()
|
|
||||||
.rounded_lg()
|
|
||||||
.bg(rgb(0x181d25))
|
|
||||||
.child(div().text_2xl().child("No sessions yet"))
|
|
||||||
.child(
|
|
||||||
div().text_color(rgb(0xa9b2c2)).child(
|
|
||||||
"Your recorded sessions and bookmarked moments will appear here.",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(rgb(0xa9b2c2))
|
|
||||||
.child(format!(
|
|
||||||
"Recording defaults: {} · {} FPS · Hardware {}",
|
|
||||||
match settings.resolution {
|
|
||||||
Some((width, height)) => format!("{width} × {height}"),
|
|
||||||
None => "Captured window size".into(),
|
|
||||||
},
|
|
||||||
settings.frames_per_second,
|
|
||||||
settings.codec,
|
|
||||||
))
|
|
||||||
.child(format!(
|
|
||||||
"Session budget: {} GB of unprotected footage · Clip: {} seconds",
|
|
||||||
settings.unprotected_session_limit_bytes / 1_000_000_000,
|
|
||||||
settings.clip_duration.as_secs(),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Screenshots each Clipbin screen in the headless rig, for reviewing the
|
||||||
|
# interface on machines without a display.
|
||||||
|
#
|
||||||
|
# scripts/rig/screenshot-ui.sh [output-dir] [screen...]
|
||||||
|
#
|
||||||
|
# Screens: sessions, session, clips, settings, failed, empty. Uses the sample
|
||||||
|
# library (CLIPBIN_SAMPLE_LIBRARY) except for "empty".
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
out="${1:-${TMPDIR:-/tmp}/clipbin-screens}"
|
||||||
|
shift || true
|
||||||
|
screens=("$@")
|
||||||
|
[[ ${#screens[@]} -eq 0 ]] && screens=(sessions session clips settings failed empty)
|
||||||
|
mkdir -p "${out}"
|
||||||
|
|
||||||
|
cargo build --locked -p clipbin-desktop --manifest-path "${repo}/Cargo.toml"
|
||||||
|
app="${repo}/target/debug/clipbin-desktop"
|
||||||
|
|
||||||
|
for screen in "${screens[@]}"; do
|
||||||
|
env=(CLIPBIN_SAMPLE_LIBRARY=1)
|
||||||
|
case "${screen}" in
|
||||||
|
sessions) ;;
|
||||||
|
session) env+=(CLIPBIN_ROUTE=session:2) ;;
|
||||||
|
clips) env+=(CLIPBIN_ROUTE=clips) ;;
|
||||||
|
settings) env+=(CLIPBIN_ROUTE=settings) ;;
|
||||||
|
failed) env+=(CLIPBIN_SAMPLE_STATUS=failed) ;;
|
||||||
|
empty) env=() ;;
|
||||||
|
*) echo "unknown screen ${screen}" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
CLIPBIN_RIG_WIDTH="${CLIPBIN_RIG_WIDTH:-1600}" CLIPBIN_RIG_HEIGHT="${CLIPBIN_RIG_HEIGHT:-1000}" \
|
||||||
|
"${repo}/scripts/rig/headless.sh" bash -c "
|
||||||
|
kill \$(cat \"\${CLIPBIN_RIG_DIR}/game.pid\")
|
||||||
|
env ${env[*]} '${app}' > '${out}/${screen}.log' 2>&1 &
|
||||||
|
sleep 3
|
||||||
|
grim '${out}/${screen}.png'
|
||||||
|
kill %1" 2> /dev/null
|
||||||
|
echo "${out}/${screen}.png"
|
||||||
|
done
|
||||||