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,334 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, DragMoveEvent, ElementId, EmptyView, EntityId, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, relative,
|
||||
transparent_black,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
forms::keyed,
|
||||
primitives::{FocusRing, tab_stop},
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
typography::{
|
||||
format::{DurationStyle, duration},
|
||||
tabular,
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) type OnTime = Rc<dyn Fn(Duration, &mut Window, &mut App)>;
|
||||
|
||||
/// Seconds an arrow key moves the time; Page keys move ten times as far.
|
||||
const STEP: f64 = 5.0;
|
||||
|
||||
/// The time at `share` of `length`, the share held to the track.
|
||||
pub(crate) fn time_at(share: f32, length: Duration) -> Duration {
|
||||
length.mul_f32(share.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// The chapter `at` falls in: the last to start at or before it.
|
||||
pub(crate) fn chapter_at(starts: &[Duration], at: Duration) -> Option<usize> {
|
||||
starts.iter().rposition(|start| *start <= at)
|
||||
}
|
||||
|
||||
/// A time as the crate's clock reads it.
|
||||
pub(crate) fn clock(at: Duration) -> String {
|
||||
duration(at.as_secs(), DurationStyle::Clock)
|
||||
}
|
||||
|
||||
/// A seeking drag, marked with its scrubber.
|
||||
struct Scrub(EntityId);
|
||||
|
||||
/// The track's bounds and the share under the pointer.
|
||||
#[derive(Default)]
|
||||
struct Track {
|
||||
bounds: Bounds<Pixels>,
|
||||
hover: Option<f32>,
|
||||
}
|
||||
|
||||
/// Where a video or a song stands along its length: what has loaded, how far it has played, its chapters, and the time under the pointer. A press or a drag seeks; with focus the arrows step five seconds, the Page keys fifty, Home and End go to the ends. The host plays it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Scrubber {
|
||||
id: ElementId,
|
||||
length: Duration,
|
||||
at: Duration,
|
||||
loaded: Duration,
|
||||
chapters: Vec<(Duration, SharedString)>,
|
||||
over_media: bool,
|
||||
on_seek: Option<OnTime>,
|
||||
}
|
||||
|
||||
impl Scrubber {
|
||||
/// `at` is how far it has played, within `length`.
|
||||
pub fn new(id: impl Into<ElementId>, length: Duration, at: Duration) -> Self {
|
||||
assert!(!length.is_zero() && at <= length, "{at:?} of {length:?}");
|
||||
Self {
|
||||
id: id.into(),
|
||||
length,
|
||||
at,
|
||||
loaded: Duration::ZERO,
|
||||
chapters: Vec::new(),
|
||||
over_media: false,
|
||||
on_seek: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Light on a dark layer over pictures, as a player's controls sit.
|
||||
pub fn over_media(mut self) -> Self {
|
||||
self.over_media = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// How far it has loaded.
|
||||
pub fn loaded(mut self, loaded: Duration) -> Self {
|
||||
assert!(
|
||||
loaded <= self.length,
|
||||
"loaded {loaded:?} of {:?}",
|
||||
self.length
|
||||
);
|
||||
self.loaded = loaded;
|
||||
self
|
||||
}
|
||||
|
||||
/// A chapter from `start`, after the last one given.
|
||||
pub fn chapter(mut self, start: Duration, title: impl Into<SharedString>) -> Self {
|
||||
let after = self.chapters.last().is_none_or(|(last, _)| *last < start);
|
||||
assert!(
|
||||
after && start < self.length,
|
||||
"a chapter at {start:?} out of order"
|
||||
);
|
||||
self.chapters.push((start, title.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the time to seek to.
|
||||
pub fn on_seek(mut self, handler: impl Fn(Duration, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_seek = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Scrubber {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let track = window.use_keyed_state((self.id.clone(), "track"), cx, |_, _| Track::default());
|
||||
let focus = tab_stop(
|
||||
(self.id.clone(), "focus").into(),
|
||||
self.on_seek.is_some(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let focused = focus.is_focused(window);
|
||||
let owner = track.entity_id();
|
||||
let (length, at) = (self.length, self.at);
|
||||
let share = |time: Duration| time.as_secs_f32() / length.as_secs_f32();
|
||||
let starts: Vec<Duration> = self.chapters.iter().map(|(start, _)| *start).collect();
|
||||
let hover = track.read(cx).hover;
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let (played, lane) = match self.over_media {
|
||||
true => (colors.on_media, colors.on_media.opacity(0.25)),
|
||||
false => (colors.fg, colors.border),
|
||||
};
|
||||
let (line, thumb) = (theme.slider_track(), theme.slider_thumb() * 0.75);
|
||||
let mut bounds: Vec<Duration> = if starts.first() == Some(&Duration::ZERO) {
|
||||
starts.clone()
|
||||
} else {
|
||||
std::iter::once(Duration::ZERO)
|
||||
.chain(starts.iter().copied())
|
||||
.collect()
|
||||
};
|
||||
bounds.push(length);
|
||||
let lanes = bounds.windows(2).map(|pair| {
|
||||
let (start, end) = (pair[0], pair[1]);
|
||||
let within = |time: Duration| {
|
||||
((time.as_secs_f32() - start.as_secs_f32()) / (end - start).as_secs_f32())
|
||||
.clamp(0.0, 1.0)
|
||||
};
|
||||
div()
|
||||
.relative()
|
||||
.h(line)
|
||||
.flex_basis(relative(share(end - start)))
|
||||
.flex_grow()
|
||||
.rounded_full()
|
||||
.bg(lane)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.h_full()
|
||||
.w(relative(within(self.loaded)))
|
||||
.rounded_full()
|
||||
.bg(played.opacity(0.35)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.h_full()
|
||||
.w(relative(within(at)))
|
||||
.rounded_full()
|
||||
.bg(played),
|
||||
)
|
||||
});
|
||||
let tip = hover.map(|hovered| {
|
||||
let time = time_at(hovered, length);
|
||||
let name = chapter_at(&starts, time).map(|ix| self.chapters[ix].1.clone());
|
||||
let text = match name {
|
||||
Some(name) => format!("{} · {name}", clock(time)),
|
||||
None => clock(time),
|
||||
};
|
||||
div()
|
||||
.absolute()
|
||||
.left(relative(hovered))
|
||||
.bottom_full()
|
||||
.size_0()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_end()
|
||||
.child(
|
||||
tabular(div())
|
||||
.flex_none()
|
||||
.mb_1()
|
||||
.px_1p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(colors.tooltip_bg)
|
||||
.text_color(colors.tooltip_fg)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.whitespace_nowrap()
|
||||
.child(text),
|
||||
)
|
||||
.debug_selector(|| "scrubber-tip".into())
|
||||
});
|
||||
let knob = (hover.is_some() || focused).then(|| {
|
||||
div()
|
||||
.absolute()
|
||||
.left(relative(share(at)))
|
||||
.top(relative(0.5))
|
||||
.size_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(div().flex_none().size(thumb).rounded_full().bg(played))
|
||||
});
|
||||
let measured = track.clone();
|
||||
let rail = div()
|
||||
.debug_selector(|| "scrubber-rail".into())
|
||||
.relative()
|
||||
.w_full()
|
||||
.flex()
|
||||
.gap_0p5()
|
||||
.items_center()
|
||||
.h(thumb)
|
||||
.children(lanes)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, window, cx| {
|
||||
if measured.read(cx).bounds != bounds {
|
||||
measured.update(cx, |track, cx| {
|
||||
track.bounds = bounds;
|
||||
cx.notify();
|
||||
});
|
||||
window.request_animation_frame();
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.children(knob)
|
||||
.children(tip);
|
||||
let spot = move |bounds: Bounds<Pixels>, pointer: Point<Pixels>| {
|
||||
f32::from(pointer.x - bounds.left()) / f32::from(bounds.size.width)
|
||||
};
|
||||
let (moved, left) = (track.clone(), track.clone());
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.debug_selector(|| "scrubber".into())
|
||||
.w_full()
|
||||
.px(thumb / 2.0)
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.border_1()
|
||||
.border_color(transparent_black())
|
||||
.child(rail)
|
||||
.on_mouse_move(move |event, _, cx| {
|
||||
moved.update(cx, |track, cx| {
|
||||
track.hover = Some(spot(track.bounds, event.position).clamp(0.0, 1.0));
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.on_hover(move |inside, _, cx| {
|
||||
if !inside {
|
||||
left.update(cx, |track, cx| {
|
||||
track.hover = None;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
})
|
||||
.when_some(self.on_seek, |scrubber, seek| {
|
||||
let (pressed, dragged, keys) = (seek.clone(), seek.clone(), seek);
|
||||
let (pressed_track, dragged_track) = (track.clone(), track.clone());
|
||||
scrubber
|
||||
.track_focus(&focus)
|
||||
.focus_ring(cx)
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
|
||||
let time =
|
||||
time_at(spot(pressed_track.read(cx).bounds, event.position), length);
|
||||
log::info!("scrubber: seek to {}", clock(time));
|
||||
pressed(time, window, cx);
|
||||
})
|
||||
.on_drag(Scrub(owner), |_, _, _, cx| cx.new(|_| EmptyView))
|
||||
.on_drag_move(move |event: &DragMoveEvent<Scrub>, window, cx| {
|
||||
if event.drag(cx).0 != owner {
|
||||
return;
|
||||
}
|
||||
let bounds = dragged_track.read(cx).bounds;
|
||||
dragged(
|
||||
time_at(spot(bounds, event.event.position), length),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.on_key_down(move |event, window, cx| {
|
||||
let (now, end) = (at.as_secs_f64(), length.as_secs_f64());
|
||||
let Some(next) = keyed(&event.keystroke.key, now, 0.0, end, STEP) else {
|
||||
return;
|
||||
};
|
||||
cx.stop_propagation();
|
||||
let time = Duration::from_secs_f64(next);
|
||||
log::info!("scrubber: seek to {}", clock(time));
|
||||
keys(time, window, cx);
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{chapter_at, clock, time_at};
|
||||
|
||||
#[test]
|
||||
fn a_share_is_a_time_held_to_the_track() {
|
||||
let length = Duration::from_secs(200);
|
||||
assert_eq!(time_at(0.25, length), Duration::from_secs(50));
|
||||
assert_eq!(time_at(1.4, length), length);
|
||||
assert_eq!(time_at(-0.2, length), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_falls_in_the_last_chapter_begun() {
|
||||
let starts = [Duration::from_secs(10), Duration::from_secs(60)];
|
||||
assert_eq!(chapter_at(&starts, Duration::from_secs(5)), None);
|
||||
assert_eq!(chapter_at(&starts, Duration::from_secs(10)), Some(0));
|
||||
assert_eq!(chapter_at(&starts, Duration::from_secs(90)), Some(1));
|
||||
assert_eq!(clock(Duration::from_secs(83)), "01:23");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user