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 - 270.0, 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 - 90, 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 - 25.0, 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._join_sub_scroll_width: float | 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 479 # messages. 480 if self._local_address is None: 481 AddrFetchThread(bui.WeakCall(self._fetch_local_addr_cb)).start() 482 483 self._set_sub_tab(self._sub_tab, region_width, region_height) 484 self._update_timer = bui.AppTimer( 485 0.1, bui.WeakCall(self._update), repeat=True 486 ) 487 return self._container 488 489 @override 490 def on_deactivate(self) -> None: 491 self._update_timer = None 492 493 @override 494 def save_state(self) -> None: 495 # Save off a small number of parties with the lowest ping; we'll 496 # display these immediately when our UI comes back up which 497 # should be enough to make things feel nice and crisp while we 498 # do a full server re-query or whatnot. 499 assert bui.app.classic is not None 500 bui.app.ui_v1.window_states[type(self)] = State( 501 sub_tab=self._sub_tab, 502 parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]], 503 next_entry_index=self._next_entry_index, 504 filter_value=self._filter_value, 505 have_server_list_response=self._have_server_list_response, 506 have_valid_server_list=self._have_valid_server_list, 507 ) 508 509 @override 510 def restore_state(self) -> None: 511 assert bui.app.classic is not None 512 state = bui.app.ui_v1.window_states.get(type(self)) 513 if state is None: 514 state = State() 515 assert isinstance(state, State) 516 self._sub_tab = state.sub_tab 517 518 # Restore the parties we stored. 519 if state.parties: 520 self._parties = { 521 key: copy.copy(party) for key, party in state.parties 522 } 523 self._parties_sorted = list(self._parties.items()) 524 self._party_lists_dirty = True 525 526 self._next_entry_index = state.next_entry_index 527 528 # FIXME: should save/restore these too? 529 self._have_server_list_response = state.have_server_list_response 530 self._have_valid_server_list = state.have_valid_server_list 531 self._filter_value = state.filter_value 532 533 def _set_sub_tab( 534 self, 535 value: SubTabType, 536 region_width: float, 537 region_height: float, 538 playsound: bool = False, 539 ) -> None: 540 assert self._container 541 if playsound: 542 bui.getsound('click01').play() 543 544 # Reset our selection (prevents selecting something way down the 545 # list if we switched away 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 self._join_sub_scroll_width = sub_scroll_width = min( 584 1200, region_width - 80 585 ) 586 v = c_height - 35 587 v -= 60 588 filter_txt = bui.Lstr(resource='filterText') 589 self._filter_text = bui.textwidget( 590 parent=self._container, 591 text=self._filter_value, 592 size=(350, 45), 593 position=(c_width * 0.5 - 150, v - 10), 594 h_align='left', 595 v_align='center', 596 editable=True, 597 maxwidth=310, 598 description=filter_txt, 599 ) 600 bui.widget(edit=self._filter_text, up_widget=self._join_text) 601 bui.textwidget( 602 text=filter_txt, 603 parent=self._container, 604 size=(0, 0), 605 position=(c_width * 0.5 - 170, v + 13), 606 maxwidth=150, 607 scale=0.8, 608 color=(0.5, 0.46, 0.5), 609 flatness=1.0, 610 h_align='right', 611 v_align='center', 612 ) 613 614 bui.textwidget( 615 text=bui.Lstr(resource='nameText'), 616 parent=self._container, 617 size=(0, 0), 618 position=((c_width - sub_scroll_width) * 0.5 + 50, v - 8), 619 maxwidth=60, 620 scale=0.6, 621 color=(0.5, 0.46, 0.5), 622 flatness=1.0, 623 h_align='center', 624 v_align='center', 625 ) 626 bui.textwidget( 627 text=bui.Lstr(resource='gatherWindow.partySizeText'), 628 parent=self._container, 629 size=(0, 0), 630 position=( 631 c_width * 0.5 + sub_scroll_width * 0.5 - 110, 632 v - 8, 633 ), 634 maxwidth=60, 635 scale=0.6, 636 color=(0.5, 0.46, 0.5), 637 flatness=1.0, 638 h_align='center', 639 v_align='center', 640 ) 641 bui.textwidget( 642 text=bui.Lstr(resource='gatherWindow.pingText'), 643 parent=self._container, 644 size=(0, 0), 645 position=( 646 c_width * 0.5 + sub_scroll_width * 0.5 - 30, 647 v - 8, 648 ), 649 maxwidth=60, 650 scale=0.6, 651 color=(0.5, 0.46, 0.5), 652 flatness=1.0, 653 h_align='center', 654 v_align='center', 655 ) 656 v -= sub_scroll_height + 23 657 self._host_scrollwidget = scrollw = bui.scrollwidget( 658 parent=self._container, 659 simple_culling_v=10, 660 position=((c_width - sub_scroll_width) * 0.5, v), 661 size=(sub_scroll_width, sub_scroll_height), 662 claims_up_down=False, 663 claims_left_right=True, 664 autoselect=True, 665 ) 666 self._join_list_column = bui.containerwidget( 667 parent=scrollw, 668 background=False, 669 size=(400, 400), 670 claims_left_right=True, 671 ) 672 673 # Create join status text and join spinner. Always make sure to 674 # update both of these together. 675 self._join_status_text = bui.textwidget( 676 parent=self._container, 677 text='', 678 size=(0, 0), 679 scale=0.9, 680 flatness=1.0, 681 shadow=0.0, 682 h_align='center', 683 v_align='top', 684 maxwidth=c_width, 685 color=(0.6, 0.6, 0.6), 686 position=(c_width * 0.5, c_height * 0.5), 687 ) 688 self._join_status_spinner = bui.spinnerwidget( 689 parent=self._container, 690 position=(c_width * 0.5, c_height * 0.5), 691 style='bomb', 692 size=64, 693 ) 694 695 self._no_servers_found_text = bui.textwidget( 696 parent=self._container, 697 text='', 698 size=(0, 0), 699 scale=0.9, 700 flatness=1.0, 701 shadow=0.0, 702 h_align='center', 703 v_align='top', 704 color=(0.6, 0.6, 0.6), 705 position=(c_width * 0.5, c_height * 0.5), 706 ) 707 708 def _build_host_tab( 709 self, region_width: float, region_height: float 710 ) -> None: 711 c_width = region_width 712 c_height = region_height - 20 713 v = c_height - 35 714 v -= 25 715 is_public_enabled = bs.get_public_party_enabled() 716 v -= 30 717 718 bui.textwidget( 719 parent=self._container, 720 size=(0, 0), 721 h_align='center', 722 v_align='center', 723 maxwidth=c_width * 0.9, 724 scale=0.7, 725 flatness=1.0, 726 color=(0.5, 0.46, 0.5), 727 position=(region_width * 0.5, v + 10), 728 text=bui.Lstr(resource='gatherWindow.publicHostRouterConfigText'), 729 ) 730 v -= 30 731 732 # Nudge party name and size values to be mostly centered. 733 xoffs = region_width * 0.5 - 500 734 735 party_name_text = bui.Lstr( 736 resource='gatherWindow.partyNameText', 737 fallback_resource='editGameListWindow.nameText', 738 ) 739 assert bui.app.classic is not None 740 bui.textwidget( 741 parent=self._container, 742 size=(0, 0), 743 h_align='right', 744 v_align='center', 745 maxwidth=200, 746 scale=0.8, 747 color=bui.app.ui_v1.infotextcolor, 748 position=(210 + xoffs, v - 9), 749 text=party_name_text, 750 ) 751 self._host_name_text = bui.textwidget( 752 parent=self._container, 753 editable=True, 754 size=(535, 40), 755 position=(230 + xoffs, v - 30), 756 text=bui.app.config.get('Public Party Name', ''), 757 maxwidth=494, 758 shadow=0.3, 759 flatness=1.0, 760 description=party_name_text, 761 autoselect=True, 762 v_align='center', 763 corner_scale=1.0, 764 ) 765 766 v -= 60 767 bui.textwidget( 768 parent=self._container, 769 size=(0, 0), 770 h_align='right', 771 v_align='center', 772 maxwidth=200, 773 scale=0.8, 774 color=bui.app.ui_v1.infotextcolor, 775 position=(210 + xoffs, v - 9), 776 text=bui.Lstr( 777 resource='maxPartySizeText', 778 fallback_resource='maxConnectionsText', 779 ), 780 ) 781 self._host_max_party_size_value = bui.textwidget( 782 parent=self._container, 783 size=(0, 0), 784 h_align='center', 785 v_align='center', 786 scale=1.2, 787 color=(1, 1, 1), 788 position=(240 + xoffs, v - 9), 789 text=str(bs.get_public_party_max_size()), 790 ) 791 btn1 = self._host_max_party_size_minus_button = bui.buttonwidget( 792 parent=self._container, 793 size=(40, 40), 794 on_activate_call=bui.WeakCall( 795 self._on_max_public_party_size_minus_press 796 ), 797 position=(280 + xoffs, v - 26), 798 label='-', 799 autoselect=True, 800 ) 801 btn2 = self._host_max_party_size_plus_button = bui.buttonwidget( 802 parent=self._container, 803 size=(40, 40), 804 on_activate_call=bui.WeakCall( 805 self._on_max_public_party_size_plus_press 806 ), 807 position=(350 + xoffs, v - 26), 808 label='+', 809 autoselect=True, 810 ) 811 v -= 50 812 v -= 70 813 if is_public_enabled: 814 label = bui.Lstr( 815 resource='gatherWindow.makePartyPrivateText', 816 fallback_resource='gatherWindow.stopAdvertisingText', 817 ) 818 else: 819 label = bui.Lstr( 820 resource='gatherWindow.makePartyPublicText', 821 fallback_resource='gatherWindow.startAdvertisingText', 822 ) 823 self._host_toggle_button = bui.buttonwidget( 824 parent=self._container, 825 label=label, 826 size=(400, 80), 827 on_activate_call=( 828 self._on_stop_advertising_press 829 if is_public_enabled 830 else self._on_start_advertizing_press 831 ), 832 position=(c_width * 0.5 - 200, v), 833 autoselect=True, 834 up_widget=btn2, 835 ) 836 bui.widget(edit=self._host_name_text, down_widget=btn2) 837 bui.widget(edit=btn2, up_widget=self._host_name_text) 838 bui.widget(edit=btn1, up_widget=self._host_name_text) 839 assert self._join_text is not None 840 bui.widget(edit=self._join_text, down_widget=self._host_name_text) 841 v -= 10 842 self._host_status_text = bui.textwidget( 843 parent=self._container, 844 text=bui.Lstr(resource='gatherWindow.' 'partyStatusNotPublicText'), 845 size=(0, 0), 846 scale=0.7, 847 flatness=1.0, 848 h_align='center', 849 v_align='top', 850 maxwidth=c_width * 0.9, 851 color=(0.6, 0.56, 0.6), 852 position=(c_width * 0.5, v), 853 ) 854 v -= 90 855 bui.textwidget( 856 parent=self._container, 857 text=bui.Lstr(resource='gatherWindow.dedicatedServerInfoText'), 858 size=(0, 0), 859 scale=0.7, 860 flatness=1.0, 861 h_align='center', 862 v_align='center', 863 maxwidth=c_width * 0.9, 864 color=(0.5, 0.46, 0.5), 865 position=(c_width * 0.5, v), 866 ) 867 868 # If public sharing is already on, launch a status-check 869 # immediately. 870 if bs.get_public_party_enabled(): 871 self._do_status_check() 872 873 def _on_public_party_query_result( 874 self, result: dict[str, Any] | None 875 ) -> None: 876 starttime = time.time() 877 self._have_server_list_response = True 878 879 if result is None: 880 self._have_valid_server_list = False 881 return 882 883 if not self._have_valid_server_list: 884 self._first_valid_server_list_time = time.time() 885 886 self._have_valid_server_list = True 887 parties_in = result['l'] 888 889 assert isinstance(parties_in, list) 890 self._pending_party_infos += parties_in 891 892 # To avoid causing a stutter here, we do most processing of 893 # these entries incrementally in our _update() method. The one 894 # thing we do here is prune parties not contained in this 895 # result. 896 for partyval in list(self._parties.values()): 897 partyval.claimed = False 898 for party_in in parties_in: 899 addr = party_in['a'] 900 assert isinstance(addr, str) 901 port = party_in['p'] 902 assert isinstance(port, int) 903 party_key = f'{addr}_{port}' 904 party = self._parties.get(party_key) 905 if party is not None: 906 party.claimed = True 907 self._parties = { 908 key: val for key, val in list(self._parties.items()) if val.claimed 909 } 910 self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed] 911 self._party_lists_dirty = True 912 913 if DEBUG_PROCESSING: 914 print( 915 f'Handled public party query results in ' 916 f'{time.time()-starttime:.5f}s.' 917 ) 918 919 def _update(self) -> None: 920 """Periodic updating.""" 921 922 plus = bui.app.plus 923 assert plus is not None 924 925 if self._sub_tab is SubTabType.JOIN: 926 # Keep our filter-text up to date from the UI. 927 text = self._filter_text 928 if text: 929 filter_value = cast(str, bui.textwidget(query=text)) 930 if filter_value != self._filter_value: 931 self._filter_value = filter_value 932 self._party_lists_dirty = True 933 934 # Also wipe out party clean-row states (otherwise if 935 # a party disappears from a row due to filtering and 936 # then reappears on that same row when the filter is 937 # removed it may not update). 938 for party in self._parties.values(): 939 party.clean_display_index = None 940 941 self._query_party_list_periodically() 942 self._ping_parties_periodically() 943 944 # If any new party infos have come in, apply some of them. 945 self._process_pending_party_infos() 946 947 # Anytime we sign in/out, make sure we refresh our list. 948 signed_in = plus.get_v1_account_state() == 'signed_in' 949 if self._signed_in != signed_in: 950 self._signed_in = signed_in 951 self._party_lists_dirty = True 952 953 # Update sorting to account for ping updates, new parties, etc. 954 self._update_party_lists() 955 956 # If we've got a party-name text widget, keep its value plugged 957 # into our public host name. 958 text = self._host_name_text 959 if text: 960 name = cast(str, bui.textwidget(query=self._host_name_text)) 961 bs.set_public_party_name(name) 962 963 # Update status text and loading spinner. 964 if self._join_status_text: 965 assert self._join_status_spinner 966 if not signed_in: 967 bui.textwidget( 968 edit=self._join_status_text, 969 text=bui.Lstr(resource='notSignedInText'), 970 ) 971 bui.spinnerwidget(edit=self._join_status_spinner, visible=False) 972 else: 973 # If we have a valid list, show no status; just the 974 # list. Otherwise show either 'loading...' or 'error' 975 # depending on whether this is our first go-round. 976 if self._have_valid_server_list: 977 bui.textwidget(edit=self._join_status_text, text='') 978 bui.spinnerwidget( 979 edit=self._join_status_spinner, visible=False 980 ) 981 else: 982 if self._have_server_list_response: 983 bui.textwidget( 984 edit=self._join_status_text, 985 text=bui.Lstr(resource='errorText'), 986 ) 987 bui.spinnerwidget( 988 edit=self._join_status_spinner, visible=False 989 ) 990 else: 991 # Show our loading spinner. 992 bui.textwidget(edit=self._join_status_text, text='') 993 bui.spinnerwidget( 994 edit=self._join_status_spinner, visible=True 995 ) 996 997 self._update_party_rows() 998 999 def _update_party_rows(self) -> None: 1000 plus = bui.app.plus 1001 assert plus is not None 1002 1003 columnwidget = self._join_list_column 1004 if not columnwidget: 1005 return 1006 1007 assert self._join_text 1008 assert self._filter_text 1009 1010 # Janky - allow escaping when there's nothing in our list. 1011 assert self._host_scrollwidget 1012 bui.containerwidget( 1013 edit=self._host_scrollwidget, 1014 claims_up_down=(len(self._parties_displayed) > 0), 1015 ) 1016 bui.textwidget(edit=self._no_servers_found_text, text='') 1017 1018 # Clip if we have more UI rows than parties to show. 1019 clipcount = len(self._ui_rows) - len(self._parties_displayed) 1020 if clipcount > 0: 1021 clipcount = max(clipcount, 50) 1022 self._ui_rows = self._ui_rows[:-clipcount] 1023 1024 # If we have no parties to show, we're done. 1025 if self._have_valid_server_list and not self._parties_displayed: 1026 bui.textwidget( 1027 edit=self._no_servers_found_text, 1028 text=bui.Lstr(resource='noServersFoundText'), 1029 ) 1030 return 1031 1032 assert self._join_sub_scroll_width is not None 1033 sub_scroll_width = self._join_sub_scroll_width 1034 lineheight = 42 1035 sub_scroll_height = lineheight * len(self._parties_displayed) + 50 1036 bui.containerwidget( 1037 edit=columnwidget, size=(sub_scroll_width, sub_scroll_height) 1038 ) 1039 1040 # Any time our height changes, reset the refresh back to the top 1041 # so we don't see ugly empty spaces appearing during initial 1042 # list filling. 1043 if sub_scroll_height != self._last_sub_scroll_height: 1044 self._refresh_ui_row = 0 1045 self._last_sub_scroll_height = sub_scroll_height 1046 1047 # Also note that we need to redisplay everything since its 1048 # pos will have changed.. :( 1049 for party in self._parties.values(): 1050 party.clean_display_index = None 1051 1052 # Ew; this rebuilding generates deferred selection callbacks so 1053 # we need to push deferred notices so we know to ignore them. 1054 def refresh_on() -> None: 1055 self._refreshing_list = True 1056 1057 bui.pushcall(refresh_on) 1058 1059 # Ok, now here's the deal: we want to avoid creating/updating 1060 # this entire list at one time because it will lead to hitches. 1061 # So we refresh individual rows quickly in a loop. 1062 rowcount = min(12, len(self._parties_displayed)) 1063 1064 party_vals_displayed = list(self._parties_displayed.values()) 1065 while rowcount > 0: 1066 refresh_row = self._refresh_ui_row % len(self._parties_displayed) 1067 if refresh_row >= len(self._ui_rows): 1068 self._ui_rows.append(UIRow()) 1069 refresh_row = len(self._ui_rows) - 1 1070 1071 # For the first few seconds after getting our first 1072 # server-list, refresh only the top section of the list; 1073 # this allows the lowest ping servers to show up more 1074 # quickly. 1075 if self._first_valid_server_list_time is not None: 1076 if time.time() - self._first_valid_server_list_time < 4.0: 1077 if refresh_row > 40: 1078 refresh_row = 0 1079 1080 self._ui_rows[refresh_row].update( 1081 refresh_row, 1082 party_vals_displayed[refresh_row], 1083 sub_scroll_width=sub_scroll_width, 1084 sub_scroll_height=sub_scroll_height, 1085 lineheight=lineheight, 1086 columnwidget=columnwidget, 1087 join_text=self._join_text, 1088 existing_selection=self._selection, 1089 filter_text=self._filter_text, 1090 tab=self, 1091 ) 1092 self._refresh_ui_row = refresh_row + 1 1093 rowcount -= 1 1094 1095 # So our selection callbacks can start firing.. 1096 def refresh_off() -> None: 1097 self._refreshing_list = False 1098 1099 bui.pushcall(refresh_off) 1100 1101 def _process_pending_party_infos(self) -> None: 1102 starttime = time.time() 1103 1104 # We want to do this in small enough pieces to not cause UI 1105 # hitches. 1106 chunksize = 30 1107 parties_in = self._pending_party_infos[:chunksize] 1108 self._pending_party_infos = self._pending_party_infos[chunksize:] 1109 for party_in in parties_in: 1110 addr = party_in['a'] 1111 assert isinstance(addr, str) 1112 port = party_in['p'] 1113 assert isinstance(port, int) 1114 party_key = f'{addr}_{port}' 1115 party = self._parties.get(party_key) 1116 if party is None: 1117 # If this party is new to us, init it. 1118 party = PartyEntry( 1119 address=addr, 1120 next_ping_time=bui.apptime() + 0.001 * party_in['pd'], 1121 index=self._next_entry_index, 1122 ) 1123 self._parties[party_key] = party 1124 self._parties_sorted.append((party_key, party)) 1125 self._party_lists_dirty = True 1126 self._next_entry_index += 1 1127 assert isinstance(party.address, str) 1128 assert isinstance(party.next_ping_time, float) 1129 1130 # Now, new or not, update its values. 1131 party.queue = party_in.get('q') 1132 assert isinstance(party.queue, (str, type(None))) 1133 party.port = port 1134 party.name = party_in['n'] 1135 assert isinstance(party.name, str) 1136 party.size = party_in['s'] 1137 assert isinstance(party.size, int) 1138 party.size_max = party_in['sm'] 1139 assert isinstance(party.size_max, int) 1140 1141 # Server provides this in milliseconds; we use seconds. 1142 party.ping_interval = 0.001 * party_in['pi'] 1143 assert isinstance(party.ping_interval, float) 1144 party.stats_addr = party_in['sa'] 1145 assert isinstance(party.stats_addr, (str, type(None))) 1146 1147 # Make sure the party's UI gets updated. 1148 party.clean_display_index = None 1149 1150 if DEBUG_PROCESSING and parties_in: 1151 print( 1152 f'Processed {len(parties_in)} raw party infos in' 1153 f' {time.time()-starttime:.5f}s.' 1154 ) 1155 1156 def _update_party_lists(self) -> None: 1157 plus = bui.app.plus 1158 assert plus is not None 1159 1160 if not self._party_lists_dirty: 1161 return 1162 starttime = time.time() 1163 assert len(self._parties_sorted) == len(self._parties) 1164 1165 self._parties_sorted.sort( 1166 key=lambda p: ( 1167 p[1].ping if p[1].ping is not None else 999999.0, 1168 p[1].index, 1169 ) 1170 ) 1171 1172 # If signed out or errored, show no parties. 1173 if ( 1174 plus.get_v1_account_state() != 'signed_in' 1175 or not self._have_valid_server_list 1176 ): 1177 self._parties_displayed = {} 1178 else: 1179 if self._filter_value: 1180 filterval = self._filter_value.lower() 1181 self._parties_displayed = { 1182 k: v 1183 for k, v in self._parties_sorted 1184 if filterval in v.name.lower() 1185 } 1186 else: 1187 self._parties_displayed = dict(self._parties_sorted) 1188 1189 # Any time our selection disappears from the displayed list, go 1190 # back to auto-selecting the top entry. 1191 if ( 1192 self._selection is not None 1193 and self._selection.entry_key not in self._parties_displayed 1194 ): 1195 self._have_user_selected_row = False 1196 1197 # Whenever the user hasn't selected something, keep the first 1198 # visible row selected. 1199 if not self._have_user_selected_row and self._parties_displayed: 1200 firstpartykey = next(iter(self._parties_displayed)) 1201 self._selection = Selection(firstpartykey, SelectionComponent.NAME) 1202 1203 self._party_lists_dirty = False 1204 if DEBUG_PROCESSING: 1205 print( 1206 f'Sorted {len(self._parties_sorted)} parties in' 1207 f' {time.time()-starttime:.5f}s.' 1208 ) 1209 1210 def _query_party_list_periodically(self) -> None: 1211 now = bui.apptime() 1212 1213 plus = bui.app.plus 1214 assert plus is not None 1215 1216 # Fire off a new public-party query periodically. 1217 if ( 1218 self._last_server_list_query_time is None 1219 or now - self._last_server_list_query_time 1220 > 0.001 1221 * plus.get_v1_account_misc_read_val('pubPartyRefreshMS', 10000) 1222 ): 1223 self._last_server_list_query_time = now 1224 if DEBUG_SERVER_COMMUNICATION: 1225 print('REQUESTING SERVER LIST') 1226 if plus.get_v1_account_state() == 'signed_in': 1227 plus.add_v1_account_transaction( 1228 { 1229 'type': 'PUBLIC_PARTY_QUERY', 1230 'proto': bs.protocol_version(), 1231 'lang': bui.app.lang.language, 1232 }, 1233 callback=bui.WeakCall(self._on_public_party_query_result), 1234 ) 1235 plus.run_v1_account_transactions() 1236 else: 1237 self._on_public_party_query_result(None) 1238 1239 def _ping_parties_periodically(self) -> None: 1240 assert bui.app.classic is not None 1241 now = bui.apptime() 1242 1243 # Go through our existing public party entries firing off pings 1244 # for any that have timed out. 1245 for party in list(self._parties.values()): 1246 if ( 1247 party.next_ping_time <= now 1248 and bui.app.classic.ping_thread_count < 15 1249 ): 1250 # Crank the interval up for high-latency or 1251 # non-responding parties to save us some useless work. 1252 mult = 1 1253 if party.ping_responses == 0: 1254 if party.ping_attempts > 4: 1255 mult = 10 1256 elif party.ping_attempts > 2: 1257 mult = 5 1258 if party.ping is not None: 1259 mult = ( 1260 10 if party.ping > 300 else 5 if party.ping > 150 else 2 1261 ) 1262 1263 interval = party.ping_interval * mult 1264 if DEBUG_SERVER_COMMUNICATION: 1265 print( 1266 f'pinging #{party.index} cur={party.ping} ' 1267 f'interval={interval} ' 1268 f'({party.ping_responses}/{party.ping_attempts})' 1269 ) 1270 1271 party.next_ping_time = now + party.ping_interval * mult 1272 party.ping_attempts += 1 1273 1274 PingThread( 1275 party.address, party.port, bui.WeakCall(self._ping_callback) 1276 ).start() 1277 1278 def _ping_callback( 1279 self, address: str, port: int | None, result: float | None 1280 ) -> None: 1281 # Look for a widget corresponding to this target. If we find 1282 # one, update our list. 1283 party_key = f'{address}_{port}' 1284 party = self._parties.get(party_key) 1285 if party is not None: 1286 if result is not None: 1287 party.ping_responses += 1 1288 1289 # We now smooth ping a bit to reduce jumping around in the 1290 # list (only where pings are relatively good). 1291 current_ping = party.ping 1292 if current_ping is not None and result is not None and result < 150: 1293 smoothing = 0.7 1294 party.ping = ( 1295 smoothing * current_ping + (1.0 - smoothing) * result 1296 ) 1297 else: 1298 party.ping = result 1299 1300 # Need to re-sort the list and update the row display. 1301 party.clean_display_index = None 1302 self._party_lists_dirty = True 1303 1304 def _fetch_local_addr_cb(self, val: str) -> None: 1305 self._local_address = str(val) 1306 1307 def _on_public_party_accessible_response( 1308 self, data: dict[str, Any] | None 1309 ) -> None: 1310 # If we've got status text widgets, update them. 1311 text = self._host_status_text 1312 if text: 1313 if data is None: 1314 bui.textwidget( 1315 edit=text, 1316 text=bui.Lstr( 1317 resource='gatherWindow.' 'partyStatusNoConnectionText' 1318 ), 1319 color=(1, 0, 0), 1320 ) 1321 else: 1322 if not data.get('accessible', False): 1323 ex_line: str | bui.Lstr 1324 if self._local_address is not None: 1325 ex_line = bui.Lstr( 1326 value='\n${A} ${B}', 1327 subs=[ 1328 ( 1329 '${A}', 1330 bui.Lstr( 1331 resource='gatherWindow.' 1332 'manualYourLocalAddressText' 1333 ), 1334 ), 1335 ('${B}', self._local_address), 1336 ], 1337 ) 1338 else: 1339 ex_line = '' 1340 bui.textwidget( 1341 edit=text, 1342 text=bui.Lstr( 1343 value='${A}\n${B}${C}', 1344 subs=[ 1345 ( 1346 '${A}', 1347 bui.Lstr( 1348 resource='gatherWindow.' 1349 'partyStatusNotJoinableText' 1350 ), 1351 ), 1352 ( 1353 '${B}', 1354 bui.Lstr( 1355 resource='gatherWindow.' 1356 'manualRouterForwardingText', 1357 subs=[ 1358 ( 1359 '${PORT}', 1360 str(bs.get_game_port()), 1361 ) 1362 ], 1363 ), 1364 ), 1365 ('${C}', ex_line), 1366 ], 1367 ), 1368 color=(1, 0, 0), 1369 ) 1370 else: 1371 bui.textwidget( 1372 edit=text, 1373 text=bui.Lstr( 1374 resource='gatherWindow.' 'partyStatusJoinableText' 1375 ), 1376 color=(0, 1, 0), 1377 ) 1378 1379 def _do_status_check(self) -> None: 1380 assert bui.app.classic is not None 1381 bui.textwidget( 1382 edit=self._host_status_text, 1383 color=(1, 1, 0), 1384 text=bui.Lstr(resource='gatherWindow.' 'partyStatusCheckingText'), 1385 ) 1386 bui.app.classic.master_server_v1_get( 1387 'bsAccessCheck', 1388 {'b': bui.app.env.engine_build_number}, 1389 callback=bui.WeakCall(self._on_public_party_accessible_response), 1390 ) 1391 1392 def _on_start_advertizing_press(self) -> None: 1393 from bauiv1lib.account.signin import show_sign_in_prompt 1394 1395 plus = bui.app.plus 1396 assert plus is not None 1397 1398 if plus.get_v1_account_state() != 'signed_in': 1399 show_sign_in_prompt() 1400 return 1401 1402 name = cast(str, bui.textwidget(query=self._host_name_text)) 1403 if name == '': 1404 bui.screenmessage( 1405 bui.Lstr(resource='internal.invalidNameErrorText'), 1406 color=(1, 0, 0), 1407 ) 1408 bui.getsound('error').play() 1409 return 1410 bs.set_public_party_name(name) 1411 cfg = bui.app.config 1412 cfg['Public Party Name'] = name 1413 cfg.commit() 1414 bui.getsound('shieldUp').play() 1415 bs.set_public_party_enabled(True) 1416 1417 # In GUI builds we want to authenticate clients only when 1418 # hosting public parties. 1419 bs.set_authenticate_clients(True) 1420 1421 self._do_status_check() 1422 bui.buttonwidget( 1423 edit=self._host_toggle_button, 1424 label=bui.Lstr( 1425 resource='gatherWindow.makePartyPrivateText', 1426 fallback_resource='gatherWindow.stopAdvertisingText', 1427 ), 1428 on_activate_call=self._on_stop_advertising_press, 1429 ) 1430 1431 def _on_stop_advertising_press(self) -> None: 1432 bs.set_public_party_enabled(False) 1433 1434 # In GUI builds we want to authenticate clients only when 1435 # hosting public parties. 1436 bs.set_authenticate_clients(False) 1437 bui.getsound('shieldDown').play() 1438 text = self._host_status_text 1439 if text: 1440 bui.textwidget( 1441 edit=text, 1442 text=bui.Lstr( 1443 resource='gatherWindow.' 'partyStatusNotPublicText' 1444 ), 1445 color=(0.6, 0.6, 0.6), 1446 ) 1447 bui.buttonwidget( 1448 edit=self._host_toggle_button, 1449 label=bui.Lstr( 1450 resource='gatherWindow.makePartyPublicText', 1451 fallback_resource='gatherWindow.startAdvertisingText', 1452 ), 1453 on_activate_call=self._on_start_advertizing_press, 1454 ) 1455 1456 def on_public_party_activate(self, party: PartyEntry) -> None: 1457 """Called when a party is clicked or otherwise activated.""" 1458 self.save_state() 1459 if party.queue is not None: 1460 from bauiv1lib.partyqueue import PartyQueueWindow 1461 1462 bui.getsound('swish').play() 1463 PartyQueueWindow(party.queue, party.address, party.port) 1464 else: 1465 address = party.address 1466 port = party.port 1467 1468 # Store UI location to return to when done. 1469 if bs.app.classic is not None: 1470 bs.app.classic.save_ui_state() 1471 1472 # Rate limit this a bit. 1473 now = time.time() 1474 last_connect_time = self._last_connect_attempt_time 1475 if last_connect_time is None or now - last_connect_time > 2.0: 1476 bs.connect_to_party(address, port=port) 1477 self._last_connect_attempt_time = now 1478 1479 def set_public_party_selection(self, sel: Selection) -> None: 1480 """Set the sel.""" 1481 if self._refreshing_list: 1482 return 1483 self._selection = sel 1484 self._have_user_selected_row = True 1485 1486 def _on_max_public_party_size_minus_press(self) -> None: 1487 val = max(1, bs.get_public_party_max_size() - 1) 1488 bs.set_public_party_max_size(val) 1489 bui.textwidget(edit=self._host_max_party_size_value, text=str(val)) 1490 1491 def _on_max_public_party_size_plus_press(self) -> None: 1492 val = bs.get_public_party_max_size() 1493 val += 1 1494 bs.set_public_party_max_size(val) 1495 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 - 270.0, 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 - 90, 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 - 25.0, 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 - 270.0, 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 - 90, 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 - 25.0, 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._join_sub_scroll_width: float | None = None 377 self._host_status_text: bui.Widget | None = None 378 self._signed_in = False 379 self._ui_rows: list[UIRow] = [] 380 self._refresh_ui_row = 0 381 self._have_user_selected_row = False 382 self._first_valid_server_list_time: float | None = None 383 384 # Parties indexed by id: 385 self._parties: dict[str, PartyEntry] = {} 386 387 # Parties sorted in display order: 388 self._parties_sorted: list[tuple[str, PartyEntry]] = [] 389 self._party_lists_dirty = True 390 391 # Sorted parties with filter applied: 392 self._parties_displayed: dict[str, PartyEntry] = {} 393 394 self._next_entry_index = 0 395 self._have_server_list_response = False 396 self._have_valid_server_list = False 397 self._filter_value = '' 398 self._pending_party_infos: list[dict[str, Any]] = [] 399 self._last_sub_scroll_height = 0.0 400 401 @override 402 def on_activate( 403 self, 404 parent_widget: bui.Widget, 405 tab_button: bui.Widget, 406 region_width: float, 407 region_height: float, 408 region_left: float, 409 region_bottom: float, 410 ) -> bui.Widget: 411 # pylint: disable=too-many-positional-arguments 412 c_width = region_width 413 c_height = region_height - 20 414 self._container = bui.containerwidget( 415 parent=parent_widget, 416 position=( 417 region_left, 418 region_bottom + (region_height - c_height) * 0.5, 419 ), 420 size=(c_width, c_height), 421 background=False, 422 selection_loops_to_parent=True, 423 ) 424 v = c_height - 30 425 self._join_text = bui.textwidget( 426 parent=self._container, 427 position=(c_width * 0.5 - 245, v - 13), 428 color=(0.6, 1.0, 0.6), 429 scale=1.3, 430 size=(200, 30), 431 maxwidth=250, 432 h_align='left', 433 v_align='center', 434 click_activate=True, 435 selectable=True, 436 autoselect=True, 437 on_activate_call=lambda: self._set_sub_tab( 438 SubTabType.JOIN, 439 region_width, 440 region_height, 441 playsound=True, 442 ), 443 text=bui.Lstr( 444 resource='gatherWindow.' 'joinPublicPartyDescriptionText' 445 ), 446 glow_type='uniform', 447 ) 448 self._host_text = bui.textwidget( 449 parent=self._container, 450 position=(c_width * 0.5 + 45, v - 13), 451 color=(0.6, 1.0, 0.6), 452 scale=1.3, 453 size=(200, 30), 454 maxwidth=250, 455 h_align='left', 456 v_align='center', 457 click_activate=True, 458 selectable=True, 459 autoselect=True, 460 on_activate_call=lambda: self._set_sub_tab( 461 SubTabType.HOST, 462 region_width, 463 region_height, 464 playsound=True, 465 ), 466 text=bui.Lstr( 467 resource='gatherWindow.' 'hostPublicPartyDescriptionText' 468 ), 469 glow_type='uniform', 470 ) 471 bui.widget(edit=self._join_text, up_widget=tab_button) 472 bui.widget( 473 edit=self._host_text, 474 left_widget=self._join_text, 475 up_widget=tab_button, 476 ) 477 bui.widget(edit=self._join_text, right_widget=self._host_text) 478 479 # Attempt to fetch our local address so we have it for error 480 # messages. 481 if self._local_address is None: 482 AddrFetchThread(bui.WeakCall(self._fetch_local_addr_cb)).start() 483 484 self._set_sub_tab(self._sub_tab, region_width, region_height) 485 self._update_timer = bui.AppTimer( 486 0.1, bui.WeakCall(self._update), repeat=True 487 ) 488 return self._container 489 490 @override 491 def on_deactivate(self) -> None: 492 self._update_timer = None 493 494 @override 495 def save_state(self) -> None: 496 # Save off a small number of parties with the lowest ping; we'll 497 # display these immediately when our UI comes back up which 498 # should be enough to make things feel nice and crisp while we 499 # do a full server re-query or whatnot. 500 assert bui.app.classic is not None 501 bui.app.ui_v1.window_states[type(self)] = State( 502 sub_tab=self._sub_tab, 503 parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]], 504 next_entry_index=self._next_entry_index, 505 filter_value=self._filter_value, 506 have_server_list_response=self._have_server_list_response, 507 have_valid_server_list=self._have_valid_server_list, 508 ) 509 510 @override 511 def restore_state(self) -> None: 512 assert bui.app.classic is not None 513 state = bui.app.ui_v1.window_states.get(type(self)) 514 if state is None: 515 state = State() 516 assert isinstance(state, State) 517 self._sub_tab = state.sub_tab 518 519 # Restore the parties we stored. 520 if state.parties: 521 self._parties = { 522 key: copy.copy(party) for key, party in state.parties 523 } 524 self._parties_sorted = list(self._parties.items()) 525 self._party_lists_dirty = True 526 527 self._next_entry_index = state.next_entry_index 528 529 # FIXME: should save/restore these too? 530 self._have_server_list_response = state.have_server_list_response 531 self._have_valid_server_list = state.have_valid_server_list 532 self._filter_value = state.filter_value 533 534 def _set_sub_tab( 535 self, 536 value: SubTabType, 537 region_width: float, 538 region_height: float, 539 playsound: bool = False, 540 ) -> None: 541 assert self._container 542 if playsound: 543 bui.getsound('click01').play() 544 545 # Reset our selection (prevents selecting something way down the 546 # list if we switched away and came back). 547 self._selection = None 548 self._have_user_selected_row = False 549 550 # Reset refresh to the top and make sure everything refreshes. 551 self._refresh_ui_row = 0 552 for party in self._parties.values(): 553 party.clean_display_index = None 554 555 self._sub_tab = value 556 active_color = (0.6, 1.0, 0.6) 557 inactive_color = (0.5, 0.4, 0.5) 558 bui.textwidget( 559 edit=self._join_text, 560 color=active_color if value is SubTabType.JOIN else inactive_color, 561 ) 562 bui.textwidget( 563 edit=self._host_text, 564 color=active_color if value is SubTabType.HOST else inactive_color, 565 ) 566 567 # Clear anything existing in the old sub-tab. 568 for widget in self._container.get_children(): 569 if widget and widget not in {self._host_text, self._join_text}: 570 widget.delete() 571 572 if value is SubTabType.JOIN: 573 self._build_join_tab(region_width, region_height) 574 575 if value is SubTabType.HOST: 576 self._build_host_tab(region_width, region_height) 577 578 def _build_join_tab( 579 self, region_width: float, region_height: float 580 ) -> None: 581 c_width = region_width 582 c_height = region_height - 20 583 sub_scroll_height = c_height - 125 584 self._join_sub_scroll_width = sub_scroll_width = min( 585 1200, region_width - 80 586 ) 587 v = c_height - 35 588 v -= 60 589 filter_txt = bui.Lstr(resource='filterText') 590 self._filter_text = bui.textwidget( 591 parent=self._container, 592 text=self._filter_value, 593 size=(350, 45), 594 position=(c_width * 0.5 - 150, v - 10), 595 h_align='left', 596 v_align='center', 597 editable=True, 598 maxwidth=310, 599 description=filter_txt, 600 ) 601 bui.widget(edit=self._filter_text, up_widget=self._join_text) 602 bui.textwidget( 603 text=filter_txt, 604 parent=self._container, 605 size=(0, 0), 606 position=(c_width * 0.5 - 170, v + 13), 607 maxwidth=150, 608 scale=0.8, 609 color=(0.5, 0.46, 0.5), 610 flatness=1.0, 611 h_align='right', 612 v_align='center', 613 ) 614 615 bui.textwidget( 616 text=bui.Lstr(resource='nameText'), 617 parent=self._container, 618 size=(0, 0), 619 position=((c_width - sub_scroll_width) * 0.5 + 50, v - 8), 620 maxwidth=60, 621 scale=0.6, 622 color=(0.5, 0.46, 0.5), 623 flatness=1.0, 624 h_align='center', 625 v_align='center', 626 ) 627 bui.textwidget( 628 text=bui.Lstr(resource='gatherWindow.partySizeText'), 629 parent=self._container, 630 size=(0, 0), 631 position=( 632 c_width * 0.5 + sub_scroll_width * 0.5 - 110, 633 v - 8, 634 ), 635 maxwidth=60, 636 scale=0.6, 637 color=(0.5, 0.46, 0.5), 638 flatness=1.0, 639 h_align='center', 640 v_align='center', 641 ) 642 bui.textwidget( 643 text=bui.Lstr(resource='gatherWindow.pingText'), 644 parent=self._container, 645 size=(0, 0), 646 position=( 647 c_width * 0.5 + sub_scroll_width * 0.5 - 30, 648 v - 8, 649 ), 650 maxwidth=60, 651 scale=0.6, 652 color=(0.5, 0.46, 0.5), 653 flatness=1.0, 654 h_align='center', 655 v_align='center', 656 ) 657 v -= sub_scroll_height + 23 658 self._host_scrollwidget = scrollw = bui.scrollwidget( 659 parent=self._container, 660 simple_culling_v=10, 661 position=((c_width - sub_scroll_width) * 0.5, v), 662 size=(sub_scroll_width, sub_scroll_height), 663 claims_up_down=False, 664 claims_left_right=True, 665 autoselect=True, 666 ) 667 self._join_list_column = bui.containerwidget( 668 parent=scrollw, 669 background=False, 670 size=(400, 400), 671 claims_left_right=True, 672 ) 673 674 # Create join status text and join spinner. Always make sure to 675 # update both of these together. 676 self._join_status_text = bui.textwidget( 677 parent=self._container, 678 text='', 679 size=(0, 0), 680 scale=0.9, 681 flatness=1.0, 682 shadow=0.0, 683 h_align='center', 684 v_align='top', 685 maxwidth=c_width, 686 color=(0.6, 0.6, 0.6), 687 position=(c_width * 0.5, c_height * 0.5), 688 ) 689 self._join_status_spinner = bui.spinnerwidget( 690 parent=self._container, 691 position=(c_width * 0.5, c_height * 0.5), 692 style='bomb', 693 size=64, 694 ) 695 696 self._no_servers_found_text = bui.textwidget( 697 parent=self._container, 698 text='', 699 size=(0, 0), 700 scale=0.9, 701 flatness=1.0, 702 shadow=0.0, 703 h_align='center', 704 v_align='top', 705 color=(0.6, 0.6, 0.6), 706 position=(c_width * 0.5, c_height * 0.5), 707 ) 708 709 def _build_host_tab( 710 self, region_width: float, region_height: float 711 ) -> None: 712 c_width = region_width 713 c_height = region_height - 20 714 v = c_height - 35 715 v -= 25 716 is_public_enabled = bs.get_public_party_enabled() 717 v -= 30 718 719 bui.textwidget( 720 parent=self._container, 721 size=(0, 0), 722 h_align='center', 723 v_align='center', 724 maxwidth=c_width * 0.9, 725 scale=0.7, 726 flatness=1.0, 727 color=(0.5, 0.46, 0.5), 728 position=(region_width * 0.5, v + 10), 729 text=bui.Lstr(resource='gatherWindow.publicHostRouterConfigText'), 730 ) 731 v -= 30 732 733 # Nudge party name and size values to be mostly centered. 734 xoffs = region_width * 0.5 - 500 735 736 party_name_text = bui.Lstr( 737 resource='gatherWindow.partyNameText', 738 fallback_resource='editGameListWindow.nameText', 739 ) 740 assert bui.app.classic is not None 741 bui.textwidget( 742 parent=self._container, 743 size=(0, 0), 744 h_align='right', 745 v_align='center', 746 maxwidth=200, 747 scale=0.8, 748 color=bui.app.ui_v1.infotextcolor, 749 position=(210 + xoffs, v - 9), 750 text=party_name_text, 751 ) 752 self._host_name_text = bui.textwidget( 753 parent=self._container, 754 editable=True, 755 size=(535, 40), 756 position=(230 + xoffs, v - 30), 757 text=bui.app.config.get('Public Party Name', ''), 758 maxwidth=494, 759 shadow=0.3, 760 flatness=1.0, 761 description=party_name_text, 762 autoselect=True, 763 v_align='center', 764 corner_scale=1.0, 765 ) 766 767 v -= 60 768 bui.textwidget( 769 parent=self._container, 770 size=(0, 0), 771 h_align='right', 772 v_align='center', 773 maxwidth=200, 774 scale=0.8, 775 color=bui.app.ui_v1.infotextcolor, 776 position=(210 + xoffs, v - 9), 777 text=bui.Lstr( 778 resource='maxPartySizeText', 779 fallback_resource='maxConnectionsText', 780 ), 781 ) 782 self._host_max_party_size_value = bui.textwidget( 783 parent=self._container, 784 size=(0, 0), 785 h_align='center', 786 v_align='center', 787 scale=1.2, 788 color=(1, 1, 1), 789 position=(240 + xoffs, v - 9), 790 text=str(bs.get_public_party_max_size()), 791 ) 792 btn1 = self._host_max_party_size_minus_button = bui.buttonwidget( 793 parent=self._container, 794 size=(40, 40), 795 on_activate_call=bui.WeakCall( 796 self._on_max_public_party_size_minus_press 797 ), 798 position=(280 + xoffs, v - 26), 799 label='-', 800 autoselect=True, 801 ) 802 btn2 = self._host_max_party_size_plus_button = bui.buttonwidget( 803 parent=self._container, 804 size=(40, 40), 805 on_activate_call=bui.WeakCall( 806 self._on_max_public_party_size_plus_press 807 ), 808 position=(350 + xoffs, v - 26), 809 label='+', 810 autoselect=True, 811 ) 812 v -= 50 813 v -= 70 814 if is_public_enabled: 815 label = bui.Lstr( 816 resource='gatherWindow.makePartyPrivateText', 817 fallback_resource='gatherWindow.stopAdvertisingText', 818 ) 819 else: 820 label = bui.Lstr( 821 resource='gatherWindow.makePartyPublicText', 822 fallback_resource='gatherWindow.startAdvertisingText', 823 ) 824 self._host_toggle_button = bui.buttonwidget( 825 parent=self._container, 826 label=label, 827 size=(400, 80), 828 on_activate_call=( 829 self._on_stop_advertising_press 830 if is_public_enabled 831 else self._on_start_advertizing_press 832 ), 833 position=(c_width * 0.5 - 200, v), 834 autoselect=True, 835 up_widget=btn2, 836 ) 837 bui.widget(edit=self._host_name_text, down_widget=btn2) 838 bui.widget(edit=btn2, up_widget=self._host_name_text) 839 bui.widget(edit=btn1, up_widget=self._host_name_text) 840 assert self._join_text is not None 841 bui.widget(edit=self._join_text, down_widget=self._host_name_text) 842 v -= 10 843 self._host_status_text = bui.textwidget( 844 parent=self._container, 845 text=bui.Lstr(resource='gatherWindow.' 'partyStatusNotPublicText'), 846 size=(0, 0), 847 scale=0.7, 848 flatness=1.0, 849 h_align='center', 850 v_align='top', 851 maxwidth=c_width * 0.9, 852 color=(0.6, 0.56, 0.6), 853 position=(c_width * 0.5, v), 854 ) 855 v -= 90 856 bui.textwidget( 857 parent=self._container, 858 text=bui.Lstr(resource='gatherWindow.dedicatedServerInfoText'), 859 size=(0, 0), 860 scale=0.7, 861 flatness=1.0, 862 h_align='center', 863 v_align='center', 864 maxwidth=c_width * 0.9, 865 color=(0.5, 0.46, 0.5), 866 position=(c_width * 0.5, v), 867 ) 868 869 # If public sharing is already on, launch a status-check 870 # immediately. 871 if bs.get_public_party_enabled(): 872 self._do_status_check() 873 874 def _on_public_party_query_result( 875 self, result: dict[str, Any] | None 876 ) -> None: 877 starttime = time.time() 878 self._have_server_list_response = True 879 880 if result is None: 881 self._have_valid_server_list = False 882 return 883 884 if not self._have_valid_server_list: 885 self._first_valid_server_list_time = time.time() 886 887 self._have_valid_server_list = True 888 parties_in = result['l'] 889 890 assert isinstance(parties_in, list) 891 self._pending_party_infos += parties_in 892 893 # To avoid causing a stutter here, we do most processing of 894 # these entries incrementally in our _update() method. The one 895 # thing we do here is prune parties not contained in this 896 # result. 897 for partyval in list(self._parties.values()): 898 partyval.claimed = False 899 for party_in in parties_in: 900 addr = party_in['a'] 901 assert isinstance(addr, str) 902 port = party_in['p'] 903 assert isinstance(port, int) 904 party_key = f'{addr}_{port}' 905 party = self._parties.get(party_key) 906 if party is not None: 907 party.claimed = True 908 self._parties = { 909 key: val for key, val in list(self._parties.items()) if val.claimed 910 } 911 self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed] 912 self._party_lists_dirty = True 913 914 if DEBUG_PROCESSING: 915 print( 916 f'Handled public party query results in ' 917 f'{time.time()-starttime:.5f}s.' 918 ) 919 920 def _update(self) -> None: 921 """Periodic updating.""" 922 923 plus = bui.app.plus 924 assert plus is not None 925 926 if self._sub_tab is SubTabType.JOIN: 927 # Keep our filter-text up to date from the UI. 928 text = self._filter_text 929 if text: 930 filter_value = cast(str, bui.textwidget(query=text)) 931 if filter_value != self._filter_value: 932 self._filter_value = filter_value 933 self._party_lists_dirty = True 934 935 # Also wipe out party clean-row states (otherwise if 936 # a party disappears from a row due to filtering and 937 # then reappears on that same row when the filter is 938 # removed it may not update). 939 for party in self._parties.values(): 940 party.clean_display_index = None 941 942 self._query_party_list_periodically() 943 self._ping_parties_periodically() 944 945 # If any new party infos have come in, apply some of them. 946 self._process_pending_party_infos() 947 948 # Anytime we sign in/out, make sure we refresh our list. 949 signed_in = plus.get_v1_account_state() == 'signed_in' 950 if self._signed_in != signed_in: 951 self._signed_in = signed_in 952 self._party_lists_dirty = True 953 954 # Update sorting to account for ping updates, new parties, etc. 955 self._update_party_lists() 956 957 # If we've got a party-name text widget, keep its value plugged 958 # into our public host name. 959 text = self._host_name_text 960 if text: 961 name = cast(str, bui.textwidget(query=self._host_name_text)) 962 bs.set_public_party_name(name) 963 964 # Update status text and loading spinner. 965 if self._join_status_text: 966 assert self._join_status_spinner 967 if not signed_in: 968 bui.textwidget( 969 edit=self._join_status_text, 970 text=bui.Lstr(resource='notSignedInText'), 971 ) 972 bui.spinnerwidget(edit=self._join_status_spinner, visible=False) 973 else: 974 # If we have a valid list, show no status; just the 975 # list. Otherwise show either 'loading...' or 'error' 976 # depending on whether this is our first go-round. 977 if self._have_valid_server_list: 978 bui.textwidget(edit=self._join_status_text, text='') 979 bui.spinnerwidget( 980 edit=self._join_status_spinner, visible=False 981 ) 982 else: 983 if self._have_server_list_response: 984 bui.textwidget( 985 edit=self._join_status_text, 986 text=bui.Lstr(resource='errorText'), 987 ) 988 bui.spinnerwidget( 989 edit=self._join_status_spinner, visible=False 990 ) 991 else: 992 # Show our loading spinner. 993 bui.textwidget(edit=self._join_status_text, text='') 994 bui.spinnerwidget( 995 edit=self._join_status_spinner, visible=True 996 ) 997 998 self._update_party_rows() 999 1000 def _update_party_rows(self) -> None: 1001 plus = bui.app.plus 1002 assert plus is not None 1003 1004 columnwidget = self._join_list_column 1005 if not columnwidget: 1006 return 1007 1008 assert self._join_text 1009 assert self._filter_text 1010 1011 # Janky - allow escaping when there's nothing in our list. 1012 assert self._host_scrollwidget 1013 bui.containerwidget( 1014 edit=self._host_scrollwidget, 1015 claims_up_down=(len(self._parties_displayed) > 0), 1016 ) 1017 bui.textwidget(edit=self._no_servers_found_text, text='') 1018 1019 # Clip if we have more UI rows than parties to show. 1020 clipcount = len(self._ui_rows) - len(self._parties_displayed) 1021 if clipcount > 0: 1022 clipcount = max(clipcount, 50) 1023 self._ui_rows = self._ui_rows[:-clipcount] 1024 1025 # If we have no parties to show, we're done. 1026 if self._have_valid_server_list and not self._parties_displayed: 1027 bui.textwidget( 1028 edit=self._no_servers_found_text, 1029 text=bui.Lstr(resource='noServersFoundText'), 1030 ) 1031 return 1032 1033 assert self._join_sub_scroll_width is not None 1034 sub_scroll_width = self._join_sub_scroll_width 1035 lineheight = 42 1036 sub_scroll_height = lineheight * len(self._parties_displayed) + 50 1037 bui.containerwidget( 1038 edit=columnwidget, size=(sub_scroll_width, sub_scroll_height) 1039 ) 1040 1041 # Any time our height changes, reset the refresh back to the top 1042 # so we don't see ugly empty spaces appearing during initial 1043 # list filling. 1044 if sub_scroll_height != self._last_sub_scroll_height: 1045 self._refresh_ui_row = 0 1046 self._last_sub_scroll_height = sub_scroll_height 1047 1048 # Also note that we need to redisplay everything since its 1049 # pos will have changed.. :( 1050 for party in self._parties.values(): 1051 party.clean_display_index = None 1052 1053 # Ew; this rebuilding generates deferred selection callbacks so 1054 # we need to push deferred notices so we know to ignore them. 1055 def refresh_on() -> None: 1056 self._refreshing_list = True 1057 1058 bui.pushcall(refresh_on) 1059 1060 # Ok, now here's the deal: we want to avoid creating/updating 1061 # this entire list at one time because it will lead to hitches. 1062 # So we refresh individual rows quickly in a loop. 1063 rowcount = min(12, len(self._parties_displayed)) 1064 1065 party_vals_displayed = list(self._parties_displayed.values()) 1066 while rowcount > 0: 1067 refresh_row = self._refresh_ui_row % len(self._parties_displayed) 1068 if refresh_row >= len(self._ui_rows): 1069 self._ui_rows.append(UIRow()) 1070 refresh_row = len(self._ui_rows) - 1 1071 1072 # For the first few seconds after getting our first 1073 # server-list, refresh only the top section of the list; 1074 # this allows the lowest ping servers to show up more 1075 # quickly. 1076 if self._first_valid_server_list_time is not None: 1077 if time.time() - self._first_valid_server_list_time < 4.0: 1078 if refresh_row > 40: 1079 refresh_row = 0 1080 1081 self._ui_rows[refresh_row].update( 1082 refresh_row, 1083 party_vals_displayed[refresh_row], 1084 sub_scroll_width=sub_scroll_width, 1085 sub_scroll_height=sub_scroll_height, 1086 lineheight=lineheight, 1087 columnwidget=columnwidget, 1088 join_text=self._join_text, 1089 existing_selection=self._selection, 1090 filter_text=self._filter_text, 1091 tab=self, 1092 ) 1093 self._refresh_ui_row = refresh_row + 1 1094 rowcount -= 1 1095 1096 # So our selection callbacks can start firing.. 1097 def refresh_off() -> None: 1098 self._refreshing_list = False 1099 1100 bui.pushcall(refresh_off) 1101 1102 def _process_pending_party_infos(self) -> None: 1103 starttime = time.time() 1104 1105 # We want to do this in small enough pieces to not cause UI 1106 # hitches. 1107 chunksize = 30 1108 parties_in = self._pending_party_infos[:chunksize] 1109 self._pending_party_infos = self._pending_party_infos[chunksize:] 1110 for party_in in parties_in: 1111 addr = party_in['a'] 1112 assert isinstance(addr, str) 1113 port = party_in['p'] 1114 assert isinstance(port, int) 1115 party_key = f'{addr}_{port}' 1116 party = self._parties.get(party_key) 1117 if party is None: 1118 # If this party is new to us, init it. 1119 party = PartyEntry( 1120 address=addr, 1121 next_ping_time=bui.apptime() + 0.001 * party_in['pd'], 1122 index=self._next_entry_index, 1123 ) 1124 self._parties[party_key] = party 1125 self._parties_sorted.append((party_key, party)) 1126 self._party_lists_dirty = True 1127 self._next_entry_index += 1 1128 assert isinstance(party.address, str) 1129 assert isinstance(party.next_ping_time, float) 1130 1131 # Now, new or not, update its values. 1132 party.queue = party_in.get('q') 1133 assert isinstance(party.queue, (str, type(None))) 1134 party.port = port 1135 party.name = party_in['n'] 1136 assert isinstance(party.name, str) 1137 party.size = party_in['s'] 1138 assert isinstance(party.size, int) 1139 party.size_max = party_in['sm'] 1140 assert isinstance(party.size_max, int) 1141 1142 # Server provides this in milliseconds; we use seconds. 1143 party.ping_interval = 0.001 * party_in['pi'] 1144 assert isinstance(party.ping_interval, float) 1145 party.stats_addr = party_in['sa'] 1146 assert isinstance(party.stats_addr, (str, type(None))) 1147 1148 # Make sure the party's UI gets updated. 1149 party.clean_display_index = None 1150 1151 if DEBUG_PROCESSING and parties_in: 1152 print( 1153 f'Processed {len(parties_in)} raw party infos in' 1154 f' {time.time()-starttime:.5f}s.' 1155 ) 1156 1157 def _update_party_lists(self) -> None: 1158 plus = bui.app.plus 1159 assert plus is not None 1160 1161 if not self._party_lists_dirty: 1162 return 1163 starttime = time.time() 1164 assert len(self._parties_sorted) == len(self._parties) 1165 1166 self._parties_sorted.sort( 1167 key=lambda p: ( 1168 p[1].ping if p[1].ping is not None else 999999.0, 1169 p[1].index, 1170 ) 1171 ) 1172 1173 # If signed out or errored, show no parties. 1174 if ( 1175 plus.get_v1_account_state() != 'signed_in' 1176 or not self._have_valid_server_list 1177 ): 1178 self._parties_displayed = {} 1179 else: 1180 if self._filter_value: 1181 filterval = self._filter_value.lower() 1182 self._parties_displayed = { 1183 k: v 1184 for k, v in self._parties_sorted 1185 if filterval in v.name.lower() 1186 } 1187 else: 1188 self._parties_displayed = dict(self._parties_sorted) 1189 1190 # Any time our selection disappears from the displayed list, go 1191 # back to auto-selecting the top entry. 1192 if ( 1193 self._selection is not None 1194 and self._selection.entry_key not in self._parties_displayed 1195 ): 1196 self._have_user_selected_row = False 1197 1198 # Whenever the user hasn't selected something, keep the first 1199 # visible row selected. 1200 if not self._have_user_selected_row and self._parties_displayed: 1201 firstpartykey = next(iter(self._parties_displayed)) 1202 self._selection = Selection(firstpartykey, SelectionComponent.NAME) 1203 1204 self._party_lists_dirty = False 1205 if DEBUG_PROCESSING: 1206 print( 1207 f'Sorted {len(self._parties_sorted)} parties in' 1208 f' {time.time()-starttime:.5f}s.' 1209 ) 1210 1211 def _query_party_list_periodically(self) -> None: 1212 now = bui.apptime() 1213 1214 plus = bui.app.plus 1215 assert plus is not None 1216 1217 # Fire off a new public-party query periodically. 1218 if ( 1219 self._last_server_list_query_time is None 1220 or now - self._last_server_list_query_time 1221 > 0.001 1222 * plus.get_v1_account_misc_read_val('pubPartyRefreshMS', 10000) 1223 ): 1224 self._last_server_list_query_time = now 1225 if DEBUG_SERVER_COMMUNICATION: 1226 print('REQUESTING SERVER LIST') 1227 if plus.get_v1_account_state() == 'signed_in': 1228 plus.add_v1_account_transaction( 1229 { 1230 'type': 'PUBLIC_PARTY_QUERY', 1231 'proto': bs.protocol_version(), 1232 'lang': bui.app.lang.language, 1233 }, 1234 callback=bui.WeakCall(self._on_public_party_query_result), 1235 ) 1236 plus.run_v1_account_transactions() 1237 else: 1238 self._on_public_party_query_result(None) 1239 1240 def _ping_parties_periodically(self) -> None: 1241 assert bui.app.classic is not None 1242 now = bui.apptime() 1243 1244 # Go through our existing public party entries firing off pings 1245 # for any that have timed out. 1246 for party in list(self._parties.values()): 1247 if ( 1248 party.next_ping_time <= now 1249 and bui.app.classic.ping_thread_count < 15 1250 ): 1251 # Crank the interval up for high-latency or 1252 # non-responding parties to save us some useless work. 1253 mult = 1 1254 if party.ping_responses == 0: 1255 if party.ping_attempts > 4: 1256 mult = 10 1257 elif party.ping_attempts > 2: 1258 mult = 5 1259 if party.ping is not None: 1260 mult = ( 1261 10 if party.ping > 300 else 5 if party.ping > 150 else 2 1262 ) 1263 1264 interval = party.ping_interval * mult 1265 if DEBUG_SERVER_COMMUNICATION: 1266 print( 1267 f'pinging #{party.index} cur={party.ping} ' 1268 f'interval={interval} ' 1269 f'({party.ping_responses}/{party.ping_attempts})' 1270 ) 1271 1272 party.next_ping_time = now + party.ping_interval * mult 1273 party.ping_attempts += 1 1274 1275 PingThread( 1276 party.address, party.port, bui.WeakCall(self._ping_callback) 1277 ).start() 1278 1279 def _ping_callback( 1280 self, address: str, port: int | None, result: float | None 1281 ) -> None: 1282 # Look for a widget corresponding to this target. If we find 1283 # one, update our list. 1284 party_key = f'{address}_{port}' 1285 party = self._parties.get(party_key) 1286 if party is not None: 1287 if result is not None: 1288 party.ping_responses += 1 1289 1290 # We now smooth ping a bit to reduce jumping around in the 1291 # list (only where pings are relatively good). 1292 current_ping = party.ping 1293 if current_ping is not None and result is not None and result < 150: 1294 smoothing = 0.7 1295 party.ping = ( 1296 smoothing * current_ping + (1.0 - smoothing) * result 1297 ) 1298 else: 1299 party.ping = result 1300 1301 # Need to re-sort the list and update the row display. 1302 party.clean_display_index = None 1303 self._party_lists_dirty = True 1304 1305 def _fetch_local_addr_cb(self, val: str) -> None: 1306 self._local_address = str(val) 1307 1308 def _on_public_party_accessible_response( 1309 self, data: dict[str, Any] | None 1310 ) -> None: 1311 # If we've got status text widgets, update them. 1312 text = self._host_status_text 1313 if text: 1314 if data is None: 1315 bui.textwidget( 1316 edit=text, 1317 text=bui.Lstr( 1318 resource='gatherWindow.' 'partyStatusNoConnectionText' 1319 ), 1320 color=(1, 0, 0), 1321 ) 1322 else: 1323 if not data.get('accessible', False): 1324 ex_line: str | bui.Lstr 1325 if self._local_address is not None: 1326 ex_line = bui.Lstr( 1327 value='\n${A} ${B}', 1328 subs=[ 1329 ( 1330 '${A}', 1331 bui.Lstr( 1332 resource='gatherWindow.' 1333 'manualYourLocalAddressText' 1334 ), 1335 ), 1336 ('${B}', self._local_address), 1337 ], 1338 ) 1339 else: 1340 ex_line = '' 1341 bui.textwidget( 1342 edit=text, 1343 text=bui.Lstr( 1344 value='${A}\n${B}${C}', 1345 subs=[ 1346 ( 1347 '${A}', 1348 bui.Lstr( 1349 resource='gatherWindow.' 1350 'partyStatusNotJoinableText' 1351 ), 1352 ), 1353 ( 1354 '${B}', 1355 bui.Lstr( 1356 resource='gatherWindow.' 1357 'manualRouterForwardingText', 1358 subs=[ 1359 ( 1360 '${PORT}', 1361 str(bs.get_game_port()), 1362 ) 1363 ], 1364 ), 1365 ), 1366 ('${C}', ex_line), 1367 ], 1368 ), 1369 color=(1, 0, 0), 1370 ) 1371 else: 1372 bui.textwidget( 1373 edit=text, 1374 text=bui.Lstr( 1375 resource='gatherWindow.' 'partyStatusJoinableText' 1376 ), 1377 color=(0, 1, 0), 1378 ) 1379 1380 def _do_status_check(self) -> None: 1381 assert bui.app.classic is not None 1382 bui.textwidget( 1383 edit=self._host_status_text, 1384 color=(1, 1, 0), 1385 text=bui.Lstr(resource='gatherWindow.' 'partyStatusCheckingText'), 1386 ) 1387 bui.app.classic.master_server_v1_get( 1388 'bsAccessCheck', 1389 {'b': bui.app.env.engine_build_number}, 1390 callback=bui.WeakCall(self._on_public_party_accessible_response), 1391 ) 1392 1393 def _on_start_advertizing_press(self) -> None: 1394 from bauiv1lib.account.signin import show_sign_in_prompt 1395 1396 plus = bui.app.plus 1397 assert plus is not None 1398 1399 if plus.get_v1_account_state() != 'signed_in': 1400 show_sign_in_prompt() 1401 return 1402 1403 name = cast(str, bui.textwidget(query=self._host_name_text)) 1404 if name == '': 1405 bui.screenmessage( 1406 bui.Lstr(resource='internal.invalidNameErrorText'), 1407 color=(1, 0, 0), 1408 ) 1409 bui.getsound('error').play() 1410 return 1411 bs.set_public_party_name(name) 1412 cfg = bui.app.config 1413 cfg['Public Party Name'] = name 1414 cfg.commit() 1415 bui.getsound('shieldUp').play() 1416 bs.set_public_party_enabled(True) 1417 1418 # In GUI builds we want to authenticate clients only when 1419 # hosting public parties. 1420 bs.set_authenticate_clients(True) 1421 1422 self._do_status_check() 1423 bui.buttonwidget( 1424 edit=self._host_toggle_button, 1425 label=bui.Lstr( 1426 resource='gatherWindow.makePartyPrivateText', 1427 fallback_resource='gatherWindow.stopAdvertisingText', 1428 ), 1429 on_activate_call=self._on_stop_advertising_press, 1430 ) 1431 1432 def _on_stop_advertising_press(self) -> None: 1433 bs.set_public_party_enabled(False) 1434 1435 # In GUI builds we want to authenticate clients only when 1436 # hosting public parties. 1437 bs.set_authenticate_clients(False) 1438 bui.getsound('shieldDown').play() 1439 text = self._host_status_text 1440 if text: 1441 bui.textwidget( 1442 edit=text, 1443 text=bui.Lstr( 1444 resource='gatherWindow.' 'partyStatusNotPublicText' 1445 ), 1446 color=(0.6, 0.6, 0.6), 1447 ) 1448 bui.buttonwidget( 1449 edit=self._host_toggle_button, 1450 label=bui.Lstr( 1451 resource='gatherWindow.makePartyPublicText', 1452 fallback_resource='gatherWindow.startAdvertisingText', 1453 ), 1454 on_activate_call=self._on_start_advertizing_press, 1455 ) 1456 1457 def on_public_party_activate(self, party: PartyEntry) -> None: 1458 """Called when a party is clicked or otherwise activated.""" 1459 self.save_state() 1460 if party.queue is not None: 1461 from bauiv1lib.partyqueue import PartyQueueWindow 1462 1463 bui.getsound('swish').play() 1464 PartyQueueWindow(party.queue, party.address, party.port) 1465 else: 1466 address = party.address 1467 port = party.port 1468 1469 # Store UI location to return to when done. 1470 if bs.app.classic is not None: 1471 bs.app.classic.save_ui_state() 1472 1473 # Rate limit this a bit. 1474 now = time.time() 1475 last_connect_time = self._last_connect_attempt_time 1476 if last_connect_time is None or now - last_connect_time > 2.0: 1477 bs.connect_to_party(address, port=port) 1478 self._last_connect_attempt_time = now 1479 1480 def set_public_party_selection(self, sel: Selection) -> None: 1481 """Set the sel.""" 1482 if self._refreshing_list: 1483 return 1484 self._selection = sel 1485 self._have_user_selected_row = True 1486 1487 def _on_max_public_party_size_minus_press(self) -> None: 1488 val = max(1, bs.get_public_party_max_size() - 1) 1489 bs.set_public_party_max_size(val) 1490 bui.textwidget(edit=self._host_max_party_size_value, text=str(val)) 1491 1492 def _on_max_public_party_size_plus_press(self) -> None: 1493 val = bs.get_public_party_max_size() 1494 val += 1 1495 bs.set_public_party_max_size(val) 1496 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._join_sub_scroll_width: float | None = None 377 self._host_status_text: bui.Widget | None = None 378 self._signed_in = False 379 self._ui_rows: list[UIRow] = [] 380 self._refresh_ui_row = 0 381 self._have_user_selected_row = False 382 self._first_valid_server_list_time: float | None = None 383 384 # Parties indexed by id: 385 self._parties: dict[str, PartyEntry] = {} 386 387 # Parties sorted in display order: 388 self._parties_sorted: list[tuple[str, PartyEntry]] = [] 389 self._party_lists_dirty = True 390 391 # Sorted parties with filter applied: 392 self._parties_displayed: dict[str, PartyEntry] = {} 393 394 self._next_entry_index = 0 395 self._have_server_list_response = False 396 self._have_valid_server_list = False 397 self._filter_value = '' 398 self._pending_party_infos: list[dict[str, Any]] = [] 399 self._last_sub_scroll_height = 0.0
401 @override 402 def on_activate( 403 self, 404 parent_widget: bui.Widget, 405 tab_button: bui.Widget, 406 region_width: float, 407 region_height: float, 408 region_left: float, 409 region_bottom: float, 410 ) -> bui.Widget: 411 # pylint: disable=too-many-positional-arguments 412 c_width = region_width 413 c_height = region_height - 20 414 self._container = bui.containerwidget( 415 parent=parent_widget, 416 position=( 417 region_left, 418 region_bottom + (region_height - c_height) * 0.5, 419 ), 420 size=(c_width, c_height), 421 background=False, 422 selection_loops_to_parent=True, 423 ) 424 v = c_height - 30 425 self._join_text = bui.textwidget( 426 parent=self._container, 427 position=(c_width * 0.5 - 245, v - 13), 428 color=(0.6, 1.0, 0.6), 429 scale=1.3, 430 size=(200, 30), 431 maxwidth=250, 432 h_align='left', 433 v_align='center', 434 click_activate=True, 435 selectable=True, 436 autoselect=True, 437 on_activate_call=lambda: self._set_sub_tab( 438 SubTabType.JOIN, 439 region_width, 440 region_height, 441 playsound=True, 442 ), 443 text=bui.Lstr( 444 resource='gatherWindow.' 'joinPublicPartyDescriptionText' 445 ), 446 glow_type='uniform', 447 ) 448 self._host_text = bui.textwidget( 449 parent=self._container, 450 position=(c_width * 0.5 + 45, v - 13), 451 color=(0.6, 1.0, 0.6), 452 scale=1.3, 453 size=(200, 30), 454 maxwidth=250, 455 h_align='left', 456 v_align='center', 457 click_activate=True, 458 selectable=True, 459 autoselect=True, 460 on_activate_call=lambda: self._set_sub_tab( 461 SubTabType.HOST, 462 region_width, 463 region_height, 464 playsound=True, 465 ), 466 text=bui.Lstr( 467 resource='gatherWindow.' 'hostPublicPartyDescriptionText' 468 ), 469 glow_type='uniform', 470 ) 471 bui.widget(edit=self._join_text, up_widget=tab_button) 472 bui.widget( 473 edit=self._host_text, 474 left_widget=self._join_text, 475 up_widget=tab_button, 476 ) 477 bui.widget(edit=self._join_text, right_widget=self._host_text) 478 479 # Attempt to fetch our local address so we have it for error 480 # messages. 481 if self._local_address is None: 482 AddrFetchThread(bui.WeakCall(self._fetch_local_addr_cb)).start() 483 484 self._set_sub_tab(self._sub_tab, region_width, region_height) 485 self._update_timer = bui.AppTimer( 486 0.1, bui.WeakCall(self._update), repeat=True 487 ) 488 return self._container
Called when the tab becomes the active one.
The tab should create and return a container widget covering the specified region.
494 @override 495 def save_state(self) -> None: 496 # Save off a small number of parties with the lowest ping; we'll 497 # display these immediately when our UI comes back up which 498 # should be enough to make things feel nice and crisp while we 499 # do a full server re-query or whatnot. 500 assert bui.app.classic is not None 501 bui.app.ui_v1.window_states[type(self)] = State( 502 sub_tab=self._sub_tab, 503 parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]], 504 next_entry_index=self._next_entry_index, 505 filter_value=self._filter_value, 506 have_server_list_response=self._have_server_list_response, 507 have_valid_server_list=self._have_valid_server_list, 508 )
Called when the parent window is saving state.
510 @override 511 def restore_state(self) -> None: 512 assert bui.app.classic is not None 513 state = bui.app.ui_v1.window_states.get(type(self)) 514 if state is None: 515 state = State() 516 assert isinstance(state, State) 517 self._sub_tab = state.sub_tab 518 519 # Restore the parties we stored. 520 if state.parties: 521 self._parties = { 522 key: copy.copy(party) for key, party in state.parties 523 } 524 self._parties_sorted = list(self._parties.items()) 525 self._party_lists_dirty = True 526 527 self._next_entry_index = state.next_entry_index 528 529 # FIXME: should save/restore these too? 530 self._have_server_list_response = state.have_server_list_response 531 self._have_valid_server_list = state.have_valid_server_list 532 self._filter_value = state.filter_value
Called when the parent window is restoring state.
1457 def on_public_party_activate(self, party: PartyEntry) -> None: 1458 """Called when a party is clicked or otherwise activated.""" 1459 self.save_state() 1460 if party.queue is not None: 1461 from bauiv1lib.partyqueue import PartyQueueWindow 1462 1463 bui.getsound('swish').play() 1464 PartyQueueWindow(party.queue, party.address, party.port) 1465 else: 1466 address = party.address 1467 port = party.port 1468 1469 # Store UI location to return to when done. 1470 if bs.app.classic is not None: 1471 bs.app.classic.save_ui_state() 1472 1473 # Rate limit this a bit. 1474 now = time.time() 1475 last_connect_time = self._last_connect_attempt_time 1476 if last_connect_time is None or now - last_connect_time > 2.0: 1477 bs.connect_to_party(address, port=port) 1478 self._last_connect_attempt_time = now
Called when a party is clicked or otherwise activated.
1480 def set_public_party_selection(self, sel: Selection) -> None: 1481 """Set the sel.""" 1482 if self._refreshing_list: 1483 return 1484 self._selection = sel 1485 self._have_user_selected_row = True
Set the sel.