diff --git a/scripts/launch_onroad_desktop.sh b/scripts/launch_onroad_desktop.sh index 8cc879b8e2..f3490a7bb7 100755 --- a/scripts/launch_onroad_desktop.sh +++ b/scripts/launch_onroad_desktop.sh @@ -420,6 +420,15 @@ launch_python_ui() { UI_PIDS+=("$!") } +launch_control_bar() { + local watch_pids="${UI_PIDS[*]}" + ( + export REPLAY_WATCH_PIDS="${watch_pids}" + exec "${ROOT_DIR}/.venv/bin/python3" "${ROOT_DIR}/tools/replay/control_bar.py" + ) & + UI_PIDS+=("$!") +} + parse_args "$@" if [[ -n "${LEGACY_UI_SELECTION}" ]]; then @@ -488,6 +497,7 @@ fi echo "Launching UIs: ${UI_TARGETS[*]}" local_target="" +has_raylib=0 for local_target in "${UI_TARGETS[@]}"; do case "${local_target}" in c3) @@ -495,11 +505,18 @@ for local_target in "${UI_TARGETS[@]}"; do ;; c4) launch_python_ui 0 + has_raylib=1 ;; raybig) launch_python_ui 1 + has_raylib=1 ;; esac done +if [[ "${has_raylib}" == "1" ]]; then + echo "Launching standalone replay controls bar..." + launch_control_bar +fi + wait "${UI_PIDS[@]}" diff --git a/tools/replay/control_bar.py b/tools/replay/control_bar.py new file mode 100755 index 0000000000..a52d40ec04 --- /dev/null +++ b/tools/replay/control_bar.py @@ -0,0 +1,279 @@ +#!/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() diff --git a/tools/replay/main.cc b/tools/replay/main.cc index 5ee6f5b57f..082bde7643 100644 --- a/tools/replay/main.cc +++ b/tools/replay/main.cc @@ -4,9 +4,13 @@ #include #include #include +#include +#include +#include #include "common/util.h" #include "common/prefix.h" +#include "third_party/json11/json11.hpp" #include "tools/replay/consoleui.h" #include "tools/replay/replay.h" #include "tools/replay/util.h" @@ -156,10 +160,63 @@ int main(int argc, char *argv[]) { if (config.headless) { replay.start(config.start_seconds); + + std::string prefix = "default"; + if (const char* env_prefix = getenv("OPENPILOT_PREFIX")) { + prefix = env_prefix; + } + std::string state_path = "/tmp/replay_state_" + prefix + ".json"; + std::string cmd_path = "/tmp/replay_cmd_" + prefix + ".json"; + + // Clean up any leftover command/state files from previous runs + std::remove(state_path.c_str()); + std::remove(cmd_path.c_str()); + ExitHandler do_exit; while (!do_exit) { + // 1. Check for commands from cmd_path + std::ifstream cmd_file(cmd_path); + if (cmd_file.good()) { + std::stringstream buffer; + buffer << cmd_file.rdbuf(); + cmd_file.close(); + + std::string err; + auto json = json11::Json::parse(buffer.str(), err); + if (err.empty()) { + if (json["play"].is_bool()) { + replay.pause(!json["play"].bool_value()); + } + if (json["seek"].is_number()) { + replay.seekTo(json["seek"].number_value(), false); + } + if (json["speed"].is_number()) { + replay.setSpeed(json["speed"].number_value()); + } + } + std::remove(cmd_path.c_str()); + } + + // 2. Write current status to state_path + json11::Json state = json11::Json::object { + {"min_sec", replay.minSeconds()}, + {"max_sec", replay.maxSeconds()}, + {"cur_sec", replay.currentSeconds()}, + {"paused", replay.isPaused()}, + {"speed", replay.getSpeed()} + }; + std::ofstream state_file(state_path); + if (state_file.is_open()) { + state_file << state.dump(); + state_file.close(); + } + util::sleep_for(100); } + + // Clean up on exit + std::remove(state_path.c_str()); + std::remove(cmd_path.c_str()); return 0; }