Parent directory

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}