1054 lines
30 KiB
Go
1054 lines
30 KiB
Go
package terminal
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.netflux.io/rob/octoplex/internal/domain"
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
"golang.design/x/clipboard"
|
|
)
|
|
|
|
type sourceViews struct {
|
|
url *tview.TextView
|
|
status *tview.TextView
|
|
tracks *tview.TextView
|
|
health *tview.TextView
|
|
cpu *tview.TextView
|
|
mem *tview.TextView
|
|
rx *tview.TextView
|
|
}
|
|
|
|
// startState represents the state of a destination from the point of view of
|
|
// the user interface: either started, starting or not started.
|
|
type startState int
|
|
|
|
const (
|
|
startStateNotStarted startState = iota
|
|
startStateStarting
|
|
startStateStarted
|
|
)
|
|
|
|
// UI is responsible for managing the terminal user interface.
|
|
type UI struct {
|
|
commandC chan Command
|
|
clipboardAvailable bool
|
|
configFilePath string
|
|
buildInfo domain.BuildInfo
|
|
logger *slog.Logger
|
|
|
|
// tview state
|
|
|
|
app *tview.Application
|
|
screen tcell.Screen
|
|
screenCaptureC chan<- ScreenCapture
|
|
pages *tview.Pages
|
|
container *tview.Flex
|
|
sourceViews sourceViews
|
|
destView *tview.Table
|
|
noDestView *tview.TextView
|
|
pullProgressModal *tview.Modal
|
|
|
|
// other mutable state
|
|
|
|
mu sync.Mutex
|
|
urlsToStartState map[string]startState
|
|
|
|
/// addingDestination is true if add destination modal is currently visible.
|
|
addingDestination bool
|
|
// hasDestinations is true if the UI thinks there are destinations
|
|
// configured.
|
|
hasDestinations bool
|
|
// lastSelectedDestIndex is the index of the last selected destination, starting
|
|
// at 1 (because 0 is the header).
|
|
lastSelectedDestIndex int
|
|
}
|
|
|
|
// Screen represents a terminal screen. This includes its desired dimensions,
|
|
// which is required to initialize the tcell.SimulationScreen.
|
|
type Screen struct {
|
|
Screen tcell.Screen
|
|
Width, Height int
|
|
CaptureC chan<- ScreenCapture
|
|
}
|
|
|
|
// ScreenCapture represents a screen capture, which is used for integration
|
|
// testing with the tcell.SimulationScreen.
|
|
type ScreenCapture struct {
|
|
Cells []tcell.SimCell
|
|
Width, Height int
|
|
}
|
|
|
|
// StartParams contains the parameters for starting a new terminal user
|
|
// interface.
|
|
type StartParams struct {
|
|
ChanSize int
|
|
Logger *slog.Logger
|
|
ClipboardAvailable bool
|
|
ConfigFilePath string
|
|
BuildInfo domain.BuildInfo
|
|
Screen *Screen // Screen may be nil.
|
|
}
|
|
|
|
const defaultChanSize = 64
|
|
|
|
// StartUI starts the terminal user interface.
|
|
func StartUI(ctx context.Context, params StartParams) (*UI, error) {
|
|
chanSize := cmp.Or(params.ChanSize, defaultChanSize)
|
|
commandCh := make(chan Command, chanSize)
|
|
|
|
app := tview.NewApplication()
|
|
|
|
var screen tcell.Screen
|
|
var screenCaptureC chan<- ScreenCapture
|
|
if params.Screen != nil {
|
|
screen = params.Screen.Screen
|
|
screenCaptureC = params.Screen.CaptureC
|
|
// Allow the tcell screen to be overridden for integration tests. If
|
|
// params.Screen is nil, the real terminal is used.
|
|
app.SetScreen(screen)
|
|
// SetSize must be called after SetScreen:
|
|
screen.SetSize(params.Screen.Width, params.Screen.Height)
|
|
}
|
|
|
|
sidebar := tview.NewFlex()
|
|
sidebar.SetDirection(tview.FlexRow)
|
|
|
|
sourceView := tview.NewFlex()
|
|
sourceView.SetDirection(tview.FlexColumn)
|
|
sourceView.SetBorder(true)
|
|
sourceView.SetTitle("Source RTMP server")
|
|
sidebar.AddItem(sourceView, 9, 0, false)
|
|
|
|
leftCol := tview.NewFlex()
|
|
leftCol.SetDirection(tview.FlexRow)
|
|
rightCol := tview.NewFlex()
|
|
rightCol.SetDirection(tview.FlexRow)
|
|
sourceView.AddItem(leftCol, 9, 0, false)
|
|
sourceView.AddItem(rightCol, 0, 1, false)
|
|
|
|
urlHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerURL)
|
|
leftCol.AddItem(urlHeaderTextView, 1, 0, false)
|
|
urlTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
|
|
rightCol.AddItem(urlTextView, 1, 0, false)
|
|
|
|
statusHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerStatus)
|
|
leftCol.AddItem(statusHeaderTextView, 1, 0, false)
|
|
statusTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
|
|
rightCol.AddItem(statusTextView, 1, 0, false)
|
|
|
|
tracksHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerTracks)
|
|
leftCol.AddItem(tracksHeaderTextView, 1, 0, false)
|
|
tracksTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
|
|
rightCol.AddItem(tracksTextView, 1, 0, false)
|
|
|
|
healthHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerHealth)
|
|
leftCol.AddItem(healthHeaderTextView, 1, 0, false)
|
|
healthTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
|
|
rightCol.AddItem(healthTextView, 1, 0, false)
|
|
|
|
cpuHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerCPU)
|
|
leftCol.AddItem(cpuHeaderTextView, 1, 0, false)
|
|
cpuTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
|
|
rightCol.AddItem(cpuTextView, 1, 0, false)
|
|
|
|
memHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerMem)
|
|
leftCol.AddItem(memHeaderTextView, 1, 0, false)
|
|
memTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
|
|
rightCol.AddItem(memTextView, 1, 0, false)
|
|
|
|
rxHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerRx)
|
|
leftCol.AddItem(rxHeaderTextView, 1, 0, false)
|
|
rxTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
|
|
rightCol.AddItem(rxTextView, 1, 0, false)
|
|
|
|
aboutView := tview.NewFlex()
|
|
aboutView.SetDirection(tview.FlexRow)
|
|
aboutView.SetBorder(true)
|
|
aboutView.SetTitle("Actions")
|
|
aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]a[-] Add destination"), 1, 0, false)
|
|
aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]Del[-] Remove destination"), 1, 0, false)
|
|
aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]Space[-] Start/stop destination"), 1, 0, false)
|
|
aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText(""), 1, 0, false)
|
|
aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]u[-] Copy source RTMP URL"), 1, 0, false)
|
|
aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]c[-] Copy config file path"), 1, 0, false)
|
|
aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]?[-] About"), 1, 0, false)
|
|
|
|
sidebar.AddItem(aboutView, 0, 1, false)
|
|
|
|
destView := tview.NewTable()
|
|
destView.SetTitle("Destinations")
|
|
destView.SetBorder(true)
|
|
destView.SetSelectable(true, false)
|
|
destView.SetWrapSelection(true, false)
|
|
destView.SetSelectedStyle(tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorDarkSlateGrey))
|
|
|
|
pullProgressModal := tview.NewModal()
|
|
pullProgressModal.
|
|
SetBackgroundColor(tcell.ColorBlack).
|
|
SetTextColor(tcell.ColorWhite).
|
|
SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
|
|
|
|
container := tview.NewFlex().
|
|
SetDirection(tview.FlexColumn).
|
|
AddItem(sidebar, 40, 0, false).
|
|
AddItem(destView, 0, 6, false)
|
|
|
|
// noDestView is overlaid on top of the main view when there are no
|
|
// destinations configured.
|
|
noDestView := tview.NewTextView().
|
|
SetText(`No destinations added yet. Press [a] to add a new destination.`).
|
|
SetTextAlign(tview.AlignCenter).
|
|
SetTextColor(tcell.ColorGrey)
|
|
noDestView.SetBorder(false)
|
|
|
|
pages := tview.NewPages()
|
|
pages.AddPage(pageNameMain, container, true, true)
|
|
pages.AddPage(pageNameNoDestinations, noDestView, false, false)
|
|
|
|
app.SetRoot(pages, true)
|
|
app.SetFocus(destView)
|
|
app.EnableMouse(false)
|
|
|
|
ui := &UI{
|
|
commandC: commandCh,
|
|
clipboardAvailable: params.ClipboardAvailable,
|
|
configFilePath: params.ConfigFilePath,
|
|
buildInfo: params.BuildInfo,
|
|
logger: params.Logger,
|
|
app: app,
|
|
screen: screen,
|
|
screenCaptureC: screenCaptureC,
|
|
pages: pages,
|
|
container: container,
|
|
sourceViews: sourceViews{
|
|
url: urlTextView,
|
|
status: statusTextView,
|
|
tracks: tracksTextView,
|
|
health: healthTextView,
|
|
cpu: cpuTextView,
|
|
mem: memTextView,
|
|
rx: rxTextView,
|
|
},
|
|
destView: destView,
|
|
noDestView: noDestView,
|
|
pullProgressModal: pullProgressModal,
|
|
urlsToStartState: make(map[string]startState),
|
|
}
|
|
|
|
app.SetInputCapture(ui.inputCaptureHandler)
|
|
app.SetAfterDrawFunc(ui.afterDrawHandler)
|
|
|
|
go ui.run(ctx)
|
|
|
|
return ui, nil
|
|
}
|
|
|
|
// C returns a channel that receives commands from the user interface.
|
|
func (ui *UI) C() <-chan Command {
|
|
return ui.commandC
|
|
}
|
|
|
|
func (ui *UI) run(ctx context.Context) {
|
|
defer close(ui.commandC)
|
|
|
|
uiDone := make(chan struct{})
|
|
go func() {
|
|
defer func() {
|
|
uiDone <- struct{}{}
|
|
}()
|
|
|
|
if err := ui.app.Run(); err != nil {
|
|
ui.logger.Error("tui application error", "err", err)
|
|
}
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-uiDone:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (ui *UI) inputCaptureHandler(event *tcell.EventKey) *tcell.EventKey {
|
|
// Special case: handle CTRL-C even when a modal is visible.
|
|
if event.Key() == tcell.KeyCtrlC {
|
|
ui.confirmQuit()
|
|
return nil
|
|
}
|
|
|
|
if ui.modalVisible() {
|
|
return event
|
|
}
|
|
|
|
handleKeyUp := func() {
|
|
row, _ := ui.destView.GetSelection()
|
|
if row == 1 {
|
|
ui.destView.Select(ui.destView.GetRowCount(), 0)
|
|
}
|
|
}
|
|
|
|
switch event.Key() {
|
|
case tcell.KeyRune:
|
|
switch event.Rune() {
|
|
case 'a', 'A':
|
|
ui.addDestination()
|
|
return nil
|
|
case ' ':
|
|
ui.toggleDestination()
|
|
case 'u', 'U':
|
|
ui.copySourceURLToClipboard(ui.clipboardAvailable)
|
|
case 'c', 'C':
|
|
ui.copyConfigFilePathToClipboard(ui.clipboardAvailable, ui.configFilePath)
|
|
case '?':
|
|
ui.showAbout()
|
|
case 'k': // tview vim bindings
|
|
handleKeyUp()
|
|
}
|
|
case tcell.KeyDelete, tcell.KeyBackspace, tcell.KeyBackspace2:
|
|
ui.removeDestination()
|
|
return nil
|
|
case tcell.KeyUp:
|
|
handleKeyUp()
|
|
}
|
|
|
|
return event
|
|
}
|
|
|
|
func (ui *UI) ShowSourceNotLiveModal() {
|
|
ui.app.QueueUpdateDraw(func() {
|
|
ui.showModal(
|
|
pageNameModalStartupCheck,
|
|
fmt.Sprintf("Waiting for stream.\nStart streaming to the source URL then try again:\n\n%s", ui.sourceViews.url.GetText(true)),
|
|
[]string{"Ok"},
|
|
nil,
|
|
)
|
|
})
|
|
}
|
|
|
|
// ShowStartupCheckModal shows a modal dialog to the user, asking if they want
|
|
// to kill a running instance of Octoplex.
|
|
//
|
|
// The method will block until the user has made a choice, after which the
|
|
// channel will receive true if the user wants to quit the other instance, or
|
|
// false to quit this instance.
|
|
func (ui *UI) ShowStartupCheckModal() bool {
|
|
done := make(chan bool)
|
|
|
|
ui.app.QueueUpdateDraw(func() {
|
|
ui.showModal(
|
|
pageNameModalStartupCheck,
|
|
"Another instance of Octoplex may already be running.\n\nPressing continue will close that instance. Continue?",
|
|
[]string{"Continue", "Exit"},
|
|
func(buttonIndex int, _ string) {
|
|
if buttonIndex == 0 {
|
|
done <- true
|
|
} else {
|
|
done <- false
|
|
}
|
|
},
|
|
)
|
|
})
|
|
|
|
return <-done
|
|
}
|
|
|
|
func (ui *UI) ShowDestinationErrorModal(name string, err error) {
|
|
ui.app.QueueUpdateDraw(func() {
|
|
ui.showModal(
|
|
pageNameModalDestinationError,
|
|
fmt.Sprintf(
|
|
"Streaming to %s failed:\n\n%s",
|
|
cmp.Or(name, "this destination"),
|
|
err,
|
|
),
|
|
[]string{"Ok"},
|
|
nil,
|
|
)
|
|
})
|
|
}
|
|
|
|
// ShowFatalErrorModal displays the provided error. It sends a CommandQuit to the
|
|
// command channel when the user selects the Quit button.
|
|
func (ui *UI) ShowFatalErrorModal(err error) {
|
|
ui.app.QueueUpdateDraw(func() {
|
|
ui.showModal(
|
|
pageNameModalFatalError,
|
|
fmt.Sprintf(
|
|
"An error occurred:\n\n%s",
|
|
err,
|
|
),
|
|
[]string{"Quit"},
|
|
func(int, string) {
|
|
ui.commandC <- CommandQuit{}
|
|
},
|
|
)
|
|
})
|
|
}
|
|
|
|
func (ui *UI) afterDrawHandler(screen tcell.Screen) {
|
|
if ui.screenCaptureC == nil {
|
|
return
|
|
}
|
|
|
|
ui.captureScreen(screen)
|
|
}
|
|
|
|
// captureScreen captures the screen and sends it to the screenCaptureC
|
|
// channel, which must have been set in StartParams.
|
|
//
|
|
// This is required for integration testing because GetContents() must be
|
|
// called inside the tview goroutine to avoid data races.
|
|
func (ui *UI) captureScreen(screen tcell.Screen) {
|
|
simScreen, ok := screen.(tcell.SimulationScreen)
|
|
if !ok {
|
|
ui.logger.Warn("captureScreen: simulation screen not available")
|
|
return
|
|
}
|
|
|
|
cells, w, h := simScreen.GetContents()
|
|
ui.screenCaptureC <- ScreenCapture{
|
|
Cells: slices.Clone(cells),
|
|
Width: w,
|
|
Height: h,
|
|
}
|
|
}
|
|
|
|
// SetState sets the state of the terminal user interface.
|
|
func (ui *UI) SetState(state domain.AppState) {
|
|
if state.Source.ExitReason != "" {
|
|
ui.handleMediaServerClosed(state.Source.ExitReason)
|
|
}
|
|
|
|
ui.updatePullProgress(state)
|
|
|
|
ui.mu.Lock()
|
|
for _, dest := range state.Destinations {
|
|
ui.urlsToStartState[dest.URL] = containerStateToStartState(dest.Container.Status)
|
|
}
|
|
|
|
ui.hasDestinations = len(state.Destinations) > 0
|
|
ui.mu.Unlock()
|
|
|
|
// The state is mutable so can't be passed into QueueUpdateDraw, which
|
|
// passes it to another goroutine, without cloning it first.
|
|
stateClone := state.Clone()
|
|
ui.app.QueueUpdateDraw(func() { ui.redrawFromState(stateClone) })
|
|
}
|
|
|
|
func (ui *UI) updatePullProgress(state domain.AppState) {
|
|
pullingContainers := make(map[string]domain.Container)
|
|
|
|
isPulling := func(containerState string, pullPercent int) bool {
|
|
return containerState == domain.ContainerStatusPulling && pullPercent > 0
|
|
}
|
|
|
|
if isPulling(state.Source.Container.Status, state.Source.Container.PullPercent) {
|
|
pullingContainers[state.Source.Container.ImageName] = state.Source.Container
|
|
}
|
|
|
|
for _, dest := range state.Destinations {
|
|
if isPulling(dest.Container.Status, dest.Container.PullPercent) {
|
|
pullingContainers[dest.Container.ImageName] = dest.Container
|
|
}
|
|
}
|
|
|
|
if len(pullingContainers) == 0 {
|
|
ui.app.QueueUpdateDraw(func() {
|
|
ui.hideModal(pageNameModalPullProgress)
|
|
})
|
|
return
|
|
}
|
|
|
|
// We don't really expect two images to be pulling simultaneously, but it's
|
|
// easy enough to handle.
|
|
imageNames := slices.Collect(maps.Keys(pullingContainers))
|
|
slices.Sort(imageNames)
|
|
container := pullingContainers[imageNames[0]]
|
|
|
|
ui.updateProgressModal(container)
|
|
}
|
|
|
|
func (ui *UI) updateProgressModal(container domain.Container) {
|
|
ui.app.QueueUpdateDraw(func() {
|
|
modalName := string(pageNameModalPullProgress)
|
|
|
|
var status string
|
|
// Avoid showing the long Docker pull status in the modal content.
|
|
if len(container.PullStatus) < 30 {
|
|
status = container.PullStatus
|
|
}
|
|
|
|
modalContent := fmt.Sprintf(
|
|
"Pulling %s:\n%s (%d%%)\n\n%s",
|
|
container.ImageName,
|
|
status,
|
|
container.PullPercent,
|
|
container.PullProgress,
|
|
)
|
|
|
|
if ui.pages.HasPage(modalName) {
|
|
ui.pullProgressModal.SetText(modalContent)
|
|
} else {
|
|
ui.pages.AddPage(modalName, ui.pullProgressModal, true, true)
|
|
}
|
|
})
|
|
}
|
|
|
|
// page names represent a specific page in the terminal user interface.
|
|
//
|
|
// Modals should generally have a unique name, which allows them to be stacked
|
|
// on top of other modals.
|
|
const (
|
|
pageNameMain = "main"
|
|
pageNameAddDestination = "add-destination"
|
|
pageNameConfigUpdateFailed = "modal-config-update-failed"
|
|
pageNameNoDestinations = "no-destinations"
|
|
pageNameModalAbout = "modal-about"
|
|
pageNameModalClipboard = "modal-clipboard"
|
|
pageNameModalDestinationError = "modal-destination-error"
|
|
pageNameModalFatalError = "modal-fatal-error"
|
|
pageNameModalPullProgress = "modal-pull-progress"
|
|
pageNameModalQuit = "modal-quit"
|
|
pageNameModalRemoveDestination = "modal-remove-destination"
|
|
pageNameModalSourceError = "modal-source-error"
|
|
pageNameModalStartupCheck = "modal-startup-check"
|
|
)
|
|
|
|
// modalVisible returns true if any modal, including the add destination form,
|
|
// is visible.
|
|
func (ui *UI) modalVisible() bool {
|
|
pageName, _ := ui.pages.GetFrontPage()
|
|
return pageName != pageNameMain && pageName != pageNameNoDestinations
|
|
}
|
|
|
|
// saveSelectedDestination saves the last selected destination index to local
|
|
// mutable state.
|
|
//
|
|
// This is needed so that the user's selection can be restored
|
|
// after redrawing the screen. It may be possible to remove this if we can
|
|
// re-render the screen more selectively instead of calling [redrawFromState]
|
|
// every time the state changes.
|
|
func (ui *UI) saveSelectedDestination() {
|
|
row, _ := ui.destView.GetSelection()
|
|
ui.mu.Lock()
|
|
ui.lastSelectedDestIndex = row
|
|
ui.mu.Unlock()
|
|
}
|
|
|
|
// selectPreviousDestination sets the focus to the last-selected destination.
|
|
func (ui *UI) selectPreviousDestination() {
|
|
if ui.modalVisible() {
|
|
return
|
|
}
|
|
|
|
var row int
|
|
ui.mu.Lock()
|
|
row = ui.lastSelectedDestIndex
|
|
ui.mu.Unlock()
|
|
|
|
// If the last element has been removed, select the new last element.
|
|
row = min(ui.destView.GetRowCount()-1, row)
|
|
|
|
ui.app.SetFocus(ui.destView)
|
|
|
|
if row == 0 {
|
|
return
|
|
}
|
|
|
|
ui.destView.Select(row, 0)
|
|
}
|
|
|
|
// selectLastDestination sets the user selection to the last destination.
|
|
func (ui *UI) selectLastDestination() {
|
|
if ui.modalVisible() {
|
|
return
|
|
}
|
|
|
|
ui.app.SetFocus(ui.destView)
|
|
|
|
if rowCount := ui.destView.GetRowCount(); rowCount > 1 {
|
|
ui.destView.Select(rowCount-1, 0)
|
|
}
|
|
}
|
|
|
|
func (ui *UI) showModal(pageName string, text string, buttons []string, doneFunc func(int, string)) {
|
|
if ui.pages.HasPage(pageName) {
|
|
return
|
|
}
|
|
|
|
modal := tview.NewModal()
|
|
modal.SetText(text).
|
|
AddButtons(buttons).
|
|
SetBackgroundColor(tcell.ColorBlack).
|
|
SetTextColor(tcell.ColorWhite).
|
|
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
|
ui.pages.RemovePage(pageName)
|
|
|
|
if !ui.modalVisible() {
|
|
ui.app.SetInputCapture(ui.inputCaptureHandler)
|
|
}
|
|
|
|
if doneFunc != nil {
|
|
doneFunc(buttonIndex, buttonLabel)
|
|
}
|
|
|
|
ui.selectPreviousDestination()
|
|
}).
|
|
SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
|
|
|
|
ui.saveSelectedDestination()
|
|
|
|
ui.pages.AddPage(pageName, modal, true, true)
|
|
}
|
|
|
|
func (ui *UI) hideModal(pageName string) {
|
|
if !ui.pages.HasPage(pageName) {
|
|
return
|
|
}
|
|
|
|
ui.pages.RemovePage(pageName)
|
|
ui.app.SetFocus(ui.destView)
|
|
}
|
|
|
|
func (ui *UI) handleMediaServerClosed(exitReason string) {
|
|
ui.app.QueueUpdateDraw(func() {
|
|
if ui.pages.HasPage(pageNameModalSourceError) {
|
|
return
|
|
}
|
|
|
|
modal := tview.NewModal()
|
|
modal.SetText("Mediaserver error: " + exitReason).
|
|
AddButtons([]string{"Quit"}).
|
|
SetBackgroundColor(tcell.ColorBlack).
|
|
SetTextColor(tcell.ColorWhite).
|
|
SetDoneFunc(func(int, string) {
|
|
ui.commandC <- CommandQuit{}
|
|
})
|
|
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
|
|
|
|
ui.pages.AddPage(pageNameModalSourceError, modal, true, true)
|
|
})
|
|
}
|
|
|
|
const dash = "—"
|
|
|
|
const (
|
|
headerName = "Name"
|
|
headerURL = "URL"
|
|
headerStatus = "Status"
|
|
headerContainer = "Container"
|
|
headerHealth = "Health"
|
|
headerCPU = "CPU %"
|
|
headerMem = "Mem M"
|
|
headerRx = "Rx Kbps"
|
|
headerTx = "Tx Kbps"
|
|
headerTracks = "Tracks"
|
|
)
|
|
|
|
func (ui *UI) redrawFromState(state domain.AppState) {
|
|
var addingDestination bool
|
|
ui.mu.Lock()
|
|
addingDestination = ui.addingDestination
|
|
ui.mu.Unlock()
|
|
|
|
var showNoDestinationsPage bool
|
|
if len(state.Destinations) == 0 && !addingDestination {
|
|
showNoDestinationsPage = true
|
|
}
|
|
|
|
if showNoDestinationsPage {
|
|
x, y, w, _ := ui.destView.GetRect()
|
|
ui.noDestView.SetRect(x+5, y+4, w-10, 3)
|
|
ui.pages.ShowPage(pageNameNoDestinations)
|
|
}
|
|
|
|
headerCell := func(content string, expansion int) *tview.TableCell {
|
|
return tview.
|
|
NewTableCell(content).
|
|
SetExpansion(expansion).
|
|
SetAlign(tview.AlignLeft).
|
|
SetSelectable(false)
|
|
}
|
|
|
|
ui.sourceViews.url.SetText(state.Source.RTMPURL)
|
|
|
|
tracks := dash
|
|
if state.Source.Live && len(state.Source.Tracks) > 0 {
|
|
tracks = strings.Join(state.Source.Tracks, ", ")
|
|
}
|
|
ui.sourceViews.tracks.SetText(tracks)
|
|
|
|
if state.Source.Live {
|
|
var durStr string
|
|
if !state.Source.LiveChangedAt.IsZero() {
|
|
durStr = fmt.Sprintf(" (%s)", time.Since(state.Source.LiveChangedAt).Round(time.Second))
|
|
}
|
|
|
|
ui.sourceViews.status.SetText("[black:green]receiving" + durStr)
|
|
} else if state.Source.Container.Status == domain.ContainerStatusRunning && state.Source.Container.HealthState == "healthy" {
|
|
ui.sourceViews.status.SetText("[black:yellow]waiting for stream")
|
|
} else {
|
|
ui.sourceViews.status.SetText("[white:red]not ready")
|
|
}
|
|
|
|
ui.sourceViews.health.SetText("[white]" + cmp.Or(rightPad(state.Source.Container.HealthState, 9), dash))
|
|
|
|
cpuPercent := dash
|
|
if state.Source.Container.Status == domain.ContainerStatusRunning {
|
|
cpuPercent = fmt.Sprintf("%.1f", state.Source.Container.CPUPercent)
|
|
}
|
|
ui.sourceViews.cpu.SetText("[white]" + cpuPercent)
|
|
|
|
memUsage := dash
|
|
if state.Source.Container.Status == domain.ContainerStatusRunning {
|
|
memUsage = fmt.Sprintf("%.1f", float64(state.Source.Container.MemoryUsageBytes)/1024/1024)
|
|
}
|
|
ui.sourceViews.mem.SetText("[white]" + memUsage)
|
|
|
|
rxRate := dash
|
|
if state.Source.Container.Status == domain.ContainerStatusRunning {
|
|
rxRate = fmt.Sprintf("%d", state.Source.Container.RxRate)
|
|
}
|
|
ui.sourceViews.rx.SetText("[white]" + rxRate)
|
|
|
|
ui.destView.Clear()
|
|
ui.destView.SetCell(0, 0, headerCell("[grey]"+headerName, 3))
|
|
ui.destView.SetCell(0, 1, headerCell("[grey]"+headerURL, 3))
|
|
ui.destView.SetCell(0, 2, headerCell("[grey]"+headerStatus, 2))
|
|
ui.destView.SetCell(0, 3, headerCell("[grey]"+headerContainer, 2))
|
|
ui.destView.SetCell(0, 4, headerCell("[grey]"+headerHealth, 2))
|
|
ui.destView.SetCell(0, 5, headerCell("[grey]"+headerCPU, 1))
|
|
ui.destView.SetCell(0, 6, headerCell("[grey]"+headerMem, 1))
|
|
ui.destView.SetCell(0, 7, headerCell("[grey]"+headerTx, 1))
|
|
|
|
for i, dest := range state.Destinations {
|
|
ui.destView.SetCell(i+1, 0, tview.NewTableCell(dest.Name))
|
|
ui.destView.SetCell(i+1, 1, tview.NewTableCell(dest.URL).SetReference(dest.URL).SetMaxWidth(20))
|
|
const statusLen = 10
|
|
switch dest.Status {
|
|
case domain.DestinationStatusLive:
|
|
ui.destView.SetCell(
|
|
i+1,
|
|
2,
|
|
tview.NewTableCell(rightPad("sending", statusLen)).
|
|
SetTextColor(tcell.ColorBlack).
|
|
SetBackgroundColor(tcell.ColorGreen).
|
|
SetSelectedStyle(
|
|
tcell.
|
|
StyleDefault.
|
|
Foreground(tcell.ColorBlack).
|
|
Background(tcell.ColorGreen),
|
|
),
|
|
)
|
|
default:
|
|
ui.destView.SetCell(i+1, 2, tview.NewTableCell("[white]"+rightPad("off-air", statusLen)))
|
|
}
|
|
|
|
ui.destView.SetCell(i+1, 3, tview.NewTableCell("[white]"+rightPad(cmp.Or(dest.Container.Status, dash), 10)))
|
|
|
|
healthState := dash
|
|
if dest.Status == domain.DestinationStatusLive {
|
|
healthState = "healthy"
|
|
}
|
|
ui.destView.SetCell(i+1, 4, tview.NewTableCell("[white]"+rightPad(healthState, 7)))
|
|
|
|
cpuPercent := dash
|
|
if dest.Container.Status == domain.ContainerStatusRunning {
|
|
cpuPercent = fmt.Sprintf("%.1f", dest.Container.CPUPercent)
|
|
}
|
|
ui.destView.SetCell(i+1, 5, tview.NewTableCell("[white]"+rightPad(cpuPercent, 4)))
|
|
|
|
memoryUsage := dash
|
|
if dest.Container.Status == domain.ContainerStatusRunning {
|
|
memoryUsage = fmt.Sprintf("%.1f", float64(dest.Container.MemoryUsageBytes)/1000/1000)
|
|
}
|
|
ui.destView.SetCell(i+1, 6, tview.NewTableCell("[white]"+rightPad(memoryUsage, 4)))
|
|
|
|
txRate := dash
|
|
if dest.Container.Status == domain.ContainerStatusRunning {
|
|
txRate = "[white]" + rightPad(strconv.Itoa(dest.Container.TxRate), 4)
|
|
}
|
|
ui.destView.SetCell(i+1, 7, tview.NewTableCell(txRate))
|
|
}
|
|
}
|
|
|
|
// Close closes the terminal user interface.
|
|
func (ui *UI) Close() {
|
|
ui.app.Stop()
|
|
}
|
|
|
|
func (ui *UI) ConfigUpdateFailed(err error) {
|
|
ui.app.QueueUpdateDraw(func() {
|
|
ui.showModal(
|
|
pageNameConfigUpdateFailed,
|
|
"Configuration update failed:\n\n"+err.Error(),
|
|
[]string{"Ok"},
|
|
func(int, string) {
|
|
pageName, frontPage := ui.pages.GetFrontPage()
|
|
if pageName != pageNameAddDestination {
|
|
ui.logger.Warn("Unexpected page when configuration form closed", "page", pageName)
|
|
}
|
|
ui.app.SetFocus(frontPage)
|
|
},
|
|
)
|
|
})
|
|
}
|
|
|
|
func (ui *UI) addDestination() {
|
|
const (
|
|
inputLen = 60
|
|
inputLabelName = "Name"
|
|
inputLabelURL = "RTMP URL"
|
|
formInnerWidth = inputLen + 8 + 1 // inputLen + length of longest label + one space
|
|
formInnerHeight = 7 // line count from first input field to last button
|
|
formWidth = formInnerWidth + 4
|
|
formHeight = formInnerHeight + 2
|
|
)
|
|
|
|
var currWidth, currHeight int
|
|
_, _, currWidth, currHeight = ui.container.GetRect()
|
|
|
|
form := tview.NewForm()
|
|
form.
|
|
AddInputField(inputLabelName, "My stream", inputLen, nil, nil).
|
|
AddInputField(inputLabelURL, "rtmp://", inputLen, nil, nil).
|
|
AddButton("Add", func() {
|
|
ui.commandC <- CommandAddDestination{
|
|
DestinationName: form.GetFormItemByLabel(inputLabelName).(*tview.InputField).GetText(),
|
|
URL: form.GetFormItemByLabel(inputLabelURL).(*tview.InputField).GetText(),
|
|
}
|
|
}).
|
|
AddButton("Cancel", func() {
|
|
ui.closeAddDestinationForm()
|
|
ui.selectPreviousDestination()
|
|
}).
|
|
SetFieldBackgroundColor(tcell.ColorDarkSlateGrey).
|
|
SetBorder(true).
|
|
SetTitle("Add a new destination").
|
|
SetTitleAlign(tview.AlignLeft).
|
|
SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
if event.Key() == tcell.KeyEscape {
|
|
ui.closeAddDestinationForm()
|
|
ui.selectPreviousDestination()
|
|
return nil
|
|
}
|
|
return event
|
|
}).
|
|
SetRect((currWidth-formWidth)/2, (currHeight-formHeight)/2, formWidth, formHeight)
|
|
|
|
ui.mu.Lock()
|
|
ui.addingDestination = true
|
|
ui.mu.Unlock()
|
|
|
|
ui.saveSelectedDestination()
|
|
|
|
ui.pages.HidePage(pageNameNoDestinations)
|
|
ui.pages.AddPage(pageNameAddDestination, form, false, true)
|
|
}
|
|
|
|
func (ui *UI) removeDestination() {
|
|
const urlCol = 1
|
|
row, _ := ui.destView.GetSelection()
|
|
url, ok := ui.destView.GetCell(row, urlCol).GetReference().(string)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var started bool
|
|
ui.mu.Lock()
|
|
started = ui.urlsToStartState[url] != startStateNotStarted
|
|
ui.mu.Unlock()
|
|
|
|
text := "Are you sure you want to remove the destination?"
|
|
if started {
|
|
text += "\n\nThis will stop the current live stream for this destination."
|
|
}
|
|
|
|
ui.showModal(
|
|
pageNameModalRemoveDestination,
|
|
text,
|
|
[]string{"Remove", "Cancel"},
|
|
func(buttonIndex int, _ string) {
|
|
if buttonIndex == 0 {
|
|
ui.commandC <- CommandRemoveDestination{URL: url}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
// DestinationAdded should be called when a new destination is added.
|
|
func (ui *UI) DestinationAdded() {
|
|
ui.mu.Lock()
|
|
ui.hasDestinations = true
|
|
ui.mu.Unlock()
|
|
|
|
ui.app.QueueUpdateDraw(func() {
|
|
ui.pages.HidePage(pageNameNoDestinations)
|
|
ui.closeAddDestinationForm()
|
|
ui.selectLastDestination()
|
|
})
|
|
}
|
|
|
|
// DestinationRemoved should be called when a destination is removed.
|
|
func (ui *UI) DestinationRemoved() {
|
|
ui.selectPreviousDestination()
|
|
}
|
|
|
|
func (ui *UI) closeAddDestinationForm() {
|
|
var hasDestinations bool
|
|
ui.mu.Lock()
|
|
ui.addingDestination = false
|
|
hasDestinations = ui.hasDestinations
|
|
ui.mu.Unlock()
|
|
|
|
ui.pages.RemovePage(pageNameAddDestination)
|
|
if !hasDestinations {
|
|
ui.pages.ShowPage(pageNameNoDestinations)
|
|
}
|
|
}
|
|
|
|
func (ui *UI) toggleDestination() {
|
|
const urlCol = 1
|
|
row, _ := ui.destView.GetSelection()
|
|
url, ok := ui.destView.GetCell(row, urlCol).GetReference().(string)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Communicating with the replicator/container client is asynchronous. To
|
|
// ensure we can limit each destination to a single container we need some
|
|
// kind of local mutable state which synchronously tracks the "start state"
|
|
// of each destination.
|
|
//
|
|
// Something about this approach feels a tiny bit hacky. Either of these
|
|
// approaches would be nicer, if one could be made to work:
|
|
//
|
|
// 1. Store the state in the *tview.Table, which would mean not recreating
|
|
// the cells on each redraw.
|
|
// 2. Piggy-back on the tview goroutine to handle synchronization, but that
|
|
// seems to introduce deadlocks and/or UI bugs.
|
|
ui.mu.Lock()
|
|
defer ui.mu.Unlock()
|
|
|
|
ss := ui.urlsToStartState[url]
|
|
switch ss {
|
|
case startStateNotStarted:
|
|
ui.urlsToStartState[url] = startStateStarting
|
|
ui.commandC <- CommandStartDestination{URL: url}
|
|
case startStateStarting:
|
|
// do nothing
|
|
return
|
|
case startStateStarted:
|
|
ui.commandC <- CommandStopDestination{URL: url}
|
|
}
|
|
}
|
|
|
|
func (ui *UI) copySourceURLToClipboard(clipboardAvailable bool) {
|
|
var text string
|
|
|
|
url := ui.sourceViews.url.GetText(true)
|
|
if clipboardAvailable {
|
|
clipboard.Write(clipboard.FmtText, []byte(url))
|
|
text = "Source URL copied to clipboard:\n\n" + url
|
|
} else {
|
|
text = "Copy to clipboard not available:\n\n" + url
|
|
}
|
|
|
|
ui.showModal(
|
|
pageNameModalClipboard,
|
|
text,
|
|
[]string{"Ok"},
|
|
nil,
|
|
)
|
|
}
|
|
|
|
func (ui *UI) copyConfigFilePathToClipboard(clipboardAvailable bool, configFilePath string) {
|
|
var text string
|
|
if clipboardAvailable {
|
|
if configFilePath != "" {
|
|
clipboard.Write(clipboard.FmtText, []byte(configFilePath))
|
|
text = "Configuration file path copied to clipboard:\n\n" + configFilePath
|
|
} else {
|
|
text = "Configuration file path not set"
|
|
}
|
|
} else {
|
|
text = "Copy to clipboard not available"
|
|
}
|
|
|
|
ui.showModal(
|
|
pageNameModalClipboard,
|
|
text,
|
|
[]string{"Ok"},
|
|
nil,
|
|
)
|
|
}
|
|
|
|
func (ui *UI) confirmQuit() {
|
|
ui.showModal(
|
|
pageNameModalQuit,
|
|
"Are you sure you want to quit?",
|
|
[]string{"Quit", "Cancel"},
|
|
func(buttonIndex int, _ string) {
|
|
if buttonIndex == 0 {
|
|
ui.commandC <- CommandQuit{}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
func (ui *UI) showAbout() {
|
|
commit := ui.buildInfo.Commit
|
|
if len(commit) > 8 {
|
|
commit = commit[:8]
|
|
}
|
|
|
|
ui.showModal(
|
|
pageNameModalAbout,
|
|
fmt.Sprintf(
|
|
"%s: live stream replicator\n(c) Rob Watson\nhttps://git.netflux.io/rob/octoplex\n\nReleased under AGPL3.\n\nv%s (%s)\nBuilt on %s (%s).",
|
|
domain.AppName,
|
|
cmp.Or(ui.buildInfo.Version, "0.0.0-devel"),
|
|
cmp.Or(commit, "unknown SHA"),
|
|
cmp.Or(ui.buildInfo.Date, "unknown date"),
|
|
ui.buildInfo.GoVersion,
|
|
),
|
|
[]string{"Ok"},
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// comtainerStateToStartState converts a container state to a start state.
|
|
func containerStateToStartState(containerState string) startState {
|
|
switch containerState {
|
|
case domain.ContainerStatusPulling, domain.ContainerStatusCreated:
|
|
return startStateStarting
|
|
case domain.ContainerStatusRunning, domain.ContainerStatusRestarting, domain.ContainerStatusPaused, domain.ContainerStatusRemoving:
|
|
return startStateStarted
|
|
default:
|
|
return startStateNotStarted
|
|
}
|
|
}
|
|
|
|
func rightPad(s string, n int) string {
|
|
if s == "" || len(s) == n {
|
|
return s
|
|
}
|
|
if len(s) > n {
|
|
return s[:n]
|
|
}
|
|
return s + strings.Repeat(" ", n-len(s))
|
|
}
|