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(``))
+}
+
+// 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{})
+ })
+ })
+}