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