🎉 live server seems to be working now
This commit is contained in:
625
third_party/sendspin-go/internal/ui/model.go
vendored
Normal file
625
third_party/sendspin-go/internal/ui/model.go
vendored
Normal file
@@ -0,0 +1,625 @@
|
||||
// ABOUTME: Bubbletea model for player TUI
|
||||
// ABOUTME: Defines application state and update logic
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio/output"
|
||||
"github.com/Sendspin/sendspin-go/pkg/sendspin"
|
||||
"github.com/Sendspin/sendspin-go/pkg/sync"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type uiMode int
|
||||
|
||||
const (
|
||||
modeNormal uiMode = iota
|
||||
modeDevicePicker
|
||||
)
|
||||
|
||||
// transientExpireMsg is fired after a transient banner's lifetime to clear
|
||||
// it. Bubbletea's scheduler guarantees at-most-once delivery.
|
||||
type transientExpireMsg struct{ nonce uint64 }
|
||||
|
||||
// Model represents the TUI state
|
||||
type Model struct {
|
||||
// Connection
|
||||
connected bool
|
||||
serverName string
|
||||
|
||||
// Sync
|
||||
syncRTT int64
|
||||
syncQuality sync.Quality
|
||||
|
||||
// Stream
|
||||
codec string
|
||||
sampleRate int
|
||||
channels int
|
||||
bitDepth int
|
||||
|
||||
// Metadata
|
||||
title string
|
||||
artist string
|
||||
album string
|
||||
artworkPath string
|
||||
|
||||
// Playback
|
||||
state string
|
||||
playbackState string // "playing", "paused", "stopped", "idle", "reconnecting"
|
||||
volume int
|
||||
muted bool
|
||||
|
||||
// Stats
|
||||
received int64
|
||||
played int64
|
||||
dropped int64
|
||||
bufferDepth int
|
||||
|
||||
// Debug
|
||||
showDebug bool
|
||||
goroutines int
|
||||
|
||||
// Dimensions
|
||||
width int
|
||||
height int
|
||||
|
||||
// Controls
|
||||
volumeCtrl *VolumeControl
|
||||
transportCtrl *TransportControl
|
||||
|
||||
// Audio device selection
|
||||
audioDevice string // current device driving playback; shown in status row
|
||||
configPath string // where picker saves audio_device; empty disables save
|
||||
|
||||
// Modal state
|
||||
mode uiMode
|
||||
picker devicePickerState
|
||||
|
||||
// Transient banner shown in the controls area (e.g. "Saved. Restart to apply.")
|
||||
transientMsg string
|
||||
transientNonce uint64 // incremented per banner so stale expire ticks are ignored
|
||||
|
||||
// listPlaybackDevices is indirected so tests can stub it.
|
||||
listPlaybackDevices func() ([]output.PlaybackDevice, error)
|
||||
// writeConfigKey persists a key to the config file. Indirected for tests.
|
||||
writeConfigKey func(path, key, value string) error
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
case StatusMsg:
|
||||
m.applyStatus(msg)
|
||||
case transientExpireMsg:
|
||||
if msg.nonce == m.transientNonce {
|
||||
m.transientMsg = ""
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.width == 0 {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
if m.mode == modeDevicePicker {
|
||||
// Modal takes over the viewport; the normal view is suspended. A
|
||||
// minimal header keeps context ("which player am I configuring?").
|
||||
return m.renderHeader() + renderPicker(m.picker, m.width)
|
||||
}
|
||||
|
||||
s := ""
|
||||
s += m.renderHeader()
|
||||
s += m.renderStreamInfo()
|
||||
s += m.renderControls()
|
||||
s += m.renderStats()
|
||||
|
||||
if m.showDebug {
|
||||
s += m.renderDebug()
|
||||
}
|
||||
|
||||
s += m.renderHelp()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (m Model) renderHeader() string {
|
||||
connStatus := "Disconnected"
|
||||
if m.connected {
|
||||
connStatus = fmt.Sprintf("Connected to %s", m.serverName)
|
||||
}
|
||||
|
||||
stateDisplay := playbackStateDisplay(m.playbackState)
|
||||
|
||||
syncDisplay := "Sync: \u2717 Lost"
|
||||
switch m.syncQuality {
|
||||
case sync.QualityGood:
|
||||
syncDisplay = fmt.Sprintf("Sync: \u2713 Good (RTT: %.1fms)", float64(m.syncRTT)/1000.0)
|
||||
case sync.QualityDegraded:
|
||||
syncDisplay = "Sync: \u26a0 Degraded"
|
||||
}
|
||||
|
||||
// Use terminal width for responsive layout
|
||||
width := m.width
|
||||
if width < 60 {
|
||||
width = 60 // Minimum width
|
||||
}
|
||||
innerWidth := width - 4 // Account for borders
|
||||
|
||||
titleWidth := width - 20 // Space for "┌─ Sendspin Player " prefix
|
||||
title := "\u250c\u2500 Sendspin Player " + repeatString("\u2500", titleWidth) + "\u2510\n"
|
||||
|
||||
statusLine := fmt.Sprintf("\u2502 Status: %-*s \u2502\n", innerWidth-9, truncate(connStatus, innerWidth-9))
|
||||
|
||||
// State + Sync on same line
|
||||
statePrefix := fmt.Sprintf("State: %s", stateDisplay)
|
||||
// Calculate available space: innerWidth minus "State: <state>" minus spacing minus sync display
|
||||
stateSyncLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth,
|
||||
fmt.Sprintf("%-30s %s", statePrefix, syncDisplay))
|
||||
|
||||
separator := "\u251c" + repeatString("\u2500", width-2) + "\u2524\n"
|
||||
|
||||
return title + statusLine + stateSyncLine + separator
|
||||
}
|
||||
|
||||
func (m Model) renderStreamInfo() string {
|
||||
width := m.width
|
||||
if width < 60 {
|
||||
width = 60
|
||||
}
|
||||
innerWidth := width - 4
|
||||
|
||||
if !m.connected || m.codec == "" {
|
||||
return fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "No stream")
|
||||
}
|
||||
|
||||
s := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "Now Playing:")
|
||||
if m.title != "" {
|
||||
metaWidth := innerWidth - 10 // Account for " Track: " prefix
|
||||
s += fmt.Sprintf("\u2502 Track: %-*s \u2502\n", innerWidth-10, truncate(m.title, metaWidth))
|
||||
s += fmt.Sprintf("\u2502 Artist: %-*s \u2502\n", innerWidth-10, truncate(m.artist, metaWidth))
|
||||
s += fmt.Sprintf("\u2502 Album: %-*s \u2502\n", innerWidth-10, truncate(m.album, metaWidth))
|
||||
if m.artworkPath != "" {
|
||||
s += fmt.Sprintf("\u2502 Art: %-*s \u2502\n", innerWidth-10, truncate(m.artworkPath, metaWidth))
|
||||
}
|
||||
} else {
|
||||
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth-3, "(No metadata)")
|
||||
}
|
||||
|
||||
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
|
||||
formatStr := formatCodecDisplay(m.codec, m.sampleRate, m.bitDepth)
|
||||
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, formatStr)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (m Model) renderControls() string {
|
||||
width := m.width
|
||||
if width < 60 {
|
||||
width = 60
|
||||
}
|
||||
innerWidth := width - 4
|
||||
|
||||
muteIcon := ""
|
||||
if m.muted {
|
||||
muteIcon = " \U0001f507"
|
||||
}
|
||||
|
||||
volumeBar := renderBar(m.volume, 100, 20)
|
||||
|
||||
s := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
|
||||
volumeStr := fmt.Sprintf("Volume: [%s] %d%%%s", volumeBar, m.volume, muteIcon)
|
||||
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, volumeStr)
|
||||
|
||||
bufferStr := fmt.Sprintf("Buffer: %dms", m.bufferDepth)
|
||||
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, bufferStr)
|
||||
|
||||
// Output device status row. Uses the hotkey helper so the 'O' rendering
|
||||
// stays in sync with the help line that advertises the trigger.
|
||||
outputName := m.audioDevice
|
||||
if outputName == "" {
|
||||
outputName = "(miniaudio default)"
|
||||
}
|
||||
outputLine := hotkey('o', "Output: "+truncate(outputName, innerWidth-10))
|
||||
s += renderLineWithANSI(innerWidth, outputLine)
|
||||
|
||||
if m.transientMsg != "" {
|
||||
s += renderLineWithANSI(innerWidth, " "+m.transientMsg)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// renderLineWithANSI emits a bordered row whose contents contain ANSI
|
||||
// escape sequences. fmt's padding verbs count those escape bytes toward
|
||||
// width, leading to short rows; this helper pads using visible-column
|
||||
// count instead so every border stays aligned.
|
||||
func renderLineWithANSI(innerWidth int, content string) string {
|
||||
pad := innerWidth - displayLen(content)
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
return fmt.Sprintf("\u2502 %s%s \u2502\n", content, strings.Repeat(" ", pad))
|
||||
}
|
||||
|
||||
func (m Model) renderStats() string {
|
||||
width := m.width
|
||||
if width < 60 {
|
||||
width = 60
|
||||
}
|
||||
innerWidth := width - 4
|
||||
|
||||
separator := "\u251c" + repeatString("\u2500", width-2) + "\u2524\n"
|
||||
statsStr := fmt.Sprintf("Stats: RX: %d Played: %d Dropped: %d", m.received, m.played, m.dropped)
|
||||
statsLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, statsStr)
|
||||
emptyLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
|
||||
|
||||
return separator + statsLine + emptyLine
|
||||
}
|
||||
|
||||
func (m Model) renderHelp() string {
|
||||
width := m.width
|
||||
if width < 60 {
|
||||
width = 60
|
||||
}
|
||||
innerWidth := width - 4
|
||||
|
||||
// Uniform hotkey rendering — every trigger character is reverse-
|
||||
// highlighted inline via hotkey(). Separators are two spaces; we rely
|
||||
// on renderLineWithANSI for correct padding because hotkey() emits
|
||||
// escape codes that fmt would miscount.
|
||||
parts := []string{
|
||||
hotkey(KeySpace, "Play/Pause"),
|
||||
hotkey('n', "Next"),
|
||||
hotkey('p', "Prev"),
|
||||
hotkey(KeyUpDown, "Volume"),
|
||||
hotkey('m', "Mute"),
|
||||
hotkey('o', "Output"),
|
||||
hotkey('q', "Quit"),
|
||||
}
|
||||
helpStr := strings.Join(parts, " ")
|
||||
helpLine := renderLineWithANSI(innerWidth, helpStr)
|
||||
bottom := "\u2514" + repeatString("\u2500", width-2) + "\u2518\n"
|
||||
|
||||
return helpLine + bottom
|
||||
}
|
||||
|
||||
func (m Model) renderDebug() string {
|
||||
width := m.width
|
||||
if width < 60 {
|
||||
width = 60
|
||||
}
|
||||
innerWidth := width - 4
|
||||
|
||||
debugTitle := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "DEBUG:")
|
||||
goroutineStr := fmt.Sprintf(" Goroutines: %d", m.goroutines)
|
||||
goroutineLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, goroutineStr)
|
||||
playbackStr := fmt.Sprintf(" Playback: %s", m.playbackState)
|
||||
playbackLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, playbackStr)
|
||||
codecStr := fmt.Sprintf(" Preferred codec: %s", m.codec)
|
||||
codecLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, codecStr)
|
||||
|
||||
return debugTitle + goroutineLine + playbackLine + codecLine
|
||||
}
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.mode == modeDevicePicker {
|
||||
return m.handlePickerMode(msg)
|
||||
}
|
||||
switch msg.String() {
|
||||
case "o":
|
||||
return m.openDevicePicker()
|
||||
case "q", "ctrl+c":
|
||||
if m.volumeCtrl != nil {
|
||||
select {
|
||||
case m.volumeCtrl.Quit <- QuitMsg{}:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "up":
|
||||
if m.volume < 100 {
|
||||
m.volume += 5
|
||||
if m.volume > 100 {
|
||||
m.volume = 100
|
||||
}
|
||||
if m.volumeCtrl != nil {
|
||||
select {
|
||||
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
case "down":
|
||||
if m.volume > 0 {
|
||||
m.volume -= 5
|
||||
if m.volume < 0 {
|
||||
m.volume = 0
|
||||
}
|
||||
if m.volumeCtrl != nil {
|
||||
select {
|
||||
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
case "m":
|
||||
m.muted = !m.muted
|
||||
// Send volume change to player via channel
|
||||
if m.volumeCtrl != nil {
|
||||
select {
|
||||
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
}
|
||||
case " ":
|
||||
if m.transportCtrl != nil {
|
||||
select {
|
||||
case m.transportCtrl.Commands <- TransportMsg{Command: "toggle"}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case "n":
|
||||
if m.transportCtrl != nil {
|
||||
select {
|
||||
case m.transportCtrl.Commands <- TransportMsg{Command: "next"}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case "p":
|
||||
if m.transportCtrl != nil {
|
||||
select {
|
||||
case m.transportCtrl.Commands <- TransportMsg{Command: "previous"}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
if m.transportCtrl != nil {
|
||||
select {
|
||||
case m.transportCtrl.Commands <- TransportMsg{Command: "reconnect"}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case "d":
|
||||
m.showDebug = !m.showDebug
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) applyStatus(msg StatusMsg) {
|
||||
if msg.Connected != nil {
|
||||
m.connected = *msg.Connected
|
||||
}
|
||||
if msg.ServerName != "" {
|
||||
m.serverName = msg.ServerName
|
||||
}
|
||||
if msg.PlaybackState != "" {
|
||||
m.playbackState = msg.PlaybackState
|
||||
}
|
||||
// Sync stats are always applied when sent
|
||||
if msg.SyncRTT != 0 {
|
||||
m.syncRTT = msg.SyncRTT
|
||||
m.syncQuality = msg.SyncQuality
|
||||
}
|
||||
if msg.Codec != "" {
|
||||
m.codec = msg.Codec
|
||||
m.sampleRate = msg.SampleRate
|
||||
m.channels = msg.Channels
|
||||
m.bitDepth = msg.BitDepth
|
||||
}
|
||||
if msg.Title != "" {
|
||||
m.title = msg.Title
|
||||
m.artist = msg.Artist
|
||||
m.album = msg.Album
|
||||
}
|
||||
if msg.ArtworkPath != "" {
|
||||
m.artworkPath = msg.ArtworkPath
|
||||
}
|
||||
// Volume is always applied when explicitly sent (can be 0 for silent)
|
||||
// We rely on caller not sending Volume=0 in messages unless it's intentional
|
||||
if msg.Volume != 0 {
|
||||
m.volume = msg.Volume
|
||||
}
|
||||
// Always apply stats - they can legitimately be zero
|
||||
m.received = msg.Received
|
||||
m.played = msg.Played
|
||||
m.dropped = msg.Dropped
|
||||
m.bufferDepth = msg.BufferDepth
|
||||
m.goroutines = msg.Goroutines
|
||||
}
|
||||
|
||||
type StatusMsg struct {
|
||||
Connected *bool
|
||||
ServerName string
|
||||
PlaybackState string
|
||||
SyncRTT int64
|
||||
SyncQuality sync.Quality
|
||||
Codec string
|
||||
SampleRate int
|
||||
Channels int
|
||||
BitDepth int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ArtworkPath string
|
||||
Volume int
|
||||
Received int64
|
||||
Played int64
|
||||
Dropped int64
|
||||
BufferDepth int
|
||||
Goroutines int
|
||||
}
|
||||
|
||||
type VolumeChangeMsg struct {
|
||||
Volume int
|
||||
Muted bool
|
||||
}
|
||||
|
||||
type QuitMsg struct{}
|
||||
|
||||
func renderBar(value, max, width int) string {
|
||||
filled := (value * width) / max
|
||||
return strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", width-filled)
|
||||
}
|
||||
|
||||
func truncate(s string, length int) string {
|
||||
if len(s) <= length {
|
||||
return s
|
||||
}
|
||||
return s[:length-3] + "..."
|
||||
}
|
||||
|
||||
func channelName(channels int) string {
|
||||
if channels == 1 {
|
||||
return "Mono"
|
||||
}
|
||||
return "Stereo"
|
||||
}
|
||||
|
||||
func repeatString(s string, count int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Repeat(s, count)
|
||||
}
|
||||
|
||||
// playbackStateDisplay returns a display string with icon for the given playback state.
|
||||
func playbackStateDisplay(state string) string {
|
||||
switch state {
|
||||
case "playing":
|
||||
return "\u25b6 Playing"
|
||||
case "paused":
|
||||
return "\u23f8 Paused"
|
||||
case "stopped":
|
||||
return "\u23f9 Stopped"
|
||||
case "idle":
|
||||
return "\u25cf Idle"
|
||||
case "reconnecting":
|
||||
return "\u21bb Reconnecting..."
|
||||
default:
|
||||
return "\u25cf Idle"
|
||||
}
|
||||
}
|
||||
|
||||
// formatSampleRate returns a human-friendly sample rate string.
|
||||
func formatSampleRate(rate int) string {
|
||||
switch rate {
|
||||
case 44100:
|
||||
return "44.1kHz"
|
||||
case 48000:
|
||||
return "48kHz"
|
||||
case 88200:
|
||||
return "88.2kHz"
|
||||
case 96000:
|
||||
return "96kHz"
|
||||
case 176400:
|
||||
return "176.4kHz"
|
||||
case 192000:
|
||||
return "192kHz"
|
||||
default:
|
||||
if rate%1000 == 0 {
|
||||
return fmt.Sprintf("%dkHz", rate/1000)
|
||||
}
|
||||
return fmt.Sprintf("%.1fkHz", float64(rate)/1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
// formatCodecDisplay returns a rich codec description string.
|
||||
func formatCodecDisplay(codec string, sampleRate, bitDepth int) string {
|
||||
rate := formatSampleRate(sampleRate)
|
||||
upper := strings.ToUpper(codec)
|
||||
|
||||
switch strings.ToLower(codec) {
|
||||
case "pcm":
|
||||
return fmt.Sprintf("%s %s/%dbit lossless", upper, rate, bitDepth)
|
||||
case "flac":
|
||||
return fmt.Sprintf("%s %s/%dbit lossless", upper, rate, bitDepth)
|
||||
case "opus":
|
||||
return fmt.Sprintf("%s %s/%dbit", upper, rate, bitDepth)
|
||||
default:
|
||||
return fmt.Sprintf("%s %s/%dbit", upper, rate, bitDepth)
|
||||
}
|
||||
}
|
||||
|
||||
// openDevicePicker transitions the Model into modeDevicePicker, enumerating
|
||||
// available devices via the injected lister (defaulting to the real
|
||||
// output.ListPlaybackDevices). The picker's own state tracks enumeration
|
||||
// errors, so we never fail to enter the modal — the user always gets to
|
||||
// see *something*, even if it's "couldn't enumerate devices".
|
||||
func (m Model) openDevicePicker() (tea.Model, tea.Cmd) {
|
||||
lister := m.listPlaybackDevices
|
||||
if lister == nil {
|
||||
lister = output.ListPlaybackDevices
|
||||
}
|
||||
saveEnabled := m.configPath != ""
|
||||
m.picker = newDevicePicker(lister, m.audioDevice, saveEnabled)
|
||||
m.mode = modeDevicePicker
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// handlePickerMode dispatches a key to the picker's pure state machine and
|
||||
// executes the requested action. Save writes audio_device to the config
|
||||
// file and shows a transient "Saved. Restart to apply." banner for 3s.
|
||||
func (m Model) handlePickerMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
next, action := m.picker.handlePickerKey(msg.String())
|
||||
m.picker = next
|
||||
switch action {
|
||||
case pickerNoop:
|
||||
return m, nil
|
||||
case pickerClose, pickerPropagate:
|
||||
m.mode = modeNormal
|
||||
return m, nil
|
||||
case pickerSave:
|
||||
chosen := m.picker.selected()
|
||||
if chosen == nil {
|
||||
m.mode = modeNormal
|
||||
return m, nil
|
||||
}
|
||||
writer := m.writeConfigKey
|
||||
if writer == nil {
|
||||
writer = sendspin.WriteStringKey
|
||||
}
|
||||
if chosen.Name != m.audioDevice {
|
||||
// No-op when the chosen value already matches what's saved —
|
||||
// matches the client_id write-back behavior and avoids
|
||||
// churning user-edited config files for no reason.
|
||||
if err := writer(m.configPath, "audio_device", chosen.Name); err != nil {
|
||||
m.mode = modeNormal
|
||||
return m.withTransient(fmt.Sprintf("Save failed: %v", err))
|
||||
}
|
||||
}
|
||||
m.mode = modeNormal
|
||||
return m.withTransient(fmt.Sprintf("Saved audio_device=%q. Restart to apply.", chosen.Name))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// withTransient schedules msg to be shown in the status row for ~3 seconds.
|
||||
// The nonce guards against a later expire firing for a previously-shown
|
||||
// banner that was already overwritten by a newer one.
|
||||
func (m Model) withTransient(msg string) (tea.Model, tea.Cmd) {
|
||||
m.transientNonce++
|
||||
m.transientMsg = msg
|
||||
nonce := m.transientNonce
|
||||
return m, tea.Tick(3*time.Second, func(time.Time) tea.Msg {
|
||||
return transientExpireMsg{nonce: nonce}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user