Skip to content

Commit fe7b63b

Browse files
committed
refactor: improve quality menu UX with descending sort, mirror disambiguation, and stable index-based selection
1 parent 7060644 commit fe7b63b

7 files changed

Lines changed: 616 additions & 91 deletions

File tree

internal/playback/common.go

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,10 @@ import (
1010
"sync"
1111
"time"
1212

13-
"charm.land/huh/v2/spinner"
1413
"github.com/alvarorichard/Goanime/internal/api"
1514
"github.com/alvarorichard/Goanime/internal/api/providers/metadata"
1615
"github.com/alvarorichard/Goanime/internal/models"
1716
"github.com/alvarorichard/Goanime/internal/player"
18-
"github.com/alvarorichard/Goanime/internal/tui"
1917
"github.com/alvarorichard/Goanime/internal/util"
2018
)
2119

@@ -65,37 +63,39 @@ func PlayEpisode(
6563
}
6664
}
6765

68-
// Fetch episode metadata and stream URL in parallel under a single spinner
69-
// GetEpisodeData (Jikan/AniList metadata) and GetVideoURLForEpisodeEnhanced (scraper)
70-
// are independent operations — running them concurrently saves a full round-trip
66+
// Fetch episode metadata and stream URL in parallel.
67+
//
68+
// 2026-04-28: removed the huh/v2 Bubble Tea spinner that previously
69+
// wrapped this block. GetVideoURLForEpisodeEnhanced may invoke a
70+
// tcell-based fuzzyfinder quality picker (AnimeFire's multi-quality
71+
// response). The Bubble Tea spinner and tcell racing for stdin/stdout
72+
// caused two user-visible bugs: arrow keys needed multiple presses to
73+
// register (input contention) and the spinner's redraw clipped the
74+
// first character of the picker's prompt ("S" of "Select"). A static
75+
// log line is the smaller evil — animation is a nice-to-have, the
76+
// picker working is not.
77+
util.Infof("Loading episode...")
78+
7179
var videoURL string
7280
var videoErr error
73-
currentEpisodeCopy := currentEpisode // capture for goroutine
74-
75-
_ = tui.RunClean(func() error {
76-
return spinner.New().
77-
Title("Loading episode...").
78-
Type(spinner.Dots).
79-
Action(func() {
80-
var wg sync.WaitGroup
81-
wg.Add(2)
82-
83-
go func() {
84-
defer wg.Done()
85-
if err := api.GetEpisodeData(anime.MalID, episodeNum, anime); err != nil {
86-
util.Debugf("Error fetching episode data: %v", err)
87-
}
88-
}()
89-
90-
go func() {
91-
defer wg.Done()
92-
videoURL, videoErr = player.GetVideoURLForEpisodeEnhanced(currentEpisodeCopy, anime)
93-
}()
94-
95-
wg.Wait()
96-
}).
97-
Run()
98-
})
81+
currentEpisodeCopy := currentEpisode
82+
83+
var wg sync.WaitGroup
84+
wg.Add(2)
85+
86+
go func() {
87+
defer wg.Done()
88+
if err := api.GetEpisodeData(anime.MalID, episodeNum, anime); err != nil {
89+
util.Debugf("Error fetching episode data: %v", err)
90+
}
91+
}()
92+
93+
go func() {
94+
defer wg.Done()
95+
videoURL, videoErr = player.GetVideoURLForEpisodeEnhanced(currentEpisodeCopy, anime)
96+
}()
97+
98+
wg.Wait()
9999

100100
if videoErr != nil {
101101
// Any video URL failure means the episode is not available on this source.

internal/playback/movie.go

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,10 @@ import (
1010
"sync"
1111
"time"
1212

13-
"charm.land/huh/v2/spinner"
1413
"github.com/alvarorichard/Goanime/internal/api"
1514
"github.com/alvarorichard/Goanime/internal/discord"
1615
"github.com/alvarorichard/Goanime/internal/models"
1716
"github.com/alvarorichard/Goanime/internal/player"
18-
"github.com/alvarorichard/Goanime/internal/tui"
1917
"github.com/alvarorichard/Goanime/internal/util"
2018
)
2119

@@ -40,16 +38,10 @@ func HandleMovie(anime *models.Anime, episodes []models.Episode, discordEnabled
4038
var videoURL string
4139
var videoErr error
4240

43-
// Use spinner while fetching video URL
44-
_ = tui.RunClean(func() error {
45-
return spinner.New().
46-
Title("Loading video stream...").
47-
Type(spinner.Dots).
48-
Action(func() {
49-
videoURL, videoErr = player.GetVideoURLForEpisodeEnhanced(&episodes[0], anime)
50-
}).
51-
Run()
52-
})
41+
// Use static log instead of spinner while fetching video URL
42+
// to avoid terminal UI contention if a quality picker opens.
43+
util.Infof("Loading video stream...")
44+
videoURL, videoErr = player.GetVideoURLForEpisodeEnhanced(&episodes[0], anime)
5345

5446
if videoErr != nil {
5547
log.Printf("Failed to extract video URL: %v", util.ErrorHandler(videoErr))
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Package player — regression tests for the AnimeFire quality picker.
2+
//
3+
// 2026-04-28: the AnimeFire quality picker (extractActualVideoURL inline)
4+
// had several visible UX bugs surfaced by a screenshot from the user:
5+
//
6+
// 1. items were displayed in whatever order AnimeFire returned them, so
7+
// users could see 360p above 1080p depending on API order;
8+
// 2. labels like "FHD" / "HD" / empty rendered raw — confusing or empty
9+
// menu rows;
10+
// 3. same-quality mirrors produced visually identical entries — the user
11+
// had no way to tell them apart from inside the picker;
12+
// 4. Esc surfaced as a generic "failed to select quality" error instead
13+
// of routing back to the menu.
14+
//
15+
// The fix moved the sort + label-rendering logic into
16+
// buildAnimeFireQualityItems and made the call site treat fuzzyfinder.ErrAbort
17+
// as ErrBackRequested. These tests pin the new contract for #1–#3.
18+
package player
19+
20+
import (
21+
"strings"
22+
"testing"
23+
)
24+
25+
// Bug 1 regression: descending sort independent of upstream order.
26+
func TestBuildAnimeFireQualityItems_SortsDescendingByParsedResolution(t *testing.T) {
27+
t.Parallel()
28+
29+
in := []VideoData{
30+
{Label: "360p", Src: "https://cdn/360.mp4"},
31+
{Label: "1080p", Src: "https://cdn/1080.mp4"},
32+
{Label: "720p", Src: "https://cdn/720.mp4"},
33+
}
34+
sorted, labels := buildAnimeFireQualityItems(in)
35+
36+
wantOrder := []string{"1080p", "720p", "360p"}
37+
for i, w := range wantOrder {
38+
if labels[i] != w {
39+
t.Fatalf("labels[%d] = %q, want %q (full=%v)", i, labels[i], w, labels)
40+
}
41+
if sorted[i].Label != w {
42+
t.Fatalf("sorted[%d].Label = %q, want %q", i, sorted[i].Label, w)
43+
}
44+
}
45+
}
46+
47+
// AnimeFire occasionally serves non-numeric labels ("FHD", "HD", "SD").
48+
// Parsed resolution is unknown, so they must render with a usable
49+
// fallback — never an empty string and never the raw Src URL.
50+
func TestBuildAnimeFireQualityItems_RendersFriendlyLabelForNonNumeric(t *testing.T) {
51+
t.Parallel()
52+
53+
in := []VideoData{
54+
{Label: "FHD", Src: "https://cdn/fhd.mp4"},
55+
{Label: "", Src: "https://cdn/blank.mp4"},
56+
{Label: "HD", Src: "https://cdn/hd.mp4"},
57+
}
58+
_, labels := buildAnimeFireQualityItems(in)
59+
60+
for i, l := range labels {
61+
if l == "" {
62+
t.Fatalf("labels[%d] is empty — picker would render a blank row", i)
63+
}
64+
if strings.HasPrefix(l, "http") {
65+
t.Fatalf("labels[%d] = %q — raw URL must never be shown", i, l)
66+
}
67+
}
68+
69+
// Empty label specifically must collapse to "Auto", not the URL.
70+
foundAuto := false
71+
for _, l := range labels {
72+
if l == "Auto" {
73+
foundAuto = true
74+
break
75+
}
76+
}
77+
if !foundAuto {
78+
t.Fatalf("expected one entry to render as 'Auto' for the empty label; got %v", labels)
79+
}
80+
}
81+
82+
// Bug 3 regression: same numeric quality across mirrors must produce
83+
// distinct labels — otherwise the index returned by the fuzzyfinder picks
84+
// the wrong source under the previous string-match logic.
85+
func TestBuildAnimeFireQualityItems_DisambiguatesSameQualityMirrors(t *testing.T) {
86+
t.Parallel()
87+
88+
in := []VideoData{
89+
{Label: "720p", Src: "https://mirror-a/720.mp4"},
90+
{Label: "720p", Src: "https://mirror-b/720.mp4"},
91+
{Label: "720p", Src: "https://mirror-c/720.mp4"},
92+
}
93+
sorted, labels := buildAnimeFireQualityItems(in)
94+
95+
uniq := map[string]struct{}{}
96+
for _, l := range labels {
97+
uniq[l] = struct{}{}
98+
}
99+
if len(uniq) != 3 {
100+
t.Fatalf("labels must be unique to keep selection unambiguous, got %v", labels)
101+
}
102+
if labels[0] != "720p" {
103+
t.Fatalf("first occurrence must keep bare label, got %q", labels[0])
104+
}
105+
for i := 1; i < 3; i++ {
106+
if !strings.Contains(labels[i], "mirror") {
107+
t.Fatalf("labels[%d] must include 'mirror' suffix, got %q", i, labels[i])
108+
}
109+
}
110+
111+
// Stable: order of equal-resolution items mirrors input order so
112+
// the index-based selection in the call site picks the exact source
113+
// the user highlighted.
114+
wantSrc := []string{
115+
"https://mirror-a/720.mp4",
116+
"https://mirror-b/720.mp4",
117+
"https://mirror-c/720.mp4",
118+
}
119+
for i, w := range wantSrc {
120+
if sorted[i].Src != w {
121+
t.Fatalf("sorted[%d].Src = %q, want %q (stable sort broken)", i, sorted[i].Src, w)
122+
}
123+
}
124+
}
125+
126+
// Locks the alignment invariant the call site depends on: labels[i]
127+
// describes sorted[i], and indexing the user's chosen idx into sorted
128+
// returns the exact VideoData they picked.
129+
func TestBuildAnimeFireQualityItems_LengthsAndAlignment(t *testing.T) {
130+
t.Parallel()
131+
132+
in := []VideoData{
133+
{Label: "1080p", Src: "https://cdn/1080.mp4"},
134+
{Label: "720p", Src: "https://m1/720.mp4"},
135+
{Label: "720p", Src: "https://m2/720.mp4"},
136+
{Label: "FHD", Src: "https://cdn/fhd.mp4"},
137+
{Label: "", Src: "https://cdn/blank.mp4"},
138+
}
139+
sorted, labels := buildAnimeFireQualityItems(in)
140+
141+
if len(sorted) != len(labels) {
142+
t.Fatalf("len(sorted)=%d must equal len(labels)=%d", len(sorted), len(labels))
143+
}
144+
if len(sorted) != len(in) {
145+
t.Fatalf("len(sorted)=%d must preserve input cardinality (%d)", len(sorted), len(in))
146+
}
147+
for i := range sorted {
148+
for j := i + 1; j < len(labels); j++ {
149+
if labels[i] == labels[j] {
150+
t.Fatalf("labels[%d] == labels[%d] == %q breaks index alignment", i, j, labels[i])
151+
}
152+
}
153+
}
154+
}
155+
156+
func TestBuildAnimeFireQualityItems_DoesNotMutateInput(t *testing.T) {
157+
t.Parallel()
158+
159+
in := []VideoData{
160+
{Label: "360p", Src: "https://cdn/360.mp4"},
161+
{Label: "1080p", Src: "https://cdn/1080.mp4"},
162+
{Label: "720p", Src: "https://cdn/720.mp4"},
163+
}
164+
snapshot := append([]VideoData(nil), in...)
165+
166+
_, _ = buildAnimeFireQualityItems(in)
167+
168+
for i := range snapshot {
169+
if in[i] != snapshot[i] {
170+
t.Fatalf("input mutated at %d: got %+v want %+v", i, in[i], snapshot[i])
171+
}
172+
}
173+
}
174+
175+
func TestBuildAnimeFireQualityItems_EmptyAndSingle(t *testing.T) {
176+
t.Parallel()
177+
178+
sorted, labels := buildAnimeFireQualityItems(nil)
179+
if len(sorted) != 0 || len(labels) != 0 {
180+
t.Fatalf("nil input must yield empty slices; got sorted=%v labels=%v", sorted, labels)
181+
}
182+
183+
in := []VideoData{{Label: "1080p", Src: "https://cdn/1080.mp4"}}
184+
sorted, labels = buildAnimeFireQualityItems(in)
185+
if len(sorted) != 1 || sorted[0].Src != "https://cdn/1080.mp4" {
186+
t.Fatalf("single source must be preserved; got %+v", sorted)
187+
}
188+
if len(labels) != 1 || labels[0] != "1080p" {
189+
t.Fatalf("single source must render bare label; got %v", labels)
190+
}
191+
}

0 commit comments

Comments
 (0)