From 04f88812dc0f4321a3f2f63e59560570dc86f3db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 18:32:45 -0400 Subject: [PATCH] =?UTF-8?q?Media=20=C2=B7=20Audio=20and=20controls:=20play?= =?UTF-8?q?er,=20waveform,=20spectrum,=20volume,=20speed,=20transport=20an?= =?UTF-8?q?d=20playlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 3 +- TASKS.md | 3 +- assets/icons/music.svg | 17 ++ assets/icons/repeat-1.svg | 19 ++ assets/icons/repeat.svg | 18 ++ assets/icons/volume-1.svg | 16 ++ assets/icons/volume.svg | 15 ++ examples/gallery/pages/media/audio.rs | 303 ++++++++++++++++++++++ examples/gallery/pages/media/mod.rs | 28 ++ src/forms/mod.rs | 3 +- src/media/audio.rs | 253 ++++++++++++++++++ src/media/controls.rs | 330 ++++++++++++++++++++++++ src/media/listen.rs | 292 +++++++++++++++++++++ src/media/mod.rs | 6 + src/media/player.rs | 86 ++++--- src/media/scrubber.rs | 63 +++-- src/media/tests/audio.rs | 356 ++++++++++++++++++++++++++ src/media/tests/mod.rs | 1 + src/media/tests/player.rs | 51 +++- src/media/tests/video.rs | 10 +- src/overlays/lightbox.rs | 1 + src/primitives/icon.rs | 5 + src/theme/media.rs | 12 + tasks/ch21-30.md | 14 +- 24 files changed, 1824 insertions(+), 81 deletions(-) create mode 100644 assets/icons/music.svg create mode 100644 assets/icons/repeat-1.svg create mode 100644 assets/icons/repeat.svg create mode 100644 assets/icons/volume-1.svg create mode 100644 assets/icons/volume.svg create mode 100644 examples/gallery/pages/media/audio.rs create mode 100644 src/media/audio.rs create mode 100644 src/media/controls.rs create mode 100644 src/media/listen.rs create mode 100644 src/media/tests/audio.rs 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