49a4bf0aae5eb3b6ca35c3871774c2c69550ab57

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add replay and extended-clip controls to web UI

Mirror the TUI's replay (Ctrl+R) and extended-hint (Ctrl+L) keys:
- Replay button re-plays the loaded clip client-side (no round-trip).
- Extended clip button fetches a 4s segment from the same start point via
  a new /audio/long endpoint. Requesting it marks the round as hinted,
  halving that round's points — matching the TUI's hint semantics.
- Result screen now reads 'Hint used · points halved'.

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> &mdash; {{.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>