tsplay/internal/tui/login.go
28allday b3bf49df05 Release v1.0.0: server switching, Walker launcher, curl install, user guide
- Switch servers from the browser (s) with auto-discovery; auto-fallback to
  discovery when a saved server is unreachable, so you're never locked to a
  rebuilt box. cliamp's Jellyfin provider is re-synced on every (re)login.
- Ignore late LAN-discovery results once off the login screen (footer/input
  clobber fix).
- install.sh: curl|bash one-liner that downloads the per-arch release binary,
  still builds from a clone when Go is present; adds a floating Walker entry
  (TUI.float app-id) + icon on Omarchy desktops.
- Docs: USER_GUIDE.md plain-English walkthrough; README install one-liner,
  accurate keybindings, features section.
- Bump version to 1.0.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:48:03 +01:00

129 lines
3.4 KiB
Go

package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tsplay/internal/discovery"
)
func (m Model) updateLogin(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
// If we reached login from an active session (switch server) and the
// old browse view is still around, esc cancels and returns to it. On
// genuine first-run, or after a failed-server fallback (empty stack),
// there's nothing to go back to, so esc quits.
if m.client != nil && len(m.stack) > 0 {
m.screen = screenBrowse
m.status = ""
return m, nil
}
return m, tea.Quit
case "ctrl+n":
// Cycle through discovered servers, filling the URL field.
if len(m.discovered) > 1 {
m.discIdx = (m.discIdx + 1) % len(m.discovered)
m.inputs[0].SetValue(m.discovered[m.discIdx].Address)
m.status = discoveryStatus(m.discovered, m.discIdx)
}
return m, nil
case "ctrl+r":
m.status = "Scanning the network for Jellyfin servers…"
return m, discoverCmd()
case "tab", "down":
m.focusField(m.focus + 1)
return m, textinput.Blink
case "shift+tab", "up":
m.focusField(m.focus - 1)
return m, textinput.Blink
case "enter":
server := strings.TrimSpace(m.inputs[0].Value())
user := strings.TrimSpace(m.inputs[1].Value())
pass := m.inputs[2].Value()
if server == "" || user == "" {
m.status = "Server URL and username are required."
return m, nil
}
if m.logging {
return m, nil
}
m.logging = true
m.status = "Connecting…"
return m, m.doLogin(server, user, pass)
}
}
// Feed the event to every input so the cursor blinks and the focused one
// receives text.
var cmds []tea.Cmd
for i := range m.inputs {
var c tea.Cmd
m.inputs[i], c = m.inputs[i].Update(msg)
cmds = append(cmds, c)
}
return m, tea.Batch(cmds...)
}
func (m *Model) focusField(i int) {
n := len(m.inputs)
i = ((i % n) + n) % n // wrap
for j := range m.inputs {
if j == i {
m.inputs[j].Focus()
} else {
m.inputs[j].Blur()
}
}
m.focus = i
}
// discoveryStatus formats the "found N servers" line shown under the form.
func discoveryStatus(servers []discovery.Server, idx int) string {
s := servers[idx]
name := s.Name
if name == "" {
name = s.Address
}
if len(servers) == 1 {
return "Found: " + name
}
return fmt.Sprintf("Found %d servers — using %s [%d/%d]", len(servers), name, idx+1, len(servers))
}
func (m Model) viewLogin() string {
var b strings.Builder
b.WriteString(m.st.title.Render("tsplay") + m.st.muted.Render(" — Jellyfin terminal player"))
b.WriteString("\n\n")
for i := range m.inputs {
b.WriteString(m.inputs[i].View())
b.WriteString("\n")
}
b.WriteString("\n")
help := "tab move · enter sign in · esc quit"
if len(m.discovered) > 1 {
help = "tab move · ctrl+n next server · enter sign in · esc quit"
} else {
help += " · ctrl+r rescan"
}
b.WriteString(m.st.help.Render(help))
body := m.st.box.Render(b.String())
// Discovery messages are informational (accent); real errors stay red.
footer := ""
if m.status != "" {
style := m.st.accent
if strings.HasPrefix(m.status, "Login failed") || strings.HasPrefix(m.status, "No servers") {
style = m.st.bad
}
footer = "\n" + style.Render(m.status)
}
return lipgloss.JoinVertical(lipgloss.Left, body, footer)
}