package tui import ( "fmt" "log" "sort" "strconv" "strings" "time" "ytgo/player" "ytgo/scraper" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" ) 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 | %s", v.Video.Channel, v.Video.Duration, v.Video.UploadDate, v.Video.Views) } func (v videoItem) FilterValue() string { return v.Video.Title } // Add these constants for sort modes const ( SortByDate = iota SortByViews SortByTitle SortByDuration ) type model struct { searchBar textinput.Model results list.Model videos []scraper.Video showSearch bool err error width int height int clock time.Time currentSort int sortAscending bool } // 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 sortAscending: false, } } // 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: // Handle global keybindings first switch msg.String() { case "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 } // Handle context-specific keybindings if m.showSearch { // When in search mode, only handle enter and let other keys go to the search input switch msg.String() { case "enter": query := m.searchBar.Value() if query == "" { return m, nil // Ignore empty queries } // Fetch video results videos, err := scraper.FetchVideos(query) if err != nil { m.err = err log.Printf("Error fetching videos: %v", err) return m, nil } // Populate the results list items := make([]list.Item, len(videos)) for i, v := range videos { items[i] = videoItem{Video: v} } m.videos = videos m.results.SetItems(items) m.showSearch = false m.searchBar.Blur() return m, nil } } else { // When not in search mode, handle navigation and sort keybindings switch msg.String() { case "q": return m, tea.Quit case "enter": if i := m.results.Index(); i != -1 { selectedVideo := m.videos[i] log.Printf("Playing video: %s", selectedVideo.URL) cmd := player.PlayVideo(selectedVideo.URL) // Execute the command and handle any errors if err := cmd.Start(); err != nil { m.err = fmt.Errorf("this video URL cannot be played: %w", err) log.Printf("Error starting video playback: %v", err) return m, nil } // Optionally, wait for the command to finish go func() { if err := cmd.Wait(); err != nil { log.Printf("Error during video playback: %v", err) } }() return m, nil } case "s": m.currentSort = (m.currentSort + 1) % 4 m.sortVideos() return m, nil case "S": m.currentSort = (m.currentSort - 1) if m.currentSort < 0 { m.currentSort = 3 } m.sortVideos() return m, nil case "r": m.sortAscending = !m.sortAscending m.sortVideos() 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 time.Time: m.clock = msg return m, tickCmd() } // Handle updates for the active component 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") } // Always show search bar at the top searchText := "Press '/' to search" if m.showSearch { searchText = m.searchBar.View() } s.WriteString(searchText + "\n\n") // Add sort mode indicator with better visual feedback sortModes := []string{"Date", "Views", "Title", "Duration"} if len(m.results.Items()) > 0 { s.WriteString("Sort ('s'/'S' to change, 'r' to reverse): ") for i, mode := range sortModes { arrow := "↓" if m.sortAscending { arrow = "↑" } if i == m.currentSort { s.WriteString(fmt.Sprintf("[%s %s]", mode, arrow)) } else { s.WriteString(fmt.Sprintf(" %s ", mode)) } } s.WriteString("\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 }) } // Add this new method to the model func (m *model) sortVideos() { items := m.results.Items() switch m.currentSort { case SortByDate: sort.Slice(items, func(i, j int) bool { vi := items[i].(videoItem).Video vj := items[j].(videoItem).Video // Use ParsedDate for sorting if m.sortAscending { return vi.ParsedDate.Before(vj.ParsedDate) } return vi.ParsedDate.After(vj.ParsedDate) }) case SortByViews: sort.Slice(items, func(i, j int) bool { vi := items[i].(videoItem).Video vj := items[j].(videoItem).Video viewsI := parseViews(vi.Views) viewsJ := parseViews(vj.Views) if m.sortAscending { return viewsI < viewsJ } return viewsI > viewsJ }) case SortByTitle: sort.Slice(items, func(i, j int) bool { if m.sortAscending { return items[i].(videoItem).Video.Title < items[j].(videoItem).Video.Title } return items[i].(videoItem).Video.Title > items[j].(videoItem).Video.Title }) case SortByDuration: sort.Slice(items, func(i, j int) bool { vi := items[i].(videoItem).Video vj := items[j].(videoItem).Video di := parseDuration(vi.Duration) dj := parseDuration(vj.Duration) if m.sortAscending { return di < dj } return di > dj }) } m.results.SetItems(items) } // Add this helper function func parseDuration(dur string) int { // Simple duration parser for formats like "5:30" or "1:23:45" parts := strings.Split(dur, ":") total := 0 if len(parts) == 2 { min, _ := strconv.Atoi(parts[0]) sec, _ := strconv.Atoi(parts[1]) total = min*60 + sec } else if len(parts) == 3 { hour, _ := strconv.Atoi(parts[0]) min, _ := strconv.Atoi(parts[1]) sec, _ := strconv.Atoi(parts[2]) total = hour*3600 + min*60 + sec } return total } func parseDate(date string) time.Time { // Try parsing absolute date formats first formats := []string{ "2006-01-02", "Jan 2, 2006", "2 Jan 2006", // Add more formats as needed } for _, format := range formats { if t, err := time.Parse(format, date); err == nil { return t } } // If absolute date parsing fails, handle relative dates if strings.Contains(date, "ago") { return parseRelativeDate(date) } return time.Time{} // Return zero time if parsing fails } func parseRelativeDate(date string) time.Time { now := time.Now() parts := strings.Fields(date) if len(parts) < 2 { return now } num, err := strconv.Atoi(parts[0]) if err != nil { return now } // Calculate time in the past based on the unit switch { case strings.HasPrefix(parts[1], "second"): return now.Add(-time.Duration(num) * time.Second) case strings.HasPrefix(parts[1], "minute"): return now.Add(-time.Duration(num) * time.Minute) case strings.HasPrefix(parts[1], "hour"): return now.Add(-time.Duration(num) * time.Hour) case strings.HasPrefix(parts[1], "day"): return now.AddDate(0, 0, -num) case strings.HasPrefix(parts[1], "week"): return now.AddDate(0, 0, -num*7) case strings.HasPrefix(parts[1], "month"): return now.AddDate(0, -num, 0) case strings.HasPrefix(parts[1], "year"): return now.AddDate(-num, 0, 0) default: return now } } func parseViews(views string) int64 { // Remove "views" and any commas views = strings.ToLower(views) views = strings.ReplaceAll(views, "views", "") views = strings.ReplaceAll(views, ",", "") views = strings.TrimSpace(views) // Handle K, M, B suffixes multiplier := int64(1) if strings.HasSuffix(views, "k") { multiplier = 1000 views = views[:len(views)-1] } else if strings.HasSuffix(views, "m") { multiplier = 1000000 views = views[:len(views)-1] } else if strings.HasSuffix(views, "b") { multiplier = 1000000000 views = views[:len(views)-1] } // Convert to float to handle decimal values if num, err := strconv.ParseFloat(views, 64); err == nil { return int64(num * float64(multiplier)) } return 0 }