410 lines
9.4 KiB
Go
410 lines
9.4 KiB
Go
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
|
|
}
|