ui.go
9284 bytes
1package main
2
3import (
4 "fmt"
5 "math/rand"
6 "strings"
7
8 "github.com/charmbracelet/bubbles/textinput"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/lipgloss"
11)
12
13type state int
14
15const (
16 statePlaying state = iota
17 stateGuess
18 stateResult
19)
20
21const (
22 defaultPlay = 2 // seconds of audio for a normal segment
23 extendedPlay = 4 // seconds of audio for the extended hint
24)
25
26// audioMsg is sent back once playback of the segment finishes, carrying the
27// start offset (in seconds) that was played so replays can reuse it.
28type audioMsg struct {
29 startSec int
30}
31
32type model struct {
33 catalog []Song
34 order []int
35 pos int
36 rounds int
37 played int
38 score int
39 resultParts []string
40 guesses []string
41 fieldCorrect []bool
42 fieldPartial []bool
43 roundPoints int
44
45 inputs []textinput.Model
46 active int
47
48 st state
49 playing bool
50 hinted bool
51 answer Song
52 startSec int
53 resultMsg string
54 done bool
55}
56
57type fieldSpec struct {
58 label string
59 weight int
60 value func(Song) string
61}
62
63var fields = []fieldSpec{
64 {"Title", pointsTitle, func(s Song) string { return s.Title }},
65 {"Album", pointsAlbum, func(s Song) string { return s.Album }},
66 {"Artist", pointsArtist, func(s Song) string { return s.Artist }},
67}
68
69const labelWidth = 6 // width of the longest field label ("Artist")
70
71// fieldPrompt renders a padded "Label: " prompt so all input fields align.
72func fieldPrompt(f fieldSpec) string {
73 return fmt.Sprintf("%-*s: ", labelWidth, f.label)
74}
75
76func newModel(catalog []Song, rounds int) model {
77 inputs := make([]textinput.Model, len(fields))
78 for i, f := range fields {
79 inputs[i] = textinput.New()
80 inputs[i].Prompt = fieldPrompt(f)
81 }
82
83 m := model{
84 catalog: catalog,
85 rounds: rounds,
86 inputs: inputs,
87 order: shuffled(len(catalog)),
88 st: statePlaying,
89 playing: true,
90 active: 0,
91 startSec: -1,
92 }
93 // Pre-select the first song so Init can return its playback command.
94 m.answer = catalog[m.order[0]]
95 m.pos = 1
96 m.inputs[0].Focus()
97 return m
98}
99
100func (m model) Init() tea.Cmd {
101 return startAudio(m.answer, m.startSec, defaultPlay)
102}
103
104// startAudio is a command that plays `seconds` of song starting at startSec
105// and reports back with the offset it played. If startSec is negative, a random
106// non-silent start is chosen first. bubbletea runs it in a goroutine, so the
107// TUI stays responsive while the audio plays.
108func startAudio(song Song, startSec, seconds int) tea.Cmd {
109 return func() tea.Msg {
110 if startSec < 0 {
111 s, err := pickStart(song.Path)
112 if err != nil {
113 s = 0
114 }
115 startSec = s
116 }
117 _ = playSegment(song.Path, startSec, seconds)
118 return audioMsg{startSec: startSec}
119 }
120}
121
122func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
123 switch msg := msg.(type) {
124 case audioMsg:
125 m.startSec = msg.startSec
126 m.playing = false
127 if m.st == statePlaying {
128 m.st = stateGuess
129 }
130 return m, nil
131
132 case tea.KeyMsg:
133 if msg.Type == tea.KeyCtrlC {
134 return m, tea.Quit
135 }
136 switch m.st {
137 case statePlaying:
138 if msg.Type == tea.KeyEsc {
139 m.st = stateGuess
140 m.moveField(0)
141 return m, nil
142 }
143 case stateGuess:
144 switch msg.Type {
145 case tea.KeyTab:
146 m.moveField(1)
147 return m, nil
148 case tea.KeyShiftTab:
149 m.moveField(-1)
150 return m, nil
151 case tea.KeyCtrlR:
152 if m.playing {
153 return m, nil
154 }
155 m.playing = true
156 sec := defaultPlay
157 if m.hinted {
158 sec = extendedPlay
159 }
160 return m, startAudio(m.answer, m.startSec, sec)
161 case tea.KeyCtrlL:
162 if m.playing {
163 return m, nil
164 }
165 m.playing = true
166 m.hinted = true
167 return m, startAudio(m.answer, m.startSec, extendedPlay)
168 case tea.KeyEnter:
169 if m.playing {
170 return m, nil
171 }
172 return m, m.submitGuess()
173 default:
174 m.inputs[m.active], _ = m.inputs[m.active].Update(msg)
175 return m, nil
176 }
177 case stateResult:
178 switch msg.Type {
179 case tea.KeyEnter, tea.KeySpace:
180 if m.done {
181 return m, tea.Quit
182 }
183 m.played++
184 if m.played >= m.rounds {
185 m.done = true
186 m.resultMsg = fmt.Sprintf("Game over! Final score: %d/%d", m.score, m.rounds*pointsPerRound)
187 return m, nil
188 }
189 next := m.nextSong()
190 return m, m.beginRound(next)
191 case tea.KeyEsc:
192 if m.done {
193 return m, tea.Quit
194 }
195 return m, nil
196 }
197 }
198 }
199 return m, nil
200}
201
202// beginRound starts a new round for song: records it as the answer, clears
203// the input fields, and kicks off playback of its segment.
204func (m *model) beginRound(song Song) tea.Cmd {
205 m.answer = song
206 m.st = statePlaying
207 m.playing = true
208 m.hinted = false
209 for i := range m.inputs {
210 m.inputs[i].SetValue("")
211 m.inputs[i].Blur()
212 }
213 m.active = 0
214 m.startSec = -1
215 m.inputs[0].Focus()
216 return startAudio(song, m.startSec, defaultPlay)
217}
218
219func (m *model) moveField(dir int) {
220 m.active = (m.active + dir + len(m.inputs)) % len(m.inputs)
221 for i := range m.inputs {
222 m.inputs[i].Blur()
223 }
224 m.inputs[m.active].Focus()
225}
226
227func (m *model) nextSong() Song {
228 if m.pos >= len(m.order) {
229 m.pos = 0
230 m.order = shuffled(len(m.catalog))
231 }
232 idx := m.order[m.pos]
233 m.pos++
234 return m.catalog[idx]
235}
236
237// submitGuess compares the entered title, artist and album against the answer.
238func (m *model) submitGuess() tea.Cmd {
239 points := 0
240 var parts []string
241 m.guesses = make([]string, len(m.inputs))
242 m.fieldCorrect = make([]bool, len(m.inputs))
243 m.fieldPartial = make([]bool, len(m.inputs))
244 for i, input := range m.inputs {
245 v := input.Value()
246 m.guesses[i] = v
247 if v != "" {
248 sim := fuzzyScore(v, fields[i].value(m.answer))
249 points += scoreFor(sim, fields[i].weight)
250 switch {
251 case sim >= matchThreshold:
252 m.fieldCorrect[i] = true
253 parts = append(parts, fields[i].label)
254 case sim >= lowerThreshold:
255 m.fieldPartial[i] = true
256 }
257 }
258 }
259 if m.hinted {
260 points /= 2
261 }
262 m.score += points
263 m.roundPoints = points
264 m.resultParts = parts
265 switch {
266 case len(parts) == 3:
267 m.resultMsg = fmt.Sprintf("Correct! +%d %s", points, pointWord(points))
268 case len(parts) > 0:
269 m.resultMsg = fmt.Sprintf("%s correct! +%d %s", strings.Join(parts, ", "), points, pointWord(points))
270 case points > 0:
271 m.resultMsg = fmt.Sprintf("+%d %s", points, pointWord(points))
272 default:
273 m.resultMsg = "Wrong."
274 }
275 if m.hinted && points > 0 {
276 m.resultMsg += fmt.Sprintf(" (halved for %ds hint)", extendedPlay)
277 }
278 m.st = stateResult
279 return nil
280}
281
282var red = lipgloss.NewStyle().Foreground(lipgloss.Color("#cc241d"))
283var strikeRed = lipgloss.NewStyle().Foreground(lipgloss.Color("#cc241d")).Strikethrough(true)
284var strikeYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("#d79921")).Strikethrough(true)
285var green = lipgloss.NewStyle().Foreground(lipgloss.Color("#98971a"))
286var yellow = lipgloss.NewStyle().Foreground(lipgloss.Color("#d79921"))
287var orange = lipgloss.NewStyle().Foreground(lipgloss.Color("#d65a00"))
288var dim = lipgloss.NewStyle().Foreground(lipgloss.Color("#666060"))
289var bright = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#a89984"))
290
291func (m model) View() string {
292 var b strings.Builder
293
294 b.WriteString(bright.Render("Muse Guessr"))
295 b.WriteString("\n")
296 b.WriteString(dim.Render(fmt.Sprintf("Score: %d/%d", m.score, m.rounds*pointsPerRound)))
297 b.WriteString("\n\n")
298
299 switch m.st {
300 case statePlaying:
301 b.WriteString(green.Render("▶ Playing... listen carefully."))
302 b.WriteString("\n")
303 b.WriteString(dim.Render("(Esc: skip)"))
304
305 case stateGuess:
306 b.WriteString(bright.Render("What is this song?"))
307 b.WriteString("\n\n")
308 for i, input := range m.inputs {
309 if i > 0 {
310 b.WriteString("\n")
311 }
312 b.WriteString(input.View())
313 }
314 b.WriteString("\n\n")
315 b.WriteString(dim.Render(fmt.Sprintf("Ctrl+R: replay Ctrl+L: play %ds", extendedPlay)))
316
317 case stateResult:
318 var style lipgloss.Style
319 var mark string
320 switch {
321 case m.done:
322 style, mark = bright, ""
323 case len(m.resultParts) == 0 && m.roundPoints == 0:
324 style, mark = red, "✘ "
325 case m.hinted:
326 style, mark = orange, "✔ "
327 case len(m.resultParts) == 3:
328 style, mark = green, "✔ "
329 default:
330 style, mark = yellow, "✔ "
331 }
332 b.WriteString(style.Render(mark + m.resultMsg))
333 if !m.done {
334 b.WriteString("\n\n")
335 for i, f := range fields {
336 if i > 0 {
337 b.WriteString("\n")
338 }
339 label := fieldPrompt(f)
340 answer := f.value(m.answer)
341 switch {
342 case m.fieldCorrect[i]:
343 b.WriteString(dim.Render(label) + green.Render(answer))
344 case m.guesses[i] == "":
345 b.WriteString(dim.Render(label) + yellow.Render(answer))
346 case m.fieldPartial[i]:
347 b.WriteString(dim.Render(label) + strikeYellow.Render(m.guesses[i]) + dim.Render(" ") + green.Render(answer))
348 default:
349 b.WriteString(dim.Render(label) + strikeRed.Render(m.guesses[i]) + dim.Render(" ") + green.Render(answer))
350 }
351 }
352 b.WriteString("\n\n")
353 }
354 if m.done {
355 b.WriteString("\n\n")
356 b.WriteString(dim.Render("Press Enter to quit."))
357 } else {
358 b.WriteString(dim.Render("Press Enter for the next song."))
359 }
360 }
361
362 return b.String()
363}
364
365func pointWord(n int) string {
366 if n == 1 {
367 return "point"
368 }
369 return "points"
370}
371
372func shuffled(n int) []int {
373 idx := make([]int, n)
374 for i := range idx {
375 idx[i] = i
376 }
377 rand.Shuffle(n, func(i, j int) {
378 idx[i], idx[j] = idx[j], idx[i]
379 })
380 return idx
381}