package terminal import ( "cmp" "context" "fmt" "log/slog" "maps" "slices" "strconv" "strings" "sync" "time" "git.netflux.io/rob/octoplex/internal/domain" "git.netflux.io/rob/octoplex/internal/shortid" "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"}, false, 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"}, false, 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"}, true, 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(errString string) { ui.app.QueueUpdateDraw(func() { ui.showModal( pageNameModalFatalError, fmt.Sprintf( "An error occurred:\n\n%s", errString, ), []string{"Quit"}, false, 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, allowMultiple bool, doneFunc func(int, string), ) { if allowMultiple { pageName = pageName + "-" + shortid.New().String() } else 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"}, false, 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"}, false, 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"}, false, 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"}, false, nil, ) } func (ui *UI) confirmQuit() { ui.showModal( pageNameModalQuit, "Are you sure you want to quit?", []string{"Quit", "Cancel"}, false, 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"}, false, 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)) }