octoplex/internal/terminal/terminal.go
2025-04-12 19:08:17 +02:00

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))
}