package tui import ( "fmt" // "log" "strings" "time" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "ytgo/scraper" "ytgo/player" ) type videoItem struct { Video scraper.Video } // Implement list.Item for videoItem func (v videoItem) Title() string { return v.Video.Title } func (v videoItem) Description() string { return fmt.Sprintf("%s | %s | %s", v.Video.Channel, v.Video.Duration, v.Video.UploadDate) } func (v videoItem) FilterValue() string { return v.Video.Title } type model struct { searchBar textinput.Model results list.Model videos []scraper.Video showSearch bool err error width int height int clock time.Time isLoading bool spinner spinner } type spinner struct { frames []string current int } // Initial model setup func initialModel() model { searchBar := textinput.New() searchBar.Placeholder = "Search YouTube" searchBar.Focus() delegate := list.NewDefaultDelegate() results := list.New([]list.Item{}, delegate, 50, 20) results.Title = "Search Results" return model{ searchBar: searchBar, results: results, showSearch: true, clock: time.Now(), width: 80, // Default width height: 24, // Default height spinner: spinner{ frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, current: 0, }, } } // Init initializes the Bubble Tea program func (m model) Init() tea.Cmd { return tea.Batch( textinput.Blink, tickCmd(), ) } // Update handles updates to the model based on user input func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "/": m.showSearch = true m.searchBar.Focus() return m, nil case "esc": if m.showSearch { m.showSearch = false m.searchBar.Blur() } return m, nil case "enter": if m.showSearch { query := m.searchBar.Value() if query == "" { return m, nil } m.isLoading = true // Return both the loading animation and the search command return m, tea.Batch( tickCmd(), func() tea.Msg { videos, err := scraper.FetchVideos(query) if err != nil { return searchErrorMsg{err: err} } return searchResultMsg{videos: videos} }, ) } // Handle video selection if i := m.results.Index(); i != -1 { selectedVideo := m.videos[i] cmd := player.PlayVideo(selectedVideo.URL) return m, tea.ExecProcess(cmd, nil) } return m, nil } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.results.SetSize(msg.Width, msg.Height-3) // Leave space for search bar case searchResultMsg: m.isLoading = false items := make([]list.Item, len(msg.videos)) for i, v := range msg.videos { items[i] = videoItem{Video: v} } m.videos = msg.videos m.results.SetItems(items) m.showSearch = false m.searchBar.Blur() return m, nil case searchErrorMsg: m.isLoading = false m.err = msg.err return m, nil case time.Time: if m.isLoading { m.spinner.current = (m.spinner.current + 1) % len(m.spinner.frames) } m.clock = msg return m, tickCmd() } var cmd tea.Cmd if m.showSearch { m.searchBar, cmd = m.searchBar.Update(msg) } else { m.results, cmd = m.results.Update(msg) } return m, cmd } // View renders the TUI func (m model) View() string { var s strings.Builder // Add clock to top-right corner with safety check for window width clock := m.clock.Format("15:04:05 02-01-2006") if m.width > len(clock) { padding := strings.Repeat(" ", m.width-len(clock)) s.WriteString(padding + clock + "\n\n") } else { s.WriteString(clock + "\n\n") } // Show search bar with loading animation if searching searchText := "Press '/' to search" if m.showSearch { searchText = m.searchBar.View() } if m.isLoading { searchText += " " + m.spinner.frames[m.spinner.current] + " Searching..." } s.WriteString(searchText + "\n\n") // Show results s.WriteString(m.results.View()) // Show error if any if m.err != nil { s.WriteString(fmt.Sprintf("\nError: %v", m.err)) } return s.String() } // Run starts the Bubble Tea program func Run() error { p := tea.NewProgram(initialModel()) return p.Start() } func tickCmd() tea.Cmd { return tea.Every(time.Second, func(t time.Time) tea.Msg { return t }) } type searchResultMsg struct { videos []scraper.Video } type searchErrorMsg struct { err error }