Add a status pane for temporarily displaying status messages
This commit is contained in:
parent
3aace117c0
commit
6ea08cd730
6 changed files with 160 additions and 74 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -27,8 +27,8 @@ func PlayChannel(chn *components.ChannelItem, ctx *context.AppContext) {
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
req, _ := http.NewRequest("GET", chn.Playlist, nil)
|
req, _ := http.NewRequest("GET", chn.Playlist, nil)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil || resp.StatusCode != 200 {
|
||||||
log.Fatal(err)
|
ctx.SetStatusMessage(fmt.Sprintf("Unable to stream channel: %s", chn.Name))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
|
@ -65,6 +65,7 @@ func TogglePause(ctx *context.AppContext) {
|
||||||
ctx.IsPlaying = !ctx.IsPlaying
|
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) {
|
func UpdateNowPlaying(chn *components.ChannelItem, ctx *context.AppContext) {
|
||||||
ctx.CurrentChannel = chn
|
ctx.CurrentChannel = chn
|
||||||
cp := difm.GetCurrentlyPlaying(ctx)
|
cp := difm.GetCurrentlyPlaying(ctx)
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,9 @@ type Track struct {
|
||||||
Duration float64 `json:"duration"`
|
Duration float64 `json:"duration"`
|
||||||
StartTime time.Time `json:"start_time"`
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package context
|
package context
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/acaloiaro/di-tui/components"
|
"github.com/acaloiaro/di-tui/components"
|
||||||
"github.com/acaloiaro/di-tui/views"
|
"github.com/acaloiaro/di-tui/views"
|
||||||
"github.com/faiface/beep"
|
"github.com/faiface/beep"
|
||||||
|
|
@ -8,17 +10,40 @@ import (
|
||||||
|
|
||||||
// CreateAppContext creates the application context
|
// CreateAppContext creates the application context
|
||||||
func CreateAppContext(view *views.ViewContext) *AppContext {
|
func CreateAppContext(view *views.ViewContext) *AppContext {
|
||||||
ctx := &AppContext{View: view}
|
ctx := &AppContext{
|
||||||
|
View: view,
|
||||||
|
StatusChannel: make(chan components.StatusMessage, 1),
|
||||||
|
}
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppContext is a shared context to be shared across the application
|
// 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 {
|
type AppContext struct {
|
||||||
AudioStream beep.StreamSeekCloser
|
AudioStream beep.StreamSeekCloser
|
||||||
CurrentChannel *components.ChannelItem
|
CurrentChannel *components.ChannelItem
|
||||||
DifmToken string
|
DifmToken string
|
||||||
IsPlaying bool
|
IsPlaying bool
|
||||||
|
ShowStatus bool // The status pane will be visible when true
|
||||||
SpeakerInitialized bool
|
SpeakerInitialized bool
|
||||||
|
StatusChannel chan components.StatusMessage
|
||||||
View *views.ViewContext
|
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}
|
||||||
|
}
|
||||||
|
|
|
||||||
48
di-tui.go
48
di-tui.go
|
|
@ -39,7 +39,7 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx = context.CreateAppContext(views.CreateAppView())
|
ctx = context.CreateAppContext(views.CreateViewContext())
|
||||||
ctx.DifmToken = token
|
ctx.DifmToken = token
|
||||||
|
|
||||||
run()
|
run()
|
||||||
|
|
@ -47,20 +47,19 @@ func main() {
|
||||||
|
|
||||||
func run() {
|
func run() {
|
||||||
configureEventHandling()
|
configureEventHandling()
|
||||||
|
updateScreenLayout()
|
||||||
configureUIComponents()
|
configureUIComponents()
|
||||||
layout := buildUILayout()
|
|
||||||
|
|
||||||
err := ctx.View.App.
|
err := ctx.View.App.Run()
|
||||||
SetRoot(layout, true).
|
|
||||||
SetFocus(ctx.View.FavoriteList).
|
|
||||||
Run()
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildUILayout() *tview.Flex {
|
func updateScreenLayout() {
|
||||||
|
focusView := ctx.View.App.GetFocus()
|
||||||
|
|
||||||
main := tview.NewFlex()
|
main := tview.NewFlex()
|
||||||
main.SetDirection(tview.FlexRow)
|
main.SetDirection(tview.FlexRow)
|
||||||
|
|
||||||
|
|
@ -69,20 +68,29 @@ func buildUILayout() *tview.Flex {
|
||||||
AddItem(ctx.View.ChannelList, 0, 2, false).
|
AddItem(ctx.View.ChannelList, 0, 2, false).
|
||||||
SetDirection(tview.FlexRow)
|
SetDirection(tview.FlexRow)
|
||||||
|
|
||||||
flex := tview.NewFlex()
|
primaryView := tview.NewFlex()
|
||||||
flex.
|
primaryView.
|
||||||
AddItem(favsAndChannels, 30, 0, false).
|
AddItem(favsAndChannels, 30, 0, false).
|
||||||
AddItem(ctx.View.NowPlaying, 0, 4, false)
|
AddItem(ctx.View.NowPlaying, 0, 4, false)
|
||||||
|
|
||||||
|
if ctx.ShowStatus {
|
||||||
|
main.AddItem(ctx.View.Status, 4, 0, false)
|
||||||
|
}
|
||||||
|
|
||||||
main.
|
main.
|
||||||
AddItem(flex, 0, 3, false).
|
AddItem(primaryView, 0, 3, false).
|
||||||
AddItem(ctx.View.Keybindings, 4, 0, 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() {
|
func configureEventHandling() {
|
||||||
|
|
||||||
ctx.View.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
ctx.View.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
focus := ctx.View.App.GetFocus().(*tview.List)
|
focus := ctx.View.App.GetFocus().(*tview.List)
|
||||||
|
|
||||||
|
|
@ -130,12 +138,26 @@ func configureEventHandling() {
|
||||||
ctx.View.App.QueueUpdateDraw(func() {})
|
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() {
|
func configureUIComponents() {
|
||||||
|
|
||||||
// configure the channel list
|
// configure the channel list
|
||||||
channels := difm.ListChannels()
|
channels := difm.ListChannels(ctx)
|
||||||
for _, chn := range channels {
|
for _, chn := range channels {
|
||||||
ctx.View.ChannelList.AddItem(chn.Name, "", 0, func() {
|
ctx.View.ChannelList.AddItem(chn.Name, "", 0, func() {
|
||||||
chn := channels[ctx.View.ChannelList.GetCurrentItem()]
|
chn := channels[ctx.View.ChannelList.GetCurrentItem()]
|
||||||
|
|
|
||||||
40
difm/difm.go
40
difm/difm.go
|
|
@ -81,16 +81,17 @@ func GetCurrentlyPlaying(ctx *context.AppContext) (currentlyPlaying components.C
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
req, _ := http.NewRequest("GET", "https://www.di.fm/_papi/v1/di/currently_playing", nil)
|
req, _ := http.NewRequest("GET", "https://www.di.fm/_papi/v1/di/currently_playing", nil)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil || resp.StatusCode != 200 {
|
||||||
|
ctx.SetStatusMessage("Unable to fetch currently playing track info.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil || resp.StatusCode != 200 {
|
||||||
// TODO: Don't exit here. Once there's a status message area in the app, populate it with the error
|
ctx.SetStatusMessage("Unable to fetch currently playing track info.")
|
||||||
log.Println("unable to list channels", err.Error())
|
|
||||||
os.Exit(1)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentlyPlayingStations []components.CurrentlyPlaying
|
var currentlyPlayingStations []components.CurrentlyPlaying
|
||||||
|
|
@ -106,7 +107,7 @@ func GetCurrentlyPlaying(ctx *context.AppContext) (currentlyPlaying components.C
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListChannels lists all premium MP3 channels
|
// ListChannels lists all premium MP3 channels
|
||||||
func ListChannels() (channels []components.ChannelItem) {
|
func ListChannels(ctx *context.AppContext) (channels []components.ChannelItem) {
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
req, _ := http.NewRequest("GET", "http://listen.di.fm/premium_high", nil)
|
req, _ := http.NewRequest("GET", "http://listen.di.fm/premium_high", nil)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
|
@ -117,15 +118,16 @@ func ListChannels() (channels []components.ChannelItem) {
|
||||||
|
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: Don't exit here. Once there's a status message area in the app, populate it with the error
|
msg := fmt.Sprintf("Unable to fetch the list of channels: %s", err.Error())
|
||||||
log.Println("unable to list channels", err.Error())
|
ctx.SetStatusMessage(msg)
|
||||||
os.Exit(1)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(body, &channels)
|
err = json.Unmarshal(body, &channels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: Don't exit here. Once there's a status message area in the app, populate it with the error
|
msg := fmt.Sprintf("Unable to fetch the list of channels: %s", err.Error())
|
||||||
log.Panicf("unable to fetch channel list: %e", err)
|
ctx.SetStatusMessage(msg)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
@ -146,8 +148,8 @@ func ListFavorites(ctx *context.AppContext) (favorites []components.FavoriteItem
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
cfg, err := ini.Load(body)
|
cfg, err := ini.Load(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: show an message in a status UI element of the app
|
msg := fmt.Sprintf("There was a problem fetching your favorites: %s", err.Error())
|
||||||
fmt.Printf("favorite list parsing failed: %v\n", err.Error())
|
ctx.SetStatusMessage(msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,16 +174,16 @@ func Stream(url string, ctx *context.AppContext) (format beep.Format) {
|
||||||
req, _ := http.NewRequest("GET", u, nil)
|
req, _ := http.NewRequest("GET", u, nil)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: Don't exit here. Once there's a status message area in the app, populate it with the error
|
msg := fmt.Sprintf("There was a problem streaming this channel channels: %s", err.Error())
|
||||||
log.Println("unable to stream channel", err.Error())
|
ctx.SetStatusMessage(msg)
|
||||||
os.Exit(1)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.AudioStream, format, err = mp3.Decode(resp.Body)
|
ctx.AudioStream, format, err = mp3.Decode(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: Don't exit here. Once there's a status message area in the app, populate it with the error
|
msg := fmt.Sprintf("There was a problem streaming this channel channels: %s", err.Error())
|
||||||
log.Println("unable to stream channel:", resp.StatusCode)
|
ctx.SetStatusMessage(msg)
|
||||||
os.Exit(1)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
|
||||||
106
views/views.go
106
views/views.go
|
|
@ -13,8 +13,9 @@ type ViewContext struct {
|
||||||
App *tview.Application
|
App *tview.Application
|
||||||
ChannelList *tview.List
|
ChannelList *tview.List
|
||||||
FavoriteList *tview.List
|
FavoriteList *tview.List
|
||||||
NowPlaying *NowPlayingView
|
|
||||||
Keybindings *KeybindingView
|
Keybindings *KeybindingView
|
||||||
|
NowPlaying *NowPlayingView
|
||||||
|
Status *StatusView
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeybindingView is a custom view for dispalying the keyboard bindings available to users
|
// 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
|
// UIKeybinding is a helper struct for building a ControlsView
|
||||||
type UIKeybinding struct {
|
type UIKeybinding struct {
|
||||||
Shortcut string // a keybinding to bind to this control
|
|
||||||
Description string // a description of what the keybinding does
|
Description string // a description of what the keybinding does
|
||||||
Func func() // the funcion to execute when the binding is pressed
|
Func func() // the funcion to execute when the binding is pressed
|
||||||
}
|
Shortcut string // a keybinding to bind to this control
|
||||||
|
|
||||||
// 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(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NowPlayingView is a custom view for dispalying the currently playing channel
|
// NowPlayingView is a custom view for dispalying the currently playing channel
|
||||||
type NowPlayingView struct {
|
type NowPlayingView struct {
|
||||||
*tview.Box
|
*tview.Box
|
||||||
Channel *components.ChannelItem
|
Channel *components.ChannelItem
|
||||||
Track components.Track
|
|
||||||
Elapsed float64
|
Elapsed float64
|
||||||
|
Track components.Track
|
||||||
}
|
}
|
||||||
|
|
||||||
func newNowPlaying() *NowPlayingView {
|
// StatusView shows temporary status messages in the application
|
||||||
np := &NowPlayingView{
|
type StatusView struct {
|
||||||
Box: tview.NewBox(),
|
*tview.Box
|
||||||
Channel: &components.ChannelItem{},
|
Message string
|
||||||
Elapsed: 0.0,
|
}
|
||||||
|
|
||||||
|
// 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 ")
|
// Draw draws the key bindings view on to the screen
|
||||||
np.SetBorder(true)
|
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
|
// 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)
|
tview.Print(screen, line, x, y+3, width, tview.AlignLeft, tcell.ColorBlue)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw draws the key bindings view on to the screen
|
// Draw draws a NowPlayingView onto the scren
|
||||||
func (n *KeybindingView) Draw(screen tcell.Screen) {
|
func (s *StatusView) Draw(screen tcell.Screen) {
|
||||||
n.Box.Draw(screen)
|
|
||||||
x, y, width, _ := n.GetInnerRect()
|
|
||||||
|
|
||||||
previousWidth := 0
|
s.Box.Draw(screen)
|
||||||
for j, bnd := range n.Bindings {
|
x, y, width, _ := s.GetInnerRect()
|
||||||
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
|
line := fmt.Sprintf("%s[white] %s", "Message:", s.Message)
|
||||||
// yes, this is hacky, but there's a comment, so it's ok, right?
|
tview.Print(screen, line, x, y, width, tview.AlignLeft, tcell.ColorBlue)
|
||||||
if j == 4 {
|
|
||||||
y = y + 1
|
|
||||||
previousWidth = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createChannelList() *tview.List {
|
func createChannelList() *tview.List {
|
||||||
|
|
@ -134,3 +140,27 @@ func createKeybindings() *KeybindingView {
|
||||||
|
|
||||||
return kbv
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue