diff --git a/README.md b/README.md index efac2d3..7e9b920 100644 --- a/README.md +++ b/README.md @@ -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 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. @@ -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 ``` -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: diff --git a/crates/clipbin-core/src/lib.rs b/crates/clipbin-core/src/lib.rs index c1de5e8..c4c2d97 100644 --- a/crates/clipbin-core/src/lib.rs +++ b/crates/clipbin-core/src/lib.rs @@ -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; diff --git a/crates/clipbin-core/src/library.rs b/crates/clipbin-core/src/library.rs new file mode 100644 index 0000000..258d3a2 --- /dev/null +++ b/crates/clipbin-core/src/library.rs @@ -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, + pub clips: Vec, + 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, + pub bookmarks: Vec, +} + +#[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); + } +} diff --git a/crates/clipbin-desktop/assets/fonts/GeistMono-Bold.ttf b/crates/clipbin-desktop/assets/fonts/GeistMono-Bold.ttf new file mode 100644 index 0000000..90eb8a8 Binary files /dev/null and b/crates/clipbin-desktop/assets/fonts/GeistMono-Bold.ttf differ diff --git a/crates/clipbin-desktop/assets/fonts/GeistMono-Medium.ttf b/crates/clipbin-desktop/assets/fonts/GeistMono-Medium.ttf new file mode 100644 index 0000000..ff49ece Binary files /dev/null and b/crates/clipbin-desktop/assets/fonts/GeistMono-Medium.ttf differ diff --git a/crates/clipbin-desktop/assets/fonts/GeistMono-Regular.ttf b/crates/clipbin-desktop/assets/fonts/GeistMono-Regular.ttf new file mode 100644 index 0000000..50c9d5a Binary files /dev/null and b/crates/clipbin-desktop/assets/fonts/GeistMono-Regular.ttf differ diff --git a/crates/clipbin-desktop/assets/fonts/GeistMono-SemiBold.ttf b/crates/clipbin-desktop/assets/fonts/GeistMono-SemiBold.ttf new file mode 100644 index 0000000..1b21724 Binary files /dev/null and b/crates/clipbin-desktop/assets/fonts/GeistMono-SemiBold.ttf differ diff --git a/crates/clipbin-desktop/assets/fonts/OFL.txt b/crates/clipbin-desktop/assets/fonts/OFL.txt new file mode 100644 index 0000000..04e95fc --- /dev/null +++ b/crates/clipbin-desktop/assets/fonts/OFL.txt @@ -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. \ No newline at end of file diff --git a/crates/clipbin-desktop/assets/icons/LICENSE b/crates/clipbin-desktop/assets/icons/LICENSE new file mode 100644 index 0000000..46e6962 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/LICENSE @@ -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. diff --git a/crates/clipbin-desktop/assets/icons/app-window.svg b/crates/clipbin-desktop/assets/icons/app-window.svg new file mode 100644 index 0000000..09b29da --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/app-window.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/arrow-left.svg b/crates/clipbin-desktop/assets/icons/arrow-left.svg new file mode 100644 index 0000000..4dbd144 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/arrow-left.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/crates/clipbin-desktop/assets/icons/bookmark.svg b/crates/clipbin-desktop/assets/icons/bookmark.svg new file mode 100644 index 0000000..f8bd6be --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/bookmark.svg @@ -0,0 +1,15 @@ + + + + diff --git a/crates/clipbin-desktop/assets/icons/chevron-left.svg b/crates/clipbin-desktop/assets/icons/chevron-left.svg new file mode 100644 index 0000000..6e7d358 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/chevron-left.svg @@ -0,0 +1,15 @@ + + + + diff --git a/crates/clipbin-desktop/assets/icons/chevron-right.svg b/crates/clipbin-desktop/assets/icons/chevron-right.svg new file mode 100644 index 0000000..2d469e0 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/chevron-right.svg @@ -0,0 +1,15 @@ + + + + diff --git a/crates/clipbin-desktop/assets/icons/circle-dot.svg b/crates/clipbin-desktop/assets/icons/circle-dot.svg new file mode 100644 index 0000000..881b1ec --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/circle-dot.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/crates/clipbin-desktop/assets/icons/clapperboard.svg b/crates/clipbin-desktop/assets/icons/clapperboard.svg new file mode 100644 index 0000000..9597c0c --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/clapperboard.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/clock.svg b/crates/clipbin-desktop/assets/icons/clock.svg new file mode 100644 index 0000000..d5b6dfe --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/clock.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/crates/clipbin-desktop/assets/icons/download.svg b/crates/clipbin-desktop/assets/icons/download.svg new file mode 100644 index 0000000..79c7942 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/download.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/film.svg b/crates/clipbin-desktop/assets/icons/film.svg new file mode 100644 index 0000000..c83d551 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/film.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/folder-open.svg b/crates/clipbin-desktop/assets/icons/folder-open.svg new file mode 100644 index 0000000..7435b1e --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/folder-open.svg @@ -0,0 +1,15 @@ + + + + diff --git a/crates/clipbin-desktop/assets/icons/gamepad-2.svg b/crates/clipbin-desktop/assets/icons/gamepad-2.svg new file mode 100644 index 0000000..a75e12b --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/gamepad-2.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/hard-drive.svg b/crates/clipbin-desktop/assets/icons/hard-drive.svg new file mode 100644 index 0000000..c2692f6 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/hard-drive.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/keyboard.svg b/crates/clipbin-desktop/assets/icons/keyboard.svg new file mode 100644 index 0000000..b40b4c2 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/keyboard.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/layout-grid.svg b/crates/clipbin-desktop/assets/icons/layout-grid.svg new file mode 100644 index 0000000..e6b7b98 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/layout-grid.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/mic.svg b/crates/clipbin-desktop/assets/icons/mic.svg new file mode 100644 index 0000000..2bb5e80 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/mic.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/monitor.svg b/crates/clipbin-desktop/assets/icons/monitor.svg new file mode 100644 index 0000000..f9c8705 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/monitor.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/pause.svg b/crates/clipbin-desktop/assets/icons/pause.svg new file mode 100644 index 0000000..efdfd73 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/pause.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/crates/clipbin-desktop/assets/icons/play.svg b/crates/clipbin-desktop/assets/icons/play.svg new file mode 100644 index 0000000..68681a0 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/play.svg @@ -0,0 +1,15 @@ + + + + diff --git a/crates/clipbin-desktop/assets/icons/power.svg b/crates/clipbin-desktop/assets/icons/power.svg new file mode 100644 index 0000000..35da6f2 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/power.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/crates/clipbin-desktop/assets/icons/rotate-ccw.svg b/crates/clipbin-desktop/assets/icons/rotate-ccw.svg new file mode 100644 index 0000000..3e3be22 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/rotate-ccw.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/crates/clipbin-desktop/assets/icons/scissors.svg b/crates/clipbin-desktop/assets/icons/scissors.svg new file mode 100644 index 0000000..1149d13 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/scissors.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/settings.svg b/crates/clipbin-desktop/assets/icons/settings.svg new file mode 100644 index 0000000..cb2422b --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/settings.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/crates/clipbin-desktop/assets/icons/skip-back.svg b/crates/clipbin-desktop/assets/icons/skip-back.svg new file mode 100644 index 0000000..ab3c1ad --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/skip-back.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/crates/clipbin-desktop/assets/icons/skip-forward.svg b/crates/clipbin-desktop/assets/icons/skip-forward.svg new file mode 100644 index 0000000..ad2d0f3 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/skip-forward.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/crates/clipbin-desktop/assets/icons/triangle-alert.svg b/crates/clipbin-desktop/assets/icons/triangle-alert.svg new file mode 100644 index 0000000..c0cf585 --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/triangle-alert.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/volume-2.svg b/crates/clipbin-desktop/assets/icons/volume-2.svg new file mode 100644 index 0000000..b6dcc6f --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/volume-2.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/clipbin-desktop/assets/icons/x.svg b/crates/clipbin-desktop/assets/icons/x.svg new file mode 100644 index 0000000..59e263b --- /dev/null +++ b/crates/clipbin-desktop/assets/icons/x.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/crates/clipbin-desktop/src/app.rs b/crates/clipbin-desktop/src/app.rs new file mode 100644 index 0000000..7adb3d2 --- /dev/null +++ b/crates/clipbin-desktop/src/app.rs @@ -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, +} + +/// 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, + pub drag: Option, + /// Where the timeline was last laid out, to turn pointer positions into times. + pub timeline_bounds: Rc>>>, +} + +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.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) -> 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| { + 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) -> Option { + 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) -> 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), + ), + ) + } +} diff --git a/crates/clipbin-desktop/src/assets.rs b/crates/clipbin-desktop/src/assets.rs new file mode 100644 index 0000000..faf8bdd --- /dev/null +++ b/crates/clipbin-desktop/src/assets.rs @@ -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>> { + Ok(FILES + .iter() + .find(|(name, _)| *name == path) + .map(|(_, bytes)| Cow::Borrowed(*bytes))) + } + + fn list(&self, path: &str) -> Result> { + 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()) +} diff --git a/crates/clipbin-desktop/src/components.rs b/crates/clipbin-desktop/src/components.rs new file mode 100644 index 0000000..3cc4442 --- /dev/null +++ b/crates/clipbin-desktop/src/components.rs @@ -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(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, + style: ButtonStyle, + icon_name: Option<&str>, + label: Option<&str>, +) -> Stateful
{ + 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) -> 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, + description: impl Into, + actions: Vec, +) -> 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) -> 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, 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, + description: impl Into, +) -> 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) + })) +} diff --git a/crates/clipbin-desktop/src/main.rs b/crates/clipbin-desktop/src/main.rs index 5f2ca16..208c06f 100644 --- a/crates/clipbin-desktop/src/main.rs +++ b/crates/clipbin-desktop/src/main.rs @@ -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); + }); } diff --git a/crates/clipbin-desktop/src/screens/clips.rs b/crates/clipbin-desktop/src/screens/clips.rs new file mode 100644 index 0000000..506d299 --- /dev/null +++ b/crates/clipbin-desktop/src/screens/clips.rs @@ -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) -> 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() +} diff --git a/crates/clipbin-desktop/src/screens/editor.rs b/crates/clipbin-desktop/src/screens/editor.rs new file mode 100644 index 0000000..71c7a56 --- /dev/null +++ b/crates/clipbin-desktop/src/screens/editor.rs @@ -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) -> 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, bounds: Bounds, 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) -> 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)) +} diff --git a/crates/clipbin-desktop/src/screens/mod.rs b/crates/clipbin-desktop/src/screens/mod.rs new file mode 100644 index 0000000..f3f1700 --- /dev/null +++ b/crates/clipbin-desktop/src/screens/mod.rs @@ -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) +} diff --git a/crates/clipbin-desktop/src/screens/sessions.rs b/crates/clipbin-desktop/src/screens/sessions.rs new file mode 100644 index 0000000..6499c6a --- /dev/null +++ b/crates/clipbin-desktop/src/screens/sessions.rs @@ -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) -> 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 { + (time > std::time::Duration::ZERO) + .then(|| chip(format!("{} {what}", format_length(time))).text_color(theme::muted())) +} diff --git a/crates/clipbin-desktop/src/screens/settings.rs b/crates/clipbin-desktop/src/screens/settings.rs new file mode 100644 index 0000000..9947bbb --- /dev/null +++ b/crates/clipbin-desktop/src/screens/settings.rs @@ -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) -> 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, + 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, +) -> 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(); + })) +} diff --git a/crates/clipbin-desktop/src/theme.rs b/crates/clipbin-desktop/src/theme.rs new file mode 100644 index 0000000..b722e8e --- /dev/null +++ b/crates/clipbin-desktop/src/theme.rs @@ -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) +} diff --git a/crates/clipbin-desktop/src/view.rs b/crates/clipbin-desktop/src/view.rs deleted file mode 100644 index 680b917..0000000 --- a/crates/clipbin-desktop/src/view.rs +++ /dev/null @@ -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) -> 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(), - )), - ) - } -} diff --git a/scripts/rig/screenshot-ui.sh b/scripts/rig/screenshot-ui.sh new file mode 100755 index 0000000..8cc1b69 --- /dev/null +++ b/scripts/rig/screenshot-ui.sh @@ -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