Diff
1diff --git a/server.go b/server.go
2index 4e092daed5584465c1206ac0ec0995f90b227c9b..75cb30afa2cf3142ffe561eb212beb470d5aec2c 100644
3--- a/server.go
4+++ b/server.go
5@@ -20,19 +20,20 @@ var staticFS embed.FS
6 // Game holds the mutable state of a single-player game session. All access is
7 // guarded by mu because HTTP handlers run concurrently.
8 type Game struct {
9- mu sync.Mutex
10- catalog []Song
11- order []int
12- pos int
13- rounds int
14- played int
15- score int
16- answer Song
17- startSec int
18- segment []byte // WAV bytes for the current round's clip
19- hinted bool
20- done bool
21- result *RoundResult
22+ mu sync.Mutex
23+ catalog []Song
24+ order []int
25+ pos int
26+ rounds int
27+ played int
28+ score int
29+ answer Song
30+ startSec int
31+ segment []byte // WAV bytes for the current round's clip
32+ extendedSegment []byte // extended hint clip, generated on demand
33+ hinted bool
34+ done bool
35+ result *RoundResult
36 }
37
38 // RoundResult captures the outcome of a submitted guess, ready for rendering.
39@@ -64,6 +65,7 @@ func (g *Game) beginRound() {
40 g.result = nil
41 g.startSec = -1
42 g.segment = nil
43+ g.extendedSegment = nil
44 start, err := pickStart(g.answer.Path)
45 if err != nil {
46 return
47@@ -214,6 +216,7 @@ func (s *server) handler() http.Handler {
48 staticSub, _ := fs.Sub(staticFS, "web/static")
49 mux.HandleFunc("/", s.handleHome)
50 mux.HandleFunc("/audio", s.handleAudio)
51+ mux.HandleFunc("/audio/long", s.handleAudioLong)
52 mux.HandleFunc("/guess", s.handleGuess)
53 mux.HandleFunc("/next", s.handleNext)
54 mux.HandleFunc("/restart", s.handleRestart)
55@@ -261,6 +264,35 @@ func (s *server) handleAudio(w http.ResponseWriter, r *http.Request) {
56 }
57 }
58
59+// handleAudioLong serves the extended (extendedPlay s) hint clip from the same
60+// start point. Requesting it marks the round as hinted, which halves that
61+// round's points — matching the TUI's Ctrl+L behaviour. Generated on first
62+// request and cached for the round.
63+func (s *server) handleAudioLong(w http.ResponseWriter, r *http.Request) {
64+ s.game.mu.Lock()
65+ defer s.game.mu.Unlock()
66+ if s.game.startSec < 0 {
67+ http.NotFound(w, r)
68+ return
69+ }
70+ s.game.hinted = true
71+ if len(s.game.extendedSegment) == 0 {
72+ wav, err := generateSegment(s.game.answer.Path, s.game.startSec, extendedPlay)
73+ if err != nil {
74+ http.Error(w, "audio generation failed", http.StatusInternalServerError)
75+ return
76+ }
77+ s.game.extendedSegment = wav
78+ }
79+ seg := s.game.extendedSegment
80+ w.Header().Set("Content-Type", "audio/wav")
81+ w.Header().Set("Cache-Control", "no-store")
82+ w.Header().Set("Content-Length", strconv.Itoa(len(seg)))
83+ if _, err := w.Write(seg); err != nil {
84+ return
85+ }
86+}
87+
88 // renderPartial writes the "game" partial (HUD + current state) to w. Every
89 // htmx response uses this so the whole game area refreshes consistently.
90 func renderPartial(w http.ResponseWriter, tpl *template.Template, data viewData) {
91diff --git a/web/static/style.css b/web/static/style.css
92index 8bea289f52973a99e8fd36e15deef703990a3ecd..cc82306c0d5a1b335ed68ea4731144a293c56332 100644
93--- a/web/static/style.css
94+++ b/web/static/style.css
95@@ -209,3 +209,15 @@ button:active { transform: translateY(1px); }
96 from { opacity: 0; transform: translateY(5px); }
97 to { opacity: 1; transform: none; }
98 }
99+
100+/* Secondary audio-control buttons */
101+.audio-controls { display: flex; gap: 10px; margin-bottom: 16px; }
102+.audio-controls button {
103+ background: transparent;
104+ color: var(--accent);
105+ border: 1px solid var(--accent);
106+ font-size: 0.85rem;
107+ font-weight: 600;
108+ padding: 7px 14px;
109+}
110+.audio-controls button:hover { background: rgba(224, 164, 88, 0.12); }
111diff --git a/web/templates/layout.html b/web/templates/layout.html
112index 55934db19db2ad51cb3e3baaed10bf99c40cc616..b48a6a415d8d7ccee809484f45e7d66b30009e30 100644
113--- a/web/templates/layout.html
114+++ b/web/templates/layout.html
115@@ -7,6 +7,23 @@
116 <title>Muse Guessr</title>
117 <link rel="stylesheet" href="/static/style.css">
118 <script src="/static/htmx.min.js"></script>
119+ <script>
120+ function replayClip() {
121+ var a = document.getElementById('clip');
122+ if (!a) return;
123+ a.pause();
124+ a.currentTime = 0;
125+ a.play().catch(function () {});
126+ }
127+ function longerClip() {
128+ var a = document.getElementById('clip');
129+ if (!a) return;
130+ a.pause();
131+ a.src = '/audio/long';
132+ a.load();
133+ a.play().catch(function () {});
134+ }
135+ </script>
136 </head>
137 <body>
138 <main class="game" id="game">
139diff --git a/web/templates/playing.html b/web/templates/playing.html
140index 566d8fc88c10b7140fc662749bcd9e62aa81b8ae..8c417cfc07851f8bc33bc32831e0544c33b0835b 100644
141--- a/web/templates/playing.html
142+++ b/web/templates/playing.html
143@@ -11,6 +11,10 @@
144 if (t) { t.focus(); }
145 })();
146 </script>
147+<div class="audio-controls">
148+ <button type="button" onclick="replayClip()">Replay</button>
149+ <button type="button" onclick="longerClip()" title="Hear a longer 4s clip — counts as a hint and halves this round's points">Extended clip</button>
150+</div>
151 <form class="guess" hx-post="/guess" hx-target="#game" hx-swap="innerHTML">
152 <div class="field">
153 <label for="g-title">Title</label>
154diff --git a/web/templates/result.html b/web/templates/result.html
155index 4bad4e8fd8d1d9c87fded25bb81fa2a5a376af18..80fb5ba4ea768c2719546476c32f62c1f2f2efcb 100644
156--- a/web/templates/result.html
157+++ b/web/templates/result.html
158@@ -14,7 +14,7 @@
159 <strong>{{.Answer.Title}}</strong> — {{.Answer.Artist}}
160 {{if .Answer.Album}}<span class="album">{{.Answer.Album}}</span>{{end}}
161 </div>
162- {{if .Result.Hinted}}<div class="hinted">Hint used</div>{{end}}
163+ {{if .Result.Hinted}}<div class="hinted">Hint used · points halved</div>{{end}}
164 </div>
165 {{if .Done}}
166 <button hx-post="/restart" hx-target="#game" hx-swap="innerHTML">Play again</button>