diff --git a/AGENTS.md b/AGENTS.md index 9b51144..89dfde8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. A dialog's card fits the window, a title bar's height clear at each end; its body scrolls and its actions stay. - Controls on a dark layer over pictures are `overlays::media_button`, light icons that dim when they have nowhere to go, for the lightbox and a video player's bar alike. - A component's letter keys step aside while Command or Control is held, so the app's shortcuts reach the app (`media::ImageAnnotator`, `VideoPlayer`, `VideoThumbnailStrip`). -- A tip over a track splits the free width around it by the pointer's share: it stays inside the track and under the pointer, and nothing is measured (`media::Scrubber`). +- A tip over a track splits the free width around it by the pointer's share: it stays inside the track and under the pointer, and nothing is measured (`media::scrubber::time_tip`, for the scrubber and the waveform). - `primitives::Severity` names info, success, warning and danger with their colors and icons, for alerts and feedback alike. - Rows that move are keyed. `motion::Flip` places them absolutely from last frame's heights, so no frame shows a row at its new place before it glides there, and drives the glide from elapsed time: an animation id that changes per move would rebuild the row's keyed state. `Reorder` holds a row by key, drags with an empty ghost and moves the held row itself. - A marker that slides between items (segment thumb, tab line) measures them with `motion::slide` and eases with `glide`. @@ -126,6 +126,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A mode that leaves on Escape holds its own focus through `primitives::hold_focus`: turned on while focus is elsewhere, it takes focus so Escape reaches it, and `hand_back` returns it (`documents::ZenMode`, `collab::FollowMode`). - A chat holds at its newest message through gpui's `list` with `ListAlignment::Bottom`: it sticks while scrolled to the end, and `scroll_to` one past the last item sticks it again; that call sends no scroll event, so the owner clears its own away state (`chat::MessageList`). - Anything pressable is a Tab stop: a row, card or toggle takes `tab_index(0)` and `focus_ring`, with a 1px border that stays transparent where it shows none; gpui presses it on the release of Enter or Space. +- A list of pressable rows goes through `lists::SelectableList`, whose cursor reaches them by key: Up and Down move, Enter or a double press activates (`media::Playlist`). A bare `ListItem` press is the pointer's only. - A row that holds text beside fixed parts lets the text give way: the text takes `flex_1().min_w_0()` and an `Ellipsis`; counts, icons, dates and chevrons take `flex_none`. - A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up. - A callback field holds a named `type` alias; clippy's type_complexity rejects `Option>`. A chapter shares one alias per shape. diff --git a/TASKS.md b/TASKS.md index 6d65a07..d4170ab 100644 --- a/TASKS.md +++ b/TASKS.md @@ -91,7 +91,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T25f Generative · Data and prompts - [x] T26a Media · Images - [x] T26b Media · Crop and annotate -- [ ] T26c Media · Video +- [x] T26c Media · Video - [ ] T26d Media · Audio and controls - [ ] T26e Media · Capture and devices - [ ] T27 Files @@ -313,3 +313,4 @@ Per-component lines live in `tasks/`. Tags there: | T26b | 2 | PASS | | | T26c | 1 | FAIL | the caption faded with the controls; Space on a focused bar button acted twice; Command letters fired the player and the strip; a long caption, cue words and the scrubber's tip ran past their boxes at 280px | | T26c | 2 | FAIL | the bar's fade restarted an animation id per rest and wake, which rebuilt the scrubber's drag, hover and focus under it | +| T26c | 3 | PASS | | diff --git a/assets/icons/music.svg b/assets/icons/music.svg new file mode 100644 index 0000000..95d1b5d --- /dev/null +++ b/assets/icons/music.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/repeat-1.svg b/assets/icons/repeat-1.svg new file mode 100644 index 0000000..8ecc4cd --- /dev/null +++ b/assets/icons/repeat-1.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/repeat.svg b/assets/icons/repeat.svg new file mode 100644 index 0000000..bbea669 --- /dev/null +++ b/assets/icons/repeat.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/volume-1.svg b/assets/icons/volume-1.svg new file mode 100644 index 0000000..6212643 --- /dev/null +++ b/assets/icons/volume-1.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/volume.svg b/assets/icons/volume.svg new file mode 100644 index 0000000..c1663d4 --- /dev/null +++ b/assets/icons/volume.svg @@ -0,0 +1,15 @@ + + + + diff --git a/examples/gallery/pages/media/audio.rs b/examples/gallery/pages/media/audio.rs new file mode 100644 index 0000000..33f1e44 --- /dev/null +++ b/examples/gallery/pages/media/audio.rs @@ -0,0 +1,303 @@ +use std::{path::Path, time::Duration}; + +use ely_gpui_component::media::{ + AudioPlayer, AudioSpectrum, AudioWaveform, MediaControls, PlaybackSpeedControl, Playlist, + Repeat, Track, VolumeControl, +}; +use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{keep, noise, section}, +}; + +const COVER: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/dunes-square.jpg" +); +/// How often the demo's clock moves while a song plays. +const TICK: Duration = Duration::from_millis(100); +const SONGS: [(&str, &str, &str, u64); 5] = [ + ("tall-windows", "Tall Windows", "Aster Quartet", 222), + ("stone-stair", "Stone Stair", "Aster Quartet", 245), + ("olive-court", "Olive Court", "Lumen Trio", 178), + ("dune-hours", "Dune Hours", "Lumen Trio", 311), + ("low-sun", "Low Sun", "Marram", 207), +]; + +/// The demo's listening: the song under way and where, how it plays, and its volume. +#[derive(Clone)] +struct Listening { + song: usize, + at: Duration, + playing: bool, + ticking: bool, + speed: f32, + shuffle: bool, + repeat: Repeat, + level: f32, + muted: bool, +} + +impl Listening { + fn length(&self) -> Duration { + Duration::from_secs(SONGS[self.song].3) + } + + /// Moves `by` songs along the list, from the start of the song. + fn skip(&mut self, by: isize) { + self.song = (self.song as isize + by).rem_euclid(SONGS.len() as isize) as usize; + self.at = Duration::ZERO; + } +} + +fn tracks() -> Vec { + SONGS + .iter() + .map(|(key, title, artist, seconds)| Track { + key: SharedString::from(*key), + title: SharedString::from(*title), + artist: SharedString::from(*artist), + length: Duration::from_secs(*seconds), + }) + .collect() +} + +/// A song's peaks, the same each run: sections that swell and fall, faded at both ends. +fn peaks(song: usize) -> Vec { + let mut next = noise(song as u64 + 7); + let count = 180; + (0..count) + .map(|ix| { + let fade = (ix.min(count - 1 - ix) as f32 / 10.0).min(1.0); + let swell = 0.55 + 0.45 * (ix as f32 / 23.0 + song as f32).sin(); + (fade * swell * (0.35 + 0.65 * next() as f32)).clamp(0.0, 1.0) + }) + .collect() +} + +/// Levels by pitch at a moment of the song: lows louder, each band beating at its own rate. +fn levels(at: Duration) -> Vec { + let time = at.as_secs_f32(); + (0..40) + .map(|band| { + let band = band as f32; + let beat = (time * 2.3 + band * 0.7).sin() * (time * 0.9 + band * 0.31).cos(); + ((1.0 - band / 52.0) * (0.3 + 0.35 * (1.0 + beat))).clamp(0.0, 1.0) + }) + .collect() +} + +/// Runs the demo's clock while a song plays, one loop at a time; at the end the next song starts, or the same one on repeat. +fn tick(state: &Entity, window: &mut Window, cx: &mut App) { + if state.update(cx, |now, _| std::mem::replace(&mut now.ticking, true)) { + return; + } + let state = state.clone(); + window + .spawn(cx, async move |cx| { + loop { + cx.background_executor().timer(TICK).await; + let going = state.update(cx, |now, cx| { + now.at = (now.at + TICK.mul_f32(now.speed)).min(now.length()); + if now.at == now.length() { + match now.repeat { + Repeat::One => now.at = Duration::ZERO, + Repeat::All => now.skip(1), + Repeat::Off if now.song + 1 < SONGS.len() => now.skip(1), + Repeat::Off => now.playing = false, + } + } + now.ticking = now.playing; + cx.notify(); + now.playing + })?; + if !going { + return anyhow::Ok(()); + } + } + }) + .detach_and_log_err(cx); +} + +fn state(window: &mut Window, cx: &mut App) -> Entity { + keep( + "media-audio", + || Listening { + song: 0, + at: Duration::from_secs(64), + playing: false, + ticking: false, + speed: 1.0, + shuffle: false, + repeat: Repeat::All, + level: 0.7, + muted: false, + }, + window, + cx, + ) +} + +/// Changes the demo's listening and redraws. +fn change(state: &Entity, cx: &mut App, edit: impl FnOnce(&mut Listening)) { + state.update(cx, |now, cx| { + edit(now); + cx.notify(); + }); +} + +fn controls(id: &'static str, state: &Entity, now: &Listening) -> MediaControls { + let (play, back, forward, shuffle, repeat) = ( + state.clone(), + state.clone(), + state.clone(), + state.clone(), + state.clone(), + ); + MediaControls::new(id, now.playing) + .on_play(move |on, window, cx| { + change(&play, cx, |now| now.playing = on); + if on { + tick(&play, window, cx); + } + }) + .on_back(move |_, cx| change(&back, cx, |now| now.skip(-1))) + .on_forward(move |_, cx| change(&forward, cx, |now| now.skip(1))) + .shuffle(now.shuffle, move |on, _, cx| { + change(&shuffle, cx, |now| now.shuffle = on) + }) + .repeat(now.repeat, move |next, _, cx| { + change(&repeat, cx, |now| now.repeat = next) + }) +} + +fn volume(id: &'static str, state: &Entity, now: &Listening) -> VolumeControl { + let (level, mute) = (state.clone(), state.clone()); + VolumeControl::new(id, now.level, now.muted) + .on_level(move |to, _, cx| change(&level, cx, |now| now.level = to as f32)) + .on_mute(move |on, _, cx| change(&mute, cx, |now| now.muted = on)) +} + +pub fn player(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = state(window, cx); + let now = state.read(cx).clone(); + let (title, artist) = (SONGS[now.song].1, SONGS[now.song].2); + let seek = state.clone(); + let player = AudioPlayer::new("media-audio", title, artist, now.length(), now.at) + .artwork(Path::new(COVER)) + .controls(controls("media-audio-transport", &state, &now)) + .volume(volume("media-audio-volume", &state, &now)) + .on_seek(move |at, _, cx| change(&seek, cx, |now| now.at = at)); + section( + "AudioPlayer", + "A song the host plays: its cover, the time played and left along a scrubber, the transport and the volume.", + cx, + ) + .child(probe("media-audio", div().w(px(360.)).child(player))) +} + +pub fn waveform(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = state(window, cx); + let now = state.read(cx).clone(); + let seek = state.clone(); + section( + "AudioWaveform", + "A sound's peaks, dark as far as it has played. A press or a drag seeks, the time under the pointer shows above it, and with focus the arrows step five seconds.", + cx, + ) + .child(probe( + "media-waveform", + div().w(px(640.)).pt_6().child( + AudioWaveform::new("media-waveform", peaks(now.song), now.length(), now.at) + .on_seek(move |at, _, cx| change(&seek, cx, |now| now.at = at)), + ), + )) +} + +pub fn spectrum(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let at = state(window, cx).read(cx).at; + section( + "AudioSpectrum / Visualizer", + "Sound by pitch, low to high. The host sends new levels as it plays; they hold while it pauses.", + cx, + ) + .child(probe( + "media-spectrum", + div().w(px(360.)).child(AudioSpectrum::new(levels(at))), + )) +} + +pub fn volume_control(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = state(window, cx); + let now = state.read(cx).clone(); + section( + "VolumeControl", + "A speaker that mutes, drawn by the level, beside a slider. Muted, the slider rests at zero; moving it unmutes.", + cx, + ) + .child(probe( + "media-volume", + div().w(px(240.)).child(volume("media-volume", &state, &now)), + )) +} + +pub fn speed(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = state(window, cx); + let now = state.read(cx).speed; + let pace = state.clone(); + section( + "PlaybackSpeedControl", + "How fast it plays, from half to double, chosen from a menu.", + cx, + ) + .child(probe( + "media-speed", + div().child( + PlaybackSpeedControl::new("media-speed", now) + .on_change(move |to, _, cx| change(&pace, cx, |now| now.speed = to)), + ), + )) +} + +pub fn transport(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = state(window, cx); + let now = state.read(cx).clone(); + section( + "MediaControls", + "Shuffle, back, play, forward and repeat. Shuffle and repeat stay lit while on; repeat steps from all to one to off.", + cx, + ) + .child(probe( + "media-transport", + div().w(px(360.)).child(controls("media-transport", &state, &now)), + )) +} + +pub fn playlist(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = state(window, cx); + let now = state.read(cx).clone(); + let pick = state.clone(); + section( + "Playlist", + "Songs in order. The one under way is lit, its bars moving while it plays; Enter or a double press plays another.", + cx, + ) + .child(probe( + "media-playlist", + div().w(px(420.)).child( + Playlist::new("media-playlist", tracks()) + .current(SONGS[now.song].0, now.playing) + .on_play(move |key, window, cx| { + let song = SONGS + .iter() + .position(|(each, ..)| *each == key.as_ref()) + .expect("a listed song"); + change(&pick, cx, |now| { + (now.song, now.at, now.playing) = (song, Duration::ZERO, true) + }); + tick(&pick, window, cx); + }), + ), + )) +} diff --git a/examples/gallery/pages/media/mod.rs b/examples/gallery/pages/media/mod.rs index 5a15133..25b94ec 100644 --- a/examples/gallery/pages/media/mod.rs +++ b/examples/gallery/pages/media/mod.rs @@ -1,3 +1,4 @@ +mod audio; mod edit; mod images; mod video; @@ -68,6 +69,26 @@ const SCRIPT: &[Step] = &[ Step::Key("enter"), Step::Wait(300), Step::Shot("retimed"), + Step::HoverAt("media-waveform", 400.0, 48.0), + Step::Wait(200), + Step::Shot("wave-tip"), + Step::DownAt("media-speed", 30.0, 16.0), + Step::UpAt("media-speed", 30.0, 16.0), + Step::Wait(300), + Step::Shot("speeds"), + Step::Key("escape"), + Step::DownAt("media-volume", 14.0, 14.0), + Step::UpAt("media-volume", 14.0, 14.0), + Step::Wait(200), + Step::Shot("muted"), + Step::DownAt("media-playlist", 200.0, 124.0), + Step::UpAt("media-playlist", 200.0, 124.0), + Step::Key("enter"), + Step::Wait(300), + Step::Shot("played"), + Step::DownAt("media-transport", 180.0, 20.0), + Step::UpAt("media-transport", 180.0, 20.0), + Step::Wait(300), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { @@ -81,5 +102,12 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(video::scrubber(window, cx)) .child(video::strip(window, cx)) .child(video::subtitles(window, cx)) + .child(audio::player(window, cx)) + .child(audio::waveform(window, cx)) + .child(audio::spectrum(window, cx)) + .child(audio::volume_control(window, cx)) + .child(audio::speed(window, cx)) + .child(audio::transport(window, cx)) + .child(audio::playlist(window, cx)) .into_any_element() } diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 80565dc..c8097b9 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -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; diff --git a/src/media/audio.rs b/src/media/audio.rs new file mode 100644 index 0000000..3f11b02 --- /dev/null +++ b/src/media/audio.rs @@ -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 { + 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, + hover: Option, +} + +/// 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>, + length: Duration, + at: Duration, + on_seek: Option, +} + +impl AudioWaveform { + pub fn new( + id: impl Into, + peaks: impl Into>, + length: Duration, + at: Duration, + ) -> Self { + let peaks: Vec = 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, pointer: Point| { + (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, 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, +} + +impl AudioSpectrum { + pub fn new(levels: impl Into>) -> Self { + let levels: Vec = 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]); + } +} diff --git a/src/media/controls.rs b/src/media/controls.rs new file mode 100644 index 0000000..1de81bb --- /dev/null +++ b/src/media/controls.rs @@ -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; +type OnRepeat = Rc; + +/// 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, + on_mute: Option, +} + +impl VolumeControl { + pub fn new(id: impl Into, 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, +} + +impl PlaybackSpeedControl { + pub fn new(id: impl Into, 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, + on_back: Option, + on_forward: Option, + on_shuffle: Option, + on_repeat: Option, +} + +impl MediaControls { + pub fn new(id: impl Into, 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.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); + } +} diff --git a/src/media/listen.rs b/src/media/listen.rs new file mode 100644 index 0000000..e6e875f --- /dev/null +++ b/src/media/listen.rs @@ -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, + current: Option, + playing: bool, + on_play: Option, +} + +impl Playlist { + pub fn new(id: impl Into, tracks: impl IntoIterator) -> Self { + let tracks: Vec = 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, 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, + controls: Option, + volume: Option, + on_seek: Option, +} + +impl AudioPlayer { + pub fn new( + id: impl Into, + title: impl Into, + artist: impl Into, + 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) -> 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) + } +} diff --git a/src/media/mod.rs b/src/media/mod.rs index 8750695..92fe959 100644 --- a/src/media/mod.rs +++ b/src/media/mod.rs @@ -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; diff --git a/src/media/player.rs b/src/media/player.rs index 1a76bc4..1d05440 100644 --- a/src/media/player.rs +++ b/src/media/player.rs @@ -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, 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); }) } } diff --git a/src/media/scrubber.rs b/src/media/scrubber.rs index 7ef9e83..eba2132 100644 --- a/src/media/scrubber.rs +++ b/src/media/scrubber.rs @@ -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 = 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() diff --git a/src/media/tests/audio.rs b/src/media/tests/audio.rs new file mode 100644 index 0000000..93c7eed --- /dev/null +++ b/src/media/tests/audio.rs @@ -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, +} + +impl Render for Waving { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let owner = cx.entity(); + let peaks: Vec = (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, &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, +} + +impl Render for Turning { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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) -> 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, + steps: Vec<&'static str>, +} + +impl Render for Steering { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, +} + +impl Render for Queueing { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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) -> 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:?}"); +} diff --git a/src/media/tests/mod.rs b/src/media/tests/mod.rs index 3c7a917..3ead71d 100644 --- a/src/media/tests/mod.rs +++ b/src/media/tests/mod.rs @@ -6,6 +6,7 @@ use gpui::{ use crate::{primitives::FocusNext, theme::Theme}; +mod audio; mod edit; mod images; mod player; diff --git a/src/media/tests/player.rs b/src/media/tests/player.rs index 4421abf..13131d4 100644 --- a/src/media/tests/player.rs +++ b/src/media/tests/player.rs @@ -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]); +} diff --git a/src/media/tests/video.rs b/src/media/tests/video.rs index 31e6fcd..d059b5b 100644 --- a/src/media/tests/video.rs +++ b/src/media/tests/video.rs @@ -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:?}"); diff --git a/src/overlays/lightbox.rs b/src/overlays/lightbox.rs index 42133f9..ec56a10 100644 --- a/src/overlays/lightbox.rs +++ b/src/overlays/lightbox.rs @@ -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() diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index d5ec07b..927162d 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -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", diff --git a/src/theme/media.rs b/src/theme/media.rs index 4757665..54fc822 100644 --- a/src/theme/media.rs +++ b/src/theme/media.rs @@ -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), } } } diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 5a36a9d..687b7de 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -271,13 +271,13 @@ ## T26d Media · Audio and controls -- [ ] AudioPlayer — host playback -- [ ] AudioWaveform -- [ ] AudioSpectrum / Visualizer -- [ ] VolumeControl -- [ ] PlaybackSpeedControl -- [ ] MediaControls -- [ ] Playlist +- [x] AudioPlayer — host playback +- [x] AudioWaveform +- [x] AudioSpectrum / Visualizer +- [x] VolumeControl +- [x] PlaybackSpeedControl +- [x] MediaControls +- [x] Playlist ## T26e Media · Capture and devices