Files
StarPilot/tools/replay/control_bar.py
T
2026-06-05 23:29:34 -04:00

280 lines
8.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Standalone replay media controls bar for StarPilot desktop replay.
Compact always-on-top window with probe-rod scrubber and transport buttons.
"""
import os
import json
import pyray as rl
# ── Config ──────────────────────────────────────
W, H = 460, 64
# Colours
BG = rl.Color(18, 18, 22, 245)
TRACK_COL = rl.Color(55, 55, 65, 255)
FILL_COL = rl.Color(0, 200, 130, 255)
KNOB_COL = rl.WHITE
BTN_HOV = rl.Color(255, 255, 255, 28)
TXT_DIM = rl.Color(175, 175, 185, 255)
TXT_HOT = rl.WHITE
SEP_COL = rl.Color(60, 60, 72, 255)
PIN_ON = rl.Color(0, 200, 130, 255)
PIN_OFF = rl.Color(90, 90, 100, 255)
def pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except OSError:
return False
def format_time(seconds: float) -> str:
s = int(seconds)
return f"{s // 60:02d}:{s % 60:02d}"
def draw_btn(font, text: str, rect: rl.Rectangle, mouse: rl.Vector2, fsize: int = 12) -> bool:
hovered = rl.check_collision_point_rec(mouse, rect)
if hovered:
rl.draw_rectangle_rounded(rect, 0.35, 6, BTN_HOV)
tw = rl.measure_text_ex(font, text, fsize, 0)
tx = rect.x + (rect.width - tw.x) / 2
ty = rect.y + (rect.height - tw.y) / 2
rl.draw_text_ex(font, text, rl.Vector2(tx, ty), fsize, 0,
TXT_HOT if hovered else TXT_DIM)
return hovered and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
def main():
prefix = os.getenv("OPENPILOT_PREFIX", "default")
state_f = f"/tmp/replay_state_{prefix}.json"
cmd_f = f"/tmp/replay_cmd_{prefix}.json"
# PIDs to watch — exit when ALL are dead
watch_pids: list[int] = []
raw = os.getenv("REPLAY_WATCH_PIDS", "")
if raw.strip():
for tok in raw.split():
try:
watch_pids.append(int(tok))
except ValueError:
pass
# ── Init window ────────────────────────────────
rl.init_window(W, H, "Replay Controls")
rl.set_target_fps(60)
# Position: bottom-right corner, clear of taskbar
mon_w = rl.get_monitor_width(0) or 1920
mon_h = rl.get_monitor_height(0) or 1080
rl.set_window_position(mon_w - W - 20, mon_h - H - 60)
# Always on top by default
rl.set_window_state(rl.ConfigFlags.FLAG_WINDOW_TOPMOST)
# Load font (Inter-Medium.fnt — same as rest of desktop UI)
basedir = os.environ.get("BASEDIR", "") or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
font_path = os.path.join(basedir, "selfdrive", "assets", "fonts", "Inter-Medium.fnt")
if os.path.exists(font_path):
font = rl.load_font(font_path)
rl.gen_texture_mipmaps(font.texture)
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR)
else:
font = rl.get_font_default()
# State
min_sec = max_sec = cur_sec = 0.0
paused = False
speed = 1.0
dragging = False
pinned = True
# Cleanup leftover cmd file
try:
os.remove(cmd_f)
except Exception:
pass
SPEEDS = [0.5, 1.0, 1.5, 2.0, 3.0]
# ── Main loop ──────────────────────────────────
while not rl.window_should_close():
# Exit if all watched parent processes have died
if watch_pids and not any(pid_alive(p) for p in watch_pids):
break
# Read state
try:
with open(state_f) as f:
s = json.load(f)
min_sec = s.get("min_sec", 0.0)
max_sec = s.get("max_sec", 0.0)
if not dragging:
cur_sec = s.get("cur_sec", 0.0)
paused = s.get("paused", False)
speed = s.get("speed", 1.0)
except Exception:
pass
mouse = rl.get_mouse_position()
# ── Layout constants ───────────────────────
PAD = 8
SL_Y = 15 # scrubber centre y
SL_H = 4 # track height
BW = 46 # button width
BH = 22 # button height
BY = H - BH - 8 # button row y
TL_W = 36
SL_X = PAD + TL_W + 4
SL_W = W - SL_X - TL_W - PAD - 4
# ── Scrubber drag ──────────────────────────
has_data = max_sec > min_sec
if has_data:
drag_rect = rl.Rectangle(SL_X - 4, 0, SL_W + 8, SL_Y + 12)
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
if rl.check_collision_point_rec(mouse, drag_rect):
dragging = True
if dragging:
if rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT):
p = max(0.0, min(1.0, (mouse.x - SL_X) / SL_W))
cur_sec = min_sec + p * (max_sec - min_sec)
try:
with open(cmd_f, "w") as f:
json.dump({"seek": cur_sec}, f)
except Exception:
pass
else:
dragging = False
# ── Draw ───────────────────────────────────
rl.begin_drawing()
rl.clear_background(BG)
# Top separator line
rl.draw_rectangle(0, 0, W, 1, SEP_COL)
if has_data:
prog = max(0.0, min(1.0, (cur_sec - min_sec) / (max_sec - min_sec)))
# Track background
rl.draw_rectangle(SL_X, SL_Y - SL_H // 2, SL_W, SL_H, TRACK_COL)
# Fill
fill_w = int(SL_W * prog)
if fill_w > 0:
rl.draw_rectangle(SL_X, SL_Y - SL_H // 2, fill_w, SL_H, FILL_COL)
# Knob
kx = int(SL_X + SL_W * prog)
rl.draw_circle(kx, SL_Y, 6 if dragging else 4, KNOB_COL)
# Time labels
rl.draw_text_ex(font, format_time(cur_sec),
rl.Vector2(PAD, SL_Y - 6), 12, 0, TXT_DIM)
rl.draw_text_ex(font, format_time(max_sec),
rl.Vector2(W - PAD - TL_W + 2, SL_Y - 6), 12, 0, TXT_DIM)
# ── Button row ────────────────────────────
# Layout (left → right): |< -10s PLAY/PAUSE +10s >| speed pin
total_btns = 7
gap = 4
seg_bw = 28 # |< and >|
speed_bw = 36
pin_bw = 26
inner_w = W - 2 * PAD
play_bw = inner_w - 2 * seg_bw - 2 * BW - speed_bw - pin_bw - gap * (total_btns - 1)
play_bw = max(48, play_bw)
cx = PAD
def btn(label, bw):
nonlocal cx
r = rl.Rectangle(cx, BY, bw, BH)
cx += bw + gap
return draw_btn(font, label, r, mouse, 12)
if btn("|<", seg_bw):
seg = int(cur_sec / 60)
target = ((seg - 1) if cur_sec - seg * 60 < 2 and seg > 0 else seg) * 60.0
try:
with open(cmd_f, "w") as f:
json.dump({"seek": max(min_sec, target)}, f)
except Exception:
pass
if btn("-10", BW):
try:
with open(cmd_f, "w") as f:
json.dump({"seek": max(min_sec, cur_sec - 10.0)}, f)
except Exception:
pass
if btn("PAUSE" if not paused else "PLAY", play_bw):
try:
with open(cmd_f, "w") as f:
json.dump({"play": paused}, f)
except Exception:
pass
if btn("+10", BW):
try:
with open(cmd_f, "w") as f:
json.dump({"seek": min(max_sec, cur_sec + 10.0)}, f)
except Exception:
pass
if btn(">|", seg_bw):
seg = int(cur_sec / 60)
try:
with open(cmd_f, "w") as f:
json.dump({"seek": min(max_sec, (seg + 1) * 60.0)}, f)
except Exception:
pass
if btn(f"{speed:.1f}x", speed_bw):
try:
idx = SPEEDS.index(speed)
except ValueError:
idx = 0
next_speed = SPEEDS[(idx + 1) % len(SPEEDS)]
try:
with open(cmd_f, "w") as f:
json.dump({"speed": next_speed}, f)
except Exception:
pass
# Pin (always-on-top) toggle
pin_rect = rl.Rectangle(cx, BY, pin_bw, BH)
hovered = rl.check_collision_point_rec(mouse, pin_rect)
if hovered:
rl.draw_rectangle_rounded(pin_rect, 0.35, 6, BTN_HOV)
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
pinned = not pinned
if pinned:
rl.set_window_state(rl.ConfigFlags.FLAG_WINDOW_TOPMOST)
else:
rl.clear_window_state(rl.ConfigFlags.FLAG_WINDOW_TOPMOST)
pin_col = PIN_ON if pinned else PIN_OFF
tw = rl.measure_text_ex(font, "Top", 11, 0)
rl.draw_text_ex(font, "Top", rl.Vector2(pin_rect.x + (pin_rect.width - tw.x) / 2,
pin_rect.y + (pin_rect.height - tw.y) / 2), 11, 0, pin_col)
else:
# Waiting state
msg = "Waiting for replay..."
tw = rl.measure_text_ex(font, msg, 12)
rl.draw_text_ex(font, msg, rl.Vector2((W - tw.x) / 2, H / 2 - 6), 12, 0, TXT_DIM)
rl.end_drawing()
rl.close_window()
if __name__ == "__main__":
main()