From 7f069adbfa2d83e144a58a94253ca1c89c58584d Mon Sep 17 00:00:00 2001 From: dhilzyi Date: Sun, 17 May 2026 02:23:38 +0700 Subject: [PATCH 1/3] chore(main): initial commit adding tui system --- cmd/tui-test/main.go | 43 ++++++++++++ cmd/tui-test/tui.go | 142 +++++++++++++++++++++++++++++++++++++++ go.mod | 20 ++++-- go.sum | 32 +++++++++ internal/app/resolver.go | 10 +-- internal/app/ui_loop.go | 4 +- internal/ui/print.go | 30 ++++++++- 7 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 cmd/tui-test/main.go create mode 100644 cmd/tui-test/tui.go diff --git a/cmd/tui-test/main.go b/cmd/tui-test/main.go new file mode 100644 index 0000000..5bf88b0 --- /dev/null +++ b/cmd/tui-test/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "os" + + "github.com/dhilzyi/hianime-cli/internal/config" + "github.com/dhilzyi/hianime-cli/internal/state" + + tea "charm.land/bubbletea/v2" +) + +func main() { + appDir, err := config.InitPath() + if err != nil { + fmt.Println("Fail to initialize path: " + err.Error()) + } + + history, err := state.LoadHistory(appDir.DataDir) + if err != nil { + fmt.Println("Fail to load history file: " + err.Error()) + } + cfg, err := config.LoadConfig(appDir.ConfigDir, "v1.9.0") + if err != nil { + fmt.Println("Fail to load config file: " + err.Error()) + } + + initialModel := model{ + state: StateHistory, + cursor: 0, + cfg: cfg, + + session: &session{ + historyList: history, + }, + } + + p := tea.NewProgram(initialModel) + if _, err := p.Run(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} diff --git a/cmd/tui-test/tui.go b/cmd/tui-test/tui.go new file mode 100644 index 0000000..613f938 --- /dev/null +++ b/cmd/tui-test/tui.go @@ -0,0 +1,142 @@ +package main + +import ( + "fmt" + "log" + "strings" + + "github.com/dhilzyi/hianime-cli/internal/app" + "github.com/dhilzyi/hianime-cli/internal/config" + "github.com/dhilzyi/hianime-cli/internal/core" + "github.com/dhilzyi/hianime-cli/internal/state" + + tea "charm.land/bubbletea/v2" +) + +type screenState int + +const ( + StateHistory screenState = iota + StateEpisodes + StatePlaying + + StateConfigSettings +) + +type model struct { + state screenState + cursor int + subs bool + + session *session + cfg *config.Config +} + +func (m model) Init() tea.Cmd { + return nil +} + +type session struct { + urlSeries string + + provider core.Provider + seriesData core.SeriesData + + historyList []state.History + episodeList []core.Episode + serverList []core.Episode +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + switch m.state { + case StateHistory: + switch msg.String() { + case "q", "esc": + return m, tea.Quit + case "up", "k": + m.cursor-- + if m.cursor < 0 { + m.cursor = len(m.session.historyList) - 1 + } + case "down", "j": + m.cursor++ + if m.cursor >= len(m.session.historyList) { + m.cursor = 0 + } + case "s": + m.subs = !m.subs + case "c": + m.state = StateConfigSettings + case "enter": + m.session.urlSeries = m.session.historyList[m.cursor].Metadata.SeriesUrl + fetchResult, err := app.ResolveInput(app.ResolveParams{URL: m.session.urlSeries}, app.NewCache()) + if err != nil { + log.Fatal(err) + return m, tea.Quit + } + m.session.provider = fetchResult.Provider + m.session.episodeList = fetchResult.Episodes + m.session.seriesData = fetchResult.SeriesData + + m.state = StateEpisodes + m.cursor = 0 + } + case StateEpisodes: + switch msg.String() { + case "q", "esc": + m.state = StateHistory + m.cursor = 0 + case "up", "k": + m.cursor-- + if m.cursor < 0 { + m.cursor = len(m.session.episodeList) - 1 + } + case "down", "j": + m.cursor++ + if m.cursor >= len(m.session.episodeList) { + m.cursor = 0 + } + case "enter": + } + } + + } + + return m, nil +} + +func (m model) View() tea.View { + var buf strings.Builder + + switch m.state { + case StateHistory: + buf.WriteString("--- RECENT HISTORY ---\n\n") + for i, item := range m.session.historyList { + if m.cursor == i { + buf.WriteString("-> ") + } else { + buf.WriteString(" ") + } + buf.WriteString(item.Metadata.Titles.GetPreferredTitle() + "\n") + } + fmt.Fprintf(&buf, "\n\n Subs: %t\n", m.subs) + case StateEpisodes: + buf.WriteString("--- EPISODES ---\n\n") + for i, ep := range m.session.episodeList { + if m.cursor == i { + buf.WriteString("-> ") + } else { + buf.WriteString(" ") + } + fmt.Fprintf(&buf, fmt.Sprintf("[%d] %s\n", ep.Number, ep.Titles.GetPreferredTitle())) + } + fmt.Fprintf(&buf, "\nSelected: %s", m.session.episodeList[m.cursor].Titles.GetPreferredTitle()) + } + + return tea.NewView(buf.String()) +} diff --git a/go.mod b/go.mod index 6783410..2d86ca6 100644 --- a/go.mod +++ b/go.mod @@ -8,20 +8,32 @@ require ( ) require ( + charm.land/bubbletea/v2 v2.0.6 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/clipperhouse/displaywidth v0.10.0 // indirect - github.com/clipperhouse/uax29/v2 v2.6.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.2.0 // indirect github.com/olekukonko/ll v0.1.6 // indirect github.com/olekukonko/tablewriter v1.1.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosuke-furukawa/json5 v0.1.1 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect ) diff --git a/go.sum b/go.sum index ace6ae5..7d76090 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,48 @@ +charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= +charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= @@ -27,6 +51,10 @@ github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA= github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosuke-furukawa/json5 v0.1.1 h1:0F9mNwTvOuDNH243hoPqvf+dxa5QsKnZzU20uNsh3ZI= github.com/yosuke-furukawa/json5 v0.1.1/go.mod h1:sw49aWDqNdRJ6DYUtIQiaA3xyj2IL9tjeNYmX2ixwcU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -61,6 +89,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -75,6 +105,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/internal/app/resolver.go b/internal/app/resolver.go index b8be329..e4f7254 100644 --- a/internal/app/resolver.go +++ b/internal/app/resolver.go @@ -20,7 +20,7 @@ type FetchResult struct { Episodes []core.Episode } -type resolveParams struct { +type ResolveParams struct { URL string AnilistID int } @@ -44,7 +44,7 @@ const ( InputAnilistID ) -func getProvider(p resolveParams) (core.Provider, ProviderType) { +func getProvider(p ResolveParams) (core.Provider, ProviderType) { if strings.Contains(p.URL, "animenosub") { return animenosub.New(p.URL), AnimeNoSub } else if strings.Contains(p.URL, "reanime") { @@ -79,7 +79,7 @@ func classifyInput(input string) (InputType, int) { return InputURL, 0 } -func handleURLInput(p resolveParams, provider core.Provider) ([]core.Episode, core.SeriesData, error) { +func handleURLInput(p ResolveParams, provider core.Provider) ([]core.Episode, core.SeriesData, error) { episodes, series, err := provider.GetEpisodes() if err != nil { return nil, core.SeriesData{}, err @@ -88,7 +88,7 @@ func handleURLInput(p resolveParams, provider core.Provider) ([]core.Episode, co return episodes, *series, nil } -func fromCache(entry *CacheEntry, p resolveParams) *FetchResult { +func fromCache(entry *CacheEntry, p ResolveParams) *FetchResult { provider, _ := getProvider(p) return &FetchResult{ Provider: provider, @@ -97,7 +97,7 @@ func fromCache(entry *CacheEntry, p resolveParams) *FetchResult { } } -func ResolveInput(p resolveParams, cache *Cache) (*FetchResult, error) { +func ResolveInput(p ResolveParams, cache *Cache) (*FetchResult, error) { if p.AnilistID != 0 { if entry, ok := cache.byAnilistID[p.AnilistID]; ok { fmt.Println("Info: cache hit by anilistId") diff --git a/internal/app/ui_loop.go b/internal/app/ui_loop.go index f912d83..40920a7 100644 --- a/internal/app/ui_loop.go +++ b/internal/app/ui_loop.go @@ -59,7 +59,7 @@ func (a *App) handleMenu() { continue } - fetchResult, err = ResolveInput(resolveParams{URL: url, AnilistID: anilistID}, a.Cache) + fetchResult, err = ResolveInput(ResolveParams{URL: url, AnilistID: anilistID}, a.Cache) if err != nil { log.Println(err) continue @@ -274,7 +274,7 @@ func (a *App) handleSearchView() string { continue } - provider, _ := getProvider(resolveParams{URL: strings.ToLower(providers[provInputInt-1])}) + provider, _ := getProvider(ResolveParams{URL: strings.ToLower(providers[provInputInt-1])}) fmt.Printf("Info: '%s' is selected\n", provider.Name()) searchState, err := NewSearchState(provider) diff --git a/internal/ui/print.go b/internal/ui/print.go index 40fac41..5a3e7b1 100644 --- a/internal/ui/print.go +++ b/internal/ui/print.go @@ -133,7 +133,35 @@ func PrintServers(servers []core.Server) { func PrintRecentHistory(history []state.History) { fmt.Printf("\n--- Recent History ---\n\n") - table := tablewriter.NewWriter(os.Stdout) + symbols := tw.NewSymbolCustom("MyGrid"). + WithRow("─"). + WithColumn("│"). + WithCenter("┼") + + table := tablewriter.NewTable(os.Stdout, + + tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{ + Borders: tw.BorderNone, + Symbols: symbols, + Settings: tw.Settings{ + Separators: tw.Separators{ + BetweenColumns: tw.On, + }, + Lines: tw.Lines{ + ShowTop: tw.On, + }, + }, + })), + + tablewriter.WithConfig(tablewriter.Config{ + Header: tw.CellConfig{ + Alignment: tw.CellAlignment{Global: tw.AlignLeft}, + }, + Row: tw.CellConfig{ + Alignment: tw.CellAlignment{Global: tw.AlignLeft}, + }, + }), + ) table.Header([]string{"NO", "NAME", "TYPE", "PROVIDER"}) var provider string From a1c31945b9e4eedf4dba18fdf1680a93cadc5750 Mon Sep 17 00:00:00 2001 From: dhilzyi Date: Sun, 17 May 2026 13:22:19 +0700 Subject: [PATCH 2/3] chore: implement handler for new enum state: StateServer, StateError. - add new fields into model struct - errData - appCache - add new function for handling an error --- cmd/tui-test/main.go | 11 ++-- cmd/tui-test/{tui.go => model.go} | 88 ++++++++++++++++++++++++------- cmd/tui-test/types.go | 30 +++++++++++ cmd/tui-test/utils.go | 11 ++++ 4 files changed, 116 insertions(+), 24 deletions(-) rename cmd/tui-test/{tui.go => model.go} (59%) create mode 100644 cmd/tui-test/types.go create mode 100644 cmd/tui-test/utils.go diff --git a/cmd/tui-test/main.go b/cmd/tui-test/main.go index 5bf88b0..7dcf72d 100644 --- a/cmd/tui-test/main.go +++ b/cmd/tui-test/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/dhilzyi/hianime-cli/internal/app" "github.com/dhilzyi/hianime-cli/internal/config" "github.com/dhilzyi/hianime-cli/internal/state" @@ -16,15 +17,16 @@ func main() { fmt.Println("Fail to initialize path: " + err.Error()) } - history, err := state.LoadHistory(appDir.DataDir) - if err != nil { - fmt.Println("Fail to load history file: " + err.Error()) - } cfg, err := config.LoadConfig(appDir.ConfigDir, "v1.9.0") if err != nil { fmt.Println("Fail to load config file: " + err.Error()) } + history, err := state.LoadHistory(appDir.DataDir) + if err != nil { + fmt.Println("Fail to load history file: " + err.Error()) + } + cache := app.NewCache() initialModel := model{ state: StateHistory, cursor: 0, @@ -33,6 +35,7 @@ func main() { session: &session{ historyList: history, }, + appCache: cache, } p := tea.NewProgram(initialModel) diff --git a/cmd/tui-test/tui.go b/cmd/tui-test/model.go similarity index 59% rename from cmd/tui-test/tui.go rename to cmd/tui-test/model.go index 613f938..3e6992c 100644 --- a/cmd/tui-test/tui.go +++ b/cmd/tui-test/model.go @@ -2,13 +2,10 @@ package main import ( "fmt" - "log" "strings" "github.com/dhilzyi/hianime-cli/internal/app" "github.com/dhilzyi/hianime-cli/internal/config" - "github.com/dhilzyi/hianime-cli/internal/core" - "github.com/dhilzyi/hianime-cli/internal/state" tea "charm.land/bubbletea/v2" ) @@ -16,10 +13,16 @@ import ( type screenState int const ( + // Main state StateHistory screenState = iota StateEpisodes + StateServer StatePlaying + // Message state + StateError + + // Miscellaneous StateConfigSettings ) @@ -28,31 +31,26 @@ type model struct { cursor int subs bool - session *session - cfg *config.Config + err *errData + + session *session + cfg *config.Config + appCache *app.Cache } func (m model) Init() tea.Cmd { return nil } -type session struct { - urlSeries string - - provider core.Provider - seriesData core.SeriesData - - historyList []state.History - episodeList []core.Episode - serverList []core.Episode -} - func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: + // Global control if msg.String() == "ctrl+c" { return m, tea.Quit } + + // Per state control switch m.state { case StateHistory: switch msg.String() { @@ -73,11 +71,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "c": m.state = StateConfigSettings case "enter": - m.session.urlSeries = m.session.historyList[m.cursor].Metadata.SeriesUrl - fetchResult, err := app.ResolveInput(app.ResolveParams{URL: m.session.urlSeries}, app.NewCache()) + m.session.selected.history = m.session.historyList[m.cursor] + m.session.urlSeries = m.session.selected.history.Metadata.SeriesUrl + fetchResult, err := app.ResolveInput(app.ResolveParams{URL: m.session.urlSeries}, m.appCache) if err != nil { - log.Fatal(err) - return m, tea.Quit + sendErr(&m, err) + return m, nil } m.session.provider = fetchResult.Provider m.session.episodeList = fetchResult.Episodes @@ -102,6 +101,40 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor = 0 } case "enter": + m.session.selected.episode = m.session.episodeList[m.cursor] + servers, err := m.session.provider.GetServers(m.session.selected.episode) + if err != nil { + sendErr(&m, err) + return m, nil + } + + m.session.serverList = servers + m.cursor = 0 + m.state = StateServer + } + case StateServer: + switch msg.String() { + case "q", "esc": + m.cursor = 0 + m.state = StateEpisodes + case "up", "k": + m.cursor-- + if m.cursor < 0 { + m.cursor = len(m.session.serverList) - 1 + } + case "down", "j": + m.cursor++ + if m.cursor >= len(m.session.serverList) { + m.cursor = 0 + } + } + case StateError: + switch msg.String() { + case "enter", "q", "esc": + m.cursor = 0 + m.state = m.err.errState + + m.err = nil } } @@ -136,6 +169,21 @@ func (m model) View() tea.View { fmt.Fprintf(&buf, fmt.Sprintf("[%d] %s\n", ep.Number, ep.Titles.GetPreferredTitle())) } fmt.Fprintf(&buf, "\nSelected: %s", m.session.episodeList[m.cursor].Titles.GetPreferredTitle()) + case StateServer: + buf.WriteString("--- AVAILABLE SERVERS ---\n\n") + for i, srv := range m.session.serverList { + if m.cursor == i { + buf.WriteString("-> ") + } else { + buf.WriteString(" ") + } + fmt.Fprintf(&buf, fmt.Sprintf("[%d] %s\n", i+1, srv.Name)) + } + case StateError: + buf.WriteString("--- ERROR ---\n\n") + buf.WriteString("Something unexpected just occured\n") + fmt.Fprintf(&buf, "Error: %s\n", m.err.errMsg.Error()) + fmt.Fprintf(&buf, "\n\nPress 'enter' to continue...\n") } return tea.NewView(buf.String()) diff --git a/cmd/tui-test/types.go b/cmd/tui-test/types.go new file mode 100644 index 0000000..771de85 --- /dev/null +++ b/cmd/tui-test/types.go @@ -0,0 +1,30 @@ +package main + +import ( + "github.com/dhilzyi/hianime-cli/internal/core" + "github.com/dhilzyi/hianime-cli/internal/state" +) + +type session struct { + urlSeries string + + selected selected + + provider core.Provider + seriesData core.SeriesData + + historyList []state.History + episodeList []core.Episode + serverList []core.Server +} + +type errData struct { + errMsg error + errState screenState +} + +type selected struct { + history state.History + episode core.Episode + server core.Server +} diff --git a/cmd/tui-test/utils.go b/cmd/tui-test/utils.go new file mode 100644 index 0000000..91fa1ab --- /dev/null +++ b/cmd/tui-test/utils.go @@ -0,0 +1,11 @@ +package main + +func sendErr(m *model, err error) { + m.err = &errData{ + errMsg: err, + errState: m.state, + } + + m.state = StateError + m.cursor = 0 +} From 10009535d330787354eeab9bf2f48640e51212b4 Mon Sep 17 00:00:00 2001 From: dhilzyi Date: Sun, 17 May 2026 13:27:14 +0700 Subject: [PATCH 3/3] docs: add some comments in the file code --- internal/common/common.go | 8 +++++++- internal/common/http.go | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/common/common.go b/internal/common/common.go index 2988946..fbd4b95 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -6,6 +6,10 @@ import ( "strings" ) +// Eliminating whitspace then replacing it with '+' symbol +// Example: +// Seishun no -> Seishun+no, +// made in abyss -> made+in+abyss func StringToQueryFormat(rawInput string) string { rawSplitted := strings.Split(rawInput, " ") if len(rawSplitted) == 0 { @@ -15,7 +19,9 @@ func StringToQueryFormat(rawInput string) string { return strings.Join(rawSplitted, "+") } -// example: https://example.com +// Getting base url from any url +// Example: +// https://youtube.com, https://yandex.ru func GetBaseURL(rawUrl string) (string, error) { parsedURL, err := url.Parse(rawUrl) if err != nil { diff --git a/internal/common/http.go b/internal/common/http.go index d576fff..78d6acd 100644 --- a/internal/common/http.go +++ b/internal/common/http.go @@ -13,6 +13,7 @@ type defaultHeadersTransport struct { } // RoundTrip intercepts the request before it is sent +// It will only use default headers if the headers is not specifically being set func (t *defaultHeadersTransport) RoundTrip(req *http.Request) (*http.Response, error) { reqClone := req.Clone(req.Context()) @@ -25,6 +26,8 @@ func (t *defaultHeadersTransport) RoundTrip(req *http.Request) (*http.Response, return t.baseTransport.RoundTrip(reqClone) } +// Retrieve cookiejar and will use it as long the client is remain same +// Use this client when fetching as one session func NewSession() (*http.Client, error) { jar, err := cookiejar.New(nil) if err != nil {