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.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 17:07:26 -04:00
1 parent 99b6ccd798
commit d125314d39
22 files changed
+2232 -25

No files matched your search

+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();
})
}
}