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

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

+16
View File
@@ -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

+466
View File
@@ -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),
),
)
}
}
+75
View File
@@ -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())
}
+224
View File
@@ -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)
}))
}
+40 -33
View File
@@ -3,46 +3,53 @@ use gpui::{
actions, px, size,
};
mod view;
mod app;
mod assets;
mod components;
mod screens;
mod theme;
actions!(clipbin, [Quit]);
fn quit(_: &Quit, cx: &mut App) {
cx.quit();
}
fn main() {
Application::new().run(|cx: &mut App| {
cx.on_action(quit);
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
// This initial shell has no background recorder yet.
cx.on_window_closed(|cx| {
if cx.windows().is_empty() {
cx.quit();
Application::new()
.with_assets(assets::Assets)
.run(|cx: &mut App| {
if let Err(error) = assets::load_fonts(cx) {
eprintln!("Could not load the interface font: {error}");
}
})
.detach();
cx.on_action(|_: &Quit, cx| cx.quit());
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
let bounds = Bounds::centered(None, size(px(1080.0), px(720.0)), cx);
let result = cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(TitlebarOptions {
title: Some("Clipbin".into()),
// This shell has no background recorder yet.
cx.on_window_closed(|cx| {
if cx.windows().is_empty() {
cx.quit();
}
})
.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()
},
|_, cx| cx.new(|_| view::ClipbinView::default()),
);
},
|_, cx| cx.new(|_| app::ClipbinApp::new()),
);
if let Err(error) = result {
eprintln!("Could not open Clipbin: {error}");
cx.quit();
return;
}
if let Err(error) = result {
eprintln!("Could not open Clipbin: {error}");
cx.quit();
return;
}
cx.activate(true);
});
cx.activate(true);
});
}
+132
View File
@@ -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))
}
+68
View File
@@ -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();
}))
}
+74
View File
@@ -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)
}
-96
View File
@@ -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(),
)),
)
}
}