diff --git a/res/translations/de.json b/res/translations/de.json index 6c9c9ed..09691c8 100644 --- a/res/translations/de.json +++ b/res/translations/de.json @@ -239,5 +239,10 @@ "Sept": "Sept", "Oct": "Okt", "Nov": "Nov", - "Dec": "Dec" + "Dec": "Dec", + + "playlist.addedtracks": { + "one": "Added {{.trackCount}} track to playlist", + "other": "Added {{.trackCount}} tracks to playlist" + } } diff --git a/res/translations/en.json b/res/translations/en.json index b84bb00..bf7b438 100644 --- a/res/translations/en.json +++ b/res/translations/en.json @@ -17,6 +17,7 @@ "All": "All", "All Tracks": "All Tracks", "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", "Artist": "Artist", "Artist (A-Z)": "Artist (A-Z)", @@ -69,6 +70,7 @@ "Enter": "Enter", "EP": "EP", "Equalizer": "Equalizer", + "Error": "Error", "Exclusive mode": "Exclusive mode", "Favorites": "Favorites", "Field Recording": "Field Recording", @@ -188,6 +190,7 @@ "Spoken Word": "Spoken Word", "Startup page": "Startup page", "Stopped": "Stopped", + "Success": "Success", "Support the project": "Support the project", "Switch Servers": "Switch Servers", "Testing connection": "Testing connection", @@ -242,5 +245,10 @@ "Sept": "Sept", "Oct": "Oct", "Nov": "Nov", - "Dec": "Dec" + "Dec": "Dec", + + "playlist.addedtracks": { + "one": "Added one track to playlist", + "other": "Added {{.trackCount}} tracks to playlist" + } } diff --git a/res/translations/es.json b/res/translations/es.json index 7830ff5..2a2bf92 100644 --- a/res/translations/es.json +++ b/res/translations/es.json @@ -17,6 +17,7 @@ "All": "Todos", "All Tracks": "Todas las pistas", "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?", "Artist": "Artista", "Artist (A-Z)": "Artista (A-Z)", @@ -69,6 +70,7 @@ "Enter": "Ingresar", "EP": "EP", "Equalizer": "Ecualizador", + "Error": "Error", "Exclusive mode": "Modo exclusivo", "Favorites": "Favoritos", "Field Recording": "Grabación en campo", @@ -187,6 +189,7 @@ "Spoken Word": "Palabra Hablada", "Startup page": "Página de inicio", "Stopped": "Detenida", + "Success": "Éxito", "Support the project": "Apoya el proyecto", "Switch Servers": "Cambiar servidores", "Testing connection": "Probando conexión", @@ -241,5 +244,10 @@ "Sept": "Set", "Oct": "Oct", "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" + } } diff --git a/res/translations/fr.json b/res/translations/fr.json index e572944..d886c16 100644 --- a/res/translations/fr.json +++ b/res/translations/fr.json @@ -240,5 +240,10 @@ "Sept": "Sept", "Oct": "Oct", "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" + } } diff --git a/res/translations/it.json b/res/translations/it.json index 2d657f1..a702371 100644 --- a/res/translations/it.json +++ b/res/translations/it.json @@ -238,5 +238,10 @@ "Sept": "Set", "Oct": "Ott", "Nov": "Nov", - "Dec": "Dic" + "Dec": "Dic", + + "playlist.addedtracks": { + "one": "Added {{.trackCount}} track to playlist", + "other": "Added {{.trackCount}} tracks to playlist" + } } diff --git a/res/translations/ja.json b/res/translations/ja.json index 2898c57..6d7eb54 100644 --- a/res/translations/ja.json +++ b/res/translations/ja.json @@ -228,5 +228,10 @@ "Remove from queue": "キューから削除", "Play song radio": "曲をラジオで再生", "to":"to", - "by":"by" + "by":"by", + + "playlist.addedtracks": { + "one": "Added {{.trackCount}} track to playlist", + "other": "Added {{.trackCount}} tracks to playlist" + } } diff --git a/res/translations/pl.json b/res/translations/pl.json index a920e60..edf39cc 100644 --- a/res/translations/pl.json +++ b/res/translations/pl.json @@ -228,5 +228,10 @@ "Remove from queue": "Usuń z kolejki odtwarzania", "Play song radio": "Odtwórz mix utworów", "to":"do", - "by":"przez" + "by":"przez", + + "playlist.addedtracks": { + "one": "Added {{.trackCount}} track to playlist", + "other": "Added {{.trackCount}} tracks to playlist" + } } diff --git a/res/translations/pt_BR.json b/res/translations/pt_BR.json index 6555288..4d63bbb 100644 --- a/res/translations/pt_BR.json +++ b/res/translations/pt_BR.json @@ -228,5 +228,10 @@ "Remove from queue": "Remover da fila", "Play song radio": "Reproduzir rádio da faixa", "to":"a", - "by":"de" + "by":"de", + + "playlist.addedtracks": { + "one": "Added {{.trackCount}} track to playlist", + "other": "Added {{.trackCount}} tracks to playlist" + } } diff --git a/res/translations/ro.json b/res/translations/ro.json index cd0b9d7..dc07566 100644 --- a/res/translations/ro.json +++ b/res/translations/ro.json @@ -230,5 +230,10 @@ "Sept": "Sept", "Oct": "Oct", "Nov": "Noiem", - "Dec": "Dec" + "Dec": "Dec", + + "playlist.addedtracks": { + "one": "Added {{.trackCount}} track to playlist", + "other": "Added {{.trackCount}} tracks to playlist" + } } diff --git a/res/translations/zh.json b/res/translations/zh.json index a71f69b..7102cff 100644 --- a/res/translations/zh.json +++ b/res/translations/zh.json @@ -216,5 +216,10 @@ "Unset favorite": "取消收藏", "Remove from queue": "从队列中移除", "Size": "大小", - "Play song radio": "播放歌曲电台" + "Play song radio": "播放歌曲电台", + + "playlist.addedtracks": { + "one": "Added {{.trackCount}} track to playlist", + "other": "Added {{.trackCount}} tracks to playlist" + } } diff --git a/res/translations/zhHans.json b/res/translations/zhHans.json index cb3e9b0..727b0f6 100644 --- a/res/translations/zhHans.json +++ b/res/translations/zhHans.json @@ -216,5 +216,10 @@ "Unset favorite": "取消收藏", "Remove from queue": "从队列中移除", "Size": "大小", - "Play song radio": "播放歌曲电台" + "Play song radio": "播放歌曲电台", + + "playlist.addedtracks": { + "one": "Added {{.trackCount}} track to playlist", + "other": "Added {{.trackCount}} tracks to playlist" + } } diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 8909621..9b1bee7 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "sync" "time" @@ -39,17 +40,25 @@ type NavigationHandler func(Route) type CurPageFunc func() Route +type ToastProvider interface { + ShowSuccessToast(string) + ShowErrorToast(string) +} + type Controller struct { visualizationData - AppVersion string - App *backend.App - MainWindow fyne.Window + AppVersion string + App *backend.App + MainWindow fyne.Window + + // dependencies injected from MainWindow NavHandler NavigationHandler CurPageFunc CurPageFunc ReloadFunc func() RefreshPageFunc func() SelectAllPageFunc func() UnselectAllPageFunc func() + ToastProvider ToastProvider popUpQueueMutex sync.Mutex popUpQueue *widget.PopUp @@ -297,10 +306,28 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) { m.doModalClosed() }) 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() m.App.Config.Application.AddToPlaylistSkipDuplicates = sp.SkipDuplicates 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 { m.App.Config.Application.DefaultPlaylistID = id if sp.SkipDuplicates { @@ -308,6 +335,7 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) { currentTrackIDs := make(map[string]struct{}) if selectedPlaylist, err := m.App.ServerManager.Server.GetPlaylist(id); err != nil { log.Printf("error getting playlist: %s", err.Error()) + notifyError() } else { for _, track := range selectedPlaylist.Tracks { currentTrackIDs[track.ID] = struct{}{} @@ -316,11 +344,25 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) { _, ok := currentTrackIDs[trackID] 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 { - 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() + } + }() } } diff --git a/ui/mainwindow.go b/ui/mainwindow.go index ad852c7..c8f1f89 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -34,6 +34,7 @@ type MainWindow struct { Controller *controller.Controller BrowsingPane *browsing.BrowsingPane BottomPanel *BottomPanel + ToastOverlay *ToastOverlay theme *theme.MyTheme haveSystemTray bool @@ -61,6 +62,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, } m.Controller = controller.New(app, appVersion, m.Window) 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) // inject controller dependencies 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.SelectAllPageFunc = m.BrowsingPane.SelectAll m.Controller.UnselectAllPageFunc = m.BrowsingPane.UnselectAll + m.Controller.ToastProvider = m.ToastOverlay if runtime.GOOS == "darwin" { // 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.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.Window.SetContent(fynetooltip.AddWindowToolTipLayer(m.content, m.Window.Canvas())) m.setInitialSize() + m.Window.SetCloseIntercept(func() { m.SaveWindowSize() // save settings in case we crash during shutdown diff --git a/ui/toastoverlay.go b/ui/toastoverlay.go new file mode 100644 index 0000000..d480190 --- /dev/null +++ b/ui/toastoverlay.go @@ -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) +}