diff --git a/go.mod b/go.mod index eeafaff..c113853 100644 --- a/go.mod +++ b/go.mod @@ -1,29 +1,33 @@ module parasocial -go 1.24.0 +go 1.24.2 require ( github.com/BurntSushi/toml v1.6.0 + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 99e9b82..7c992c1 100644 --- a/go.sum +++ b/go.sum @@ -2,44 +2,51 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/internal/app/app.go b/internal/app/app.go index 1102d2c..53abbb1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,251 +1,18 @@ -// app.go wires together config loading, auth bootstrap, and the Bubble Tea program. -// It decides whether cached Twitch auth can be reused and, when needed, -// connects the interactive login flow to the TUI through auth update messages. +// app.go owns process-level application bootstrap before handing control to the TUI. package app import ( "context" - "errors" - "fmt" - "net/http" - "os" - "strings" - "time" - tea "github.com/charmbracelet/bubbletea" - - "parasocial/internal/auth" "parasocial/internal/config" - "parasocial/internal/gql" - "parasocial/internal/irc" "parasocial/internal/tui" - "parasocial/internal/twitch" ) -const streamerRefreshInterval = 5 * time.Minute - -// Run loads the application configuration and starts the terminal UI. +// Run loads configuration and starts the terminal UI. func Run(ctx context.Context) error { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - cfg, err := config.LoadDefault() if err != nil { return err } - - httpClient := &http.Client{Timeout: 30 * time.Second} - authClient := auth.NewClient(httpClient) - authPath := auth.DefaultPath() - - state, err := authClient.ReuseAuth(ctx, authPath) - if err != nil { - return err - } - - program := tea.NewProgram( - tui.New(tui.Options{ - Streamers: twitch.LoadingStreamerEntries(cfg.Streamers), - AuthState: state, - StartAuth: func(ch chan<- tui.AuthUpdate) { - go func() { - defer close(ch) - - state, err := authClient.EnsureAuth(ctx, authPath, func(line string) { - ch <- tui.AuthUpdate{Line: strings.TrimRight(line, "\n")} - }) - if err != nil { - ch <- tui.AuthUpdate{ - Line: fmt.Sprintf("Authentication failed: %v", err), - Err: err, - Done: true, - } - return - } - - ch <- tui.AuthUpdate{State: state, Done: true} - }() - }, - StartResolve: func(state *auth.State, ch chan<- tui.StreamerUpdate) { - go func() { - defer close(ch) - ircManager := newIRCManager(ch) - defer ircManager.Close() - - service, err := newTwitchService(httpClient, state) - if err != nil { - ch <- tui.StreamerUpdate{Err: err, Done: true} - return - } - if err := resolveStreamerEntries(ctx, service, state, cfg.Streamers, ircManager, func(update tui.StreamerUpdate) { - ch <- update - }); err != nil { - if errors.Is(err, context.Canceled) { - return - } - ch <- tui.StreamerUpdate{Err: err, Done: true} - return - } - ch <- tui.StreamerUpdate{Done: true} - }() - }, - }), - tea.WithContext(ctx), - tea.WithInput(os.Stdin), - tea.WithOutput(os.Stdout), - ) - _, err = program.Run() - return err -} - -// streamerService captures the Twitch lookups the app needs during UI resolution. -type streamerService interface { - CurrentUser(context.Context) (*twitch.Viewer, error) - ResolveStreamer(context.Context, string) (*twitch.Channel, error) - StreamInfo(context.Context, string) (*twitch.StreamInfo, error) - PlaybackAccessToken(context.Context, string) (*twitch.PlaybackToken, error) -} - -type ircSyncer interface { - Sync(context.Context, string, string, []irc.Target) -} - -// newTwitchService builds a Twitch service from the authenticated session state. -func newTwitchService(httpClient *http.Client, state *auth.State) (*twitch.Service, error) { - client := &gql.Client{ - HTTPClient: httpClient, - Session: gql.Session{ - AccessToken: state.AccessToken, - ClientID: state.ClientID, - DeviceID: state.DeviceID, - UserAgent: auth.TVUserAgent(), - }, - } - if err := client.Validate(); err != nil { - return nil, fmt.Errorf("configure graphql session: %w", err) - } - return &twitch.Service{GQL: client}, nil -} - -func newIRCManager(ch chan<- tui.StreamerUpdate) *irc.Manager { - return &irc.Manager{ - Addr: irc.DefaultAddr, - Events: func(event irc.Event) { - ch <- tui.StreamerUpdate{ - IRC: &tui.IRCUpdate{ - Login: event.Streamer, - State: tui.IRCState(event.State), - Line: event.Line, - }, - } - }, - } -} - -// resolveStreamerEntries streams viewer and streamer resolution results into the TUI. -func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(tui.StreamerUpdate)) error { - return resolveStreamerEntriesWithSleep(ctx, service, state, logins, syncer, send, streamerRefreshInterval, sleepContext) -} - -func resolveStreamerEntriesWithSleep(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(tui.StreamerUpdate), interval time.Duration, sleep func(context.Context, time.Duration) error) error { - viewer, err := service.CurrentUser(ctx) - if err != nil { - return fmt.Errorf("resolve current user: %w", err) - } - send(tui.StreamerUpdate{Viewer: viewer}) - - entries := twitch.LoadingStreamerEntries(logins) - syncWatchedChannels(ctx, syncer, state, entries) - - for { - for index, login := range logins { - entry, err := resolveStreamerEntry(ctx, service, login) - if errors.Is(err, context.Canceled) { - return err - } - entries[index] = *entry - send(tui.StreamerUpdate{ - Index: index, - Entry: entry, - }) - syncWatchedChannels(ctx, syncer, state, entries) - } - if err := sleep(ctx, interval); err != nil { - return err - } - } -} - -func resolveStreamerEntry(ctx context.Context, service streamerService, login string) (*twitch.StreamerEntry, error) { - entry := &twitch.StreamerEntry{ - ConfigLogin: login, - Status: twitch.StreamerLoading, - } - - channel, err := service.ResolveStreamer(ctx, login) - switch { - case err == nil: - entry.Login = channel.Login - entry.ChannelID = channel.ID - case errors.Is(err, context.Canceled): - return nil, err - default: - entry.Status = twitch.StreamerError - entry.Error = err.Error() - return entry, nil - } - - stream, err := service.StreamInfo(ctx, channel.ID) - switch { - case err == nil: - entry.Status = twitch.StreamerReady - entry.Live = stream.Online - case errors.Is(err, context.Canceled): - return nil, err - default: - entry.Status = twitch.StreamerError - entry.Error = err.Error() - return entry, nil - } - - if !entry.Live { - return entry, nil - } - if _, err := service.PlaybackAccessToken(ctx, channel.Login); err != nil && errors.Is(err, context.Canceled) { - return nil, err - } - return entry, nil -} - -func sleepContext(ctx context.Context, duration time.Duration) error { - timer := time.NewTimer(duration) - defer timer.Stop() - - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -func syncWatchedChannels(ctx context.Context, syncer ircSyncer, state *auth.State, entries []twitch.StreamerEntry) { - if syncer == nil || state == nil { - return - } - syncer.Sync(ctx, state.Login, state.AccessToken, watchedIRCTargets(entries)) -} - -func watchedIRCTargets(entries []twitch.StreamerEntry) []irc.Target { - targets := make([]irc.Target, 0, 2) - for _, entry := range entries { - if entry.Status != twitch.StreamerReady || !entry.Live || entry.Login == "" { - continue - } - targets = append(targets, irc.Target{Login: entry.Login}) - if len(targets) == 2 { - break - } - } - return targets + return tui.Run(ctx, tui.Options{Streamers: cfg.Streamers}) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go deleted file mode 100644 index 8e61a51..0000000 --- a/internal/app/app_test.go +++ /dev/null @@ -1,288 +0,0 @@ -// app_test.go covers the app-layer orchestration around Twitch viewer and streamer resolution. -// It exercises the background resolution loop independently of Bubble Tea startup -// so the app wiring can be validated without requiring a full interactive session. -package app - -import ( - "context" - "errors" - "reflect" - "testing" - "time" - - "parasocial/internal/auth" - "parasocial/internal/irc" - "parasocial/internal/tui" - "parasocial/internal/twitch" -) - -// fakeStreamerService is a test double for the app's Twitch resolution dependency. -type fakeStreamerService struct { - viewer *twitch.Viewer - viewerErr error - channels map[string]*twitch.Channel - resolveErrs map[string]error - streams map[string][]streamResult - streamCalls map[string]int - playbackErr map[string]error -} - -type streamResult struct { - info *twitch.StreamInfo - err error -} - -// CurrentUser returns the configured fake viewer or viewer error. -func (f *fakeStreamerService) CurrentUser(context.Context) (*twitch.Viewer, error) { - if f.viewerErr != nil { - return nil, f.viewerErr - } - return f.viewer, nil -} - -// ResolveStreamer returns the configured fake channel or resolution error. -func (f *fakeStreamerService) ResolveStreamer(_ context.Context, login string) (*twitch.Channel, error) { - if err, ok := f.resolveErrs[login]; ok { - return nil, err - } - return f.channels[login], nil -} - -func (f *fakeStreamerService) StreamInfo(_ context.Context, channelID string) (*twitch.StreamInfo, error) { - if f.streamCalls == nil { - f.streamCalls = map[string]int{} - } - call := f.streamCalls[channelID] - f.streamCalls[channelID] = call + 1 - - results := f.streams[channelID] - if len(results) == 0 { - return &twitch.StreamInfo{Online: false}, nil - } - if call >= len(results) { - call = len(results) - 1 - } - result := results[call] - if result.err != nil { - return nil, result.err - } - return result.info, nil -} - -func (f *fakeStreamerService) PlaybackAccessToken(_ context.Context, login string) (*twitch.PlaybackToken, error) { - if err, ok := f.playbackErr[login]; ok { - return nil, err - } - return &twitch.PlaybackToken{Signature: "sig", Value: "token"}, nil -} - -type fakeIRCSyncer struct { - calls [][]string -} - -func (f *fakeIRCSyncer) Sync(_ context.Context, _, _ string, targets []irc.Target) { - logins := make([]string, 0, len(targets)) - for _, target := range targets { - logins = append(logins, target.Login) - } - f.calls = append(f.calls, logins) -} - -// TestResolveStreamerEntries verifies that app resolution emits viewer, success, and error updates. -func TestResolveStreamerEntries(t *testing.T) { - t.Parallel() - - service := &fakeStreamerService{ - viewer: &twitch.Viewer{ID: "7", Login: "viewer"}, - channels: map[string]*twitch.Channel{ - "alpha": {ID: "1", Login: "alpha_live"}, - }, - resolveErrs: map[string]error{ - "beta": errors.New("lookup failed"), - }, - streams: map[string][]streamResult{ - "1": {{info: &twitch.StreamInfo{Online: true}}}, - }, - } - - var updates []tui.StreamerUpdate - err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha", "beta"}, nil, func(update tui.StreamerUpdate) { - updates = append(updates, update) - }, 0, func(context.Context, time.Duration) error { - return context.Canceled - }) - if !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - if len(updates) != 3 { - t.Fatalf("len(updates) = %d", len(updates)) - } - if updates[0].Viewer == nil || updates[0].Viewer.Login != "viewer" { - t.Fatalf("viewer update = %#v", updates[0]) - } - if updates[1].Entry == nil || updates[1].Entry.Status != twitch.StreamerReady || updates[1].Entry.Login != "alpha_live" || !updates[1].Entry.Live { - t.Fatalf("alpha update = %#v", updates[1]) - } - if updates[2].Entry == nil || updates[2].Entry.Status != twitch.StreamerError { - t.Fatalf("beta update = %#v", updates[2]) - } -} - -func TestResolveStreamerEntriesPlaybackFailureStillMarksLive(t *testing.T) { - t.Parallel() - - service := &fakeStreamerService{ - viewer: &twitch.Viewer{ID: "7", Login: "viewer"}, - channels: map[string]*twitch.Channel{ - "alpha": {ID: "1", Login: "alpha_live"}, - }, - streams: map[string][]streamResult{ - "1": {{info: &twitch.StreamInfo{Online: true}}}, - }, - playbackErr: map[string]error{ - "alpha_live": errors.New("token lookup failed"), - }, - } - - var updates []tui.StreamerUpdate - err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha"}, nil, func(update tui.StreamerUpdate) { - updates = append(updates, update) - }, 0, func(context.Context, time.Duration) error { - return context.Canceled - }) - if !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - if len(updates) != 2 { - t.Fatalf("len(updates) = %d", len(updates)) - } - entry := updates[1].Entry - if entry == nil || entry.Status != twitch.StreamerReady || !entry.Live { - t.Fatalf("entry = %#v", entry) - } -} - -func TestResolveStreamerEntriesRefreshesLiveState(t *testing.T) { - t.Parallel() - - service := &fakeStreamerService{ - viewer: &twitch.Viewer{ID: "7", Login: "viewer"}, - channels: map[string]*twitch.Channel{ - "alpha": {ID: "1", Login: "alpha_live"}, - }, - streams: map[string][]streamResult{ - "1": { - {info: &twitch.StreamInfo{Online: false}}, - {info: &twitch.StreamInfo{Online: true}}, - }, - }, - } - - var updates []tui.StreamerUpdate - sleepCalls := 0 - err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha"}, nil, func(update tui.StreamerUpdate) { - updates = append(updates, update) - }, 0, func(context.Context, time.Duration) error { - sleepCalls++ - if sleepCalls == 1 { - return nil - } - return context.Canceled - }) - if !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - if len(updates) != 3 { - t.Fatalf("len(updates) = %d", len(updates)) - } - if updates[1].Entry == nil || updates[1].Entry.Live { - t.Fatalf("first pass = %#v", updates[1]) - } - if updates[2].Entry == nil || !updates[2].Entry.Live { - t.Fatalf("second pass = %#v", updates[2]) - } -} - -func TestResolveStreamerEntriesSyncsTopTwoLiveChannels(t *testing.T) { - t.Parallel() - - service := &fakeStreamerService{ - viewer: &twitch.Viewer{ID: "7", Login: "viewer"}, - channels: map[string]*twitch.Channel{ - "alpha": {ID: "1", Login: "alpha_live"}, - "beta": {ID: "2", Login: "beta_live"}, - "gamma": {ID: "3", Login: "gamma_live"}, - }, - streams: map[string][]streamResult{ - "1": {{info: &twitch.StreamInfo{Online: true}}}, - "2": {{info: &twitch.StreamInfo{Online: true}}}, - "3": {{info: &twitch.StreamInfo{Online: true}}}, - }, - } - syncer := &fakeIRCSyncer{} - - err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha", "beta", "gamma"}, syncer, func(tui.StreamerUpdate) { - }, 0, func(context.Context, time.Duration) error { - return context.Canceled - }) - if !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - - want := [][]string{ - {}, - {"alpha_live"}, - {"alpha_live", "beta_live"}, - {"alpha_live", "beta_live"}, - } - if !reflect.DeepEqual(syncer.calls, want) { - t.Fatalf("sync calls = %#v, want %#v", syncer.calls, want) - } -} - -func TestResolveStreamerEntriesRefreshUpdatesWatchedChannels(t *testing.T) { - t.Parallel() - - service := &fakeStreamerService{ - viewer: &twitch.Viewer{ID: "7", Login: "viewer"}, - channels: map[string]*twitch.Channel{ - "alpha": {ID: "1", Login: "alpha_live"}, - "beta": {ID: "2", Login: "beta_live"}, - }, - streams: map[string][]streamResult{ - "1": { - {info: &twitch.StreamInfo{Online: true}}, - {info: &twitch.StreamInfo{Online: false}}, - }, - "2": { - {info: &twitch.StreamInfo{Online: false}}, - {info: &twitch.StreamInfo{Online: true}}, - }, - }, - } - syncer := &fakeIRCSyncer{} - - sleepCalls := 0 - err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha", "beta"}, syncer, func(tui.StreamerUpdate) { - }, 0, func(context.Context, time.Duration) error { - sleepCalls++ - if sleepCalls == 1 { - return nil - } - return context.Canceled - }) - if !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - - want := [][]string{ - {}, - {"alpha_live"}, - {"alpha_live"}, - {}, - {"beta_live"}, - } - if !reflect.DeepEqual(syncer.calls, want) { - t.Fatalf("sync calls = %#v, want %#v", syncer.calls, want) - } -} diff --git a/internal/tui/messages.go b/internal/tui/messages.go new file mode 100644 index 0000000..0736af9 --- /dev/null +++ b/internal/tui/messages.go @@ -0,0 +1,48 @@ +package tui + +import ( + "parasocial/internal/auth" + "parasocial/internal/twitch" +) + +// IRCState describes the current IRC join lifecycle for one streamer row. +type IRCState string + +const ( + IRCPending IRCState = "pending" + IRCJoined IRCState = "joined" + IRCDisconnected IRCState = "disconnected" +) + +// 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 + IRC *IRCUpdate + Index int + Err error + Done bool +} + +// IRCUpdate carries one IRC connection state or log line into the TUI. +type IRCUpdate struct { + Login string + State IRCState + Line string +} + +type authStartedMsg struct { + Updates <-chan AuthUpdate +} + +type streamerStartedMsg struct { + Updates <-chan StreamerUpdate +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 2696614..6909df0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,13 +1,10 @@ -// 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" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "parasocial/internal/auth" @@ -15,7 +12,6 @@ import ( ) const defaultViewWidth = 80 -const greenDot = "\x1b[32m●\x1b[0m" type viewMode int @@ -24,70 +20,16 @@ const ( streamerView ) -// IRCState describes the current IRC join lifecycle for one streamer row. -type IRCState string - -const ( - IRCPending IRCState = "pending" - IRCJoined IRCState = "joined" - IRCDisconnected IRCState = "disconnected" -) - -// 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 - IRC *IRCUpdate - Index int - Err error - Done bool -} - -// IRCUpdate carries one IRC connection state or log line into the TUI. -type IRCUpdate struct { - Login string - State IRCState - Line string -} - -// 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 +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 - startAuth StartAuthFunc - startResolve StartResolveFunc + runtime modelRuntime authUpdates <-chan AuthUpdate streamerUpdates <-chan StreamerUpdate authLogs []string @@ -99,44 +41,49 @@ type Model struct { height int selectedConfig string ircDetails map[string]ircDetail + authViewport viewport.Model } type ircDetail struct { joined bool -} - -type streamerRow struct { - index int - entry twitch.StreamerEntry + line 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: append([]twitch.StreamerEntry(nil), options.Streamers...), + streamers: streamers, authState: options.AuthState, - startAuth: options.StartAuth, - startResolve: options.StartResolve, + runtime: options.runtime, authLogs: []string{}, mode: authView, ircDetails: make(map[string]ircDetail), + width: defaultViewWidth, + height: 24, + authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)), } if options.AuthState != nil { model.mode = streamerView } model.ensureSelection() + model.syncAuthViewport() 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 startAuthSession(m.runtime) } - return startStreamerResolution(m.startResolve, m.authState) + return startStreamerResolution(m.runtime, m.authState) } -// Update applies auth progress events and handles the global quit keys. +// 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: @@ -146,158 +93,95 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) - m.ircDetails = make(map[string]ircDetail) - m.selectedConfig = "" - m.ensureSelection() - return m, startStreamerResolution(m.startResolve, m.authState) - } - return m, nil - } - if m.authUpdates != nil { - return m, waitForAuthUpdate(m.authUpdates) - } + return m.updateAuth(msg) 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.IRC != nil { - m.applyIRCUpdate(*msg.IRC) - } - if msg.Err != nil { - m.resolveErr = msg.Err - } - m.ensureSelection() - if msg.Done { - return m, nil - } - if m.streamerUpdates != nil { - return m, waitForStreamerUpdate(m.streamerUpdates) - } + return m.updateStreamer(msg) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.resizeComponents() m.ensureSelection() + return m, nil case tea.KeyMsg: switch msg.String() { case "up", "k": if m.mode == streamerView { m.moveSelection(-1) } + return m, nil case "down", "j": if m.mode == streamerView { m.moveSelection(1) } + 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 + } return m, nil } -// View renders either the login log screen or the authenticated streamer list. -func (m Model) View() string { - if m.mode == authView { - var builder strings.Builder - 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() +func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) { + if msg.Line != "" { + m.authLogs = append(m.authLogs, msg.Line) + m.syncAuthViewport() } - - return m.renderStreamerView() + 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.selectedConfig = "" + m.ensureSelection() + m.resizeComponents() + 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) renderStreamerView() string { - var builder strings.Builder - switch { - case m.viewer != nil: - fmt.Fprintf(&builder, "Logged in as %s (%s)\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", m.resolveErr) - } else { - builder.WriteString("Resolving viewer identity...\n") - } +func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) { + if msg.Viewer != nil { + m.viewer = msg.Viewer + m.resolveErr = nil } - fmt.Fprintf(&builder, "Watching: %s\n", watchingSummary(m.streamers)) - - rows := m.orderedRows() - selectedIndex := m.selectedRowIndex(rows) - leftPane := m.leftPaneLines(rows) - rightPane := m.rightPaneLines(rows, selectedIndex) - - if m.height > 0 { - available := m.height - 3 - if available > 0 { - leftPane = clipListLines(leftPane, available, selectedIndex+1) - rightPane = clipTailLines(rightPane, available) - } + if msg.Entry != nil && msg.Index >= 0 && msg.Index < len(m.streamers) { + m.streamers[msg.Index] = *msg.Entry } - - bodyWidth := m.width - if bodyWidth <= 0 { - bodyWidth = defaultViewWidth + if msg.IRC != nil { + m.applyIRCUpdate(*msg.IRC) } - - builder.WriteString("\n") - for _, line := range renderColumns(leftPane, rightPane, bodyWidth) { - builder.WriteString(line) - builder.WriteByte('\n') + if msg.Err != nil { + m.resolveErr = msg.Err } - builder.WriteByte('\n') - return builder.String() -} - -func watchingSummary(streamers []twitch.StreamerEntry) string { - live := make([]string, 0, 2) - for _, streamer := range streamers { - if streamer.Status != twitch.StreamerReady || !streamer.Live { - continue - } - name := streamer.Login - if name == "" { - name = streamer.ConfigLogin - } - live = append(live, name) - if len(live) == 2 { - break - } + m.ensureSelection() + m.resizeComponents() + if msg.Done { + return m, nil } - if len(live) == 0 { - return "no live streamers" + if m.streamerUpdates != nil { + return m, waitForStreamerUpdate(m.streamerUpdates) } - return strings.Join(live, ", ") + return m, nil } func (m *Model) applyIRCUpdate(update IRCUpdate) { @@ -314,114 +198,89 @@ func (m *Model) applyIRCUpdate(update IRCUpdate) { detail.joined = false } if update.Line != "" { + detail.line = update.Line } m.ircDetails[login] = detail } func (m *Model) ensureSelection() { - rows := m.orderedRows() - if len(rows) == 0 { + entries := m.orderedStreamers() + if len(entries) == 0 { m.selectedConfig = "" return } if m.selectedConfig == "" { - m.selectedConfig = rows[0].entry.ConfigLogin + m.selectedConfig = entries[0].ConfigLogin return } - for _, row := range rows { - if row.entry.ConfigLogin == m.selectedConfig { + for _, entry := range entries { + if entry.ConfigLogin == m.selectedConfig { return } } - m.selectedConfig = rows[0].entry.ConfigLogin + m.selectedConfig = entries[0].ConfigLogin } func (m *Model) moveSelection(delta int) { - rows := m.orderedRows() - if len(rows) == 0 { + entries := m.orderedStreamers() + if len(entries) == 0 { m.selectedConfig = "" return } - m.ensureSelection() - selected := m.selectedRowIndex(rows) + selected := m.selectedRowIndex(entries) + delta if selected < 0 { selected = 0 } - selected += delta - if selected < 0 { - selected = 0 + if selected >= len(entries) { + selected = len(entries) - 1 } - if selected >= len(rows) { - selected = len(rows) - 1 - } - m.selectedConfig = rows[selected].entry.ConfigLogin + m.selectedConfig = entries[selected].ConfigLogin } -func (m Model) orderedRows() []streamerRow { - rows := make([]streamerRow, 0, len(m.streamers)) - for index, entry := range m.streamers { +func (m Model) orderedStreamers() []twitch.StreamerEntry { + entries := make([]twitch.StreamerEntry, 0, len(m.streamers)) + for _, entry := range m.streamers { if isActive(entry) { - rows = append(rows, streamerRow{index: index, entry: entry}) + entries = append(entries, entry) } } - for index, entry := range m.streamers { + for _, entry := range m.streamers { if isActive(entry) { continue } - rows = append(rows, streamerRow{index: index, entry: entry}) + entries = append(entries, entry) } - return rows + return entries } -func (m Model) selectedRowIndex(rows []streamerRow) int { - for index, row := range rows { - if row.entry.ConfigLogin == m.selectedConfig { +func (m Model) selectedRowIndex(entries []twitch.StreamerEntry) int { + for index, entry := range entries { + if entry.ConfigLogin == m.selectedConfig { return index } } - if len(rows) == 0 { + if len(entries) == 0 { return -1 } return 0 } -func (m Model) leftPaneLines(rows []streamerRow) []string { - lines := []string{"Streamers"} - for _, row := range rows { - prefix := " " - if row.entry.ConfigLogin == m.selectedConfig { - prefix = "> " - } - - status := "inactive" - if isActive(row.entry) { - status = "active" - } - lines = append(lines, prefix+streamerName(row.entry)+" ["+status+"]") - } - if len(rows) == 0 { - lines = append(lines, " none") - } - return lines +func (m *Model) resizeComponents() { + m.authViewport.Width = contentWidth(m.width) + m.authViewport.Height = authViewportHeight(m.height) } -func (m Model) rightPaneLines(rows []streamerRow, selected int) []string { - lines := []string{"IRC Chat"} - if selected < 0 || selected >= len(rows) { - return append(lines, "") - } +func (m *Model) syncAuthViewport() { + m.authViewport.SetContent(m.authLogContent()) + m.authViewport.GotoBottom() +} - row := rows[selected].entry - if !isActive(row) { - return append(lines, "inactive") +func (m Model) authLogContent() string { + if len(m.authLogs) == 0 { + return "Starting authentication..." } - - detail := m.ircDetails[normalizeKey(row.Login)] - if !detail.joined { - return append(lines, "not joined") - } - return append(lines, greenDot) + return strings.Join(m.authLogs, "\n") } func isActive(entry twitch.StreamerEntry) bool { @@ -439,140 +298,39 @@ func normalizeKey(value string) string { return strings.ToLower(strings.TrimSpace(value)) } -func renderColumns(left, right []string, totalWidth int) []string { - if totalWidth <= 0 { - totalWidth = defaultViewWidth +func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry { + logins := make([]string, 0, len(entries)) + for _, entry := range entries { + logins = append(logins, entry.ConfigLogin) } - - leftWidth, rightWidth := splitWidths(totalWidth) - height := len(left) - if len(right) > height { - height = len(right) - } - - lines := make([]string, 0, height) - for index := 0; index < height; index++ { - leftLine := "" - if index < len(left) { - leftLine = left[index] - } - rightLine := "" - if index < len(right) { - rightLine = right[index] - } - lines = append(lines, fitWidth(leftLine, leftWidth)+" │ "+fitWidth(rightLine, rightWidth)) - } - return lines + return twitch.LoadingStreamerEntries(logins) } -func splitWidths(totalWidth int) (int, int) { - if totalWidth < 8 { - return max(1, totalWidth-4), 1 - } - - leftWidth := totalWidth / 3 - if leftWidth < 20 { - leftWidth = 20 - } - if leftWidth > 32 { - leftWidth = 32 - } - - rightWidth := totalWidth - leftWidth - 3 - if rightWidth < 8 { - rightWidth = 8 - leftWidth = totalWidth - rightWidth - 3 - if leftWidth < 1 { - leftWidth = 1 - rightWidth = max(1, totalWidth-leftWidth-3) - } - } - return leftWidth, rightWidth -} - -func fitWidth(value string, width int) string { - if width <= 0 { - return "" - } - - runes := []rune(value) - if len(runes) > width { - return string(runes[:width]) - } - return value + strings.Repeat(" ", width-len(runes)) -} - -func clipListLines(lines []string, height, selectedLine int) []string { - if height <= 0 || len(lines) <= height { - return lines - } - if height == 1 { - return lines[:1] - } - - rows := lines[1:] - visibleRows := height - 1 - selectedRow := selectedLine - 1 - if selectedRow < 0 { - selectedRow = 0 - } - start := 0 - if selectedRow >= visibleRows { - start = selectedRow - visibleRows + 1 - } - if start+visibleRows > len(rows) { - start = len(rows) - visibleRows - } - if start < 0 { - start = 0 - } - return append([]string{lines[0]}, rows[start:start+visibleRows]...) -} - -func clipTailLines(lines []string, height int) []string { - if height <= 0 || len(lines) <= height { - return lines - } - if height == 1 { - return lines[:1] - } - - body := lines[1:] - visibleRows := height - 1 - if len(body) > visibleRows { - body = body[len(body)-visibleRows:] - } - return append([]string{lines[0]}, body...) -} - -// startAuthSession starts the background auth worker and returns its update channel to Bubble Tea. -func startAuthSession(start StartAuthFunc) tea.Cmd { +func startAuthSession(runtime modelRuntime) tea.Cmd { return func() tea.Msg { - if start == nil { - return AuthUpdate{Err: errors.New("auth start function is nil"), Done: true} + if runtime == nil { + return AuthUpdate{Err: errors.New("auth runtime is nil"), Done: true} } updates := make(chan AuthUpdate, 32) - start(updates) + runtime.startAuth(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 { +func startStreamerResolution(runtime modelRuntime, 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 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) - start(state, updates) + runtime.startResolve(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 @@ -583,7 +341,6 @@ func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd { } } -// 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 @@ -593,12 +350,3 @@ func waitForStreamerUpdate(updates <-chan StreamerUpdate) tea.Cmd { 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) -} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 7f4125a..4cbcc9b 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -5,118 +5,94 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "parasocial/internal/auth" "parasocial/internal/twitch" ) -func TestViewDisplaysSplitPaneWithSelectedStreamerDetails(t *testing.T) { - model := New(Options{ - Streamers: []twitch.StreamerEntry{ - {ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Live: true, Status: twitch.StreamerReady}, - {ConfigLogin: "beta", Login: "beta_live", ChannelID: "2", Live: true, Status: twitch.StreamerReady}, - {ConfigLogin: "gamma", Status: twitch.StreamerLoading}, - }, - }) - model.mode = streamerView - model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"} - model.width = 80 +type fakeModelRuntime struct { + authStarted bool + resolveStarted *auth.State +} - got := model.View() - if !strings.Contains(got, "Watching: alpha_live, beta_live\n\n") { - t.Fatalf("View() missing watching summary:\n%s", got) - } - if !strings.Contains(got, "> alpha_live [active]") { - t.Fatalf("View() missing selected active row:\n%s", got) - } - if !strings.Contains(got, "gamma [inactive]") { - t.Fatalf("View() missing inactive row:\n%s", got) - } - if !strings.Contains(got, "IRC Chat") || !strings.Contains(got, "not joined") { - t.Fatalf("View() missing detail pane:\n%s", got) - } +func (f *fakeModelRuntime) startAuth(ch chan<- AuthUpdate) { + f.authStarted = true + close(ch) +} + +func (f *fakeModelRuntime) startResolve(state *auth.State, ch chan<- StreamerUpdate) { + f.resolveStarted = state + close(ch) +} + +func TestViewDisplaysDashboardWithSelectedStreamerDetails(t *testing.T) { + model := dashboardModel( + twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady}, + twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady}, + twitch.StreamerEntry{ConfigLogin: "gamma", Status: twitch.StreamerLoading}, + ) + + assertContainsAll(t, model.View(), + "Watching: alpha_live, beta_live", + "alpha_live", + "live | irc idle", + "gamma", + "loading", + "IRC Chat", + "not joined", + ) } func TestAuthUpdateAppendsLogLine(t *testing.T) { - model := New(Options{Streamers: twitch.LoadingStreamerEntries([]string{"alpha"})}) - - updated, cmd := model.Update(AuthUpdate{Line: "Open page: https://www.twitch.tv/activate"}) + updated, cmd := New(Options{Streamers: []string{"alpha"}}).Update(AuthUpdate{Line: "Open page: https://www.twitch.tv/activate"}) if cmd != nil { t.Fatal("expected nil cmd after auth update without channel") } - - next := updated.(Model) - got := next.View() - want := "Twitch Login\nOpen page: https://www.twitch.tv/activate\n\n" - if got != want { - t.Fatalf("View() = %q, want %q", got, want) - } + assertContainsAll(t, updated.(Model).View(), "Twitch Login", "Open page: https://www.twitch.tv/activate") } func TestAuthSuccessSwitchesToStreamerViewAndStartsResolution(t *testing.T) { - var started *auth.State - model := New(Options{ - Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}), - StartResolve: func(state *auth.State, ch chan<- StreamerUpdate) { - started = state - close(ch) - }, - }) - + runtime := &fakeModelRuntime{} state := &auth.State{Login: "viewer", UserID: "7"} - - updated, cmd := model.Update(AuthUpdate{ - State: state, - Done: true, - }) + updated, cmd := New(Options{Streamers: []string{"alpha"}, runtime: runtime}).Update(AuthUpdate{State: state, Done: true}) if cmd == nil { t.Fatal("expected streamer resolution command") } if _, ok := cmd().(streamerStartedMsg); !ok { t.Fatalf("cmd() returned %T, want streamerStartedMsg", cmd()) } - if started != state { - t.Fatalf("started state = %#v, want %#v", started, state) + if runtime.resolveStarted != state { + t.Fatalf("started state = %#v, want %#v", runtime.resolveStarted, state) } next := updated.(Model) - got := next.View() - if !strings.Contains(got, "Watching: no live streamers") { - t.Fatalf("View() = %q", got) - } + assertContainsAll(t, next.View(), "Watching: no live streamers") if next.selectedConfig != "alpha" { t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig) } } -func TestInitStartsResolutionWhenAlreadyAuthenticated(t *testing.T) { - var started *auth.State - state := &auth.State{Login: "viewer", UserID: "7"} - model := New(Options{ - Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}), - AuthState: state, - StartResolve: func(got *auth.State, ch chan<- StreamerUpdate) { - started = got - close(ch) - }, - }) +func TestInitStartsAuthOrResolution(t *testing.T) { + runtime := &fakeModelRuntime{} + if _, ok := New(Options{Streamers: []string{"alpha"}, runtime: runtime}).Init()().(authStartedMsg); !ok { + t.Fatal("unauthenticated Init() did not start auth") + } + if !runtime.authStarted { + t.Fatal("expected auth runtime to start") + } - cmd := model.Init() - if cmd == nil { - t.Fatal("expected init command") + state := &auth.State{Login: "viewer", UserID: "7"} + runtime = &fakeModelRuntime{} + if _, ok := New(Options{Streamers: []string{"alpha"}, AuthState: state, runtime: runtime}).Init()().(streamerStartedMsg); !ok { + t.Fatal("authenticated Init() did not start streamer resolution") } - if _, ok := cmd().(streamerStartedMsg); !ok { - t.Fatalf("cmd() returned %T, want streamerStartedMsg", cmd()) - } - if started != state { - t.Fatalf("started state = %#v, want %#v", started, state) + if runtime.resolveStarted != state { + t.Fatalf("started state = %#v, want %#v", runtime.resolveStarted, state) } } func TestStreamerUpdateAppliesEntry(t *testing.T) { - model := New(Options{ - Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}), - AuthState: &auth.State{Login: "viewer"}, - }) + model := New(Options{Streamers: []string{"alpha"}, AuthState: &auth.State{Login: "viewer"}}) model.mode = streamerView updated, cmd := model.Update(StreamerUpdate{ @@ -135,39 +111,29 @@ func TestStreamerUpdateAppliesEntry(t *testing.T) { } next := updated.(Model) + assertContainsAll(t, next.View(), "alpha_live", "live") if next.selectedConfig != "alpha" { t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig) } } func TestViewDisplaysInactiveDetailForOfflineStreamer(t *testing.T) { - model := New(Options{ - Streamers: []twitch.StreamerEntry{ - {ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Status: twitch.StreamerReady}, - }, - }) - model.mode = streamerView - model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"} - model.width = 80 - - got := model.View() - if !strings.Contains(got, "inactive") { - t.Fatalf("View() = %q", got) - } + model := dashboardModel(twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady}) + assertContainsAll(t, model.View(), "offline", "inactive") } func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) { - model := New(Options{ - Streamers: []twitch.StreamerEntry{ - {ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady}, - {ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady}, - {ConfigLogin: "gamma", Login: "gamma_live", Live: true, Status: twitch.StreamerReady}, - {ConfigLogin: "delta", Status: twitch.StreamerLoading}, - }, - }) + model := New(Options{initialStreamers: []twitch.StreamerEntry{ + {ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady}, + {ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady}, + {ConfigLogin: "gamma", Login: "gamma_live", Live: true, Status: twitch.StreamerReady}, + {ConfigLogin: "delta", Status: twitch.StreamerLoading}, + }}) - rows := model.orderedRows() - got := []string{rows[0].entry.ConfigLogin, rows[1].entry.ConfigLogin, rows[2].entry.ConfigLogin, rows[3].entry.ConfigLogin} + got := []string{} + for _, entry := range model.orderedStreamers() { + got = append(got, entry.ConfigLogin) + } want := []string{"beta", "gamma", "alpha", "delta"} for index := range want { if got[index] != want[index] { @@ -178,7 +144,7 @@ func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) { func TestSelectionTracksSameStreamerAcrossReorder(t *testing.T) { model := New(Options{ - Streamers: []twitch.StreamerEntry{ + initialStreamers: []twitch.StreamerEntry{ {ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady}, {ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady}, }, @@ -189,33 +155,19 @@ func TestSelectionTracksSameStreamerAcrossReorder(t *testing.T) { updated, _ := model.Update(StreamerUpdate{ Index: 1, - Entry: &twitch.StreamerEntry{ - ConfigLogin: "beta", - Login: "beta_live", - Live: true, - Status: twitch.StreamerReady, - }, + Entry: &twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady}, }) next := updated.(Model) - - if next.selectedConfig != "beta" { - t.Fatalf("selectedConfig = %q, want beta", next.selectedConfig) - } - if next.selectedRowIndex(next.orderedRows()) != 0 { - t.Fatalf("selected row index = %d, want 0", next.selectedRowIndex(next.orderedRows())) + if next.selectedConfig != "beta" || next.selectedRowIndex(next.orderedStreamers()) != 0 { + t.Fatalf("selection moved after reorder: %q at %d", next.selectedConfig, next.selectedRowIndex(next.orderedStreamers())) } } func TestUpDownNavigationMovesSelectedStreamer(t *testing.T) { - model := New(Options{ - Streamers: []twitch.StreamerEntry{ - {ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady}, - {ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady}, - }, - AuthState: &auth.State{Login: "viewer"}, - }) - model.mode = streamerView - model.selectedConfig = "alpha" + model := dashboardModel( + twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady}, + twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady}, + ) updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown}) next := updated.(Model) @@ -231,49 +183,47 @@ func TestUpDownNavigationMovesSelectedStreamer(t *testing.T) { } func TestIRCUpdatesShowJoinedStatusOnly(t *testing.T) { - model := New(Options{ - Streamers: []twitch.StreamerEntry{ - {ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady}, - }, - AuthState: &auth.State{Login: "viewer"}, - }) - model.mode = streamerView - model.width = 80 - - updated, _ := model.Update(StreamerUpdate{ - IRC: &IRCUpdate{ - Login: "alpha_live", - State: IRCJoined, - }, - }) + updated, _ := dashboardModel(twitch.StreamerEntry{ + ConfigLogin: "alpha", + Login: "alpha_live", + Live: true, + Status: twitch.StreamerReady, + }).Update(StreamerUpdate{IRC: &IRCUpdate{Login: "alpha_live", State: IRCJoined}}) next := updated.(Model) - detail := next.ircDetails["alpha_live"] - if !detail.joined { + if !next.ircDetails["alpha_live"].joined { t.Fatal("expected joined detail") } - if !strings.Contains(next.View(), greenDot) { - t.Fatalf("View() missing green status dot:\n%s", next.View()) - } + assertContainsAll(t, next.View(), "joined") } -func TestWindowSizeClipsListAroundSelection(t *testing.T) { - model := New(Options{ - Streamers: []twitch.StreamerEntry{ - {ConfigLogin: "alpha", Status: twitch.StreamerReady}, - {ConfigLogin: "beta", Status: twitch.StreamerReady}, - {ConfigLogin: "gamma", Status: twitch.StreamerReady}, - {ConfigLogin: "delta", Status: twitch.StreamerReady}, - }, - AuthState: &auth.State{Login: "viewer"}, - }) - model.mode = streamerView +func TestWindowSizeKeepsSelectionVisible(t *testing.T) { + model := dashboardModel( + twitch.StreamerEntry{ConfigLogin: "alpha", Status: twitch.StreamerReady}, + twitch.StreamerEntry{ConfigLogin: "beta", Status: twitch.StreamerReady}, + twitch.StreamerEntry{ConfigLogin: "gamma", Status: twitch.StreamerReady}, + twitch.StreamerEntry{ConfigLogin: "delta", Status: twitch.StreamerReady}, + ) model.selectedConfig = "delta" - updated, _ := model.Update(tea.WindowSizeMsg{Width: 60, Height: 6}) - next := updated.(Model) - got := next.View() - if !strings.Contains(got, "> delta [inactive]") { - t.Fatalf("View() missing selected row after clipping:\n%s", got) + updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) + assertContainsAll(t, updated.(Model).View(), "delta") +} + +func dashboardModel(entries ...twitch.StreamerEntry) Model { + model := New(Options{initialStreamers: entries, AuthState: &auth.State{Login: "viewer"}}) + model.mode = streamerView + model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"} + model.width = 100 + model.height = 28 + return model +} + +func assertContainsAll(t *testing.T, got string, wants ...string) { + t.Helper() + for _, want := range wants { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in:\n%s", want, got) + } } } diff --git a/internal/tui/runtime.go b/internal/tui/runtime.go new file mode 100644 index 0000000..caef95a --- /dev/null +++ b/internal/tui/runtime.go @@ -0,0 +1,298 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "parasocial/internal/auth" + "parasocial/internal/gql" + "parasocial/internal/irc" + "parasocial/internal/twitch" +) + +const streamerRefreshInterval = 5 * time.Minute + +// Options configures the TUI runtime. +type Options struct { + Streamers []string + AuthState *auth.State + AuthPath string + Input io.Reader + Output io.Writer + + runtime modelRuntime + initialStreamers []twitch.StreamerEntry + httpClient *http.Client +} + +type runtime struct { + ctx context.Context + logins []string + httpClient *http.Client + authClient *auth.Client + authPath string + refreshEvery time.Duration + sleep func(context.Context, time.Duration) error +} + +// Run starts the Bubble Tea UI and keeps application orchestration inside the TUI package. +func Run(ctx context.Context, options Options) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + rt := newRuntime(ctx, options) + state := options.AuthState + if state == nil { + var err error + state, err = rt.reuseAuth() + if err != nil { + return err + } + } + + options.AuthState = state + options.runtime = rt + + input := options.Input + if input == nil { + input = os.Stdin + } + output := options.Output + if output == nil { + output = os.Stdout + } + + program := tea.NewProgram( + New(options), + tea.WithContext(ctx), + tea.WithInput(input), + tea.WithOutput(output), + ) + _, err := program.Run() + return err +} + +func newRuntime(ctx context.Context, options Options) *runtime { + httpClient := options.httpClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + authPath := options.AuthPath + if authPath == "" { + authPath = auth.DefaultPath() + } + return &runtime{ + ctx: ctx, + logins: append([]string(nil), options.Streamers...), + httpClient: httpClient, + authClient: auth.NewClient(httpClient), + authPath: authPath, + refreshEvery: streamerRefreshInterval, + sleep: sleepContext, + } +} + +func (r *runtime) reuseAuth() (*auth.State, error) { + return r.authClient.ReuseAuth(r.ctx, r.authPath) +} + +func (r *runtime) startAuth(ch chan<- AuthUpdate) { + go func() { + defer close(ch) + + state, err := r.authClient.EnsureAuth(r.ctx, r.authPath, func(line string) { + ch <- AuthUpdate{Line: strings.TrimRight(line, "\n")} + }) + if err != nil { + ch <- AuthUpdate{ + Line: fmt.Sprintf("Authentication failed: %v", err), + Err: err, + Done: true, + } + return + } + + ch <- AuthUpdate{State: state, Done: true} + }() +} + +func (r *runtime) startResolve(state *auth.State, ch chan<- StreamerUpdate) { + go func() { + defer close(ch) + ircManager := newIRCManager(ch) + defer ircManager.Close() + + service, err := newTwitchService(r.httpClient, state) + if err != nil { + ch <- StreamerUpdate{Err: err, Done: true} + return + } + if err := resolveStreamerEntriesWithSleep(r.ctx, service, state, r.logins, ircManager, func(update StreamerUpdate) { + ch <- update + }, r.refreshEvery, r.sleep); err != nil { + if errors.Is(err, context.Canceled) { + return + } + ch <- StreamerUpdate{Err: err, Done: true} + return + } + ch <- StreamerUpdate{Done: true} + }() +} + +type streamerService interface { + CurrentUser(context.Context) (*twitch.Viewer, error) + ResolveStreamer(context.Context, string) (*twitch.Channel, error) + StreamInfo(context.Context, string) (*twitch.StreamInfo, error) + PlaybackAccessToken(context.Context, string) (*twitch.PlaybackToken, error) +} + +type ircSyncer interface { + Sync(context.Context, string, string, []irc.Target) +} + +func newTwitchService(httpClient *http.Client, state *auth.State) (*twitch.Service, error) { + client := &gql.Client{ + HTTPClient: httpClient, + Session: gql.Session{ + AccessToken: state.AccessToken, + ClientID: state.ClientID, + DeviceID: state.DeviceID, + UserAgent: auth.TVUserAgent(), + }, + } + if err := client.Validate(); err != nil { + return nil, fmt.Errorf("configure graphql session: %w", err) + } + return &twitch.Service{GQL: client}, nil +} + +func newIRCManager(ch chan<- StreamerUpdate) *irc.Manager { + return &irc.Manager{ + Addr: irc.DefaultAddr, + Events: func(event irc.Event) { + ch <- StreamerUpdate{ + IRC: &IRCUpdate{ + Login: event.Streamer, + State: IRCState(event.State), + Line: event.Line, + }, + } + }, + } +} + +func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(StreamerUpdate)) error { + return resolveStreamerEntriesWithSleep(ctx, service, state, logins, syncer, send, streamerRefreshInterval, sleepContext) +} + +func resolveStreamerEntriesWithSleep(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(StreamerUpdate), interval time.Duration, sleep func(context.Context, time.Duration) error) error { + viewer, err := service.CurrentUser(ctx) + if err != nil { + return fmt.Errorf("resolve current user: %w", err) + } + send(StreamerUpdate{Viewer: viewer}) + + entries := twitch.LoadingStreamerEntries(logins) + syncWatchedChannels(ctx, syncer, state, entries) + + for { + for index, login := range logins { + entry, err := resolveStreamerEntry(ctx, service, login) + if errors.Is(err, context.Canceled) { + return err + } + entries[index] = *entry + send(StreamerUpdate{ + Index: index, + Entry: entry, + }) + syncWatchedChannels(ctx, syncer, state, entries) + } + if err := sleep(ctx, interval); err != nil { + return err + } + } +} + +func resolveStreamerEntry(ctx context.Context, service streamerService, login string) (*twitch.StreamerEntry, error) { + entry := &twitch.StreamerEntry{ + ConfigLogin: login, + Status: twitch.StreamerLoading, + } + + channel, err := service.ResolveStreamer(ctx, login) + switch { + case err == nil: + entry.Login = channel.Login + entry.ChannelID = channel.ID + case errors.Is(err, context.Canceled): + return nil, err + default: + entry.Status = twitch.StreamerError + entry.Error = err.Error() + return entry, nil + } + + stream, err := service.StreamInfo(ctx, channel.ID) + switch { + case err == nil: + entry.Status = twitch.StreamerReady + entry.Live = stream.Online + case errors.Is(err, context.Canceled): + return nil, err + default: + entry.Status = twitch.StreamerError + entry.Error = err.Error() + return entry, nil + } + + if !entry.Live { + return entry, nil + } + if _, err := service.PlaybackAccessToken(ctx, channel.Login); err != nil && errors.Is(err, context.Canceled) { + return nil, err + } + return entry, nil +} + +func sleepContext(ctx context.Context, duration time.Duration) error { + timer := time.NewTimer(duration) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func syncWatchedChannels(ctx context.Context, syncer ircSyncer, state *auth.State, entries []twitch.StreamerEntry) { + if syncer == nil || state == nil { + return + } + syncer.Sync(ctx, state.Login, state.AccessToken, watchedIRCTargets(entries)) +} + +func watchedIRCTargets(entries []twitch.StreamerEntry) []irc.Target { + targets := make([]irc.Target, 0, 2) + for _, entry := range entries { + if entry.Status != twitch.StreamerReady || !entry.Live || entry.Login == "" { + continue + } + targets = append(targets, irc.Target{Login: entry.Login}) + if len(targets) == 2 { + break + } + } + return targets +} diff --git a/internal/tui/runtime_test.go b/internal/tui/runtime_test.go new file mode 100644 index 0000000..529542b --- /dev/null +++ b/internal/tui/runtime_test.go @@ -0,0 +1,236 @@ +package tui + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "parasocial/internal/auth" + "parasocial/internal/irc" + "parasocial/internal/twitch" +) + +type fakeStreamerService struct { + channels map[string]*twitch.Channel + resolveErrs map[string]error + streams map[string][]streamResult + streamCalls map[string]int + playbackErr map[string]error +} + +type streamResult struct { + info *twitch.StreamInfo + err error +} + +func (f *fakeStreamerService) CurrentUser(context.Context) (*twitch.Viewer, error) { + return &twitch.Viewer{ID: "7", Login: "viewer"}, nil +} + +func (f *fakeStreamerService) ResolveStreamer(_ context.Context, login string) (*twitch.Channel, error) { + if err, ok := f.resolveErrs[login]; ok { + return nil, err + } + return f.channels[login], nil +} + +func (f *fakeStreamerService) StreamInfo(_ context.Context, channelID string) (*twitch.StreamInfo, error) { + call := f.streamCalls[channelID] + f.streamCalls[channelID] = call + 1 + results := f.streams[channelID] + if len(results) == 0 { + return &twitch.StreamInfo{Online: false}, nil + } + if call >= len(results) { + call = len(results) - 1 + } + if results[call].err != nil { + return nil, results[call].err + } + return results[call].info, nil +} + +func (f *fakeStreamerService) PlaybackAccessToken(_ context.Context, login string) (*twitch.PlaybackToken, error) { + if err, ok := f.playbackErr[login]; ok { + return nil, err + } + return &twitch.PlaybackToken{Signature: "sig", Value: "token"}, nil +} + +type fakeIRCSyncer struct { + calls [][]string +} + +func (f *fakeIRCSyncer) Sync(_ context.Context, _, _ string, targets []irc.Target) { + logins := make([]string, 0, len(targets)) + for _, target := range targets { + logins = append(logins, target.Login) + } + f.calls = append(f.calls, logins) +} + +func TestResolveStreamerEntries(t *testing.T) { + service := streamService( + channel("alpha", "1", "alpha_live"), + resolveErr("beta", errors.New("lookup failed")), + live("1", true), + ) + + updates := collectUpdates(t, service, []string{"alpha", "beta"}, nil, cancelAfter(0)) + if len(updates) != 3 { + t.Fatalf("len(updates) = %d", len(updates)) + } + if updates[0].Viewer == nil || updates[0].Viewer.Login != "viewer" { + t.Fatalf("viewer update = %#v", updates[0]) + } + if entry := updates[1].Entry; entry == nil || entry.Status != twitch.StreamerReady || entry.Login != "alpha_live" || !entry.Live { + t.Fatalf("alpha update = %#v", updates[1]) + } + if entry := updates[2].Entry; entry == nil || entry.Status != twitch.StreamerError { + t.Fatalf("beta update = %#v", updates[2]) + } +} + +func TestResolveStreamerEntriesPlaybackFailureStillMarksLive(t *testing.T) { + updates := collectUpdates(t, streamService( + channel("alpha", "1", "alpha_live"), + live("1", true), + playbackErr("alpha_live", errors.New("token lookup failed")), + ), []string{"alpha"}, nil, cancelAfter(0)) + + if entry := updates[1].Entry; entry == nil || entry.Status != twitch.StreamerReady || !entry.Live { + t.Fatalf("entry = %#v", entry) + } +} + +func TestResolveStreamerEntriesRefreshesLiveState(t *testing.T) { + updates := collectUpdates(t, streamService( + channel("alpha", "1", "alpha_live"), + streams("1", false, true), + ), []string{"alpha"}, nil, cancelAfter(1)) + + if len(updates) != 3 { + t.Fatalf("len(updates) = %d", len(updates)) + } + if updates[1].Entry == nil || updates[1].Entry.Live { + t.Fatalf("first pass = %#v", updates[1]) + } + if updates[2].Entry == nil || !updates[2].Entry.Live { + t.Fatalf("second pass = %#v", updates[2]) + } +} + +func TestResolveStreamerEntriesSyncsTopTwoLiveChannels(t *testing.T) { + syncer := &fakeIRCSyncer{} + collectUpdates(t, streamService( + channel("alpha", "1", "alpha_live"), + channel("beta", "2", "beta_live"), + channel("gamma", "3", "gamma_live"), + live("1", true), + live("2", true), + live("3", true), + ), []string{"alpha", "beta", "gamma"}, syncer, cancelAfter(0)) + + assertSyncCalls(t, syncer.calls, [][]string{ + {}, + {"alpha_live"}, + {"alpha_live", "beta_live"}, + {"alpha_live", "beta_live"}, + }) +} + +func TestResolveStreamerEntriesRefreshUpdatesWatchedChannels(t *testing.T) { + syncer := &fakeIRCSyncer{} + collectUpdates(t, streamService( + channel("alpha", "1", "alpha_live"), + channel("beta", "2", "beta_live"), + streams("1", true, false), + streams("2", false, true), + ), []string{"alpha", "beta"}, syncer, cancelAfter(1)) + + assertSyncCalls(t, syncer.calls, [][]string{ + {}, + {"alpha_live"}, + {"alpha_live"}, + {}, + {"beta_live"}, + }) +} + +type serviceOption func(*fakeStreamerService) + +func streamService(options ...serviceOption) *fakeStreamerService { + service := &fakeStreamerService{ + channels: map[string]*twitch.Channel{}, + resolveErrs: map[string]error{}, + streams: map[string][]streamResult{}, + streamCalls: map[string]int{}, + playbackErr: map[string]error{}, + } + for _, option := range options { + option(service) + } + return service +} + +func channel(login, id, resolved string) serviceOption { + return func(service *fakeStreamerService) { + service.channels[login] = &twitch.Channel{ID: id, Login: resolved} + } +} + +func resolveErr(login string, err error) serviceOption { + return func(service *fakeStreamerService) { + service.resolveErrs[login] = err + } +} + +func live(channelID string, online bool) serviceOption { + return streams(channelID, online) +} + +func streams(channelID string, online ...bool) serviceOption { + return func(service *fakeStreamerService) { + for _, value := range online { + service.streams[channelID] = append(service.streams[channelID], streamResult{info: &twitch.StreamInfo{Online: value}}) + } + } +} + +func playbackErr(login string, err error) serviceOption { + return func(service *fakeStreamerService) { + service.playbackErr[login] = err + } +} + +func collectUpdates(t *testing.T, service *fakeStreamerService, logins []string, syncer ircSyncer, sleep func(context.Context, time.Duration) error) []StreamerUpdate { + t.Helper() + var updates []StreamerUpdate + err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, logins, syncer, func(update StreamerUpdate) { + updates = append(updates, update) + }, 0, sleep) + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + return updates +} + +func cancelAfter(allowedSleeps int) func(context.Context, time.Duration) error { + calls := 0 + return func(context.Context, time.Duration) error { + calls++ + if calls <= allowedSleeps { + return nil + } + return context.Canceled + } +} + +func assertSyncCalls(t *testing.T, got, want [][]string) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("sync calls = %#v, want %#v", got, want) + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..f601d59 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,54 @@ +package tui + +import ( + "github.com/charmbracelet/bubbles/viewport" + "github.com/charmbracelet/lipgloss" +) + +var ( + pageStyle = lipgloss.NewStyle(). + Padding(1, 2) + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#E6EDF3")) + mutedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6E7681")) + accentStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7CE38B")) + warnStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F0883E")) + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF7B72")) + panelStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#30363D")). + Padding(1, 2) + labelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B949E")). + Bold(true) + statusLiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7CE38B")) + statusIdleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B949E")) + statusErrorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF7B72")) + rowNameStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C9D1D9")). + PaddingLeft(1) + rowDescStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B949E")). + PaddingLeft(1) + selectedRowNameStyle = rowNameStyle. + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(lipgloss.Color("#7CE38B")). + Foreground(lipgloss.Color("#E6EDF3")) + selectedRowDescStyle = rowDescStyle. + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(lipgloss.Color("#7CE38B")) +) + +func newAuthViewport(width, height int) viewport.Model { + vp := viewport.New(width, height) + vp.Style = lipgloss.NewStyle() + return vp +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..3dc17e0 --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,239 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "parasocial/internal/twitch" +) + +// View renders either the login log screen or the authenticated streamer dashboard. +func (m Model) View() string { + if m.mode == authView { + return m.renderAuthView() + } + return m.renderStreamerView() +} + +func (m Model) renderAuthView() string { + body := []string{ + titleStyle.Render("Twitch Login"), + mutedStyle.Render("Complete the device authorization flow to continue."), + "", + panelStyle.Width(contentWidth(m.width)).Render(m.authViewport.View()), + } + if m.authErr != nil { + body = append(body, "", errorStyle.Render("Login did not complete. Press q to quit.")) + } + return pageStyle.Render(strings.Join(body, "\n")) + "\n" +} + +func (m Model) renderStreamerView() string { + header := m.renderDashboardHeader() + left := panelStyle. + Width(streamerListWidth(m.width)). + Height(panelHeight(m.height)). + Render(labelStyle.Render("Streamers") + "\n" + m.renderStreamerRows()) + right := panelStyle. + Width(detailWidth(m.width)). + Height(panelHeight(m.height)). + Render(m.renderDetailPanel()) + + body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) + return pageStyle.Render(header+"\n\n"+body) + "\n" +} + +func (m Model) renderDashboardHeader() string { + var identity string + switch { + case m.viewer != nil: + identity = fmt.Sprintf("Logged in as %s (%s)", m.viewer.Login, m.viewer.ID) + case m.authState != nil && m.authState.Login != "": + identity = fmt.Sprintf("Logged in as %s", m.authState.Login) + default: + identity = "Logged in" + } + + summary := "Watching: " + watchingSummary(m.streamers) + if m.resolveErr != nil { + summary += " " + warnStyle.Render(fmt.Sprintf("Viewer lookup failed: %v", m.resolveErr)) + } else if m.viewer == nil { + summary += " " + mutedStyle.Render("Resolving viewer identity...") + } + return titleStyle.Render(identity) + "\n" + mutedStyle.Render(summary) +} + +func (m Model) renderDetailPanel() string { + entries := m.orderedStreamers() + selected := m.selectedRowIndex(entries) + if selected < 0 || selected >= len(entries) { + return labelStyle.Render("IRC Chat") + "\n\n" + mutedStyle.Render("No streamers configured") + } + + entry := entries[selected] + lines := []string{ + labelStyle.Render("IRC Chat"), + "", + titleStyle.Render(streamerName(entry)), + "Status: " + statusText(entry), + } + if entry.Error != "" { + lines = append(lines, "Error: "+errorStyle.Render(entry.Error)) + } + + if !isActive(entry) { + lines = append(lines, "IRC: "+mutedStyle.Render("inactive")) + return strings.Join(lines, "\n") + } + + detail := m.ircDetails[normalizeKey(entry.Login)] + if detail.joined { + lines = append(lines, "IRC: "+accentStyle.Render("joined")) + } else { + lines = append(lines, "IRC: "+mutedStyle.Render("not joined")) + } + if detail.line != "" { + lines = append(lines, "", mutedStyle.Render(detail.line)) + } + return strings.Join(lines, "\n") +} + +func (m Model) renderStreamerRows() string { + entries := m.visibleStreamers() + if len(entries) == 0 { + return mutedStyle.Render("none") + } + + lines := make([]string, 0, len(entries)*2) + for _, entry := range entries { + nameStyle, descStyle := rowNameStyle, rowDescStyle + if entry.ConfigLogin == m.selectedConfig { + nameStyle, descStyle = selectedRowNameStyle, selectedRowDescStyle + } + detail := m.ircDetails[normalizeKey(entry.Login)] + lines = append(lines, + nameStyle.Render(streamerName(entry)), + descStyle.Render(rawStatus(entry)+" | "+ircSummary(detail)), + ) + } + return strings.Join(lines, "\n") +} + +func (m Model) visibleStreamers() []twitch.StreamerEntry { + entries := m.orderedStreamers() + visible := max(1, streamerListHeight(m.height)/2) + if len(entries) <= visible { + return entries + } + + selected := max(0, m.selectedRowIndex(entries)) + start := selected - visible + 1 + if start < 0 { + start = 0 + } + if start+visible > len(entries) { + start = len(entries) - visible + } + return entries[start : start+visible] +} + +func watchingSummary(streamers []twitch.StreamerEntry) string { + live := make([]string, 0, 2) + for _, streamer := range streamers { + if streamer.Status != twitch.StreamerReady || !streamer.Live { + continue + } + name := streamer.Login + if name == "" { + name = streamer.ConfigLogin + } + live = append(live, name) + if len(live) == 2 { + break + } + } + if len(live) == 0 { + return "no live streamers" + } + return strings.Join(live, ", ") +} + +func statusText(entry twitch.StreamerEntry) string { + switch { + case entry.Status == twitch.StreamerError: + return statusErrorStyle.Render("error") + case entry.Status == twitch.StreamerLoading: + return mutedStyle.Render("loading") + case entry.Live: + return statusLiveStyle.Render("live") + default: + return statusIdleStyle.Render("offline") + } +} + +func rawStatus(entry twitch.StreamerEntry) string { + switch { + case entry.Status == twitch.StreamerError: + return "error" + case entry.Status == twitch.StreamerLoading: + return "loading" + case entry.Live: + return "live" + default: + return "offline" + } +} + +func ircSummary(detail ircDetail) string { + if detail.joined { + return "irc joined" + } + return "irc idle" +} + +func contentWidth(width int) int { + if width <= 0 { + return defaultViewWidth + } + return max(24, width-4) +} + +func authViewportHeight(height int) int { + if height <= 0 { + return 8 + } + return max(4, height-8) +} + +func streamerListWidth(width int) int { + if width <= 0 { + return 34 + } + if width < 72 { + return max(24, width/2-5) + } + return min(40, max(30, width/3)) +} + +func detailWidth(width int) int { + if width <= 0 { + return defaultViewWidth - streamerListWidth(width) - 7 + } + return max(24, width-streamerListWidth(width)-11) +} + +func streamerListHeight(height int) int { + if height <= 0 { + return 14 + } + return max(4, height-10) +} + +func panelHeight(height int) int { + if height <= 0 { + return 16 + } + return max(6, height-8) +}