Files
Ely-GPUI-Components/src/media/player.rs
T

421 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{rc::Rc, time::Duration};
use gpui::{
App, ElementId, Entity, 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: web_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:?} of no length");
let at = super::played(at, length, "video player");
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
}
}
/// Wakes the bar, as a move or a key just now; a timer lets it rest once all is still again.
fn wake(watch: &Entity<Watch>, window: &mut Window, cx: &mut App) {
let arm = watch.update(cx, |watch, cx| {
watch.moved = cx.background_executor().now();
cx.notify();
!std::mem::replace(&mut watch.waking, true)
});
if !arm {
return;
}
let watch = watch.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;
}
cx.update(|_, cx| {
watch.update(cx, |watch, cx| {
watch.waking = false;
cx.notify();
})
})
})
.detach_and_log_err(cx);
}
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 keys = self.on_play.is_some()
|| self.on_seek.is_some()
|| self.on_fullscreen.is_some()
|| (self.on_captions.is_some() && self.captions.is_some());
let focus = tab_stop((self.id.clone(), "focus").into(), keys, window, cx);
let now = cx.background_executor().now();
let in_bar = focus.contains_focused(window, cx) && !focus.is_focused(window);
let resting = self.playing && now.duration_since(watch.read(cx).moved) >= IDLE && !in_bar;
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;
let (length, at, playing, speed) = (self.length, self.at, self.playing, self.speed);
let button = |key: &'static str, icon: IconName, run: Run| {
let watch = watch.clone();
media_button(
(self.id.clone(), key),
icon,
ControlSize::Sm,
true,
move |window, cx| match resting {
true => wake(&watch, window, cx),
false => run(window, cx),
},
cx,
)
};
let toggle: Option<Run> = self.on_play.clone().map(|play| {
Rc::new(move |window: &mut Window, cx: &mut App| {
log::info!("video player: {}", if playing { "pause" } else { "play" });
play(!playing, window, cx)
}) as Run
});
let turn: Option<Run> = self
.on_captions
.clone()
.zip(self.captions)
.map(|(set, on)| {
Rc::new(move |window: &mut Window, cx: &mut App| {
log::info!("video player: captions {}", if on { "off" } else { "on" });
set(!on, window, cx)
}) as Run
});
let play = toggle.clone().map(|run| {
let icon = if playing {
IconName::Pause
} else {
IconName::Play
};
button("play", icon, run)
});
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 = turn.clone().map(|run| {
let icon = if self.captions == Some(true) {
IconName::Captions
} else {
IconName::CaptionsOff
};
button("captions", icon, run)
});
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) => {
let watch = watch.clone();
scrubber.on_seek(move |time, window, cx| match resting {
true => wake(&watch, window, cx),
false => 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()
.debug_selector(|| "video-caption".into())
.min_w_0()
.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 bar = bar.when(resting, |bar| {
bar.debug_selector(|| "video-controls-resting".into())
});
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()
.right_0()
.bottom_0()
.p_2()
.flex()
.flex_col()
.gap_2()
.children(caption)
.child(bar);
let frame = self
.frame
.map(|frame| img(frame).id((self.id.clone(), "frame")).size_full());
let (moved, pressed) = (watch.clone(), watch.clone());
let keyed = (
toggle,
self.on_seek.clone(),
turn,
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)
.when(keys, |stage| stage.focus_ring(cx))
.children(frame)
.child(controls)
.on_mouse_move(move |_, window, cx| wake(&moved, window, cx))
.on_key_down(move |event, window, cx| {
let held = &event.keystroke.modifiers;
if held.platform || held.control {
return;
}
let (play, seek, captions, 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(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 {
Some(turn) => turn(window, cx),
None => return,
},
"f" => match fullscreen {
Some(run) => run(window, cx),
None => return,
},
_ => return,
}
cx.stop_propagation();
wake(&pressed, window, cx);
})
}
}