Diff
1diff --git a/README.md b/README.md
2index ac062bdc50b67ef4c5b742c26570fbd90f6a60e7..752bb35082405889b625a62caaed0e337664ee98 100644
3--- a/README.md
4+++ b/README.md
5@@ -50,6 +50,27 @@ museguessr -dir /path/to/music [-blacklist blacklist.txt] [-rounds 10]
6 | `-blacklist` | `blacklist` | File with one directory per line; every file under it is excluded. |
7 | `-rounds` | `10` | Number of songs to play. `0` means one per song in the catalog. |
8
9+## Web UI
10+
11+Run the game as a web app instead of the TUI with `-web`:
12+
13+```sh
14+museguessr -dir /path/to/music -web [-port 8080]
15+```
16+
17+This starts an HTTP server (default port `8080`) that serves a single-page
18+interface built with [Go templates](https://pkg.go.dev/html/template) and
19+[htmx](https://htmx.org). Audio clips are generated on the fly with `ffmpeg`
20+and streamed to the browser as WAV, so no local audio playback is required —
21+just `ffmpeg` and `ffprobe` on `PATH`. Open <http://localhost:8080> in a
22+browser. Type your guess in the three fields and submit; use **Next song** to
23+advance and **Play again** to restart.
24+
25+| Flag | Default | Description |
26+| ------- | ------- | ---------------------------------- |
27+| `-web` | `false` | Serve a web UI instead of the TUI. |
28+| `-port` | `8080` | Port for the web server. |
29+
30 ### Blacklist file
31
32 One **directory** per line — every file under that directory is excluded.
33diff --git a/server.go b/server.go
34index 33023444c5d91f53b7284dcaaa5ee37645a97533..4e092daed5584465c1206ac0ec0995f90b227c9b 100644
35--- a/server.go
36+++ b/server.go
37@@ -166,6 +166,7 @@ type viewData struct {
38 MaxScore int
39 Round int
40 TotalRounds int
41+ Dots []bool
42 Answer Song
43 Hinted bool
44 Done bool
45@@ -177,11 +178,16 @@ func (g *Game) viewData() viewData {
46 if round > g.rounds {
47 round = g.rounds
48 }
49+ dots := make([]bool, g.rounds)
50+ for i := 0; i < g.played && i < g.rounds; i++ {
51+ dots[i] = true
52+ }
53 return viewData{
54 Score: g.score,
55 MaxScore: g.rounds * pointsPerRound,
56 Round: round,
57 TotalRounds: g.rounds,
58+ Dots: dots,
59 Answer: g.answer,
60 Hinted: g.hinted,
61 Done: g.done,
62diff --git a/web/static/style.css b/web/static/style.css
63index c6b52ecb7fb2e9fae26767594f6d8162e0d6a626..8bea289f52973a99e8fd36e15deef703990a3ecd 100644
64--- a/web/static/style.css
65+++ b/web/static/style.css
66@@ -168,3 +168,44 @@ button:active { transform: translateY(1px); }
67 line-height: 1;
68 }
69 .final-sub { color: var(--dim); margin: 0; }
70+
71+/* Round progress dots */
72+.progress { display: flex; gap: 6px; margin-bottom: 18px; }
73+.dot {
74+ width: 9px;
75+ height: 9px;
76+ border-radius: 50%;
77+ background: var(--line);
78+ transition: background 0.2s, box-shadow 0.2s;
79+}
80+.dot.done { background: var(--accent); box-shadow: 0 0 6px rgba(224, 164, 88, 0.5); }
81+
82+/* Now-playing equalizer */
83+.now-playing {
84+ display: flex;
85+ align-items: center;
86+ gap: 8px;
87+ color: var(--dim);
88+ font-size: 0.85rem;
89+ margin-bottom: 14px;
90+}
91+.eq { display: inline-flex; align-items: flex-end; gap: 2px; height: 14px; }
92+.eq i {
93+ width: 3px;
94+ background: var(--accent);
95+ border-radius: 2px;
96+ animation: eq 0.9s ease-in-out infinite;
97+}
98+.eq i:nth-child(2) { animation-delay: 0.25s; }
99+.eq i:nth-child(3) { animation-delay: 0.5s; }
100+@keyframes eq {
101+ 0%, 100% { height: 4px; }
102+ 50% { height: 14px; }
103+}
104+
105+/* Smooth htmx state swaps */
106+#game .htmx-added { animation: fadeIn 0.28s ease; }
107+@keyframes fadeIn {
108+ from { opacity: 0; transform: translateY(5px); }
109+ to { opacity: 1; transform: none; }
110+}
111diff --git a/web/templates/game.html b/web/templates/game.html
112index 642bb80a3f80a926a77787fc9d5350259319d107..f35176be78ce9b30be61b359c19b3ac25078d566 100644
113--- a/web/templates/game.html
114+++ b/web/templates/game.html
115@@ -4,6 +4,9 @@
116 <div class="score">Score: {{.Score}}/{{.MaxScore}}</div>
117 </header>
118 <p class="round">Round {{.Round}} of {{.TotalRounds}}</p>
119+<div class="progress">
120+ {{range .Dots}}<span class="dot {{if .}}done{{end}}"></span>{{end}}
121+</div>
122
123 {{if .Done}}{{template "gameover" .}}{{else if .Result}}{{template "result" .}}{{else}}{{template "playing" .}}{{end}}
124 {{end}}
125diff --git a/web/templates/playing.html b/web/templates/playing.html
126index 9466a0807baeb2cd4e6e26280731902c4ce118e5..566d8fc88c10b7140fc662749bcd9e62aa81b8ae 100644
127--- a/web/templates/playing.html
128+++ b/web/templates/playing.html
129@@ -1,4 +1,7 @@
130 {{define "playing"}}
131+<div class="now-playing">
132+ <span class="eq"><i></i><i></i><i></i></span> Now playing
133+</div>
134 <audio id="clip" src="/audio" autoplay></audio>
135 <script>
136 (function () {