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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-26 17:07:26 -04:00
1 parent f7f20b5146
commit d0175edb7b
22 files changed
+2232 -25

No files matched your search

+334
View File
@@ -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");
}
}