bauiv1lib.gather.publictab
Defines the public tab in the gather UI.
1# Released under the MIT License. See LICENSE for details. 2# 3# pylint: disable=too-many-lines 4"""Defines the public tab in the gather UI.""" 5 6from __future__ import annotations 7 8import copy 9import time 10import logging 11from threading import Thread 12from enum import Enum 13from dataclasses import dataclass 14from typing import TYPE_CHECKING, cast, override 15 16from bauiv1lib.gather import GatherTab 17import bauiv1 as bui 18import bascenev1 as bs 19 20if TYPE_CHECKING: 21 from typing import Callable, Any 22 23 from bauiv1lib.gather import GatherWindow 24 25# Print a bit of info about pings, queries, etc. 26DEBUG_SERVER_COMMUNICATION = False 27DEBUG_PROCESSING = False 28 29 30class SubTabType(Enum): 31 """Available sub-tabs.""" 32 33 JOIN = 'join' 34 HOST = 'host' 35 36 37@dataclass 38class PartyEntry: 39 """Info about a public party.""" 40 41 address: str 42 index: int 43 queue: str | None = None 44 port: int = -1 45 name: str = '' 46 size: int = -1 47 size_max: int = -1 48 claimed: bool = False 49 ping: float | None = None 50 ping_interval: float = -1.0 51 next_ping_time: float = -1.0 52 ping_attempts: int = 0 53 ping_responses: int = 0 54 stats_addr: str | None = None 55 clean_display_index: int | None = None 56 57 def get_key(self) -> str: 58 """Return the key used to store this party.""" 59 return f'{self.address}_{self.port}' 60 61 62class UIRow: 63 """Wrangles UI for a row in the party list.""" 64 65 def __init__(self) -> None: 66 self._name_widget: bui.Widget | None = None 67 self._size_widget: bui.Widget | None = None 68 self._ping_widget: bui.Widget | None = None 69 self._stats_button: bui.Widget | None = None 70 71 def __del__(self) -> None: 72 self._clear() 73 74 def _clear(self) -> None: 75 for widget in [ 76 self._name_widget, 77 self._size_widget, 78 self._ping_widget, 79 self._stats_button, 80 ]: 81 if widget: 82 widget.delete() 83 84 def update( 85 self, 86 index: int, 87 party: PartyEntry, 88 sub_scroll_width: float, 89 sub_scroll_height: float, 90 lineheight: float, 91 columnwidget: bui.Widget, 92 join_text: bui.Widget, 93 filter_text: bui.Widget, 94 existing_selection: Selection | None, 95 tab: PublicGatherTab, 96 ) -> None: 97 """Update for the given data.""" 98 # pylint: disable=too-many-locals 99 # pylint: disable=too-many-positional-arguments 100 101 plus = bui.app.plus 102 assert plus is not None 103 104 # Quick-out: if we've been marked clean for a certain index and 105 # we're still at that index, we're done. 106 if party.clean_display_index == index: 107 return 108 109 ping_good = plus.get_v1_account_misc_read_val('pingGood', 100) 110 ping_med = plus.get_v1_account_misc_read_val('pingMed', 500) 111 112 self._clear() 113 hpos = 20 114 vpos = sub_scroll_height - lineheight * index - 50 115 self._name_widget = bui.textwidget( 116 text=bui.Lstr(value=party.name), 117 parent=columnwidget, 118 size=(sub_scroll_width * 0.46, 20), 119 position=(0 + hpos, 4 + vpos), 120 selectable=True, 121 on_select_call=bui.WeakCall( 122 tab.set_public_party_selection, 123 Selection(party.get_key(), SelectionComponent.NAME), 124 ), 125 on_activate_call=bui.WeakCall(tab.on_public_party_activate, party), 126 click_activate=True, 127 maxwidth=sub_scroll_width * 0.45, 128 corner_scale=1.4, 129 autoselect=True, 130 color=(1, 1, 1, 0.3 if party.ping is None else 1.0), 131 h_align='left', 132 v_align='center', 133 ) 134 bui.widget( 135 edit=self._name_widget, 136 left_widget=join_text, 137 show_buffer_top=64.0, 138 show_buffer_bottom=64.0, 139 ) 140 if existing_selection == Selection( 141 party.get_key(), SelectionComponent.NAME 142 ): 143 bui.containerwidget( 144 edit=columnwidget, selected_child=self._name_widget 145 ) 146 if party.stats_addr: 147 url = party.stats_addr.replace( 148 '${ACCOUNT}', 149 plus.get_v1_account_misc_read_val_2( 150 'resolvedAccountID', 'UNKNOWN' 151 ), 152 ) 153 self._stats_button = bui.buttonwidget( 154 color=(0.3, 0.6, 0.94), 155 textcolor=(1.0, 1.0, 1.0), 156 label=bui.Lstr(resource='statsText'), 157 parent=columnwidget, 158 autoselect=True, 159 on_activate_call=bui.Call(bui.open_url, url), 160 on_select_call=bui.WeakCall( 161 tab.set_public_party_selection, 162 Selection(party.get_key(), SelectionComponent.STATS_BUTTON), 163 ), 164 size=(120, 40), 165 position=(sub_scroll_width * 0.66 + hpos, 1 + vpos), 166 scale=0.9, 167 ) 168 if existing_selection == Selection( 169 party.get_key(), SelectionComponent.STATS_BUTTON 170 ): 171 bui.containerwidget( 172 edit=columnwidget, selected_child=self._stats_button 173 ) 174 175 self._size_widget = bui.textwidget( 176 text=str(party.size) + '/' + str(party.size_max), 177 parent=columnwidget, 178 size=(0, 0), 179 position=(sub_scroll_width * 0.86 + hpos, 20 + vpos), 180 scale=0.7, 181 color=(0.8, 0.8, 0.8), 182 h_align='right', 183 v_align='center', 184 ) 185 186 if index == 0: 187 bui.widget(edit=self._name_widget, up_widget=filter_text) 188 if self._stats_button: 189 bui.widget(edit=self._stats_button, up_widget=filter_text) 190 191 self._ping_widget = bui.textwidget( 192 parent=columnwidget, 193 size=(0, 0), 194 position=(sub_scroll_width * 0.94 + hpos, 20 + vpos), 195 scale=0.7, 196 h_align='right', 197 v_align='center', 198 ) 199 if party.ping is None: 200 bui.textwidget( 201 edit=self._ping_widget, text='-', color=(0.5, 0.5, 0.5) 202 ) 203 else: 204 bui.textwidget( 205 edit=self._ping_widget, 206 text=str(int(party.ping)), 207 color=( 208 (0, 1, 0) 209 if party.ping <= ping_good 210 else (1, 1, 0) if party.ping <= ping_med else (1, 0, 0) 211 ), 212 ) 213 214 party.clean_display_index = index 215 216 217@dataclass 218class State: 219 """State saved/restored only while the app is running.""" 220 221 sub_tab: SubTabType = SubTabType.JOIN 222 parties: list[tuple[str, PartyEntry]] | None = None 223 next_entry_index: int = 0 224 filter_value: str = '' 225 have_server_list_response: bool = False 226 have_valid_server_list: bool = False 227 228 229class SelectionComponent(Enum): 230 """Describes what part of an entry is selected.""" 231 232 NAME = 'name' 233 STATS_BUTTON = 'stats_button' 234 235 236@dataclass 237class Selection: 238 """Describes the currently selected list element.""" 239 240 entry_key: str 241 component: SelectionComponent 242 243 244class AddrFetchThread(Thread): 245 """Thread for fetching an address in the bg.""" 246 247 def __init__(self, call: Callable[[Any], Any]): 248 super().__init__() 249 self._call = call 250 251 @override 252 def run(self) -> None: 253 sock: socket.socket | None = None 254 try: 255 # FIXME: Update this to work with IPv6 at some point. 256 import socket 257 258 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 259 sock.connect(('8.8.8.8', 80)) 260 val = sock.getsockname()[0] 261 bui.pushcall(bui.Call(self._call, val), from_other_thread=True) 262 except Exception as exc: 263 from efro.error import is_udp_communication_error 264 265 # Ignore expected network errors; log others. 266 if is_udp_communication_error(exc): 267 pass 268 else: 269 logging.exception('Error in addr-fetch-thread') 270 finally: 271 if sock is not None: 272 sock.close() 273 274 275class PingThread(Thread): 276 """Thread for sending out game pings.""" 277 278 def __init__( 279 self, 280 address: str, 281 port: int, 282 call: Callable[[str, int, float | None], int | None], 283 ): 284 super().__init__() 285 self._address = address 286 self._port = port 287 self._call = call 288 289 @override 290 def run(self) -> None: 291 assert bui.app.classic is not None 292 bui.app.classic.ping_thread_count += 1 293 sock: socket.socket | None = None 294 try: 295 import socket 296 297 socket_type = bui.get_ip_address_type(self._address) 298 sock = socket.socket(socket_type, socket.SOCK_DGRAM) 299 sock.connect((self._address, self._port)) 300 301 accessible = False 302 starttime = time.time() 303 304 # Send a few pings and wait a second for 305 # a response. 306 sock.settimeout(1) 307 for _i in range(3): 308 sock.send(b'\x0b') 309 result: bytes | None 310 try: 311 # 11: BA_PACKET_SIMPLE_PING 312 result = sock.recv(10) 313 except Exception: 314 result = None 315 if result == b'\x0c': 316 # 12: BA_PACKET_SIMPLE_PONG 317 accessible = True 318 break 319 time.sleep(1) 320 ping = (time.time() - starttime) * 1000.0 321 bui.pushcall( 322 bui.Call( 323 self._call, 324 self._address, 325 self._port, 326 ping if accessible else None, 327 ), 328 from_other_thread=True, 329 ) 330 except Exception as exc: 331 from efro.error import is_udp_communication_error 332 333 if is_udp_communication_error(exc): 334 pass 335 else: 336 if bui.do_once(): 337 logging.exception('Error on gather ping.') 338 finally: 339 try: 340 if sock is not None: 341 sock.close() 342 except Exception: 343 if bui.do_once(): 344 logging.exception('Error on gather ping cleanup') 345 346 bui.app.classic.ping_thread_count -= 1 347 348 349class PublicGatherTab(GatherTab): 350 """The public tab in the gather UI""" 351 352 def __init__(self, window: GatherWindow) -> None: 353 super().__init__(window) 354 self._container: bui.Widget | None = None 355 self._join_text: bui.Widget | None = None 356 self._host_text: bui.Widget | None = None 357 self._filter_text: bui.Widget | None = None 358 self._local_address: str | None = None 359 self._last_connect_attempt_time: float | None = None 360 self._sub_tab: SubTabType = SubTabType.JOIN 361 self._selection: Selection | None = None 362 self._refreshing_list = False 363 self._update_timer: bui.AppTimer | None = None 364 self._host_scrollwidget: bui.Widget | None = None 365 self._host_name_text: bui.Widget | None = None 366 self._host_toggle_button: bui.Widget | None = None 367 self._last_server_list_query_time: float | None = None 368 self._join_list_column: bui.Widget | None = None 369 self._join_status_text: bui.Widget | None = None 370 self._join_status_spinner: bui.Widget | None = None 371 self._no_servers_found_text: bui.Widget | None = None 372 self._host_max_party_size_value: bui.Widget | None = None 373 self._host_max_party_size_minus_button: bui.Widget | None = None 374 self._host_max_party_size_plus_button: bui.Widget | None = None 375 self._host_status_text: bui.Widget | None = None 376 self._signed_in = False 377 self._ui_rows: list[UIRow] = [] 378 self._refresh_ui_row = 0 379 self._have_user_selected_row = False 380 self._first_valid_server_list_time: float | None = None 381 382 # Parties indexed by id: 383 self._parties: dict[str, PartyEntry] = {} 384 385 # Parties sorted in display order: 386 self._parties_sorted: list[tuple[str, PartyEntry]] = [] 387 self._party_lists_dirty = True 388 389 # Sorted parties with filter applied: 390 self._parties_displayed: dict[str, PartyEntry] = {} 391 392 self._next_entry_index = 0 393 self._have_server_list_response = False 394 self._have_valid_server_list = False 395 self._filter_value = '' 396 self._pending_party_infos: list[dict[str, Any]] = [] 397 self._last_sub_scroll_height = 0.0 398 399 @override 400 def on_activate( 401 self, 402 parent_widget: bui.Widget, 403 tab_button: bui.Widget, 404 region_width: float, 405 region_height: float, 406 region_left: float, 407 region_bottom: float, 408 ) -> bui.Widget: 409 # pylint: disable=too-many-positional-arguments 410 c_width = region_width 411 c_height = region_height - 20 412 self._container = bui.containerwidget( 413 parent=parent_widget, 414 position=( 415 region_left, 416 region_bottom + (region_height - c_height) * 0.5, 417 ), 418 size=(c_width, c_height), 419 background=False, 420 selection_loops_to_parent=True, 421 ) 422 v = c_height - 30 423 self._join_text = bui.textwidget( 424 parent=self._container, 425 position=(c_width * 0.5 - 245, v - 13), 426 color=(0.6, 1.0, 0.6), 427 scale=1.3, 428 size=(200, 30), 429 maxwidth=250, 430 h_align='left', 431 v_align='center', 432 click_activate=True, 433 selectable=True, 434 autoselect=True, 435 on_activate_call=lambda: self._set_sub_tab( 436 SubTabType.JOIN, 437 region_width, 438 region_height, 439 playsound=True, 440 ), 441 text=bui.Lstr( 442 resource='gatherWindow.' 'joinPublicPartyDescriptionText' 443 ), 444 glow_type='uniform', 445 ) 446 self._host_text = bui.textwidget( 447 parent=self._container, 448 position=(c_width * 0.5 + 45, v - 13), 449 color=(0.6, 1.0, 0.6), 450 scale=1.3, 451 size=(200, 30), 452 maxwidth=250, 453 h_align='left', 454 v_align='center', 455 click_activate=True, 456 selectable=True, 457 autoselect=True, 458 on_activate_call=lambda: self._set_sub_tab( 459 SubTabType.HOST, 460 region_width, 461 region_height, 462 playsound=True, 463 ), 464 text=bui.Lstr( 465 resource='gatherWindow.' 'hostPublicPartyDescriptionText' 466 ), 467 glow_type='uniform', 468 ) 469 bui.widget(edit=self._join_text, up_widget=tab_button) 470 bui.widget( 471 edit=self._host_text, 472 left_widget=self._join_text, 473 up_widget=tab_button, 474 ) 475 bui.widget(edit=self._join_text, right_widget=self._host_text) 476 477 # Attempt to fetch our local address so we have it for error messages. 478 if self._local_address is None: 479 AddrFetchThread(bui.WeakCall(self._fetch_local_addr_cb)).start() 480 481 self._set_sub_tab(self._sub_tab, region_width, region_height) 482 self._update_timer = bui.AppTimer( 483 0.1, bui.WeakCall(self._update), repeat=True 484 ) 485 return self._container 486 487 @override 488 def on_deactivate(self) -> None: 489 self._update_timer = None 490 491 @override 492 def save_state(self) -> None: 493 # Save off a small number of parties with the lowest ping; we'll 494 # display these immediately when our UI comes back up which should 495 # be enough to make things feel nice and crisp while we do a full 496 # server re-query or whatnot. 497 assert bui.app.classic is not None 498 bui.app.ui_v1.window_states[type(self)] = State( 499 sub_tab=self._sub_tab, 500 parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]], 501 next_entry_index=self._next_entry_index, 502 filter_value=self._filter_value, 503 have_server_list_response=self._have_server_list_response, 504 have_valid_server_list=self._have_valid_server_list, 505 ) 506 507 @override 508 def restore_state(self) -> None: 509 assert bui.app.classic is not None 510 state = bui.app.ui_v1.window_states.get(type(self)) 511 if state is None: 512 state = State() 513 assert isinstance(state, State) 514 self._sub_tab = state.sub_tab 515 516 # Restore the parties we stored. 517 if state.parties: 518 self._parties = { 519 key: copy.copy(party) for key, party in state.parties 520 } 521 self._parties_sorted = list(self._parties.items()) 522 self._party_lists_dirty = True 523 524 self._next_entry_index = state.next_entry_index 525 526 # FIXME: should save/restore these too?.. 527 self._have_server_list_response = state.have_server_list_response 528 self._have_valid_server_list = state.have_valid_server_list 529 self._filter_value = state.filter_value 530 531 def _set_sub_tab( 532 self, 533 value: SubTabType, 534 region_width: float, 535 region_height: float, 536 playsound: bool = False, 537 ) -> None: 538 assert self._container 539 if playsound: 540 bui.getsound('click01').play() 541 542 # Reset our selection. 543 # (prevents selecting something way down the list if we switched away 544 # and came back) 545 self._selection = None 546 self._have_user_selected_row = False 547 548 # Reset refresh to the top and make sure everything refreshes. 549 self._refresh_ui_row = 0 550 for party in self._parties.values(): 551 party.clean_display_index = None 552 553 self._sub_tab = value 554 active_color = (0.6, 1.0, 0.6) 555 inactive_color = (0.5, 0.4, 0.5) 556 bui.textwidget( 557 edit=self._join_text, 558 color=active_color if value is SubTabType.JOIN else inactive_color, 559 ) 560 bui.textwidget( 561 edit=self._host_text, 562 color=active_color if value is SubTabType.HOST else inactive_color, 563 ) 564 565 # Clear anything existing in the old sub-tab. 566 for widget in self._container.get_children(): 567 if widget and widget not in {self._host_text, self._join_text}: 568 widget.delete() 569 570 if value is SubTabType.JOIN: 571 self._build_join_tab(region_width, region_height) 572 573 if value is SubTabType.HOST: 574 self._build_host_tab(region_width, region_height) 575 576 def _build_join_tab( 577 self, region_width: float, region_height: float 578 ) -> None: 579 c_width = region_width 580 c_height = region_height - 20 581 sub_scroll_height = c_height - 125 582 sub_scroll_width = 830 583 v = c_height - 35 584 v -= 60 585 filter_txt = bui.Lstr(resource='filterText') 586 self._filter_text = bui.textwidget( 587 parent=self._container, 588 text=self._filter_value, 589 size=(350, 45), 590 position=(c_width * 0.5 - 150, v - 10), 591 h_align='left', 592 v_align='center', 593 editable=True, 594 maxwidth=310, 595 description=filter_txt, 596 ) 597 bui.widget(edit=self._filter_text, up_widget=self._join_text) 598 bui.textwidget( 599 text=filter_txt, 600 parent=self._container, 601 size=(0, 0), 602 position=(c_width * 0.5 - 170, v + 13), 603 maxwidth=150, 604 scale=0.8, 605 color=(0.5, 0.46, 0.5), 606 flatness=1.0, 607 h_align='right', 608 v_align='center', 609 ) 610 611 bui.textwidget( 612 text=bui.Lstr(resource='nameText'), 613 parent=self._container, 614 size=(0, 0), 615 position=((c_width - sub_scroll_width) * 0.5 + 50, v - 8), 616 maxwidth=60, 617 scale=0.6, 618 color=(0.5, 0.46, 0.5), 619 flatness=1.0, 620 h_align='center', 621 v_align='center', 622 ) 623 bui.textwidget( 624 text=bui.Lstr(resource='gatherWindow.partySizeText'), 625 parent=self._container, 626 size=(0, 0), 627 position=( 628 c_width * 0.5 + sub_scroll_width * 0.5 - 110, 629 v - 8, 630 ), 631 maxwidth=60, 632 scale=0.6, 633 color=(0.5, 0.46, 0.5), 634 flatness=1.0, 635 h_align='center', 636 v_align='center', 637 ) 638 bui.textwidget( 639 text=bui.Lstr(resource='gatherWindow.pingText'), 640 parent=self._container, 641 size=(0, 0), 642 position=( 643 c_width * 0.5 + sub_scroll_width * 0.5 - 30, 644 v - 8, 645 ), 646 maxwidth=60, 647 scale=0.6, 648 color=(0.5, 0.46, 0.5), 649 flatness=1.0, 650 h_align='center', 651 v_align='center', 652 ) 653 v -= sub_scroll_height + 23 654 self._host_scrollwidget = scrollw = bui.scrollwidget( 655 parent=self._container, 656 simple_culling_v=10, 657 position=((c_width - sub_scroll_width) * 0.5, v), 658 size=(sub_scroll_width, sub_scroll_height), 659 claims_up_down=False, 660 claims_left_right=True, 661 autoselect=True, 662 ) 663 self._join_list_column = bui.containerwidget( 664 parent=scrollw, 665 background=False, 666 size=(400, 400), 667 claims_left_right=True, 668 ) 669 670 # Create join status text and join spinner. Always make sure to 671 # update both of these together. 672 self._join_status_text = bui.textwidget( 673 parent=self._container, 674 text='', 675 size=(0, 0), 676 scale=0.9, 677 flatness=1.0, 678 shadow=0.0, 679 h_align='center', 680 v_align='top', 681 maxwidth=c_width, 682 color=(0.6, 0.6, 0.6), 683 position=(c_width * 0.5, c_height * 0.5), 684 ) 685 self._join_status_spinner = bui.spinnerwidget( 686 parent=self._container, position=(c_width * 0.5, c_height * 0.5) 687 ) 688 689 self._no_servers_found_text = bui.textwidget( 690 parent=self._container, 691 text='', 692 size=(0, 0), 693 scale=0.9, 694 flatness=1.0, 695 shadow=0.0, 696 h_align='center', 697 v_align='top', 698 color=(0.6, 0.6, 0.6), 699 position=(c_width * 0.5, c_height * 0.5), 700 ) 701 702 def _build_host_tab( 703 self, region_width: float, region_height: float 704 ) -> None: 705 c_width = region_width 706 c_height = region_height - 20 707 v = c_height - 35 708 v -= 25 709 is_public_enabled = bs.get_public_party_enabled() 710 v -= 30 711 712 bui.textwidget( 713 parent=self._container, 714 size=(0, 0), 715 h_align='center', 716 v_align='center', 717 maxwidth=c_width * 0.9, 718 scale=0.7, 719 flatness=1.0, 720 color=(0.5, 0.46, 0.5), 721 position=(region_width * 0.5, v + 10), 722 text=bui.Lstr(resource='gatherWindow.publicHostRouterConfigText'), 723 ) 724 v -= 30 725 726 party_name_text = bui.Lstr( 727 resource='gatherWindow.partyNameText', 728 fallback_resource='editGameListWindow.nameText', 729 ) 730 assert bui.app.classic is not None 731 bui.textwidget( 732 parent=self._container, 733 size=(0, 0), 734 h_align='right', 735 v_align='center', 736 maxwidth=200, 737 scale=0.8, 738 color=bui.app.ui_v1.infotextcolor, 739 position=(210, v - 9), 740 text=party_name_text, 741 ) 742 self._host_name_text = bui.textwidget( 743 parent=self._container, 744 editable=True, 745 size=(535, 40), 746 position=(230, v - 30), 747 text=bui.app.config.get('Public Party Name', ''), 748 maxwidth=494, 749 shadow=0.3, 750 flatness=1.0, 751 description=party_name_text, 752 autoselect=True, 753 v_align='center', 754 corner_scale=1.0, 755 ) 756 757 v -= 60 758 bui.textwidget( 759 parent=self._container, 760 size=(0, 0), 761 h_align='right', 762 v_align='center', 763 maxwidth=200, 764 scale=0.8, 765 color=bui.app.ui_v1.infotextcolor, 766 position=(210, v - 9), 767 text=bui.Lstr( 768 resource='maxPartySizeText', 769 fallback_resource='maxConnectionsText', 770 ), 771 ) 772 self._host_max_party_size_value = bui.textwidget( 773 parent=self._container, 774 size=(0, 0), 775 h_align='center', 776 v_align='center', 777 scale=1.2, 778 color=(1, 1, 1), 779 position=(240, v - 9), 780 text=str(bs.get_public_party_max_size()), 781 ) 782 btn1 = self._host_max_party_size_minus_button = bui.buttonwidget( 783 parent=self._container, 784 size=(40, 40), 785 on_activate_call=bui.WeakCall( 786 self._on_max_public_party_size_minus_press 787 ), 788 position=(280, v - 26), 789 label='-', 790 autoselect=True, 791 ) 792 btn2 = self._host_max_party_size_plus_button = bui.buttonwidget( 793 parent=self._container, 794 size=(40, 40), 795 on_activate_call=bui.WeakCall( 796 self._on_max_public_party_size_plus_press 797 ), 798 position=(350, v - 26), 799 label='+', 800 autoselect=True, 801 ) 802 v -= 50 803 v -= 70 804 if is_public_enabled: 805 label = bui.Lstr( 806 resource='gatherWindow.makePartyPrivateText', 807 fallback_resource='gatherWindow.stopAdvertisingText', 808 ) 809 else: 810 label = bui.Lstr( 811 resource='gatherWindow.makePartyPublicText', 812 fallback_resource='gatherWindow.startAdvertisingText', 813 ) 814 self._host_toggle_button = bui.buttonwidget( 815 parent=self._container, 816 label=label, 817 size=(400, 80), 818 on_activate_call=( 819 self._on_stop_advertising_press 820 if is_public_enabled 821 else self._on_start_advertizing_press 822 ), 823 position=(c_width * 0.5 - 200, v), 824 autoselect=True, 825 up_widget=btn2, 826 ) 827 bui.widget(edit=self._host_name_text, down_widget=btn2) 828 bui.widget(edit=btn2, up_widget=self._host_name_text) 829 bui.widget(edit=btn1, up_widget=self._host_name_text) 830 assert self._join_text is not None 831 bui.widget(edit=self._join_text, down_widget=self._host_name_text) 832 v -= 10 833 self._host_status_text = bui.textwidget( 834 parent=self._container, 835 text=bui.Lstr(resource='gatherWindow.' 'partyStatusNotPublicText'), 836 size=(0, 0), 837 scale=0.7, 838 flatness=1.0, 839 h_align='center', 840 v_align='top', 841 maxwidth=c_width * 0.9, 842 color=(0.6, 0.56, 0.6), 843 position=(c_width * 0.5, v), 844 ) 845 v -= 90 846 bui.textwidget( 847 parent=self._container, 848 text=bui.Lstr(resource='gatherWindow.dedicatedServerInfoText'), 849 size=(0, 0), 850 scale=0.7, 851 flatness=1.0, 852 h_align='center', 853 v_align='center', 854 maxwidth=c_width * 0.9, 855 color=(0.5, 0.46, 0.5), 856 position=(c_width * 0.5, v), 857 ) 858 859 # If public sharing is already on, 860 # launch a status-check immediately. 861 if bs.get_public_party_enabled(): 862 self._do_status_check() 863 864 def _on_public_party_query_result( 865 self, result: dict[str, Any] | None 866 ) -> None: 867 starttime = time.time() 868 self._have_server_list_response = True 869 870 if result is None: 871 self._have_valid_server_list = False 872 return 873 874 if not self._have_valid_server_list: 875 self._first_valid_server_list_time = time.time() 876 877 self._have_valid_server_list = True 878 parties_in = result['l'] 879 880 assert isinstance(parties_in, list) 881 self._pending_party_infos += parties_in 882 883 # To avoid causing a stutter here, we do most processing of 884 # these entries incrementally in our _update() method. 885 # The one thing we do here is prune parties not contained in 886 # this result. 887 for partyval in list(self._parties.values()): 888 partyval.claimed = False 889 for party_in in parties_in: 890 addr = party_in['a'] 891 assert isinstance(addr, str) 892 port = party_in['p'] 893 assert isinstance(port, int) 894 party_key = f'{addr}_{port}' 895 party = self._parties.get(party_key) 896 if party is not None: 897 party.claimed = True 898 self._parties = { 899 key: val for key, val in list(self._parties.items()) if val.claimed 900 } 901 self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed] 902 self._party_lists_dirty = True 903 904 # self._update_server_list() 905 if DEBUG_PROCESSING: 906 print( 907 f'Handled public party query results in ' 908 f'{time.time()-starttime:.5f}s.' 909 ) 910 911 def _update(self) -> None: 912 """Periodic updating.""" 913 914 plus = bui.app.plus 915 assert plus is not None 916 917 if self._sub_tab is SubTabType.JOIN: 918 # Keep our filter-text up to date from the UI. 919 text = self._filter_text 920 if text: 921 filter_value = cast(str, bui.textwidget(query=text)) 922 if filter_value != self._filter_value: 923 self._filter_value = filter_value 924 self._party_lists_dirty = True 925 926 # Also wipe out party clean-row states. 927 # (otherwise if a party disappears from a row due to 928 # filtering and then reappears on that same row when 929 # the filter is removed it may not update) 930 for party in self._parties.values(): 931 party.clean_display_index = None 932 933 self._query_party_list_periodically() 934 self._ping_parties_periodically() 935 936 # If any new party infos have come in, apply some of them. 937 self._process_pending_party_infos() 938 939 # Anytime we sign in/out, make sure we refresh our list. 940 signed_in = plus.get_v1_account_state() == 'signed_in' 941 if self._signed_in != signed_in: 942 self._signed_in = signed_in 943 self._party_lists_dirty = True 944 945 # Update sorting to account for ping updates, new parties, etc. 946 self._update_party_lists() 947 948 # If we've got a party-name text widget, keep its value plugged 949 # into our public host name. 950 text = self._host_name_text 951 if text: 952 name = cast(str, bui.textwidget(query=self._host_name_text)) 953 bs.set_public_party_name(name) 954 955 # Update status text and loading spinner. 956 if self._join_status_text: 957 assert self._join_status_spinner 958 if not signed_in: 959 bui.textwidget( 960 edit=self._join_status_text, 961 text=bui.Lstr(resource='notSignedInText'), 962 ) 963 bui.spinnerwidget(edit=self._join_status_spinner, visible=False) 964 else: 965 # If we have a valid list, show no status; just the list. 966 # Otherwise show either 'loading...' or 'error' depending 967 # on whether this is our first go-round. 968 if self._have_valid_server_list: 969 bui.textwidget(edit=self._join_status_text, text='') 970 bui.spinnerwidget( 971 edit=self._join_status_spinner, visible=False 972 ) 973 else: 974 if self._have_server_list_response: 975 bui.textwidget( 976 edit=self._join_status_text, 977 text=bui.Lstr(resource='errorText'), 978 ) 979 bui.spinnerwidget( 980 edit=self._join_status_spinner, visible=False 981 ) 982 else: 983 # Show our loading spinner. 984 bui.textwidget(edit=self._join_status_text, text='') 985 # bui.textwidget( 986 # edit=self._join_status_text, 987 # text=bui.Lstr( 988 # value='${A}...', 989 # subs=[ 990 # ( 991 # '${A}', 992 # 993 # bui.Lstr(resource='store.loadingText'), 994 # ) 995 # ], 996 # ), 997 # ) 998 bui.spinnerwidget( 999 edit=self._join_status_spinner, visible=True 1000 ) 1001 1002 self._update_party_rows() 1003 1004 def _update_party_rows(self) -> None: 1005 plus = bui.app.plus 1006 assert plus is not None 1007 1008 columnwidget = self._join_list_column 1009 if not columnwidget: 1010 return 1011 1012 assert self._join_text 1013 assert self._filter_text 1014 1015 # Janky - allow escaping when there's nothing in our list. 1016 assert self._host_scrollwidget 1017 bui.containerwidget( 1018 edit=self._host_scrollwidget, 1019 claims_up_down=(len(self._parties_displayed) > 0), 1020 ) 1021 bui.textwidget(edit=self._no_servers_found_text, text='') 1022 1023 # Clip if we have more UI rows than parties to show. 1024 clipcount = len(self._ui_rows) - len(self._parties_displayed) 1025 if clipcount > 0: 1026 clipcount = max(clipcount, 50) 1027 self._ui_rows = self._ui_rows[:-clipcount] 1028 1029 # If we have no parties to show, we're done. 1030 if self._have_valid_server_list and not self._parties_displayed: 1031 bui.textwidget( 1032 edit=self._no_servers_found_text, 1033 text=bui.Lstr(resource='noServersFoundText'), 1034 ) 1035 return 1036 1037 sub_scroll_width = 830 1038 lineheight = 42 1039 sub_scroll_height = lineheight * len(self._parties_displayed) + 50 1040 bui.containerwidget( 1041 edit=columnwidget, size=(sub_scroll_width, sub_scroll_height) 1042 ) 1043 1044 # Any time our height changes, reset the refresh back to the top 1045 # so we don't see ugly empty spaces appearing during initial list 1046 # filling. 1047 if sub_scroll_height != self._last_sub_scroll_height: 1048 self._refresh_ui_row = 0 1049 self._last_sub_scroll_height = sub_scroll_height 1050 1051 # Also note that we need to redisplay everything since its pos 1052 # will have changed.. :( 1053 for party in self._parties.values(): 1054 party.clean_display_index = None 1055 1056 # Ew; this rebuilding generates deferred selection callbacks 1057 # so we need to push deferred notices so we know to ignore them. 1058 def refresh_on() -> None: 1059 self._refreshing_list = True 1060 1061 bui.pushcall(refresh_on) 1062 1063 # Ok, now here's the deal: we want to avoid creating/updating this 1064 # entire list at one time because it will lead to hitches. So we 1065 # refresh individual rows quickly in a loop. 1066 rowcount = min(12, len(self._parties_displayed)) 1067 1068 party_vals_displayed = list(self._parties_displayed.values()) 1069 while rowcount > 0: 1070 refresh_row = self._refresh_ui_row % len(self._parties_displayed) 1071 if refresh_row >= len(self._ui_rows): 1072 self._ui_rows.append(UIRow()) 1073 refresh_row = len(self._ui_rows) - 1 1074 1075 # For the first few seconds after getting our first server-list, 1076 # refresh only the top section of the list; this allows the lowest 1077 # ping servers to show up more quickly. 1078 if self._first_valid_server_list_time is not None: 1079 if time.time() - self._first_valid_server_list_time < 4.0: 1080 if refresh_row > 40: 1081 refresh_row = 0 1082 1083 self._ui_rows[refresh_row].update( 1084 refresh_row, 1085 party_vals_displayed[refresh_row], 1086 sub_scroll_width=sub_scroll_width, 1087 sub_scroll_height=sub_scroll_height, 1088 lineheight=lineheight, 1089 columnwidget=columnwidget, 1090 join_text=self._join_text, 1091 existing_selection=self._selection, 1092 filter_text=self._filter_text, 1093 tab=self, 1094 ) 1095 self._refresh_ui_row = refresh_row + 1 1096 rowcount -= 1 1097 1098 # So our selection callbacks can start firing.. 1099 def refresh_off() -> None: 1100 self._refreshing_list = False 1101 1102 bui.pushcall(refresh_off) 1103 1104 def _process_pending_party_infos(self) -> None: 1105 starttime = time.time() 1106 1107 # We want to do this in small enough pieces to not cause UI hitches. 1108 chunksize = 30 1109 parties_in = self._pending_party_infos[:chunksize] 1110 self._pending_party_infos = self._pending_party_infos[chunksize:] 1111 for party_in in parties_in: 1112 addr = party_in['a'] 1113 assert isinstance(addr, str) 1114 port = party_in['p'] 1115 assert isinstance(port, int) 1116 party_key = f'{addr}_{port}' 1117 party = self._parties.get(party_key) 1118 if party is None: 1119 # If this party is new to us, init it. 1120 party = PartyEntry( 1121 address=addr, 1122 next_ping_time=bui.apptime() + 0.001 * party_in['pd'], 1123 index=self._next_entry_index, 1124 ) 1125 self._parties[party_key] = party 1126 self._parties_sorted.append((party_key, party)) 1127 self._party_lists_dirty = True 1128 self._next_entry_index += 1 1129 assert isinstance(party.address, str) 1130 assert isinstance(party.next_ping_time, float) 1131 1132 # Now, new or not, update its values. 1133 party.queue = party_in.get('q') 1134 assert isinstance(party.queue, (str, type(None))) 1135 party.port = port 1136 party.name = party_in['n'] 1137 assert isinstance(party.name, str) 1138 party.size = party_in['s'] 1139 assert isinstance(party.size, int) 1140 party.size_max = party_in['sm'] 1141 assert isinstance(party.size_max, int) 1142 1143 # Server provides this in milliseconds; we use seconds. 1144 party.ping_interval = 0.001 * party_in['pi'] 1145 assert isinstance(party.ping_interval, float) 1146 party.stats_addr = party_in['sa'] 1147 assert isinstance(party.stats_addr, (str, type(None))) 1148 1149 # Make sure the party's UI gets updated. 1150 party.clean_display_index = None 1151 1152 if DEBUG_PROCESSING and parties_in: 1153 print( 1154 f'Processed {len(parties_in)} raw party infos in' 1155 f' {time.time()-starttime:.5f}s.' 1156 ) 1157 1158 def _update_party_lists(self) -> None: 1159 plus = bui.app.plus 1160 assert plus is not None 1161 1162 if not self._party_lists_dirty: 1163 return 1164 starttime = time.time() 1165 assert len(self._parties_sorted) == len(self._parties) 1166 1167 self._parties_sorted.sort( 1168 key=lambda p: ( 1169 p[1].ping if p[1].ping is not None else 999999.0, 1170 p[1].index, 1171 ) 1172 ) 1173 1174 # If signed out or errored, show no parties. 1175 if ( 1176 plus.get_v1_account_state() != 'signed_in' 1177 or not self._have_valid_server_list 1178 ): 1179 self._parties_displayed = {} 1180 else: 1181 if self._filter_value: 1182 filterval = self._filter_value.lower() 1183 self._parties_displayed = { 1184 k: v 1185 for k, v in self._parties_sorted 1186 if filterval in v.name.lower() 1187 } 1188 else: 1189 self._parties_displayed = dict(self._parties_sorted) 1190 1191 # Any time our selection disappears from the displayed list, go back to 1192 # auto-selecting the top entry. 1193 if ( 1194 self._selection is not None 1195 and self._selection.entry_key not in self._parties_displayed 1196 ): 1197 self._have_user_selected_row = False 1198 1199 # Whenever the user hasn't selected something, keep the first visible 1200 # row selected. 1201 if not self._have_user_selected_row and self._parties_displayed: 1202 firstpartykey = next(iter(self._parties_displayed)) 1203 self._selection = Selection(firstpartykey, SelectionComponent.NAME) 1204 1205 self._party_lists_dirty = False 1206 if DEBUG_PROCESSING: 1207 print( 1208 f'Sorted {len(self._parties_sorted)} parties in' 1209 f' {time.time()-starttime:.5f}s.' 1210 ) 1211 1212 def _query_party_list_periodically(self) -> None: 1213 now = bui.apptime() 1214 1215 plus = bui.app.plus 1216 assert plus is not None 1217 1218 # Fire off a new public-party query periodically. 1219 if ( 1220 self._last_server_list_query_time is None 1221 or now - self._last_server_list_query_time 1222 > 0.001 1223 * plus.get_v1_account_misc_read_val('pubPartyRefreshMS', 10000) 1224 ): 1225 self._last_server_list_query_time = now 1226 if DEBUG_SERVER_COMMUNICATION: 1227 print('REQUESTING SERVER LIST') 1228 if plus.get_v1_account_state() == 'signed_in': 1229 plus.add_v1_account_transaction( 1230 { 1231 'type': 'PUBLIC_PARTY_QUERY', 1232 'proto': bs.protocol_version(), 1233 'lang': bui.app.lang.language, 1234 }, 1235 callback=bui.WeakCall(self._on_public_party_query_result), 1236 ) 1237 plus.run_v1_account_transactions() 1238 else: 1239 self._on_public_party_query_result(None) 1240 1241 def _ping_parties_periodically(self) -> None: 1242 assert bui.app.classic is not None 1243 now = bui.apptime() 1244 1245 # Go through our existing public party entries firing off pings 1246 # for any that have timed out. 1247 for party in list(self._parties.values()): 1248 if ( 1249 party.next_ping_time <= now 1250 and bui.app.classic.ping_thread_count < 15 1251 ): 1252 # Crank the interval up for high-latency or non-responding 1253 # parties to save us some useless work. 1254 mult = 1 1255 if party.ping_responses == 0: 1256 if party.ping_attempts > 4: 1257 mult = 10 1258 elif party.ping_attempts > 2: 1259 mult = 5 1260 if party.ping is not None: 1261 mult = ( 1262 10 if party.ping > 300 else 5 if party.ping > 150 else 2 1263 ) 1264 1265 interval = party.ping_interval * mult 1266 if DEBUG_SERVER_COMMUNICATION: 1267 print( 1268 f'pinging #{party.index} cur={party.ping} ' 1269 f'interval={interval} ' 1270 f'({party.ping_responses}/{party.ping_attempts})' 1271 ) 1272 1273 party.next_ping_time = now + party.ping_interval * mult 1274 party.ping_attempts += 1 1275 1276 PingThread( 1277 party.address, party.port, bui.WeakCall(self._ping_callback) 1278 ).start() 1279 1280 def _ping_callback( 1281 self, address: str, port: int | None, result: float | None 1282 ) -> None: 1283 # Look for a widget corresponding to this target. 1284 # If we find one, update our list. 1285 party_key = f'{address}_{port}' 1286 party = self._parties.get(party_key) 1287 if party is not None: 1288 if result is not None: 1289 party.ping_responses += 1 1290 1291 # We now smooth ping a bit to reduce jumping around in the list 1292 # (only where pings are relatively good). 1293 current_ping = party.ping 1294 if current_ping is not None and result is not None and result < 150: 1295 smoothing = 0.7 1296 party.ping = ( 1297 smoothing * current_ping + (1.0 - smoothing) * result 1298 ) 1299 else: 1300 party.ping = result 1301 1302 # Need to re-sort the list and update the row display. 1303 party.clean_display_index = None 1304 self._party_lists_dirty = True 1305 1306 def _fetch_local_addr_cb(self, val: str) -> None: 1307 self._local_address = str(val) 1308 1309 def _on_public_party_accessible_response( 1310 self, data: dict[str, Any] | None 1311 ) -> None: 1312 # If we've got status text widgets, update them. 1313 text = self._host_status_text 1314 if text: 1315 if data is None: 1316 bui.textwidget( 1317 edit=text, 1318 text=bui.Lstr( 1319 resource='gatherWindow.' 'partyStatusNoConnectionText' 1320 ), 1321 color=(1, 0, 0), 1322 ) 1323 else: 1324 if not data.get('accessible', False): 1325 ex_line: str | bui.Lstr 1326 if self._local_address is not None: 1327 ex_line = bui.Lstr( 1328 value='\n${A} ${B}', 1329 subs=[ 1330 ( 1331 '${A}', 1332 bui.Lstr( 1333 resource='gatherWindow.' 1334 'manualYourLocalAddressText' 1335 ), 1336 ), 1337 ('${B}', self._local_address), 1338 ], 1339 ) 1340 else: 1341 ex_line = '' 1342 bui.textwidget( 1343 edit=text, 1344 text=bui.Lstr( 1345 value='${A}\n${B}${C}', 1346 subs=[ 1347 ( 1348 '${A}', 1349 bui.Lstr( 1350 resource='gatherWindow.' 1351 'partyStatusNotJoinableText' 1352 ), 1353 ), 1354 ( 1355 '${B}', 1356 bui.Lstr( 1357 resource='gatherWindow.' 1358 'manualRouterForwardingText', 1359 subs=[ 1360 ( 1361 '${PORT}', 1362 str(bs.get_game_port()), 1363 ) 1364 ], 1365 ), 1366 ), 1367 ('${C}', ex_line), 1368 ], 1369 ), 1370 color=(1, 0, 0), 1371 ) 1372 else: 1373 bui.textwidget( 1374 edit=text, 1375 text=bui.Lstr( 1376 resource='gatherWindow.' 'partyStatusJoinableText' 1377 ), 1378 color=(0, 1, 0), 1379 ) 1380 1381 def _do_status_check(self) -> None: 1382 assert bui.app.classic is not None 1383 bui.textwidget( 1384 edit=self._host_status_text, 1385 color=(1, 1, 0), 1386 text=bui.Lstr(resource='gatherWindow.' 'partyStatusCheckingText'), 1387 ) 1388 bui.app.classic.master_server_v1_get( 1389 'bsAccessCheck', 1390 {'b': bui.app.env.engine_build_number}, 1391 callback=bui.WeakCall(self._on_public_party_accessible_response), 1392 ) 1393 1394 def _on_start_advertizing_press(self) -> None: 1395 from bauiv1lib.account.signin import show_sign_in_prompt 1396 1397 plus = bui.app.plus 1398 assert plus is not None 1399 1400 if plus.get_v1_account_state() != 'signed_in': 1401 show_sign_in_prompt() 1402 return 1403 1404 name = cast(str, bui.textwidget(query=self._host_name_text)) 1405 if name == '': 1406 bui.screenmessage( 1407 bui.Lstr(resource='internal.invalidNameErrorText'), 1408 color=(1, 0, 0), 1409 ) 1410 bui.getsound('error').play() 1411 return 1412 bs.set_public_party_name(name) 1413 cfg = bui.app.config 1414 cfg['Public Party Name'] = name 1415 cfg.commit() 1416 bui.getsound('shieldUp').play() 1417 bs.set_public_party_enabled(True) 1418 1419 # In GUI builds we want to authenticate clients only when hosting 1420 # public parties. 1421 bs.set_authenticate_clients(True) 1422 1423 self._do_status_check() 1424 bui.buttonwidget( 1425 edit=self._host_toggle_button, 1426 label=bui.Lstr( 1427 resource='gatherWindow.makePartyPrivateText', 1428 fallback_resource='gatherWindow.stopAdvertisingText', 1429 ), 1430 on_activate_call=self._on_stop_advertising_press, 1431 ) 1432 1433 def _on_stop_advertising_press(self) -> None: 1434 bs.set_public_party_enabled(False) 1435 1436 # In GUI builds we want to authenticate clients only when hosting 1437 # public parties. 1438 bs.set_authenticate_clients(False) 1439 bui.getsound('shieldDown').play() 1440 text = self._host_status_text 1441 if text: 1442 bui.textwidget( 1443 edit=text, 1444 text=bui.Lstr( 1445 resource='gatherWindow.' 'partyStatusNotPublicText' 1446 ), 1447 color=(0.6, 0.6, 0.6), 1448 ) 1449 bui.buttonwidget( 1450 edit=self._host_toggle_button, 1451 label=bui.Lstr( 1452 resource='gatherWindow.makePartyPublicText', 1453 fallback_resource='gatherWindow.startAdvertisingText', 1454 ), 1455 on_activate_call=self._on_start_advertizing_press, 1456 ) 1457 1458 def on_public_party_activate(self, party: PartyEntry) -> None: 1459 """Called when a party is clicked or otherwise activated.""" 1460 self.save_state() 1461 if party.queue is not None: 1462 from bauiv1lib.partyqueue import PartyQueueWindow 1463 1464 bui.getsound('swish').play() 1465 PartyQueueWindow(party.queue, party.address, party.port) 1466 else: 1467 address = party.address 1468 port = party.port 1469 1470 # Store UI location to return to when done. 1471 if bs.app.classic is not None: 1472 bs.app.classic.save_ui_state() 1473 1474 # Rate limit this a bit. 1475 now = time.time() 1476 last_connect_time = self._last_connect_attempt_time 1477 if last_connect_time is None or now - last_connect_time > 2.0: 1478 bs.connect_to_party(address, port=port) 1479 self._last_connect_attempt_time = now 1480 1481 def set_public_party_selection(self, sel: Selection) -> None: 1482 """Set the sel.""" 1483 if self._refreshing_list: 1484 return 1485 self._selection = sel 1486 self._have_user_selected_row = True 1487 1488 def _on_max_public_party_size_minus_press(self) -> None: 1489 val = max(1, bs.get_public_party_max_size() - 1) 1490 bs.set_public_party_max_size(val) 1491 bui.textwidget(edit=self._host_max_party_size_value, text=str(val)) 1492 1493 def _on_max_public_party_size_plus_press(self) -> None: 1494 val = bs.get_public_party_max_size() 1495 val += 1 1496 bs.set_public_party_max_size(val) 1497 bui.textwidget(edit=self._host_max_party_size_value, text=str(val))
Available sub-tabs.
38@dataclass 39class PartyEntry: 40 """Info about a public party.""" 41 42 address: str 43 index: int 44 queue: str | None = None 45 port: int = -1 46 name: str = '' 47 size: int = -1 48 size_max: int = -1 49 claimed: bool = False 50 ping: float | None = None 51 ping_interval: float = -1.0 52 next_ping_time: float = -1.0 53 ping_attempts: int = 0 54 ping_responses: int = 0 55 stats_addr: str | None = None 56 clean_display_index: int | None = None 57 58 def get_key(self) -> str: 59 """Return the key used to store this party.""" 60 return f'{self.address}_{self.port}'
Info about a public party.
63class UIRow: 64 """Wrangles UI for a row in the party list.""" 65 66 def __init__(self) -> None: 67 self._name_widget: bui.Widget | None = None 68 self._size_widget: bui.Widget | None = None 69 self._ping_widget: bui.Widget | None = None 70 self._stats_button: bui.Widget | None = None 71 72 def __del__(self) -> None: 73 self._clear() 74 75 def _clear(self) -> None: 76 for widget in [ 77 self._name_widget, 78 self._size_widget, 79 self._ping_widget, 80 self._stats_button, 81 ]: 82 if widget: 83 widget.delete() 84 85 def update( 86 self, 87 index: int, 88 party: PartyEntry, 89 sub_scroll_width: float, 90 sub_scroll_height: float, 91 lineheight: float, 92 columnwidget: bui.Widget, 93 join_text: bui.Widget, 94 filter_text: bui.Widget, 95 existing_selection: Selection | None, 96 tab: PublicGatherTab, 97 ) -> None: 98 """Update for the given data.""" 99 # pylint: disable=too-many-locals 100 # pylint: disable=too-many-positional-arguments 101 102 plus = bui.app.plus 103 assert plus is not None 104 105 # Quick-out: if we've been marked clean for a certain index and 106 # we're still at that index, we're done. 107 if party.clean_display_index == index: 108 return 109 110 ping_good = plus.get_v1_account_misc_read_val('pingGood', 100) 111 ping_med = plus.get_v1_account_misc_read_val('pingMed', 500) 112 113 self._clear() 114 hpos = 20 115 vpos = sub_scroll_height - lineheight * index - 50 116 self._name_widget = bui.textwidget( 117 text=bui.Lstr(value=party.name), 118 parent=columnwidget, 119 size=(sub_scroll_width * 0.46, 20), 120 position=(0 + hpos, 4 + vpos), 121 selectable=True, 122 on_select_call=bui.WeakCall( 123 tab.set_public_party_selection, 124 Selection(party.get_key(), SelectionComponent.NAME), 125 ), 126 on_activate_call=bui.WeakCall(tab.on_public_party_activate, party), 127 click_activate=True, 128 maxwidth=sub_scroll_width * 0.45, 129 corner_scale=1.4, 130 autoselect=True, 131 color=(1, 1, 1, 0.3 if party.ping is None else 1.0), 132 h_align='left', 133 v_align='center', 134 ) 135 bui.widget( 136 edit=self._name_widget, 137 left_widget=join_text, 138 show_buffer_top=64.0, 139 show_buffer_bottom=64.0, 140 ) 141 if existing_selection == Selection( 142 party.get_key(), SelectionComponent.NAME 143 ): 144 bui.containerwidget( 145 edit=columnwidget, selected_child=self._name_widget 146 ) 147 if party.stats_addr: 148 url = party.stats_addr.replace( 149 '${ACCOUNT}', 150 plus.get_v1_account_misc_read_val_2( 151 'resolvedAccountID', 'UNKNOWN' 152 ), 153 ) 154 self._stats_button = bui.buttonwidget( 155 color=(0.3, 0.6, 0.94), 156 textcolor=(1.0, 1.0, 1.0), 157 label=bui.Lstr(resource='statsText'), 158 parent=columnwidget, 159 autoselect=True, 160 on_activate_call=bui.Call(bui.open_url, url), 161 on_select_call=bui.WeakCall( 162 tab.set_public_party_selection, 163 Selection(party.get_key(), SelectionComponent.STATS_BUTTON), 164 ), 165 size=(120, 40), 166 position=(sub_scroll_width * 0.66 + hpos, 1 + vpos), 167 scale=0.9, 168 ) 169 if existing_selection == Selection( 170 party.get_key(), SelectionComponent.STATS_BUTTON 171 ): 172 bui.containerwidget( 173 edit=columnwidget, selected_child=self._stats_button 174 ) 175 176 self._size_widget = bui.textwidget( 177 text=str(party.size) + '/' + str(party.size_max), 178 parent=columnwidget, 179 size=(0, 0), 180 position=(sub_scroll_width * 0.86 + hpos, 20 + vpos), 181 scale=0.7, 182 color=(0.8, 0.8, 0.8), 183 h_align='right', 184 v_align='center', 185 ) 186 187 if index == 0: 188 bui.widget(edit=self._name_widget, up_widget=filter_text) 189 if self._stats_button: 190 bui.widget(edit=self._stats_button, up_widget=filter_text) 191 192 self._ping_widget = bui.textwidget( 193 parent=columnwidget, 194 size=(0, 0), 195 position=(sub_scroll_width * 0.94 + hpos, 20 + vpos), 196 scale=0.7, 197 h_align='right', 198 v_align='center', 199 ) 200 if party.ping is None: 201 bui.textwidget( 202 edit=self._ping_widget, text='-', color=(0.5, 0.5, 0.5) 203 ) 204 else: 205 bui.textwidget( 206 edit=self._ping_widget, 207 text=str(int(party.ping)), 208 color=( 209 (0, 1, 0) 210 if party.ping <= ping_good 211 else (1, 1, 0) if party.ping <= ping_med else (1, 0, 0) 212 ), 213 ) 214 215 party.clean_display_index = index
Wrangles UI for a row in the party list.
85 def update( 86 self, 87 index: int, 88 party: PartyEntry, 89 sub_scroll_width: float, 90 sub_scroll_height: float, 91 lineheight: float, 92 columnwidget: bui.Widget, 93 join_text: bui.Widget, 94 filter_text: bui.Widget, 95 existing_selection: Selection | None, 96 tab: PublicGatherTab, 97 ) -> None: 98 """Update for the given data.""" 99 # pylint: disable=too-many-locals 100 # pylint: disable=too-many-positional-arguments 101 102 plus = bui.app.plus 103 assert plus is not None 104 105 # Quick-out: if we've been marked clean for a certain index and 106 # we're still at that index, we're done. 107 if party.clean_display_index == index: 108 return 109 110 ping_good = plus.get_v1_account_misc_read_val('pingGood', 100) 111 ping_med = plus.get_v1_account_misc_read_val('pingMed', 500) 112 113 self._clear() 114 hpos = 20 115 vpos = sub_scroll_height - lineheight * index - 50 116 self._name_widget = bui.textwidget( 117 text=bui.Lstr(value=party.name), 118 parent=columnwidget, 119 size=(sub_scroll_width * 0.46, 20), 120 position=(0 + hpos, 4 + vpos), 121 selectable=True, 122 on_select_call=bui.WeakCall( 123 tab.set_public_party_selection, 124 Selection(party.get_key(), SelectionComponent.NAME), 125 ), 126 on_activate_call=bui.WeakCall(tab.on_public_party_activate, party), 127 click_activate=True, 128 maxwidth=sub_scroll_width * 0.45, 129 corner_scale=1.4, 130 autoselect=True, 131 color=(1, 1, 1, 0.3 if party.ping is None else 1.0), 132 h_align='left', 133 v_align='center', 134 ) 135 bui.widget( 136 edit=self._name_widget, 137 left_widget=join_text, 138 show_buffer_top=64.0, 139 show_buffer_bottom=64.0, 140 ) 141 if existing_selection == Selection( 142 party.get_key(), SelectionComponent.NAME 143 ): 144 bui.containerwidget( 145 edit=columnwidget, selected_child=self._name_widget 146 ) 147 if party.stats_addr: 148 url = party.stats_addr.replace( 149 '${ACCOUNT}', 150 plus.get_v1_account_misc_read_val_2( 151 'resolvedAccountID', 'UNKNOWN' 152 ), 153 ) 154 self._stats_button = bui.buttonwidget( 155 color=(0.3, 0.6, 0.94), 156 textcolor=(1.0, 1.0, 1.0), 157 label=bui.Lstr(resource='statsText'), 158 parent=columnwidget, 159 autoselect=True, 160 on_activate_call=bui.Call(bui.open_url, url), 161 on_select_call=bui.WeakCall( 162 tab.set_public_party_selection, 163 Selection(party.get_key(), SelectionComponent.STATS_BUTTON), 164 ), 165 size=(120, 40), 166 position=(sub_scroll_width * 0.66 + hpos, 1 + vpos), 167 scale=0.9, 168 ) 169 if existing_selection == Selection( 170 party.get_key(), SelectionComponent.STATS_BUTTON 171 ): 172 bui.containerwidget( 173 edit=columnwidget, selected_child=self._stats_button 174 ) 175 176 self._size_widget = bui.textwidget( 177 text=str(party.size) + '/' + str(party.size_max), 178 parent=columnwidget, 179 size=(0, 0), 180 position=(sub_scroll_width * 0.86 + hpos, 20 + vpos), 181 scale=0.7, 182 color=(0.8, 0.8, 0.8), 183 h_align='right', 184 v_align='center', 185 ) 186 187 if index == 0: 188 bui.widget(edit=self._name_widget, up_widget=filter_text) 189 if self._stats_button: 190 bui.widget(edit=self._stats_button, up_widget=filter_text) 191 192 self._ping_widget = bui.textwidget( 193 parent=columnwidget, 194 size=(0, 0), 195 position=(sub_scroll_width * 0.94 + hpos, 20 + vpos), 196 scale=0.7, 197 h_align='right', 198 v_align='center', 199 ) 200 if party.ping is None: 201 bui.textwidget( 202 edit=self._ping_widget, text='-', color=(0.5, 0.5, 0.5) 203 ) 204 else: 205 bui.textwidget( 206 edit=self._ping_widget, 207 text=str(int(party.ping)), 208 color=( 209 (0, 1, 0) 210 if party.ping <= ping_good 211 else (1, 1, 0) if party.ping <= ping_med else (1, 0, 0) 212 ), 213 ) 214 215 party.clean_display_index = index
Update for the given data.
218@dataclass 219class State: 220 """State saved/restored only while the app is running.""" 221 222 sub_tab: SubTabType = SubTabType.JOIN 223 parties: list[tuple[str, PartyEntry]] | None = None 224 next_entry_index: int = 0 225 filter_value: str = '' 226 have_server_list_response: bool = False 227 have_valid_server_list: bool = False
State saved/restored only while the app is running.
230class SelectionComponent(Enum): 231 """Describes what part of an entry is selected.""" 232 233 NAME = 'name' 234 STATS_BUTTON = 'stats_button'
Describes what part of an entry is selected.
237@dataclass 238class Selection: 239 """Describes the currently selected list element.""" 240 241 entry_key: str 242 component: SelectionComponent
Describes the currently selected list element.
245class AddrFetchThread(Thread): 246 """Thread for fetching an address in the bg.""" 247 248 def __init__(self, call: Callable[[Any], Any]): 249 super().__init__() 250 self._call = call 251 252 @override 253 def run(self) -> None: 254 sock: socket.socket | None = None 255 try: 256 # FIXME: Update this to work with IPv6 at some point. 257 import socket 258 259 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 260 sock.connect(('8.8.8.8', 80)) 261 val = sock.getsockname()[0] 262 bui.pushcall(bui.Call(self._call, val), from_other_thread=True) 263 except Exception as exc: 264 from efro.error import is_udp_communication_error 265 266 # Ignore expected network errors; log others. 267 if is_udp_communication_error(exc): 268 pass 269 else: 270 logging.exception('Error in addr-fetch-thread') 271 finally: 272 if sock is not None: 273 sock.close()
Thread for fetching an address in the bg.
This constructor should always be called with keyword arguments. Arguments are:
group should be None; reserved for future extension when a ThreadGroup class is implemented.
target is the callable object to be invoked by the run() method. Defaults to None, meaning nothing is called.
name is the thread name. By default, a unique name is constructed of the form "Thread-N" where N is a small decimal number.
args is a list or tuple of arguments for the target invocation. Defaults to ().
kwargs is a dictionary of keyword arguments for the target invocation. Defaults to {}.
If a subclass overrides the constructor, it must make sure to invoke the base class constructor (Thread.__init__()) before doing anything else to the thread.
252 @override 253 def run(self) -> None: 254 sock: socket.socket | None = None 255 try: 256 # FIXME: Update this to work with IPv6 at some point. 257 import socket 258 259 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 260 sock.connect(('8.8.8.8', 80)) 261 val = sock.getsockname()[0] 262 bui.pushcall(bui.Call(self._call, val), from_other_thread=True) 263 except Exception as exc: 264 from efro.error import is_udp_communication_error 265 266 # Ignore expected network errors; log others. 267 if is_udp_communication_error(exc): 268 pass 269 else: 270 logging.exception('Error in addr-fetch-thread') 271 finally: 272 if sock is not None: 273 sock.close()
Method representing the thread's activity.
You may override this method in a subclass. The standard run() method invokes the callable object passed to the object's constructor as the target argument, if any, with sequential and keyword arguments taken from the args and kwargs arguments, respectively.
276class PingThread(Thread): 277 """Thread for sending out game pings.""" 278 279 def __init__( 280 self, 281 address: str, 282 port: int, 283 call: Callable[[str, int, float | None], int | None], 284 ): 285 super().__init__() 286 self._address = address 287 self._port = port 288 self._call = call 289 290 @override 291 def run(self) -> None: 292 assert bui.app.classic is not None 293 bui.app.classic.ping_thread_count += 1 294 sock: socket.socket | None = None 295 try: 296 import socket 297 298 socket_type = bui.get_ip_address_type(self._address) 299 sock = socket.socket(socket_type, socket.SOCK_DGRAM) 300 sock.connect((self._address, self._port)) 301 302 accessible = False 303 starttime = time.time() 304 305 # Send a few pings and wait a second for 306 # a response. 307 sock.settimeout(1) 308 for _i in range(3): 309 sock.send(b'\x0b') 310 result: bytes | None 311 try: 312 # 11: BA_PACKET_SIMPLE_PING 313 result = sock.recv(10) 314 except Exception: 315 result = None 316 if result == b'\x0c': 317 # 12: BA_PACKET_SIMPLE_PONG 318 accessible = True 319 break 320 time.sleep(1) 321 ping = (time.time() - starttime) * 1000.0 322 bui.pushcall( 323 bui.Call( 324 self._call, 325 self._address, 326 self._port, 327 ping if accessible else None, 328 ), 329 from_other_thread=True, 330 ) 331 except Exception as exc: 332 from efro.error import is_udp_communication_error 333 334 if is_udp_communication_error(exc): 335 pass 336 else: 337 if bui.do_once(): 338 logging.exception('Error on gather ping.') 339 finally: 340 try: 341 if sock is not None: 342 sock.close() 343 except Exception: 344 if bui.do_once(): 345 logging.exception('Error on gather ping cleanup') 346 347 bui.app.classic.ping_thread_count -= 1
Thread for sending out game pings.
279 def __init__( 280 self, 281 address: str, 282 port: int, 283 call: Callable[[str, int, float | None], int | None], 284 ): 285 super().__init__() 286 self._address = address 287 self._port = port 288 self._call = call
This constructor should always be called with keyword arguments. Arguments are:
group should be None; reserved for future extension when a ThreadGroup class is implemented.
target is the callable object to be invoked by the run() method. Defaults to None, meaning nothing is called.
name is the thread name. By default, a unique name is constructed of the form "Thread-N" where N is a small decimal number.
args is a list or tuple of arguments for the target invocation. Defaults to ().
kwargs is a dictionary of keyword arguments for the target invocation. Defaults to {}.
If a subclass overrides the constructor, it must make sure to invoke the base class constructor (Thread.__init__()) before doing anything else to the thread.
290 @override 291 def run(self) -> None: 292 assert bui.app.classic is not None 293 bui.app.classic.ping_thread_count += 1 294 sock: socket.socket | None = None 295 try: 296 import socket 297 298 socket_type = bui.get_ip_address_type(self._address) 299 sock = socket.socket(socket_type, socket.SOCK_DGRAM) 300 sock.connect((self._address, self._port)) 301 302 accessible = False 303 starttime = time.time() 304 305 # Send a few pings and wait a second for 306 # a response. 307 sock.settimeout(1) 308 for _i in range(3): 309 sock.send(b'\x0b') 310 result: bytes | None 311 try: 312 # 11: BA_PACKET_SIMPLE_PING 313 result = sock.recv(10) 314 except Exception: 315 result = None 316 if result == b'\x0c': 317 # 12: BA_PACKET_SIMPLE_PONG 318 accessible = True 319 break 320 time.sleep(1) 321 ping = (time.time() - starttime) * 1000.0 322 bui.pushcall( 323 bui.Call( 324 self._call, 325 self._address, 326 self._port, 327 ping if accessible else None, 328 ), 329 from_other_thread=True, 330 ) 331 except Exception as exc: 332 from efro.error import is_udp_communication_error 333 334 if is_udp_communication_error(exc): 335 pass 336 else: 337 if bui.do_once(): 338 logging.exception('Error on gather ping.') 339 finally: 340 try: 341 if sock is not None: 342 sock.close() 343 except Exception: 344 if bui.do_once(): 345 logging.exception('Error on gather ping cleanup') 346 347 bui.app.classic.ping_thread_count -= 1
Method representing the thread's activity.
You may override this method in a subclass. The standard run() method invokes the callable object passed to the object's constructor as the target argument, if any, with sequential and keyword arguments taken from the args and kwargs arguments, respectively.
350class PublicGatherTab(GatherTab): 351 """The public tab in the gather UI""" 352 353 def __init__(self, window: GatherWindow) -> None: 354 super().__init__(window) 355 self._container: bui.Widget | None = None 356 self._join_text: bui.Widget | None = None 357 self._host_text: bui.Widget | None = None 358 self._filter_text: bui.Widget | None = None 359 self._local_address: str | None = None 360 self._last_connect_attempt_time: float | None = None 361 self._sub_tab: SubTabType = SubTabType.JOIN 362 self._selection: Selection | None = None 363 self._refreshing_list = False 364 self._update_timer: bui.AppTimer | None = None 365 self._host_scrollwidget: bui.Widget | None = None 366 self._host_name_text: bui.Widget | None = None 367 self._host_toggle_button: bui.Widget | None = None 368 self._last_server_list_query_time: float | None = None 369 self._join_list_column: bui.Widget | None = None 370 self._join_status_text: bui.Widget | None = None 371 self._join_status_spinner: bui.Widget | None = None 372 self._no_servers_found_text: bui.Widget | None = None 373 self._host_max_party_size_value: bui.Widget | None = None 374 self._host_max_party_size_minus_button: bui.Widget | None = None 375 self._host_max_party_size_plus_button: bui.Widget | None = None 376 self._host_status_text: bui.Widget | None = None 377 self._signed_in = False 378 self._ui_rows: list[UIRow] = [] 379 self._refresh_ui_row = 0 380 self._have_user_selected_row = False 381 self._first_valid_server_list_time: float | None = None 382 383 # Parties indexed by id: 384 self._parties: dict[str, PartyEntry] = {} 385 386 # Parties sorted in display order: 387 self._parties_sorted: list[tuple[str, PartyEntry]] = [] 388 self._party_lists_dirty = True 389 390 # Sorted parties with filter applied: 391 self._parties_displayed: dict[str, PartyEntry] = {} 392 393 self._next_entry_index = 0 394 self._have_server_list_response = False 395 self._have_valid_server_list = False 396 self._filter_value = '' 397 self._pending_party_infos: list[dict[str, Any]] = [] 398 self._last_sub_scroll_height = 0.0 399 400 @override 401 def on_activate( 402 self, 403 parent_widget: bui.Widget, 404 tab_button: bui.Widget, 405 region_width: float, 406 region_height: float, 407 region_left: float, 408 region_bottom: float, 409 ) -> bui.Widget: 410 # pylint: disable=too-many-positional-arguments 411 c_width = region_width 412 c_height = region_height - 20 413 self._container = bui.containerwidget( 414 parent=parent_widget, 415 position=( 416 region_left, 417 region_bottom + (region_height - c_height) * 0.5, 418 ), 419 size=(c_width, c_height), 420 background=False, 421 selection_loops_to_parent=True, 422 ) 423 v = c_height - 30 424 self._join_text = bui.textwidget( 425 parent=self._container, 426 position=(c_width * 0.5 - 245, v - 13), 427 color=(0.6, 1.0, 0.6), 428 scale=1.3, 429 size=(200, 30), 430 maxwidth=250, 431 h_align='left', 432 v_align='center', 433 click_activate=True, 434 selectable=True, 435 autoselect=True, 436 on_activate_call=lambda: self._set_sub_tab( 437 SubTabType.JOIN, 438 region_width, 439 region_height, 440 playsound=True, 441 ), 442 text=bui.Lstr( 443 resource='gatherWindow.' 'joinPublicPartyDescriptionText' 444 ), 445 glow_type='uniform', 446 ) 447 self._host_text = bui.textwidget( 448 parent=self._container, 449 position=(c_width * 0.5 + 45, v - 13), 450 color=(0.6, 1.0, 0.6), 451 scale=1.3, 452 size=(200, 30), 453 maxwidth=250, 454 h_align='left', 455 v_align='center', 456 click_activate=True, 457 selectable=True, 458 autoselect=True, 459 on_activate_call=lambda: self._set_sub_tab( 460 SubTabType.HOST, 461 region_width, 462 region_height, 463 playsound=True, 464 ), 465 text=bui.Lstr( 466 resource='gatherWindow.' 'hostPublicPartyDescriptionText' 467 ), 468 glow_type='uniform', 469 ) 470 bui.widget(edit=self._join_text, up_widget=tab_button) 471 bui.widget( 472 edit=self._host_text, 473 left_widget=self._join_text, 474 up_widget=tab_button, 475 ) 476 bui.widget(edit=self._join_text, right_widget=self._host_text) 477 478 # Attempt to fetch our local address so we have it for error messages. 479 if self._local_address is None: 480 AddrFetchThread(bui.WeakCall(self._fetch_local_addr_cb)).start() 481 482 self._set_sub_tab(self._sub_tab, region_width, region_height) 483 self._update_timer = bui.AppTimer( 484 0.1, bui.WeakCall(self._update), repeat=True 485 ) 486 return self._container 487 488 @override 489 def on_deactivate(self) -> None: 490 self._update_timer = None 491 492 @override 493 def save_state(self) -> None: 494 # Save off a small number of parties with the lowest ping; we'll 495 # display these immediately when our UI comes back up which should 496 # be enough to make things feel nice and crisp while we do a full 497 # server re-query or whatnot. 498 assert bui.app.classic is not None 499 bui.app.ui_v1.window_states[type(self)] = State( 500 sub_tab=self._sub_tab, 501 parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]], 502 next_entry_index=self._next_entry_index, 503 filter_value=self._filter_value, 504 have_server_list_response=self._have_server_list_response, 505 have_valid_server_list=self._have_valid_server_list, 506 ) 507 508 @override 509 def restore_state(self) -> None: 510 assert bui.app.classic is not None 511 state = bui.app.ui_v1.window_states.get(type(self)) 512 if state is None: 513 state = State() 514 assert isinstance(state, State) 515 self._sub_tab = state.sub_tab 516 517 # Restore the parties we stored. 518 if state.parties: 519 self._parties = { 520 key: copy.copy(party) for key, party in state.parties 521 } 522 self._parties_sorted = list(self._parties.items()) 523 self._party_lists_dirty = True 524 525 self._next_entry_index = state.next_entry_index 526 527 # FIXME: should save/restore these too?.. 528 self._have_server_list_response = state.have_server_list_response 529 self._have_valid_server_list = state.have_valid_server_list 530 self._filter_value = state.filter_value 531 532 def _set_sub_tab( 533 self, 534 value: SubTabType, 535 region_width: float, 536 region_height: float, 537 playsound: bool = False, 538 ) -> None: 539 assert self._container 540 if playsound: 541 bui.getsound('click01').play() 542 543 # Reset our selection. 544 # (prevents selecting something way down the list if we switched away 545 # and came back) 546 self._selection = None 547 self._have_user_selected_row = False 548 549 # Reset refresh to the top and make sure everything refreshes. 550 self._refresh_ui_row = 0 551 for party in self._parties.values(): 552 party.clean_display_index = None 553 554 self._sub_tab = value 555 active_color = (0.6, 1.0, 0.6) 556 inactive_color = (0.5, 0.4, 0.5) 557 bui.textwidget( 558 edit=self._join_text, 559 color=active_color if value is SubTabType.JOIN else inactive_color, 560 ) 561 bui.textwidget( 562 edit=self._host_text, 563 color=active_color if value is SubTabType.HOST else inactive_color, 564 ) 565 566 # Clear anything existing in the old sub-tab. 567 for widget in self._container.get_children(): 568 if widget and widget not in {self._host_text, self._join_text}: 569 widget.delete() 570 571 if value is SubTabType.JOIN: 572 self._build_join_tab(region_width, region_height) 573 574 if value is SubTabType.HOST: 575 self._build_host_tab(region_width, region_height) 576 577 def _build_join_tab( 578 self, region_width: float, region_height: float 579 ) -> None: 580 c_width = region_width 581 c_height = region_height - 20 582 sub_scroll_height = c_height - 125 583 sub_scroll_width = 830 584 v = c_height - 35 585 v -= 60 586 filter_txt = bui.Lstr(resource='filterText') 587 self._filter_text = bui.textwidget( 588 parent=self._container, 589 text=self._filter_value, 590 size=(350, 45), 591 position=(c_width * 0.5 - 150, v - 10), 592 h_align='left', 593 v_align='center', 594 editable=True, 595 maxwidth=310, 596 description=filter_txt, 597 ) 598 bui.widget(edit=self._filter_text, up_widget=self._join_text) 599 bui.textwidget( 600 text=filter_txt, 601 parent=self._container, 602 size=(0, 0), 603 position=(c_width * 0.5 - 170, v + 13), 604 maxwidth=150, 605 scale=0.8, 606 color=(0.5, 0.46, 0.5), 607 flatness=1.0, 608 h_align='right', 609 v_align='center', 610 ) 611 612 bui.textwidget( 613 text=bui.Lstr(resource='nameText'), 614 parent=self._container, 615 size=(0, 0), 616 position=((c_width - sub_scroll_width) * 0.5 + 50, v - 8), 617 maxwidth=60, 618 scale=0.6, 619 color=(0.5, 0.46, 0.5), 620 flatness=1.0, 621 h_align='center', 622 v_align='center', 623 ) 624 bui.textwidget( 625 text=bui.Lstr(resource='gatherWindow.partySizeText'), 626 parent=self._container, 627 size=(0, 0), 628 position=( 629 c_width * 0.5 + sub_scroll_width * 0.5 - 110, 630 v - 8, 631 ), 632 maxwidth=60, 633 scale=0.6, 634 color=(0.5, 0.46, 0.5), 635 flatness=1.0, 636 h_align='center', 637 v_align='center', 638 ) 639 bui.textwidget( 640 text=bui.Lstr(resource='gatherWindow.pingText'), 641 parent=self._container, 642 size=(0, 0), 643 position=( 644 c_width * 0.5 + sub_scroll_width * 0.5 - 30, 645 v - 8, 646 ), 647 maxwidth=60, 648 scale=0.6, 649 color=(0.5, 0.46, 0.5), 650 flatness=1.0, 651 h_align='center', 652 v_align='center', 653 ) 654 v -= sub_scroll_height + 23 655 self._host_scrollwidget = scrollw = bui.scrollwidget( 656 parent=self._container, 657 simple_culling_v=10, 658 position=((c_width - sub_scroll_width) * 0.5, v), 659 size=(sub_scroll_width, sub_scroll_height), 660 claims_up_down=False, 661 claims_left_right=True, 662 autoselect=True, 663 ) 664 self._join_list_column = bui.containerwidget( 665 parent=scrollw, 666 background=False, 667 size=(400, 400), 668 claims_left_right=True, 669 ) 670 671 # Create join status text and join spinner. Always make sure to 672 # update both of these together. 673 self._join_status_text = bui.textwidget( 674 parent=self._container, 675 text='', 676 size=(0, 0), 677 scale=0.9, 678 flatness=1.0, 679 shadow=0.0, 680 h_align='center', 681 v_align='top', 682 maxwidth=c_width, 683 color=(0.6, 0.6, 0.6), 684 position=(c_width * 0.5, c_height * 0.5), 685 ) 686 self._join_status_spinner = bui.spinnerwidget( 687 parent=self._container, position=(c_width * 0.5, c_height * 0.5) 688 ) 689 690 self._no_servers_found_text = bui.textwidget( 691 parent=self._container, 692 text='', 693 size=(0, 0), 694 scale=0.9, 695 flatness=1.0, 696 shadow=0.0, 697 h_align='center', 698 v_align='top', 699 color=(0.6, 0.6, 0.6), 700 position=(c_width * 0.5, c_height * 0.5), 701 ) 702 703 def _build_host_tab( 704 self, region_width: float, region_height: float 705 ) -> None: 706 c_width = region_width 707 c_height = region_height - 20 708 v = c_height - 35 709 v -= 25 710 is_public_enabled = bs.get_public_party_enabled() 711 v -= 30 712 713 bui.textwidget( 714 parent=self._container, 715 size=(0, 0), 716 h_align='center', 717 v_align='center', 718 maxwidth=c_width * 0.9, 719 scale=0.7, 720 flatness=1.0, 721 color=(0.5, 0.46, 0.5), 722 position=(region_width * 0.5, v + 10), 723 text=bui.Lstr(resource='gatherWindow.publicHostRouterConfigText'), 724 ) 725 v -= 30 726 727 party_name_text = bui.Lstr( 728 resource='gatherWindow.partyNameText', 729 fallback_resource='editGameListWindow.nameText', 730 ) 731 assert bui.app.classic is not None 732 bui.textwidget( 733 parent=self._container, 734 size=(0, 0), 735 h_align='right', 736 v_align='center', 737 maxwidth=200, 738 scale=0.8, 739 color=bui.app.ui_v1.infotextcolor, 740 position=(210, v - 9), 741 text=party_name_text, 742 ) 743 self._host_name_text = bui.textwidget( 744 parent=self._container, 745 editable=True, 746 size=(535, 40), 747 position=(230, v - 30), 748 text=bui.app.config.get('Public Party Name', ''), 749 maxwidth=494, 750 shadow=0.3, 751 flatness=1.0, 752 description=party_name_text, 753 autoselect=True, 754 v_align='center', 755 corner_scale=1.0, 756 ) 757 758 v -= 60 759 bui.textwidget( 760 parent=self._container, 761 size=(0, 0), 762 h_align='right', 763 v_align='center', 764 maxwidth=200, 765 scale=0.8, 766 color=bui.app.ui_v1.infotextcolor, 767 position=(210, v - 9), 768 text=bui.Lstr( 769 resource='maxPartySizeText', 770 fallback_resource='maxConnectionsText', 771 ), 772 ) 773 self._host_max_party_size_value = bui.textwidget( 774 parent=self._container, 775 size=(0, 0), 776 h_align='center', 777 v_align='center', 778 scale=1.2, 779 color=(1, 1, 1), 780 position=(240, v - 9), 781 text=str(bs.get_public_party_max_size()), 782 ) 783 btn1 = self._host_max_party_size_minus_button = bui.buttonwidget( 784 parent=self._container, 785 size=(40, 40), 786 on_activate_call=bui.WeakCall( 787 self._on_max_public_party_size_minus_press 788 ), 789 position=(280, v - 26), 790 label='-', 791 autoselect=True, 792 ) 793 btn2 = self._host_max_party_size_plus_button = bui.buttonwidget( 794 parent=self._container, 795 size=(40, 40), 796 on_activate_call=bui.WeakCall( 797 self._on_max_public_party_size_plus_press 798 ), 799 position=(350, v - 26), 800 label='+', 801 autoselect=True, 802 ) 803 v -= 50 804 v -= 70 805 if is_public_enabled: 806 label = bui.Lstr( 807 resource='gatherWindow.makePartyPrivateText', 808 fallback_resource='gatherWindow.stopAdvertisingText', 809 ) 810 else: 811 label = bui.Lstr( 812 resource='gatherWindow.makePartyPublicText', 813 fallback_resource='gatherWindow.startAdvertisingText', 814 ) 815 self._host_toggle_button = bui.buttonwidget( 816 parent=self._container, 817 label=label, 818 size=(400, 80), 819 on_activate_call=( 820 self._on_stop_advertising_press 821 if is_public_enabled 822 else self._on_start_advertizing_press 823 ), 824 position=(c_width * 0.5 - 200, v), 825 autoselect=True, 826 up_widget=btn2, 827 ) 828 bui.widget(edit=self._host_name_text, down_widget=btn2) 829 bui.widget(edit=btn2, up_widget=self._host_name_text) 830 bui.widget(edit=btn1, up_widget=self._host_name_text) 831 assert self._join_text is not None 832 bui.widget(edit=self._join_text, down_widget=self._host_name_text) 833 v -= 10 834 self._host_status_text = bui.textwidget( 835 parent=self._container, 836 text=bui.Lstr(resource='gatherWindow.' 'partyStatusNotPublicText'), 837 size=(0, 0), 838 scale=0.7, 839 flatness=1.0, 840 h_align='center', 841 v_align='top', 842 maxwidth=c_width * 0.9, 843 color=(0.6, 0.56, 0.6), 844 position=(c_width * 0.5, v), 845 ) 846 v -= 90 847 bui.textwidget( 848 parent=self._container, 849 text=bui.Lstr(resource='gatherWindow.dedicatedServerInfoText'), 850 size=(0, 0), 851 scale=0.7, 852 flatness=1.0, 853 h_align='center', 854 v_align='center', 855 maxwidth=c_width * 0.9, 856 color=(0.5, 0.46, 0.5), 857 position=(c_width * 0.5, v), 858 ) 859 860 # If public sharing is already on, 861 # launch a status-check immediately. 862 if bs.get_public_party_enabled(): 863 self._do_status_check() 864 865 def _on_public_party_query_result( 866 self, result: dict[str, Any] | None 867 ) -> None: 868 starttime = time.time() 869 self._have_server_list_response = True 870 871 if result is None: 872 self._have_valid_server_list = False 873 return 874 875 if not self._have_valid_server_list: 876 self._first_valid_server_list_time = time.time() 877 878 self._have_valid_server_list = True 879 parties_in = result['l'] 880 881 assert isinstance(parties_in, list) 882 self._pending_party_infos += parties_in 883 884 # To avoid causing a stutter here, we do most processing of 885 # these entries incrementally in our _update() method. 886 # The one thing we do here is prune parties not contained in 887 # this result. 888 for partyval in list(self._parties.values()): 889 partyval.claimed = False 890 for party_in in parties_in: 891 addr = party_in['a'] 892 assert isinstance(addr, str) 893 port = party_in['p'] 894 assert isinstance(port, int) 895 party_key = f'{addr}_{port}' 896 party = self._parties.get(party_key) 897 if party is not None: 898 party.claimed = True 899 self._parties = { 900 key: val for key, val in list(self._parties.items()) if val.claimed 901 } 902 self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed] 903 self._party_lists_dirty = True 904 905 # self._update_server_list() 906 if DEBUG_PROCESSING: 907 print( 908 f'Handled public party query results in ' 909 f'{time.time()-starttime:.5f}s.' 910 ) 911 912 def _update(self) -> None: 913 """Periodic updating.""" 914 915 plus = bui.app.plus 916 assert plus is not None 917 918 if self._sub_tab is SubTabType.JOIN: 919 # Keep our filter-text up to date from the UI. 920 text = self._filter_text 921 if text: 922 filter_value = cast(str, bui.textwidget(query=text)) 923 if filter_value != self._filter_value: 924 self._filter_value = filter_value 925 self._party_lists_dirty = True 926 927 # Also wipe out party clean-row states. 928 # (otherwise if a party disappears from a row due to 929 # filtering and then reappears on that same row when 930 # the filter is removed it may not update) 931 for party in self._parties.values(): 932 party.clean_display_index = None 933 934 self._query_party_list_periodically() 935 self._ping_parties_periodically() 936 937 # If any new party infos have come in, apply some of them. 938 self._process_pending_party_infos() 939 940 # Anytime we sign in/out, make sure we refresh our list. 941 signed_in = plus.get_v1_account_state() == 'signed_in' 942 if self._signed_in != signed_in: 943 self._signed_in = signed_in 944 self._party_lists_dirty = True 945 946 # Update sorting to account for ping updates, new parties, etc. 947 self._update_party_lists() 948 949 # If we've got a party-name text widget, keep its value plugged 950 # into our public host name. 951 text = self._host_name_text 952 if text: 953 name = cast(str, bui.textwidget(query=self._host_name_text)) 954 bs.set_public_party_name(name) 955 956 # Update status text and loading spinner. 957 if self._join_status_text: 958 assert self._join_status_spinner 959 if not signed_in: 960 bui.textwidget( 961 edit=self._join_status_text, 962 text=bui.Lstr(resource='notSignedInText'), 963 ) 964 bui.spinnerwidget(edit=self._join_status_spinner, visible=False) 965 else: 966 # If we have a valid list, show no status; just the list. 967 # Otherwise show either 'loading...' or 'error' depending 968 # on whether this is our first go-round. 969 if self._have_valid_server_list: 970 bui.textwidget(edit=self._join_status_text, text='') 971 bui.spinnerwidget( 972 edit=self._join_status_spinner, visible=False 973 ) 974 else: 975 if self._have_server_list_response: 976 bui.textwidget( 977 edit=self._join_status_text, 978 text=bui.Lstr(resource='errorText'), 979 ) 980 bui.spinnerwidget( 981 edit=self._join_status_spinner, visible=False 982 ) 983 else: 984 # Show our loading spinner. 985 bui.textwidget(edit=self._join_status_text, text='') 986 # bui.textwidget( 987 # edit=self._join_status_text, 988 # text=bui.Lstr( 989 # value='${A}...', 990 # subs=[ 991 # ( 992 # '${A}', 993 # 994 # bui.Lstr(resource='store.loadingText'), 995 # ) 996 # ], 997 # ), 998 # ) 999 bui.spinnerwidget( 1000 edit=self._join_status_spinner, visible=True 1001 ) 1002 1003 self._update_party_rows() 1004 1005 def _update_party_rows(self) -> None: 1006 plus = bui.app.plus 1007 assert plus is not None 1008 1009 columnwidget = self._join_list_column 1010 if not columnwidget: 1011 return 1012 1013 assert self._join_text 1014 assert self._filter_text 1015 1016 # Janky - allow escaping when there's nothing in our list. 1017 assert self._host_scrollwidget 1018 bui.containerwidget( 1019 edit=self._host_scrollwidget, 1020 claims_up_down=(len(self._parties_displayed) > 0), 1021 ) 1022 bui.textwidget(edit=self._no_servers_found_text, text='') 1023 1024 # Clip if we have more UI rows than parties to show. 1025 clipcount = len(self._ui_rows) - len(self._parties_displayed) 1026 if clipcount > 0: 1027 clipcount = max(clipcount, 50) 1028 self._ui_rows = self._ui_rows[:-clipcount] 1029 1030 # If we have no parties to show, we're done. 1031 if self._have_valid_server_list and not self._parties_displayed: 1032 bui.textwidget( 1033 edit=self._no_servers_found_text, 1034 text=bui.Lstr(resource='noServersFoundText'), 1035 ) 1036 return 1037 1038 sub_scroll_width = 830 1039 lineheight = 42 1040 sub_scroll_height = lineheight * len(self._parties_displayed) + 50 1041 bui.containerwidget( 1042 edit=columnwidget, size=(sub_scroll_width, sub_scroll_height) 1043 ) 1044 1045 # Any time our height changes, reset the refresh back to the top 1046 # so we don't see ugly empty spaces appearing during initial list 1047 # filling. 1048 if sub_scroll_height != self._last_sub_scroll_height: 1049 self._refresh_ui_row = 0 1050 self._last_sub_scroll_height = sub_scroll_height 1051 1052 # Also note that we need to redisplay everything since its pos 1053 # will have changed.. :( 1054 for party in self._parties.values(): 1055 party.clean_display_index = None 1056 1057 # Ew; this rebuilding generates deferred selection callbacks 1058 # so we need to push deferred notices so we know to ignore them. 1059 def refresh_on() -> None: 1060 self._refreshing_list = True 1061 1062 bui.pushcall(refresh_on) 1063 1064 # Ok, now here's the deal: we want to avoid creating/updating this 1065 # entire list at one time because it will lead to hitches. So we 1066 # refresh individual rows quickly in a loop. 1067 rowcount = min(12, len(self._parties_displayed)) 1068 1069 party_vals_displayed = list(self._parties_displayed.values()) 1070 while rowcount > 0: 1071 refresh_row = self._refresh_ui_row % len(self._parties_displayed) 1072 if refresh_row >= len(self._ui_rows): 1073 self._ui_rows.append(UIRow()) 1074 refresh_row = len(self._ui_rows) - 1 1075 1076 # For the first few seconds after getting our first server-list, 1077 # refresh only the top section of the list; this allows the lowest 1078 # ping servers to show up more quickly. 1079 if self._first_valid_server_list_time is not None: 1080 if time.time() - self._first_valid_server_list_time < 4.0: 1081 if refresh_row > 40: 1082 refresh_row = 0 1083 1084 self._ui_rows[refresh_row].update( 1085 refresh_row, 1086 party_vals_displayed[refresh_row], 1087 sub_scroll_width=sub_scroll_width, 1088 sub_scroll_height=sub_scroll_height, 1089 lineheight=lineheight, 1090 columnwidget=columnwidget, 1091 join_text=self._join_text, 1092 existing_selection=self._selection, 1093 filter_text=self._filter_text, 1094 tab=self, 1095 ) 1096 self._refresh_ui_row = refresh_row + 1 1097 rowcount -= 1 1098 1099 # So our selection callbacks can start firing.. 1100 def refresh_off() -> None: 1101 self._refreshing_list = False 1102 1103 bui.pushcall(refresh_off) 1104 1105 def _process_pending_party_infos(self) -> None: 1106 starttime = time.time() 1107 1108 # We want to do this in small enough pieces to not cause UI hitches. 1109 chunksize = 30 1110 parties_in = self._pending_party_infos[:chunksize] 1111 self._pending_party_infos = self._pending_party_infos[chunksize:] 1112 for party_in in parties_in: 1113 addr = party_in['a'] 1114 assert isinstance(addr, str) 1115 port = party_in['p'] 1116 assert isinstance(port, int) 1117 party_key = f'{addr}_{port}' 1118 party = self._parties.get(party_key) 1119 if party is None: 1120 # If this party is new to us, init it. 1121 party = PartyEntry( 1122 address=addr, 1123 next_ping_time=bui.apptime() + 0.001 * party_in['pd'], 1124 index=self._next_entry_index, 1125 ) 1126 self._parties[party_key] = party 1127 self._parties_sorted.append((party_key, party)) 1128 self._party_lists_dirty = True 1129 self._next_entry_index += 1 1130 assert isinstance(party.address, str) 1131 assert isinstance(party.next_ping_time, float) 1132 1133 # Now, new or not, update its values. 1134 party.queue = party_in.get('q') 1135 assert isinstance(party.queue, (str, type(None))) 1136 party.port = port 1137 party.name = party_in['n'] 1138 assert isinstance(party.name, str) 1139 party.size = party_in['s'] 1140 assert isinstance(party.size, int) 1141 party.size_max = party_in['sm'] 1142 assert isinstance(party.size_max, int) 1143 1144 # Server provides this in milliseconds; we use seconds. 1145 party.ping_interval = 0.001 * party_in['pi'] 1146 assert isinstance(party.ping_interval, float) 1147 party.stats_addr = party_in['sa'] 1148 assert isinstance(party.stats_addr, (str, type(None))) 1149 1150 # Make sure the party's UI gets updated. 1151 party.clean_display_index = None 1152 1153 if DEBUG_PROCESSING and parties_in: 1154 print( 1155 f'Processed {len(parties_in)} raw party infos in' 1156 f' {time.time()-starttime:.5f}s.' 1157 ) 1158 1159 def _update_party_lists(self) -> None: 1160 plus = bui.app.plus 1161 assert plus is not None 1162 1163 if not self._party_lists_dirty: 1164 return 1165 starttime = time.time() 1166 assert len(self._parties_sorted) == len(self._parties) 1167 1168 self._parties_sorted.sort( 1169 key=lambda p: ( 1170 p[1].ping if p[1].ping is not None else 999999.0, 1171 p[1].index, 1172 ) 1173 ) 1174 1175 # If signed out or errored, show no parties. 1176 if ( 1177 plus.get_v1_account_state() != 'signed_in' 1178 or not self._have_valid_server_list 1179 ): 1180 self._parties_displayed = {} 1181 else: 1182 if self._filter_value: 1183 filterval = self._filter_value.lower() 1184 self._parties_displayed = { 1185 k: v 1186 for k, v in self._parties_sorted 1187 if filterval in v.name.lower() 1188 } 1189 else: 1190 self._parties_displayed = dict(self._parties_sorted) 1191 1192 # Any time our selection disappears from the displayed list, go back to 1193 # auto-selecting the top entry. 1194 if ( 1195 self._selection is not None 1196 and self._selection.entry_key not in self._parties_displayed 1197 ): 1198 self._have_user_selected_row = False 1199 1200 # Whenever the user hasn't selected something, keep the first visible 1201 # row selected. 1202 if not self._have_user_selected_row and self._parties_displayed: 1203 firstpartykey = next(iter(self._parties_displayed)) 1204 self._selection = Selection(firstpartykey, SelectionComponent.NAME) 1205 1206 self._party_lists_dirty = False 1207 if DEBUG_PROCESSING: 1208 print( 1209 f'Sorted {len(self._parties_sorted)} parties in' 1210 f' {time.time()-starttime:.5f}s.' 1211 ) 1212 1213 def _query_party_list_periodically(self) -> None: 1214 now = bui.apptime() 1215 1216 plus = bui.app.plus 1217 assert plus is not None 1218 1219 # Fire off a new public-party query periodically. 1220 if ( 1221 self._last_server_list_query_time is None 1222 or now - self._last_server_list_query_time 1223 > 0.001 1224 * plus.get_v1_account_misc_read_val('pubPartyRefreshMS', 10000) 1225 ): 1226 self._last_server_list_query_time = now 1227 if DEBUG_SERVER_COMMUNICATION: 1228 print('REQUESTING SERVER LIST') 1229 if plus.get_v1_account_state() == 'signed_in': 1230 plus.add_v1_account_transaction( 1231 { 1232 'type': 'PUBLIC_PARTY_QUERY', 1233 'proto': bs.protocol_version(), 1234 'lang': bui.app.lang.language, 1235 }, 1236 callback=bui.WeakCall(self._on_public_party_query_result), 1237 ) 1238 plus.run_v1_account_transactions() 1239 else: 1240 self._on_public_party_query_result(None) 1241 1242 def _ping_parties_periodically(self) -> None: 1243 assert bui.app.classic is not None 1244 now = bui.apptime() 1245 1246 # Go through our existing public party entries firing off pings 1247 # for any that have timed out. 1248 for party in list(self._parties.values()): 1249 if ( 1250 party.next_ping_time <= now 1251 and bui.app.classic.ping_thread_count < 15 1252 ): 1253 # Crank the interval up for high-latency or non-responding 1254 # parties to save us some useless work. 1255 mult = 1 1256 if party.ping_responses == 0: 1257 if party.ping_attempts > 4: 1258 mult = 10 1259 elif party.ping_attempts > 2: 1260 mult = 5 1261 if party.ping is not None: 1262 mult = ( 1263 10 if party.ping > 300 else 5 if party.ping > 150 else 2 1264 ) 1265 1266 interval = party.ping_interval * mult 1267 if DEBUG_SERVER_COMMUNICATION: 1268 print( 1269 f'pinging #{party.index} cur={party.ping} ' 1270 f'interval={interval} ' 1271 f'({party.ping_responses}/{party.ping_attempts})' 1272 ) 1273 1274 party.next_ping_time = now + party.ping_interval * mult 1275 party.ping_attempts += 1 1276 1277 PingThread( 1278 party.address, party.port, bui.WeakCall(self._ping_callback) 1279 ).start() 1280 1281 def _ping_callback( 1282 self, address: str, port: int | None, result: float | None 1283 ) -> None: 1284 # Look for a widget corresponding to this target. 1285 # If we find one, update our list. 1286 party_key = f'{address}_{port}' 1287 party = self._parties.get(party_key) 1288 if party is not None: 1289 if result is not None: 1290 party.ping_responses += 1 1291 1292 # We now smooth ping a bit to reduce jumping around in the list 1293 # (only where pings are relatively good). 1294 current_ping = party.ping 1295 if current_ping is not None and result is not None and result < 150: 1296 smoothing = 0.7 1297 party.ping = ( 1298 smoothing * current_ping + (1.0 - smoothing) * result 1299 ) 1300 else: 1301 party.ping = result 1302 1303 # Need to re-sort the list and update the row display. 1304 party.clean_display_index = None 1305 self._party_lists_dirty = True 1306 1307 def _fetch_local_addr_cb(self, val: str) -> None: 1308 self._local_address = str(val) 1309 1310 def _on_public_party_accessible_response( 1311 self, data: dict[str, Any] | None 1312 ) -> None: 1313 # If we've got status text widgets, update them. 1314 text = self._host_status_text 1315 if text: 1316 if data is None: 1317 bui.textwidget( 1318 edit=text, 1319 text=bui.Lstr( 1320 resource='gatherWindow.' 'partyStatusNoConnectionText' 1321 ), 1322 color=(1, 0, 0), 1323 ) 1324 else: 1325 if not data.get('accessible', False): 1326 ex_line: str | bui.Lstr 1327 if self._local_address is not None: 1328 ex_line = bui.Lstr( 1329 value='\n${A} ${B}', 1330 subs=[ 1331 ( 1332 '${A}', 1333 bui.Lstr( 1334 resource='gatherWindow.' 1335 'manualYourLocalAddressText' 1336 ), 1337 ), 1338 ('${B}', self._local_address), 1339 ], 1340 ) 1341 else: 1342 ex_line = '' 1343 bui.textwidget( 1344 edit=text, 1345 text=bui.Lstr( 1346 value='${A}\n${B}${C}', 1347 subs=[ 1348 ( 1349 '${A}', 1350 bui.Lstr( 1351 resource='gatherWindow.' 1352 'partyStatusNotJoinableText' 1353 ), 1354 ), 1355 ( 1356 '${B}', 1357 bui.Lstr( 1358 resource='gatherWindow.' 1359 'manualRouterForwardingText', 1360 subs=[ 1361 ( 1362 '${PORT}', 1363 str(bs.get_game_port()), 1364 ) 1365 ], 1366 ), 1367 ), 1368 ('${C}', ex_line), 1369 ], 1370 ), 1371 color=(1, 0, 0), 1372 ) 1373 else: 1374 bui.textwidget( 1375 edit=text, 1376 text=bui.Lstr( 1377 resource='gatherWindow.' 'partyStatusJoinableText' 1378 ), 1379 color=(0, 1, 0), 1380 ) 1381 1382 def _do_status_check(self) -> None: 1383 assert bui.app.classic is not None 1384 bui.textwidget( 1385 edit=self._host_status_text, 1386 color=(1, 1, 0), 1387 text=bui.Lstr(resource='gatherWindow.' 'partyStatusCheckingText'), 1388 ) 1389 bui.app.classic.master_server_v1_get( 1390 'bsAccessCheck', 1391 {'b': bui.app.env.engine_build_number}, 1392 callback=bui.WeakCall(self._on_public_party_accessible_response), 1393 ) 1394 1395 def _on_start_advertizing_press(self) -> None: 1396 from bauiv1lib.account.signin import show_sign_in_prompt 1397 1398 plus = bui.app.plus 1399 assert plus is not None 1400 1401 if plus.get_v1_account_state() != 'signed_in': 1402 show_sign_in_prompt() 1403 return 1404 1405 name = cast(str, bui.textwidget(query=self._host_name_text)) 1406 if name == '': 1407 bui.screenmessage( 1408 bui.Lstr(resource='internal.invalidNameErrorText'), 1409 color=(1, 0, 0), 1410 ) 1411 bui.getsound('error').play() 1412 return 1413 bs.set_public_party_name(name) 1414 cfg = bui.app.config 1415 cfg['Public Party Name'] = name 1416 cfg.commit() 1417 bui.getsound('shieldUp').play() 1418 bs.set_public_party_enabled(True) 1419 1420 # In GUI builds we want to authenticate clients only when hosting 1421 # public parties. 1422 bs.set_authenticate_clients(True) 1423 1424 self._do_status_check() 1425 bui.buttonwidget( 1426 edit=self._host_toggle_button, 1427 label=bui.Lstr( 1428 resource='gatherWindow.makePartyPrivateText', 1429 fallback_resource='gatherWindow.stopAdvertisingText', 1430 ), 1431 on_activate_call=self._on_stop_advertising_press, 1432 ) 1433 1434 def _on_stop_advertising_press(self) -> None: 1435 bs.set_public_party_enabled(False) 1436 1437 # In GUI builds we want to authenticate clients only when hosting 1438 # public parties. 1439 bs.set_authenticate_clients(False) 1440 bui.getsound('shieldDown').play() 1441 text = self._host_status_text 1442 if text: 1443 bui.textwidget( 1444 edit=text, 1445 text=bui.Lstr( 1446 resource='gatherWindow.' 'partyStatusNotPublicText' 1447 ), 1448 color=(0.6, 0.6, 0.6), 1449 ) 1450 bui.buttonwidget( 1451 edit=self._host_toggle_button, 1452 label=bui.Lstr( 1453 resource='gatherWindow.makePartyPublicText', 1454 fallback_resource='gatherWindow.startAdvertisingText', 1455 ), 1456 on_activate_call=self._on_start_advertizing_press, 1457 ) 1458 1459 def on_public_party_activate(self, party: PartyEntry) -> None: 1460 """Called when a party is clicked or otherwise activated.""" 1461 self.save_state() 1462 if party.queue is not None: 1463 from bauiv1lib.partyqueue import PartyQueueWindow 1464 1465 bui.getsound('swish').play() 1466 PartyQueueWindow(party.queue, party.address, party.port) 1467 else: 1468 address = party.address 1469 port = party.port 1470 1471 # Store UI location to return to when done. 1472 if bs.app.classic is not None: 1473 bs.app.classic.save_ui_state() 1474 1475 # Rate limit this a bit. 1476 now = time.time() 1477 last_connect_time = self._last_connect_attempt_time 1478 if last_connect_time is None or now - last_connect_time > 2.0: 1479 bs.connect_to_party(address, port=port) 1480 self._last_connect_attempt_time = now 1481 1482 def set_public_party_selection(self, sel: Selection) -> None: 1483 """Set the sel.""" 1484 if self._refreshing_list: 1485 return 1486 self._selection = sel 1487 self._have_user_selected_row = True 1488 1489 def _on_max_public_party_size_minus_press(self) -> None: 1490 val = max(1, bs.get_public_party_max_size() - 1) 1491 bs.set_public_party_max_size(val) 1492 bui.textwidget(edit=self._host_max_party_size_value, text=str(val)) 1493 1494 def _on_max_public_party_size_plus_press(self) -> None: 1495 val = bs.get_public_party_max_size() 1496 val += 1 1497 bs.set_public_party_max_size(val) 1498 bui.textwidget(edit=self._host_max_party_size_value, text=str(val))
The public tab in the gather UI
353 def __init__(self, window: GatherWindow) -> None: 354 super().__init__(window) 355 self._container: bui.Widget | None = None 356 self._join_text: bui.Widget | None = None 357 self._host_text: bui.Widget | None = None 358 self._filter_text: bui.Widget | None = None 359 self._local_address: str | None = None 360 self._last_connect_attempt_time: float | None = None 361 self._sub_tab: SubTabType = SubTabType.JOIN 362 self._selection: Selection | None = None 363 self._refreshing_list = False 364 self._update_timer: bui.AppTimer | None = None 365 self._host_scrollwidget: bui.Widget | None = None 366 self._host_name_text: bui.Widget | None = None 367 self._host_toggle_button: bui.Widget | None = None 368 self._last_server_list_query_time: float | None = None 369 self._join_list_column: bui.Widget | None = None 370 self._join_status_text: bui.Widget | None = None 371 self._join_status_spinner: bui.Widget | None = None 372 self._no_servers_found_text: bui.Widget | None = None 373 self._host_max_party_size_value: bui.Widget | None = None 374 self._host_max_party_size_minus_button: bui.Widget | None = None 375 self._host_max_party_size_plus_button: bui.Widget | None = None 376 self._host_status_text: bui.Widget | None = None 377 self._signed_in = False 378 self._ui_rows: list[UIRow] = [] 379 self._refresh_ui_row = 0 380 self._have_user_selected_row = False 381 self._first_valid_server_list_time: float | None = None 382 383 # Parties indexed by id: 384 self._parties: dict[str, PartyEntry] = {} 385 386 # Parties sorted in display order: 387 self._parties_sorted: list[tuple[str, PartyEntry]] = [] 388 self._party_lists_dirty = True 389 390 # Sorted parties with filter applied: 391 self._parties_displayed: dict[str, PartyEntry] = {} 392 393 self._next_entry_index = 0 394 self._have_server_list_response = False 395 self._have_valid_server_list = False 396 self._filter_value = '' 397 self._pending_party_infos: list[dict[str, Any]] = [] 398 self._last_sub_scroll_height = 0.0
400 @override 401 def on_activate( 402 self, 403 parent_widget: bui.Widget, 404 tab_button: bui.Widget, 405 region_width: float, 406 region_height: float, 407 region_left: float, 408 region_bottom: float, 409 ) -> bui.Widget: 410 # pylint: disable=too-many-positional-arguments 411 c_width = region_width 412 c_height = region_height - 20 413 self._container = bui.containerwidget( 414 parent=parent_widget, 415 position=( 416 region_left, 417 region_bottom + (region_height - c_height) * 0.5, 418 ), 419 size=(c_width, c_height), 420 background=False, 421 selection_loops_to_parent=True, 422 ) 423 v = c_height - 30 424 self._join_text = bui.textwidget( 425 parent=self._container, 426 position=(c_width * 0.5 - 245, v - 13), 427 color=(0.6, 1.0, 0.6), 428 scale=1.3, 429 size=(200, 30), 430 maxwidth=250, 431 h_align='left', 432 v_align='center', 433 click_activate=True, 434 selectable=True, 435 autoselect=True, 436 on_activate_call=lambda: self._set_sub_tab( 437 SubTabType.JOIN, 438 region_width, 439 region_height, 440 playsound=True, 441 ), 442 text=bui.Lstr( 443 resource='gatherWindow.' 'joinPublicPartyDescriptionText' 444 ), 445 glow_type='uniform', 446 ) 447 self._host_text = bui.textwidget( 448 parent=self._container, 449 position=(c_width * 0.5 + 45, v - 13), 450 color=(0.6, 1.0, 0.6), 451 scale=1.3, 452 size=(200, 30), 453 maxwidth=250, 454 h_align='left', 455 v_align='center', 456 click_activate=True, 457 selectable=True, 458 autoselect=True, 459 on_activate_call=lambda: self._set_sub_tab( 460 SubTabType.HOST, 461 region_width, 462 region_height, 463 playsound=True, 464 ), 465 text=bui.Lstr( 466 resource='gatherWindow.' 'hostPublicPartyDescriptionText' 467 ), 468 glow_type='uniform', 469 ) 470 bui.widget(edit=self._join_text, up_widget=tab_button) 471 bui.widget( 472 edit=self._host_text, 473 left_widget=self._join_text, 474 up_widget=tab_button, 475 ) 476 bui.widget(edit=self._join_text, right_widget=self._host_text) 477 478 # Attempt to fetch our local address so we have it for error messages. 479 if self._local_address is None: 480 AddrFetchThread(bui.WeakCall(self._fetch_local_addr_cb)).start() 481 482 self._set_sub_tab(self._sub_tab, region_width, region_height) 483 self._update_timer = bui.AppTimer( 484 0.1, bui.WeakCall(self._update), repeat=True 485 ) 486 return self._container
Called when the tab becomes the active one.
The tab should create and return a container widget covering the specified region.
492 @override 493 def save_state(self) -> None: 494 # Save off a small number of parties with the lowest ping; we'll 495 # display these immediately when our UI comes back up which should 496 # be enough to make things feel nice and crisp while we do a full 497 # server re-query or whatnot. 498 assert bui.app.classic is not None 499 bui.app.ui_v1.window_states[type(self)] = State( 500 sub_tab=self._sub_tab, 501 parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]], 502 next_entry_index=self._next_entry_index, 503 filter_value=self._filter_value, 504 have_server_list_response=self._have_server_list_response, 505 have_valid_server_list=self._have_valid_server_list, 506 )
Called when the parent window is saving state.
508 @override 509 def restore_state(self) -> None: 510 assert bui.app.classic is not None 511 state = bui.app.ui_v1.window_states.get(type(self)) 512 if state is None: 513 state = State() 514 assert isinstance(state, State) 515 self._sub_tab = state.sub_tab 516 517 # Restore the parties we stored. 518 if state.parties: 519 self._parties = { 520 key: copy.copy(party) for key, party in state.parties 521 } 522 self._parties_sorted = list(self._parties.items()) 523 self._party_lists_dirty = True 524 525 self._next_entry_index = state.next_entry_index 526 527 # FIXME: should save/restore these too?.. 528 self._have_server_list_response = state.have_server_list_response 529 self._have_valid_server_list = state.have_valid_server_list 530 self._filter_value = state.filter_value
Called when the parent window is restoring state.
1459 def on_public_party_activate(self, party: PartyEntry) -> None: 1460 """Called when a party is clicked or otherwise activated.""" 1461 self.save_state() 1462 if party.queue is not None: 1463 from bauiv1lib.partyqueue import PartyQueueWindow 1464 1465 bui.getsound('swish').play() 1466 PartyQueueWindow(party.queue, party.address, party.port) 1467 else: 1468 address = party.address 1469 port = party.port 1470 1471 # Store UI location to return to when done. 1472 if bs.app.classic is not None: 1473 bs.app.classic.save_ui_state() 1474 1475 # Rate limit this a bit. 1476 now = time.time() 1477 last_connect_time = self._last_connect_attempt_time 1478 if last_connect_time is None or now - last_connect_time > 2.0: 1479 bs.connect_to_party(address, port=port) 1480 self._last_connect_attempt_time = now
Called when a party is clicked or otherwise activated.
1482 def set_public_party_selection(self, sel: Selection) -> None: 1483 """Set the sel.""" 1484 if self._refreshing_list: 1485 return 1486 self._selection = sel 1487 self._have_user_selected_row = True
Set the sel.