bombsquad-plugin-manager/plugins/utilities/replay.py

1619 lines
55 KiB
Python
Raw Normal View History

2025-08-03 13:53:51 +03:00
# Copyright 2025 - Solely by BrotherBoard
# Intended for personal use only
# Bug? Feedback? Telegram >> @BroBordd
"""
2025-11-06 12:30:07 +02:00
Replay v3.0 - Simple replay player
2025-08-03 13:53:51 +03:00
Experimental. Feedback is appreciated.
Adds a button to pause menu and watch menu.
Features:
- Common features (pause/play/seek/speed/replay)
- Press on progress bar to seek anywhere
- Advanced free camera target control
2025-11-06 12:30:07 +02:00
- Ability to zoom in/out anywhere in the 3D world
- Instant replay start with progressive duration scanning
- Stream-like buffering (like YouTube) which plays while loading
2025-08-03 13:53:51 +03:00
- Good UI with detailed toast pop ups
- Ability to show/hide UI
2025-11-06 12:30:07 +02:00
- Uses threading for non-blocking duration calculation
- Dynamic progress bar that grows with estimated duration
- Visual scan progress indicator with alternating % and time display
2025-08-03 13:53:51 +03:00
"""
from babase import Plugin
2025-11-06 12:30:07 +02:00
import bauiv1 as bui
from bauiv1 import CallPartial
import bascenev1 as bs
import _babase as _ba
import os
from time import strftime, gmtime, time
from random import uniform
2025-08-03 13:53:51 +03:00
from threading import Thread
from struct import unpack
2025-11-06 14:40:23 +00:00
2025-08-03 13:53:51 +03:00
class Replay:
2025-11-06 12:30:07 +02:00
VER = '3.0'
2025-08-10 13:02:22 +00:00
COL1 = (0.18, 0.18, 0.18)
COL2 = (1, 1, 1)
COL3 = (0, 1, 0)
COL4 = (0, 1, 1)
2025-08-03 13:53:51 +03:00
BUSY = False
2025-08-10 13:02:22 +00:00
2025-08-03 13:53:51 +03:00
@classmethod
2025-11-06 12:30:07 +02:00
def is_busy(cls, value=None):
if value is None:
return cls.BUSY
cls.BUSY = value
def __init__(self, source=None):
self.selected = self.replay_name = self.buffer = None
self.error = False
self.huffman = _Huffman()
self.parent = self.create_container(
2025-08-03 13:53:51 +03:00
src=source.get_screen_space_center(),
2025-11-06 12:30:07 +02:00
p=get_overlay_stack(),
2025-08-10 13:02:22 +00:00
size=(400, 500),
2025-11-06 12:30:07 +02:00
oac=lambda: (
2025-11-06 14:40:23 +00:00
bui.containerwidget(
self.parent, transition='out_scale' if source and source.exists() else 'out_left'),
2025-11-06 12:30:07 +02:00
self.play_sound('laser'),
self.transition_sound.stop()
)
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
self.transition_sound = self.play_sound('powerup01')
self.create_text(
p=self.parent,
2025-08-03 13:53:51 +03:00
h_align='center',
text='Replay',
2025-08-10 13:02:22 +00:00
pos=(175, 460),
2025-08-03 13:53:51 +03:00
scale=2
)
2025-11-06 12:30:07 +02:00
scroll_y = 360
scroll_parent = bui.scrollwidget(
parent=self.parent,
size=(scroll_y, scroll_y),
2025-08-10 13:02:22 +00:00
position=(25, 80)
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
self.replays_dir = bui.get_replays_dir()
replays = [f for f in os.listdir(self.replays_dir) if f.endswith('.brp')]
scroll_height = 30 * len(replays)
scroll_content = bui.containerwidget(
parent=scroll_parent,
2025-08-03 13:53:51 +03:00
background=False,
2025-11-06 12:30:07 +02:00
size=(scroll_y, scroll_height)
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
self.replay_widgets = []
for idx, replay_file in enumerate(replays):
widget = self.create_text(
p=scroll_content,
2025-08-03 13:53:51 +03:00
click_activate=True,
selectable=True,
2025-11-06 12:30:07 +02:00
pos=(0, scroll_height - 30 * idx - 30),
text=replay_file,
maxwidth=scroll_y,
size=(scroll_y, 30),
color=self.COL2,
oac=CallPartial(self.highlight, idx, replay_file)
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
self.replay_widgets.append(widget)
self.play_source = None
for i in range(3):
btn = self.create_button(
p=self.parent,
pos=(25 + 120 * i, 30),
2025-08-10 13:02:22 +00:00
size=(120, 40),
2025-11-06 12:30:07 +02:00
label=['Show', 'Copy', 'Run'][i],
oac=CallPartial(self.execute, [self.show, self.copy, self.play][i]),
icon=bui.gettexture(['folder', 'file', 'nextLevelIcon'][i])
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
if i == 2:
self.play_source = btn
def play_sound(self, name):
sound = bui.getsound(name)
sound.play()
bui.apptimer(uniform(0.14, 0.17), sound.stop)
return sound
def get_replay_path(self):
return os.path.join(self.replays_dir, self.replay_name)
def copy(self):
self.play_sound('dingSmallHigh')
bui.clipboard_set_text(self.get_replay_path())
bui.screenmessage('Copied replay path to clipboard!', color=self.COL3)
def show(self):
bui.getsound('ding').play()
bui.screenmessage(self.get_replay_path(), color=self.COL3)
def execute(self, func):
if self.selected is None:
show_warning('Select a replay!')
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
if bs.is_in_replay():
show_warning('A replay is already running!')
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
return func()
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def highlight(self, idx, name):
if self.selected == idx:
self.play_source = self.replay_widgets[idx]
self.play()
2025-08-03 13:53:51 +03:00
return
2025-11-06 12:30:07 +02:00
self.selected = idx
self.replay_name = name
[bui.textwidget(w, color=self.COL2) for w in self.replay_widgets]
bui.textwidget(self.replay_widgets[idx], color=self.COL3)
def play(self):
"""Start replay immediately without waiting for duration calculation"""
if self.is_busy():
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
self.is_busy(True)
bui.getsound('deek').play()
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Start replay immediately
bs.set_replay_speed_exponent(0)
bui.fade_screen(1)
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Create player with unknown duration
Player(path=self.get_replay_path(), duration=None)
self.is_busy(False)
def load(self):
src = self.play_source.get_screen_space_center()
if self.play_source.get_widget_type() == 'text':
src = (src[0] - 170, src[1])
self.parent_container = container = self.create_container(
2025-08-03 13:53:51 +03:00
src=src,
2025-08-10 13:02:22 +00:00
size=(300, 200),
2025-11-06 12:30:07 +02:00
p=get_overlay_stack()
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
self.create_text(
p=container,
2025-08-03 13:53:51 +03:00
text='Player',
2025-08-10 13:02:22 +00:00
pos=(125, 150),
2025-08-03 13:53:51 +03:00
h_align='center',
scale=1.4
)
2025-11-06 12:30:07 +02:00
bui.spinnerwidget(
parent=container,
2025-08-03 13:53:51 +03:00
size=60,
2025-08-10 13:02:22 +00:00
position=(75, 100)
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
self.status_text = self.create_text(
p=container,
2025-08-03 13:53:51 +03:00
text='Reading...',
2025-08-10 13:02:22 +00:00
pos=(115, 87)
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
self.progress_text = self.create_text(
p=container,
2025-08-10 13:02:22 +00:00
pos=(125, 30),
2025-08-03 13:53:51 +03:00
maxwidth=240,
2025-11-06 12:30:07 +02:00
text=f'{self.replay_name} with total of {os.path.getsize(self.get_replay_path())} bytes\nstreaming bytes to pybrp_stream',
2025-08-03 13:53:51 +03:00
h_align='center'
)
2025-11-06 12:30:07 +02:00
self.progress_text2 = self.create_text(
p=container,
2025-08-03 13:53:51 +03:00
maxwidth=240,
2025-08-10 13:02:22 +00:00
pos=(30, 20),
2025-08-03 13:53:51 +03:00
v_align='bottom'
)
2025-11-06 12:30:07 +02:00
self.progress = [0, 1]
bui.apptimer(0.5, Thread(target=self.calculate).start)
bui.apptimer(0.5, self.update_progress)
self.wait_for_calculation(self.finish_calculation)
def update_progress(self):
current, total = self.progress
bui.apptimer(0.1, self.update_progress) if (current != total) and (not self.error) else 0
if not current:
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
percent = current / total * 100
bar = '\u2588' * int(percent) + '\u2591' * int(100 - percent)
if not self.error:
2025-08-03 13:53:51 +03:00
try:
2025-11-06 12:30:07 +02:00
bui.textwidget(self.progress_text, text=bar)
bui.textwidget(self.progress_text2, text=f'{current} of {total} bytes read')
2025-08-10 13:02:22 +00:00
except:
return
2025-11-06 12:30:07 +02:00
def calculate(self):
2025-08-10 13:02:22 +00:00
try:
2025-11-06 12:30:07 +02:00
self.buffer = get_replay_duration(self.huffman, self.get_replay_path(), self.progress)
2025-08-10 13:02:22 +00:00
except:
2025-11-06 12:30:07 +02:00
self.buffer = 0
def finish_calculation(self, duration):
bui.textwidget(self.status_text, text='Starting...' if duration else 'Wait what?')
2025-11-06 14:40:23 +00:00
bui.textwidget(self.progress_text2,
text=f'result was {duration} milleseconds') if duration else duration
2025-11-06 12:30:07 +02:00
if not duration:
self.error = True
2025-11-06 14:40:23 +00:00
bui.textwidget(
self.progress_text, text='pybrp returned zero duration, error?\nclosing this window in 5 seconds')
2025-11-06 12:30:07 +02:00
bui.textwidget(self.progress_text2, text='')
bui.apptimer(1 if duration else 5, CallPartial(self.start_player, duration))
def wait_for_calculation(self, callback, iterations=60):
if not iterations:
self.buffer = None
callback(None)
2025-08-03 13:53:51 +03:00
return
2025-11-06 12:30:07 +02:00
if self.buffer is not None:
result = self.buffer
self.buffer = None
callback(result)
2025-08-03 13:53:51 +03:00
return
2025-11-06 12:30:07 +02:00
bui.apptimer(0.5, CallPartial(self.wait_for_calculation, callback, iterations - 1))
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def start_player(self, duration):
if duration == 0:
show_warning("Couldn't load replay!")
bui.containerwidget(self.parent_container, transition='out_scale')
self.is_busy(False)
2025-08-03 13:53:51 +03:00
return
2025-11-06 12:30:07 +02:00
bs.set_replay_speed_exponent(0)
bui.fade_screen(1)
Player(path=self.get_replay_path(), duration=duration)
self.is_busy(False)
def create_button(self, p=None, oac=None, pos=None, **kwargs):
return bui.buttonwidget(
parent=p,
color=self.COL1,
textcolor=self.COL2,
on_activate_call=oac,
position=pos,
button_type='square',
enable_sound=False,
**kwargs
)
2025-08-03 13:53:51 +03:00
2025-11-06 12:30:07 +02:00
def create_container(self, p=None, pos=None, src=None, oac=None, **kwargs):
return bui.containerwidget(
color=self.COL1,
parent=p,
position=pos,
scale_origin_stack_offset=src,
transition='in_scale',
on_outside_click_call=oac,
**kwargs
)
def create_text(self, color=None, oac=None, p=None, pos=None, **kwargs):
return bui.textwidget(
parent=p,
position=pos,
color=color or self.COL2,
on_activate_call=oac,
**kwargs
)
2025-08-10 13:02:22 +00:00
2025-11-06 14:40:23 +00:00
2025-08-03 13:53:51 +03:00
class Player:
TICK = 0.01
2025-08-10 13:02:22 +00:00
COL0 = (0.5, 0, 0)
COL1 = (1, 0, 0)
COL2 = (0.5, 0.5, 0)
COL3 = (1, 1, 0)
COL4 = (0, 0.5, 0)
COL5 = (0, 1, 0)
COL6 = (0, 0.5, 0.5)
COL7 = (0, 1, 1)
COL8 = (0.6, 0.6, 0.6)
COL9 = (8, 0, 0)
COL10 = (0.5, 0.25, 0)
COL11 = (1, 0.5, 0)
COL12 = (0.5, 0.25, 0.5)
COL13 = (1, 0.5, 1)
COL14 = (0.5, 0.5, 0.5)
COL15 = (1, 1, 1)
2025-08-03 13:53:51 +03:00
COL16 = (0.1, 0.2, 0.4)
COL17 = (1, 1.7, 2)
2025-11-06 12:30:07 +02:00
COL18 = (0.45, 0.45, 0.45)
COL19 = (1, 0.8, 0)
def __init__(self, path, duration):
self.path = path
self.duration_ms = duration # Will be None initially
self.duration_sec = None if duration is None else duration / 1000
self.scanning = duration is None
self.scan_progress = [0, 1]
self.estimated_duration = 10.0 # Start with 10 second estimate
self.confirmed_duration_sec = 0
self.paused = self.ui_hidden = self.camera_on = self.cinema_mode = self.manual_zoom = False
self.camera_look = None
self.replay_time = self.start_time = self.progress_val = 0
self.camera_zoom = 1
2025-11-06 14:40:23 +00:00
[setattr(self, attr, []) for attr in ['ui_widgets', 'camera_widgets',
'hide_widgets', 'cinema_widgets', 'cinema_ui_widgets']]
2025-11-06 12:30:07 +02:00
# Start replay immediately
bs.new_replay_session(path)
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
width, height = bui.get_virtual_screen_size()
self.bar_height = 80
self.parent = bui.containerwidget(
size=(width, self.bar_height),
stack_offset=(0, -height / 2 + self.bar_height / 2),
2025-08-03 13:53:51 +03:00
background=False
)
2025-11-06 12:30:07 +02:00
self.background = bui.imagewidget(
parent=self.parent,
texture=bui.gettexture('black'),
size=(width + 3, self.bar_height + 5),
2025-08-10 13:02:22 +00:00
position=(0, -2),
2025-08-03 13:53:51 +03:00
opacity=0.4
)
2025-11-06 12:30:07 +02:00
self.create_ui()
self.create_hide_button()
self.speed = 1
self.start_focus()
self.play()
if self.scanning:
self.huffman = _Huffman()
self.scan_cycle = 0 # For alternating display
Thread(target=self.calculate_duration).start()
self.scan_timer = bui.AppTimer(0.1, self.update_scan_progress, repeat=True)
def calculate_duration(self):
"""Calculate duration in background thread with reduced load"""
import time as pytime
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
try:
total_ms = 0
last_yield = pytime.time()
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
with open(self.path, 'rb') as f:
f.seek(0, 2)
file_size = f.tell()
self.scan_progress[1] = file_size
f.seek(6)
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
while True:
current_pos = f.tell()
self.scan_progress[0] = current_pos
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Yield control every 10ms to prevent freezing
current_time = pytime.time()
if current_time - last_yield > 0.01:
pytime.sleep(0.005) # Small sleep to reduce CPU load
last_yield = current_time
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
b_data = f.read(1)
if not b_data:
break
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
b1, comp_len = b_data[0], 0
if b1 < 254:
comp_len = b1
elif b1 == 254:
comp_len = int.from_bytes(f.read(2), 'little')
else:
comp_len = int.from_bytes(f.read(4), 'little')
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
if comp_len == 0:
continue
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
raw_msg = self.huffman.decompress(f.read(comp_len))
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
if not raw_msg or raw_msg[0] != 1:
continue
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
sub_off = 1
while sub_off + 2 <= len(raw_msg):
sub_size_bytes = raw_msg[sub_off:sub_off+2]
if len(sub_size_bytes) < 2:
break
sub_size = int.from_bytes(sub_size_bytes, 'little')
sub_off += 2
if sub_off + sub_size > len(raw_msg):
break
sub_data = raw_msg[sub_off:sub_off+sub_size]
if len(sub_data) >= 2 and sub_data[0] == 0:
total_ms += sub_data[1]
sub_off += sub_size
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Update confirmed duration (this is safe to seek to!)
self.confirmed_duration_sec = total_ms / 1000
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Update estimated duration based on progress
if current_pos > 1000: # After reading at least 1KB
progress_percent = current_pos / file_size
if progress_percent > 0:
estimated_total_ms = total_ms / progress_percent
self.estimated_duration = max(10.0, estimated_total_ms / 1000)
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
self.scan_progress[0] = self.scan_progress[1]
self.duration_ms = total_ms
self.duration_sec = total_ms / 1000
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
except Exception as e:
self.duration_ms = 0
self.duration_sec = 0
finally:
self.scanning = False
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
def update_scan_progress(self):
"""Update duration text with scan progress - alternates between % and estimated time"""
if not self.scanning:
self.scan_timer = None
# Fade out loading bar
if hasattr(self, 'loading_bar') and self.loading_bar.exists():
bui.imagewidget(self.loading_bar, opacity=0)
if self.main_bar.exists():
bui.imagewidget(self.main_bar, opacity=0.6)
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Update to show final duration with normal color
if hasattr(self, 'duration_time_text') and self.duration_time_text.exists():
bui.textwidget(
self.duration_time_text,
text=format_time(self.duration_sec),
color=self.COL6
)
return
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Show scanning progress
if hasattr(self, 'duration_time_text') and self.duration_time_text.exists():
current, total = self.scan_progress
if total > 0:
percent = int((current / total) * 100)
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Cycle between showing percentage and estimated time
self.scan_cycle += 1
show_percent = (self.scan_cycle // 10) % 2 == 0 # Switch every second
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
if show_percent:
# Show scan percentage
bui.textwidget(
self.duration_time_text,
text=f'Scan {percent}%',
color=self.COL6
)
else:
# Show estimated duration in orange
est_mins = int(self.estimated_duration // 60)
est_secs = int(self.estimated_duration % 60)
bui.textwidget(
self.duration_time_text,
text=format_time(self.estimated_duration),
color=self.COL19 # Orange color
)
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Update loading bar width based on scan progress
if hasattr(self, 'loading_bar') and self.loading_bar.exists():
loading_width = (current / total) * self.progress_width
bui.imagewidget(self.loading_bar, size=(loading_width, 5))
def create_hide_button(self):
widgets = self.hide_widgets.append
self.hide_icons = ['\u25bc', '\u25b2']
self.hide_button = self.create_button(
p=self.parent,
2025-08-10 13:02:22 +00:00
pos=(20, 15),
size=(50, 50),
2025-11-06 12:30:07 +02:00
oac=self.toggle_hide,
color=self.COL10
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
widgets(self.hide_button)
self.hide_text = bui.textwidget(
parent=self.parent,
text=self.hide_icons[self.ui_hidden],
2025-08-10 13:02:22 +00:00
position=(44, 30),
2025-08-03 13:53:51 +03:00
scale=2,
shadow=0.4,
2025-11-06 12:30:07 +02:00
color=self.COL11
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
widgets(self.hide_text)
widgets(bui.imagewidget(
parent=self.parent,
2025-08-10 13:02:22 +00:00
position=(18, 13),
size=(54, 54),
2025-11-06 12:30:07 +02:00
color=self.COL10,
texture=bui.gettexture('white'),
2025-08-03 13:53:51 +03:00
opacity=0.4
))
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def destroy_hide_button(self):
[w.delete() for w in self.hide_widgets]
self.hide_widgets.clear()
def create_ui(self):
"""Modified to handle unknown duration initially"""
self.ui_visible = True
widgets = self.ui_widgets.append
width, height = bui.get_virtual_screen_size()
bar_height = self.bar_height
parent = self.parent
# Exit button
widgets(self.create_button(
p=parent,
pos=(width - 65, 15),
2025-08-10 13:02:22 +00:00
size=(50, 50),
2025-11-06 12:30:07 +02:00
color=self.COL0,
oac=self.exit
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
color = self.COL1
widgets(bui.imagewidget(
parent=parent,
texture=bui.gettexture('crossOut'),
color=(color[0] * 10, color[1] * 10, color[2] * 10),
position=(width - 60, 20),
2025-08-10 13:02:22 +00:00
size=(40, 40)
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
# Speed buttons
for i in range(2):
arrow = ['FAST_FORWARD_BUTTON', 'REWIND_BUTTON'][i]
pos = (width - 130 - 260 * i, 15)
widgets(self.create_button(
p=parent,
2025-08-03 13:53:51 +03:00
pos=pos,
2025-08-10 13:02:22 +00:00
size=(50, 50),
2025-11-06 12:30:07 +02:00
color=self.COL2,
oac=CallPartial(self.change_speed, [1, -1][i]),
2025-08-03 13:53:51 +03:00
repeat=True
))
2025-11-06 12:30:07 +02:00
widgets(bui.textwidget(
parent=parent,
text=bui.charstr(getattr(bui.SpecialChar, arrow)),
color=self.COL3,
position=(pos[0] - 2, pos[1] + 13),
2025-08-03 13:53:51 +03:00
h_align='center',
v_align='center',
scale=1.8,
shadow=0.3
))
2025-11-06 12:30:07 +02:00
# Seek buttons
for i in range(2):
arrow = ['RIGHT_ARROW', 'LEFT_ARROW'][i]
pos = (width - 195 - 130 * i, 15)
widgets(self.create_button(
p=parent,
2025-08-03 13:53:51 +03:00
pos=pos,
2025-08-10 13:02:22 +00:00
size=(50, 50),
2025-11-06 12:30:07 +02:00
color=self.COL4,
oac=CallPartial(self.seek, [1, -1][i]),
2025-08-03 13:53:51 +03:00
repeat=True
))
2025-11-06 12:30:07 +02:00
widgets(bui.textwidget(
parent=parent,
text=bui.charstr(getattr(bui.SpecialChar, arrow)),
color=self.COL5,
position=(pos[0] - 1, pos[1] + 12),
2025-08-03 13:53:51 +03:00
h_align='center',
v_align='center',
scale=1.7,
shadow=0.2
))
2025-11-06 12:30:07 +02:00
# Pause button
pos = (width - 260, 15)
widgets(self.create_button(
p=parent,
2025-08-03 13:53:51 +03:00
pos=pos,
2025-08-10 13:02:22 +00:00
size=(50, 50),
2025-11-06 12:30:07 +02:00
color=self.COL6,
oac=self.toggle_pause
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
self.pause_text = bui.textwidget(
parent=parent,
color=self.COL7,
position=(pos[0] + 12, pos[1] + 11),
2025-08-03 13:53:51 +03:00
scale=1.5,
shadow=0.3
)
2025-11-06 12:30:07 +02:00
widgets(self.pause_text)
self.toggle_pause(dry=True)
# Restart button
pos = (width - 455, 15)
widgets(self.create_button(
p=parent,
2025-08-03 13:53:51 +03:00
pos=pos,
2025-08-10 13:02:22 +00:00
size=(50, 50),
2025-11-06 12:30:07 +02:00
color=self.COL12,
oac=self.restart
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
color = self.COL13
scale = 1.5
widgets(bui.imagewidget(
parent=parent,
texture=bui.gettexture('replayIcon'),
color=(color[0] * scale, color[1] * scale, color[2] * scale),
position=(pos[0] + 2, pos[1] + 1),
2025-08-10 13:02:22 +00:00
size=(47, 47),
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
# Progress bar
pos = (285, bar_height / 2 - 2)
self.progress_width = width - 790
self.main_bar = bui.imagewidget(
parent=parent,
texture=bui.gettexture('white'),
size=(self.progress_width, 5),
2025-08-03 13:53:51 +03:00
position=pos,
2025-11-06 12:30:07 +02:00
opacity=0.2 if self.scanning else 0.6,
color=self.COL8
)
widgets(self.main_bar)
# Secondary progress bar
self.loading_bar = bui.imagewidget(
parent=parent,
texture=bui.gettexture('white'),
size=(0, 5),
position=pos,
opacity=0.5,
color=self.COL18
)
widgets(self.loading_bar)
# Nub
self.nub_pos = (pos[0] - 24, pos[1] - 22)
self.nub = bui.imagewidget(
parent=parent,
texture=bui.gettexture('nub'),
2025-08-10 13:02:22 +00:00
size=(50, 50),
2025-11-06 12:30:07 +02:00
position=self.nub_pos,
2025-08-03 13:53:51 +03:00
opacity=0.4,
2025-11-06 12:30:07 +02:00
color=self.COL9
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
widgets(self.nub)
# Time displays
self.current_time_text = bui.textwidget(
parent=parent,
2025-08-10 13:02:22 +00:00
position=(155, 40),
2025-11-06 12:30:07 +02:00
color=self.COL7,
text=format_time(self.replay_time - self.start_time),
maxwidth=100
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
widgets(self.current_time_text)
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Duration display - alternates between scan% and estimated time while scanning
self.duration_time_text = bui.textwidget(
parent=parent,
2025-08-10 13:02:22 +00:00
position=(155, 11),
2025-11-06 12:30:07 +02:00
text='Scan 0%' if self.scanning else format_time(self.duration_sec),
color=self.COL6,
maxwidth=100
)
widgets(self.duration_time_text)
# Seekbar sensors
sensor_x, sensor_y = (285, 15)
sensor_count = 100
tile_width = self.progress_width / sensor_count
for i in range(sensor_count):
widgets(bui.buttonwidget(
2025-08-03 13:53:51 +03:00
label='',
2025-11-06 12:30:07 +02:00
parent=parent,
position=(sensor_x + tile_width * i, sensor_y),
size=(tile_width, 50),
texture=bui.gettexture('empty'),
2025-08-03 13:53:51 +03:00
enable_sound=False,
2025-11-06 12:30:07 +02:00
on_activate_call=CallPartial(self.jump, i / sensor_count),
2025-08-03 13:53:51 +03:00
selectable=False
))
2025-11-06 12:30:07 +02:00
# Camera button
widgets(self.create_button(
p=self.parent,
2025-08-10 13:02:22 +00:00
pos=(85, 15),
size=(50, 50),
2025-11-06 12:30:07 +02:00
color=self.COL14,
oac=self.toggle_camera
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
color = self.COL15
scale = 1.5
widgets(bui.imagewidget(
parent=self.parent,
texture=bui.gettexture('achievementOutline'),
2025-08-10 13:02:22 +00:00
position=(88, 18),
2025-11-06 12:30:07 +02:00
color=(color[0] * scale, color[1] * scale, color[2] * scale),
2025-08-10 13:02:22 +00:00
size=(45, 45)
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
# Info panel
info_w, info_h = (443, 98)
self.info_bg = bui.imagewidget(
texture=bui.gettexture('white'),
position=(width - 456, 100),
parent=parent,
size=(info_w, info_h),
2025-08-03 13:53:51 +03:00
opacity=0
)
2025-11-06 12:30:07 +02:00
widgets(self.info_bg)
self.info_title = bui.textwidget(
position=(width - info_w + 182.5, info_h + 64),
2025-08-03 13:53:51 +03:00
h_align='center',
scale=1.2,
2025-11-06 12:30:07 +02:00
parent=parent,
maxwidth=info_w - 20
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
widgets(self.info_title)
self.info_text = bui.textwidget(
position=(width - info_w + 182.5, info_h + 10),
2025-08-03 13:53:51 +03:00
h_align='center',
2025-11-06 12:30:07 +02:00
parent=parent,
maxwidth=info_w - 20
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
widgets(self.info_text)
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def create_camera_ui(self):
widgets = self.camera_widgets.append
cam_x, cam_y = (19, 100)
self.camera_bg = bui.imagewidget(
parent=self.parent,
2025-08-10 13:02:22 +00:00
size=(235, 260),
2025-11-06 12:30:07 +02:00
position=(cam_x, cam_y),
texture=bui.gettexture('white'),
color=self.COL14,
2025-08-03 13:53:51 +03:00
opacity=0.05
)
2025-11-06 12:30:07 +02:00
widgets(self.camera_bg)
self.camera_bg2 = bui.imagewidget(
parent=self.parent,
2025-08-10 13:02:22 +00:00
size=(235, 100),
2025-08-03 13:53:51 +03:00
opacity=0.05,
2025-11-06 12:30:07 +02:00
position=(cam_x, cam_y + 267),
color=self.COL14,
texture=bui.gettexture('white')
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
widgets(self.camera_bg2)
self.fade_camera()
widgets(bui.textwidget(
parent=self.parent,
2025-08-03 13:53:51 +03:00
h_align='center',
v_align='center',
text='To maintain animated\nsmooth camera, keep\nzoom at auto.',
maxwidth=205,
max_height=190,
2025-11-06 12:30:07 +02:00
position=(cam_x + 92, cam_y + 300),
color=self.COL15
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
widgets(self.create_button(
p=self.parent,
2025-08-03 13:53:51 +03:00
label='Reset',
2025-11-06 12:30:07 +02:00
color=self.COL14,
textcolor=self.COL15,
2025-08-10 13:02:22 +00:00
size=(205, 30),
2025-11-06 12:30:07 +02:00
pos=(cam_x + 15, cam_y + 7),
oac=self.reset_camera
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
widgets(bui.imagewidget(
parent=self.parent,
2025-08-10 13:02:22 +00:00
size=(219, 2),
2025-11-06 12:30:07 +02:00
position=(cam_x + 8, cam_y + 44),
texture=bui.gettexture('white'),
color=self.COL15,
2025-08-03 13:53:51 +03:00
opacity=0.6
))
2025-11-06 12:30:07 +02:00
widgets(self.create_button(
p=self.parent,
2025-08-03 13:53:51 +03:00
label='Look',
2025-11-06 12:30:07 +02:00
pos=(cam_x + 15, cam_y + 53),
color=self.COL14,
textcolor=self.COL15,
2025-08-10 13:02:22 +00:00
size=(205, 30),
2025-11-06 12:30:07 +02:00
oac=self.look
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
self.look_text = bui.textwidget(
parent=self.parent,
text=str(round_tuple(self.camera_look) if self.camera_look else 'players'),
2025-08-03 13:53:51 +03:00
v_align='center',
h_align='center',
2025-11-06 12:30:07 +02:00
position=(cam_x + 92, cam_y + 90),
color=self.COL14,
2025-08-03 13:53:51 +03:00
maxwidth=205,
max_height=40
)
2025-11-06 12:30:07 +02:00
widgets(self.look_text)
widgets(bui.textwidget(
parent=self.parent,
2025-08-03 13:53:51 +03:00
text='Currently looking at:',
v_align='center',
h_align='center',
2025-11-06 12:30:07 +02:00
position=(cam_x + 92, cam_y + 120),
color=self.COL15,
2025-08-03 13:53:51 +03:00
maxwidth=205,
max_height=40
))
2025-11-06 12:30:07 +02:00
widgets(bui.imagewidget(
parent=self.parent,
2025-08-10 13:02:22 +00:00
size=(219, 2),
2025-11-06 12:30:07 +02:00
position=(cam_x + 8, cam_y + 154),
texture=bui.gettexture('white'),
color=self.COL15,
2025-08-03 13:53:51 +03:00
opacity=0.6
))
2025-11-06 12:30:07 +02:00
[widgets(self.create_button(
p=self.parent,
label=['-', '+'][i],
pos=(cam_x + 13 + 113 * i, cam_y + 163),
color=self.COL14,
textcolor=self.COL15,
2025-08-10 13:02:22 +00:00
size=(98, 30),
2025-08-03 13:53:51 +03:00
repeat=True,
2025-11-06 12:30:07 +02:00
oac=CallPartial(self.zoom, [1, -1][i])
)) for i in [0, 1]]
self.zoom_text = bui.textwidget(
parent=self.parent,
text=f'x{round(0.5 ** (self.camera_zoom - 1), 2)}' if self.camera_zoom != 1 else 'x1.0' if self.manual_zoom else 'auto',
2025-08-03 13:53:51 +03:00
v_align='center',
h_align='center',
2025-11-06 12:30:07 +02:00
position=(cam_x + 92, cam_y + 200),
color=self.COL14,
2025-08-03 13:53:51 +03:00
maxwidth=205,
max_height=40
)
2025-11-06 12:30:07 +02:00
widgets(self.zoom_text)
widgets(bui.textwidget(
parent=self.parent,
2025-08-03 13:53:51 +03:00
text='Current zoom:',
v_align='center',
h_align='center',
2025-11-06 12:30:07 +02:00
position=(cam_x + 92, cam_y + 227),
color=self.COL15,
2025-08-03 13:53:51 +03:00
maxwidth=205,
max_height=40
))
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def zoom(self, direction):
new_zoom = round(self.camera_zoom + direction * 0.05, 2)
if self.camera_zoom == 1 and not self.manual_zoom:
_ba.set_camera_manual(True)
self.camera_pos = _ba.get_camera_position()
self.camera_look = _ba.get_camera_target()
bui.textwidget(self.look_text, text=str(round_tuple(self.camera_look)))
if new_zoom == 1 and not self.manual_zoom:
_ba.set_camera_manual(False)
self.camera_look = None
bui.textwidget(self.look_text, text='players')
self.camera_zoom = new_zoom
2025-11-06 14:40:23 +00:00
bui.textwidget(
self.zoom_text, text=f'x{round(0.5 ** (new_zoom - 1), 2)}' if new_zoom != 1 else 'x1.0' if self.manual_zoom else 'auto')
2025-11-06 12:30:07 +02:00
self.apply_zoom()
def look(self):
self.destroy_ui()
self.destroy_hide_button()
self.fade_hide_button(0.4, -0.1)
self.look_backup = self.camera_look
self.pos_backup = _ba.get_camera_position()
self.manual_backup = self.manual_zoom
self.create_cinema_sensors()
self.create_cinema_ui()
self.create_cinema_button()
self.create_cinema_indicator()
def update_look(self, dx, dy):
origin = self.camera_look or _ba.get_camera_target()
scale = 0.7 * self.camera_zoom
self.camera_look = new_look = (origin[0] + dx * scale, origin[1] + dy * scale, origin[2])
0 if self.cinema_mode else bui.textwidget(self.cinema_text, text=str(round_tuple(new_look)))
self.camera_pos = _ba.get_camera_position()
if self.camera_zoom != 1:
self.manual_zoom = True
self.camera_zoom = 1
def start_focus(self):
self.focus_timer = bui.AppTimer(0.01, self.focus, repeat=True)
def focus(self):
_ba.set_camera_target(*self.camera_look) if self.camera_look else 0
def apply_zoom(self):
if self.camera_zoom == 1 and not self.manual_zoom:
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
zoom = self.camera_zoom
target_x, target_y, target_z = _ba.get_camera_target()
pos_x, pos_y, pos_z = self.camera_pos
new_pos_x = target_x + (pos_x - target_x) * zoom
new_pos_y = target_y + (pos_y - target_y) * zoom
new_pos_z = target_z + (pos_z - target_z) * zoom
_ba.set_camera_position(new_pos_x, new_pos_y, new_pos_z)
def stop_focus(self):
self.focus_timer = None
def save_cinema(self):
self.exit_cinema()
def exit_cinema(self):
self.destroy_cinema()
self.create_ui()
self.create_hide_button()
self.fade_hide_button(0, 0.1)
self.update_progress_ui()
def create_cinema_sensors(self):
width, height = bui.get_virtual_screen_size()
tile = 50
cols = int(width / tile)
rows = int(height / tile)
half_cols = cols / 2
half_rows = rows / 2
[self.cinema_widgets.append(bui.buttonwidget(
parent=self.parent,
size=(tile, tile),
position=(i * tile, j * tile),
texture=bui.gettexture('empty'),
2025-08-03 13:53:51 +03:00
enable_sound=False,
2025-11-06 12:30:07 +02:00
on_activate_call=CallPartial(self.update_look, i - half_cols, j - half_rows),
2025-08-03 13:53:51 +03:00
label='',
repeat=True
))
2025-11-06 14:40:23 +00:00
for i in range(cols)
for j in range(rows)]
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def create_cinema_ui(self):
widgets = self.cinema_ui_widgets.append
widgets(bui.imagewidget(
parent=self.parent,
2025-08-10 13:02:22 +00:00
position=(0, 3),
2025-11-06 12:30:07 +02:00
color=self.COL14,
2025-08-03 13:53:51 +03:00
opacity=0.4,
2025-11-06 12:30:07 +02:00
texture=bui.gettexture('white'),
2025-08-10 13:02:22 +00:00
size=(232, 190)
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
widgets(self.create_button(
p=self.parent,
2025-08-10 13:02:22 +00:00
pos=(14, 50),
size=(204, 30),
2025-08-03 13:53:51 +03:00
label='Target Players',
2025-11-06 12:30:07 +02:00
color=self.COL14,
textcolor=self.COL15,
oac=self.target_players
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
widgets(self.create_button(
p=self.parent,
2025-08-10 13:02:22 +00:00
pos=(10, 90),
2025-11-06 12:30:07 +02:00
color=self.COL14,
2025-08-03 13:53:51 +03:00
label='Cancel',
2025-08-10 13:02:22 +00:00
size=(99, 30),
2025-11-06 12:30:07 +02:00
textcolor=self.COL15,
oac=self.cancel_cinema
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
widgets(self.create_button(
p=self.parent,
2025-08-10 13:02:22 +00:00
pos=(123, 90),
2025-11-06 12:30:07 +02:00
color=self.COL14,
2025-08-03 13:53:51 +03:00
label='Save',
2025-08-10 13:02:22 +00:00
size=(99, 30),
2025-11-06 12:30:07 +02:00
textcolor=self.COL15,
oac=self.save_cinema
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
widgets(bui.textwidget(
parent=self.parent,
2025-08-10 13:02:22 +00:00
position=(90, 160),
2025-11-06 12:30:07 +02:00
color=self.COL15,
2025-08-03 13:53:51 +03:00
text='Currently looking at:',
h_align='center',
maxwidth=220
))
2025-11-06 12:30:07 +02:00
self.cinema_text = bui.textwidget(
parent=self.parent,
2025-08-10 13:02:22 +00:00
position=(90, 130),
2025-11-06 12:30:07 +02:00
color=self.COL14,
2025-08-03 13:53:51 +03:00
h_align='center',
2025-11-06 12:30:07 +02:00
text=str(round_tuple(self.camera_look) if self.camera_look else 'players')
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
widgets(self.cinema_text)
widgets(bui.imagewidget(
parent=self.parent,
2025-08-10 13:02:22 +00:00
position=(0, 200),
2025-11-06 12:30:07 +02:00
color=self.COL14,
2025-08-03 13:53:51 +03:00
opacity=0.4,
2025-11-06 12:30:07 +02:00
texture=bui.gettexture('white'),
2025-08-10 13:02:22 +00:00
size=(232, 110)
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
widgets(bui.textwidget(
parent=self.parent,
2025-08-10 13:02:22 +00:00
position=(90, 240),
2025-08-03 13:53:51 +03:00
text='Longpress anywhere\nto look around. Tap on \nsomething to look at it.\nPause for calmer control!',
h_align='center',
v_align='center',
maxwidth=225,
max_height=105
))
2025-11-06 12:30:07 +02:00
width, height = bui.get_virtual_screen_size()
crosshair = 20
[widgets(bui.imagewidget(
parent=self.parent,
2025-11-06 14:40:23 +00:00
position=(width / 2, height / 2 - crosshair / 2 + crosshair *
0.1) if i else (width / 2 - crosshair / 2, height / 2 + crosshair * 0.1),
2025-11-06 12:30:07 +02:00
size=(3, crosshair * 1.15) if i else (crosshair * 1.15, 3),
color=self.COL1,
texture=bui.gettexture('white')
)) for i in [0, 1]]
offset = 60
2025-08-03 13:53:51 +03:00
for j in range(2):
2025-11-06 12:30:07 +02:00
widgets(bui.imagewidget(
parent=self.parent,
texture=bui.gettexture('white'),
color=self.COL1,
position=(width / 2 + [-offset, offset - crosshair][j], height / 2 + offset),
size=(crosshair * 1.1, 3)
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
2025-08-03 13:53:51 +03:00
for j in range(2):
2025-11-06 12:30:07 +02:00
widgets(bui.imagewidget(
parent=self.parent,
texture=bui.gettexture('white'),
color=self.COL1,
2025-11-06 14:40:23 +00:00
position=(width / 2 + offset, height / 2 +
[offset - crosshair, -offset + crosshair * 0.3][j]),
2025-11-06 12:30:07 +02:00
size=(3, crosshair * +1.1)
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
2025-08-03 13:53:51 +03:00
for j in range(2):
2025-11-06 12:30:07 +02:00
widgets(bui.imagewidget(
parent=self.parent,
texture=bui.gettexture('white'),
color=self.COL1,
2025-11-06 14:40:23 +00:00
position=(width / 2 + [-offset, offset - crosshair][j],
height / 2 - crosshair / 2 - offset + crosshair * 0.8),
2025-11-06 12:30:07 +02:00
size=(crosshair * 1.1, 3)
2025-08-03 13:53:51 +03:00
))
2025-11-06 12:30:07 +02:00
2025-08-03 13:53:51 +03:00
for j in range(2):
2025-11-06 12:30:07 +02:00
widgets(bui.imagewidget(
parent=self.parent,
texture=bui.gettexture('white'),
color=self.COL1,
2025-11-06 14:40:23 +00:00
position=(width / 2 - offset, height / 2 +
[offset - crosshair, -offset + crosshair * 0.3][j]),
2025-11-06 12:30:07 +02:00
size=(3, crosshair * 1.1)
2025-08-03 13:53:51 +03:00
))
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def destroy_cinema_ui(self):
[w.delete() for w in self.cinema_ui_widgets]
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def toggle_cinema_hide(self):
if getattr(self, 'cinema_busy', 0):
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
self.cinema_mode = not self.cinema_mode
self.cinema_busy = True
if self.cinema_mode:
self.animate_cinema(204, 14, -1)
bui.buttonwidget(self.cinema_button, label=bui.charstr(bui.SpecialChar.UP_ARROW))
self.destroy_cinema_ui()
2025-08-03 13:53:51 +03:00
else:
2025-11-06 12:30:07 +02:00
bui.buttonwidget(self.cinema_button, texture=bui.gettexture('white'))
self.animate_cinema(36, 7, 1)
bui.imagewidget(self.cinema_indicator, opacity=0)
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def create_cinema_indicator(self):
self.cinema_indicator = bui.imagewidget(
parent=self.parent,
2025-08-10 13:02:22 +00:00
position=(7, 8),
2025-11-06 12:30:07 +02:00
color=self.COL14,
2025-08-03 13:53:51 +03:00
opacity=0,
2025-08-10 13:02:22 +00:00
size=(36, 33),
2025-11-06 12:30:07 +02:00
texture=bui.gettexture('white')
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
self.cinema_widgets.append(self.cinema_indicator)
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def create_cinema_button(self):
self.cinema_button = self.create_button(
p=self.parent,
2025-08-10 13:02:22 +00:00
pos=(14, 10),
2025-11-06 12:30:07 +02:00
color=self.COL14,
2025-08-03 13:53:51 +03:00
label='Cinema Mode',
2025-08-10 13:02:22 +00:00
size=(204, 30),
2025-11-06 12:30:07 +02:00
textcolor=self.COL15,
oac=self.toggle_cinema_hide
2025-08-03 13:53:51 +03:00
)
2025-11-06 12:30:07 +02:00
self.cinema_widgets.append(self.cinema_button)
def animate_cinema(self, width_val, x_val, direction):
width_val += (163 / 35) * direction
x_val += 0.2 * direction
bui.buttonwidget(self.cinema_button, size=(width_val, 30), position=(x_val, 10))
if not (14 >= x_val >= 7):
self.cinema_busy = False
if self.cinema_mode:
bui.buttonwidget(self.cinema_button, texture=bui.gettexture('empty'))
bui.imagewidget(self.cinema_indicator, opacity=0.4)
2025-08-03 13:53:51 +03:00
else:
2025-11-06 12:30:07 +02:00
self.create_cinema_ui()
self.cinema_button.delete()
self.create_cinema_button()
bui.buttonwidget(self.cinema_button, label='Cinema Mode')
2025-08-03 13:53:51 +03:00
return
2025-11-06 12:30:07 +02:00
bui.apptimer(0.004, CallPartial(self.animate_cinema, width_val, x_val, direction))
def destroy_cinema(self):
self.destroy_cinema_ui()
[w.delete() for w in self.cinema_widgets]
def get_all_widgets(self):
return self.get_deletable_widgets() + self.hide_widgets + self.cinema_widgets
def target_players(self):
self.camera_look = None
bui.textwidget(self.cinema_text, text='players')
if self.camera_zoom != 1 or self.manual_zoom:
self.camera_zoom = 1
self.manual_zoom = False
_ba.set_camera_manual(False)
def cancel_cinema(self):
self.camera_look = self.look_backup
self.camera_pos = self.pos_backup
self.manual_zoom = self.manual_backup
if self.camera_zoom != 1 or self.manual_zoom:
_ba.set_camera_manual(True)
_ba.set_camera_position(*self.camera_pos)
self.exit_cinema()
def toggle_camera(self):
if self.camera_on:
self.camera_on = False
[w.delete() for w in self.camera_widgets]
self.camera_widgets.clear()
self.fade_camera(0.4, -0.1)
2025-08-03 13:53:51 +03:00
else:
2025-11-06 12:30:07 +02:00
self.camera_on = True
self.create_camera_ui()
def reset_camera(self):
_ba.set_camera_manual(False)
self.camera_look = None
self.manual_zoom = False
self.camera_zoom = 1
bui.textwidget(self.look_text, text='players')
bui.textwidget(self.zoom_text, text='auto')
def fade_camera(self, opacity=0, delta=0.1):
if opacity > 0.4 or opacity < 0:
if delta < 0:
self.camera_bg.delete()
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
if not self.camera_bg.exists():
2025-08-03 13:53:51 +03:00
return
2025-11-06 12:30:07 +02:00
bui.imagewidget(self.camera_bg, opacity=opacity)
bui.imagewidget(self.camera_bg2, opacity=opacity)
bui.apptimer(0.02, CallPartial(self.fade_camera, opacity + delta, delta))
def restart(self):
self.loop()
self.fix_pause()
self.show_message('Replay', f'Version {Replay.VER} BETA', self.COL12, self.COL13)
def destroy_ui(self):
self.ui_visible = self.camera_on = False
[w.delete() for w in self.get_deletable_widgets()]
self.ui_widgets.clear()
self.camera_widgets.clear()
def get_deletable_widgets(self):
return self.ui_widgets + self.camera_widgets
def toggle_hide(self):
if getattr(self, 'hide_busy', 0):
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
self.hide_busy = True
if getattr(self, 'exit_timer', 0) and getattr(self, 'exit_ready', 0):
self.exit_ready = self.exit_timer = False
self.info_timer = None
self.ui_hidden = hidden = not self.ui_hidden
bui.textwidget(self.hide_text, text=self.hide_icons[hidden])
if hidden:
2025-11-06 14:40:23 +00:00
bui.apptimer(0.2, lambda: bui.buttonwidget(
self.hide_button, texture=bui.gettexture('empty')))
2025-11-06 12:30:07 +02:00
self.fade_hide_button(0.4, -0.05)
self.destroy_ui()
2025-08-03 13:53:51 +03:00
else:
2025-11-06 12:30:07 +02:00
bui.buttonwidget(self.hide_button, texture=bui.gettexture('white'))
self.fade_hide_button(0, 0.05)
self.create_ui()
self.update_progress_ui()
bui.apptimer(0.21, CallPartial(setattr, self, 'hide_busy', 0))
def fade_hide_button(self, opacity=0, delta=0.1):
if opacity > 0.4 or opacity < 0:
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
if not self.background.exists():
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
bui.imagewidget(self.background, opacity=opacity)
bui.apptimer(0.02, CallPartial(self.fade_hide_button, opacity + delta, delta))
def show_message(self, title, text, color1, color2):
if getattr(self, 'exit_timer', 0) and getattr(self, 'exit_ready', 0):
self.exit_ready = self.exit_timer = False
self.info_timer = None
bui.imagewidget(self.info_bg, color=color1)
bui.textwidget(self.info_title, text=title, color=color2)
bui.textwidget(self.info_text, text=text, color=color2)
self.fade_info()
self.info_timer = bui.AppTimer(1.5, self.hide_message)
def hide_message(self):
self.fade_info(0.7, -0.1)
[bui.textwidget(w, text='') for w in [self.info_title, self.info_text] if w.exists()]
def fade_info(self, opacity=0, delta=0.1):
if opacity > 0.7 or opacity < 0:
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
if not self.info_bg.exists():
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
bui.imagewidget(self.info_bg, opacity=opacity)
bui.apptimer(0.02, CallPartial(self.fade_info, opacity + delta, delta))
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def toggle_pause(self, dry=False, silent=False):
2025-08-03 13:53:51 +03:00
if not dry:
2025-11-06 12:30:07 +02:00
self.paused = not self.paused
icon = bui.charstr(getattr(bui.SpecialChar, ['PAUSE', 'PLAY'][self.paused] + '_BUTTON'))
bui.textwidget(self.pause_text, text=icon)
2025-08-10 13:02:22 +00:00
if not dry:
2025-11-06 12:30:07 +02:00
if not silent:
2025-11-06 14:40:23 +00:00
self.show_message(['Resume', 'Pause'][self.paused], os.path.basename(
self.path) + f' of {os.path.getsize(self.path)} bytes', self.COL6, self.COL7)
2025-11-06 12:30:07 +02:00
if self.paused:
self.stop()
bs.pause_replay()
2025-08-03 13:53:51 +03:00
else:
2025-11-06 12:30:07 +02:00
self.play()
bs.resume_replay()
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def fix_pause(self):
if not self.paused:
return
self.toggle_pause(silent=True)
bui.apptimer(0.02, CallPartial(self.toggle_pause, silent=True))
def update_clock(self):
current = time()
elapsed = current - self.real_time
self.real_time = current
self.replay_time += elapsed * self.speed
def change_speed(self, direction):
new_exp = bs.get_replay_speed_exponent() + direction
bs.set_replay_speed_exponent(new_exp)
self.speed = 2 ** new_exp
label = 'Snail Mode' if self.speed == 0.0625 else 'Slow Motion' if self.speed < 1 else 'Quake Pro' if self.speed == 16 else 'Fast Motion' if self.speed > 1 else 'Normal Speed'
self.show_message(label, f'Current exponent: x{self.speed}', self.COL2, self.COL3)
def play(self):
self.real_time = time()
self.update_clock()
self.start_progress_timer()
self.clock_timer = bui.AppTimer(self.TICK, self.update_clock, repeat=True)
def stop(self):
self.clock_timer = None
self.stop_progress_timer()
def stop_progress_timer(self):
self.progress_timer = None
def start_progress_timer(self):
self.progress_timer = bui.AppTimer(self.TICK, self.update_progress_ui, repeat=True)
def seek(self, direction):
"""Seek with confirmed duration checking"""
label = ['Forward by', 'Rewind by'][direction == -1]
amount = direction * self.speed
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Use estimated duration for calculating seek amount
if self.scanning:
seek_base = self.estimated_duration
else:
seek_base = self.duration_sec
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
amount = (seek_base / 20) * amount
new_time = (self.replay_time - self.start_time) + amount
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Check if trying to seek beyond confirmed duration while scanning
if self.scanning and new_time > self.confirmed_duration_sec:
current, total = self.scan_progress
percent = int((current / total) * 100) if total > 0 else 0
self.show_message('Buffering...', f'{percent}% loaded', self.COL2, self.COL3)
2025-08-10 13:02:22 +00:00
return
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Use appropriate duration for loop check
max_duration = self.confirmed_duration_sec if self.scanning else self.duration_sec
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
if (new_time >= max_duration) or (new_time <= 0):
self.loop()
2025-08-03 13:53:51 +03:00
else:
2025-11-06 12:30:07 +02:00
self.start_time = self.replay_time - new_time
self.reset_replay()
bs.seek_replay(new_time)
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
self.real_time = time()
self.fix_pause()
amount = abs(round(amount, 2))
2025-11-06 14:40:23 +00:00
self.show_message(
'Seek', label + f" {amount} second{['s', ''][amount == 1]}", self.COL4, self.COL5)
2025-11-06 12:30:07 +02:00
def jump(self, percent):
"""Jump with WYSIWYG behavior - percent is based on ESTIMATED duration, checked against CONFIRMED"""
# Calculate target time based on ESTIMATED duration (what user sees on bar)
if self.scanning:
target_time = self.estimated_duration * percent
else:
target_time = self.duration_sec * percent
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Check if trying to jump beyond CONFIRMED duration while scanning
if self.scanning and target_time > self.confirmed_duration_sec:
current, total = self.scan_progress
scan_percent = int((current / total) * 100) if total > 0 else 0
self.show_message('Buffering...', f'{scan_percent}% loaded', self.COL2, self.COL3)
return
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
self.start_time = self.replay_time - target_time
self.reset_replay()
bs.seek_replay(target_time)
self.real_time = time()
self.fix_pause()
def exit(self):
if getattr(self, 'exit_ready', 0):
self.confirm_exit()
2025-08-10 13:02:22 +00:00
return
2025-11-06 12:30:07 +02:00
self.show_message('Exit', 'Press again to confirm', self.COL0, self.COL1)
self.exit_ready = True
self.exit_timer = bui.AppTimer(1.5, CallPartial(setattr, self, 'exit_ready', False))
def confirm_exit(self):
bui.fade_screen(0, time=0.75, endcall=CallPartial(bui.fade_screen, 1, time=0.75))
bui.getsound('deek').play()
return_to_menu()
self.stop()
self.stop_focus()
self.exit_timer = None
_ba.set_camera_manual(False)
def update_progress_ui(self):
"""Modified to handle scanning state with growing progress bar"""
elapsed = self.replay_time - self.start_time
2025-11-06 14:40:23 +00:00
2025-11-06 16:39:37 +02:00
# Determine the current max duration to check against
if self.scanning:
max_duration = self.estimated_duration
elif self.duration_sec:
max_duration = self.duration_sec
else:
max_duration = None
2025-11-06 14:40:23 +00:00
2025-11-06 16:39:37 +02:00
# Check if elapsed exceeds duration and loop if needed
if max_duration and elapsed >= max_duration:
2025-11-06 12:30:07 +02:00
self.loop()
2025-11-06 16:39:37 +02:00
elapsed = 0 # Reset elapsed after loop
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
nub_x, nub_y = self.nub_pos
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
# Calculate progress
if self.scanning:
# Use estimated duration for progress bar
if elapsed < self.estimated_duration:
progress = (elapsed / self.estimated_duration) * self.progress_width
else:
2025-11-06 16:39:37 +02:00
# Cap at 100% if somehow elapsed exceeds estimate
progress = self.progress_width
2025-11-06 12:30:07 +02:00
elif not self.duration_sec:
# Fallback if scan failed
2025-11-06 14:40:23 +00:00
progress = min(elapsed / max(elapsed, 1) * self.progress_width *
0.1, self.progress_width * 0.1)
2025-11-06 12:30:07 +02:00
else:
2025-11-06 16:39:37 +02:00
# Normal progress calculation, cap at 100%
progress = min((elapsed / self.duration_sec) * self.progress_width, self.progress_width)
2025-11-06 14:40:23 +00:00
2025-08-03 13:53:51 +03:00
try:
2025-11-06 12:30:07 +02:00
bui.imagewidget(self.nub, position=(nub_x + progress, nub_y))
bui.textwidget(self.current_time_text, text=format_time(elapsed))
2025-08-10 13:02:22 +00:00
except ReferenceError:
pass
2025-11-06 12:30:07 +02:00
def reset_replay(self):
bs.seek_replay(-10 ** 10)
2025-08-10 13:02:22 +00:00
2025-11-06 12:30:07 +02:00
def loop(self):
self.start_time = self.replay_time = 0
self.reset_replay()
2025-08-03 13:53:51 +03:00
2025-11-06 12:30:07 +02:00
def create_button(self, label='', p=None, oac=None, pos=None, texture='white', **kwargs):
return bui.buttonwidget(
parent=p,
on_activate_call=oac,
position=pos,
label=label,
texture=bui.gettexture(texture),
enable_sound=False,
**kwargs
)
2025-08-10 13:02:22 +00:00
2025-08-03 13:53:51 +03:00
# Tools
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
def get_ui_scale(small, medium, large=None):
scale = bui.app.ui_v1.uiscale
return small if scale is bui.UIScale.SMALL else medium if scale is bui.UIScale.MEDIUM else (large or medium)
2025-11-06 14:40:23 +00:00
def return_to_menu(): return bui.app.classic.return_to_main_menu_session_gracefully(reset_ui=False)
def show_warning(text): return (bui.getsound('block').play()
or 1) and bui.screenmessage(text, color=(1, 1, 0))
def format_time(seconds): return strftime('%H:%M:%S', gmtime(seconds))
def get_overlay_stack(): return bui.get_special_widget('overlay_stack')
def round_tuple(tup): return type(tup)([round(val, 1) for val in tup])
2025-08-03 13:53:51 +03:00
# pybrp
2025-11-06 14:40:23 +00:00
def Z(_): return [0]*_
def G_FREQS(): return [
101342, 9667, 3497, 1072, 0, 3793, *Z(2), 2815, 5235, *Z(3), 3570, *Z(3),
1383, *Z(3), 2970, *Z(2), 2857, *Z(8), 1199, *Z(30),
1494, 1974, *Z(12), 1351, *Z(122), 1475, *Z(65)
2025-08-03 13:53:51 +03:00
]
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
class _Huffman:
2025-08-03 13:53:51 +03:00
class _N:
def __init__(self):
2025-11-06 14:40:23 +00:00
self.l, self.r, self.p, self.f = -1, -1, 0, 0
2025-08-03 13:53:51 +03:00
def __init__(self):
2025-11-06 14:40:23 +00:00
self.nodes = [self._N()for _ in range(511)]
2025-11-06 12:30:07 +02:00
gf = G_FREQS()
2025-11-06 14:40:23 +00:00
for i in range(256):
self.nodes[i].f = gf[i]
nc = 256
while nc < 511:
s1, s2 = -1, -1
i = 0
while self.nodes[i].p != 0:
i += 1
s1 = i
i += 1
while self.nodes[i].p != 0:
i += 1
s2 = i
i += 1
while i < nc:
if self.nodes[i].p == 0:
if self.nodes[s1].f > self.nodes[s2].f:
if self.nodes[i].f < self.nodes[s1].f:
s1 = i
elif self.nodes[i].f < self.nodes[s2].f:
s2 = i
i += 1
self.nodes[nc].f = self.nodes[s1].f+self.nodes[s2].f
self.nodes[s1].p = self.nodes[s2].p = nc-255
self.nodes[nc].r, self.nodes[nc].l = s1, s2
nc += 1
def decompress(self, src):
if not src:
return b''
rem, comp = src[0] & 15, src[0] >> 7
if not comp:
return src
out, ptr, l = bytearray(), src[1:], len(src)
bl = ((l-1)*8)-rem
bit = 0
while bit < bl:
m_bit = (ptr[bit >> 3] >> (bit & 7)) & 1
bit += 1
2025-08-03 13:53:51 +03:00
if m_bit:
2025-11-06 14:40:23 +00:00
n = 510
while n >= 256:
if bit >= bl:
raise ValueError("Incomplete Huffman code")
p_bit = (ptr[bit >> 3] >> (bit & 7)) & 1
bit += 1
n = self.nodes[n].l if p_bit == 0 else self.nodes[n].r
2025-08-03 13:53:51 +03:00
out.append(n)
else:
2025-11-06 14:40:23 +00:00
if bit+8 > bl:
break
bi, b_in_b = bit >> 3, bit & 7
val = ptr[bi]if b_in_b == 0 else (ptr[bi] >> b_in_b) | (ptr[bi+1] << (8-b_in_b))
out.append(val & 255)
bit += 8
2025-08-03 13:53:51 +03:00
return bytes(out)
2025-11-06 14:40:23 +00:00
2025-11-06 12:30:07 +02:00
def get_replay_duration(_h, brp_path, progress):
2025-08-03 13:53:51 +03:00
total_ms = 0
with open(brp_path, 'rb') as f:
2025-11-06 14:40:23 +00:00
f.seek(0, 2)
2025-11-06 12:30:07 +02:00
progress[1] = f.tell()
2025-08-03 13:53:51 +03:00
f.seek(6)
while True:
2025-11-06 12:30:07 +02:00
progress[0] = f.tell()
2025-08-03 13:53:51 +03:00
b_data = f.read(1)
if not b_data:
break
b1, comp_len = b_data[0], 0
if b1 < 254:
comp_len = b1
elif b1 == 254:
comp_len = int.from_bytes(f.read(2), 'little')
2025-11-06 12:30:07 +02:00
else:
2025-08-03 13:53:51 +03:00
comp_len = int.from_bytes(f.read(4), 'little')
if comp_len == 0:
continue
raw_msg = _h.decompress(f.read(comp_len))
if not raw_msg or raw_msg[0] != 1:
continue
sub_off = 1
2025-11-06 12:30:07 +02:00
while sub_off + 2 <= len(raw_msg):
sub_size_bytes = raw_msg[sub_off:sub_off+2]
if len(sub_size_bytes) < 2:
2025-08-03 13:53:51 +03:00
break
2025-11-06 12:30:07 +02:00
sub_size = int.from_bytes(sub_size_bytes, 'little')
sub_off += 2
if sub_off + sub_size > len(raw_msg):
2025-08-03 13:53:51 +03:00
break
2025-11-06 12:30:07 +02:00
sub_data = raw_msg[sub_off:sub_off+sub_size]
if len(sub_data) >= 2 and sub_data[0] == 0:
2025-08-03 13:53:51 +03:00
total_ms += sub_data[1]
2025-11-06 12:30:07 +02:00
sub_off += sub_size
progress[0] = progress[1]
2025-08-03 13:53:51 +03:00
return total_ms
# brobord collide grass
# ba_meta require api 9
# ba_meta export babase.Plugin
2025-11-06 14:40:23 +00:00
2025-08-03 13:53:51 +03:00
class byBordd(Plugin):
2025-11-06 14:40:23 +00:00
def has_settings_ui(c=0): return True
def show_settings_ui(c=0, w=None): return Replay(source=w)
2025-11-06 12:30:07 +02:00
def __init__(self):
from bauiv1lib.ingamemenu import InGameMenuWindow as ingame
orig_refresh = getattr(ingame, '_refresh_in_game')
2025-11-06 14:40:23 +00:00
setattr(ingame, '_refresh_in_game', lambda window, *args, **
kwargs: (self.add_button(window), orig_refresh(window, *args, **kwargs))[1])
2025-11-06 12:30:07 +02:00
from bauiv1lib.watch import WatchWindow as watch
orig_init = getattr(watch, '__init__')
2025-11-06 14:40:23 +00:00
setattr(watch, '__init__', lambda window, *args, **
kwargs: (orig_init(window, *args, **kwargs), self.add_button(window, 1))[0])
2025-11-06 12:30:07 +02:00
def add_button(self, window, watch_mode=0):
if watch_mode:
btn_x = window._width / 2 + get_ui_scale(window._scroll_width * -0.5 + 93, 0) + 100
btn_y = window.yoffs - get_ui_scale(63, 10) - 25
self.button = Replay.create_button(
2025-08-03 13:53:51 +03:00
Replay,
2025-11-06 12:30:07 +02:00
p=window._root_widget,
2025-08-03 13:53:51 +03:00
label='Replay',
2025-11-06 12:30:07 +02:00
pos=(btn_x, btn_y) if watch_mode else (-70, 0),
icon=bui.gettexture('replayIcon'),
iconscale=1.6 if watch_mode else 0.8,
size=(140, 50) if watch_mode else (90, 35),
oac=lambda: Replay(source=self.button),
id='Replay'
2025-08-03 13:53:51 +03:00
)