kryten-webqueue 0.25.0__tar.gz → 0.26.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/CHANGELOG.md +9 -0
  2. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/queue/race_poller.py +31 -2
  4. kryten_webqueue-0.26.0/kryten_webqueue/templates/race.html +395 -0
  5. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/pyproject.toml +1 -1
  6. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_race_poller.py +61 -0
  7. kryten_webqueue-0.25.0/kryten_webqueue/templates/race.html +0 -223
  8. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/.github/workflows/python-publish.yml +0 -0
  9. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/.github/workflows/release.yml +0 -0
  10. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/.gitignore +0 -0
  11. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/README.md +0 -0
  12. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/config.example.json +0 -0
  13. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/deploy/kryten-webqueue.service +0 -0
  14. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/deploy/nginx-queue.conf +0 -0
  15. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  16. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/docs/IMPL_API_GATE.md +0 -0
  17. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/docs/IMPL_ECONOMY.md +0 -0
  18. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  19. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/docs/IMPL_ROBOT.md +0 -0
  20. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  21. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/docs/PRE_PLAN_GAPS.md +0 -0
  22. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/docs/PRODUCT_PLAN.md +0 -0
  23. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  24. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/docs/UX_POLISH_PLAN.md +0 -0
  25. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/__init__.py +0 -0
  26. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/__main__.py +0 -0
  27. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  28. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/api_gate/client.py +0 -0
  29. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/app.py +0 -0
  30. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/auth/__init__.py +0 -0
  31. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/auth/otp.py +0 -0
  32. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  33. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/auth/session.py +0 -0
  34. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/catalog/__init__.py +0 -0
  35. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/catalog/db.py +0 -0
  36. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/catalog/images.py +0 -0
  37. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  38. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/catalog/sync.py +0 -0
  39. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/config.py +0 -0
  40. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/__init__.py +0 -0
  41. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  42. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  43. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  44. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  45. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  46. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  47. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  48. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  49. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/jobs/__init__.py +0 -0
  50. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  51. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/jobs/manager.py +0 -0
  52. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/jobs/tasks.py +0 -0
  53. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/logging_config.py +0 -0
  54. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/playlists/__init__.py +0 -0
  55. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  56. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/playlists/fire.py +0 -0
  57. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/playlists/importer.py +0 -0
  58. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/playlists/ordering.py +0 -0
  59. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  60. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/promos/__init__.py +0 -0
  61. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/promos/director.py +0 -0
  62. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/queue/__init__.py +0 -0
  63. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/queue/ordering.py +0 -0
  64. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/queue/poller.py +0 -0
  65. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/queue/presence.py +0 -0
  66. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/queue/shadow.py +0 -0
  67. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/__init__.py +0 -0
  68. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  69. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  70. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  71. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  72. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  73. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  74. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/auth.py +0 -0
  75. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/catalog.py +0 -0
  76. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/pages.py +0 -0
  77. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/queue.py +0 -0
  78. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/user.py +0 -0
  79. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/static/css/main.css +0 -0
  80. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/static/js/main.js +0 -0
  81. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/index.html +0 -0
  82. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  83. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  84. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  85. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  86. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/auth/login.html +0 -0
  87. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/base.html +0 -0
  88. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  89. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  90. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  91. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/queue/index.html +0 -0
  92. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  93. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/ws/__init__.py +0 -0
  94. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/ws/handler.py +0 -0
  95. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/kryten_webqueue/ws/manager.py +0 -0
  96. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/__init__.py +0 -0
  97. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_config_persistence.py +0 -0
  98. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_fetchurls_sharepoint.py +0 -0
  99. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_phase1.py +0 -0
  100. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_phase2_jobs.py +0 -0
  101. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_phase3_jobs.py +0 -0
  102. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_phase4_live_fixes.py +0 -0
  103. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_playlist_import.py +0 -0
  104. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_presence_refund.py +0 -0
  105. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_promo_director.py +0 -0
  106. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_promo_pool_exclusion.py +0 -0
  107. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_queue_announce.py +0 -0
  108. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_save_results_to_playlist.py +0 -0
  109. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_schedule_lock.py +0 -0
  110. {kryten_webqueue-0.25.0 → kryten_webqueue-0.26.0}/tests/test_search_facets.py +0 -0
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.26.0] — 2026-06-22
9
+
10
+ ### Changed
11
+
12
+ - **Race view is now smooth, with cars, drivers, and live commentary.** The economy now ships the whole race as a precomputed timeline (economy 0.13.0), so the `/race` view animates it client-side with `requestAnimationFrame` + interpolation — cars glide continuously and re-sync to the server clock instead of jumping between polls. Visual overhaul: each racer is a little CSS race **car** (cabin + wheels) that drives down a striped track from a colour start-cap to a checkered finish, with the **driver's name** (e.g. *Manuel Transmission*) under its colour, a live **position** column, and bet totals. A **commentary banner** above the field shows play-by-play (start, driver-named lead changes, close-finish, winner call) with the newest line replacing the last. Now shows all **6** cars.
13
+ - **Race WebSocket bandwidth cut.** The heavy position timeline is broadcast only once per race; subsequent frames carry just the server `elapsed` re-sync hint (the browser self-animates), so per-tick traffic stays tiny even with many spectators. Late-joiners still receive the full timeline on connect.
14
+
15
+ [0.26.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.26.0
16
+
8
17
  ## [0.25.0] — 2026-06-22
9
18
 
10
19
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.25.0
3
+ Version: 0.26.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -31,6 +31,9 @@ class RacePoller:
31
31
  self._idle_interval = idle_interval
32
32
  self._task: asyncio.Task | None = None
33
33
  self._last_frame: dict | None = None
34
+ # race_id whose heavy timeline has already been broadcast; subsequent
35
+ # frames for the same race are sent "light" (timeline body stripped).
36
+ self._bcast_race_id: str | None = None
34
37
 
35
38
  @property
36
39
  def last_frame(self) -> dict | None:
@@ -67,18 +70,44 @@ class RacePoller:
67
70
 
68
71
  active = bool(result.get("active")) and result.get("frame") is not None
69
72
  if active:
70
- self._last_frame = result["frame"]
73
+ frame = result["frame"]
74
+ self._last_frame = frame
71
75
  await self._ws_manager.broadcast(
72
- {"type": "race_frame", "data": self._last_frame}
76
+ {"type": "race_frame", "data": self._strip_repeat_timeline(frame)}
73
77
  )
74
78
  return self._active_interval
75
79
 
76
80
  # Not active. If we were showing a race, tell spectators to clear once.
77
81
  if self._last_frame is not None:
78
82
  self._last_frame = None
83
+ self._bcast_race_id = None
79
84
  await self._ws_manager.broadcast({"type": "race_clear"})
80
85
  return self._idle_interval
81
86
 
87
+ def _strip_repeat_timeline(self, frame: dict) -> dict:
88
+ """Drop the heavy timeline body after its first broadcast per race.
89
+
90
+ The browser caches the timeline and self-animates, so resending ~100
91
+ position frames every poll is pure waste (especially with many
92
+ spectators). The first racing frame for a race carries the full
93
+ timeline; later frames keep only ``elapsed`` (a tiny re-sync hint).
94
+ Betting/finished frames and new-race frames always pass through whole.
95
+ """
96
+ rid = frame.get("race_id")
97
+ tl = frame.get("timeline")
98
+ if tl is None:
99
+ # No timeline (betting/finished) — pass through, reset on new race.
100
+ if frame.get("phase") != "racing":
101
+ self._bcast_race_id = None if frame.get("phase") == "finished" else self._bcast_race_id
102
+ return frame
103
+ if rid == self._bcast_race_id:
104
+ light = dict(frame)
105
+ light["timeline"] = {"elapsed": tl.get("elapsed", 0)}
106
+ return light
107
+ # First racing frame for this race — send full timeline, then mark sent.
108
+ self._bcast_race_id = rid
109
+ return frame
110
+
82
111
  async def _loop(self):
83
112
  while True:
84
113
  try:
@@ -0,0 +1,395 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Live Race{% endblock %}
3
+ {% block body_class %}race-page{% endblock %}
4
+
5
+ {% block head %}
6
+ <style>
7
+ .race-wrap { max-width: 960px; margin: 0 auto; }
8
+ .race-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
9
+ .race-head h1 { font-family: 'Sora', sans-serif; margin: 0; }
10
+ .race-status { font-weight: 600; opacity: 0.9; }
11
+ .race-status .pill { padding: 0.18rem 0.7rem; border-radius: 999px; font-size: 0.82rem; }
12
+ .pill-betting { background: #1f6feb33; color: #79c0ff; }
13
+ .pill-racing { background: #2ea04333; color: #7ee787; }
14
+ .pill-finished{ background: #d2992233; color: #f0c674; }
15
+ .pill-idle { background: #6e768166; color: #c9d1d9; }
16
+
17
+ /* Commentary feed — newest line, fades on change. */
18
+ .race-commentary {
19
+ margin: 0.9rem 0 0.4rem; min-height: 1.9rem; display: flex; align-items: center;
20
+ gap: 0.5rem; padding: 0.5rem 0.85rem; border-radius: 10px;
21
+ background: linear-gradient(90deg, #161b22, #0d1117);
22
+ border: 1px solid #232a33; font-size: 1.02rem; font-weight: 500;
23
+ }
24
+ .race-commentary .mic { opacity: 0.7; }
25
+ .race-commentary .txt { transition: opacity 0.25s ease; }
26
+ .race-commentary .txt.swap { opacity: 0; }
27
+
28
+ .race-track { margin: 0.6rem 0 1rem; display: flex; flex-direction: column; gap: 0.5rem; }
29
+ .lane {
30
+ position: relative; display: grid; grid-template-columns: 2.2rem 12.5rem 1fr auto;
31
+ align-items: center; gap: 0.7rem; padding: 0.4rem 0.55rem; border-radius: 11px;
32
+ background: #11141a; border: 1px solid #232a33; transition: border-color 0.3s, box-shadow 0.3s;
33
+ }
34
+ .lane.win { border-color: #f0c674; box-shadow: 0 0 0 1px #f0c67455, 0 0 22px #f0c67433; }
35
+ .lane-pos {
36
+ font-weight: 800; text-align: center; font-variant-numeric: tabular-nums;
37
+ color: #c9d1d9; font-size: 0.95rem;
38
+ }
39
+ .lane-pos.p1 { color: #f0c674; }
40
+ .lane-label { display: flex; flex-direction: column; line-height: 1.15; min-width: 0; }
41
+ .lane-color { display: flex; align-items: center; gap: 0.4rem; font-weight: 700; font-size: 0.92rem; }
42
+ .lane-swatch { width: 0.8rem; height: 0.8rem; border-radius: 50%; flex: 0 0 auto; box-shadow: 0 0 0 1px #0006 inset; }
43
+ .lane-driver { font-size: 0.78rem; opacity: 0.72; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
44
+ .lane-meta { text-align: right; font-variant-numeric: tabular-nums; }
45
+ .lane-odds { font-weight: 700; font-size: 0.9rem; }
46
+ .lane-amt { font-size: 0.74rem; opacity: 0.72; }
47
+
48
+ /* Track rail with start/finish color markers and a moving car. */
49
+ .lane-rail {
50
+ position: relative; height: 30px; border-radius: 8px;
51
+ background: repeating-linear-gradient(90deg, #0c0e12 0 18px, #0e1117 18px 36px);
52
+ border: 1px solid #1c222b; overflow: hidden;
53
+ }
54
+ .rail-cap { position: absolute; top: 0; bottom: 0; width: 5px; opacity: 0.85; }
55
+ .rail-cap.start { left: 0; border-radius: 8px 0 0 8px; }
56
+ .rail-cap.finish {
57
+ right: 0; border-radius: 0 8px 8px 0;
58
+ background-image: repeating-linear-gradient(45deg, #fff 0 4px, #000 4px 8px) !important;
59
+ opacity: 0.55; width: 8px;
60
+ }
61
+ .car {
62
+ position: absolute; top: 50%; left: 0; transform: translate(-50%, -50%);
63
+ width: 30px; height: 15px; border-radius: 5px 7px 4px 4px;
64
+ box-shadow: 0 1px 3px #000a, inset 0 -3px 4px #0004; will-change: left;
65
+ }
66
+ .car::before { /* cabin */
67
+ content: ''; position: absolute; left: 6px; top: -4px; width: 12px; height: 7px;
68
+ border-radius: 4px 4px 0 0; background: #ffffffcc;
69
+ }
70
+ .car::after { /* wheels */
71
+ content: ''; position: absolute; left: 3px; right: 3px; bottom: -3px; height: 5px;
72
+ background:
73
+ radial-gradient(circle at 5px 50%, #111 0 3px, transparent 3px),
74
+ radial-gradient(circle at calc(100% - 5px) 50%, #111 0 3px, transparent 3px);
75
+ }
76
+
77
+ .race-side { display: flex; gap: 1.6rem; flex-wrap: wrap; font-variant-numeric: tabular-nums; opacity: 0.92; }
78
+ .race-side .stat b { font-size: 1.12rem; }
79
+ .race-winner { margin: 1rem 0; padding: 0.8rem 1.05rem; border-radius: 11px;
80
+ background: #d2992218; border: 1px solid #f0c67455; }
81
+ .race-winner h2 { margin: 0 0 0.4rem; font-family: 'Sora', sans-serif; }
82
+ .race-payouts { margin: 0.3rem 0 0; padding-left: 1.15rem; }
83
+ .race-payouts li { margin: 0.1rem 0; }
84
+ .race-idle { text-align: center; opacity: 0.7; padding: 3rem 1rem; }
85
+
86
+ @media (max-width: 640px) {
87
+ .lane { grid-template-columns: 1.7rem 1fr auto; }
88
+ .lane-rail { grid-column: 1 / -1; order: 5; }
89
+ .lane-driver { white-space: normal; }
90
+ }
91
+ </style>
92
+ {% endblock %}
93
+
94
+ {% block content %}
95
+ <div class="race-wrap">
96
+ <div class="race-head">
97
+ <h1>🏁 Live Race</h1>
98
+ <div class="race-status" id="race-status"><span class="pill pill-idle">Connecting…</span></div>
99
+ </div>
100
+
101
+ <div class="race-commentary" id="race-commentary" hidden>
102
+ <span class="mic">📣</span><span class="txt" id="commentary-txt"></span>
103
+ </div>
104
+
105
+ <div id="race-body">
106
+ <div class="race-idle" id="race-idle">Waiting for the next race…</div>
107
+ <div class="race-track" id="race-track" hidden></div>
108
+ <div class="race-side" id="race-side" hidden></div>
109
+ <div class="race-winner" id="race-winner" hidden></div>
110
+ </div>
111
+ </div>
112
+ {% endblock %}
113
+
114
+ {% block scripts %}
115
+ <script>
116
+ (function () {
117
+ const COLORS = {
118
+ Blue: '#3b82f6', Red: '#ef4444', Green: '#22c55e',
119
+ Yellow: '#eab308', Purple: '#a855f7', Orange: '#f97316',
120
+ };
121
+ const colorOf = (c) => COLORS[c] || '#9aa4af';
122
+
123
+ const statusEl = document.getElementById('race-status');
124
+ const idleEl = document.getElementById('race-idle');
125
+ const trackEl = document.getElementById('race-track');
126
+ const sideEl = document.getElementById('race-side');
127
+ const winnerEl = document.getElementById('race-winner');
128
+ const commEl = document.getElementById('race-commentary');
129
+ const commTxt = document.getElementById('commentary-txt');
130
+
131
+ const lanes = new Map(); // color -> { row, car, pos, amt }
132
+ let laneSig = '';
133
+ let raceId = null;
134
+
135
+ // playback state
136
+ let timeline = null; // {frame_dt, duration, frames, commentary, elapsed}
137
+ let currentColors = [];
138
+ let playbackStart = 0; // performance.now() baseline
139
+ let rafId = 0;
140
+ let commentaryShown = -1;
141
+
142
+ function esc(s) {
143
+ return String(s).replace(/[&<>"']/g, c => (
144
+ { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]
145
+ ));
146
+ }
147
+ function setStatus(kind, text) {
148
+ statusEl.innerHTML = '<span class="pill pill-' + kind + '">' + esc(text) + '</span>';
149
+ }
150
+ function setCommentary(text) {
151
+ if (!text) return;
152
+ commEl.hidden = false;
153
+ if (commTxt.textContent === text) return;
154
+ commTxt.classList.add('swap');
155
+ setTimeout(() => { commTxt.textContent = text; commTxt.classList.remove('swap'); }, 130);
156
+ }
157
+
158
+ function buildLanes(racers) {
159
+ trackEl.innerHTML = '';
160
+ lanes.clear();
161
+ for (const r of racers) {
162
+ const col = colorOf(r.color);
163
+ const row = document.createElement('div');
164
+ row.className = 'lane';
165
+ row.innerHTML =
166
+ '<div class="lane-pos"></div>' +
167
+ '<div class="lane-label">' +
168
+ '<span class="lane-color"><span class="lane-swatch" style="background:' + col + '"></span>' + esc(r.color) + '</span>' +
169
+ '<span class="lane-driver">' + esc(r.name || '') + '</span>' +
170
+ '</div>' +
171
+ '<div class="lane-rail">' +
172
+ '<span class="rail-cap start" style="background:' + col + '"></span>' +
173
+ '<span class="rail-cap finish"></span>' +
174
+ '<span class="car" style="background:' + col + '"></span>' +
175
+ '</div>' +
176
+ '<div class="lane-meta">' +
177
+ '<div class="lane-odds">' + esc(r.odds || '') + '</div>' +
178
+ '<div class="lane-amt"></div>' +
179
+ '</div>';
180
+ trackEl.appendChild(row);
181
+ lanes.set(r.color, {
182
+ row,
183
+ car: row.querySelector('.car'),
184
+ pos: row.querySelector('.lane-pos'),
185
+ amt: row.querySelector('.lane-amt'),
186
+ });
187
+ }
188
+ }
189
+
190
+ // Place a car along its rail (pct 0..100 → left 3%..97% so it stays on track).
191
+ function placeCar(color, pct) {
192
+ const lane = lanes.get(color);
193
+ if (!lane) return;
194
+ const left = 3 + Math.max(0, Math.min(100, pct)) * 0.94;
195
+ lane.car.style.left = left + '%';
196
+ }
197
+
198
+ function updateRanking(colors, values) {
199
+ const order = colors
200
+ .map((c, i) => [c, values[i]])
201
+ .sort((a, b) => b[1] - a[1]);
202
+ order.forEach(([c], idx) => {
203
+ const lane = lanes.get(c);
204
+ if (!lane) return;
205
+ lane.pos.textContent = '#' + (idx + 1);
206
+ lane.pos.classList.toggle('p1', idx === 0);
207
+ });
208
+ }
209
+
210
+ function applyBets(frame) {
211
+ const bets = frame.bets_by_color || {};
212
+ for (const [color, lane] of lanes) {
213
+ const b = bets[color];
214
+ lane.amt.textContent = b ? ('💰 ' + b.amount.toLocaleString()) : '';
215
+ }
216
+ }
217
+
218
+ function sideStats(frame) {
219
+ const sym = frame.symbol || '';
220
+ sideEl.hidden = false;
221
+ sideEl.innerHTML =
222
+ '<div class="stat">Pool <b>' + (frame.pool || 0).toLocaleString() + ' ' + esc(sym) + '</b></div>' +
223
+ '<div class="stat">Bettors <b>' + (frame.bettor_count || 0) + '</b></div>' +
224
+ (frame.phase === 'betting'
225
+ ? '<div class="stat">Min bet <b>' + (frame.min_bet || 0).toLocaleString() + ' ' + esc(sym) + '</b></div>'
226
+ : '');
227
+ }
228
+
229
+ // ── Timeline playback (smooth, rAF + interpolation) ────────
230
+ function posAt(t) {
231
+ const { frame_dt, frames } = timeline;
232
+ const pos = t / frame_dt;
233
+ let i = Math.floor(pos);
234
+ if (i >= frames.length - 1) return frames[frames.length - 1].slice();
235
+ const frac = pos - i;
236
+ const a = frames[i], b = frames[i + 1];
237
+ return a.map((v, j) => v + (b[j] - v) * frac);
238
+ }
239
+
240
+ function showCommentaryUpTo(t) {
241
+ if (!timeline.commentary) return;
242
+ let idx = commentaryShown;
243
+ for (let k = commentaryShown + 1; k < timeline.commentary.length; k++) {
244
+ if (timeline.commentary[k].t <= t) idx = k; else break;
245
+ }
246
+ if (idx !== commentaryShown) {
247
+ commentaryShown = idx;
248
+ setCommentary(timeline.commentary[idx].text);
249
+ }
250
+ }
251
+
252
+ function frameTick(now) {
253
+ if (!timeline) return;
254
+ let t = (now - playbackStart) / 1000;
255
+ if (t < 0) t = 0;
256
+ const done = t >= timeline.duration;
257
+ if (done) t = timeline.duration;
258
+
259
+ const row = posAt(t);
260
+ currentColors.forEach((c, j) => placeCar(c, row[j]));
261
+ updateRanking(currentColors, row);
262
+ showCommentaryUpTo(t);
263
+
264
+ if (!done) rafId = requestAnimationFrame(frameTick);
265
+ }
266
+
267
+ function startPlayback(frame) {
268
+ timeline = frame.timeline;
269
+ currentColors = frame.racers.map(r => r.color);
270
+ commentaryShown = -1;
271
+ const elapsed = Math.max(0, timeline.elapsed || 0);
272
+ playbackStart = performance.now() - elapsed * 1000;
273
+ cancelAnimationFrame(rafId);
274
+ rafId = requestAnimationFrame(frameTick);
275
+ }
276
+
277
+ function resync(frame) {
278
+ // Gently correct drift (e.g. after a backgrounded tab) without jumping.
279
+ const elapsed = Math.max(0, frame.timeline.elapsed || 0);
280
+ const localT = (performance.now() - playbackStart) / 1000;
281
+ if (Math.abs(localT - elapsed) > 1.2) {
282
+ playbackStart = performance.now() - elapsed * 1000;
283
+ }
284
+ timeline.commentary = frame.timeline.commentary || timeline.commentary;
285
+ }
286
+
287
+ // ── Frame router ───────────────────────────────────────────
288
+ function render(frame) {
289
+ idleEl.hidden = true;
290
+ trackEl.hidden = false;
291
+
292
+ const sig = frame.racers.map(r => r.color).join('|');
293
+ const isNewRace = frame.race_id !== raceId || sig !== laneSig;
294
+ if (isNewRace) {
295
+ buildLanes(frame.racers);
296
+ laneSig = sig;
297
+ raceId = frame.race_id;
298
+ timeline = null;
299
+ cancelAnimationFrame(rafId);
300
+ }
301
+ applyBets(frame);
302
+ sideStats(frame);
303
+
304
+ if (frame.phase === 'betting') {
305
+ cancelAnimationFrame(rafId); timeline = null;
306
+ frame.racers.forEach(r => placeCar(r.color, 0));
307
+ updateRanking(frame.racers.map(r => r.color), frame.racers.map(() => 0));
308
+ winnerEl.hidden = true;
309
+ setStatus('betting', 'Betting open — ' + (frame.betting_closes_in || 0) + 's left');
310
+ setCommentary('Place your bets! ' + frame.racers.length + ' cars on the grid.');
311
+ } else if (frame.phase === 'racing') {
312
+ winnerEl.hidden = true;
313
+ setStatus('racing', 'They\u2019re off!');
314
+ if (frame.timeline) {
315
+ const hasFrames = Array.isArray(frame.timeline.frames) && frame.timeline.frames.length;
316
+ if (!timeline && hasFrames) startPlayback(frame);
317
+ else if (timeline) resync(frame);
318
+ // else: a light sync arrived before the full timeline — wait for it.
319
+ }
320
+ } else if (frame.phase === 'finished') {
321
+ cancelAnimationFrame(rafId); timeline = null;
322
+ showFinished(frame);
323
+ }
324
+ }
325
+
326
+ function showFinished(frame) {
327
+ setStatus('finished', 'Finished');
328
+ const winnerColor = frame.winner && frame.winner.color;
329
+ frame.racers.forEach(r => placeCar(r.color, r.pct || 0));
330
+ updateRanking(frame.racers.map(r => r.color), frame.racers.map(r => r.pct || 0));
331
+ for (const [c, lane] of lanes) lane.row.classList.toggle('win', c === winnerColor);
332
+
333
+ const sym = frame.symbol || '';
334
+ if (frame.winner) {
335
+ const w = frame.winner;
336
+ const driver = w.name ? (' — ' + esc(w.name)) : '';
337
+ const payouts = (frame.payouts || []).slice(0, 6)
338
+ .map(p => '<li>' + esc(p.username) + ' <b>+' + (p.net || 0).toLocaleString() + ' ' + esc(sym) + '</b></li>')
339
+ .join('');
340
+ winnerEl.hidden = false;
341
+ winnerEl.innerHTML =
342
+ '<h2><span class="lane-swatch" style="display:inline-block;vertical-align:middle;background:' + colorOf(w.color) + '"></span> ' +
343
+ esc(w.color) + ' wins!' + driver + '</h2>' +
344
+ (payouts ? '<ul class="race-payouts">' + payouts + '</ul>' : '<div>No winning bets this race.</div>');
345
+ setCommentary('🏆 ' + esc(w.color) + (w.name ? ' (' + esc(w.name) + ')' : '') + ' takes the checkered flag!');
346
+ }
347
+ }
348
+
349
+ function showIdle() {
350
+ laneSig = ''; raceId = null; timeline = null;
351
+ cancelAnimationFrame(rafId);
352
+ lanes.clear();
353
+ trackEl.hidden = true; sideEl.hidden = true; winnerEl.hidden = true;
354
+ commEl.hidden = true;
355
+ idleEl.hidden = false;
356
+ setStatus('idle', 'No race running');
357
+ }
358
+
359
+ // ── WebSocket with auto-reconnect ──────────────────────────
360
+ let ws = null, retry = 0, pingTimer = null;
361
+ function connect() {
362
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
363
+ ws = new WebSocket(proto + '://' + location.host + '/ws/race');
364
+ ws.onopen = () => {
365
+ retry = 0; setStatus('idle', 'Connected');
366
+ clearInterval(pingTimer);
367
+ pingTimer = setInterval(() => { try { ws.send(JSON.stringify({ type: 'ping' })); } catch (e) {} }, 25000);
368
+ };
369
+ ws.onmessage = (ev) => {
370
+ let m; try { m = JSON.parse(ev.data); } catch (e) { return; }
371
+ if (m.type === 'race_frame') render(m.data);
372
+ else if (m.type === 'race_clear') showIdle();
373
+ };
374
+ ws.onclose = () => {
375
+ clearInterval(pingTimer);
376
+ setStatus('idle', 'Reconnecting…');
377
+ retry = Math.min(retry + 1, 6);
378
+ setTimeout(connect, 500 * retry);
379
+ };
380
+ ws.onerror = () => { try { ws.close(); } catch (e) {} };
381
+ }
382
+
383
+ // Re-sync playback when returning to a backgrounded tab.
384
+ document.addEventListener('visibilitychange', () => {
385
+ if (!document.hidden && timeline) {
386
+ cancelAnimationFrame(rafId);
387
+ rafId = requestAnimationFrame(frameTick);
388
+ }
389
+ });
390
+
391
+ showIdle();
392
+ connect();
393
+ })();
394
+ </script>
395
+ {% endblock %}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.25.0"
3
+ version = "0.26.0"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -100,3 +100,64 @@ async def test_error_backs_off_without_clearing():
100
100
  # spectator still sees the race in progress.
101
101
  assert poller.last_frame == {"phase": "racing"}
102
102
  assert [m["type"] for m in ws.messages] == ["race_frame"]
103
+
104
+
105
+ def _racing_frame(race_id, elapsed, n_frames=3):
106
+ return {
107
+ "active": True,
108
+ "frame": {
109
+ "race_id": race_id,
110
+ "phase": "racing",
111
+ "timeline": {
112
+ "frame_dt": 0.3,
113
+ "duration": 1.0,
114
+ "elapsed": elapsed,
115
+ "frames": [[0.0]] * n_frames,
116
+ "commentary": [{"t": 0.0, "text": "go"}],
117
+ },
118
+ },
119
+ }
120
+
121
+
122
+ async def test_full_timeline_sent_once_then_stripped():
123
+ """First racing frame carries the full timeline; later frames for the same
124
+ race carry only ``elapsed`` (the browser self-animates)."""
125
+ ws = _FakeWs()
126
+ poller = RacePoller(
127
+ api_gate=_FakeApiGate([
128
+ _racing_frame("race-A", 0.0),
129
+ _racing_frame("race-A", 0.3),
130
+ _racing_frame("race-A", 0.6),
131
+ ]),
132
+ ws_manager=ws,
133
+ )
134
+ await poller._poll_once()
135
+ await poller._poll_once()
136
+ await poller._poll_once()
137
+
138
+ first = ws.messages[0]["data"]["timeline"]
139
+ assert "frames" in first and len(first["frames"]) == 3 # full
140
+
141
+ for later in ws.messages[1:]:
142
+ tl = later["data"]["timeline"]
143
+ assert tl == {"elapsed": tl["elapsed"]} # light: elapsed only
144
+ assert [m["data"]["timeline"]["elapsed"] for m in ws.messages] == [0.0, 0.3, 0.6]
145
+ # last_frame retains the FULL frame for late-joiner bootstrap.
146
+ assert "frames" in poller.last_frame["timeline"]
147
+
148
+
149
+ async def test_new_race_resends_full_timeline():
150
+ """A different race_id resets the strip state — full timeline again."""
151
+ ws = _FakeWs()
152
+ poller = RacePoller(
153
+ api_gate=_FakeApiGate([
154
+ _racing_frame("race-A", 0.0),
155
+ _racing_frame("race-B", 0.0),
156
+ ]),
157
+ ws_manager=ws,
158
+ )
159
+ await poller._poll_once()
160
+ await poller._poll_once()
161
+ assert "frames" in ws.messages[0]["data"]["timeline"]
162
+ assert "frames" in ws.messages[1]["data"]["timeline"]
163
+
@@ -1,223 +0,0 @@
1
- {% extends "base.html" %}
2
- {% block title %}Live Race{% endblock %}
3
- {% block body_class %}race-page{% endblock %}
4
-
5
- {% block head %}
6
- <style>
7
- .race-wrap { max-width: 900px; margin: 0 auto; }
8
- .race-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
9
- .race-head h1 { font-family: 'Sora', sans-serif; margin: 0; }
10
- .race-status { font-weight: 600; opacity: 0.85; }
11
- .race-status .pill { padding: 0.15rem 0.6rem; border-radius: 999px; font-size: 0.8rem; }
12
- .pill-betting { background: #1f6feb33; color: #79c0ff; }
13
- .pill-racing { background: #2ea04333; color: #7ee787; }
14
- .pill-finished{ background: #d2992233; color: #f0c674; }
15
- .pill-idle { background: #6e768166; color: #c9d1d9; }
16
-
17
- .race-track { margin: 1.25rem 0; display: flex; flex-direction: column; gap: 0.55rem; }
18
- .lane {
19
- position: relative; display: grid; grid-template-columns: 2.4rem 1fr auto;
20
- align-items: center; gap: 0.6rem; padding: 0.35rem 0.5rem; border-radius: 10px;
21
- background: #11141a; border: 1px solid #232a33;
22
- }
23
- .lane.win { border-color: #f0c674; box-shadow: 0 0 0 1px #f0c67455, 0 0 18px #f0c67433; }
24
- .lane-pos { font-weight: 700; text-align: center; opacity: 0.8; font-variant-numeric: tabular-nums; }
25
- .lane-rail { position: relative; height: 26px; background: #0c0e12; border-radius: 6px; overflow: hidden; }
26
- .lane-fill {
27
- position: absolute; inset: 0 auto 0 0; width: 0%;
28
- background: linear-gradient(90deg, #1f6feb44, #1f6feb22);
29
- transition: width 1.4s linear; border-right: 2px solid #ffffff22;
30
- }
31
- .lane-runner {
32
- position: absolute; top: 50%; left: 0; transform: translate(-50%, -50%);
33
- font-size: 1.15rem; transition: left 1.4s linear; will-change: left;
34
- text-shadow: 0 1px 2px #000a;
35
- }
36
- .lane-meta { display: flex; align-items: baseline; gap: 0.5rem; }
37
- .lane-name { font-weight: 600; }
38
- .lane-odds { font-size: 0.8rem; opacity: 0.7; font-variant-numeric: tabular-nums; }
39
- .lane-trait { font-size: 0.72rem; opacity: 0.6; }
40
- .lane-amt { font-size: 0.75rem; opacity: 0.7; font-variant-numeric: tabular-nums; }
41
-
42
- .race-side { display: flex; gap: 1.5rem; flex-wrap: wrap; font-variant-numeric: tabular-nums; }
43
- .race-side .stat b { font-size: 1.1rem; }
44
- .race-winner { margin: 1rem 0; padding: 0.75rem 1rem; border-radius: 10px;
45
- background: #d2992218; border: 1px solid #f0c67455; }
46
- .race-winner h2 { margin: 0 0 0.35rem; font-family: 'Sora', sans-serif; }
47
- .race-payouts { margin: 0.25rem 0 0; padding-left: 1.1rem; }
48
- .race-idle { text-align: center; opacity: 0.7; padding: 3rem 1rem; }
49
- .race-finish-flag { position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
50
- font-size: 0.95rem; opacity: 0.5; }
51
- </style>
52
- {% endblock %}
53
-
54
- {% block content %}
55
- <div class="race-wrap">
56
- <div class="race-head">
57
- <h1>🏁 Live Race</h1>
58
- <div class="race-status" id="race-status"><span class="pill pill-idle">Connecting…</span></div>
59
- </div>
60
-
61
- <div id="race-body">
62
- <div class="race-idle" id="race-idle">Waiting for the next race…</div>
63
- <div class="race-track" id="race-track" hidden></div>
64
- <div class="race-side" id="race-side" hidden></div>
65
- <div class="race-winner" id="race-winner" hidden></div>
66
- </div>
67
- </div>
68
- {% endblock %}
69
-
70
- {% block scripts %}
71
- <script>
72
- (function () {
73
- const statusEl = document.getElementById('race-status');
74
- const idleEl = document.getElementById('race-idle');
75
- const trackEl = document.getElementById('race-track');
76
- const sideEl = document.getElementById('race-side');
77
- const winnerEl = document.getElementById('race-winner');
78
-
79
- const lanes = new Map(); // color -> { row, fill, runner, pos, amt }
80
- let laneSig = ''; // signature of the current racer set (to detect changes)
81
-
82
- function esc(s) {
83
- return String(s).replace(/[&<>"']/g, c => (
84
- { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]
85
- ));
86
- }
87
-
88
- function setStatus(kind, text) {
89
- statusEl.innerHTML = '<span class="pill pill-' + kind + '">' + esc(text) + '</span>';
90
- }
91
-
92
- function buildLanes(racers) {
93
- trackEl.innerHTML = '';
94
- lanes.clear();
95
- for (const r of racers) {
96
- const row = document.createElement('div');
97
- row.className = 'lane';
98
- row.innerHTML =
99
- '<div class="lane-pos"></div>' +
100
- '<div class="lane-rail">' +
101
- '<div class="lane-fill"></div>' +
102
- '<span class="lane-runner">' + esc(r.emoji || '🏃') + '</span>' +
103
- '<span class="race-finish-flag">🏁</span>' +
104
- '</div>' +
105
- '<div class="lane-meta">' +
106
- '<span class="lane-name">' + esc(r.color) + '</span>' +
107
- '<span class="lane-odds">' + esc(r.odds || '') + '</span>' +
108
- (r.trait ? '<span class="lane-trait">' + esc(r.trait) + '</span>' : '') +
109
- '<span class="lane-amt"></span>' +
110
- '</div>';
111
- trackEl.appendChild(row);
112
- lanes.set(r.color, {
113
- row,
114
- fill: row.querySelector('.lane-fill'),
115
- runner: row.querySelector('.lane-runner'),
116
- pos: row.querySelector('.lane-pos'),
117
- amt: row.querySelector('.lane-amt'),
118
- });
119
- }
120
- }
121
-
122
- function render(frame) {
123
- idleEl.hidden = true;
124
- trackEl.hidden = false;
125
- sideEl.hidden = false;
126
-
127
- // Rebuild lanes only when the racer set changes (keeps CSS transitions smooth).
128
- const sig = frame.racers.map(r => r.color).join('|');
129
- if (sig !== laneSig) { buildLanes(frame.racers); laneSig = sig; }
130
-
131
- const finished = frame.phase === 'finished';
132
- const winnerColor = frame.winner && frame.winner.color;
133
- const betsByColor = frame.bets_by_color || {};
134
-
135
- for (const r of frame.racers) {
136
- const lane = lanes.get(r.color);
137
- if (!lane) continue;
138
- const pct = Math.max(0, Math.min(100, r.pct || 0));
139
- lane.fill.style.width = pct + '%';
140
- lane.runner.style.left = pct + '%';
141
- lane.pos.textContent = r.position ? ('#' + r.position) : '';
142
- const bet = betsByColor[r.color];
143
- lane.amt.textContent = bet ? ('💰 ' + bet.amount.toLocaleString()) : '';
144
- lane.row.classList.toggle('win', finished && r.color === winnerColor);
145
- }
146
-
147
- // Status line
148
- if (frame.phase === 'betting') {
149
- setStatus('betting', 'Betting open — ' + (frame.betting_closes_in || 0) + 's left');
150
- } else if (frame.phase === 'racing') {
151
- setStatus('racing', 'Racing! Leader: ' + (frame.leader || '—'));
152
- } else if (finished) {
153
- setStatus('finished', 'Finished');
154
- }
155
-
156
- // Side stats
157
- const sym = frame.symbol || '';
158
- sideEl.innerHTML =
159
- '<div class="stat">Pool <b>' + (frame.pool || 0).toLocaleString() + ' ' + esc(sym) + '</b></div>' +
160
- '<div class="stat">Bettors <b>' + (frame.bettor_count || 0) + '</b></div>' +
161
- (frame.phase === 'betting'
162
- ? '<div class="stat">Min bet <b>' + (frame.min_bet || 0).toLocaleString() + ' ' + esc(sym) + '</b></div>'
163
- : '');
164
-
165
- // Winner banner + payouts
166
- if (finished && frame.winner) {
167
- const payouts = (frame.payouts || []).slice(0, 5)
168
- .map(p => '<li>' + esc(p.username) + ' <b>+' + (p.net || 0).toLocaleString() + ' ' + esc(sym) + '</b></li>')
169
- .join('');
170
- winnerEl.hidden = false;
171
- winnerEl.innerHTML =
172
- '<h2>' + esc(frame.winner.emoji || '🏆') + ' ' + esc(frame.winner.color) + ' wins!</h2>' +
173
- (payouts ? '<ul class="race-payouts">' + payouts + '</ul>'
174
- : '<div>No winning bets this race.</div>');
175
- } else {
176
- winnerEl.hidden = true;
177
- }
178
- }
179
-
180
- function showIdle() {
181
- laneSig = '';
182
- lanes.clear();
183
- trackEl.hidden = true;
184
- sideEl.hidden = true;
185
- winnerEl.hidden = true;
186
- idleEl.hidden = false;
187
- setStatus('idle', 'No race running');
188
- }
189
-
190
- // ── WebSocket with auto-reconnect ──────────────────────────
191
- let ws = null, retry = 0, pingTimer = null;
192
-
193
- function connect() {
194
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
195
- ws = new WebSocket(proto + '://' + location.host + '/ws/race');
196
-
197
- ws.onopen = () => {
198
- retry = 0;
199
- setStatus('idle', 'Connected');
200
- clearInterval(pingTimer);
201
- pingTimer = setInterval(() => {
202
- try { ws.send(JSON.stringify({ type: 'ping' })); } catch (e) {}
203
- }, 25000);
204
- };
205
- ws.onmessage = (ev) => {
206
- let m; try { m = JSON.parse(ev.data); } catch (e) { return; }
207
- if (m.type === 'race_frame') render(m.data);
208
- else if (m.type === 'race_clear') showIdle();
209
- };
210
- ws.onclose = () => {
211
- clearInterval(pingTimer);
212
- setStatus('idle', 'Reconnecting…');
213
- retry = Math.min(retry + 1, 6);
214
- setTimeout(connect, 500 * retry);
215
- };
216
- ws.onerror = () => { try { ws.close(); } catch (e) {} };
217
- }
218
-
219
- showIdle();
220
- connect();
221
- })();
222
- </script>
223
- {% endblock %}