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