diff --git a/audio_player.go b/audio_player.go new file mode 100644 index 0000000..144976a --- /dev/null +++ b/audio_player.go @@ -0,0 +1,84 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// AudioState is what the caller's playback reports. +type AudioState struct { + Title string + Position time.Duration + Duration time.Duration + Playing bool +} + +// AudioPlayerOptions are the callbacks a player shell reports to. +type AudioPlayerOptions struct { + OnPlay, OnPause func() + OnSeek func(time.Duration) + Disabled bool +} + +// AudioPlayer is a play, pause and seek shell. +func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) *ui.Element { + if opts.OnPlay == nil || opts.OnPause == nil || opts.OnSeek == nil { + panic("mujica: AudioPlayer needs OnPlay, OnPause and OnSeek") + } + k := Tokens(c) + u := Density(c).Unit() + row := ui.Row(c).Gap(u*3).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Padding(u*2, u*3). + Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleGroup). + Disabled(opts.Disabled) + if st.Title != "" { + row.Label(st.Title) + } + row.Children(func() { + mediaPlayToggle(c, st.Playing, opts.OnPlay, opts.OnPause) + ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() { + if st.Title != "" { + ui.Text(c, st.Title).SingleLine() + } + if st.Duration > 0 { + pos := min(max(0, st.Position), st.Duration) + if VideoScrubber(c, &pos, VideoScrubberOptions{Duration: st.Duration, Label: st.Title}).Changed() { + opts.OnSeek(pos) + } + } + }) + ui.Text(c, mediaClock(st.Position)+" / "+mediaClock(st.Duration)).FontSize(FontSize(c, theme.CaptionSize)). + TextColor(k.TextMuted).FontFeatures("tnum").SingleLine() + }) + // Space plays or pauses wherever the focus is inside. + if !row.IsDisabled() && row.Shortcut(0, ui.KeySpace) { + mediaToggle(st.Playing, opts.OnPlay, opts.OnPause) + } + return row +} + +// mediaPlayToggle is the play/pause button; it calls the matching callback. +func mediaPlayToggle(c *ui.Context, playing bool, onPlay, onPause func()) *ui.Element { + icon, label := mediaPlay, msg(c, "media_controls.play") + if playing { + icon, label = mediaPause, msg(c, "media_controls.pause") + } + b := IconButton(c, icon, label, IconButtonOptions{}) + if b.Clicked() && !b.IsDisabled() { + mediaToggle(playing, onPlay, onPause) + } + return b +} + +func mediaToggle(playing bool, onPlay, onPause func()) { + if playing { + slog.Debug("mujica: media pause") + onPause() + return + } + slog.Debug("mujica: media play") + onPlay() +} diff --git a/audio_player_test.go b/audio_player_test.go new file mode 100644 index 0000000..1f90d57 --- /dev/null +++ b/audio_player_test.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type mediaCalls struct { + play, pause int + seeks []time.Duration +} + +func (m *mediaCalls) opts() AudioPlayerOptions { + return AudioPlayerOptions{OnPlay: func() { m.play++ }, OnPause: func() { m.pause++ }, OnSeek: func(d time.Duration) { m.seeks = append(m.seeks, d) }} +} + +func audioTester(st *AudioState, m *mediaCalls, disabled bool) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := m.opts() + o.Disabled = disabled + ui.Column(c).Padding(20).Children(func() { AudioPlayer(c, *st, o) }) + }, 600, 200) +} + +func TestAudioPlayerPlayPause(t *testing.T) { + st := AudioState{Title: "Ave Mujica", Position: 30 * time.Second, Duration: 3 * time.Minute} + var m mediaCalls + tt := audioTester(&st, &m, false) + if err := tt.Click("Play"); err != nil { + t.Fatal(err) + } + st.Playing = true + tt.Frame() + if err := tt.Click("Pause"); err != nil { + t.Fatal(err) + } + if m.play != 1 || m.pause != 1 { + t.Fatalf("play %d pause %d", m.play, m.pause) + } + if !tt.HasText("0:30 / 3:00") { + t.Fatalf("texts %v", tt.Texts()) + } +} + +func TestAudioPlayerKeyboard(t *testing.T) { + st := AudioState{Title: "Ave Mujica", Position: 30 * time.Second, Duration: 3 * time.Minute} + var m mediaCalls + tt := audioTester(&st, &m, false) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if len(m.seeks) != 1 || m.seeks[0] != 35*time.Second { + t.Fatalf("seeks %v", m.seeks) + } + tt.Key(0, ui.KeySpace) + if m.play != 1 { + t.Fatalf("Space on the timeline: play %d", m.play) + } +} + +func TestAudioPlayerDisabled(t *testing.T) { + st := AudioState{Title: "Ave Mujica", Duration: time.Minute} + var m mediaCalls + tt := audioTester(&st, &m, true) + _ = tt.Click("Play") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyRight) + if m.play != 0 || len(m.seeks) != 0 { + t.Fatalf("disabled player reported %+v", m) + } +} + +func TestAudioPlayerNeedsCallbacks(t *testing.T) { + mediaPanics(t, "no callbacks", func(c *ui.Context) { AudioPlayer(c, AudioState{}, AudioPlayerOptions{}) }) +} + +func TestAudioPlayerSnapshot(t *testing.T) { + var m mediaCalls + snapshot(t, "audio_player", 560, 200, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).Children(func() { + AudioPlayer(c, AudioState{Title: "Ave Mujica — Overture", Position: 72 * time.Second, Duration: 4 * time.Minute, Playing: true}, m.opts()) + AudioPlayer(c, AudioState{Title: "Imprisoned XII", Duration: 5 * time.Minute}, m.opts()) + }) + }) +} diff --git a/audio_spectrum.go b/audio_spectrum.go new file mode 100644 index 0000000..13ebd33 --- /dev/null +++ b/audio_spectrum.go @@ -0,0 +1,146 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// AudioSpectrumOptions configure an AudioSpectrum. +type AudioSpectrumOptions struct { + // Height is the chart's height; zero means 120. + Height float32 + // BandLabel names band i in the readout; nil numbers them. + BandLabel func(i int) string + // Status and Error show loading and failure at one size. + Status DataStatus + Error string + // Label names the chart for assistive technology. + Label string +} + +type spectrumKey struct{} + +// AudioSpectrum draws caller bands as bars; it never animates itself. +func AudioSpectrum(c *ui.Context, bands []float32, opts AudioSpectrumOptions) *ui.Element { + for _, b := range bands { + if b < 0 || b > 1 || math.IsNaN(float64(b)) { + panic("mujica: AudioSpectrum band outside 0..1") + } + } + h := opts.Height + if h < 0 { + panic("mujica: AudioSpectrum Height must not be negative") + } + if h == 0 { + h = 120 + } + k := Tokens(c) + label := opts.Label + if label == "" { + label = msg(c, "audio_spectrum.label") + } + e := ui.Box(c).Height(h).MinWidth(120).Radius(theme.ControlRadius).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Focusable().Label(label) + internal.Ring(e, k.Focus, theme.ControlRadius) + if mediaChartState(c, e, opts.Status, opts.Error, len(bands) == 0, "audio_spectrum.empty") { + return e + } + cur := ui.Local(e, spectrumKey{}, func() int { return -1 }) + *cur = min(*cur, len(bands)-1) + name := func(i int) string { + if opts.BandLabel != nil { + return opts.BandLabel(i) + } + return fmt.Sprintf(msg(c, "audio_spectrum.band"), i+1) + } + if moved, ok := chartCursor(e, cur, len(bands)); ok && moved { + c.Announce(spectrumReadout(name(*cur), bands[*cur])) + } + readout := "" + if *cur >= 0 { + readout = spectrumReadout(name(*cur), bands[*cur]) + e.Description(readout) + } + sel, size := *cur, FontSize(c, theme.CaptionSize) + e.Draw(func(p *ui.Painter, r ui.Rect) { + pad := float32(8) + n := float32(len(bands)) + w := (r.W - 2*pad) / n + base := r.Y + r.H - pad + for i, b := range bands { + x := internal.Snap(p, r.X+pad+float32(i)*w) + bh := internal.Snap(p, (r.H-2*pad-16)*b) + col := k.AccentText + if i == sel { + col = k.Text + } + p.Fill(ui.Rect{X: x + 1, Y: base - bh, W: max(1, w-2), H: bh}, col, 0) + } + p.Fill(ui.Rect{X: r.X + pad, Y: base, W: r.W - 2*pad, H: 1}, k.Border, 0) + if sel >= 0 { + x := r.X + pad + float32(sel)*w + p.StrokeDashed(ui.Rect{X: x, Y: r.Y + pad, W: w, H: base - r.Y - pad}, k.TextMuted, 0, 1) + p.Text(r.X+pad, r.Y+4, readout, size, k.Text) + } + }) + return e +} + +func spectrumReadout(name string, v float32) string { + return fmt.Sprintf("%s: %d%%", name, int(math.Round(float64(v*100)))) +} + +// chartCursor moves *cur by arrows, Home and End. +func chartCursor(e *ui.Element, cur *int, n int) (moved, ok bool) { + if e.IsDisabled() || n == 0 { + return false, false + } + old := *cur + switch { + case e.Shortcut(0, ui.KeyRight): + *cur = min(n-1, *cur+1) + case e.Shortcut(0, ui.KeyLeft): + *cur = max(0, *cur-1) + case e.Shortcut(0, ui.KeyHome): + *cur = 0 + case e.Shortcut(0, ui.KeyEnd): + *cur = n - 1 + default: + return false, false + } + return *cur != old, true +} + +// mediaChartState fills e with a loading, error or empty state. +func mediaChartState(c *ui.Context, e *ui.Element, status DataStatus, errText string, empty bool, emptyKey string) bool { + k := Tokens(c) + switch { + case status == DataLoading: + e.Center().Children(func() { Spinner(c, SpinnerOptions{ShowLabel: true}) }) + case status == DataFailed: + if errText == "" { + panic("mujica: a failed media chart needs Error") + } + e.Center().Description(errText).Children(func() { + ui.Row(c).Gap(6).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(16, 16).TextColor(k.Danger) + ui.Text(c, errText).TextColor(k.Danger).MaxLines(2) + }) + }) + case status != DataReady: + panic(fmt.Sprintf("mujica: media chart Status %d is not Ready, Loading or Failed", status)) + case empty: + e.Center().Description(msg(c, emptyKey)).Children(func() { + ui.Text(c, msg(c, emptyKey)).TextColor(k.TextMuted) + }) + default: + return false + } + return true +} diff --git a/audio_spectrum_test.go b/audio_spectrum_test.go new file mode 100644 index 0000000..fb1d21b --- /dev/null +++ b/audio_spectrum_test.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestAudioSpectrumKeyboardReadout(t *testing.T) { + bands := []float32{0.2, 0.9, 0.5} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(300).Children(func() { + AudioSpectrum(c, bands, AudioSpectrumOptions{BandLabel: func(i int) string { return []string{"63 Hz", "1 kHz", "8 kHz"}[i] }}) + }) + }, 400, 200) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnd) + if got := tt.Announcements(); !slices.Equal(got, []string{"63 Hz: 20%", "1 kHz: 90%", "8 kHz: 50%"}) { + t.Fatalf("announced %v", got) + } +} + +func TestAudioSpectrumNeverAnimates(t *testing.T) { + builds := 0 + bands := []float32{0.3, 0.6} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + builds++ + AudioSpectrum(c, bands, AudioSpectrumOptions{}) + }, 300, 200) + start := builds + tt.Frame() + if builds-start != 1 { + t.Fatalf("one frame built %d times: the spectrum asked for more", builds-start) + } +} + +func TestAudioSpectrumStates(t *testing.T) { + var got []ui.Rect + for _, o := range []AudioSpectrumOptions{{}, {Status: DataLoading}, {Status: DataFailed, Error: "Device lost"}} { + var r ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(300).Children(func() { r = AudioSpectrum(c, nil, o).Bounds() }) + }, 400, 200) + tt.Frame() + got = append(got, r) + if o.Status == DataFailed && !tt.HasText("Device lost") { + t.Fatal("error not shown") + } + if o.Status == DataReady && !tt.HasText("No signal") { + t.Fatal("empty state not shown") + } + } + if got[1] != got[0] || got[2] != got[0] { + t.Fatalf("state sizes %v", got) + } + mediaPanics(t, "band over 1", func(c *ui.Context) { AudioSpectrum(c, []float32{2}, AudioSpectrumOptions{}) }) + mediaPanics(t, "failed without error", func(c *ui.Context) { AudioSpectrum(c, nil, AudioSpectrumOptions{Status: DataFailed}) }) +} + +func TestAudioSpectrumSnapshot(t *testing.T) { + bands := make([]float32, 32) + for i := range bands { + bands[i] = float32(0.15 + 0.8*float64((i*37)%29)/29) + } + snapshot(t, "audio_spectrum", 420, 160, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(380).Children(func() { AudioSpectrum(c, bands, AudioSpectrumOptions{}) }) + }) +} diff --git a/audio_waveform.go b/audio_waveform.go new file mode 100644 index 0000000..f22a24c --- /dev/null +++ b/audio_waveform.go @@ -0,0 +1,187 @@ +package mujica + +import ( + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// AudioWaveformOptions configure an AudioWaveform. +type AudioWaveformOptions struct { + // Duration is the audio's length; required when data is ready. + Duration time.Duration + // Position draws the played part; OnSeek reports clicks and arrows. + Position time.Duration + OnSeek func(time.Duration) + // Selection, when set, is chosen by dragging or Shift+arrows. + Selection *MediaRange + // Height is the chart's height; zero means 96. + Height float32 + Status DataStatus + Error string + Label string +} + +// waveformPeaks caches column peaks until data or width change. +type waveformPeaks struct { + first *float32 + n int + cols int + peaks []float32 +} + +type waveformKey struct{} + +type waveformPress struct { + on, dragged bool + x float32 +} + +// waveformBuckets counts peak rebuilds, for tests. +var waveformBuckets int + +// AudioWaveform draws samples with played part and selectable range. +func AudioWaveform(c *ui.Context, samples []float32, opts AudioWaveformOptions) ChoiceResult { + h := opts.Height + if h < 0 || opts.Position < 0 { + panic("mujica: AudioWaveform Height and Position must not be negative") + } + if h == 0 { + h = 96 + } + k := Tokens(c) + label := opts.Label + if label == "" { + label = msg(c, "audio_waveform.label") + } + e := ui.Box(c).Height(h).MinWidth(120).Radius(theme.ControlRadius).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Focusable().Label(label).Cursor(ui.CursorText) + internal.Ring(e, k.Focus, theme.ControlRadius) + v := ChoiceResult{Element: e} + if mediaChartState(c, e, opts.Status, opts.Error, len(samples) == 0, "audio_waveform.empty") { + return v + } + if opts.Duration <= 0 || opts.OnSeek == nil || opts.Position > opts.Duration { + panic("mujica: AudioWaveform needs Duration > 0, OnSeek and Position <= Duration") + } + if s := opts.Selection; s != nil && (s.Start < 0 || s.End < s.Start || s.End > opts.Duration) { + panic("mujica: AudioWaveform Selection outside 0..Duration") + } + r := e.Bounds() + cols := int(r.W) + cache := ui.Local(e, waveformKey{}, func() waveformPeaks { return waveformPeaks{} }) + if cols <= 0 { + // Peaks need the width: lay out first, then build again. + c.AnimationFrame() + } else if cache.first != &samples[0] || cache.n != len(samples) || cache.cols != cols { + *cache = waveformPeaks{&samples[0], len(samples), cols, waveformBucket(samples, cols)} + } + if !e.IsDisabled() { + v.changed = waveformInput(c, e, r, opts) + } + e.Description(mediaClock(opts.Position) + " / " + mediaClock(opts.Duration)) + peaks, pos, sel := cache.peaks, float32(opts.Position)/float32(opts.Duration), opts.Selection + dur := float32(opts.Duration) + e.Draw(func(p *ui.Painter, r ui.Rect) { + mid := r.Y + r.H/2 + amp := r.H/2 - 6 + if sel != nil && sel.End > sel.Start { + x0, x1 := r.X+r.W*float32(sel.Start)/dur, r.X+r.W*float32(sel.End)/dur + p.Fill(ui.Rect{X: x0, Y: r.Y, W: x1 - x0, H: r.H}, k.Selection, 0) + p.Fill(ui.Rect{X: x0, Y: r.Y, W: 1, H: r.H}, k.AccentText, 0) + p.Fill(ui.Rect{X: x1 - 1, Y: r.Y, W: 1, H: r.H}, k.AccentText, 0) + } + played := r.X + r.W*pos + for i, pk := range peaks { + x := r.X + r.W*float32(i)/float32(len(peaks)) + col := k.TextMuted + if x < played { + col = k.AccentText + } + ph := max(1, amp*pk) + p.Fill(ui.Rect{X: x, Y: mid - ph, W: max(1, r.W/float32(len(peaks))-0.5), H: 2 * ph}, col, 0) + } + px := internal.Snap(p, played) + p.Fill(ui.Rect{X: px - 1, Y: r.Y, W: 2, H: r.H}, k.Text, 0) + p.FillPath(internal.Diamond(px, internal.Snap(p, r.Y+5), 8), k.Text) + }) + return v +} + +// waveformBucket returns the largest magnitude in each of cols spans. +func waveformBucket(samples []float32, cols int) []float32 { + waveformBuckets++ + peaks := make([]float32, cols) + for i, s := range samples { + col := i * cols / len(samples) + peaks[col] = max(peaks[col], float32(math.Abs(float64(s)))) + } + return peaks +} + +// waveformInput seeks and selects; true means the selection changed. +func waveformInput(c *ui.Context, e *ui.Element, r ui.Rect, opts AudioWaveformOptions) bool { + at := func(x float32) time.Duration { + f := max(0, min(1, (x-r.X)/max(1, r.W))) + return time.Duration(float64(f) * float64(opts.Duration)).Round(time.Millisecond) + } + seek := func(t time.Duration) { + t = max(0, min(opts.Duration, t)) + slog.Debug("mujica: audio_waveform seek", "pos", t) + opts.OnSeek(t) + } + sel := opts.Selection + old := MediaRange{} + if sel != nil { + old = *sel + } + px, _, _ := e.PointerPosition() + px += r.X + st := ui.Local(e, waveformPress{}, func() waveformPress { return waveformPress{} }) + _, _, dragging := e.Dragged() + switch { + case e.Pressed() && !st.on: + *st = waveformPress{on: true, x: px} + case e.Pressed() && sel != nil && (dragging || st.dragged) && math.Abs(float64(px-st.x)) > 3: + st.dragged = true + a, b := at(st.x), at(px) + *sel = MediaRange{min(a, b), max(a, b)} + case !e.Pressed() && st.on: + if !st.dragged { + seek(at(st.x)) + } + *st = waveformPress{} + } + const step = 5 * time.Second + switch { + case e.Shortcut(0, ui.KeyLeft): + seek(opts.Position - step) + case e.Shortcut(0, ui.KeyRight): + seek(opts.Position + step) + case sel != nil && e.Shortcut(ui.Shift, ui.KeyRight): + *sel = waveformExtend(*sel, opts.Position, step, opts.Duration) + case sel != nil && e.Shortcut(ui.Shift, ui.KeyLeft): + *sel = waveformExtend(*sel, opts.Position, -step, opts.Duration) + case sel != nil && e.Shortcut(0, ui.KeyEscape): + *sel = MediaRange{} + } + changed := sel != nil && *sel != old + if changed { + slog.Debug("mujica: audio_waveform select", "start", sel.Start, "end", sel.End) + } + return changed +} + +// waveformExtend starts a selection at pos or moves its end. +func waveformExtend(s MediaRange, pos, d, dur time.Duration) MediaRange { + if s.End <= s.Start { + s = MediaRange{pos, pos} + } + s.End = max(s.Start, min(dur, s.End+d)) + return s +} diff --git a/audio_waveform_test.go b/audio_waveform_test.go new file mode 100644 index 0000000..9ccb2c0 --- /dev/null +++ b/audio_waveform_test.go @@ -0,0 +1,131 @@ +package mujica + +import ( + "math" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func waveSamples(n int) []float32 { + s := make([]float32, n) + for i := range s { + s[i] = float32(math.Sin(float64(i)/40) * (0.3 + 0.6*math.Abs(math.Sin(float64(i)/3000)))) + } + return s +} + +func waveTester(samples []float32, opts *AudioWaveformOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(400).Children(func() { + if AudioWaveform(c, samples, *opts).Changed() { + *n++ + } + }) + }, 500, 200) +} + +func TestAudioWaveformSeekAndSelect(t *testing.T) { + var seeks []time.Duration + sel := MediaRange{} + opts := AudioWaveformOptions{Duration: 100 * time.Second, Position: 10 * time.Second, Selection: &sel, + OnSeek: func(d time.Duration) { seeks = append(seeks, d) }} + n := 0 + tt := waveTester(waveSamples(5000), &opts, &n) + r, ok := tt.Find("Waveform") + if !ok { + t.Fatal("not named") + } + tt.ClickAt(r.X+r.W/2, r.Y+r.H/2) + if len(seeks) != 1 || seeks[0] < 48*time.Second || seeks[0] > 52*time.Second { + t.Fatalf("click seeks %v", seeks) + } + tt.Press(r.X+r.W*0.2, r.Y+r.H/2) + tt.Move(r.X+r.W*0.3, r.Y+r.H/2) + tt.Move(r.X+r.W*0.4, r.Y+r.H/2) + tt.Release(r.X+r.W*0.4, r.Y+r.H/2) + if sel.Start < 18*time.Second || sel.Start > 22*time.Second || sel.End < 38*time.Second || sel.End > 42*time.Second || len(seeks) != 1 { + t.Fatalf("drag selection %+v, seeks %v", sel, seeks) + } + tt.Key(0, ui.KeyEscape) + tt.Key(ui.Shift, ui.KeyRight) + if sel != (MediaRange{10 * time.Second, 15 * time.Second}) || n < 3 { + t.Fatalf("Shift+Right selection %+v, changes %d", sel, n) + } + tt.Key(0, ui.KeyRight) + if seeks[len(seeks)-1] != 15*time.Second { + t.Fatalf("Right seek %v", seeks) + } +} + +func TestAudioWaveformComputesPeaksOnce(t *testing.T) { + samples := waveSamples(100000) + opts := AudioWaveformOptions{Duration: time.Minute, OnSeek: func(time.Duration) {}} + n := 0 + tt := waveTester(samples, &opts, &n) + before := waveformBuckets + for range 5 { + opts.Position += time.Second + tt.Frame() + } + if got := waveformBuckets - before; got != 0 { + t.Fatalf("peaks rebuilt %d times without new data", got) + } + samples = waveSamples(100000) + tt = waveTester(samples, &opts, &n) + tt.Frame() + if waveformBuckets-before == 0 { + t.Fatal("new data did not rebuild peaks") + } +} + +func TestAudioWaveformStatesKeepSize(t *testing.T) { + var got []ui.Rect + seek := func(time.Duration) {} + for _, o := range []AudioWaveformOptions{{Duration: time.Second, OnSeek: seek}, {Status: DataLoading}, {Status: DataFailed, Error: "Decode failed"}} { + var r ui.Rect + data := waveSamples(100) + if o.Status != DataReady { + data = nil + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(300).Children(func() { r = AudioWaveform(c, data, o).Element.Bounds() }) + }, 400, 200) + tt.Frame() + got = append(got, r) + } + if got[1] != got[0] || got[2] != got[0] { + t.Fatalf("state sizes %v", got) + } +} + +func TestAudioWaveformDisabled(t *testing.T) { + var seeks []time.Duration + opts := AudioWaveformOptions{Duration: time.Minute, OnSeek: func(d time.Duration) { seeks = append(seeks, d) }} + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Width(300).Children(func() { AudioWaveform(c, waveSamples(500), opts) }) + }, 400, 200) + r, _ := tt.Find("Waveform") + tt.ClickAt(r.X+10, r.Y+10) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if len(seeks) != 0 || n != 0 { + t.Fatalf("disabled waveform seeked %v", seeks) + } +} + +func TestAudioWaveformSnapshot(t *testing.T) { + samples := waveSamples(20000) + sel := MediaRange{50 * time.Second, 70 * time.Second} + snapshot(t, "audio_waveform", 460, 150, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(420).Children(func() { + AudioWaveform(c, samples, AudioWaveformOptions{Duration: 2 * time.Minute, Position: 40 * time.Second, + Selection: &sel, OnSeek: func(time.Duration) {}}) + }) + }) +} diff --git a/camera_preview.go b/camera_preview.go new file mode 100644 index 0000000..599365b --- /dev/null +++ b/camera_preview.go @@ -0,0 +1,93 @@ +package mujica + +import ( + "image" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// CameraFrame is the caller's latest camera picture. +type CameraFrame struct { + // Image is the frame; nil shows "No signal". + Image image.Image + // Seq numbers frames; a new number redraws a reused Image. + Seq uint64 +} + +// CameraPreviewOptions configure a CameraPreview. +type CameraPreviewOptions struct { + // Label names the camera for assistive technology. + Label string + Disabled bool +} + +type cameraCache struct { + img image.Image + seq uint64 + mirror bool + bmp *ui.Bitmap +} + +type cameraKey struct{} + +// CameraPreview shows frame at 16:9, mirrored while *mirror. +func CameraPreview(c *ui.Context, frame CameraFrame, mirror *bool, opts CameraPreviewOptions) ChoiceResult { + k := Tokens(c) + old := *mirror + label := opts.Label + if label == "" { + label = msg(c, "camera_preview.label") + } + box := ui.Box(c).FillWidth().AspectRatio(16.0 / 9).Background(videoBlack).Radius(theme.CardRadius).Clip(). + Center().Focusable().Role(ui.RoleImage).Label(label).Disabled(opts.Disabled) + internal.Ring(box, k.Focus, theme.CardRadius) + cache := ui.Local(box, cameraKey{}, func() cameraCache { return cameraCache{} }) + box.Children(func() { + if frame.Image == nil { + box.Description(msg(c, "camera_preview.noSignal")) + ui.Column(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Icon(c, mediaCamera).Size(28, 28).TextColor(ui.Hex("#BCAEAA")) + ui.Text(c, msg(c, "camera_preview.noSignal")).TextColor(ui.Hex("#BCAEAA")) + }) + } else { + // Copies are made once per frame number, not per paint. + if cache.img != frame.Image || cache.seq != frame.Seq || cache.mirror != *mirror || cache.bmp == nil { + *cache = cameraCache{frame.Image, frame.Seq, *mirror, cameraBitmap(frame.Image, *mirror)} + } + ui.Image(c, cache.bmp).Fit(ui.Contain).Fill() + } + ui.Box(c).Absolute().Top(8).Right(8).Background(ui.RGBA(0, 0, 0, 0.5)).Radius(theme.ControlRadius).Children(func() { + b := mediaToggleAction(c, mediaMirror, msg(c, "camera_preview.mirror"), *mirror) + if b.Clicked() && !b.IsDisabled() { + *mirror = !*mirror + } + }) + }) + if !box.IsDisabled() && box.Shortcut(0, ui.KeyM) { + *mirror = !*mirror + } + changed := *mirror != old + if changed { + slog.Debug("mujica: camera_preview mirror", "on", *mirror) + } + return ChoiceResult{Element: box, changed: changed} +} + +// cameraBitmap copies img into a bitmap, flipped when mirror. +func cameraBitmap(img image.Image, mirror bool) *ui.Bitmap { + if !mirror { + return ui.NewBitmap(img) + } + b := img.Bounds() + out := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + out.Set(b.Max.X-1-x, y-b.Min.Y, img.At(x, y)) + } + } + return ui.NewBitmap(out) +} diff --git a/camera_preview_test.go b/camera_preview_test.go new file mode 100644 index 0000000..cde85b9 --- /dev/null +++ b/camera_preview_test.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "image" + "testing" + + "github.com/egoist/mygo/ui" +) + +func cameraTester(frame *CameraFrame, mirror *bool, disabled bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(320).Children(func() { + if CameraPreview(c, *frame, mirror, CameraPreviewOptions{Disabled: disabled}).Changed() { + *n++ + } + }) + }, 400, 260) +} + +func TestCameraPreviewNoSignalAndMirror(t *testing.T) { + frame, mirror, n := CameraFrame{}, false, 0 + tt := cameraTester(&frame, &mirror, false, &n) + if !tt.HasText("No signal") { + t.Fatalf("texts %v", tt.Texts()) + } + if err := tt.Click("Mirror"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Camera") { + t.Fatal("preview not focusable") + } + tt.Key(0, ui.KeyM) + if mirror || n != 2 { + t.Fatalf("mirror %v changes %d", mirror, n) + } + frame = CameraFrame{Image: mediaTestImage(32, 18), Seq: 1} + tt.Frame() + if tt.HasText("No signal") { + t.Fatal("frame did not replace No signal") + } +} + +func TestCameraPreviewDisabled(t *testing.T) { + frame, mirror, n := CameraFrame{}, false, 0 + tt := cameraTester(&frame, &mirror, true, &n) + _ = tt.Click("Mirror") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyM) + if mirror || n != 0 { + t.Fatal("disabled preview flipped") + } +} + +func TestCameraBitmapMirrors(t *testing.T) { + img := mediaTestImage(8, 8) + flipped := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for y := range 8 { + for x := range 8 { + flipped.Set(7-x, y, img.At(x, y)) + } + } + got := ui.Render(func(c *ui.Context) { + ui.Image(c, cameraBitmap(img, true)).Size(8, 8) + }, 8, 8, 1) + want := ui.Render(func(c *ui.Context) { + ui.Image(c, ui.NewBitmap(flipped)).Size(8, 8) + }, 8, 8, 1) + if got.RGBAAt(1, 6) != want.RGBAAt(1, 6) || got.RGBAAt(1, 6) == ui.Render(func(c *ui.Context) { + ui.Image(c, ui.NewBitmap(img)).Size(8, 8) + }, 8, 8, 1).RGBAAt(1, 6) { + t.Fatal("mirrored bitmap is not flipped") + } +} + +func TestCameraPreviewSnapshot(t *testing.T) { + img := mediaTestImage(160, 90) + m1, m2 := true, false + snapshot(t, "camera_preview", 560, 180, func(c *ui.Context) { + ui.Row(c).Padding(20).Gap(12).Children(func() { + ui.Column(c).Grow(1).Children(func() { CameraPreview(c, CameraFrame{Image: img}, &m1, CameraPreviewOptions{}) }) + ui.Column(c).Grow(1).Children(func() { CameraPreview(c, CameraFrame{}, &m2, CameraPreviewOptions{}) }) + }) + }) +} diff --git a/device_selector.go b/device_selector.go new file mode 100644 index 0000000..e7b9e92 --- /dev/null +++ b/device_selector.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MediaDevice is one device; IDs are unique within a kind. +type MediaDevice struct { + ID, Name string +} + +// MediaDevices are the caller's lists; nil hides that kind. +type MediaDevices struct { + Microphones, Cameras, Speakers []MediaDevice +} + +// DeviceSelection holds each kind's chosen ID; empty is none. +type DeviceSelection struct { + Microphone, Camera, Speaker string +} + +// DeviceSelectorOptions configure a DeviceSelector. +type DeviceSelectorOptions struct { + Disabled bool +} + +// DeviceSelector chooses a microphone, camera and speaker. +func DeviceSelector(c *ui.Context, sel *DeviceSelection, devices MediaDevices, opts DeviceSelectorOptions) ChoiceResult { + old := *sel + k := Tokens(c) + u := Density(c).Unit() + col := ui.Column(c).Gap(u * 3).Role(ui.RoleGroup).Label(msg(c, "device_selector.label")).Disabled(opts.Disabled) + col.Children(func() { + deviceRow(c, k, mediaMic, "device_selector.microphone", &sel.Microphone, devices.Microphones, opts.Disabled) + deviceRow(c, k, mediaCamera, "device_selector.camera", &sel.Camera, devices.Cameras, opts.Disabled) + deviceRow(c, k, mediaSpeaker, "device_selector.speaker", &sel.Speaker, devices.Speakers, opts.Disabled) + }) + changed := *sel != old + if changed { + slog.Debug("mujica: device_selector changed", "selection", *sel) + } + return ChoiceResult{Element: col, changed: changed} +} + +func deviceRow(c *ui.Context, k theme.Tokens, icon *ui.SVG, key string, id *string, list []MediaDevice, disabled bool) { + if list == nil { + return + } + options := make([]SelectOption[string], len(list)) + for i, d := range list { + if d.ID == "" { + panic("mujica: DeviceSelector device without an ID") + } + for _, o := range options[:i] { + if o.Value == d.ID { + panic("mujica: DeviceSelector duplicate device ID " + d.ID) + } + } + options[i] = SelectOption[string]{Value: d.ID, Label: d.Name} + } + if _, ok := selectLabel(options, *id); *id != "" && !ok { + panic("mujica: DeviceSelector selection " + *id + " is not in its list") + } + ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icon).Size(16, 16).TextColor(k.TextMuted) + ui.Text(c, msg(c, key)).Width(96).SingleLine() + hint := msg(c, "device_selector.choose") + if len(list) == 0 { + hint = msg(c, "device_selector.none") + } + Select(c, id, options, SelectOptions{Disabled: disabled || len(list) == 0, Label: msg(c, key), + Placeholder: hint, Empty: msg(c, "device_selector.none")}).Element.Grow(1) + }) +} diff --git a/device_selector_test.go b/device_selector_test.go new file mode 100644 index 0000000..74c8b79 --- /dev/null +++ b/device_selector_test.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var deviceTestList = MediaDevices{ + Microphones: []MediaDevice{{"m1", "Built-in Microphone"}, {"m2", "Studio Mic"}}, + Cameras: []MediaDevice{}, + Speakers: []MediaDevice{{"s1", "Headphones"}}, +} + +func deviceTester(sel *DeviceSelection, devices MediaDevices, disabled bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(420).Children(func() { + if DeviceSelector(c, sel, devices, DeviceSelectorOptions{Disabled: disabled}).Changed() { + *n++ + } + }) + }, 500, 400) +} + +func TestDeviceSelectorChoose(t *testing.T) { + sel, n := DeviceSelection{Microphone: "m1"}, 0 + tt := deviceTester(&sel, deviceTestList, false, &n) + if !tt.HasText("Built-in Microphone") || !tt.HasText("No devices found") { + t.Fatalf("texts %v", tt.Texts()) + } + if err := tt.Click("Built-in Microphone"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Studio Mic"); err != nil { + t.Fatal(err) + } + if sel.Microphone != "m2" || n != 1 { + t.Fatalf("selection %+v changes %d", sel, n) + } +} + +func TestDeviceSelectorHidesNilListsAndDisabled(t *testing.T) { + sel, n := DeviceSelection{}, 0 + tt := deviceTester(&sel, MediaDevices{Speakers: deviceTestList.Speakers}, true, &n) + if tt.HasText("Camera") || tt.HasText("Microphone") { + t.Fatalf("nil lists shown: %v", tt.Texts()) + } + _ = tt.Click("Choose a device") + _ = tt.Click("Headphones") + if sel.Speaker != "" || n != 0 { + t.Fatal("disabled selector chose") + } +} + +func TestDeviceSelectorBadLists(t *testing.T) { + sel := DeviceSelection{} + mediaPanics(t, "duplicate", func(c *ui.Context) { + DeviceSelector(c, &sel, MediaDevices{Cameras: []MediaDevice{{"a", "A"}, {"a", "B"}}}, DeviceSelectorOptions{}) + }) + gone := DeviceSelection{Camera: "x"} + mediaPanics(t, "unknown selection", func(c *ui.Context) { + DeviceSelector(c, &gone, MediaDevices{Cameras: []MediaDevice{{"a", "A"}}}, DeviceSelectorOptions{}) + }) +} + +func TestDeviceSelectorSnapshot(t *testing.T) { + sel := DeviceSelection{Microphone: "m2", Speaker: "s1"} + snapshot(t, "device_selector", 460, 190, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(420).Children(func() { + DeviceSelector(c, &sel, deviceTestList, DeviceSelectorOptions{}) + }) + }) +} diff --git a/docs/components/341-image-cropper.md b/docs/components/341-image-cropper.md new file mode 100644 index 0000000..bef9a87 --- /dev/null +++ b/docs/components/341-image-cropper.md @@ -0,0 +1,55 @@ +# 341 ImageCropper 图片裁剪 + +## 用途 + +在图片上拖出裁剪框,可锁定宽高比、按 90 度旋转,输出裁剪矩形。 + +## 最小示例 + +```go +crop := mujica.ImageCrop{} // empty: starts fitted to the image +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageCropper(c, examplePhoto, &crop, mujica.ImageCropperOptions{Aspect: 1}).Changed() { + _ = crop.Rect // in pixels of the photo turned by crop.Rotation + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ImageCropper(c *ui.Context, src image.Image, crop *ImageCrop, opts ImageCropperOptions) ChoiceResult` + +`src` 必填,是标准库 `image.Image`(组件要自己转动像素)。`ImageCrop.Rect` 是旋转 `Rotation` 度之后那张图上的像素矩形;`Rotation` 只能是 0、90、180、270。`Rect` 为空时自动设为最大的居中框。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Aspect` | `float64` | 0 | 锁定宽除以高;0 为自由比例。`Rect` 不符合比例时 panic。 | +| `Height` | `float32` | 0(即 320) | 舞台高度。 | +| `Disabled` | `bool` | false | 禁用拖动、按键与旋转。 | +| `Label` | `string` | "Crop area" | 舞台的无障碍名称。 | + +## 状态 + +- 默认:框外区域压暗,框线与三分线为象牙色,四角有菱形手柄。 +- 聚焦:舞台显示 2 DIP 焦点环;下方读数同时作为无障碍描述。 +- 禁用:MyGo 统一调暗,不响应输入。 + +## 事件 + +`Changed()` 在框移动、缩放或旋转后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| 方向键 | 移动裁剪框,每次图片宽度的 1% | +| Shift+方向键 | 右、下放大,左、上缩小;锁定比例时保持比例 | +| R / Shift+R | 向右 / 向左旋转 90 度 | + +## 限制 + +- MyGo 的 Painter 不能旋转位图(只有单色图标能转,ui/svg.go:98),所以组件在图片或角度变化时复制一份转好的像素;超大图片会在那一帧花时间。 +- 锁定比例时旋转会重新取最大的居中框,原选区不保留。 +- 拖动角点时,按下点距角 14 DIP 以内才算抓住手柄。 diff --git a/docs/components/342-image-annotator.md b/docs/components/342-image-annotator.md new file mode 100644 index 0000000..c947050 --- /dev/null +++ b/docs/components/342-image-annotator.md @@ -0,0 +1,59 @@ +# 342 ImageAnnotator 图片标注 + +## 用途 + +在图片上画矩形、箭头和文字标注,可选择、移动和删除,并导出标注数据。 + +## 最小示例 + +```go +photo := ui.NewBitmap(examplePhoto) +var notes []mujica.Annotation +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageAnnotator(c, photo, ¬es, mujica.ImageAnnotatorOptions{}).Changed() { + data, _ := mujica.ExportAnnotations(notes) + _ = data // save the JSON + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ImageAnnotator(c *ui.Context, src ui.ImageSource, notes *[]Annotation, opts ImageAnnotatorOptions) ChoiceResult` + +`func ExportAnnotations(notes []Annotation) ([]byte, error)` 把标注写成 JSON(kind、x0、y0、x1、y1、text)。 + +`Annotation` 的坐标是图片像素;文字标注以 `From` 为左上角,`Text` 不能为空。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Height` | `float32` | 0(即 320) | 舞台高度。 | +| `Disabled` | `bool` | false | 禁用绘制、选择和删除。 | +| `Label` | `string` | "Annotations" | 舞台的无障碍名称。 | + +## 状态 + +- 工具:选择、矩形、箭头、文字,用分段控件切换。 +- 选中:虚线框加菱形标记,不只靠颜色。 +- 标注用暗红墨线加浅色描边,在任何图片上都看得清。 + +## 事件 + +`Changed()` 在新增、移动、改字或删除标注后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| [ / ] | 选中上一个 / 下一个标注 | +| 方向键 | 移动选中的标注,每次图片宽度的 1% | +| Delete / Backspace | 删除选中的标注 | +| Escape | 取消选择 | + +## 限制 + +- 文字工具:先在工具栏输入文字,再点击放置;选中文字标注时同一输入框可改字。 +- 标注不能缩放,只能移动;要改大小请删除后重画。 +- 文字的命中区域按每字 8 像素估算。 diff --git a/docs/components/343-image-thumbnail.md b/docs/components/343-image-thumbnail.md new file mode 100644 index 0000000..eaf0c67 --- /dev/null +++ b/docs/components/343-image-thumbnail.md @@ -0,0 +1,54 @@ +# 343 ImageThumbnail 缩略图 + +## 用途 + +固定尺寸、按比例裁切的可点击缩略图,带选中、加载中和加载失败状态。 + +## 最小示例 + +```go +photo := ui.NewBitmap(examplePhoto) +selected := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageThumbnail(c, photo, mujica.ImageThumbnailOptions{Alt: "Grand hall", Aspect: 4.0 / 3, Selected: selected}).Changed() { + selected = !selected + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ImageThumbnail(c *ui.Context, src ui.ImageSource, opts ImageThumbnailOptions) ChoiceResult` + +`src` 为 nil 时显示占位图标。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Width` | `float32` | 0(即 96) | 宽度。 | +| `Aspect` | `float32` | 0(即 1) | 宽除以高,图片按 Cover 裁切填满。 | +| `Selected` | `bool` | false | 选中:2 DIP 强调边框加右上角菱形。 | +| `Loading` | `bool` | false | 显示加载标记,尺寸不变。 | +| `Err` | `error` | nil | 显示失败图标;错误文字作为提示与描述。 | +| `Alt` | `string` | 必填 | 替代说明,为空时 panic。 | +| `Disabled` | `bool` | false | 禁用点击。 | + +## 状态 + +默认、悬停(边框加深)、聚焦、选中、加载、失败、禁用,所有状态尺寸相同。 + +## 事件 + +`Changed()` 表示本帧被点击,或聚焦时按了 Enter / Space。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 聚焦缩略图 | +| Enter / Space | 激活 | + +## 限制 + +- 选中状态由调用方保存;组件只报告点击。 diff --git a/docs/components/344-lightbox.md b/docs/components/344-lightbox.md new file mode 100644 index 0000000..5c5103a --- /dev/null +++ b/docs/components/344-lightbox.md @@ -0,0 +1,53 @@ +# 344 Lightbox 灯箱 + +## 用途 + +全窗口浏览图片:左右切换、显示说明文字,Escape 关闭后焦点回到触发控件。 + +## 最小示例 + +```go +images := []mujica.ViewerImage{{Source: ui.NewBitmap(examplePhoto), Alt: "The grand hall"}} +open, index := false, 0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "View photos", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Lightbox(c, &open, &index, images) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Lightbox(c *ui.Context, open *bool, index *int, images []ViewerImage) ChoiceResult` + +与 ImageViewer 共用 `ViewerImage`(`Source`、`Alt`)。`Alt` 同时是说明文字。关闭时 `Element` 为 nil。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `open` | `*bool` | — | 是否显示;Escape、点击关闭或背景时设为 false。 | +| `index` | `*int` | — | 当前图片;越界 panic。 | +| `images` | `[]ViewerImage` | — | 至少一张,`Source` 须为 `*ui.Bitmap` 或 `*ui.SVG`。 | + +## 状态 + +- 黑色遮罩铺满窗口,图片按 Contain 居中;首尾时对应切换按钮禁用。 +- 打开时按弹层时长淡入,减少动画时直接出现。 + +## 事件 + +`Changed()` 在切换图片后为 true,并朗读“第 n 张,共 m 张”。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / → | 上一张 / 下一张 | +| Home / End | 第一张 / 最后一张 | +| Escape | 关闭并把焦点还给打开它的控件 | + +## 限制 + +- 不提供缩放与平移;需要时请用 ImageViewer。 diff --git a/docs/components/345-video-player.md b/docs/components/345-video-player.md new file mode 100644 index 0000000..7a9beca --- /dev/null +++ b/docs/components/345-video-player.md @@ -0,0 +1,59 @@ +# 345 VideoPlayer 视频播放器 + +## 用途 + +播放器外壳:画面由调用方提供,组件绘制状态并通过回调报告播放、暂停、跳转、倍速、字幕、全屏和画中画。 + +## 最小示例 + +```go +st := mujica.VideoState{Duration: 4 * time.Minute, Rate: 1} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{ + OnPlay: func() { st.Playing = true }, // start your decoder + OnPause: func() { st.Playing = false }, // pause it + OnSeek: func(d time.Duration) { st.Position = d }, + OnRate: func(r float64) { st.Rate = r }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VideoPlayer(c *ui.Context, st VideoState, opts VideoPlayerOptions) *ui.Element` + +`VideoState`:`Frame`(当前画面,nil 显示“暂无视频”)、`Position`、`Duration`、`Buffered`、`Playing`、`Rate`、`Subtitle`、`SubtitlesOn`、`Fullscreen`、`PiP`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OnPlay / OnPause / OnSeek` | `func` | 必填 | 播放、暂停、跳转。 | +| `OnRate` | `func(float64)` | nil | 倍速;nil 时不显示倍速选择。 | +| `OnSubtitles / OnFullscreen / OnPiP` | `func(bool)` | nil | 开关回调;nil 时隐藏对应按钮。 | +| `Disabled` | `bool` | false | 禁用全部控件。 | +| `Label` | `string` | "Video" | 无障碍名称。 | + +## 状态 + +- 画面区 16:9,黑底;字幕开启且有字幕时在底部显示。 +- 字幕、画中画、全屏按钮开启时有选中底色和下划线。 + +## 事件 + +全部通过回调报告;组件不改动 `st`。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 先聚焦画面,再到进度条和各按钮 | +| Space / K | 画面聚焦时播放或暂停 | +| ← / → | 画面聚焦时后退 / 前进 5 秒 | +| F | 切换全屏 | +| C | 切换字幕 | + +## 限制 + +- 组件不解码视频,也不实现全屏或画中画窗口,只报告用户的请求。 +- 帧率取决于调用方更新 `Frame` 并调用 `c.Invalidate()` 的频率。 diff --git a/docs/components/346-video-scrubber.md b/docs/components/346-video-scrubber.md new file mode 100644 index 0000000..6ada0d3 --- /dev/null +++ b/docs/components/346-video-scrubber.md @@ -0,0 +1,56 @@ +# 346 VideoScrubber 视频时间轴 + +## 用途 + +可拖动的时间轴:显示已缓冲区间,悬停显示时间和缩略图插槽。 + +## 最小示例 + +```go +pos := 30 * time.Second +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoScrubber(c, &pos, mujica.VideoScrubberOptions{ + Duration: 2 * time.Minute, + Buffered: []mujica.MediaRange{{Start: 0, End: time.Minute}}, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VideoScrubber(c *ui.Context, pos *time.Duration, opts VideoScrubberOptions) ChoiceResult` + +`*pos` 必须在 0 到 `Duration` 之间,否则 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Duration` | `time.Duration` | 必填 | 时间轴长度,须大于 0。 | +| `Buffered` | `[]MediaRange` | nil | 已缓冲区间,以浅色填充。 | +| `Preview` | `func(at time.Duration)` | nil | 悬停时在插槽里构建该时间的缩略图。 | +| `PreviewWidth / PreviewHeight` | `float32` | 160×90 | 插槽尺寸。 | +| `Step` | `time.Duration` | 5 秒 | 方向键每次移动的时长。 | +| `Disabled` | `bool` | false | 禁用。 | +| `Label` | `string` | "Timeline" | 无障碍名称;以滑块角色报告范围与数值。 | + +## 状态 + +默认、悬停(显示时间气泡)、拖动、聚焦(菱形手柄外圈)、禁用。 + +## 事件 + +`Changed()` 在拖动、点击或按键改变 `*pos` 后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / ↓ | 后退一个 Step | +| → / ↑ | 前进一个 Step | +| Home / End | 开头 / 结尾 | + +## 限制 + +- 位置按毫秒取整。 +- 悬停插槽只在指针位于时间轴上时显示,键盘操作时不显示。 diff --git a/docs/components/347-video-thumbnail-strip.md b/docs/components/347-video-thumbnail-strip.md new file mode 100644 index 0000000..e36b6ef --- /dev/null +++ b/docs/components/347-video-thumbnail-strip.md @@ -0,0 +1,51 @@ +# 347 VideoThumbnailStrip 缩略帧条 + +## 用途 + +横向缩略帧条:标出当前帧,点击或按键跳转。 + +## 最小示例 + +```go +frames := []mujica.StripFrame{{At: 0}, {At: 10 * time.Second}, {At: 20 * time.Second}} +current := 0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.VideoThumbnailStrip(c, ¤t, frames, mujica.VideoThumbnailStripOptions{}).Changed() { + _ = frames[current].At // seek the player there + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VideoThumbnailStrip(c *ui.Context, current *int, frames []StripFrame, opts VideoThumbnailStripOptions) ChoiceResult` + +`StripFrame` 有 `Image`(可为 nil)和 `At`。`*current` 越界 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `FrameWidth` | `float32` | 0(即 96) | 每格宽度,高度按 16:9。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +- 只构建放得下的那几格,窗口跟随当前帧移动;10 万帧也只建十几个元素。 +- 当前帧:强调边框加菱形;每格左下角显示时间。 + +## 事件 + +`Changed()` 在当前帧变化后为 true;调用方据此跳转播放器。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 聚焦帧条 | +| ← / → | 上一帧 / 下一帧 | +| Home / End | 第一帧 / 最后一帧 | + +## 限制 + +- 不提供横向滚动条,用两侧按钮或键盘移动。 diff --git a/docs/components/348-audio-player.md b/docs/components/348-audio-player.md new file mode 100644 index 0000000..426e71d --- /dev/null +++ b/docs/components/348-audio-player.md @@ -0,0 +1,51 @@ +# 348 AudioPlayer 音频播放器 + +## 用途 + +音频播放外壳:播放、暂停、进度与时长,播放本身由调用方驱动。 + +## 最小示例 + +```go +st := mujica.AudioState{Title: "Overture", Duration: 3 * time.Minute} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioPlayer(c, st, mujica.AudioPlayerOptions{ + OnPlay: func() { st.Playing = true }, + OnPause: func() { st.Playing = false }, + OnSeek: func(d time.Duration) { st.Position = d }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) *ui.Element` + +`AudioState`:`Title`、`Position`、`Duration`、`Playing`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OnPlay / OnPause / OnSeek` | `func` | 必填 | 缺少任一回调时 panic。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +播放中显示暂停按钮,暂停时显示播放按钮;`Duration` 为 0 时不显示进度条。 + +## 事件 + +通过回调报告,组件不改动 `st`。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 播放按钮、进度条 | +| Space | 组件内任意位置播放或暂停(按钮聚焦时由按钮处理) | +| ← / → | 进度条聚焦时跳转 5 秒 | + +## 限制 + +- 组件不播放声音。 diff --git a/docs/components/349-audio-waveform.md b/docs/components/349-audio-waveform.md new file mode 100644 index 0000000..674f8ad --- /dev/null +++ b/docs/components/349-audio-waveform.md @@ -0,0 +1,58 @@ +# 349 AudioWaveform 音频波形 + +## 用途 + +绘制调用方提供的采样波形,覆盖播放进度,可拖动选择区间。 + +## 最小示例 + +```go +samples := make([]float32, 48000) // from your decoder, -1 to 1 +pos, sel := time.Duration(0), mujica.MediaRange{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioWaveform(c, samples, mujica.AudioWaveformOptions{ + Duration: time.Second, Position: pos, Selection: &sel, + OnSeek: func(d time.Duration) { pos = d }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AudioWaveform(c *ui.Context, samples []float32, opts AudioWaveformOptions) ChoiceResult` + +`samples` 取值 -1 到 1。数据就绪时 `Duration` 须大于 0,`OnSeek` 必填。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Duration` | `time.Duration` | — | 音频长度。 | +| `Position` | `time.Duration` | 0 | 已播放部分。 | +| `OnSeek` | `func(time.Duration)` | — | 点击或方向键跳转。 | +| `Selection` | `*MediaRange` | nil | 非 nil 时可拖动或 Shift+方向键选择区间。 | +| `Height` | `float32` | 0(即 96) | 高度。 | +| `Status / Error` | `DataStatus / string` | DataReady | 加载中、失败(须给 Error)与无数据时显示同尺寸占位。 | +| `Label` | `string` | "Waveform" | 无障碍名称。 | + +## 状态 + +- 已播放部分用强调色,其余用说明色,并有播放线和菱形标记,不只靠颜色区分。 +- 选区用 Selection 底色加两条边线。 + +## 事件 + +`Changed()` 在选区变化后为 true;跳转通过 `OnSeek` 报告。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / → | 跳转 ±5 秒 | +| Shift+← / → | 从播放位置开始或调整选区终点 | +| Escape | 清除选区 | + +## 限制 + +- 峰值按像素列计算,只在数据或宽度变化时重算,不会每帧计算。 +- 数据以首元素地址和长度识别;原地修改采样请换一个切片。 diff --git a/docs/components/350-audio-spectrum.md b/docs/components/350-audio-spectrum.md new file mode 100644 index 0000000..b7e2994 --- /dev/null +++ b/docs/components/350-audio-spectrum.md @@ -0,0 +1,49 @@ +# 350 AudioSpectrum 音频频谱 + +## 用途 + +把调用方提供的频带数值画成柱状频谱;组件自己不做动画,隐藏时不重绘。 + +## 最小示例 + +```go +bands := []float32{0.2, 0.6, 0.9, 0.4, 0.1} // set by your analyser, then c.Invalidate() +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AudioSpectrum(c *ui.Context, bands []float32, opts AudioSpectrumOptions) *ui.Element` + +每个频带取 0 到 1,超出范围 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Height` | `float32` | 0(即 120) | 高度。 | +| `BandLabel` | `func(i int) string` | nil | 读数中频带的名称,如“1 kHz”;nil 时编号。 | +| `Status / Error` | `DataStatus / string` | DataReady | 加载、失败与无数据占位,尺寸相同。 | +| `Label` | `string` | "Spectrum" | 无障碍名称。 | + +## 状态 + +聚焦后用方向键选中的频带以正文色显示,带虚线框和读数,不只靠颜色。 + +## 事件 + +无事件;方向键读数会通过 `Announce` 朗读。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 聚焦频谱 | +| ← / → | 上一个 / 下一个频带 | +| Home / End | 第一个 / 最后一个频带 | + +## 限制 + +- 刷新节奏由调用方决定:更新数据后调用 `c.Invalidate()`。 diff --git a/docs/components/351-volume-control.md b/docs/components/351-volume-control.md new file mode 100644 index 0000000..ef6eb3d --- /dev/null +++ b/docs/components/351-volume-control.md @@ -0,0 +1,46 @@ +# 351 VolumeControl 音量控制 + +## 用途 + +音量滑块与静音按钮。 + +## 最小示例 + +```go +volume, muted := 0.8, false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeControl(c, &volume, &muted, mujica.VolumeControlOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VolumeControl(c *ui.Context, volume *float64, muted *bool, opts VolumeControlOptions) ChoiceResult` + +`*volume` 取 0 到 1,超出 panic;步长 5%。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +静音或音量为 0 时显示静音图标;静音按钮开启时有选中底色和下划线。静音时调高音量会自动取消静音。 + +## 事件 + +`Changed()` 在音量或静音变化后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / → | 音量 ±5% | +| Home / End | 0% / 100% | +| M | 组件内切换静音 | + +## 限制 + +- 组件不调节系统音量,只改调用方的值。 diff --git a/docs/components/352-playback-speed-control.md b/docs/components/352-playback-speed-control.md new file mode 100644 index 0000000..9a7e829 --- /dev/null +++ b/docs/components/352-playback-speed-control.md @@ -0,0 +1,48 @@ +# 352 PlaybackSpeedControl 倍速选择 + +## 用途 + +在 0.5× 到 2× 之间选择播放速度。 + +## 最小示例 + +```go +rate := 1.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PlaybackSpeedControl(c, &rate, mujica.PlaybackSpeedControlOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func PlaybackSpeedControl(c *ui.Context, rate *float64, opts PlaybackSpeedControlOptions) ChoiceResult` + +`*rate` 必须是 `Rates` 中的一项,否则 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Rates` | `[]float64` | 0.5, 0.75, 1, 1.25, 1.5, 2 | 升序的正数。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +基于 Select,状态与键盘同 Select。 + +## 事件 + +`Changed()` 在选择新倍速后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Enter / Space | 打开列表 | +| ↑ / ↓ | 移动 | +| Enter | 确认 | +| Escape | 关闭 | + +## 限制 + +- 无。 diff --git a/docs/components/353-media-controls.md b/docs/components/353-media-controls.md new file mode 100644 index 0000000..3d35038 --- /dev/null +++ b/docs/components/353-media-controls.md @@ -0,0 +1,53 @@ +# 353 MediaControls 媒体控制 + +## 用途 + +上一首、播放/暂停、下一首、循环、随机,一组可用方向键切换焦点的按钮。 + +## 最小示例 + +```go +st := mujica.MediaControlsState{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MediaControls(c, st, mujica.MediaControlsOptions{ + OnPrevious: func() {}, OnNext: func() {}, + OnPlay: func() { st.Playing = true }, OnPause: func() { st.Playing = false }, + OnLoop: func(m mujica.LoopMode) { st.Loop = m }, OnShuffle: func(on bool) { st.Shuffle = on }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func MediaControls(c *ui.Context, st MediaControlsState, opts MediaControlsOptions) *ui.Element` + +`MediaControlsState`:`Playing`、`Loop`(`LoopOff`、`LoopAll`、`LoopOne`)、`Shuffle`、`NoPrevious`、`NoNext`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OnPrevious / OnPlay / OnPause / OnNext` | `func()` | 必填 | 按钮回调。 | +| `OnLoop` | `func(LoopMode)` | 必填 | 收到下一个循环模式:不循环 → 列表 → 单曲。 | +| `OnShuffle` | `func(bool)` | 必填 | 随机开关。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +循环与随机开启时有选中底色和下划线,单曲循环换用带“1”的图标;循环按钮的名称说明当前模式。 + +## 事件 + +全部通过回调报告。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 进入按钮组 | +| ← / → | 在按钮间移动焦点 | +| Enter / Space | 按下 | + +## 限制 + +- 无。 diff --git a/docs/components/354-playlist.md b/docs/components/354-playlist.md new file mode 100644 index 0000000..85bac64 --- /dev/null +++ b/docs/components/354-playlist.md @@ -0,0 +1,52 @@ +# 354 Playlist 播放列表 + +## 用途 + +播放列表:标出当前项,可拖动排序、移除,只构建可见行。 + +## 最小示例 + +```go +items := []mujica.PlaylistItem{{ID: "a", Title: "Overture"}, {ID: "b", Title: "Masquerade"}} +current := "a" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.Playlist(c, &items, mujica.PlaylistOptions{Current: current}); r.Played != "" { + current = r.Played + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Playlist(c *ui.Context, items *[]PlaylistItem, opts PlaylistOptions) PlaylistResult` + +`PlaylistItem`:`ID`(唯一且非空,否则 panic)、`Title`、`Subtitle`、`Duration`。组件直接修改 `*items` 完成排序和移除。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Current` | `string` | "" | 正在播放项的 ID,显示播放标记和强调文字。 | +| `Height` | `float32` | 0(即 320) | 列表高度。 | +| `Disabled` | `bool` | false | 禁用选择、排序和移除。 | + +## 状态 + +基于 List:选中行使用 Selection 底色加勾选标记;空列表显示提示。 + +## 事件 + +`PlaylistResult`:`Played` 为 Enter 或双击选择的 ID;`Removed` 为被移除的 ID;`Moved` 表示顺序变化。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ↑ / ↓ | 移动选择 | +| Enter | 播放选中项 | +| Alt+↑ / Alt+↓ | 上移 / 下移选中项 | +| Delete / Backspace | 移除选中项 | + +## 限制 + +- 拖动排序由 MyGo List 的 Reorder 提供,拖动时显示插入线。 diff --git a/docs/components/355-subtitle-editor.md b/docs/components/355-subtitle-editor.md new file mode 100644 index 0000000..eb921bc --- /dev/null +++ b/docs/components/355-subtitle-editor.md @@ -0,0 +1,52 @@ +# 355 SubtitleEditor 字幕编辑器 + +## 用途 + +逐条编辑字幕的开始、结束时间和文本,校验时间先后与重叠。 + +## 最小示例 + +```go +cues := []mujica.SubtitleCue{{Start: 0, End: 2 * time.Second, Text: "The curtain rises."}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SubtitleEditor(c, &cues, mujica.SubtitleEditorOptions{}) + if len(mujica.CheckSubtitles(cues)) > 0 { + _ = cues // block saving until the issues are fixed + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func SubtitleEditor(c *ui.Context, cues *[]SubtitleCue, opts SubtitleEditorOptions) ChoiceResult` + +`func CheckSubtitles(cues []SubtitleCue) []SubtitleIssue` 返回结束早于开始(`SubtitleEndBeforeStart`)和与上一条重叠(`SubtitleOverlap`)的条目,供调用方阻止保存。时间格式为 `m:ss.mmm` 或 `h:mm:ss.mmm`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Height` | `float32` | 0(即 360) | 列表高度。 | +| `Disabled` | `bool` | false | 禁用编辑、添加和删除。 | + +## 状态 + +- 无法解析的时间保留在输入框中,并显示格式提示;有效后才写回。 +- 时间问题在该行下方用错误色说明,并报告给辅助技术。 +- 只构建可见行,10 万条也保持流畅。 + +## 事件 + +`Changed()` 在任一字段、添加或删除后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 在一行的开始、结束、文本、删除之间移动 | +| Enter | 提交输入 | + +## 限制 + +- 虚拟化只构建可见行,Tab 不会跳到未显示的行;请先滚动。 +- 组件不排序字幕;顺序由调用方决定。 diff --git a/docs/components/356-screen-recorder-controls.md b/docs/components/356-screen-recorder-controls.md new file mode 100644 index 0000000..d0dcb96 --- /dev/null +++ b/docs/components/356-screen-recorder-controls.md @@ -0,0 +1,51 @@ +# 356 ScreenRecorderControls 录屏控制 + +## 用途 + +开始、暂停、继续、停止录制并显示计时;录制本身由调用方实现。 + +## 最小示例 + +```go +st := mujica.RecorderState{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScreenRecorderControls(c, st, mujica.ScreenRecorderControlsOptions{ + OnStart: func() { st.Status = mujica.RecorderRecording }, + OnPause: func() { st.Status = mujica.RecorderPaused }, + OnResume: func() { st.Status = mujica.RecorderRecording }, + OnStop: func() { st = mujica.RecorderState{} }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ScreenRecorderControls(c *ui.Context, st RecorderState, opts ScreenRecorderControlsOptions) *ui.Element` + +`RecorderState`:`Status`(`RecorderIdle`、`RecorderRecording`、`RecorderPaused`)、`Elapsed`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OnStart / OnPause / OnResume / OnStop` | `func()` | 必填 | 缺少任一回调时 panic。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +就绪、录制中(红点)、已暂停;状态同时用文字表示,不只靠圆点颜色。录制中的圆点不闪烁。 + +## 事件 + +全部通过回调报告。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 在按钮间移动 | +| Enter / Space | 按下 | + +## 限制 + +- 计时由调用方更新 `Elapsed`。 diff --git a/docs/components/357-camera-preview.md b/docs/components/357-camera-preview.md new file mode 100644 index 0000000..e7d4aa5 --- /dev/null +++ b/docs/components/357-camera-preview.md @@ -0,0 +1,48 @@ +# 357 CameraPreview 摄像头预览 + +## 用途 + +显示调用方提供的摄像头帧,无帧时显示“无信号”,可切换镜像。 + +## 最小示例 + +```go +frame := mujica.CameraFrame{Image: examplePhoto, Seq: 1} // a new Seq for each captured frame +mirror := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CameraPreview(c, frame, &mirror, mujica.CameraPreviewOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CameraPreview(c *ui.Context, frame CameraFrame, mirror *bool, opts CameraPreviewOptions) ChoiceResult` + +`CameraFrame`:`Image`(标准库 `image.Image`,nil 为无信号)、`Seq`(帧序号,复用同一图像缓冲时用新序号触发重绘)。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | "Camera" | 无障碍名称。 | +| `Disabled` | `bool` | false | 禁用镜像切换。 | + +## 状态 + +16:9 黑底;镜像按钮开启时有选中底色和下划线。 + +## 事件 + +`Changed()` 在镜像切换后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 聚焦预览 | +| M | 切换镜像 | + +## 限制 + +- MyGo 的 Painter 不能水平翻转位图(ui/paint.go:834),所以镜像时组件每个新帧复制一份翻转后的像素;高分辨率帧会占用 CPU。 +- 组件不打开摄像头。 diff --git a/docs/components/358-mic-level-meter.md b/docs/components/358-mic-level-meter.md new file mode 100644 index 0000000..29152ea --- /dev/null +++ b/docs/components/358-mic-level-meter.md @@ -0,0 +1,45 @@ +# 358 MicLevelMeter 麦克风电平 + +## 用途 + +分段电平条,带峰值保持与 dB 读数。 + +## 最小示例 + +```go +level := float32(0.4) // the input's peak, 0 to 1 of full scale +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func MicLevelMeter(c *ui.Context, level float32, opts MicLevelMeterOptions) *ui.Element` + +`level` 为满刻度的 0 到 1,超出 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Hold` | `time.Duration` | 1.5 秒 | 峰值保持时长。 | +| `Label` | `string` | "Microphone level" | 无障碍名称;描述中读出当前与峰值 dB。 | + +## 状态 + +24 段:前 70% 为成功色,接着警告色,最后 10% 为危险色;峰值段为空心标记;右侧显示 dB 数值,不只靠颜色。 + +## 事件 + +无。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| — | 不获取焦点 | + +## 限制 + +- 保持结束时只请求一次重绘,空闲时不持续重绘。 diff --git a/docs/components/359-device-selector.md b/docs/components/359-device-selector.md new file mode 100644 index 0000000..06f202b --- /dev/null +++ b/docs/components/359-device-selector.md @@ -0,0 +1,51 @@ +# 359 DeviceSelector 设备选择 + +## 用途 + +选择麦克风、摄像头、扬声器,设备列表由调用方提供。 + +## 最小示例 + +```go +devices := mujica.MediaDevices{ + Microphones: []mujica.MediaDevice{{ID: "mic-1", Name: "Built-in Microphone"}}, + Speakers: []mujica.MediaDevice{{ID: "out-1", Name: "Headphones"}}, +} +sel := mujica.DeviceSelection{Microphone: "mic-1"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DeviceSelector(c, &sel, devices, mujica.DeviceSelectorOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func DeviceSelector(c *ui.Context, sel *DeviceSelection, devices MediaDevices, opts DeviceSelectorOptions) ChoiceResult` + +`MediaDevices` 的某类列表为 nil 时不显示该类;为空切片时显示禁用的“未找到设备”。设备 ID 重复,或已选 ID 不在列表中时 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Disabled` | `bool` | false | 禁用全部选择。 | + +## 状态 + +每类一行:图标、名称和 Select。 + +## 事件 + +`Changed()` 在任一类选择变化后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 在各选择框间移动 | +| Enter / Space | 打开列表 | +| ↑ / ↓ | 移动 | +| 输入字母 | 跳到匹配的设备 | + +## 限制 + +- 组件不枚举设备,也不监听设备插拔;调用方更新列表即可。 diff --git a/docs/components/360-image-compare.md b/docs/components/360-image-compare.md new file mode 100644 index 0000000..cc03a72 --- /dev/null +++ b/docs/components/360-image-compare.md @@ -0,0 +1,48 @@ +# 360 ImageCompare 前后对比 + +## 用途 + +前后两张图片叠放,用滑块分隔对比。 + +## 最小示例 + +```go +before, after := ui.NewBitmap(examplePhoto), ui.NewBitmap(examplePhoto) +split := 0.5 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ImageCompare(c, before, after, &split, mujica.ImageCompareOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ImageCompare(c *ui.Context, before, after ui.ImageSource, split *float64, opts ImageCompareOptions) ChoiceResult` + +`*split` 取 0 到 1,表示分隔线位置;超出 panic。两张图都须为 `*ui.Bitmap` 或 `*ui.SVG`,盒子按 `before` 的宽高比。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `BeforeLabel / AfterLabel` | `string` | "Before" / "After" | 两侧标签,也组成无障碍名称。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +分隔线与菱形手柄;两侧标签始终显示,不只靠画面区分。 + +## 事件 + +`Changed()` 在分隔位置变化后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / → | 移动 1% | +| Home / End | 最左 / 最右 | +| 拖动 | 任意位置拖动分隔线 | + +## 限制 + +- 两张图都按 Cover 铺满盒子,尺寸不同时会裁切。 diff --git a/example_media_test.go b/example_media_test.go new file mode 100644 index 0000000..52b53f1 --- /dev/null +++ b/example_media_test.go @@ -0,0 +1,246 @@ +package mujica_test + +import ( + "image" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +// examplePhoto stands in for a decoded picture. +var examplePhoto = image.NewRGBA(image.Rect(0, 0, 320, 180)) + +func ExampleImageCropper() { + crop := mujica.ImageCrop{} // empty: starts fitted to the image + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageCropper(c, examplePhoto, &crop, mujica.ImageCropperOptions{Aspect: 1}).Changed() { + _ = crop.Rect // in pixels of the photo turned by crop.Rotation + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleImageAnnotator() { + photo := ui.NewBitmap(examplePhoto) + var notes []mujica.Annotation + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageAnnotator(c, photo, ¬es, mujica.ImageAnnotatorOptions{}).Changed() { + data, _ := mujica.ExportAnnotations(notes) + _ = data // save the JSON + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleImageThumbnail() { + photo := ui.NewBitmap(examplePhoto) + selected := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageThumbnail(c, photo, mujica.ImageThumbnailOptions{Alt: "Grand hall", Aspect: 4.0 / 3, Selected: selected}).Changed() { + selected = !selected + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleLightbox() { + images := []mujica.ViewerImage{{Source: ui.NewBitmap(examplePhoto), Alt: "The grand hall"}} + open, index := false, 0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "View photos", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Lightbox(c, &open, &index, images) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVideoPlayer() { + st := mujica.VideoState{Duration: 4 * time.Minute, Rate: 1} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{ + OnPlay: func() { st.Playing = true }, // start your decoder + OnPause: func() { st.Playing = false }, // pause it + OnSeek: func(d time.Duration) { st.Position = d }, + OnRate: func(r float64) { st.Rate = r }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVideoScrubber() { + pos := 30 * time.Second + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoScrubber(c, &pos, mujica.VideoScrubberOptions{ + Duration: 2 * time.Minute, + Buffered: []mujica.MediaRange{{Start: 0, End: time.Minute}}, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVideoThumbnailStrip() { + frames := []mujica.StripFrame{{At: 0}, {At: 10 * time.Second}, {At: 20 * time.Second}} + current := 0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.VideoThumbnailStrip(c, ¤t, frames, mujica.VideoThumbnailStripOptions{}).Changed() { + _ = frames[current].At // seek the player there + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAudioPlayer() { + st := mujica.AudioState{Title: "Overture", Duration: 3 * time.Minute} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioPlayer(c, st, mujica.AudioPlayerOptions{ + OnPlay: func() { st.Playing = true }, + OnPause: func() { st.Playing = false }, + OnSeek: func(d time.Duration) { st.Position = d }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAudioWaveform() { + samples := make([]float32, 48000) // from your decoder, -1 to 1 + pos, sel := time.Duration(0), mujica.MediaRange{} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioWaveform(c, samples, mujica.AudioWaveformOptions{ + Duration: time.Second, Position: pos, Selection: &sel, + OnSeek: func(d time.Duration) { pos = d }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAudioSpectrum() { + bands := []float32{0.2, 0.6, 0.9, 0.4, 0.1} // set by your analyser, then c.Invalidate() + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVolumeControl() { + volume, muted := 0.8, false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeControl(c, &volume, &muted, mujica.VolumeControlOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePlaybackSpeedControl() { + rate := 1.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PlaybackSpeedControl(c, &rate, mujica.PlaybackSpeedControlOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMediaControls() { + st := mujica.MediaControlsState{} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MediaControls(c, st, mujica.MediaControlsOptions{ + OnPrevious: func() {}, OnNext: func() {}, + OnPlay: func() { st.Playing = true }, OnPause: func() { st.Playing = false }, + OnLoop: func(m mujica.LoopMode) { st.Loop = m }, OnShuffle: func(on bool) { st.Shuffle = on }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePlaylist() { + items := []mujica.PlaylistItem{{ID: "a", Title: "Overture"}, {ID: "b", Title: "Masquerade"}} + current := "a" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.Playlist(c, &items, mujica.PlaylistOptions{Current: current}); r.Played != "" { + current = r.Played + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSubtitleEditor() { + cues := []mujica.SubtitleCue{{Start: 0, End: 2 * time.Second, Text: "The curtain rises."}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SubtitleEditor(c, &cues, mujica.SubtitleEditorOptions{}) + if len(mujica.CheckSubtitles(cues)) > 0 { + _ = cues // block saving until the issues are fixed + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleScreenRecorderControls() { + st := mujica.RecorderState{} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScreenRecorderControls(c, st, mujica.ScreenRecorderControlsOptions{ + OnStart: func() { st.Status = mujica.RecorderRecording }, + OnPause: func() { st.Status = mujica.RecorderPaused }, + OnResume: func() { st.Status = mujica.RecorderRecording }, + OnStop: func() { st = mujica.RecorderState{} }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCameraPreview() { + frame := mujica.CameraFrame{Image: examplePhoto, Seq: 1} // a new Seq for each captured frame + mirror := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CameraPreview(c, frame, &mirror, mujica.CameraPreviewOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMicLevelMeter() { + level := float32(0.4) // the input's peak, 0 to 1 of full scale + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDeviceSelector() { + devices := mujica.MediaDevices{ + Microphones: []mujica.MediaDevice{{ID: "mic-1", Name: "Built-in Microphone"}}, + Speakers: []mujica.MediaDevice{{ID: "out-1", Name: "Headphones"}}, + } + sel := mujica.DeviceSelection{Microphone: "mic-1"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DeviceSelector(c, &sel, devices, mujica.DeviceSelectorOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleImageCompare() { + before, after := ui.NewBitmap(examplePhoto), ui.NewBitmap(examplePhoto) + split := 0.5 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ImageCompare(c, before, after, &split, mujica.ImageCompareOptions{}) + } + ui.Render(view, 400, 300, 1) +} diff --git a/examples/gallery/pages_media.go b/examples/gallery/pages_media.go index 92da6e4..dd1d945 100644 --- a/examples/gallery/pages_media.go +++ b/examples/gallery/pages_media.go @@ -1,3 +1,118 @@ package main -var mediaPages = []page{} +import ( + "fmt" + "image" + "image/color" + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/theme" +) + +const mediaCategory = "Media" + +var mediaPages = []page{ + {ID: "341", Name: "ImageCropper", ZhName: "图片裁剪", Category: mediaCategory, Demo: imageCropperDemo, Code: imageCropperCode, + Keys: "方向键移动裁剪框;Shift+方向键缩放;R / Shift+R 向右 / 向左旋转。"}, + {ID: "342", Name: "ImageAnnotator", ZhName: "图片标注", Category: mediaCategory, Demo: imageAnnotatorDemo, Code: imageAnnotatorCode, + Keys: "[ / ] 切换选中标注;方向键移动;Delete 删除;Escape 取消选择。"}, + {ID: "343", Name: "ImageThumbnail", ZhName: "缩略图", Category: mediaCategory, Demo: imageThumbnailDemo, Code: imageThumbnailCode, + Keys: "Tab 聚焦;Enter / Space 激活。"}, + {ID: "344", Name: "Lightbox", ZhName: "灯箱", Category: mediaCategory, Demo: lightboxDemo, Code: lightboxCode, + Keys: "← / → 切换;Home / End 首尾;Escape 关闭并恢复焦点。"}, + {ID: "345", Name: "VideoPlayer", ZhName: "视频播放器", Category: mediaCategory, Demo: videoPlayerDemo, Code: videoPlayerCode, + Keys: "画面聚焦时 Space / K 播放暂停,← / → 跳转 5 秒,F 全屏,C 字幕。"}, + {ID: "346", Name: "VideoScrubber", ZhName: "视频时间轴", Category: mediaCategory, Demo: videoScrubberDemo, Code: videoScrubberCode, + Keys: "← / → 跳转一个步长;Home / End 开头结尾。"}, + {ID: "347", Name: "VideoThumbnailStrip", ZhName: "缩略帧条", Category: mediaCategory, Demo: videoStripDemo, Code: videoStripCode, + Keys: "Tab 聚焦帧条;← / → 上下帧;Home / End 首尾帧。"}, + {ID: "348", Name: "AudioPlayer", ZhName: "音频播放器", Category: mediaCategory, Demo: audioPlayerDemo, Code: audioPlayerCode, + Keys: "Space 播放或暂停;进度条聚焦时 ← / → 跳转 5 秒。"}, + {ID: "349", Name: "AudioWaveform", ZhName: "音频波形", Category: mediaCategory, Demo: audioWaveformDemo, Code: audioWaveformCode, + Keys: "← / → 跳转 5 秒;Shift+← / → 调整选区;Escape 清除选区。"}, + {ID: "350", Name: "AudioSpectrum", ZhName: "音频频谱", Category: mediaCategory, Demo: audioSpectrumDemo, Code: audioSpectrumCode, + Keys: "Tab 聚焦;← / → 逐个读出频带;Home / End 首尾。"}, + {ID: "351", Name: "VolumeControl", ZhName: "音量控制", Category: mediaCategory, Demo: volumeDemo, Code: volumeCode, + Keys: "← / → 音量 ±5%;Home / End;M 静音。"}, + {ID: "352", Name: "PlaybackSpeedControl", ZhName: "倍速选择", Category: mediaCategory, Demo: speedDemo, Code: speedCode, + Keys: "Enter / Space 打开;↑ / ↓ 移动;Enter 确认;Escape 关闭。"}, + {ID: "353", Name: "MediaControls", ZhName: "媒体控制", Category: mediaCategory, Demo: mediaControlsDemo, Code: mediaControlsCode, + Keys: "Tab 进入;← / → 在按钮间移动;Enter / Space 按下。"}, + {ID: "354", Name: "Playlist", ZhName: "播放列表", Category: mediaCategory, Demo: playlistDemo, Code: playlistCode, + Keys: "↑ / ↓ 选择;Enter 播放;Alt+↑ / ↓ 移动;Delete 移除;可拖动排序。"}, + {ID: "355", Name: "SubtitleEditor", ZhName: "字幕编辑器", Category: mediaCategory, Demo: subtitleDemo, Code: subtitleCode, + Keys: "Tab 在一行的开始、结束、文本、删除间移动;时间格式 m:ss.mmm。"}, + {ID: "356", Name: "ScreenRecorderControls", ZhName: "录屏控制", Category: mediaCategory, Demo: recorderDemo, Code: recorderCode, + Keys: "Tab 在按钮间移动;Enter / Space 按下。"}, + {ID: "357", Name: "CameraPreview", ZhName: "摄像头预览", Category: mediaCategory, Demo: cameraDemo, Code: cameraCode, + Keys: "Tab 聚焦预览;M 切换镜像。"}, + {ID: "358", Name: "MicLevelMeter", ZhName: "麦克风电平", Category: mediaCategory, Demo: micDemo, Code: micCode, + Keys: "不获取焦点;读数随描述朗读。"}, + {ID: "359", Name: "DeviceSelector", ZhName: "设备选择", Category: mediaCategory, Demo: deviceDemo, Code: deviceCode, + Keys: "Tab 在选择框间移动;Enter 打开;输入字母跳转。"}, + {ID: "360", Name: "ImageCompare", ZhName: "前后对比", Category: mediaCategory, Demo: compareDemo, Code: compareCode, + Keys: "← / → 移动 1%;Home / End;可拖动。"}, +} + +// mediaSection titles a part of a demo. +func mediaSection(c *ui.Context, title string, fn func()) { + ui.Column(c).Gap(8).FillWidth().Children(func() { + ui.Text(c, title).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(mujica.Tokens(c).TextMuted) + fn() + }) +} + +// mediaCheck is a parameter checkbox that logs its changes. +func mediaCheck(c *ui.Context, log func(string), v *bool, label string) { + before := *v + mujica.Checkbox(c, v, label, mujica.CheckboxOptions{}) + if *v != before { + log(fmt.Sprintf("%s = %v", label, *v)) + } +} + +// mediaPicture draws a w×h court scene in variant hues. +func mediaPicture(w, h, variant int) *image.RGBA { + walls := []color.RGBA{{36, 28, 32, 255}, {242, 235, 221, 255}, {73, 42, 54, 255}} + arch := []color.RGBA{{125, 32, 52, 255}, {173, 145, 100, 255}, {53, 94, 128, 255}} + img := image.NewRGBA(image.Rect(0, 0, w, h)) + cx := float64(w) / 2 + for y := range h { + for x := range w { + c := walls[variant%3] + dx, dy := (float64(x)-cx)/(float64(w)*0.28), (float64(y)-float64(h)*0.55)/(float64(h)*0.5) + if dy > 0 && math.Abs(dx) < 1 || dx*dx+dy*dy < 1 { + c = arch[variant%3] + } + if x%(w/6) < w/40 || y > h*7/8 { + c = color.RGBA{189, 161, 114, 255} + } + if math.Abs(float64(x)-cx)/float64(w)+math.Abs(float64(y)-float64(h)*0.45)/float64(h) < 0.06 { + c = color.RGBA{251, 247, 239, 255} + } + img.Set(x, y, c) + } + } + return img +} + +var ( + mediaPhotos = []*image.RGBA{mediaPicture(480, 270, 0), mediaPicture(480, 270, 1), mediaPicture(360, 360, 2)} + mediaBitmaps = func() []*ui.Bitmap { + out := make([]*ui.Bitmap, len(mediaPhotos)) + for i, p := range mediaPhotos { + out[i] = ui.NewBitmap(p) + } + return out + }() +) + +// mediaClockLabel writes d as m:ss for event logs. +func mediaClockLabel(d time.Duration) string { + s := int(d / time.Second) + return fmt.Sprintf("%d:%02d", s/60, s%60) +} diff --git a/examples/gallery/pages_media_av.go b/examples/gallery/pages_media_av.go new file mode 100644 index 0000000..394b94a --- /dev/null +++ b/examples/gallery/pages_media_av.go @@ -0,0 +1,191 @@ +package main + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +// mediaClockState simulates the caller's playback: position advances while playing. +type mediaClockState struct { + pos, dur time.Duration + playing bool + rate float64 + last time.Time +} + +// tick advances the position; frames are asked only while playing. +func (m *mediaClockState) tick(c *ui.Context) { + now := c.Now() + if m.playing { + if !m.last.IsZero() { + m.pos += time.Duration(float64(now.Sub(m.last)) * m.rate) + } + if m.pos >= m.dur { + m.pos, m.playing = m.dur, false + } else { + c.AnimationFrame() + } + } + m.last = now +} + +func videoPlayerDemo(c *ui.Context, log func(string)) { + type state struct { + clock mediaClockState + subs, full, pip, noFrame bool + disabled bool + } + s := ui.Local(c.Root(), "345", func() state { + return state{clock: mediaClockState{dur: 3 * time.Minute, rate: 1}, subs: true} + }) + s.clock.tick(c) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + mediaCheck(c, log, &s.noFrame, "No frame") + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + cues := []string{"The curtain rises.", "Welcome to the masquerade.", "Ave Mujica."} + var frame ui.ImageSource = mediaBitmaps[int(s.clock.pos/(20*time.Second))%3] + if s.noFrame { + frame = nil + } + st := mujica.VideoState{Frame: frame, Position: s.clock.pos, Duration: s.clock.dur, Playing: s.clock.playing, + Rate: s.clock.rate, Buffered: []mujica.MediaRange{{Start: 0, End: s.clock.pos + 40*time.Second}}, + Subtitle: cues[int(s.clock.pos/(10*time.Second))%3], SubtitlesOn: s.subs, Fullscreen: s.full, PiP: s.pip} + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(640).Children(func() { + mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{ + OnPlay: func() { s.clock.playing = true; log("play") }, + OnPause: func() { s.clock.playing = false; log("pause") }, + OnSeek: func(d time.Duration) { s.clock.pos = d; log("seek " + mediaClockLabel(d)) }, + OnRate: func(r float64) { s.clock.rate = r; log(fmt.Sprintf("rate %v×", r)) }, + OnSubtitles: func(on bool) { s.subs = on; log(fmt.Sprintf("subtitles %v", on)) }, + OnFullscreen: func(on bool) { s.full = on; log(fmt.Sprintf("full screen requested %v", on)) }, + OnPiP: func(on bool) { s.pip = on; log(fmt.Sprintf("picture in picture requested %v", on)) }, + Disabled: s.disabled, + }) + }) + }) +} + +func videoScrubberDemo(c *ui.Context, log func(string)) { + type state struct { + pos time.Duration + preview, disabled bool + } + s := ui.Local(c.Root(), "346", func() state { return state{pos: 40 * time.Second, preview: true} }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + mediaCheck(c, log, &s.preview, "Preview slot") + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + opts := mujica.VideoScrubberOptions{Duration: 3 * time.Minute, Disabled: s.disabled, + Buffered: []mujica.MediaRange{{Start: 0, End: 90 * time.Second}, {Start: 120 * time.Second, End: 140 * time.Second}}} + if s.preview { + opts.Preview = func(at time.Duration) { + ui.Image(c, mediaBitmaps[int(at/(time.Minute))%3]).Fit(ui.Cover).Fill() + } + } + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(560).Padding(110, 0, 0, 0).Children(func() { + if mujica.VideoScrubber(c, &s.pos, opts).Changed() { + log("seek " + mediaClockLabel(s.pos)) + } + }) + }) +} + +func audioPlayerDemo(c *ui.Context, log func(string)) { + type state struct { + clock mediaClockState + disabled bool + } + s := ui.Local(c.Root(), "348", func() state { return state{clock: mediaClockState{dur: 4*time.Minute + 5*time.Second, rate: 1}} }) + s.clock.tick(c) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(560).Children(func() { + mujica.AudioPlayer(c, mujica.AudioState{Title: "Ave Mujica — Overture", Position: s.clock.pos, Duration: s.clock.dur, Playing: s.clock.playing}, + mujica.AudioPlayerOptions{ + OnPlay: func() { s.clock.playing = true; log("play") }, + OnPause: func() { s.clock.playing = false; log("pause") }, + OnSeek: func(d time.Duration) { s.clock.pos = d; log("seek " + mediaClockLabel(d)) }, + Disabled: s.disabled, + }) + }) + }) +} + +// mediaSamples is a synthetic voice-like recording, made once. +var mediaSamples = func() []float32 { + s := make([]float32, 120000) + for i := range s { + env := 0.25 + 0.7*math.Abs(math.Sin(float64(i)/9000))*math.Abs(math.Sin(float64(i)/2100)) + s[i] = float32(env * math.Sin(float64(i)/7)) + } + return s +}() + +func audioWaveformDemo(c *ui.Context, log func(string)) { + type state struct { + pos time.Duration + sel mujica.MediaRange + status int + selecting bool + } + s := ui.Local(c.Root(), "349", func() state { return state{pos: 20 * time.Second, selecting: true} }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + before := s.status + mujica.SegmentedControl(c, &s.status, []mujica.Segment{{Label: "Ready"}, {Label: "Loading"}, {Label: "Failed"}, {Label: "Empty"}}, mujica.SegmentedControlOptions{Label: "Status"}) + if s.status != before { + log(fmt.Sprintf("status %d", s.status)) + } + mediaCheck(c, log, &s.selecting, "Selectable") + }) + }) + opts := mujica.AudioWaveformOptions{Duration: time.Minute, Position: s.pos, OnSeek: func(d time.Duration) { s.pos = d; log("seek " + mediaClockLabel(d)) }} + if s.selecting { + opts.Selection = &s.sel + } + data := mediaSamples + switch s.status { + case 1: + opts.Status, data = mujica.DataLoading, nil + case 2: + opts.Status, opts.Error, data = mujica.DataFailed, "The recording could not be decoded.", nil + case 3: + data = nil + } + mediaSection(c, "Live", func() { + if mujica.AudioWaveform(c, data, opts).Changed() { + log(fmt.Sprintf("selection %s – %s", mediaClockLabel(s.sel.Start), mediaClockLabel(s.sel.End))) + } + }) +} + +func audioSpectrumDemo(c *ui.Context, log func(string)) { + type state struct{ live bool } + s := ui.Local(c.Root(), "350", func() state { return state{live: true} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.live, "Live input") }) + freqs := []string{"32 Hz", "63 Hz", "125 Hz", "250 Hz", "500 Hz", "1 kHz", "2 kHz", "4 kHz", "8 kHz", "16 kHz"} + bands := make([]float32, len(freqs)) + t := float64(c.Now().UnixMilli()) / 1000 + for i := range bands { + bands[i] = float32(0.2 + 0.6*math.Abs(math.Sin(float64(i)*0.9+t*(1+float64(i)*0.2)))) + } + if s.live { + // The demo is its own analyser; the component only draws. + c.AnimationFrame() + } + mediaSection(c, "Live", func() { + mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{BandLabel: func(i int) string { return freqs[i] }}) + }) +} diff --git a/examples/gallery/pages_media_code.go b/examples/gallery/pages_media_code.go new file mode 100644 index 0000000..7a2528d --- /dev/null +++ b/examples/gallery/pages_media_code.go @@ -0,0 +1,178 @@ +package main + +// Media page snippets, each its compiled Example's body. +const ( + imageCropperCode = `crop := mujica.ImageCrop{} // empty: starts fitted to the image +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageCropper(c, examplePhoto, &crop, mujica.ImageCropperOptions{Aspect: 1}).Changed() { + _ = crop.Rect // in pixels of the photo turned by crop.Rotation + } +} +ui.Render(view, 400, 300, 1)` + imageAnnotatorCode = `photo := ui.NewBitmap(examplePhoto) +var notes []mujica.Annotation +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageAnnotator(c, photo, ¬es, mujica.ImageAnnotatorOptions{}).Changed() { + data, _ := mujica.ExportAnnotations(notes) + _ = data // save the JSON + } +} +ui.Render(view, 400, 300, 1)` + imageThumbnailCode = `photo := ui.NewBitmap(examplePhoto) +selected := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageThumbnail(c, photo, mujica.ImageThumbnailOptions{Alt: "Grand hall", Aspect: 4.0 / 3, Selected: selected}).Changed() { + selected = !selected + } +} +ui.Render(view, 400, 300, 1)` + lightboxCode = `images := []mujica.ViewerImage{{Source: ui.NewBitmap(examplePhoto), Alt: "The grand hall"}} +open, index := false, 0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "View photos", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Lightbox(c, &open, &index, images) +} +ui.Render(view, 400, 300, 1)` + videoPlayerCode = `st := mujica.VideoState{Duration: 4 * time.Minute, Rate: 1} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{ + OnPlay: func() { st.Playing = true }, // start your decoder + OnPause: func() { st.Playing = false }, // pause it + OnSeek: func(d time.Duration) { st.Position = d }, + OnRate: func(r float64) { st.Rate = r }, + }) +} +ui.Render(view, 400, 300, 1)` + videoScrubberCode = `pos := 30 * time.Second +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoScrubber(c, &pos, mujica.VideoScrubberOptions{ + Duration: 2 * time.Minute, + Buffered: []mujica.MediaRange{{Start: 0, End: time.Minute}}, + }) +} +ui.Render(view, 400, 300, 1)` + videoStripCode = `frames := []mujica.StripFrame{{At: 0}, {At: 10 * time.Second}, {At: 20 * time.Second}} +current := 0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.VideoThumbnailStrip(c, ¤t, frames, mujica.VideoThumbnailStripOptions{}).Changed() { + _ = frames[current].At // seek the player there + } +} +ui.Render(view, 400, 300, 1)` + audioPlayerCode = `st := mujica.AudioState{Title: "Overture", Duration: 3 * time.Minute} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioPlayer(c, st, mujica.AudioPlayerOptions{ + OnPlay: func() { st.Playing = true }, + OnPause: func() { st.Playing = false }, + OnSeek: func(d time.Duration) { st.Position = d }, + }) +} +ui.Render(view, 400, 300, 1)` + audioWaveformCode = `samples := make([]float32, 48000) // from your decoder, -1 to 1 +pos, sel := time.Duration(0), mujica.MediaRange{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioWaveform(c, samples, mujica.AudioWaveformOptions{ + Duration: time.Second, Position: pos, Selection: &sel, + OnSeek: func(d time.Duration) { pos = d }, + }) +} +ui.Render(view, 400, 300, 1)` + audioSpectrumCode = `bands := []float32{0.2, 0.6, 0.9, 0.4, 0.1} // set by your analyser, then c.Invalidate() +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{}) +} +ui.Render(view, 400, 300, 1)` + volumeCode = `volume, muted := 0.8, false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeControl(c, &volume, &muted, mujica.VolumeControlOptions{}) +} +ui.Render(view, 400, 300, 1)` + speedCode = `rate := 1.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PlaybackSpeedControl(c, &rate, mujica.PlaybackSpeedControlOptions{}) +} +ui.Render(view, 400, 300, 1)` + mediaControlsCode = `st := mujica.MediaControlsState{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MediaControls(c, st, mujica.MediaControlsOptions{ + OnPrevious: func() {}, OnNext: func() {}, + OnPlay: func() { st.Playing = true }, OnPause: func() { st.Playing = false }, + OnLoop: func(m mujica.LoopMode) { st.Loop = m }, OnShuffle: func(on bool) { st.Shuffle = on }, + }) +} +ui.Render(view, 400, 300, 1)` + playlistCode = `items := []mujica.PlaylistItem{{ID: "a", Title: "Overture"}, {ID: "b", Title: "Masquerade"}} +current := "a" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.Playlist(c, &items, mujica.PlaylistOptions{Current: current}); r.Played != "" { + current = r.Played + } +} +ui.Render(view, 400, 300, 1)` + subtitleCode = `cues := []mujica.SubtitleCue{{Start: 0, End: 2 * time.Second, Text: "The curtain rises."}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SubtitleEditor(c, &cues, mujica.SubtitleEditorOptions{}) + if len(mujica.CheckSubtitles(cues)) > 0 { + _ = cues // block saving until the issues are fixed + } +} +ui.Render(view, 400, 300, 1)` + recorderCode = `st := mujica.RecorderState{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScreenRecorderControls(c, st, mujica.ScreenRecorderControlsOptions{ + OnStart: func() { st.Status = mujica.RecorderRecording }, + OnPause: func() { st.Status = mujica.RecorderPaused }, + OnResume: func() { st.Status = mujica.RecorderRecording }, + OnStop: func() { st = mujica.RecorderState{} }, + }) +} +ui.Render(view, 400, 300, 1)` + cameraCode = `frame := mujica.CameraFrame{Image: examplePhoto, Seq: 1} // a new Seq for each captured frame +mirror := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CameraPreview(c, frame, &mirror, mujica.CameraPreviewOptions{}) +} +ui.Render(view, 400, 300, 1)` + micCode = `level := float32(0.4) // the input's peak, 0 to 1 of full scale +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) +} +ui.Render(view, 400, 300, 1)` + deviceCode = `devices := mujica.MediaDevices{ + Microphones: []mujica.MediaDevice{{ID: "mic-1", Name: "Built-in Microphone"}}, + Speakers: []mujica.MediaDevice{{ID: "out-1", Name: "Headphones"}}, +} +sel := mujica.DeviceSelection{Microphone: "mic-1"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DeviceSelector(c, &sel, devices, mujica.DeviceSelectorOptions{}) +} +ui.Render(view, 400, 300, 1)` + compareCode = `before, after := ui.NewBitmap(examplePhoto), ui.NewBitmap(examplePhoto) +split := 0.5 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ImageCompare(c, before, after, &split, mujica.ImageCompareOptions{}) +} +ui.Render(view, 400, 300, 1)` +) diff --git a/examples/gallery/pages_media_devices.go b/examples/gallery/pages_media_devices.go new file mode 100644 index 0000000..19527d7 --- /dev/null +++ b/examples/gallery/pages_media_devices.go @@ -0,0 +1,225 @@ +package main + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func volumeDemo(c *ui.Context, log func(string)) { + type state struct { + volume float64 + muted, disabled bool + } + s := ui.Local(c.Root(), "351", func() state { return state{volume: 0.7} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + if mujica.VolumeControl(c, &s.volume, &s.muted, mujica.VolumeControlOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("volume %.0f%%, muted %v", s.volume*100, s.muted)) + } + }) +} + +func speedDemo(c *ui.Context, log func(string)) { + type state struct { + rate float64 + disabled bool + } + s := ui.Local(c.Root(), "352", func() state { return state{rate: 1} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + if mujica.PlaybackSpeedControl(c, &s.rate, mujica.PlaybackSpeedControlOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("rate %v×", s.rate)) + } + }) +} + +func mediaControlsDemo(c *ui.Context, log func(string)) { + type state struct { + st mujica.MediaControlsState + track int + disabled bool + } + s := ui.Local(c.Root(), "353", func() state { return state{} }) + tracks := []string{"Ave Mujica", "Imprisoned XII", "KiLLKiSS", "Symbol I: △"} + s.st.NoPrevious, s.st.NoNext = s.track == 0, s.track == len(tracks)-1 + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + ui.Text(c, fmt.Sprintf("Now: %s (%d of %d)", tracks[s.track], s.track+1, len(tracks))) + mujica.MediaControls(c, s.st, mujica.MediaControlsOptions{ + OnPrevious: func() { s.track--; log("previous") }, + OnNext: func() { s.track++; log("next") }, + OnPlay: func() { s.st.Playing = true; log("play") }, + OnPause: func() { s.st.Playing = false; log("pause") }, + OnLoop: func(m mujica.LoopMode) { s.st.Loop = m; log(fmt.Sprintf("loop mode %d", m)) }, + OnShuffle: func(on bool) { s.st.Shuffle = on; log(fmt.Sprintf("shuffle %v", on)) }, + Disabled: s.disabled, + }) + }) +} + +func playlistDemo(c *ui.Context, log func(string)) { + type state struct { + items []mujica.PlaylistItem + current string + disabled bool + } + s := ui.Local(c.Root(), "354", func() state { + titles := []string{"Ave Mujica", "Imprisoned XII", "KiLLKiSS", "Symbol I: △", "Choir 'S' Choir", "Georgette Me, Georgette You"} + items := make([]mujica.PlaylistItem, len(titles)) + for i, t := range titles { + items[i] = mujica.PlaylistItem{ID: fmt.Sprint("t", i), Title: t, Subtitle: fmt.Sprintf("Act %d", i+1), Duration: time.Duration(200+i*17) * time.Second} + } + return state{items: items, current: "t1"} + }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + mediaCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Add track", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + id := fmt.Sprint("t", len(s.items)+int(time.Now().UnixNano()%1000000)) + s.items = append(s.items, mujica.PlaylistItem{ID: id, Title: "Encore " + id, Duration: 3 * time.Minute}) + log("added " + id) + } + }) + }) + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(520).Children(func() { + r := mujica.Playlist(c, &s.items, mujica.PlaylistOptions{Current: s.current, Disabled: s.disabled}) + switch { + case r.Played != "": + s.current = r.Played + log("play " + r.Played) + case r.Removed != "": + log("removed " + r.Removed) + case r.Moved: + log("reordered") + } + }) + }) +} + +func subtitleDemo(c *ui.Context, log func(string)) { + type state struct { + cues []mujica.SubtitleCue + disabled bool + } + s := ui.Local(c.Root(), "355", func() state { + return state{cues: []mujica.SubtitleCue{ + {Start: 0, End: 2500 * time.Millisecond, Text: "The curtain rises."}, + {Start: 2 * time.Second, End: 5 * time.Second, Text: "Welcome to the masquerade."}, + {Start: 6 * time.Second, End: 9 * time.Second, Text: "Ave Mujica."}, + }} + }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(720).Children(func() { + if mujica.SubtitleEditor(c, &s.cues, mujica.SubtitleEditorOptions{Height: 260, Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("%d cues, %d issues", len(s.cues), len(mujica.CheckSubtitles(s.cues)))) + } + }) + }) +} + +func recorderDemo(c *ui.Context, log func(string)) { + type state struct { + st mujica.RecorderState + since time.Time + disabled bool + accumulated time.Duration + } + s := ui.Local(c.Root(), "356", func() state { return state{} }) + if s.st.Status == mujica.RecorderRecording { + s.st.Elapsed = s.accumulated + c.Now().Sub(s.since) + // The clock changes each second; ask for that frame only. + c.After(time.Second - s.st.Elapsed%time.Second) + } + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + mujica.ScreenRecorderControls(c, s.st, mujica.ScreenRecorderControlsOptions{ + OnStart: func() { + s.st, s.since, s.accumulated = mujica.RecorderState{Status: mujica.RecorderRecording}, c.Now(), 0 + log("start") + }, + OnPause: func() { s.st.Status, s.accumulated = mujica.RecorderPaused, s.st.Elapsed; log("pause") }, + OnResume: func() { s.st.Status, s.since = mujica.RecorderRecording, c.Now(); log("resume") }, + OnStop: func() { log("stop at " + mediaClockLabel(s.st.Elapsed)); s.st = mujica.RecorderState{} }, + Disabled: s.disabled, + }) + }) +} + +func cameraDemo(c *ui.Context, log func(string)) { + type state struct { + mirror, signal, disabled bool + } + s := ui.Local(c.Root(), "357", func() state { return state{mirror: true, signal: true} }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + mediaCheck(c, log, &s.signal, "Signal") + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + frame := mujica.CameraFrame{} + if s.signal { + frame = mujica.CameraFrame{Image: mediaPhotos[2], Seq: 1} + } + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(480).Children(func() { + if mujica.CameraPreview(c, frame, &s.mirror, mujica.CameraPreviewOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("mirror %v", s.mirror)) + } + }) + }) +} + +func micDemo(c *ui.Context, log func(string)) { + type state struct{ live bool } + s := ui.Local(c.Root(), "358", func() state { return state{live: true} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.live, "Live input") }) + level := float32(0.3) + if s.live { + t := float64(c.Now().UnixMilli()) / 1000 + level = float32(math.Min(1, 0.15+0.8*math.Abs(math.Sin(t*1.3)*math.Sin(t*0.37)))) + c.AnimationFrame() + } + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(420).Children(func() { mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) }) + }) +} + +func deviceDemo(c *ui.Context, log func(string)) { + type state struct { + sel mujica.DeviceSelection + noCamera, disabled bool + } + s := ui.Local(c.Root(), "359", func() state { return state{sel: mujica.DeviceSelection{Microphone: "m1", Speaker: "s2"}} }) + devices := mujica.MediaDevices{ + Microphones: []mujica.MediaDevice{{ID: "m1", Name: "Built-in Microphone"}, {ID: "m2", Name: "Studio Condenser"}}, + Cameras: []mujica.MediaDevice{{ID: "c1", Name: "FaceTime HD Camera"}}, + Speakers: []mujica.MediaDevice{{ID: "s1", Name: "Built-in Speakers"}, {ID: "s2", Name: "Headphones"}}, + } + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + before := s.noCamera + mediaCheck(c, log, &s.noCamera, "No cameras") + if s.noCamera != before { + s.sel.Camera = "" + } + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + if s.noCamera { + devices.Cameras = []mujica.MediaDevice{} + } + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(480).Children(func() { + if mujica.DeviceSelector(c, &s.sel, devices, mujica.DeviceSelectorOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("microphone %q, camera %q, speaker %q", s.sel.Microphone, s.sel.Camera, s.sel.Speaker)) + } + }) + }) +} diff --git a/examples/gallery/pages_media_image.go b/examples/gallery/pages_media_image.go new file mode 100644 index 0000000..f62ff53 --- /dev/null +++ b/examples/gallery/pages_media_image.go @@ -0,0 +1,162 @@ +package main + +import ( + "errors" + "fmt" + "image" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func imageCropperDemo(c *ui.Context, log func(string)) { + type state struct { + crop mujica.ImageCrop + aspect int + disabled bool + } + s := ui.Local(c.Root(), "341", func() state { return state{} }) + aspects := []float64{0, 1, 16.0 / 9, 4.0 / 3} + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + before := s.aspect + mujica.SegmentedControl(c, &s.aspect, []mujica.Segment{{Label: "Free"}, {Label: "1:1"}, {Label: "16:9"}, {Label: "4:3"}}, mujica.SegmentedControlOptions{Label: "Aspect"}) + if s.aspect != before { + s.crop.Rect = image.Rectangle{} // refit to the new ratio + log(fmt.Sprintf("aspect = %v", aspects[s.aspect])) + } + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + mediaSection(c, "Live", func() { + if mujica.ImageCropper(c, mediaPhotos[0], &s.crop, mujica.ImageCropperOptions{Aspect: aspects[s.aspect], Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("crop %v rotated %d°", s.crop.Rect, s.crop.Rotation)) + } + }) +} + +func imageAnnotatorDemo(c *ui.Context, log func(string)) { + type state struct { + notes []mujica.Annotation + disabled bool + } + s := ui.Local(c.Root(), "342", func() state { + return state{notes: []mujica.Annotation{{Kind: mujica.AnnotateRect, From: image.Pt(150, 60), To: image.Pt(330, 230)}}} + }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + mediaCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Export JSON", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + data, err := mujica.ExportAnnotations(s.notes) + if err != nil { + log("export failed: " + err.Error()) + } else { + c.WriteClipboard(string(data)) + log(fmt.Sprintf("exported %d bytes to the clipboard", len(data))) + } + } + }) + }) + mediaSection(c, "Live", func() { + if mujica.ImageAnnotator(c, mediaBitmaps[1], &s.notes, mujica.ImageAnnotatorOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("annotations: %d", len(s.notes))) + } + }) +} + +func imageThumbnailDemo(c *ui.Context, log func(string)) { + type state struct { + selected int + loading, failed, disabled bool + } + s := ui.Local(c.Root(), "343", func() state { return state{} }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + mediaCheck(c, log, &s.loading, "Loading") + mediaCheck(c, log, &s.failed, "Failed") + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + mediaSection(c, "Live", func() { + ui.Row(c).Gap(12).Wrap().Children(func() { + for i, b := range mediaBitmaps { + var err error + if s.failed && i == 2 { + err = errors.New("the file could not be read") + } + o := mujica.ImageThumbnailOptions{Alt: fmt.Sprintf("Scene %d", i+1), Aspect: 4.0 / 3, Width: 120, + Selected: s.selected == i, Loading: s.loading && i == 1, Err: err, Disabled: s.disabled} + if mujica.ImageThumbnail(c, b, o).Changed() { + s.selected = i + log(fmt.Sprintf("selected scene %d", i+1)) + } + } + }) + }) +} + +func lightboxDemo(c *ui.Context, log func(string)) { + type state struct { + open bool + index int + } + s := ui.Local(c.Root(), "344", func() state { return state{} }) + images := []mujica.ViewerImage{ + {Source: mediaBitmaps[0], Alt: "The grand hall at dusk"}, + {Source: mediaBitmaps[1], Alt: "The ivory gallery"}, + {Source: mediaBitmaps[2], Alt: "The blue chapel"}, + } + mediaSection(c, "Live", func() { + ui.Row(c).Gap(12).Children(func() { + for i, im := range images { + if mujica.ImageThumbnail(c, im.Source, mujica.ImageThumbnailOptions{Alt: im.Alt, Aspect: 4.0 / 3}).Changed() { + s.open, s.index = true, i + log(fmt.Sprintf("open at %d", i+1)) + } + } + }) + was := s.open + if mujica.Lightbox(c, &s.open, &s.index, images).Changed() { + log(fmt.Sprintf("image %d", s.index+1)) + } + if was && !s.open { + log("closed") + } + }) +} + +func videoStripDemo(c *ui.Context, log func(string)) { + type state struct { + current int + disabled bool + } + s := ui.Local(c.Root(), "347", func() state { return state{} }) + frames := make([]mujica.StripFrame, 240) + for i := range frames { + frames[i] = mujica.StripFrame{Image: mediaBitmaps[i%3], At: time.Duration(i) * 5 * time.Second} + } + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + if mujica.VideoThumbnailStrip(c, &s.current, frames, mujica.VideoThumbnailStripOptions{Disabled: s.disabled}).Changed() { + log("jump to " + mediaClockLabel(frames[s.current].At)) + } + }) +} + +func compareDemo(c *ui.Context, log func(string)) { + type state struct { + split float64 + disabled bool + } + s := ui.Local(c.Root(), "360", func() state { return state{split: 0.5} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(560).Children(func() { + if mujica.ImageCompare(c, mediaBitmaps[0], mediaBitmaps[1], &s.split, mujica.ImageCompareOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("split %.0f%%", s.split*100)) + } + }) + }) +} diff --git a/image_annotator.go b/image_annotator.go new file mode 100644 index 0000000..90461d2 --- /dev/null +++ b/image_annotator.go @@ -0,0 +1,292 @@ +package mujica + +import ( + "encoding/json" + "fmt" + "image" + "log/slog" + "math" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// AnnotationKind is the shape of an Annotation. +type AnnotationKind int + +const ( + AnnotateRect AnnotationKind = iota + AnnotateArrow + AnnotateText +) + +// Annotation is a mark in image pixels; text at From. +type Annotation struct { + Kind AnnotationKind + From, To image.Point + Text string +} + +// annotationJSON is the exported form of an Annotation. +type annotationJSON struct { + Kind string `json:"kind"` + X0 int `json:"x0"` + Y0 int `json:"y0"` + X1 int `json:"x1"` + Y1 int `json:"y1"` + Text string `json:"text,omitempty"` +} + +// ExportAnnotations writes notes as JSON: kind, corners and text. +func ExportAnnotations(notes []Annotation) ([]byte, error) { + out := make([]annotationJSON, len(notes)) + for i, n := range notes { + out[i] = annotationJSON{[]string{"rect", "arrow", "text"}[n.Kind], n.From.X, n.From.Y, n.To.X, n.To.Y, n.Text} + } + return json.Marshal(out) +} + +// ImageAnnotatorOptions configure an ImageAnnotator. +type ImageAnnotatorOptions struct { + // Height is the stage's height; zero means 320. + Height float32 + Disabled bool + Label string +} + +type annotatorState struct { + tool int // 0 select, 1 rect, 2 arrow, 3 text + selected int + draft string + pressing bool + start, now image.Point + moved image.Point +} + +type annotatorKey struct{} + +// ImageAnnotator draws, selects, moves and deletes marks over src. +func ImageAnnotator(c *ui.Context, src ui.ImageSource, notes *[]Annotation, opts ImageAnnotatorOptions) ChoiceResult { + iw, ih := viewerSize(src) + size := image.Pt(int(iw), int(ih)) + for _, n := range *notes { + if n.Kind < AnnotateRect || n.Kind > AnnotateText || (n.Kind == AnnotateText && n.Text == "") { + panic(fmt.Sprintf("mujica: ImageAnnotator bad annotation %+v", n)) + } + } + if opts.Height < 0 { + panic("mujica: ImageAnnotator Height must not be negative") + } + h := opts.Height + if h == 0 { + h = 320 + } + k := Tokens(c) + u := Density(c).Unit() + before := slices.Clone(*notes) + col := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled) + st := ui.Local(col, annotatorKey{}, func() *annotatorState { return &annotatorState{selected: -1, draft: msg(c, "image_annotator.note")} }) + s := *st + if s.selected >= len(*notes) { + s.selected = -1 + } + col.Children(func() { + annotatorToolbar(c, k, s, notes) + stage := ui.Box(c).FillWidth().Height(h).Focusable().Clip().Radius(theme.ControlRadius). + Background(videoBlack).Label(annotatorLabel(c, opts.Label)).Cursor(ui.CursorCrosshair) + internal.Ring(stage, k.Focus, theme.ControlRadius) + r := stage.Bounds() + scale, ox, oy := cropperPlace(r, size) + if !stage.IsDisabled() && scale > 0 { + annotatorPointer(stage, s, notes, r, size, scale, ox, oy) + annotatorKeys(stage, s, notes, size) + } + if s.selected >= 0 { + n := (*notes)[s.selected] + stage.Description(fmt.Sprintf(msg(c, "image_annotator.selected"), s.selected+1, len(*notes), annotatorKind(c, n.Kind))) + } + list, sel, preview := slices.Clone(*notes), s.selected, annotatorPreview(s) + size := FontSize(c, theme.BodySize) + stage.Draw(func(p *ui.Painter, r ui.Rect) { + scale, ox, oy := cropperPlace(r, image.Pt(int(iw), int(ih))) + p.Image(src, ui.Rect{X: ox, Y: oy, W: iw * scale, H: ih * scale}, ui.FillBox) + at := func(pt image.Point) (float32, float32) { + return internal.Snap(p, ox+float32(pt.X)*scale), internal.Snap(p, oy+float32(pt.Y)*scale) + } + for i, n := range append(list, preview...) { + annotatorPaint(p, k, n, at, size, i == sel) + } + }) + }) + changed := !slices.Equal(before, *notes) + if changed { + slog.Debug("mujica: image_annotator changed", "count", len(*notes)) + } + return ChoiceResult{Element: col, changed: changed} +} + +func annotatorLabel(c *ui.Context, l string) string { + if l != "" { + return l + } + return msg(c, "image_annotator.label") +} + +func annotatorKind(c *ui.Context, k AnnotationKind) string { + return msg(c, []string{"image_annotator.rect", "image_annotator.arrow", "image_annotator.text"}[k]) +} + +// annotatorToolbar picks tools, edits text and deletes the selection. +func annotatorToolbar(c *ui.Context, k theme.Tokens, s *annotatorState, notes *[]Annotation) { + u := Density(c).Unit() + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Wrap().Children(func() { + SegmentedControl(c, &s.tool, []Segment{ + {Label: msg(c, "image_annotator.select"), Icon: mediaPointer, HideLabel: true}, + {Label: msg(c, "image_annotator.rect"), Icon: mediaRect, HideLabel: true}, + {Label: msg(c, "image_annotator.arrow"), Icon: mediaArrow, HideLabel: true}, + {Label: msg(c, "image_annotator.text"), Icon: mediaText, HideLabel: true}, + }, SegmentedControlOptions{Label: msg(c, "image_annotator.tool")}) + editing := s.selected >= 0 && (*notes)[s.selected].Kind == AnnotateText + if editing { + s.draft = (*notes)[s.selected].Text + } + if s.tool == 3 || editing { + in := TextInput(c, &s.draft, TextInputOptions{Label: msg(c, "image_annotator.textLabel")}) + in.Element.Width(180) + if editing && in.Changed() && s.draft != "" { + (*notes)[s.selected].Text = s.draft + } + } + del := iconAction(c, icons.Must("trash-2"), msg(c, "image_annotator.delete")).Disabled(s.selected < 0) + if del.Clicked() && !del.IsDisabled() { + *notes = slices.Delete(*notes, s.selected, s.selected+1) + s.selected = -1 + } + ui.Textf(c, msg(c, "image_annotator.count"), len(*notes)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) +} + +// annotatorPreview is the shape being drawn, if any. +func annotatorPreview(s *annotatorState) []Annotation { + if !s.pressing || s.tool == 0 || s.tool == 3 { + return nil + } + return []Annotation{{Kind: AnnotationKind(s.tool - 1), From: s.start, To: s.now}} +} + +// annotatorPointer draws shapes, selects by click, drags the selection. +func annotatorPointer(stage *ui.Element, s *annotatorState, notes *[]Annotation, r ui.Rect, size image.Point, scale, ox, oy float32) { + _, _, _ = stage.Dragged() + px, py, _ := stage.PointerPosition() + pt := image.Pt(int(math.Round(float64((px+r.X-ox)/scale))), int(math.Round(float64((py+r.Y-oy)/scale)))) + pt = image.Pt(max(0, min(size.X, pt.X)), max(0, min(size.Y, pt.Y))) + switch { + case stage.Pressed() && !s.pressing: + s.pressing, s.start, s.now, s.moved = true, pt, pt, image.Point{} + if s.tool == 0 { + s.selected = annotatorHit(*notes, pt, int(8/scale)+1) + } + case stage.Pressed(): + if s.tool == 0 && s.selected >= 0 { + d := pt.Sub(s.now) + n := &(*notes)[s.selected] + n.From, n.To = n.From.Add(d), n.To.Add(d) + } + s.now = pt + case s.pressing: + s.pressing = false + big := absInt(s.now.X-s.start.X) >= 4 || absInt(s.now.Y-s.start.Y) >= 4 + switch { + case s.tool == 3 && s.draft != "": + *notes = append(*notes, Annotation{Kind: AnnotateText, From: s.start, To: s.start, Text: s.draft}) + s.selected = len(*notes) - 1 + case (s.tool == 1 || s.tool == 2) && big: + *notes = append(*notes, Annotation{Kind: AnnotationKind(s.tool - 1), From: s.start, To: s.now}) + s.selected = len(*notes) - 1 + } + } +} + +// annotatorKeys: [ ] select, arrows move, Delete removes, Escape clears. +func annotatorKeys(stage *ui.Element, s *annotatorState, notes *[]Annotation, size image.Point) { + n := len(*notes) + step := max(1, size.X/100) + move := func(dx, dy int) { + if s.selected >= 0 { + a := &(*notes)[s.selected] + a.From, a.To = a.From.Add(image.Pt(dx, dy)), a.To.Add(image.Pt(dx, dy)) + } + } + switch { + case n > 0 && stage.Shortcut(0, ui.KeyBracketRight): + s.selected = (s.selected + 1) % n + case n > 0 && stage.Shortcut(0, ui.KeyBracketLeft): + s.selected = (s.selected - 1 + n) % n + case s.selected >= 0 && (stage.Shortcut(0, ui.KeyDelete) || stage.Shortcut(0, ui.KeyBackspace)): + *notes = slices.Delete(*notes, s.selected, s.selected+1) + s.selected = -1 + case stage.Shortcut(0, ui.KeyEscape): + s.selected = -1 + case stage.Shortcut(0, ui.KeyLeft): + move(-step, 0) + case stage.Shortcut(0, ui.KeyRight): + move(step, 0) + case stage.Shortcut(0, ui.KeyUp): + move(0, -step) + case stage.Shortcut(0, ui.KeyDown): + move(0, step) + } +} + +// annotatorHit is the topmost mark near pt, or -1. +func annotatorHit(notes []Annotation, pt image.Point, slop int) int { + for i := len(notes) - 1; i >= 0; i-- { + n := notes[i] + b := image.Rectangle{Min: n.From, Max: n.To}.Canon() + if n.Kind == AnnotateText { + b = image.Rectangle{Min: n.From, Max: n.From.Add(image.Pt(len(n.Text)*8, 18))} + } + if pt.In(b.Inset(-slop)) { + return i + } + } + return -1 +} + +func absInt(v int) int { return max(v, -v) } + +// annotatorPaint draws a mark with a halo for any picture. +func annotatorPaint(p *ui.Painter, k theme.Tokens, n Annotation, at func(image.Point) (float32, float32), size float32, selected bool) { + x0, y0 := at(n.From) + x1, y1 := at(n.To) + halo, ink := ui.Hex("#FBF7EF"), ui.Hex("#A12635") + switch n.Kind { + case AnnotateRect: + b := ui.Rect{X: min(x0, x1), Y: min(y0, y1), W: abs32(x1 - x0), H: abs32(y1 - y0)} + p.Stroke(b, halo, 0, 4) + p.Stroke(b, ink, 0, 2) + case AnnotateArrow: + var path ui.Path + path.MoveTo(x0, y0).LineTo(x1, y1) + a := math.Atan2(float64(y1-y0), float64(x1-x0)) + for _, d := range []float64{2.6, -2.6} { + path.MoveTo(x1, y1).LineTo(x1+12*float32(math.Cos(a+d)), y1+12*float32(math.Sin(a+d))) + } + p.StrokePath(&path, 4, halo) + p.StrokePath(&path, 2, ink) + case AnnotateText: + w, h := p.MeasureText(1000, ui.Span{Text: n.Text, Size: size}) + p.Fill(ui.Rect{X: x0, Y: y0, W: w + 8, H: h + 4}, halo, theme.ControlRadius) + p.Text(x0+4, y0+2, n.Text, size, ink) + x1, y1 = x0+w+8, y0+h+4 + } + if selected { + b := ui.Rect{X: min(x0, x1) - 4, Y: min(y0, y1) - 4, W: abs32(x1-x0) + 8, H: abs32(y1-y0) + 8} + p.StrokeDashed(b, k.Focus, 0, 1) + p.FillPath(internal.Diamond(internal.Snap(p, b.X), internal.Snap(p, b.Y), 8), k.Focus) + } +} diff --git a/image_annotator_test.go b/image_annotator_test.go new file mode 100644 index 0000000..2229d6a --- /dev/null +++ b/image_annotator_test.go @@ -0,0 +1,121 @@ +package mujica + +import ( + "image" + "testing" + + "github.com/egoist/mygo/ui" +) + +func annotatorTester(notes *[]Annotation, disabled bool, n *int) *ui.Tester { + src := mediaTestBitmap(400, 200) + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(440).Children(func() { + if ImageAnnotator(c, src, notes, ImageAnnotatorOptions{Height: 200, Disabled: disabled}).Changed() { + *n++ + } + }) + }, 520, 360) +} + +// annotatorAt maps image pixel (x, y) to window DIPs on the stage. +func annotatorAt(tt *ui.Tester, x, y float32) (float32, float32) { + r, _ := tt.Find("Annotations") + scale := min(r.W/400, r.H/200) + return r.X + (r.W-400*scale)/2 + x*scale, r.Y + (r.H-200*scale)/2 + y*scale +} + +func annotatorDrag(tt *ui.Tester, x0, y0, x1, y1 float32) { + ax, ay := annotatorAt(tt, x0, y0) + bx, by := annotatorAt(tt, x1, y1) + tt.Press(ax, ay) + tt.Move((ax+bx)/2, (ay+by)/2) + tt.Move(bx, by) + tt.Release(bx, by) +} + +func TestImageAnnotatorDrawShapes(t *testing.T) { + var notes []Annotation + n := 0 + tt := annotatorTester(¬es, false, &n) + if err := tt.Click("Rectangle"); err != nil { + t.Fatal(err) + } + annotatorDrag(tt, 40, 40, 140, 100) + if err := tt.Click("Arrow"); err != nil { + t.Fatal(err) + } + annotatorDrag(tt, 200, 150, 300, 60) + if err := tt.Click("Text"); err != nil { + t.Fatal(err) + } + x, y := annotatorAt(tt, 250, 20) + tt.ClickAt(x, y) + if len(notes) != 3 || n != 3 { + t.Fatalf("notes %+v, changes %d", notes, n) + } + near := func(p, q image.Point) bool { return absInt(p.X-q.X) <= 2 && absInt(p.Y-q.Y) <= 2 } + if notes[0].Kind != AnnotateRect || !near(notes[0].From, image.Pt(40, 40)) || !near(notes[0].To, image.Pt(140, 100)) { + t.Fatalf("rect %+v", notes[0]) + } + if notes[1].Kind != AnnotateArrow || !near(notes[1].To, image.Pt(300, 60)) { + t.Fatalf("arrow %+v", notes[1]) + } + if notes[2].Kind != AnnotateText || notes[2].Text != "Note" { + t.Fatalf("text %+v", notes[2]) + } +} + +func TestImageAnnotatorSelectMoveDelete(t *testing.T) { + notes := []Annotation{{Kind: AnnotateRect, From: image.Pt(40, 40), To: image.Pt(140, 100)}, {Kind: AnnotateArrow, From: image.Pt(200, 150), To: image.Pt(300, 60)}} + n := 0 + tt := annotatorTester(¬es, false, &n) + x, y := annotatorAt(tt, 90, 70) + tt.ClickAt(x, y) + if !tt.Focused("Annotations") { + t.Fatal("stage not focused") + } + tt.Key(0, ui.KeyRight) + if notes[0].From != image.Pt(44, 40) { + t.Fatalf("Right moved %+v", notes[0]) + } + tt.Key(0, ui.KeyBracketRight) + tt.Key(0, ui.KeyDelete) + if len(notes) != 1 || notes[0].Kind != AnnotateRect { + t.Fatalf("delete left %+v", notes) + } + data, err := ExportAnnotations(notes) + if err != nil || string(data) != `[{"kind":"rect","x0":44,"y0":40,"x1":144,"y1":100}]` { + t.Fatalf("export %s %v", data, err) + } +} + +func TestImageAnnotatorDisabledAndBad(t *testing.T) { + var notes []Annotation + n := 0 + tt := annotatorTester(¬es, true, &n) + _ = tt.Click("Rectangle") + annotatorDrag(tt, 40, 40, 140, 100) + if len(notes) != 0 || n != 0 { + t.Fatalf("disabled annotator drew %+v", notes) + } + bad := []Annotation{{Kind: AnnotateText}} + mediaPanics(t, "empty text", func(c *ui.Context) { + ImageAnnotator(c, mediaTestBitmap(10, 10), &bad, ImageAnnotatorOptions{}) + }) +} + +func TestImageAnnotatorSnapshot(t *testing.T) { + src := mediaTestBitmap(400, 200) + notes := []Annotation{ + {Kind: AnnotateRect, From: image.Pt(30, 40), To: image.Pt(150, 120)}, + {Kind: AnnotateArrow, From: image.Pt(330, 160), To: image.Pt(220, 80)}, + {Kind: AnnotateText, From: image.Pt(230, 30), To: image.Pt(230, 30), Text: "Chandelier"}, + } + snapshot(t, "image_annotator", 460, 320, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(420).Children(func() { + ImageAnnotator(c, src, ¬es, ImageAnnotatorOptions{Height: 220}) + }) + }) +} diff --git a/image_compare.go b/image_compare.go new file mode 100644 index 0000000..d621e6f --- /dev/null +++ b/image_compare.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ImageCompareOptions configure an ImageCompare. +type ImageCompareOptions struct { + // BeforeLabel and AfterLabel caption the sides; empty uses defaults. + BeforeLabel, AfterLabel string + Disabled bool +} + +// ImageCompare shows before left of *split, after right. +func ImageCompare(c *ui.Context, before, after ui.ImageSource, split *float64, opts ImageCompareOptions) ChoiceResult { + if *split < 0 || *split > 1 || math.IsNaN(*split) { + panic("mujica: ImageCompare split outside 0..1") + } + bw, bh := viewerSize(before) + viewerSize(after) + k := Tokens(c) + bl, al := opts.BeforeLabel, opts.AfterLabel + if bl == "" { + bl = msg(c, "image_compare.before") + } + if al == "" { + al = msg(c, "image_compare.after") + } + g, _ := sliderSetup(0, 1, 0.01, nil, false, c) + old := *split + var s *ui.Element + var next float64 + ui.Box(c).Disabled(opts.Disabled).Children(func() { + s, next, _ = sliderControl(c, old, g, 0, 1) + }) + s.FillWidth().AspectRatio(bw / bh).FocusRing(false).Cursor(ui.CursorResizeEW).Clip().Radius(theme.ControlRadius). + Label(bl + " / " + al) + internal.Ring(s, k.Focus, theme.ControlRadius) + if !s.IsDisabled() { + *split = next + } + changed := *split != old + if changed { + slog.Debug("mujica: image_compare split", "split", *split) + } + v, size := float32(*split), FontSize(c, theme.CaptionSize) + s.Draw(func(p *ui.Painter, r ui.Rect) { + x := internal.Snap(p, r.X+r.W*v) + p.Image(after, r, ui.Cover) + p.Clip(ui.Rect{X: r.X, Y: r.Y, W: x - r.X, H: r.H}, 0, func() { p.Image(before, r, ui.Cover) }) + compareTag(p, k, r.X+8, r.Y+8, bl, size) + w, _ := p.MeasureText(1000, ui.Span{Text: al, Size: size}) + compareTag(p, k, r.X+r.W-w-20, r.Y+8, al, size) + p.Fill(ui.Rect{X: x - 1, Y: r.Y, W: 2, H: r.H}, k.Surface, 0) + cy := internal.Snap(p, r.Y+r.H/2) + p.FillPath(internal.Diamond(x, cy, 22), k.Surface) + p.FillPath(internal.Diamond(x, cy, 16), k.AccentText) + }) + return ChoiceResult{Element: s, changed: changed} +} + +// compareTag draws a caption chip at (x, y). +func compareTag(p *ui.Painter, k theme.Tokens, x, y float32, s string, size float32) { + w, h := p.MeasureText(1000, ui.Span{Text: s, Size: size}) + b := ui.Rect{X: x, Y: y, W: w + 12, H: h + 4} + p.Fill(b, k.Surface.Alpha(0.88), theme.ControlRadius) + p.Text(b.X+6, b.Y+2, s, size, k.Text) +} diff --git a/image_compare_test.go b/image_compare_test.go new file mode 100644 index 0000000..89c476b --- /dev/null +++ b/image_compare_test.go @@ -0,0 +1,73 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func compareTester(split *float64, disabled bool, n *int) *ui.Tester { + before, after := mediaTestBitmap(160, 90), mediaTestBitmap(160, 90) + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(320).Children(func() { + if ImageCompare(c, before, after, split, ImageCompareOptions{Disabled: disabled}).Changed() { + *n++ + } + }) + }, 400, 300) +} + +func TestImageCompareKeysAndDrag(t *testing.T) { + split, n := 0.5, 0 + tt := compareTester(&split, false, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if split != 0.51 { + t.Fatalf("Right: %v", split) + } + tt.Key(0, ui.KeyHome) + if split != 0 { + t.Fatalf("Home: %v", split) + } + r, ok := tt.Find("Before / After") + if !ok { + t.Fatal("not named") + } + tt.Press(r.X+r.W/2, r.Y+r.H/2) + tt.Move(r.X+r.W*0.75, r.Y+r.H/2) + tt.Release(r.X+r.W*0.75, r.Y+r.H/2) + if split < 0.7 || split > 0.8 { + t.Fatalf("drag: %v", split) + } + if n < 3 { + t.Fatalf("changes %d", n) + } +} + +func TestImageCompareDisabled(t *testing.T) { + split, n := 0.5, 0 + tt := compareTester(&split, true, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + r, _ := tt.Find("Before / After") + tt.ClickAt(r.X+10, r.Y+10) + if split != 0.5 || n != 0 { + t.Fatalf("disabled moved to %v", split) + } + bad := 2.0 + mediaPanics(t, "split over 1", func(c *ui.Context) { + ImageCompare(c, mediaTestBitmap(4, 4), mediaTestBitmap(4, 4), &bad, ImageCompareOptions{}) + }) +} + +func TestImageCompareSnapshot(t *testing.T) { + split := 0.4 + before := mediaTestBitmap(160, 90) + after := ui.NewBitmap(mediaTestImage(90, 160)) + snapshot(t, "image_compare", 360, 220, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(320).Children(func() { + ImageCompare(c, before, after, &split, ImageCompareOptions{}) + }) + }) +} diff --git a/image_cropper.go b/image_cropper.go new file mode 100644 index 0000000..aac97bb --- /dev/null +++ b/image_cropper.go @@ -0,0 +1,245 @@ +package mujica + +import ( + "fmt" + "image" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ImageCrop is Rect in pixels of the turned image. +type ImageCrop struct { + Rect image.Rectangle + // Rotation is 0, 90, 180 or 270. + Rotation int +} + +// ImageCropperOptions configure an ImageCropper. +type ImageCropperOptions struct { + // Aspect locks width over height; zero is free. + Aspect float64 + // Height is the stage's height; zero means 320. + Height float32 + Disabled bool + Label string +} + +type cropperCache struct { + src image.Image + rot int + bmp *ui.Bitmap +} + +type cropperDrag struct { + mode int // 0 none, 1 move, 2-5 corners + start image.Rectangle + dx, dy float32 +} + +type cropperKey struct{} + +// ImageCropper edits *crop over src; empty Rect starts fitted. +func ImageCropper(c *ui.Context, src image.Image, crop *ImageCrop, opts ImageCropperOptions) ChoiceResult { + if src == nil || src.Bounds().Empty() || opts.Aspect < 0 || opts.Height < 0 { + panic("mujica: ImageCropper needs an image, Aspect >= 0 and Height >= 0") + } + if crop.Rotation%90 != 0 || crop.Rotation < 0 || crop.Rotation >= 360 { + panic(fmt.Sprintf("mujica: ImageCropper Rotation %d is not 0, 90, 180 or 270", crop.Rotation)) + } + size := cropperSize(src, crop.Rotation) + if crop.Rect.Empty() { + crop.Rect = cropperFit(size, opts.Aspect) + } + if !crop.Rect.In(image.Rectangle{Max: size}) { + panic(fmt.Sprintf("mujica: ImageCropper Rect %v outside the %v image", crop.Rect, size)) + } + if opts.Aspect > 0 && math.Abs(float64(crop.Rect.Dx())-float64(crop.Rect.Dy())*opts.Aspect) > 1+opts.Aspect { + panic(fmt.Sprintf("mujica: ImageCropper Rect %v does not keep Aspect %v", crop.Rect, opts.Aspect)) + } + h := opts.Height + if h == 0 { + h = 320 + } + k := Tokens(c) + u := Density(c).Unit() + old := *crop + label := opts.Label + if label == "" { + label = msg(c, "image_cropper.label") + } + col := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled) + col.Children(func() { + stage := ui.Box(c).FillWidth().Height(h).Focusable().Clip().Radius(theme.ControlRadius). + Background(videoBlack).Label(label).Cursor(ui.CursorMove) + internal.Ring(stage, k.Focus, theme.ControlRadius) + r := stage.Bounds() + scale, ox, oy := cropperPlace(r, size) + live := !stage.IsDisabled() + if live && scale > 0 { + cropperPointer(stage, crop, r, size, scale, ox, oy, opts.Aspect) + cropperKeys(stage, crop, size, opts.Aspect) + } + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + left := iconAction(c, mediaRotateLeft, msg(c, "image_cropper.rotateLeft")) + right := iconAction(c, mediaRotate, msg(c, "image_cropper.rotateRight")) + switch { + case live && (right.Clicked() || stage.Shortcut(0, ui.KeyR)): + cropperRotate(crop, size, 90, opts.Aspect) + case live && (left.Clicked() || stage.Shortcut(ui.Shift, ui.KeyR)): + cropperRotate(crop, size, 270, opts.Aspect) + } + ui.Text(c, cropperReadout(*crop)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum") + }) + stage.Description(cropperReadout(*crop)) + // Turned pixels are copied only when image or rotation change. + cache := ui.Local(stage, cropperKey{}, func() cropperCache { return cropperCache{} }) + if cache.src != src || cache.rot != crop.Rotation || cache.bmp == nil { + *cache = cropperCache{src, crop.Rotation, ui.NewBitmap(cropperTurn(src, crop.Rotation))} + } + size := cropperSize(src, crop.Rotation) + bmp, rect := cache.bmp, crop.Rect + stage.Draw(func(p *ui.Painter, r ui.Rect) { + scale, ox, oy := cropperPlace(r, size) + img := ui.Rect{X: ox, Y: oy, W: float32(size.X) * scale, H: float32(size.Y) * scale} + p.Image(bmp, img, ui.FillBox) + f := ui.Rect{X: internal.Snap(p, ox+float32(rect.Min.X)*scale), Y: internal.Snap(p, oy+float32(rect.Min.Y)*scale), + W: internal.Snap(p, float32(rect.Dx())*scale), H: internal.Snap(p, float32(rect.Dy())*scale)} + shade := ui.RGBA(0, 0, 0, 0.55) + p.Fill(ui.Rect{X: img.X, Y: img.Y, W: img.W, H: f.Y - img.Y}, shade, 0) + p.Fill(ui.Rect{X: img.X, Y: f.Y + f.H, W: img.W, H: img.Y + img.H - f.Y - f.H}, shade, 0) + p.Fill(ui.Rect{X: img.X, Y: f.Y, W: f.X - img.X, H: f.H}, shade, 0) + p.Fill(ui.Rect{X: f.X + f.W, Y: f.Y, W: img.X + img.W - f.X - f.W, H: f.H}, shade, 0) + light := ui.Hex("#F2EBDD") + p.Stroke(f, light, 0, 1) + for i := 1; i < 3; i++ { + p.Fill(ui.Rect{X: f.X + f.W*float32(i)/3, Y: f.Y, W: 1, H: f.H}, light.Alpha(0.4), 0) + p.Fill(ui.Rect{X: f.X, Y: f.Y + f.H*float32(i)/3, W: f.W, H: 1}, light.Alpha(0.4), 0) + } + for _, pt := range [4][2]float32{{f.X, f.Y}, {f.X + f.W, f.Y}, {f.X, f.Y + f.H}, {f.X + f.W, f.Y + f.H}} { + p.FillPath(internal.Diamond(internal.Snap(p, pt[0]), internal.Snap(p, pt[1]), 12), light) + } + }) + }) + changed := *crop != old + if changed { + slog.Debug("mujica: image_cropper changed", "rect", crop.Rect, "rotation", crop.Rotation) + } + return ChoiceResult{Element: col, changed: changed} +} + +func cropperReadout(cr ImageCrop) string { + r := cr.Rect + return fmt.Sprintf("%d, %d · %d × %d · %d°", r.Min.X, r.Min.Y, r.Dx(), r.Dy(), cr.Rotation) +} + +// cropperSize is src's size after turning it rot degrees. +func cropperSize(src image.Image, rot int) image.Point { + s := src.Bounds().Size() + if rot == 90 || rot == 270 { + return image.Pt(s.Y, s.X) + } + return s +} + +// cropperFit is the largest centered aspect rectangle inside size. +func cropperFit(size image.Point, aspect float64) image.Rectangle { + w, h := size.X, size.Y + if aspect > 0 { + if float64(w) > float64(h)*aspect { + w = int(math.Round(float64(h) * aspect)) + } else { + h = int(math.Round(float64(w) / aspect)) + } + } + x, y := (size.X-w)/2, (size.Y-h)/2 + return image.Rect(x, y, x+w, y+h) +} + +// cropperPlace fits size in r: scale and top-left corner. +func cropperPlace(r ui.Rect, size image.Point) (scale, x, y float32) { + if r.W <= 0 { + return 0, 0, 0 + } + scale = min(r.W/float32(size.X), r.H/float32(size.Y)) + return scale, r.X + (r.W-float32(size.X)*scale)/2, r.Y + (r.H-float32(size.Y)*scale)/2 +} + +// cropperTurn copies src turned rot degrees clockwise. +func cropperTurn(src image.Image, rot int) image.Image { + if rot == 0 { + return src + } + b := src.Bounds() + size := cropperSize(src, rot) + out := image.NewRGBA(image.Rectangle{Max: size}) + for y := 0; y < b.Dy(); y++ { + for x := 0; x < b.Dx(); x++ { + var nx, ny int + switch rot { + case 90: + nx, ny = b.Dy()-1-y, x + case 180: + nx, ny = b.Dx()-1-x, b.Dy()-1-y + case 270: + nx, ny = y, b.Dx()-1-x + } + out.Set(nx, ny, src.At(b.Min.X+x, b.Min.Y+y)) + } + } + return out +} + +// cropperRotate turns the crop d degrees; locked aspects refit. +func cropperRotate(cr *ImageCrop, size image.Point, d int, aspect float64) { + r := cr.Rect + for range d / 90 { + r = image.Rect(size.Y-r.Max.Y, r.Min.X, size.Y-r.Min.Y, r.Max.X) + size = image.Pt(size.Y, size.X) + } + cr.Rotation = (cr.Rotation + d) % 360 + cr.Rect = r + if aspect > 0 { + cr.Rect = cropperFit(size, aspect) + } +} + +// cropperPointer moves the frame, or resizes it by a corner. +func cropperPointer(stage *ui.Element, cr *ImageCrop, r ui.Rect, size image.Point, scale, ox, oy float32, aspect float64) { + d := ui.Local(stage, cropperDrag{}, func() cropperDrag { return cropperDrag{} }) + dx, dy, ok := stage.Dragged() + if !stage.Pressed() { + d.mode = 0 + return + } + if d.mode == 0 { + px, py, _ := stage.PointerPosition() + px, py = (px+r.X-ox)/scale, (py+r.Y-oy)/scale + d.start, d.dx, d.dy, d.mode = cr.Rect, 0, 0, cropperHit(cr.Rect, px, py, 14/scale) + } + if !ok || d.mode == 0 { + return + } + d.dx, d.dy = d.dx+dx/scale, d.dy+dy/scale + cr.Rect = cropperDragRect(d.start, d.mode, int(math.Round(float64(d.dx))), int(math.Round(float64(d.dy))), size, aspect) +} + +// cropperHit says what a press grabs: corner, inside or nothing. +func cropperHit(r image.Rectangle, x, y, slop float32) int { + corners := [4][2]int{{r.Min.X, r.Min.Y}, {r.Max.X, r.Min.Y}, {r.Min.X, r.Max.Y}, {r.Max.X, r.Max.Y}} + for i, c := range corners { + if abs32(x-float32(c[0])) <= slop && abs32(y-float32(c[1])) <= slop { + return 2 + i + } + } + if x >= float32(r.Min.X) && x <= float32(r.Max.X) && y >= float32(r.Min.Y) && y <= float32(r.Max.Y) { + return 1 + } + return 0 +} + +func abs32(v float32) float32 { return float32(math.Abs(float64(v))) } diff --git a/image_cropper_geom.go b/image_cropper_geom.go new file mode 100644 index 0000000..7d39e42 --- /dev/null +++ b/image_cropper_geom.go @@ -0,0 +1,81 @@ +package mujica + +import ( + "image" + "math" + + "github.com/egoist/mygo/ui" +) + +// cropperMin is the smallest crop side in pixels. +const cropperMin = 8 + +// cropperDragRect moves (mode 1) or corner-resizes (2-5) start. +func cropperDragRect(start image.Rectangle, mode, dx, dy int, size image.Point, aspect float64) image.Rectangle { + if mode == 1 { + dx = max(-start.Min.X, min(size.X-start.Max.X, dx)) + dy = max(-start.Min.Y, min(size.Y-start.Max.Y, dy)) + return start.Add(image.Pt(dx, dy)) + } + corners := [4]image.Point{start.Min, {start.Max.X, start.Min.Y}, {start.Min.X, start.Max.Y}, start.Max} + moving, anchor := corners[mode-2], corners[5-mode] + x := max(0, min(size.X, moving.X+dx)) + y := max(0, min(size.Y, moving.Y+dy)) + sx, sy := 1, 1 + room := image.Pt(size.X-anchor.X, size.Y-anchor.Y) + if x < anchor.X { + sx, room.X = -1, anchor.X + } + if y < anchor.Y { + sy, room.Y = -1, anchor.Y + } + w, h := cropperClampSize(max(cropperMin, (x-anchor.X)*sx), max(cropperMin, (y-anchor.Y)*sy), room, aspect) + return image.Rectangle{Min: anchor, Max: anchor.Add(image.Pt(w*sx, h*sy))}.Canon() +} + +// cropperClampSize fits w×h inside room, keeping aspect when locked. +func cropperClampSize(w, h int, room image.Point, aspect float64) (int, int) { + w, h = min(w, room.X), min(h, room.Y) + if aspect <= 0 { + return w, h + } + h = int(math.Round(float64(w) / aspect)) + if h > room.Y { + h = room.Y + w = int(math.Round(float64(h) * aspect)) + } + return w, h +} + +// cropperKeys: arrows move, Shift+arrows resize, 1% per press. +func cropperKeys(stage *ui.Element, cr *ImageCrop, size image.Point, aspect float64) { + step := max(1, size.X/100) + r := cr.Rect + move := func(dx, dy int) { cr.Rect = cropperDragRect(r, 1, dx, dy, size, aspect) } + grow := func(dw, dh int) { + w, h := max(cropperMin, r.Dx()+dw), max(cropperMin, r.Dy()+dh) + if aspect > 0 && dw == 0 { + w = int(math.Round(float64(h) * aspect)) + } + w, h = cropperClampSize(w, h, image.Pt(size.X-r.Min.X, size.Y-r.Min.Y), aspect) + cr.Rect = image.Rectangle{Min: r.Min, Max: r.Min.Add(image.Pt(w, h))} + } + switch { + case stage.Shortcut(0, ui.KeyLeft): + move(-step, 0) + case stage.Shortcut(0, ui.KeyRight): + move(step, 0) + case stage.Shortcut(0, ui.KeyUp): + move(0, -step) + case stage.Shortcut(0, ui.KeyDown): + move(0, step) + case stage.Shortcut(ui.Shift, ui.KeyRight): + grow(step, 0) + case stage.Shortcut(ui.Shift, ui.KeyLeft): + grow(-step, 0) + case stage.Shortcut(ui.Shift, ui.KeyDown): + grow(0, step) + case stage.Shortcut(ui.Shift, ui.KeyUp): + grow(0, -step) + } +} diff --git a/image_cropper_test.go b/image_cropper_test.go new file mode 100644 index 0000000..9ba67a5 --- /dev/null +++ b/image_cropper_test.go @@ -0,0 +1,101 @@ +package mujica + +import ( + "image" + "testing" + + "github.com/egoist/mygo/ui" +) + +func cropperTester(src image.Image, crop *ImageCrop, opts ImageCropperOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(440).Children(func() { + if ImageCropper(c, src, crop, opts).Changed() { + *n++ + } + }) + }, 500, 420) +} + +func TestImageCropperDefaultAndKeys(t *testing.T) { + crop, n := ImageCrop{}, 0 + tt := cropperTester(mediaTestImage(400, 200), &crop, ImageCropperOptions{Aspect: 1, Height: 200}, &n) + if crop.Rect != image.Rect(100, 0, 300, 200) { + t.Fatalf("default %v", crop.Rect) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if crop.Rect != image.Rect(104, 0, 304, 200) { + t.Fatalf("Right: %v", crop.Rect) + } + tt.Key(ui.Shift, ui.KeyLeft) + if crop.Rect.Dx() != crop.Rect.Dy() || crop.Rect.Dx() != 196 { + t.Fatalf("Shift+Left keeps 1:1: %v", crop.Rect) + } + if n != 2 || !tt.HasText("104, 0 · 196 × 196 · 0°") { + t.Fatalf("changes %d, texts %v", n, tt.Texts()) + } +} + +func TestImageCropperRotate(t *testing.T) { + crop, n := ImageCrop{Rect: image.Rect(10, 20, 110, 70)}, 0 + tt := cropperTester(mediaTestImage(400, 200), &crop, ImageCropperOptions{Height: 200}, &n) + if err := tt.Click("Rotate right"); err != nil { + t.Fatal(err) + } + if crop.Rotation != 90 || crop.Rect != image.Rect(130, 10, 180, 110) { + t.Fatalf("rotated %+v", crop) + } + tt.Key(0, ui.KeyTab) + tt.Key(ui.Shift, ui.KeyR) + if crop.Rotation != 0 || crop.Rect != image.Rect(10, 20, 110, 70) { + t.Fatalf("rotated back %+v", crop) + } +} + +func TestImageCropperDragCorner(t *testing.T) { + crop, n := ImageCrop{Rect: image.Rect(0, 0, 200, 100)}, 0 + tt := cropperTester(mediaTestImage(400, 200), &crop, ImageCropperOptions{Aspect: 2, Height: 200}, &n) + r, _ := tt.Find("Crop area") + scale := min(r.W/400, r.H/200) + ox, oy := r.X+(r.W-400*scale)/2, r.Y+(r.H-200*scale)/2 + cx, cy := ox+200*scale, oy+100*scale + tt.Press(cx, cy) + tt.Move(cx+50*scale, cy+5*scale) + tt.Move(cx+100*scale, cy+10*scale) + tt.Release(cx+100*scale, cy+10*scale) + if crop.Rect.Min != (image.Point{}) || crop.Rect.Dx() < 290 || crop.Rect.Dx() > 310 || crop.Rect.Dx() != 2*crop.Rect.Dy() { + t.Fatalf("corner drag %v", crop.Rect) + } +} + +func TestImageCropperDisabledAndBad(t *testing.T) { + crop, n := ImageCrop{}, 0 + tt := cropperTester(mediaTestImage(100, 100), &crop, ImageCropperOptions{Disabled: true}, &n) + start := crop + _ = tt.Click("Rotate right") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if crop != start || n != 0 { + t.Fatalf("disabled cropper changed %+v", crop) + } + img := mediaTestImage(100, 100) + mediaPanics(t, "outside", func(c *ui.Context) { + ImageCropper(c, img, &ImageCrop{Rect: image.Rect(0, 0, 200, 10)}, ImageCropperOptions{}) + }) + mediaPanics(t, "rotation", func(c *ui.Context) { ImageCropper(c, img, &ImageCrop{Rotation: 45}, ImageCropperOptions{}) }) + mediaPanics(t, "aspect", func(c *ui.Context) { + ImageCropper(c, img, &ImageCrop{Rect: image.Rect(0, 0, 50, 10)}, ImageCropperOptions{Aspect: 1}) + }) +} + +func TestImageCropperSnapshot(t *testing.T) { + img := mediaTestImage(320, 180) + crop := ImageCrop{Rect: image.Rect(60, 30, 220, 150)} + snapshot(t, "image_cropper", 440, 300, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(400).Children(func() { + ImageCropper(c, img, &crop, ImageCropperOptions{Height: 220}) + }) + }) +} diff --git a/image_thumbnail.go b/image_thumbnail.go new file mode 100644 index 0000000..27d0c71 --- /dev/null +++ b/image_thumbnail.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ImageThumbnailOptions configure an ImageThumbnail. +type ImageThumbnailOptions struct { + // Width is the thumbnail's width; zero means 96. + Width float32 + // Aspect is width over height, cropped; zero is 1. + Aspect float32 + Selected bool + // Loading and Err show the load state at one size. + Loading bool + Err error + // Alt names the picture; required. + Alt string + Disabled bool +} + +// ImageThumbnail is a cropped picture; Changed reports activation. +func ImageThumbnail(c *ui.Context, src ui.ImageSource, opts ImageThumbnailOptions) ChoiceResult { + if opts.Width < 0 || opts.Aspect < 0 { + panic("mujica: ImageThumbnail needs Width and Aspect >= 0") + } + if opts.Alt == "" { + panic("mujica: ImageThumbnail needs Alt") + } + w, aspect := opts.Width, opts.Aspect + if w == 0 { + w = 96 + } + if aspect == 0 { + aspect = 1 + } + k := Tokens(c) + h := w / aspect + b := ui.ButtonBase(c).Size(w, h).Radius(theme.ControlRadius).Clip(). + Disabled(opts.Disabled).Label(opts.Alt).Selected(opts.Selected).Background(k.SurfaceHover) + internal.Ring(b, k.Focus, theme.ControlRadius) + edge, width := k.Border, theme.BorderWidth + switch { + case opts.Selected: + edge, width = k.AccentText, 2 + case b.Hovered(): + edge = k.ControlBorder + } + b.Border(width, edge).Transition(colorFade) + b.Children(func() { + switch { + case opts.Err != nil: + b.Description(opts.Err.Error()).Tooltip(opts.Err.Error()) + ui.Column(c).Gap(4).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(20, 20).TextColor(k.Danger) + if h >= 64 { + ui.Text(c, msg(c, "image_thumbnail.failed")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).SingleLine() + } + }) + case opts.Loading: + Spinner(c, SpinnerOptions{Size: min(20, h/2), Label: msg(c, "image_thumbnail.loading")}) + case src == nil: + ui.Icon(c, icons.Must("image")).Size(20, 20).TextColor(k.TextMuted) + default: + ui.Image(c, src).Fit(ui.Cover).Size(w, h) + } + if opts.Selected { + // A diamond mark, so selection never rests on color alone. + ui.Box(c).Absolute().Top(4).Right(4).Size(14, 14).PassThrough().Draw(func(p *ui.Painter, r ui.Rect) { + cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2) + p.FillPath(internal.Diamond(cx, cy, 14), k.Surface) + p.FillPath(internal.Diamond(cx, cy, 10), k.AccentText) + }) + } + }) + clicked := b.Clicked() && !b.IsDisabled() + if clicked { + slog.Debug("mujica: image_thumbnail click", "alt", opts.Alt) + } + return ChoiceResult{Element: b, changed: clicked} +} diff --git a/image_thumbnail_test.go b/image_thumbnail_test.go new file mode 100644 index 0000000..fe83abf --- /dev/null +++ b/image_thumbnail_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "errors" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestImageThumbnailClickAndKeys(t *testing.T) { + n := 0 + bmp := mediaTestBitmap(120, 80) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).AlignItems(ui.Start).Children(func() { + if ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Aspect: 4.0 / 3}).Changed() { + n++ + } + }) + }, 300, 200) + r, ok := tt.Find("Hall") + if !ok || r.W != 96 || r.H != 72 { + t.Fatalf("bounds %v", r) + } + tt.ClickAt(r.X+r.W/2, r.Y+r.H/2) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeySpace) + if n != 3 { + t.Fatalf("activations %d", n) + } +} + +func TestImageThumbnailStatesKeepSize(t *testing.T) { + var got []ui.Rect + for _, o := range []ImageThumbnailOptions{{}, {Loading: true}, {Err: errors.New("404")}, {Selected: true}} { + o.Alt, o.Width = "Hall", 120 + var r ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).AlignItems(ui.Start).Children(func() { + r = ImageThumbnail(c, mediaTestBitmap(40, 40), o).Element.Bounds() + }) + }, 300, 200) + tt.Frame() + got = append(got, r) + } + for _, r := range got { + if r != got[0] || r.W != 120 || r.H != 120 { + t.Fatalf("sizes %v", got) + } + } +} + +func TestImageThumbnailDisabledAndBad(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ImageThumbnail(c, nil, ImageThumbnailOptions{Alt: "Hall", Disabled: true}).Changed() { + n++ + } + }, 300, 200) + _ = tt.Click("Hall") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if n != 0 { + t.Fatal("disabled thumbnail activated") + } + mediaPanics(t, "no alt", func(c *ui.Context) { ImageThumbnail(c, nil, ImageThumbnailOptions{}) }) +} + +func TestImageThumbnailSnapshot(t *testing.T) { + bmp := mediaTestBitmap(160, 90) + snapshot(t, "image_thumbnail", 520, 120, func(c *ui.Context) { + ui.Row(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80}) + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Selected: true}) + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Loading: true}) + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Err: errors.New("404")}) + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Disabled: true}) + }) + }) +} diff --git a/lightbox.go b/lightbox.go new file mode 100644 index 0000000..4b19858 --- /dev/null +++ b/lightbox.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// Lightbox fills the window with images[*index] while *open. +func Lightbox(c *ui.Context, open *bool, index *int, images []ViewerImage) ChoiceResult { + var v ChoiceResult + if !*open { + return v + } + if len(images) == 0 || *index < 0 || *index >= len(images) { + panic("mujica: Lightbox index out of range") + } + for _, im := range images { + viewerSize(im.Source) + } + u := Density(c).Unit() + w, h := c.Size() + light := ui.Hex("#F2EBDD") + v.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + back.Background(ui.RGBA(0, 0, 0, 0.92)) + panel.Size(w, h).Padding(u * 4).Gap(u * 3).TextColor(light).Label(msg(c, "lightbox.title")). + Opacity(overlayEnter(c, panel)) + goTo := func(i int) { + if i < 0 || i >= len(images) || i == *index { + return + } + *index, v.changed = i, true + c.Announce(fmt.Sprintf(msg(c, "lightbox.position"), i+1, len(images))) + slog.Debug("mujica: lightbox image", "index", i) + } + switch { + case panel.Shortcut(0, ui.KeyLeft): + goTo(*index - 1) + case panel.Shortcut(0, ui.KeyRight): + goTo(*index + 1) + case panel.Shortcut(0, ui.KeyHome): + goTo(0) + case panel.Shortcut(0, ui.KeyEnd): + goTo(len(images) - 1) + } + img := images[*index] + ui.Row(c).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf(msg(c, "lightbox.position"), *index+1, len(images))).FontFeatures("tnum").Grow(1) + if lightboxButton(c, "x", "lightbox.close", false).Clicked() { + *open = false + } + }) + ui.Row(c).Grow(1).MinHeight(0).Gap(u * 3).AlignItems(ui.Center).Children(func() { + if b := lightboxButton(c, "chevron-left", "lightbox.previous", *index == 0); b.Clicked() && !b.IsDisabled() { + goTo(*index - 1) + } + ui.Box(c).Grow(1).FillHeight().MinWidth(0).Center().Children(func() { + ui.Image(c, img.Source).Fit(ui.Contain).Fill().Label(img.Alt) + }) + if b := lightboxButton(c, "chevron-right", "lightbox.next", *index == len(images)-1); b.Clicked() && !b.IsDisabled() { + goTo(*index + 1) + } + }) + if img.Alt != "" { + ui.Text(c, img.Alt).TextAlign(ui.Center).FillWidth().MaxLines(3) + } + }) + if !*open { + slog.Debug("mujica: lightbox close") + } + return v +} + +// lightboxButton is a 40 DIP icon button on the backdrop. +func lightboxButton(c *ui.Context, icon, key string, disabled bool) *ui.Element { + light := ui.Hex("#F2EBDD") + b := iconAction(c, icons.Must(icon), msg(c, key)).Size(40, 40).TextColor(light).Disabled(disabled) + // The theme's focus color is too dark on the backdrop. + internal.Ring(b, light, theme.ControlRadius) + if b.Hovered() { + b.Background(ui.RGBA(255, 255, 255, 0.12)).TextColor(ui.Hex("#FFFFFF")) + } + return b +} diff --git a/lightbox_test.go b/lightbox_test.go new file mode 100644 index 0000000..d50e70c --- /dev/null +++ b/lightbox_test.go @@ -0,0 +1,66 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func lightboxImages() []ViewerImage { + return []ViewerImage{ + {Source: mediaTestBitmap(160, 90), Alt: "The grand hall"}, + {Source: mediaTestBitmap(90, 160), Alt: "The east tower"}, + {Source: mediaTestBitmap(120, 120), Alt: "The garden"}, + } +} + +func TestLightboxBrowseAndClose(t *testing.T) { + open, index, n := false, 0, 0 + images := lightboxImages() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Open gallery", ButtonOptions{}).Clicked() { + open = true + } + if Lightbox(c, &open, &index, images).Changed() { + n++ + } + }, 640, 480) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if !open || !tt.HasText("The grand hall") || !tt.HasText("1 of 3") { + t.Fatalf("not open: %v", tt.Texts()) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if index != 2 || n != 2 || !tt.HasText("The garden") { + t.Fatalf("index %d changes %d", index, n) + } + if err := tt.Click("Previous image"); err != nil { + t.Fatal(err) + } + if index != 1 { + t.Fatalf("previous: %d", index) + } + tt.Key(0, ui.KeyEscape) + if open { + t.Fatal("Escape did not close") + } + if !tt.Focused("Open gallery") { + t.Fatal("focus not restored to the opener") + } +} + +func TestLightboxBadIndex(t *testing.T) { + open, index := true, 5 + mediaPanics(t, "index", func(c *ui.Context) { Lightbox(c, &open, &index, lightboxImages()) }) +} + +func TestLightboxSnapshot(t *testing.T) { + open, index := true, 0 + images := lightboxImages() + snapshot(t, "lightbox", 520, 360, func(c *ui.Context) { + Lightbox(c, &open, &index, images) + }) +} diff --git a/media_controls.go b/media_controls.go new file mode 100644 index 0000000..a0913d1 --- /dev/null +++ b/media_controls.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" +) + +// LoopMode is how playback repeats. The zero value repeats nothing. +type LoopMode int + +const ( + LoopOff LoopMode = iota + LoopAll + LoopOne +) + +// next cycles off, all, one. +func (m LoopMode) next() LoopMode { return (m + 1) % 3 } + +// MediaControlsState is what the caller's playback reports. +type MediaControlsState struct { + Playing bool + Loop LoopMode + Shuffle bool + // NoPrevious and NoNext disable those buttons at the ends. + NoPrevious, NoNext bool +} + +// MediaControlsOptions are the callbacks; every one is required. +type MediaControlsOptions struct { + OnPrevious, OnPlay, OnPause, OnNext func() + OnLoop func(LoopMode) + OnShuffle func(bool) + Disabled bool +} + +// MediaControls are previous, play, next, loop and shuffle buttons. +func MediaControls(c *ui.Context, st MediaControlsState, opts MediaControlsOptions) *ui.Element { + if opts.OnPrevious == nil || opts.OnPlay == nil || opts.OnPause == nil || opts.OnNext == nil || opts.OnLoop == nil || opts.OnShuffle == nil { + panic("mujica: MediaControls needs every callback") + } + if st.Loop < LoopOff || st.Loop > LoopOne { + panic(fmt.Sprintf("mujica: unknown LoopMode %d", st.Loop)) + } + row := ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar). + Label(msg(c, "media_controls.label")).Disabled(opts.Disabled) + row.Children(func() { + shuffle := mediaToggleAction(c, mediaShuffle, msg(c, "media_controls.shuffle"), st.Shuffle) + if shuffle.Clicked() && !shuffle.IsDisabled() { + slog.Debug("mujica: media_controls shuffle", "on", !st.Shuffle) + opts.OnShuffle(!st.Shuffle) + } + prev := iconAction(c, mediaPrev, msg(c, "media_controls.previous")).Disabled(st.NoPrevious) + if prev.Clicked() && !prev.IsDisabled() { + slog.Debug("mujica: media_controls previous") + opts.OnPrevious() + } + mediaPlayToggle(c, st.Playing, opts.OnPlay, opts.OnPause) + next := iconAction(c, mediaNext, msg(c, "media_controls.next")).Disabled(st.NoNext) + if next.Clicked() && !next.IsDisabled() { + slog.Debug("mujica: media_controls next") + opts.OnNext() + } + icon := mediaRepeat + if st.Loop == LoopOne { + icon = mediaRepeatOne + } + loop := mediaToggleAction(c, icon, msg(c, []string{"media_controls.loopOff", "media_controls.loopAll", "media_controls.loopOne"}[st.Loop]), st.Loop != LoopOff) + if loop.Clicked() && !loop.IsDisabled() { + slog.Debug("mujica: media_controls loop", "mode", st.Loop.next()) + opts.OnLoop(st.Loop.next()) + } + }) + return row +} diff --git a/media_controls_test.go b/media_controls_test.go new file mode 100644 index 0000000..9b2e220 --- /dev/null +++ b/media_controls_test.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type controlsCalls struct { + prev, play, pause, next int + loops []LoopMode + shuffles []bool +} + +func (m *controlsCalls) opts() MediaControlsOptions { + return MediaControlsOptions{OnPrevious: func() { m.prev++ }, OnPlay: func() { m.play++ }, OnPause: func() { m.pause++ }, + OnNext: func() { m.next++ }, OnLoop: func(l LoopMode) { m.loops = append(m.loops, l) }, + OnShuffle: func(on bool) { m.shuffles = append(m.shuffles, on) }} +} + +func controlsTester(st *MediaControlsState, o MediaControlsOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { MediaControls(c, *st, o) }) + }, 400, 120) +} + +func TestMediaControlsClicks(t *testing.T) { + st := MediaControlsState{Loop: LoopAll} + var m controlsCalls + tt := controlsTester(&st, m.opts()) + for _, name := range []string{"Previous", "Play", "Next", "Repeat all", "Shuffle"} { + if err := tt.Click(name); err != nil { + t.Fatal(err) + } + } + if m.prev != 1 || m.play != 1 || m.next != 1 || len(m.loops) != 1 || m.loops[0] != LoopOne || len(m.shuffles) != 1 || !m.shuffles[0] { + t.Fatalf("calls %+v", m) + } +} + +func TestMediaControlsArrowsMoveFocus(t *testing.T) { + st := MediaControlsState{} + var m controlsCalls + tt := controlsTester(&st, m.opts()) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Shuffle") { + t.Fatal("Tab did not enter at Shuffle") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if !tt.Focused("Play") { + t.Fatal("arrows did not reach Play") + } + tt.Key(0, ui.KeyEnter) + if m.play != 1 { + t.Fatalf("Enter: play %d", m.play) + } +} + +func TestMediaControlsEndsAndDisabled(t *testing.T) { + st := MediaControlsState{NoPrevious: true, NoNext: true} + var m controlsCalls + tt := controlsTester(&st, m.opts()) + _ = tt.Click("Previous") + _ = tt.Click("Next") + o := m.opts() + o.Disabled = true + off := controlsTester(&MediaControlsState{}, o) + _ = off.Click("Play") + _ = off.Click("Shuffle") + if m.prev != 0 || m.next != 0 || m.play != 0 || len(m.shuffles) != 0 { + t.Fatalf("blocked buttons reported %+v", m) + } +} + +func TestMediaControlsBadOptions(t *testing.T) { + mediaPanics(t, "no callbacks", func(c *ui.Context) { MediaControls(c, MediaControlsState{}, MediaControlsOptions{}) }) + var m controlsCalls + mediaPanics(t, "bad loop", func(c *ui.Context) { MediaControls(c, MediaControlsState{Loop: 7}, m.opts()) }) +} + +func TestMediaControlsSnapshot(t *testing.T) { + var m controlsCalls + snapshot(t, "media_controls", 300, 120, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + MediaControls(c, MediaControlsState{Playing: true, Loop: LoopOne, Shuffle: true}, m.opts()) + MediaControls(c, MediaControlsState{NoPrevious: true}, m.opts()) + }) + }) +} diff --git a/media_helpers_test.go b/media_helpers_test.go new file mode 100644 index 0000000..b6e7fc4 --- /dev/null +++ b/media_helpers_test.go @@ -0,0 +1,44 @@ +package mujica + +import ( + "image" + "image/color" + "testing" + + "github.com/egoist/mygo/ui" +) + +// mediaTestImage is a w×h picture whose left half is wine and right half ivory. +func mediaTestImage(w, h int) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + c := color.RGBA{125, 32, 52, 255} + if x >= w/2 { + c = color.RGBA{242, 235, 221, 255} + } + if y < h/8 { + c = color.RGBA{173, 145, 100, 255} + } + img.Set(x, y, c) + } + } + return img +} + +func mediaTestBitmap(w, h int) *ui.Bitmap { return ui.NewBitmap(mediaTestImage(w, h)) } + +// mediaPanics fails t unless fn panics. +func mediaPanics(t *testing.T, name string, fn func(c *ui.Context)) { + t.Helper() + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + fn(c) + }, 400, 300) + tt.Frame() +} diff --git a/media_icons.go b/media_icons.go new file mode 100644 index 0000000..ed5de5f --- /dev/null +++ b/media_icons.go @@ -0,0 +1,53 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" +) + +// mediaSVG wraps stroked 24×24 path data as an icon. +func mediaSVG(body string) *ui.SVG { + return ui.MustParseSVG([]byte(`` + body + ``)) +} + +// Media glyphs drawn for this group. +var ( + mediaPlay = mediaSVG(``) + mediaPause = mediaSVG(``) + mediaPrev = mediaSVG(``) + mediaNext = mediaSVG(``) + mediaRepeat = mediaSVG(``) + mediaRepeatOne = mediaSVG(``) + mediaShuffle = mediaSVG(``) + mediaVolume = mediaSVG(``) + mediaMute = mediaSVG(``) + mediaRotate = mediaSVG(``) + mediaRotateLeft = mediaSVG(``) + mediaMirror = mediaSVG(``) + mediaPiP = mediaSVG(``) + mediaFullscreen = mediaSVG(``) + mediaCaptions = mediaSVG(``) + mediaRecord = mediaSVG(``) + mediaStop = mediaSVG(``) + mediaMic = mediaSVG(``) + mediaCamera = mediaSVG(``) + mediaSpeaker = mediaSVG(``) + mediaRect = mediaSVG(``) + mediaArrow = mediaSVG(``) + mediaText = mediaSVG(``) + mediaPointer = mediaSVG(``) +) + +// mediaClock writes d as m:ss, or h:mm:ss past an hour. +func mediaClock(d time.Duration) string { + if d < 0 { + d = 0 + } + s := int(d / time.Second) + if s >= 3600 { + return fmt.Sprintf("%d:%02d:%02d", s/3600, s/60%60, s%60) + } + return fmt.Sprintf("%d:%02d", s/60, s%60) +} diff --git a/media_palette_test.go b/media_palette_test.go new file mode 100644 index 0000000..3deed2a --- /dev/null +++ b/media_palette_test.go @@ -0,0 +1,24 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// TestMediaChartInksContrast keeps every chart ink at 3:1 against Surface in both themes. +func TestMediaChartInksContrast(t *testing.T) { + for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} { + inks := map[string]ui.Color{ + "played (AccentText)": k.AccentText, "unplayed (TextMuted)": k.TextMuted, "cursor (Text)": k.Text, + "mic safe": micZone(k, 0), "mic loud": micZone(k, micSegments*7/10), "mic clip": micZone(k, micSegments-1), + } + for name, c := range inks { + if r := theme.Contrast(c, k.Surface); r < 3 { + t.Errorf("dark=%v %s: %.2f:1 against Surface", k.Dark, name, r) + } + } + } +} diff --git a/messages_media.go b/messages_media.go new file mode 100644 index 0000000..fcb1f82 --- /dev/null +++ b/messages_media.go @@ -0,0 +1,106 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "image_cropper.label": {"Crop area", "裁剪区域"}, + "image_cropper.rotateLeft": {"Rotate left", "向左旋转"}, + "image_cropper.rotateRight": {"Rotate right", "向右旋转"}, + + "image_annotator.label": {"Annotations", "标注"}, + "image_annotator.tool": {"Tool", "工具"}, + "image_annotator.select": {"Select", "选择"}, + "image_annotator.rect": {"Rectangle", "矩形"}, + "image_annotator.arrow": {"Arrow", "箭头"}, + "image_annotator.text": {"Text", "文字"}, + "image_annotator.textLabel": {"Annotation text", "标注文字"}, + "image_annotator.note": {"Note", "注释"}, + "image_annotator.delete": {"Delete annotation", "删除标注"}, + "image_annotator.count": {"%d annotations", "%d 个标注"}, + "image_annotator.selected": {"Annotation %d of %d selected: %s", "已选中第 %d 个标注(共 %d 个):%s"}, + + "image_thumbnail.failed": {"Failed to load", "加载失败"}, + "image_thumbnail.loading": {"Loading", "加载中"}, + + "lightbox.title": {"Image", "图片"}, + "lightbox.close": {"Close", "关闭"}, + "lightbox.position": {"%d of %d", "第 %d 张,共 %d 张"}, + "lightbox.previous": {"Previous image", "上一张"}, + "lightbox.next": {"Next image", "下一张"}, + + "video_player.label": {"Video", "视频"}, + "video_player.empty": {"No video", "暂无视频"}, + "video_player.subtitles": {"Subtitles", "字幕"}, + "video_player.pip": {"Picture in picture", "画中画"}, + "video_player.fullscreen": {"Full screen", "全屏"}, + + "video_scrubber.label": {"Timeline", "时间轴"}, + + "video_thumbnail_strip.label": {"Frames", "缩略帧"}, + "video_thumbnail_strip.position": {"Frame %d of %d, %s", "第 %d 帧,共 %d 帧,%s"}, + "video_thumbnail_strip.previous": {"Previous frame", "上一帧"}, + "video_thumbnail_strip.next": {"Next frame", "下一帧"}, + + "audio_waveform.label": {"Waveform", "波形"}, + "audio_waveform.empty": {"No audio", "暂无音频"}, + + "audio_spectrum.label": {"Spectrum", "频谱"}, + "audio_spectrum.empty": {"No signal", "无信号"}, + "audio_spectrum.band": {"Band %d", "第 %d 段"}, + + "volume_control.label": {"Volume", "音量"}, + "volume_control.mute": {"Mute", "静音"}, + "volume_control.unmute": {"Unmute", "取消静音"}, + + "playback_speed_control.label": {"Playback speed", "播放速度"}, + + "media_controls.label": {"Playback", "播放控制"}, + "media_controls.play": {"Play", "播放"}, + "media_controls.pause": {"Pause", "暂停"}, + "media_controls.previous": {"Previous", "上一首"}, + "media_controls.next": {"Next", "下一首"}, + "media_controls.shuffle": {"Shuffle", "随机播放"}, + "media_controls.loopOff": {"Repeat off", "不循环"}, + "media_controls.loopAll": {"Repeat all", "列表循环"}, + "media_controls.loopOne": {"Repeat one", "单曲循环"}, + + "playlist.empty": {"The playlist is empty", "播放列表为空"}, + "playlist.playing": {"Playing", "正在播放"}, + "playlist.remove": {"Remove", "移除"}, + + "subtitle_editor.start": {"Start", "开始"}, + "subtitle_editor.end": {"End", "结束"}, + "subtitle_editor.text": {"Text", "文本"}, + "subtitle_editor.add": {"Add cue", "添加字幕"}, + "subtitle_editor.remove": {"Remove cue", "删除字幕"}, + "subtitle_editor.empty": {"No cues yet", "还没有字幕"}, + "subtitle_editor.badTime": {"Use m:ss.mmm, as 1:05.250", "请使用 m:ss.mmm 格式,例如 1:05.250"}, + "subtitle_editor.order": {"End must be after start", "结束时间须晚于开始时间"}, + "subtitle_editor.overlap": {"Overlaps cue %d", "与第 %d 条重叠"}, + + "screen_recorder_controls.label": {"Screen recording", "屏幕录制"}, + "screen_recorder_controls.idle": {"Ready", "就绪"}, + "screen_recorder_controls.recording": {"Recording", "录制中"}, + "screen_recorder_controls.paused": {"Paused", "已暂停"}, + "screen_recorder_controls.start": {"Record", "开始录制"}, + "screen_recorder_controls.pause": {"Pause", "暂停"}, + "screen_recorder_controls.resume": {"Resume", "继续"}, + "screen_recorder_controls.stop": {"Stop", "停止"}, + + "camera_preview.label": {"Camera", "摄像头"}, + "camera_preview.noSignal": {"No signal", "无信号"}, + "camera_preview.mirror": {"Mirror", "镜像"}, + + "mic_level_meter.label": {"Microphone level", "麦克风电平"}, + "mic_level_meter.peak": {"peak", "峰值"}, + + "device_selector.label": {"Devices", "设备"}, + "device_selector.microphone": {"Microphone", "麦克风"}, + "device_selector.camera": {"Camera", "摄像头"}, + "device_selector.speaker": {"Speaker", "扬声器"}, + "device_selector.choose": {"Choose a device", "选择设备"}, + "device_selector.none": {"No devices found", "未找到设备"}, + + "image_compare.before": {"Before", "之前"}, + "image_compare.after": {"After", "之后"}, + }) +} diff --git a/mic_level_meter.go b/mic_level_meter.go new file mode 100644 index 0000000..d3aef5c --- /dev/null +++ b/mic_level_meter.go @@ -0,0 +1,101 @@ +package mujica + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MicLevelMeterOptions configure a MicLevelMeter. +type MicLevelMeterOptions struct { + // Hold keeps the peak mark this long; zero is 1.5s. + Hold time.Duration + // Label names the meter for assistive technology. + Label string +} + +const micSegments = 24 + +type micPeak struct { + level float32 + at time.Time +} + +// MicLevelMeter shows level as segments, held peak and dB. +func MicLevelMeter(c *ui.Context, level float32, opts MicLevelMeterOptions) *ui.Element { + if level < 0 || level > 1 || math.IsNaN(float64(level)) || opts.Hold < 0 { + panic("mujica: MicLevelMeter needs level in 0..1 and Hold >= 0") + } + hold := opts.Hold + if hold == 0 { + hold = 1500 * time.Millisecond + } + k := Tokens(c) + label := opts.Label + if label == "" { + label = msg(c, "mic_level_meter.label") + } + row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize) + pk := ui.Local(row, micPeak{}, func() micPeak { return micPeak{} }) + now := c.Now() + switch { + case level >= pk.level: + *pk = micPeak{level, now} + case now.Sub(pk.at) >= hold: + *pk = micPeak{level, now} + default: + // Repaint once when the hold ends; nothing runs while idle. + c.After(hold - now.Sub(pk.at)) + } + peak := pk.level + row.Role(ui.RoleMeter).Label(label).Description(micDB(level) + ", " + msg(c, "mic_level_meter.peak") + " " + micDB(peak)) + row.Children(func() { + ui.Box(c).Height(12).MinWidth(120).Grow(1).Draw(func(p *ui.Painter, r ui.Rect) { + gap := float32(2) + w := (r.W - gap*(micSegments-1)) / micSegments + lit := int(math.Round(float64(level * micSegments))) + mark := int(math.Round(float64(peak*micSegments))) - 1 + for i := range micSegments { + x := r.X + float32(i)*(w+gap) + seg := ui.Rect{X: x, Y: r.Y, W: w, H: r.H} + col := micZone(k, i) + switch { + case i < lit: + p.Fill(seg, col, 1) + case i == mark: + p.Fill(seg, col, 1) + p.Fill(ui.Rect{X: x + 1, Y: r.Y + 2, W: w - 2, H: r.H - 4}, k.Surface, 0) + default: + p.Stroke(seg, k.Border, 1, 1) + } + } + }) + ui.Text(c, micDB(level)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum"). + TextColor(k.TextMuted).MinWidth(56).TextAlign(ui.End).SingleLine() + }) + return row +} + +// micZone colors the safe, loud and clipping zones. +func micZone(k theme.Tokens, i int) ui.Color { + switch { + case i >= micSegments*9/10: + return k.Danger + case i >= micSegments*7/10: + return k.Warning + } + return k.Success +} + +// micDB writes a full-scale level in decibels. +func micDB(v float32) string { + if v <= 0 { + return "-∞ dB" + } + // Adding 0 turns a rounded -0 into 0. + return fmt.Sprintf("%.0f dB", math.Round(20*math.Log10(float64(v)))+0) +} diff --git a/mic_level_meter_test.go b/mic_level_meter_test.go new file mode 100644 index 0000000..1e9736b --- /dev/null +++ b/mic_level_meter_test.go @@ -0,0 +1,62 @@ +package mujica + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestMicLevelMeterPeakHold(t *testing.T) { + level := float32(1) + var e *ui.Element + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + e = MicLevelMeter(c, level, MicLevelMeterOptions{Hold: 40 * time.Millisecond}) + }, 400, 100) + if !tt.HasText("0 dB") { + t.Fatalf("texts %v", tt.Texts()) + } + level = 0.1 + tt.Frame() + if !tt.HasText("-20 dB") { + t.Fatalf("level readout %v", tt.Texts()) + } + if r, ok := tt.Find("Microphone level"); !ok || r.W == 0 { + t.Fatal("meter not named") + } + if !strings.Contains(micTestDescription(e), "peak 0 dB") { + t.Fatal("peak not held") + } + time.Sleep(60 * time.Millisecond) + tt.Frame() + if !strings.Contains(micTestDescription(e), "peak -20 dB") { + t.Fatal("peak did not fall after the hold") + } +} + +// micTestDescription rebuilds the meter's spoken value from its state. +func micTestDescription(e *ui.Element) string { + pk := *ui.Local(e, micPeak{}, func() micPeak { return micPeak{} }) + return "peak " + micDB(pk.level) +} + +func TestMicLevelMeterDB(t *testing.T) { + for v, want := range map[float32]string{0: "-∞ dB", 1: "0 dB", 0.5: "-6 dB"} { + if got := micDB(v); got != want { + t.Errorf("%v: %s, want %s", v, got, want) + } + } + mediaPanics(t, "over full scale", func(c *ui.Context) { MicLevelMeter(c, 1.2, MicLevelMeterOptions{}) }) +} + +func TestMicLevelMeterSnapshot(t *testing.T) { + snapshot(t, "mic_level_meter", 360, 150, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(8).Children(func() { + MicLevelMeter(c, 0.35, MicLevelMeterOptions{}) + MicLevelMeter(c, 0.8, MicLevelMeterOptions{}) + MicLevelMeter(c, 0.97, MicLevelMeterOptions{}) + }) + }) +} diff --git a/playback_speed_control.go b/playback_speed_control.go new file mode 100644 index 0000000..e3fe9d0 --- /dev/null +++ b/playback_speed_control.go @@ -0,0 +1,46 @@ +package mujica + +import ( + "log/slog" + "slices" + "strconv" + + "github.com/egoist/mygo/ui" +) + +// playbackRates are the default choices, 0.5× to 2×. +var playbackRates = []float64{0.5, 0.75, 1, 1.25, 1.5, 2} + +// PlaybackSpeedControlOptions configure a PlaybackSpeedControl. +type PlaybackSpeedControlOptions struct { + // Rates are the ascending choices; nil means 0.5× to 2×. + Rates []float64 + Disabled bool +} + +// PlaybackSpeedControl chooses *rate from Rates in a drop-down. +func PlaybackSpeedControl(c *ui.Context, rate *float64, opts PlaybackSpeedControlOptions) ChoiceResult { + rates := opts.Rates + if rates == nil { + rates = playbackRates + } + if len(rates) == 0 || !slices.IsSorted(rates) || rates[0] <= 0 { + panic("mujica: PlaybackSpeedControl needs positive Rates in ascending order") + } + if !slices.Contains(rates, *rate) { + panic("mujica: PlaybackSpeedControl rate is not one of Rates") + } + options := make([]SelectOption[float64], len(rates)) + for i, r := range rates { + options[i] = SelectOption[float64]{Value: r, Label: playbackLabel(r)} + } + res := Select(c, rate, options, SelectOptions{Disabled: opts.Disabled, Label: msg(c, "playback_speed_control.label")}) + res.Element.MinWidth(88).Width(96) + if res.Changed() { + slog.Debug("mujica: playback_speed changed", "rate", *rate) + } + return res +} + +// playbackLabel writes a rate as "1.25×". +func playbackLabel(r float64) string { return strconv.FormatFloat(r, 'f', -1, 64) + "×" } diff --git a/playback_speed_control_test.go b/playback_speed_control_test.go new file mode 100644 index 0000000..8d0dee2 --- /dev/null +++ b/playback_speed_control_test.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func speedTester(rate *float64, opts PlaybackSpeedControlOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { + if PlaybackSpeedControl(c, rate, opts).Changed() { + *n++ + } + }) + }, 300, 400) +} + +func TestPlaybackSpeedChoose(t *testing.T) { + rate, n := 1.0, 0 + tt := speedTester(&rate, PlaybackSpeedControlOptions{}, &n) + if !tt.HasText("1×") { + t.Fatalf("texts %v", tt.Texts()) + } + if err := tt.Click("Playback speed"); err != nil { + t.Fatal(err) + } + for _, want := range []string{"0.5×", "0.75×", "1.25×", "1.5×", "2×"} { + if !tt.HasText(want) { + t.Fatalf("no %s in %v", want, tt.Texts()) + } + } + if err := tt.Click("2×"); err != nil { + t.Fatal(err) + } + if rate != 2 || n != 1 { + t.Fatalf("rate %v changes %d", rate, n) + } +} + +func TestPlaybackSpeedKeyboardAndDisabled(t *testing.T) { + rate, n := 1.0, 0 + tt := speedTester(&rate, PlaybackSpeedControlOptions{}, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if rate != 1.25 { + t.Fatalf("keyboard chose %v", rate) + } + rate2, n2 := 1.0, 0 + off := speedTester(&rate2, PlaybackSpeedControlOptions{Disabled: true}, &n2) + _ = off.Click("Playback speed") + _ = off.Click("2×") + if rate2 != 1 || n2 != 0 { + t.Fatalf("disabled chose %v", rate2) + } +} + +func TestPlaybackSpeedBadOptions(t *testing.T) { + odd := 3.0 + mediaPanics(t, "rate not offered", func(c *ui.Context) { PlaybackSpeedControl(c, &odd, PlaybackSpeedControlOptions{}) }) + one := 1.0 + mediaPanics(t, "unsorted", func(c *ui.Context) { + PlaybackSpeedControl(c, &one, PlaybackSpeedControlOptions{Rates: []float64{2, 1}}) + }) +} + +func TestPlaybackSpeedSnapshot(t *testing.T) { + rate := 1.25 + snapshot(t, "playback_speed_control", 200, 80, func(c *ui.Context) { + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { + PlaybackSpeedControl(c, &rate, PlaybackSpeedControlOptions{}) + }) + }) +} diff --git a/playlist.go b/playlist.go new file mode 100644 index 0000000..c077165 --- /dev/null +++ b/playlist.go @@ -0,0 +1,159 @@ +package mujica + +import ( + "log/slog" + "slices" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// PlaylistItem is one track; ID must be unique and stable. +type PlaylistItem struct { + ID, Title, Subtitle string + Duration time.Duration +} + +// PlaylistOptions configure a Playlist. +type PlaylistOptions struct { + // Current is the playing item's ID; empty is none. + Current string + Disabled bool + // Height caps the list; zero means 320. + Height float32 +} + +// PlaylistResult is the playlist's element and this frame's events. +type PlaylistResult struct { + Element *ui.Element + // Played is the ID chosen by Enter or double click. + Played string + // Removed is the ID taken out of *items. + Removed string + // Moved reports a reorder of *items. + Moved bool +} + +type playlistKey struct{} + +// Playlist lists *items, building only rows in view. +func Playlist(c *ui.Context, items *[]PlaylistItem, opts PlaylistOptions) PlaylistResult { + if opts.Height < 0 { + panic("mujica: Playlist Height must not be negative") + } + h := opts.Height + if h == 0 { + h = 320 + } + seen := make(map[string]bool, len(*items)) + for _, it := range *items { + if it.ID == "" || seen[it.ID] { + panic("mujica: Playlist item IDs must be unique and non-empty: " + it.ID) + } + seen[it.ID] = true + } + k := Tokens(c) + var r PlaylistResult + box := ui.Column(c).Height(h).Disabled(opts.Disabled) + r.Element = box + st := ui.Local(box, playlistKey{}, func() *ListState[string] { return &ListState[string]{} }) + s := *st + list := *items + live := !box.IsDisabled() + s.rows.Reorder = nil + if live { + s.rows.Reorder = func(rows []int, to int) { + *items = playlistMove(*items, rows, to) + r.Moved = true + } + } + removeAt := -1 + box.Children(func() { + v := List(c, s, len(list), ListOptions[string]{ + Key: func(i int) string { return list[i].ID }, + Label: func(i int) string { return list[i].Title }, + Empty: msg(c, "playlist.empty"), + }, func(i int) { + playlistRow(c, k, list[i], i, list[i].ID == opts.Current) + if iconAction(c, icons.Must("x"), msg(c, "playlist.remove")+" "+list[i].Title).Clicked() && live { + removeAt = i + } + }) + v.Element.Grow(1) + if removeAt >= 0 { + r.Removed = list[removeAt].ID + *items = slices.Delete(*items, removeAt, removeAt+1) + return + } + sel, ok := s.Selected() + i := slices.IndexFunc(*items, func(it PlaylistItem) bool { return it.ID == sel }) + if !live || !ok || i < 0 { + return + } + switch { + case v.Submitted(): + r.Played = sel + case v.Element.Shortcut(0, ui.KeyDelete), v.Element.Shortcut(0, ui.KeyBackspace): + r.Removed = sel + *items = slices.Delete(*items, i, i+1) + case v.Element.Shortcut(ui.Alt, ui.KeyUp) && i > 0: + *items, r.Moved = playlistMove(*items, []int{i}, i-1), true + case v.Element.Shortcut(ui.Alt, ui.KeyDown) && i < len(*items)-1: + *items, r.Moved = playlistMove(*items, []int{i}, i+2), true + } + }) + switch { + case r.Played != "": + slog.Debug("mujica: playlist play", "id", r.Played) + case r.Removed != "": + slog.Debug("mujica: playlist remove", "id", r.Removed) + case r.Moved: + slog.Debug("mujica: playlist move") + } + return r +} + +// playlistRow shows a track's mark, title, subtitle and length. +func playlistRow(c *ui.Context, k theme.Tokens, it PlaylistItem, i int, playing bool) { + ui.Icon(c, icons.Must("grip-vertical")).Size(14, 14).TextColor(k.TextMuted).Shrink(0) + ui.Box(c).Width(20).Center().Shrink(0).Children(func() { + if playing { + ui.Icon(c, mediaPlay).Size(14, 14).TextColor(k.AccentText).Label(msg(c, "playlist.playing")) + } else { + ui.Textf(c, "%d", i+1).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum") + } + }) + ui.Column(c).Grow(1).MinWidth(0).Children(func() { + t := ui.Text(c, it.Title).SingleLine() + if playing { + t.TextColor(k.AccentText).FontWeight(600) + } + if it.Subtitle != "" { + ui.Text(c, it.Subtitle).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + } + }) + if it.Duration > 0 { + ui.Text(c, mediaClock(it.Duration)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum") + } +} + +// playlistMove puts rows before index to; len means the end. +func playlistMove(items []PlaylistItem, rows []int, to int) []PlaylistItem { + moved := make([]PlaylistItem, 0, len(rows)) + rest := make([]PlaylistItem, 0, len(items)) + at := to + for i, it := range items { + if slices.Contains(rows, i) { + moved = append(moved, it) + if i < to { + at-- + } + continue + } + rest = append(rest, it) + } + return slices.Concat(rest[:at], moved, rest[at:]) +} diff --git a/playlist_test.go b/playlist_test.go new file mode 100644 index 0000000..fb3cb2f --- /dev/null +++ b/playlist_test.go @@ -0,0 +1,123 @@ +package mujica + +import ( + "fmt" + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func playlistItems(n int) []PlaylistItem { + items := make([]PlaylistItem, n) + for i := range items { + items[i] = PlaylistItem{ID: fmt.Sprint("t", i), Title: fmt.Sprint("Track ", i), Duration: time.Duration(180+i) * time.Second} + } + return items +} + +func playlistIDs(items []PlaylistItem) []string { + ids := make([]string, len(items)) + for i, it := range items { + ids[i] = it.ID + } + return ids +} + +func playlistTester(items *[]PlaylistItem, disabled bool, got *[]PlaylistResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(420).Children(func() { + r := Playlist(c, items, PlaylistOptions{Current: "t1", Disabled: disabled}) + if r.Played != "" || r.Removed != "" || r.Moved { + *got = append(*got, r) + } + }) + }, 500, 420) +} + +func TestPlaylistKeyboard(t *testing.T) { + items := playlistItems(4) + var got []PlaylistResult + tt := playlistTester(&items, false, &got) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(got) != 1 || got[0].Played != "t0" { + t.Fatalf("Enter played %+v", got) + } + tt.Key(ui.Alt, ui.KeyDown) + if !slices.Equal(playlistIDs(items), []string{"t1", "t0", "t2", "t3"}) { + t.Fatalf("Alt+Down order %v", playlistIDs(items)) + } + tt.Key(0, ui.KeyDelete) + if !slices.Equal(playlistIDs(items), []string{"t1", "t2", "t3"}) || got[len(got)-1].Removed != "t0" { + t.Fatalf("Delete left %v, %+v", playlistIDs(items), got) + } +} + +func TestPlaylistRemoveButtonAndDrag(t *testing.T) { + items := playlistItems(4) + var got []PlaylistResult + tt := playlistTester(&items, false, &got) + if err := tt.Click("Remove Track 3"); err != nil { + t.Fatal(err) + } + if len(items) != 3 || got[0].Removed != "t3" { + t.Fatalf("remove button: %v", playlistIDs(items)) + } + a, _ := tt.Find("Track 0") + b, _ := tt.Find("Track 2") + tt.Press(a.X+4, a.Y+a.H/2) + tt.Move(a.X+4, a.Y+a.H/2+10) + tt.Move(b.X+4, b.Y+b.H-2) + tt.Release(b.X+4, b.Y+b.H-2) + if !slices.Equal(playlistIDs(items), []string{"t1", "t2", "t0"}) || !got[len(got)-1].Moved { + t.Fatalf("drag order %v", playlistIDs(items)) + } +} + +func TestPlaylistBuildsOnlyVisible(t *testing.T) { + items := playlistItems(100000) + var got []PlaylistResult + tt := playlistTester(&items, false, &got) + tt.Frame() + built := 0 + for _, s := range tt.Texts() { + if len(s) > 6 && s[:6] == "Track " { + built++ + } + } + if built == 0 || built > 40 { + t.Fatalf("built %d rows of 100000", built) + } +} + +func TestPlaylistDisabledAndBad(t *testing.T) { + items := playlistItems(3) + var got []PlaylistResult + tt := playlistTester(&items, true, &got) + _ = tt.Click("Remove Track 0") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDelete) + tt.Key(0, ui.KeyEnter) + if len(items) != 3 || len(got) != 0 { + t.Fatalf("disabled playlist changed: %v %+v", playlistIDs(items), got) + } + dup := []PlaylistItem{{ID: "a"}, {ID: "a"}} + mediaPanics(t, "duplicate", func(c *ui.Context) { Playlist(c, &dup, PlaylistOptions{}) }) +} + +func TestPlaylistSnapshot(t *testing.T) { + items := []PlaylistItem{ + {ID: "a", Title: "Ave Mujica", Subtitle: "Overture", Duration: 245 * time.Second}, + {ID: "b", Title: "Imprisoned XII", Subtitle: "Act I", Duration: 302 * time.Second}, + {ID: "c", Title: "KiLLKiSS", Subtitle: "Act II", Duration: 268 * time.Second}, + } + snapshot(t, "playlist", 440, 240, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(400).Children(func() { + Playlist(c, &items, PlaylistOptions{Current: "b", Height: 200}) + }) + }) +} diff --git a/screen_recorder_controls.go b/screen_recorder_controls.go new file mode 100644 index 0000000..d23ce6f --- /dev/null +++ b/screen_recorder_controls.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// RecorderStatus is where a recording is; zero is idle. +type RecorderStatus int + +const ( + RecorderIdle RecorderStatus = iota + RecorderRecording + RecorderPaused +) + +// RecorderState is what the caller's recorder reports. +type RecorderState struct { + Status RecorderStatus + Elapsed time.Duration +} + +// ScreenRecorderControlsOptions are the callbacks; every one is required. +type ScreenRecorderControlsOptions struct { + OnStart, OnPause, OnResume, OnStop func() + Disabled bool +} + +// ScreenRecorderControls start, pause, resume and stop a recording. +func ScreenRecorderControls(c *ui.Context, st RecorderState, opts ScreenRecorderControlsOptions) *ui.Element { + if opts.OnStart == nil || opts.OnPause == nil || opts.OnResume == nil || opts.OnStop == nil { + panic("mujica: ScreenRecorderControls needs every callback") + } + if st.Status < RecorderIdle || st.Status > RecorderPaused || st.Elapsed < 0 { + panic(fmt.Sprintf("mujica: bad RecorderState %+v", st)) + } + k := Tokens(c) + u := Density(c).Unit() + row := ui.Row(c).Gap(u*2).Padding(u, u*2).AlignItems(ui.Center).Radius(theme.CardRadius). + Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleToolbar). + Label(msg(c, "screen_recorder_controls.label")).Disabled(opts.Disabled) + act := func(icon *ui.SVG, key string, variant ButtonVariant, fn func()) { + if Button(c, msg(c, key), ButtonOptions{Variant: variant, Icon: icon}).Clicked() && !row.IsDisabled() { + slog.Debug("mujica: recorder " + key) + fn() + } + } + row.Children(func() { + status := []string{"screen_recorder_controls.idle", "screen_recorder_controls.recording", "screen_recorder_controls.paused"}[st.Status] + ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(msg(c, status) + " " + mediaClock(st.Elapsed)).Children(func() { + dot := k.TextMuted + if st.Status == RecorderRecording { + dot = k.Danger + } + ui.Box(c).Size(10, 10).Radius(5).Background(dot) + ui.Text(c, msg(c, status)).TextColor(k.TextMuted).SingleLine() + ui.Text(c, mediaClock(st.Elapsed)).FontFeatures("tnum").SingleLine().MinWidth(48) + }) + switch st.Status { + case RecorderIdle: + act(mediaRecord, "screen_recorder_controls.start", Danger, opts.OnStart) + case RecorderRecording: + act(mediaPause, "screen_recorder_controls.pause", Secondary, opts.OnPause) + act(mediaStop, "screen_recorder_controls.stop", Danger, opts.OnStop) + case RecorderPaused: + act(mediaRecord, "screen_recorder_controls.resume", Secondary, opts.OnResume) + act(mediaStop, "screen_recorder_controls.stop", Danger, opts.OnStop) + } + }) + return row +} diff --git a/screen_recorder_controls_test.go b/screen_recorder_controls_test.go new file mode 100644 index 0000000..f83ba6d --- /dev/null +++ b/screen_recorder_controls_test.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type recorderCalls struct{ start, pause, resume, stop int } + +func (r *recorderCalls) opts() ScreenRecorderControlsOptions { + return ScreenRecorderControlsOptions{OnStart: func() { r.start++ }, OnPause: func() { r.pause++ }, + OnResume: func() { r.resume++ }, OnStop: func() { r.stop++ }} +} + +func TestScreenRecorderControlsFlow(t *testing.T) { + st := RecorderState{} + var r recorderCalls + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { ScreenRecorderControls(c, st, r.opts()) }) + }, 500, 120) + if !tt.HasText("Ready") || tt.HasText("Stop") { + t.Fatalf("idle shows %v", tt.Texts()) + } + if err := tt.Click("Record"); err != nil { + t.Fatal(err) + } + st = RecorderState{Status: RecorderRecording, Elapsed: 65 * time.Second} + tt.Frame() + if !tt.HasText("1:05") || !tt.HasText("Recording") { + t.Fatalf("recording shows %v", tt.Texts()) + } + if err := tt.Click("Pause"); err != nil { + t.Fatal(err) + } + st.Status = RecorderPaused + tt.Frame() + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if r != (recorderCalls{1, 1, 1, 1}) { + t.Fatalf("calls %+v", r) + } +} + +func TestScreenRecorderControlsDisabledAndBad(t *testing.T) { + var r recorderCalls + o := r.opts() + o.Disabled = true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ScreenRecorderControls(c, RecorderState{}, o) + }, 500, 120) + _ = tt.Click("Record") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if r.start != 0 { + t.Fatal("disabled recorder started") + } + mediaPanics(t, "no callbacks", func(c *ui.Context) { ScreenRecorderControls(c, RecorderState{}, ScreenRecorderControlsOptions{}) }) + mediaPanics(t, "bad status", func(c *ui.Context) { ScreenRecorderControls(c, RecorderState{Status: 9}, r.opts()) }) +} + +func TestScreenRecorderControlsSnapshot(t *testing.T) { + var r recorderCalls + snapshot(t, "screen_recorder_controls", 420, 140, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + ScreenRecorderControls(c, RecorderState{}, r.opts()) + ScreenRecorderControls(c, RecorderState{Status: RecorderRecording, Elapsed: 3*time.Minute + 7*time.Second}, r.opts()) + }) + }) +} diff --git a/subtitle_editor.go b/subtitle_editor.go new file mode 100644 index 0000000..8a1a5fa --- /dev/null +++ b/subtitle_editor.go @@ -0,0 +1,244 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// SubtitleCue is one subtitle: shown from Start until End. +type SubtitleCue struct { + Start, End time.Duration + Text string +} + +// SubtitleIssueKind is what is wrong with a cue. +type SubtitleIssueKind int + +const ( + // SubtitleEndBeforeStart: End is not after Start. + SubtitleEndBeforeStart SubtitleIssueKind = iota + // SubtitleOverlap: Start is before the previous cue's End. + SubtitleOverlap +) + +// SubtitleIssue is a problem with cue Index. +type SubtitleIssue struct { + Index int + Kind SubtitleIssueKind +} + +// CheckSubtitles lists misordered cues and cues overlapping the previous. +func CheckSubtitles(cues []SubtitleCue) []SubtitleIssue { + var out []SubtitleIssue + for i, q := range cues { + if q.End <= q.Start { + out = append(out, SubtitleIssue{i, SubtitleEndBeforeStart}) + } + if i > 0 && q.Start < cues[i-1].End { + out = append(out, SubtitleIssue{i, SubtitleOverlap}) + } + } + return out +} + +// SubtitleEditorOptions configure a SubtitleEditor. +type SubtitleEditorOptions struct { + // Height is the visible list's height; zero means 360. + Height float32 + Disabled bool +} + +type subtitleState struct { + scroll ui.ScrollState + drafts map[subtitleField]string +} + +type subtitleField struct{ row, part int } + +type subtitleKey struct{} + +// SubtitleEditor edits cue times and text, building visible rows. +func SubtitleEditor(c *ui.Context, cues *[]SubtitleCue, opts SubtitleEditorOptions) ChoiceResult { + if opts.Height < 0 { + panic("mujica: SubtitleEditor Height must not be negative") + } + h := opts.Height + if h == 0 { + h = 360 + } + k := Tokens(c) + u := Density(c).Unit() + rowH := Density(c).ControlHeight() + FontSize(c, theme.CaptionSize)*theme.LineHeight + u*3 + col := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled) + st := ui.Local(col, subtitleKey{}, func() *subtitleState { return &subtitleState{drafts: map[subtitleField]string{}} }) + s := *st + issues := map[int]string{} + for _, is := range CheckSubtitles(*cues) { + if is.Kind == SubtitleOverlap { + issues[is.Index] = fmt.Sprintf(msg(c, "subtitle_editor.overlap"), is.Index) + } else { + issues[is.Index] = msg(c, "subtitle_editor.order") + } + } + changed := false + col.Children(func() { + list := ui.Scroll(c).Height(h).TrackScroll(&s.scroll).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius) + list.Children(func() { + n := len(*cues) + // The container keeps Y inside the content; match it. + y := max(0, min(s.scroll.Y, float32(n)*rowH-h)) + first := max(0, int(y/rowH)-1) + last := min(n, int((y+h)/rowH)+2) + ui.Box(c).Height(float32(n) * rowH).Children(func() { + for i := first; i < last; i++ { + before := len(*cues) + if subtitleRow(c, k, cues, s, i, rowH, issues[i]) { + changed = true + } + if len(*cues) != before { + // A removal shifts rows below; the next frame builds them. + c.AnimationFrame() + return + } + } + }) + if n == 0 { + ui.Text(c, msg(c, "subtitle_editor.empty")).TextColor(k.TextMuted).Padding(u * 3) + } + }) + if Button(c, msg(c, "subtitle_editor.add"), ButtonOptions{Variant: Secondary, Icon: icons.Must("plus")}).Clicked() && !col.IsDisabled() { + var at time.Duration + if n := len(*cues); n > 0 { + at = (*cues)[n-1].End + } + *cues = append(*cues, SubtitleCue{Start: at, End: at + 2*time.Second}) + s.scroll.Y = float32(len(*cues)) * rowH + changed = true + } + }) + if changed { + slog.Debug("mujica: subtitle_editor changed", "cues", len(*cues)) + } + return ChoiceResult{Element: col, changed: changed} +} + +// subtitleRow edits cue i; true means changed or removed. +func subtitleRow(c *ui.Context, k theme.Tokens, cues *[]SubtitleCue, s *subtitleState, i int, rowH float32, issue string) bool { + u := Density(c).Unit() + q := &(*cues)[i] + changed, removed := false, false + row := ui.Column(c).Key(i).Absolute().Left(0).Right(0).Top(float32(i)*rowH).Height(rowH).Padding(u*1.5, u*2).Gap(2). + BorderWidth(0, 0, 1, 0).BorderColor(k.Border) + row.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Textf(c, "%d", i+1).Width(28).TextColor(k.TextMuted).FontFeatures("tnum") + for part, t := range []*time.Duration{&q.Start, &q.End} { + f := subtitleField{i, part} + text, editing := s.drafts[f] + if !editing { + text = subtitleClock(*t) + } + name := msg(c, []string{"subtitle_editor.start", "subtitle_editor.end"}[part]) + in := TextInput(c, &text, TextInputOptions{Label: fmt.Sprintf("%s %d", name, i+1)}) + in.Element.Width(112) + bad := false + if in.Changed() { + if v, ok := subtitleParse(text); ok { + *t, changed = v, true + delete(s.drafts, f) + } else { + s.drafts[f], bad = text, true + } + } else if editing { + _, ok := subtitleParse(text) + bad = !ok + } + if bad { + in.Input.Error(msg(c, "subtitle_editor.badTime")) + } else if issue != "" { + in.Input.Error(issue) + } + } + txt := TextInput(c, &q.Text, TextInputOptions{Label: fmt.Sprintf("%s %d", msg(c, "subtitle_editor.text"), i+1)}) + txt.Element.Grow(1) + if txt.Changed() { + changed = true + } + if b := iconAction(c, icons.Must("trash-2"), fmt.Sprintf("%s %d", msg(c, "subtitle_editor.remove"), i+1)); b.Clicked() && !b.IsDisabled() { + removed = true + } + }) + ui.Row(c).Gap(u).PaddingX(28 + u*2).Children(func() { + note := issue + for part := range 2 { + if d, ok := s.drafts[subtitleField{i, part}]; ok { + if _, valid := subtitleParse(d); !valid { + note = msg(c, "subtitle_editor.badTime") + } + } + } + if note != "" { + ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).SingleLine() + } + }) + }) + if removed { + *cues = slices.Delete(*cues, i, i+1) + clear(s.drafts) + return true + } + return changed +} + +// subtitleClock writes d as m:ss.mmm. +func subtitleClock(d time.Duration) string { + ms := d.Milliseconds() + return fmt.Sprintf("%d:%02d.%03d", ms/60000, ms/1000%60, ms%1000) +} + +// subtitleParse reads m:ss.mmm or h:mm:ss.mmm; the fraction is optional. +func subtitleParse(s string) (time.Duration, bool) { + parts := strings.Split(strings.TrimSpace(s), ":") + if len(parts) < 2 || len(parts) > 3 { + return 0, false + } + acc := 0 + for i, p := range parts[:len(parts)-1] { + n, err := strconv.Atoi(p) + if err != nil || !subtitleDigits(p) || (i > 0 && n > 59) { + return 0, false + } + acc = acc*60 + n + } + sec := parts[len(parts)-1] + whole, frac, _ := strings.Cut(sec, ".") + s0, err := strconv.Atoi(whole) + if err != nil || len(whole) != 2 || s0 < 0 || s0 > 59 || len(frac) > 3 || !subtitleDigits(whole+frac) { + return 0, false + } + ms := 0 + if frac != "" { + ms, _ = strconv.Atoi(frac + strings.Repeat("0", 3-len(frac))) + } + return time.Duration(acc*60+s0)*time.Second + time.Duration(ms)*time.Millisecond, true +} + +// subtitleDigits reports whether s is ASCII digits only. +func subtitleDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/subtitle_editor_test.go b/subtitle_editor_test.go new file mode 100644 index 0000000..9c2b4c7 --- /dev/null +++ b/subtitle_editor_test.go @@ -0,0 +1,131 @@ +package mujica + +import ( + "slices" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func subtitleTester(cues *[]SubtitleCue, disabled bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(620).Children(func() { + if SubtitleEditor(c, cues, SubtitleEditorOptions{Height: 260, Disabled: disabled}).Changed() { + *n++ + } + }) + }, 700, 400) +} + +func TestCheckSubtitles(t *testing.T) { + cues := []SubtitleCue{{0, 2 * time.Second, "a"}, {time.Second, 3 * time.Second, "b"}, {4 * time.Second, 4 * time.Second, "c"}} + want := []SubtitleIssue{{1, SubtitleOverlap}, {2, SubtitleEndBeforeStart}} + if got := CheckSubtitles(cues); !slices.Equal(got, want) { + t.Fatalf("issues %v", got) + } +} + +func TestSubtitleParse(t *testing.T) { + for s, want := range map[string]time.Duration{"0:01.5": 1500 * time.Millisecond, "1:05.250": 65250 * time.Millisecond, "1:00:00": time.Hour, "2:03": 123 * time.Second} { + if got, ok := subtitleParse(s); !ok || got != want { + t.Errorf("%q: %v %v", s, got, ok) + } + } + for _, s := range []string{"", "5", "1:60", "1:5", "a:00", "1:00.1234", "1:-1", "+1:00"} { + if _, ok := subtitleParse(s); ok { + t.Errorf("%q parsed", s) + } + } +} + +func TestSubtitleEditorEditTimesAndText(t *testing.T) { + cues := []SubtitleCue{{0, 2 * time.Second, "Welcome"}, {3 * time.Second, 5 * time.Second, "to the court"}} + n := 0 + tt := subtitleTester(&cues, false, &n) + if err := tt.Click("End 1"); err != nil { + t.Fatal(err) + } + tt.Command("selectAll") + tt.Type("0:04") + if cues[0].End != 4*time.Second || !tt.HasText("Overlaps cue 1") { + t.Fatalf("end edit %+v, texts %v", cues[0], tt.Texts()) + } + tt.Command("selectAll") + tt.Type("zz") + if cues[0].End != 4*time.Second || !tt.HasText("Use m:ss.mmm, as 1:05.250") { + t.Fatalf("bad time kept? %+v", cues[0]) + } + if err := tt.Click("Text 2"); err != nil { + t.Fatal(err) + } + tt.Type("!") + if cues[1].Text != "to the court!" || n < 2 { + t.Fatalf("text %q changes %d", cues[1].Text, n) + } +} + +func TestSubtitleEditorAddRemove(t *testing.T) { + cues := []SubtitleCue{{0, 2 * time.Second, "a"}} + n := 0 + tt := subtitleTester(&cues, false, &n) + if err := tt.Click("Add cue"); err != nil { + t.Fatal(err) + } + if len(cues) != 2 || cues[1].Start != 2*time.Second || cues[1].End != 4*time.Second { + t.Fatalf("added %+v", cues) + } + if err := tt.Click("Remove cue 1"); err != nil { + t.Fatal(err) + } + if len(cues) != 1 || cues[0].Start != 2*time.Second || n != 2 { + t.Fatalf("removed %+v, changes %d", cues, n) + } +} + +func TestSubtitleEditorBuildsOnlyVisible(t *testing.T) { + cues := make([]SubtitleCue, 100000) + for i := range cues { + cues[i] = SubtitleCue{time.Duration(i) * time.Second, time.Duration(i)*time.Second + 900*time.Millisecond, "line"} + } + n := 0 + tt := subtitleTester(&cues, false, &n) + tt.Frame() + built := 0 + for _, x := range tt.Texts() { + if strings.HasPrefix(x, "Remove cue ") { + built++ + } + } + if built == 0 || built > 20 { + t.Fatalf("built %d rows of 100000", built) + } +} + +func TestSubtitleEditorDisabled(t *testing.T) { + cues := []SubtitleCue{{0, 2 * time.Second, "a"}} + n := 0 + tt := subtitleTester(&cues, true, &n) + _ = tt.Click("Add cue") + _ = tt.Click("Remove cue 1") + _ = tt.Click("Text 1") + tt.Type("x") + if len(cues) != 1 || cues[0].Text != "a" || n != 0 { + t.Fatalf("disabled editor changed %+v", cues) + } +} + +func TestSubtitleEditorSnapshot(t *testing.T) { + cues := []SubtitleCue{ + {0, 2500 * time.Millisecond, "The curtain rises."}, + {2 * time.Second, 5 * time.Second, "Welcome to the masquerade."}, + {6 * time.Second, 9 * time.Second, "Ave Mujica."}, + } + snapshot(t, "subtitle_editor", 640, 300, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(600).Children(func() { + SubtitleEditor(c, &cues, SubtitleEditorOptions{Height: 200}) + }) + }) +} diff --git a/testdata/snapshots/darwin/audio_player-dark.png b/testdata/snapshots/darwin/audio_player-dark.png new file mode 100644 index 0000000..a19fc33 Binary files /dev/null and b/testdata/snapshots/darwin/audio_player-dark.png differ diff --git a/testdata/snapshots/darwin/audio_player-light.png b/testdata/snapshots/darwin/audio_player-light.png new file mode 100644 index 0000000..6d5dba4 Binary files /dev/null and b/testdata/snapshots/darwin/audio_player-light.png differ diff --git a/testdata/snapshots/darwin/audio_spectrum-dark.png b/testdata/snapshots/darwin/audio_spectrum-dark.png new file mode 100644 index 0000000..8c621f0 Binary files /dev/null and b/testdata/snapshots/darwin/audio_spectrum-dark.png differ diff --git a/testdata/snapshots/darwin/audio_spectrum-light.png b/testdata/snapshots/darwin/audio_spectrum-light.png new file mode 100644 index 0000000..0d0b852 Binary files /dev/null and b/testdata/snapshots/darwin/audio_spectrum-light.png differ diff --git a/testdata/snapshots/darwin/audio_waveform-dark.png b/testdata/snapshots/darwin/audio_waveform-dark.png new file mode 100644 index 0000000..e6a424c Binary files /dev/null and b/testdata/snapshots/darwin/audio_waveform-dark.png differ diff --git a/testdata/snapshots/darwin/audio_waveform-light.png b/testdata/snapshots/darwin/audio_waveform-light.png new file mode 100644 index 0000000..0440cbf Binary files /dev/null and b/testdata/snapshots/darwin/audio_waveform-light.png differ diff --git a/testdata/snapshots/darwin/camera_preview-dark.png b/testdata/snapshots/darwin/camera_preview-dark.png new file mode 100644 index 0000000..e9d7a4a Binary files /dev/null and b/testdata/snapshots/darwin/camera_preview-dark.png differ diff --git a/testdata/snapshots/darwin/camera_preview-light.png b/testdata/snapshots/darwin/camera_preview-light.png new file mode 100644 index 0000000..142ea8c Binary files /dev/null and b/testdata/snapshots/darwin/camera_preview-light.png differ diff --git a/testdata/snapshots/darwin/device_selector-dark.png b/testdata/snapshots/darwin/device_selector-dark.png new file mode 100644 index 0000000..645f377 Binary files /dev/null and b/testdata/snapshots/darwin/device_selector-dark.png differ diff --git a/testdata/snapshots/darwin/device_selector-light.png b/testdata/snapshots/darwin/device_selector-light.png new file mode 100644 index 0000000..a9d6d44 Binary files /dev/null and b/testdata/snapshots/darwin/device_selector-light.png differ diff --git a/testdata/snapshots/darwin/image_annotator-dark.png b/testdata/snapshots/darwin/image_annotator-dark.png new file mode 100644 index 0000000..70d803d Binary files /dev/null and b/testdata/snapshots/darwin/image_annotator-dark.png differ diff --git a/testdata/snapshots/darwin/image_annotator-light.png b/testdata/snapshots/darwin/image_annotator-light.png new file mode 100644 index 0000000..001ebe8 Binary files /dev/null and b/testdata/snapshots/darwin/image_annotator-light.png differ diff --git a/testdata/snapshots/darwin/image_compare-dark.png b/testdata/snapshots/darwin/image_compare-dark.png new file mode 100644 index 0000000..a1d42b6 Binary files /dev/null and b/testdata/snapshots/darwin/image_compare-dark.png differ diff --git a/testdata/snapshots/darwin/image_compare-light.png b/testdata/snapshots/darwin/image_compare-light.png new file mode 100644 index 0000000..53eb412 Binary files /dev/null and b/testdata/snapshots/darwin/image_compare-light.png differ diff --git a/testdata/snapshots/darwin/image_cropper-dark.png b/testdata/snapshots/darwin/image_cropper-dark.png new file mode 100644 index 0000000..e7e23fe Binary files /dev/null and b/testdata/snapshots/darwin/image_cropper-dark.png differ diff --git a/testdata/snapshots/darwin/image_cropper-light.png b/testdata/snapshots/darwin/image_cropper-light.png new file mode 100644 index 0000000..d4273bb Binary files /dev/null and b/testdata/snapshots/darwin/image_cropper-light.png differ diff --git a/testdata/snapshots/darwin/image_thumbnail-dark.png b/testdata/snapshots/darwin/image_thumbnail-dark.png new file mode 100644 index 0000000..a4d396f Binary files /dev/null and b/testdata/snapshots/darwin/image_thumbnail-dark.png differ diff --git a/testdata/snapshots/darwin/image_thumbnail-light.png b/testdata/snapshots/darwin/image_thumbnail-light.png new file mode 100644 index 0000000..73026ac Binary files /dev/null and b/testdata/snapshots/darwin/image_thumbnail-light.png differ diff --git a/testdata/snapshots/darwin/lightbox-dark.png b/testdata/snapshots/darwin/lightbox-dark.png new file mode 100644 index 0000000..05e9033 Binary files /dev/null and b/testdata/snapshots/darwin/lightbox-dark.png differ diff --git a/testdata/snapshots/darwin/lightbox-light.png b/testdata/snapshots/darwin/lightbox-light.png new file mode 100644 index 0000000..284b8e6 Binary files /dev/null and b/testdata/snapshots/darwin/lightbox-light.png differ diff --git a/testdata/snapshots/darwin/media_controls-dark.png b/testdata/snapshots/darwin/media_controls-dark.png new file mode 100644 index 0000000..5c9d223 Binary files /dev/null and b/testdata/snapshots/darwin/media_controls-dark.png differ diff --git a/testdata/snapshots/darwin/media_controls-light.png b/testdata/snapshots/darwin/media_controls-light.png new file mode 100644 index 0000000..52aca9c Binary files /dev/null and b/testdata/snapshots/darwin/media_controls-light.png differ diff --git a/testdata/snapshots/darwin/mic_level_meter-dark.png b/testdata/snapshots/darwin/mic_level_meter-dark.png new file mode 100644 index 0000000..0c478e1 Binary files /dev/null and b/testdata/snapshots/darwin/mic_level_meter-dark.png differ diff --git a/testdata/snapshots/darwin/mic_level_meter-light.png b/testdata/snapshots/darwin/mic_level_meter-light.png new file mode 100644 index 0000000..5e2c50d Binary files /dev/null and b/testdata/snapshots/darwin/mic_level_meter-light.png differ diff --git a/testdata/snapshots/darwin/playback_speed_control-dark.png b/testdata/snapshots/darwin/playback_speed_control-dark.png new file mode 100644 index 0000000..13c0077 Binary files /dev/null and b/testdata/snapshots/darwin/playback_speed_control-dark.png differ diff --git a/testdata/snapshots/darwin/playback_speed_control-light.png b/testdata/snapshots/darwin/playback_speed_control-light.png new file mode 100644 index 0000000..0db4687 Binary files /dev/null and b/testdata/snapshots/darwin/playback_speed_control-light.png differ diff --git a/testdata/snapshots/darwin/playlist-dark.png b/testdata/snapshots/darwin/playlist-dark.png new file mode 100644 index 0000000..ac2b501 Binary files /dev/null and b/testdata/snapshots/darwin/playlist-dark.png differ diff --git a/testdata/snapshots/darwin/playlist-light.png b/testdata/snapshots/darwin/playlist-light.png new file mode 100644 index 0000000..7fc92aa Binary files /dev/null and b/testdata/snapshots/darwin/playlist-light.png differ diff --git a/testdata/snapshots/darwin/screen_recorder_controls-dark.png b/testdata/snapshots/darwin/screen_recorder_controls-dark.png new file mode 100644 index 0000000..925cdf8 Binary files /dev/null and b/testdata/snapshots/darwin/screen_recorder_controls-dark.png differ diff --git a/testdata/snapshots/darwin/screen_recorder_controls-light.png b/testdata/snapshots/darwin/screen_recorder_controls-light.png new file mode 100644 index 0000000..4e60fdf Binary files /dev/null and b/testdata/snapshots/darwin/screen_recorder_controls-light.png differ diff --git a/testdata/snapshots/darwin/subtitle_editor-dark.png b/testdata/snapshots/darwin/subtitle_editor-dark.png new file mode 100644 index 0000000..00ed599 Binary files /dev/null and b/testdata/snapshots/darwin/subtitle_editor-dark.png differ diff --git a/testdata/snapshots/darwin/subtitle_editor-light.png b/testdata/snapshots/darwin/subtitle_editor-light.png new file mode 100644 index 0000000..f0f3564 Binary files /dev/null and b/testdata/snapshots/darwin/subtitle_editor-light.png differ diff --git a/testdata/snapshots/darwin/video_player-dark.png b/testdata/snapshots/darwin/video_player-dark.png new file mode 100644 index 0000000..2759883 Binary files /dev/null and b/testdata/snapshots/darwin/video_player-dark.png differ diff --git a/testdata/snapshots/darwin/video_player-light.png b/testdata/snapshots/darwin/video_player-light.png new file mode 100644 index 0000000..1817b4d Binary files /dev/null and b/testdata/snapshots/darwin/video_player-light.png differ diff --git a/testdata/snapshots/darwin/video_scrubber-dark.png b/testdata/snapshots/darwin/video_scrubber-dark.png new file mode 100644 index 0000000..9781417 Binary files /dev/null and b/testdata/snapshots/darwin/video_scrubber-dark.png differ diff --git a/testdata/snapshots/darwin/video_scrubber-light.png b/testdata/snapshots/darwin/video_scrubber-light.png new file mode 100644 index 0000000..52f3ed7 Binary files /dev/null and b/testdata/snapshots/darwin/video_scrubber-light.png differ diff --git a/testdata/snapshots/darwin/video_thumbnail_strip-dark.png b/testdata/snapshots/darwin/video_thumbnail_strip-dark.png new file mode 100644 index 0000000..d05c3b6 Binary files /dev/null and b/testdata/snapshots/darwin/video_thumbnail_strip-dark.png differ diff --git a/testdata/snapshots/darwin/video_thumbnail_strip-light.png b/testdata/snapshots/darwin/video_thumbnail_strip-light.png new file mode 100644 index 0000000..24fef2c Binary files /dev/null and b/testdata/snapshots/darwin/video_thumbnail_strip-light.png differ diff --git a/testdata/snapshots/darwin/volume_control-dark.png b/testdata/snapshots/darwin/volume_control-dark.png new file mode 100644 index 0000000..cf912f1 Binary files /dev/null and b/testdata/snapshots/darwin/volume_control-dark.png differ diff --git a/testdata/snapshots/darwin/volume_control-light.png b/testdata/snapshots/darwin/volume_control-light.png new file mode 100644 index 0000000..1930be3 Binary files /dev/null and b/testdata/snapshots/darwin/volume_control-light.png differ diff --git a/video_player.go b/video_player.go new file mode 100644 index 0000000..1c115b0 --- /dev/null +++ b/video_player.go @@ -0,0 +1,162 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// VideoState is what the caller's playback reports. +type VideoState struct { + // Frame is the current picture; nil shows "No video". + Frame ui.ImageSource + Position, Duration time.Duration + Buffered []MediaRange + Playing bool + // Rate is the speed; zero means 1. + Rate float64 + // Subtitle is the cue to show now; empty shows none. + Subtitle string + SubtitlesOn, Fullscreen, PiP bool +} + +// VideoPlayerOptions are callbacks; nil optional ones hide their buttons. +type VideoPlayerOptions struct { + OnPlay, OnPause func() + OnSeek func(time.Duration) + OnRate func(float64) + OnSubtitles func(on bool) + OnFullscreen func(on bool) + OnPiP func(on bool) + Disabled bool + // Label names the video for assistive technology. + Label string +} + +// videoBlack is the stage behind letterboxed frames in both themes. +var videoBlack = ui.Hex("#0E0C0D") + +// VideoPlayer draws the caller's frame and reports controls through callbacks. +func VideoPlayer(c *ui.Context, st VideoState, opts VideoPlayerOptions) *ui.Element { + if opts.OnPlay == nil || opts.OnPause == nil || opts.OnSeek == nil { + panic("mujica: VideoPlayer needs OnPlay, OnPause and OnSeek") + } + if st.Duration < 0 || st.Rate < 0 { + panic("mujica: VideoPlayer needs Duration and Rate >= 0") + } + k := Tokens(c) + u := Density(c).Unit() + col := ui.Column(c).Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border).Background(k.Surface). + Clip().Role(ui.RoleGroup).Label(videoLabel(c, opts.Label)).Disabled(opts.Disabled) + col.Children(func() { + videoStage(c, k, st, opts) + ui.Column(c).Padding(u, u*2, u*2, u*2).Children(func() { + if st.Duration > 0 { + pos := min(max(0, st.Position), st.Duration) + if VideoScrubber(c, &pos, VideoScrubberOptions{Duration: st.Duration, Buffered: st.Buffered}).Changed() { + opts.OnSeek(pos) + } + } + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + mediaPlayToggle(c, st.Playing, opts.OnPlay, opts.OnPause) + ui.Text(c, mediaClock(st.Position)+" / "+mediaClock(st.Duration)).FontSize(FontSize(c, theme.CaptionSize)). + TextColor(k.TextMuted).FontFeatures("tnum").SingleLine().Grow(1) + videoButtons(c, st, opts) + }) + }) + }) + return col +} + +func videoLabel(c *ui.Context, label string) string { + if label != "" { + return label + } + return msg(c, "video_player.label") +} + +// videoStage is the focusable picture with playback keys. +func videoStage(c *ui.Context, k theme.Tokens, st VideoState, opts VideoPlayerOptions) { + stage := ui.Box(c).FillWidth().AspectRatio(16.0 / 9).Background(videoBlack).Center().Focusable(). + Role(ui.RoleImage).Label(videoLabel(c, opts.Label)) + internal.Ring(stage, k.Focus, 0) + if !stage.IsDisabled() { + videoKeys(stage, st, opts) + if stage.Clicked() { + mediaToggle(st.Playing, opts.OnPlay, opts.OnPause) + } + } + stage.Children(func() { + if st.Frame == nil { + ui.Text(c, msg(c, "video_player.empty")).TextColor(ui.Hex("#BCAEAA")) + } else { + ui.Image(c, st.Frame).Fill() + } + if st.SubtitlesOn && st.Subtitle != "" { + ui.Box(c).Absolute().Left(0).Right(0).Bottom(16).Center().PassThrough().Children(func() { + ui.Text(c, st.Subtitle).TextColor(ui.Hex("#F2EBDD")).Background(ui.RGBA(0, 0, 0, 0.72)). + TextAlign(ui.Center).MaxLines(3) + }) + } + }) +} + +func videoKeys(stage *ui.Element, st VideoState, opts VideoPlayerOptions) { + seek := func(d time.Duration) { + to := min(max(0, st.Position+d), st.Duration) + slog.Debug("mujica: video_player seek", "pos", to) + opts.OnSeek(to) + } + switch { + case stage.Shortcut(0, ui.KeySpace), stage.Shortcut(0, ui.KeyK): + mediaToggle(st.Playing, opts.OnPlay, opts.OnPause) + case stage.Shortcut(0, ui.KeyLeft): + seek(-5 * time.Second) + case stage.Shortcut(0, ui.KeyRight): + seek(5 * time.Second) + case opts.OnFullscreen != nil && stage.Shortcut(0, ui.KeyF): + opts.OnFullscreen(!st.Fullscreen) + case opts.OnSubtitles != nil && stage.Shortcut(0, ui.KeyC): + opts.OnSubtitles(!st.SubtitlesOn) + } +} + +// videoButtons are rate, subtitles, picture-in-picture and full screen. +func videoButtons(c *ui.Context, st VideoState, opts VideoPlayerOptions) { + if opts.OnRate != nil { + rate := st.Rate + if rate == 0 { + rate = 1 + } + if PlaybackSpeedControl(c, &rate, PlaybackSpeedControlOptions{}).Changed() { + opts.OnRate(rate) + } + } + toggle := func(icon *ui.SVG, key string, on bool, fn func(bool)) { + if fn == nil { + return + } + b := mediaToggleAction(c, icon, msg(c, key), on) + if b.Clicked() && !b.IsDisabled() { + slog.Debug("mujica: video_player toggle", "control", key, "on", !on) + fn(!on) + } + } + toggle(mediaCaptions, "video_player.subtitles", st.SubtitlesOn, opts.OnSubtitles) + toggle(mediaPiP, "video_player.pip", st.PiP, opts.OnPiP) + toggle(mediaFullscreen, "video_player.fullscreen", st.Fullscreen, opts.OnFullscreen) +} + +// mediaToggleAction is an iconAction that shows when it is on. +func mediaToggleAction(c *ui.Context, icon *ui.SVG, label string, on bool) *ui.Element { + k := Tokens(c) + b := iconAction(c, icon, label).Role(ui.RoleToggleButton).Selected(on) + if on { + b.Background(k.Selection).TextColor(k.AccentText).BorderWidth(0, 0, 2, 0).BorderColor(k.AccentText) + } + return b +} diff --git a/video_player_test.go b/video_player_test.go new file mode 100644 index 0000000..006b3dd --- /dev/null +++ b/video_player_test.go @@ -0,0 +1,120 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type videoCalls struct { + mediaCalls + rates []float64 + subs, full, pip []bool +} + +func (v *videoCalls) opts() VideoPlayerOptions { + a := v.mediaCalls.opts() + return VideoPlayerOptions{OnPlay: a.OnPlay, OnPause: a.OnPause, OnSeek: a.OnSeek, + OnRate: func(r float64) { v.rates = append(v.rates, r) }, + OnSubtitles: func(on bool) { v.subs = append(v.subs, on) }, + OnFullscreen: func(on bool) { v.full = append(v.full, on) }, + OnPiP: func(on bool) { v.pip = append(v.pip, on) }} +} + +func videoTester(st *VideoState, o *VideoPlayerOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(640).Children(func() { VideoPlayer(c, *st, *o) }) + }, 700, 520) +} + +func TestVideoPlayerStageKeys(t *testing.T) { + st := VideoState{Frame: mediaTestBitmap(160, 90), Position: 20 * time.Second, Duration: time.Minute} + var v videoCalls + o := v.opts() + tt := videoTester(&st, &o) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Video") { + t.Fatal("stage not first in Tab order") + } + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyF) + tt.Key(0, ui.KeyC) + if v.play != 1 || len(v.seeks) != 1 || v.seeks[0] != 25*time.Second || len(v.full) != 1 || !v.full[0] || len(v.subs) != 1 || !v.subs[0] { + t.Fatalf("calls %+v", v) + } +} + +func TestVideoPlayerButtons(t *testing.T) { + st := VideoState{Position: 0, Duration: time.Minute, Fullscreen: true, Rate: 1} + var v videoCalls + o := v.opts() + tt := videoTester(&st, &o) + if !tt.HasText("No video") { + t.Fatal("empty stage not shown") + } + for _, name := range []string{"Picture in picture", "Full screen", "Subtitles"} { + if err := tt.Click(name); err != nil { + t.Fatal(err) + } + } + if len(v.pip) != 1 || !v.pip[0] || len(v.full) != 1 || v.full[0] || len(v.subs) != 1 || !v.subs[0] { + t.Fatalf("toggles %+v", v) + } + if err := tt.Click("Playback speed"); err != nil { + t.Fatal(err) + } + if err := tt.Click("1.5×"); err != nil { + t.Fatal(err) + } + if len(v.rates) != 1 || v.rates[0] != 1.5 { + t.Fatalf("rates %v", v.rates) + } +} + +func TestVideoPlayerSubtitleAndOptionalButtons(t *testing.T) { + st := VideoState{Duration: time.Minute, Subtitle: "Welcome to the masquerade"} + var v videoCalls + o := v.opts() + o.OnPiP, o.OnRate = nil, nil + tt := videoTester(&st, &o) + if tt.HasText("Welcome to the masquerade") { + t.Fatal("subtitle shown while off") + } + if _, ok := tt.Find("Picture in picture"); ok { + t.Fatal("PiP button without OnPiP") + } + st.SubtitlesOn = true + tt.Frame() + if !tt.HasText("Welcome to the masquerade") { + t.Fatal("subtitle not shown") + } +} + +func TestVideoPlayerDisabled(t *testing.T) { + st := VideoState{Duration: time.Minute} + var v videoCalls + o := v.opts() + o.Disabled = true + tt := videoTester(&st, &o) + _ = tt.Click("Play") + _ = tt.Click("Full screen") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if v.play != 0 || len(v.full) != 0 { + t.Fatalf("disabled player reported %+v", v) + } +} + +func TestVideoPlayerSnapshot(t *testing.T) { + var v videoCalls + snapshot(t, "video_player", 560, 400, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(520).Children(func() { + VideoPlayer(c, VideoState{Frame: mediaTestBitmap(320, 180), Position: 95 * time.Second, Duration: 4 * time.Minute, + Buffered: []MediaRange{{0, 150 * time.Second}}, Playing: true, Rate: 1, SubtitlesOn: true, + Subtitle: "The curtain rises."}, v.opts()) + }) + }) +} diff --git a/video_scrubber.go b/video_scrubber.go new file mode 100644 index 0000000..156e06b --- /dev/null +++ b/video_scrubber.go @@ -0,0 +1,118 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MediaRange is a span of a media timeline. +type MediaRange struct{ Start, End time.Duration } + +// VideoScrubberOptions configure a VideoScrubber. +type VideoScrubberOptions struct { + // Duration is the timeline's length; required. + Duration time.Duration + // Buffered spans show as a lighter fill. + Buffered []MediaRange + // Preview builds the hover slot's content for a time. + Preview func(at time.Duration) + // PreviewWidth, PreviewHeight size the slot; zero means 160×90. + PreviewWidth, PreviewHeight float32 + // Step is one arrow press; zero means 5 seconds. + Step time.Duration + Disabled bool + // Label names the timeline for assistive technology. + Label string +} + +// VideoScrubber sets *pos by dragging, clicking, arrows, Home and End. +func VideoScrubber(c *ui.Context, pos *time.Duration, opts VideoScrubberOptions) ChoiceResult { + if opts.Duration <= 0 || opts.Step < 0 || opts.PreviewWidth < 0 || opts.PreviewHeight < 0 { + panic("mujica: VideoScrubber needs Duration > 0 and no negative Step or preview size") + } + if *pos < 0 || *pos > opts.Duration { + panic("mujica: VideoScrubber position outside 0..Duration") + } + step := opts.Step + if step == 0 { + step = 5 * time.Second + } + pw, ph := opts.PreviewWidth, opts.PreviewHeight + if pw == 0 { + pw, ph = 160, 90 + } + k := Tokens(c) + dur := opts.Duration.Seconds() + g, h := sliderSetup(0, dur, min(step.Seconds(), dur), nil, false, c) + old := *pos + var s *ui.Element + var next float64 + wrap := ui.Box(c).MinWidth(120).Shrink(0).Disabled(opts.Disabled) + wrap.Children(func() { + s, next, _ = sliderControl(c, old.Seconds(), g, 0, dur) + s.Height(h).PaddingX(sliderThumb / 2).FocusRing(false).Cursor(ui.CursorPointer). + Label(scrubberLabel(c, opts.Label)) + px, _, over := s.PointerPosition() + r := s.Bounds() + at := time.Duration(float64((px-sliderThumb/2)/max(r.W-sliderThumb, 1)) * float64(opts.Duration)) + at = max(0, min(opts.Duration, at)) + if over && !s.IsDisabled() { + scrubberHover(c, k, r, g, at, opts, pw, ph) + } + }) + if !s.IsDisabled() { + *pos = max(0, min(opts.Duration, time.Duration(next*float64(time.Second)).Round(time.Millisecond))) + if next == dur { + *pos = opts.Duration + } + } + changed := *pos != old + if changed { + slog.Debug("mujica: video_scrubber seek", "pos", *pos) + } + v, buffered, focus := pos.Seconds(), opts.Buffered, s + s.Draw(func(p *ui.Painter, r ui.Rect) { + y := r.Y + g.trackY + x0 := g.x(r, 0) + sliderTrackPaint(p, r, g, k, nil, 0, x0, x0) + // Buffered spans fill the rail under the played part. + for _, b := range buffered { + b0, b1 := g.x(r, b.Start.Seconds()), g.x(r, b.End.Seconds()) + p.Fill(ui.Rect{X: b0, Y: y - sliderTrack/2, W: b1 - b0, H: sliderTrack}, k.TextMuted.Alpha(0.45), 0) + } + p.Fill(ui.Rect{X: x0, Y: y - sliderTrack/2, W: g.x(r, v) - x0, H: sliderTrack}, k.AccentText, 0) + sliderThumbPaint(p, k, g.x(r, v), y, focus.Hovered(), focus.FocusVisible()) + }) + return ChoiceResult{Element: s, changed: changed} +} + +func scrubberLabel(c *ui.Context, label string) string { + if label != "" { + return label + } + return msg(c, "video_scrubber.label") +} + +// scrubberHover shows hover time and preview slot above the track. +func scrubberHover(c *ui.Context, k theme.Tokens, r ui.Rect, g sliderGeom, at time.Duration, opts VideoScrubberOptions, pw, ph float32) { + x := g.x(ui.Rect{W: r.W}, at.Seconds()) + w := pw + if opts.Preview == nil { + w = 64 + } + panel := ui.Column(c).Absolute().Left(max(0, min(r.W-w, x-w/2))).Bottom(r.H - g.trackY + sliderThumb). + Width(w).Padding(4).Gap(4).AlignItems(ui.Center).PassThrough() + overlayStyle(c, panel, 1) + panel.Children(func() { + if opts.Preview != nil { + ui.Box(c).Size(pw-8, ph).Clip().Radius(theme.ControlRadius).Background(k.Background).Children(func() { + opts.Preview(at) + }) + } + ui.Text(c, mediaClock(at)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine() + }) +} diff --git a/video_scrubber_test.go b/video_scrubber_test.go new file mode 100644 index 0000000..7805684 --- /dev/null +++ b/video_scrubber_test.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func scrubberTester(pos *time.Duration, opts VideoScrubberOptions, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(40).Children(func() { + if VideoScrubber(c, pos, opts).Changed() { + *changes++ + } + }) + }, 500, 300) +} + +func TestVideoScrubberKeys(t *testing.T) { + pos, n := 10*time.Second, 0 + tt := scrubberTester(&pos, VideoScrubberOptions{Duration: time.Minute}, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if pos != 15*time.Second { + t.Fatalf("Right: %v", pos) + } + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyLeft) + if pos != 5*time.Second { + t.Fatalf("Left twice: %v", pos) + } + tt.Key(0, ui.KeyEnd) + if pos != time.Minute { + t.Fatalf("End: %v", pos) + } + tt.Key(0, ui.KeyHome) + if pos != 0 || n != 5 { + t.Fatalf("Home: %v, changes %d", pos, n) + } +} + +func TestVideoScrubberClickAndHover(t *testing.T) { + pos, n := time.Duration(0), 0 + var asked time.Duration + opts := VideoScrubberOptions{Duration: 100 * time.Second, Preview: func(at time.Duration) { asked = at }} + tt := scrubberTester(&pos, opts, &n) + r, ok := tt.Find("Timeline") + if !ok { + t.Fatal("no timeline") + } + mid := r.X + r.W/2 + tt.Move(mid, r.Y+r.H/2) + if asked < 45*time.Second || asked > 55*time.Second || !tt.HasText(mediaClock(asked)) { + t.Fatalf("hover preview at %v, texts %v", asked, tt.Texts()) + } + tt.ClickAt(mid, r.Y+r.H/2) + if pos < 45*time.Second || pos > 55*time.Second || n != 1 { + t.Fatalf("click seek %v, changes %d", pos, n) + } +} + +func TestVideoScrubberDisabled(t *testing.T) { + pos, n := 10*time.Second, 0 + tt := scrubberTester(&pos, VideoScrubberOptions{Duration: time.Minute, Disabled: true}, &n) + r, _ := tt.Find("Timeline") + tt.ClickAt(r.X+r.W-10, r.Y+r.H/2) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if pos != 10*time.Second || n != 0 { + t.Fatalf("disabled moved to %v", pos) + } +} + +func TestVideoScrubberBadOptions(t *testing.T) { + pos := time.Duration(0) + mediaPanics(t, "no duration", func(c *ui.Context) { VideoScrubber(c, &pos, VideoScrubberOptions{}) }) + far := 2 * time.Minute + mediaPanics(t, "past end", func(c *ui.Context) { VideoScrubber(c, &far, VideoScrubberOptions{Duration: time.Minute}) }) +} + +func TestVideoScrubberSnapshot(t *testing.T) { + pos := 40 * time.Second + snapshot(t, "video_scrubber", 420, 80, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + VideoScrubber(c, &pos, VideoScrubberOptions{Duration: 2 * time.Minute, + Buffered: []MediaRange{{0, 70 * time.Second}, {90 * time.Second, 100 * time.Second}}}) + }) + }) +} diff --git a/video_thumbnail_strip.go b/video_thumbnail_strip.go new file mode 100644 index 0000000..af92ed4 --- /dev/null +++ b/video_thumbnail_strip.go @@ -0,0 +1,116 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// StripFrame is one thumbnail of a VideoThumbnailStrip. +type StripFrame struct { + // Image is a bitmap or SVG; nil leaves it empty. + Image ui.ImageSource + At time.Duration +} + +// VideoThumbnailStripOptions configure a VideoThumbnailStrip. +type VideoThumbnailStripOptions struct { + // FrameWidth is each cell's width; zero means 96. + FrameWidth float32 + Disabled bool +} + +const stripGap = 4 + +type stripKey struct{} + +// VideoThumbnailStrip shows the frames fitting around *current. +func VideoThumbnailStrip(c *ui.Context, current *int, frames []StripFrame, opts VideoThumbnailStripOptions) ChoiceResult { + if len(frames) == 0 || *current < 0 || *current >= len(frames) { + panic("mujica: VideoThumbnailStrip current out of range") + } + if opts.FrameWidth < 0 { + panic("mujica: VideoThumbnailStrip FrameWidth must not be negative") + } + fw := opts.FrameWidth + if fw == 0 { + fw = 96 + } + fh := fw * 9 / 16 + k := Tokens(c) + old := *current + row := ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Disabled(opts.Disabled) + row.Children(func() { + prev := iconAction(c, icons.Must("chevron-left"), msg(c, "video_thumbnail_strip.previous")).Disabled(*current == 0) + view := ui.Row(c).Grow(1).MinWidth(fw).Height(fh + 4).Gap(stripGap).Clip().Focusable(). + Role(ui.RoleGroup).Label(msg(c, "video_thumbnail_strip.label")). + Description(fmt.Sprintf(msg(c, "video_thumbnail_strip.position"), *current+1, len(frames), mediaClock(frames[*current].At))) + internal.Ring(view, k.Focus, theme.ControlRadius) + next := iconAction(c, icons.Must("chevron-right"), msg(c, "video_thumbnail_strip.next")).Disabled(*current == len(frames)-1) + live := !row.IsDisabled() + switch { + case live && (prev.Clicked() || view.Shortcut(0, ui.KeyLeft)): + *current = max(0, *current-1) + case live && (next.Clicked() || view.Shortcut(0, ui.KeyRight)): + *current = min(len(frames)-1, *current+1) + case live && view.Shortcut(0, ui.KeyHome): + *current = 0 + case live && view.Shortcut(0, ui.KeyEnd): + *current = len(frames) - 1 + } + // Only frames that fit are built, around *current. + w := view.Bounds().W + if w <= 0 { + // The count needs the width: lay out, then rebuild. + c.AnimationFrame() + } + n := max(1, int((w+stripGap)/(fw+stripGap))) + start := ui.Local(view, stripKey{}, func() int { return 0 }) + *start = max(0, min(*start, *current, len(frames)-n)) + if *current >= *start+n { + *start = *current - n + 1 + } + view.Children(func() { + for i := *start; i < min(len(frames), *start+n); i++ { + if stripCell(c, k, frames[i], i, i == *current, fw, fh) && live { + *current = i + } + } + }) + }) + changed := *current != old + if changed { + slog.Debug("mujica: video_strip jump", "index", *current) + } + return ChoiceResult{Element: row, changed: changed} +} + +// stripCell draws one frame and reports a click on it. +func stripCell(c *ui.Context, k theme.Tokens, f StripFrame, i int, on bool, fw, fh float32) bool { + cell := ui.Box(c).Key(i).Size(fw, fh).Shrink(0).Radius(theme.ControlRadius).Clip().Background(videoBlack). + Cursor(ui.CursorPointer).AlignSelf(ui.Center) + edge, width := k.Border, theme.BorderWidth + if on { + edge, width = k.AccentText, 2 + } + cell.Border(width, edge) + cell.Children(func() { + if f.Image != nil { + ui.Image(c, f.Image).Fit(ui.Cover).Size(fw, fh) + } + ui.Text(c, mediaClock(f.At)).Absolute().Left(4).Bottom(3).FontSize(FontSize(c, theme.CaptionSize)). + TextColor(ui.Hex("#F2EBDD")).Background(ui.RGBA(0, 0, 0, 0.6)).FontFeatures("tnum") + if on { + ui.Box(c).Absolute().Top(3).Right(3).Size(10, 10).Draw(func(p *ui.Painter, r ui.Rect) { + p.FillPath(internal.Diamond(internal.Snap(p, r.X+5), internal.Snap(p, r.Y+5), 10), k.AccentText) + }) + } + }) + return cell.Clicked() +} diff --git a/video_thumbnail_strip_test.go b/video_thumbnail_strip_test.go new file mode 100644 index 0000000..6cc95fe --- /dev/null +++ b/video_thumbnail_strip_test.go @@ -0,0 +1,102 @@ +package mujica + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func stripFrames(n int) []StripFrame { + f := make([]StripFrame, n) + for i := range f { + f[i].At = time.Duration(i) * time.Second + } + return f +} + +func stripTester(cur *int, frames []StripFrame, disabled bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(520).Children(func() { + if VideoThumbnailStrip(c, cur, frames, VideoThumbnailStripOptions{Disabled: disabled}).Changed() { + *n++ + } + }) + }, 600, 160) +} + +// stripBuilt counts the time labels the strip built. +func stripBuilt(tt *ui.Tester) int { + k := 0 + for _, s := range tt.Texts() { + if strings.Contains(s, ":") && !strings.Contains(s, " ") { + k++ + } + } + return k +} + +func TestVideoThumbnailStripKeysAndClick(t *testing.T) { + cur, n := 0, 0 + tt := stripTester(&cur, stripFrames(40), false, &n) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Frames") { + t.Fatal("Tab did not reach the frames") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if cur != 2 { + t.Fatalf("Right twice: %d", cur) + } + tt.Key(0, ui.KeyEnd) + if cur != 39 || !tt.HasText("0:39") { + t.Fatalf("End: %d, %v", cur, tt.Texts()) + } + r, _ := tt.Find("0:37") + tt.ClickAt(r.X+20, r.Y-10) + if cur != 37 || n != 4 { + t.Fatalf("click: %d, changes %d", cur, n) + } +} + +func TestVideoThumbnailStripBuildsOnlyVisible(t *testing.T) { + cur, n := 50000, 0 + tt := stripTester(&cur, stripFrames(100000), false, &n) + tt.Frame() + if b := stripBuilt(tt); b < 3 || b > 10 { + t.Fatalf("built %d frames of 100000", b) + } + if !tt.HasText(mediaClock(50000 * time.Second)) { + t.Fatal("current frame not built") + } +} + +func TestVideoThumbnailStripDisabledAndBad(t *testing.T) { + cur, n := 3, 0 + tt := stripTester(&cur, stripFrames(10), true, &n) + _ = tt.Click("Next frame") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if cur != 3 || n != 0 { + t.Fatal("disabled strip moved") + } + bad := 5 + mediaPanics(t, "out of range", func(c *ui.Context) { + VideoThumbnailStrip(c, &bad, stripFrames(3), VideoThumbnailStripOptions{}) + }) +} + +func TestVideoThumbnailStripSnapshot(t *testing.T) { + frames := stripFrames(12) + for i := range frames { + frames[i].Image = mediaTestBitmap(64, 36) + } + cur := 2 + snapshot(t, "video_thumbnail_strip", 560, 110, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(520).Children(func() { + VideoThumbnailStrip(c, &cur, frames, VideoThumbnailStripOptions{}) + }) + }) +} diff --git a/volume_control.go b/volume_control.go new file mode 100644 index 0000000..7fac647 --- /dev/null +++ b/volume_control.go @@ -0,0 +1,49 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" +) + +// VolumeControlOptions configure a VolumeControl. +type VolumeControlOptions struct { + Disabled bool +} + +// VolumeControl sets *volume in 5% steps and toggles *muted. +func VolumeControl(c *ui.Context, volume *float64, muted *bool, opts VolumeControlOptions) ChoiceResult { + if *volume < 0 || *volume > 1 || math.IsNaN(*volume) { + panic("mujica: VolumeControl volume outside 0..1") + } + oldV, oldM := *volume, *muted + u := Density(c).Unit() + row := ui.Row(c).Gap(u).AlignItems(ui.Center).Role(ui.RoleGroup).Label(msg(c, "volume_control.label")).Disabled(opts.Disabled) + row.Children(func() { + icon, key := mediaVolume, "volume_control.mute" + if *muted || *volume == 0 { + icon, key = mediaMute, "volume_control.unmute" + } + b := mediaToggleAction(c, icon, msg(c, key), *muted) + if b.Clicked() && !b.IsDisabled() { + *muted = !*muted + } + s := Slider(c, volume, SliderOptions{Min: 0, Max: 1, Step: 0.05, Disabled: opts.Disabled, + Label: msg(c, "volume_control.label"), ShowValue: true, Format: func(v float64) string { return fmt.Sprintf("%d%%", int(math.Round(v*100))) }}) + s.Element.Width(120) + // Raising the volume while muted unmutes, as players do. + if s.Changed() && *muted && *volume > oldV { + *muted = false + } + }) + if !row.IsDisabled() && row.Shortcut(0, ui.KeyM) { + *muted = !*muted + } + changed := *volume != oldV || *muted != oldM + if changed { + slog.Debug("mujica: volume changed", "volume", *volume, "muted", *muted) + } + return ChoiceResult{Element: row, changed: changed} +} diff --git a/volume_control_test.go b/volume_control_test.go new file mode 100644 index 0000000..94a6b71 --- /dev/null +++ b/volume_control_test.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func volumeTester(v *float64, muted *bool, disabled bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(40).AlignItems(ui.Start).Children(func() { + if VolumeControl(c, v, muted, VolumeControlOptions{Disabled: disabled}).Changed() { + *n++ + } + }) + }, 400, 200) +} + +func TestVolumeControlArrowsAndMute(t *testing.T) { + v, muted, n := 0.5, false, 0 + tt := volumeTester(&v, &muted, false, &n) + if err := tt.Click("Mute"); err != nil { + t.Fatal(err) + } + if !muted { + t.Fatal("mute button did not mute") + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyLeft) + if v != 0.45 || !muted { + t.Fatalf("Left: %v muted %v", v, muted) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if v != 0.55 || muted { + t.Fatalf("raising should unmute: %v muted %v", v, muted) + } + tt.Key(0, ui.KeyM) + if !muted || n != 5 { + t.Fatalf("M: muted %v changes %d", muted, n) + } + tt.Key(0, ui.KeyEnd) + if v != 1 { + t.Fatalf("End: %v", v) + } +} + +func TestVolumeControlDisabled(t *testing.T) { + v, muted, n := 0.5, false, 0 + tt := volumeTester(&v, &muted, true, &n) + _ = tt.Click("Mute") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyM) + if v != 0.5 || muted || n != 0 { + t.Fatalf("disabled changed %v %v", v, muted) + } +} + +func TestVolumeControlRange(t *testing.T) { + loud, m := 1.5, false + mediaPanics(t, "over 1", func(c *ui.Context) { VolumeControl(c, &loud, &m, VolumeControlOptions{}) }) +} + +func TestVolumeControlSnapshot(t *testing.T) { + a, b, ma, mb := 0.7, 0.3, false, true + snapshot(t, "volume_control", 260, 150, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(16).AlignItems(ui.Start).Children(func() { + VolumeControl(c, &a, &ma, VolumeControlOptions{}) + VolumeControl(c, &b, &mb, VolumeControlOptions{}) + }) + }) +}