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:
1 parent
f7f20b5146
commit
d0175edb7b
22 files changed
+2232
-25
No files matched your search
@@ -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;
|
||||
@@ -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();
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user