From 85575ea2e2f66e74d70e09e6e2625599c706c191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:33:40 -0400 Subject: [PATCH] Media: components 341-360 --- audio_player.go | 84 +++++ audio_player_test.go | 89 ++++++ audio_spectrum.go | 146 +++++++++ audio_spectrum_test.go | 74 +++++ audio_waveform.go | 187 +++++++++++ audio_waveform_test.go | 131 ++++++++ camera_preview.go | 93 ++++++ camera_preview_test.go | 86 ++++++ device_selector.go | 78 +++++ device_selector_test.go | 74 +++++ docs/components/341-image-cropper.md | 55 ++++ docs/components/342-image-annotator.md | 59 ++++ docs/components/343-image-thumbnail.md | 54 ++++ docs/components/344-lightbox.md | 53 ++++ docs/components/345-video-player.md | 59 ++++ docs/components/346-video-scrubber.md | 56 ++++ docs/components/347-video-thumbnail-strip.md | 51 +++ docs/components/348-audio-player.md | 51 +++ docs/components/349-audio-waveform.md | 58 ++++ docs/components/350-audio-spectrum.md | 49 +++ docs/components/351-volume-control.md | 46 +++ docs/components/352-playback-speed-control.md | 48 +++ docs/components/353-media-controls.md | 53 ++++ docs/components/354-playlist.md | 52 ++++ docs/components/355-subtitle-editor.md | 52 ++++ .../356-screen-recorder-controls.md | 51 +++ docs/components/357-camera-preview.md | 48 +++ docs/components/358-mic-level-meter.md | 45 +++ docs/components/359-device-selector.md | 51 +++ docs/components/360-image-compare.md | 48 +++ example_media_test.go | 246 +++++++++++++++ examples/gallery/pages_media.go | 117 ++++++- examples/gallery/pages_media_av.go | 191 ++++++++++++ examples/gallery/pages_media_code.go | 178 +++++++++++ examples/gallery/pages_media_devices.go | 225 ++++++++++++++ examples/gallery/pages_media_image.go | 162 ++++++++++ image_annotator.go | 292 ++++++++++++++++++ image_annotator_test.go | 121 ++++++++ image_compare.go | 74 +++++ image_compare_test.go | 73 +++++ image_cropper.go | 245 +++++++++++++++ image_cropper_geom.go | 81 +++++ image_cropper_test.go | 101 ++++++ image_thumbnail.go | 87 ++++++ image_thumbnail_test.go | 82 +++++ lightbox.go | 89 ++++++ lightbox_test.go | 66 ++++ media_controls.go | 77 +++++ media_controls_test.go | 91 ++++++ media_helpers_test.go | 44 +++ media_icons.go | 53 ++++ media_palette_test.go | 24 ++ messages_media.go | 106 +++++++ mic_level_meter.go | 101 ++++++ mic_level_meter_test.go | 62 ++++ playback_speed_control.go | 46 +++ playback_speed_control_test.go | 77 +++++ playlist.go | 159 ++++++++++ playlist_test.go | 123 ++++++++ screen_recorder_controls.go | 76 +++++ screen_recorder_controls_test.go | 75 +++++ subtitle_editor.go | 244 +++++++++++++++ subtitle_editor_test.go | 131 ++++++++ .../snapshots/darwin/audio_player-dark.png | Bin 0 -> 10475 bytes .../snapshots/darwin/audio_player-light.png | Bin 0 -> 10039 bytes .../snapshots/darwin/audio_spectrum-dark.png | Bin 0 -> 1666 bytes .../snapshots/darwin/audio_spectrum-light.png | Bin 0 -> 1673 bytes .../snapshots/darwin/audio_waveform-dark.png | Bin 0 -> 3181 bytes .../snapshots/darwin/audio_waveform-light.png | Bin 0 -> 3221 bytes .../snapshots/darwin/camera_preview-dark.png | Bin 0 -> 5170 bytes .../snapshots/darwin/camera_preview-light.png | Bin 0 -> 5182 bytes .../snapshots/darwin/device_selector-dark.png | Bin 0 -> 12843 bytes .../darwin/device_selector-light.png | Bin 0 -> 12358 bytes .../snapshots/darwin/image_annotator-dark.png | Bin 0 -> 8632 bytes .../darwin/image_annotator-light.png | Bin 0 -> 8595 bytes .../snapshots/darwin/image_compare-dark.png | Bin 0 -> 3694 bytes .../snapshots/darwin/image_compare-light.png | Bin 0 -> 3606 bytes .../snapshots/darwin/image_cropper-dark.png | Bin 0 -> 5969 bytes .../snapshots/darwin/image_cropper-light.png | Bin 0 -> 5790 bytes .../snapshots/darwin/image_thumbnail-dark.png | Bin 0 -> 4517 bytes .../darwin/image_thumbnail-light.png | Bin 0 -> 4442 bytes testdata/snapshots/darwin/lightbox-dark.png | Bin 0 -> 6197 bytes testdata/snapshots/darwin/lightbox-light.png | Bin 0 -> 6272 bytes .../snapshots/darwin/media_controls-dark.png | Bin 0 -> 3633 bytes .../snapshots/darwin/media_controls-light.png | Bin 0 -> 3548 bytes .../snapshots/darwin/mic_level_meter-dark.png | Bin 0 -> 3704 bytes .../darwin/mic_level_meter-light.png | Bin 0 -> 3654 bytes .../darwin/playback_speed_control-dark.png | Bin 0 -> 1584 bytes .../darwin/playback_speed_control-light.png | Bin 0 -> 1520 bytes testdata/snapshots/darwin/playlist-dark.png | Bin 0 -> 12296 bytes testdata/snapshots/darwin/playlist-light.png | Bin 0 -> 12010 bytes .../darwin/screen_recorder_controls-dark.png | Bin 0 -> 10125 bytes .../darwin/screen_recorder_controls-light.png | Bin 0 -> 10021 bytes .../snapshots/darwin/subtitle_editor-dark.png | Bin 0 -> 20314 bytes .../darwin/subtitle_editor-light.png | Bin 0 -> 19550 bytes .../snapshots/darwin/video_player-dark.png | Bin 0 -> 9864 bytes .../snapshots/darwin/video_player-light.png | Bin 0 -> 9732 bytes .../snapshots/darwin/video_scrubber-dark.png | Bin 0 -> 1132 bytes .../snapshots/darwin/video_scrubber-light.png | Bin 0 -> 1002 bytes .../darwin/video_thumbnail_strip-dark.png | Bin 0 -> 3371 bytes .../darwin/video_thumbnail_strip-light.png | Bin 0 -> 3355 bytes .../snapshots/darwin/volume_control-dark.png | Bin 0 -> 2270 bytes .../snapshots/darwin/volume_control-light.png | Bin 0 -> 2182 bytes video_player.go | 162 ++++++++++ video_player_test.go | 120 +++++++ video_scrubber.go | 118 +++++++ video_scrubber_test.go | 91 ++++++ video_thumbnail_strip.go | 116 +++++++ video_thumbnail_strip_test.go | 102 ++++++ volume_control.go | 49 +++ volume_control_test.go | 74 +++++ 111 files changed, 6831 insertions(+), 1 deletion(-) create mode 100644 audio_player.go create mode 100644 audio_player_test.go create mode 100644 audio_spectrum.go create mode 100644 audio_spectrum_test.go create mode 100644 audio_waveform.go create mode 100644 audio_waveform_test.go create mode 100644 camera_preview.go create mode 100644 camera_preview_test.go create mode 100644 device_selector.go create mode 100644 device_selector_test.go create mode 100644 docs/components/341-image-cropper.md create mode 100644 docs/components/342-image-annotator.md create mode 100644 docs/components/343-image-thumbnail.md create mode 100644 docs/components/344-lightbox.md create mode 100644 docs/components/345-video-player.md create mode 100644 docs/components/346-video-scrubber.md create mode 100644 docs/components/347-video-thumbnail-strip.md create mode 100644 docs/components/348-audio-player.md create mode 100644 docs/components/349-audio-waveform.md create mode 100644 docs/components/350-audio-spectrum.md create mode 100644 docs/components/351-volume-control.md create mode 100644 docs/components/352-playback-speed-control.md create mode 100644 docs/components/353-media-controls.md create mode 100644 docs/components/354-playlist.md create mode 100644 docs/components/355-subtitle-editor.md create mode 100644 docs/components/356-screen-recorder-controls.md create mode 100644 docs/components/357-camera-preview.md create mode 100644 docs/components/358-mic-level-meter.md create mode 100644 docs/components/359-device-selector.md create mode 100644 docs/components/360-image-compare.md create mode 100644 example_media_test.go create mode 100644 examples/gallery/pages_media_av.go create mode 100644 examples/gallery/pages_media_code.go create mode 100644 examples/gallery/pages_media_devices.go create mode 100644 examples/gallery/pages_media_image.go create mode 100644 image_annotator.go create mode 100644 image_annotator_test.go create mode 100644 image_compare.go create mode 100644 image_compare_test.go create mode 100644 image_cropper.go create mode 100644 image_cropper_geom.go create mode 100644 image_cropper_test.go create mode 100644 image_thumbnail.go create mode 100644 image_thumbnail_test.go create mode 100644 lightbox.go create mode 100644 lightbox_test.go create mode 100644 media_controls.go create mode 100644 media_controls_test.go create mode 100644 media_helpers_test.go create mode 100644 media_icons.go create mode 100644 media_palette_test.go create mode 100644 messages_media.go create mode 100644 mic_level_meter.go create mode 100644 mic_level_meter_test.go create mode 100644 playback_speed_control.go create mode 100644 playback_speed_control_test.go create mode 100644 playlist.go create mode 100644 playlist_test.go create mode 100644 screen_recorder_controls.go create mode 100644 screen_recorder_controls_test.go create mode 100644 subtitle_editor.go create mode 100644 subtitle_editor_test.go create mode 100644 testdata/snapshots/darwin/audio_player-dark.png create mode 100644 testdata/snapshots/darwin/audio_player-light.png create mode 100644 testdata/snapshots/darwin/audio_spectrum-dark.png create mode 100644 testdata/snapshots/darwin/audio_spectrum-light.png create mode 100644 testdata/snapshots/darwin/audio_waveform-dark.png create mode 100644 testdata/snapshots/darwin/audio_waveform-light.png create mode 100644 testdata/snapshots/darwin/camera_preview-dark.png create mode 100644 testdata/snapshots/darwin/camera_preview-light.png create mode 100644 testdata/snapshots/darwin/device_selector-dark.png create mode 100644 testdata/snapshots/darwin/device_selector-light.png create mode 100644 testdata/snapshots/darwin/image_annotator-dark.png create mode 100644 testdata/snapshots/darwin/image_annotator-light.png create mode 100644 testdata/snapshots/darwin/image_compare-dark.png create mode 100644 testdata/snapshots/darwin/image_compare-light.png create mode 100644 testdata/snapshots/darwin/image_cropper-dark.png create mode 100644 testdata/snapshots/darwin/image_cropper-light.png create mode 100644 testdata/snapshots/darwin/image_thumbnail-dark.png create mode 100644 testdata/snapshots/darwin/image_thumbnail-light.png create mode 100644 testdata/snapshots/darwin/lightbox-dark.png create mode 100644 testdata/snapshots/darwin/lightbox-light.png create mode 100644 testdata/snapshots/darwin/media_controls-dark.png create mode 100644 testdata/snapshots/darwin/media_controls-light.png create mode 100644 testdata/snapshots/darwin/mic_level_meter-dark.png create mode 100644 testdata/snapshots/darwin/mic_level_meter-light.png create mode 100644 testdata/snapshots/darwin/playback_speed_control-dark.png create mode 100644 testdata/snapshots/darwin/playback_speed_control-light.png create mode 100644 testdata/snapshots/darwin/playlist-dark.png create mode 100644 testdata/snapshots/darwin/playlist-light.png create mode 100644 testdata/snapshots/darwin/screen_recorder_controls-dark.png create mode 100644 testdata/snapshots/darwin/screen_recorder_controls-light.png create mode 100644 testdata/snapshots/darwin/subtitle_editor-dark.png create mode 100644 testdata/snapshots/darwin/subtitle_editor-light.png create mode 100644 testdata/snapshots/darwin/video_player-dark.png create mode 100644 testdata/snapshots/darwin/video_player-light.png create mode 100644 testdata/snapshots/darwin/video_scrubber-dark.png create mode 100644 testdata/snapshots/darwin/video_scrubber-light.png create mode 100644 testdata/snapshots/darwin/video_thumbnail_strip-dark.png create mode 100644 testdata/snapshots/darwin/video_thumbnail_strip-light.png create mode 100644 testdata/snapshots/darwin/volume_control-dark.png create mode 100644 testdata/snapshots/darwin/volume_control-light.png create mode 100644 video_player.go create mode 100644 video_player_test.go create mode 100644 video_scrubber.go create mode 100644 video_scrubber_test.go create mode 100644 video_thumbnail_strip.go create mode 100644 video_thumbnail_strip_test.go create mode 100644 volume_control.go create mode 100644 volume_control_test.go diff --git a/audio_player.go b/audio_player.go new file mode 100644 index 0000000..144976a --- /dev/null +++ b/audio_player.go @@ -0,0 +1,84 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// AudioState is what the caller's playback reports. +type AudioState struct { + Title string + Position time.Duration + Duration time.Duration + Playing bool +} + +// AudioPlayerOptions are the callbacks a player shell reports to. +type AudioPlayerOptions struct { + OnPlay, OnPause func() + OnSeek func(time.Duration) + Disabled bool +} + +// AudioPlayer is a play, pause and seek shell. +func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) *ui.Element { + if opts.OnPlay == nil || opts.OnPause == nil || opts.OnSeek == nil { + panic("mujica: AudioPlayer needs OnPlay, OnPause and OnSeek") + } + k := Tokens(c) + u := Density(c).Unit() + row := ui.Row(c).Gap(u*3).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Padding(u*2, u*3). + Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleGroup). + Disabled(opts.Disabled) + if st.Title != "" { + row.Label(st.Title) + } + row.Children(func() { + mediaPlayToggle(c, st.Playing, opts.OnPlay, opts.OnPause) + ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() { + if st.Title != "" { + ui.Text(c, st.Title).SingleLine() + } + if st.Duration > 0 { + pos := min(max(0, st.Position), st.Duration) + if VideoScrubber(c, &pos, VideoScrubberOptions{Duration: st.Duration, Label: st.Title}).Changed() { + opts.OnSeek(pos) + } + } + }) + ui.Text(c, mediaClock(st.Position)+" / "+mediaClock(st.Duration)).FontSize(FontSize(c, theme.CaptionSize)). + TextColor(k.TextMuted).FontFeatures("tnum").SingleLine() + }) + // Space plays or pauses wherever the focus is inside. + if !row.IsDisabled() && row.Shortcut(0, ui.KeySpace) { + mediaToggle(st.Playing, opts.OnPlay, opts.OnPause) + } + return row +} + +// mediaPlayToggle is the play/pause button; it calls the matching callback. +func mediaPlayToggle(c *ui.Context, playing bool, onPlay, onPause func()) *ui.Element { + icon, label := mediaPlay, msg(c, "media_controls.play") + if playing { + icon, label = mediaPause, msg(c, "media_controls.pause") + } + b := IconButton(c, icon, label, IconButtonOptions{}) + if b.Clicked() && !b.IsDisabled() { + mediaToggle(playing, onPlay, onPause) + } + return b +} + +func mediaToggle(playing bool, onPlay, onPause func()) { + if playing { + slog.Debug("mujica: media pause") + onPause() + return + } + slog.Debug("mujica: media play") + onPlay() +} diff --git a/audio_player_test.go b/audio_player_test.go new file mode 100644 index 0000000..1f90d57 --- /dev/null +++ b/audio_player_test.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type mediaCalls struct { + play, pause int + seeks []time.Duration +} + +func (m *mediaCalls) opts() AudioPlayerOptions { + return AudioPlayerOptions{OnPlay: func() { m.play++ }, OnPause: func() { m.pause++ }, OnSeek: func(d time.Duration) { m.seeks = append(m.seeks, d) }} +} + +func audioTester(st *AudioState, m *mediaCalls, disabled bool) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := m.opts() + o.Disabled = disabled + ui.Column(c).Padding(20).Children(func() { AudioPlayer(c, *st, o) }) + }, 600, 200) +} + +func TestAudioPlayerPlayPause(t *testing.T) { + st := AudioState{Title: "Ave Mujica", Position: 30 * time.Second, Duration: 3 * time.Minute} + var m mediaCalls + tt := audioTester(&st, &m, false) + if err := tt.Click("Play"); err != nil { + t.Fatal(err) + } + st.Playing = true + tt.Frame() + if err := tt.Click("Pause"); err != nil { + t.Fatal(err) + } + if m.play != 1 || m.pause != 1 { + t.Fatalf("play %d pause %d", m.play, m.pause) + } + if !tt.HasText("0:30 / 3:00") { + t.Fatalf("texts %v", tt.Texts()) + } +} + +func TestAudioPlayerKeyboard(t *testing.T) { + st := AudioState{Title: "Ave Mujica", Position: 30 * time.Second, Duration: 3 * time.Minute} + var m mediaCalls + tt := audioTester(&st, &m, false) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if len(m.seeks) != 1 || m.seeks[0] != 35*time.Second { + t.Fatalf("seeks %v", m.seeks) + } + tt.Key(0, ui.KeySpace) + if m.play != 1 { + t.Fatalf("Space on the timeline: play %d", m.play) + } +} + +func TestAudioPlayerDisabled(t *testing.T) { + st := AudioState{Title: "Ave Mujica", Duration: time.Minute} + var m mediaCalls + tt := audioTester(&st, &m, true) + _ = tt.Click("Play") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyRight) + if m.play != 0 || len(m.seeks) != 0 { + t.Fatalf("disabled player reported %+v", m) + } +} + +func TestAudioPlayerNeedsCallbacks(t *testing.T) { + mediaPanics(t, "no callbacks", func(c *ui.Context) { AudioPlayer(c, AudioState{}, AudioPlayerOptions{}) }) +} + +func TestAudioPlayerSnapshot(t *testing.T) { + var m mediaCalls + snapshot(t, "audio_player", 560, 200, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).Children(func() { + AudioPlayer(c, AudioState{Title: "Ave Mujica — Overture", Position: 72 * time.Second, Duration: 4 * time.Minute, Playing: true}, m.opts()) + AudioPlayer(c, AudioState{Title: "Imprisoned XII", Duration: 5 * time.Minute}, m.opts()) + }) + }) +} diff --git a/audio_spectrum.go b/audio_spectrum.go new file mode 100644 index 0000000..13ebd33 --- /dev/null +++ b/audio_spectrum.go @@ -0,0 +1,146 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// AudioSpectrumOptions configure an AudioSpectrum. +type AudioSpectrumOptions struct { + // Height is the chart's height; zero means 120. + Height float32 + // BandLabel names band i in the readout; nil numbers them. + BandLabel func(i int) string + // Status and Error show loading and failure at one size. + Status DataStatus + Error string + // Label names the chart for assistive technology. + Label string +} + +type spectrumKey struct{} + +// AudioSpectrum draws caller bands as bars; it never animates itself. +func AudioSpectrum(c *ui.Context, bands []float32, opts AudioSpectrumOptions) *ui.Element { + for _, b := range bands { + if b < 0 || b > 1 || math.IsNaN(float64(b)) { + panic("mujica: AudioSpectrum band outside 0..1") + } + } + h := opts.Height + if h < 0 { + panic("mujica: AudioSpectrum Height must not be negative") + } + if h == 0 { + h = 120 + } + k := Tokens(c) + label := opts.Label + if label == "" { + label = msg(c, "audio_spectrum.label") + } + e := ui.Box(c).Height(h).MinWidth(120).Radius(theme.ControlRadius).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Focusable().Label(label) + internal.Ring(e, k.Focus, theme.ControlRadius) + if mediaChartState(c, e, opts.Status, opts.Error, len(bands) == 0, "audio_spectrum.empty") { + return e + } + cur := ui.Local(e, spectrumKey{}, func() int { return -1 }) + *cur = min(*cur, len(bands)-1) + name := func(i int) string { + if opts.BandLabel != nil { + return opts.BandLabel(i) + } + return fmt.Sprintf(msg(c, "audio_spectrum.band"), i+1) + } + if moved, ok := chartCursor(e, cur, len(bands)); ok && moved { + c.Announce(spectrumReadout(name(*cur), bands[*cur])) + } + readout := "" + if *cur >= 0 { + readout = spectrumReadout(name(*cur), bands[*cur]) + e.Description(readout) + } + sel, size := *cur, FontSize(c, theme.CaptionSize) + e.Draw(func(p *ui.Painter, r ui.Rect) { + pad := float32(8) + n := float32(len(bands)) + w := (r.W - 2*pad) / n + base := r.Y + r.H - pad + for i, b := range bands { + x := internal.Snap(p, r.X+pad+float32(i)*w) + bh := internal.Snap(p, (r.H-2*pad-16)*b) + col := k.AccentText + if i == sel { + col = k.Text + } + p.Fill(ui.Rect{X: x + 1, Y: base - bh, W: max(1, w-2), H: bh}, col, 0) + } + p.Fill(ui.Rect{X: r.X + pad, Y: base, W: r.W - 2*pad, H: 1}, k.Border, 0) + if sel >= 0 { + x := r.X + pad + float32(sel)*w + p.StrokeDashed(ui.Rect{X: x, Y: r.Y + pad, W: w, H: base - r.Y - pad}, k.TextMuted, 0, 1) + p.Text(r.X+pad, r.Y+4, readout, size, k.Text) + } + }) + return e +} + +func spectrumReadout(name string, v float32) string { + return fmt.Sprintf("%s: %d%%", name, int(math.Round(float64(v*100)))) +} + +// chartCursor moves *cur by arrows, Home and End. +func chartCursor(e *ui.Element, cur *int, n int) (moved, ok bool) { + if e.IsDisabled() || n == 0 { + return false, false + } + old := *cur + switch { + case e.Shortcut(0, ui.KeyRight): + *cur = min(n-1, *cur+1) + case e.Shortcut(0, ui.KeyLeft): + *cur = max(0, *cur-1) + case e.Shortcut(0, ui.KeyHome): + *cur = 0 + case e.Shortcut(0, ui.KeyEnd): + *cur = n - 1 + default: + return false, false + } + return *cur != old, true +} + +// mediaChartState fills e with a loading, error or empty state. +func mediaChartState(c *ui.Context, e *ui.Element, status DataStatus, errText string, empty bool, emptyKey string) bool { + k := Tokens(c) + switch { + case status == DataLoading: + e.Center().Children(func() { Spinner(c, SpinnerOptions{ShowLabel: true}) }) + case status == DataFailed: + if errText == "" { + panic("mujica: a failed media chart needs Error") + } + e.Center().Description(errText).Children(func() { + ui.Row(c).Gap(6).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(16, 16).TextColor(k.Danger) + ui.Text(c, errText).TextColor(k.Danger).MaxLines(2) + }) + }) + case status != DataReady: + panic(fmt.Sprintf("mujica: media chart Status %d is not Ready, Loading or Failed", status)) + case empty: + e.Center().Description(msg(c, emptyKey)).Children(func() { + ui.Text(c, msg(c, emptyKey)).TextColor(k.TextMuted) + }) + default: + return false + } + return true +} diff --git a/audio_spectrum_test.go b/audio_spectrum_test.go new file mode 100644 index 0000000..fb1d21b --- /dev/null +++ b/audio_spectrum_test.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestAudioSpectrumKeyboardReadout(t *testing.T) { + bands := []float32{0.2, 0.9, 0.5} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(300).Children(func() { + AudioSpectrum(c, bands, AudioSpectrumOptions{BandLabel: func(i int) string { return []string{"63 Hz", "1 kHz", "8 kHz"}[i] }}) + }) + }, 400, 200) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnd) + if got := tt.Announcements(); !slices.Equal(got, []string{"63 Hz: 20%", "1 kHz: 90%", "8 kHz: 50%"}) { + t.Fatalf("announced %v", got) + } +} + +func TestAudioSpectrumNeverAnimates(t *testing.T) { + builds := 0 + bands := []float32{0.3, 0.6} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + builds++ + AudioSpectrum(c, bands, AudioSpectrumOptions{}) + }, 300, 200) + start := builds + tt.Frame() + if builds-start != 1 { + t.Fatalf("one frame built %d times: the spectrum asked for more", builds-start) + } +} + +func TestAudioSpectrumStates(t *testing.T) { + var got []ui.Rect + for _, o := range []AudioSpectrumOptions{{}, {Status: DataLoading}, {Status: DataFailed, Error: "Device lost"}} { + var r ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(300).Children(func() { r = AudioSpectrum(c, nil, o).Bounds() }) + }, 400, 200) + tt.Frame() + got = append(got, r) + if o.Status == DataFailed && !tt.HasText("Device lost") { + t.Fatal("error not shown") + } + if o.Status == DataReady && !tt.HasText("No signal") { + t.Fatal("empty state not shown") + } + } + if got[1] != got[0] || got[2] != got[0] { + t.Fatalf("state sizes %v", got) + } + mediaPanics(t, "band over 1", func(c *ui.Context) { AudioSpectrum(c, []float32{2}, AudioSpectrumOptions{}) }) + mediaPanics(t, "failed without error", func(c *ui.Context) { AudioSpectrum(c, nil, AudioSpectrumOptions{Status: DataFailed}) }) +} + +func TestAudioSpectrumSnapshot(t *testing.T) { + bands := make([]float32, 32) + for i := range bands { + bands[i] = float32(0.15 + 0.8*float64((i*37)%29)/29) + } + snapshot(t, "audio_spectrum", 420, 160, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(380).Children(func() { AudioSpectrum(c, bands, AudioSpectrumOptions{}) }) + }) +} diff --git a/audio_waveform.go b/audio_waveform.go new file mode 100644 index 0000000..f22a24c --- /dev/null +++ b/audio_waveform.go @@ -0,0 +1,187 @@ +package mujica + +import ( + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// AudioWaveformOptions configure an AudioWaveform. +type AudioWaveformOptions struct { + // Duration is the audio's length; required when data is ready. + Duration time.Duration + // Position draws the played part; OnSeek reports clicks and arrows. + Position time.Duration + OnSeek func(time.Duration) + // Selection, when set, is chosen by dragging or Shift+arrows. + Selection *MediaRange + // Height is the chart's height; zero means 96. + Height float32 + Status DataStatus + Error string + Label string +} + +// waveformPeaks caches column peaks until data or width change. +type waveformPeaks struct { + first *float32 + n int + cols int + peaks []float32 +} + +type waveformKey struct{} + +type waveformPress struct { + on, dragged bool + x float32 +} + +// waveformBuckets counts peak rebuilds, for tests. +var waveformBuckets int + +// AudioWaveform draws samples with played part and selectable range. +func AudioWaveform(c *ui.Context, samples []float32, opts AudioWaveformOptions) ChoiceResult { + h := opts.Height + if h < 0 || opts.Position < 0 { + panic("mujica: AudioWaveform Height and Position must not be negative") + } + if h == 0 { + h = 96 + } + k := Tokens(c) + label := opts.Label + if label == "" { + label = msg(c, "audio_waveform.label") + } + e := ui.Box(c).Height(h).MinWidth(120).Radius(theme.ControlRadius).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Focusable().Label(label).Cursor(ui.CursorText) + internal.Ring(e, k.Focus, theme.ControlRadius) + v := ChoiceResult{Element: e} + if mediaChartState(c, e, opts.Status, opts.Error, len(samples) == 0, "audio_waveform.empty") { + return v + } + if opts.Duration <= 0 || opts.OnSeek == nil || opts.Position > opts.Duration { + panic("mujica: AudioWaveform needs Duration > 0, OnSeek and Position <= Duration") + } + if s := opts.Selection; s != nil && (s.Start < 0 || s.End < s.Start || s.End > opts.Duration) { + panic("mujica: AudioWaveform Selection outside 0..Duration") + } + r := e.Bounds() + cols := int(r.W) + cache := ui.Local(e, waveformKey{}, func() waveformPeaks { return waveformPeaks{} }) + if cols <= 0 { + // Peaks need the width: lay out first, then build again. + c.AnimationFrame() + } else if cache.first != &samples[0] || cache.n != len(samples) || cache.cols != cols { + *cache = waveformPeaks{&samples[0], len(samples), cols, waveformBucket(samples, cols)} + } + if !e.IsDisabled() { + v.changed = waveformInput(c, e, r, opts) + } + e.Description(mediaClock(opts.Position) + " / " + mediaClock(opts.Duration)) + peaks, pos, sel := cache.peaks, float32(opts.Position)/float32(opts.Duration), opts.Selection + dur := float32(opts.Duration) + e.Draw(func(p *ui.Painter, r ui.Rect) { + mid := r.Y + r.H/2 + amp := r.H/2 - 6 + if sel != nil && sel.End > sel.Start { + x0, x1 := r.X+r.W*float32(sel.Start)/dur, r.X+r.W*float32(sel.End)/dur + p.Fill(ui.Rect{X: x0, Y: r.Y, W: x1 - x0, H: r.H}, k.Selection, 0) + p.Fill(ui.Rect{X: x0, Y: r.Y, W: 1, H: r.H}, k.AccentText, 0) + p.Fill(ui.Rect{X: x1 - 1, Y: r.Y, W: 1, H: r.H}, k.AccentText, 0) + } + played := r.X + r.W*pos + for i, pk := range peaks { + x := r.X + r.W*float32(i)/float32(len(peaks)) + col := k.TextMuted + if x < played { + col = k.AccentText + } + ph := max(1, amp*pk) + p.Fill(ui.Rect{X: x, Y: mid - ph, W: max(1, r.W/float32(len(peaks))-0.5), H: 2 * ph}, col, 0) + } + px := internal.Snap(p, played) + p.Fill(ui.Rect{X: px - 1, Y: r.Y, W: 2, H: r.H}, k.Text, 0) + p.FillPath(internal.Diamond(px, internal.Snap(p, r.Y+5), 8), k.Text) + }) + return v +} + +// waveformBucket returns the largest magnitude in each of cols spans. +func waveformBucket(samples []float32, cols int) []float32 { + waveformBuckets++ + peaks := make([]float32, cols) + for i, s := range samples { + col := i * cols / len(samples) + peaks[col] = max(peaks[col], float32(math.Abs(float64(s)))) + } + return peaks +} + +// waveformInput seeks and selects; true means the selection changed. +func waveformInput(c *ui.Context, e *ui.Element, r ui.Rect, opts AudioWaveformOptions) bool { + at := func(x float32) time.Duration { + f := max(0, min(1, (x-r.X)/max(1, r.W))) + return time.Duration(float64(f) * float64(opts.Duration)).Round(time.Millisecond) + } + seek := func(t time.Duration) { + t = max(0, min(opts.Duration, t)) + slog.Debug("mujica: audio_waveform seek", "pos", t) + opts.OnSeek(t) + } + sel := opts.Selection + old := MediaRange{} + if sel != nil { + old = *sel + } + px, _, _ := e.PointerPosition() + px += r.X + st := ui.Local(e, waveformPress{}, func() waveformPress { return waveformPress{} }) + _, _, dragging := e.Dragged() + switch { + case e.Pressed() && !st.on: + *st = waveformPress{on: true, x: px} + case e.Pressed() && sel != nil && (dragging || st.dragged) && math.Abs(float64(px-st.x)) > 3: + st.dragged = true + a, b := at(st.x), at(px) + *sel = MediaRange{min(a, b), max(a, b)} + case !e.Pressed() && st.on: + if !st.dragged { + seek(at(st.x)) + } + *st = waveformPress{} + } + const step = 5 * time.Second + switch { + case e.Shortcut(0, ui.KeyLeft): + seek(opts.Position - step) + case e.Shortcut(0, ui.KeyRight): + seek(opts.Position + step) + case sel != nil && e.Shortcut(ui.Shift, ui.KeyRight): + *sel = waveformExtend(*sel, opts.Position, step, opts.Duration) + case sel != nil && e.Shortcut(ui.Shift, ui.KeyLeft): + *sel = waveformExtend(*sel, opts.Position, -step, opts.Duration) + case sel != nil && e.Shortcut(0, ui.KeyEscape): + *sel = MediaRange{} + } + changed := sel != nil && *sel != old + if changed { + slog.Debug("mujica: audio_waveform select", "start", sel.Start, "end", sel.End) + } + return changed +} + +// waveformExtend starts a selection at pos or moves its end. +func waveformExtend(s MediaRange, pos, d, dur time.Duration) MediaRange { + if s.End <= s.Start { + s = MediaRange{pos, pos} + } + s.End = max(s.Start, min(dur, s.End+d)) + return s +} diff --git a/audio_waveform_test.go b/audio_waveform_test.go new file mode 100644 index 0000000..9ccb2c0 --- /dev/null +++ b/audio_waveform_test.go @@ -0,0 +1,131 @@ +package mujica + +import ( + "math" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func waveSamples(n int) []float32 { + s := make([]float32, n) + for i := range s { + s[i] = float32(math.Sin(float64(i)/40) * (0.3 + 0.6*math.Abs(math.Sin(float64(i)/3000)))) + } + return s +} + +func waveTester(samples []float32, opts *AudioWaveformOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(400).Children(func() { + if AudioWaveform(c, samples, *opts).Changed() { + *n++ + } + }) + }, 500, 200) +} + +func TestAudioWaveformSeekAndSelect(t *testing.T) { + var seeks []time.Duration + sel := MediaRange{} + opts := AudioWaveformOptions{Duration: 100 * time.Second, Position: 10 * time.Second, Selection: &sel, + OnSeek: func(d time.Duration) { seeks = append(seeks, d) }} + n := 0 + tt := waveTester(waveSamples(5000), &opts, &n) + r, ok := tt.Find("Waveform") + if !ok { + t.Fatal("not named") + } + tt.ClickAt(r.X+r.W/2, r.Y+r.H/2) + if len(seeks) != 1 || seeks[0] < 48*time.Second || seeks[0] > 52*time.Second { + t.Fatalf("click seeks %v", seeks) + } + tt.Press(r.X+r.W*0.2, r.Y+r.H/2) + tt.Move(r.X+r.W*0.3, r.Y+r.H/2) + tt.Move(r.X+r.W*0.4, r.Y+r.H/2) + tt.Release(r.X+r.W*0.4, r.Y+r.H/2) + if sel.Start < 18*time.Second || sel.Start > 22*time.Second || sel.End < 38*time.Second || sel.End > 42*time.Second || len(seeks) != 1 { + t.Fatalf("drag selection %+v, seeks %v", sel, seeks) + } + tt.Key(0, ui.KeyEscape) + tt.Key(ui.Shift, ui.KeyRight) + if sel != (MediaRange{10 * time.Second, 15 * time.Second}) || n < 3 { + t.Fatalf("Shift+Right selection %+v, changes %d", sel, n) + } + tt.Key(0, ui.KeyRight) + if seeks[len(seeks)-1] != 15*time.Second { + t.Fatalf("Right seek %v", seeks) + } +} + +func TestAudioWaveformComputesPeaksOnce(t *testing.T) { + samples := waveSamples(100000) + opts := AudioWaveformOptions{Duration: time.Minute, OnSeek: func(time.Duration) {}} + n := 0 + tt := waveTester(samples, &opts, &n) + before := waveformBuckets + for range 5 { + opts.Position += time.Second + tt.Frame() + } + if got := waveformBuckets - before; got != 0 { + t.Fatalf("peaks rebuilt %d times without new data", got) + } + samples = waveSamples(100000) + tt = waveTester(samples, &opts, &n) + tt.Frame() + if waveformBuckets-before == 0 { + t.Fatal("new data did not rebuild peaks") + } +} + +func TestAudioWaveformStatesKeepSize(t *testing.T) { + var got []ui.Rect + seek := func(time.Duration) {} + for _, o := range []AudioWaveformOptions{{Duration: time.Second, OnSeek: seek}, {Status: DataLoading}, {Status: DataFailed, Error: "Decode failed"}} { + var r ui.Rect + data := waveSamples(100) + if o.Status != DataReady { + data = nil + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(300).Children(func() { r = AudioWaveform(c, data, o).Element.Bounds() }) + }, 400, 200) + tt.Frame() + got = append(got, r) + } + if got[1] != got[0] || got[2] != got[0] { + t.Fatalf("state sizes %v", got) + } +} + +func TestAudioWaveformDisabled(t *testing.T) { + var seeks []time.Duration + opts := AudioWaveformOptions{Duration: time.Minute, OnSeek: func(d time.Duration) { seeks = append(seeks, d) }} + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Width(300).Children(func() { AudioWaveform(c, waveSamples(500), opts) }) + }, 400, 200) + r, _ := tt.Find("Waveform") + tt.ClickAt(r.X+10, r.Y+10) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if len(seeks) != 0 || n != 0 { + t.Fatalf("disabled waveform seeked %v", seeks) + } +} + +func TestAudioWaveformSnapshot(t *testing.T) { + samples := waveSamples(20000) + sel := MediaRange{50 * time.Second, 70 * time.Second} + snapshot(t, "audio_waveform", 460, 150, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(420).Children(func() { + AudioWaveform(c, samples, AudioWaveformOptions{Duration: 2 * time.Minute, Position: 40 * time.Second, + Selection: &sel, OnSeek: func(time.Duration) {}}) + }) + }) +} diff --git a/camera_preview.go b/camera_preview.go new file mode 100644 index 0000000..599365b --- /dev/null +++ b/camera_preview.go @@ -0,0 +1,93 @@ +package mujica + +import ( + "image" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// CameraFrame is the caller's latest camera picture. +type CameraFrame struct { + // Image is the frame; nil shows "No signal". + Image image.Image + // Seq numbers frames; a new number redraws a reused Image. + Seq uint64 +} + +// CameraPreviewOptions configure a CameraPreview. +type CameraPreviewOptions struct { + // Label names the camera for assistive technology. + Label string + Disabled bool +} + +type cameraCache struct { + img image.Image + seq uint64 + mirror bool + bmp *ui.Bitmap +} + +type cameraKey struct{} + +// CameraPreview shows frame at 16:9, mirrored while *mirror. +func CameraPreview(c *ui.Context, frame CameraFrame, mirror *bool, opts CameraPreviewOptions) ChoiceResult { + k := Tokens(c) + old := *mirror + label := opts.Label + if label == "" { + label = msg(c, "camera_preview.label") + } + box := ui.Box(c).FillWidth().AspectRatio(16.0 / 9).Background(videoBlack).Radius(theme.CardRadius).Clip(). + Center().Focusable().Role(ui.RoleImage).Label(label).Disabled(opts.Disabled) + internal.Ring(box, k.Focus, theme.CardRadius) + cache := ui.Local(box, cameraKey{}, func() cameraCache { return cameraCache{} }) + box.Children(func() { + if frame.Image == nil { + box.Description(msg(c, "camera_preview.noSignal")) + ui.Column(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Icon(c, mediaCamera).Size(28, 28).TextColor(ui.Hex("#BCAEAA")) + ui.Text(c, msg(c, "camera_preview.noSignal")).TextColor(ui.Hex("#BCAEAA")) + }) + } else { + // Copies are made once per frame number, not per paint. + if cache.img != frame.Image || cache.seq != frame.Seq || cache.mirror != *mirror || cache.bmp == nil { + *cache = cameraCache{frame.Image, frame.Seq, *mirror, cameraBitmap(frame.Image, *mirror)} + } + ui.Image(c, cache.bmp).Fit(ui.Contain).Fill() + } + ui.Box(c).Absolute().Top(8).Right(8).Background(ui.RGBA(0, 0, 0, 0.5)).Radius(theme.ControlRadius).Children(func() { + b := mediaToggleAction(c, mediaMirror, msg(c, "camera_preview.mirror"), *mirror) + if b.Clicked() && !b.IsDisabled() { + *mirror = !*mirror + } + }) + }) + if !box.IsDisabled() && box.Shortcut(0, ui.KeyM) { + *mirror = !*mirror + } + changed := *mirror != old + if changed { + slog.Debug("mujica: camera_preview mirror", "on", *mirror) + } + return ChoiceResult{Element: box, changed: changed} +} + +// cameraBitmap copies img into a bitmap, flipped when mirror. +func cameraBitmap(img image.Image, mirror bool) *ui.Bitmap { + if !mirror { + return ui.NewBitmap(img) + } + b := img.Bounds() + out := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + out.Set(b.Max.X-1-x, y-b.Min.Y, img.At(x, y)) + } + } + return ui.NewBitmap(out) +} diff --git a/camera_preview_test.go b/camera_preview_test.go new file mode 100644 index 0000000..cde85b9 --- /dev/null +++ b/camera_preview_test.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "image" + "testing" + + "github.com/egoist/mygo/ui" +) + +func cameraTester(frame *CameraFrame, mirror *bool, disabled bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(320).Children(func() { + if CameraPreview(c, *frame, mirror, CameraPreviewOptions{Disabled: disabled}).Changed() { + *n++ + } + }) + }, 400, 260) +} + +func TestCameraPreviewNoSignalAndMirror(t *testing.T) { + frame, mirror, n := CameraFrame{}, false, 0 + tt := cameraTester(&frame, &mirror, false, &n) + if !tt.HasText("No signal") { + t.Fatalf("texts %v", tt.Texts()) + } + if err := tt.Click("Mirror"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Camera") { + t.Fatal("preview not focusable") + } + tt.Key(0, ui.KeyM) + if mirror || n != 2 { + t.Fatalf("mirror %v changes %d", mirror, n) + } + frame = CameraFrame{Image: mediaTestImage(32, 18), Seq: 1} + tt.Frame() + if tt.HasText("No signal") { + t.Fatal("frame did not replace No signal") + } +} + +func TestCameraPreviewDisabled(t *testing.T) { + frame, mirror, n := CameraFrame{}, false, 0 + tt := cameraTester(&frame, &mirror, true, &n) + _ = tt.Click("Mirror") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyM) + if mirror || n != 0 { + t.Fatal("disabled preview flipped") + } +} + +func TestCameraBitmapMirrors(t *testing.T) { + img := mediaTestImage(8, 8) + flipped := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for y := range 8 { + for x := range 8 { + flipped.Set(7-x, y, img.At(x, y)) + } + } + got := ui.Render(func(c *ui.Context) { + ui.Image(c, cameraBitmap(img, true)).Size(8, 8) + }, 8, 8, 1) + want := ui.Render(func(c *ui.Context) { + ui.Image(c, ui.NewBitmap(flipped)).Size(8, 8) + }, 8, 8, 1) + if got.RGBAAt(1, 6) != want.RGBAAt(1, 6) || got.RGBAAt(1, 6) == ui.Render(func(c *ui.Context) { + ui.Image(c, ui.NewBitmap(img)).Size(8, 8) + }, 8, 8, 1).RGBAAt(1, 6) { + t.Fatal("mirrored bitmap is not flipped") + } +} + +func TestCameraPreviewSnapshot(t *testing.T) { + img := mediaTestImage(160, 90) + m1, m2 := true, false + snapshot(t, "camera_preview", 560, 180, func(c *ui.Context) { + ui.Row(c).Padding(20).Gap(12).Children(func() { + ui.Column(c).Grow(1).Children(func() { CameraPreview(c, CameraFrame{Image: img}, &m1, CameraPreviewOptions{}) }) + ui.Column(c).Grow(1).Children(func() { CameraPreview(c, CameraFrame{}, &m2, CameraPreviewOptions{}) }) + }) + }) +} diff --git a/device_selector.go b/device_selector.go new file mode 100644 index 0000000..e7b9e92 --- /dev/null +++ b/device_selector.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MediaDevice is one device; IDs are unique within a kind. +type MediaDevice struct { + ID, Name string +} + +// MediaDevices are the caller's lists; nil hides that kind. +type MediaDevices struct { + Microphones, Cameras, Speakers []MediaDevice +} + +// DeviceSelection holds each kind's chosen ID; empty is none. +type DeviceSelection struct { + Microphone, Camera, Speaker string +} + +// DeviceSelectorOptions configure a DeviceSelector. +type DeviceSelectorOptions struct { + Disabled bool +} + +// DeviceSelector chooses a microphone, camera and speaker. +func DeviceSelector(c *ui.Context, sel *DeviceSelection, devices MediaDevices, opts DeviceSelectorOptions) ChoiceResult { + old := *sel + k := Tokens(c) + u := Density(c).Unit() + col := ui.Column(c).Gap(u * 3).Role(ui.RoleGroup).Label(msg(c, "device_selector.label")).Disabled(opts.Disabled) + col.Children(func() { + deviceRow(c, k, mediaMic, "device_selector.microphone", &sel.Microphone, devices.Microphones, opts.Disabled) + deviceRow(c, k, mediaCamera, "device_selector.camera", &sel.Camera, devices.Cameras, opts.Disabled) + deviceRow(c, k, mediaSpeaker, "device_selector.speaker", &sel.Speaker, devices.Speakers, opts.Disabled) + }) + changed := *sel != old + if changed { + slog.Debug("mujica: device_selector changed", "selection", *sel) + } + return ChoiceResult{Element: col, changed: changed} +} + +func deviceRow(c *ui.Context, k theme.Tokens, icon *ui.SVG, key string, id *string, list []MediaDevice, disabled bool) { + if list == nil { + return + } + options := make([]SelectOption[string], len(list)) + for i, d := range list { + if d.ID == "" { + panic("mujica: DeviceSelector device without an ID") + } + for _, o := range options[:i] { + if o.Value == d.ID { + panic("mujica: DeviceSelector duplicate device ID " + d.ID) + } + } + options[i] = SelectOption[string]{Value: d.ID, Label: d.Name} + } + if _, ok := selectLabel(options, *id); *id != "" && !ok { + panic("mujica: DeviceSelector selection " + *id + " is not in its list") + } + ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icon).Size(16, 16).TextColor(k.TextMuted) + ui.Text(c, msg(c, key)).Width(96).SingleLine() + hint := msg(c, "device_selector.choose") + if len(list) == 0 { + hint = msg(c, "device_selector.none") + } + Select(c, id, options, SelectOptions{Disabled: disabled || len(list) == 0, Label: msg(c, key), + Placeholder: hint, Empty: msg(c, "device_selector.none")}).Element.Grow(1) + }) +} diff --git a/device_selector_test.go b/device_selector_test.go new file mode 100644 index 0000000..74c8b79 --- /dev/null +++ b/device_selector_test.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var deviceTestList = MediaDevices{ + Microphones: []MediaDevice{{"m1", "Built-in Microphone"}, {"m2", "Studio Mic"}}, + Cameras: []MediaDevice{}, + Speakers: []MediaDevice{{"s1", "Headphones"}}, +} + +func deviceTester(sel *DeviceSelection, devices MediaDevices, disabled bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(420).Children(func() { + if DeviceSelector(c, sel, devices, DeviceSelectorOptions{Disabled: disabled}).Changed() { + *n++ + } + }) + }, 500, 400) +} + +func TestDeviceSelectorChoose(t *testing.T) { + sel, n := DeviceSelection{Microphone: "m1"}, 0 + tt := deviceTester(&sel, deviceTestList, false, &n) + if !tt.HasText("Built-in Microphone") || !tt.HasText("No devices found") { + t.Fatalf("texts %v", tt.Texts()) + } + if err := tt.Click("Built-in Microphone"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Studio Mic"); err != nil { + t.Fatal(err) + } + if sel.Microphone != "m2" || n != 1 { + t.Fatalf("selection %+v changes %d", sel, n) + } +} + +func TestDeviceSelectorHidesNilListsAndDisabled(t *testing.T) { + sel, n := DeviceSelection{}, 0 + tt := deviceTester(&sel, MediaDevices{Speakers: deviceTestList.Speakers}, true, &n) + if tt.HasText("Camera") || tt.HasText("Microphone") { + t.Fatalf("nil lists shown: %v", tt.Texts()) + } + _ = tt.Click("Choose a device") + _ = tt.Click("Headphones") + if sel.Speaker != "" || n != 0 { + t.Fatal("disabled selector chose") + } +} + +func TestDeviceSelectorBadLists(t *testing.T) { + sel := DeviceSelection{} + mediaPanics(t, "duplicate", func(c *ui.Context) { + DeviceSelector(c, &sel, MediaDevices{Cameras: []MediaDevice{{"a", "A"}, {"a", "B"}}}, DeviceSelectorOptions{}) + }) + gone := DeviceSelection{Camera: "x"} + mediaPanics(t, "unknown selection", func(c *ui.Context) { + DeviceSelector(c, &gone, MediaDevices{Cameras: []MediaDevice{{"a", "A"}}}, DeviceSelectorOptions{}) + }) +} + +func TestDeviceSelectorSnapshot(t *testing.T) { + sel := DeviceSelection{Microphone: "m2", Speaker: "s1"} + snapshot(t, "device_selector", 460, 190, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(420).Children(func() { + DeviceSelector(c, &sel, deviceTestList, DeviceSelectorOptions{}) + }) + }) +} diff --git a/docs/components/341-image-cropper.md b/docs/components/341-image-cropper.md new file mode 100644 index 0000000..bef9a87 --- /dev/null +++ b/docs/components/341-image-cropper.md @@ -0,0 +1,55 @@ +# 341 ImageCropper 图片裁剪 + +## 用途 + +在图片上拖出裁剪框,可锁定宽高比、按 90 度旋转,输出裁剪矩形。 + +## 最小示例 + +```go +crop := mujica.ImageCrop{} // empty: starts fitted to the image +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageCropper(c, examplePhoto, &crop, mujica.ImageCropperOptions{Aspect: 1}).Changed() { + _ = crop.Rect // in pixels of the photo turned by crop.Rotation + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ImageCropper(c *ui.Context, src image.Image, crop *ImageCrop, opts ImageCropperOptions) ChoiceResult` + +`src` 必填,是标准库 `image.Image`(组件要自己转动像素)。`ImageCrop.Rect` 是旋转 `Rotation` 度之后那张图上的像素矩形;`Rotation` 只能是 0、90、180、270。`Rect` 为空时自动设为最大的居中框。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Aspect` | `float64` | 0 | 锁定宽除以高;0 为自由比例。`Rect` 不符合比例时 panic。 | +| `Height` | `float32` | 0(即 320) | 舞台高度。 | +| `Disabled` | `bool` | false | 禁用拖动、按键与旋转。 | +| `Label` | `string` | "Crop area" | 舞台的无障碍名称。 | + +## 状态 + +- 默认:框外区域压暗,框线与三分线为象牙色,四角有菱形手柄。 +- 聚焦:舞台显示 2 DIP 焦点环;下方读数同时作为无障碍描述。 +- 禁用:MyGo 统一调暗,不响应输入。 + +## 事件 + +`Changed()` 在框移动、缩放或旋转后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| 方向键 | 移动裁剪框,每次图片宽度的 1% | +| Shift+方向键 | 右、下放大,左、上缩小;锁定比例时保持比例 | +| R / Shift+R | 向右 / 向左旋转 90 度 | + +## 限制 + +- MyGo 的 Painter 不能旋转位图(只有单色图标能转,ui/svg.go:98),所以组件在图片或角度变化时复制一份转好的像素;超大图片会在那一帧花时间。 +- 锁定比例时旋转会重新取最大的居中框,原选区不保留。 +- 拖动角点时,按下点距角 14 DIP 以内才算抓住手柄。 diff --git a/docs/components/342-image-annotator.md b/docs/components/342-image-annotator.md new file mode 100644 index 0000000..c947050 --- /dev/null +++ b/docs/components/342-image-annotator.md @@ -0,0 +1,59 @@ +# 342 ImageAnnotator 图片标注 + +## 用途 + +在图片上画矩形、箭头和文字标注,可选择、移动和删除,并导出标注数据。 + +## 最小示例 + +```go +photo := ui.NewBitmap(examplePhoto) +var notes []mujica.Annotation +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageAnnotator(c, photo, ¬es, mujica.ImageAnnotatorOptions{}).Changed() { + data, _ := mujica.ExportAnnotations(notes) + _ = data // save the JSON + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ImageAnnotator(c *ui.Context, src ui.ImageSource, notes *[]Annotation, opts ImageAnnotatorOptions) ChoiceResult` + +`func ExportAnnotations(notes []Annotation) ([]byte, error)` 把标注写成 JSON(kind、x0、y0、x1、y1、text)。 + +`Annotation` 的坐标是图片像素;文字标注以 `From` 为左上角,`Text` 不能为空。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Height` | `float32` | 0(即 320) | 舞台高度。 | +| `Disabled` | `bool` | false | 禁用绘制、选择和删除。 | +| `Label` | `string` | "Annotations" | 舞台的无障碍名称。 | + +## 状态 + +- 工具:选择、矩形、箭头、文字,用分段控件切换。 +- 选中:虚线框加菱形标记,不只靠颜色。 +- 标注用暗红墨线加浅色描边,在任何图片上都看得清。 + +## 事件 + +`Changed()` 在新增、移动、改字或删除标注后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| [ / ] | 选中上一个 / 下一个标注 | +| 方向键 | 移动选中的标注,每次图片宽度的 1% | +| Delete / Backspace | 删除选中的标注 | +| Escape | 取消选择 | + +## 限制 + +- 文字工具:先在工具栏输入文字,再点击放置;选中文字标注时同一输入框可改字。 +- 标注不能缩放,只能移动;要改大小请删除后重画。 +- 文字的命中区域按每字 8 像素估算。 diff --git a/docs/components/343-image-thumbnail.md b/docs/components/343-image-thumbnail.md new file mode 100644 index 0000000..eaf0c67 --- /dev/null +++ b/docs/components/343-image-thumbnail.md @@ -0,0 +1,54 @@ +# 343 ImageThumbnail 缩略图 + +## 用途 + +固定尺寸、按比例裁切的可点击缩略图,带选中、加载中和加载失败状态。 + +## 最小示例 + +```go +photo := ui.NewBitmap(examplePhoto) +selected := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageThumbnail(c, photo, mujica.ImageThumbnailOptions{Alt: "Grand hall", Aspect: 4.0 / 3, Selected: selected}).Changed() { + selected = !selected + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ImageThumbnail(c *ui.Context, src ui.ImageSource, opts ImageThumbnailOptions) ChoiceResult` + +`src` 为 nil 时显示占位图标。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Width` | `float32` | 0(即 96) | 宽度。 | +| `Aspect` | `float32` | 0(即 1) | 宽除以高,图片按 Cover 裁切填满。 | +| `Selected` | `bool` | false | 选中:2 DIP 强调边框加右上角菱形。 | +| `Loading` | `bool` | false | 显示加载标记,尺寸不变。 | +| `Err` | `error` | nil | 显示失败图标;错误文字作为提示与描述。 | +| `Alt` | `string` | 必填 | 替代说明,为空时 panic。 | +| `Disabled` | `bool` | false | 禁用点击。 | + +## 状态 + +默认、悬停(边框加深)、聚焦、选中、加载、失败、禁用,所有状态尺寸相同。 + +## 事件 + +`Changed()` 表示本帧被点击,或聚焦时按了 Enter / Space。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 聚焦缩略图 | +| Enter / Space | 激活 | + +## 限制 + +- 选中状态由调用方保存;组件只报告点击。 diff --git a/docs/components/344-lightbox.md b/docs/components/344-lightbox.md new file mode 100644 index 0000000..5c5103a --- /dev/null +++ b/docs/components/344-lightbox.md @@ -0,0 +1,53 @@ +# 344 Lightbox 灯箱 + +## 用途 + +全窗口浏览图片:左右切换、显示说明文字,Escape 关闭后焦点回到触发控件。 + +## 最小示例 + +```go +images := []mujica.ViewerImage{{Source: ui.NewBitmap(examplePhoto), Alt: "The grand hall"}} +open, index := false, 0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "View photos", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Lightbox(c, &open, &index, images) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Lightbox(c *ui.Context, open *bool, index *int, images []ViewerImage) ChoiceResult` + +与 ImageViewer 共用 `ViewerImage`(`Source`、`Alt`)。`Alt` 同时是说明文字。关闭时 `Element` 为 nil。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `open` | `*bool` | — | 是否显示;Escape、点击关闭或背景时设为 false。 | +| `index` | `*int` | — | 当前图片;越界 panic。 | +| `images` | `[]ViewerImage` | — | 至少一张,`Source` 须为 `*ui.Bitmap` 或 `*ui.SVG`。 | + +## 状态 + +- 黑色遮罩铺满窗口,图片按 Contain 居中;首尾时对应切换按钮禁用。 +- 打开时按弹层时长淡入,减少动画时直接出现。 + +## 事件 + +`Changed()` 在切换图片后为 true,并朗读“第 n 张,共 m 张”。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / → | 上一张 / 下一张 | +| Home / End | 第一张 / 最后一张 | +| Escape | 关闭并把焦点还给打开它的控件 | + +## 限制 + +- 不提供缩放与平移;需要时请用 ImageViewer。 diff --git a/docs/components/345-video-player.md b/docs/components/345-video-player.md new file mode 100644 index 0000000..7a9beca --- /dev/null +++ b/docs/components/345-video-player.md @@ -0,0 +1,59 @@ +# 345 VideoPlayer 视频播放器 + +## 用途 + +播放器外壳:画面由调用方提供,组件绘制状态并通过回调报告播放、暂停、跳转、倍速、字幕、全屏和画中画。 + +## 最小示例 + +```go +st := mujica.VideoState{Duration: 4 * time.Minute, Rate: 1} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{ + OnPlay: func() { st.Playing = true }, // start your decoder + OnPause: func() { st.Playing = false }, // pause it + OnSeek: func(d time.Duration) { st.Position = d }, + OnRate: func(r float64) { st.Rate = r }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VideoPlayer(c *ui.Context, st VideoState, opts VideoPlayerOptions) *ui.Element` + +`VideoState`:`Frame`(当前画面,nil 显示“暂无视频”)、`Position`、`Duration`、`Buffered`、`Playing`、`Rate`、`Subtitle`、`SubtitlesOn`、`Fullscreen`、`PiP`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OnPlay / OnPause / OnSeek` | `func` | 必填 | 播放、暂停、跳转。 | +| `OnRate` | `func(float64)` | nil | 倍速;nil 时不显示倍速选择。 | +| `OnSubtitles / OnFullscreen / OnPiP` | `func(bool)` | nil | 开关回调;nil 时隐藏对应按钮。 | +| `Disabled` | `bool` | false | 禁用全部控件。 | +| `Label` | `string` | "Video" | 无障碍名称。 | + +## 状态 + +- 画面区 16:9,黑底;字幕开启且有字幕时在底部显示。 +- 字幕、画中画、全屏按钮开启时有选中底色和下划线。 + +## 事件 + +全部通过回调报告;组件不改动 `st`。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 先聚焦画面,再到进度条和各按钮 | +| Space / K | 画面聚焦时播放或暂停 | +| ← / → | 画面聚焦时后退 / 前进 5 秒 | +| F | 切换全屏 | +| C | 切换字幕 | + +## 限制 + +- 组件不解码视频,也不实现全屏或画中画窗口,只报告用户的请求。 +- 帧率取决于调用方更新 `Frame` 并调用 `c.Invalidate()` 的频率。 diff --git a/docs/components/346-video-scrubber.md b/docs/components/346-video-scrubber.md new file mode 100644 index 0000000..6ada0d3 --- /dev/null +++ b/docs/components/346-video-scrubber.md @@ -0,0 +1,56 @@ +# 346 VideoScrubber 视频时间轴 + +## 用途 + +可拖动的时间轴:显示已缓冲区间,悬停显示时间和缩略图插槽。 + +## 最小示例 + +```go +pos := 30 * time.Second +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoScrubber(c, &pos, mujica.VideoScrubberOptions{ + Duration: 2 * time.Minute, + Buffered: []mujica.MediaRange{{Start: 0, End: time.Minute}}, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VideoScrubber(c *ui.Context, pos *time.Duration, opts VideoScrubberOptions) ChoiceResult` + +`*pos` 必须在 0 到 `Duration` 之间,否则 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Duration` | `time.Duration` | 必填 | 时间轴长度,须大于 0。 | +| `Buffered` | `[]MediaRange` | nil | 已缓冲区间,以浅色填充。 | +| `Preview` | `func(at time.Duration)` | nil | 悬停时在插槽里构建该时间的缩略图。 | +| `PreviewWidth / PreviewHeight` | `float32` | 160×90 | 插槽尺寸。 | +| `Step` | `time.Duration` | 5 秒 | 方向键每次移动的时长。 | +| `Disabled` | `bool` | false | 禁用。 | +| `Label` | `string` | "Timeline" | 无障碍名称;以滑块角色报告范围与数值。 | + +## 状态 + +默认、悬停(显示时间气泡)、拖动、聚焦(菱形手柄外圈)、禁用。 + +## 事件 + +`Changed()` 在拖动、点击或按键改变 `*pos` 后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / ↓ | 后退一个 Step | +| → / ↑ | 前进一个 Step | +| Home / End | 开头 / 结尾 | + +## 限制 + +- 位置按毫秒取整。 +- 悬停插槽只在指针位于时间轴上时显示,键盘操作时不显示。 diff --git a/docs/components/347-video-thumbnail-strip.md b/docs/components/347-video-thumbnail-strip.md new file mode 100644 index 0000000..e36b6ef --- /dev/null +++ b/docs/components/347-video-thumbnail-strip.md @@ -0,0 +1,51 @@ +# 347 VideoThumbnailStrip 缩略帧条 + +## 用途 + +横向缩略帧条:标出当前帧,点击或按键跳转。 + +## 最小示例 + +```go +frames := []mujica.StripFrame{{At: 0}, {At: 10 * time.Second}, {At: 20 * time.Second}} +current := 0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.VideoThumbnailStrip(c, ¤t, frames, mujica.VideoThumbnailStripOptions{}).Changed() { + _ = frames[current].At // seek the player there + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VideoThumbnailStrip(c *ui.Context, current *int, frames []StripFrame, opts VideoThumbnailStripOptions) ChoiceResult` + +`StripFrame` 有 `Image`(可为 nil)和 `At`。`*current` 越界 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `FrameWidth` | `float32` | 0(即 96) | 每格宽度,高度按 16:9。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +- 只构建放得下的那几格,窗口跟随当前帧移动;10 万帧也只建十几个元素。 +- 当前帧:强调边框加菱形;每格左下角显示时间。 + +## 事件 + +`Changed()` 在当前帧变化后为 true;调用方据此跳转播放器。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 聚焦帧条 | +| ← / → | 上一帧 / 下一帧 | +| Home / End | 第一帧 / 最后一帧 | + +## 限制 + +- 不提供横向滚动条,用两侧按钮或键盘移动。 diff --git a/docs/components/348-audio-player.md b/docs/components/348-audio-player.md new file mode 100644 index 0000000..426e71d --- /dev/null +++ b/docs/components/348-audio-player.md @@ -0,0 +1,51 @@ +# 348 AudioPlayer 音频播放器 + +## 用途 + +音频播放外壳:播放、暂停、进度与时长,播放本身由调用方驱动。 + +## 最小示例 + +```go +st := mujica.AudioState{Title: "Overture", Duration: 3 * time.Minute} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioPlayer(c, st, mujica.AudioPlayerOptions{ + OnPlay: func() { st.Playing = true }, + OnPause: func() { st.Playing = false }, + OnSeek: func(d time.Duration) { st.Position = d }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) *ui.Element` + +`AudioState`:`Title`、`Position`、`Duration`、`Playing`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OnPlay / OnPause / OnSeek` | `func` | 必填 | 缺少任一回调时 panic。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +播放中显示暂停按钮,暂停时显示播放按钮;`Duration` 为 0 时不显示进度条。 + +## 事件 + +通过回调报告,组件不改动 `st`。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 播放按钮、进度条 | +| Space | 组件内任意位置播放或暂停(按钮聚焦时由按钮处理) | +| ← / → | 进度条聚焦时跳转 5 秒 | + +## 限制 + +- 组件不播放声音。 diff --git a/docs/components/349-audio-waveform.md b/docs/components/349-audio-waveform.md new file mode 100644 index 0000000..674f8ad --- /dev/null +++ b/docs/components/349-audio-waveform.md @@ -0,0 +1,58 @@ +# 349 AudioWaveform 音频波形 + +## 用途 + +绘制调用方提供的采样波形,覆盖播放进度,可拖动选择区间。 + +## 最小示例 + +```go +samples := make([]float32, 48000) // from your decoder, -1 to 1 +pos, sel := time.Duration(0), mujica.MediaRange{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioWaveform(c, samples, mujica.AudioWaveformOptions{ + Duration: time.Second, Position: pos, Selection: &sel, + OnSeek: func(d time.Duration) { pos = d }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AudioWaveform(c *ui.Context, samples []float32, opts AudioWaveformOptions) ChoiceResult` + +`samples` 取值 -1 到 1。数据就绪时 `Duration` 须大于 0,`OnSeek` 必填。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Duration` | `time.Duration` | — | 音频长度。 | +| `Position` | `time.Duration` | 0 | 已播放部分。 | +| `OnSeek` | `func(time.Duration)` | — | 点击或方向键跳转。 | +| `Selection` | `*MediaRange` | nil | 非 nil 时可拖动或 Shift+方向键选择区间。 | +| `Height` | `float32` | 0(即 96) | 高度。 | +| `Status / Error` | `DataStatus / string` | DataReady | 加载中、失败(须给 Error)与无数据时显示同尺寸占位。 | +| `Label` | `string` | "Waveform" | 无障碍名称。 | + +## 状态 + +- 已播放部分用强调色,其余用说明色,并有播放线和菱形标记,不只靠颜色区分。 +- 选区用 Selection 底色加两条边线。 + +## 事件 + +`Changed()` 在选区变化后为 true;跳转通过 `OnSeek` 报告。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / → | 跳转 ±5 秒 | +| Shift+← / → | 从播放位置开始或调整选区终点 | +| Escape | 清除选区 | + +## 限制 + +- 峰值按像素列计算,只在数据或宽度变化时重算,不会每帧计算。 +- 数据以首元素地址和长度识别;原地修改采样请换一个切片。 diff --git a/docs/components/350-audio-spectrum.md b/docs/components/350-audio-spectrum.md new file mode 100644 index 0000000..b7e2994 --- /dev/null +++ b/docs/components/350-audio-spectrum.md @@ -0,0 +1,49 @@ +# 350 AudioSpectrum 音频频谱 + +## 用途 + +把调用方提供的频带数值画成柱状频谱;组件自己不做动画,隐藏时不重绘。 + +## 最小示例 + +```go +bands := []float32{0.2, 0.6, 0.9, 0.4, 0.1} // set by your analyser, then c.Invalidate() +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AudioSpectrum(c *ui.Context, bands []float32, opts AudioSpectrumOptions) *ui.Element` + +每个频带取 0 到 1,超出范围 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Height` | `float32` | 0(即 120) | 高度。 | +| `BandLabel` | `func(i int) string` | nil | 读数中频带的名称,如“1 kHz”;nil 时编号。 | +| `Status / Error` | `DataStatus / string` | DataReady | 加载、失败与无数据占位,尺寸相同。 | +| `Label` | `string` | "Spectrum" | 无障碍名称。 | + +## 状态 + +聚焦后用方向键选中的频带以正文色显示,带虚线框和读数,不只靠颜色。 + +## 事件 + +无事件;方向键读数会通过 `Announce` 朗读。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 聚焦频谱 | +| ← / → | 上一个 / 下一个频带 | +| Home / End | 第一个 / 最后一个频带 | + +## 限制 + +- 刷新节奏由调用方决定:更新数据后调用 `c.Invalidate()`。 diff --git a/docs/components/351-volume-control.md b/docs/components/351-volume-control.md new file mode 100644 index 0000000..ef6eb3d --- /dev/null +++ b/docs/components/351-volume-control.md @@ -0,0 +1,46 @@ +# 351 VolumeControl 音量控制 + +## 用途 + +音量滑块与静音按钮。 + +## 最小示例 + +```go +volume, muted := 0.8, false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeControl(c, &volume, &muted, mujica.VolumeControlOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VolumeControl(c *ui.Context, volume *float64, muted *bool, opts VolumeControlOptions) ChoiceResult` + +`*volume` 取 0 到 1,超出 panic;步长 5%。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +静音或音量为 0 时显示静音图标;静音按钮开启时有选中底色和下划线。静音时调高音量会自动取消静音。 + +## 事件 + +`Changed()` 在音量或静音变化后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / → | 音量 ±5% | +| Home / End | 0% / 100% | +| M | 组件内切换静音 | + +## 限制 + +- 组件不调节系统音量,只改调用方的值。 diff --git a/docs/components/352-playback-speed-control.md b/docs/components/352-playback-speed-control.md new file mode 100644 index 0000000..9a7e829 --- /dev/null +++ b/docs/components/352-playback-speed-control.md @@ -0,0 +1,48 @@ +# 352 PlaybackSpeedControl 倍速选择 + +## 用途 + +在 0.5× 到 2× 之间选择播放速度。 + +## 最小示例 + +```go +rate := 1.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PlaybackSpeedControl(c, &rate, mujica.PlaybackSpeedControlOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func PlaybackSpeedControl(c *ui.Context, rate *float64, opts PlaybackSpeedControlOptions) ChoiceResult` + +`*rate` 必须是 `Rates` 中的一项,否则 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Rates` | `[]float64` | 0.5, 0.75, 1, 1.25, 1.5, 2 | 升序的正数。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +基于 Select,状态与键盘同 Select。 + +## 事件 + +`Changed()` 在选择新倍速后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Enter / Space | 打开列表 | +| ↑ / ↓ | 移动 | +| Enter | 确认 | +| Escape | 关闭 | + +## 限制 + +- 无。 diff --git a/docs/components/353-media-controls.md b/docs/components/353-media-controls.md new file mode 100644 index 0000000..3d35038 --- /dev/null +++ b/docs/components/353-media-controls.md @@ -0,0 +1,53 @@ +# 353 MediaControls 媒体控制 + +## 用途 + +上一首、播放/暂停、下一首、循环、随机,一组可用方向键切换焦点的按钮。 + +## 最小示例 + +```go +st := mujica.MediaControlsState{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MediaControls(c, st, mujica.MediaControlsOptions{ + OnPrevious: func() {}, OnNext: func() {}, + OnPlay: func() { st.Playing = true }, OnPause: func() { st.Playing = false }, + OnLoop: func(m mujica.LoopMode) { st.Loop = m }, OnShuffle: func(on bool) { st.Shuffle = on }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func MediaControls(c *ui.Context, st MediaControlsState, opts MediaControlsOptions) *ui.Element` + +`MediaControlsState`:`Playing`、`Loop`(`LoopOff`、`LoopAll`、`LoopOne`)、`Shuffle`、`NoPrevious`、`NoNext`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OnPrevious / OnPlay / OnPause / OnNext` | `func()` | 必填 | 按钮回调。 | +| `OnLoop` | `func(LoopMode)` | 必填 | 收到下一个循环模式:不循环 → 列表 → 单曲。 | +| `OnShuffle` | `func(bool)` | 必填 | 随机开关。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +循环与随机开启时有选中底色和下划线,单曲循环换用带“1”的图标;循环按钮的名称说明当前模式。 + +## 事件 + +全部通过回调报告。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 进入按钮组 | +| ← / → | 在按钮间移动焦点 | +| Enter / Space | 按下 | + +## 限制 + +- 无。 diff --git a/docs/components/354-playlist.md b/docs/components/354-playlist.md new file mode 100644 index 0000000..85bac64 --- /dev/null +++ b/docs/components/354-playlist.md @@ -0,0 +1,52 @@ +# 354 Playlist 播放列表 + +## 用途 + +播放列表:标出当前项,可拖动排序、移除,只构建可见行。 + +## 最小示例 + +```go +items := []mujica.PlaylistItem{{ID: "a", Title: "Overture"}, {ID: "b", Title: "Masquerade"}} +current := "a" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.Playlist(c, &items, mujica.PlaylistOptions{Current: current}); r.Played != "" { + current = r.Played + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Playlist(c *ui.Context, items *[]PlaylistItem, opts PlaylistOptions) PlaylistResult` + +`PlaylistItem`:`ID`(唯一且非空,否则 panic)、`Title`、`Subtitle`、`Duration`。组件直接修改 `*items` 完成排序和移除。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Current` | `string` | "" | 正在播放项的 ID,显示播放标记和强调文字。 | +| `Height` | `float32` | 0(即 320) | 列表高度。 | +| `Disabled` | `bool` | false | 禁用选择、排序和移除。 | + +## 状态 + +基于 List:选中行使用 Selection 底色加勾选标记;空列表显示提示。 + +## 事件 + +`PlaylistResult`:`Played` 为 Enter 或双击选择的 ID;`Removed` 为被移除的 ID;`Moved` 表示顺序变化。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ↑ / ↓ | 移动选择 | +| Enter | 播放选中项 | +| Alt+↑ / Alt+↓ | 上移 / 下移选中项 | +| Delete / Backspace | 移除选中项 | + +## 限制 + +- 拖动排序由 MyGo List 的 Reorder 提供,拖动时显示插入线。 diff --git a/docs/components/355-subtitle-editor.md b/docs/components/355-subtitle-editor.md new file mode 100644 index 0000000..eb921bc --- /dev/null +++ b/docs/components/355-subtitle-editor.md @@ -0,0 +1,52 @@ +# 355 SubtitleEditor 字幕编辑器 + +## 用途 + +逐条编辑字幕的开始、结束时间和文本,校验时间先后与重叠。 + +## 最小示例 + +```go +cues := []mujica.SubtitleCue{{Start: 0, End: 2 * time.Second, Text: "The curtain rises."}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SubtitleEditor(c, &cues, mujica.SubtitleEditorOptions{}) + if len(mujica.CheckSubtitles(cues)) > 0 { + _ = cues // block saving until the issues are fixed + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func SubtitleEditor(c *ui.Context, cues *[]SubtitleCue, opts SubtitleEditorOptions) ChoiceResult` + +`func CheckSubtitles(cues []SubtitleCue) []SubtitleIssue` 返回结束早于开始(`SubtitleEndBeforeStart`)和与上一条重叠(`SubtitleOverlap`)的条目,供调用方阻止保存。时间格式为 `m:ss.mmm` 或 `h:mm:ss.mmm`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Height` | `float32` | 0(即 360) | 列表高度。 | +| `Disabled` | `bool` | false | 禁用编辑、添加和删除。 | + +## 状态 + +- 无法解析的时间保留在输入框中,并显示格式提示;有效后才写回。 +- 时间问题在该行下方用错误色说明,并报告给辅助技术。 +- 只构建可见行,10 万条也保持流畅。 + +## 事件 + +`Changed()` 在任一字段、添加或删除后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 在一行的开始、结束、文本、删除之间移动 | +| Enter | 提交输入 | + +## 限制 + +- 虚拟化只构建可见行,Tab 不会跳到未显示的行;请先滚动。 +- 组件不排序字幕;顺序由调用方决定。 diff --git a/docs/components/356-screen-recorder-controls.md b/docs/components/356-screen-recorder-controls.md new file mode 100644 index 0000000..d0dcb96 --- /dev/null +++ b/docs/components/356-screen-recorder-controls.md @@ -0,0 +1,51 @@ +# 356 ScreenRecorderControls 录屏控制 + +## 用途 + +开始、暂停、继续、停止录制并显示计时;录制本身由调用方实现。 + +## 最小示例 + +```go +st := mujica.RecorderState{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScreenRecorderControls(c, st, mujica.ScreenRecorderControlsOptions{ + OnStart: func() { st.Status = mujica.RecorderRecording }, + OnPause: func() { st.Status = mujica.RecorderPaused }, + OnResume: func() { st.Status = mujica.RecorderRecording }, + OnStop: func() { st = mujica.RecorderState{} }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ScreenRecorderControls(c *ui.Context, st RecorderState, opts ScreenRecorderControlsOptions) *ui.Element` + +`RecorderState`:`Status`(`RecorderIdle`、`RecorderRecording`、`RecorderPaused`)、`Elapsed`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OnStart / OnPause / OnResume / OnStop` | `func()` | 必填 | 缺少任一回调时 panic。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +就绪、录制中(红点)、已暂停;状态同时用文字表示,不只靠圆点颜色。录制中的圆点不闪烁。 + +## 事件 + +全部通过回调报告。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 在按钮间移动 | +| Enter / Space | 按下 | + +## 限制 + +- 计时由调用方更新 `Elapsed`。 diff --git a/docs/components/357-camera-preview.md b/docs/components/357-camera-preview.md new file mode 100644 index 0000000..e7d4aa5 --- /dev/null +++ b/docs/components/357-camera-preview.md @@ -0,0 +1,48 @@ +# 357 CameraPreview 摄像头预览 + +## 用途 + +显示调用方提供的摄像头帧,无帧时显示“无信号”,可切换镜像。 + +## 最小示例 + +```go +frame := mujica.CameraFrame{Image: examplePhoto, Seq: 1} // a new Seq for each captured frame +mirror := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CameraPreview(c, frame, &mirror, mujica.CameraPreviewOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CameraPreview(c *ui.Context, frame CameraFrame, mirror *bool, opts CameraPreviewOptions) ChoiceResult` + +`CameraFrame`:`Image`(标准库 `image.Image`,nil 为无信号)、`Seq`(帧序号,复用同一图像缓冲时用新序号触发重绘)。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | "Camera" | 无障碍名称。 | +| `Disabled` | `bool` | false | 禁用镜像切换。 | + +## 状态 + +16:9 黑底;镜像按钮开启时有选中底色和下划线。 + +## 事件 + +`Changed()` 在镜像切换后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 聚焦预览 | +| M | 切换镜像 | + +## 限制 + +- MyGo 的 Painter 不能水平翻转位图(ui/paint.go:834),所以镜像时组件每个新帧复制一份翻转后的像素;高分辨率帧会占用 CPU。 +- 组件不打开摄像头。 diff --git a/docs/components/358-mic-level-meter.md b/docs/components/358-mic-level-meter.md new file mode 100644 index 0000000..29152ea --- /dev/null +++ b/docs/components/358-mic-level-meter.md @@ -0,0 +1,45 @@ +# 358 MicLevelMeter 麦克风电平 + +## 用途 + +分段电平条,带峰值保持与 dB 读数。 + +## 最小示例 + +```go +level := float32(0.4) // the input's peak, 0 to 1 of full scale +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func MicLevelMeter(c *ui.Context, level float32, opts MicLevelMeterOptions) *ui.Element` + +`level` 为满刻度的 0 到 1,超出 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Hold` | `time.Duration` | 1.5 秒 | 峰值保持时长。 | +| `Label` | `string` | "Microphone level" | 无障碍名称;描述中读出当前与峰值 dB。 | + +## 状态 + +24 段:前 70% 为成功色,接着警告色,最后 10% 为危险色;峰值段为空心标记;右侧显示 dB 数值,不只靠颜色。 + +## 事件 + +无。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| — | 不获取焦点 | + +## 限制 + +- 保持结束时只请求一次重绘,空闲时不持续重绘。 diff --git a/docs/components/359-device-selector.md b/docs/components/359-device-selector.md new file mode 100644 index 0000000..06f202b --- /dev/null +++ b/docs/components/359-device-selector.md @@ -0,0 +1,51 @@ +# 359 DeviceSelector 设备选择 + +## 用途 + +选择麦克风、摄像头、扬声器,设备列表由调用方提供。 + +## 最小示例 + +```go +devices := mujica.MediaDevices{ + Microphones: []mujica.MediaDevice{{ID: "mic-1", Name: "Built-in Microphone"}}, + Speakers: []mujica.MediaDevice{{ID: "out-1", Name: "Headphones"}}, +} +sel := mujica.DeviceSelection{Microphone: "mic-1"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DeviceSelector(c, &sel, devices, mujica.DeviceSelectorOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func DeviceSelector(c *ui.Context, sel *DeviceSelection, devices MediaDevices, opts DeviceSelectorOptions) ChoiceResult` + +`MediaDevices` 的某类列表为 nil 时不显示该类;为空切片时显示禁用的“未找到设备”。设备 ID 重复,或已选 ID 不在列表中时 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Disabled` | `bool` | false | 禁用全部选择。 | + +## 状态 + +每类一行:图标、名称和 Select。 + +## 事件 + +`Changed()` 在任一类选择变化后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 在各选择框间移动 | +| Enter / Space | 打开列表 | +| ↑ / ↓ | 移动 | +| 输入字母 | 跳到匹配的设备 | + +## 限制 + +- 组件不枚举设备,也不监听设备插拔;调用方更新列表即可。 diff --git a/docs/components/360-image-compare.md b/docs/components/360-image-compare.md new file mode 100644 index 0000000..cc03a72 --- /dev/null +++ b/docs/components/360-image-compare.md @@ -0,0 +1,48 @@ +# 360 ImageCompare 前后对比 + +## 用途 + +前后两张图片叠放,用滑块分隔对比。 + +## 最小示例 + +```go +before, after := ui.NewBitmap(examplePhoto), ui.NewBitmap(examplePhoto) +split := 0.5 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ImageCompare(c, before, after, &split, mujica.ImageCompareOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ImageCompare(c *ui.Context, before, after ui.ImageSource, split *float64, opts ImageCompareOptions) ChoiceResult` + +`*split` 取 0 到 1,表示分隔线位置;超出 panic。两张图都须为 `*ui.Bitmap` 或 `*ui.SVG`,盒子按 `before` 的宽高比。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `BeforeLabel / AfterLabel` | `string` | "Before" / "After" | 两侧标签,也组成无障碍名称。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +分隔线与菱形手柄;两侧标签始终显示,不只靠画面区分。 + +## 事件 + +`Changed()` 在分隔位置变化后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / → | 移动 1% | +| Home / End | 最左 / 最右 | +| 拖动 | 任意位置拖动分隔线 | + +## 限制 + +- 两张图都按 Cover 铺满盒子,尺寸不同时会裁切。 diff --git a/example_media_test.go b/example_media_test.go new file mode 100644 index 0000000..52b53f1 --- /dev/null +++ b/example_media_test.go @@ -0,0 +1,246 @@ +package mujica_test + +import ( + "image" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +// examplePhoto stands in for a decoded picture. +var examplePhoto = image.NewRGBA(image.Rect(0, 0, 320, 180)) + +func ExampleImageCropper() { + crop := mujica.ImageCrop{} // empty: starts fitted to the image + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageCropper(c, examplePhoto, &crop, mujica.ImageCropperOptions{Aspect: 1}).Changed() { + _ = crop.Rect // in pixels of the photo turned by crop.Rotation + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleImageAnnotator() { + photo := ui.NewBitmap(examplePhoto) + var notes []mujica.Annotation + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageAnnotator(c, photo, ¬es, mujica.ImageAnnotatorOptions{}).Changed() { + data, _ := mujica.ExportAnnotations(notes) + _ = data // save the JSON + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleImageThumbnail() { + photo := ui.NewBitmap(examplePhoto) + selected := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageThumbnail(c, photo, mujica.ImageThumbnailOptions{Alt: "Grand hall", Aspect: 4.0 / 3, Selected: selected}).Changed() { + selected = !selected + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleLightbox() { + images := []mujica.ViewerImage{{Source: ui.NewBitmap(examplePhoto), Alt: "The grand hall"}} + open, index := false, 0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "View photos", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Lightbox(c, &open, &index, images) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVideoPlayer() { + st := mujica.VideoState{Duration: 4 * time.Minute, Rate: 1} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{ + OnPlay: func() { st.Playing = true }, // start your decoder + OnPause: func() { st.Playing = false }, // pause it + OnSeek: func(d time.Duration) { st.Position = d }, + OnRate: func(r float64) { st.Rate = r }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVideoScrubber() { + pos := 30 * time.Second + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoScrubber(c, &pos, mujica.VideoScrubberOptions{ + Duration: 2 * time.Minute, + Buffered: []mujica.MediaRange{{Start: 0, End: time.Minute}}, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVideoThumbnailStrip() { + frames := []mujica.StripFrame{{At: 0}, {At: 10 * time.Second}, {At: 20 * time.Second}} + current := 0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.VideoThumbnailStrip(c, ¤t, frames, mujica.VideoThumbnailStripOptions{}).Changed() { + _ = frames[current].At // seek the player there + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAudioPlayer() { + st := mujica.AudioState{Title: "Overture", Duration: 3 * time.Minute} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioPlayer(c, st, mujica.AudioPlayerOptions{ + OnPlay: func() { st.Playing = true }, + OnPause: func() { st.Playing = false }, + OnSeek: func(d time.Duration) { st.Position = d }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAudioWaveform() { + samples := make([]float32, 48000) // from your decoder, -1 to 1 + pos, sel := time.Duration(0), mujica.MediaRange{} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioWaveform(c, samples, mujica.AudioWaveformOptions{ + Duration: time.Second, Position: pos, Selection: &sel, + OnSeek: func(d time.Duration) { pos = d }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAudioSpectrum() { + bands := []float32{0.2, 0.6, 0.9, 0.4, 0.1} // set by your analyser, then c.Invalidate() + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVolumeControl() { + volume, muted := 0.8, false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeControl(c, &volume, &muted, mujica.VolumeControlOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePlaybackSpeedControl() { + rate := 1.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PlaybackSpeedControl(c, &rate, mujica.PlaybackSpeedControlOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMediaControls() { + st := mujica.MediaControlsState{} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MediaControls(c, st, mujica.MediaControlsOptions{ + OnPrevious: func() {}, OnNext: func() {}, + OnPlay: func() { st.Playing = true }, OnPause: func() { st.Playing = false }, + OnLoop: func(m mujica.LoopMode) { st.Loop = m }, OnShuffle: func(on bool) { st.Shuffle = on }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePlaylist() { + items := []mujica.PlaylistItem{{ID: "a", Title: "Overture"}, {ID: "b", Title: "Masquerade"}} + current := "a" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.Playlist(c, &items, mujica.PlaylistOptions{Current: current}); r.Played != "" { + current = r.Played + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSubtitleEditor() { + cues := []mujica.SubtitleCue{{Start: 0, End: 2 * time.Second, Text: "The curtain rises."}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SubtitleEditor(c, &cues, mujica.SubtitleEditorOptions{}) + if len(mujica.CheckSubtitles(cues)) > 0 { + _ = cues // block saving until the issues are fixed + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleScreenRecorderControls() { + st := mujica.RecorderState{} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScreenRecorderControls(c, st, mujica.ScreenRecorderControlsOptions{ + OnStart: func() { st.Status = mujica.RecorderRecording }, + OnPause: func() { st.Status = mujica.RecorderPaused }, + OnResume: func() { st.Status = mujica.RecorderRecording }, + OnStop: func() { st = mujica.RecorderState{} }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCameraPreview() { + frame := mujica.CameraFrame{Image: examplePhoto, Seq: 1} // a new Seq for each captured frame + mirror := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CameraPreview(c, frame, &mirror, mujica.CameraPreviewOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMicLevelMeter() { + level := float32(0.4) // the input's peak, 0 to 1 of full scale + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDeviceSelector() { + devices := mujica.MediaDevices{ + Microphones: []mujica.MediaDevice{{ID: "mic-1", Name: "Built-in Microphone"}}, + Speakers: []mujica.MediaDevice{{ID: "out-1", Name: "Headphones"}}, + } + sel := mujica.DeviceSelection{Microphone: "mic-1"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DeviceSelector(c, &sel, devices, mujica.DeviceSelectorOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleImageCompare() { + before, after := ui.NewBitmap(examplePhoto), ui.NewBitmap(examplePhoto) + split := 0.5 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ImageCompare(c, before, after, &split, mujica.ImageCompareOptions{}) + } + ui.Render(view, 400, 300, 1) +} diff --git a/examples/gallery/pages_media.go b/examples/gallery/pages_media.go index 92da6e4..dd1d945 100644 --- a/examples/gallery/pages_media.go +++ b/examples/gallery/pages_media.go @@ -1,3 +1,118 @@ package main -var mediaPages = []page{} +import ( + "fmt" + "image" + "image/color" + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/theme" +) + +const mediaCategory = "Media" + +var mediaPages = []page{ + {ID: "341", Name: "ImageCropper", ZhName: "图片裁剪", Category: mediaCategory, Demo: imageCropperDemo, Code: imageCropperCode, + Keys: "方向键移动裁剪框;Shift+方向键缩放;R / Shift+R 向右 / 向左旋转。"}, + {ID: "342", Name: "ImageAnnotator", ZhName: "图片标注", Category: mediaCategory, Demo: imageAnnotatorDemo, Code: imageAnnotatorCode, + Keys: "[ / ] 切换选中标注;方向键移动;Delete 删除;Escape 取消选择。"}, + {ID: "343", Name: "ImageThumbnail", ZhName: "缩略图", Category: mediaCategory, Demo: imageThumbnailDemo, Code: imageThumbnailCode, + Keys: "Tab 聚焦;Enter / Space 激活。"}, + {ID: "344", Name: "Lightbox", ZhName: "灯箱", Category: mediaCategory, Demo: lightboxDemo, Code: lightboxCode, + Keys: "← / → 切换;Home / End 首尾;Escape 关闭并恢复焦点。"}, + {ID: "345", Name: "VideoPlayer", ZhName: "视频播放器", Category: mediaCategory, Demo: videoPlayerDemo, Code: videoPlayerCode, + Keys: "画面聚焦时 Space / K 播放暂停,← / → 跳转 5 秒,F 全屏,C 字幕。"}, + {ID: "346", Name: "VideoScrubber", ZhName: "视频时间轴", Category: mediaCategory, Demo: videoScrubberDemo, Code: videoScrubberCode, + Keys: "← / → 跳转一个步长;Home / End 开头结尾。"}, + {ID: "347", Name: "VideoThumbnailStrip", ZhName: "缩略帧条", Category: mediaCategory, Demo: videoStripDemo, Code: videoStripCode, + Keys: "Tab 聚焦帧条;← / → 上下帧;Home / End 首尾帧。"}, + {ID: "348", Name: "AudioPlayer", ZhName: "音频播放器", Category: mediaCategory, Demo: audioPlayerDemo, Code: audioPlayerCode, + Keys: "Space 播放或暂停;进度条聚焦时 ← / → 跳转 5 秒。"}, + {ID: "349", Name: "AudioWaveform", ZhName: "音频波形", Category: mediaCategory, Demo: audioWaveformDemo, Code: audioWaveformCode, + Keys: "← / → 跳转 5 秒;Shift+← / → 调整选区;Escape 清除选区。"}, + {ID: "350", Name: "AudioSpectrum", ZhName: "音频频谱", Category: mediaCategory, Demo: audioSpectrumDemo, Code: audioSpectrumCode, + Keys: "Tab 聚焦;← / → 逐个读出频带;Home / End 首尾。"}, + {ID: "351", Name: "VolumeControl", ZhName: "音量控制", Category: mediaCategory, Demo: volumeDemo, Code: volumeCode, + Keys: "← / → 音量 ±5%;Home / End;M 静音。"}, + {ID: "352", Name: "PlaybackSpeedControl", ZhName: "倍速选择", Category: mediaCategory, Demo: speedDemo, Code: speedCode, + Keys: "Enter / Space 打开;↑ / ↓ 移动;Enter 确认;Escape 关闭。"}, + {ID: "353", Name: "MediaControls", ZhName: "媒体控制", Category: mediaCategory, Demo: mediaControlsDemo, Code: mediaControlsCode, + Keys: "Tab 进入;← / → 在按钮间移动;Enter / Space 按下。"}, + {ID: "354", Name: "Playlist", ZhName: "播放列表", Category: mediaCategory, Demo: playlistDemo, Code: playlistCode, + Keys: "↑ / ↓ 选择;Enter 播放;Alt+↑ / ↓ 移动;Delete 移除;可拖动排序。"}, + {ID: "355", Name: "SubtitleEditor", ZhName: "字幕编辑器", Category: mediaCategory, Demo: subtitleDemo, Code: subtitleCode, + Keys: "Tab 在一行的开始、结束、文本、删除间移动;时间格式 m:ss.mmm。"}, + {ID: "356", Name: "ScreenRecorderControls", ZhName: "录屏控制", Category: mediaCategory, Demo: recorderDemo, Code: recorderCode, + Keys: "Tab 在按钮间移动;Enter / Space 按下。"}, + {ID: "357", Name: "CameraPreview", ZhName: "摄像头预览", Category: mediaCategory, Demo: cameraDemo, Code: cameraCode, + Keys: "Tab 聚焦预览;M 切换镜像。"}, + {ID: "358", Name: "MicLevelMeter", ZhName: "麦克风电平", Category: mediaCategory, Demo: micDemo, Code: micCode, + Keys: "不获取焦点;读数随描述朗读。"}, + {ID: "359", Name: "DeviceSelector", ZhName: "设备选择", Category: mediaCategory, Demo: deviceDemo, Code: deviceCode, + Keys: "Tab 在选择框间移动;Enter 打开;输入字母跳转。"}, + {ID: "360", Name: "ImageCompare", ZhName: "前后对比", Category: mediaCategory, Demo: compareDemo, Code: compareCode, + Keys: "← / → 移动 1%;Home / End;可拖动。"}, +} + +// mediaSection titles a part of a demo. +func mediaSection(c *ui.Context, title string, fn func()) { + ui.Column(c).Gap(8).FillWidth().Children(func() { + ui.Text(c, title).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(mujica.Tokens(c).TextMuted) + fn() + }) +} + +// mediaCheck is a parameter checkbox that logs its changes. +func mediaCheck(c *ui.Context, log func(string), v *bool, label string) { + before := *v + mujica.Checkbox(c, v, label, mujica.CheckboxOptions{}) + if *v != before { + log(fmt.Sprintf("%s = %v", label, *v)) + } +} + +// mediaPicture draws a w×h court scene in variant hues. +func mediaPicture(w, h, variant int) *image.RGBA { + walls := []color.RGBA{{36, 28, 32, 255}, {242, 235, 221, 255}, {73, 42, 54, 255}} + arch := []color.RGBA{{125, 32, 52, 255}, {173, 145, 100, 255}, {53, 94, 128, 255}} + img := image.NewRGBA(image.Rect(0, 0, w, h)) + cx := float64(w) / 2 + for y := range h { + for x := range w { + c := walls[variant%3] + dx, dy := (float64(x)-cx)/(float64(w)*0.28), (float64(y)-float64(h)*0.55)/(float64(h)*0.5) + if dy > 0 && math.Abs(dx) < 1 || dx*dx+dy*dy < 1 { + c = arch[variant%3] + } + if x%(w/6) < w/40 || y > h*7/8 { + c = color.RGBA{189, 161, 114, 255} + } + if math.Abs(float64(x)-cx)/float64(w)+math.Abs(float64(y)-float64(h)*0.45)/float64(h) < 0.06 { + c = color.RGBA{251, 247, 239, 255} + } + img.Set(x, y, c) + } + } + return img +} + +var ( + mediaPhotos = []*image.RGBA{mediaPicture(480, 270, 0), mediaPicture(480, 270, 1), mediaPicture(360, 360, 2)} + mediaBitmaps = func() []*ui.Bitmap { + out := make([]*ui.Bitmap, len(mediaPhotos)) + for i, p := range mediaPhotos { + out[i] = ui.NewBitmap(p) + } + return out + }() +) + +// mediaClockLabel writes d as m:ss for event logs. +func mediaClockLabel(d time.Duration) string { + s := int(d / time.Second) + return fmt.Sprintf("%d:%02d", s/60, s%60) +} diff --git a/examples/gallery/pages_media_av.go b/examples/gallery/pages_media_av.go new file mode 100644 index 0000000..394b94a --- /dev/null +++ b/examples/gallery/pages_media_av.go @@ -0,0 +1,191 @@ +package main + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +// mediaClockState simulates the caller's playback: position advances while playing. +type mediaClockState struct { + pos, dur time.Duration + playing bool + rate float64 + last time.Time +} + +// tick advances the position; frames are asked only while playing. +func (m *mediaClockState) tick(c *ui.Context) { + now := c.Now() + if m.playing { + if !m.last.IsZero() { + m.pos += time.Duration(float64(now.Sub(m.last)) * m.rate) + } + if m.pos >= m.dur { + m.pos, m.playing = m.dur, false + } else { + c.AnimationFrame() + } + } + m.last = now +} + +func videoPlayerDemo(c *ui.Context, log func(string)) { + type state struct { + clock mediaClockState + subs, full, pip, noFrame bool + disabled bool + } + s := ui.Local(c.Root(), "345", func() state { + return state{clock: mediaClockState{dur: 3 * time.Minute, rate: 1}, subs: true} + }) + s.clock.tick(c) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + mediaCheck(c, log, &s.noFrame, "No frame") + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + cues := []string{"The curtain rises.", "Welcome to the masquerade.", "Ave Mujica."} + var frame ui.ImageSource = mediaBitmaps[int(s.clock.pos/(20*time.Second))%3] + if s.noFrame { + frame = nil + } + st := mujica.VideoState{Frame: frame, Position: s.clock.pos, Duration: s.clock.dur, Playing: s.clock.playing, + Rate: s.clock.rate, Buffered: []mujica.MediaRange{{Start: 0, End: s.clock.pos + 40*time.Second}}, + Subtitle: cues[int(s.clock.pos/(10*time.Second))%3], SubtitlesOn: s.subs, Fullscreen: s.full, PiP: s.pip} + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(640).Children(func() { + mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{ + OnPlay: func() { s.clock.playing = true; log("play") }, + OnPause: func() { s.clock.playing = false; log("pause") }, + OnSeek: func(d time.Duration) { s.clock.pos = d; log("seek " + mediaClockLabel(d)) }, + OnRate: func(r float64) { s.clock.rate = r; log(fmt.Sprintf("rate %v×", r)) }, + OnSubtitles: func(on bool) { s.subs = on; log(fmt.Sprintf("subtitles %v", on)) }, + OnFullscreen: func(on bool) { s.full = on; log(fmt.Sprintf("full screen requested %v", on)) }, + OnPiP: func(on bool) { s.pip = on; log(fmt.Sprintf("picture in picture requested %v", on)) }, + Disabled: s.disabled, + }) + }) + }) +} + +func videoScrubberDemo(c *ui.Context, log func(string)) { + type state struct { + pos time.Duration + preview, disabled bool + } + s := ui.Local(c.Root(), "346", func() state { return state{pos: 40 * time.Second, preview: true} }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + mediaCheck(c, log, &s.preview, "Preview slot") + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + opts := mujica.VideoScrubberOptions{Duration: 3 * time.Minute, Disabled: s.disabled, + Buffered: []mujica.MediaRange{{Start: 0, End: 90 * time.Second}, {Start: 120 * time.Second, End: 140 * time.Second}}} + if s.preview { + opts.Preview = func(at time.Duration) { + ui.Image(c, mediaBitmaps[int(at/(time.Minute))%3]).Fit(ui.Cover).Fill() + } + } + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(560).Padding(110, 0, 0, 0).Children(func() { + if mujica.VideoScrubber(c, &s.pos, opts).Changed() { + log("seek " + mediaClockLabel(s.pos)) + } + }) + }) +} + +func audioPlayerDemo(c *ui.Context, log func(string)) { + type state struct { + clock mediaClockState + disabled bool + } + s := ui.Local(c.Root(), "348", func() state { return state{clock: mediaClockState{dur: 4*time.Minute + 5*time.Second, rate: 1}} }) + s.clock.tick(c) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(560).Children(func() { + mujica.AudioPlayer(c, mujica.AudioState{Title: "Ave Mujica — Overture", Position: s.clock.pos, Duration: s.clock.dur, Playing: s.clock.playing}, + mujica.AudioPlayerOptions{ + OnPlay: func() { s.clock.playing = true; log("play") }, + OnPause: func() { s.clock.playing = false; log("pause") }, + OnSeek: func(d time.Duration) { s.clock.pos = d; log("seek " + mediaClockLabel(d)) }, + Disabled: s.disabled, + }) + }) + }) +} + +// mediaSamples is a synthetic voice-like recording, made once. +var mediaSamples = func() []float32 { + s := make([]float32, 120000) + for i := range s { + env := 0.25 + 0.7*math.Abs(math.Sin(float64(i)/9000))*math.Abs(math.Sin(float64(i)/2100)) + s[i] = float32(env * math.Sin(float64(i)/7)) + } + return s +}() + +func audioWaveformDemo(c *ui.Context, log func(string)) { + type state struct { + pos time.Duration + sel mujica.MediaRange + status int + selecting bool + } + s := ui.Local(c.Root(), "349", func() state { return state{pos: 20 * time.Second, selecting: true} }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + before := s.status + mujica.SegmentedControl(c, &s.status, []mujica.Segment{{Label: "Ready"}, {Label: "Loading"}, {Label: "Failed"}, {Label: "Empty"}}, mujica.SegmentedControlOptions{Label: "Status"}) + if s.status != before { + log(fmt.Sprintf("status %d", s.status)) + } + mediaCheck(c, log, &s.selecting, "Selectable") + }) + }) + opts := mujica.AudioWaveformOptions{Duration: time.Minute, Position: s.pos, OnSeek: func(d time.Duration) { s.pos = d; log("seek " + mediaClockLabel(d)) }} + if s.selecting { + opts.Selection = &s.sel + } + data := mediaSamples + switch s.status { + case 1: + opts.Status, data = mujica.DataLoading, nil + case 2: + opts.Status, opts.Error, data = mujica.DataFailed, "The recording could not be decoded.", nil + case 3: + data = nil + } + mediaSection(c, "Live", func() { + if mujica.AudioWaveform(c, data, opts).Changed() { + log(fmt.Sprintf("selection %s – %s", mediaClockLabel(s.sel.Start), mediaClockLabel(s.sel.End))) + } + }) +} + +func audioSpectrumDemo(c *ui.Context, log func(string)) { + type state struct{ live bool } + s := ui.Local(c.Root(), "350", func() state { return state{live: true} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.live, "Live input") }) + freqs := []string{"32 Hz", "63 Hz", "125 Hz", "250 Hz", "500 Hz", "1 kHz", "2 kHz", "4 kHz", "8 kHz", "16 kHz"} + bands := make([]float32, len(freqs)) + t := float64(c.Now().UnixMilli()) / 1000 + for i := range bands { + bands[i] = float32(0.2 + 0.6*math.Abs(math.Sin(float64(i)*0.9+t*(1+float64(i)*0.2)))) + } + if s.live { + // The demo is its own analyser; the component only draws. + c.AnimationFrame() + } + mediaSection(c, "Live", func() { + mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{BandLabel: func(i int) string { return freqs[i] }}) + }) +} diff --git a/examples/gallery/pages_media_code.go b/examples/gallery/pages_media_code.go new file mode 100644 index 0000000..7a2528d --- /dev/null +++ b/examples/gallery/pages_media_code.go @@ -0,0 +1,178 @@ +package main + +// Media page snippets, each its compiled Example's body. +const ( + imageCropperCode = `crop := mujica.ImageCrop{} // empty: starts fitted to the image +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageCropper(c, examplePhoto, &crop, mujica.ImageCropperOptions{Aspect: 1}).Changed() { + _ = crop.Rect // in pixels of the photo turned by crop.Rotation + } +} +ui.Render(view, 400, 300, 1)` + imageAnnotatorCode = `photo := ui.NewBitmap(examplePhoto) +var notes []mujica.Annotation +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageAnnotator(c, photo, ¬es, mujica.ImageAnnotatorOptions{}).Changed() { + data, _ := mujica.ExportAnnotations(notes) + _ = data // save the JSON + } +} +ui.Render(view, 400, 300, 1)` + imageThumbnailCode = `photo := ui.NewBitmap(examplePhoto) +selected := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageThumbnail(c, photo, mujica.ImageThumbnailOptions{Alt: "Grand hall", Aspect: 4.0 / 3, Selected: selected}).Changed() { + selected = !selected + } +} +ui.Render(view, 400, 300, 1)` + lightboxCode = `images := []mujica.ViewerImage{{Source: ui.NewBitmap(examplePhoto), Alt: "The grand hall"}} +open, index := false, 0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "View photos", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Lightbox(c, &open, &index, images) +} +ui.Render(view, 400, 300, 1)` + videoPlayerCode = `st := mujica.VideoState{Duration: 4 * time.Minute, Rate: 1} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{ + OnPlay: func() { st.Playing = true }, // start your decoder + OnPause: func() { st.Playing = false }, // pause it + OnSeek: func(d time.Duration) { st.Position = d }, + OnRate: func(r float64) { st.Rate = r }, + }) +} +ui.Render(view, 400, 300, 1)` + videoScrubberCode = `pos := 30 * time.Second +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoScrubber(c, &pos, mujica.VideoScrubberOptions{ + Duration: 2 * time.Minute, + Buffered: []mujica.MediaRange{{Start: 0, End: time.Minute}}, + }) +} +ui.Render(view, 400, 300, 1)` + videoStripCode = `frames := []mujica.StripFrame{{At: 0}, {At: 10 * time.Second}, {At: 20 * time.Second}} +current := 0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.VideoThumbnailStrip(c, ¤t, frames, mujica.VideoThumbnailStripOptions{}).Changed() { + _ = frames[current].At // seek the player there + } +} +ui.Render(view, 400, 300, 1)` + audioPlayerCode = `st := mujica.AudioState{Title: "Overture", Duration: 3 * time.Minute} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioPlayer(c, st, mujica.AudioPlayerOptions{ + OnPlay: func() { st.Playing = true }, + OnPause: func() { st.Playing = false }, + OnSeek: func(d time.Duration) { st.Position = d }, + }) +} +ui.Render(view, 400, 300, 1)` + audioWaveformCode = `samples := make([]float32, 48000) // from your decoder, -1 to 1 +pos, sel := time.Duration(0), mujica.MediaRange{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioWaveform(c, samples, mujica.AudioWaveformOptions{ + Duration: time.Second, Position: pos, Selection: &sel, + OnSeek: func(d time.Duration) { pos = d }, + }) +} +ui.Render(view, 400, 300, 1)` + audioSpectrumCode = `bands := []float32{0.2, 0.6, 0.9, 0.4, 0.1} // set by your analyser, then c.Invalidate() +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{}) +} +ui.Render(view, 400, 300, 1)` + volumeCode = `volume, muted := 0.8, false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeControl(c, &volume, &muted, mujica.VolumeControlOptions{}) +} +ui.Render(view, 400, 300, 1)` + speedCode = `rate := 1.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PlaybackSpeedControl(c, &rate, mujica.PlaybackSpeedControlOptions{}) +} +ui.Render(view, 400, 300, 1)` + mediaControlsCode = `st := mujica.MediaControlsState{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MediaControls(c, st, mujica.MediaControlsOptions{ + OnPrevious: func() {}, OnNext: func() {}, + OnPlay: func() { st.Playing = true }, OnPause: func() { st.Playing = false }, + OnLoop: func(m mujica.LoopMode) { st.Loop = m }, OnShuffle: func(on bool) { st.Shuffle = on }, + }) +} +ui.Render(view, 400, 300, 1)` + playlistCode = `items := []mujica.PlaylistItem{{ID: "a", Title: "Overture"}, {ID: "b", Title: "Masquerade"}} +current := "a" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.Playlist(c, &items, mujica.PlaylistOptions{Current: current}); r.Played != "" { + current = r.Played + } +} +ui.Render(view, 400, 300, 1)` + subtitleCode = `cues := []mujica.SubtitleCue{{Start: 0, End: 2 * time.Second, Text: "The curtain rises."}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SubtitleEditor(c, &cues, mujica.SubtitleEditorOptions{}) + if len(mujica.CheckSubtitles(cues)) > 0 { + _ = cues // block saving until the issues are fixed + } +} +ui.Render(view, 400, 300, 1)` + recorderCode = `st := mujica.RecorderState{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScreenRecorderControls(c, st, mujica.ScreenRecorderControlsOptions{ + OnStart: func() { st.Status = mujica.RecorderRecording }, + OnPause: func() { st.Status = mujica.RecorderPaused }, + OnResume: func() { st.Status = mujica.RecorderRecording }, + OnStop: func() { st = mujica.RecorderState{} }, + }) +} +ui.Render(view, 400, 300, 1)` + cameraCode = `frame := mujica.CameraFrame{Image: examplePhoto, Seq: 1} // a new Seq for each captured frame +mirror := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CameraPreview(c, frame, &mirror, mujica.CameraPreviewOptions{}) +} +ui.Render(view, 400, 300, 1)` + micCode = `level := float32(0.4) // the input's peak, 0 to 1 of full scale +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) +} +ui.Render(view, 400, 300, 1)` + deviceCode = `devices := mujica.MediaDevices{ + Microphones: []mujica.MediaDevice{{ID: "mic-1", Name: "Built-in Microphone"}}, + Speakers: []mujica.MediaDevice{{ID: "out-1", Name: "Headphones"}}, +} +sel := mujica.DeviceSelection{Microphone: "mic-1"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DeviceSelector(c, &sel, devices, mujica.DeviceSelectorOptions{}) +} +ui.Render(view, 400, 300, 1)` + compareCode = `before, after := ui.NewBitmap(examplePhoto), ui.NewBitmap(examplePhoto) +split := 0.5 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ImageCompare(c, before, after, &split, mujica.ImageCompareOptions{}) +} +ui.Render(view, 400, 300, 1)` +) diff --git a/examples/gallery/pages_media_devices.go b/examples/gallery/pages_media_devices.go new file mode 100644 index 0000000..19527d7 --- /dev/null +++ b/examples/gallery/pages_media_devices.go @@ -0,0 +1,225 @@ +package main + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func volumeDemo(c *ui.Context, log func(string)) { + type state struct { + volume float64 + muted, disabled bool + } + s := ui.Local(c.Root(), "351", func() state { return state{volume: 0.7} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + if mujica.VolumeControl(c, &s.volume, &s.muted, mujica.VolumeControlOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("volume %.0f%%, muted %v", s.volume*100, s.muted)) + } + }) +} + +func speedDemo(c *ui.Context, log func(string)) { + type state struct { + rate float64 + disabled bool + } + s := ui.Local(c.Root(), "352", func() state { return state{rate: 1} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + if mujica.PlaybackSpeedControl(c, &s.rate, mujica.PlaybackSpeedControlOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("rate %v×", s.rate)) + } + }) +} + +func mediaControlsDemo(c *ui.Context, log func(string)) { + type state struct { + st mujica.MediaControlsState + track int + disabled bool + } + s := ui.Local(c.Root(), "353", func() state { return state{} }) + tracks := []string{"Ave Mujica", "Imprisoned XII", "KiLLKiSS", "Symbol I: △"} + s.st.NoPrevious, s.st.NoNext = s.track == 0, s.track == len(tracks)-1 + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + ui.Text(c, fmt.Sprintf("Now: %s (%d of %d)", tracks[s.track], s.track+1, len(tracks))) + mujica.MediaControls(c, s.st, mujica.MediaControlsOptions{ + OnPrevious: func() { s.track--; log("previous") }, + OnNext: func() { s.track++; log("next") }, + OnPlay: func() { s.st.Playing = true; log("play") }, + OnPause: func() { s.st.Playing = false; log("pause") }, + OnLoop: func(m mujica.LoopMode) { s.st.Loop = m; log(fmt.Sprintf("loop mode %d", m)) }, + OnShuffle: func(on bool) { s.st.Shuffle = on; log(fmt.Sprintf("shuffle %v", on)) }, + Disabled: s.disabled, + }) + }) +} + +func playlistDemo(c *ui.Context, log func(string)) { + type state struct { + items []mujica.PlaylistItem + current string + disabled bool + } + s := ui.Local(c.Root(), "354", func() state { + titles := []string{"Ave Mujica", "Imprisoned XII", "KiLLKiSS", "Symbol I: △", "Choir 'S' Choir", "Georgette Me, Georgette You"} + items := make([]mujica.PlaylistItem, len(titles)) + for i, t := range titles { + items[i] = mujica.PlaylistItem{ID: fmt.Sprint("t", i), Title: t, Subtitle: fmt.Sprintf("Act %d", i+1), Duration: time.Duration(200+i*17) * time.Second} + } + return state{items: items, current: "t1"} + }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + mediaCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Add track", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + id := fmt.Sprint("t", len(s.items)+int(time.Now().UnixNano()%1000000)) + s.items = append(s.items, mujica.PlaylistItem{ID: id, Title: "Encore " + id, Duration: 3 * time.Minute}) + log("added " + id) + } + }) + }) + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(520).Children(func() { + r := mujica.Playlist(c, &s.items, mujica.PlaylistOptions{Current: s.current, Disabled: s.disabled}) + switch { + case r.Played != "": + s.current = r.Played + log("play " + r.Played) + case r.Removed != "": + log("removed " + r.Removed) + case r.Moved: + log("reordered") + } + }) + }) +} + +func subtitleDemo(c *ui.Context, log func(string)) { + type state struct { + cues []mujica.SubtitleCue + disabled bool + } + s := ui.Local(c.Root(), "355", func() state { + return state{cues: []mujica.SubtitleCue{ + {Start: 0, End: 2500 * time.Millisecond, Text: "The curtain rises."}, + {Start: 2 * time.Second, End: 5 * time.Second, Text: "Welcome to the masquerade."}, + {Start: 6 * time.Second, End: 9 * time.Second, Text: "Ave Mujica."}, + }} + }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(720).Children(func() { + if mujica.SubtitleEditor(c, &s.cues, mujica.SubtitleEditorOptions{Height: 260, Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("%d cues, %d issues", len(s.cues), len(mujica.CheckSubtitles(s.cues)))) + } + }) + }) +} + +func recorderDemo(c *ui.Context, log func(string)) { + type state struct { + st mujica.RecorderState + since time.Time + disabled bool + accumulated time.Duration + } + s := ui.Local(c.Root(), "356", func() state { return state{} }) + if s.st.Status == mujica.RecorderRecording { + s.st.Elapsed = s.accumulated + c.Now().Sub(s.since) + // The clock changes each second; ask for that frame only. + c.After(time.Second - s.st.Elapsed%time.Second) + } + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + mujica.ScreenRecorderControls(c, s.st, mujica.ScreenRecorderControlsOptions{ + OnStart: func() { + s.st, s.since, s.accumulated = mujica.RecorderState{Status: mujica.RecorderRecording}, c.Now(), 0 + log("start") + }, + OnPause: func() { s.st.Status, s.accumulated = mujica.RecorderPaused, s.st.Elapsed; log("pause") }, + OnResume: func() { s.st.Status, s.since = mujica.RecorderRecording, c.Now(); log("resume") }, + OnStop: func() { log("stop at " + mediaClockLabel(s.st.Elapsed)); s.st = mujica.RecorderState{} }, + Disabled: s.disabled, + }) + }) +} + +func cameraDemo(c *ui.Context, log func(string)) { + type state struct { + mirror, signal, disabled bool + } + s := ui.Local(c.Root(), "357", func() state { return state{mirror: true, signal: true} }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + mediaCheck(c, log, &s.signal, "Signal") + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + frame := mujica.CameraFrame{} + if s.signal { + frame = mujica.CameraFrame{Image: mediaPhotos[2], Seq: 1} + } + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(480).Children(func() { + if mujica.CameraPreview(c, frame, &s.mirror, mujica.CameraPreviewOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("mirror %v", s.mirror)) + } + }) + }) +} + +func micDemo(c *ui.Context, log func(string)) { + type state struct{ live bool } + s := ui.Local(c.Root(), "358", func() state { return state{live: true} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.live, "Live input") }) + level := float32(0.3) + if s.live { + t := float64(c.Now().UnixMilli()) / 1000 + level = float32(math.Min(1, 0.15+0.8*math.Abs(math.Sin(t*1.3)*math.Sin(t*0.37)))) + c.AnimationFrame() + } + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(420).Children(func() { mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) }) + }) +} + +func deviceDemo(c *ui.Context, log func(string)) { + type state struct { + sel mujica.DeviceSelection + noCamera, disabled bool + } + s := ui.Local(c.Root(), "359", func() state { return state{sel: mujica.DeviceSelection{Microphone: "m1", Speaker: "s2"}} }) + devices := mujica.MediaDevices{ + Microphones: []mujica.MediaDevice{{ID: "m1", Name: "Built-in Microphone"}, {ID: "m2", Name: "Studio Condenser"}}, + Cameras: []mujica.MediaDevice{{ID: "c1", Name: "FaceTime HD Camera"}}, + Speakers: []mujica.MediaDevice{{ID: "s1", Name: "Built-in Speakers"}, {ID: "s2", Name: "Headphones"}}, + } + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + before := s.noCamera + mediaCheck(c, log, &s.noCamera, "No cameras") + if s.noCamera != before { + s.sel.Camera = "" + } + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + if s.noCamera { + devices.Cameras = []mujica.MediaDevice{} + } + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(480).Children(func() { + if mujica.DeviceSelector(c, &s.sel, devices, mujica.DeviceSelectorOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("microphone %q, camera %q, speaker %q", s.sel.Microphone, s.sel.Camera, s.sel.Speaker)) + } + }) + }) +} diff --git a/examples/gallery/pages_media_image.go b/examples/gallery/pages_media_image.go new file mode 100644 index 0000000..f62ff53 --- /dev/null +++ b/examples/gallery/pages_media_image.go @@ -0,0 +1,162 @@ +package main + +import ( + "errors" + "fmt" + "image" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func imageCropperDemo(c *ui.Context, log func(string)) { + type state struct { + crop mujica.ImageCrop + aspect int + disabled bool + } + s := ui.Local(c.Root(), "341", func() state { return state{} }) + aspects := []float64{0, 1, 16.0 / 9, 4.0 / 3} + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + before := s.aspect + mujica.SegmentedControl(c, &s.aspect, []mujica.Segment{{Label: "Free"}, {Label: "1:1"}, {Label: "16:9"}, {Label: "4:3"}}, mujica.SegmentedControlOptions{Label: "Aspect"}) + if s.aspect != before { + s.crop.Rect = image.Rectangle{} // refit to the new ratio + log(fmt.Sprintf("aspect = %v", aspects[s.aspect])) + } + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + mediaSection(c, "Live", func() { + if mujica.ImageCropper(c, mediaPhotos[0], &s.crop, mujica.ImageCropperOptions{Aspect: aspects[s.aspect], Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("crop %v rotated %d°", s.crop.Rect, s.crop.Rotation)) + } + }) +} + +func imageAnnotatorDemo(c *ui.Context, log func(string)) { + type state struct { + notes []mujica.Annotation + disabled bool + } + s := ui.Local(c.Root(), "342", func() state { + return state{notes: []mujica.Annotation{{Kind: mujica.AnnotateRect, From: image.Pt(150, 60), To: image.Pt(330, 230)}}} + }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + mediaCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Export JSON", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + data, err := mujica.ExportAnnotations(s.notes) + if err != nil { + log("export failed: " + err.Error()) + } else { + c.WriteClipboard(string(data)) + log(fmt.Sprintf("exported %d bytes to the clipboard", len(data))) + } + } + }) + }) + mediaSection(c, "Live", func() { + if mujica.ImageAnnotator(c, mediaBitmaps[1], &s.notes, mujica.ImageAnnotatorOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("annotations: %d", len(s.notes))) + } + }) +} + +func imageThumbnailDemo(c *ui.Context, log func(string)) { + type state struct { + selected int + loading, failed, disabled bool + } + s := ui.Local(c.Root(), "343", func() state { return state{} }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + mediaCheck(c, log, &s.loading, "Loading") + mediaCheck(c, log, &s.failed, "Failed") + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + mediaSection(c, "Live", func() { + ui.Row(c).Gap(12).Wrap().Children(func() { + for i, b := range mediaBitmaps { + var err error + if s.failed && i == 2 { + err = errors.New("the file could not be read") + } + o := mujica.ImageThumbnailOptions{Alt: fmt.Sprintf("Scene %d", i+1), Aspect: 4.0 / 3, Width: 120, + Selected: s.selected == i, Loading: s.loading && i == 1, Err: err, Disabled: s.disabled} + if mujica.ImageThumbnail(c, b, o).Changed() { + s.selected = i + log(fmt.Sprintf("selected scene %d", i+1)) + } + } + }) + }) +} + +func lightboxDemo(c *ui.Context, log func(string)) { + type state struct { + open bool + index int + } + s := ui.Local(c.Root(), "344", func() state { return state{} }) + images := []mujica.ViewerImage{ + {Source: mediaBitmaps[0], Alt: "The grand hall at dusk"}, + {Source: mediaBitmaps[1], Alt: "The ivory gallery"}, + {Source: mediaBitmaps[2], Alt: "The blue chapel"}, + } + mediaSection(c, "Live", func() { + ui.Row(c).Gap(12).Children(func() { + for i, im := range images { + if mujica.ImageThumbnail(c, im.Source, mujica.ImageThumbnailOptions{Alt: im.Alt, Aspect: 4.0 / 3}).Changed() { + s.open, s.index = true, i + log(fmt.Sprintf("open at %d", i+1)) + } + } + }) + was := s.open + if mujica.Lightbox(c, &s.open, &s.index, images).Changed() { + log(fmt.Sprintf("image %d", s.index+1)) + } + if was && !s.open { + log("closed") + } + }) +} + +func videoStripDemo(c *ui.Context, log func(string)) { + type state struct { + current int + disabled bool + } + s := ui.Local(c.Root(), "347", func() state { return state{} }) + frames := make([]mujica.StripFrame, 240) + for i := range frames { + frames[i] = mujica.StripFrame{Image: mediaBitmaps[i%3], At: time.Duration(i) * 5 * time.Second} + } + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + if mujica.VideoThumbnailStrip(c, &s.current, frames, mujica.VideoThumbnailStripOptions{Disabled: s.disabled}).Changed() { + log("jump to " + mediaClockLabel(frames[s.current].At)) + } + }) +} + +func compareDemo(c *ui.Context, log func(string)) { + type state struct { + split float64 + disabled bool + } + s := ui.Local(c.Root(), "360", func() state { return state{split: 0.5} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(560).Children(func() { + if mujica.ImageCompare(c, mediaBitmaps[0], mediaBitmaps[1], &s.split, mujica.ImageCompareOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("split %.0f%%", s.split*100)) + } + }) + }) +} diff --git a/image_annotator.go b/image_annotator.go new file mode 100644 index 0000000..90461d2 --- /dev/null +++ b/image_annotator.go @@ -0,0 +1,292 @@ +package mujica + +import ( + "encoding/json" + "fmt" + "image" + "log/slog" + "math" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// AnnotationKind is the shape of an Annotation. +type AnnotationKind int + +const ( + AnnotateRect AnnotationKind = iota + AnnotateArrow + AnnotateText +) + +// Annotation is a mark in image pixels; text at From. +type Annotation struct { + Kind AnnotationKind + From, To image.Point + Text string +} + +// annotationJSON is the exported form of an Annotation. +type annotationJSON struct { + Kind string `json:"kind"` + X0 int `json:"x0"` + Y0 int `json:"y0"` + X1 int `json:"x1"` + Y1 int `json:"y1"` + Text string `json:"text,omitempty"` +} + +// ExportAnnotations writes notes as JSON: kind, corners and text. +func ExportAnnotations(notes []Annotation) ([]byte, error) { + out := make([]annotationJSON, len(notes)) + for i, n := range notes { + out[i] = annotationJSON{[]string{"rect", "arrow", "text"}[n.Kind], n.From.X, n.From.Y, n.To.X, n.To.Y, n.Text} + } + return json.Marshal(out) +} + +// ImageAnnotatorOptions configure an ImageAnnotator. +type ImageAnnotatorOptions struct { + // Height is the stage's height; zero means 320. + Height float32 + Disabled bool + Label string +} + +type annotatorState struct { + tool int // 0 select, 1 rect, 2 arrow, 3 text + selected int + draft string + pressing bool + start, now image.Point + moved image.Point +} + +type annotatorKey struct{} + +// ImageAnnotator draws, selects, moves and deletes marks over src. +func ImageAnnotator(c *ui.Context, src ui.ImageSource, notes *[]Annotation, opts ImageAnnotatorOptions) ChoiceResult { + iw, ih := viewerSize(src) + size := image.Pt(int(iw), int(ih)) + for _, n := range *notes { + if n.Kind < AnnotateRect || n.Kind > AnnotateText || (n.Kind == AnnotateText && n.Text == "") { + panic(fmt.Sprintf("mujica: ImageAnnotator bad annotation %+v", n)) + } + } + if opts.Height < 0 { + panic("mujica: ImageAnnotator Height must not be negative") + } + h := opts.Height + if h == 0 { + h = 320 + } + k := Tokens(c) + u := Density(c).Unit() + before := slices.Clone(*notes) + col := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled) + st := ui.Local(col, annotatorKey{}, func() *annotatorState { return &annotatorState{selected: -1, draft: msg(c, "image_annotator.note")} }) + s := *st + if s.selected >= len(*notes) { + s.selected = -1 + } + col.Children(func() { + annotatorToolbar(c, k, s, notes) + stage := ui.Box(c).FillWidth().Height(h).Focusable().Clip().Radius(theme.ControlRadius). + Background(videoBlack).Label(annotatorLabel(c, opts.Label)).Cursor(ui.CursorCrosshair) + internal.Ring(stage, k.Focus, theme.ControlRadius) + r := stage.Bounds() + scale, ox, oy := cropperPlace(r, size) + if !stage.IsDisabled() && scale > 0 { + annotatorPointer(stage, s, notes, r, size, scale, ox, oy) + annotatorKeys(stage, s, notes, size) + } + if s.selected >= 0 { + n := (*notes)[s.selected] + stage.Description(fmt.Sprintf(msg(c, "image_annotator.selected"), s.selected+1, len(*notes), annotatorKind(c, n.Kind))) + } + list, sel, preview := slices.Clone(*notes), s.selected, annotatorPreview(s) + size := FontSize(c, theme.BodySize) + stage.Draw(func(p *ui.Painter, r ui.Rect) { + scale, ox, oy := cropperPlace(r, image.Pt(int(iw), int(ih))) + p.Image(src, ui.Rect{X: ox, Y: oy, W: iw * scale, H: ih * scale}, ui.FillBox) + at := func(pt image.Point) (float32, float32) { + return internal.Snap(p, ox+float32(pt.X)*scale), internal.Snap(p, oy+float32(pt.Y)*scale) + } + for i, n := range append(list, preview...) { + annotatorPaint(p, k, n, at, size, i == sel) + } + }) + }) + changed := !slices.Equal(before, *notes) + if changed { + slog.Debug("mujica: image_annotator changed", "count", len(*notes)) + } + return ChoiceResult{Element: col, changed: changed} +} + +func annotatorLabel(c *ui.Context, l string) string { + if l != "" { + return l + } + return msg(c, "image_annotator.label") +} + +func annotatorKind(c *ui.Context, k AnnotationKind) string { + return msg(c, []string{"image_annotator.rect", "image_annotator.arrow", "image_annotator.text"}[k]) +} + +// annotatorToolbar picks tools, edits text and deletes the selection. +func annotatorToolbar(c *ui.Context, k theme.Tokens, s *annotatorState, notes *[]Annotation) { + u := Density(c).Unit() + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Wrap().Children(func() { + SegmentedControl(c, &s.tool, []Segment{ + {Label: msg(c, "image_annotator.select"), Icon: mediaPointer, HideLabel: true}, + {Label: msg(c, "image_annotator.rect"), Icon: mediaRect, HideLabel: true}, + {Label: msg(c, "image_annotator.arrow"), Icon: mediaArrow, HideLabel: true}, + {Label: msg(c, "image_annotator.text"), Icon: mediaText, HideLabel: true}, + }, SegmentedControlOptions{Label: msg(c, "image_annotator.tool")}) + editing := s.selected >= 0 && (*notes)[s.selected].Kind == AnnotateText + if editing { + s.draft = (*notes)[s.selected].Text + } + if s.tool == 3 || editing { + in := TextInput(c, &s.draft, TextInputOptions{Label: msg(c, "image_annotator.textLabel")}) + in.Element.Width(180) + if editing && in.Changed() && s.draft != "" { + (*notes)[s.selected].Text = s.draft + } + } + del := iconAction(c, icons.Must("trash-2"), msg(c, "image_annotator.delete")).Disabled(s.selected < 0) + if del.Clicked() && !del.IsDisabled() { + *notes = slices.Delete(*notes, s.selected, s.selected+1) + s.selected = -1 + } + ui.Textf(c, msg(c, "image_annotator.count"), len(*notes)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) +} + +// annotatorPreview is the shape being drawn, if any. +func annotatorPreview(s *annotatorState) []Annotation { + if !s.pressing || s.tool == 0 || s.tool == 3 { + return nil + } + return []Annotation{{Kind: AnnotationKind(s.tool - 1), From: s.start, To: s.now}} +} + +// annotatorPointer draws shapes, selects by click, drags the selection. +func annotatorPointer(stage *ui.Element, s *annotatorState, notes *[]Annotation, r ui.Rect, size image.Point, scale, ox, oy float32) { + _, _, _ = stage.Dragged() + px, py, _ := stage.PointerPosition() + pt := image.Pt(int(math.Round(float64((px+r.X-ox)/scale))), int(math.Round(float64((py+r.Y-oy)/scale)))) + pt = image.Pt(max(0, min(size.X, pt.X)), max(0, min(size.Y, pt.Y))) + switch { + case stage.Pressed() && !s.pressing: + s.pressing, s.start, s.now, s.moved = true, pt, pt, image.Point{} + if s.tool == 0 { + s.selected = annotatorHit(*notes, pt, int(8/scale)+1) + } + case stage.Pressed(): + if s.tool == 0 && s.selected >= 0 { + d := pt.Sub(s.now) + n := &(*notes)[s.selected] + n.From, n.To = n.From.Add(d), n.To.Add(d) + } + s.now = pt + case s.pressing: + s.pressing = false + big := absInt(s.now.X-s.start.X) >= 4 || absInt(s.now.Y-s.start.Y) >= 4 + switch { + case s.tool == 3 && s.draft != "": + *notes = append(*notes, Annotation{Kind: AnnotateText, From: s.start, To: s.start, Text: s.draft}) + s.selected = len(*notes) - 1 + case (s.tool == 1 || s.tool == 2) && big: + *notes = append(*notes, Annotation{Kind: AnnotationKind(s.tool - 1), From: s.start, To: s.now}) + s.selected = len(*notes) - 1 + } + } +} + +// annotatorKeys: [ ] select, arrows move, Delete removes, Escape clears. +func annotatorKeys(stage *ui.Element, s *annotatorState, notes *[]Annotation, size image.Point) { + n := len(*notes) + step := max(1, size.X/100) + move := func(dx, dy int) { + if s.selected >= 0 { + a := &(*notes)[s.selected] + a.From, a.To = a.From.Add(image.Pt(dx, dy)), a.To.Add(image.Pt(dx, dy)) + } + } + switch { + case n > 0 && stage.Shortcut(0, ui.KeyBracketRight): + s.selected = (s.selected + 1) % n + case n > 0 && stage.Shortcut(0, ui.KeyBracketLeft): + s.selected = (s.selected - 1 + n) % n + case s.selected >= 0 && (stage.Shortcut(0, ui.KeyDelete) || stage.Shortcut(0, ui.KeyBackspace)): + *notes = slices.Delete(*notes, s.selected, s.selected+1) + s.selected = -1 + case stage.Shortcut(0, ui.KeyEscape): + s.selected = -1 + case stage.Shortcut(0, ui.KeyLeft): + move(-step, 0) + case stage.Shortcut(0, ui.KeyRight): + move(step, 0) + case stage.Shortcut(0, ui.KeyUp): + move(0, -step) + case stage.Shortcut(0, ui.KeyDown): + move(0, step) + } +} + +// annotatorHit is the topmost mark near pt, or -1. +func annotatorHit(notes []Annotation, pt image.Point, slop int) int { + for i := len(notes) - 1; i >= 0; i-- { + n := notes[i] + b := image.Rectangle{Min: n.From, Max: n.To}.Canon() + if n.Kind == AnnotateText { + b = image.Rectangle{Min: n.From, Max: n.From.Add(image.Pt(len(n.Text)*8, 18))} + } + if pt.In(b.Inset(-slop)) { + return i + } + } + return -1 +} + +func absInt(v int) int { return max(v, -v) } + +// annotatorPaint draws a mark with a halo for any picture. +func annotatorPaint(p *ui.Painter, k theme.Tokens, n Annotation, at func(image.Point) (float32, float32), size float32, selected bool) { + x0, y0 := at(n.From) + x1, y1 := at(n.To) + halo, ink := ui.Hex("#FBF7EF"), ui.Hex("#A12635") + switch n.Kind { + case AnnotateRect: + b := ui.Rect{X: min(x0, x1), Y: min(y0, y1), W: abs32(x1 - x0), H: abs32(y1 - y0)} + p.Stroke(b, halo, 0, 4) + p.Stroke(b, ink, 0, 2) + case AnnotateArrow: + var path ui.Path + path.MoveTo(x0, y0).LineTo(x1, y1) + a := math.Atan2(float64(y1-y0), float64(x1-x0)) + for _, d := range []float64{2.6, -2.6} { + path.MoveTo(x1, y1).LineTo(x1+12*float32(math.Cos(a+d)), y1+12*float32(math.Sin(a+d))) + } + p.StrokePath(&path, 4, halo) + p.StrokePath(&path, 2, ink) + case AnnotateText: + w, h := p.MeasureText(1000, ui.Span{Text: n.Text, Size: size}) + p.Fill(ui.Rect{X: x0, Y: y0, W: w + 8, H: h + 4}, halo, theme.ControlRadius) + p.Text(x0+4, y0+2, n.Text, size, ink) + x1, y1 = x0+w+8, y0+h+4 + } + if selected { + b := ui.Rect{X: min(x0, x1) - 4, Y: min(y0, y1) - 4, W: abs32(x1-x0) + 8, H: abs32(y1-y0) + 8} + p.StrokeDashed(b, k.Focus, 0, 1) + p.FillPath(internal.Diamond(internal.Snap(p, b.X), internal.Snap(p, b.Y), 8), k.Focus) + } +} diff --git a/image_annotator_test.go b/image_annotator_test.go new file mode 100644 index 0000000..2229d6a --- /dev/null +++ b/image_annotator_test.go @@ -0,0 +1,121 @@ +package mujica + +import ( + "image" + "testing" + + "github.com/egoist/mygo/ui" +) + +func annotatorTester(notes *[]Annotation, disabled bool, n *int) *ui.Tester { + src := mediaTestBitmap(400, 200) + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(440).Children(func() { + if ImageAnnotator(c, src, notes, ImageAnnotatorOptions{Height: 200, Disabled: disabled}).Changed() { + *n++ + } + }) + }, 520, 360) +} + +// annotatorAt maps image pixel (x, y) to window DIPs on the stage. +func annotatorAt(tt *ui.Tester, x, y float32) (float32, float32) { + r, _ := tt.Find("Annotations") + scale := min(r.W/400, r.H/200) + return r.X + (r.W-400*scale)/2 + x*scale, r.Y + (r.H-200*scale)/2 + y*scale +} + +func annotatorDrag(tt *ui.Tester, x0, y0, x1, y1 float32) { + ax, ay := annotatorAt(tt, x0, y0) + bx, by := annotatorAt(tt, x1, y1) + tt.Press(ax, ay) + tt.Move((ax+bx)/2, (ay+by)/2) + tt.Move(bx, by) + tt.Release(bx, by) +} + +func TestImageAnnotatorDrawShapes(t *testing.T) { + var notes []Annotation + n := 0 + tt := annotatorTester(¬es, false, &n) + if err := tt.Click("Rectangle"); err != nil { + t.Fatal(err) + } + annotatorDrag(tt, 40, 40, 140, 100) + if err := tt.Click("Arrow"); err != nil { + t.Fatal(err) + } + annotatorDrag(tt, 200, 150, 300, 60) + if err := tt.Click("Text"); err != nil { + t.Fatal(err) + } + x, y := annotatorAt(tt, 250, 20) + tt.ClickAt(x, y) + if len(notes) != 3 || n != 3 { + t.Fatalf("notes %+v, changes %d", notes, n) + } + near := func(p, q image.Point) bool { return absInt(p.X-q.X) <= 2 && absInt(p.Y-q.Y) <= 2 } + if notes[0].Kind != AnnotateRect || !near(notes[0].From, image.Pt(40, 40)) || !near(notes[0].To, image.Pt(140, 100)) { + t.Fatalf("rect %+v", notes[0]) + } + if notes[1].Kind != AnnotateArrow || !near(notes[1].To, image.Pt(300, 60)) { + t.Fatalf("arrow %+v", notes[1]) + } + if notes[2].Kind != AnnotateText || notes[2].Text != "Note" { + t.Fatalf("text %+v", notes[2]) + } +} + +func TestImageAnnotatorSelectMoveDelete(t *testing.T) { + notes := []Annotation{{Kind: AnnotateRect, From: image.Pt(40, 40), To: image.Pt(140, 100)}, {Kind: AnnotateArrow, From: image.Pt(200, 150), To: image.Pt(300, 60)}} + n := 0 + tt := annotatorTester(¬es, false, &n) + x, y := annotatorAt(tt, 90, 70) + tt.ClickAt(x, y) + if !tt.Focused("Annotations") { + t.Fatal("stage not focused") + } + tt.Key(0, ui.KeyRight) + if notes[0].From != image.Pt(44, 40) { + t.Fatalf("Right moved %+v", notes[0]) + } + tt.Key(0, ui.KeyBracketRight) + tt.Key(0, ui.KeyDelete) + if len(notes) != 1 || notes[0].Kind != AnnotateRect { + t.Fatalf("delete left %+v", notes) + } + data, err := ExportAnnotations(notes) + if err != nil || string(data) != `[{"kind":"rect","x0":44,"y0":40,"x1":144,"y1":100}]` { + t.Fatalf("export %s %v", data, err) + } +} + +func TestImageAnnotatorDisabledAndBad(t *testing.T) { + var notes []Annotation + n := 0 + tt := annotatorTester(¬es, true, &n) + _ = tt.Click("Rectangle") + annotatorDrag(tt, 40, 40, 140, 100) + if len(notes) != 0 || n != 0 { + t.Fatalf("disabled annotator drew %+v", notes) + } + bad := []Annotation{{Kind: AnnotateText}} + mediaPanics(t, "empty text", func(c *ui.Context) { + ImageAnnotator(c, mediaTestBitmap(10, 10), &bad, ImageAnnotatorOptions{}) + }) +} + +func TestImageAnnotatorSnapshot(t *testing.T) { + src := mediaTestBitmap(400, 200) + notes := []Annotation{ + {Kind: AnnotateRect, From: image.Pt(30, 40), To: image.Pt(150, 120)}, + {Kind: AnnotateArrow, From: image.Pt(330, 160), To: image.Pt(220, 80)}, + {Kind: AnnotateText, From: image.Pt(230, 30), To: image.Pt(230, 30), Text: "Chandelier"}, + } + snapshot(t, "image_annotator", 460, 320, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(420).Children(func() { + ImageAnnotator(c, src, ¬es, ImageAnnotatorOptions{Height: 220}) + }) + }) +} diff --git a/image_compare.go b/image_compare.go new file mode 100644 index 0000000..d621e6f --- /dev/null +++ b/image_compare.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ImageCompareOptions configure an ImageCompare. +type ImageCompareOptions struct { + // BeforeLabel and AfterLabel caption the sides; empty uses defaults. + BeforeLabel, AfterLabel string + Disabled bool +} + +// ImageCompare shows before left of *split, after right. +func ImageCompare(c *ui.Context, before, after ui.ImageSource, split *float64, opts ImageCompareOptions) ChoiceResult { + if *split < 0 || *split > 1 || math.IsNaN(*split) { + panic("mujica: ImageCompare split outside 0..1") + } + bw, bh := viewerSize(before) + viewerSize(after) + k := Tokens(c) + bl, al := opts.BeforeLabel, opts.AfterLabel + if bl == "" { + bl = msg(c, "image_compare.before") + } + if al == "" { + al = msg(c, "image_compare.after") + } + g, _ := sliderSetup(0, 1, 0.01, nil, false, c) + old := *split + var s *ui.Element + var next float64 + ui.Box(c).Disabled(opts.Disabled).Children(func() { + s, next, _ = sliderControl(c, old, g, 0, 1) + }) + s.FillWidth().AspectRatio(bw / bh).FocusRing(false).Cursor(ui.CursorResizeEW).Clip().Radius(theme.ControlRadius). + Label(bl + " / " + al) + internal.Ring(s, k.Focus, theme.ControlRadius) + if !s.IsDisabled() { + *split = next + } + changed := *split != old + if changed { + slog.Debug("mujica: image_compare split", "split", *split) + } + v, size := float32(*split), FontSize(c, theme.CaptionSize) + s.Draw(func(p *ui.Painter, r ui.Rect) { + x := internal.Snap(p, r.X+r.W*v) + p.Image(after, r, ui.Cover) + p.Clip(ui.Rect{X: r.X, Y: r.Y, W: x - r.X, H: r.H}, 0, func() { p.Image(before, r, ui.Cover) }) + compareTag(p, k, r.X+8, r.Y+8, bl, size) + w, _ := p.MeasureText(1000, ui.Span{Text: al, Size: size}) + compareTag(p, k, r.X+r.W-w-20, r.Y+8, al, size) + p.Fill(ui.Rect{X: x - 1, Y: r.Y, W: 2, H: r.H}, k.Surface, 0) + cy := internal.Snap(p, r.Y+r.H/2) + p.FillPath(internal.Diamond(x, cy, 22), k.Surface) + p.FillPath(internal.Diamond(x, cy, 16), k.AccentText) + }) + return ChoiceResult{Element: s, changed: changed} +} + +// compareTag draws a caption chip at (x, y). +func compareTag(p *ui.Painter, k theme.Tokens, x, y float32, s string, size float32) { + w, h := p.MeasureText(1000, ui.Span{Text: s, Size: size}) + b := ui.Rect{X: x, Y: y, W: w + 12, H: h + 4} + p.Fill(b, k.Surface.Alpha(0.88), theme.ControlRadius) + p.Text(b.X+6, b.Y+2, s, size, k.Text) +} diff --git a/image_compare_test.go b/image_compare_test.go new file mode 100644 index 0000000..89c476b --- /dev/null +++ b/image_compare_test.go @@ -0,0 +1,73 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func compareTester(split *float64, disabled bool, n *int) *ui.Tester { + before, after := mediaTestBitmap(160, 90), mediaTestBitmap(160, 90) + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(320).Children(func() { + if ImageCompare(c, before, after, split, ImageCompareOptions{Disabled: disabled}).Changed() { + *n++ + } + }) + }, 400, 300) +} + +func TestImageCompareKeysAndDrag(t *testing.T) { + split, n := 0.5, 0 + tt := compareTester(&split, false, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if split != 0.51 { + t.Fatalf("Right: %v", split) + } + tt.Key(0, ui.KeyHome) + if split != 0 { + t.Fatalf("Home: %v", split) + } + r, ok := tt.Find("Before / After") + if !ok { + t.Fatal("not named") + } + tt.Press(r.X+r.W/2, r.Y+r.H/2) + tt.Move(r.X+r.W*0.75, r.Y+r.H/2) + tt.Release(r.X+r.W*0.75, r.Y+r.H/2) + if split < 0.7 || split > 0.8 { + t.Fatalf("drag: %v", split) + } + if n < 3 { + t.Fatalf("changes %d", n) + } +} + +func TestImageCompareDisabled(t *testing.T) { + split, n := 0.5, 0 + tt := compareTester(&split, true, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + r, _ := tt.Find("Before / After") + tt.ClickAt(r.X+10, r.Y+10) + if split != 0.5 || n != 0 { + t.Fatalf("disabled moved to %v", split) + } + bad := 2.0 + mediaPanics(t, "split over 1", func(c *ui.Context) { + ImageCompare(c, mediaTestBitmap(4, 4), mediaTestBitmap(4, 4), &bad, ImageCompareOptions{}) + }) +} + +func TestImageCompareSnapshot(t *testing.T) { + split := 0.4 + before := mediaTestBitmap(160, 90) + after := ui.NewBitmap(mediaTestImage(90, 160)) + snapshot(t, "image_compare", 360, 220, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(320).Children(func() { + ImageCompare(c, before, after, &split, ImageCompareOptions{}) + }) + }) +} diff --git a/image_cropper.go b/image_cropper.go new file mode 100644 index 0000000..aac97bb --- /dev/null +++ b/image_cropper.go @@ -0,0 +1,245 @@ +package mujica + +import ( + "fmt" + "image" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ImageCrop is Rect in pixels of the turned image. +type ImageCrop struct { + Rect image.Rectangle + // Rotation is 0, 90, 180 or 270. + Rotation int +} + +// ImageCropperOptions configure an ImageCropper. +type ImageCropperOptions struct { + // Aspect locks width over height; zero is free. + Aspect float64 + // Height is the stage's height; zero means 320. + Height float32 + Disabled bool + Label string +} + +type cropperCache struct { + src image.Image + rot int + bmp *ui.Bitmap +} + +type cropperDrag struct { + mode int // 0 none, 1 move, 2-5 corners + start image.Rectangle + dx, dy float32 +} + +type cropperKey struct{} + +// ImageCropper edits *crop over src; empty Rect starts fitted. +func ImageCropper(c *ui.Context, src image.Image, crop *ImageCrop, opts ImageCropperOptions) ChoiceResult { + if src == nil || src.Bounds().Empty() || opts.Aspect < 0 || opts.Height < 0 { + panic("mujica: ImageCropper needs an image, Aspect >= 0 and Height >= 0") + } + if crop.Rotation%90 != 0 || crop.Rotation < 0 || crop.Rotation >= 360 { + panic(fmt.Sprintf("mujica: ImageCropper Rotation %d is not 0, 90, 180 or 270", crop.Rotation)) + } + size := cropperSize(src, crop.Rotation) + if crop.Rect.Empty() { + crop.Rect = cropperFit(size, opts.Aspect) + } + if !crop.Rect.In(image.Rectangle{Max: size}) { + panic(fmt.Sprintf("mujica: ImageCropper Rect %v outside the %v image", crop.Rect, size)) + } + if opts.Aspect > 0 && math.Abs(float64(crop.Rect.Dx())-float64(crop.Rect.Dy())*opts.Aspect) > 1+opts.Aspect { + panic(fmt.Sprintf("mujica: ImageCropper Rect %v does not keep Aspect %v", crop.Rect, opts.Aspect)) + } + h := opts.Height + if h == 0 { + h = 320 + } + k := Tokens(c) + u := Density(c).Unit() + old := *crop + label := opts.Label + if label == "" { + label = msg(c, "image_cropper.label") + } + col := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled) + col.Children(func() { + stage := ui.Box(c).FillWidth().Height(h).Focusable().Clip().Radius(theme.ControlRadius). + Background(videoBlack).Label(label).Cursor(ui.CursorMove) + internal.Ring(stage, k.Focus, theme.ControlRadius) + r := stage.Bounds() + scale, ox, oy := cropperPlace(r, size) + live := !stage.IsDisabled() + if live && scale > 0 { + cropperPointer(stage, crop, r, size, scale, ox, oy, opts.Aspect) + cropperKeys(stage, crop, size, opts.Aspect) + } + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + left := iconAction(c, mediaRotateLeft, msg(c, "image_cropper.rotateLeft")) + right := iconAction(c, mediaRotate, msg(c, "image_cropper.rotateRight")) + switch { + case live && (right.Clicked() || stage.Shortcut(0, ui.KeyR)): + cropperRotate(crop, size, 90, opts.Aspect) + case live && (left.Clicked() || stage.Shortcut(ui.Shift, ui.KeyR)): + cropperRotate(crop, size, 270, opts.Aspect) + } + ui.Text(c, cropperReadout(*crop)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum") + }) + stage.Description(cropperReadout(*crop)) + // Turned pixels are copied only when image or rotation change. + cache := ui.Local(stage, cropperKey{}, func() cropperCache { return cropperCache{} }) + if cache.src != src || cache.rot != crop.Rotation || cache.bmp == nil { + *cache = cropperCache{src, crop.Rotation, ui.NewBitmap(cropperTurn(src, crop.Rotation))} + } + size := cropperSize(src, crop.Rotation) + bmp, rect := cache.bmp, crop.Rect + stage.Draw(func(p *ui.Painter, r ui.Rect) { + scale, ox, oy := cropperPlace(r, size) + img := ui.Rect{X: ox, Y: oy, W: float32(size.X) * scale, H: float32(size.Y) * scale} + p.Image(bmp, img, ui.FillBox) + f := ui.Rect{X: internal.Snap(p, ox+float32(rect.Min.X)*scale), Y: internal.Snap(p, oy+float32(rect.Min.Y)*scale), + W: internal.Snap(p, float32(rect.Dx())*scale), H: internal.Snap(p, float32(rect.Dy())*scale)} + shade := ui.RGBA(0, 0, 0, 0.55) + p.Fill(ui.Rect{X: img.X, Y: img.Y, W: img.W, H: f.Y - img.Y}, shade, 0) + p.Fill(ui.Rect{X: img.X, Y: f.Y + f.H, W: img.W, H: img.Y + img.H - f.Y - f.H}, shade, 0) + p.Fill(ui.Rect{X: img.X, Y: f.Y, W: f.X - img.X, H: f.H}, shade, 0) + p.Fill(ui.Rect{X: f.X + f.W, Y: f.Y, W: img.X + img.W - f.X - f.W, H: f.H}, shade, 0) + light := ui.Hex("#F2EBDD") + p.Stroke(f, light, 0, 1) + for i := 1; i < 3; i++ { + p.Fill(ui.Rect{X: f.X + f.W*float32(i)/3, Y: f.Y, W: 1, H: f.H}, light.Alpha(0.4), 0) + p.Fill(ui.Rect{X: f.X, Y: f.Y + f.H*float32(i)/3, W: f.W, H: 1}, light.Alpha(0.4), 0) + } + for _, pt := range [4][2]float32{{f.X, f.Y}, {f.X + f.W, f.Y}, {f.X, f.Y + f.H}, {f.X + f.W, f.Y + f.H}} { + p.FillPath(internal.Diamond(internal.Snap(p, pt[0]), internal.Snap(p, pt[1]), 12), light) + } + }) + }) + changed := *crop != old + if changed { + slog.Debug("mujica: image_cropper changed", "rect", crop.Rect, "rotation", crop.Rotation) + } + return ChoiceResult{Element: col, changed: changed} +} + +func cropperReadout(cr ImageCrop) string { + r := cr.Rect + return fmt.Sprintf("%d, %d · %d × %d · %d°", r.Min.X, r.Min.Y, r.Dx(), r.Dy(), cr.Rotation) +} + +// cropperSize is src's size after turning it rot degrees. +func cropperSize(src image.Image, rot int) image.Point { + s := src.Bounds().Size() + if rot == 90 || rot == 270 { + return image.Pt(s.Y, s.X) + } + return s +} + +// cropperFit is the largest centered aspect rectangle inside size. +func cropperFit(size image.Point, aspect float64) image.Rectangle { + w, h := size.X, size.Y + if aspect > 0 { + if float64(w) > float64(h)*aspect { + w = int(math.Round(float64(h) * aspect)) + } else { + h = int(math.Round(float64(w) / aspect)) + } + } + x, y := (size.X-w)/2, (size.Y-h)/2 + return image.Rect(x, y, x+w, y+h) +} + +// cropperPlace fits size in r: scale and top-left corner. +func cropperPlace(r ui.Rect, size image.Point) (scale, x, y float32) { + if r.W <= 0 { + return 0, 0, 0 + } + scale = min(r.W/float32(size.X), r.H/float32(size.Y)) + return scale, r.X + (r.W-float32(size.X)*scale)/2, r.Y + (r.H-float32(size.Y)*scale)/2 +} + +// cropperTurn copies src turned rot degrees clockwise. +func cropperTurn(src image.Image, rot int) image.Image { + if rot == 0 { + return src + } + b := src.Bounds() + size := cropperSize(src, rot) + out := image.NewRGBA(image.Rectangle{Max: size}) + for y := 0; y < b.Dy(); y++ { + for x := 0; x < b.Dx(); x++ { + var nx, ny int + switch rot { + case 90: + nx, ny = b.Dy()-1-y, x + case 180: + nx, ny = b.Dx()-1-x, b.Dy()-1-y + case 270: + nx, ny = y, b.Dx()-1-x + } + out.Set(nx, ny, src.At(b.Min.X+x, b.Min.Y+y)) + } + } + return out +} + +// cropperRotate turns the crop d degrees; locked aspects refit. +func cropperRotate(cr *ImageCrop, size image.Point, d int, aspect float64) { + r := cr.Rect + for range d / 90 { + r = image.Rect(size.Y-r.Max.Y, r.Min.X, size.Y-r.Min.Y, r.Max.X) + size = image.Pt(size.Y, size.X) + } + cr.Rotation = (cr.Rotation + d) % 360 + cr.Rect = r + if aspect > 0 { + cr.Rect = cropperFit(size, aspect) + } +} + +// cropperPointer moves the frame, or resizes it by a corner. +func cropperPointer(stage *ui.Element, cr *ImageCrop, r ui.Rect, size image.Point, scale, ox, oy float32, aspect float64) { + d := ui.Local(stage, cropperDrag{}, func() cropperDrag { return cropperDrag{} }) + dx, dy, ok := stage.Dragged() + if !stage.Pressed() { + d.mode = 0 + return + } + if d.mode == 0 { + px, py, _ := stage.PointerPosition() + px, py = (px+r.X-ox)/scale, (py+r.Y-oy)/scale + d.start, d.dx, d.dy, d.mode = cr.Rect, 0, 0, cropperHit(cr.Rect, px, py, 14/scale) + } + if !ok || d.mode == 0 { + return + } + d.dx, d.dy = d.dx+dx/scale, d.dy+dy/scale + cr.Rect = cropperDragRect(d.start, d.mode, int(math.Round(float64(d.dx))), int(math.Round(float64(d.dy))), size, aspect) +} + +// cropperHit says what a press grabs: corner, inside or nothing. +func cropperHit(r image.Rectangle, x, y, slop float32) int { + corners := [4][2]int{{r.Min.X, r.Min.Y}, {r.Max.X, r.Min.Y}, {r.Min.X, r.Max.Y}, {r.Max.X, r.Max.Y}} + for i, c := range corners { + if abs32(x-float32(c[0])) <= slop && abs32(y-float32(c[1])) <= slop { + return 2 + i + } + } + if x >= float32(r.Min.X) && x <= float32(r.Max.X) && y >= float32(r.Min.Y) && y <= float32(r.Max.Y) { + return 1 + } + return 0 +} + +func abs32(v float32) float32 { return float32(math.Abs(float64(v))) } diff --git a/image_cropper_geom.go b/image_cropper_geom.go new file mode 100644 index 0000000..7d39e42 --- /dev/null +++ b/image_cropper_geom.go @@ -0,0 +1,81 @@ +package mujica + +import ( + "image" + "math" + + "github.com/egoist/mygo/ui" +) + +// cropperMin is the smallest crop side in pixels. +const cropperMin = 8 + +// cropperDragRect moves (mode 1) or corner-resizes (2-5) start. +func cropperDragRect(start image.Rectangle, mode, dx, dy int, size image.Point, aspect float64) image.Rectangle { + if mode == 1 { + dx = max(-start.Min.X, min(size.X-start.Max.X, dx)) + dy = max(-start.Min.Y, min(size.Y-start.Max.Y, dy)) + return start.Add(image.Pt(dx, dy)) + } + corners := [4]image.Point{start.Min, {start.Max.X, start.Min.Y}, {start.Min.X, start.Max.Y}, start.Max} + moving, anchor := corners[mode-2], corners[5-mode] + x := max(0, min(size.X, moving.X+dx)) + y := max(0, min(size.Y, moving.Y+dy)) + sx, sy := 1, 1 + room := image.Pt(size.X-anchor.X, size.Y-anchor.Y) + if x < anchor.X { + sx, room.X = -1, anchor.X + } + if y < anchor.Y { + sy, room.Y = -1, anchor.Y + } + w, h := cropperClampSize(max(cropperMin, (x-anchor.X)*sx), max(cropperMin, (y-anchor.Y)*sy), room, aspect) + return image.Rectangle{Min: anchor, Max: anchor.Add(image.Pt(w*sx, h*sy))}.Canon() +} + +// cropperClampSize fits w×h inside room, keeping aspect when locked. +func cropperClampSize(w, h int, room image.Point, aspect float64) (int, int) { + w, h = min(w, room.X), min(h, room.Y) + if aspect <= 0 { + return w, h + } + h = int(math.Round(float64(w) / aspect)) + if h > room.Y { + h = room.Y + w = int(math.Round(float64(h) * aspect)) + } + return w, h +} + +// cropperKeys: arrows move, Shift+arrows resize, 1% per press. +func cropperKeys(stage *ui.Element, cr *ImageCrop, size image.Point, aspect float64) { + step := max(1, size.X/100) + r := cr.Rect + move := func(dx, dy int) { cr.Rect = cropperDragRect(r, 1, dx, dy, size, aspect) } + grow := func(dw, dh int) { + w, h := max(cropperMin, r.Dx()+dw), max(cropperMin, r.Dy()+dh) + if aspect > 0 && dw == 0 { + w = int(math.Round(float64(h) * aspect)) + } + w, h = cropperClampSize(w, h, image.Pt(size.X-r.Min.X, size.Y-r.Min.Y), aspect) + cr.Rect = image.Rectangle{Min: r.Min, Max: r.Min.Add(image.Pt(w, h))} + } + switch { + case stage.Shortcut(0, ui.KeyLeft): + move(-step, 0) + case stage.Shortcut(0, ui.KeyRight): + move(step, 0) + case stage.Shortcut(0, ui.KeyUp): + move(0, -step) + case stage.Shortcut(0, ui.KeyDown): + move(0, step) + case stage.Shortcut(ui.Shift, ui.KeyRight): + grow(step, 0) + case stage.Shortcut(ui.Shift, ui.KeyLeft): + grow(-step, 0) + case stage.Shortcut(ui.Shift, ui.KeyDown): + grow(0, step) + case stage.Shortcut(ui.Shift, ui.KeyUp): + grow(0, -step) + } +} diff --git a/image_cropper_test.go b/image_cropper_test.go new file mode 100644 index 0000000..9ba67a5 --- /dev/null +++ b/image_cropper_test.go @@ -0,0 +1,101 @@ +package mujica + +import ( + "image" + "testing" + + "github.com/egoist/mygo/ui" +) + +func cropperTester(src image.Image, crop *ImageCrop, opts ImageCropperOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(440).Children(func() { + if ImageCropper(c, src, crop, opts).Changed() { + *n++ + } + }) + }, 500, 420) +} + +func TestImageCropperDefaultAndKeys(t *testing.T) { + crop, n := ImageCrop{}, 0 + tt := cropperTester(mediaTestImage(400, 200), &crop, ImageCropperOptions{Aspect: 1, Height: 200}, &n) + if crop.Rect != image.Rect(100, 0, 300, 200) { + t.Fatalf("default %v", crop.Rect) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if crop.Rect != image.Rect(104, 0, 304, 200) { + t.Fatalf("Right: %v", crop.Rect) + } + tt.Key(ui.Shift, ui.KeyLeft) + if crop.Rect.Dx() != crop.Rect.Dy() || crop.Rect.Dx() != 196 { + t.Fatalf("Shift+Left keeps 1:1: %v", crop.Rect) + } + if n != 2 || !tt.HasText("104, 0 · 196 × 196 · 0°") { + t.Fatalf("changes %d, texts %v", n, tt.Texts()) + } +} + +func TestImageCropperRotate(t *testing.T) { + crop, n := ImageCrop{Rect: image.Rect(10, 20, 110, 70)}, 0 + tt := cropperTester(mediaTestImage(400, 200), &crop, ImageCropperOptions{Height: 200}, &n) + if err := tt.Click("Rotate right"); err != nil { + t.Fatal(err) + } + if crop.Rotation != 90 || crop.Rect != image.Rect(130, 10, 180, 110) { + t.Fatalf("rotated %+v", crop) + } + tt.Key(0, ui.KeyTab) + tt.Key(ui.Shift, ui.KeyR) + if crop.Rotation != 0 || crop.Rect != image.Rect(10, 20, 110, 70) { + t.Fatalf("rotated back %+v", crop) + } +} + +func TestImageCropperDragCorner(t *testing.T) { + crop, n := ImageCrop{Rect: image.Rect(0, 0, 200, 100)}, 0 + tt := cropperTester(mediaTestImage(400, 200), &crop, ImageCropperOptions{Aspect: 2, Height: 200}, &n) + r, _ := tt.Find("Crop area") + scale := min(r.W/400, r.H/200) + ox, oy := r.X+(r.W-400*scale)/2, r.Y+(r.H-200*scale)/2 + cx, cy := ox+200*scale, oy+100*scale + tt.Press(cx, cy) + tt.Move(cx+50*scale, cy+5*scale) + tt.Move(cx+100*scale, cy+10*scale) + tt.Release(cx+100*scale, cy+10*scale) + if crop.Rect.Min != (image.Point{}) || crop.Rect.Dx() < 290 || crop.Rect.Dx() > 310 || crop.Rect.Dx() != 2*crop.Rect.Dy() { + t.Fatalf("corner drag %v", crop.Rect) + } +} + +func TestImageCropperDisabledAndBad(t *testing.T) { + crop, n := ImageCrop{}, 0 + tt := cropperTester(mediaTestImage(100, 100), &crop, ImageCropperOptions{Disabled: true}, &n) + start := crop + _ = tt.Click("Rotate right") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if crop != start || n != 0 { + t.Fatalf("disabled cropper changed %+v", crop) + } + img := mediaTestImage(100, 100) + mediaPanics(t, "outside", func(c *ui.Context) { + ImageCropper(c, img, &ImageCrop{Rect: image.Rect(0, 0, 200, 10)}, ImageCropperOptions{}) + }) + mediaPanics(t, "rotation", func(c *ui.Context) { ImageCropper(c, img, &ImageCrop{Rotation: 45}, ImageCropperOptions{}) }) + mediaPanics(t, "aspect", func(c *ui.Context) { + ImageCropper(c, img, &ImageCrop{Rect: image.Rect(0, 0, 50, 10)}, ImageCropperOptions{Aspect: 1}) + }) +} + +func TestImageCropperSnapshot(t *testing.T) { + img := mediaTestImage(320, 180) + crop := ImageCrop{Rect: image.Rect(60, 30, 220, 150)} + snapshot(t, "image_cropper", 440, 300, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(400).Children(func() { + ImageCropper(c, img, &crop, ImageCropperOptions{Height: 220}) + }) + }) +} diff --git a/image_thumbnail.go b/image_thumbnail.go new file mode 100644 index 0000000..27d0c71 --- /dev/null +++ b/image_thumbnail.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ImageThumbnailOptions configure an ImageThumbnail. +type ImageThumbnailOptions struct { + // Width is the thumbnail's width; zero means 96. + Width float32 + // Aspect is width over height, cropped; zero is 1. + Aspect float32 + Selected bool + // Loading and Err show the load state at one size. + Loading bool + Err error + // Alt names the picture; required. + Alt string + Disabled bool +} + +// ImageThumbnail is a cropped picture; Changed reports activation. +func ImageThumbnail(c *ui.Context, src ui.ImageSource, opts ImageThumbnailOptions) ChoiceResult { + if opts.Width < 0 || opts.Aspect < 0 { + panic("mujica: ImageThumbnail needs Width and Aspect >= 0") + } + if opts.Alt == "" { + panic("mujica: ImageThumbnail needs Alt") + } + w, aspect := opts.Width, opts.Aspect + if w == 0 { + w = 96 + } + if aspect == 0 { + aspect = 1 + } + k := Tokens(c) + h := w / aspect + b := ui.ButtonBase(c).Size(w, h).Radius(theme.ControlRadius).Clip(). + Disabled(opts.Disabled).Label(opts.Alt).Selected(opts.Selected).Background(k.SurfaceHover) + internal.Ring(b, k.Focus, theme.ControlRadius) + edge, width := k.Border, theme.BorderWidth + switch { + case opts.Selected: + edge, width = k.AccentText, 2 + case b.Hovered(): + edge = k.ControlBorder + } + b.Border(width, edge).Transition(colorFade) + b.Children(func() { + switch { + case opts.Err != nil: + b.Description(opts.Err.Error()).Tooltip(opts.Err.Error()) + ui.Column(c).Gap(4).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(20, 20).TextColor(k.Danger) + if h >= 64 { + ui.Text(c, msg(c, "image_thumbnail.failed")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).SingleLine() + } + }) + case opts.Loading: + Spinner(c, SpinnerOptions{Size: min(20, h/2), Label: msg(c, "image_thumbnail.loading")}) + case src == nil: + ui.Icon(c, icons.Must("image")).Size(20, 20).TextColor(k.TextMuted) + default: + ui.Image(c, src).Fit(ui.Cover).Size(w, h) + } + if opts.Selected { + // A diamond mark, so selection never rests on color alone. + ui.Box(c).Absolute().Top(4).Right(4).Size(14, 14).PassThrough().Draw(func(p *ui.Painter, r ui.Rect) { + cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2) + p.FillPath(internal.Diamond(cx, cy, 14), k.Surface) + p.FillPath(internal.Diamond(cx, cy, 10), k.AccentText) + }) + } + }) + clicked := b.Clicked() && !b.IsDisabled() + if clicked { + slog.Debug("mujica: image_thumbnail click", "alt", opts.Alt) + } + return ChoiceResult{Element: b, changed: clicked} +} diff --git a/image_thumbnail_test.go b/image_thumbnail_test.go new file mode 100644 index 0000000..fe83abf --- /dev/null +++ b/image_thumbnail_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "errors" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestImageThumbnailClickAndKeys(t *testing.T) { + n := 0 + bmp := mediaTestBitmap(120, 80) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).AlignItems(ui.Start).Children(func() { + if ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Aspect: 4.0 / 3}).Changed() { + n++ + } + }) + }, 300, 200) + r, ok := tt.Find("Hall") + if !ok || r.W != 96 || r.H != 72 { + t.Fatalf("bounds %v", r) + } + tt.ClickAt(r.X+r.W/2, r.Y+r.H/2) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeySpace) + if n != 3 { + t.Fatalf("activations %d", n) + } +} + +func TestImageThumbnailStatesKeepSize(t *testing.T) { + var got []ui.Rect + for _, o := range []ImageThumbnailOptions{{}, {Loading: true}, {Err: errors.New("404")}, {Selected: true}} { + o.Alt, o.Width = "Hall", 120 + var r ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).AlignItems(ui.Start).Children(func() { + r = ImageThumbnail(c, mediaTestBitmap(40, 40), o).Element.Bounds() + }) + }, 300, 200) + tt.Frame() + got = append(got, r) + } + for _, r := range got { + if r != got[0] || r.W != 120 || r.H != 120 { + t.Fatalf("sizes %v", got) + } + } +} + +func TestImageThumbnailDisabledAndBad(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ImageThumbnail(c, nil, ImageThumbnailOptions{Alt: "Hall", Disabled: true}).Changed() { + n++ + } + }, 300, 200) + _ = tt.Click("Hall") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if n != 0 { + t.Fatal("disabled thumbnail activated") + } + mediaPanics(t, "no alt", func(c *ui.Context) { ImageThumbnail(c, nil, ImageThumbnailOptions{}) }) +} + +func TestImageThumbnailSnapshot(t *testing.T) { + bmp := mediaTestBitmap(160, 90) + snapshot(t, "image_thumbnail", 520, 120, func(c *ui.Context) { + ui.Row(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80}) + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Selected: true}) + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Loading: true}) + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Err: errors.New("404")}) + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Disabled: true}) + }) + }) +} diff --git a/lightbox.go b/lightbox.go new file mode 100644 index 0000000..4b19858 --- /dev/null +++ b/lightbox.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// Lightbox fills the window with images[*index] while *open. +func Lightbox(c *ui.Context, open *bool, index *int, images []ViewerImage) ChoiceResult { + var v ChoiceResult + if !*open { + return v + } + if len(images) == 0 || *index < 0 || *index >= len(images) { + panic("mujica: Lightbox index out of range") + } + for _, im := range images { + viewerSize(im.Source) + } + u := Density(c).Unit() + w, h := c.Size() + light := ui.Hex("#F2EBDD") + v.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + back.Background(ui.RGBA(0, 0, 0, 0.92)) + panel.Size(w, h).Padding(u * 4).Gap(u * 3).TextColor(light).Label(msg(c, "lightbox.title")). + Opacity(overlayEnter(c, panel)) + goTo := func(i int) { + if i < 0 || i >= len(images) || i == *index { + return + } + *index, v.changed = i, true + c.Announce(fmt.Sprintf(msg(c, "lightbox.position"), i+1, len(images))) + slog.Debug("mujica: lightbox image", "index", i) + } + switch { + case panel.Shortcut(0, ui.KeyLeft): + goTo(*index - 1) + case panel.Shortcut(0, ui.KeyRight): + goTo(*index + 1) + case panel.Shortcut(0, ui.KeyHome): + goTo(0) + case panel.Shortcut(0, ui.KeyEnd): + goTo(len(images) - 1) + } + img := images[*index] + ui.Row(c).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf(msg(c, "lightbox.position"), *index+1, len(images))).FontFeatures("tnum").Grow(1) + if lightboxButton(c, "x", "lightbox.close", false).Clicked() { + *open = false + } + }) + ui.Row(c).Grow(1).MinHeight(0).Gap(u * 3).AlignItems(ui.Center).Children(func() { + if b := lightboxButton(c, "chevron-left", "lightbox.previous", *index == 0); b.Clicked() && !b.IsDisabled() { + goTo(*index - 1) + } + ui.Box(c).Grow(1).FillHeight().MinWidth(0).Center().Children(func() { + ui.Image(c, img.Source).Fit(ui.Contain).Fill().Label(img.Alt) + }) + if b := lightboxButton(c, "chevron-right", "lightbox.next", *index == len(images)-1); b.Clicked() && !b.IsDisabled() { + goTo(*index + 1) + } + }) + if img.Alt != "" { + ui.Text(c, img.Alt).TextAlign(ui.Center).FillWidth().MaxLines(3) + } + }) + if !*open { + slog.Debug("mujica: lightbox close") + } + return v +} + +// lightboxButton is a 40 DIP icon button on the backdrop. +func lightboxButton(c *ui.Context, icon, key string, disabled bool) *ui.Element { + light := ui.Hex("#F2EBDD") + b := iconAction(c, icons.Must(icon), msg(c, key)).Size(40, 40).TextColor(light).Disabled(disabled) + // The theme's focus color is too dark on the backdrop. + internal.Ring(b, light, theme.ControlRadius) + if b.Hovered() { + b.Background(ui.RGBA(255, 255, 255, 0.12)).TextColor(ui.Hex("#FFFFFF")) + } + return b +} diff --git a/lightbox_test.go b/lightbox_test.go new file mode 100644 index 0000000..d50e70c --- /dev/null +++ b/lightbox_test.go @@ -0,0 +1,66 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func lightboxImages() []ViewerImage { + return []ViewerImage{ + {Source: mediaTestBitmap(160, 90), Alt: "The grand hall"}, + {Source: mediaTestBitmap(90, 160), Alt: "The east tower"}, + {Source: mediaTestBitmap(120, 120), Alt: "The garden"}, + } +} + +func TestLightboxBrowseAndClose(t *testing.T) { + open, index, n := false, 0, 0 + images := lightboxImages() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Open gallery", ButtonOptions{}).Clicked() { + open = true + } + if Lightbox(c, &open, &index, images).Changed() { + n++ + } + }, 640, 480) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if !open || !tt.HasText("The grand hall") || !tt.HasText("1 of 3") { + t.Fatalf("not open: %v", tt.Texts()) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if index != 2 || n != 2 || !tt.HasText("The garden") { + t.Fatalf("index %d changes %d", index, n) + } + if err := tt.Click("Previous image"); err != nil { + t.Fatal(err) + } + if index != 1 { + t.Fatalf("previous: %d", index) + } + tt.Key(0, ui.KeyEscape) + if open { + t.Fatal("Escape did not close") + } + if !tt.Focused("Open gallery") { + t.Fatal("focus not restored to the opener") + } +} + +func TestLightboxBadIndex(t *testing.T) { + open, index := true, 5 + mediaPanics(t, "index", func(c *ui.Context) { Lightbox(c, &open, &index, lightboxImages()) }) +} + +func TestLightboxSnapshot(t *testing.T) { + open, index := true, 0 + images := lightboxImages() + snapshot(t, "lightbox", 520, 360, func(c *ui.Context) { + Lightbox(c, &open, &index, images) + }) +} diff --git a/media_controls.go b/media_controls.go new file mode 100644 index 0000000..a0913d1 --- /dev/null +++ b/media_controls.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" +) + +// LoopMode is how playback repeats. The zero value repeats nothing. +type LoopMode int + +const ( + LoopOff LoopMode = iota + LoopAll + LoopOne +) + +// next cycles off, all, one. +func (m LoopMode) next() LoopMode { return (m + 1) % 3 } + +// MediaControlsState is what the caller's playback reports. +type MediaControlsState struct { + Playing bool + Loop LoopMode + Shuffle bool + // NoPrevious and NoNext disable those buttons at the ends. + NoPrevious, NoNext bool +} + +// MediaControlsOptions are the callbacks; every one is required. +type MediaControlsOptions struct { + OnPrevious, OnPlay, OnPause, OnNext func() + OnLoop func(LoopMode) + OnShuffle func(bool) + Disabled bool +} + +// MediaControls are previous, play, next, loop and shuffle buttons. +func MediaControls(c *ui.Context, st MediaControlsState, opts MediaControlsOptions) *ui.Element { + if opts.OnPrevious == nil || opts.OnPlay == nil || opts.OnPause == nil || opts.OnNext == nil || opts.OnLoop == nil || opts.OnShuffle == nil { + panic("mujica: MediaControls needs every callback") + } + if st.Loop < LoopOff || st.Loop > LoopOne { + panic(fmt.Sprintf("mujica: unknown LoopMode %d", st.Loop)) + } + row := ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar). + Label(msg(c, "media_controls.label")).Disabled(opts.Disabled) + row.Children(func() { + shuffle := mediaToggleAction(c, mediaShuffle, msg(c, "media_controls.shuffle"), st.Shuffle) + if shuffle.Clicked() && !shuffle.IsDisabled() { + slog.Debug("mujica: media_controls shuffle", "on", !st.Shuffle) + opts.OnShuffle(!st.Shuffle) + } + prev := iconAction(c, mediaPrev, msg(c, "media_controls.previous")).Disabled(st.NoPrevious) + if prev.Clicked() && !prev.IsDisabled() { + slog.Debug("mujica: media_controls previous") + opts.OnPrevious() + } + mediaPlayToggle(c, st.Playing, opts.OnPlay, opts.OnPause) + next := iconAction(c, mediaNext, msg(c, "media_controls.next")).Disabled(st.NoNext) + if next.Clicked() && !next.IsDisabled() { + slog.Debug("mujica: media_controls next") + opts.OnNext() + } + icon := mediaRepeat + if st.Loop == LoopOne { + icon = mediaRepeatOne + } + loop := mediaToggleAction(c, icon, msg(c, []string{"media_controls.loopOff", "media_controls.loopAll", "media_controls.loopOne"}[st.Loop]), st.Loop != LoopOff) + if loop.Clicked() && !loop.IsDisabled() { + slog.Debug("mujica: media_controls loop", "mode", st.Loop.next()) + opts.OnLoop(st.Loop.next()) + } + }) + return row +} diff --git a/media_controls_test.go b/media_controls_test.go new file mode 100644 index 0000000..9b2e220 --- /dev/null +++ b/media_controls_test.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type controlsCalls struct { + prev, play, pause, next int + loops []LoopMode + shuffles []bool +} + +func (m *controlsCalls) opts() MediaControlsOptions { + return MediaControlsOptions{OnPrevious: func() { m.prev++ }, OnPlay: func() { m.play++ }, OnPause: func() { m.pause++ }, + OnNext: func() { m.next++ }, OnLoop: func(l LoopMode) { m.loops = append(m.loops, l) }, + OnShuffle: func(on bool) { m.shuffles = append(m.shuffles, on) }} +} + +func controlsTester(st *MediaControlsState, o MediaControlsOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { MediaControls(c, *st, o) }) + }, 400, 120) +} + +func TestMediaControlsClicks(t *testing.T) { + st := MediaControlsState{Loop: LoopAll} + var m controlsCalls + tt := controlsTester(&st, m.opts()) + for _, name := range []string{"Previous", "Play", "Next", "Repeat all", "Shuffle"} { + if err := tt.Click(name); err != nil { + t.Fatal(err) + } + } + if m.prev != 1 || m.play != 1 || m.next != 1 || len(m.loops) != 1 || m.loops[0] != LoopOne || len(m.shuffles) != 1 || !m.shuffles[0] { + t.Fatalf("calls %+v", m) + } +} + +func TestMediaControlsArrowsMoveFocus(t *testing.T) { + st := MediaControlsState{} + var m controlsCalls + tt := controlsTester(&st, m.opts()) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Shuffle") { + t.Fatal("Tab did not enter at Shuffle") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if !tt.Focused("Play") { + t.Fatal("arrows did not reach Play") + } + tt.Key(0, ui.KeyEnter) + if m.play != 1 { + t.Fatalf("Enter: play %d", m.play) + } +} + +func TestMediaControlsEndsAndDisabled(t *testing.T) { + st := MediaControlsState{NoPrevious: true, NoNext: true} + var m controlsCalls + tt := controlsTester(&st, m.opts()) + _ = tt.Click("Previous") + _ = tt.Click("Next") + o := m.opts() + o.Disabled = true + off := controlsTester(&MediaControlsState{}, o) + _ = off.Click("Play") + _ = off.Click("Shuffle") + if m.prev != 0 || m.next != 0 || m.play != 0 || len(m.shuffles) != 0 { + t.Fatalf("blocked buttons reported %+v", m) + } +} + +func TestMediaControlsBadOptions(t *testing.T) { + mediaPanics(t, "no callbacks", func(c *ui.Context) { MediaControls(c, MediaControlsState{}, MediaControlsOptions{}) }) + var m controlsCalls + mediaPanics(t, "bad loop", func(c *ui.Context) { MediaControls(c, MediaControlsState{Loop: 7}, m.opts()) }) +} + +func TestMediaControlsSnapshot(t *testing.T) { + var m controlsCalls + snapshot(t, "media_controls", 300, 120, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + MediaControls(c, MediaControlsState{Playing: true, Loop: LoopOne, Shuffle: true}, m.opts()) + MediaControls(c, MediaControlsState{NoPrevious: true}, m.opts()) + }) + }) +} diff --git a/media_helpers_test.go b/media_helpers_test.go new file mode 100644 index 0000000..b6e7fc4 --- /dev/null +++ b/media_helpers_test.go @@ -0,0 +1,44 @@ +package mujica + +import ( + "image" + "image/color" + "testing" + + "github.com/egoist/mygo/ui" +) + +// mediaTestImage is a w×h picture whose left half is wine and right half ivory. +func mediaTestImage(w, h int) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + c := color.RGBA{125, 32, 52, 255} + if x >= w/2 { + c = color.RGBA{242, 235, 221, 255} + } + if y < h/8 { + c = color.RGBA{173, 145, 100, 255} + } + img.Set(x, y, c) + } + } + return img +} + +func mediaTestBitmap(w, h int) *ui.Bitmap { return ui.NewBitmap(mediaTestImage(w, h)) } + +// mediaPanics fails t unless fn panics. +func mediaPanics(t *testing.T, name string, fn func(c *ui.Context)) { + t.Helper() + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + fn(c) + }, 400, 300) + tt.Frame() +} diff --git a/media_icons.go b/media_icons.go new file mode 100644 index 0000000..ed5de5f --- /dev/null +++ b/media_icons.go @@ -0,0 +1,53 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" +) + +// mediaSVG wraps stroked 24×24 path data as an icon. +func mediaSVG(body string) *ui.SVG { + return ui.MustParseSVG([]byte(`` + body + ``)) +} + +// Media glyphs drawn for this group. +var ( + mediaPlay = mediaSVG(``) + mediaPause = mediaSVG(``) + mediaPrev = mediaSVG(``) + mediaNext = mediaSVG(``) + mediaRepeat = mediaSVG(``) + mediaRepeatOne = mediaSVG(``) + mediaShuffle = mediaSVG(``) + mediaVolume = mediaSVG(``) + mediaMute = mediaSVG(``) + mediaRotate = mediaSVG(``) + mediaRotateLeft = mediaSVG(``) + mediaMirror = mediaSVG(``) + mediaPiP = mediaSVG(``) + mediaFullscreen = mediaSVG(``) + mediaCaptions = mediaSVG(``) + mediaRecord = mediaSVG(``) + mediaStop = mediaSVG(``) + mediaMic = mediaSVG(``) + mediaCamera = mediaSVG(``) + mediaSpeaker = mediaSVG(``) + mediaRect = mediaSVG(``) + mediaArrow = mediaSVG(``) + mediaText = mediaSVG(``) + mediaPointer = mediaSVG(``) +) + +// mediaClock writes d as m:ss, or h:mm:ss past an hour. +func mediaClock(d time.Duration) string { + if d < 0 { + d = 0 + } + s := int(d / time.Second) + if s >= 3600 { + return fmt.Sprintf("%d:%02d:%02d", s/3600, s/60%60, s%60) + } + return fmt.Sprintf("%d:%02d", s/60, s%60) +} diff --git a/media_palette_test.go b/media_palette_test.go new file mode 100644 index 0000000..3deed2a --- /dev/null +++ b/media_palette_test.go @@ -0,0 +1,24 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// TestMediaChartInksContrast keeps every chart ink at 3:1 against Surface in both themes. +func TestMediaChartInksContrast(t *testing.T) { + for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} { + inks := map[string]ui.Color{ + "played (AccentText)": k.AccentText, "unplayed (TextMuted)": k.TextMuted, "cursor (Text)": k.Text, + "mic safe": micZone(k, 0), "mic loud": micZone(k, micSegments*7/10), "mic clip": micZone(k, micSegments-1), + } + for name, c := range inks { + if r := theme.Contrast(c, k.Surface); r < 3 { + t.Errorf("dark=%v %s: %.2f:1 against Surface", k.Dark, name, r) + } + } + } +} diff --git a/messages_media.go b/messages_media.go new file mode 100644 index 0000000..fcb1f82 --- /dev/null +++ b/messages_media.go @@ -0,0 +1,106 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "image_cropper.label": {"Crop area", "裁剪区域"}, + "image_cropper.rotateLeft": {"Rotate left", "向左旋转"}, + "image_cropper.rotateRight": {"Rotate right", "向右旋转"}, + + "image_annotator.label": {"Annotations", "标注"}, + "image_annotator.tool": {"Tool", "工具"}, + "image_annotator.select": {"Select", "选择"}, + "image_annotator.rect": {"Rectangle", "矩形"}, + "image_annotator.arrow": {"Arrow", "箭头"}, + "image_annotator.text": {"Text", "文字"}, + "image_annotator.textLabel": {"Annotation text", "标注文字"}, + "image_annotator.note": {"Note", "注释"}, + "image_annotator.delete": {"Delete annotation", "删除标注"}, + "image_annotator.count": {"%d annotations", "%d 个标注"}, + "image_annotator.selected": {"Annotation %d of %d selected: %s", "已选中第 %d 个标注(共 %d 个):%s"}, + + "image_thumbnail.failed": {"Failed to load", "加载失败"}, + "image_thumbnail.loading": {"Loading", "加载中"}, + + "lightbox.title": {"Image", "图片"}, + "lightbox.close": {"Close", "关闭"}, + "lightbox.position": {"%d of %d", "第 %d 张,共 %d 张"}, + "lightbox.previous": {"Previous image", "上一张"}, + "lightbox.next": {"Next image", "下一张"}, + + "video_player.label": {"Video", "视频"}, + "video_player.empty": {"No video", "暂无视频"}, + "video_player.subtitles": {"Subtitles", "字幕"}, + "video_player.pip": {"Picture in picture", "画中画"}, + "video_player.fullscreen": {"Full screen", "全屏"}, + + "video_scrubber.label": {"Timeline", "时间轴"}, + + "video_thumbnail_strip.label": {"Frames", "缩略帧"}, + "video_thumbnail_strip.position": {"Frame %d of %d, %s", "第 %d 帧,共 %d 帧,%s"}, + "video_thumbnail_strip.previous": {"Previous frame", "上一帧"}, + "video_thumbnail_strip.next": {"Next frame", "下一帧"}, + + "audio_waveform.label": {"Waveform", "波形"}, + "audio_waveform.empty": {"No audio", "暂无音频"}, + + "audio_spectrum.label": {"Spectrum", "频谱"}, + "audio_spectrum.empty": {"No signal", "无信号"}, + "audio_spectrum.band": {"Band %d", "第 %d 段"}, + + "volume_control.label": {"Volume", "音量"}, + "volume_control.mute": {"Mute", "静音"}, + "volume_control.unmute": {"Unmute", "取消静音"}, + + "playback_speed_control.label": {"Playback speed", "播放速度"}, + + "media_controls.label": {"Playback", "播放控制"}, + "media_controls.play": {"Play", "播放"}, + "media_controls.pause": {"Pause", "暂停"}, + "media_controls.previous": {"Previous", "上一首"}, + "media_controls.next": {"Next", "下一首"}, + "media_controls.shuffle": {"Shuffle", "随机播放"}, + "media_controls.loopOff": {"Repeat off", "不循环"}, + "media_controls.loopAll": {"Repeat all", "列表循环"}, + "media_controls.loopOne": {"Repeat one", "单曲循环"}, + + "playlist.empty": {"The playlist is empty", "播放列表为空"}, + "playlist.playing": {"Playing", "正在播放"}, + "playlist.remove": {"Remove", "移除"}, + + "subtitle_editor.start": {"Start", "开始"}, + "subtitle_editor.end": {"End", "结束"}, + "subtitle_editor.text": {"Text", "文本"}, + "subtitle_editor.add": {"Add cue", "添加字幕"}, + "subtitle_editor.remove": {"Remove cue", "删除字幕"}, + "subtitle_editor.empty": {"No cues yet", "还没有字幕"}, + "subtitle_editor.badTime": {"Use m:ss.mmm, as 1:05.250", "请使用 m:ss.mmm 格式,例如 1:05.250"}, + "subtitle_editor.order": {"End must be after start", "结束时间须晚于开始时间"}, + "subtitle_editor.overlap": {"Overlaps cue %d", "与第 %d 条重叠"}, + + "screen_recorder_controls.label": {"Screen recording", "屏幕录制"}, + "screen_recorder_controls.idle": {"Ready", "就绪"}, + "screen_recorder_controls.recording": {"Recording", "录制中"}, + "screen_recorder_controls.paused": {"Paused", "已暂停"}, + "screen_recorder_controls.start": {"Record", "开始录制"}, + "screen_recorder_controls.pause": {"Pause", "暂停"}, + "screen_recorder_controls.resume": {"Resume", "继续"}, + "screen_recorder_controls.stop": {"Stop", "停止"}, + + "camera_preview.label": {"Camera", "摄像头"}, + "camera_preview.noSignal": {"No signal", "无信号"}, + "camera_preview.mirror": {"Mirror", "镜像"}, + + "mic_level_meter.label": {"Microphone level", "麦克风电平"}, + "mic_level_meter.peak": {"peak", "峰值"}, + + "device_selector.label": {"Devices", "设备"}, + "device_selector.microphone": {"Microphone", "麦克风"}, + "device_selector.camera": {"Camera", "摄像头"}, + "device_selector.speaker": {"Speaker", "扬声器"}, + "device_selector.choose": {"Choose a device", "选择设备"}, + "device_selector.none": {"No devices found", "未找到设备"}, + + "image_compare.before": {"Before", "之前"}, + "image_compare.after": {"After", "之后"}, + }) +} diff --git a/mic_level_meter.go b/mic_level_meter.go new file mode 100644 index 0000000..d3aef5c --- /dev/null +++ b/mic_level_meter.go @@ -0,0 +1,101 @@ +package mujica + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MicLevelMeterOptions configure a MicLevelMeter. +type MicLevelMeterOptions struct { + // Hold keeps the peak mark this long; zero is 1.5s. + Hold time.Duration + // Label names the meter for assistive technology. + Label string +} + +const micSegments = 24 + +type micPeak struct { + level float32 + at time.Time +} + +// MicLevelMeter shows level as segments, held peak and dB. +func MicLevelMeter(c *ui.Context, level float32, opts MicLevelMeterOptions) *ui.Element { + if level < 0 || level > 1 || math.IsNaN(float64(level)) || opts.Hold < 0 { + panic("mujica: MicLevelMeter needs level in 0..1 and Hold >= 0") + } + hold := opts.Hold + if hold == 0 { + hold = 1500 * time.Millisecond + } + k := Tokens(c) + label := opts.Label + if label == "" { + label = msg(c, "mic_level_meter.label") + } + row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize) + pk := ui.Local(row, micPeak{}, func() micPeak { return micPeak{} }) + now := c.Now() + switch { + case level >= pk.level: + *pk = micPeak{level, now} + case now.Sub(pk.at) >= hold: + *pk = micPeak{level, now} + default: + // Repaint once when the hold ends; nothing runs while idle. + c.After(hold - now.Sub(pk.at)) + } + peak := pk.level + row.Role(ui.RoleMeter).Label(label).Description(micDB(level) + ", " + msg(c, "mic_level_meter.peak") + " " + micDB(peak)) + row.Children(func() { + ui.Box(c).Height(12).MinWidth(120).Grow(1).Draw(func(p *ui.Painter, r ui.Rect) { + gap := float32(2) + w := (r.W - gap*(micSegments-1)) / micSegments + lit := int(math.Round(float64(level * micSegments))) + mark := int(math.Round(float64(peak*micSegments))) - 1 + for i := range micSegments { + x := r.X + float32(i)*(w+gap) + seg := ui.Rect{X: x, Y: r.Y, W: w, H: r.H} + col := micZone(k, i) + switch { + case i < lit: + p.Fill(seg, col, 1) + case i == mark: + p.Fill(seg, col, 1) + p.Fill(ui.Rect{X: x + 1, Y: r.Y + 2, W: w - 2, H: r.H - 4}, k.Surface, 0) + default: + p.Stroke(seg, k.Border, 1, 1) + } + } + }) + ui.Text(c, micDB(level)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum"). + TextColor(k.TextMuted).MinWidth(56).TextAlign(ui.End).SingleLine() + }) + return row +} + +// micZone colors the safe, loud and clipping zones. +func micZone(k theme.Tokens, i int) ui.Color { + switch { + case i >= micSegments*9/10: + return k.Danger + case i >= micSegments*7/10: + return k.Warning + } + return k.Success +} + +// micDB writes a full-scale level in decibels. +func micDB(v float32) string { + if v <= 0 { + return "-∞ dB" + } + // Adding 0 turns a rounded -0 into 0. + return fmt.Sprintf("%.0f dB", math.Round(20*math.Log10(float64(v)))+0) +} diff --git a/mic_level_meter_test.go b/mic_level_meter_test.go new file mode 100644 index 0000000..1e9736b --- /dev/null +++ b/mic_level_meter_test.go @@ -0,0 +1,62 @@ +package mujica + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestMicLevelMeterPeakHold(t *testing.T) { + level := float32(1) + var e *ui.Element + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + e = MicLevelMeter(c, level, MicLevelMeterOptions{Hold: 40 * time.Millisecond}) + }, 400, 100) + if !tt.HasText("0 dB") { + t.Fatalf("texts %v", tt.Texts()) + } + level = 0.1 + tt.Frame() + if !tt.HasText("-20 dB") { + t.Fatalf("level readout %v", tt.Texts()) + } + if r, ok := tt.Find("Microphone level"); !ok || r.W == 0 { + t.Fatal("meter not named") + } + if !strings.Contains(micTestDescription(e), "peak 0 dB") { + t.Fatal("peak not held") + } + time.Sleep(60 * time.Millisecond) + tt.Frame() + if !strings.Contains(micTestDescription(e), "peak -20 dB") { + t.Fatal("peak did not fall after the hold") + } +} + +// micTestDescription rebuilds the meter's spoken value from its state. +func micTestDescription(e *ui.Element) string { + pk := *ui.Local(e, micPeak{}, func() micPeak { return micPeak{} }) + return "peak " + micDB(pk.level) +} + +func TestMicLevelMeterDB(t *testing.T) { + for v, want := range map[float32]string{0: "-∞ dB", 1: "0 dB", 0.5: "-6 dB"} { + if got := micDB(v); got != want { + t.Errorf("%v: %s, want %s", v, got, want) + } + } + mediaPanics(t, "over full scale", func(c *ui.Context) { MicLevelMeter(c, 1.2, MicLevelMeterOptions{}) }) +} + +func TestMicLevelMeterSnapshot(t *testing.T) { + snapshot(t, "mic_level_meter", 360, 150, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(8).Children(func() { + MicLevelMeter(c, 0.35, MicLevelMeterOptions{}) + MicLevelMeter(c, 0.8, MicLevelMeterOptions{}) + MicLevelMeter(c, 0.97, MicLevelMeterOptions{}) + }) + }) +} diff --git a/playback_speed_control.go b/playback_speed_control.go new file mode 100644 index 0000000..e3fe9d0 --- /dev/null +++ b/playback_speed_control.go @@ -0,0 +1,46 @@ +package mujica + +import ( + "log/slog" + "slices" + "strconv" + + "github.com/egoist/mygo/ui" +) + +// playbackRates are the default choices, 0.5× to 2×. +var playbackRates = []float64{0.5, 0.75, 1, 1.25, 1.5, 2} + +// PlaybackSpeedControlOptions configure a PlaybackSpeedControl. +type PlaybackSpeedControlOptions struct { + // Rates are the ascending choices; nil means 0.5× to 2×. + Rates []float64 + Disabled bool +} + +// PlaybackSpeedControl chooses *rate from Rates in a drop-down. +func PlaybackSpeedControl(c *ui.Context, rate *float64, opts PlaybackSpeedControlOptions) ChoiceResult { + rates := opts.Rates + if rates == nil { + rates = playbackRates + } + if len(rates) == 0 || !slices.IsSorted(rates) || rates[0] <= 0 { + panic("mujica: PlaybackSpeedControl needs positive Rates in ascending order") + } + if !slices.Contains(rates, *rate) { + panic("mujica: PlaybackSpeedControl rate is not one of Rates") + } + options := make([]SelectOption[float64], len(rates)) + for i, r := range rates { + options[i] = SelectOption[float64]{Value: r, Label: playbackLabel(r)} + } + res := Select(c, rate, options, SelectOptions{Disabled: opts.Disabled, Label: msg(c, "playback_speed_control.label")}) + res.Element.MinWidth(88).Width(96) + if res.Changed() { + slog.Debug("mujica: playback_speed changed", "rate", *rate) + } + return res +} + +// playbackLabel writes a rate as "1.25×". +func playbackLabel(r float64) string { return strconv.FormatFloat(r, 'f', -1, 64) + "×" } diff --git a/playback_speed_control_test.go b/playback_speed_control_test.go new file mode 100644 index 0000000..8d0dee2 --- /dev/null +++ b/playback_speed_control_test.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func speedTester(rate *float64, opts PlaybackSpeedControlOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { + if PlaybackSpeedControl(c, rate, opts).Changed() { + *n++ + } + }) + }, 300, 400) +} + +func TestPlaybackSpeedChoose(t *testing.T) { + rate, n := 1.0, 0 + tt := speedTester(&rate, PlaybackSpeedControlOptions{}, &n) + if !tt.HasText("1×") { + t.Fatalf("texts %v", tt.Texts()) + } + if err := tt.Click("Playback speed"); err != nil { + t.Fatal(err) + } + for _, want := range []string{"0.5×", "0.75×", "1.25×", "1.5×", "2×"} { + if !tt.HasText(want) { + t.Fatalf("no %s in %v", want, tt.Texts()) + } + } + if err := tt.Click("2×"); err != nil { + t.Fatal(err) + } + if rate != 2 || n != 1 { + t.Fatalf("rate %v changes %d", rate, n) + } +} + +func TestPlaybackSpeedKeyboardAndDisabled(t *testing.T) { + rate, n := 1.0, 0 + tt := speedTester(&rate, PlaybackSpeedControlOptions{}, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if rate != 1.25 { + t.Fatalf("keyboard chose %v", rate) + } + rate2, n2 := 1.0, 0 + off := speedTester(&rate2, PlaybackSpeedControlOptions{Disabled: true}, &n2) + _ = off.Click("Playback speed") + _ = off.Click("2×") + if rate2 != 1 || n2 != 0 { + t.Fatalf("disabled chose %v", rate2) + } +} + +func TestPlaybackSpeedBadOptions(t *testing.T) { + odd := 3.0 + mediaPanics(t, "rate not offered", func(c *ui.Context) { PlaybackSpeedControl(c, &odd, PlaybackSpeedControlOptions{}) }) + one := 1.0 + mediaPanics(t, "unsorted", func(c *ui.Context) { + PlaybackSpeedControl(c, &one, PlaybackSpeedControlOptions{Rates: []float64{2, 1}}) + }) +} + +func TestPlaybackSpeedSnapshot(t *testing.T) { + rate := 1.25 + snapshot(t, "playback_speed_control", 200, 80, func(c *ui.Context) { + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { + PlaybackSpeedControl(c, &rate, PlaybackSpeedControlOptions{}) + }) + }) +} diff --git a/playlist.go b/playlist.go new file mode 100644 index 0000000..c077165 --- /dev/null +++ b/playlist.go @@ -0,0 +1,159 @@ +package mujica + +import ( + "log/slog" + "slices" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// PlaylistItem is one track; ID must be unique and stable. +type PlaylistItem struct { + ID, Title, Subtitle string + Duration time.Duration +} + +// PlaylistOptions configure a Playlist. +type PlaylistOptions struct { + // Current is the playing item's ID; empty is none. + Current string + Disabled bool + // Height caps the list; zero means 320. + Height float32 +} + +// PlaylistResult is the playlist's element and this frame's events. +type PlaylistResult struct { + Element *ui.Element + // Played is the ID chosen by Enter or double click. + Played string + // Removed is the ID taken out of *items. + Removed string + // Moved reports a reorder of *items. + Moved bool +} + +type playlistKey struct{} + +// Playlist lists *items, building only rows in view. +func Playlist(c *ui.Context, items *[]PlaylistItem, opts PlaylistOptions) PlaylistResult { + if opts.Height < 0 { + panic("mujica: Playlist Height must not be negative") + } + h := opts.Height + if h == 0 { + h = 320 + } + seen := make(map[string]bool, len(*items)) + for _, it := range *items { + if it.ID == "" || seen[it.ID] { + panic("mujica: Playlist item IDs must be unique and non-empty: " + it.ID) + } + seen[it.ID] = true + } + k := Tokens(c) + var r PlaylistResult + box := ui.Column(c).Height(h).Disabled(opts.Disabled) + r.Element = box + st := ui.Local(box, playlistKey{}, func() *ListState[string] { return &ListState[string]{} }) + s := *st + list := *items + live := !box.IsDisabled() + s.rows.Reorder = nil + if live { + s.rows.Reorder = func(rows []int, to int) { + *items = playlistMove(*items, rows, to) + r.Moved = true + } + } + removeAt := -1 + box.Children(func() { + v := List(c, s, len(list), ListOptions[string]{ + Key: func(i int) string { return list[i].ID }, + Label: func(i int) string { return list[i].Title }, + Empty: msg(c, "playlist.empty"), + }, func(i int) { + playlistRow(c, k, list[i], i, list[i].ID == opts.Current) + if iconAction(c, icons.Must("x"), msg(c, "playlist.remove")+" "+list[i].Title).Clicked() && live { + removeAt = i + } + }) + v.Element.Grow(1) + if removeAt >= 0 { + r.Removed = list[removeAt].ID + *items = slices.Delete(*items, removeAt, removeAt+1) + return + } + sel, ok := s.Selected() + i := slices.IndexFunc(*items, func(it PlaylistItem) bool { return it.ID == sel }) + if !live || !ok || i < 0 { + return + } + switch { + case v.Submitted(): + r.Played = sel + case v.Element.Shortcut(0, ui.KeyDelete), v.Element.Shortcut(0, ui.KeyBackspace): + r.Removed = sel + *items = slices.Delete(*items, i, i+1) + case v.Element.Shortcut(ui.Alt, ui.KeyUp) && i > 0: + *items, r.Moved = playlistMove(*items, []int{i}, i-1), true + case v.Element.Shortcut(ui.Alt, ui.KeyDown) && i < len(*items)-1: + *items, r.Moved = playlistMove(*items, []int{i}, i+2), true + } + }) + switch { + case r.Played != "": + slog.Debug("mujica: playlist play", "id", r.Played) + case r.Removed != "": + slog.Debug("mujica: playlist remove", "id", r.Removed) + case r.Moved: + slog.Debug("mujica: playlist move") + } + return r +} + +// playlistRow shows a track's mark, title, subtitle and length. +func playlistRow(c *ui.Context, k theme.Tokens, it PlaylistItem, i int, playing bool) { + ui.Icon(c, icons.Must("grip-vertical")).Size(14, 14).TextColor(k.TextMuted).Shrink(0) + ui.Box(c).Width(20).Center().Shrink(0).Children(func() { + if playing { + ui.Icon(c, mediaPlay).Size(14, 14).TextColor(k.AccentText).Label(msg(c, "playlist.playing")) + } else { + ui.Textf(c, "%d", i+1).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum") + } + }) + ui.Column(c).Grow(1).MinWidth(0).Children(func() { + t := ui.Text(c, it.Title).SingleLine() + if playing { + t.TextColor(k.AccentText).FontWeight(600) + } + if it.Subtitle != "" { + ui.Text(c, it.Subtitle).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + } + }) + if it.Duration > 0 { + ui.Text(c, mediaClock(it.Duration)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum") + } +} + +// playlistMove puts rows before index to; len means the end. +func playlistMove(items []PlaylistItem, rows []int, to int) []PlaylistItem { + moved := make([]PlaylistItem, 0, len(rows)) + rest := make([]PlaylistItem, 0, len(items)) + at := to + for i, it := range items { + if slices.Contains(rows, i) { + moved = append(moved, it) + if i < to { + at-- + } + continue + } + rest = append(rest, it) + } + return slices.Concat(rest[:at], moved, rest[at:]) +} diff --git a/playlist_test.go b/playlist_test.go new file mode 100644 index 0000000..fb3cb2f --- /dev/null +++ b/playlist_test.go @@ -0,0 +1,123 @@ +package mujica + +import ( + "fmt" + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func playlistItems(n int) []PlaylistItem { + items := make([]PlaylistItem, n) + for i := range items { + items[i] = PlaylistItem{ID: fmt.Sprint("t", i), Title: fmt.Sprint("Track ", i), Duration: time.Duration(180+i) * time.Second} + } + return items +} + +func playlistIDs(items []PlaylistItem) []string { + ids := make([]string, len(items)) + for i, it := range items { + ids[i] = it.ID + } + return ids +} + +func playlistTester(items *[]PlaylistItem, disabled bool, got *[]PlaylistResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(420).Children(func() { + r := Playlist(c, items, PlaylistOptions{Current: "t1", Disabled: disabled}) + if r.Played != "" || r.Removed != "" || r.Moved { + *got = append(*got, r) + } + }) + }, 500, 420) +} + +func TestPlaylistKeyboard(t *testing.T) { + items := playlistItems(4) + var got []PlaylistResult + tt := playlistTester(&items, false, &got) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(got) != 1 || got[0].Played != "t0" { + t.Fatalf("Enter played %+v", got) + } + tt.Key(ui.Alt, ui.KeyDown) + if !slices.Equal(playlistIDs(items), []string{"t1", "t0", "t2", "t3"}) { + t.Fatalf("Alt+Down order %v", playlistIDs(items)) + } + tt.Key(0, ui.KeyDelete) + if !slices.Equal(playlistIDs(items), []string{"t1", "t2", "t3"}) || got[len(got)-1].Removed != "t0" { + t.Fatalf("Delete left %v, %+v", playlistIDs(items), got) + } +} + +func TestPlaylistRemoveButtonAndDrag(t *testing.T) { + items := playlistItems(4) + var got []PlaylistResult + tt := playlistTester(&items, false, &got) + if err := tt.Click("Remove Track 3"); err != nil { + t.Fatal(err) + } + if len(items) != 3 || got[0].Removed != "t3" { + t.Fatalf("remove button: %v", playlistIDs(items)) + } + a, _ := tt.Find("Track 0") + b, _ := tt.Find("Track 2") + tt.Press(a.X+4, a.Y+a.H/2) + tt.Move(a.X+4, a.Y+a.H/2+10) + tt.Move(b.X+4, b.Y+b.H-2) + tt.Release(b.X+4, b.Y+b.H-2) + if !slices.Equal(playlistIDs(items), []string{"t1", "t2", "t0"}) || !got[len(got)-1].Moved { + t.Fatalf("drag order %v", playlistIDs(items)) + } +} + +func TestPlaylistBuildsOnlyVisible(t *testing.T) { + items := playlistItems(100000) + var got []PlaylistResult + tt := playlistTester(&items, false, &got) + tt.Frame() + built := 0 + for _, s := range tt.Texts() { + if len(s) > 6 && s[:6] == "Track " { + built++ + } + } + if built == 0 || built > 40 { + t.Fatalf("built %d rows of 100000", built) + } +} + +func TestPlaylistDisabledAndBad(t *testing.T) { + items := playlistItems(3) + var got []PlaylistResult + tt := playlistTester(&items, true, &got) + _ = tt.Click("Remove Track 0") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDelete) + tt.Key(0, ui.KeyEnter) + if len(items) != 3 || len(got) != 0 { + t.Fatalf("disabled playlist changed: %v %+v", playlistIDs(items), got) + } + dup := []PlaylistItem{{ID: "a"}, {ID: "a"}} + mediaPanics(t, "duplicate", func(c *ui.Context) { Playlist(c, &dup, PlaylistOptions{}) }) +} + +func TestPlaylistSnapshot(t *testing.T) { + items := []PlaylistItem{ + {ID: "a", Title: "Ave Mujica", Subtitle: "Overture", Duration: 245 * time.Second}, + {ID: "b", Title: "Imprisoned XII", Subtitle: "Act I", Duration: 302 * time.Second}, + {ID: "c", Title: "KiLLKiSS", Subtitle: "Act II", Duration: 268 * time.Second}, + } + snapshot(t, "playlist", 440, 240, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(400).Children(func() { + Playlist(c, &items, PlaylistOptions{Current: "b", Height: 200}) + }) + }) +} diff --git a/screen_recorder_controls.go b/screen_recorder_controls.go new file mode 100644 index 0000000..d23ce6f --- /dev/null +++ b/screen_recorder_controls.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// RecorderStatus is where a recording is; zero is idle. +type RecorderStatus int + +const ( + RecorderIdle RecorderStatus = iota + RecorderRecording + RecorderPaused +) + +// RecorderState is what the caller's recorder reports. +type RecorderState struct { + Status RecorderStatus + Elapsed time.Duration +} + +// ScreenRecorderControlsOptions are the callbacks; every one is required. +type ScreenRecorderControlsOptions struct { + OnStart, OnPause, OnResume, OnStop func() + Disabled bool +} + +// ScreenRecorderControls start, pause, resume and stop a recording. +func ScreenRecorderControls(c *ui.Context, st RecorderState, opts ScreenRecorderControlsOptions) *ui.Element { + if opts.OnStart == nil || opts.OnPause == nil || opts.OnResume == nil || opts.OnStop == nil { + panic("mujica: ScreenRecorderControls needs every callback") + } + if st.Status < RecorderIdle || st.Status > RecorderPaused || st.Elapsed < 0 { + panic(fmt.Sprintf("mujica: bad RecorderState %+v", st)) + } + k := Tokens(c) + u := Density(c).Unit() + row := ui.Row(c).Gap(u*2).Padding(u, u*2).AlignItems(ui.Center).Radius(theme.CardRadius). + Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleToolbar). + Label(msg(c, "screen_recorder_controls.label")).Disabled(opts.Disabled) + act := func(icon *ui.SVG, key string, variant ButtonVariant, fn func()) { + if Button(c, msg(c, key), ButtonOptions{Variant: variant, Icon: icon}).Clicked() && !row.IsDisabled() { + slog.Debug("mujica: recorder " + key) + fn() + } + } + row.Children(func() { + status := []string{"screen_recorder_controls.idle", "screen_recorder_controls.recording", "screen_recorder_controls.paused"}[st.Status] + ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(msg(c, status) + " " + mediaClock(st.Elapsed)).Children(func() { + dot := k.TextMuted + if st.Status == RecorderRecording { + dot = k.Danger + } + ui.Box(c).Size(10, 10).Radius(5).Background(dot) + ui.Text(c, msg(c, status)).TextColor(k.TextMuted).SingleLine() + ui.Text(c, mediaClock(st.Elapsed)).FontFeatures("tnum").SingleLine().MinWidth(48) + }) + switch st.Status { + case RecorderIdle: + act(mediaRecord, "screen_recorder_controls.start", Danger, opts.OnStart) + case RecorderRecording: + act(mediaPause, "screen_recorder_controls.pause", Secondary, opts.OnPause) + act(mediaStop, "screen_recorder_controls.stop", Danger, opts.OnStop) + case RecorderPaused: + act(mediaRecord, "screen_recorder_controls.resume", Secondary, opts.OnResume) + act(mediaStop, "screen_recorder_controls.stop", Danger, opts.OnStop) + } + }) + return row +} diff --git a/screen_recorder_controls_test.go b/screen_recorder_controls_test.go new file mode 100644 index 0000000..f83ba6d --- /dev/null +++ b/screen_recorder_controls_test.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type recorderCalls struct{ start, pause, resume, stop int } + +func (r *recorderCalls) opts() ScreenRecorderControlsOptions { + return ScreenRecorderControlsOptions{OnStart: func() { r.start++ }, OnPause: func() { r.pause++ }, + OnResume: func() { r.resume++ }, OnStop: func() { r.stop++ }} +} + +func TestScreenRecorderControlsFlow(t *testing.T) { + st := RecorderState{} + var r recorderCalls + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { ScreenRecorderControls(c, st, r.opts()) }) + }, 500, 120) + if !tt.HasText("Ready") || tt.HasText("Stop") { + t.Fatalf("idle shows %v", tt.Texts()) + } + if err := tt.Click("Record"); err != nil { + t.Fatal(err) + } + st = RecorderState{Status: RecorderRecording, Elapsed: 65 * time.Second} + tt.Frame() + if !tt.HasText("1:05") || !tt.HasText("Recording") { + t.Fatalf("recording shows %v", tt.Texts()) + } + if err := tt.Click("Pause"); err != nil { + t.Fatal(err) + } + st.Status = RecorderPaused + tt.Frame() + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if r != (recorderCalls{1, 1, 1, 1}) { + t.Fatalf("calls %+v", r) + } +} + +func TestScreenRecorderControlsDisabledAndBad(t *testing.T) { + var r recorderCalls + o := r.opts() + o.Disabled = true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ScreenRecorderControls(c, RecorderState{}, o) + }, 500, 120) + _ = tt.Click("Record") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if r.start != 0 { + t.Fatal("disabled recorder started") + } + mediaPanics(t, "no callbacks", func(c *ui.Context) { ScreenRecorderControls(c, RecorderState{}, ScreenRecorderControlsOptions{}) }) + mediaPanics(t, "bad status", func(c *ui.Context) { ScreenRecorderControls(c, RecorderState{Status: 9}, r.opts()) }) +} + +func TestScreenRecorderControlsSnapshot(t *testing.T) { + var r recorderCalls + snapshot(t, "screen_recorder_controls", 420, 140, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + ScreenRecorderControls(c, RecorderState{}, r.opts()) + ScreenRecorderControls(c, RecorderState{Status: RecorderRecording, Elapsed: 3*time.Minute + 7*time.Second}, r.opts()) + }) + }) +} diff --git a/subtitle_editor.go b/subtitle_editor.go new file mode 100644 index 0000000..8a1a5fa --- /dev/null +++ b/subtitle_editor.go @@ -0,0 +1,244 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// SubtitleCue is one subtitle: shown from Start until End. +type SubtitleCue struct { + Start, End time.Duration + Text string +} + +// SubtitleIssueKind is what is wrong with a cue. +type SubtitleIssueKind int + +const ( + // SubtitleEndBeforeStart: End is not after Start. + SubtitleEndBeforeStart SubtitleIssueKind = iota + // SubtitleOverlap: Start is before the previous cue's End. + SubtitleOverlap +) + +// SubtitleIssue is a problem with cue Index. +type SubtitleIssue struct { + Index int + Kind SubtitleIssueKind +} + +// CheckSubtitles lists misordered cues and cues overlapping the previous. +func CheckSubtitles(cues []SubtitleCue) []SubtitleIssue { + var out []SubtitleIssue + for i, q := range cues { + if q.End <= q.Start { + out = append(out, SubtitleIssue{i, SubtitleEndBeforeStart}) + } + if i > 0 && q.Start < cues[i-1].End { + out = append(out, SubtitleIssue{i, SubtitleOverlap}) + } + } + return out +} + +// SubtitleEditorOptions configure a SubtitleEditor. +type SubtitleEditorOptions struct { + // Height is the visible list's height; zero means 360. + Height float32 + Disabled bool +} + +type subtitleState struct { + scroll ui.ScrollState + drafts map[subtitleField]string +} + +type subtitleField struct{ row, part int } + +type subtitleKey struct{} + +// SubtitleEditor edits cue times and text, building visible rows. +func SubtitleEditor(c *ui.Context, cues *[]SubtitleCue, opts SubtitleEditorOptions) ChoiceResult { + if opts.Height < 0 { + panic("mujica: SubtitleEditor Height must not be negative") + } + h := opts.Height + if h == 0 { + h = 360 + } + k := Tokens(c) + u := Density(c).Unit() + rowH := Density(c).ControlHeight() + FontSize(c, theme.CaptionSize)*theme.LineHeight + u*3 + col := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled) + st := ui.Local(col, subtitleKey{}, func() *subtitleState { return &subtitleState{drafts: map[subtitleField]string{}} }) + s := *st + issues := map[int]string{} + for _, is := range CheckSubtitles(*cues) { + if is.Kind == SubtitleOverlap { + issues[is.Index] = fmt.Sprintf(msg(c, "subtitle_editor.overlap"), is.Index) + } else { + issues[is.Index] = msg(c, "subtitle_editor.order") + } + } + changed := false + col.Children(func() { + list := ui.Scroll(c).Height(h).TrackScroll(&s.scroll).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius) + list.Children(func() { + n := len(*cues) + // The container keeps Y inside the content; match it. + y := max(0, min(s.scroll.Y, float32(n)*rowH-h)) + first := max(0, int(y/rowH)-1) + last := min(n, int((y+h)/rowH)+2) + ui.Box(c).Height(float32(n) * rowH).Children(func() { + for i := first; i < last; i++ { + before := len(*cues) + if subtitleRow(c, k, cues, s, i, rowH, issues[i]) { + changed = true + } + if len(*cues) != before { + // A removal shifts rows below; the next frame builds them. + c.AnimationFrame() + return + } + } + }) + if n == 0 { + ui.Text(c, msg(c, "subtitle_editor.empty")).TextColor(k.TextMuted).Padding(u * 3) + } + }) + if Button(c, msg(c, "subtitle_editor.add"), ButtonOptions{Variant: Secondary, Icon: icons.Must("plus")}).Clicked() && !col.IsDisabled() { + var at time.Duration + if n := len(*cues); n > 0 { + at = (*cues)[n-1].End + } + *cues = append(*cues, SubtitleCue{Start: at, End: at + 2*time.Second}) + s.scroll.Y = float32(len(*cues)) * rowH + changed = true + } + }) + if changed { + slog.Debug("mujica: subtitle_editor changed", "cues", len(*cues)) + } + return ChoiceResult{Element: col, changed: changed} +} + +// subtitleRow edits cue i; true means changed or removed. +func subtitleRow(c *ui.Context, k theme.Tokens, cues *[]SubtitleCue, s *subtitleState, i int, rowH float32, issue string) bool { + u := Density(c).Unit() + q := &(*cues)[i] + changed, removed := false, false + row := ui.Column(c).Key(i).Absolute().Left(0).Right(0).Top(float32(i)*rowH).Height(rowH).Padding(u*1.5, u*2).Gap(2). + BorderWidth(0, 0, 1, 0).BorderColor(k.Border) + row.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Textf(c, "%d", i+1).Width(28).TextColor(k.TextMuted).FontFeatures("tnum") + for part, t := range []*time.Duration{&q.Start, &q.End} { + f := subtitleField{i, part} + text, editing := s.drafts[f] + if !editing { + text = subtitleClock(*t) + } + name := msg(c, []string{"subtitle_editor.start", "subtitle_editor.end"}[part]) + in := TextInput(c, &text, TextInputOptions{Label: fmt.Sprintf("%s %d", name, i+1)}) + in.Element.Width(112) + bad := false + if in.Changed() { + if v, ok := subtitleParse(text); ok { + *t, changed = v, true + delete(s.drafts, f) + } else { + s.drafts[f], bad = text, true + } + } else if editing { + _, ok := subtitleParse(text) + bad = !ok + } + if bad { + in.Input.Error(msg(c, "subtitle_editor.badTime")) + } else if issue != "" { + in.Input.Error(issue) + } + } + txt := TextInput(c, &q.Text, TextInputOptions{Label: fmt.Sprintf("%s %d", msg(c, "subtitle_editor.text"), i+1)}) + txt.Element.Grow(1) + if txt.Changed() { + changed = true + } + if b := iconAction(c, icons.Must("trash-2"), fmt.Sprintf("%s %d", msg(c, "subtitle_editor.remove"), i+1)); b.Clicked() && !b.IsDisabled() { + removed = true + } + }) + ui.Row(c).Gap(u).PaddingX(28 + u*2).Children(func() { + note := issue + for part := range 2 { + if d, ok := s.drafts[subtitleField{i, part}]; ok { + if _, valid := subtitleParse(d); !valid { + note = msg(c, "subtitle_editor.badTime") + } + } + } + if note != "" { + ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).SingleLine() + } + }) + }) + if removed { + *cues = slices.Delete(*cues, i, i+1) + clear(s.drafts) + return true + } + return changed +} + +// subtitleClock writes d as m:ss.mmm. +func subtitleClock(d time.Duration) string { + ms := d.Milliseconds() + return fmt.Sprintf("%d:%02d.%03d", ms/60000, ms/1000%60, ms%1000) +} + +// subtitleParse reads m:ss.mmm or h:mm:ss.mmm; the fraction is optional. +func subtitleParse(s string) (time.Duration, bool) { + parts := strings.Split(strings.TrimSpace(s), ":") + if len(parts) < 2 || len(parts) > 3 { + return 0, false + } + acc := 0 + for i, p := range parts[:len(parts)-1] { + n, err := strconv.Atoi(p) + if err != nil || !subtitleDigits(p) || (i > 0 && n > 59) { + return 0, false + } + acc = acc*60 + n + } + sec := parts[len(parts)-1] + whole, frac, _ := strings.Cut(sec, ".") + s0, err := strconv.Atoi(whole) + if err != nil || len(whole) != 2 || s0 < 0 || s0 > 59 || len(frac) > 3 || !subtitleDigits(whole+frac) { + return 0, false + } + ms := 0 + if frac != "" { + ms, _ = strconv.Atoi(frac + strings.Repeat("0", 3-len(frac))) + } + return time.Duration(acc*60+s0)*time.Second + time.Duration(ms)*time.Millisecond, true +} + +// subtitleDigits reports whether s is ASCII digits only. +func subtitleDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/subtitle_editor_test.go b/subtitle_editor_test.go new file mode 100644 index 0000000..9c2b4c7 --- /dev/null +++ b/subtitle_editor_test.go @@ -0,0 +1,131 @@ +package mujica + +import ( + "slices" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func subtitleTester(cues *[]SubtitleCue, disabled bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(620).Children(func() { + if SubtitleEditor(c, cues, SubtitleEditorOptions{Height: 260, Disabled: disabled}).Changed() { + *n++ + } + }) + }, 700, 400) +} + +func TestCheckSubtitles(t *testing.T) { + cues := []SubtitleCue{{0, 2 * time.Second, "a"}, {time.Second, 3 * time.Second, "b"}, {4 * time.Second, 4 * time.Second, "c"}} + want := []SubtitleIssue{{1, SubtitleOverlap}, {2, SubtitleEndBeforeStart}} + if got := CheckSubtitles(cues); !slices.Equal(got, want) { + t.Fatalf("issues %v", got) + } +} + +func TestSubtitleParse(t *testing.T) { + for s, want := range map[string]time.Duration{"0:01.5": 1500 * time.Millisecond, "1:05.250": 65250 * time.Millisecond, "1:00:00": time.Hour, "2:03": 123 * time.Second} { + if got, ok := subtitleParse(s); !ok || got != want { + t.Errorf("%q: %v %v", s, got, ok) + } + } + for _, s := range []string{"", "5", "1:60", "1:5", "a:00", "1:00.1234", "1:-1", "+1:00"} { + if _, ok := subtitleParse(s); ok { + t.Errorf("%q parsed", s) + } + } +} + +func TestSubtitleEditorEditTimesAndText(t *testing.T) { + cues := []SubtitleCue{{0, 2 * time.Second, "Welcome"}, {3 * time.Second, 5 * time.Second, "to the court"}} + n := 0 + tt := subtitleTester(&cues, false, &n) + if err := tt.Click("End 1"); err != nil { + t.Fatal(err) + } + tt.Command("selectAll") + tt.Type("0:04") + if cues[0].End != 4*time.Second || !tt.HasText("Overlaps cue 1") { + t.Fatalf("end edit %+v, texts %v", cues[0], tt.Texts()) + } + tt.Command("selectAll") + tt.Type("zz") + if cues[0].End != 4*time.Second || !tt.HasText("Use m:ss.mmm, as 1:05.250") { + t.Fatalf("bad time kept? %+v", cues[0]) + } + if err := tt.Click("Text 2"); err != nil { + t.Fatal(err) + } + tt.Type("!") + if cues[1].Text != "to the court!" || n < 2 { + t.Fatalf("text %q changes %d", cues[1].Text, n) + } +} + +func TestSubtitleEditorAddRemove(t *testing.T) { + cues := []SubtitleCue{{0, 2 * time.Second, "a"}} + n := 0 + tt := subtitleTester(&cues, false, &n) + if err := tt.Click("Add cue"); err != nil { + t.Fatal(err) + } + if len(cues) != 2 || cues[1].Start != 2*time.Second || cues[1].End != 4*time.Second { + t.Fatalf("added %+v", cues) + } + if err := tt.Click("Remove cue 1"); err != nil { + t.Fatal(err) + } + if len(cues) != 1 || cues[0].Start != 2*time.Second || n != 2 { + t.Fatalf("removed %+v, changes %d", cues, n) + } +} + +func TestSubtitleEditorBuildsOnlyVisible(t *testing.T) { + cues := make([]SubtitleCue, 100000) + for i := range cues { + cues[i] = SubtitleCue{time.Duration(i) * time.Second, time.Duration(i)*time.Second + 900*time.Millisecond, "line"} + } + n := 0 + tt := subtitleTester(&cues, false, &n) + tt.Frame() + built := 0 + for _, x := range tt.Texts() { + if strings.HasPrefix(x, "Remove cue ") { + built++ + } + } + if built == 0 || built > 20 { + t.Fatalf("built %d rows of 100000", built) + } +} + +func TestSubtitleEditorDisabled(t *testing.T) { + cues := []SubtitleCue{{0, 2 * time.Second, "a"}} + n := 0 + tt := subtitleTester(&cues, true, &n) + _ = tt.Click("Add cue") + _ = tt.Click("Remove cue 1") + _ = tt.Click("Text 1") + tt.Type("x") + if len(cues) != 1 || cues[0].Text != "a" || n != 0 { + t.Fatalf("disabled editor changed %+v", cues) + } +} + +func TestSubtitleEditorSnapshot(t *testing.T) { + cues := []SubtitleCue{ + {0, 2500 * time.Millisecond, "The curtain rises."}, + {2 * time.Second, 5 * time.Second, "Welcome to the masquerade."}, + {6 * time.Second, 9 * time.Second, "Ave Mujica."}, + } + snapshot(t, "subtitle_editor", 640, 300, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(600).Children(func() { + SubtitleEditor(c, &cues, SubtitleEditorOptions{Height: 200}) + }) + }) +} diff --git a/testdata/snapshots/darwin/audio_player-dark.png b/testdata/snapshots/darwin/audio_player-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a19fc33c89f40715722d602f0ce5811a451908b6 GIT binary patch literal 10475 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 Ca$i*d literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/audio_player-light.png b/testdata/snapshots/darwin/audio_player-light.png new file mode 100644 index 0000000000000000000000000000000000000000..6d5dba4fe929b481ba84651075bbf1456d1edf2e GIT binary patch literal 10039 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/audio_spectrum-dark.png b/testdata/snapshots/darwin/audio_spectrum-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..8c621f0c1118a0f2aeb0c866a23a9751313922ef GIT binary patch literal 1666 zcmeAS@N?(olHy`uVBq!ia0y~yU|a%ZFW_JTl4nlzA7Ef$^YnCa45^s&=DML5v!h7E z!+EHv zw)bSe>0dS!n^_D2hzWg;&Z|N5sS^uKW^?4hL_wI^|U2hw+ zKG){YRsKKDlm68VpwGw%(s#;lig+%qFWbGl^J8rI?$;7a{l6WXCws?$0~ob$_rI30 zaP^qLE3s#u`t^jDeAcMD^&V~o=1GumQbAUGr>yz(DjMO$zp3s@ zDnfb_eU<9&?6rpkf}T?uECk|DMLb=)XUg?3gpL&RrEff1TviDwLV^%T?v6}N?=W$Z zQo1jzbP{ZaQi?jnK9E5-4);urb>7?!3&OrP=Rl!;i9;D4Ql@Yx#u_Ha#>N1l(Rw|& zv@cNo>ZBH!0qfMPVg5M`3Prt}rL$rY?(cikc9PF6fyJ%vU9udGn2t*=~O8 z@->$XL|PeVG;rz4E?u#>d9t40zW-02ESlWRA28)yX4`88c5zX?PLrL_ts^J(INAOC zFSjzq{BVkTr-#yIHNo2vaCqZY?i#7p$5w630#VnlL}-fx;dIOOF)G$4{WeX01Eln7 zHwnA-NxLCL*6H0`3DL1?0)#vjhkXo>o-Cs_gd;075q`$>!^)N&HHqG`0 z8dKCJh!9!rT?V!mX#1NEsnumGW3#TGxb?Jq_P6ff;VZEU+DthHc$}{rYqJDe?RW=%j$RQ@*iE3*!-0iY@J?q?#-N=OD#6jq1=t;D4R4lMg68H9%`vy*?eXO<-`RG Uw(-w01=cMLp00i_>zopr0P=w+CjbBd literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/audio_spectrum-light.png b/testdata/snapshots/darwin/audio_spectrum-light.png new file mode 100644 index 0000000000000000000000000000000000000000..0d0b85256cea62a24d94ecb78bdc2e83f90b8b2b GIT binary patch literal 1673 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 SVEFzU!DV4;Wl{n5jQtZF&k(5q literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/audio_waveform-light.png b/testdata/snapshots/darwin/audio_waveform-light.png new file mode 100644 index 0000000000000000000000000000000000000000..0440cbf51ff5dbd8d5ab6c1be447f5b95397b10e GIT binary patch literal 3221 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/camera_preview-dark.png b/testdata/snapshots/darwin/camera_preview-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e9d7a4acb42015f91088231dfef9f4052a8ac302 GIT binary patch literal 5170 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/camera_preview-light.png b/testdata/snapshots/darwin/camera_preview-light.png new file mode 100644 index 0000000000000000000000000000000000000000..142ea8ceb8be3dc7d8bb5a1e9f4bd2462bb1cf22 GIT binary patch literal 5182 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 HPhR{7Bc}Nq literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/device_selector-dark.png b/testdata/snapshots/darwin/device_selector-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..645f377a60c20184a6683d6694236454790f3fc2 GIT binary patch literal 12843 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 O8#yUu$#T%A!2bYlK$SHB literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/device_selector-light.png b/testdata/snapshots/darwin/device_selector-light.png new file mode 100644 index 0000000000000000000000000000000000000000..a9d6d441a5430fa089517aa5509ec15c3db5f112 GIT binary patch literal 12358 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/image_annotator-dark.png b/testdata/snapshots/darwin/image_annotator-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..70d803d6585645d445de0b12c3db2047c18deab8 GIT binary patch literal 8632 zcmdUV2T;?&x-TdQ5&;3}y$DF}y+{Y?h(Kr&i4^G|y+oQQ7>FRfiS!Pk3JL&d*#HX* z`v>Or5dQ{-Y3iMD$HHQUXecWf`lPK-0rVal)8K7;c#yrvd8FurqX3IwiZsX|z+u!g zb3jF!It?_G9yryM)k;@6)zRlq0c~w-J*A{JyeglM@D9e08(Gz`MBezMuZ%+y2CJ7@ zjK_P8vr{iF&748y%gJ>6QPV|oB{~S%kDIrc*!C5fArSZl2meO!L#Eo>H(jvcuXvaM zT{r%}dBl*xwY~`U;GCSOhTS^4En{Kvo2~8M&BLV22o*9c! z6k`!7$f{gwjelYON^6@6sv7y}LtSIv#Sm*A9`C>jg+kOEeN4QC6rY9c~)ylfRc372L7-FtRh zMW1aFnf41Ui9Pm54Iz~H&Dq)nCB_H3m*N@P%|_-gbQ`R7gP+vr{kNz^Y<^OVn#&yg z^P^KKLGl*z)k9SVc8#5*O|4jEd}_`|>~XZ51VK0HR>ZMjH6-vdxeT}RavphDF|3u% zt26z3KU=p-w?-Kso%zEe=l#`Bq^=Zc=|27Aw7|TfoNu_5!GLG?ZTaFEC5m9RvkaX* zd$SK25~I*syqr(h-Y*={XcP0jb8A%$xj1bx`Xa7=zJo4*fok{pSv$8oWnm$aANBLW z2Mc^=hEb>Y0`Gr%yEtrpi@4gH(p+Hj!R2Vv2l75#qcgTD9Ym{RLjDG{)Pj#C_=5^Q zgQ^Ei0xS*npU32fi8}?fhi*lqH`!0NNxn#SxOw>BV6K*Ct#ToD^0iiKw%ElMgO
  • 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/image_annotator-light.png b/testdata/snapshots/darwin/image_annotator-light.png new file mode 100644 index 0000000000000000000000000000000000000000..001ebe888752a803a5cb0b6ddeada45d5e41e4ab GIT binary patch literal 8595 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 Tv|;{ifu*dVE?*)0+W+4G<6s7` literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/image_compare-dark.png b/testdata/snapshots/darwin/image_compare-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a1d42b682cbe2e422a1fc01ff95855a37b9eea56 GIT binary patch literal 3694 zcmd^Cdo&YnAD?La^xHB?#Y8KYP%eeJCSr-CNU28sEacL1TN6e^q~?-BE{UIP=04#^ zZq3YcS#zChW-&CBOBk(p{my&N`_KEwd*1W@(K+uq&v~BDdCvFwobU7be81;8PrQRY z0wgIX2><{ zK}z5_(aA{oFR*AM{Nq>?R;-3ukiM8!M{I3`G!s^NGd|5qNg6NlO<_OJ0LHqcBwcx2 z+Zd;76bU--pF3$zz5ghG%^5nwF;^7l!#~jOn}JvSa}!0w|Kly~5Jd#Sf8(CZ+?$j- zz6YM`QW`2Hp$TMN=3~`qi9smC4}*pwb3FDZ$KB5^8fD|#qYBQ7X~{_LmfRRkav{6D ze4j#q50l^*%AWNNn9gB*q#(4fdM{qv4xYKZZN+~DYHt?YRBTbmu|tDkl^vSd&;0P}P*>)LZ|K!Y&WU6?icVJ*I!+OroY5AnKUC1o2nuO6%DG6aFs^xz>T#zr z2*(0(`r!c~AyXHF^+y^OVcP7`b4uDt>4YmyxIhbwyy)n#?|ilDiX#DkH9URlZfEEF zN8Y0;fVSbn!qNSfV+x-z?r*K-q*3S-ADB$`({sDN5mVx3xa7i3K9)STc^r9wog)zp1oF#V$azIY)&+bGh_bSRY2fr$XNDt%l7>PN))vML z!q&?bj8`H~XV}e6=@r%0sp#vw!XW68>g2eO4V!^JpK&8K5!Y|sqRWcqk}qGrY?s4} zVAuDhrY498%%tOM>!w@oiz4D@+L^tcjPIKcOEniS>bbZWG;VdAgz;C)oS!HhyZ2Ik zmcRP8pY-l64X*gPQQH1IJyxL-|FWfOvpkTL98r|T1~Sy`i& z5AnO0xhLO80-9=TO^L*Pr-G)=c(}UaF0-`y=8BQ^(s6`pXY$PsG;e-Bj!;+U@p)ix z4mUblwBSOfX=o#%q=Zyj8jl}05$eX{$@`uK7{wHFHNXM&sXK^rXOZhmF}OyKR#@7L z=dV;?!5;QF+yRJQ-wDmbhiz?})RW^>-x^JS7De>*^v~|gaBq9Ol%YL6Wp}1!vY3-PAGE?3=H%S3><3u5m>Eg)0#5i z#$9cinwv|>!S$!pn}RdrMz0~`2zhxa+!dF*2A5#NWUiyW2q?hW!*gtg_X;Kzn=bu{ zvAp#@&ViHPJE0W5+M-`gb(uXRBT12E?6_)@_`BM(k`nsVR6~&)<$Y|5U zRRq#MXt2JHwJp&1-CUCp<8yy&yJvWNaQw%X0rI7CkM|qjJyCE$BD|<_IE{qQkwzq# z9YYa9ITRIyDpmf#u{br`@Ua0*bCF)hYrD+U%%?R*>em*oH(>>5>`5P83a5gO)IC1F zI@dmh?#@Ogxvkry*2AN{;CODxIJ}S_APcc-tXY~Er0;TDM#@3tDh@3gE6VqTWs>uQwy@f- zB_HnQo#6Mjfs&cV`oexhUF$XpSgG=76q3eV<6|zcTiY02l0Iqr``n=iLGl>M@SRA}Ul9s+T&v+-t=%4Ju$SCjfXa(v#fW~O~x=yrC`Akm8 z_6{5J562{l?Dx<8+V=O0?rI@eK+EUURWR4s%yZTq@(k!3AHX?Y+h z!VuoYhUar@21)fJ*%-}{Z*vRZHs*t~n76=*0p+-DLLztc#JYwt;pCG_??_G|d)@LU ze(xQbFAm2Hp9Fc!ToQ$TZ?2$%y&du!2HiCtt`Z2F+HG-~PAdnC2Uu<-q^ieU>F0 zS8 zrvKHl2yH0kwnDq`2L+H=uEFQe%c_DWmHvMQz5y-=;OQvZ1%0iX`Kf}9mHnwwi|bMU E0C%K*Bme*a literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/image_compare-light.png b/testdata/snapshots/darwin/image_compare-light.png new file mode 100644 index 0000000000000000000000000000000000000000..53eb4124d4c096d4ee67b3185e331319e6c99bf0 GIT binary patch literal 3606 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 JE5GIv{Wlr*S6cu8 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/image_cropper-dark.png b/testdata/snapshots/darwin/image_cropper-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e7e23fe166a325cfc54c24d5443fe76337c8c741 GIT binary patch literal 5969 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/image_cropper-light.png b/testdata/snapshots/darwin/image_cropper-light.png new file mode 100644 index 0000000000000000000000000000000000000000..d4273bb7a986ef2f7452b845768ed24b4e591920 GIT binary patch literal 5790 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/image_thumbnail-dark.png b/testdata/snapshots/darwin/image_thumbnail-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a4d396f1f23907c7b62787b58b28c54498419730 GIT binary patch literal 4517 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 Gh5sL#t@0ZH literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/lightbox-dark.png b/testdata/snapshots/darwin/lightbox-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..05e90336336b9807ad788e5f97289e8e5344559f GIT binary patch literal 6197 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/lightbox-light.png b/testdata/snapshots/darwin/lightbox-light.png new file mode 100644 index 0000000000000000000000000000000000000000..284b8e6e31b572bf1fad486417af99bead38ad83 GIT binary patch literal 6272 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0c478e13a2a7f5e9d651b3f4c51b2b959b80e750 GIT binary patch literal 3704 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 iQvmw^*&+Tkln!8D$N#3VTR7c0A%6$z!m4=y literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5e2c50d11328e3744303a5a32c3d2a4da950d289 GIT binary patch literal 3654 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 Y0Mf_LC^5e^x_cbEo|X~30(L**pI5-6!2kdN literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..13c007780c302b5403e5dcad506870fab7ef2f9b GIT binary patch literal 1584 zcmbuAX)qfI7>1*5>ZrSovyF<;?mCOOSEwSyEu=~*rLLxwqK>*1jV5)hGoi&$O*WL$ zy6;xQrg0xtK^&m z4cDm?9TXrl0RSM|%;>s(7-3~H(gNu%c6Ob;eQ|)>qDC=G&1k5~@+!Y9=DgQX`eN5J z9i{4D{k|ECyF&T{H>$NoFR1u0b%`aY^(yI@X<+%iRd~!D<{)1w##ou)*bi=Ghd6rn z?mAxp`qi~u7_D3C2fgrOMrS3H5FLO#W4mh1H9$|V;*144{!d=;o4W~sWo1sC1p;|j zSS-0r4j(le64mQkJ|-Bn|C&}U;Z_So*|Zs8-<#fv%mN9*wQyTPdN&f1Vbr(aWsAYd zNf42RG&TxtY$h&Xk{c!E0VqlL8J;jUNRX4sN;oa9*wYq9h`R#K@AEM%;l}>UV*TWN z1KFbupf7$^UQxH3hq|$~EV|y)>-Da_-0`N1i}{yrJ5m>uweMQjgJ^Dlm_P+Aze%2F zv9bwuZXI|JtjS<*9~a8Mr}*g_4TJSN@QEF6@Y_lH!5NhDiz zXMgg+&1U43@^i0STRTVZ_!Zn%(!?vGD_7HIfgdX>CLx`jDc>JfpzJ{6;OyG998PPG z*kSIs5|UIb)=T5?wSrkLlQ|mDJ5nGlRBG36PL@+{eWiyI`z>gU`BK(N0cB}L*PQZt zV~-IZlsR*qO9D2Rw6W0iLfEp*8Lhc0Ldo>(4Wu#cWkDRIE%#bgwK8~@1)jj3cvktC z6)I9*Q^V*h`lhV;;KRf!ozAFOQ-w?wmQyG)#mXA~>}`9TlxA5`k+c%5zHC=@!crH0 z_kwyjL}+z95gaD+_FOusa}spe0&*xjguR377$`Zg2=-8_p z@3);ncV7}dd1znj>fl{i6&lq#j4b8CUznJ1wy<_?-+E%Zk@vma4984?<~PYjg_u#s z-yl2u=}iu(=xD@Vn63SBjV3;2Axcahvy>=H+O#gzi7JD7JX*>~tj{NWTOO?n{pv7c zb&PZr@y-`9R2(sRH;bcB#Tb0>rwR5qsvyUcf;a9uWVPt{-dZ}S0lo7!)}7*jvW^1p zEa0S4d}bH^TuFdqtQS+tTUy*_7+K0KeNw~*oE(G!X2vZ3k*gosiBr(H@9e3HOF?Vs z%GZcJEr!#;-~;{pPWvO(sm~L&(@CvgDPCG0A#>u(teT|Xp-W4=At7fqZX8LahitCF z3;O^fZX7?M2T>qVKLuWN)8k5=)gn=M)2;$Y>Th{XGGBbBc$p%#aLY18K9j*#baECu z8%0kI1{Jl(f%k}?MOx%CKz;IPk4nMQC8g4x()Gt_{}}Oe=zqL~03Boa0A$kGt8ITp R;7Jnz%#5v!Dh%D?{|1Nm;?@8F literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0db46878b9d6832a2ced5249a6ca76d54c5e9470 GIT binary patch literal 1520 zcmbu9`#Tc~7{_<2m6YSMNA8z{thr1hmTP2Vqg+xlcP)09lx<-Wow*f7IP^rV5W^-L z4Kc z8D**IbgG#Jo~nQ@dem@o~WjA(_1a|P#>EVCO>uui>RSB?;AEQ7 zXVoZi)rfU@&m6|>c429(F6qwp9!;m8Z09??o6z6$-7znLU#CPlO6rV0!~`7qe<9fl zL;v2tCL{zc0y%xe@x}F3hSj?-N4V&&-m7kx0`C(H=|A{}ga$FkkAd-Eqzh}GAX`^v ztmH|dvPfUew;h^J9gnG+FU`7((DQD#d$ZP9q70ztGH(DEl7XGs!#f`o<=+x@yFnca z>drqOErq3Z{d*%;NtT%$doW9OQZUG;ZI=hvjm@=K8J#$xXa0oOEm>dPZXc~Xu5=G& zaK%;Zr{9J>ObInF?DGb{sGRp(jP1t^3=A7ULmCNx2$a#;MQ}LV>vC@&m&UE)oT;hl zXSS#1SPE>5xJ0mp1!toS`!4OUfed5&q?Xna8!v|+6;FlK#d9VDnv$AheLRpzYehCE z5C-!h662Xfh>?Fs+%r3q6zr^aU*18c57YS%af^~ko>ej|zUQZo|+pAWJQ(y-?r+mKUwg5yRkSS;Aq zvy|Q(@d8F7gba3cbhPF0c4w=+oir>SJc91n-2V0oK9v+y{@5L{xio3<_~V7@Q#f~D z;@Ztt2j>cEA2sx?68y~WGAG_NJVMXyoWJmLIK+ZhiGLQ2y<;iEOl&VKDyGv@ppZ|q zwz`DZfhuM;`94K+B)Z2_o7R}xnU$IS7QeF8`zFc+ls-&ADurTFB%6M=4d)rA8#BK{ z-EQ2rt)BBwQDkPwnb3I~m|!QDt~ZxKDsjrFisHhT0c=9JtYYi>MzK(tKU6M~Egs$x|LctrQ$K~bc%Lgndes-*5NpQ_nd(Iv8C`_; z4aBvtrqWZ`+8MG z&Tdmbvnvvdt3cr7s%q8}bOMbqEO=e{bz){&J}uL;cPGKsHS-p>lC?@J(1aVbc>pAP z86vz3<0nhYrc-eCO3P+T-_&oP4a(#sa2BZgPjS2vkI~W+h}~$6pT?NUoJFBfqYm&7XWHFy(~q_SREiZ|1>?2W_1u-lTd?vq)n%RLWodHy4LT+B^! z3XcBOG}fwGqAQ~bRuk@ZuB2P~8gizD2N>GSB%PMH+E_-krg5mKHP?7qN|l@z2k<*;6pQ{eSrW4sbA;!l>q?e zDH(AQHTR5@buTTog$?+NhWl{@&Y`J02sV;d+;_G74^tgK9*smjj!SBGKfZ%Oz>N$=O=kS7fbLfh|<=QNV(69i>;ESR}O z^Rwv#VE_0WN+2TWjhG5U6w@LdE+N<|9}cAD&=*0du~xnPc8zmi=MTpNV=PRDKFmh^ zU(yZ{t&*jAH4*XYP&7v`3Uq8mMPWw=O6Bd~xOG5&zCglbfBxIlAY(22kH|4bDCb`ozF%PgJNV70srv`4qq|X{rw+V~V@Ng_(SL5isT+5-Wk5*|Qyy9|kHjo8rbhdrK2vS)I<};E+WCQ(F z?u~NPGE01VdO508kdVMb7xlILqSW6+PRtjWbgv|6@Uoc%&dl89`u zRd6W$zpd6AN$KgyJ&rQ}rwWJyZGQSYb`7C~6J`h5~GiSV)AKwDU75b^K*C2xfxit$$y z(TCsfwSk|xYk73md+Lm_Iv0^JtvZ)ymoN=r!kqZ_=454gmYk$NHc89E%4+ZGI?N2T zwMy{x@|eD!7)dyF?4(x%Vn^b^s)t){xU+fdo^-nPeTJxllvI&l&d!&v1&F!8^wAfi z1PgP!N{vp@Cn5Ox8N)CaPr^bfB{?wEQKXkcLo1;wTfL)d!LiE|7_)%IML>0zG5}OWPgBrraj) zCGgG`)`wnS!yvAp#X$R3Bd#x|Jabv4m>9XyUqgo#A_vVl=)sNit&D&ZDy>gnVFf* zi|PGhm$>^yI7wXe^mZ69Uly0q?*ac~1|Q-2%sLOEH|?r?{OVdicuczbF`vjYPZ4OZ zc--C-iUzzE@d~_4^e!Rf?&CV>>-*^H>fS8sg9_ux5Zt&9NZ6kfA>U8B1q)vDpHgc> zR&vr1h%gxf}*XHwi8N{V6|i`=jKw|RL9ijnCY9uZkwU3;@BG8a@1tO za|6Tn?UnAJB;gVZ9RWNn`gY5xSYd8yjvq5~?hDH=>4(1*b#^xC-D&IU>YMZ{g&Orypn9~UevQj<@u5yUQ?+6A7XRuT#d zoKaAck6;pZ6Qe3CHv$Fkj|tOs`!@{OG&}2@9|q*rJBeFhmOWszp31rKg;H4h(jAS2)7OXf)^Y%4L06EzrxZu_lJRh~A;Kp# zW?M&Sdw=`piN0sVBDE!Wpc%!Zi-m%+`~EOI(TS?C*{0fKW9`^`HM55n!?ER+Hj&LLu;t$<{n?DxD5&a9f;1!vR2WP8R5jWXuYwv%jQJ-LJ+Go5 zNlsymNR(>JWK!5*{|9ABVd2VZ>Cbyu@Qu+V2?QDVmg0G}Kd=Ry|K*x{m zk=m~0wu%majq=rh_3`v8YLQr5`^>4-fzUCz<3JmMcRk}t9O3pi=J28>0R@fl@<(7T zX5xvm<4?nVVSlJ#COxJhyDT?Jg)FVvh~FfLDPwWcl+7Xi@fyUS+e`X^og*bR&&d_$ zPF@A&_NFhjKXNDRCxJri?^Nj(63iV-)@z@WMxWLI{!#G{6W8%0-~(T#h1q#Z8A!86 zi=joYGPftsEbd!P!2v_2=VgJsot)VLF8BaQh(7tUzG1+RtiiC*%&^wX z08#dQ%g~el{o+o8+KTi7jtUz{(Sa~deRb=+lP7fY=3+K?X`Hn~D(LI@Togvilt}59 zuDyBD-Di`QTvSxvky%wNd+C0QLPvCJQ%=gxIjhuIHYLiE`jP(_kaW_6QGQb6$el~H zx+8)P7&ng?63RmT_g;hm|vgW)BtwN^e?-CL){?+HQWc28@+1`qn3f+l1O4!qn zUHtq!X8Td(7cbss`*0#aX199yIrEU*wh0K8OU4{bk?CD)VkIGFZiJY3Gi ziNozF+8qx(!duJ^xThKE7M@8nbXbO_YY1A)IV7<#9>f{ng{F)c)$caNO#DmR8d0O}hN zgFcT)S!sB*v^~b$fu}8mh^C1#oQU^s_P2u#Z8z5!sAN-xhNgyOXEz*Z|L#ZYwtBZ( zX)qFRm3WtbZ`(IN2M;WxC@oC`lk53-mNggW zJF{xL*P>>r5<+1fXcIxX((TsDwZ-YOt0mc)#KbE5Qx;nd>S{|Y1RwFtinEztG_>{0 zX3VX5IHTnmHSIQLlK{r7QC?}zpC;DiG;Pb>-hT+cdmyq+>9;drhaXIZr;O|k%}p7d z!y+MBhQStZw3cv9AETpq2T!-%2X%C;e6DaA~-WW=+jt#!lAMFy6T(H-<12vh~=z%~<6tj4A4IY@?U`8dR6&V5*6NlBmu^y#UjRxn)FL`L*WH{3_ccyVwAtC1 z$WWrkW0}#=xN4$U4Q64=z%`zlpV$o#Fi;U#)r@lp9FJv_bV4C-FS>~pvw`&;?>Q6_~m4CC5RZd+P}5Yrx-CR zDJh9D^G93ycaT&Q*_`M|K%H2E%L>W^MsgJKxlErD5k=kvA^P`H@nfB@Sg>r+NS0et zR)CMmQaKjemIaN8LA{uEvD@gWxvR_Tdk#u8=&;dJx3*SxN@fd8|Hvidr?mHW2JbKY z{agcnSspII8su61Ez<@@k)JS0TIhIQn&)64xM^M6^|$ndeSk zM5&lq#^Fz$i%sC^o3`gB8e0W1B>#GfgHDpB+_vx5>djRYL+@w#GPGrHiX9=xy$2mm^DstF_K|qu2az%y=h3QU8r(<01S56t?UBL#pVGEC<}I- z3KC{~nh}P^3m{W{38a36HR5uXB*$!eu~~Ujx7tj7biBu) zfHHcXO?uX>$Rri$+0A$NBt`!Bw1%Pg=CHc-<7<7c<1=-iofxhNe#z!8UyacMg!QDw zr-57mVbD032DeQaxa@VrzldC<9cZ!mHI3t^>FE}`RmVVqN)xmG$hkSQjpa`aY&Zb7 z_l{$kcsoDm#%O@Xs@nU?QN2dEyU(nsqHZXgo1-b5R5d}=_R}i#5)%&B{0G2C`IN{r z#VeG8_>rpfr>Dr^s>p)OvisBCEqmm=p)PD9TRV%1gs@6noC4F=A`xYz@Op0@yxGCT#di5y*43RSU?g&L+Mx1$#A@TeexwY}Wy;#}JN5 z97k7H_CJ%yiYGogTpp^>&DPdcll~=Nr=xFi7qatII$T`|CyXHlg{MYYHl)wa?Y?p+ zA_{;Gl|^H(VDEu0BvcrR_TOs6^wzQR2WdGhWsxJ3*i5U^o6uv)5K!?>wmWr%j2nz~ zcxsmIx)Q&R)l*`H8Mk{2${T!>&nHaBRm?Cov!K!}L2z%!iaZ5Rzyait5qzzl7;vNG zR5f~8ikZ^58NXN(1&|RMYO`}WmZJnhbQ^1bzsmC7ARRk1n5)r0I5$3Sj$Nm1q#Q{j zKli$7-;8o?n2v=ZPac;x(2H-^H9_mwR9F8Q6XBo#7!dktWUD}>x)GTj`Knd4`u3N= z$Sjm?%oJh};!s=elMV1L9MnE!K=OalGhCsv-TUUGX4h+bK@CaIR_z!lzH6oa?Pq0c zdrBtsBJ8fZdi%?s-@eUjfk<~#eRbI7U7t>OLp=Ot`eH|;51Xr)*_#3r;QM$$JIWAR zN1NaM*?1InZui6>XYYEMDwFQZdh)=g4UhLL5{XFsVE;62dO&k4Av)?OxO+VF+vEOrMTu}{NQT34q;xi2=)~_~ITexbz$Ssg3 z*zozm0fY;cj$v(t3_8Cz2rG&Hi|)`8^Cl;2i{P*&%G_JmJlx7gKLGY7qIpvirt1vi zeED=MW}HV&@Xj`20nJV))wuTit}(}GN!ZB8r5^y{$~y|>j}Hx(s-J>hPHHjj;Ax1R z%II~ez5%+c9^C+d{8QqZP>ArNfbNboP%ucMP-$EmZ0EI3u@4?i?qYm?i+1Z>@3@KO zZaLcQ&K#}f^)1sV6Y%&8$0TPAwkvF_i{ZHT$6Cxj7J3*`{;5w zWGB>SsBNnEhMNk286HZ6 zEl5#zx!q_pDH8C##-P*0Q;Nepkv>UoX7lokXO*96h!0_jXy@f_oF6G)GvDxb-P znLvDVsIA|5Oa6VHYKxnmCseDGN|awWqa0f zJnE{Aw}||oK!fE1D5TY~Eks&*d1ZfnW&b=CY9*-E0cAz-6SH?nZ@qeDZZh|L{|#{= zPwQ69=6rnjlMgw3{qwV!Jm7nsn=MbBb%r*l{O7HroBGv8jUE2=83np_#{u4!+T}Plx;ad6Np|dsPzMJE>~OHT9~m7oR@I3FpL*d@I3jyIlH=fxyn@ z6jwhN6r#o|QK(EGDiy}3=2!2s(=|R0T*h%f&wo#otNz>QlFD-M#&};(|mxhU{sTmh4S= zD!EFfHSY`f>mmzFV&AIcQWSe(gouBg3SvX^IUw`sdym|tMl=H5&;4&*qF0&$$5H1y8R(3(sib!fWdcCf~Z z=7PZnJ+{IqMIp2KFP=-Wrl#0^^IoT|ir`ureT_bf;r3rY3loYrD;Z>;j^~PbZ$sd0 z&?`!de8*x0aMW9O?h0|#1>bLP?wswm8seqJ1Mya7W=4$aqFxD{k!zX{ruclH5~s=D z?cC0>WS_LE&$ktPRD}@QdxVZ zsMzxfV>27B^~G%;5?d*>p7iLztVfZ4e(pQ>ziB4H^;fVEa7qiV)yu7}18G6op&=?P731|yd`k-bvW)n(+*;Js47Y=}ukWYWO5p^quCxu$VU zdEfRnh+_rs;9)te)wNaK&bId+nTKd-sDVkPJ4Xi}30M9(U-<2>HS4?Ep9<@=JtQTC zB(Xi8PlJP$DaPU}M;RxKfQ*EIJew)3Ub1lRT}=XU5{LkPQ7P?-E_G$Hcgqf5EPuT1 zp(e8fDWV{_A`b%*hF+H15ctH#NsjQ%WCR;Qg5th>9sz}$bX4CiSj8fzfazVH(2rZ` zDsX|G)3+YC?O3-mKZAN8s;9D5f;!IC_OXAgZ(>&vTkXA2rOdYjJA zk_`;r5)s%Eu}*GCk7OHL9i12;Xj=;9U(+j6i28Cn?!Yac4Mp7c0T%Z5C5+|9&S+Un zP0L;txhh}v6ArGPy6RFUskw^W_)lrLKn-PeFP2mA_bNBN6EJIdy~V4L+qsaQ>eBSR z)^v`DSIkGxL;(^Lu=&OMOK;m_uu;#4kl9*DeT}(&MT%XR`LvSUXOPHW6{hRT)m5FZ z>dA77U6u4ceGS2VQzbc4E2qk>+qScw|SYNQ+Kdv89_i#{5MVU04e$cl(F?$%#xYfVs)?}0%(ozEmwm;^dDDz zKBc7*rlrYe1>^+%i@cSE!^x!G?Z09)jiZ;-r_e*adJls!4asU78`+B(zfK>)X>3=+ z|2r~;oM2!pJ-K^KTi^~&WmwtFQ3({POmuy`K}NjTN!FyMnIyp?QQ4e-S@qVBK4I=$ zr%H0w)@BvZ(=WCBn{}~FwKndb&STF8Z@VA$b0P!caZiJ$&{Q7)Di(N6?X2v;KRS2k zns#d-1v#zzH%m6Q(XE}MkiLI_sg(w!lN)g`%CtVi2e@W|X8;%Qmsw6Oe=3{k&HG6v zqMGVb>yX!Q21?Peu)$T=jZx@~T2ls|r12;HplK_LFszr6I9p zq(O+Z9kRn6qe^GOH0)m}=+{UcnE z5q>m?3Z`Rps?@PA(BHqKSp=-mMd36*u&W*8sj5OiC~MHWNZ7bheH&77kFe|s`HjE8 z5F}3o0#JR&6h_Q}QGxu`J zVvywG&LEEpFbuS?oc(|kEm;OuizYTUwKAIkHwyTb&(_zo(h~-&Dd!Re9(BjOylkV){7!V|z3W@T#obpX>?|!UP33h_smPmEeHfko1M8nq z{F+I06IDa!lz!+4v@mnF?p?T%^S}Tap7#qFvU9O%2Kh-A`3MRUrmJr_nsV)3B9 zx_>r#Z<5*-^KNe(8ah5Sr%%HOog;3u@R~e@x)PC&_#pFzj24b^0Kc~YUqD_;>baW> zF*(_|bGh|i(iBw`cyY>t+xC|rwzHa_pZaX0rX)WQr-iquBfst-qpUZjxtW`a_~~+c zzzPoT>0R%A=E?+_tVP>ohYSHJASZ_$ z6$wh|4vH#T^3`*5@p6aA?M(3DIoR1_fra<=B6q#`Ey`T(@$4_ZpFl z3y)46`agCOijv43b1og0$vg1c-D6DK#Xpz_7o)AKjZg6DhvF^d&0)+b?h|FSELE0I z%Kyv9b8^&cfr&}sz0S_+s>DfRz+_@#{2VnKlBUX4TF$8S@OSz--dI1jM9N=2)|r&z zLm!)g|KE8*Cw2-NaK}UrY>$`g)!J0+Twfx6?4zSED=j09x9PY!_0QkQAolj@@bT#g z7FE``%SKj~si1i2{tOa)eQ-!Pv zAPik*WOx$|xt&MNEiQgeho=e`PfO9<-QKRZwSie#l(ptGJGxR#4z1#IJW=xzo3nmT z4UKp8^i&W>SRbMHzOERUsX;WG(l40#C5y}au=U*G%$rGs^dF}ZLmjPU9i;iYt?=SK zxH!mayHgCl&T(crS}y+EG|&G_zt-KqYtt`o!be?4$AjhQ;W<)pE!|yOOpM~|B%w5U zu97>40GsdgThj*W*vLpzW37GWA30R0e|zjGIlKvSojhF#{Mm?EU5hBf7?&P|^^kQI_%F^6iq0L+0;B;}Z)XYm`k@!l*cHy4%Y~@*ht;nU#)mEqF zn5|Yj>y_)kwI^dm@cGWVCCfopO3F)n`57Viyb$Vq)@Fegn zYH^#IzwE!ZD;^c$$H!Ia$eR6d86mQu#i|%{bL)-9d%{{Lvf0I1%n0#sH9xHnLn*(7 zkDeCQl~Wx38gtre$|G<>q4cpBiv`{(Xk?eVLKv3%Uq8UCOxtT>n=4!E*nj$3#Y~hWDSNv$X1SfT+3ZybzM?C>p6EHQzH0ePj*BnCOI@JS5pF z!?UwIg6;qH0k4>u=~J|+kr5MS_@_^4&uTz6_IWN&PD>jbv%2gc&Hw{VMpVSB%q%Ii$PD5WmXB7pMkJ3sg_XJIB!?30 z#;dy~x#VWPZSJR%oGT&w<7tluN`J!rUZ2>7rF14TwxVvCfOH*+Sv}vJxb^i@X4$Tg z;NQw_kEG~-^J#Aq3F@dg*co)6*fQN4*$AwZz{N{bGCac1$aC^zh#>ItY6TQW+iHnoq(_vNQMoe%G>7!||ZP zp6dhNaW@gvkReX!->CTqQ?jr1Rv&CKu;M7w1|YA_0BD&H~tM=OCw?H5G_mq>9 zP%!IC(qFFI+Z+Bh>SlTTs^a2wLoHF)0O4bDq3ollC(i?>0*T6xVo2fQdCd<3(z#MD zC`0k8KSWgp)_L^TZBDNWKCHAnl2&TfUaU8@wX6aBHgBRB96NhzF@Ys{yDWiMdua(B zr|`&|ki;khknZ&O>}~8oI7d^Fmv{AgBg1Lyid27Hul981$~l|cs^~tpcoNuT$iSIC zu>bHp`9r$!TU>K)Nw7Ei4J$`R&dXC9g^o_zXocJSzWW>5#tM1RRU`;f${B}zueL@3 zLYsA6#c={;(`A(&c9YZypK0DJsWW4Yeujj+2sA>H9y7m0bHlumv~_jL^{(EcPkuMy z*g5W&y!Ya|xYoK-l3*hRlZ*Ic$l?uHuP;oz`k6m#7V)O2RnQwLm*}aX(f|t~c{(gc z5d=6_;S%o78I+F~ZT1pxR(iUdS~@-W=O@At?OBpZ@##{C1^WEn#&#Q?92}irK3pD2 z)Hm!m-P_N6yq5_2;orHD7FHNl`b=AbN8;zj!9FgPZZOe2vU80g%H?zhk)4H$t)g-Z zH6)&n8;V}NoJc{bli{9>B=$gHK~+kZ?{*tU4BBVua&o`~2TX%d0LPn_ktb73%QQ19 z(Gz?8iAlc$;-BI-zDB!}#)X<;U;1fZ@s6B`~}^7(OVDg+T9__5jdcISjnMoJ+ukuB!MnU~+% zXuNd+8>uiq!R7E!5&D`eS(;MuS=OK}Zer(B$~`KObLz zD+H1_&vyM=s073hF~W{`8ItmaB|S+%!-Y|Cv-tQfw3YPboR%bdf)Qf0kOl`DX^IWP z#r0+CSz%R`uKx95@myD{JU%J@AI0JsSL<*=Z+`6#C@K-M>24V0Y#L z^0nXlhSU=yRFvsJ5+y*K!aTMAmpXeG5(F5J>}=2N3NsIgqpPd@3XdkHcedBu3ylYw ziiB5}6N{&=0*vo{hO|`gsi_@W;`5DFzXkkm>K(NwA6@gE|3mywl87Ls0L;W37%HI1 h|Gnm@8ITkjfH23@v_d&r4{;TMjD&)CrRdjy{|5DW{0IO5 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/playlist-light.png b/testdata/snapshots/darwin/playlist-light.png new file mode 100644 index 0000000000000000000000000000000000000000..7fc92aaeb682e518db504f9c22b10c76e1123d0e GIT binary patch literal 12010 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..925cdf88f0e8a30cd245b2d57ff82c9b936f2b64 GIT binary patch literal 10125 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 T=CbaCH-wUrP!z8fH4gbN5jgES literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/subtitle_editor-dark.png b/testdata/snapshots/darwin/subtitle_editor-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..00ed59948c1454464dc50e119096c45ef261310d GIT binary patch literal 20314 zcmdSBWmFzbv?luE5Znpw1c%@r+}+(Ff#B{0cLD@=cXxMpcMtCFaQi!R=E$0vduH7= zKkiibkFM%!sa@OZ*;N&)ASaIa3HK8S1VWUQ5K#hwzjy@2c>8cj)7NbGiR_O zGRxa(Q~ViU&)#bU#)i9tkxVjn&PwtTWB`J|=>kDB`GO(*-+6hCyT$7HedcckosGC= zO1~X`2GBA^Rn<36!bSat((!}KpE#hz#@y)DyN6zW-rwZKvthwcCXSopzLPC}6EhJV zxA}}-N79=<`8F7$t-bGpDSsRt)rUz)o#Syv-W zjxDFXaGTfbo~0se34(YtW%b*=Z?r?U}&|NVpBtshJLOsCM1PXwm($!EvdB4XiXK5gFx*lF`eu;(Y1qz z%fT^+iDa&uQ>INW^NBPt5QHyS7r<>yARG_^2>O4KwRHj4*uLSyU;Q(SmlW$tg44%hWhMLGx2H9~b(rW84FCV|t@kAmI~)7q zqPoXJH(By>Gc1Xh?tOw&MZo~HB%{8vCaCKIqN`m+H5tL(TaZK$^3vq%y^Tj4A#m4=$%SKQUgm5=#szXd%r6)n-R3TA z(2$X(_MRWqxi)N87G7`IQa@1TP3HzPI;5P`t}>SI3jAtmSs^5P*lOKYuUs$i%*r4y z71WQaA}lG-A1D#QAZ6W+8;BSW4sghg#*;23`&TLb9h`lq((o( zz{Fs;z9}x-sgHl@mDbdxzKqBW4JAQ4(HrWOwdN`AYsBS!9L)`Op{9+KrwkCvK8S*F z@jI8!kSWtWYjYgd8IB9>>DlR*R>#J|a;ZM$Stx@o(Rkt7+rz9uKY)TaqtoO~)$%Ci^YEdqEOM-Be0zAJB_iq? z5PO~&#fF4@PkwEghkK4hxD7&hQc{-AMF$no()!%ZXO*4m96>5fqQ(hcl$OUok|t{_ zh&)N`VqsjBJc&WOqp=IaH%ZeQsA3kJgyloKr^U{EMZDYi)5>$VyAn@H8C3YjxB>U+ z&3bR=3dz_^F4OVunE*@LqcPg!(SL}={KJ6haD#_EOeClNgtx?XUT>A7rL{aX(jy~N z-@uB`HFP%ZFe%^-CJGVPR(0-I6S=W>f#=zGAp!oeQ= z{kcGFY-|*Y)Z`GZ8DGH2UG;or=6xTW@x_M6614B6Zj(FXUqfp>{=GwAfJ+Dp=@@7$ zf07{1p<|$D29S&meY+Sc28}F=40^c)27Hjgk8dKs;Kl~I+h9`eE~1+Y5%K1DuG0;M z?#9OI5%3ZIPRQ6hb115-^Q_6`6Ow3+UI4gWO&I#OzA?cqly>3fu<`++7;j{x>~sGE zXWTH3u_YOG)b67*b(>+ft;FFJ{0s5V; z?}uf{aTM$89F^$=7ffPlQDvRDjyEfs4i4y)adx}MpXH=df`^ycEDd^?qB4pGCTt z?H~X1)RXe z2+RdoXY_+_t^Kw0Dc>zASD?LPEi2o+s!Ec4?hd)S1!3j$Q@Dwj7n0By{_Xxe>Tr&q zw?eR=L-^z~2SVYqspO6O<#g?neEF$37zH>U5aTh(?36qXWtQ13=7q~o?B3m0`G;+O zp?qawJI>8dPDzop>UcK_L&4?$IW*rX2eZ!KxoRGsolPhtQLwXCVK$i(n{-QaL0=H=V`?PR*xCoCTlHT(ZdEm*J}p4 zH`F90LwgPH)J{bAze$J&4sTZ_Aq3^*byvoI;1R_6qJ)LYBEUs{ zgSA?2bD!y}zPe%*y7(|Qvv6Fpp$amzuqeSt3a=<5r{=dgSMipW)p*S9Sn=}K`rr#P zRMC})TY-umoSik5cAxX!XmO4xm$kKBO3!*>?=4^9AHZVqcWNznuxmK9v6$X;Ob=(- zizD&sV)*Vyd>kNTQT~!o>ur*hssD{j!P|SgD5f_Ffx9wga&N)smuh0}EGJ&3zOtTT zaj7)7larH@GE32niQUZbbZDgPv7;t4l2$q9-ihR~dm7$cY2rwR?l~s|_nD7i*wtT+ z3`KUs?Pv^)zXYdNV%xgzI}X-@B^IOQjc0O1j?%v_xapdj#wR2MUHm#LpbQ!wF2AQD3)tC- zRV!Nw+2wQ-R8&Y}de;~1HhchF6xemRPl%s7h}<)E02QEd%o^MqW9>fBFW_RgE`$;= zTJjoCi{*7C`jMBXu99=hkQ6Dlk(`>kzrRPsZ!boS62KRta*`j0VsVC}J%+GwRzPh1 zE`{}l2=wR$dQ?W&_e{^9ji-j;g2W!Q6SN}OBQ8&oH&ZU4m4;?A#t~CjE5S-UlTVC57h&uW?); zqME>N?|2h12F{q6s++AS=!vWbzJal5<1=f2h(lC2xkA^ATx{6r=@BX8x_9TJ-3-4T zKD(bl-r{dR^i{vVX z-i1ZU{dSbi} zioH0_srNB6eU_yFP(>ptbx0*`RW&lpTgadLm{M3WnGz!mw*~AN8@C<*3l(FuVN%s} zxOSwhoL)u0O;sbR@P~2-U^FY0XOxs?m=R_T@A8#4O2ml28Nor;%c8BU_E+!)@9t!* zE=LkR)z|yCpK)wPxY#*a{?p|$R&bEVgv0e)FEafHi?+>yNcYA#J9>0v!tK8x=x14m z3g!XDu*r~mP-6in^hNeGncpg>2_=Qu3yrIWH68yTHX|dyxR~F4j_HaGHkI88JZd+V zgEUD->|mrpkv2K9_V4m`Q#Ba`IAbRFdftsD+QH{pDp5YTng&9;dy?T7IZe&xub2mN zhpP4^IXOMawLYhp_v_nd9!Jr5-5+q4YY_UHn(?WL0%Pv5kp*8q&wtdutPDpvgEN!i zo&S`PO^pb(v_^nlp3SNpWM-e~v}1977s5uK^tyQKKOyn&42hD}X`L|k+wB32_I7Vj z(Vddjzcf5U5us&H*}N+HM?Xcr>E0f20vCUfwYoBm;c-YDcOD!v2u~g^?qJ~}lQ_(2 zgbp8+4~8dN4g+?nz|qn4Ndo}e$rl!3pJt-2p)t0r%IK65my+Pd_!ms*@HMcMr9ghA z#*Hof?jY+|LP0^$yE)_h9BhYvy-EH}58z}83z2baHAHF+|4d6yWK3dnu(z}OfmylY z_5^MRA%TJ7+DtMLO~}`4=b6~J8e?p}$MkdFMNTODQqkm3S$}ixC%8xrPM>F+&S#zH z-JlX0hvk`OuI49)eJ}C+IOiAVA;2^u{bMJ^S0V|1Sn)mfk78I0D!Pg0uQw?M2KKXo z3x;11aoqY42Vl`BQKM}m6Y*nx3Iwz%U=zv;M>F^=?S&gE@FEMEY${=bsy3sZ;UVjv z-O~WaFKna)E7~q5&N4iC{3R7K;|{+4BRchk!J;=Sx}==vL7E=qyUF5f=jX{EfG#pR zOr*$R4Xv$_h_s7 z?Rg*I(z%`SKJKQ(Tc_%r90!nOqf+?@1f!sZiU?&(%#f{2*I3P!9B5(h@}APx{3tCh zK0mIJNsEcF{kF9i#OVMZwqSL6yV7B|I(dR3L_uLHV0Ha&V-Zx=pZp2w>0{othjG|m zxUpP9BqNP8ii)!1_HymxjiwOsMBNZ4A?eY6^&Ubc+nqq*$p#a1KQGkSDoFe4iS1xQ zzuArjhor9e1RHQB0B?0}9xjtVoB)t6*IH6PV%!UE9-fPpJDz*zTn=knxKACOABn~S z6`Duq4MBsY-Z)o%&UKLrbk_wXnpe*j94okP>)%sgs9^D_)GaMfcpJ_fT(r;5#rX3l zhQ_W|+U`1Egk8wsV&d)ExoaQEHCA5(&ESj3Lrz(i*^R9f06~qdtu6vPu*;xMB zQ70vH@-DsfyjSb>_K~x&lK77=DYrQ`mbM@kB_Sg%EvzReXHkJQoSDJUnpanL^%`=0 zeT}#n5|wbKP~O6vL?kdVKF>LU6J*Cphfjs8#~nM)=^_+c-DidHF~C%&&Nx3ZG7uOcW;lvATBNr-KM1EN?^zX<4MJjIE6?$d~k`kHZ4u(Ap?$2H=R>A z)h8&sn)^XA2u#c@I?gByYE20KQ914#v1{U>qNpgvTuxoeWB`?u-I2mF(!3PlqO9Q# zCKJOJKT(^qOr-zDNJzmGM2U6dTXkyq4k^gWN^6JDNK0!I`>Hq36!4-ORqbY$Wutz$ z$^~a(=|aT2a!JxNKxs)t@kbQo|BV=>2#=zuJifS+aQN}dcY6$0oPW|%q49q?ApL(I zuKu^X33i}xTYNJIFUHztBOJ;eyAYPXr<)L#L*ddhmd_wMbPz*j(TwR`_QJPxj37gI zy0e-v8ft;Q&pT`Ay9m!y77sGemW%wsp`1qg1uI@w`2{_{pQUg$f@K1BO)*vN7?nvT zP!1-EC$f;sohMDIoX;GR;Bg_re|@VdBjmF^;IA3@86pTl%{L8Qb|ck`+E9K?JR%~-D+mSAJ@jch}hJi^L=ci zdu+_TYu|m?%sZ|3&W<`O2V*~%O+q~^4}1OVi{6wtwiulRSd}yHnw$7}nxjuv4qA?N z-7o$qNfrM^`={mb2LFK!|8-^pr%V4-*F{HJ9gm*_?iUK=azNDs!VsFMO3iJ+czGzU$zh`A zi)QmR4vq(|3y%#4hBY|&^8hH99zPz#?)0jl9ytw^f08iLj!8{NMFOWLKB&v6_EWf& z=1=wvI5-?MCUOIbuHU{SV8Y<T^3QH2cBZNFvNB1@ z@$Wx=^gBE=z=N`;|KJZUtE;F`Q06-{>*%a)>LHRKh)kM%UGD1x2|&q?k26Y1(_WG( zng&N_U}EkaIcmwt@u#lCfr!yU_xA_J3J&)C20w#*q9_*Hx`cE|p3n-y^gpjuU1QBWBYndAsq&{7jTUmj8hbMNg z@LAnt?C;Z<>pQBc8K^1leqb>KLB3wVX~YU$>2H^p7e^6-e#fMHkiH601^XZPfJ;b( zWO6wv$jfWnLY|BA>0#HQPkJIsOV^M+fIe1`Z*%k2F#1bv%?-9k<-l#5-CH9&d%|wIS*-?L+mxk- zb}}B`k&h7g!u<5DCcD7vSp2KsyY-%c*NO=(?M?aWlm~>M?9eH>mx&#y1e{LbR2d^%C5)3xv)C+;4;fPbMNi#Fobmb0XT@9f|{q{t!vL7 z0-WQeOha3Jvx)h8eb^^%#p*GSjF^~>bH3nos&TkwFNyeq)00|IC0+=FrKM%FYmB*+ z#Q<+*eCq?gn!@kZ7Sr-WJw#oAP}kQrAm8%Ud&V2xa4+YNSNJ&xWIig&>^4iQeiZy) z)%Ob^5V|qM=Ofg^XCd%khKA3qWC!V|JUFpoVX>57$fiY^okcSb=VVK*A?Jd=SbVlQU;hC(!~p7Fi5oUtghV7Gc$@BJ#R! z`rIKa@6Bvi?>HAN?ZsiWEq`Vw*9@;6@}2!MP$D>ih_cTEkl{1+oBQBC>Q(%g7A z7S6I&RoS2d`xCIt&aLI_?a7kI_BY%E7rAelzVXoxje>!UGRfJPGj~_xDJjM0+Nz=hi){{M%}OGS2G}L z>f@Df0+zJU@ib3B$Mg{$*gL>q%}&=@n*Ywt<@@Ek*5v$bCNdYgISCG$n^XDV?AYv5 zXe=l=cYNH_MpXm>Z?G`%y*Qj!BS&NAq9CI?b&bX=7D)6Z9@+`iA5Y>J)zqvmXn0?_ z3voE8%*hqzT1FIn$HJl%0Vm-p+$zkBR#BY?fz+Pqot^AdRQ|}z4S%;VN=duPE+i5dizUXPTc^6gqsgq~d64sqHM+QBZ&j+WH|D^)eS| z>9d3cnnII6L!X5T1nqCmqH)e}SUr8_fdLt0Rpu5E>#N)j5Rm1R6%=TxI?>%OBCJU_Su`c@|_>;?~_oN<*PWe=rkZVPfK!2k6Np{yj~_qh4`4 z=Z1t}%oBpQ=E?e!Mb;*-KnmqNQ&~Cua$iw zK0Yy^_Cv=(Kgi&qf8;73=X$9hKZM`6YYpORw64M6% z*K5JPvzPnaa_65fV4y0KEBwtpcvsK&@{lhO5b2EOKH1rVgOufS^Q!%QeURhe6ryg3 zPAVJPRLtuWS7`uF;sWSH8r5rsC1)%8$kfK1u<+5f^)zs`mrp4-x4Ip^y?YyBAv3eH zgk+EXqAKC~4+bJzQbYiDYApQL70=IClLf^~46l*&*F@aii!HZ0^mAs~0@;bNkgn~? z;=;l%xPta7(?oX^jpPq2%gWLdcZkxK6pM#hKx*~cJo76m!mxz|W@eNG&Q^wY?LIi& z2ovuV)pT|1i;6G=P_1w=ONu`oW-aP%*JKC$I?)u=LlQ(051x)TMvp+aOQ5?}$)~Q| zL;=5B)13tQHa-5Kj-yIR{X9H6iUvF64Zf?0CF|h-0q59$+{TvsuJ=VEAJ7(OF80d#35l-Ch07b;e!N z29_6G5}VJ%Ms7mFf_7uhWCqt56NjXfw}nZbzN)Ur;}A7g_}!Kw{{H2MeS)}^)tgdg zS3-i?icf`3$0eR0F)9ALR?6|kDR~Oax5Hn%2aGzs`%=9~8AIdreRS7&i*n331K;Q7 zWujA3o&jA`S67$uG?|&m@J`vX(pc$r=_^7S8l!+q8Y4l}dba(o3vFko)DtUi_^e@FY~oY>ALYCl4{8{+ow?echaY z;~6aj^@aKJeFIWhAD(P(&wZ5ly#;LkuaoAM7KVFCrOO5{88em`iPt@akkatnh+A00 zq8p|~mICv+4*73yUl!E%SyQe@6X}yR-I%K-<B)hdwb_}e zcL_+Pmro<9=e2Sk9Ql`R$F>>a4jr zP?=I*b@YCT+qI`)bXA*6N)mdvC8U%*6DH)PZBnbj1ODVugN_ zBS9+lmVL9!1wXzO$H0pl!T~5+bK8LY;(j|K{ zOjLI4;KvV5L_}H20o{`w=Ql@AKEBod{%OOqGxEi&*#cRl6%;>W3G;&!yyDi4DNLhB z+LOSItqtzt@_Z5v)Tw!90I8j&LI!ooOHVS;mn$f$^?6rj2q3m9JJvsj{`L+24Cdj* z*4roXG1lMtwdTflnM&BhLtIz4mwXW}*qdv40dQ%{yA6{1`f?O`0S*S-(-jRCU?dYR zt(fb)kI*<*Cj;uD6HIcHlw2g8Lp*s)Psi_>)Oml4=17}dI@A+cj*Ybsu5O6hJtZ6@ z2b5sM5y0+xZlbE8e0lj2AhGmEp%nJp;B;y=02v5LX&=;j;}vAIc1H`0swgXyajEps+hBQ_Rx>LI()0^e zt#qqBbc_eQF@Q6ze$q*hqXD6h-zbfBO!PD~Ua2LUq5OE5l#-HjqA`il6(YFCHj(QH zxpd?G{rxvLo7KEV1Y1O)9t&Ij;9T}rbx@0?Cx1dyY{DM`2(7#O=xZ-H@fy?`*E z>t}IfN3t0|*A2UpH~f&&+K==(S168!5o(NVz=Hx6*?h07u0}y8b8@y9i}+n*khHZ6 z;iya{EWA!-Hw^1{cx(}RPA=9Z#2Haf5!YX}3`QD*b2X{iWz-3b3@wpfe&prnkBvn< zw>g)P=a;t|GoAKLK>&;~&ZuXgZ~K{i>jMT%-kDHfVxyrfM=C-jg~=Hh^DMuTvK%OX zy|SU2fVR=Ab(!2RI!tQLm<0oA*qYf(4ODMVn3(K?gKBE7Gx{hznp%O8ReeJPz2;v{ z6&+{BPfk%!bg3VEr;I5n8I5%fKqfj;wHN5u6`ShH=*5<-bj4}r_IN&*_v5nUbG(I@ z+3)IfIF!%ND^&9OeHmo!cNT|6zV6=maRve_$5X!u06l^8`FK2?r9G>vsiA@JxH&k3 znXzuMP1BSALsI7}WvEwdUS*}Ome#Lkn4BErw*7HHkq-4se-JSH9k-jvnW~l_tYNxB z9nx3<1K9iXWzY8=HdBiJryDa$IB-lG@eTcBJ`P8HTbZA+rQu|e1&&W6h;0f)hqa2^ zAr{@Q%Q_5 zH)=nF4Ka$#*J%8dMXV1BDoCu?j9ZVM6N(z_qI${?jA$=dlz1a(*g{ zWh~OEjxA}-3^lzE??Nb&*uv?(EjcXg+FF^C=F3n$tmmVvLwB`&Ebm&Hsi`T`g^SJn z%=ZyJSwdh*RYDvIe1FY%kX+7rsOK}7s6!g?Xpkwj*qmz=8 zTe(~>7QMq4Dz1c4$XQriMi;!6mSV^*yZ8TM{amYiS+z;9)4oq8YEa< zpx1h&2okBxeb`fQ7?t#$hI4U*b_*k#8ERB>y7K10pgEH z!9#1ReAD<i91p(@}%2JL(`P7>87n|c_ z-0SNML(4Uf6Y}1%B9FnG^Fg@?_tVqAOwJLqigudbf~fm_SnM1V-4#uFyj&E*^6JVW z_0Z4U07<)9R(p(cgMtgV+skrF#M#BLmf(89U}Yuq3(Gc#H^yjcA7=%67U*7RZvSRJ zDsG}+W2>vGGSHU(y>e|jB~VcKO@;=`cW-e-j~Xhny(#9XiRi?ANLX&ThsRX`_1z6^$upDI^ z;Pc|*C2O-o$z`w_t_36uwI-92TwSYpC~LGLE;?wuTa#}aCw=4kHF?m<@p50N$e+2n{`F+CL-&n5HVP>rqpb^Ga zck!;m)|x%_Z2~WL{QQ)V<<5p@dDS+69^^eIUQZO_waHk$bP9lnfsCZ589}SkaKU6? z!r#7q^W0Fm?S;RWcKP!{R{Tm`&rnL@jGWWcFuqvv&*8o}w7EF18T~{juZ*RsjsfW>)(?Y2xkFpPDgqfPG=zg!R!!O97LlOE z!#-=LM8uMCB>Z!XydFz5yMR1PW4g$~=MV_)m-UU6rDU0}JzrarS|cNCsoWwOl`W(%JY7kPa|;J2 zR@`5%FWm^0TQu39O76RsS$bMea)l^Yv2d}NmX#+zE8W4% zR$E#F!#BGS>cxJPFU2LyjMpF%oMra4?J>V6uhz5mI04hnyX+0%z;!m(Oy1{B3tJ-* zdvKb0xYIkmq-~he=-WP@?s~K~h@Jg)R7xzYajA-x0I*VPI5@UViTob*d6znPeLfIT z{ObMQHtqq~Isq?vMUl0Ec~esb!2K;t%M>~zDf<;0A7V0zdLK{qv_3NO==)jk7o1JW zp8IBCsN-oxs1-irk_-$+L6RG<9ZcUWsHk9>AaTGEz=A-+zT!ooMAo?*-p)Cj6@xve z1hd%ONLfYQOvn5qdzW&z^0I2?&XSszdQr;wYu-*aW|EVME@m`j&dKX_QxbaJbnWQ( z+M0YW!(B7OB$fO7eSzWmjbALdsXRPfxmS0O0>Iv#8BX{22D|H^J($kAzVB-8eKdkfaCcTNw zD=k7{8RL=G$F;F+^jtWNBkGg0bwn%p1rhXb2caQSQpkO}RJ?RPGdx!N!;HK}RI8p| z10Qt^+p2iRf&Au>KAZW*k>Bfks&O>M>-Kz+FdjQI>2zR@wxoKUh{;#~u`SsGZ)Eo^ zoq^!(Qn}Y1|C)k*rCsAd-8RFF^fa6Wr&GGIBJy zUdP%pCMJ+}b6dFS)5Qkfpj#IwGR^_mF0Vlv4DZS$L<0&8?{`F6?G%`gXM@ZC=}K%p{uvh&u5<4*wi`X0%FEa)s7U(nKLw59akZzG{0N)1r#7iJd7C~~ zGcW;(1QEicYz<;*!PAhCEcrU)Vqo57r~B81rg^kS5-TDbrM}H!{*|!O;00MUBl05fIIr$ z2|AOV=$TyDcQ63-^5T{;(Wa%j`UL^h`%8k{-o;6i*Jr`y&83IiI9+_2+h0Bv*(5*GHTAhFEk;`4!OP^()i>87K_NYt*2sr7WQEC}j2eQAhERlC5YAoFU11FFP{QDPq)&>6($ zLP%-$pZK13HxAZ#soY>6JSLhCGT^nV=11w_KJ}Zg)2L#auKl9O3W|h4 z&~(V)YiSZ2FCjAHlL09TY(Z{BL?q%xejYYf!q$04juJ(o%zFe4b+YS=wXiYz`GAa^ z+=BgxImV|wL$!%Gs=%gJb=45q(t?7YT>Uw9b-Zl!wY9bAF32i(%s^<|Ew4Jeqr+NR z*+ELmJz7UFqxB{NB}W1JGajnY)4lHq@yMr{e)D2Y{u-Ts%)Lh}r zkQx>$^f^^k7uRH8INrbrTp)Zm@w>jx%#^NSpB#TTKQkB zkcZIjd}>>{y-Y}CsXpUedTsxP12s;w_IoH*oTh&;zU#4ZhN(#&^7*Ej(N{kojFRHw z)hg<(?%q>9H4=U|tfad->Q(2XzAcMIcmhuHWbxo686_UYPn zJ2=Kaoh?1Xqp=7Qs+(fwFd6cW+q+0kcBQPKPp4(PJ0l^z48e<25w^MVe8i{&?o4Ie0A za&=^AJKMC;sinLdQ7s@Rx#ME{`}|LPTX4|+$+omle=B{ye=qW4_ja@?K`G@W3;6Q~ zgN`zZiXxvl0RM^nUh4$a?WpOG$|8%!{&;nC7%$hF0y0V6WodI~I=3y)_I0+tAC*=_ zzwzxKe}BYCF)!+9)j#n$r1BG0Z)u&u1JNwuz%xbx9eH_uF} zwUM%s4}p;6eY$P9cPCi-j5MlfB;<^Yjr3DI`_IXJkJ6~yBz!4V21cgtE!_S?e+5ly zGAfYH(aG+}d~GNh~bo0)e0ZF-;(6?OaZqj>Hm*-u)Tp1w?c zIojzlJqQN57Al=S;CB{YOiBv%p%}1U>@t1n+jCX+ifF%SI2O0KKA#ua{%bUz`W`mM z{M^D_!6#HhCr8#%TAI<(%pK7qa4JG4-{K3mgqWE9>n2y;zO%jtHnw@<$sV_J>kyRC zK4VmKK+42D@W6llPE^mzE&pN4mEGz?M@AN}ufS_l(Hlgh_Io(*WZDk;xy8fzt*nv* z1NlbA)KotIy9$hlWnW^)#uT%$OiLUk2}yQwF}sFbLT5050|QGz^1id8h2{b9{-h0C zNePXmMUfHQ;=&p>E~i<9q|R%4O-)#{!|B@_J8^)g_ibHc>iw3Wv!_r^c6SxTiytf` zxSoJSTb*@&>$ELI?l$y^TFWj!=&}`5)#d!?2J=>Dcl;+*-N+h<6n4C3Js4?e;V#KM zjL-mzpEmi^C;$A%NH1vr<%!Jqiu_^*PYod4E^Hi^fVHopq=aK#bg*-J%S3x%r}sYg zSveYig@TMMau8h=5Kb+vW4?`3oUABz+uuOVk)@z?eabmj{WwGs+$|v_5!9vnaIA24@?a6Hb1jcr_Vvi zY$q^nBsDcE&6K0PIWRuS6PeL%)56s7rW4ZNVBNvAS%;yu9m2(cGKrE>G9F{BjRFN$ zMG_9{qaBDRYrL$xczA+4BO33ySQCzRHY6E%@9v*1s*#ZXq%Do4d0NwEGPlG#$1yne z$apwI=3rynsPgW2ue=Bm5z^I#5GrbDr~p)zdbOrwCQHy@&j)RQWfC6`MB zE<|sAO+O(wQw*jm`NijUn6&sePUa`QrR|WF7WdN7%6iZGtvK`l~l zZWO`2+0#WCrHPs{OoK?Y1J+U}{4$D)8A90@iGN4}Mn*Cl8XKwBo`d!Mj6OOjIN^4# zL{tI7*hwZoJJ@tLgolsX4uWbs4w`qUjkj}Dwcj2x_*~pxqTU2odr^b{)%V+co7Y9Y zv!*VsijjPaN}usPpqN^vaP`Yr22{zu4c>@!8OqLEvC5Fk-+0!V0 zV%hbLSKh{G%<98ycQtMl7b^!z z0&sCtX=qC|=+w3vm_C4Mky%rzc9Jf!%2x)H#o5)=_kg9g92M1{q@||sOpJ|6BxX;g zUpr)F!U;-j6KQUihel3d+5Sj{E%3E^HQZAmkZm{Q1_aEz-gXbGQ@4l1Hg4Di4i3%} zS%$8x0Ei%DXLn=^x_(7O5PWcgO_^PO%F7yBu~5QG)m2e40(E>--B}EUnodjIY zD>kFg>pYEp{hLoSPV7R58jqj5la{v0I{C*&1YxI-nm^XsFAQvYO&03vsuJj&#~HtL zd=5tZn#t+5v0=T^G3A@PZJw9^gSNxTxLQkDX;$6yV1K*AHqJ!>P?_ogtDPp-CLT;w zKRit<%bxiq1IvkjS@#X!SXrGC5_yA)qutDEmuu6_h9smnBU-IYe=z^pX%b)%iC0$< z0;tUUV}=v(6U-+?of}CQK0dcw>~Y9NH7r&PiVl*~X`Y6mv<$#h8FZP2uD<@}^A2si z00f~i-P-Bzq0JLiziZop<91K{vK)(J9h-1Z)TheA?xGUw?>}p*GkSnFRWDn=j^0$( z06hZe%=LDM(dC;Mnh{|vPd2JEZ^}5$-fdgJR=0BUSXlmO;^f&0&b=}J;1H4QHeA?A zz_M#b#Q2L>*E$FddL#hh^v);4OJi}auF8?cNt~S#xXjLcgtUcWd*i%eVp^J*FpW+- z=kzbFtUUjo%8jn6Hl5VcUX8kzq;rL`hKJwSzZs8xT1uPw`1A@Pyz;ad3jayk)6?I} zYr%RO`2hfCpB|xEN=kSDY$Q#tJ2JYmL`XzKOx5X|LeU~F@`Y?E4>zaXl}i*M&-$Ao zU=5fj3%y&5DzdqO&#uf#{$%H2c$Yiuunj=@tI0X3Tuwc{A|iVf{K0Bz9=mu z1St6YrY4zNaU~@sV$@K+1&i(u)a0`4ik0bU#jD%pJzBI-a!*_|#s1PB(tx^pR*RG4 z)KqQTL73kmCLIU7(vs72D)FNunV(#gNG2KgpCU!b(M(KDfjWBiMJ6^P#3(>cOIv&O zD%1(^{KNu+PnSI&PoE@Si_Bwl6nUC;wmZpUB>u$5|FE#YdbdOiT^g6D@*C{Z>fWXI zu3+R364bDHkO)%jMYx!r^>FWRhFRmo(pBG_>U}coH>>raBY0H;3{5{rCVFr$>KGd2hV7dFF!43&D4!ta^S6BUok(~rW6qfqVvIG7;s35~aK8vU- zX?0jmW_vu$1P%s#BStl9Yz&`Qj{eux4NdwWU`FAU#}AAS81P^2v_>C6FcANeJd(lw zcQzXM_azYjwo||>GgwpHXy8F;N8CXu5DSrGz`jOsduP2yIJOCWr-1P72k%0R9M`fC zc6aaIn1Y4$Ye=#8GG;mVy%-6)Yf>3WxT!}jF z=C$X6{joI9H88gzUgRxa|GUQa`$Po5`+lg;>hAg{J(%N3Fvk4I%^>esDW6hGlV_5vrXXHBxuJUupE&>DH8BU$?3t z2@NJHIkX3oJ>!&+P}{`?+65H9w~GQ)nJ;D)xLVgI4KP}R|9@m`38C&TwilRDJAuV)sYrvdr$#HWI;Pi;q zyGp}&X(gSW724QzZTPl>c|hDlpCT^d*%EF1AKESD0CcI<8Q`RrF*b7K%1Za?w?F=0 zVi>NW)u31NX3vkuw9O6u&9(emUQkB->}cuJ?u|4z6AO!_4M{-n@Ol5!4bl@`O|JK^2L=)cLM;TUYHVQj)P%HRpnMJJ2M@cgBoql~kSYpNm7*XJ1YD#@M35@d)2mV}uT5f8drJcTNy6uu$i$z(Cq953>_TW(KgGhP_(QZ}AB$8&Lk8*IE!mb*9hITQgC{;KFX6HvTi695Uv z?c!bJ9f?zpR+{u(?oT-#m8wuE^yBpY*3x%oC23@t-M|GCsCmPB`4QwT(P^ ze)MY1o+F>aQ|oa;g(ZYkC})}hJDAZiEz38efxnpH zZ(s++8pqDue`GDz+}C$*2MnejytL>O;A&%&oP8k!X|`lla z_=yus+6rrfUFJY`lZM!GOkN zKEb%to@_;sV!KUb5SlN-+}-!GqN^&O9rg_j^xqW7W;r_){=>vC5J)tR8MikBXchBy zxNSqd)Z~_h1S@Hn3=XHmj5;OsmX5xz5evJf)ab*Yw}VhDypbi)__i!hhj!6=AP*m@wr^a34_T` z7uU#XnVe~&^;=!ti2}veUJcQz&`l>m(JnW4v>0dY#R%hoh9kxowK@OGeDM&lS6W?f zBdwUTyX%8|6C~J{2L3ajsb&6@dK>Hcsg^F?l}RkvfVft7lx83?MHHpSv5b;!vdF3I zbO(Np8pJO9^OBN$hVk2DED{;! zP%Ku@<$zi7N>0$idlZR4b62a%Y96}KHMt|1phs6fT{2S*36C9NvGG`=~gx z;xQx<;ephrkPshOD&h*t|~E_XcRqbo3Cjpv~$l_!rn z009>vwox-W!S;i$JGx|}lg2Z#ozJQhenZcK!EBNJ2gsHx(jw6tD8jJo4vjm*SfVE_ zh9h%OBpR1{@*%|rDFKN%Dj_7Ouk>LPF7y*r{%*|!fSa!uC34RU|DGq1XyfX5D0`ICMqZo0)hL0Kw#9+;6Teta=Zr!BuOeJ z$fxL%cCy@Ij;e?G(dHaQ4xWr*Yh#P)X+5PXDV7Au+MM z(UmJJC+DMzh>tTiUqu4k(^O*nO&*=6wb**edT07s?5y3VHw*YqvhjcX&B|mTLFBj~ zy$c8m3bKZ=d@&cDiz+2j6Q~X}L>R=5`Hu}nHVB6iGwZxLm!?UhF}F{|X@h-cbxW^Z z(~+4SUJo~kKf#!9Hk5Wc4o)d=2r>=}z`oH;N^ zS^uqL-GgWJF>~K_kNv&RW1M~5ehdr*;RDtQ(go3jFhTGjsQ={B#u{9;`rQH+6CTR{ zIRZzmbmb&X7#)s?`MbK?OwuGoZr-K%e;9%rv>}X6<mXC+VO2p90Y3!Q-geD~D#Tt}E2#uI3mGGjdJFZ`4XMcTj z<3Rd_0~?%p~=4K4@4DN z+0Lp4{hCEw#E*2gG^C`Y%C37h-z=Os*@$A|;;5;pI$SOzg+f=L5U>M!GNTS|vZo8i z%d}dmaaKv7w>o_ZEG+V|F~fQoWOb(24-bndoQH>pKFT$|KhM+Y^qDud$E10@?NH1T z6~~rR_ot?8*>osHzC&@l-h87boVdr4iOw8evr|-DF|@}a@Bc8ToS7-f;onoHC@0sn z;Mx26IGKs}z1ucJLwZc26>7E7*1hkh2@#PtcGU94!#^c8^)dyi_?f5eNaHDr3l;Jc zDjsjwpYGY-RgWoV-u2)4`DB{G74cMDH~q=5+GN`_e`v=0BQuf5d^Q|kpUS%V-6S16 zEG_Y9pDz^^6zoqHD$vm4zfDA(<*)EQyl%F(df*=T{V+H0SoS^`=Plm=6`miCrm`84 zl75CY3Tozvu;muiO6Sjti;rt{y@u-FfNpFN=?tfyyX@gL;&Nqlcrd9bHn(YHyFI20 zCP#_kZ?LMbujjIVda1XJR$ofl+cUnsy>;KZ*HF_~SdcWTVdq%`tE+P)m&VN*Knz(z zYp_bcf4mm^3`?3RetnHl?VQwNJQ{*AUQ=%s9uyI>jDixBDzaMgT&}&T{G7o zrcC7~6C7NishO^&mCnLE!{*|el!Ted;fhW)Gtt@XaA;?|2N!@GFYbAcnvW{g z7cskvhm$Omx7v~zg52zKiKM0`?&30Vv6(qq{kqa)?MlQJwxUX<)%wt{ ze~*SLRi>%6QlZr{h$Hpj5xBbAa<4I$b5fdyHNB6HVP7yllFWV1`_8_#*SxSW@G=x% zQljii9KglRdbLKMJaTc7K5e-t$(Y(~ahTH0&%ytd`}J}@RHfbPHAUt1;WB_!LRpHr zuDT)feWOcYCA0zME$Hd#X|`lutKFl{D9R73mE9ubR+4HMYi$i|b{5Zay9L(YO~tjT zNeYm)9`i$62lk`5zpExs9 z8+0lMouyLoqeJajCXP&}a>L)_AXX)tHQV zzutC-uN{Ykgs{0ik^~$bCp)-(N*!ILqNL6}k9eH>^Jl?4#{{B=$mzbJk;Izx<kI^%0zwEU)9e_&WV`sYlnVYL93I8se^V>&TQ!^~|IzBqO z$>R|PB{%??ICiilyS^cuIXe21vjS&w7SH~5X|%?~tE8|ncM|p+E@CJttgZmW zii`n-A0cMgR6WS*p2T=OqmGHO?+tGMaJpbL+1y;WaJ`GQUIP>UOiQ9x{b)WWF;PKQ zR`+v9+gYldW_A%v_AZUM^joYn9Y3_0*^lbQwCZZcE^BUox#C%QNeP7-)j~I0hWnkG ze6o(8XS1h`DqG1W8At>?DK1-z7Av%CRdJOwv$I=UIW9N5c&`J)!`$vEM9QvbGk(X_ z12Kkg-%Pm`gpGfrB;?H+VyKvCrd8zU?}0(6YfaqFL3X{X?1%{RXU|qg`TOy=wLw%@ zR|9Gw5a}KMmxdUuH*T_|RUN;mAHxSdI*s~=2RdHtNVaAhqQya5KFyVO#QLh$Ze5#m z_3YT&R|yiw+V^HR0eXt(9@7J$# z=@mXQH8JF(que53ez~1zK|?cgb!A`iU^{LvQPEE|37E*+8Mdu*Cn2t1S{nOP)~@v9 z$CAY|-SF_RT&Q+iaaoD`=(8tA377M%+qMG^4vweC+()PN?^SOV|c0;=%CZxJG+YdNnLKs1fnGg%Z!`pIC+X5fG zN5<(E@Y_QNEmZ4%VBJ@WNleero2w1Kd}661vVz+qczL@>=X56EeIw`KSnHlz842Sg zUz?p36B1&#Fc-8s8@Au4eeoh%G|wToUJGe^^`K}!PBye6vvNHv&X-E%B#t*W;%we_rFZfwhkJjf4%$5jV1A$vG^Rt8VK-3W|IDJl?gI zdv{GuMS))k@i8$R?sp9ylv3($K4~i(&-Vm-JFTsb#~mLLUwkB9IiG%X#q^$0+1Y6` zVv>e!q0m^4{!J}M$BOjyc5z9rpQ(_Z7C(hfr{*Rn=O$r7mwp=1L#+_>`+VY0NQh;# zxOg}$Fu1&YW@esg5DB>2lMxdY_1Lb0P2>jyYZtoNVkzNf$L(HNpl7txkieAt8h3Dj zr>Dnxvd1wv0Pm1--l*#fcdjk{sHiSAT4FVG`WxV&{oFs(Qc_ZE)@$vq*R1bkRX+sI zUd~ep$CsAWMI!K*4tA~0k2_P%9Cr?g*6OnK<4xL|dg_AMeo#^K1qg0+%+BCmuKGRz zD>m+|Es7ivA1~w}2}wJaxa{}1i-y+m5`#(5VSS;i#>-n_#;SnKzuY6hYH^{{Zczrv z^wd^&T#S4no`Hik>Dis=T$E%ZAa+z4V^ksB>6l|2YpXq;Tuf*$@L-PsT?R@Hefx!{s-d*Q|A04V>m!a~BByrCgVVABqcSy4$KL#B@Ip^-pmS67RRiB+xIt{^#U zt(r5cWuI!%EU)&}{}gJM+bD0w(V4H0)I(Oj#~-}lI!%~UC255e>CD{uMDLwSO1f@x zAtNUCsRIq|+%U0H@GY=Ku|cBkNZ{>)t07)AW1Xc!r1WY90Z!+2Ht!w7$cS`O)*TQn z;G(gjx%h3ivjC~jJHjpDLOpUi3AzB^>Q%t+0}}D)`bO?JSzIu9aNqC0wP!$PW>s!S zpCr4tq`QF@LgWL~PyeM4_g{N>|M;{Qx6`ot3m@`nDad&ek}qm7Cdy>M{(XR{v0(L* z4GTn#4pNqtr~SRRMJmfCge=IX9pM+lxPx`If^-FcWajuJ1?7D7R(Y+jZ{lk)gkg9p&8nogo$ceFb@B z5B{IHqyMi_>I63vF$OO<{bbL3?K2)z?r6$GaX zM(y)X=@Thku_coiCx1jhsQ|kKw)~&|C00Q=1@6EPV#P3hF%cb$N+V(yu=_6?gxvy} z1HHV8Q{C#5e(hc{@hEhZ4;nT%)p^=^xq3K#%1o|d-{&xiZ{}Dx1k)4*vE80L?Kzp8 z6Y|KGC@$BZTUQ|6JFmYwGn&|snT~NdI=2t<@L^oSxG^BTso%bvD=E#My={s>U;47A z+V|Pd6}4PTp?3;FR?;~_cojYCCGA;^Esm-cSXG#3&N~`-zEP-~mpSs#_|sA{ka6)47uki$Z|J zLnXl5T7j_@3uFY4d?8m%z^yDT%ZOFPCF6+UQ=YLY<5Mzq3bV7n5O!L_v=)a<&CRj8 zUVTxV^qrflUs>TYI}`(=lZE9CmS7KR>@h@QK5lu?T(ogYyrS{ThAy8;mIsIgy_;^g>qK%go)Ia34;d_mzi`&(>VE;)@%W(5UQ zrFWCb$PPY9GNzYqAtdsBLING<{?mKwbv|<_6fZB|W{#S&L~6E)Om~(SsNy&H*;$qn zl~)T>-5(tDc7t1hrS@TEv>)mDqgmnc;$r#KB&*Zv=2#D^#}YVbYinyiNtIgDw4{V- z+3}aVyZf_>8cS_1otKrVXcRX)xwes+h##e#d+dDbU zFCma_E0c@cn--D=0gRcAO+`r=76v9fG3vXc1r{9<(U&iZN@|NMD`7{3VBS<<{r!LP zJH(f_s#nkdRFMv!WLvEdx;-P7;bLmGe@1sBU9lf8e;`LvDc8I^u2`efZiNKZ*4DbX zKk|CLKqj*_ersW2W&If#xYBN~B`9cktZcI5*J!()Nu^Sb&*`B!&!y(Q z#}Yb5eB+tTJljNQc#HE*p||{$y?4j=H)RB^o5}In*;$Ye?^rrKT#JtfZoQSC*WM*l za4rN$R2b_k!zET|uiFzdOxef5!J;oQwCdOn53*{z+n$Ztej^%}M#0``Hd_x^wU7*7 z7!(wy|BS$cDkleRBj>v^S*^Fc?p*Lz4tAhH^bAZmh|}wDq`L4>H8w?cF~vs%(Inwg z`>_?x%0K~p^*_N&}THXHkV7g zbuT0-8b>3eJQA?AULn%)3{nWt((-b;R(*16Dl8maYD!9>w9?>!OXwZKLqnVWenw^G zgfB#gWXcUM({+;uE^ENAbzR0}k5cUBDG^?Uud@KB_w@y)s2>f82pcorFhynt3ub3e zeWnHD^-RglD$-UG8XO%o=pZhwq97-q@AP@?>+$pB-#=}+aWGk4EQdwvh;X`q`T0|5 zIo&DdMsj2n1E1X^uIpey`+cr#V7A07BhRSO=``@*R~0UvsJI)C^O#>yQ0m?uoTFpd z_?s8c_Nc}&=`ah6C~v9e{hQ~9=ieDno-Yr7%a zj8E(ki9GG}h2Tk~{i&>^G|Eu5F}8YqbbNY>XKaS0N~e~Q^bnR~;4Sm|0=}P2mtvq_ zWqqDCH>X^SbfHvWbG?U0Rg#B&l-nDcASArDUtZ+ucEZL?KTAGp>*Q2YQnJ6cMx)kh zk&uv;x|~tdk*U+*M8f&|iO~41RVI3~#>Ah^+HS|-%hqybrFp(R4PL#4$eZ)=oYhQ* z5p(~atxhlkt8e>Pd6mHUqf?9vhewlBD}E6Z*G)*QpZE5s*}smAi#YZ`zlwI&j7o|L z3*%{z4Hrxw#2Oh+41s}S2dOD26y($VD1Bn4;ZM0UsS3N`G^02W0}Lo)9eT)rzBtP%nidWxQnhj=?N)eEV}# zb912RXI`(9+NQ_uc&hop08L?cKd-kP6OfND*dZzyIhRKLh_IX-5fRZRP*loO$e`g7 z&H*bkv(?&;qP%=_&#c+fP0{lmJ`k|kgZW@xxeiF)O3&vx*S&lqqC^OY2rv*ncgXtb zU1-1`NSSf1?(XifF)Ip+if^0QbJf*X9dE6TX47sY2L7F$+c>NiEKHd#?hWQvRvDSr zAkgoAzWBJ$^W_={Ai#y|U8+0by*uUpjPK>S+5G`ggUzUYc&$+nM+Vn!l2`(WQBLOc zpR6=#MKV2q1VLtY5Ymw!pKOWdP1SQW%CaNXy5L>GA+=bUo3uHt9u*dv-mkVEW3;jV zY`{8SaXL-rP%;8G69om#Jl+{8X&%VO$H&0dHqyDJ<;>3BH92kTbQcK}6cWPYGNGca zJ=SAyBIP)GIPD!Z7%y5{S6YgThdwjUW|Iit%!0yja#O@>VOge9s@Z4A$Y$wKT2^L% zZ==<^K*@EYZgcVB%mx#KywH&S7;Z1!w|*1KQiyGP^Lo#vLuCsKfr$Hx(TkrVJOXcFr~>6)#s zYU}F2!95pu8Wx6}4u>YFRO)J(7c4>FTx===^Pgf!L-7TBlTt?cthC`^L-h^t!9dwL z29HRT6cl=<-#JbQa;<+CX?Y5?ij8C(zq4~L_rZOg@bo(cWtz5*xg<3{`>;u3r3q@(4E)XkLR18 zY`0w$$R0DhA7Hixdia@HW*IQUf`g;W$4+W%9n1~~latjH3W^B_&DGVhZ_}`B4&qBV zIBJ0D0R4ch4L_BJL)6*i%F0Sv^a%$j0{3!qc3L39gsLkfwm(KIB&$!riO*yhx(=( zI4tb!LYb)EVMzj<*PwY30C@w|OMPf*u~Cxx>eXhR&LC$KF5pMUpG4%)i*OHrCd* zR#klhDQjrpfOOZmml*eq zjT{7(Y}$PL@Zs?nl0MMu2nYM>8ZOU>>>6H#5ZTzwJg-fBfBIBc`UB=3@yuW^VG4*&fC`2)>c|~{Q1-SjU-e$gUhV#m4iWUvYh0a_h-^UV;(CN$+<8L>=uwW zf`sIF(rK`|?UmR!8BuI;q?&RYyaL2EHF~IU&gNxi*!vVoA~kvmo3I@Mpeh@xv~6z= zg7fnuQS)*Ih5Ct9ILB>Bgu@<|?0;c?IatTK*giENGxniCky1%p8_`vI z+saT;x3fy%*PWxg^;kh!TC0WNpV9kss;HvAo?i@GTlxn55t|0H6Y)Gx>>C5tyXRD1 z&!_Y)bfBT(we8CX7}UO;RRLb-hh)5;tP?HRUOClQJ<8JA-|!>nXJ>g_x16cb+04;7 zBNc^|D9FghdW~?pk%64mz&JBTaq&=e^nGdyPXzvG#K+wH+}(W)Y8}0j((I&+3}5^Y zYq=djWJXwm8_;7-`~_76I|`eU!qd}CQF+NgPH1YX+UnQw(*^-jR5=jrHy$@8|4ol6 z3Hy_nmNszi9-Ylvn37Vu$?+K*1ms5VMw0C{!0z7HsD#AIYc#anT@LxptX+0odwbbA^#pJ%D9v;C$iZEOsjLayxReCsM{Y4xq* zQqQj=k6T+){vSOwG?q_J=c@{&7^gU$zLjko{sdBwUW-?GCeoP_qN3sw68HB=QzVCk z9Y%wL6joO5Vc`gQWa4CGJQ9)~fw_5qc%>_=)zuXhM+(Z))9V@=7v4O70ekZ8?RoB0 zBU*YH??HpbVN!%wMkUqhea+u~|1DegtC-lhzh6YGXfq@@*i&-%s3ct46TQ7O z+oQ`13;n5VoNeL;bj<2%G>y(|N8=lCfVLu!$P|)G0OcWhYPX+2(^_R zvp$}-Pqw0_nzY_>zX42Vagn{f9n6|(?ee_7=rAYzHUuzKGjDmmRj!Y#C~IpgYkPHM z(#fEuf!&)yrG0)6=+)2T^3J$=VHW{2HZ^@Ysl+NH07Z#5M?`4&`3v~@MM#K?<8swv zB$LBHLtCurN8*22S#fHmt5|64@*OlBO=4JGepZRV4)~>2Fnt(_-^=SZQ-#T@*ZmMs z03<8pwz5%np0D%j`T6gu=TOxNGzCIy*5LlWC1fN-Avblf*6GZm}2$79zPcF-+dxDIz4uB$2RG zuAW!By?sF<;1@_gVHpla&67@l8$FKdC_AO3MmIOiJs%V^SFiiwD&;5zSY!a(0KS&9 zvx-E?QojtE>ugO2;@k3XqwSkcb!JhqIc^U^XR{dmmST0%mHCj{id zg2ZkXANNL*%S5IwZPpviuAd@uTV?um8}k$jBAc7tES8T}=7iI~$sOGsuDclfwy}SB zM&NDpdfn@vJ+4yqy*-}K%Wv-k)vt}eNBw%{8U8Sb2`{Xqw79+=ikJ9y*k65hUm*Ru zc}|d9$ZRwi^K$@RNm|-zxqfti{M`|7UewgEm}=Np;^VBscNioU6byP{TTsczaJk+1 z0ZVA5sV0@ZiDbZrTqfi88ctCm)!5wp^~sSV)2gKStB?@s%HzEcTn1`39SvB68Y-d-k)$uD`PD4Ya zV472e=h3H2*Jhsjv4Mt`GMSJ8EIHl7UkT!1%^B82_X9l z0}Jcq2#paQE=3}Y%t1$M=U={_CcM1;8&EC4KAGyL206Qar@~ zYeGT-F7s92odf|P+=Z*X0WB81A}E}Z$}Vtdfr>j`2T8XdI}5_W?-1K zW{MHd1MnP9-1%*7uYmQqT7SAkGmf$UtU1``jUZs+u}p;s)-}fpPEWC-Gj$uEIntzJY9qG`uP|z z;S)Why`K_UE8eD9E=V`nze}t`e{ug!#j*a`&bMc?xs4DItPLm25+pFVzw=}|H*Hp{ zts7SFrp(qwE+D(dbhw)6h)<|{PV)kKqJ4-62??K&paQ>$Q3P9263TAC1nAw)k^Ccy z_QDOVDqujyMve&knTL4doaFnz91dXrG1i8OLjS+lz?B4z8QN>h7UDmJp;x7o%*)K+4@IIPh4&XCI|aj2PQ#p!jUSBarnC(l^D#B z!12eX4|x^tsRP+B5b(o%d@${NfPmru_9Xu4D<7%A8WiPK!`gXTCnev$i@Ap;5dLc% z@?VG5A=X7dR_a{r*e`Fa(;an$NOkFNs|_TpJuBgp7MB9@gzDImx>eP5@19ETdddTi ziXRweF1+u`xxW@4BWAiUlD7<)t77os^ZkY`@=@|QE{6D8Rzb;--}gsM7+1t1+jgeP zbe8$MXPs1SZk;3U(Vv)y?x!F#7s$(>1FF!QpVpu$D`oV~c{t(R!-)8PsiQbOwx1*2 zy`3%En~ZDTE!%tD-{V2kLt=u%gE^6b(Y|i4n~!T}G&>JVu-R=z5KK|cKH@m5hmpCh ziAA+|iQEp_m)jAjqIDNf5S79bbt3CRSH{9s&gz$O*t$(wvkn#fsr1MYF})b^=j0rs z;v6m+Vyv+`??=J98vdTplI5Z&p5>;dZZ{KeP$egeW}Z~ zG^;hxpR(tsNlZ^^*&6HWh{9hSmJy>F9%)XBOXId2oGm*~zDX-HYG^lgf9ju3%n0`0 zEh$x~h2V6na1oDoVAWH4oQ#AfN_pC|4=y3ds6AfyS{JvHg7$pij>(EgvJ5mAz=57asy8 zn%WdL_f|^ey_-6v)T?=Ga$0}%`OZY;!Ic-BDa_8G^52{eJ%u*OQJ8CMHiS3{{215t zvUxrW^KT)u($GnEY(6zMvmU<6A@47jPO&Adt7$zNYnS8~b-Z&m5|UcE6hK`QYkz)Y z(2~G8?djXP=|MV=lag5H@#--*N>&?nou?KyP+1meF1I+8(n`J2?5c1Ud)>8XoQAZD z@oR9NMm!06Jtb=wl#C?&YjOT%W#8mJsLoIIp@u@9SuyM8lxXIgo5l7DUggC+H&}Ep zra5!d^YKBOz0?v??mC_iR7R0r@=QjT3H8>qAv501>OlP&b%49b@r@pp}6aPa}y8k8nE<-)l7wK+aJ%Ayp3TWprB$%RS!D^|D*~6)yEo z8@8CnWhbvFyQSHiIsK)5o%iSIOZC$4*ZddF@`j;lFy&o^HH9Px&tP2eb| zFyvEm^X-##{J^k(Ur>3`to58>H@)v=O{#r`Yv3;jWySHgX?%b6l5@pfOVK=p;F)#W z1Y&C$>>7dP2@iw&th*L8<(i+!D9F6SUcDIL7T(adNQehs3wlR$zbZ7RhoC{V(+fhocz z@YjGrj(ZAnRtt{K)>GlFuVI{&ID35HZWBGVIYVlAWb-3RRgr{@$te$t&SJKN+6@Ol zMxW_!tc2e%J%XjKMPIfr+PXCW1lOaI)F!93{Tz&J&8fioBhMLf+ca_;ag;30_1wOZR{(G9& zeNC9l_HH-L`CP{9jDcj7h$%e+>SG*?injOi?@8Bixb%efQK#U5MU{ru)Yrie-PL?> zpGn9g3M?(tTFs|1Qt#6;8vKw^m~}qIeM{WECZ2yQHuT;*z3K6VY180f{yq3 zS$pm^Be?jJeMw;+ka1Rv1S~*j(T3(Tm`2%BzH;!&FSHA@LY<`MTlnEd=%36JNTtgD z!uLC<+KDPw&>Br&O}lUvRM>PVv-EJnn?<;)f^2QGe6723_f;`465dB6@k6^zSyS4d zb!+%?wk1g6hbKUH5Pe>q3rLvHU?^MnCz^BcL#*h3anVh!)Yuss^W4jv)2AK^kG z@&*_j{Z;kAFfm_NSW05A!UTi|EH)$`G9+I3zl)fEm!AJ3aRNa|NMK%TxHdwz zYYh>9i`9So;2i|cReR(E2Jq(Q$B*f-#2>?O>FqiPz)1e5Z!Rtdz;fVWj6}Z*@MED2 zMac#RFn)<6%KGn`m<5NU0>LG$(aNv+gcsSo}z2BuVO~Ps*!kNA;c_lHf*6jrjED95_R_Q`&7x_dutZC59#Uy zKZVFPwxNsKaD2T|UV82E85y9U18z4A9FXhm_CrNatK10qKu-Nluj~ItQ(zM+IZ-ML*)B2L;cx(ZCHsOGVM9qSK*2`U8(!D?nC>)Ih>eQBADqvN7@k8Dx_QwlXm^yFc^dx8j)K1vazz()zuvym>Tq>&d$u#*H9;LO^glasT;wO2H&R*4>@c_j{D){#s#9j=)3F zOERw#e&qQ6H4w9^$|-p8@RA%kzq_mN>&vOB9s`n)?rzQ7(#QwFR+?>Y6m%U$RfiB$ zAEC2O!r)g(Y8A}Pf-gm81B;|;t8uN316R+>4A&)-_OT-SIo&Q~51jer%-eMywI(h} zEMf-yN(bVf6 zBsmN^ZDHuT`g&m@;r+b~RL=#ScIulh|6jJYRTM?Tanh08$gvd8!KNV-yZbZc;foPT_Z*K~!7@SyWWCo579NN(@c(8MWqg;qu!G1UPrzrLl26A|f==`tQ!!=wr{9*FTYe z{r3m|#1FJj(Z8PRfXt7NylTE}kf5TXF)ohiXI~*3p~nVueiR1s9k&+__csne!XFwQ z7Kq=nV|&9B&CK<&+Y;wgRzedA7EA*XotM|<_arHLJnkja>Aga6j_vLbo09hrRfe$f zt+v~N3{B|2v{Hs-QXl}5;h_QdFbdeJg5pi;&d}YMu5ELq<$mnx>{EmlX^R?OCCVTuRU8 z!tyA9n>?_{0`?ogvW|}3_RMe?v^??A!N^N>_UDmuse=2al#~Y&{`}Oub&d`Wi#OM- z_%bfrUaYBjpDuICMsI@m6k0p(%F4^TioVMigj84GNh`(0>t%i}A^_tWb7<`vKzj6e z+ZKsrl%HNGvzUf~c+3hM-)F*{70waX|H#OQEmojH98jnz{`Qk86yq7TOP1&+th;As zX6EbXYE#T~ZT7RAD);+74$dYSdx!9_u!O|K<90nVqMrekXbw-m2(UxgE`-Ce2RChw zj&W?l`10H5T?deNoz7x=HzS-juTzee)^ejVI6|p;JAM}hA8s-X&yA5$FrX0`>gj2# zsye(bN$IiiACfbgGE;^NzdoP8OvG<;a4co%b&ZbFym|RbNsT{#Klx_)Lr-rq9H)Up zBWTo^oHYL%$I|u?TycrHx<<=fAv>PL)#32*OJS85{LE0EGGe?s|4=&TF&rO$7)mCJvu^Ws3+XkdW>|S~&*GDM@`n z`Gtt*hbO9_;NMkQ+2_O$hjm(tW23MDJ~M z0=f#Prm!PC{lz>hYI+5)+uRMVuV_`(JckUcolX|Qk$;#r&Zqt*id)~(07xUNMN&%5 z^i5|cnk3oqf=ev`g*8@=ScII8h=^EOu^&$GBu~RS=hbCdyebGk{=|=nc$X<<3j{us z*{l&GX7c26cr1P}hlYj%h+KosTz1m*ag0W3B~0>Ppbz2V{=jP^2BD*Q6VjDFPBl|H7l(@qB_W?m3izW8-)I(*enLVty2|vRd-Y>w<*2dC9jKU1k`e1EciU7YuaG)|2hif^wd)K|`;* zWB`#7U!3y8T3%EpUFeq@BU23qb&`&zVm8p>J7I2q{__L1jiaNusOV>_Y@NoLrJ;;N zW;`NARn^?$d~Edk{A-a7GGzbd}O4%*sYT3L;Knfl26@s|Im z^BFcab`bCSNap$ncaj{rMKzNM(k~3j)KEDgu98W&8=4CU+Ns`Uab2pu}w3qX#$P`>th)Zy)DUfLH%uLVjUOt3}hx60% zgZut&f6LI`r$7Cq4p4>oi8uf#CL;{6T5q3D1z6_j@MzBQIl7r+GrO?dY90>9VeXy- zHCP;w*}<>&_bNaDb>UIs;YgM?(foJCzi>@}tgtXy>T13Fy%(SY7i;zbCI4re0-DGq z5eon~0bmxjWOilcSX1WW?}WNF%Zh=BdHSwn$(F2Lp8=voOy?tFWqVK)9qx^K!@`B` zxqJG$S6=uw7L4_B8)#a!_;uTtYXb4z(`uuv-d>;xOijZb(uw5n+P;@QP%9}T<@*Zi z)(=wzYcs39GT&(5thd7v+{?YW$7|PY$I1{`w~MRF(`7FK=tG=?!GQroW8=`duCesj z)Sk_tz;H%vH5HZlxjDzVxGr62v7(&6DS?f7wBBUjP+wnO^D&(#J+D|?K@KZK0Fmdm zYOJW}P%O9zKv<*GTvgS*QeV1+w6?ZJluH9_qLdzsqEJ>wb4{1_iQDV-JRv)K3kZ^1 zo_+%A;tFI;OpMZPZ5{ccf%^!K>3+bDL&8>*g{~s;hQ}&U&z*5ox*z!wMT|qY-fBeDCN13)>PwswqXC}@pgO6 zmXp)q^}|fhik6G#ZdOH`k+FI>A#ma_zTv$I$o-WUi{n`Tuw%V%1#G5CqTd*KQOI-C z_6fz5maF$TfI%2ZAR{d%4X56W*|Eb{#50#Xyj!l-@>bEYb$pDo75U|48KZ^kn=F7@ zK3ZuT1M2eR$h`D?oo}Hy&z1zqULBhj53CQ_m=aiBW7l)1G zUYPCY#r&3ZyHkXPRbX40@w5d8Ak>$3c9`iZnij~J!F_cfviR)e)wS1tnyXa2UkxL! zT;nb-NXyAa>9lj*WNI|j)Y!ki`j=~UWEB?DI~+s-k`^%Anws|Y^}YIg)~ixSs(S7f}5{t|panc*lN*i>anA z4GRyy&_L&OJf{Pc7|mI$QZ4+8!G=g7(1)qw%b4$^4R$;s&BIiayMhEP#OyUbHo z=kkwUH2(6Qr8TLk=4xs}JAs+RUO@n|o0e93-Qzm`U{6`r#5R3eej4rb=RY23leT^E z{WEc>dU{<<#?ql-VHrFO)4WpL$4s!8YiiM=@B(%?AcrUecn3)a!FHiC3%euY9xAQHxS_z5%p<0p4 z%ETw^!J0ds| z%fWC$eMbp-OgJ7ft|`Hsg$jIHJ@*D%`X&$L8b3IC>80z;Dz5)A#tFaMiEPgHJ3GhtN=|NVX&LM( zQ}nE)x21MfLnpxH2<6@(#V5xAxPuR&&@HL8o)lRt8bc7^zH17ff(5NJD=aKZ#lE22 z`F@Yd{ayG~Y26L-UkkBc!F8pi5H4BTb#-;y*wh~K5ZZOHF@a-YgY7?*TmQEO-2Xt# zuakmMq3ytZH`wuXD@?(CeZEKea|oobM{@YzHi&tVL-^!@cN$Wbg=hzA{GN~dPFPs{ zl~ln@)}-*-UDia_`#A61urhJ!`C_woM&C|Zy}M&{)Po1<`FjO5IA4ThUb1v;H)0{v zj4SdyR%yrS-@Vm4*5R<>|2j%_ETh)oo;+3kp#G{^Fr9$;FRIS}ant_<^Z(CE)c^a= zFHoQtZ>KZNJYPakk11I&+1EnqT&2&J)WoVTkP@8MW+umA{ILGeMR7i5Li&(Z^qhW- zAL*l`9oMaEIDh84dnlBGD|f=Gf>G&yDD^?B{D0%35&r`&f6Zjj;poT+_4^iv@a(gB z!7rowxkKr`1^`%p*a@`n^E(^Zw@ph`)KP4lW~!@}mjCZOe!>LIC67)o{QO+Fr^|ztW>|=WiHT{rR=K22;q&Ws&GwrZN6E*| zoXw4wEj#CTdc36M+`gWfy~6p;Em>-s z&l2QxeX_SWT9azRHg1)1z0UQH6qeF)!8ksqeS&#+Tj05v%%0P!n|e2QVp z@J3M0*TnJ|Coqh8f8^+Gu1S!;$zh=L@@B5##XK;JGT&CGC zV4%@c>MC{?`pALhfD3;JgT?%}21~R`dG_Cmh?V#ts7|252SOJF@3RlNv6a2uE7Y%M zJ;#tbN){V@Ybg{HC9^?HDX)blBEKB3@7u@WT9xLcyzkvy&`{Ls*+04=6?p|?xn=?9!v54tM z7)0~eJ!}*}SJoE0jV{gNun8-Ql`LXn!3Vyw6a4yV6$0)3{CrNiP+11}xu;9 z=Sj%FzGxdw6JW;zg7l5{<_z5PsAzbG0u;^{{Hh0 ziu~Clt2JC=;&kcEn6$IAvwAiXEm{@ahgWiNUqVJZrc~&EC*nAcPo^U0`V1FGab%d= z^5|&z@b28~APOp~1BaGY*-5AG$th<{&{tL5IOL8|*3`oGcDaJ-^wO8H`T736hQ*g# z6>q)JRRFG{x+`Wj_Evho-r)ITwV5p>GJ$6+#>vxjdtySiH@Jp4Ix2|y$0*WzCg>k* zLelBwRe?{DRLp}{XDw$7hU2m#T2c%F0m1wu9Kg7?aWsmnrza)-menN6o~T5w7NJH| zZ(WEzIh50MJ*`7S^bG#W#kJ!NmN1N_T*5-kH8V#93$3Xpea{^Z)ab)48vkQSxvm94OoCgtm;d_lr==rgmE+c^+}{irPc_UxmGYIX z`QXQi+rIT>W(!?h6}nzHFgyFV_WB<}7e9P>YrH&rQSzc6HTy0CH{P(zv|ncSnp$hM zcc0dRg%9t{v(2=b*Zb|`$HL~sCf&B^3p-eQ;e>5}zT@XNiF=RthE%E9*c`ck|NLq1 zCr?#BeXIL9_k=HS*V4Uv`s*W=?Y_Kx_>5aTI{MbPcRx>@JlXl?<_5(xNrvfZDdpv{ z>sC*lDx;m;?dz%$BP6xU?tMUA@a5LNzIA7M-dws=#Ls{Jz=5eQC0llcEPP>Ex@y(N zjh$~gJ2;GI>uxjM{dBtCU*m6km-KI{J1)kIh481n#95_@WXZc=8gw6zUSPM!=raAnfu zav{OXm0GSxS(6GcZ@-3t{*kiMYX3bU%K=}O8xItpF`7A<8$x*;XXdg zd-^YVZOu1(>y-7(GLL_oyKkRe`)B(^ZAFexI**rx$0wiP?00ga^UR&|H_J6CYzD42 zlW0F&S~~aPg74qn3N|Ku^wzh1^*wpu2AHgTN(&A>psKD2sK^ z>{SFRI3d$|_#s!zq6b^;)0w%S?Op)#TEZO0$2=102P~M6Nq#W4V20!YP$fXRI|=Lg o@so}0P#|pjNHY3-eZzlih8*uVx!VKgWH11Mr>mdKI;Vst04GaAbpQYW literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/video_player-dark.png b/testdata/snapshots/darwin/video_player-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..2759883bacfe0018892d0e31dd283e9260385b16 GIT binary patch literal 9864 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 KDpUe3g8vU@dJt;> literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/video_player-light.png b/testdata/snapshots/darwin/video_player-light.png new file mode 100644 index 0000000000000000000000000000000000000000..1817b4d9f1fb42be770edd1fef391f1435c7b98b GIT binary patch literal 9732 zcmdUVbx<7Lnm(F9fZ*?jFzx3q}jd&^~ zlyr(uv1OTNw|H+3PdSf29)gv4XLBszb0+MS*_uJ}*7Frb>|FlU)tXm>o_gAun(siA zZa2qTk|VsHx3ix3gFyGsf?v zeQeyVn&NZ*6tdshk>=wyHmj&EQyBY>J{t%O20g(fCSV1q%Um=8foiC!6eAP<`Hy&b zzZM}Y;#gXX|Nlj|4!pc!rCnd_n5w*Ti&|7|KG+bRd%hp*hHQ45yIs>qgoitxHue&V zB~o#+n`|A@1zo*#Y7P#X4;Q~VyPKjjgIG|p2}@yk zTsIWtImq(&IKb3Pth_cu2=Ei9ms5Xj6v_N^ZCD-N6OFqkUX{|%_OZ%F)!b# zbZuHjpEnp4aWjAd=HYbSR}bR8etr+B?o%M?kScg+)oslNHzv0?^23j%AjC09k@oWP zhK!72M#felIDd}ujS>5|xEV6c&jNZwgl@`c9Jo1Fp3Ckx>suRU1w0S% zb{5{NBM&Zc^QO8iIHQyA@Qx)S=k)BXq_itPp9zE~k9AUz>EiC>BA~4&C{TE;U*Fc} z$m+$F%54=7t4Z9zI@ublZv46J7@RZzT@OH_iwE2k6=F8ke&=;_womKiA>5H7R>tk8 zV9Ho~`(x2{NJ&jOot>PF3|kup5Ewa9ikxjl`!B!wnSBn+od4qu%vvDmS z8gE?gWUnKGNYhfmA&*0|M4Xf08;64RYyvz2A{$A|?|$|dYawh)i$gnaTQx;D)j@Gy zaL_FEc9lA-6(7hd#|D8ztaTJIMz^-Pu-~WDi5*!ksQ_h`0&d4Qw&d7YG3lSYb${ny zo;b5=Zb9l*Z2kh9@MfiedfEZuX>-?CS@+8)&%wIS887ixaYXdp#Ny(bkwedm0XBGG zh|u+CbYM0ZKc>K$+l%pcBqzjF^)r0HlR-sA?KrRt31Lkv&MPu2Y_T)_V7k4tqeAo+ zYU#F`w7s(zHBUytni#Q2l{K-rJf~b$-Zb~ly(WMmAusr~mnqM)I%y0QoLyK##FV2v zu?3W`)34HKNe|b3nwXyJG5V!cYjW>~lV$4Rc72&XFocin`?lV~)YOmx_8+Na3>4sU zgNPR%o*wyr83u;B${3DLt^^UFXB%;wMLNkjMcIAh@GtOwK*vtm@ovkuBVO2czsut*vCYwg$2=`tlHWlll8>NfX) ztyh_oiIa95b{YIaEqBfH^K&;h*RH+~R@)X~jaqr-V>~{$y^e`;XX{_=v3xd_Ye~3H z!Tgo`u+MFzK87g|`|at0Sd&h5bL`0iwye7T#Ugdv7-OsUp5*3Jgr!zk{4I_j) zSKJZxS!8t*XK~KQC%imd{hgr?$pJby0`hNTME5B?H~Ee)BlE3l_%t>&D)vmCeoE^cP!WVOGfa)Wq-8D8LDKDr(P3rWvH7r>3Lyp;Lq0rZ0OD<^0>x?}C_?eT<+ z1}80+#HIO@*d3pJI;Y=Ff8+X)Ro8lP4Lb9=)wYkVHMkQk@U0W~IL zveLEgt^67`4P=RERCi|d(4EU5k+-#bq>-v?R31eaV^mmn&bEIpQlO-Hb=M!YPqP#X zx}aZtxS;=qj>sG{p{{E{m4t9`c#ax(ZK9E->TE96O;OB{rB~CL1}kx>h2@4V7Z2&# z>3MYUmS_kRbL;#mdgeK!f}Gj%BI(}F4$d9rFHt}g}2h-~dhv}pC~V9*QWIP9%*4^p9Ex8XE7C5lc zxo3z&0!?bzOAZLoXknMQV83Bw;d15c<8Sw?gP5p)uUQR|`uG9MvG=!>V?(=h1-@E4 zyQP~`qXsRLpC1Q9Vu|8`sxnVx>7N6F6U^xUrC;#ZoHAl}VvxI=69M9XX!Tn~t9Gn^ zZiQ}TES{>E-(l~^2ku7w{qd3)bP7>9S~)hKIp7YLRp?)Um?Dr?N0yPzT_1r{{;^#N zITrWi!uyia2^mn7W16&llWWauLh!e~scMuIPqE%JCdAv|v;>7~QfXNsTZc|7ayy?o zHCu45?Ac25WFS^k%DO#)3Zb+iknwvNA@Og8N zWsjBiz9~Ma*J%l0Z#?RJ zlPch~m&aGLX%Bc=2_VZJ<)?yiE=)_ju4yo%d7l)>G%mt!-If!=tTQoU^2~w@Hx&u# z6qL5J)gld(xfrRGgg@nIlGbs{xOv1E7-f`eP=UW0`yv$d8p1h9FES+f4c``xQoA9F z4uG*^g;gYZ(I)kuA_*BhRa%Uqi~yr&GZ=i8PLz&(fw6QD7`|#`XtN}$@!zH2D;?Nx zd@0~CT4`sy>Zn8f|6b)6;j#jdkSrgA(C85Z68s6LF=fm?Rb^`#v2*=7QSh#Pjf%t9 z27~UTVdwKxvQ}H;-qi-4$Kql@-!qfzE_GM^(oxHA`-zX#e}3||sMt$!G?w_lJVJ#~ zOj}jP>c+IjEYW3{f$Z#&ne5v+Uvh-}Oqo8$GIs5G7pvKe`wLv?(Q=xy%b>wgbF;mt7R7e9Ay=1BYa1&RU|=jeI{@ywd%X)p zav^ouopam$x#@B@C;=}^)^)=`Lm90ad6_Du5j1K<$F6urMri_H!_1;IJz5u6D~v5E z2cjVPXBt<=!T4?8U>GYZCP-QkpYJ0b-BMp=^{@csOp-0KeX z8}@h&3{2xvtlvpF5Rq)Mf3BLxJFIG@S{jASxB zLOTBZxdgg1nAZ80JvuA`QXbp+NxM{awpq6OGY)3F+zuOFd>5#*uI_|t!!RB!PiSiS zvoJz0*kJw*ArcZ)YPvAWutG^&M~N`z?d^ksicoIWh%B=iW|0_#?9>x67fqI!)o#L7 z9F|p1sHwhUa)AX}unQBVhvy@DG@vI1@c1T}XLs0!A0`mW)LU6!`U}dpkp~$Cb&Xo` zxS=4&EJwLZ7L0FfY(_uDuCA!aZYawshI$Rnf)ByQWacAZC4^~WnEWS=Uy!{|+Bmrq z5x$>|ibxkWv%1R37(Kif<(WpFjQCE-Hl?w?j)R2>X|Pw*d!OKRt?+SsrtODO>Yl$1 z82r3o7)>^>!#iYL4;S-E9PHQp@<@xOPI2B{)5XZbE2U+>Qx7*3Scd;0IM9Llhe)~y z0nH1A+m&atU~O85x$VOhiE;B{R<^bCPR+&Amp<%}_Q+%oVhn}krOQ#~{+kP3nwFYp zZu}zO6;ymBzK^bn;XcX1eC644uS}T>Wj!j3nO$Wd8LtNKIeC1K`+(gMrr%ZGdX-;S zzT-#Q@nYHg%+WDF|-^1kI)JxXM^T8}bcrP}&*bo3S(R$D#OAv>~*7H(En)h8caAj|~ z-TBopfbj$!rIvjBs8gHI-o2#gfs(0J-y4h+=9=`lsU&e!rWK9Tq4;0sABP z*+Ctf)bnt8xSQR>SkLnC^m0}vuJBFa!SrEZ&5-uv#3Nz6JETr^PLvrw@MQddE@oiy7{W!1DenfdsXU3Ot%(yg`U zw%D4pIQ|-LHN5>{y}<{teao7;?njCzOke@C{`9abVVYrWb~pT{sp)A#U~08ahSC;& zoIu1I24-gIxGB@ZamWYoa^NBR{La= z8(U3B&vs<5s;Y9cnZlTh2(jgWj{eyB&csMWT~qzZL2$u+A{oil^%hxWl{241M79@T z%hTTFl+@O5EV124pi%7bcGj84IO4=&n0k@c;@iv@e42>~gBhzk~YWX3$(4(XzX zF3+9U6JD$sjfz*}Pbb?n#b)<>ct?-p|0Fz(b%J+iPKj74<8_dIJc91KpvQIg3%&4` z>|!Y8xNxQlG#I!NFw-)1NqOt>u$gnflkv0U2!v9(7)mlMKxG@Ja$@W&0u7)eY^GEajL-n(bk6 zVv&nYk%wS}!}dh?fq*NIp;}7~YL(tsLhWPRr(M1h+n{8M-95rJGf-^b%D4a}V>g^3 zhk=$O?UA6X+{T4$U+SZiSHFZqvB9Did_Hl1OQ8D6hzy!t0$J~D@90lWwW_kf32{s395`I5RJL;n_$pmIkqQeA!_ygK(1IVU8)+M zt`o^29Vb(&TBN}$BqW5K@ygkSplDa!1>>7RcNArl-A$4P+XEdQ1_5E~$=TU##k2g! zY?ka4>7fG5j_`zMISdVuV7!J#P|Mj|UQlH}Wu2|Z397jQXX4g+o*qVuXy3#^^~}wQ zkE+vL>qCc6dugdk|M;tBZr2GfHEBFmXsi?(n)h|r7GfFYbv`@W+n2|uAH$QULn)`6 zVc}sB14Hqo3z}%5(RtFbf%6e7zP@%xPuby_35=lO%ncSNTkG9V?TuAc&hxv7^)!X) zr|avbrXp;%w53HvQAfU&%w=c*ykaxe+-e5rb2!Rwy72Du0bWz4``$Y;Wa1s~=CDN0 z_1;FNqgVB?Tb3#rXg?q?R@0@1gw)!sXma}Lj(^=TqRO1`XXbUeki~gWxIE|LMr>9^ zrFOsSE2zh$sesyiHca%ZiE-`OcjYAs)R%E`x#-~A%u_(|?)ufC4Q11znnX=ZS6YO1ywGT}l3 z?3b5cT*xZ0K-W}MQ&W?ZE0zJUvO#-?U`pe6(Fl_v;nJ`Cv_k-&mHA<0t*_4Z#46`Q zJ@UejrLL(d&CJa9JNY~Mu8F9+gA8kSZi6XR6E~lMhZ&*i8J1$vr^7=7hqg`fGxoZ3 zsxu^W{K1u04d)yI7bY61WY`z-3^Mp55SBOsmG2vo++;!Alekjq;&N{T@yGGFE68MC z0G{-|oq(Vxvi!)KPpZwHw|5_K^CHn{(wWh3G|Sw&FdG8Dl;@j08Spb7B4>r%6aXpW zGHI#<%_o(pgr+u55=8}@YVtPbv6kzxuu#;Nh&I}miW4Rh7Mh!}46|C!;gb~&Et!V( zdRm66g7VcUvN8(Z_VY<20;*TXY|yEsn7+O^v)4k*%-bo=m{inKKh?oSf4I5UMF_Vc&8RNCt=@9uGkxhAF!1y6rH_HxrIHX{0)fV4%lOAKLYA-CQ2sdK?FXcINh%=DIx zOL_lu*oY1`{yh#bB09EFP|&A^R`92*i`&^@IA_-@3s9y2i`Q=7uDiRR+q#-;`~o^U zuKRMK0=zdx#BV9BT;oxG|L%4C23&PqD$I}L&hxIZu4OSXadHdIIsrcU3{cMhlaM0O z>OfRY_q?c9W0xsmoFlAw^g8}z&6(sy|Tb|Z8Fj|hZuc;#CbzV=q;4=gR^s; zyf^i1?XEIa2vOoK`M>1$^<*PI7NiZj_d#(A0j4iIZj`|v0gT~_FIdv*78-SID&7b3 z?UF|iZrvJt9d%Gsl=fu`(|m z`x8Dw^C?VUvI8SzWbTsGKzKlg&$g#r{&+wcZ!~&(00B9p154^U9(ewr6X>*mkj>0WV{BWbx%YLE=s)`(c37f)^3-wi@p}oP{?b_LoYrm zHu~36`N4~%8dTsN6GA%JM}aTpw{3eQH!ypZ=`*(EJ*pwoQRETH5+&6LI7#M$&$p{N z2VcVy19Nt00JNivZ8P$z|E0-TtHl(}{kMjS(W9e?17@5emWbVtOv5;z@vIdlrVcO= zF&okX^Bv69= z!|lDz#?6v@e}%C)DSC8pb7lq4j=Fm8fa?Qs$$%4~uQTpW=wA3sDmUw~Qb*ji#BPtS zfMU19PdZZW?rd6^{_J~wj7?0e?1$sF-!11{3izL(q;%<}r!%wlje~q;8=7U`B(M)5 zW;f?D1F#V(Cm8@4KP8rUbX$CJV}!`Juro%KII=f&H{vhORdU)#NA{r8F& zp5pUMl>as_J}e%$uYUKvnEQ9`@a%5~d42bfjkhPRTOQ&y>B*m$oA&@U-Kt{WePY9J z=Us2*?|tkm&)wS`>vQ98s1=Cgx~DnTUK zzPZNqmFJ`<9@~T0+wGfOz0sib`M0MNyB|i1KYcy@{K8(%<>z=>t~KT8o@jRWcdAa% z6#b=bx#x}dE)~ypaZ+<<@3Q}0^YGV`Cm#;{|0Qmd&3nJf z_UX-Ze@}O-OHaMirKB⋘vwZT|3)4{Cb!NSn&CrSeEB+in2;`Red-2_D-AKbvNYA zvLtiS@;AOmZ@XGunz*i`WJ9-{_0o`oO-KncmgPp}=Fe%*&z_q-*W5b4?B1I_H%A3f i!=v=??7Qn38B~5f4P&ghZwD+z7(8A5T-G@yGywq1Yhqvk literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/video_scrubber-light.png b/testdata/snapshots/darwin/video_scrubber-light.png new file mode 100644 index 0000000000000000000000000000000000000000..52f3ed72c025d1b2593e3a19c903e60b7023f3e7 GIT binary patch literal 1002 zcmeAS@N?(olHy`uVBq!ia0y~yU|a%Z2XHU}$(vErZZa@1Z}fC=45^s&=C)zBaJ~%t zhttJB)kSWJe`FV5zNON8 zm(rN$t7T~CcQ&?5u(oLa`^|qUUX(QQ|M|5yTTbpxx{}i8c>?S=4)T?4XqJ7Gz-~Q+ zn6XYbOr7ra7=xXCR=uqE+M%TCM9uU6yUoF#G0(rzyX5zWPj8Q1y{)*V;<)v_&?<3{ z=^IM2@7_()wDt#kU@w)oF|<$G3t(VL)``SMtM`JTtIOUoNeR(b=q ze$114&-C({gVnu{edSZ{ney>}du+4$PV7z@_nD5f-i9r|esj&+^LKPr{oyY*O9a`wh! z{{-KNh}17#Ao2h3y34ywWyG2fZm(G0d-YgW^45)-TQ{m74gCMVTij}I-sZbXvM)W# z)m|n{7bp|GH?gl|iS|Ukvdl#Di`JKbyr-8A>RG<=`v??Qj$K;9)z!k)b@_LfdH?pX z_jeV5TEq2Z-&{KSZh3j-J)OQ^OO$0_o?7~u)s-nez{hTJo!OJ01qCwsE-6V(@p>2I z-yOG91nM#GE!p5LYb`j_3^`$WGwgo<=2{~#fipBH)W3E-r?g4gdbpt7d*4Vg^b|gy U_4u&R8ju$~UHx3vIVCg!0L%Bu3;+NC literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d05c3b619a350f9e89bfd5d0ecd8b7efb52dd30d GIT binary patch literal 3371 zcmeHJX*3&L8cu5qqE(HeC?V9YrKs3Z(=008}h8|vK(%UPSOvUIc;V%_xiiQxLs!SADh@3=pFM&3~X z_7VrmonVvNNVQMIdzfYVSS$VJ)6J6x0kU9!i{{~Xf%=%(MWni(!$LCqh%Fm(i+Svk z*puoMI@rz&4iytqqTax8rA9MDMNhGt>YnELA4_fC=*q6O^0?;sax`@ZTzri!_id)?J_ghj$a7KutOZCn)f)y!q#91!ko53!J$QU|^fiL0idmm=u|S@op-tWwu6;q} z*T%(&l%tD8FCwsO3>PxRG5PZ{GKW@dCk-xY`y3X;ugWL_Y&>mqs7Ly~`tHI=sWnlD z0#-}oDU-^|3xoq)z<1kzd!2jJ5~!YjLbkosTdN^97Lx@O~(#<2B| zI_QwjzJ;DjlYgo%S*TFfBL7^nr(wacH*+DJ`N3D{Me;M_Yor)sHP;W2Y=R+U!dQU+ z?OduP9~Ar!Xe32CV^pywxks`zgmxUX&y1U>gv>lVhMT@;j@RXBMPp#dIAcj8@#mrx3|?JvPX*u+m1+yA0Q4yVs%Zqd19F%djI*b&hfy;bTjt5 z=wns@p!JRM(s9w}eKwJH#>Sm{d<1D|R zw)!>n@Dk&>@K!ID4oyht{ zL$Q&V?Jw=z3(uFs@qsAuZ$Q1abT+4CeGM%_~6N1`is)`49jjSAJx4wOm&c=P>*(c`1kzB zqhH2rlFKH#6T{_aYs?~XsJUMq^1^OAVRmea!uCjM&qsvt`XQaCsiM#>Ft*oTTgEhP zH_Ggxv-RO_4c>e@VR?3_uz%O zq_k~oTxfX7$n%ijamCIP4Tp)pHkQgX+wTW)GRJJMKWMCbe^;?FbUVo)mb*CRd4FQr zgqsoY9R0@eZk*0r&Etg~ymzp++lZxIo|2;eGm1kLoZPX+g3KMuaTnNqKD|w`|pYtm2-+R?vp|Wn;Uu)k=!~3OCJ&?oSdUf}DOe25ZNZ zT^b zIINBK^nC$TfImMqKY#vAWL|c9o>BO5dkjJSk)!I~T1QZpq$KFlN)~#vp6prZ>oy`O z`#R-@fFMy#;)mseFHAtsZvFm(bQX`k-(7j6aCSiXHfgXu7Rd}MsMa^-#=3GOIC=>3 z7Jk2wb-MFKMZF)@$8@|&P0>*b9C|Ifk^5eY>k#HkWnIOT>EaLh+-rLzmY(-7*e5?p zJn}m}CJ(%L_s5x~W3y2Pm182^>Pr$?iH2hK38j+*;bcVG?d-Bd&ZL=y7qj&dk+ig? zkt%iz*h!_Nrnv{^?_#v+n_dy?BP>Z=O?>#2Z0>R#cR=@;oukmHi(4sqC`4_+De5Jv z;Lr>okojoWO}3MTRf}(Ga_BGpBm+hlY|Xu!@~dL?HMn0wLS12wdycCXD^*1#3!VS# z-^lJ4XA$DZNg%x2GqjQb61sn6#w)m5s3n!z_vxT<4QIa2;6(#L~}{veC|R z>^4^O+nrkXohOQsTR&@Wv7CbiTnOz+yvmQv)~Ke&A9d}D9F+e;Gr$`a4g5%7D?5Q| z0E;I%ihoUJZ+*!KL~b-3g*3z&Ptej%6}`xdAp2_y&x7Btdu9`?gtTdh5}B3qSD2nz zA0N+S=mWI#%yJfybx=f~Ax@c=Uz;(;j+NyNA$9r38kk^i{A111{Gmk#OiFuSwxABO za%f}fcn%lD$NE!C7MMWS_abIazdqH}r5aQX%FcQ962B}$)Y6LjKL?F7izIs@Zf%intrT9bD@_QVsHUVK|GreXL9rn#akG(SLWd-&VhH&{Bp04#xH0dHbKP^Z)Jt{_hA|z`jgp0<7gLmMB$0 RKn(f;a3f2@3Vl?}-vEq%&@=!5 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..24fef2cc0f2bac86099bcf2ae3522c8e363b26a1 GIT binary patch literal 3355 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 a09aTNi@$K_d$;};fb&Ur2j+=@lz#)g=lr$+ literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/volume_control-light.png b/testdata/snapshots/darwin/volume_control-light.png new file mode 100644 index 0000000000000000000000000000000000000000..1930be30a74bf9d9720fa2efa56fb5a5a49abfcc GIT binary patch literal 2182 zcmdUxdo&XaAIGPa4bw`hDP0y5Jwmg0x!;Bv#uP#{mxvgT+ndY7G;)b#&22;G79N+^ zLl43vmym|o`*dnCOGs%8WA1ysf4zUc|Gw`z-}5`4-#Onue&_tYzjJ;W1P83V>?v6Q z03eUEvAhNVNFChgeDHz&+ScMz1^|HZI7@TqnBpZa(f+23a?f&PSm%gATN^#k`D(#& zk^czF2Wq$eJdLgy?~b1TK z?jWp(APuwdE4xTEcE53~D{p%D>2Aa22D-hqL$VYE@$l8oK*)CYk9WIRHV=J+0K)y^8)n@wzxH2K!~@x6QmE*7ceezjnnq(j@o_nJ=Le)AyZ{qC8NsH z@!n%$Y^hMcRR8uHLvZg=etvRE7OmQGI%YMsYs)Z8cJ}QD-2k0CGhgMfeUm<$(7gL^ z2<3VARmFh$Nj&-_*7!BD(=$L6oW<)?zOWsXUV`q5{4)LBBepbVOT?!6n7=bKn}PVy5{Z{ZnF$BYVeJai_{+o= z?gqHBv(D~t5G{~a%ux$R*J_TcQE%Rt4A(@b# z<0N1<8iy~s&0VxtbK(BXka#{S#!zf!ya}m3!B89Z?t?lt{Y`X`syY=QJpkKVl#=;f zwJFK9HEAAv72B@g%X)QGKmqkdj<;Uk_w<+~Qxa0y6RnfKjF5kRsO}vzc{QU5u}IG% zEi*HhneJwWky3cQi~r>|G48$_nXGNOjQWQ_y+i@4MYpr5$u2mPi^TMXnv?V8Z+>qJgjqaynLX$iECTZU>c7&?-{BVR0{)`|#wacH*bDmWdaOqDtLDZ?-mhZX zbhGT3O83eA!Jh>^)B3{=>m5`?jos9r{jhZzZu*S=rQ)9r>_BUwQg*@I7j!B~fm_)d zLVGfjFi46nX;`Uov!0X(A?;wawHjlMRE8E0<{s)UHp~A1Rakb*We2g zs;#qRlT%O2L5Kd`pF<4YK!}*0Py@tH=fOBa3#de4BHs)uyPmU(t)n;g8F?fu*p}>U zm=5wI;&wZECqGrEu%h`N^B7{{X@SrkJUSFh-VeSl}H9*`-!*lT)oEI$}XI|zr zPrqJVoy&`c4j+Tigu{1>oNETq8Ki4ktvD+s%ycM|hKSh>TUzShq*xD=%`tP;I9H<+ z4eblf?}d@=#fy7V2z6)5=PSzNK!m!>fh#+Apt(#BnTLZpS#bP5ddocInN`0m-CLjJ kGqMA02@MC~{pWyyTjDj}0|mX*{r>`hvvRO(wD6|<3l2@jFaQ7m literal 0 HcmV?d00001 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{}) + }) + }) +}