"""Native route picker and passive replay viewer; the shared owner runs playback.""" from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime import math import time import uuid import pyray as rl from openpilot.selfdrive.ui.mici.widgets.button import BigButton, GreyBigButton from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.starpilot.ui.clip import placed_at from openpilot.starpilot.ui.feature_settings import FeatureSettingsView from openpilot.starpilot.ui.feature_settings_state import (FEATURE_ROW_HEIGHT, FeatureInput, FeatureRow, FeatureSettingsState, FeatureUiAction, feature_scroll) from openpilot.starpilot.ui.onroad_customization import read_customization from openpilot.starpilot.ui.presentation import FontRole, Profile from openpilot.starpilot.replay.timeline import LEGEND, TimelineHistory from openpilot.starpilot.ui.settings_geometry import (ACCENT, CONTROL_BG, CONTROL_BORDER, CONTROL_PRESSED_BG, PANEL_BG, ROW_SEPARATOR, TEXT_MUTED, TEXT_PRIMARY, TEXT_SECONDARY, draw_rounded_fill, draw_rounded_stroke, draw_settings_header, elide_text) from openpilot.system.ui.lib.application import FontWeight, gui_app from openpilot.system.ui.lib.scroll_panel2 import MIN_DRAG_PIXELS from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.system.ui.widgets.scroller import Scroller ACTIVE_STATES = frozenset(('preparing', 'playing', 'paused', 'seeking', 'buffering', 'ended', 'stopping')) TIMELINE_NEUTRAL = rl.Color(54, 60, 71, 255) @dataclass(frozen=True) class ReplayRouteRow(FeatureRow): segment_count: int = 0 def _request(payload): from openpilot.starpilot.replay.client import request_operation return request_operation(payload) def _reader(path, session_id, profile): from openpilot.starpilot.replay.frames import ReplayFrameReader return ReplayFrameReader(path, session_id, profile=profile) def duration(value): if type(value) not in (int, float) or not math.isfinite(value) or value < 0: return 'Unavailable' minutes, seconds = divmod(round(value), 60) return f'{minutes}:{seconds:02d}' def route_date(route, profile, clock24_hour=False): for key in ('startTime', 'fileTime'): value = route.get(key) if type(value) not in (int, float): continue try: if not math.isfinite(value) or value <= 0: continue date = datetime.fromtimestamp(value).astimezone() calendar = date.strftime('%a, %b %-d, %Y' if profile == Profile.LARGE else '%b %-d, %Y') clock = date.strftime('%H:%M') if clock24_hour else date.strftime('%I:%M %p').lstrip('0') return calendar, clock, date.tzname() or '', 'File date' if key == 'fileTime' else '' except (OSError, OverflowError, ValueError): continue return 'Date unavailable', '', '', '' class ReplayController: """One bounded worker for IPC and frame copies; graphics stay on the UI thread.""" def __init__(self, requester=_request, reader_factory=_reader, clock=time.monotonic): self.requester, self.reader_factory, self.clock = requester, reader_factory, clock self.client_id = uuid.uuid4().hex self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='native-replay') self.pending = None self.pending_op = '' self.queued = None self.status = {} self.routes = [] self.detail = None self.error = '' self.frame = None self.autoplay_at = None self.own_session = None self.selected_route = None self.reader = None self.reader_key = None self.reader_generation = 0 self.closed = False self.catalog_loaded = False self.next_status = self.next_catalog = self.next_frame = 0.0 self.status_at = None self.timeline = None self.timeline_error = '' self.next_timeline = 0.0 @property def busy(self): return self.queued is not None or self.pending is not None and self.pending_op in ('detail', 'start', 'control', 'stop') @property def active(self): return bool(self.own_session and self.status.get('sessionId') == self.own_session and self.status.get('state') in ACTIVE_STATES) @property def available(self): return bool(self.status.get('ownerId') and self.status.get('offroad') is True and self.status_at is not None and 0 <= self.clock() - self.status_at < 2.0) def submit(self, payload): if self.closed: return False if self.pending is not None: if self.pending_op in ('frame', 'status', 'catalog', 'timeline') and self.queued is None: self.queued = payload return True return False self.pending_op = payload['op'] self.pending = self.executor.submit(self.requester, payload) return True def select(self, route): if self.busy: return False self.selected_route = route['routeId'] self.detail = route self.error = '' return self.submit({'op': 'detail', 'routeId': self.selected_route}) def start(self, segment=None): if (not self.available or self.status.get('state') in ACTIVE_STATES or self.selected_route is None or not (self.detail or {}).get('playable')): return False self.error = '' payload = {'op': 'start', 'routeId': self.selected_route, 'target': 'native', 'clientId': self.client_id, 'requestId': uuid.uuid4().hex, 'expectedOwnerId': self.status['ownerId']} if segment is not None: payload['segment'] = segment submitted = self.submit(payload) if submitted: self.autoplay_at = math.inf # Wait for the first accepted frame before starting the delay. return submitted def control(self, action, **value): if not self.available or not self.active or self.status.get('state') not in ('playing', 'paused', 'buffering', 'ended'): return False submitted = self.submit({'op': 'control', 'sessionId': self.own_session, 'clientId': self.client_id, 'expectedRevision': self.status['revision'], 'requestId': uuid.uuid4().hex, 'action': action, **value}) if submitted: self.autoplay_at = None return submitted def seek(self, position): if type(position) not in (int, float) or not math.isfinite(position): return False start, end = self.status.get('start'), self.status.get('end') if start is None or end is None or type(start) not in (int, float) or type(end) not in (int, float) or end <= start: return False position = max(start, min(end, position)) ranges = self.status.get('seekRanges') if ranges and not any(left <= position <= right for left, right in ranges): position = min((point for bounds in ranges for point in bounds), key=lambda point: abs(point - position)) self.error = 'Unavailable gap; seeking to available footage' return self.control('seek', position=position) def stop(self): self.autoplay_at = None if not self.own_session: return False return self.submit(self._stop_payload(self.own_session)) def _stop_payload(self, session): return {'op': 'stop', 'sessionId': session, 'clientId': self.client_id, 'requestId': uuid.uuid4().hex} def _read_frame(self, key): if key != self.reader_key: self._close_reader() self.reader = self.reader_factory(*key[:3]) self.reader_key = key return self.reader.latest() def _close_reader(self): if self.reader is not None: self.reader.close() self.reader = self.reader_key = None def tick(self): if self.closed: return now = self.clock() if self.pending is not None and self.pending.done(): future, operation = self.pending, self.pending_op self.pending, self.pending_op = None, '' try: response = future.result() if operation == 'frame': if response is not None: if response.session_id == self.own_session and response.epoch == self.status.get('epoch'): self.frame = response if self.autoplay_at == math.inf: self.autoplay_at = now + 1.0 else: self.frame = None self.next_status = 0.0 elif response.get('ok') is not True: if operation == 'timeline': self.timeline_error = 'History unavailable' self.timeline = None else: self.error = response.get('error', {}).get('message', 'Replay unavailable') self.next_status = 0.0 else: result = response['result'] if operation == 'catalog': self.routes = result['routes'] self.catalog_loaded = True elif operation == 'detail': self.detail = result elif operation == 'timeline': if self.timeline is not None: self.timeline.append(result, self.status.get('seekRanges') or [[self.status['start'], self.status['end']]]) else: if result.get('schemaVersion') != 1 or type(result.get('revision')) is not int: raise ValueError('Invalid replay status') if operation == 'start' or result.get('owned') is True: self.own_session = result['sessionId'] if (result.get('sessionId'), result.get('epoch'), result.get('frameReady')) != ( self.status.get('sessionId'), self.status.get('epoch'), self.status.get('frameReady')): self.frame = None self.reader_generation += 1 prior_identity = self.status.get('ownerId'), self.status.get('sessionId') self.status, self.status_at = result, now if (prior_identity != (result['ownerId'], result['sessionId']) or not self.active or not result.get('owned') or not result.get('offroad') or result.get('state') == 'stopping' or self.timeline is not None and (self.timeline.owner, self.timeline.session) != (result['ownerId'], result['sessionId'])): self.timeline = None self.timeline_error = '' if self.active and result.get('owned') and result.get('offroad') and result.get('timelineState') == 'unavailable': self.timeline = None self.timeline_error = 'History unavailable' elif (self.active and result.get('owned') and result.get('offroad') and result.get('state') != 'stopping' and result.get('timelineState') == 'ready' and self.timeline is None and not self.timeline_error): self.timeline = TimelineHistory(result['ownerId'], result['sessionId']) self.error = result.get('errorCode') or '' if result.get('paused') is False or result.get('state') in ('ended', 'failed'): self.autoplay_at = None if not self.active or result.get('offroad') is not True or not result.get('frameReady'): self.frame = None except Exception as error: if operation == 'timeline': self.timeline, self.timeline_error = None, 'History unavailable' elif operation == 'frame': self.error = 'Replay frame unavailable' self.frame = None else: self.error = str(error) or 'Replay service unavailable' self.status_at = None if self.pending is not None: return if self.queued is not None: payload, self.queued = self.queued, None self.submit(payload) elif (self.autoplay_at is not None and now >= self.autoplay_at and self.frame is not None and self.available and self.active and self.status.get('state') == 'paused' and not self.error): self.control('pause', paused=False) elif now >= self.next_catalog and not self.active: self.next_catalog = now + 5.0 self.submit({'op': 'catalog'}) elif now >= self.next_status: self.next_status = now + 0.25 self.submit({'op': 'status', 'clientId': self.client_id}) elif self.active and self.timeline is not None and self.timeline.rows is None and now >= self.next_timeline: self.next_timeline = now + .05 self.submit({'op': 'timeline', 'clientId': self.client_id, 'sessionId': self.own_session, 'cursor': self.timeline.cursor}) elif self.active and self.status.get('frameReady') and self.status.get('framePath') and now >= self.next_frame: # Poll below the 60 Hz UI interval to avoid skipping frames. self.next_frame = now + 0.01 key = (self.status['framePath'], self.own_session, self.status.get('profile'), self.reader_generation) self.pending_op = 'frame' self.pending = self.executor.submit(self._read_frame, key) def close(self): if self.closed: return self.closed = True self.autoplay_at = None if self.pending_op == 'start' or self.pending_op == 'status' and not self.own_session: def stop_late_start(future): try: response = future.result() if (response.get('ok') is True and response['result'].get('sessionId') and response['result'].get('owned') is True): self.requester(self._stop_payload(response['result']['sessionId'])) except Exception: pass # The owner also expires the native lease after disconnect. self.pending.add_done_callback(stop_late_start) elif self.own_session: self.executor.submit(self.requester, self._stop_payload(self.own_session)) self.executor.submit(self._close_reader) self.executor.shutdown(wait=False) self.frame = None self.timeline = None class ReplayPage(Widget): def __init__(self, session, on_close, controller=None): super().__init__() self.fonts, self.profile = session.fonts, session.profile self.controller = controller or ReplayController() self.on_close = on_close self.page = 'routes' self.scroll = 0 self.view = FeatureSettingsView(self.fonts) if self.profile == Profile.LARGE else None self.input = FeatureInput(self._action) if self.view: self.view.pressed_action = self.input.pressed_action self.cards = self._child(Scroller(snap_items=True)) if self.profile == Profile.COMPACT else None if self.cards: self.cards.set_enabled(lambda: self.enabled) self.card_signature = None self.route_rows = () self.route_source = None self.clock24_hour = read_customization(ui_state.params).get('clock24Hour', False) self.detail_source = None self.detail_text = ('Recorded route', 'Date unavailable', '') self.detail_message = '' self.texture = None self.rail_texture = self.rail_key = None self.frame_key = None self.press = None self.seek_preview = None self.controls = True self.last_touch = time.monotonic() self.stopping = False self._epoch = None self.stack_widget: Widget = self def show_event(self): super().show_event() gui_app.add_nav_stack_tick(self._tick) gui_app.add_render_prepare(self._prepare_rail, self._clear_rail) def hide_event(self): gui_app.remove_nav_stack_tick(self._tick) gui_app.remove_render_prepare(self._prepare_rail) self.controller.close() self._clear_frame() self._clear_rail() self.input.cancel() self.press = self.seek_preview = None super().hide_event() def _tick(self): self.controller.tick() if self.controller.active and self.controller.available and self.controller.status.get('paused') is False: device._reset_interactive_timeout() if ui_state.started and gui_app.widget_in_stack(self.stack_widget): self.on_close() def _clear_frame(self): if self.texture is not None: rl.unload_texture(self.texture) self.texture = self.frame_key = None def _clear_rail(self): if getattr(self, 'rail_texture', None) is not None: rl.unload_render_texture(self.rail_texture) self.rail_texture = self.rail_key = None def _rail_size(self): return (28, 6, 2) if self.profile == Profile.LARGE else (16, 4, 2) def _prepare_rail(self): history = getattr(self.controller, 'timeline', None) large = self.profile == Profile.LARGE (_, _, width, _), _buttons = self._transport() start, end = (self.controller.status.get(key) or 0 for key in ('start', 'end')) ranges = self.controller.status.get('seekRanges') or ((start, end),) rail, tick, gap = self._rail_size() if history is None or history.rows is None or end <= start: self._clear_rail() return scale = max(.1, gui_app._scale) pixels = max(1, round(width * scale)) paint_h = max(1, round((rail + tick + gap) * scale)) key = id(history), start, end, tuple(map(tuple, ranges)), pixels, paint_h, large if getattr(self, 'rail_key', None) != key: self._clear_rail() target = rl.load_render_texture(pixels, paint_h) if not target.id: return self.rail_texture, self.rail_key = target, key rl.begin_texture_mode(target) rl.clear_background(rl.BLANK) line_y, line_h = round((tick + gap) * scale), max(1, round(rail * scale)) def rect(left, right, color, bookmark=False): lo = max(0, min(pixels - 1, math.floor(pixels * (left - start) / (end - start)))) minimum = max(1, round((4 if large else 3) * scale)) if bookmark else 1 hi = min(pixels, max(lo + minimum, math.ceil(pixels * (right - start) / (end - start)))) if bookmark: lo = max(0, hi - minimum) rl.draw_rectangle(lo, 0 if bookmark else line_y, hi - lo, max(1, round(tick * scale)) if bookmark else line_h, color) for left, right in ranges: rect(left, right, TIMELINE_NEUTRAL) for layer in ('mode', 'error', 'bookmark'): for left, right, kind in history.rows: if (kind if kind in ('error', 'bookmark') else 'mode') == layer: rect(left, right, rl.Color(*LEGEND[kind]['rgb'], 255), layer == 'bookmark') rl.end_texture_mode() def _timeline_rail(self, x, y, width, height, start, end, ranges): history = getattr(self.controller, 'timeline', None) rail, tick, gap = self._rail_size() top = y + (height - rail - tick - gap) / 2 target = getattr(self, 'rail_texture', None) if target is not None and history is not None and self.rail_key is not None and self.rail_key[0] == id(history): rl.draw_texture_pro(target.texture, rl.Rectangle(0, 0, target.texture.width, -target.texture.height), rl.Rectangle(x, top, width, rail + tick + gap), rl.Vector2(0, 0), 0, rl.WHITE) elif end > start: for left, right in ranges: rl.draw_rectangle(round(x + width * (left - start) / (end - start)), round(top + tick + gap), max(1, round(width * (right - left) / (end - start))), rail, TIMELINE_NEUTRAL) return top + tick + gap, rail def _scrub_preview(self, head_x, position, label, entry): large = self.profile == Profile.LARGE size, padding, swatch = (28, 22, 14) if large else (18, 16, 10) text = elide_text(self.fonts, f'{duration(position)} - {label}', FontRole.MEDIUM, size, 460) measured = self.fonts.measure(text, FontRole.MEDIUM, size) width = measured.width + padding * 3 + swatch if large else 520 left = max(100, min(2060 - width, head_x - width / 2)) if large else 8 top, height = (784, 62) if large else (8, 42) bounds = rl.Rectangle(left, top, width, height) radius = 14 if large else 10 draw_rounded_fill(bounds, rl.Color(8, 8, 10, 245), radius_px=radius) draw_rounded_stroke(bounds, TEXT_MUTED, radius_px=radius) if large: rl.draw_line(round(head_x), top + height + 4, round(head_x), 868, TEXT_MUTED) color = rl.Color(*entry['rgb'], 255) if entry else TEXT_MUTED rl.draw_rectangle(round(left + padding), round(top + (height - swatch) / 2), swatch, swatch, color) self.fonts.draw(text, FontRole.MEDIUM, size, left + padding * 2 + swatch, top + (height - measured.height) / 2, TEXT_PRIMARY) def _update_state(self): self.controller.tick() epoch = (self.controller.status.get('sessionId'), self.controller.status.get('epoch')) if epoch != self._epoch: self.press = self.seek_preview = None self.input.cancel() self._epoch = epoch if self.controller.active and not self.stopping: self.page = 'viewer' elif self.page == 'viewer' and self.controller.own_session and not self.controller.busy: self.page = 'detail' self.stopping = False if not self.controller.active and not self.controller.busy: self.stopping = False if self.controller.frame is None or not self.controller.available or ui_state.started: self._clear_frame() def _rows(self): owner = self.controller if self.route_source is not owner.routes: self.route_source = owner.routes rows = [] for route in sorted(owner.routes, key=lambda route: route.get('startTime') or route.get('fileTime') or 0, reverse=True): calendar, clock, _, source = route_date(route, self.profile, self.clock24_hour) identifier = route['routeId'] name = route.get('displayName') or identifier named = name != identifier count = route.get('segmentCount', 0) segments = f'{count} segment' + ('s' if count != 1 else '') label = name if named or not clock else calendar + (f' - {clock}' if self.profile == Profile.LARGE else '') if self.profile == Profile.LARGE: label = name if named else calendar + (f' - {clock}' if clock else '') summary = ' - '.join(part for part in (calendar, clock, identifier) if part) if named else identifier value = ' - '.join(part for part in (summary, source) if part) else: if named and clock: date = f'{source}: {calendar}' if source else calendar value = f'{date}\n{clock} - {segments}' else: value = f'{clock or calendar}\n' + ' - '.join(part for part in (source, segments) if part) reason = route.get('reason') or (route.get('availability', '') if route.get('availability') != 'ready' else '') if self.profile == Profile.COMPACT and named and source and not route.get('reason'): reason = '' # Keep the date source visible within the card's two subtitle lines. rows.append(ReplayRouteRow(identifier, label, value, available=True, reason=reason, segment_count=count, page='detail' if self.profile == Profile.LARGE else '', actions=() if self.profile == Profile.LARGE else (('DETAILS', True),))) self.route_rows = tuple(rows) return self.route_rows or (FeatureRow('', 'No recorded routes' if owner.catalog_loaded else 'Loading recorded routes', 'Drives recorded on this device will appear here.' if owner.catalog_loaded else 'Reading routes on this device...'),) def _render_route_row(self, row, y, left, pressed): x, right = left + 55, 2094 if row.page and pressed == FeatureUiAction('open', row): draw_rounded_fill(rl.Rectangle(left + 25, y + 2, 2100 - left, FEATURE_ROW_HEIGHT - 8), CONTROL_PRESSED_BG, radius_px=12) rl.draw_line(x, y + FEATURE_ROW_HEIGHT - 3, right, y + FEATURE_ROW_HEIGHT - 3, ROW_SEPARATOR) width = right - x - (360 if row.page else 0) detail = ' - '.join(part for part in (row.value, row.reason) if part) label = elide_text(self.fonts, row.label, FontRole.MEDIUM, 46, width) detail = elide_text(self.fonts, detail, FontRole.NORMAL, 30, width) top, bottom = self.fonts.vertical_ink(label, FontRole.MEDIUM, 46) detail_top, detail_bottom = self.fonts.vertical_ink(detail, FontRole.NORMAL, 30) if detail else (0, 0) height = bottom - top + (14 + detail_bottom - detail_top if detail else 0) label_y = y + (FEATURE_ROW_HEIGHT - height) / 2 - top self.fonts.draw(label, FontRole.MEDIUM, 46, x, label_y, TEXT_PRIMARY) if detail: self.fonts.draw(detail, FontRole.NORMAL, 30, x, label_y + bottom + 14 - detail_top, TEXT_SECONDARY) if row.page: count = f'{row.segment_count} segment' + ('s' if row.segment_count != 1 else '') measured = self.fonts.measure(count, FontRole.NORMAL, 32) top, bottom = self.fonts.vertical_ink(count, FontRole.NORMAL, 32) self.fonts.draw(count, FontRole.NORMAL, 32, right - 76 - measured.width, y + (FEATURE_ROW_HEIGHT - top - bottom) / 2, TEXT_SECONDARY) cx, cy = right - 12, y + FEATURE_ROW_HEIGHT / 2 rl.draw_line_ex(rl.Vector2(cx - 12, cy - 12), rl.Vector2(cx, cy), 3, TEXT_MUTED) rl.draw_line_ex(rl.Vector2(cx, cy), rl.Vector2(cx - 12, cy + 12), 3, TEXT_MUTED) def _play_available(self): owner = self.controller return owner.available and not owner.busy and bool((owner.detail or {}).get('playable')) and owner.status.get('state') not in ACTIVE_STATES def _segments(self): return [segment for segment in (self.controller.detail or {}).get('segments', []) if (segment['files']['rlog'] or segment['files']['qlog']) and (segment['files']['fcamera'] or segment['files']['qcamera'])] def _detail_lines(self, message, width): # Match the bitmap renderer, independent of the native app's fallback font. remaining, lines = ' '.join(message.split()), [] for _ in range(2): if not remaining: break if self.fonts.measure(remaining, FontRole.NORMAL, 18).width <= width: lines.append(remaining) remaining = '' break line = elide_text(self.fonts, remaining, FontRole.NORMAL, 18, width)[:-3] if ' ' in line: line = line.rsplit(' ', 1)[0] lines.append(line) remaining = remaining[len(line):].lstrip() return lines, remaining def _show_detail_message(self): if not self.detail_message: return from openpilot.selfdrive.ui.mici.widgets.dialog import SettingDescriptionDialog font = gui_app.font(FontWeight.ROMAN) paragraphs = [] for paragraph in self.detail_message.splitlines(): # Native cards cannot paginate inside one oversized path or error token. words = ['\n'.join(wrap_text(font, word, 36, 396)) for word in paragraph.split()] paragraphs.append(' '.join(words)) dialog = SettingDescriptionDialog('replay', '\n'.join(paragraphs)) dialog._scroller.items.pop(0) # Open directly on the message instead of the help cover. gui_app.push_widget(dialog) def _choose_segment(self): if not self._play_available(): return options = {f"Segment {segment['number']}": segment['number'] for segment in self._segments()} if not options: return route_id = self.controller.selected_route def play_segment(number): if (self.page == 'detail' and self.controller.selected_route == route_id and gui_app.get_active_widget() is self.stack_widget): self.controller.start(segment=number) if self.profile == Profile.COMPACT: from openpilot.system.ui.widgets.scroller import NavScroller picker = NavScroller() def selected_segment(number): if gui_app.get_active_widget() is picker: gui_app.pop_widget() play_segment(number) calendar = route_date(self.controller.detail, self.profile, self.clock24_hour)[0] for label, number in options.items(): button = BigButton(label.lower(), f'{calendar.lower()}\nstart here') button._sub_label.set_font_size(30) button.set_click_callback(lambda number=number: selected_segment(number)) picker._scroller.add_widgets([button]) gui_app.push_widget(picker) return from openpilot.system.ui.widgets import DialogResult from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog def selected(result): if result == DialogResult.CONFIRM and picker.selection in options: play_segment(options[picker.selection]) picker = MultiOptionDialog('Play from segment', list(options), callback=selected) picker.select_button.set_text('Play') gui_app.push_widget(picker) def _detail_buttons(self): available = self._play_available() if self.profile == Profile.COMPACT: message = (('More', (420, 112, 104, 44), True, self._show_detail_message),) if self.detail_message else () return (('< Back', (12, 22, 88, 48), True, self._back), *message, ('Choose segment', (12, 164, 200, 64), available and bool(self._segments()), self._choose_segment), ('Play from beginning', (224, 164, 300, 64), available, self.controller.start)) return (('< Back', (20, 12, 208, 88), True, self._back), ('Choose segment', (72, 900, 640, 144), available and bool(self._segments()), self._choose_segment), ('Play from beginning', (744, 900, 1344, 144), available, self.controller.start)) def _render_detail(self): owner = self.controller route = owner.detail or {} large = self.profile == Profile.LARGE if large: draw_settings_header(self.fonts, False, 'Route details', 'Replay', back=True) if self.detail_source is not owner.detail: self.detail_source = owner.detail calendar, clock, zone, source = route_date(route, self.profile, self.clock24_hour) identifier = route.get('routeId') or owner.selected_route or '' name = route.get('displayName') or identifier named = name != identifier date_line = ' - '.join(part for part in (source, calendar if named or not clock else '', f'{clock} {zone}'.strip()) if part) title = name if named else calendar if clock else 'Recorded route' self.detail_text = title, date_line, identifier title, date_line, identifier = self.detail_text if large: self._render_large_detail(title, date_line, identifier) return size, width, x, y = 26, 412, 112, 24 self.fonts.draw(elide_text(self.fonts, title, FontRole.SEMI_BOLD, size, width), FontRole.SEMI_BOLD, size, x, y, TEXT_PRIMARY) size, width, x, y = 18, 412, 112, 57 self.fonts.draw(elide_text(self.fonts, date_line, FontRole.NORMAL, size, width), FontRole.NORMAL, size, x, y, TEXT_MUTED) size, width, x, y = 16, 340, 12, 91 self.fonts.draw(elide_text(self.fonts, identifier, FontRole.NORMAL, size, width), FontRole.NORMAL, size, x, y, TEXT_MUTED) count = route.get('segmentCount', 0) segments = f'{count} segment' + ('s' if count != 1 else '') width = self.fonts.measure(segments, FontRole.NORMAL, 18).width self.fonts.draw(segments, FontRole.NORMAL, 18, 524 - width, 91, TEXT_MUTED) reason = owner.error or ('Loading route details...' if owner.busy else route.get('reason')) if reason and not owner.error and not owner.busy and not route.get('playable') and 'Choose another route' not in reason: reason = reason.rstrip('. ') + '. Choose another route.' if not reason and not self._play_available(): reason = 'Replay is available while offroad and no other player is active' if not reason and route.get('segments') is not None and len(self._segments()) != count: reason = f'{len(self._segments())} of {count} available to play' reason = reason or '' self.detail_message = reason.rstrip() + '\n\nReplay timeline: green Chill; cyan AOL; orange Experimental; red alerts; yellow bookmarks.' lines, _ = self._detail_lines(reason, 392) for index, line in enumerate(lines[:2]): if index == 1: line = elide_text(self.fonts, line + ' ...', FontRole.NORMAL, 18, 392) self.fonts.draw(line, FontRole.NORMAL, 18, 12, 114 + index * 21, TEXT_PRIMARY) for label, bounds, enabled, _ in self._detail_buttons(): self._button(label, bounds, enabled) def _render_large_detail(self, title, date_line, identifier): owner = self.controller route = owner.detail or {} def text(value, size, x, y, width=2016, role=FontRole.NORMAL, color=TEXT_PRIMARY): self.fonts.draw(elide_text(self.fonts, value, role, size, width), role, size, x, y, color) text(title, 64, 72, 180, role=FontRole.SEMI_BOLD) text(date_line, 36, 72, 274, color=TEXT_SECONDARY) rl.draw_line(72, 366, 2088, 366, ROW_SEPARATOR) text('Route ID', 30, 72, 418, color=TEXT_MUTED) text(identifier, 38, 72, 472, width=1320, role=FontRole.MEDIUM) text('Segments', 30, 1512, 418, color=TEXT_MUTED) text(str(route.get('segmentCount', 0)), 38, 1512, 472, width=576, role=FontRole.MEDIUM) if not owner.busy and route.get('segments') is not None: text(f'{len(self._segments())} available to play', 30, 1512, 532, width=576, color=TEXT_SECONDARY) rl.draw_line(72, 624, 2088, 624, ROW_SEPARATOR) text('Play the route from the beginning, or choose a segment to start partway through.', 32, 72, 682, color=TEXT_SECONDARY) reason = owner.error or ('Loading route details...' if owner.busy else route.get('reason')) if not reason and not self._play_available(): reason = 'Replay is available while offroad and no other player is active' if reason: text(reason, 32, 72, 800, color=TEXT_MUTED) for label, bounds, enabled, _ in self._detail_buttons()[1:]: self._button(label, bounds, enabled) def _state(self): rows = self._rows() self.scroll = feature_scroll(self.scroll, 0, len(rows)) return FeatureSettingsState(page=f'replay:{self.page}:{self.controller.selected_route}', title='Replay', parent_title='Device' if self.page == 'routes' else 'Recorded Routes', rows=rows, subtitle=self.controller.error or (f'{len(self.controller.routes)} recorded route' + ('s' if len(self.controller.routes) != 1 else '') + ' - Newest first' if self.controller.routes else ''), sidebar_expanded=False, scroll=self.scroll, save_hint='Recorded driving routes on this device') def _action(self, action): if action.kind == 'back': self._back() elif action.kind == 'scroll': self.scroll = feature_scroll(self.scroll, action.direction, len(self._rows())) elif action.row in self._rows() and action.kind in ('action', 'open'): route = next((route for route in self.controller.routes if route['routeId'] == action.row.key), None) if route is not None and self.controller.select(route): self.page = 'detail' self.input.cancel() def _back(self): self.press = self.seek_preview = None self.detail_message = '' self.input.cancel() if self.page == 'viewer': if self.controller.stop(): self.stopping = True self.page = 'detail' self._clear_frame() elif self.page == 'detail': self.page = 'routes' else: self.on_close() def _compact_cards(self): rows = self._rows() signature = (self.page, rows, self.controller.busy, self.controller.error) if signature == self.card_signature or any(event.left_down for event in gui_app.mouse_events): return self.card_signature = signature cards = [] for row in rows: detail = ' - '.join(part for part in (row.value, row.reason) if part).lower() if row.actions: button = BigButton(row.label.lower(), detail, scroll=True) size = 30 while size > 24 and any(self.fonts.measure(line, FontRole.NORMAL, size).width > 322 for line in detail.splitlines()): size -= 1 button._sub_label.set_font_size(size) button.set_enabled(row.available and not self.controller.busy) button.set_click_callback(lambda row=row: self._action(FeatureUiAction('action', row=row))) else: button = GreyBigButton(row.label.lower(), detail) cards.append(button) if self.controller.error: cards.append(GreyBigButton('replay unavailable', self.controller.error)) self.cards._scroller.items.clear() self.cards._scroller.add_widgets(cards) def _render(self, rect): if self.profile == Profile.COMPACT and self.page == 'routes': self._compact_cards() self.cards.render(rect) return with placed_at(rect, self._parent_rect): rl.draw_rectangle(0, 0, *self.profile.size, PANEL_BG) if self.page == 'viewer': self._render_viewer() elif self.page == 'detail': self._render_detail() elif self.profile == Profile.LARGE: self.view.render(self._state(), self.input.drag_x, draw_row=self._render_route_row) def _button(self, label, bounds, enabled=True): rect = rl.Rectangle(*bounds) held = enabled and self.press is not None and self.press[2] == label compact = self.profile == Profile.COMPACT video = compact and self.page == 'viewer' primary = enabled and (self.page == 'detail' and label == 'Play from beginning' or video and label in ('Play', 'Pause')) fill = CONTROL_PRESSED_BG if held else rl.Color(8, 8, 10, 235) if video else CONTROL_BG if primary: if compact: fill = rl.color_lerp(ACCENT, PANEL_BG, 0.3 if held else 0.2) else: fill = rl.color_lerp(ACCENT, PANEL_BG, 0.2) if held else ACCENT if self.page == 'viewer' and not self.controls and not compact: fill = rl.Color(8, 8, 10, 210) draw_rounded_fill(rect, fill) draw_rounded_stroke(rect, ACCENT if primary or held else CONTROL_BORDER) size = (48 if self.page == 'detail' else 38) if self.profile == Profile.LARGE else 22 size = min(size, size * (rect.width - 12) / max(1, self.fonts.measure(label, FontRole.MEDIUM, size).width)) measured = self.fonts.measure(label, FontRole.MEDIUM, size) self.fonts.draw(label, FontRole.MEDIUM, size, rect.x + (rect.width - measured.width) / 2, rect.y + (rect.height - measured.height) / 2, TEXT_PRIMARY if enabled else TEXT_MUTED) def _transport(self): large = self.profile == Profile.LARGE timeline = (100, 916, 1960, 48) if large else (16, 64, 504, 48) state = self.controller.status labels = ('-10 s' if large else '-10s', 'Play' if state.get('paused') else 'Pause', '+10 s' if large else '+10s', f"{state.get('rate', 1):g}x", 'Stop') return timeline, tuple((label, (100 + index * 400, 976, 360, 84) if large else (16 + index * 102, 132, 96, 76)) for index, label in enumerate(labels)) def _collapsed_stop_bounds(self): return (1950, 16, 180, 76) if self.profile == Profile.LARGE else (448, 8, 80, 44) def _render_viewer(self): owner = self.controller large = self.profile == Profile.LARGE bounds = rl.Rectangle(0, 0, *self.profile.size) frame = owner.frame if frame is not None and owner.available and not ui_state.started: key = (frame.session_id, frame.epoch, frame.frame_id) if key != self.frame_key: if self.frame_key is None: self.last_touch = time.monotonic() if self.texture is None or (self.texture.width, self.texture.height) != (frame.width, frame.height): self._clear_frame() self.texture = rl.load_texture_from_image(rl.Image(rl.ffi.NULL, frame.width, frame.height, 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8)) rl.set_texture_filter(self.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) rl.update_texture(self.texture, rl.ffi.cast('void *', rl.ffi.from_buffer(frame.data))) self.frame_key = key rl.draw_texture_pro(self.texture, rl.Rectangle(0, 0, frame.width, frame.height), bounds, rl.Vector2(0, 0), 0, rl.WHITE) self.controls = ((large or frame is not None) and (owner.status.get('state') != 'playing' or time.monotonic() - self.last_touch < 3)) if self.press: self.controls = self.press[4] scrubbing = self.seek_preview is not None and self.press is not None and self.press[2] == 'seek' if not large and not scrubbing: draw_rounded_fill(rl.Rectangle(8, 8, 94, 28), rl.Color(8, 8, 10, 235)) if large or not scrubbing: self.fonts.draw('REPLAY', FontRole.SEMI_BOLD, 36 if large else 18, 30 if large else 12, 30 if large else 12, ACCENT) if not self.controls: self._button('Stop', self._collapsed_stop_bounds(), not owner.busy) self._render_loading() return if large: rl.draw_rectangle(0, 872, self.profile.size[0], 208, rl.Color(8, 8, 10, 190)) else: draw_rounded_fill(rl.Rectangle(8, 70, 520, 36), rl.Color(8, 8, 10, 225)) timeline, buttons = self._transport() x, y, width, height = timeline start, end, position = (owner.status.get(key) or 0 for key in ('start', 'end', 'position')) displayed = self.seek_preview if self.seek_preview is not None else position amount = max(0, min(1, (displayed - start) / (end - start))) if end > start else 0 ranges = owner.status.get('seekRanges') or ((start, end),) rail_y, rail_h = self._timeline_rail(x, y, width, height, start, end, ranges) head_x = round(x + width * amount) rl.draw_rectangle(head_x - 1, round(rail_y - 1), 3 if large else 2, rail_h + 2, rl.WHITE) rl.draw_rectangle(head_x - 4, round(rail_y + rail_h + 3), 9 if large else 8, 3, rl.WHITE) timing = f'{duration(displayed)} / {duration(end)}' if end > start else '' history = getattr(owner, 'timeline', None) entry = history.entry(displayed) if history is not None else None label = entry['label'] if entry else '' if history is not None and not label: label = 'Loading history' if history.rows is None else 'Neutral' if not label and owner.status.get('timelineState'): label = getattr(owner, 'timeline_error', '') or 'Loading history' if end > start and not any(left <= displayed <= right for left, right in ranges): label, entry = 'Unavailable gap', None if label: timing += ' - ' + label if not large and not scrubbing and timing: timing = elide_text(self.fonts, timing, FontRole.MEDIUM, 18, 504) width = self.fonts.measure(timing, FontRole.MEDIUM, 18).width + 16 draw_rounded_fill(rl.Rectangle(8, 30, width, 30), rl.Color(8, 8, 10, 235)) if large or not scrubbing: self.fonts.draw(timing, FontRole.MEDIUM, 26 if large else 18, x, y - 26 if large else 36, TEXT_PRIMARY) if scrubbing: self._scrub_preview(head_x, displayed, label or 'History unavailable', entry) if large: for index, item in enumerate(LEGEND.values()): left = 1130 + index * 184 rl.draw_rectangle(left, 890, 8, 18, rl.Color(*item['rgb'], 255)) self.fonts.draw(item['label'], FontRole.MEDIUM, 22, left + 14, 888, TEXT_SECONDARY) enabled = owner.available and not owner.busy and owner.status.get('state') in ('playing', 'paused', 'buffering', 'ended') for label, button in buttons: self._button(label, button, not owner.busy if label == 'Stop' else enabled) self._render_loading() def _render_loading(self): owner = self.controller if owner.frame is not None and owner.available and not ui_state.started: return large = self.profile == Profile.LARGE size, radius = (32, 40) if large else (18, 14) center = rl.Vector2(self.profile.size[0] / 2, self.profile.size[1] / 2) loading = not owner.error and owner.available and owner.status.get('state') in ('preparing', 'seeking', 'buffering', 'playing', 'paused') message = owner.error or f"Replay {owner.status.get('state', 'unavailable')}" if loading: angle = time.monotonic() * 240 % 360 rl.draw_ring(center, radius - (5 if large else 3), radius, angle, angle + 270, 48, TEXT_PRIMARY) message = 'Replay preparing' if owner.status.get('state') == 'preparing' else 'Replay loading' message = elide_text(self.fonts, message, FontRole.MEDIUM, size, self.profile.size[0] - 40) width = self.fonts.measure(message, FontRole.MEDIUM, size).width self.fonts.draw(message, FontRole.MEDIUM, size, center.x - width / 2, center.y + radius + (20 if large else 8) if loading else center.y, TEXT_PRIMARY) @staticmethod def _inside(x, y, bounds): left, top, width, height = bounds return left <= x <= left + width and top <= y <= top + height def _target(self, x, y): if self.page == 'detail': return next((label for label, bounds, enabled, _ in self._detail_buttons() if enabled and self._inside(x, y, bounds)), None) if self.page != 'viewer': return None if not self.controls: return 'Stop' if self._inside(x, y, self._collapsed_stop_bounds()) else 'show' timeline, buttons = self._transport() if self._inside(x, y, timeline): return 'seek' return next((label for label, bounds in buttons if self._inside(x, y, bounds)), 'show') def _handle_mouse_press(self, pos): x, y = pos.x - self.rect.x, pos.y - self.rect.y self.last_touch = time.monotonic() if self.page in ('viewer', 'detail'): self.press = (x, y, self._target(x, y), self.controller.status.get('epoch'), self.controls) self._move_seek(x) elif self.view: state = self._state() block_tap = self.view.reset(state) and FeatureInput._in_body(x, y, state) self.input.press(x, y, state) if block_tap: self.input.cancel_tap() def _move_seek(self, x): if self.press and self.press[2] == 'seek': left, _, width, _ = self._transport()[0] start, end = self.controller.status.get('start') or 0, self.controller.status.get('end') or 0 self.seek_preview = start + max(0, min(1, (x - left) / width)) * (end - start) def _handle_mouse_event(self, mouse_event): if mouse_event.left_released and not rl.check_collision_point_rec(mouse_event.pos, self._hit_rect): self.press = self.seek_preview = None self.input.cancel() if mouse_event.left_down: x, y = mouse_event.pos.x - self.rect.x, mouse_event.pos.y - self.rect.y if self.press: px, py, target, epoch, _ = self.press tolerance = 36 if self.profile == Profile.LARGE else MIN_DRAG_PIXELS if epoch != self.controller.status.get('epoch') or (target != 'seek' and math.hypot(x - px, y - py) > tolerance): self.press = self.seek_preview = None else: self._move_seek(x) elif self.view: self.input.move(x, y, self._state(), mouse_event.t) def _handle_mouse_release(self, pos): x, y = pos.x - self.rect.x, pos.y - self.rect.y if self.page == 'detail': held, self.press = self.press, None if held and held[3] == self.controller.status.get('epoch') and held[2] == self._target(x, y): for label, _, enabled, callback in self._detail_buttons(): if enabled and label == held[2]: callback() break return if self.page != 'viewer': if self.view: self.input.release(x, y, self._state(), time.monotonic()) return held, self.press = self.press, None if held and held[3] == self.controller.status.get('epoch'): self.last_touch = time.monotonic() target = held[2] if target == 'seek' and self.seek_preview is not None: self.controller.seek(self.seek_preview) elif target == 'show': self.last_touch = -math.inf if held[4] else time.monotonic() elif target == self._target(x, y): state = self.controller.status if target == 'Stop': self._back() elif target in ('Play', 'Pause'): self.controller.control('pause', paused=not bool(state.get('paused'))) elif target.startswith(('-10', '+10')): self.controller.seek(state.get('position', 0) + (-10 if target.startswith('-') else 10)) elif target.endswith('x'): rates = (0.5, 1, 2) self.controller.control('rate', rate=rates[(rates.index(state.get('rate', 1)) + 1) % len(rates)]) self.seek_preview = None class ReplayCompactPage(NavWidget): def __init__(self, session, controller=None): super().__init__() self.content = self._child(ReplayPage(session, self.dismiss, controller)) self.content.set_enabled(lambda: self.enabled and not self.is_dismissing) self.content.stack_widget = self def _back_enabled(self): return self.content.page != 'viewer' def _handle_mouse_event(self, mouse_event): was_dismissing = self._playing_dismiss_animation super()._handle_mouse_event(mouse_event) if mouse_event.left_released and not was_dismissing and self._playing_dismiss_animation and self.content.page == 'detail': self._playing_dismiss_animation = False self.content._back() def _render(self, rect): self.content.render(rect) def open_replay(session): page = ReplayCompactPage(session) if session.profile == Profile.COMPACT else ReplayPage(session, gui_app.pop_widget) page.set_rect(rl.Rectangle(0, 0, *session.profile.size)) gui_app.push_widget(page) def replay_entry(session): button = BigButton('replay', 'recorded driving routes on this device') button.set_click_callback(lambda: open_replay(session)) return button