577 lines
13 KiB
Go
577 lines
13 KiB
Go
package tui
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"parasocial/internal/auth"
|
|
"parasocial/internal/twitch"
|
|
)
|
|
|
|
const defaultViewWidth = 80
|
|
const maxIRCMessageHistory = 50
|
|
|
|
type viewMode int
|
|
|
|
const (
|
|
authView viewMode = iota
|
|
streamerView
|
|
)
|
|
|
|
type panelFocus int
|
|
|
|
const (
|
|
focusStreamers panelFocus = iota
|
|
focusInfo
|
|
focusIRC
|
|
focusMiner
|
|
)
|
|
|
|
type detailTab int
|
|
|
|
const (
|
|
infoTab detailTab = iota
|
|
ircTab
|
|
minerTab
|
|
)
|
|
|
|
type modelRuntime interface {
|
|
startAuth(chan<- AuthUpdate)
|
|
startResolve(*auth.State, chan<- StreamerUpdate)
|
|
}
|
|
|
|
// Model is the terminal UI for auth and streamer display.
|
|
type Model struct {
|
|
streamers []twitch.StreamerEntry
|
|
authState *auth.State
|
|
runtime modelRuntime
|
|
authUpdates <-chan AuthUpdate
|
|
streamerUpdates <-chan StreamerUpdate
|
|
authLogs []string
|
|
authErr error
|
|
resolveErr error
|
|
viewer *twitch.Viewer
|
|
mode viewMode
|
|
width int
|
|
height int
|
|
selectedConfig string
|
|
focus panelFocus
|
|
ircDetails map[string]ircDetail
|
|
minerDetails map[string][]string
|
|
authViewport viewport.Model
|
|
ircViewport viewport.Model
|
|
minerViewport viewport.Model
|
|
}
|
|
|
|
type ircDetail struct {
|
|
joined bool
|
|
messages []string
|
|
}
|
|
|
|
// New returns a Bubble Tea model for auth and streamer display.
|
|
func New(options Options) Model {
|
|
streamers := twitch.LoadingStreamerEntries(options.Streamers)
|
|
if len(options.initialStreamers) > 0 {
|
|
streamers = append([]twitch.StreamerEntry(nil), options.initialStreamers...)
|
|
}
|
|
|
|
model := Model{
|
|
streamers: streamers,
|
|
authState: options.AuthState,
|
|
runtime: options.runtime,
|
|
authLogs: []string{},
|
|
mode: authView,
|
|
ircDetails: make(map[string]ircDetail),
|
|
minerDetails: make(map[string][]string),
|
|
width: defaultViewWidth,
|
|
height: 24,
|
|
focus: focusStreamers,
|
|
authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)),
|
|
ircViewport: newIRCViewport(detailViewportWidth(defaultViewWidth), detailViewportHeight(24)),
|
|
minerViewport: newMinerViewport(
|
|
detailViewportWidth(defaultViewWidth),
|
|
detailViewportHeight(24),
|
|
),
|
|
}
|
|
if options.AuthState != nil {
|
|
model.mode = streamerView
|
|
}
|
|
model.ensureSelection()
|
|
model.syncAuthViewport()
|
|
model.syncDetailViewports(true)
|
|
return model
|
|
}
|
|
|
|
// Init kicks off authentication only when the UI starts in the login state.
|
|
func (m Model) Init() tea.Cmd {
|
|
if m.mode == authView {
|
|
return startAuthSession(m.runtime)
|
|
}
|
|
return startStreamerResolution(m.runtime, m.authState)
|
|
}
|
|
|
|
// Update applies runtime progress events and handles global keys.
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case authStartedMsg:
|
|
m.authUpdates = msg.Updates
|
|
return m, waitForAuthUpdate(msg.Updates)
|
|
case streamerStartedMsg:
|
|
m.streamerUpdates = msg.Updates
|
|
return m, waitForStreamerUpdate(msg.Updates)
|
|
case AuthUpdate:
|
|
return m.updateAuth(msg)
|
|
case StreamerUpdate:
|
|
return m.updateStreamer(msg)
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.resizeComponents()
|
|
m.ensureSelection()
|
|
m.syncDetailViewports(false)
|
|
return m, nil
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "left":
|
|
if m.mode == streamerView {
|
|
m.moveFocusLeft()
|
|
}
|
|
return m, nil
|
|
case "right":
|
|
if m.mode == streamerView {
|
|
m.moveFocusRight()
|
|
}
|
|
return m, nil
|
|
case "up", "k":
|
|
if m.mode == streamerView && m.focus == focusStreamers {
|
|
m.moveSelection(-1)
|
|
return m, nil
|
|
}
|
|
if m.mode == streamerView && m.focus == focusInfo {
|
|
return m, nil
|
|
}
|
|
case "down", "j":
|
|
if m.mode == streamerView && m.focus == focusStreamers {
|
|
m.moveSelection(1)
|
|
return m, nil
|
|
}
|
|
if m.mode == streamerView && m.focus == focusInfo {
|
|
return m, nil
|
|
}
|
|
case "ctrl+c", "esc", "q":
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
|
|
if m.mode == authView {
|
|
var cmd tea.Cmd
|
|
m.authViewport, cmd = m.authViewport.Update(msg)
|
|
return m, cmd
|
|
}
|
|
if m.focus == focusIRC {
|
|
var cmd tea.Cmd
|
|
m.ircViewport, cmd = m.ircViewport.Update(msg)
|
|
return m, cmd
|
|
}
|
|
if m.focus == focusMiner {
|
|
var cmd tea.Cmd
|
|
m.minerViewport, cmd = m.minerViewport.Update(msg)
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) {
|
|
if msg.Line != "" {
|
|
m.authLogs = append(m.authLogs, msg.Line)
|
|
m.syncAuthViewport()
|
|
}
|
|
if msg.Done {
|
|
if msg.Err != nil {
|
|
m.authErr = msg.Err
|
|
m.syncAuthViewport()
|
|
return m, nil
|
|
}
|
|
if msg.State != nil {
|
|
m.authState = msg.State
|
|
m.authErr = nil
|
|
m.mode = streamerView
|
|
m.viewer = nil
|
|
m.resolveErr = nil
|
|
m.streamers = loadingEntries(m.streamers)
|
|
m.ircDetails = make(map[string]ircDetail)
|
|
m.minerDetails = make(map[string][]string)
|
|
m.selectedConfig = ""
|
|
m.focus = focusStreamers
|
|
m.ensureSelection()
|
|
m.resizeComponents()
|
|
m.syncDetailViewports(true)
|
|
return m, startStreamerResolution(m.runtime, m.authState)
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.authUpdates != nil {
|
|
return m, waitForAuthUpdate(m.authUpdates)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) {
|
|
if msg.Viewer != nil {
|
|
m.viewer = msg.Viewer
|
|
m.resolveErr = nil
|
|
}
|
|
if msg.Entry != nil && msg.Index >= 0 && msg.Index < len(m.streamers) {
|
|
m.streamers[msg.Index] = *msg.Entry
|
|
}
|
|
if msg.IRC != nil {
|
|
m.applyIRCUpdate(*msg.IRC)
|
|
}
|
|
if msg.Miner != nil {
|
|
m.applyMinerUpdate(*msg.Miner)
|
|
}
|
|
if msg.Err != nil {
|
|
m.resolveErr = msg.Err
|
|
}
|
|
m.ensureSelection()
|
|
m.resizeComponents()
|
|
m.syncDetailViewports(false)
|
|
if msg.Done {
|
|
return m, nil
|
|
}
|
|
if m.streamerUpdates != nil {
|
|
return m, waitForStreamerUpdate(m.streamerUpdates)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) applyIRCUpdate(update IRCUpdate) {
|
|
login := normalizeKey(update.Login)
|
|
if login == "" {
|
|
return
|
|
}
|
|
|
|
detail := m.ircDetails[login]
|
|
switch update.State {
|
|
case IRCJoined:
|
|
detail.joined = true
|
|
case IRCPending, IRCDisconnected:
|
|
detail.joined = false
|
|
}
|
|
if message, ok := formatIRCChatLine(update.Line); ok {
|
|
detail.messages = appendCappedHistory(detail.messages, message)
|
|
}
|
|
m.ircDetails[login] = detail
|
|
}
|
|
|
|
func (m *Model) applyMinerUpdate(update MinerUpdate) {
|
|
login := normalizeKey(update.Login)
|
|
line := strings.TrimSpace(update.Line)
|
|
if login == "" || line == "" {
|
|
return
|
|
}
|
|
m.minerDetails[login] = appendCappedHistory(m.minerDetails[login], line)
|
|
}
|
|
|
|
func (m *Model) ensureSelection() {
|
|
entries := m.orderedStreamers()
|
|
if len(entries) == 0 {
|
|
m.selectedConfig = ""
|
|
return
|
|
}
|
|
if m.selectedConfig == "" {
|
|
m.selectedConfig = entries[0].ConfigLogin
|
|
return
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.ConfigLogin == m.selectedConfig {
|
|
return
|
|
}
|
|
}
|
|
m.selectedConfig = entries[0].ConfigLogin
|
|
}
|
|
|
|
func (m *Model) moveSelection(delta int) {
|
|
entries := m.orderedStreamers()
|
|
if len(entries) == 0 {
|
|
m.selectedConfig = ""
|
|
m.syncDetailViewports(true)
|
|
return
|
|
}
|
|
|
|
current := m.selectedConfig
|
|
selected := m.selectedRowIndex(entries) + delta
|
|
if selected < 0 {
|
|
selected = 0
|
|
}
|
|
if selected >= len(entries) {
|
|
selected = len(entries) - 1
|
|
}
|
|
m.selectedConfig = entries[selected].ConfigLogin
|
|
if m.selectedConfig != current {
|
|
m.syncDetailViewports(true)
|
|
}
|
|
}
|
|
|
|
func (m *Model) moveFocusLeft() {
|
|
switch m.focus {
|
|
case focusInfo:
|
|
m.focus = focusStreamers
|
|
case focusIRC:
|
|
m.focus = focusInfo
|
|
case focusMiner:
|
|
m.focus = focusIRC
|
|
}
|
|
}
|
|
|
|
func (m *Model) moveFocusRight() {
|
|
switch m.focus {
|
|
case focusStreamers:
|
|
m.focus = focusInfo
|
|
case focusInfo:
|
|
m.focus = focusIRC
|
|
case focusIRC:
|
|
m.focus = focusMiner
|
|
}
|
|
}
|
|
|
|
func (m Model) orderedStreamers() []twitch.StreamerEntry {
|
|
entries := make([]twitch.StreamerEntry, 0, len(m.streamers))
|
|
for _, entry := range m.streamers {
|
|
if isActive(entry) {
|
|
entries = append(entries, entry)
|
|
}
|
|
}
|
|
for _, entry := range m.streamers {
|
|
if isActive(entry) {
|
|
continue
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func (m Model) selectedRowIndex(entries []twitch.StreamerEntry) int {
|
|
for index, entry := range entries {
|
|
if entry.ConfigLogin == m.selectedConfig {
|
|
return index
|
|
}
|
|
}
|
|
if len(entries) == 0 {
|
|
return -1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (m *Model) resizeComponents() {
|
|
m.authViewport.Width = contentWidth(m.width)
|
|
m.authViewport.Height = authViewportHeight(m.height)
|
|
m.ircViewport.Width = detailViewportWidth(m.width)
|
|
m.ircViewport.Height = detailViewportHeight(m.height)
|
|
m.minerViewport.Width = detailViewportWidth(m.width)
|
|
m.minerViewport.Height = detailViewportHeight(m.height)
|
|
}
|
|
|
|
func (m *Model) syncAuthViewport() {
|
|
m.authViewport.SetContent(m.authLogContent())
|
|
m.authViewport.GotoBottom()
|
|
}
|
|
|
|
func (m Model) authLogContent() string {
|
|
if len(m.authLogs) == 0 {
|
|
return "Starting authentication..."
|
|
}
|
|
return strings.Join(m.authLogs, "\n")
|
|
}
|
|
|
|
func (m *Model) syncIRCViewport(forceBottom bool) {
|
|
if m.ircViewport.Width <= 0 || m.ircViewport.Height <= 0 {
|
|
return
|
|
}
|
|
|
|
stickToBottom := forceBottom || m.ircViewport.AtBottom()
|
|
m.ircViewport.SetContent(m.ircViewportContent())
|
|
if stickToBottom {
|
|
m.ircViewport.GotoBottom()
|
|
}
|
|
}
|
|
|
|
func (m *Model) syncMinerViewport(forceBottom bool) {
|
|
if m.minerViewport.Width <= 0 || m.minerViewport.Height <= 0 {
|
|
return
|
|
}
|
|
|
|
stickToBottom := forceBottom || m.minerViewport.AtBottom()
|
|
m.minerViewport.SetContent(m.minerViewportContent())
|
|
if stickToBottom {
|
|
m.minerViewport.GotoBottom()
|
|
}
|
|
}
|
|
|
|
func (m *Model) syncDetailViewports(forceBottom bool) {
|
|
m.syncIRCViewport(forceBottom)
|
|
m.syncMinerViewport(forceBottom)
|
|
}
|
|
|
|
func (m Model) ircViewportContent() string {
|
|
entry, ok := m.selectedEntry()
|
|
if !ok {
|
|
return "No streamers configured"
|
|
}
|
|
if !isActive(entry) {
|
|
return "Chat becomes available when this streamer is live."
|
|
}
|
|
|
|
detail := m.ircDetails[normalizeKey(entry.Login)]
|
|
if !detail.joined {
|
|
return "Connecting to Twitch IRC..."
|
|
}
|
|
if len(detail.messages) == 0 {
|
|
return "Waiting for chat messages..."
|
|
}
|
|
return strings.Join(detail.messages, "\n")
|
|
}
|
|
|
|
func (m Model) minerViewportContent() string {
|
|
entry, ok := m.selectedEntry()
|
|
if !ok {
|
|
return "No streamers configured"
|
|
}
|
|
|
|
logins := m.minerDetails[normalizeKey(entry.Login)]
|
|
if len(logins) == 0 {
|
|
return "Waiting for miner activity..."
|
|
}
|
|
return strings.Join(logins, "\n")
|
|
}
|
|
|
|
func (m Model) selectedEntry() (twitch.StreamerEntry, bool) {
|
|
entries := m.orderedStreamers()
|
|
selected := m.selectedRowIndex(entries)
|
|
if selected < 0 || selected >= len(entries) {
|
|
return twitch.StreamerEntry{}, false
|
|
}
|
|
return entries[selected], true
|
|
}
|
|
|
|
func (m Model) visibleDetailTab() detailTab {
|
|
switch m.focus {
|
|
case focusIRC:
|
|
return ircTab
|
|
case focusMiner:
|
|
return minerTab
|
|
default:
|
|
return infoTab
|
|
}
|
|
}
|
|
|
|
func (m Model) isStreamersFocused() bool {
|
|
return m.focus == focusStreamers
|
|
}
|
|
|
|
func (m Model) isRightPanelFocused() bool {
|
|
return m.focus == focusInfo || m.focus == focusIRC || m.focus == focusMiner
|
|
}
|
|
|
|
func isActive(entry twitch.StreamerEntry) bool {
|
|
return entry.Status == twitch.StreamerReady && entry.Live
|
|
}
|
|
|
|
func streamerName(entry twitch.StreamerEntry) string {
|
|
if entry.Login != "" {
|
|
return entry.Login
|
|
}
|
|
return entry.ConfigLogin
|
|
}
|
|
|
|
func normalizeKey(value string) string {
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
}
|
|
|
|
func formatIRCChatLine(line string) (string, bool) {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
return "", false
|
|
}
|
|
|
|
parts := strings.SplitN(line, " :", 2)
|
|
if len(parts) != 2 || !strings.Contains(parts[0], " PRIVMSG ") {
|
|
return "", false
|
|
}
|
|
|
|
prefix := strings.TrimPrefix(parts[0], ":")
|
|
user, _, _ := strings.Cut(prefix, "!")
|
|
if user == "" {
|
|
user = "unknown"
|
|
}
|
|
message := strings.TrimSpace(parts[1])
|
|
if message == "" {
|
|
return "", false
|
|
}
|
|
return user + ": " + message, true
|
|
}
|
|
|
|
func appendCappedHistory(history []string, line string) []string {
|
|
history = append(history, line)
|
|
if len(history) <= maxIRCMessageHistory {
|
|
return history
|
|
}
|
|
return append([]string(nil), history[len(history)-maxIRCMessageHistory:]...)
|
|
}
|
|
|
|
func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry {
|
|
logins := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
logins = append(logins, entry.ConfigLogin)
|
|
}
|
|
return twitch.LoadingStreamerEntries(logins)
|
|
}
|
|
|
|
func startAuthSession(runtime modelRuntime) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if runtime == nil {
|
|
return AuthUpdate{Err: errors.New("auth runtime is nil"), Done: true}
|
|
}
|
|
updates := make(chan AuthUpdate, 32)
|
|
runtime.startAuth(updates)
|
|
return authStartedMsg{Updates: updates}
|
|
}
|
|
}
|
|
|
|
func startStreamerResolution(runtime modelRuntime, state *auth.State) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if runtime == nil {
|
|
return StreamerUpdate{Err: errors.New("streamer runtime is nil"), Done: true}
|
|
}
|
|
if state == nil {
|
|
return StreamerUpdate{Err: errors.New("auth state is nil"), Done: true}
|
|
}
|
|
updates := make(chan StreamerUpdate, 32)
|
|
runtime.startResolve(state, updates)
|
|
return streamerStartedMsg{Updates: updates}
|
|
}
|
|
}
|
|
|
|
func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd {
|
|
return func() tea.Msg {
|
|
update, ok := <-updates
|
|
if !ok {
|
|
return AuthUpdate{Err: errors.New("auth updates ended unexpectedly"), Done: true}
|
|
}
|
|
return update
|
|
}
|
|
}
|
|
|
|
func waitForStreamerUpdate(updates <-chan StreamerUpdate) tea.Cmd {
|
|
return func() tea.Msg {
|
|
update, ok := <-updates
|
|
if !ok {
|
|
return StreamerUpdate{Done: true}
|
|
}
|
|
return update
|
|
}
|
|
}
|