Merge pull request #524 from dweymouth/feature/toasts

Show success/error "toast" message when adding tracks to playlists
This commit is contained in:
Drew Weymouth
2025-01-05 17:29:21 -08:00
committed by GitHub
14 changed files with 395 additions and 18 deletions
+6 -1
View File
@@ -239,5 +239,10 @@
"Sept": "Sept", "Sept": "Sept",
"Oct": "Okt", "Oct": "Okt",
"Nov": "Nov", "Nov": "Nov",
"Dec": "Dec" "Dec": "Dec",
"playlist.addedtracks": {
"one": "Added {{.trackCount}} track to playlist",
"other": "Added {{.trackCount}} tracks to playlist"
}
} }
+9 -1
View File
@@ -17,6 +17,7 @@
"All": "All", "All": "All",
"All Tracks": "All Tracks", "All Tracks": "All Tracks",
"Alt. URL": "Alt. URL", "Alt. URL": "Alt. URL",
"An error occurred adding tracks to the playlist": "An error occurred adding tracks to the playlist",
"Are you sure you want to delete the server": "Are you sure you want to delete the server", "Are you sure you want to delete the server": "Are you sure you want to delete the server",
"Artist": "Artist", "Artist": "Artist",
"Artist (A-Z)": "Artist (A-Z)", "Artist (A-Z)": "Artist (A-Z)",
@@ -69,6 +70,7 @@
"Enter": "Enter", "Enter": "Enter",
"EP": "EP", "EP": "EP",
"Equalizer": "Equalizer", "Equalizer": "Equalizer",
"Error": "Error",
"Exclusive mode": "Exclusive mode", "Exclusive mode": "Exclusive mode",
"Favorites": "Favorites", "Favorites": "Favorites",
"Field Recording": "Field Recording", "Field Recording": "Field Recording",
@@ -188,6 +190,7 @@
"Spoken Word": "Spoken Word", "Spoken Word": "Spoken Word",
"Startup page": "Startup page", "Startup page": "Startup page",
"Stopped": "Stopped", "Stopped": "Stopped",
"Success": "Success",
"Support the project": "Support the project", "Support the project": "Support the project",
"Switch Servers": "Switch Servers", "Switch Servers": "Switch Servers",
"Testing connection": "Testing connection", "Testing connection": "Testing connection",
@@ -242,5 +245,10 @@
"Sept": "Sept", "Sept": "Sept",
"Oct": "Oct", "Oct": "Oct",
"Nov": "Nov", "Nov": "Nov",
"Dec": "Dec" "Dec": "Dec",
"playlist.addedtracks": {
"one": "Added one track to playlist",
"other": "Added {{.trackCount}} tracks to playlist"
}
} }
+9 -1
View File
@@ -17,6 +17,7 @@
"All": "Todos", "All": "Todos",
"All Tracks": "Todas las pistas", "All Tracks": "Todas las pistas",
"Alt. URL": "URL alternativa", "Alt. URL": "URL alternativa",
"An error occurred adding tracks to the playlist": "Ha ocurrido un error al añadir pistas a la lista de reproducción",
"Are you sure you want to delete the server": "¿Estás seguro de que quieres eliminar el servidor?", "Are you sure you want to delete the server": "¿Estás seguro de que quieres eliminar el servidor?",
"Artist": "Artista", "Artist": "Artista",
"Artist (A-Z)": "Artista (A-Z)", "Artist (A-Z)": "Artista (A-Z)",
@@ -69,6 +70,7 @@
"Enter": "Ingresar", "Enter": "Ingresar",
"EP": "EP", "EP": "EP",
"Equalizer": "Ecualizador", "Equalizer": "Ecualizador",
"Error": "Error",
"Exclusive mode": "Modo exclusivo", "Exclusive mode": "Modo exclusivo",
"Favorites": "Favoritos", "Favorites": "Favoritos",
"Field Recording": "Grabación en campo", "Field Recording": "Grabación en campo",
@@ -187,6 +189,7 @@
"Spoken Word": "Palabra Hablada", "Spoken Word": "Palabra Hablada",
"Startup page": "Página de inicio", "Startup page": "Página de inicio",
"Stopped": "Detenida", "Stopped": "Detenida",
"Success": "Éxito",
"Support the project": "Apoya el proyecto", "Support the project": "Apoya el proyecto",
"Switch Servers": "Cambiar servidores", "Switch Servers": "Cambiar servidores",
"Testing connection": "Probando conexión", "Testing connection": "Probando conexión",
@@ -241,5 +244,10 @@
"Sept": "Set", "Sept": "Set",
"Oct": "Oct", "Oct": "Oct",
"Nov": "Nov", "Nov": "Nov",
"Dec": "Dic" "Dec": "Dic",
"playlist.addedtracks": {
"one": "Se ha añadido una pista a la lista de reproducción",
"other": "Se han añadido {{.trackCount}} pistas a la lista de reproducción"
}
} }
+6 -1
View File
@@ -240,5 +240,10 @@
"Sept": "Sept", "Sept": "Sept",
"Oct": "Oct", "Oct": "Oct",
"Nov": "Nov", "Nov": "Nov",
"Dec": "Déc" "Dec": "Déc",
"playlist.addedtracks": {
"one": "Une piste ajoutée à la liste de lecture",
"other": "{{.trackCount}} pistes ajoutées à la liste de lecture"
}
} }
+6 -1
View File
@@ -238,5 +238,10 @@
"Sept": "Set", "Sept": "Set",
"Oct": "Ott", "Oct": "Ott",
"Nov": "Nov", "Nov": "Nov",
"Dec": "Dic" "Dec": "Dic",
"playlist.addedtracks": {
"one": "Added {{.trackCount}} track to playlist",
"other": "Added {{.trackCount}} tracks to playlist"
}
} }
+6 -1
View File
@@ -228,5 +228,10 @@
"Remove from queue": "キューから削除", "Remove from queue": "キューから削除",
"Play song radio": "曲をラジオで再生", "Play song radio": "曲をラジオで再生",
"to":"to", "to":"to",
"by":"by" "by":"by",
"playlist.addedtracks": {
"one": "Added {{.trackCount}} track to playlist",
"other": "Added {{.trackCount}} tracks to playlist"
}
} }
+6 -1
View File
@@ -228,5 +228,10 @@
"Remove from queue": "Usuń z kolejki odtwarzania", "Remove from queue": "Usuń z kolejki odtwarzania",
"Play song radio": "Odtwórz mix utworów", "Play song radio": "Odtwórz mix utworów",
"to":"do", "to":"do",
"by":"przez" "by":"przez",
"playlist.addedtracks": {
"one": "Added {{.trackCount}} track to playlist",
"other": "Added {{.trackCount}} tracks to playlist"
}
} }
+6 -1
View File
@@ -228,5 +228,10 @@
"Remove from queue": "Remover da fila", "Remove from queue": "Remover da fila",
"Play song radio": "Reproduzir rádio da faixa", "Play song radio": "Reproduzir rádio da faixa",
"to":"a", "to":"a",
"by":"de" "by":"de",
"playlist.addedtracks": {
"one": "Added {{.trackCount}} track to playlist",
"other": "Added {{.trackCount}} tracks to playlist"
}
} }
+6 -1
View File
@@ -230,5 +230,10 @@
"Sept": "Sept", "Sept": "Sept",
"Oct": "Oct", "Oct": "Oct",
"Nov": "Noiem", "Nov": "Noiem",
"Dec": "Dec" "Dec": "Dec",
"playlist.addedtracks": {
"one": "Added {{.trackCount}} track to playlist",
"other": "Added {{.trackCount}} tracks to playlist"
}
} }
+6 -1
View File
@@ -216,5 +216,10 @@
"Unset favorite": "取消收藏", "Unset favorite": "取消收藏",
"Remove from queue": "从队列中移除", "Remove from queue": "从队列中移除",
"Size": "大小", "Size": "大小",
"Play song radio": "播放歌曲电台" "Play song radio": "播放歌曲电台",
"playlist.addedtracks": {
"one": "Added {{.trackCount}} track to playlist",
"other": "Added {{.trackCount}} tracks to playlist"
}
} }
+6 -1
View File
@@ -216,5 +216,10 @@
"Unset favorite": "取消收藏", "Unset favorite": "取消收藏",
"Remove from queue": "从队列中移除", "Remove from queue": "从队列中移除",
"Size": "大小", "Size": "大小",
"Play song radio": "播放歌曲电台" "Play song radio": "播放歌曲电台",
"playlist.addedtracks": {
"one": "Added {{.trackCount}} track to playlist",
"other": "Added {{.trackCount}} tracks to playlist"
}
} }
+48 -6
View File
@@ -11,6 +11,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"sync" "sync"
"time" "time"
@@ -39,17 +40,25 @@ type NavigationHandler func(Route)
type CurPageFunc func() Route type CurPageFunc func() Route
type ToastProvider interface {
ShowSuccessToast(string)
ShowErrorToast(string)
}
type Controller struct { type Controller struct {
visualizationData visualizationData
AppVersion string AppVersion string
App *backend.App App *backend.App
MainWindow fyne.Window MainWindow fyne.Window
// dependencies injected from MainWindow
NavHandler NavigationHandler NavHandler NavigationHandler
CurPageFunc CurPageFunc CurPageFunc CurPageFunc
ReloadFunc func() ReloadFunc func()
RefreshPageFunc func() RefreshPageFunc func()
SelectAllPageFunc func() SelectAllPageFunc func()
UnselectAllPageFunc func() UnselectAllPageFunc func()
ToastProvider ToastProvider
popUpQueueMutex sync.Mutex popUpQueueMutex sync.Mutex
popUpQueue *widget.PopUp popUpQueue *widget.PopUp
@@ -297,10 +306,28 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
m.doModalClosed() m.doModalClosed()
}) })
sp.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) { sp.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) {
notifySuccess := func(n int) {
msg := lang.LocalizePluralKey("playlist.addedtracks",
"Added tracks to playlist", n, map[string]string{"trackCount": strconv.Itoa(n)})
m.ToastProvider.ShowSuccessToast(msg)
}
notifyError := func() {
m.ToastProvider.ShowErrorToast(
lang.L("An error occurred adding tracks to the playlist"),
)
}
pop.Hide() pop.Hide()
m.App.Config.Application.AddToPlaylistSkipDuplicates = sp.SkipDuplicates m.App.Config.Application.AddToPlaylistSkipDuplicates = sp.SkipDuplicates
if id == "" /* creating new playlist */ { if id == "" /* creating new playlist */ {
go m.App.ServerManager.Server.CreatePlaylist(sp.SearchDialog.SearchQuery(), trackIDs) go func() {
err := m.App.ServerManager.Server.CreatePlaylist(sp.SearchDialog.SearchQuery(), trackIDs)
if err == nil {
notifySuccess(len(trackIDs))
} else {
log.Println("error adding tracks to playlist: %s", err.Error())
notifyError()
}
}()
} else { } else {
m.App.Config.Application.DefaultPlaylistID = id m.App.Config.Application.DefaultPlaylistID = id
if sp.SkipDuplicates { if sp.SkipDuplicates {
@@ -308,6 +335,7 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
currentTrackIDs := make(map[string]struct{}) currentTrackIDs := make(map[string]struct{})
if selectedPlaylist, err := m.App.ServerManager.Server.GetPlaylist(id); err != nil { if selectedPlaylist, err := m.App.ServerManager.Server.GetPlaylist(id); err != nil {
log.Printf("error getting playlist: %s", err.Error()) log.Printf("error getting playlist: %s", err.Error())
notifyError()
} else { } else {
for _, track := range selectedPlaylist.Tracks { for _, track := range selectedPlaylist.Tracks {
currentTrackIDs[track.ID] = struct{}{} currentTrackIDs[track.ID] = struct{}{}
@@ -316,11 +344,25 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
_, ok := currentTrackIDs[trackID] _, ok := currentTrackIDs[trackID]
return !ok return !ok
}) })
m.App.ServerManager.Server.AddPlaylistTracks(id, filterTrackIDs) err := m.App.ServerManager.Server.AddPlaylistTracks(id, filterTrackIDs)
if err == nil {
notifySuccess(len(filterTrackIDs))
} else {
log.Println("error adding tracks to playlist: %s", err.Error())
notifyError()
}
} }
}() }()
} else { } else {
go m.App.ServerManager.Server.AddPlaylistTracks(id, trackIDs) go func() {
err := m.App.ServerManager.Server.AddPlaylistTracks(id, trackIDs)
if err == nil {
notifySuccess(len(trackIDs))
} else {
log.Println("error adding tracks to playlist: %s", err.Error())
notifyError()
}
}()
} }
} }
+6 -1
View File
@@ -34,6 +34,7 @@ type MainWindow struct {
Controller *controller.Controller Controller *controller.Controller
BrowsingPane *browsing.BrowsingPane BrowsingPane *browsing.BrowsingPane
BottomPanel *BottomPanel BottomPanel *BottomPanel
ToastOverlay *ToastOverlay
theme *theme.MyTheme theme *theme.MyTheme
haveSystemTray bool haveSystemTray bool
@@ -61,6 +62,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
} }
m.Controller = controller.New(app, appVersion, m.Window) m.Controller = controller.New(app, appVersion, m.Window)
m.BrowsingPane = browsing.NewBrowsingPane(app, m.Controller, func() { m.Router.NavigateTo(m.StartupPage()) }) m.BrowsingPane = browsing.NewBrowsingPane(app, m.Controller, func() { m.Router.NavigateTo(m.StartupPage()) })
m.ToastOverlay = NewToastOverlay()
m.Router = browsing.NewRouter(app, m.Controller, m.BrowsingPane) m.Router = browsing.NewRouter(app, m.Controller, m.BrowsingPane)
// inject controller dependencies // inject controller dependencies
m.Controller.NavHandler = m.Router.NavigateTo m.Controller.NavHandler = m.Router.NavigateTo
@@ -69,6 +71,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
m.Controller.RefreshPageFunc = m.BrowsingPane.RefreshPage m.Controller.RefreshPageFunc = m.BrowsingPane.RefreshPage
m.Controller.SelectAllPageFunc = m.BrowsingPane.SelectAll m.Controller.SelectAllPageFunc = m.BrowsingPane.SelectAll
m.Controller.UnselectAllPageFunc = m.BrowsingPane.UnselectAll m.Controller.UnselectAllPageFunc = m.BrowsingPane.UnselectAll
m.Controller.ToastProvider = m.ToastOverlay
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
// Fyne will extract out an "About" menu item and // Fyne will extract out an "About" menu item and
@@ -137,10 +140,12 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
m.BrowsingPane.DisableNavigationButtons() m.BrowsingPane.DisableNavigationButtons()
m.addShortcuts() m.addShortcuts()
m.content = newMainWindowContent(container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane), center := container.NewStack(m.BrowsingPane, m.ToastOverlay)
m.content = newMainWindowContent(container.NewBorder(nil, m.BottomPanel, nil, nil, center),
m.Controller.UnselectAll) m.Controller.UnselectAll)
m.Window.SetContent(fynetooltip.AddWindowToolTipLayer(m.content, m.Window.Canvas())) m.Window.SetContent(fynetooltip.AddWindowToolTipLayer(m.content, m.Window.Canvas()))
m.setInitialSize() m.setInitialSize()
m.Window.SetCloseIntercept(func() { m.Window.SetCloseIntercept(func() {
m.SaveWindowSize() m.SaveWindowSize()
// save settings in case we crash during shutdown // save settings in case we crash during shutdown
+269
View File
@@ -0,0 +1,269 @@
package ui
import (
"context"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"github.com/dweymouth/supersonic/ui/util"
"github.com/dweymouth/supersonic/ui/widgets"
)
const (
toastAutoDismissalTime = 3 * time.Second
toastAnimationDuration = 100 * time.Millisecond
)
type ToastOverlay struct {
widget.BaseWidget
currentToast *toast
currentToastAnim *fyne.Animation
dismissCancel context.CancelFunc
container *fyne.Container
}
func NewToastOverlay() *ToastOverlay {
t := &ToastOverlay{container: container.NewWithoutLayout()}
t.container.Objects = make([]fyne.CanvasObject, 0, 1)
t.ExtendBaseWidget(t)
return t
}
func (t *ToastOverlay) ShowSuccessToast(message string) {
t.showToast(false, message)
}
func (t *ToastOverlay) ShowErrorToast(message string) {
t.showToast(true, message)
}
func (t *ToastOverlay) showToast(isErr bool, message string) {
t.cancelPreviousToast()
t.currentToast = newToast(isErr, message, t.dismissToast)
t.container.Objects = append(t.container.Objects, t.currentToast)
s := t.Size()
min := t.currentToast.MinSize()
pad := theme.Padding()
t.currentToast.Resize(min)
endPos := fyne.NewPos(s.Width-min.Width-pad, s.Height-min.Height-pad)
startPos := fyne.NewPos(s.Width, endPos.Y)
f := t.makeToastAnimFunc(endPos, false)
t.currentToastAnim = canvas.NewPositionAnimation(startPos, endPos, toastAnimationDuration, f)
t.currentToastAnim.Curve = fyne.AnimationEaseOut
t.currentToastAnim.Start()
t.Refresh()
ctx, cancel := context.WithCancel(context.Background())
t.dismissCancel = cancel // always canceled by dismissToast
go func() {
time.Sleep(toastAutoDismissalTime)
select {
case <-ctx.Done():
return
default:
t.dismissToast()
}
}()
}
func (t *ToastOverlay) makeToastAnimFunc(endPos fyne.Position, dismissal bool) func(fyne.Position) {
return func(p fyne.Position) {
if ct := t.currentToast; ct != nil {
ct.Move(p)
}
if p == endPos {
t.currentToastAnim = nil
if dismissal {
t.cancelPreviousToast()
}
}
}
}
func (t *ToastOverlay) Resize(size fyne.Size) {
if t.currentToast != nil && t.currentToastAnim == nil {
// move to the anchor position
min := t.currentToast.MinSize()
pad := theme.Padding()
t.currentToast.Move(fyne.NewPos(size.Width-min.Width-pad, size.Height-min.Height-pad))
} // else if animation is running -- well, hope this doesn't happen ;)
t.BaseWidget.Resize(size)
}
func (t *ToastOverlay) cancelPreviousToast() {
if t.currentToast == nil {
return
}
if t.currentToastAnim != nil {
t.currentToastAnim.Stop()
t.currentToastAnim = nil
}
t.container.Objects[0] = nil
t.container.Objects = t.container.Objects[:0]
t.currentToast = nil
}
func (t *ToastOverlay) dismissToast() {
if t.currentToast == nil {
return
}
if t.dismissCancel != nil {
t.dismissCancel()
t.dismissCancel = nil
}
if t.currentToastAnim != nil {
t.currentToastAnim.Stop()
}
s := t.Size()
min := t.currentToast.MinSize()
pad := theme.Padding()
startPos := fyne.NewPos(s.Width-min.Width-pad, s.Height-min.Height-pad)
endPos := fyne.NewPos(s.Width, startPos.Y)
f := t.makeToastAnimFunc(endPos, true)
t.currentToastAnim = canvas.NewPositionAnimation(startPos, endPos, toastAnimationDuration, f)
t.currentToastAnim.Curve = fyne.AnimationEaseIn
t.currentToastAnim.Start()
}
func (t *ToastOverlay) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(t.container)
}
type toast struct {
widget.BaseWidget
isErr bool
message string
onDismiss func()
}
func newToast(isErr bool, message string, onDismiss func()) *toast {
t := &toast{isErr: isErr, message: message, onDismiss: onDismiss}
t.ExtendBaseWidget(t)
return t
}
func (t *toast) CreateRenderer() fyne.WidgetRenderer {
return newToastRenderer(t)
}
func (t *toast) Dismiss() {
if t.onDismiss != nil {
t.onDismiss()
}
}
// swallow all tap/mouse events because toast is transparent
var (
_ fyne.Tappable = (*toast)(nil)
_ fyne.SecondaryTappable = (*toast)(nil)
_ desktop.Hoverable = (*toast)(nil)
_ desktop.Mouseable = (*toast)(nil)
)
func (*toast) Tapped(*fyne.PointEvent) {}
func (*toast) TappedSecondary(*fyne.PointEvent) {}
func (*toast) MouseIn(*desktop.MouseEvent) {}
func (*toast) MouseOut() {}
func (*toast) MouseMoved(*desktop.MouseEvent) {}
func (*toast) MouseUp(*desktop.MouseEvent) {}
func (*toast) MouseDown(*desktop.MouseEvent) {}
type toastRenderer struct {
container *fyne.Container
background *canvas.Rectangle
accent *canvas.Rectangle
accentColor fyne.ThemeColorName
}
func newToastRenderer(t *toast) *toastRenderer {
title := lang.L("Success")
accentColor := theme.ColorNamePrimary
if t.isErr {
title = lang.L("Error")
accentColor = theme.ColorNameError
}
th := fyne.CurrentApp().Settings().Theme()
v := fyne.CurrentApp().Settings().ThemeVariant()
background := canvas.NewRectangle(th.Color(theme.ColorNameOverlayBackground, v))
background.CornerRadius = th.Size(theme.SizeNameInputRadius)
background.StrokeColor = th.Color(theme.ColorNameInputBorder, v)
background.StrokeWidth = th.Size(theme.SizeNameInputBorder) * 2
accent := canvas.NewRectangle(th.Color(accentColor, v))
accent.SetMinSize(fyne.NewSize(4, 1))
close := widgets.NewIconButton(theme.CancelIcon(), t.Dismiss)
close.IconSize = widgets.IconButtonSizeSmaller
titleText := widget.NewRichTextWithText(title)
titleText.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyleSubHeading
pad := theme.Padding()
return &toastRenderer{
background: background,
accent: accent,
accentColor: accentColor,
container: container.NewStack(
background,
container.New(&layout.CustomPaddedLayout{
TopPadding: 2 * pad,
BottomPadding: 2 * pad,
LeftPadding: 2 * pad,
RightPadding: pad,
},
container.NewBorder(nil, nil, accent, nil,
container.NewVBox(
container.NewBorder(nil, nil, nil, container.NewHBox(close, util.NewHSpace(2)), titleText),
widget.NewLabel(t.message),
),
),
),
),
}
}
var _ fyne.WidgetRenderer = (*toastRenderer)(nil)
func (*toastRenderer) Destroy() {}
func (t *toastRenderer) Layout(s fyne.Size) {
t.container.Layout.Layout(t.container.Objects, s)
}
func (t *toastRenderer) MinSize() fyne.Size {
return t.container.MinSize()
}
func (t *toastRenderer) Objects() []fyne.CanvasObject {
return t.container.Objects
}
func (t *toastRenderer) Refresh() {
th := fyne.CurrentApp().Settings().Theme()
v := fyne.CurrentApp().Settings().ThemeVariant()
t.background.FillColor = th.Color(theme.ColorNameOverlayBackground, v)
t.background.CornerRadius = th.Size(theme.SizeNameInputRadius)
t.background.StrokeColor = th.Color(theme.ColorNameInputBorder, v)
t.background.StrokeWidth = th.Size(theme.SizeNameInputBorder) * 2
t.background.Refresh()
t.accent.FillColor = th.Color(t.accentColor, v)
t.accent.Refresh()
canvas.Refresh(t.container)
}