mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
1006 lines
48 KiB
Python
1006 lines
48 KiB
Python
"""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
|