216 lines
4.5 KiB
Go
216 lines
4.5 KiB
Go
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
|
|
}
|