diff --git a/AGENTS.md b/AGENTS.md
index defcbed..eb4dea8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -71,8 +71,10 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event.
- gpui focuses the nearest focusable ancestor on mouse down, and apps wrap everything in a `FocusScope`. A press that must leave focus alone calls `prevent_default`, as buttons and context regions do.
- gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens.
-- gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again.
+- gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again. An edit in place that hands focus back keeps its text on Enter's release (`forms::InlineEdit`).
+- A focus handle a component hands back lives across all its modes: take it with `tab_stop` before any early return, or keyed state drops it while the other mode draws and the handed-back focus lands nowhere.
- A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. A dialog's card fits the window, a title bar's height clear at each end; its body scrolls and its actions stay.
+- Controls on a dark layer over pictures are `overlays::media_button`, light icons that dim when they have nowhere to go, for the lightbox and a video player's bar alike.
- `primitives::Severity` names info, success, warning and danger with their colors and icons, for alerts and feedback alike.
- Rows that move are keyed. `motion::Flip` places them absolutely from last frame's heights, so no frame shows a row at its new place before it glides there, and drives the glide from elapsed time: an animation id that changes per move would rebuild the row's keyed state. `Reorder` holds a row by key, drags with an empty ghost and moves the held row itself.
- A marker that slides between items (segment thumb, tab line) measures them with `motion::slide` and eases with `glide`.
diff --git a/assets/icons/captions-off.svg b/assets/icons/captions-off.svg
new file mode 100644
index 0000000..8d72e11
--- /dev/null
+++ b/assets/icons/captions-off.svg
@@ -0,0 +1,20 @@
+
+
diff --git a/assets/icons/captions.svg b/assets/icons/captions.svg
new file mode 100644
index 0000000..90d00f9
--- /dev/null
+++ b/assets/icons/captions.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/assets/icons/fullscreen.svg b/assets/icons/fullscreen.svg
new file mode 100644
index 0000000..0de1e04
--- /dev/null
+++ b/assets/icons/fullscreen.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/examples/gallery/pages/media/mod.rs b/examples/gallery/pages/media/mod.rs
index 66d7ee6..5a15133 100644
--- a/examples/gallery/pages/media/mod.rs
+++ b/examples/gallery/pages/media/mod.rs
@@ -1,5 +1,6 @@
mod edit;
mod images;
+mod video;
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
@@ -53,6 +54,20 @@ const SCRIPT: &[Step] = &[
Step::UpAt("media-upload", 388.0, 307.0),
Step::Wait(300),
Step::Shot("emptied"),
+ Step::HoverAt("media-scrubber", 320.0, 31.0),
+ Step::Wait(200),
+ Step::Shot("scrub-tip"),
+ Step::DownAt("media-strip", 467.0, 25.0),
+ Step::DragTo("media-strip", 560.0, 25.0),
+ Step::UpAt("media-strip", 560.0, 25.0),
+ Step::Wait(200),
+ Step::Shot("trimmed"),
+ Step::DownAt("media-subtitles", 80.0, 142.0),
+ Step::UpAt("media-subtitles", 80.0, 142.0),
+ Step::Type("00:20.000"),
+ Step::Key("enter"),
+ Step::Wait(300),
+ Step::Shot("retimed"),
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
@@ -62,5 +77,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(edit::cropper(window, cx))
.child(edit::upload(window, cx))
.child(edit::annotator(window, cx))
+ .child(video::video(window, cx))
+ .child(video::scrubber(window, cx))
+ .child(video::strip(window, cx))
+ .child(video::subtitles(window, cx))
.into_any_element()
}
diff --git a/examples/gallery/pages/media/video.rs b/examples/gallery/pages/media/video.rs
new file mode 100644
index 0000000..53ee632
--- /dev/null
+++ b/examples/gallery/pages/media/video.rs
@@ -0,0 +1,236 @@
+use std::{path::Path, time::Duration};
+
+use ely_gpui_component::media::{Cue, Scrubber, SubtitleEditor, VideoPlayer, VideoThumbnailStrip};
+use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
+
+use crate::{
+ probe::probe,
+ ui::{keep, section, set},
+};
+
+const FRAMES: [&str; 3] = [
+ concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/examples/gallery/assets/atrium.jpg"
+ ),
+ concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/examples/gallery/assets/atrium-stair.jpg"
+ ),
+ concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/examples/gallery/assets/atrium-olive.jpg"
+ ),
+];
+const LENGTH: Duration = Duration::from_secs(90);
+/// How often the demo's clock moves while it plays.
+const TICK: Duration = Duration::from_millis(250);
+
+/// The demo video: where it stands, how it plays, its trim and its captions.
+#[derive(Clone)]
+struct Playback {
+ at: Duration,
+ playing: bool,
+ ticking: bool,
+ speed: f32,
+ captions: bool,
+ trim: (Duration, Duration),
+ cues: Vec,
+}
+
+fn cue(key: &str, start: u64, end: u64, text: &str) -> Cue {
+ Cue {
+ key: key.to_string().into(),
+ start: Duration::from_secs(start),
+ end: Duration::from_secs(end),
+ text: text.to_string().into(),
+ }
+}
+
+fn frame(at: Duration) -> &'static str {
+ FRAMES[(at.as_secs() / 30) as usize % FRAMES.len()]
+}
+
+/// Runs the demo's clock while it plays, one loop at a time.
+fn tick(state: &Entity, window: &mut Window, cx: &mut App) {
+ if state.update(cx, |now, _| std::mem::replace(&mut now.ticking, true)) {
+ return;
+ }
+ let state = state.clone();
+ window
+ .spawn(cx, async move |cx| {
+ loop {
+ cx.background_executor().timer(TICK).await;
+ let going = state.update(cx, |now, cx| {
+ let step = TICK.mul_f32(now.speed);
+ now.at = (now.at + step).min(LENGTH);
+ now.playing &= now.at < LENGTH;
+ now.ticking = now.playing;
+ cx.notify();
+ now.playing
+ })?;
+ if !going {
+ return anyhow::Ok(());
+ }
+ }
+ })
+ .detach_and_log_err(cx);
+}
+
+/// The one video every section shows.
+fn state(window: &mut Window, cx: &mut App) -> Entity {
+ keep(
+ "media-video",
+ || Playback {
+ at: Duration::from_secs(34),
+ playing: false,
+ ticking: false,
+ speed: 1.0,
+ captions: true,
+ trim: (Duration::from_secs(12), Duration::from_secs(66)),
+ cues: vec![
+ cue("cue-1", 2, 9, "Morning comes in through the tall window."),
+ cue("cue-2", 31, 38, "Light falls on the stair."),
+ cue("cue-3", 61, 70, "The shadow walks the length of the hall."),
+ ],
+ },
+ window,
+ cx,
+ )
+}
+
+pub fn video(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let state = state(window, cx);
+ let now = state.read(cx).clone();
+ let caption: Option = now
+ .cues
+ .iter()
+ .find(|cue| cue.start <= now.at && now.at < cue.end)
+ .map(|cue| cue.text.clone());
+ let (play, seek, speed, captions) =
+ (state.clone(), state.clone(), state.clone(), state.clone());
+ let player = VideoPlayer::new(
+ "media-player",
+ Some(Path::new(frame(now.at)).into()),
+ 1.5,
+ LENGTH,
+ now.at,
+ )
+ .loaded(LENGTH.mul_f32(0.8))
+ .playing(now.playing)
+ .speed(now.speed)
+ .captions(now.captions, caption)
+ .on_play(move |on, window, cx| {
+ play.update(cx, |now, cx| {
+ now.playing = on;
+ cx.notify();
+ });
+ if on {
+ tick(&play, window, cx);
+ }
+ })
+ .on_seek(move |at, _, cx| {
+ seek.update(cx, |now, cx| {
+ now.at = at;
+ cx.notify();
+ })
+ })
+ .on_speed(move |to, _, cx| {
+ speed.update(cx, |now, cx| {
+ now.speed = to;
+ cx.notify();
+ })
+ })
+ .on_captions(move |on, _, cx| {
+ captions.update(cx, |now, cx| {
+ now.captions = on;
+ cx.notify();
+ })
+ })
+ .on_picture(|_, _| log::info!("gallery: picture in picture"))
+ .on_fullscreen(|window, _| window.toggle_fullscreen());
+ section(
+ "VideoPlayer",
+ "A video the host plays, a frame at a time. Its controls fade once the pointer rests while it plays. Space plays, J and L jump ten seconds, C turns captions, F fills the screen.",
+ cx,
+ )
+ .child(probe("media-player", div().w(px(640.)).child(player)))
+}
+
+pub fn scrubber(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let state = state(window, cx);
+ let at = state.read(cx).at;
+ let seek = state.clone();
+ section(
+ "VideoTimeline / Scrubber",
+ "Where a video stands: what has loaded, how far it has played, its chapters, and the time under the pointer. A press or a drag seeks; with focus the arrows step five seconds.",
+ cx,
+ )
+ .child(probe(
+ "media-scrubber",
+ div().w(px(640.)).pt_6().child(
+ Scrubber::new("media-scrubber", LENGTH, at)
+ .loaded(LENGTH.mul_f32(0.8))
+ .chapter(Duration::ZERO, "Morning")
+ .chapter(Duration::from_secs(30), "The stair")
+ .chapter(Duration::from_secs(60), "The hall")
+ .on_seek(move |at, _, cx| seek.update(cx, |now, cx| {
+ now.at = at;
+ cx.notify();
+ })),
+ ),
+ ))
+}
+
+pub fn strip(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let state = state(window, cx);
+ let now = state.read(cx).clone();
+ let (seek, cut) = (state.clone(), state.clone());
+ let frames = (0..8).map(|ix| Path::new(FRAMES[ix * 3 / 8]));
+ section(
+ "VideoThumbnailStrip",
+ "Frames along the video under its playhead, and the part kept between two handles. A press seeks; a handle trims; I and O set the ends at the playhead.",
+ cx,
+ )
+ .child(probe(
+ "media-strip",
+ div().w(px(640.)).child(
+ VideoThumbnailStrip::new("media-strip", frames, LENGTH, now.at)
+ .trim(now.trim.0, now.trim.1)
+ .on_seek(move |at, _, cx| seek.update(cx, |now, cx| {
+ now.at = at;
+ cx.notify();
+ }))
+ .on_trim(move |trim, _, cx| cut.update(cx, |now, cx| {
+ now.trim = trim;
+ cx.notify();
+ })),
+ ),
+ ))
+}
+
+pub fn subtitles(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let state = state(window, cx);
+ let now = state.read(cx).clone();
+ let (change, seek) = (state.clone(), state.clone());
+ section(
+ "SubtitleEditor",
+ "Captions to edit in place, their times as mm:ss.mmm over their words. The one under the playhead is lit; Add puts a new cue there.",
+ cx,
+ )
+ .child(probe(
+ "media-subtitles",
+ div().w(px(480.)).child(
+ SubtitleEditor::new("media-subtitles", now.cues, now.at)
+ .on_change(move |cues, _, cx| {
+ let mut next = change.read(cx).clone();
+ next.cues = cues.to_vec();
+ set(&change, next, cx)
+ })
+ .on_seek(move |at, _, cx| seek.update(cx, |now, cx| {
+ now.at = at;
+ cx.notify();
+ })),
+ ),
+ ))
+}
diff --git a/src/buttons/mod.rs b/src/buttons/mod.rs
index 312c257..5f7eb96 100644
--- a/src/buttons/mod.rs
+++ b/src/buttons/mod.rs
@@ -1,5 +1,6 @@
mod bars;
mod button;
+pub(crate) use button::label_size;
mod confirm;
mod copy;
mod fab;
diff --git a/src/forms/inline.rs b/src/forms/inline.rs
index 56b49da..d7a5157 100644
--- a/src/forms/inline.rs
+++ b/src/forms/inline.rs
@@ -1,24 +1,25 @@
use std::rc::Rc;
use gpui::{
- App, Context, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
- RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div,
- prelude::*,
+ App, Context, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement, MouseButton,
+ ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription,
+ Window, div, prelude::*,
};
use super::{Input, InputEvent, TextInput};
use crate::{
- primitives::{Icon, IconName},
+ primitives::{FocusRing, Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
};
pub(crate) type OnCommit = Rc;
-/// A text being edited in place: its field while open, and who hears the kept text.
+/// A text being edited in place: its field while open, who hears the kept text, and where focus goes back after Enter or Escape. An edit that hands focus back keeps its text on Enter's release, so the release does not open it again.
#[derive(Default)]
pub(crate) struct Editing {
field: Option<(Entity, Subscription)>,
pub on_commit: Option,
+ back: Option,
}
impl Editing {
@@ -33,10 +34,17 @@ impl Editing {
field.update(cx, |input, cx| input.select(0..all, cx));
window.focus(&field.read(cx).focus().clone());
let owner = state.clone();
- let events = window.subscribe(&field, cx, move |_, event, window, cx| {
- if matches!(event, InputEvent::Submit | InputEvent::Blur) {
- owner.update(cx, |editing, cx| editing.finish(false, window, cx));
- }
+ let events = window.subscribe(&field, cx, move |_, event, window, cx| match event {
+ InputEvent::Submit => owner.update(cx, |editing, cx| {
+ if editing.back.is_none() {
+ editing.finish(false, window, cx);
+ }
+ }),
+ InputEvent::Blur => owner.update(cx, |editing, cx| {
+ editing.back = None;
+ editing.finish(false, window, cx);
+ }),
+ _ => {}
});
state.update(cx, |editing, cx| {
editing.field = Some((field, events));
@@ -44,6 +52,18 @@ impl Editing {
});
}
+ /// Focus goes back here after Enter or Escape.
+ fn returning(&mut self, back: FocusHandle) {
+ self.back = Some(back);
+ }
+
+ fn give_back(&mut self, window: &mut Window) {
+ if let Some(back) = self.back.take() {
+ log::info!("inline edit: focus handed back");
+ window.focus(&back);
+ }
+ }
+
pub(crate) fn field(&self) -> Option> {
self.field.as_ref().map(|(field, _)| field.clone())
}
@@ -102,16 +122,29 @@ impl InlineEdit {
impl RenderOnce for InlineEdit {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Editing::default());
+ let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
state.update(cx, |editing, _| editing.on_commit = self.on_commit.clone());
if let Some(field) = state.read(cx).field() {
- let escape = state.clone();
+ let (escape, enter) = (state.clone(), state.clone());
return div()
.id(self.id)
.w_full()
.on_key_down(move |event, window, cx| {
if event.keystroke.key == "escape" {
cx.stop_propagation();
- escape.update(cx, |editing, cx| editing.finish(true, window, cx));
+ escape.update(cx, |editing, cx| {
+ editing.finish(true, window, cx);
+ editing.give_back(window);
+ });
+ }
+ })
+ .on_key_up(move |event, window, cx| {
+ if event.keystroke.key == "enter" {
+ cx.stop_propagation();
+ enter.update(cx, |editing, cx| {
+ editing.finish(false, window, cx);
+ editing.give_back(window);
+ });
}
})
.child(Input::new(&field).size(ControlSize::Sm))
@@ -120,7 +153,7 @@ impl RenderOnce for InlineEdit {
let theme = cx.theme();
let colors = &theme.colors;
let empty = self.value.is_empty();
- let (value, start) = (self.value.clone(), state);
+ let (value, start, back) = (self.value.clone(), state, focus.clone());
let group = SharedString::from(format!("inline-edit-{:?}", self.id));
div()
.id(self.id)
@@ -133,12 +166,15 @@ impl RenderOnce for InlineEdit {
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(gpui::transparent_black())
+ .track_focus(&focus)
+ .focus_ring(cx)
.cursor_text()
.hover(|style| style.bg(colors.hover))
.text_color(if empty { colors.fg_subtle } else { colors.fg })
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("inline edit: editing");
+ start.update(cx, |editing, _| editing.returning(back.clone()));
Editing::begin(&start, value.to_string(), window, cx);
})
.child(if empty { self.placeholder } else { self.value })
diff --git a/src/forms/mod.rs b/src/forms/mod.rs
index 0e70198..80565dc 100644
--- a/src/forms/mod.rs
+++ b/src/forms/mod.rs
@@ -97,6 +97,7 @@ pub use select::Select;
pub(crate) use select::{Listing, field_button, field_text, listing};
pub(crate) use signature::{Drawing, ink, pen};
pub use signature::{SignaturePad, Stroke};
+pub(crate) use slider::keyed;
pub use slider::{RangeSlider, Slider};
pub use stepper::Stepper;
pub use structure::{
diff --git a/src/forms/tests/other.rs b/src/forms/tests/other.rs
index 69b1a12..6f5fe8e 100644
--- a/src/forms/tests/other.rs
+++ b/src/forms/tests/other.rs
@@ -1,11 +1,12 @@
use gpui::{
- Context, IntoElement, Modifiers, MouseButton, Render, TestAppContext, Window, point, px,
+ Context, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, MouseButton, Render,
+ SharedString, TestAppContext, VisualTestContext, Window, point, px,
};
use super::setup;
use crate::{
- forms::{IconPicker, SignaturePad, Stroke},
- primitives::IconName,
+ forms::{IconPicker, InlineEdit, SignaturePad, Stroke},
+ primitives::{FocusNext, IconName},
};
struct Icons {
@@ -92,3 +93,54 @@ fn one_move_keeps_both_ends_of_the_stroke(cx: &mut TestAppContext) {
assert_eq!(stroke.len(), 2, "{stroke:?}");
assert_eq!(stroke[1] - stroke[0], point(px(60.0), px(-20.0)));
}
+
+/// An inline edit and the texts it kept.
+struct Titled(Vec);
+
+impl Render for Titled {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let view = cx.entity();
+ let value = self.0.last().cloned().unwrap_or_else(|| "Draft".into());
+ InlineEdit::new("title", value).on_commit(move |text, _, cx| {
+ view.update(cx, |view, cx| {
+ view.0.push(text.clone());
+ cx.notify();
+ })
+ })
+ }
+}
+
+/// Presses and releases `key`, a drawn frame between.
+fn release(key: &str, cx: &mut VisualTestContext) {
+ let redraw = |cx: &mut VisualTestContext| {
+ cx.run_until_parked();
+ cx.update(|window, _| window.refresh());
+ cx.run_until_parked();
+ };
+ cx.simulate_keystrokes(key);
+ redraw(cx);
+ cx.simulate_event(KeyUpEvent {
+ keystroke: Keystroke::parse(key).expect("a key"),
+ });
+ redraw(cx);
+}
+
+#[gpui::test]
+fn an_inline_edit_takes_tab_opens_on_enter_and_takes_focus_back(cx: &mut TestAppContext) {
+ setup(cx);
+ cx.update(|cx| cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]));
+ let (view, cx) = cx.add_window_view(|_, _| Titled(Vec::new()));
+ cx.update(|window, _| window.activate_window());
+ cx.update(|window, _| window.focus_next());
+ release("enter", cx);
+ cx.simulate_input("Stairs");
+ release("enter", cx);
+ release("enter", cx);
+ cx.simulate_input("Light");
+ release("enter", cx);
+ assert_eq!(
+ view.read_with(cx, |view, _| view.0.clone()),
+ ["Stairs", "Light"],
+ "focus came back after the first"
+ );
+}
diff --git a/src/media/mod.rs b/src/media/mod.rs
index a368af8..8750695 100644
--- a/src/media/mod.rs
+++ b/src/media/mod.rs
@@ -2,6 +2,10 @@ mod annotate;
mod crop;
mod cropper;
mod marks;
+mod player;
+mod scrubber;
+mod strip;
+mod subtitles;
mod thumbnail;
mod turn;
mod upload;
@@ -14,6 +18,10 @@ pub use annotate::ImageAnnotator;
pub use crop::Crop;
pub use cropper::ImageCropper;
pub use marks::{Mark, Tool};
+pub use player::{SPEEDS, VideoPlayer};
+pub use scrubber::Scrubber;
+pub use strip::VideoThumbnailStrip;
+pub use subtitles::{Cue, SubtitleEditor};
pub use thumbnail::ImageThumbnail;
pub use upload::ImageUpload;
pub use viewer::ImageViewer;
diff --git a/src/media/player.rs b/src/media/player.rs
new file mode 100644
index 0000000..81e7672
--- /dev/null
+++ b/src/media/player.rs
@@ -0,0 +1,401 @@
+use std::{rc::Rc, time::Duration};
+
+use gpui::{
+ Animation, AnimationExt, App, ElementId, ImageSource, InteractiveElement, IntoElement,
+ ParentElement, RenderOnce, SharedString, Styled, Window, div, img, prelude::*,
+ transparent_black,
+};
+
+use super::scrubber::{OnTime, Scrubber, clock};
+use crate::{
+ forms::{OnFlag, Run},
+ motion::duration as motion_duration,
+ overlays::media_button,
+ primitives::{FocusRing, IconName, framed, tab_stop},
+ theme::{ActiveTheme, ControlSize, Radius, TextSize},
+ typography::tabular,
+};
+
+type OnSpeed = Rc;
+
+/// Speeds a video plays at.
+pub const SPEEDS: [f32; 6] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
+/// How long a still pointer leaves the controls up while a video plays.
+const IDLE: Duration = Duration::from_millis(2500);
+/// Seconds J and L jump.
+const JUMP: f64 = 10.0;
+
+/// When the pointer last moved over the player, and whether a wake is set to hide the controls.
+struct Watch {
+ moved: std::time::Instant,
+ waking: bool,
+}
+
+/// A video the host plays, one frame at a time, in its own shape. Controls sit over it and fade once the pointer rests while it plays: play and pause, the time, the scrubber, the speed, captions, picture in picture and full screen, each only with its handler. With focus, Space and K play and pause, J and L jump ten seconds and the arrows five, C turns captions, F fills the screen.
+#[derive(IntoElement)]
+pub struct VideoPlayer {
+ id: ElementId,
+ frame: Option,
+ ratio: f32,
+ length: Duration,
+ at: Duration,
+ loaded: Duration,
+ playing: bool,
+ speed: f32,
+ caption: Option,
+ captions: Option,
+ on_play: Option,
+ on_seek: Option,
+ on_speed: Option,
+ on_captions: Option,
+ on_picture: Option,
+ on_fullscreen: Option,
+}
+
+impl VideoPlayer {
+ /// `frame` is the frame to show, none before the first; `ratio` its width over its height.
+ pub fn new(
+ id: impl Into,
+ frame: Option,
+ ratio: f32,
+ length: Duration,
+ at: Duration,
+ ) -> Self {
+ assert!(!length.is_zero() && at <= length, "{at:?} of {length:?}");
+ Self {
+ id: id.into(),
+ frame,
+ ratio: crate::primitives::checked_ratio(ratio),
+ length,
+ at,
+ loaded: Duration::ZERO,
+ playing: false,
+ speed: 1.0,
+ caption: None,
+ captions: None,
+ on_play: None,
+ on_seek: None,
+ on_speed: None,
+ on_captions: None,
+ on_picture: None,
+ on_fullscreen: None,
+ }
+ }
+
+ pub fn loaded(mut self, loaded: Duration) -> Self {
+ self.loaded = loaded;
+ self
+ }
+
+ pub fn playing(mut self, playing: bool) -> Self {
+ self.playing = playing;
+ self
+ }
+
+ /// One of `SPEEDS`.
+ pub fn speed(mut self, speed: f32) -> Self {
+ assert!(SPEEDS.contains(&speed), "a speed of {speed}");
+ self.speed = speed;
+ self
+ }
+
+ /// Captions on or off, and the words showing now; without this the video has none.
+ pub fn captions(mut self, on: bool, caption: Option) -> Self {
+ self.captions = Some(on);
+ self.caption = caption.filter(|_| on);
+ self
+ }
+
+ /// Gets whether to play.
+ pub fn on_play(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
+ self.on_play = Some(Rc::new(handler));
+ self
+ }
+
+ pub fn on_seek(mut self, handler: impl Fn(Duration, &mut Window, &mut App) + 'static) -> Self {
+ self.on_seek = Some(Rc::new(handler));
+ self
+ }
+
+ pub fn on_speed(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self {
+ self.on_speed = Some(Rc::new(handler));
+ self
+ }
+
+ pub fn on_captions(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
+ self.on_captions = Some(Rc::new(handler));
+ self
+ }
+
+ /// Gets a press on picture in picture.
+ pub fn on_picture(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
+ self.on_picture = Some(Rc::new(handler));
+ self
+ }
+
+ pub fn on_fullscreen(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
+ self.on_fullscreen = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for VideoPlayer {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let started = cx.background_executor().now();
+ let watch = window.use_keyed_state((self.id.clone(), "watch"), cx, move |_, _| Watch {
+ moved: started,
+ waking: false,
+ });
+ let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
+ let now = cx.background_executor().now();
+ let resting = self.playing
+ && now.duration_since(watch.read(cx).moved) >= IDLE
+ && !focus.contains_focused(window, cx);
+ let changes = crate::motion::changes((self.id.clone(), "shown"), !resting, window, cx);
+ let theme = cx.theme();
+ let colors = theme.colors.clone();
+ let light = colors.on_media;
+ let (length, at, playing, speed) = (self.length, self.at, self.playing, self.speed);
+ let button = |key: &'static str, icon: IconName, run: Run| {
+ media_button(
+ (self.id.clone(), key),
+ icon,
+ ControlSize::Sm,
+ true,
+ move |window, cx| run(window, cx),
+ cx,
+ )
+ };
+ let play = self.on_play.clone().map(|play| {
+ let icon = if playing {
+ IconName::Pause
+ } else {
+ IconName::Play
+ };
+ button(
+ "play",
+ icon,
+ Rc::new(move |window, cx| {
+ log::info!("video player: {}", if playing { "pause" } else { "play" });
+ play(!playing, window, cx)
+ }),
+ )
+ });
+ let speed_button = self.on_speed.clone().map(|set| {
+ let next = SPEEDS[(SPEEDS
+ .iter()
+ .position(|each| *each == speed)
+ .expect("a listed speed")
+ + 1)
+ % SPEEDS.len()];
+ div()
+ .flex()
+ .items_center()
+ .child(button(
+ "speed",
+ IconName::Gauge,
+ Rc::new(move |window, cx| {
+ log::info!("video player: speed {next}×");
+ set(next, window, cx)
+ }),
+ ))
+ .child(
+ tabular(div())
+ .text_size(theme.text_size(TextSize::Xs))
+ .text_color(light)
+ .child(format!("{speed}×")),
+ )
+ });
+ let captions = self
+ .on_captions
+ .clone()
+ .zip(self.captions)
+ .map(|(set, on)| {
+ let icon = if on {
+ IconName::Captions
+ } else {
+ IconName::CaptionsOff
+ };
+ button(
+ "captions",
+ icon,
+ Rc::new(move |window, cx| set(!on, window, cx)),
+ )
+ });
+ let picture = self
+ .on_picture
+ .clone()
+ .map(|run| button("picture", IconName::PictureInPicture2, run));
+ let fullscreen = self
+ .on_fullscreen
+ .clone()
+ .map(|run| button("fullscreen", IconName::Fullscreen, run));
+ let scrubber = Scrubber::new((self.id.clone(), "scrubber"), length, at)
+ .loaded(self.loaded)
+ .over_media();
+ let scrubber = match self.on_seek.clone() {
+ Some(seek) => scrubber.on_seek(move |time, window, cx| seek(time, window, cx)),
+ None => scrubber,
+ };
+ let bar = div()
+ .flex()
+ .flex_col()
+ .px_2()
+ .pt_1()
+ .rounded(theme.radius(Radius::Md))
+ .bg(colors.media_backdrop.alpha(0.6))
+ .child(scrubber)
+ .child(
+ div()
+ .flex()
+ .flex_wrap()
+ .items_center()
+ .justify_between()
+ .child(
+ div().flex().items_center().children(play).child(
+ tabular(div())
+ .flex_none()
+ .px_1()
+ .text_size(theme.text_size(TextSize::Xs))
+ .text_color(light)
+ .child(format!("{} / {}", clock(at), clock(length))),
+ ),
+ )
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .children(speed_button)
+ .children(captions)
+ .children(picture)
+ .children(fullscreen),
+ ),
+ );
+ let caption = self.caption.map(|words| {
+ div().flex().justify_center().child(
+ div()
+ .max_w(theme.prose_width())
+ .px_2()
+ .py_0p5()
+ .rounded(theme.radius(Radius::Sm))
+ .bg(colors.media_backdrop.alpha(0.7))
+ .text_color(light)
+ .text_size(theme.text_size(TextSize::Base))
+ .child(words),
+ )
+ });
+ let controls = div()
+ .when(resting, |controls| {
+ controls.debug_selector(|| "video-controls-resting".into())
+ })
+ .absolute()
+ .left_0()
+ .right_0()
+ .bottom_0()
+ .p_2()
+ .flex()
+ .flex_col()
+ .gap_2()
+ .children(caption)
+ .child(bar);
+ let (from, to) = if resting { (1.0, 0.0) } else { (0.0, 1.0) };
+ let controls = if changes == 0 {
+ controls.opacity(to).into_any_element()
+ } else {
+ let fade = motion_duration(crate::motion::BASE, cx);
+ controls
+ .with_animation(
+ ("controls", changes),
+ Animation::new(fade),
+ move |controls, t| controls.opacity(crate::motion::lerp(from, to, t)),
+ )
+ .into_any_element()
+ };
+ let frame = self
+ .frame
+ .map(|frame| img(frame).id((self.id.clone(), "frame")).size_full());
+ let moved = watch.clone();
+ let keyed = (
+ self.on_play.clone(),
+ self.on_seek.clone(),
+ self.on_captions.clone(),
+ self.captions,
+ self.on_fullscreen.clone(),
+ );
+ framed(self.ratio, cx)
+ .id(self.id.clone())
+ .debug_selector(|| "video-player".into())
+ .bg(colors.media_backdrop)
+ .border_1()
+ .border_color(transparent_black())
+ .track_focus(&focus)
+ .focus_ring(cx)
+ .children(frame)
+ .child(controls)
+ .on_mouse_move(move |_, window, cx| {
+ let arm = moved.update(cx, |watch, cx| {
+ watch.moved = cx.background_executor().now();
+ cx.notify();
+ !std::mem::replace(&mut watch.waking, true)
+ });
+ if arm {
+ let watch = moved.clone();
+ window
+ .spawn(cx, async move |cx| {
+ loop {
+ let wait = watch.read_with(cx, |watch, cx| {
+ IDLE.saturating_sub(
+ cx.background_executor().now().duration_since(watch.moved),
+ )
+ })?;
+ if wait.is_zero() {
+ break;
+ }
+ cx.background_executor().timer(wait).await;
+ }
+ watch.update(cx, |watch, cx| {
+ watch.waking = false;
+ cx.notify();
+ })
+ })
+ .detach();
+ }
+ })
+ .on_key_down(move |event, window, cx| {
+ let (play, seek, captions, on, fullscreen) = &keyed;
+ let step = |by: f64| {
+ Duration::from_secs_f64(
+ (at.as_secs_f64() + by).clamp(0.0, length.as_secs_f64()),
+ )
+ };
+ match event.keystroke.key.as_str() {
+ "space" | "k" => match play {
+ Some(play) => play(!playing, window, cx),
+ None => return,
+ },
+ "j" | "l" | "left" | "right" => {
+ let Some(seek) = seek else {
+ return;
+ };
+ let by = match event.keystroke.key.as_str() {
+ "j" => -JUMP,
+ "l" => JUMP,
+ "left" => -5.0,
+ _ => 5.0,
+ };
+ seek(step(by), window, cx)
+ }
+ "c" => match (captions, on) {
+ (Some(set), Some(on)) => set(!on, window, cx),
+ _ => return,
+ },
+ "f" => match fullscreen {
+ Some(run) => run(window, cx),
+ None => return,
+ },
+ _ => return,
+ }
+ cx.stop_propagation();
+ })
+ }
+}
diff --git a/src/media/scrubber.rs b/src/media/scrubber.rs
new file mode 100644
index 0000000..bcf7b57
--- /dev/null
+++ b/src/media/scrubber.rs
@@ -0,0 +1,334 @@
+use std::{rc::Rc, time::Duration};
+
+use gpui::{
+ App, Bounds, DragMoveEvent, ElementId, EmptyView, EntityId, InteractiveElement, IntoElement,
+ MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString,
+ StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, relative,
+ transparent_black,
+};
+
+use crate::{
+ forms::keyed,
+ primitives::{FocusRing, tab_stop},
+ theme::{ActiveTheme, Radius, TextSize},
+ typography::{
+ format::{DurationStyle, duration},
+ tabular,
+ },
+};
+
+pub(crate) type OnTime = Rc;
+
+/// Seconds an arrow key moves the time; Page keys move ten times as far.
+const STEP: f64 = 5.0;
+
+/// The time at `share` of `length`, the share held to the track.
+pub(crate) fn time_at(share: f32, length: Duration) -> Duration {
+ length.mul_f32(share.clamp(0.0, 1.0))
+}
+
+/// The chapter `at` falls in: the last to start at or before it.
+pub(crate) fn chapter_at(starts: &[Duration], at: Duration) -> Option {
+ starts.iter().rposition(|start| *start <= at)
+}
+
+/// A time as the crate's clock reads it.
+pub(crate) fn clock(at: Duration) -> String {
+ duration(at.as_secs(), DurationStyle::Clock)
+}
+
+/// A seeking drag, marked with its scrubber.
+struct Scrub(EntityId);
+
+/// The track's bounds and the share under the pointer.
+#[derive(Default)]
+struct Track {
+ bounds: Bounds,
+ hover: Option,
+}
+
+/// Where a video or a song stands along its length: what has loaded, how far it has played, its chapters, and the time under the pointer. A press or a drag seeks; with focus the arrows step five seconds, the Page keys fifty, Home and End go to the ends. The host plays it.
+#[derive(IntoElement)]
+pub struct Scrubber {
+ id: ElementId,
+ length: Duration,
+ at: Duration,
+ loaded: Duration,
+ chapters: Vec<(Duration, SharedString)>,
+ over_media: bool,
+ on_seek: Option,
+}
+
+impl Scrubber {
+ /// `at` is how far it has played, within `length`.
+ pub fn new(id: impl Into, length: Duration, at: Duration) -> Self {
+ assert!(!length.is_zero() && at <= length, "{at:?} of {length:?}");
+ Self {
+ id: id.into(),
+ length,
+ at,
+ loaded: Duration::ZERO,
+ chapters: Vec::new(),
+ over_media: false,
+ on_seek: None,
+ }
+ }
+
+ /// Light on a dark layer over pictures, as a player's controls sit.
+ pub fn over_media(mut self) -> Self {
+ self.over_media = true;
+ self
+ }
+
+ /// How far it has loaded.
+ pub fn loaded(mut self, loaded: Duration) -> Self {
+ assert!(
+ loaded <= self.length,
+ "loaded {loaded:?} of {:?}",
+ self.length
+ );
+ self.loaded = loaded;
+ self
+ }
+
+ /// A chapter from `start`, after the last one given.
+ pub fn chapter(mut self, start: Duration, title: impl Into) -> Self {
+ let after = self.chapters.last().is_none_or(|(last, _)| *last < start);
+ assert!(
+ after && start < self.length,
+ "a chapter at {start:?} out of order"
+ );
+ self.chapters.push((start, title.into()));
+ self
+ }
+
+ /// Gets the time to seek to.
+ pub fn on_seek(mut self, handler: impl Fn(Duration, &mut Window, &mut App) + 'static) -> Self {
+ self.on_seek = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for Scrubber {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let track = window.use_keyed_state((self.id.clone(), "track"), cx, |_, _| Track::default());
+ let focus = tab_stop(
+ (self.id.clone(), "focus").into(),
+ self.on_seek.is_some(),
+ window,
+ cx,
+ );
+ let focused = focus.is_focused(window);
+ let owner = track.entity_id();
+ let (length, at) = (self.length, self.at);
+ let share = |time: Duration| time.as_secs_f32() / length.as_secs_f32();
+ let starts: Vec = self.chapters.iter().map(|(start, _)| *start).collect();
+ let hover = track.read(cx).hover;
+ let theme = cx.theme();
+ let colors = theme.colors.clone();
+ let (played, lane) = match self.over_media {
+ true => (colors.on_media, colors.on_media.opacity(0.25)),
+ false => (colors.fg, colors.border),
+ };
+ let (line, thumb) = (theme.slider_track(), theme.slider_thumb() * 0.75);
+ let mut bounds: Vec = if starts.first() == Some(&Duration::ZERO) {
+ starts.clone()
+ } else {
+ std::iter::once(Duration::ZERO)
+ .chain(starts.iter().copied())
+ .collect()
+ };
+ bounds.push(length);
+ let lanes = bounds.windows(2).map(|pair| {
+ let (start, end) = (pair[0], pair[1]);
+ let within = |time: Duration| {
+ ((time.as_secs_f32() - start.as_secs_f32()) / (end - start).as_secs_f32())
+ .clamp(0.0, 1.0)
+ };
+ div()
+ .relative()
+ .h(line)
+ .flex_basis(relative(share(end - start)))
+ .flex_grow()
+ .rounded_full()
+ .bg(lane)
+ .child(
+ div()
+ .absolute()
+ .top_0()
+ .left_0()
+ .h_full()
+ .w(relative(within(self.loaded)))
+ .rounded_full()
+ .bg(played.opacity(0.35)),
+ )
+ .child(
+ div()
+ .absolute()
+ .top_0()
+ .left_0()
+ .h_full()
+ .w(relative(within(at)))
+ .rounded_full()
+ .bg(played),
+ )
+ });
+ let tip = hover.map(|hovered| {
+ let time = time_at(hovered, length);
+ let name = chapter_at(&starts, time).map(|ix| self.chapters[ix].1.clone());
+ let text = match name {
+ Some(name) => format!("{} · {name}", clock(time)),
+ None => clock(time),
+ };
+ div()
+ .absolute()
+ .left(relative(hovered))
+ .bottom_full()
+ .size_0()
+ .flex()
+ .justify_center()
+ .items_end()
+ .child(
+ tabular(div())
+ .flex_none()
+ .mb_1()
+ .px_1p5()
+ .rounded(theme.radius(Radius::Sm))
+ .bg(colors.tooltip_bg)
+ .text_color(colors.tooltip_fg)
+ .text_size(theme.text_size(TextSize::Xs))
+ .whitespace_nowrap()
+ .child(text),
+ )
+ .debug_selector(|| "scrubber-tip".into())
+ });
+ let knob = (hover.is_some() || focused).then(|| {
+ div()
+ .absolute()
+ .left(relative(share(at)))
+ .top(relative(0.5))
+ .size_0()
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(div().flex_none().size(thumb).rounded_full().bg(played))
+ });
+ let measured = track.clone();
+ let rail = div()
+ .debug_selector(|| "scrubber-rail".into())
+ .relative()
+ .w_full()
+ .flex()
+ .gap_0p5()
+ .items_center()
+ .h(thumb)
+ .children(lanes)
+ .child(
+ canvas(
+ move |bounds, window, cx| {
+ if measured.read(cx).bounds != bounds {
+ measured.update(cx, |track, cx| {
+ track.bounds = bounds;
+ cx.notify();
+ });
+ window.request_animation_frame();
+ }
+ },
+ |_, _, _, _| {},
+ )
+ .absolute()
+ .top_0()
+ .left_0()
+ .size_full(),
+ )
+ .children(knob)
+ .children(tip);
+ let spot = move |bounds: Bounds, pointer: Point| {
+ f32::from(pointer.x - bounds.left()) / f32::from(bounds.size.width)
+ };
+ let (moved, left) = (track.clone(), track.clone());
+ div()
+ .id(self.id.clone())
+ .debug_selector(|| "scrubber".into())
+ .w_full()
+ .px(thumb / 2.0)
+ .rounded(theme.radius(Radius::Sm))
+ .border_1()
+ .border_color(transparent_black())
+ .child(rail)
+ .on_mouse_move(move |event, _, cx| {
+ moved.update(cx, |track, cx| {
+ track.hover = Some(spot(track.bounds, event.position).clamp(0.0, 1.0));
+ cx.notify();
+ })
+ })
+ .on_hover(move |inside, _, cx| {
+ if !inside {
+ left.update(cx, |track, cx| {
+ track.hover = None;
+ cx.notify();
+ })
+ }
+ })
+ .when_some(self.on_seek, |scrubber, seek| {
+ let (pressed, dragged, keys) = (seek.clone(), seek.clone(), seek);
+ let (pressed_track, dragged_track) = (track.clone(), track.clone());
+ scrubber
+ .track_focus(&focus)
+ .focus_ring(cx)
+ .cursor_pointer()
+ .on_mouse_down(MouseButton::Left, move |event, window, cx| {
+ let time =
+ time_at(spot(pressed_track.read(cx).bounds, event.position), length);
+ log::info!("scrubber: seek to {}", clock(time));
+ pressed(time, window, cx);
+ })
+ .on_drag(Scrub(owner), |_, _, _, cx| cx.new(|_| EmptyView))
+ .on_drag_move(move |event: &DragMoveEvent, window, cx| {
+ if event.drag(cx).0 != owner {
+ return;
+ }
+ let bounds = dragged_track.read(cx).bounds;
+ dragged(
+ time_at(spot(bounds, event.event.position), length),
+ window,
+ cx,
+ );
+ })
+ .on_key_down(move |event, window, cx| {
+ let (now, end) = (at.as_secs_f64(), length.as_secs_f64());
+ let Some(next) = keyed(&event.keystroke.key, now, 0.0, end, STEP) else {
+ return;
+ };
+ cx.stop_propagation();
+ let time = Duration::from_secs_f64(next);
+ log::info!("scrubber: seek to {}", clock(time));
+ keys(time, window, cx);
+ })
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::time::Duration;
+
+ use super::{chapter_at, clock, time_at};
+
+ #[test]
+ fn a_share_is_a_time_held_to_the_track() {
+ let length = Duration::from_secs(200);
+ assert_eq!(time_at(0.25, length), Duration::from_secs(50));
+ assert_eq!(time_at(1.4, length), length);
+ assert_eq!(time_at(-0.2, length), Duration::ZERO);
+ }
+
+ #[test]
+ fn a_time_falls_in_the_last_chapter_begun() {
+ let starts = [Duration::from_secs(10), Duration::from_secs(60)];
+ assert_eq!(chapter_at(&starts, Duration::from_secs(5)), None);
+ assert_eq!(chapter_at(&starts, Duration::from_secs(10)), Some(0));
+ assert_eq!(chapter_at(&starts, Duration::from_secs(90)), Some(1));
+ assert_eq!(clock(Duration::from_secs(83)), "01:23");
+ }
+}
diff --git a/src/media/strip.rs b/src/media/strip.rs
new file mode 100644
index 0000000..addc52d
--- /dev/null
+++ b/src/media/strip.rs
@@ -0,0 +1,344 @@
+use std::{rc::Rc, time::Duration};
+
+use gpui::{
+ App, Bounds, CursorStyle, DragMoveEvent, ElementId, EmptyView, EntityId, ImageSource,
+ InteractiveElement, IntoElement, MouseButton, ObjectFit, ParentElement, Pixels, Point,
+ RenderOnce, StatefulInteractiveElement, Styled, Window, canvas, div, img, prelude::*, relative,
+ transparent_black,
+};
+
+use super::scrubber::{OnTime, clock, time_at};
+use crate::{
+ primitives::{FocusRing, tab_stop},
+ theme::ActiveTheme,
+};
+
+type OnTrim = Rc;
+
+/// Least span a trim keeps.
+const LEAST: Duration = Duration::from_secs(1);
+
+/// Which end of a trim a press holds.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum End {
+ In,
+ Out,
+}
+
+/// The trim after `end` moves to `to`, kept within `length` and at least `LEAST` long.
+pub(crate) fn trimmed(
+ trim: (Duration, Duration),
+ end: End,
+ to: Duration,
+ length: Duration,
+) -> (Duration, Duration) {
+ let (start, stop) = trim;
+ match end {
+ End::In => (to.min(stop.saturating_sub(LEAST)), stop),
+ End::Out => (start, to.max(start + LEAST).min(length)),
+ }
+}
+
+/// The end of `trim` within `reach`, both shares of `length`, from `at`; the nearer when both are.
+pub(crate) fn end_at(
+ trim: (Duration, Duration),
+ at: f32,
+ reach: f32,
+ length: Duration,
+) -> Option {
+ let share = |time: Duration| time.as_secs_f32() / length.as_secs_f32();
+ let (start, stop) = ((share(trim.0) - at).abs(), (share(trim.1) - at).abs());
+ match (start <= reach, stop <= reach) {
+ (true, true) if stop < start => Some(End::Out),
+ (true, _) => Some(End::In),
+ (false, true) => Some(End::Out),
+ _ => None,
+ }
+}
+
+/// A drag on the strip, marked with its strip.
+struct Hold(EntityId);
+
+/// The strip's bounds and the end the last press took.
+#[derive(Default)]
+struct Strip {
+ bounds: Bounds,
+ held: Option,
+}
+
+/// A video's frames laid along its length under a playhead, and with a trim, the part kept between two handles. A press seeks and a drag scrubs; a handle's drag moves its end. With focus, the arrows step the playhead a frame's span, and I and O set the trim's ends at the playhead.
+#[derive(IntoElement)]
+pub struct VideoThumbnailStrip {
+ id: ElementId,
+ frames: Vec,
+ length: Duration,
+ at: Duration,
+ trim: Option<(Duration, Duration)>,
+ on_seek: Option,
+ on_trim: Option,
+}
+
+impl VideoThumbnailStrip {
+ /// `frames` spread evenly along `length`.
+ pub fn new(
+ id: impl Into,
+ frames: impl IntoIterator- >,
+ length: Duration,
+ at: Duration,
+ ) -> Self {
+ let frames: Vec = frames.into_iter().map(Into::into).collect();
+ assert!(
+ !frames.is_empty() && !length.is_zero() && at <= length,
+ "{} frames, {at:?} of {length:?}",
+ frames.len()
+ );
+ Self {
+ id: id.into(),
+ frames,
+ length,
+ at,
+ trim: None,
+ on_seek: None,
+ on_trim: None,
+ }
+ }
+
+ /// The part kept, from its start to its end.
+ pub fn trim(mut self, start: Duration, end: Duration) -> Self {
+ assert!(
+ start + LEAST <= end && end <= self.length,
+ "a trim of {start:?} to {end:?}"
+ );
+ self.trim = Some((start, end));
+ self
+ }
+
+ pub fn on_seek(mut self, handler: impl Fn(Duration, &mut Window, &mut App) + 'static) -> Self {
+ self.on_seek = Some(Rc::new(handler));
+ self
+ }
+
+ pub fn on_trim(
+ mut self,
+ handler: impl Fn((Duration, Duration), &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_trim = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for VideoThumbnailStrip {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let strip = window.use_keyed_state((self.id.clone(), "strip"), cx, |_, _| Strip::default());
+ let editable = self.on_seek.is_some() || self.on_trim.is_some();
+ let focus = tab_stop((self.id.clone(), "focus").into(), editable, window, cx);
+ let owner = strip.entity_id();
+ let (length, at, trim) = (self.length, self.at, self.trim);
+ let span = length / self.frames.len() as u32;
+ let share = move |time: Duration| time.as_secs_f32() / length.as_secs_f32();
+ let theme = cx.theme();
+ let colors = theme.colors.clone();
+ let sizes = theme.media();
+ let (tall, handle) = (sizes.strip, sizes.trim_handle);
+ let reach = handle.to_pixels(window.rem_size());
+ let frames = self.frames.into_iter().enumerate().map(|(ix, frame)| {
+ div().flex_1().min_w_0().h_full().overflow_hidden().child(
+ img(frame)
+ .id((self.id.clone(), format!("frame-{ix}")))
+ .size_full()
+ .object_fit(ObjectFit::Cover),
+ )
+ });
+ let veil = |from: f32, to: f32| {
+ div()
+ .absolute()
+ .top_0()
+ .bottom_0()
+ .left(relative(from))
+ .w(relative(to - from))
+ .bg(colors.media_backdrop.alpha(0.55))
+ };
+ let grip = |at: f32, cursor: CursorStyle| {
+ div()
+ .absolute()
+ .top_0()
+ .bottom_0()
+ .left(relative(at))
+ .w_0()
+ .flex()
+ .justify_center()
+ .child(
+ div()
+ .flex_none()
+ .w(handle)
+ .h_full()
+ .rounded(theme.radius(crate::theme::Radius::Sm))
+ .bg(colors.accent)
+ .cursor(cursor),
+ )
+ };
+ let kept = trim.map(|(start, stop)| {
+ let (from, to) = (share(start), share(stop));
+ div()
+ .absolute()
+ .inset_0()
+ .child(veil(0.0, from))
+ .child(veil(to, 1.0))
+ .child(
+ div()
+ .absolute()
+ .top_0()
+ .bottom_0()
+ .left(relative(from))
+ .w(relative(to - from))
+ .border_y_2()
+ .border_color(colors.accent),
+ )
+ .child(grip(from, CursorStyle::ResizeLeftRight))
+ .child(grip(to, CursorStyle::ResizeLeftRight))
+ });
+ let playhead = div()
+ .absolute()
+ .top_0()
+ .bottom_0()
+ .left(relative(share(at)))
+ .w_0()
+ .border_l_2()
+ .border_color(colors.on_media)
+ .debug_selector(|| "strip-playhead".into());
+ let measured = strip.clone();
+ let row = div()
+ .relative()
+ .h(tall)
+ .flex()
+ .debug_selector(|| "video-strip".into())
+ .children(frames)
+ .child(
+ canvas(
+ move |bounds, window, cx| {
+ if measured.read(cx).bounds != bounds {
+ measured.update(cx, |strip, cx| {
+ strip.bounds = bounds;
+ cx.notify();
+ });
+ window.request_animation_frame();
+ }
+ },
+ |_, _, _, _| {},
+ )
+ .absolute()
+ .top_0()
+ .left_0()
+ .size_full(),
+ )
+ .children(kept)
+ .child(playhead);
+ let spot = move |bounds: Bounds, pointer: Point| {
+ f32::from(pointer.x - bounds.left()) / f32::from(bounds.size.width)
+ };
+ let (on_seek, on_trim) = (self.on_seek, self.on_trim);
+ let act = move |held: Option, time: Duration, window: &mut Window, cx: &mut App| match (
+ held, trim, &on_trim, &on_seek,
+ ) {
+ (Some(end), Some(trim), Some(set), _) => {
+ set(trimmed(trim, end, time, length), window, cx)
+ }
+ (None, _, _, Some(seek)) => seek(time, window, cx),
+ _ => {}
+ };
+ let act = Rc::new(act);
+ let (pressed, moved, keys) = (act.clone(), act.clone(), act);
+ let (pressed_strip, moved_strip) = (strip.clone(), strip.clone());
+ div()
+ .id(self.id.clone())
+ .w_full()
+ .px(handle / 2.0)
+ .border_1()
+ .border_color(transparent_black())
+ .child(row)
+ .when(editable, |outer| {
+ outer
+ .track_focus(&focus)
+ .focus_ring(cx)
+ .cursor_pointer()
+ .on_mouse_down(MouseButton::Left, move |event, window, cx| {
+ let bounds = pressed_strip.read(cx).bounds;
+ let at = spot(bounds, event.position);
+ let reach = f32::from(reach) / f32::from(bounds.size.width);
+ let held = trim.and_then(|trim| end_at(trim, at, reach, length));
+ pressed_strip.update(cx, |strip, _| strip.held = held);
+ if held.is_none() {
+ let time = time_at(at, length);
+ log::info!("video strip: seek to {}", clock(time));
+ pressed(None, time, window, cx);
+ }
+ })
+ .on_drag(Hold(owner), |_, _, _, cx| cx.new(|_| EmptyView))
+ .on_drag_move(move |event: &DragMoveEvent, window, cx| {
+ if event.drag(cx).0 != owner {
+ return;
+ }
+ let (bounds, held) = {
+ let strip = moved_strip.read(cx);
+ (strip.bounds, strip.held)
+ };
+ moved(
+ held,
+ time_at(spot(bounds, event.event.position), length),
+ window,
+ cx,
+ );
+ })
+ .on_key_down(move |event, window, cx| {
+ let (held, time) = match event.keystroke.key.as_str() {
+ "left" => (None, at.saturating_sub(span)),
+ "right" => (None, (at + span).min(length)),
+ "i" if trim.is_some() => (Some(End::In), at),
+ "o" if trim.is_some() => (Some(End::Out), at),
+ _ => return,
+ };
+ cx.stop_propagation();
+ log::info!("video strip: {:?} at {}", held, clock(time));
+ keys(held, time, window, cx);
+ })
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::time::Duration;
+
+ use super::{End, end_at, trimmed};
+
+ const TEN: Duration = Duration::from_secs(10);
+
+ #[test]
+ fn a_trim_end_moves_and_stops_short_of_the_other_and_the_length() {
+ let (trim, length) = ((TEN, TEN * 5), TEN * 10);
+ assert_eq!(trimmed(trim, End::In, TEN * 2, length), (TEN * 2, TEN * 5));
+ assert_eq!(
+ trimmed(trim, End::In, TEN * 6, length),
+ (TEN * 5 - Duration::from_secs(1), TEN * 5)
+ );
+ assert_eq!(
+ trimmed(trim, End::Out, TEN / 2, length),
+ (TEN, TEN + Duration::from_secs(1))
+ );
+ assert_eq!(trimmed(trim, End::Out, TEN * 20, length), (TEN, length));
+ }
+
+ #[test]
+ fn a_press_holds_the_nearer_end_within_reach() {
+ let (trim, length) = ((TEN, TEN * 5), TEN * 10);
+ assert_eq!(end_at(trim, 0.11, 0.02, length), Some(End::In));
+ assert_eq!(end_at(trim, 0.49, 0.02, length), Some(End::Out));
+ assert_eq!(end_at(trim, 0.3, 0.02, length), None);
+ let close = (TEN * 5, TEN * 5 + Duration::from_secs(1));
+ assert_eq!(
+ end_at(close, 0.509, 0.02, length),
+ Some(End::Out),
+ "the nearer of two"
+ );
+ }
+}
diff --git a/src/media/subtitles.rs b/src/media/subtitles.rs
new file mode 100644
index 0000000..2527740
--- /dev/null
+++ b/src/media/subtitles.rs
@@ -0,0 +1,362 @@
+use std::{rc::Rc, time::Duration};
+
+use gpui::{
+ App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
+ prelude::*,
+};
+
+use super::scrubber::OnTime;
+use crate::{
+ buttons::{Button, ButtonVariant, IconButton},
+ forms::InlineEdit,
+ primitives::IconName,
+ theme::{ActiveTheme, ControlSize, Radius, TextSize},
+};
+
+type OnCues = Rc;
+
+/// How long a cue added at the playhead lasts.
+const FRESH: Duration = Duration::from_secs(2);
+
+/// A caption: its key, when it shows and when it goes, and its words.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Cue {
+ pub key: SharedString,
+ pub start: Duration,
+ pub end: Duration,
+ pub text: SharedString,
+}
+
+/// A time read from `mm:ss.mmm` or `h:mm:ss.mmm`, its fraction optional; none when it does not read.
+pub(crate) fn parse_time(text: &str) -> Option {
+ let text = text.trim();
+ let (clock, fraction) = text.split_once('.').unwrap_or((text, ""));
+ let parts: Vec = clock
+ .split(':')
+ .map(|part| part.parse().ok())
+ .collect::