T26c Media: video

Scrubber shows what has loaded, what has played and its chapters, names
the time under the pointer, seeks by press and gpui's drag, and steps by
the slider's keys; over_media lights it for a dark layer. VideoPlayer
shows the host's frame in its shape with a caption and a control bar over
it: the scrubber, play, the time, speed, captions, picture in picture and
full screen, each only with its handler; the bar rests after a still
pointer while it plays, on one wake from the executor's timer. Keys:
Space and K, J and L, the arrows, C and F. VideoThumbnailStrip lays frames
along the length under a playhead with a trim whose handles drag and whose
ends I and O set. SubtitleEditor edits cues in place: times as mm:ss.mmm,
kept in order, a time that does not read kept out with the reason shown.

InlineEdit is a Tab stop with the ring; it hands focus back after Enter or
Escape and keeps its text on Enter's release. overlays::media_button is
shared by the lightbox and the player's bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-26 17:07:26 -04:00
1 parent f7f20b5146
commit d0175edb7b
22 files changed
+2232 -25

No files matched your search

+8
View File
@@ -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;
+401
View File
@@ -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<dyn Fn(f32, &mut Window, &mut App)>;
/// 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<ImageSource>,
ratio: f32,
length: Duration,
at: Duration,
loaded: Duration,
playing: bool,
speed: f32,
caption: Option<SharedString>,
captions: Option<bool>,
on_play: Option<OnFlag>,
on_seek: Option<OnTime>,
on_speed: Option<OnSpeed>,
on_captions: Option<OnFlag>,
on_picture: Option<Run>,
on_fullscreen: Option<Run>,
}
impl VideoPlayer {
/// `frame` is the frame to show, none before the first; `ratio` its width over its height.
pub fn new(
id: impl Into<ElementId>,
frame: Option<ImageSource>,
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<SharedString>) -> 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();
})
}
}
+334
View File
@@ -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<dyn Fn(Duration, &mut Window, &mut App)>;
/// 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<usize> {
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<Pixels>,
hover: Option<f32>,
}
/// 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<OnTime>,
}
impl Scrubber {
/// `at` is how far it has played, within `length`.
pub fn new(id: impl Into<ElementId>, 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<SharedString>) -> 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<Duration> = 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<Duration> = 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<Pixels>, pointer: Point<Pixels>| {
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<Scrub>, 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");
}
}
+344
View File
@@ -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<dyn Fn((Duration, Duration), &mut Window, &mut App)>;
/// 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<End> {
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<Pixels>,
held: Option<End>,
}
/// 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<ImageSource>,
length: Duration,
at: Duration,
trim: Option<(Duration, Duration)>,
on_seek: Option<OnTime>,
on_trim: Option<OnTrim>,
}
impl VideoThumbnailStrip {
/// `frames` spread evenly along `length`.
pub fn new(
id: impl Into<ElementId>,
frames: impl IntoIterator<Item = impl Into<ImageSource>>,
length: Duration,
at: Duration,
) -> Self {
let frames: Vec<ImageSource> = 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<Pixels>, pointer: Point<Pixels>| {
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<End>, 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<Hold>, 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"
);
}
}
+362
View File
@@ -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<dyn Fn(&[Cue], &mut Window, &mut App)>;
/// 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<Duration> {
let text = text.trim();
let (clock, fraction) = text.split_once('.').unwrap_or((text, ""));
let parts: Vec<u64> = clock
.split(':')
.map(|part| part.parse().ok())
.collect::<Option<_>>()?;
let seconds = match parts.as_slice() {
[minutes, seconds] if *seconds < 60 => minutes * 60 + seconds,
[hours, minutes, seconds] if *minutes < 60 && *seconds < 60 => {
hours * 3600 + minutes * 60 + seconds
}
_ => return None,
};
let millis = match fraction.len() {
0 => 0,
1..=3 if fraction.chars().all(|ch| ch.is_ascii_digit()) => {
format!("{fraction:0<3}").parse().ok()?
}
_ => return None,
};
Some(Duration::from_millis(seconds * 1000 + millis))
}
/// A time as `mm:ss.mmm`, the hours ahead once it runs past one.
pub(crate) fn stamp(time: Duration) -> String {
let (millis, seconds) = (time.subsec_millis(), time.as_secs());
match seconds / 3600 {
0 => format!("{:02}:{:02}.{millis:03}", seconds / 60, seconds % 60),
hours => format!(
"{hours}:{:02}:{:02}.{millis:03}",
seconds / 60 % 60,
seconds % 60
),
}
}
/// Which time of a cue an edit changes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Edge {
Start,
End,
}
/// The cues after `key`'s `edge` reads `text`, in time order, or why they stay as they were.
pub(crate) fn retimed(cues: &[Cue], key: &str, edge: Edge, text: &str) -> Result<Vec<Cue>, String> {
let time = parse_time(text).ok_or_else(|| {
format!(
"\u{201c}{}\u{201d} is not a time; write mm:ss.mmm",
text.trim()
)
})?;
let mut next = cues.to_vec();
let cue = next
.iter_mut()
.find(|cue| cue.key == key)
.expect("a listed cue");
match edge {
Edge::Start => cue.start = time,
Edge::End => cue.end = time,
}
if cue.start >= cue.end {
return Err("A cue ends after it starts".to_string());
}
next.sort_by_key(|cue| cue.start);
Ok(next)
}
/// Captions to edit, a cue a block in time order: its start and end as mm:ss.mmm over its words, each edited in place, with seek and remove beside. The cue under the playhead is lit; Add puts a two-second cue there. A time that does not read, or ends before it starts, leaves the cue as it was and says why under it.
#[derive(IntoElement)]
pub struct SubtitleEditor {
id: ElementId,
cues: Vec<Cue>,
at: Duration,
on_change: Option<OnCues>,
on_seek: Option<OnTime>,
}
impl SubtitleEditor {
/// `cues` in time order, each ending after it starts; `at` is the playhead.
pub fn new(
id: impl Into<ElementId>,
cues: impl IntoIterator<Item = Cue>,
at: Duration,
) -> Self {
let cues: Vec<Cue> = cues.into_iter().collect();
for (ix, cue) in cues.iter().enumerate() {
assert!(cue.start < cue.end, "cue {} ends before it starts", cue.key);
assert!(
ix == 0 || cues[ix - 1].start <= cue.start,
"cue {} out of order",
cue.key
);
assert!(
!cues[..ix].iter().any(|other| other.key == cue.key),
"cue {} twice",
cue.key
);
}
Self {
id: id.into(),
cues,
at,
on_change: None,
on_seek: None,
}
}
pub fn on_change(mut self, handler: impl Fn(&[Cue], &mut Window, &mut App) + 'static) -> Self {
self.on_change = 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
}
}
impl RenderOnce for SubtitleEditor {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let wrong = window.use_keyed_state((self.id.clone(), "wrong"), cx, |_, _| {
None::<(SharedString, String)>
});
let complaint = wrong.read(cx).clone();
let theme = cx.theme();
let colors = theme.colors.clone();
let cues = Rc::new(self.cues);
let at = self.at;
let rows = cues.iter().map(|cue| {
let lit = cue.start <= at && at < cue.end;
let key = cue.key.clone();
let retime = |edge: Edge| {
let (cues, key, wrong, change) = (
cues.clone(),
key.clone(),
wrong.clone(),
self.on_change.clone(),
);
move |text: &SharedString, window: &mut Window, cx: &mut App| match retimed(
&cues, &key, edge, text,
) {
Ok(next) => {
wrong.update(cx, |wrong, cx| {
*wrong = None;
cx.notify();
});
log::info!("subtitle editor: {key} {edge:?} at {}", text.trim());
if let Some(change) = &change {
change(&next, window, cx);
}
}
Err(reason) => {
log::info!("subtitle editor: {key} kept: {reason}");
wrong.update(cx, |wrong, cx| {
*wrong = Some((key.clone(), reason));
cx.notify();
});
}
}
};
let words = {
let (cues, key, change) = (cues.clone(), key.clone(), self.on_change.clone());
move |text: &SharedString, window: &mut Window, cx: &mut App| {
let next: Vec<Cue> = cues
.iter()
.map(|cue| {
if cue.key == key {
Cue {
text: text.clone(),
..cue.clone()
}
} else {
cue.clone()
}
})
.collect();
if let Some(change) = &change {
change(&next, window, cx);
}
}
};
let editable = self.on_change.is_some();
let time = |edge: Edge, value: Duration| {
let field =
InlineEdit::new((self.id.clone(), format!("{key}-{edge:?}")), stamp(value));
if editable {
field.on_commit(retime(edge))
} else {
field
}
};
let seek = self.on_seek.clone().map(|seek| {
let start = cue.start;
IconButton::new((self.id.clone(), format!("{key}-seek")), IconName::Play)
.size(ControlSize::Sm)
.tooltip("Play from here")
.on_click(move |_, window, cx| seek(start, window, cx))
});
let remove = self.on_change.clone().map(|change| {
let (cues, key) = (cues.clone(), key.clone());
IconButton::new((self.id.clone(), format!("{key}-remove")), IconName::X)
.size(ControlSize::Sm)
.tooltip("Remove")
.on_click(move |_, window, cx| {
let next: Vec<Cue> =
cues.iter().filter(|cue| cue.key != key).cloned().collect();
log::info!("subtitle editor: removed {key}");
change(&next, window, cx)
})
});
let said = complaint
.as_ref()
.filter(|(which, _)| *which == key)
.map(|(_, reason)| {
div()
.debug_selector(|| "subtitle-complaint".into())
.px_2()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.danger)
.child(reason.clone())
});
let text = InlineEdit::new((self.id.clone(), format!("{key}-words")), cue.text.clone())
.placeholder("Words");
div()
.w_full()
.flex()
.flex_col()
.gap_0p5()
.p_1()
.rounded(theme.radius(Radius::Md))
.when(lit, |row| row.bg(colors.active))
.child(
div()
.flex()
.items_center()
.gap_1()
.children(seek)
.child(time(Edge::Start, cue.start))
.child(div().text_color(colors.fg_muted).child("→"))
.child(time(Edge::End, cue.end)),
)
.child(
div()
.flex()
.items_center()
.gap_1()
.child(div().flex_1().min_w_0().child(if editable {
text.on_commit(words)
} else {
text
}))
.children(remove),
)
.children(said)
});
let add = self.on_change.clone().map(|change| {
let cues = cues.clone();
Button::new((self.id.clone(), "add"), "Add a cue")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.icon(IconName::Plus)
.on_click(move |_, window, cx| {
let taken = |key: &str| cues.iter().any(|cue| cue.key == key);
let key = (cues.len() + 1..)
.map(|n| format!("cue-{n}"))
.find(|key| !taken(key))
.expect("a free key");
let mut next = cues.to_vec();
next.push(Cue {
key: key.clone().into(),
start: at,
end: at + FRESH,
text: SharedString::default(),
});
next.sort_by_key(|cue| cue.start);
log::info!("subtitle editor: added {key} at {}", stamp(at));
change(&next, window, cx)
})
});
div()
.w_full()
.flex()
.flex_col()
.gap_1()
.children(rows)
.child(div().flex().children(add))
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{Cue, Edge, parse_time, retimed, stamp};
#[test]
fn times_read_minutes_and_hours_with_or_without_a_fraction() {
assert_eq!(parse_time("01:02.5"), Some(Duration::from_millis(62_500)));
assert_eq!(
parse_time(" 1:02:03.004 "),
Some(Duration::from_millis(3_723_004))
);
assert_eq!(parse_time("75:00"), Some(Duration::from_secs(4500)));
for bad in ["1:60", "a:10", "1:2:3:4", "01:02.5000", "01:02.x", ""] {
assert_eq!(parse_time(bad), None, "{bad:?}");
}
assert_eq!(stamp(Duration::from_millis(62_500)), "01:02.500");
assert_eq!(stamp(Duration::from_millis(3_723_004)), "1:02:03.004");
}
#[test]
fn a_retimed_cue_keeps_order_and_refuses_a_bad_time_or_a_backward_one() {
let cue = |key: &str, start: u64, end: u64| Cue {
key: key.to_string().into(),
start: Duration::from_secs(start),
end: Duration::from_secs(end),
text: key.to_string().into(),
};
let cues = [cue("a", 1, 3), cue("b", 5, 8)];
let moved = retimed(&cues, "b", Edge::Start, "00:00.500").expect("a good time");
assert_eq!(
moved.iter().map(|cue| cue.key.as_ref()).collect::<Vec<_>>(),
["b", "a"]
);
assert!(retimed(&cues, "a", Edge::End, "1:x").is_err());
assert_eq!(
retimed(&cues, "a", Edge::End, "00:00.500"),
Err("A cue ends after it starts".to_string())
);
}
}
+1
View File
@@ -6,6 +6,7 @@ use crate::{primitives::FocusNext, theme::Theme};
mod edit;
mod images;
mod video;
pub(super) fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
+341
View File
@@ -0,0 +1,341 @@
use std::{path::PathBuf, time::Duration};
use gpui::{
Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Pixels, Point, Render,
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
use super::{picture, press, settle, setup};
use crate::media::{Cue, Scrubber, SubtitleEditor, VideoPlayer, VideoThumbnailStrip};
const SECOND: Duration = Duration::from_secs(1);
/// Where a share of a box sits in the window, halfway down.
fn across(selector: &'static str, share: f32, cx: &mut VisualTestContext) -> Point<Pixels> {
let bounds = cx.debug_bounds(selector).expect("the box draws");
point(bounds.left() + bounds.size.width * share, bounds.center().y)
}
/// A scrubber over a hundred seconds, 400 wide, and the times it was sent to.
struct Scrubbing {
at: Duration,
sought: Vec<Duration>,
}
impl Render for Scrubbing {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
div().w(px(400.0)).mt(px(40.0)).child(
Scrubber::new("scrub", SECOND * 100, self.at)
.chapter(Duration::ZERO, "Intro")
.chapter(SECOND * 40, "Stair")
.on_seek(move |time, _, cx| {
owner.update(cx, |view, cx| {
(view.at, view.sought) = (time, [view.sought.clone(), vec![time]].concat());
cx.notify();
})
}),
)
}
}
fn scrubbing(cx: &mut TestAppContext) -> (Entity<Scrubbing>, &mut VisualTestContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Scrubbing {
at: Duration::ZERO,
sought: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
(view, cx)
}
#[gpui::test]
fn a_press_seeks_and_a_drag_past_the_end_stops_there(cx: &mut TestAppContext) {
let (view, cx) = scrubbing(cx);
let quarter = across("scrubber-rail", 0.25, cx);
let none = Modifiers::none();
cx.simulate_mouse_down(quarter, MouseButton::Left, none);
cx.simulate_mouse_move(quarter + point(px(20.0), px(0.0)), MouseButton::Left, none);
cx.simulate_mouse_move(quarter + point(px(900.0), px(0.0)), MouseButton::Left, none);
cx.simulate_mouse_up(quarter + point(px(900.0), px(0.0)), MouseButton::Left, none);
settle(cx);
let sought = view.read_with(cx, |view, _| view.sought.clone());
let first = sought.first().expect("a press seeks");
assert!(first.abs_diff(SECOND * 25) < SECOND, "{sought:?}");
assert_eq!(sought.last(), Some(&(SECOND * 100)), "{sought:?}");
}
#[gpui::test]
fn the_arrows_step_five_seconds_and_end_goes_to_the_end(cx: &mut TestAppContext) {
let (view, cx) = scrubbing(cx);
let tenth = across("scrubber-rail", 0.1, cx);
cx.simulate_click(tenth, Modifiers::none());
settle(cx);
let start = view.read_with(cx, |view, _| view.at);
press("right", cx);
assert_eq!(view.read_with(cx, |view, _| view.at), start + SECOND * 5);
press("end", cx);
assert_eq!(view.read_with(cx, |view, _| view.at), SECOND * 100);
}
#[gpui::test]
fn the_pointer_shows_its_time_over_the_track(cx: &mut TestAppContext) {
let (_, cx) = scrubbing(cx);
assert!(cx.debug_bounds("scrubber-tip").is_none());
let half = across("scrubber-rail", 0.5, cx);
cx.simulate_mouse_move(half, None, Modifiers::none());
settle(cx);
let tip = cx
.debug_bounds("scrubber-tip")
.expect("a tip over the track");
assert!((tip.left() - half.x).abs() < px(1.0), "{tip:?}");
}
/// A player of a hundred seconds, what it was told, and whether it plays.
struct Watching {
path: PathBuf,
at: Duration,
playing: bool,
captions: bool,
}
impl Render for Watching {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (play, seek, caption) = (cx.entity(), cx.entity(), cx.entity());
div().w(px(480.0)).child(
VideoPlayer::new(
"player",
Some(self.path.clone().into()),
1.6,
SECOND * 100,
self.at,
)
.playing(self.playing)
.captions(self.captions, Some("Light falls on the stair".into()))
.on_play(move |on, _, cx| {
play.update(cx, |view, cx| {
view.playing = on;
cx.notify();
})
})
.on_seek(move |time, _, cx| {
seek.update(cx, |view, cx| {
view.at = time;
cx.notify();
})
})
.on_captions(move |on, _, cx| {
caption.update(cx, |view, cx| {
view.captions = on;
cx.notify();
})
}),
)
}
}
fn watching(cx: &mut TestAppContext) -> (Entity<Watching>, &mut VisualTestContext) {
setup(cx);
let path = picture("frame", 160, 100);
let (view, cx) = cx.add_window_view(|_, _| Watching {
path,
at: SECOND * 30,
playing: false,
captions: false,
});
cx.update(|window, _| window.activate_window());
settle(cx);
let frame = cx.debug_bounds("video-player").expect("the player draws");
cx.simulate_click(frame.origin + point(px(8.0), px(8.0)), Modifiers::none());
settle(cx);
(view, cx)
}
#[gpui::test]
fn space_plays_l_and_j_jump_ten_seconds_and_c_turns_captions(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
press("space", cx);
assert!(view.read_with(cx, |view, _| view.playing));
press("l", cx);
press("l", cx);
press("j", cx);
assert_eq!(view.read_with(cx, |view, _| view.at), SECOND * 40);
press("c", cx);
assert!(view.read_with(cx, |view, _| view.captions));
}
#[gpui::test]
fn controls_rest_once_the_pointer_is_still_while_it_plays(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
cx.update(|window, _| window.blur());
view.update(cx, |view, cx| {
view.playing = true;
cx.notify();
});
let frame = cx.debug_bounds("video-player").expect("the player draws");
cx.simulate_mouse_move(frame.center(), None, Modifiers::none());
settle(cx);
assert!(
cx.debug_bounds("video-controls-resting").is_none(),
"a moving pointer keeps them up"
);
cx.executor().advance_clock(Duration::from_millis(2600));
cx.run_until_parked();
assert!(
cx.debug_bounds("video-controls-resting").is_some(),
"a still one lets them rest"
);
}
/// A strip of four frames over a hundred seconds with a trim, and what it was told.
struct Trimming {
path: PathBuf,
at: Duration,
trim: (Duration, Duration),
}
impl Render for Trimming {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (seek, cut) = (cx.entity(), cx.entity());
let frames: Vec<PathBuf> = (0..4).map(|_| self.path.clone()).collect();
div().w(px(400.0)).child(
VideoThumbnailStrip::new("strip", frames, SECOND * 100, self.at)
.trim(self.trim.0, self.trim.1)
.on_seek(move |time, _, cx| {
seek.update(cx, |view, cx| {
view.at = time;
cx.notify();
})
})
.on_trim(move |trim, _, cx| {
cut.update(cx, |view, cx| {
view.trim = trim;
cx.notify();
})
}),
)
}
}
fn trimming(cx: &mut TestAppContext) -> (Entity<Trimming>, &mut VisualTestContext) {
setup(cx);
let path = picture("strip", 160, 100);
let (view, cx) = cx.add_window_view(|_, _| Trimming {
path,
at: Duration::ZERO,
trim: (SECOND * 10, SECOND * 50),
});
cx.update(|window, _| window.activate_window());
settle(cx);
(view, cx)
}
#[gpui::test]
fn a_press_seeks_and_a_handle_drag_moves_its_end_to_the_length(cx: &mut TestAppContext) {
let (view, cx) = trimming(cx);
let press_at = across("video-strip", 0.3, cx);
cx.simulate_click(press_at, Modifiers::none());
settle(cx);
let at = view.read_with(cx, |view, _| view.at);
assert!(at.abs_diff(SECOND * 30) < SECOND, "{at:?}");
let out = across("video-strip", 0.5, cx);
let none = Modifiers::none();
cx.simulate_mouse_down(out, MouseButton::Left, none);
cx.simulate_mouse_move(out + point(px(10.0), px(0.0)), MouseButton::Left, none);
cx.simulate_mouse_move(out + point(px(900.0), px(0.0)), MouseButton::Left, none);
cx.simulate_mouse_up(out + point(px(900.0), px(0.0)), MouseButton::Left, none);
settle(cx);
assert_eq!(
view.read_with(cx, |view, _| view.trim),
(SECOND * 10, SECOND * 100)
);
}
#[gpui::test]
fn i_and_o_set_the_trim_at_the_playhead(cx: &mut TestAppContext) {
let (view, cx) = trimming(cx);
let press_at = across("video-strip", 0.3, cx);
cx.simulate_click(press_at, Modifiers::none());
settle(cx);
let at = view.read_with(cx, |view, _| view.at);
press("i", cx);
assert_eq!(view.read_with(cx, |view, _| view.trim), (at, SECOND * 50));
press("right", cx);
press("o", cx);
assert_eq!(
view.read_with(cx, |view, _| view.trim),
(at, at + SECOND * 25)
);
}
/// A subtitle editor over two cues, its playhead, and the cues it sent.
struct Captioning {
cues: Vec<Cue>,
at: Duration,
}
impl Render for Captioning {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
div().w(px(420.0)).child(
SubtitleEditor::new("subs", self.cues.clone(), self.at).on_change(
move |cues, _, cx| {
owner.update(cx, |view, cx| {
view.cues = cues.to_vec();
cx.notify();
})
},
),
)
}
}
fn captioning(cx: &mut TestAppContext) -> (Entity<Captioning>, &mut VisualTestContext) {
setup(cx);
let cue = |key: &str, start: u64, end: u64| Cue {
key: key.to_string().into(),
start: SECOND * start as u32,
end: SECOND * end as u32,
text: key.to_string().into(),
};
let (view, cx) = cx.add_window_view(|_, _| Captioning {
cues: vec![cue("a", 1, 3), cue("b", 5, 8)],
at: SECOND * 6,
});
cx.update(|window, _| window.activate_window());
settle(cx);
(view, cx)
}
/// Tabs to the `nth` stop and types over it.
fn retype(nth: usize, text: &str, cx: &mut VisualTestContext) {
cx.update(|window, _| {
window.blur();
for _ in 0..nth {
window.focus_next();
}
});
press("enter", cx);
cx.simulate_input(text);
press("enter", cx);
}
#[gpui::test]
fn a_start_typed_in_place_moves_its_cue_into_order(cx: &mut TestAppContext) {
let (view, cx) = captioning(cx);
retype(5, "00:00.500", cx);
let keys: Vec<String> = view.read_with(cx, |view, _| {
view.cues.iter().map(|cue| cue.key.to_string()).collect()
});
assert_eq!(keys, ["b", "a"]);
}
#[gpui::test]
fn a_time_that_does_not_read_keeps_the_cue_and_says_why(cx: &mut TestAppContext) {
let (view, cx) = captioning(cx);
let before = view.read_with(cx, |view, _| view.cues.clone());
retype(1, "1:x", cx);
assert_eq!(view.read_with(cx, |view, _| view.cues.clone()), before);
assert!(cx.debug_bounds("subtitle-complaint").is_some());
}