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

+3 -1
View File
@@ -71,8 +71,10 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event.
- gpui focuses the nearest focusable ancestor on mouse down, and apps wrap everything in a `FocusScope`. A press that must leave focus alone calls `prevent_default`, as buttons and context regions do.
- gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens.
- gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again.
- gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again. An edit in place that hands focus back keeps its text on Enter's release (`forms::InlineEdit`).
- A focus handle a component hands back lives across all its modes: take it with `tab_stop` before any early return, or keyed state drops it while the other mode draws and the handed-back focus lands nowhere.
- A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. A dialog's card fits the window, a title bar's height clear at each end; its body scrolls and its actions stay.
- Controls on a dark layer over pictures are `overlays::media_button`, light icons that dim when they have nowhere to go, for the lightbox and a video player's bar alike.
- `primitives::Severity` names info, success, warning and danger with their colors and icons, for alerts and feedback alike.
- Rows that move are keyed. `motion::Flip` places them absolutely from last frame's heights, so no frame shows a row at its new place before it glides there, and drives the glide from elapsed time: an animation id that changes per move would rebuild the row's keyed state. `Reorder` holds a row by key, drags with an empty ghost and moves the held row itself.
- A marker that slides between items (segment thumb, tab line) measures them with `motion::slide` and eases with `glide`.
+20
View File
@@ -0,0 +1,20 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-captions-off"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10.5 5H19a2 2 0 0 1 2 2v8.5" />
<path d="M17 11h-.5" />
<path d="M19 19H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2" />
<path d="m2 2 20 20" />
<path d="M7 11h4" />
<path d="M7 15h2.5" />
</svg>

After

Width:  |  Height:  |  Size: 489 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-captions"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="14" x="3" y="5" rx="2" ry="2" />
<path d="M7 15h4M15 15h2M7 11h2M13 11h4" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

+19
View File
@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-fullscreen"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
<rect width="10" height="8" x="7" y="8" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 499 B

+19
View File
@@ -1,5 +1,6 @@
mod edit;
mod images;
mod video;
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
@@ -53,6 +54,20 @@ const SCRIPT: &[Step] = &[
Step::UpAt("media-upload", 388.0, 307.0),
Step::Wait(300),
Step::Shot("emptied"),
Step::HoverAt("media-scrubber", 320.0, 31.0),
Step::Wait(200),
Step::Shot("scrub-tip"),
Step::DownAt("media-strip", 467.0, 25.0),
Step::DragTo("media-strip", 560.0, 25.0),
Step::UpAt("media-strip", 560.0, 25.0),
Step::Wait(200),
Step::Shot("trimmed"),
Step::DownAt("media-subtitles", 80.0, 142.0),
Step::UpAt("media-subtitles", 80.0, 142.0),
Step::Type("00:20.000"),
Step::Key("enter"),
Step::Wait(300),
Step::Shot("retimed"),
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
@@ -62,5 +77,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(edit::cropper(window, cx))
.child(edit::upload(window, cx))
.child(edit::annotator(window, cx))
.child(video::video(window, cx))
.child(video::scrubber(window, cx))
.child(video::strip(window, cx))
.child(video::subtitles(window, cx))
.into_any_element()
}
+236
View File
@@ -0,0 +1,236 @@
use std::{path::Path, time::Duration};
use ely_gpui_component::media::{Cue, Scrubber, SubtitleEditor, VideoPlayer, VideoThumbnailStrip};
use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use crate::{
probe::probe,
ui::{keep, section, set},
};
const FRAMES: [&str; 3] = [
concat!(
env!("CARGO_MANIFEST_DIR"),
"/examples/gallery/assets/atrium.jpg"
),
concat!(
env!("CARGO_MANIFEST_DIR"),
"/examples/gallery/assets/atrium-stair.jpg"
),
concat!(
env!("CARGO_MANIFEST_DIR"),
"/examples/gallery/assets/atrium-olive.jpg"
),
];
const LENGTH: Duration = Duration::from_secs(90);
/// How often the demo's clock moves while it plays.
const TICK: Duration = Duration::from_millis(250);
/// The demo video: where it stands, how it plays, its trim and its captions.
#[derive(Clone)]
struct Playback {
at: Duration,
playing: bool,
ticking: bool,
speed: f32,
captions: bool,
trim: (Duration, Duration),
cues: Vec<Cue>,
}
fn cue(key: &str, start: u64, end: u64, text: &str) -> Cue {
Cue {
key: key.to_string().into(),
start: Duration::from_secs(start),
end: Duration::from_secs(end),
text: text.to_string().into(),
}
}
fn frame(at: Duration) -> &'static str {
FRAMES[(at.as_secs() / 30) as usize % FRAMES.len()]
}
/// Runs the demo's clock while it plays, one loop at a time.
fn tick(state: &Entity<Playback>, window: &mut Window, cx: &mut App) {
if state.update(cx, |now, _| std::mem::replace(&mut now.ticking, true)) {
return;
}
let state = state.clone();
window
.spawn(cx, async move |cx| {
loop {
cx.background_executor().timer(TICK).await;
let going = state.update(cx, |now, cx| {
let step = TICK.mul_f32(now.speed);
now.at = (now.at + step).min(LENGTH);
now.playing &= now.at < LENGTH;
now.ticking = now.playing;
cx.notify();
now.playing
})?;
if !going {
return anyhow::Ok(());
}
}
})
.detach_and_log_err(cx);
}
/// The one video every section shows.
fn state(window: &mut Window, cx: &mut App) -> Entity<Playback> {
keep(
"media-video",
|| Playback {
at: Duration::from_secs(34),
playing: false,
ticking: false,
speed: 1.0,
captions: true,
trim: (Duration::from_secs(12), Duration::from_secs(66)),
cues: vec![
cue("cue-1", 2, 9, "Morning comes in through the tall window."),
cue("cue-2", 31, 38, "Light falls on the stair."),
cue("cue-3", 61, 70, "The shadow walks the length of the hall."),
],
},
window,
cx,
)
}
pub fn video(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = state(window, cx);
let now = state.read(cx).clone();
let caption: Option<SharedString> = now
.cues
.iter()
.find(|cue| cue.start <= now.at && now.at < cue.end)
.map(|cue| cue.text.clone());
let (play, seek, speed, captions) =
(state.clone(), state.clone(), state.clone(), state.clone());
let player = VideoPlayer::new(
"media-player",
Some(Path::new(frame(now.at)).into()),
1.5,
LENGTH,
now.at,
)
.loaded(LENGTH.mul_f32(0.8))
.playing(now.playing)
.speed(now.speed)
.captions(now.captions, caption)
.on_play(move |on, window, cx| {
play.update(cx, |now, cx| {
now.playing = on;
cx.notify();
});
if on {
tick(&play, window, cx);
}
})
.on_seek(move |at, _, cx| {
seek.update(cx, |now, cx| {
now.at = at;
cx.notify();
})
})
.on_speed(move |to, _, cx| {
speed.update(cx, |now, cx| {
now.speed = to;
cx.notify();
})
})
.on_captions(move |on, _, cx| {
captions.update(cx, |now, cx| {
now.captions = on;
cx.notify();
})
})
.on_picture(|_, _| log::info!("gallery: picture in picture"))
.on_fullscreen(|window, _| window.toggle_fullscreen());
section(
"VideoPlayer",
"A video the host plays, a frame at a time. Its controls fade once the pointer rests while it plays. Space plays, J and L jump ten seconds, C turns captions, F fills the screen.",
cx,
)
.child(probe("media-player", div().w(px(640.)).child(player)))
}
pub fn scrubber(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = state(window, cx);
let at = state.read(cx).at;
let seek = state.clone();
section(
"VideoTimeline / Scrubber",
"Where a video stands: what has loaded, how far it has played, its chapters, and the time under the pointer. A press or a drag seeks; with focus the arrows step five seconds.",
cx,
)
.child(probe(
"media-scrubber",
div().w(px(640.)).pt_6().child(
Scrubber::new("media-scrubber", LENGTH, at)
.loaded(LENGTH.mul_f32(0.8))
.chapter(Duration::ZERO, "Morning")
.chapter(Duration::from_secs(30), "The stair")
.chapter(Duration::from_secs(60), "The hall")
.on_seek(move |at, _, cx| seek.update(cx, |now, cx| {
now.at = at;
cx.notify();
})),
),
))
}
pub fn strip(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = state(window, cx);
let now = state.read(cx).clone();
let (seek, cut) = (state.clone(), state.clone());
let frames = (0..8).map(|ix| Path::new(FRAMES[ix * 3 / 8]));
section(
"VideoThumbnailStrip",
"Frames along the video under its playhead, and the part kept between two handles. A press seeks; a handle trims; I and O set the ends at the playhead.",
cx,
)
.child(probe(
"media-strip",
div().w(px(640.)).child(
VideoThumbnailStrip::new("media-strip", frames, LENGTH, now.at)
.trim(now.trim.0, now.trim.1)
.on_seek(move |at, _, cx| seek.update(cx, |now, cx| {
now.at = at;
cx.notify();
}))
.on_trim(move |trim, _, cx| cut.update(cx, |now, cx| {
now.trim = trim;
cx.notify();
})),
),
))
}
pub fn subtitles(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = state(window, cx);
let now = state.read(cx).clone();
let (change, seek) = (state.clone(), state.clone());
section(
"SubtitleEditor",
"Captions to edit in place, their times as mm:ss.mmm over their words. The one under the playhead is lit; Add puts a new cue there.",
cx,
)
.child(probe(
"media-subtitles",
div().w(px(480.)).child(
SubtitleEditor::new("media-subtitles", now.cues, now.at)
.on_change(move |cues, _, cx| {
let mut next = change.read(cx).clone();
next.cues = cues.to_vec();
set(&change, next, cx)
})
.on_seek(move |at, _, cx| seek.update(cx, |now, cx| {
now.at = at;
cx.notify();
})),
),
))
}
+1
View File
@@ -1,5 +1,6 @@
mod bars;
mod button;
pub(crate) use button::label_size;
mod confirm;
mod copy;
mod fab;
+48 -12
View File
@@ -1,24 +1,25 @@
use std::rc::Rc;
use gpui::{
App, Context, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div,
prelude::*,
App, Context, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription,
Window, div, prelude::*,
};
use super::{Input, InputEvent, TextInput};
use crate::{
primitives::{Icon, IconName},
primitives::{FocusRing, Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
};
pub(crate) type OnCommit = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A text being edited in place: its field while open, and who hears the kept text.
/// A text being edited in place: its field while open, who hears the kept text, and where focus goes back after Enter or Escape. An edit that hands focus back keeps its text on Enter's release, so the release does not open it again.
#[derive(Default)]
pub(crate) struct Editing {
field: Option<(Entity<TextInput>, Subscription)>,
pub on_commit: Option<OnCommit>,
back: Option<FocusHandle>,
}
impl Editing {
@@ -33,10 +34,17 @@ impl Editing {
field.update(cx, |input, cx| input.select(0..all, cx));
window.focus(&field.read(cx).focus().clone());
let owner = state.clone();
let events = window.subscribe(&field, cx, move |_, event, window, cx| {
if matches!(event, InputEvent::Submit | InputEvent::Blur) {
owner.update(cx, |editing, cx| editing.finish(false, window, cx));
}
let events = window.subscribe(&field, cx, move |_, event, window, cx| match event {
InputEvent::Submit => owner.update(cx, |editing, cx| {
if editing.back.is_none() {
editing.finish(false, window, cx);
}
}),
InputEvent::Blur => owner.update(cx, |editing, cx| {
editing.back = None;
editing.finish(false, window, cx);
}),
_ => {}
});
state.update(cx, |editing, cx| {
editing.field = Some((field, events));
@@ -44,6 +52,18 @@ impl Editing {
});
}
/// Focus goes back here after Enter or Escape.
fn returning(&mut self, back: FocusHandle) {
self.back = Some(back);
}
fn give_back(&mut self, window: &mut Window) {
if let Some(back) = self.back.take() {
log::info!("inline edit: focus handed back");
window.focus(&back);
}
}
pub(crate) fn field(&self) -> Option<Entity<TextInput>> {
self.field.as_ref().map(|(field, _)| field.clone())
}
@@ -102,16 +122,29 @@ impl InlineEdit {
impl RenderOnce for InlineEdit {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Editing::default());
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
state.update(cx, |editing, _| editing.on_commit = self.on_commit.clone());
if let Some(field) = state.read(cx).field() {
let escape = state.clone();
let (escape, enter) = (state.clone(), state.clone());
return div()
.id(self.id)
.w_full()
.on_key_down(move |event, window, cx| {
if event.keystroke.key == "escape" {
cx.stop_propagation();
escape.update(cx, |editing, cx| editing.finish(true, window, cx));
escape.update(cx, |editing, cx| {
editing.finish(true, window, cx);
editing.give_back(window);
});
}
})
.on_key_up(move |event, window, cx| {
if event.keystroke.key == "enter" {
cx.stop_propagation();
enter.update(cx, |editing, cx| {
editing.finish(false, window, cx);
editing.give_back(window);
});
}
})
.child(Input::new(&field).size(ControlSize::Sm))
@@ -120,7 +153,7 @@ impl RenderOnce for InlineEdit {
let theme = cx.theme();
let colors = &theme.colors;
let empty = self.value.is_empty();
let (value, start) = (self.value.clone(), state);
let (value, start, back) = (self.value.clone(), state, focus.clone());
let group = SharedString::from(format!("inline-edit-{:?}", self.id));
div()
.id(self.id)
@@ -133,12 +166,15 @@ impl RenderOnce for InlineEdit {
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(gpui::transparent_black())
.track_focus(&focus)
.focus_ring(cx)
.cursor_text()
.hover(|style| style.bg(colors.hover))
.text_color(if empty { colors.fg_subtle } else { colors.fg })
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("inline edit: editing");
start.update(cx, |editing, _| editing.returning(back.clone()));
Editing::begin(&start, value.to_string(), window, cx);
})
.child(if empty { self.placeholder } else { self.value })
+1
View File
@@ -97,6 +97,7 @@ pub use select::Select;
pub(crate) use select::{Listing, field_button, field_text, listing};
pub(crate) use signature::{Drawing, ink, pen};
pub use signature::{SignaturePad, Stroke};
pub(crate) use slider::keyed;
pub use slider::{RangeSlider, Slider};
pub use stepper::Stepper;
pub use structure::{
+55 -3
View File
@@ -1,11 +1,12 @@
use gpui::{
Context, IntoElement, Modifiers, MouseButton, Render, TestAppContext, Window, point, px,
Context, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, MouseButton, Render,
SharedString, TestAppContext, VisualTestContext, Window, point, px,
};
use super::setup;
use crate::{
forms::{IconPicker, SignaturePad, Stroke},
primitives::IconName,
forms::{IconPicker, InlineEdit, SignaturePad, Stroke},
primitives::{FocusNext, IconName},
};
struct Icons {
@@ -92,3 +93,54 @@ fn one_move_keeps_both_ends_of_the_stroke(cx: &mut TestAppContext) {
assert_eq!(stroke.len(), 2, "{stroke:?}");
assert_eq!(stroke[1] - stroke[0], point(px(60.0), px(-20.0)));
}
/// An inline edit and the texts it kept.
struct Titled(Vec<SharedString>);
impl Render for Titled {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let value = self.0.last().cloned().unwrap_or_else(|| "Draft".into());
InlineEdit::new("title", value).on_commit(move |text, _, cx| {
view.update(cx, |view, cx| {
view.0.push(text.clone());
cx.notify();
})
})
}
}
/// Presses and releases `key`, a drawn frame between.
fn release(key: &str, cx: &mut VisualTestContext) {
let redraw = |cx: &mut VisualTestContext| {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
};
cx.simulate_keystrokes(key);
redraw(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
redraw(cx);
}
#[gpui::test]
fn an_inline_edit_takes_tab_opens_on_enter_and_takes_focus_back(cx: &mut TestAppContext) {
setup(cx);
cx.update(|cx| cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]));
let (view, cx) = cx.add_window_view(|_, _| Titled(Vec::new()));
cx.update(|window, _| window.activate_window());
cx.update(|window, _| window.focus_next());
release("enter", cx);
cx.simulate_input("Stairs");
release("enter", cx);
release("enter", cx);
cx.simulate_input("Light");
release("enter", cx);
assert_eq!(
view.read_with(cx, |view, _| view.0.clone()),
["Stairs", "Light"],
"focus came back after the first"
);
}
+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());
}
+9 -5
View File
@@ -7,10 +7,11 @@ use gpui::{
};
use crate::{
buttons::label_size,
forms::Run,
motion,
primitives::{FocusRing, FocusScope, Icon, IconName, Image, give_back, take_focus},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
theme::{ActiveTheme, ControlSize, TextSize},
};
/// One picture in a lightbox, and the line under it.
@@ -31,10 +32,11 @@ impl Slide {
type OnStep = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// A round button drawn for the dark layer: light icon, a faint fill on hover, dim when it has nowhere to go.
fn media_button(
/// A round button drawn for a dark layer over pictures: light icon, a faint fill on hover, dim when it has nowhere to go.
pub(crate) fn media_button(
id: (ElementId, &'static str),
icon: IconName,
size: ControlSize,
enabled: bool,
run: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
@@ -47,11 +49,11 @@ fn media_button(
.flex_none()
.items_center()
.justify_center()
.size(theme.control_height(ControlSize::Lg))
.size(theme.control_height(size))
.rounded_full()
.border_1()
.border_color(gpui::transparent_black())
.child(Icon::new(icon).size(IconSize::Md).color(if enabled {
.child(Icon::new(icon).size(label_size(size).1).color(if enabled {
light
} else {
light.opacity(0.3)
@@ -147,6 +149,7 @@ impl RenderOnce for Lightbox {
media_button(
(self.id.clone(), name),
icon,
ControlSize::Lg,
to.is_some(),
move |window, cx| {
if let Some(to) = to {
@@ -206,6 +209,7 @@ impl RenderOnce for Lightbox {
.child(div().flex().justify_end().p_3().child(media_button(
(self.id.clone(), "close"),
IconName::X,
ControlSize::Lg,
true,
move |window, cx| button(window, cx),
cx,
+1
View File
@@ -12,6 +12,7 @@ pub use dialog::{Close, Dialog};
pub use dialogs::{AlertDialog, ConfirmDialog, PromptDialog};
pub use floats::{FloatingToolbar, Peek};
pub use hover::HoverCard;
pub(crate) use lightbox::media_button;
pub use lightbox::{Lightbox, Slide};
pub use popover::Popover;
pub use spotlight::{Spotlight, Tour, TourStep};
+3
View File
@@ -72,6 +72,8 @@ icons! {
CalendarRange => "calendar-range",
Calendar => "calendar",
Camera => "camera",
Captions => "captions",
CaptionsOff => "captions-off",
CaseSensitive => "case-sensitive",
ChartArea => "chart-area",
ChartBar => "chart-bar",
@@ -138,6 +140,7 @@ icons! {
Folder => "folder",
Forward => "forward",
Frame => "frame",
Fullscreen => "fullscreen",
Gauge => "gauge",
GitBranch => "git-branch",
GitCommitHorizontal => "git-commit-horizontal",
+6
View File
@@ -13,6 +13,10 @@ pub struct MediaSizes {
pub arrow_head: Rems,
/// A numbered pin, round.
pub pin: Rems,
/// A strip of video frames, tall.
pub strip: Rems,
/// A trim's handle, wide.
pub trim_handle: Rems,
}
impl Theme {
@@ -22,6 +26,8 @@ impl Theme {
mark_stroke: px_to_rems(2.0),
arrow_head: px_to_rems(12.0),
pin: px_to_rems(20.0),
strip: px_to_rems(48.0),
trim_handle: px_to_rems(8.0),
}
}
}
+4 -4
View File
@@ -264,10 +264,10 @@
## T26c Media · Video
- [ ] VideoPlayer — host frames
- [ ] VideoTimeline / Scrubber
- [ ] VideoThumbnailStrip
- [ ] SubtitleEditor
- [x] VideoPlayer — host frames
- [x] VideoTimeline / Scrubber
- [x] VideoThumbnailStrip
- [x] SubtitleEditor
## T26d Media · Audio and controls