Media · Audio and controls: player, waveform, spectrum, volume, speed, transport and playlist
AudioPlayer sets a song's cover, title and artist over a scrubber with the time played and left, the transport and the volume. AudioWaveform draws peaks gathered to the bars that fit, dark as far as it has played, and seeks by press, drag and keys, with the time tip the scrubber shares. AudioSpectrum shows levels by pitch. VolumeControl mutes from its speaker, and moving its slider while muted unmutes. PlaybackSpeedControl picks from a menu. MediaControls holds shuffle, back, play, forward and repeat. Playlist builds on SelectableList: the song under way is lit with moving bars, and Enter or a double press plays another. The video player's bar now rests while the stage itself holds focus, a key it takes wakes the bar, and a press on a resting bar only wakes it.
This commit is contained in:
1 parent
1ac50e4002
commit
04f88812dc
24 files changed
+1824
-81
No files matched your search
+2
-1
@@ -83,7 +83,8 @@ pub use multi::MultiSelect;
|
||||
pub use number::{NumberInput, ScrubInput};
|
||||
pub use options::Choice;
|
||||
pub(crate) use options::{
|
||||
OnFlag, OnValue, OnValues, Pick, Run, float, float_height, reveal, revealer, step, surface,
|
||||
OnFlag, OnNumber, OnValue, OnValues, Pick, Run, float, float_height, reveal, revealer, step,
|
||||
surface,
|
||||
};
|
||||
pub use pairs::{FieldArray, KeyValueInput, ListInput};
|
||||
pub use path::PathInput;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Corners, DragMoveEvent, ElementId, EmptyView, EntityId, InteractiveElement,
|
||||
IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, StatefulInteractiveElement,
|
||||
Styled, Window, canvas, div, fill, point, prelude::*, relative, size, transparent_black,
|
||||
};
|
||||
|
||||
use super::scrubber::{OnTime, clock, time_at, time_tip};
|
||||
use crate::{
|
||||
forms::keyed,
|
||||
primitives::{FocusRing, tab_stop},
|
||||
theme::{ActiveTheme, Radius},
|
||||
};
|
||||
|
||||
/// Seconds an arrow key moves the time.
|
||||
const STEP: f64 = 5.0;
|
||||
|
||||
/// `peaks` gathered into `count` bars, each the loudest peak it covers.
|
||||
pub(crate) fn resample(peaks: &[f32], count: usize) -> Vec<f32> {
|
||||
assert!(
|
||||
!peaks.is_empty() && count > 0,
|
||||
"{} peaks into {count}",
|
||||
peaks.len()
|
||||
);
|
||||
(0..count)
|
||||
.map(|bar| {
|
||||
let from = bar * peaks.len() / count;
|
||||
let to = ((bar + 1) * peaks.len() / count).max(from + 1);
|
||||
peaks[from..to].iter().copied().fold(0.0, f32::max)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A seeking drag on a waveform, marked with its waveform.
|
||||
struct Seek(EntityId);
|
||||
|
||||
/// The waveform's bounds and the share under the pointer.
|
||||
#[derive(Default)]
|
||||
struct Wave {
|
||||
bounds: Bounds<Pixels>,
|
||||
hover: Option<f32>,
|
||||
}
|
||||
|
||||
/// A sound's waveform, its peaks from 0 to 1 in time order, dark as far as it has played; the time under the pointer shows above it. 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 AudioWaveform {
|
||||
id: ElementId,
|
||||
peaks: Rc<Vec<f32>>,
|
||||
length: Duration,
|
||||
at: Duration,
|
||||
on_seek: Option<OnTime>,
|
||||
}
|
||||
|
||||
impl AudioWaveform {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
peaks: impl Into<Vec<f32>>,
|
||||
length: Duration,
|
||||
at: Duration,
|
||||
) -> Self {
|
||||
let peaks: Vec<f32> = peaks.into();
|
||||
assert!(
|
||||
!peaks.is_empty() && peaks.iter().all(|peak| (0.0..=1.0).contains(peak)),
|
||||
"peaks lie within 0 to 1"
|
||||
);
|
||||
assert!(!length.is_zero() && at <= length, "{at:?} of {length:?}");
|
||||
Self {
|
||||
id: id.into(),
|
||||
peaks: Rc::new(peaks),
|
||||
length,
|
||||
at,
|
||||
on_seek: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 AudioWaveform {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let wave = window.use_keyed_state((self.id.clone(), "wave"), cx, |_, _| Wave::default());
|
||||
let focus = tab_stop(
|
||||
(self.id.clone(), "focus").into(),
|
||||
self.on_seek.is_some(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let owner = wave.entity_id();
|
||||
let (length, at, peaks) = (self.length, self.at, self.peaks);
|
||||
let played = at.as_secs_f32() / length.as_secs_f32();
|
||||
let hover = wave.read(cx).hover;
|
||||
let theme = cx.theme();
|
||||
let sizes = theme.media();
|
||||
let rem = window.rem_size();
|
||||
let (bar, gap) = (sizes.wave_bar.to_pixels(rem), sizes.wave_gap.to_pixels(rem));
|
||||
let (dark, light) = (theme.colors.fg, theme.colors.fg_subtle);
|
||||
let tip = hover.map(|share| time_tip(share, clock(time_at(share, length)), cx));
|
||||
let measured = wave.clone();
|
||||
let drawn = canvas(
|
||||
move |bounds, window, cx| {
|
||||
if measured.read(cx).bounds != bounds {
|
||||
measured.update(cx, |wave, cx| {
|
||||
wave.bounds = bounds;
|
||||
cx.notify();
|
||||
});
|
||||
window.request_animation_frame();
|
||||
}
|
||||
},
|
||||
move |bounds, _, window, _| {
|
||||
let width = f32::from(bounds.size.width);
|
||||
let pitch = f32::from(bar + gap);
|
||||
let count = ((width + f32::from(gap)) / pitch).floor().max(1.0) as usize;
|
||||
let inset = (width - (pitch * count as f32 - f32::from(gap))) / 2.0;
|
||||
for (ix, peak) in resample(&peaks, count).into_iter().enumerate() {
|
||||
let left = inset + pitch * ix as f32;
|
||||
let tall = (bounds.size.height * peak).max(bar);
|
||||
let top = bounds.top() + (bounds.size.height - tall) / 2.0;
|
||||
let origin = point(bounds.left() + Pixels::from(left), top);
|
||||
let lit = (left + f32::from(bar) / 2.0) / width <= played;
|
||||
window.paint_quad(
|
||||
fill(
|
||||
Bounds::new(origin, size(bar, tall)),
|
||||
if lit { dark } else { light },
|
||||
)
|
||||
.corner_radii(Corners::all(bar / 2.0)),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full();
|
||||
let spot = move |bounds: Bounds<Pixels>, pointer: Point<Pixels>| {
|
||||
(f32::from(pointer.x - bounds.left()) / f32::from(bounds.size.width)).clamp(0.0, 1.0)
|
||||
};
|
||||
let (moved, left) = (wave.clone(), wave.clone());
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.debug_selector(|| "audio-waveform".into())
|
||||
.relative()
|
||||
.w_full()
|
||||
.h(sizes.waveform)
|
||||
.border_1()
|
||||
.border_color(transparent_black())
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.child(drawn)
|
||||
.children(tip)
|
||||
.on_mouse_move(move |event, _, cx| {
|
||||
moved.update(cx, |wave, cx| {
|
||||
wave.hover = Some(spot(wave.bounds, event.position));
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.on_hover(move |inside, _, cx| {
|
||||
if !inside {
|
||||
left.update(cx, |wave, cx| {
|
||||
wave.hover = None;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
})
|
||||
.when_some(self.on_seek, |shown, seek| {
|
||||
let (pressed, dragged, keys) = (seek.clone(), seek.clone(), seek);
|
||||
let (pressed_wave, dragged_wave) = (wave.clone(), wave.clone());
|
||||
shown
|
||||
.track_focus(&focus)
|
||||
.focus_ring(cx)
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
|
||||
let time =
|
||||
time_at(spot(pressed_wave.read(cx).bounds, event.position), length);
|
||||
log::info!("audio waveform: seek to {}", clock(time));
|
||||
pressed(time, window, cx);
|
||||
})
|
||||
.on_drag(Seek(owner), |_, _, _, cx| cx.new(|_| EmptyView))
|
||||
.on_drag_move(move |event: &DragMoveEvent<Seek>, window, cx| {
|
||||
if event.drag(cx).0 != owner {
|
||||
return;
|
||||
}
|
||||
let bounds = dragged_wave.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();
|
||||
keys(Duration::from_secs_f64(next), window, cx);
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Sound by pitch, low to high: bars rising from a line, each level from 0 to 1. The host sends new levels as it plays.
|
||||
#[derive(IntoElement)]
|
||||
pub struct AudioSpectrum {
|
||||
levels: Vec<f32>,
|
||||
}
|
||||
|
||||
impl AudioSpectrum {
|
||||
pub fn new(levels: impl Into<Vec<f32>>) -> Self {
|
||||
let levels: Vec<f32> = levels.into();
|
||||
assert!(
|
||||
!levels.is_empty() && levels.iter().all(|level| (0.0..=1.0).contains(level)),
|
||||
"levels lie within 0 to 1"
|
||||
);
|
||||
Self { levels }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for AudioSpectrum {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (color, round) = (theme.colors.fg_muted, theme.radius(Radius::Sm));
|
||||
div()
|
||||
.debug_selector(|| "audio-spectrum".into())
|
||||
.w_full()
|
||||
.h(theme.media().waveform)
|
||||
.flex()
|
||||
.items_end()
|
||||
.gap_0p5()
|
||||
.children(self.levels.into_iter().map(move |level| {
|
||||
div()
|
||||
.flex_1()
|
||||
.h(relative(level.max(0.02)))
|
||||
.rounded_t(round)
|
||||
.bg(color)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resample;
|
||||
|
||||
#[test]
|
||||
fn peaks_gather_into_bars_by_their_loudest() {
|
||||
assert_eq!(resample(&[0.1, 0.9, 0.3, 0.2], 2), [0.9, 0.3]);
|
||||
assert_eq!(resample(&[0.4, 0.8], 4), [0.4, 0.4, 0.8, 0.8]);
|
||||
assert_eq!(resample(&[0.5], 3), [0.5, 0.5, 0.5]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
|
||||
|
||||
use super::player::SPEEDS;
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton, ToggleButton, ToggleItem},
|
||||
forms::{OnFlag, OnNumber, Run, Slider},
|
||||
menus::{DropdownMenu, Menu, MenuItem},
|
||||
primitives::IconName,
|
||||
theme::ControlSize,
|
||||
};
|
||||
|
||||
type OnSpeed = Rc<dyn Fn(f32, &mut Window, &mut App)>;
|
||||
type OnRepeat = Rc<dyn Fn(Repeat, &mut Window, &mut App)>;
|
||||
|
||||
/// The speaker for a level from 0 to 1: struck through when silent, with more waves as it grows.
|
||||
pub(crate) fn speaker(level: f32, muted: bool) -> IconName {
|
||||
match level {
|
||||
_ if muted || level == 0.0 => IconName::VolumeX,
|
||||
level if level < 1.0 / 3.0 => IconName::Volume,
|
||||
level if level < 2.0 / 3.0 => IconName::Volume1,
|
||||
_ => IconName::Volume2,
|
||||
}
|
||||
}
|
||||
|
||||
/// A volume from 0 to 1 and a mute: a speaker that mutes and unmutes, drawn by the level, beside a slider whose arrows step a twentieth. Muted, the slider rests at zero, and moving it unmutes.
|
||||
#[derive(IntoElement)]
|
||||
pub struct VolumeControl {
|
||||
id: ElementId,
|
||||
level: f32,
|
||||
muted: bool,
|
||||
on_level: Option<OnNumber>,
|
||||
on_mute: Option<OnFlag>,
|
||||
}
|
||||
|
||||
impl VolumeControl {
|
||||
pub fn new(id: impl Into<ElementId>, level: f32, muted: bool) -> Self {
|
||||
assert!((0.0..=1.0).contains(&level), "a volume of {level}");
|
||||
Self {
|
||||
id: id.into(),
|
||||
level,
|
||||
muted,
|
||||
on_level: None,
|
||||
on_mute: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_level(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_level = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets true to mute, false to unmute.
|
||||
pub fn on_mute(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_mute = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for VolumeControl {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let muted = self.muted;
|
||||
let speaker = IconButton::new((self.id.clone(), "mute"), speaker(self.level, muted))
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(if muted { "Unmute" } else { "Mute" })
|
||||
.disabled(self.on_mute.is_none());
|
||||
let speaker = match self.on_mute.clone() {
|
||||
Some(mute) => speaker.on_click(move |_, window, cx| {
|
||||
log::info!("volume: {}", if muted { "unmuted" } else { "muted" });
|
||||
mute(!muted, window, cx)
|
||||
}),
|
||||
None => speaker,
|
||||
};
|
||||
let shown = if muted { 0.0 } else { self.level };
|
||||
let slider = Slider::new((self.id.clone(), "level"), f64::from(shown))
|
||||
.range(0.0, 1.0)
|
||||
.step(0.05)
|
||||
.disabled(self.on_level.is_none());
|
||||
let slider = match self.on_level {
|
||||
Some(level) => {
|
||||
let mute = self.on_mute;
|
||||
slider.on_change(move |to, window, cx| {
|
||||
if let Some(mute) = mute.as_ref().filter(|_| muted && to > 0.0) {
|
||||
log::info!("volume: unmuted by the slider");
|
||||
mute(false, window, cx);
|
||||
}
|
||||
level(to, window, cx)
|
||||
})
|
||||
}
|
||||
None => slider,
|
||||
};
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(div().flex_none().child(speaker))
|
||||
.child(div().flex_1().min_w_0().child(slider))
|
||||
}
|
||||
}
|
||||
|
||||
/// How fast it plays, one of `SPEEDS`, chosen from a menu under its button.
|
||||
#[derive(IntoElement)]
|
||||
pub struct PlaybackSpeedControl {
|
||||
id: ElementId,
|
||||
speed: f32,
|
||||
on_change: Option<OnSpeed>,
|
||||
}
|
||||
|
||||
impl PlaybackSpeedControl {
|
||||
pub fn new(id: impl Into<ElementId>, speed: f32) -> Self {
|
||||
assert!(SPEEDS.contains(&speed), "a speed of {speed}");
|
||||
Self {
|
||||
id: id.into(),
|
||||
speed,
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_change(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for PlaybackSpeedControl {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let menu = SPEEDS.iter().fold(Menu::new(), |menu, speed| {
|
||||
let item = MenuItem::radio(format!("{speed}×"), *speed == self.speed);
|
||||
menu.item(match self.on_change.clone() {
|
||||
Some(change) => {
|
||||
let speed = *speed;
|
||||
item.on_click(move |window, cx| {
|
||||
log::info!("playback speed: {speed}×");
|
||||
change(speed, window, cx)
|
||||
})
|
||||
}
|
||||
None => item.disabled(true),
|
||||
})
|
||||
});
|
||||
DropdownMenu::new(self.id, format!("{}×", self.speed), menu)
|
||||
.icon(IconName::Gauge)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a list plays again: not, all of it, or the one song.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Repeat {
|
||||
Off,
|
||||
All,
|
||||
One,
|
||||
}
|
||||
|
||||
impl Repeat {
|
||||
/// The next along a press: off, all, one, off.
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Off => Self::All,
|
||||
Self::All => Self::One,
|
||||
Self::One => Self::Off,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The transport: shuffle, back, play or pause, forward and repeat, each shown only with its handler; shuffle and repeat stay lit while on.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MediaControls {
|
||||
id: ElementId,
|
||||
playing: bool,
|
||||
shuffle: bool,
|
||||
repeat: Repeat,
|
||||
on_play: Option<OnFlag>,
|
||||
on_back: Option<Run>,
|
||||
on_forward: Option<Run>,
|
||||
on_shuffle: Option<OnFlag>,
|
||||
on_repeat: Option<OnRepeat>,
|
||||
}
|
||||
|
||||
impl MediaControls {
|
||||
pub fn new(id: impl Into<ElementId>, playing: bool) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
playing,
|
||||
shuffle: false,
|
||||
repeat: Repeat::Off,
|
||||
on_play: None,
|
||||
on_back: None,
|
||||
on_forward: None,
|
||||
on_shuffle: None,
|
||||
on_repeat: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets true to play, false to pause.
|
||||
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_back(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_back = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_forward(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_forward = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether it shuffles, and what gets the change.
|
||||
pub fn shuffle(
|
||||
mut self,
|
||||
on: bool,
|
||||
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.shuffle = on;
|
||||
self.on_shuffle = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// How it repeats, and what gets the next along a press.
|
||||
pub fn repeat(
|
||||
mut self,
|
||||
repeat: Repeat,
|
||||
handler: impl Fn(Repeat, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.repeat = repeat;
|
||||
self.on_repeat = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MediaControls {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let id = self.id.clone();
|
||||
let step = |key: &'static str, icon: IconName, tip: &'static str, run: Option<Run>| {
|
||||
run.map(|run| {
|
||||
IconButton::new((id.clone(), key), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip(tip)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("media controls: {key}");
|
||||
run(window, cx)
|
||||
})
|
||||
})
|
||||
};
|
||||
let playing = self.playing;
|
||||
let play = self.on_play.map(|play| {
|
||||
let icon = if playing {
|
||||
IconName::Pause
|
||||
} else {
|
||||
IconName::Play
|
||||
};
|
||||
IconButton::new((id.clone(), "play"), icon)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.size(ControlSize::Lg)
|
||||
.tooltip(if playing { "Pause" } else { "Play" })
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("media controls: {}", if playing { "pause" } else { "play" });
|
||||
play(!playing, window, cx)
|
||||
})
|
||||
});
|
||||
let shuffle = self.on_shuffle.map(|set| {
|
||||
let item = ToggleItem::new("shuffle")
|
||||
.icon(IconName::Shuffle)
|
||||
.tooltip("Shuffle");
|
||||
ToggleButton::new((id.clone(), "shuffle"), item, self.shuffle).on_toggle(
|
||||
move |to, window, cx| {
|
||||
log::info!("media controls: shuffle {to}");
|
||||
set(to, window, cx)
|
||||
},
|
||||
)
|
||||
});
|
||||
let repeat = self.on_repeat.map(|set| {
|
||||
let now = self.repeat;
|
||||
let icon = if now == Repeat::One {
|
||||
IconName::Repeat1
|
||||
} else {
|
||||
IconName::Repeat
|
||||
};
|
||||
let item = ToggleItem::new("repeat").icon(icon).tooltip("Repeat");
|
||||
ToggleButton::new((id.clone(), "repeat"), item, now != Repeat::Off).on_toggle(
|
||||
move |_, window, cx| {
|
||||
log::info!("media controls: repeat {:?}", now.next());
|
||||
set(now.next(), window, cx)
|
||||
},
|
||||
)
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.children(shuffle)
|
||||
.children(step("back", IconName::SkipBack, "Back", self.on_back))
|
||||
.children(play)
|
||||
.children(step(
|
||||
"forward",
|
||||
IconName::SkipForward,
|
||||
"Forward",
|
||||
self.on_forward,
|
||||
))
|
||||
.children(repeat)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Repeat, speaker};
|
||||
use crate::primitives::IconName;
|
||||
|
||||
#[test]
|
||||
fn the_speaker_grows_waves_with_the_level_and_strikes_through_when_silent() {
|
||||
assert_eq!(speaker(0.8, true), IconName::VolumeX);
|
||||
assert_eq!(speaker(0.0, false), IconName::VolumeX);
|
||||
assert_eq!(speaker(0.2, false), IconName::Volume);
|
||||
assert_eq!(speaker(0.5, false), IconName::Volume1);
|
||||
assert_eq!(speaker(0.9, false), IconName::Volume2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_steps_off_all_one_and_round() {
|
||||
assert_eq!(Repeat::Off.next(), Repeat::All);
|
||||
assert_eq!(Repeat::All.next(), Repeat::One);
|
||||
assert_eq!(Repeat::One.next(), Repeat::Off);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, ElementId, ImageSource, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Window, div, relative,
|
||||
};
|
||||
|
||||
use super::{
|
||||
controls::{MediaControls, VolumeControl},
|
||||
scrubber::{OnTime, Scrubber, clock, knob},
|
||||
};
|
||||
use crate::{
|
||||
forms::OnValue,
|
||||
lists::{ListItem, SelectableList},
|
||||
primitives::{Icon, IconName, Image},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
typography::{Ellipsis, tabular},
|
||||
};
|
||||
|
||||
/// A song: its key, title, artist and length.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Track {
|
||||
pub key: SharedString,
|
||||
pub title: SharedString,
|
||||
pub artist: SharedString,
|
||||
pub length: Duration,
|
||||
}
|
||||
|
||||
/// How long the playing bars take to rise and fall.
|
||||
const SWAY: Duration = Duration::from_millis(900);
|
||||
|
||||
/// Three bars that rise and fall while a song plays; they rest while it pauses and under reduced motion.
|
||||
fn playing(id: ElementId, moving: bool, cx: &App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (color, round) = (theme.colors.accent, theme.radius(Radius::Sm));
|
||||
let (wide, still) = (theme.media().wave_bar, theme.reduced_motion);
|
||||
div()
|
||||
.flex_none()
|
||||
.flex()
|
||||
.items_end()
|
||||
.gap_0p5()
|
||||
.h(theme.icon_size(IconSize::Sm))
|
||||
.children((0..3).map(move |ix| {
|
||||
let rest = [0.45, 0.9, 0.65][ix];
|
||||
let bar = div().w(wide).h(relative(rest)).rounded_t(round).bg(color);
|
||||
if moving && !still {
|
||||
bar.with_animation(
|
||||
(id.clone(), format!("bar-{ix}")),
|
||||
Animation::new(SWAY).repeat(),
|
||||
move |bar, t| {
|
||||
let phase = (t + ix as f32 / 3.0) * std::f32::consts::PI;
|
||||
bar.h(relative(0.3 + 0.7 * phase.sin().abs()))
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
bar.into_any_element()
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Songs in order, a row a song: its number, title and artist, and its length. The song under way is lit, its bars moving while it plays. Up and Down move; Enter or a double press plays a song.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Playlist {
|
||||
id: ElementId,
|
||||
tracks: Vec<Track>,
|
||||
current: Option<SharedString>,
|
||||
playing: bool,
|
||||
on_play: Option<OnValue>,
|
||||
}
|
||||
|
||||
impl Playlist {
|
||||
pub fn new(id: impl Into<ElementId>, tracks: impl IntoIterator<Item = Track>) -> Self {
|
||||
let tracks: Vec<Track> = tracks.into_iter().collect();
|
||||
for (ix, track) in tracks.iter().enumerate() {
|
||||
assert!(
|
||||
!tracks[..ix].iter().any(|other| other.key == track.key),
|
||||
"track {} twice",
|
||||
track.key
|
||||
);
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
tracks,
|
||||
current: None,
|
||||
playing: false,
|
||||
on_play: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The song under way, by key, and whether it plays.
|
||||
pub fn current(mut self, key: impl Into<SharedString>, playing: bool) -> Self {
|
||||
let key = key.into();
|
||||
assert!(
|
||||
self.tracks.iter().any(|track| track.key == key),
|
||||
"no track {key}"
|
||||
);
|
||||
self.current = Some(key);
|
||||
self.playing = playing;
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the key of the song to play.
|
||||
pub fn on_play(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_play = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Playlist {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (muted, lead) = (theme.colors.fg_muted, theme.icon_size(IconSize::Lg));
|
||||
let small = theme.text_size(TextSize::Sm);
|
||||
let mut list = SelectableList::new(self.id.clone()).w_full();
|
||||
for (ix, track) in self.tracks.into_iter().enumerate() {
|
||||
let now = self.current.as_ref() == Some(&track.key);
|
||||
let mark = if now {
|
||||
playing(
|
||||
(self.id.clone(), format!("now-{ix}")).into(),
|
||||
self.playing,
|
||||
cx,
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
tabular(div())
|
||||
.text_size(small)
|
||||
.text_color(muted)
|
||||
.child(format!("{}", ix + 1))
|
||||
.into_any_element()
|
||||
};
|
||||
let row = ListItem::new((self.id.clone(), format!("track-{ix}")), track.title)
|
||||
.description(track.artist)
|
||||
.leading(div().w(lead).flex().justify_center().child(mark))
|
||||
.trailing(
|
||||
tabular(div())
|
||||
.text_size(small)
|
||||
.text_color(muted)
|
||||
.child(clock(track.length)),
|
||||
);
|
||||
list = list.row(track.key, row);
|
||||
}
|
||||
let list = list.selected(self.current);
|
||||
match self.on_play {
|
||||
Some(play) => list.on_activate(move |key, window, cx| {
|
||||
log::info!("playlist: play {key}");
|
||||
play(key, window, cx)
|
||||
}),
|
||||
None => list,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A song the host plays: its cover, title and artist, where it stands along a scrubber with the time played and left, the transport, and the volume.
|
||||
#[derive(IntoElement)]
|
||||
pub struct AudioPlayer {
|
||||
id: ElementId,
|
||||
title: SharedString,
|
||||
artist: SharedString,
|
||||
length: Duration,
|
||||
at: Duration,
|
||||
artwork: Option<ImageSource>,
|
||||
controls: Option<MediaControls>,
|
||||
volume: Option<VolumeControl>,
|
||||
on_seek: Option<OnTime>,
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
title: impl Into<SharedString>,
|
||||
artist: impl Into<SharedString>,
|
||||
length: Duration,
|
||||
at: Duration,
|
||||
) -> Self {
|
||||
assert!(!length.is_zero() && at <= length, "{at:?} of {length:?}");
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
artist: artist.into(),
|
||||
length,
|
||||
at,
|
||||
artwork: None,
|
||||
controls: None,
|
||||
volume: None,
|
||||
on_seek: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cover, a square picture.
|
||||
pub fn artwork(mut self, source: impl Into<ImageSource>) -> Self {
|
||||
self.artwork = Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The transport under the song.
|
||||
pub fn controls(mut self, controls: MediaControls) -> Self {
|
||||
self.controls = Some(controls);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn volume(mut self, volume: VolumeControl) -> Self {
|
||||
self.volume = Some(volume);
|
||||
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 AudioPlayer {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let (side, round) = (theme.media().artwork, theme.radius(Radius::Md));
|
||||
let cover = match self.artwork {
|
||||
Some(source) => Image::new((self.id.clone(), "artwork"), source)
|
||||
.size(side)
|
||||
.rounded(round)
|
||||
.into_any_element(),
|
||||
None => div()
|
||||
.size(side)
|
||||
.rounded(round)
|
||||
.bg(colors.sunken)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
Icon::new(IconName::Music)
|
||||
.size(IconSize::Lg)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.into_any_element(),
|
||||
};
|
||||
let scrubber = Scrubber::new((self.id.clone(), "scrubber"), self.length, self.at);
|
||||
let scrubber = match self.on_seek {
|
||||
Some(seek) => scrubber.on_seek(move |time, window, cx| seek(time, window, cx)),
|
||||
None => scrubber,
|
||||
};
|
||||
let times = |text: String| {
|
||||
tabular(div())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(text)
|
||||
};
|
||||
let left = format!("-{}", clock(self.length - self.at));
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.child(div().flex_none().child(cover))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(div().text_color(colors.fg).child(Ellipsis::new(self.title)))
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(Ellipsis::new(self.artist)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().flex().flex_col().gap_1().child(scrubber).child(
|
||||
div()
|
||||
.px(knob(cx) / 2.0)
|
||||
.flex()
|
||||
.justify_between()
|
||||
.child(times(clock(self.at)))
|
||||
.child(times(left)),
|
||||
),
|
||||
)
|
||||
.children(self.controls)
|
||||
.children(self.volume)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
mod annotate;
|
||||
mod audio;
|
||||
mod controls;
|
||||
mod crop;
|
||||
mod cropper;
|
||||
mod listen;
|
||||
mod marks;
|
||||
mod player;
|
||||
mod scrubber;
|
||||
@@ -15,8 +18,11 @@ mod viewer;
|
||||
mod tests;
|
||||
|
||||
pub use annotate::ImageAnnotator;
|
||||
pub use audio::{AudioSpectrum, AudioWaveform};
|
||||
pub use controls::{MediaControls, PlaybackSpeedControl, Repeat, VolumeControl};
|
||||
pub use crop::Crop;
|
||||
pub use cropper::ImageCropper;
|
||||
pub use listen::{AudioPlayer, Playlist, Track};
|
||||
pub use marks::{Mark, Tool};
|
||||
pub use player::{SPEEDS, VideoPlayer};
|
||||
pub use scrubber::Scrubber;
|
||||
|
||||
+49
-37
@@ -1,8 +1,8 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, ImageSource, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Window, div, img, prelude::*, transparent_black,
|
||||
App, ElementId, Entity, ImageSource, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, SharedString, Styled, Window, div, img, prelude::*, transparent_black,
|
||||
};
|
||||
|
||||
use super::scrubber::{OnTime, Scrubber, clock};
|
||||
@@ -138,6 +138,36 @@ impl VideoPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
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();
|
||||
@@ -151,9 +181,8 @@ impl RenderOnce for VideoPlayer {
|
||||
|| (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 resting = self.playing
|
||||
&& now.duration_since(watch.read(cx).moved) >= IDLE
|
||||
&& !focus.contains_focused(window, cx);
|
||||
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,
|
||||
@@ -166,12 +195,16 @@ impl RenderOnce for VideoPlayer {
|
||||
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| run(window, cx),
|
||||
move |window, cx| match resting {
|
||||
true => wake(&watch, window, cx),
|
||||
false => run(window, cx),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
};
|
||||
@@ -244,7 +277,13 @@ impl RenderOnce for VideoPlayer {
|
||||
.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)),
|
||||
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()
|
||||
@@ -315,7 +354,7 @@ impl RenderOnce for VideoPlayer {
|
||||
let frame = self
|
||||
.frame
|
||||
.map(|frame| img(frame).id((self.id.clone(), "frame")).size_full());
|
||||
let moved = watch.clone();
|
||||
let (moved, pressed) = (watch.clone(), watch.clone());
|
||||
let keyed = (
|
||||
toggle,
|
||||
self.on_seek.clone(),
|
||||
@@ -332,35 +371,7 @@ impl RenderOnce for VideoPlayer {
|
||||
.when(keys, |stage| stage.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_and_log_err(cx);
|
||||
}
|
||||
})
|
||||
.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 {
|
||||
@@ -400,6 +411,7 @@ impl RenderOnce for VideoPlayer {
|
||||
_ => return,
|
||||
}
|
||||
cx.stop_propagation();
|
||||
wake(&pressed, window, cx);
|
||||
})
|
||||
}
|
||||
}
|
||||
+37
-26
@@ -1,8 +1,8 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, DragMoveEvent, ElementId, EmptyView, EntityId, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString,
|
||||
App, Bounds, Div, DragMoveEvent, ElementId, EmptyView, EntityId, InteractiveElement,
|
||||
IntoElement, MouseButton, ParentElement, Pixels, Point, Rems, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, relative,
|
||||
transparent_black,
|
||||
};
|
||||
@@ -38,6 +38,39 @@ pub(crate) fn clock(at: Duration) -> String {
|
||||
duration(at.as_secs(), DurationStyle::Clock)
|
||||
}
|
||||
|
||||
/// The time under the pointer over a track. The free width splits by the pointer's share, so the tip stays inside the track and under the pointer.
|
||||
pub(crate) fn time_tip(share: f32, text: String, cx: &App) -> Div {
|
||||
let theme = cx.theme();
|
||||
let side = |share: f32| div().flex_basis(relative(share));
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.bottom_full()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_end()
|
||||
.child(side(share))
|
||||
.child(
|
||||
tabular(div())
|
||||
.flex_none()
|
||||
.max_w_full()
|
||||
.mb_1()
|
||||
.px_1p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(theme.colors.tooltip_bg)
|
||||
.text_color(theme.colors.tooltip_fg)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.debug_selector(|| "time-tip".into())
|
||||
.child(Ellipsis::new(text)),
|
||||
)
|
||||
.child(side(1.0 - share))
|
||||
}
|
||||
|
||||
/// The scrubber's knob, round; its track is padded by half of it.
|
||||
pub(crate) fn knob(cx: &App) -> Rems {
|
||||
cx.theme().slider_thumb() * 0.75
|
||||
}
|
||||
|
||||
/// A seeking drag, marked with its scrubber.
|
||||
struct Scrub(EntityId);
|
||||
|
||||
@@ -131,7 +164,7 @@ impl RenderOnce for Scrubber {
|
||||
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 (line, thumb) = (theme.slider_track(), knob(cx));
|
||||
let mut bounds: Vec<Duration> = if starts.first() == Some(&Duration::ZERO) {
|
||||
starts.clone()
|
||||
} else {
|
||||
@@ -181,29 +214,7 @@ impl RenderOnce for Scrubber {
|
||||
Some(name) => format!("{} · {name}", clock(time)),
|
||||
None => clock(time),
|
||||
};
|
||||
let side = |share: f32| div().flex_basis(relative(share));
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.bottom_full()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_end()
|
||||
.child(side(hovered))
|
||||
.child(
|
||||
tabular(div())
|
||||
.flex_none()
|
||||
.max_w_full()
|
||||
.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))
|
||||
.debug_selector(|| "scrubber-tip".into())
|
||||
.child(Ellipsis::new(text)),
|
||||
)
|
||||
.child(side(1.0 - hovered))
|
||||
time_tip(hovered, text, cx)
|
||||
});
|
||||
let knob = (hover.is_some() || focused).then(|| {
|
||||
div()
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{
|
||||
Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Render, SharedString,
|
||||
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||
};
|
||||
|
||||
use super::{across, press, settle, setup};
|
||||
use crate::media::{
|
||||
AudioPlayer, AudioWaveform, MediaControls, PlaybackSpeedControl, Playlist, Repeat, Track,
|
||||
VolumeControl,
|
||||
};
|
||||
|
||||
const SECOND: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Moves focus to the `nth` Tab stop from none.
|
||||
fn tab_to(nth: usize, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, _| {
|
||||
window.blur();
|
||||
for _ in 0..nth {
|
||||
window.focus_next();
|
||||
}
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
/// A waveform of a hundred seconds, 400 wide, and the times it was sent to.
|
||||
struct Waving {
|
||||
at: Duration,
|
||||
sought: Vec<Duration>,
|
||||
}
|
||||
|
||||
impl Render for Waving {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let owner = cx.entity();
|
||||
let peaks: Vec<f32> = (0..120).map(|ix| (ix % 7) as f32 / 7.0).collect();
|
||||
div().w(px(400.0)).mt(px(40.0)).child(
|
||||
AudioWaveform::new("wave", peaks, SECOND * 100, self.at).on_seek(move |time, _, cx| {
|
||||
owner.update(cx, |view, cx| {
|
||||
view.at = time;
|
||||
view.sought.push(time);
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn waving(cx: &mut TestAppContext) -> (Entity<Waving>, &mut VisualTestContext) {
|
||||
setup(cx);
|
||||
let (view, cx) = cx.add_window_view(|_, _| Waving {
|
||||
at: Duration::ZERO,
|
||||
sought: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
(view, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_on_the_waveform_seeks_and_a_drag_past_its_end_stops_there(cx: &mut TestAppContext) {
|
||||
let (view, cx) = waving(cx);
|
||||
let quarter = across("audio-waveform", 0.25, cx);
|
||||
let none = Modifiers::none();
|
||||
cx.simulate_mouse_down(quarter, MouseButton::Left, none);
|
||||
cx.simulate_mouse_move(quarter + point(px(20.0), px(0.0)), MouseButton::Left, none);
|
||||
cx.simulate_mouse_move(quarter + point(px(900.0), px(0.0)), MouseButton::Left, none);
|
||||
cx.simulate_mouse_up(quarter + point(px(900.0), px(0.0)), MouseButton::Left, none);
|
||||
settle(cx);
|
||||
let sought = view.read_with(cx, |view, _| view.sought.clone());
|
||||
let first = sought.first().expect("a press seeks");
|
||||
assert!(first.abs_diff(SECOND * 25) < SECOND, "{sought:?}");
|
||||
assert_eq!(sought.last(), Some(&(SECOND * 100)), "{sought:?}");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_waveform_steps_five_seconds_by_arrow_and_home_goes_to_the_start(cx: &mut TestAppContext) {
|
||||
let (view, cx) = waving(cx);
|
||||
let half = across("audio-waveform", 0.5, cx);
|
||||
cx.simulate_click(half, Modifiers::none());
|
||||
settle(cx);
|
||||
let start = view.read_with(cx, |view, _| view.at);
|
||||
press("left", cx);
|
||||
assert_eq!(view.read_with(cx, |view, _| view.at), start - SECOND * 5);
|
||||
press("home", cx);
|
||||
assert_eq!(view.read_with(cx, |view, _| view.at), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_waveform_tip_follows_the_pointer_and_stays_over_it(cx: &mut TestAppContext) {
|
||||
let (_, cx) = waving(cx);
|
||||
assert!(cx.debug_bounds("time-tip").is_none());
|
||||
for share in [0.01, 0.5, 0.99] {
|
||||
let at = across("audio-waveform", share, cx);
|
||||
cx.simulate_mouse_move(at, None, Modifiers::none());
|
||||
settle(cx);
|
||||
let tip = cx
|
||||
.debug_bounds("time-tip")
|
||||
.expect("a tip over the waveform");
|
||||
let wave = cx
|
||||
.debug_bounds("audio-waveform")
|
||||
.expect("the waveform draws");
|
||||
let inside = tip.left() >= wave.left() - px(0.5) && tip.right() <= wave.right() + px(0.5);
|
||||
assert!(inside, "at {share}: {tip:?} over {wave:?}");
|
||||
assert!(
|
||||
tip.left() <= at.x && at.x <= tip.right(),
|
||||
"under the pointer: {tip:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A volume control and what it was told.
|
||||
struct Turning {
|
||||
level: f32,
|
||||
muted: bool,
|
||||
levels: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Render for Turning {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (level, mute) = (cx.entity(), cx.entity());
|
||||
div().w(px(240.0)).child(
|
||||
VolumeControl::new("volume", self.level, self.muted)
|
||||
.on_level(move |to, _, cx| {
|
||||
level.update(cx, |view, cx| {
|
||||
view.level = to as f32;
|
||||
view.levels.push(to);
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.on_mute(move |on, _, cx| {
|
||||
mute.update(cx, |view, cx| {
|
||||
view.muted = on;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_speaker_mutes_and_moving_the_slider_unmutes(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
let (view, cx) = cx.add_window_view(|_, _| Turning {
|
||||
level: 0.6,
|
||||
muted: false,
|
||||
levels: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
tab_to(1, cx);
|
||||
press("space", cx);
|
||||
assert!(
|
||||
view.read_with(cx, |view, _| view.muted),
|
||||
"the speaker mutes"
|
||||
);
|
||||
tab_to(2, cx);
|
||||
press("right", cx);
|
||||
let heard = view.read_with(cx, |view, _| (view.muted, view.levels.clone()));
|
||||
assert_eq!(
|
||||
heard,
|
||||
(false, vec![0.05]),
|
||||
"the slider starts from zero and unmutes"
|
||||
);
|
||||
}
|
||||
|
||||
/// A speed control and the speed it holds.
|
||||
struct Pacing {
|
||||
speed: f32,
|
||||
}
|
||||
|
||||
impl Render for Pacing {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let owner = cx.entity();
|
||||
div()
|
||||
.size_full()
|
||||
.child(
|
||||
PlaybackSpeedControl::new("speed", self.speed).on_change(move |to, _, cx| {
|
||||
owner.update(cx, |view, cx| {
|
||||
view.speed = to;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_speed_menu_picks_a_speed(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
let (view, cx) = cx.add_window_view(|_, _| Pacing { speed: 1.0 });
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
tab_to(1, cx);
|
||||
press("enter", cx);
|
||||
for _ in 0..4 {
|
||||
press("down", cx);
|
||||
}
|
||||
press("enter", cx);
|
||||
let speed = view.read_with(cx, |view, _| view.speed);
|
||||
assert_eq!(speed, 1.5, "it opens on the first speed");
|
||||
}
|
||||
|
||||
/// A transport and what it was told.
|
||||
#[derive(Default)]
|
||||
struct Steering {
|
||||
playing: bool,
|
||||
shuffle: bool,
|
||||
repeat: Option<Repeat>,
|
||||
steps: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl Render for Steering {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (play, back, forward, shuffle, repeat) = (
|
||||
cx.entity(),
|
||||
cx.entity(),
|
||||
cx.entity(),
|
||||
cx.entity(),
|
||||
cx.entity(),
|
||||
);
|
||||
let now = self.repeat.unwrap_or(Repeat::Off);
|
||||
div().w(px(320.0)).child(
|
||||
MediaControls::new("transport", self.playing)
|
||||
.on_play(move |on, _, cx| play.update(cx, |view, _| view.playing = on))
|
||||
.on_back(move |_, cx| back.update(cx, |view, _| view.steps.push("back")))
|
||||
.on_forward(move |_, cx| forward.update(cx, |view, _| view.steps.push("forward")))
|
||||
.shuffle(self.shuffle, move |on, _, cx| {
|
||||
shuffle.update(cx, |view, _| view.shuffle = on)
|
||||
})
|
||||
.repeat(now, move |next, _, cx| {
|
||||
repeat.update(cx, |view, cx| {
|
||||
view.repeat = Some(next);
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn each_transport_button_says_what_it_does(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
let (view, cx) = cx.add_window_view(|_, _| Steering::default());
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
for nth in 1..=5 {
|
||||
tab_to(nth, cx);
|
||||
press("space", cx);
|
||||
}
|
||||
let heard = view.read_with(cx, |view, _| {
|
||||
(view.shuffle, view.playing, view.steps.clone(), view.repeat)
|
||||
});
|
||||
assert_eq!(
|
||||
heard,
|
||||
(true, true, vec!["back", "forward"], Some(Repeat::All))
|
||||
);
|
||||
press("space", cx);
|
||||
press("space", cx);
|
||||
assert_eq!(view.read_with(cx, |view, _| view.repeat), Some(Repeat::Off));
|
||||
}
|
||||
|
||||
fn track(key: &str, title: &str) -> Track {
|
||||
Track {
|
||||
key: SharedString::from(key.to_string()),
|
||||
title: SharedString::from(title.to_string()),
|
||||
artist: "Aster Quartet".into(),
|
||||
length: SECOND * 200,
|
||||
}
|
||||
}
|
||||
|
||||
/// A playlist and the songs it was told to play.
|
||||
struct Queueing {
|
||||
played: Vec<SharedString>,
|
||||
}
|
||||
|
||||
impl Render for Queueing {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let owner = cx.entity();
|
||||
div().w(px(320.0)).child(
|
||||
Playlist::new(
|
||||
"list",
|
||||
[track("a", "Tall Windows"), track("b", "Stone Stair")],
|
||||
)
|
||||
.current("a", true)
|
||||
.on_play(move |key, _, cx| owner.update(cx, |view, _| view.played.push(key.clone()))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn down_and_enter_play_the_next_song(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
let (view, cx) = cx.add_window_view(|_, _| Queueing { played: Vec::new() });
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
tab_to(1, cx);
|
||||
press("down", cx);
|
||||
assert!(
|
||||
view.read_with(cx, |view, _| view.played.is_empty()),
|
||||
"a move plays nothing"
|
||||
);
|
||||
press("enter", cx);
|
||||
assert_eq!(view.read_with(cx, |view, _| view.played.clone()), ["b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "no track c")]
|
||||
fn the_song_under_way_is_one_of_the_list() {
|
||||
let _ = Playlist::new("list", [track("a", "Tall Windows")]).current("c", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "track a twice")]
|
||||
fn a_song_is_listed_once() {
|
||||
let _ = Playlist::new("list", [track("a", "One"), track("a", "Two")]);
|
||||
}
|
||||
|
||||
/// An audio player and where it was sent.
|
||||
struct Hearing {
|
||||
at: Duration,
|
||||
}
|
||||
|
||||
impl Render for Hearing {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let owner = cx.entity();
|
||||
div().w(px(280.0)).child(
|
||||
AudioPlayer::new(
|
||||
"song",
|
||||
"Tall Windows",
|
||||
"Aster Quartet",
|
||||
SECOND * 200,
|
||||
self.at,
|
||||
)
|
||||
.on_seek(move |time, _, cx| {
|
||||
owner.update(cx, |view, cx| {
|
||||
view.at = time;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_on_the_players_track_seeks(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
let (view, cx) = cx.add_window_view(|_, _| Hearing { at: SECOND * 10 });
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
let half = across("scrubber-rail", 0.5, cx);
|
||||
cx.simulate_click(half, Modifiers::none());
|
||||
settle(cx);
|
||||
let at = view.read_with(cx, |view, _| view.at);
|
||||
assert!(at.abs_diff(SECOND * 100) < SECOND * 2, "{at:?}");
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use gpui::{
|
||||
|
||||
use crate::{primitives::FocusNext, theme::Theme};
|
||||
|
||||
mod audio;
|
||||
mod edit;
|
||||
mod images;
|
||||
mod player;
|
||||
|
||||
@@ -258,7 +258,7 @@ fn the_first_move_onto_a_resting_bar_shows_the_tip(cx: &mut TestAppContext) {
|
||||
let half = across("scrubber-rail", 0.5, cx);
|
||||
cx.simulate_mouse_move(half, None, Modifiers::none());
|
||||
settle(cx);
|
||||
assert!(cx.debug_bounds("scrubber-tip").is_some());
|
||||
assert!(cx.debug_bounds("time-tip").is_some());
|
||||
}
|
||||
|
||||
/// A playing player whose only control is its speed.
|
||||
@@ -294,3 +294,52 @@ fn the_bar_stays_up_while_a_button_in_it_has_focus(cx: &mut TestAppContext) {
|
||||
settle(cx);
|
||||
assert!(cx.debug_bounds("video-controls-resting").is_none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_focused_stage_lets_the_bar_rest(cx: &mut TestAppContext) {
|
||||
let (view, cx) = watching(cx);
|
||||
view.update(cx, |view, cx| {
|
||||
view.playing = true;
|
||||
cx.notify();
|
||||
});
|
||||
let frame = cx.debug_bounds("video-player").expect("the player draws");
|
||||
cx.simulate_mouse_move(frame.center(), None, Modifiers::none());
|
||||
settle(cx);
|
||||
cx.executor().advance_clock(Duration::from_millis(2600));
|
||||
settle(cx);
|
||||
assert!(cx.debug_bounds("video-controls-resting").is_some());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_on_a_resting_bar_wakes_it_before_a_button_runs(cx: &mut TestAppContext) {
|
||||
let (view, cx) = watching(cx);
|
||||
let frame = cx.debug_bounds("video-player").expect("the player draws");
|
||||
rest(frame.origin + point(px(8.0), px(8.0)), &view, cx);
|
||||
let play = cx.debug_bounds("media-button play").expect("a play button");
|
||||
cx.simulate_click(play.center(), Modifiers::none());
|
||||
settle(cx);
|
||||
let plays = view.read_with(cx, |view, _| view.plays.clone());
|
||||
assert!(plays.is_empty(), "the press woke it: {plays:?}");
|
||||
cx.simulate_click(play.center(), Modifiers::none());
|
||||
settle(cx);
|
||||
assert_eq!(view.read_with(cx, |view, _| view.plays.clone()), [false]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_key_wakes_a_resting_bar(cx: &mut TestAppContext) {
|
||||
let (view, cx) = watching(cx);
|
||||
view.update(cx, |view, cx| {
|
||||
view.playing = true;
|
||||
cx.notify();
|
||||
});
|
||||
let frame = cx.debug_bounds("video-player").expect("the player draws");
|
||||
cx.simulate_mouse_move(frame.center(), None, Modifiers::none());
|
||||
settle(cx);
|
||||
cx.executor().advance_clock(Duration::from_millis(2600));
|
||||
settle(cx);
|
||||
press("l", cx);
|
||||
let play = cx.debug_bounds("media-button play").expect("a play button");
|
||||
cx.simulate_click(play.center(), Modifiers::none());
|
||||
settle(cx);
|
||||
assert_eq!(view.read_with(cx, |view, _| view.plays.clone()), [false]);
|
||||
}
|
||||
@@ -76,13 +76,11 @@ fn the_arrows_step_five_seconds_and_end_goes_to_the_end(cx: &mut TestAppContext)
|
||||
#[gpui::test]
|
||||
fn the_pointer_shows_its_time_over_the_track(cx: &mut TestAppContext) {
|
||||
let (_, cx) = scrubbing(cx);
|
||||
assert!(cx.debug_bounds("scrubber-tip").is_none());
|
||||
assert!(cx.debug_bounds("time-tip").is_none());
|
||||
let half = across("scrubber-rail", 0.5, cx);
|
||||
cx.simulate_mouse_move(half, None, Modifiers::none());
|
||||
settle(cx);
|
||||
let tip = cx
|
||||
.debug_bounds("scrubber-tip")
|
||||
.expect("a tip over the track");
|
||||
let tip = cx.debug_bounds("time-tip").expect("a tip over the track");
|
||||
assert!(
|
||||
(tip.center().x - half.x).abs() < px(1.0),
|
||||
"centered on the pointer: {tip:?}"
|
||||
@@ -97,9 +95,7 @@ fn the_tip_stays_over_the_track_at_its_ends(cx: &mut TestAppContext) {
|
||||
cx.simulate_mouse_move(at, None, Modifiers::none());
|
||||
settle(cx);
|
||||
settle(cx);
|
||||
let tip = cx
|
||||
.debug_bounds("scrubber-tip")
|
||||
.expect("a tip over the track");
|
||||
let tip = cx.debug_bounds("time-tip").expect("a tip over the track");
|
||||
let rail = cx.debug_bounds("scrubber-rail").expect("the rail draws");
|
||||
let inside = tip.left() >= rail.left() - px(0.5) && tip.right() <= rail.right() + px(0.5);
|
||||
assert!(inside, "at {share}: {tip:?} over {rail:?}");
|
||||
|
||||
@@ -44,6 +44,7 @@ pub(crate) fn media_button(
|
||||
let theme = cx.theme();
|
||||
let light = theme.colors.on_media;
|
||||
div()
|
||||
.debug_selector(|| format!("media-button {}", id.1))
|
||||
.id(id)
|
||||
.flex()
|
||||
.flex_none()
|
||||
|
||||
@@ -203,6 +203,7 @@ icons! {
|
||||
Moon => "moon",
|
||||
MousePointer2 => "mouse-pointer-2",
|
||||
Move => "move",
|
||||
Music => "music",
|
||||
Network => "network",
|
||||
NotebookPen => "notebook-pen",
|
||||
Package => "package",
|
||||
@@ -231,6 +232,8 @@ icons! {
|
||||
RedoDot => "redo-dot",
|
||||
RefreshCw => "refresh-cw",
|
||||
Regex => "regex",
|
||||
Repeat => "repeat",
|
||||
Repeat1 => "repeat-1",
|
||||
Reply => "reply",
|
||||
Rocket => "rocket",
|
||||
RotateCcw => "rotate-ccw",
|
||||
@@ -284,6 +287,8 @@ icons! {
|
||||
Variable => "variable",
|
||||
VideoOff => "video-off",
|
||||
Video => "video",
|
||||
Volume => "volume",
|
||||
Volume1 => "volume-1",
|
||||
Volume2 => "volume-2",
|
||||
VolumeX => "volume-x",
|
||||
Wallet => "wallet",
|
||||
|
||||
@@ -17,6 +17,14 @@ pub struct MediaSizes {
|
||||
pub strip: Rems,
|
||||
/// A trim's handle, wide.
|
||||
pub trim_handle: Rems,
|
||||
/// A waveform's bar, wide.
|
||||
pub wave_bar: Rems,
|
||||
/// The gap between a waveform's bars.
|
||||
pub wave_gap: Rems,
|
||||
/// A waveform or a spectrum, tall.
|
||||
pub waveform: Rems,
|
||||
/// A song's cover, square.
|
||||
pub artwork: Rems,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
@@ -28,6 +36,10 @@ impl Theme {
|
||||
pin: px_to_rems(20.0),
|
||||
strip: px_to_rems(48.0),
|
||||
trim_handle: px_to_rems(8.0),
|
||||
wave_bar: px_to_rems(3.0),
|
||||
wave_gap: px_to_rems(2.0),
|
||||
waveform: px_to_rems(48.0),
|
||||
artwork: px_to_rems(64.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user