From 6ea08cd73081a3b2f39793edf8c8c0da3255b83a Mon Sep 17 00:00:00 2001 From: Adriano Caloiaro Date: Wed, 4 Mar 2020 18:03:16 -0500 Subject: [PATCH] Add a status pane for temporarily displaying status messages --- app/app.go | 7 +-- components/components.go | 6 +++ context/context.go | 27 +++++++++- di-tui.go | 48 +++++++++++++----- difm/difm.go | 40 ++++++++------- views/views.go | 106 +++++++++++++++++++++++++-------------- 6 files changed, 160 insertions(+), 74 deletions(-) diff --git a/app/app.go b/app/app.go index cf8f024..37057fa 100644 --- a/app/app.go +++ b/app/app.go @@ -1,8 +1,8 @@ package app import ( + "fmt" "io/ioutil" - "log" "net/http" "time" @@ -27,8 +27,8 @@ func PlayChannel(chn *components.ChannelItem, ctx *context.AppContext) { client := &http.Client{} req, _ := http.NewRequest("GET", chn.Playlist, nil) resp, err := client.Do(req) - if err != nil { - log.Fatal(err) + if err != nil || resp.StatusCode != 200 { + ctx.SetStatusMessage(fmt.Sprintf("Unable to stream channel: %s", chn.Name)) } defer resp.Body.Close() @@ -65,6 +65,7 @@ func TogglePause(ctx *context.AppContext) { ctx.IsPlaying = !ctx.IsPlaying } +// UpdateNowPlaying updates the application's now playing view with the currently playing channel func UpdateNowPlaying(chn *components.ChannelItem, ctx *context.AppContext) { ctx.CurrentChannel = chn cp := difm.GetCurrentlyPlaying(ctx) diff --git a/components/components.go b/components/components.go index de670f2..a130338 100644 --- a/components/components.go +++ b/components/components.go @@ -30,3 +30,9 @@ type Track struct { Duration float64 `json:"duration"` StartTime time.Time `json:"start_time"` } + +// StatusMessage is a message to display in the application for a fixed period of time +type StatusMessage struct { + Message string + Duration time.Duration +} diff --git a/context/context.go b/context/context.go index 39268a3..1162969 100644 --- a/context/context.go +++ b/context/context.go @@ -1,6 +1,8 @@ package context import ( + "time" + "github.com/acaloiaro/di-tui/components" "github.com/acaloiaro/di-tui/views" "github.com/faiface/beep" @@ -8,17 +10,40 @@ import ( // CreateAppContext creates the application context func CreateAppContext(view *views.ViewContext) *AppContext { - ctx := &AppContext{View: view} + ctx := &AppContext{ + View: view, + StatusChannel: make(chan components.StatusMessage, 1), + } return ctx } // AppContext is a shared context to be shared across the application +// AudioStream - The raw audio stream used by the beep library to stream audio +// CurrentChannel - The ChannelItem representing the currently playing di.fm channel +// DfmToken - The token used to authenticate to di.fm +// IsPlaying - Is there audio playing? +// SpeakerInitialized - Has the speaker been initialized with a bitrate? +// View - The view context +// Status - Gets and sets current application status messages type AppContext struct { AudioStream beep.StreamSeekCloser CurrentChannel *components.ChannelItem DifmToken string IsPlaying bool + ShowStatus bool // The status pane will be visible when true SpeakerInitialized bool + StatusChannel chan components.StatusMessage View *views.ViewContext } + +// SetStatusMessage sets the application's status message for three seconds. +func (c *AppContext) SetStatusMessage(msg string) { + c.SetStatusMessageTimed(msg, 5*time.Second) +} + +// SetStatusMessageTimed sets the application's status message for a fixed period of time. +func (c *AppContext) SetStatusMessageTimed(msg string, d time.Duration) { + c.ShowStatus = true + c.StatusChannel <- components.StatusMessage{Message: msg, Duration: d} +} diff --git a/di-tui.go b/di-tui.go index c641605..accc54d 100644 --- a/di-tui.go +++ b/di-tui.go @@ -39,7 +39,7 @@ func main() { os.Exit(1) } - ctx = context.CreateAppContext(views.CreateAppView()) + ctx = context.CreateAppContext(views.CreateViewContext()) ctx.DifmToken = token run() @@ -47,20 +47,19 @@ func main() { func run() { configureEventHandling() + updateScreenLayout() configureUIComponents() - layout := buildUILayout() - err := ctx.View.App. - SetRoot(layout, true). - SetFocus(ctx.View.FavoriteList). - Run() + err := ctx.View.App.Run() if err != nil { panic(err) } } -func buildUILayout() *tview.Flex { +func updateScreenLayout() { + focusView := ctx.View.App.GetFocus() + main := tview.NewFlex() main.SetDirection(tview.FlexRow) @@ -69,20 +68,29 @@ func buildUILayout() *tview.Flex { AddItem(ctx.View.ChannelList, 0, 2, false). SetDirection(tview.FlexRow) - flex := tview.NewFlex() - flex. + primaryView := tview.NewFlex() + primaryView. AddItem(favsAndChannels, 30, 0, false). AddItem(ctx.View.NowPlaying, 0, 4, false) + if ctx.ShowStatus { + main.AddItem(ctx.View.Status, 4, 0, false) + } + main. - AddItem(flex, 0, 3, false). + AddItem(primaryView, 0, 3, false). AddItem(ctx.View.Keybindings, 4, 0, false) - return main + if focusView == nil { + focusView = ctx.View.FavoriteList + } + + ctx.View.App. + SetRoot(main, true). + SetFocus(focusView) } func configureEventHandling() { - ctx.View.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { focus := ctx.View.App.GetFocus().(*tview.List) @@ -130,12 +138,26 @@ func configureEventHandling() { ctx.View.App.QueueUpdateDraw(func() {}) } }() + + // display the status pane when new status messages arrive + go func() { + for { + status := <-ctx.StatusChannel + ctx.View.Status.Message = status.Message + updateScreenLayout() // add the status pane to the screen + + <-time.Tick(status.Duration) + ctx.ShowStatus = false + updateScreenLayout() // remove the status pane from the screen + } + }() + } func configureUIComponents() { // configure the channel list - channels := difm.ListChannels() + channels := difm.ListChannels(ctx) for _, chn := range channels { ctx.View.ChannelList.AddItem(chn.Name, "", 0, func() { chn := channels[ctx.View.ChannelList.GetCurrentItem()] diff --git a/difm/difm.go b/difm/difm.go index 6d29014..0b8f6ed 100644 --- a/difm/difm.go +++ b/difm/difm.go @@ -81,16 +81,17 @@ func GetCurrentlyPlaying(ctx *context.AppContext) (currentlyPlaying components.C client := &http.Client{} req, _ := http.NewRequest("GET", "https://www.di.fm/_papi/v1/di/currently_playing", nil) resp, err := client.Do(req) - if err != nil { + if err != nil || resp.StatusCode != 200 { + ctx.SetStatusMessage("Unable to fetch currently playing track info.") return } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) - if err != nil { - // TODO: Don't exit here. Once there's a status message area in the app, populate it with the error - log.Println("unable to list channels", err.Error()) - os.Exit(1) + if err != nil || resp.StatusCode != 200 { + ctx.SetStatusMessage("Unable to fetch currently playing track info.") + + return } var currentlyPlayingStations []components.CurrentlyPlaying @@ -106,7 +107,7 @@ func GetCurrentlyPlaying(ctx *context.AppContext) (currentlyPlaying components.C } // ListChannels lists all premium MP3 channels -func ListChannels() (channels []components.ChannelItem) { +func ListChannels(ctx *context.AppContext) (channels []components.ChannelItem) { client := &http.Client{} req, _ := http.NewRequest("GET", "http://listen.di.fm/premium_high", nil) resp, err := client.Do(req) @@ -117,15 +118,16 @@ func ListChannels() (channels []components.ChannelItem) { body, err := ioutil.ReadAll(resp.Body) if err != nil { - // TODO: Don't exit here. Once there's a status message area in the app, populate it with the error - log.Println("unable to list channels", err.Error()) - os.Exit(1) + msg := fmt.Sprintf("Unable to fetch the list of channels: %s", err.Error()) + ctx.SetStatusMessage(msg) + return } err = json.Unmarshal(body, &channels) if err != nil { - // TODO: Don't exit here. Once there's a status message area in the app, populate it with the error - log.Panicf("unable to fetch channel list: %e", err) + msg := fmt.Sprintf("Unable to fetch the list of channels: %s", err.Error()) + ctx.SetStatusMessage(msg) + return } return @@ -146,8 +148,8 @@ func ListFavorites(ctx *context.AppContext) (favorites []components.FavoriteItem body, err := ioutil.ReadAll(resp.Body) cfg, err := ini.Load(body) if err != nil { - // TODO: show an message in a status UI element of the app - fmt.Printf("favorite list parsing failed: %v\n", err.Error()) + msg := fmt.Sprintf("There was a problem fetching your favorites: %s", err.Error()) + ctx.SetStatusMessage(msg) return } @@ -172,16 +174,16 @@ func Stream(url string, ctx *context.AppContext) (format beep.Format) { req, _ := http.NewRequest("GET", u, nil) resp, err := client.Do(req) if err != nil { - // TODO: Don't exit here. Once there's a status message area in the app, populate it with the error - log.Println("unable to stream channel", err.Error()) - os.Exit(1) + msg := fmt.Sprintf("There was a problem streaming this channel channels: %s", err.Error()) + ctx.SetStatusMessage(msg) + return } ctx.AudioStream, format, err = mp3.Decode(resp.Body) if err != nil { - // TODO: Don't exit here. Once there's a status message area in the app, populate it with the error - log.Println("unable to stream channel:", resp.StatusCode) - os.Exit(1) + msg := fmt.Sprintf("There was a problem streaming this channel channels: %s", err.Error()) + ctx.SetStatusMessage(msg) + return } return diff --git a/views/views.go b/views/views.go index 5d61368..2725ee3 100644 --- a/views/views.go +++ b/views/views.go @@ -13,8 +13,9 @@ type ViewContext struct { App *tview.Application ChannelList *tview.List FavoriteList *tview.List - NowPlaying *NowPlayingView Keybindings *KeybindingView + NowPlaying *NowPlayingView + Status *StatusView } // KeybindingView is a custom view for dispalying the keyboard bindings available to users @@ -25,41 +26,56 @@ type KeybindingView struct { // UIKeybinding is a helper struct for building a ControlsView type UIKeybinding struct { - Shortcut string // a keybinding to bind to this control Description string // a description of what the keybinding does Func func() // the funcion to execute when the binding is pressed -} - -// CreateAppView creates the primary application view of di-tui -func CreateAppView() *ViewContext { - return &ViewContext{ - App: tview.NewApplication(), - ChannelList: createChannelList(), - FavoriteList: createFavoriteList(), - NowPlaying: newNowPlaying(), - Keybindings: createKeybindings(), - } + Shortcut string // a keybinding to bind to this control } // NowPlayingView is a custom view for dispalying the currently playing channel type NowPlayingView struct { *tview.Box Channel *components.ChannelItem - Track components.Track Elapsed float64 + Track components.Track } -func newNowPlaying() *NowPlayingView { - np := &NowPlayingView{ - Box: tview.NewBox(), - Channel: &components.ChannelItem{}, - Elapsed: 0.0, +// StatusView shows temporary status messages in the application +type StatusView struct { + *tview.Box + Message string +} + +// CreateViewContext creates the primary application view of di-tui +func CreateViewContext() *ViewContext { + return &ViewContext{ + App: tview.NewApplication(), + ChannelList: createChannelList(), + FavoriteList: createFavoriteList(), + Keybindings: createKeybindings(), + NowPlaying: createNowPlaying(), + Status: createStatusView(), } +} - np.SetTitle(" Now Playing ") - np.SetBorder(true) +// Draw draws the key bindings view on to the screen +func (n *KeybindingView) Draw(screen tcell.Screen) { + n.Box.Draw(screen) + x, y, width, _ := n.GetInnerRect() - return np + previousWidth := 0 + for j, bnd := range n.Bindings { + line := fmt.Sprintf("(%s)[white] %s", bnd.Shortcut, bnd.Description) + tview.Print(screen, line, x+previousWidth, y, width, tview.AlignLeft, tcell.ColorBlue) + previousWidth += len(bnd.Shortcut) + len(bnd.Description) + 4 + + // virtically separate playback controls from ui controls + // yes, this is hacky, but there's a comment, so it's ok, right? + // TODO clean up this mess + if j == 4 { + y = y + 1 + previousWidth = 0 + } + } } // Draw draws a NowPlayingView onto the scren @@ -86,24 +102,14 @@ func (n *NowPlayingView) Draw(screen tcell.Screen) { tview.Print(screen, line, x, y+3, width, tview.AlignLeft, tcell.ColorBlue) } -// Draw draws the key bindings view on to the screen -func (n *KeybindingView) Draw(screen tcell.Screen) { - n.Box.Draw(screen) - x, y, width, _ := n.GetInnerRect() +// Draw draws a NowPlayingView onto the scren +func (s *StatusView) Draw(screen tcell.Screen) { - previousWidth := 0 - for j, bnd := range n.Bindings { - line := fmt.Sprintf("(%s)[white] %s", bnd.Shortcut, bnd.Description) - tview.Print(screen, line, x+previousWidth, y, width, tview.AlignLeft, tcell.ColorBlue) - previousWidth += len(bnd.Shortcut) + len(bnd.Description) + 4 + s.Box.Draw(screen) + x, y, width, _ := s.GetInnerRect() - // virtically separate playback controls from ui controls - // yes, this is hacky, but there's a comment, so it's ok, right? - if j == 4 { - y = y + 1 - previousWidth = 0 - } - } + line := fmt.Sprintf("%s[white] %s", "Message:", s.Message) + tview.Print(screen, line, x, y, width, tview.AlignLeft, tcell.ColorBlue) } func createChannelList() *tview.List { @@ -134,3 +140,27 @@ func createKeybindings() *KeybindingView { return kbv } + +func createNowPlaying() *NowPlayingView { + np := &NowPlayingView{ + Box: tview.NewBox(), + Channel: &components.ChannelItem{}, + Elapsed: 0.0, + } + + np.SetTitle(" Now Playing ") + np.SetBorder(true) + + return np +} + +func createStatusView() *StatusView { + sv := &StatusView{ + Box: tview.NewBox(), + Message: "Ready to Play", + } + sv.SetTitle(" Status ") + sv.SetBorder(true) + + return sv +}