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