From 43f6d501c91ea3aa29797a8bff284102e47e2372 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 24 Jul 2025 08:51:25 -0700 Subject: [PATCH] create waveform seekbar widget; still more to do --- backend/playbackmanager.go | 35 ++++------- backend/waveformimage.go | 3 - ui/bottompanel.go | 7 +++ ui/widgets/playercontrols.go | 15 ++++- ui/widgets/waveformseekbar.go | 109 ++++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 29 deletions(-) create mode 100644 ui/widgets/waveformseekbar.go diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 76d9ae1..21abcd9 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -11,9 +11,6 @@ import ( "sync" "time" - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/theme" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player/dlna" @@ -36,6 +33,8 @@ type PlaybackManager struct { remotePlayers []RemotePlaybackDevice currentRemotePlayer *RemotePlaybackDevice + onWaveformImgUpdate []func(*WaveformImage) + autoplay bool lastPlayTime float64 @@ -91,34 +90,16 @@ func (p *PlaybackManager) addOnTrackChangeHook() { // TODO: make more permanent if p.cache != nil && item != nil { go func() { - log.Println("begin generating waveform image") if path := p.cache.PathForCachedFile(item.Metadata().ID); path != "" { - t := time.Now() wd, err := GetWaveformDataForFile(context.Background(), path) - log.Printf("generate data took %0.3f milliseconds", float64(time.Since(t).Nanoseconds())/1000000) if err != nil { log.Println(err.Error()) } else { - log.Println("have waveform data") im := NewWaveformImage() - var c color.Color - fyne.DoAndWait(func() { - c = fyne.CurrentApp().Settings().Theme().Color( - theme.ColorNamePrimary, - fyne.CurrentApp().Settings().ThemeVariant(), - ) - }) - log.Println("generating image...") - t := time.Now() - GenerateWaveformImage(wd, im, c) - log.Printf("generate image took %0.3f milliseconds", float64(time.Since(t).Nanoseconds())/1000000) - fyne.Do(func() { - w := fyne.CurrentApp().NewWindow("waveform") - w.SetPadded(false) - w.SetContent(canvas.NewImageFromImage(im)) - w.Resize(fyne.NewSize(1024, 32)) - w.Show() - }) + GenerateWaveformImage(wd, im, color.White) + for _, cb := range p.onWaveformImgUpdate { + cb(im) + } } } else { log.Println("no cached file for waveform image") @@ -213,6 +194,10 @@ func (p *PlaybackManager) OnPlayerChange(cb func()) { p.engine.onPlayerChange = append(p.engine.onPlayerChange, cb) } +func (p *PlaybackManager) OnWaveformImgUpdate(cb func(*WaveformImage)) { + p.onWaveformImgUpdate = append(p.onWaveformImgUpdate, cb) +} + func (p *PlaybackManager) IsSeeking() bool { return p.engine.IsSeeking() } diff --git a/backend/waveformimage.go b/backend/waveformimage.go index 52acb90..201f4a3 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -204,9 +204,6 @@ func convertToWav(ctx context.Context, inPath, outPath string) error { } func setPixel(img *image.NRGBA, x, y int, c color.NRGBA) { - if x < 0 || x >= img.Bounds().Dx() || y < 0 || y >= img.Bounds().Dy() { - return - } offset := img.PixOffset(x, y) img.Pix[offset+0] = c.R img.Pix[offset+1] = c.G diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 60e0ae4..1359d05 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -33,6 +33,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr bp.ExtendBaseWidget(bp) pm.OnSongChange(bp.onSongChange) + pm.OnWaveformImgUpdate(bp.updateWaveformImg) pm.OnPlayTimeUpdate(func(cur, total float64, _ bool) { fyne.Do(func() { if !pm.IsSeeking() { @@ -141,6 +142,12 @@ func (bp *BottomPanel) onSongChange(song mediaprovider.MediaItem, _ *mediaprovid }) } +func (bp *BottomPanel) updateWaveformImg(img *backend.WaveformImage) { + fyne.Do(func() { + bp.Controls.UpdateWaveformImg(img) + }) +} + func (bp *BottomPanel) CreateRenderer() fyne.WidgetRenderer { bp.ExtendBaseWidget(bp) return widget.NewSimpleRenderer(bp.container) diff --git a/ui/widgets/playercontrols.go b/ui/widgets/playercontrols.go index 619aece..d3a286e 100644 --- a/ui/widgets/playercontrols.go +++ b/ui/widgets/playercontrols.go @@ -1,6 +1,7 @@ package widgets import ( + "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/ui/util" "fyne.io/fyne/v2" @@ -82,6 +83,7 @@ type PlayerControls struct { widget.BaseWidget slider *TrackPosSlider + waveform *WaveformSeekbar curTimeLabel *labelMinSize totalTimeLabel *labelMinSize prev *IconButton @@ -116,6 +118,8 @@ func NewPlayerControls() *PlayerControls { pc.slider = NewTrackPosSlider() pc.slider.Disable() + pc.slider.Hide() + pc.waveform = NewWaveformSeekbar() pc.curTimeLabel = NewLabelMinSize(util.SecondsToMMSS(0), 55) pc.curTimeLabel.Alignment = fyne.TextAlignTrailing pc.totalTimeLabel = NewLabelMinSize(util.SecondsToMMSS(0), 55) @@ -138,7 +142,11 @@ func NewPlayerControls() *PlayerControls { buttons := container.NewHBox(layout.NewSpacer(), pc.prev, pc.playpause, pc.next, layout.NewSpacer()) - c := container.NewBorder(nil, nil, pc.curTimeLabel, pc.totalTimeLabel, pc.slider) + seekCtrl := container.NewStack( + pc.slider, + pc.waveform, + ) + c := container.NewBorder(nil, nil, pc.curTimeLabel, pc.totalTimeLabel, seekCtrl) pc.container = container.New(layout.NewCustomPaddedVBoxLayout(0), c, buttons) return pc @@ -203,10 +211,15 @@ func (pc *PlayerControls) UpdatePlayTime(curTime, totalTime float64) { if updated { // Only update slider once a second when time label changes pc.slider.SetValue(v) + pc.waveform.SetProgress(v) } } } +func (p *PlayerControls) UpdateWaveformImg(img *backend.WaveformImage) { + p.waveform.UpdateImage(img) +} + func (p *PlayerControls) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(p.container) } diff --git a/ui/widgets/waveformseekbar.go b/ui/widgets/waveformseekbar.go new file mode 100644 index 0000000..9c2b81d --- /dev/null +++ b/ui/widgets/waveformseekbar.go @@ -0,0 +1,109 @@ +package widgets + +import ( + "image" + "image/color" + "log" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend" +) + +type WaveformSeekbar struct { + widget.BaseWidget + + imgColorL color.Color + imgColorR color.Color + imgProgress float64 + + img *canvas.Image +} + +func NewWaveformSeekbar() *WaveformSeekbar { + w := &WaveformSeekbar{ + img: canvas.NewImageFromImage(nil), + } + w.ExtendBaseWidget(w) + return w +} + +func (w *WaveformSeekbar) UpdateImage(img *backend.WaveformImage) { + w.img.Image = img + w.Refresh() +} + +func (w *WaveformSeekbar) Refresh() { + prm, fg := w.getThemeColors() + w.recolorImage(prm, fg, w.imgProgress) + w.img.Refresh() + //w.BaseWidget.Refresh() +} + +func (w *WaveformSeekbar) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer( + container.New(layout.NewCustomPaddedLayout(4, 4, 0, 0), w.img), + ) +} + +// SetProgress sets how much of the seekbar has been played +// (ratio from 0 to 1) +func (w *WaveformSeekbar) SetProgress(v float64) { + prm, fg := w.getThemeColors() + w.recolorImage(prm, fg, v) + w.Refresh() +} + +func (w *WaveformSeekbar) getThemeColors() (primary, foreground color.Color) { + th := w.Theme() + vnt := fyne.CurrentApp().Settings().ThemeVariant() + primary = th.Color(theme.ColorNamePrimary, vnt) + foreground = th.Color(theme.ColorNameForeground, vnt) + return primary, foreground +} + +func (w *WaveformSeekbar) recolorImage(cL, cR color.Color, progress float64) { + if w.img.Image == nil { + return + } + if w.imgColorL == cL && w.imgColorR == cR && w.imgProgress == progress { + return + } + + _r, _g, _b, _ := cL.RGBA() + rL, gL, bL := byte(_r>>8), byte(_g>>8), byte(_b>>8) + _r, _g, _b, _ = cR.RGBA() + rR, gR, bR := byte(_r>>8), byte(_g>>8), byte(_b>>8) + + // TODO- smartly figure out which pixels we need + // to update for different scenarios (e.g. progress update only) + // and not iterate the whole thing every time + img := w.img.Image.(*image.NRGBA) + bnds := img.Rect.Bounds() + thresholdPixel := int(float64(bnds.Dx()) * progress) + log.Println("progress = ", progress, " bounds Dx = ", bnds.Dx()) + log.Println("thresholdPixel = ", thresholdPixel) + for x := 0; x < bnds.Dx(); x++ { + for y := 0; y < bnds.Dy(); y++ { + if x < thresholdPixel { + setPixelRGB(img, x, y, rL, gL, bL) + } else { + setPixelRGB(img, x, y, rR, gR, bR) + } + } + } + + w.imgColorL, w.imgColorR = cL, cR + w.imgProgress = progress +} + +func setPixelRGB(img *image.NRGBA, x, y int, r, g, b byte) { + offset := img.PixOffset(x, y) + img.Pix[offset+0] = r + img.Pix[offset+1] = g + img.Pix[offset+2] = b +}