272 lines
7.4 KiB
Go
272 lines
7.4 KiB
Go
// model.go contains the Bubble Tea state machine for the current terminal UI.
|
|
// It switches between the Twitch login log view and the streamer list view,
|
|
// and applies auth progress messages emitted by the app layer.
|
|
package tui
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"parasocial/internal/auth"
|
|
"parasocial/internal/twitch"
|
|
)
|
|
|
|
const liveDot = "\x1b[32m●\x1b[0m"
|
|
|
|
type viewMode int
|
|
|
|
const (
|
|
authView viewMode = iota
|
|
streamerView
|
|
)
|
|
|
|
// StartAuthFunc begins the asynchronous Twitch login flow for the model.
|
|
type StartAuthFunc func(chan<- AuthUpdate)
|
|
|
|
// StartResolveFunc begins the asynchronous viewer and streamer resolution flow for the model.
|
|
type StartResolveFunc func(*auth.State, chan<- StreamerUpdate)
|
|
|
|
// AuthUpdate carries one incremental auth log line or completion result into the TUI.
|
|
type AuthUpdate struct {
|
|
Line string
|
|
State *auth.State
|
|
Err error
|
|
Done bool
|
|
}
|
|
|
|
// StreamerUpdate carries one streamer resolution update into the TUI.
|
|
type StreamerUpdate struct {
|
|
Viewer *twitch.Viewer
|
|
Entry *twitch.StreamerEntry
|
|
Index int
|
|
Err error
|
|
Done bool
|
|
}
|
|
|
|
// Options configures the initial streamer list, auth state, and worker starter hooks.
|
|
type Options struct {
|
|
Streamers []twitch.StreamerEntry
|
|
AuthState *auth.State
|
|
StartAuth StartAuthFunc
|
|
StartResolve StartResolveFunc
|
|
}
|
|
|
|
// authStartedMsg hands the model the channel that will stream auth updates.
|
|
type authStartedMsg struct {
|
|
Updates <-chan AuthUpdate
|
|
}
|
|
|
|
// streamerStartedMsg hands the model the channel that will stream streamer updates.
|
|
type streamerStartedMsg struct {
|
|
Updates <-chan StreamerUpdate
|
|
}
|
|
|
|
// Model is the terminal UI for auth and streamer display.
|
|
type Model struct {
|
|
streamers []twitch.StreamerEntry
|
|
authState *auth.State
|
|
startAuth StartAuthFunc
|
|
startResolve StartResolveFunc
|
|
authUpdates <-chan AuthUpdate
|
|
streamerUpdates <-chan StreamerUpdate
|
|
authLogs []string
|
|
authErr error
|
|
resolveErr error
|
|
viewer *twitch.Viewer
|
|
mode viewMode
|
|
}
|
|
|
|
// New returns a Bubble Tea model for auth and streamer display.
|
|
func New(options Options) Model {
|
|
model := Model{
|
|
streamers: append([]twitch.StreamerEntry(nil), options.Streamers...),
|
|
authState: options.AuthState,
|
|
startAuth: options.StartAuth,
|
|
startResolve: options.StartResolve,
|
|
authLogs: []string{},
|
|
mode: authView,
|
|
}
|
|
if options.AuthState != nil {
|
|
model.mode = streamerView
|
|
}
|
|
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.startAuth)
|
|
}
|
|
return startStreamerResolution(m.startResolve, m.authState)
|
|
}
|
|
|
|
// Update applies auth progress events and handles the global quit 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:
|
|
if msg.Line != "" {
|
|
m.authLogs = append(m.authLogs, msg.Line)
|
|
}
|
|
if msg.Done {
|
|
if msg.Err != nil {
|
|
m.authErr = msg.Err
|
|
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)
|
|
return m, startStreamerResolution(m.startResolve, m.authState)
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.authUpdates != nil {
|
|
return m, waitForAuthUpdate(m.authUpdates)
|
|
}
|
|
case StreamerUpdate:
|
|
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.Err != nil {
|
|
m.resolveErr = msg.Err
|
|
}
|
|
if msg.Done {
|
|
return m, nil
|
|
}
|
|
if m.streamerUpdates != nil {
|
|
return m, waitForStreamerUpdate(m.streamerUpdates)
|
|
}
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc", "q":
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// View renders either the login log screen or the authenticated streamer list.
|
|
func (m Model) View() string {
|
|
var builder strings.Builder
|
|
if m.mode == authView {
|
|
builder.WriteString("Twitch Login\n")
|
|
if len(m.authLogs) == 0 {
|
|
builder.WriteString("Starting authentication...\n")
|
|
} else {
|
|
for _, line := range m.authLogs {
|
|
builder.WriteString(line)
|
|
builder.WriteByte('\n')
|
|
}
|
|
}
|
|
if m.authErr != nil {
|
|
builder.WriteString("\nLogin did not complete. Press q to quit.\n")
|
|
}
|
|
builder.WriteString("\n")
|
|
return builder.String()
|
|
}
|
|
|
|
switch {
|
|
case m.viewer != nil:
|
|
fmt.Fprintf(&builder, "Logged in as %s (%s)\n\n", m.viewer.Login, m.viewer.ID)
|
|
case m.authState != nil && m.authState.Login != "":
|
|
fmt.Fprintf(&builder, "Logged in as %s\n", m.authState.Login)
|
|
if m.resolveErr != nil {
|
|
fmt.Fprintf(&builder, "Viewer lookup failed: %v\n\n", m.resolveErr)
|
|
} else {
|
|
builder.WriteString("Resolving viewer identity...\n\n")
|
|
}
|
|
}
|
|
builder.WriteString("Streamers\n")
|
|
for i, streamer := range m.streamers {
|
|
switch streamer.Status {
|
|
case twitch.StreamerReady:
|
|
label := streamer.Login
|
|
if streamer.Live {
|
|
label += " " + liveDot
|
|
}
|
|
fmt.Fprintf(&builder, "%d. %s (%s)\n", i+1, label, streamer.ChannelID)
|
|
case twitch.StreamerError:
|
|
fmt.Fprintf(&builder, "%d. %s [error: %s]\n", i+1, streamer.ConfigLogin, streamer.Error)
|
|
default:
|
|
fmt.Fprintf(&builder, "%d. %s [loading]\n", i+1, streamer.ConfigLogin)
|
|
}
|
|
}
|
|
builder.WriteString("\n")
|
|
return builder.String()
|
|
}
|
|
|
|
// startAuthSession starts the background auth worker and returns its update channel to Bubble Tea.
|
|
func startAuthSession(start StartAuthFunc) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if start == nil {
|
|
return AuthUpdate{Err: errors.New("auth start function is nil"), Done: true}
|
|
}
|
|
updates := make(chan AuthUpdate, 32)
|
|
start(updates)
|
|
return authStartedMsg{Updates: updates}
|
|
}
|
|
}
|
|
|
|
// startStreamerResolution starts the background streamer resolver and returns its update channel.
|
|
func startStreamerResolution(start StartResolveFunc, state *auth.State) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if start == nil {
|
|
return StreamerUpdate{Err: errors.New("streamer resolution start function is nil"), Done: true}
|
|
}
|
|
if state == nil {
|
|
return StreamerUpdate{Err: errors.New("auth state is nil"), Done: true}
|
|
}
|
|
updates := make(chan StreamerUpdate, 32)
|
|
start(state, updates)
|
|
return streamerStartedMsg{Updates: updates}
|
|
}
|
|
}
|
|
|
|
// waitForAuthUpdate blocks until the next auth update is available from the worker.
|
|
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
|
|
}
|
|
}
|
|
|
|
// waitForStreamerUpdate blocks until the next streamer update is available from the worker.
|
|
func waitForStreamerUpdate(updates <-chan StreamerUpdate) tea.Cmd {
|
|
return func() tea.Msg {
|
|
update, ok := <-updates
|
|
if !ok {
|
|
return StreamerUpdate{Done: true}
|
|
}
|
|
return update
|
|
}
|
|
}
|
|
|
|
// loadingEntries resets the UI rows back to their initial loading state.
|
|
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)
|
|
}
|