Media: the player's bar fades by elapsed time, so its scrubber and buttons keep their state

The fade restarted an animation id at each rest and wake, and gpui hands
that id to every descendant: the scrubber's drag, hover and focus handle
and the buttons' focus were rebuilt under it. The bar's opacity now reads
the time since the last change through motion::since_change. The stage
tracks focus even without key handlers, so a focused bar button keeps
the bar up. The player's tests move to their own file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-26 18:14:06 -04:00
1 parent fbf99ed4f7
commit fcb1b2d9df
8 files changed
+330 -220

No files matched your search

+13 -17
View File
@@ -1,9 +1,8 @@
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,
App, ElementId, ImageSource, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, img, prelude::*, transparent_black,
};
use super::scrubber::{OnTime, Scrubber, clock};
@@ -155,7 +154,13 @@ impl RenderOnce for VideoPlayer {
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 fading = crate::motion::since_change(
&(self.id.clone(), "fade").into(),
resting,
motion_duration(crate::motion::BASE, cx),
window,
cx,
);
let theme = cx.theme();
let colors = theme.colors.clone();
let light = colors.on_media;
@@ -294,18 +299,8 @@ impl RenderOnce for VideoPlayer {
let bar = bar.when(resting, |bar| {
bar.debug_selector(|| "video-controls-resting".into())
});
let (from, to) = if resting { (1.0, 0.0) } else { (0.0, 1.0) };
let bar = if changes == 0 {
bar.opacity(to).into_any_element()
} else {
let fade = motion_duration(crate::motion::BASE, cx);
bar.with_animation(
(self.id.clone(), format!("controls-{changes}")),
Animation::new(fade),
move |bar, t| bar.opacity(crate::motion::lerp(from, to, t)),
)
.into_any_element()
};
let shown = if resting { 0.0 } else { 1.0 };
let bar = bar.opacity(fading.map_or(shown, |t| crate::motion::lerp(1.0 - shown, shown, t)));
let controls = div()
.absolute()
.left_0()
@@ -333,7 +328,8 @@ impl RenderOnce for VideoPlayer {
.bg(colors.media_backdrop)
.border_1()
.border_color(transparent_black())
.when(keys, |stage| stage.track_focus(&focus).focus_ring(cx))
.track_focus(&focus)
.when(keys, |stage| stage.focus_ring(cx))
.children(frame)
.child(controls)
.on_mouse_move(move |_, window, cx| {
+14 -1
View File
@@ -1,11 +1,14 @@
use std::path::PathBuf;
use gpui::{KeyBinding, KeyUpEvent, Keystroke, TestAppContext, VisualTestContext};
use gpui::{
KeyBinding, KeyUpEvent, Keystroke, Pixels, Point, TestAppContext, VisualTestContext, point,
};
use crate::{primitives::FocusNext, theme::Theme};
mod edit;
mod images;
mod player;
mod video;
pub(super) fn setup(cx: &mut TestAppContext) {
@@ -41,3 +44,13 @@ pub(super) fn picture(name: &str, w: u32, h: u32) -> PathBuf {
.expect("the picture writes");
path
}
/// Where a share of a box sits in the window, halfway down.
pub(super) 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)
}
+296
View File
@@ -0,0 +1,296 @@
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::{across, picture, press, settle, setup};
use crate::media::VideoPlayer;
const SECOND: Duration = Duration::from_secs(1);
/// A player of a hundred seconds, this wide, with this caption, and what it was told.
struct Watching {
path: PathBuf,
width: f32,
caption: &'static str,
at: Duration,
playing: bool,
plays: Vec<bool>,
captions: bool,
fullscreens: usize,
}
impl Render for Watching {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (play, seek, caption, full) = (cx.entity(), cx.entity(), cx.entity(), cx.entity());
div().w(px(self.width)).child(
VideoPlayer::new(
"player",
Some(self.path.clone().into()),
1.6,
SECOND * 100,
self.at,
)
.playing(self.playing)
.captions(self.captions, Some(self.caption.into()))
.on_play(move |on, _, cx| {
play.update(cx, |view, cx| {
view.playing = on;
view.plays.push(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();
})
})
.on_fullscreen(move |_, cx| {
full.update(cx, |view, cx| {
view.fullscreens += 1;
cx.notify();
})
}),
)
}
}
fn watching_at<'a>(
width: f32,
caption: &'static str,
captions: bool,
cx: &'a mut TestAppContext,
) -> (Entity<Watching>, &'a mut VisualTestContext) {
setup(cx);
let path = picture("frame", 160, 100);
let (view, cx) = cx.add_window_view(|_, _| Watching {
path,
width,
caption,
at: SECOND * 30,
playing: false,
plays: Vec::new(),
captions,
fullscreens: 0,
});
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)
}
fn watching(cx: &mut TestAppContext) -> (Entity<Watching>, &mut VisualTestContext) {
watching_at(480.0, "Light falls on the stair", false, cx)
}
#[gpui::test]
fn space_on_a_focused_bar_button_presses_it_once(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
cx.update(|window, _| {
window.blur();
for _ in 0..3 {
window.focus_next();
}
});
press("space", cx);
assert_eq!(view.read_with(cx, |view, _| view.plays.clone()), [true]);
}
#[gpui::test]
fn command_letters_leave_the_player_to_the_app(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
for key in ["cmd-f", "cmd-c", "cmd-k", "ctrl-f"] {
press(key, cx);
}
let heard = view.read_with(cx, |view, _| {
(view.fullscreens, view.captions, view.plays.clone())
});
assert_eq!(heard, (0, false, vec![]));
}
#[gpui::test]
fn a_long_caption_wraps_inside_a_narrow_player(cx: &mut TestAppContext) {
let (view, cx) = watching_at(280.0, "Light falls", true, cx);
let line = cx.debug_bounds("video-caption").expect("the caption draws");
view.update(cx, |view, cx| {
view.caption = "The shadow walks the length of the hall while the morning comes in.";
cx.notify();
});
settle(cx);
let (chip, frame) = (
cx.debug_bounds("video-caption").expect("the caption draws"),
cx.debug_bounds("video-player").expect("the player draws"),
);
assert!(
chip.left() >= frame.left() && chip.right() <= frame.right(),
"{chip:?} in {frame:?}"
);
assert!(
chip.size.height > line.size.height * 1.5,
"{chip:?} wraps past {line:?}"
);
}
#[gpui::test]
fn a_caption_stays_while_the_controls_rest(cx: &mut TestAppContext) {
let (view, cx) = watching_at(480.0, "Light falls on the stair", true, 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);
cx.executor().advance_clock(Duration::from_millis(2600));
cx.run_until_parked();
let faded = cx
.debug_bounds("video-controls-resting")
.expect("the bar rests");
let caption = cx.debug_bounds("video-caption").expect("the caption draws");
assert!(
caption.bottom() <= faded.top(),
"{caption:?} outside {faded:?}"
);
}
#[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"
);
}
/// Rests a playing player's bar, focus outside it and the pointer still at `spot`.
fn rest(spot: Point<Pixels>, view: &Entity<Watching>, cx: &mut VisualTestContext) {
cx.update(|window, _| window.blur());
view.update(cx, |view, cx| {
view.playing = true;
cx.notify();
});
cx.simulate_mouse_move(spot, None, Modifiers::none());
settle(cx);
cx.executor().advance_clock(Duration::from_millis(2600));
cx.run_until_parked();
settle(cx);
assert!(
cx.debug_bounds("video-controls-resting").is_some(),
"the bar rests"
);
}
#[gpui::test]
fn a_drag_begun_on_a_resting_bar_follows_the_pointer(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
rest(across("scrubber-rail", 0.1, cx), &view, cx);
let none = Modifiers::none();
let start = across("scrubber-rail", 0.1, cx);
cx.simulate_mouse_down(start, MouseButton::Left, none);
settle(cx);
for step in 1..=5 {
let to = across("scrubber-rail", 0.1 + 0.1 * step as f32, cx);
cx.simulate_mouse_move(to, MouseButton::Left, none);
settle(cx);
}
let end = across("scrubber-rail", 0.6, cx);
cx.simulate_mouse_up(end, MouseButton::Left, none);
settle(cx);
let at = view.read_with(cx, |view, _| view.at);
assert!(at.abs_diff(SECOND * 60) < SECOND * 2, "{at:?}");
}
#[gpui::test]
fn shift_tab_into_a_resting_bar_keeps_focus_on_its_last_button(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
let frame = cx.debug_bounds("video-player").expect("the player draws");
rest(frame.center(), &view, cx);
cx.update(|window, _| window.focus_prev());
settle(cx);
press("enter", cx);
assert_eq!(view.read_with(cx, |view, _| view.fullscreens), 1);
}
#[gpui::test]
fn the_first_move_onto_a_resting_bar_shows_the_tip(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
let frame = cx.debug_bounds("video-player").expect("the player draws");
rest(frame.origin + point(px(8.0), px(8.0)), &view, cx);
let half = across("scrubber-rail", 0.5, cx);
cx.simulate_mouse_move(half, None, Modifiers::none());
settle(cx);
assert!(cx.debug_bounds("scrubber-tip").is_some());
}
/// A playing player whose only control is its speed.
struct Paced {
path: PathBuf,
}
impl Render for Paced {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let player = VideoPlayer::new(
"paced",
Some(self.path.clone().into()),
1.6,
SECOND * 100,
SECOND * 30,
);
div()
.w(px(480.0))
.child(player.playing(true).on_speed(|_, _, _| {}))
}
}
#[gpui::test]
fn the_bar_stays_up_while_a_button_in_it_has_focus(cx: &mut TestAppContext) {
setup(cx);
let path = picture("paced", 160, 100);
let (_, cx) = cx.add_window_view(|_, _| Paced { path });
cx.update(|window, _| window.activate_window());
settle(cx);
cx.update(|window, _| window.focus_next());
settle(cx);
cx.executor().advance_clock(Duration::from_millis(2600));
settle(cx);
assert!(cx.debug_bounds("video-controls-resting").is_none());
}
+3 -200
View File
@@ -2,20 +2,14 @@ use std::{path::PathBuf, time::Duration};
use gpui::{
Context, Entity, InteractiveElement, IntoElement, Modifiers, MouseButton, ParentElement,
Pixels, Point, Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
use super::{picture, press, settle, setup};
use crate::media::{Cue, Scrubber, SubtitleEditor, VideoPlayer, VideoThumbnailStrip};
use super::{across, picture, press, settle, setup};
use crate::media::{Cue, Scrubber, SubtitleEditor, 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,
@@ -112,197 +106,6 @@ fn the_tip_stays_over_the_track_at_its_ends(cx: &mut TestAppContext) {
}
}
/// A player of a hundred seconds, this wide, with this caption, and what it was told.
struct Watching {
path: PathBuf,
width: f32,
caption: &'static str,
at: Duration,
playing: bool,
plays: Vec<bool>,
captions: bool,
fullscreens: usize,
}
impl Render for Watching {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (play, seek, caption, full) = (cx.entity(), cx.entity(), cx.entity(), cx.entity());
div().w(px(self.width)).child(
VideoPlayer::new(
"player",
Some(self.path.clone().into()),
1.6,
SECOND * 100,
self.at,
)
.playing(self.playing)
.captions(self.captions, Some(self.caption.into()))
.on_play(move |on, _, cx| {
play.update(cx, |view, cx| {
view.playing = on;
view.plays.push(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();
})
})
.on_fullscreen(move |_, cx| {
full.update(cx, |view, cx| {
view.fullscreens += 1;
cx.notify();
})
}),
)
}
}
fn watching_at<'a>(
width: f32,
caption: &'static str,
captions: bool,
cx: &'a mut TestAppContext,
) -> (Entity<Watching>, &'a mut VisualTestContext) {
setup(cx);
let path = picture("frame", 160, 100);
let (view, cx) = cx.add_window_view(|_, _| Watching {
path,
width,
caption,
at: SECOND * 30,
playing: false,
plays: Vec::new(),
captions,
fullscreens: 0,
});
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)
}
fn watching(cx: &mut TestAppContext) -> (Entity<Watching>, &mut VisualTestContext) {
watching_at(480.0, "Light falls on the stair", false, cx)
}
#[gpui::test]
fn space_on_a_focused_bar_button_presses_it_once(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
cx.update(|window, _| {
window.blur();
for _ in 0..3 {
window.focus_next();
}
});
press("space", cx);
assert_eq!(view.read_with(cx, |view, _| view.plays.clone()), [true]);
}
#[gpui::test]
fn command_letters_leave_the_player_to_the_app(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
for key in ["cmd-f", "cmd-c", "cmd-k", "ctrl-f"] {
press(key, cx);
}
let heard = view.read_with(cx, |view, _| {
(view.fullscreens, view.captions, view.plays.clone())
});
assert_eq!(heard, (0, false, vec![]));
}
#[gpui::test]
fn a_long_caption_wraps_inside_a_narrow_player(cx: &mut TestAppContext) {
let (view, cx) = watching_at(280.0, "Light falls", true, cx);
let line = cx.debug_bounds("video-caption").expect("the caption draws");
view.update(cx, |view, cx| {
view.caption = "The shadow walks the length of the hall while the morning comes in.";
cx.notify();
});
settle(cx);
let (chip, frame) = (
cx.debug_bounds("video-caption").expect("the caption draws"),
cx.debug_bounds("video-player").expect("the player draws"),
);
assert!(
chip.left() >= frame.left() && chip.right() <= frame.right(),
"{chip:?} in {frame:?}"
);
assert!(
chip.size.height > line.size.height * 1.5,
"{chip:?} wraps past {line:?}"
);
}
#[gpui::test]
fn a_caption_stays_while_the_controls_rest(cx: &mut TestAppContext) {
let (view, cx) = watching_at(480.0, "Light falls on the stair", true, 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);
cx.executor().advance_clock(Duration::from_millis(2600));
cx.run_until_parked();
let faded = cx
.debug_bounds("video-controls-resting")
.expect("the bar rests");
let caption = cx.debug_bounds("video-caption").expect("the caption draws");
assert!(
caption.bottom() <= faded.top(),
"{caption:?} outside {faded:?}"
);
}
#[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,