Compare commits

...
Sign in to create a new pull request.

1 commit
main ... noapi

Author SHA1 Message Date
pika
999376e5f7 working without api key 2024-12-12 11:37:43 +01:00
5 changed files with 371 additions and 166 deletions

8
go.sum
View file

@ -26,6 +26,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@ -80,6 +82,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sixel v0.0.5 h1:55w2FR5ncuhKhXrM5ly1eiqMQfZsnAHIpYNGZX03Cv8=
github.com/mattn/go-sixel v0.0.5/go.mod h1:h2Sss+DiUEHy0pUqcIB6PFXo5Cy8sTQEFr3a9/5ZLNw=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@ -94,6 +98,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y=
github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -117,6 +123,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=

View file

@ -1,15 +1,13 @@
package scraper package scraper
import ( import (
"context"
"fmt" "fmt"
"time" "io"
// "log" "net/http"
"html" "net/url"
"regexp"
"strings" "strings"
"time"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
) )
type Video struct { type Video struct {
@ -17,155 +15,103 @@ type Video struct {
URL string URL string
Channel string Channel string
Duration string Duration string
Views string
Thumbnail string
UploadDate string UploadDate string
} }
// Replace with your actual API key // Updated regular expressions to match the shell script
const API_KEY = "AIzaSyAzsihRkp8mYTOXLOkVN09yTqld9TJ4Nts" var (
titleRegex = regexp.MustCompile(`"title":\{"runs":\[\{"text":"([^"]+)"\}\]`)
func formatViews(count uint64) string { channelRegex = regexp.MustCompile(`"ownerText":\{"runs":\[\{"text":"([^"]+)"\}\]`)
switch { durationRegex = regexp.MustCompile(`"lengthText":\{"accessibility":\{"accessibilityData":\{"label":"[^"]*"\}\},"simpleText":"([^"]+)"`)
case count >= 1000000000: uploadDateRegex = regexp.MustCompile(`"publishedTimeText":\{"simpleText":"([^"]+)"\}`)
return fmt.Sprintf("%.1fB views", float64(count)/1000000000) videoIDRegex = regexp.MustCompile(`watch\?v=([^"]+)`)
case count >= 1000000: )
return fmt.Sprintf("%.1fM views", float64(count)/1000000)
case count >= 1000:
return fmt.Sprintf("%.1fK views", float64(count)/1000)
default:
return fmt.Sprintf("%d views", count)
}
}
func formatDuration(duration string) string {
// Remove PT from the start
duration = strings.TrimPrefix(duration, "PT")
var result strings.Builder
// Handle hours
if i := strings.Index(duration, "H"); i != -1 {
result.WriteString(duration[:i])
result.WriteString(":")
duration = duration[i+1:]
}
// Handle minutes
if i := strings.Index(duration, "M"); i != -1 {
minutes := duration[:i]
if len(minutes) == 1 {
result.WriteString("0")
}
result.WriteString(minutes)
result.WriteString(":")
duration = duration[i+1:]
} else if result.Len() > 0 {
result.WriteString("00:")
}
// Handle seconds
if i := strings.Index(duration, "S"); i != -1 {
seconds := duration[:i]
if len(seconds) == 1 {
result.WriteString("0")
}
result.WriteString(seconds)
} else {
result.WriteString("00")
}
return result.String()
}
func formatUploadDate(uploadDate string) string {
t, err := time.Parse(time.RFC3339, uploadDate)
if err != nil {
return uploadDate
}
now := time.Now()
diff := now.Sub(t)
days := int(diff.Hours() / 24)
formattedDate := t.Format("02-01-2006")
// If video is less than 30 days old, add "X days ago"
if days < 30 {
var timeAgo string
switch {
case days == 0:
hours := int(diff.Hours())
if hours == 0 {
timeAgo = "just now"
} else {
timeAgo = fmt.Sprintf("%dh ago", hours)
}
case days == 1:
timeAgo = "1 day ago"
default:
timeAgo = fmt.Sprintf("%d days ago", days)
}
return fmt.Sprintf("%s (%s)", formattedDate, timeAgo)
}
return formattedDate
}
func FetchVideos(query string) ([]Video, error) { func FetchVideos(query string) ([]Video, error) {
ctx := context.Background() client := &http.Client{
youtubeService, err := youtube.NewService(ctx, option.WithAPIKey(API_KEY)) Timeout: 10 * time.Second,
if err != nil {
return nil, fmt.Errorf("error creating YouTube client: %w", err)
} }
// Make the search request // Format URL similar to the shell script
call := youtubeService.Search.List([]string{"snippet"}). searchURL := fmt.Sprintf("https://www.youtube.com/results?search_query=%s",
Q(query). url.QueryEscape(strings.ReplaceAll(query, " ", "+")))
MaxResults(50).
Type("video").
VideoDuration("any")
response, err := call.Do() fmt.Printf("Fetching: %s\n", searchURL) // Debug print
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("error making search request: %w", err) return nil, fmt.Errorf("error creating request: %w", err)
} }
// Add headers to mimic a browser
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response: %w", err)
}
content := string(body)
// Extract information
titles := titleRegex.FindAllStringSubmatch(content, -1)
channels := channelRegex.FindAllStringSubmatch(content, -1)
durations := durationRegex.FindAllStringSubmatch(content, -1)
uploadDates := uploadDateRegex.FindAllStringSubmatch(content, -1)
videoIDs := videoIDRegex.FindAllStringSubmatch(content, -1)
fmt.Printf("Found: %d titles, %d channels, %d durations, %d dates, %d IDs\n",
len(titles), len(channels), len(durations), len(uploadDates), len(videoIDs))
var videos []Video var videos []Video
for _, item := range response.Items { for i := 0; i < len(titles) && i < 10; i++ { // Limit to 10 results like the shell script
video := Video{ if i >= len(videoIDs) {
Title: item.Snippet.Title, break
URL: fmt.Sprintf("https://www.youtube.com/watch?v=%s", item.Id.VideoId),
Channel: item.Snippet.ChannelTitle,
Thumbnail: item.Snippet.Thumbnails.Default.Url,
UploadDate: item.Snippet.PublishedAt,
} }
video := Video{
Title: unescapeHTML(titles[i][1]),
URL: fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoIDs[i][1]),
}
if i < len(channels) {
video.Channel = unescapeHTML(channels[i][1])
}
if i < len(durations) {
video.Duration = durations[i][1]
}
if i < len(uploadDates) {
video.UploadDate = uploadDates[i][1]
}
videos = append(videos, video) videos = append(videos, video)
} }
// Get additional video details (duration, views) in a single request if len(videos) == 0 {
videoIds := make([]string, len(response.Items)) return nil, fmt.Errorf("no videos found")
for i, item := range response.Items {
videoIds[i] = item.Id.VideoId
}
// Get video statistics
statsCall := youtubeService.Videos.List([]string{"contentDetails", "statistics"}).
Id(videoIds...)
statsResponse, err := statsCall.Do()
if err != nil {
return nil, fmt.Errorf("error fetching video details: %w", err)
}
// Update videos with additional information
for i, stat := range statsResponse.Items {
if i < len(videos) {
videos[i].Duration = formatDuration(stat.ContentDetails.Duration)
videos[i].Views = formatViews(stat.Statistics.ViewCount)
videos[i].Title = html.UnescapeString(videos[i].Title)
videos[i].UploadDate = formatUploadDate(videos[i].UploadDate)
}
} }
return videos, nil return videos, nil
} }
func unescapeHTML(s string) string {
replacements := map[string]string{
"\\u0026": "&",
"\\\"": "\"",
"\\u003c": "<",
"\\u003e": ">",
"&quot;": "\"",
"&#39;": "'",
}
for old, new := range replacements {
s = strings.ReplaceAll(s, old, new)
}
return s
}

107
thumbnail/thumbnail.go Normal file
View file

@ -0,0 +1,107 @@
package thumbnail
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/color"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/mattn/go-sixel"
)
func FetchAndProcessThumbnail(url string) (string, error) {
if url == "" {
return "", fmt.Errorf("empty URL")
}
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get(url)
if err != nil {
return "", fmt.Errorf("failed to fetch image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to fetch image: status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read image data: %w", err)
}
img, _, err := image.Decode(bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("failed to decode image: %w", err)
}
// Consistent thumbnail size
width := 40
height := 15
img = imaging.Resize(img, width*2, height*2, imaging.Lanczos)
// Try iTerm2 protocol first
if isITerm() {
return getITermImageSequence(img), nil
}
// Try Sixel next
var buf bytes.Buffer
enc := sixel.NewEncoder(&buf)
err = enc.Encode(img)
if err == nil {
return buf.String(), nil
}
// Fall back to ASCII art
return imageToASCII(img), nil
}
func imageToASCII(img image.Image) string {
bounds := img.Bounds()
width := 40
height := 15
chars := []string{"█", "▓", "▒", "░", " "}
var ascii strings.Builder
stepX := float64(bounds.Dx()) / float64(width)
stepY := float64(bounds.Dy()) / float64(height)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
px := int(float64(x) * stepX)
py := int(float64(y) * stepY)
c := color.GrayModel.Convert(img.At(px+bounds.Min.X, py+bounds.Min.Y)).(color.Gray)
index := int(c.Y) * (len(chars) - 1) / 255
ascii.WriteString(chars[index])
}
ascii.WriteString("\n")
}
return ascii.String()
}
func getITermImageSequence(img image.Image) string {
var buf bytes.Buffer
imaging.Encode(&buf, img, imaging.PNG)
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
return fmt.Sprintf("\033]1337;File=inline=1;width=40ch;height=15;preserveAspectRatio=1:%s\a", encoded)
}
func isITerm() bool {
return os.Getenv("TERM_PROGRAM") == "iTerm.app"
}

107
tui/image.go Normal file
View file

@ -0,0 +1,107 @@
package tui
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/color"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/mattn/go-sixel"
)
func FetchAndProcessThumbnail(url string) (string, error) {
if url == "" {
return "", fmt.Errorf("empty URL")
}
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get(url)
if err != nil {
return "", fmt.Errorf("failed to fetch image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to fetch image: status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read image data: %w", err)
}
img, _, err := image.Decode(bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("failed to decode image: %w", err)
}
// Consistent thumbnail size
width := 40
height := 15
img = imaging.Resize(img, width*2, height*2, imaging.Lanczos)
// Try iTerm2 protocol first
if isITerm() {
return getITermImageSequence(img), nil
}
// Try Sixel next
var buf bytes.Buffer
enc := sixel.NewEncoder(&buf)
err = enc.Encode(img)
if err == nil {
return buf.String(), nil
}
// Fall back to ASCII art
return imageToASCII(img), nil
}
func imageToASCII(img image.Image) string {
bounds := img.Bounds()
width := 40
height := 15
chars := []string{"█", "▓", "▒", "░", " "}
var ascii strings.Builder
stepX := float64(bounds.Dx()) / float64(width)
stepY := float64(bounds.Dy()) / float64(height)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
px := int(float64(x) * stepX)
py := int(float64(y) * stepY)
c := color.GrayModel.Convert(img.At(px+bounds.Min.X, py+bounds.Min.Y)).(color.Gray)
index := int(c.Y) * (len(chars) - 1) / 255
ascii.WriteString(chars[index])
}
ascii.WriteString("\n")
}
return ascii.String()
}
func getITermImageSequence(img image.Image) string {
var buf bytes.Buffer
imaging.Encode(&buf, img, imaging.PNG)
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
return fmt.Sprintf("\033]1337;File=inline=1;width=40ch;height=15;preserveAspectRatio=1:%s\a", encoded)
}
func isITerm() bool {
return os.Getenv("TERM_PROGRAM") == "iTerm.app"
}

View file

@ -2,7 +2,7 @@ package tui
import ( import (
"fmt" "fmt"
"log" // "log"
"strings" "strings"
"time" "time"
@ -19,7 +19,7 @@ type videoItem struct {
// Implement list.Item for videoItem // Implement list.Item for videoItem
func (v videoItem) Title() string { return v.Video.Title } 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) 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 } func (v videoItem) FilterValue() string { return v.Video.Title }
type model struct { type model struct {
@ -31,6 +31,13 @@ type model struct {
width int width int
height int height int
clock time.Time clock time.Time
isLoading bool
spinner spinner
}
type spinner struct {
frames []string
current int
} }
// Initial model setup // Initial model setup
@ -50,6 +57,10 @@ func initialModel() model {
clock: time.Now(), clock: time.Now(),
width: 80, // Default width width: 80, // Default width
height: 24, // Default height height: 24, // Default height
spinner: spinner{
frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
current: 0,
},
} }
} }
@ -85,33 +96,28 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.showSearch { if m.showSearch {
query := m.searchBar.Value() query := m.searchBar.Value()
if query == "" { 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 return m, nil
} }
// Populate the results list m.isLoading = true
items := make([]list.Item, len(videos)) // Return both the loading animation and the search command
for i, v := range videos { return m, tea.Batch(
items[i] = videoItem{Video: v} tickCmd(),
} func() tea.Msg {
m.videos = videos videos, err := scraper.FetchVideos(query)
m.results.SetItems(items) if err != nil {
m.showSearch = false return searchErrorMsg{err: err}
m.searchBar.Blur() }
} else { 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) // 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 return m, nil
} }
@ -121,7 +127,27 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height m.height = msg.Height
m.results.SetSize(msg.Width, msg.Height-3) // Leave space for search bar 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: case time.Time:
if m.isLoading {
m.spinner.current = (m.spinner.current + 1) % len(m.spinner.frames)
}
m.clock = msg m.clock = msg
return m, tickCmd() return m, tickCmd()
} }
@ -148,11 +174,14 @@ func (m model) View() string {
s.WriteString(clock + "\n\n") s.WriteString(clock + "\n\n")
} }
// Always show search bar at the top // Show search bar with loading animation if searching
searchText := "Press '/' to search" searchText := "Press '/' to search"
if m.showSearch { if m.showSearch {
searchText = m.searchBar.View() searchText = m.searchBar.View()
} }
if m.isLoading {
searchText += " " + m.spinner.frames[m.spinner.current] + " Searching..."
}
s.WriteString(searchText + "\n\n") s.WriteString(searchText + "\n\n")
// Show results // Show results
@ -177,3 +206,11 @@ func tickCmd() tea.Cmd {
return t return t
}) })
} }
type searchResultMsg struct {
videos []scraper.Video
}
type searchErrorMsg struct {
err error
}