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