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=(c_width * 0.5 - 150, 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=(c_width * 0.5 - 170, 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=((c_width - sub_scroll_width) * 0.5 + 50, 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=( 625 c_width * 0.5 + sub_scroll_width * 0.5 - 110, 626 v - 8, 627 ), 628 maxwidth=60, 629 scale=0.6, 630 color=(0.5, 0.46, 0.5), 631 flatness=1.0, 632 h_align='center', 633 v_align='center', 634 ) 635 bui.textwidget( 636 text=bui.Lstr(resource='gatherWindow.pingText'), 637 parent=self._container, 638 size=(0, 0), 639 position=( 640 c_width * 0.5 + sub_scroll_width * 0.5 - 30, 641 v - 8, 642 ), 643 maxwidth=60, 644 scale=0.6, 645 color=(0.5, 0.46, 0.5), 646 flatness=1.0, 647 h_align='center', 648 v_align='center', 649 ) 650 v -= sub_scroll_height + 23 651 self._host_scrollwidget = scrollw = bui.scrollwidget( 652 parent=self._container, 653 simple_culling_v=10, 654 position=((c_width - sub_scroll_width) * 0.5, v), 655 size=(sub_scroll_width, sub_scroll_height), 656 claims_up_down=False, 657 claims_left_right=True, 658 autoselect=True, 659 ) 660 self._join_list_column = bui.containerwidget( 661 parent=scrollw, 662 background=False, 663 size=(400, 400), 664 claims_left_right=True, 665 ) 666 self._join_status_text = bui.textwidget( 667 parent=self._container, 668 text='', 669 size=(0, 0), 670 scale=0.9, 671 flatness=1.0, 672 shadow=0.0, 673 h_align='center', 674 v_align='top', 675 maxwidth=c_width, 676 color=(0.6, 0.6, 0.6), 677 position=(c_width * 0.5, c_height * 0.5), 678 ) 679 self._no_servers_found_text = bui.textwidget( 680 parent=self._container, 681 text='', 682 size=(0, 0), 683 scale=0.9, 684 flatness=1.0, 685 shadow=0.0, 686 h_align='center', 687 v_align='top', 688 color=(0.6, 0.6, 0.6), 689 position=(c_width * 0.5, c_height * 0.5), 690 ) 691 692 def _build_host_tab( 693 self, region_width: float, region_height: float 694 ) -> None: 695 c_width = region_width 696 c_height = region_height - 20 697 v = c_height - 35 698 v -= 25 699 is_public_enabled = bs.get_public_party_enabled() 700 v -= 30 701 702 bui.textwidget( 703 parent=self._container, 704 size=(0, 0), 705 h_align='center', 706 v_align='center', 707 maxwidth=c_width * 0.9, 708 scale=0.7, 709 flatness=1.0, 710 color=(0.5, 0.46, 0.5), 711 position=(region_width * 0.5, v + 10), 712 text=bui.Lstr(resource='gatherWindow.publicHostRouterConfigText'), 713 ) 714 v -= 30 715 716 party_name_text = bui.Lstr( 717 resource='gatherWindow.partyNameText', 718 fallback_resource='editGameListWindow.nameText', 719 ) 720 assert bui.app.classic is not None 721 bui.textwidget( 722 parent=self._container, 723 size=(0, 0), 724 h_align='right', 725 v_align='center', 726 maxwidth=200, 727 scale=0.8, 728 color=bui.app.ui_v1.infotextcolor, 729 position=(210, v - 9), 730 text=party_name_text, 731 ) 732 self._host_name_text = bui.textwidget( 733 parent=self._container, 734 editable=True, 735 size=(535, 40), 736 position=(230, v - 30), 737 text=bui.app.config.get('Public Party Name', ''), 738 maxwidth=494, 739 shadow=0.3, 740 flatness=1.0, 741 description=party_name_text, 742 autoselect=True, 743 v_align='center', 744 corner_scale=1.0, 745 ) 746 747 v -= 60 748 bui.textwidget( 749 parent=self._container, 750 size=(0, 0), 751 h_align='right', 752 v_align='center', 753 maxwidth=200, 754 scale=0.8, 755 color=bui.app.ui_v1.infotextcolor, 756 position=(210, v - 9), 757 text=bui.Lstr( 758 resource='maxPartySizeText', 759 fallback_resource='maxConnectionsText', 760 ), 761 ) 762 self._host_max_party_size_value = bui.textwidget( 763 parent=self._container, 764 size=(0, 0), 765 h_align='center', 766 v_align='center', 767 scale=1.2, 768 color=(1, 1, 1), 769 position=(240, v - 9), 770 text=str(bs.get_public_party_max_size()), 771 ) 772 btn1 = self._host_max_party_size_minus_button = bui.buttonwidget( 773 parent=self._container, 774 size=(40, 40), 775 on_activate_call=bui.WeakCall( 776 self._on_max_public_party_size_minus_press 777 ), 778 position=(280, v - 26), 779 label='-', 780 autoselect=True, 781 ) 782 btn2 = self._host_max_party_size_plus_button = bui.buttonwidget( 783 parent=self._container, 784 size=(40, 40), 785 on_activate_call=bui.WeakCall( 786 self._on_max_public_party_size_plus_press 787 ), 788 position=(350, v - 26), 789 label='+', 790 autoselect=True, 791 ) 792 v -= 50 793 v -= 70 794 if is_public_enabled: 795 label = bui.Lstr( 796 resource='gatherWindow.makePartyPrivateText', 797 fallback_resource='gatherWindow.stopAdvertisingText', 798 ) 799 else: 800 label = bui.Lstr( 801 resource='gatherWindow.makePartyPublicText', 802 fallback_resource='gatherWindow.startAdvertisingText', 803 ) 804 self._host_toggle_button = bui.buttonwidget( 805 parent=self._container, 806 label=label, 807 size=(400, 80), 808 on_activate_call=( 809 self._on_stop_advertising_press 810 if is_public_enabled 811 else self._on_start_advertizing_press 812 ), 813 position=(c_width * 0.5 - 200, v), 814 autoselect=True, 815 up_widget=btn2, 816 ) 817 bui.widget(edit=self._host_name_text, down_widget=btn2) 818 bui.widget(edit=btn2, up_widget=self._host_name_text) 819 bui.widget(edit=btn1, up_widget=self._host_name_text) 820 assert self._join_text is not None 821 bui.widget(edit=self._join_text, down_widget=self._host_name_text) 822 v -= 10 823 self._host_status_text = bui.textwidget( 824 parent=self._container, 825 text=bui.Lstr(resource='gatherWindow.' 'partyStatusNotPublicText'), 826 size=(0, 0), 827 scale=0.7, 828 flatness=1.0, 829 h_align='center', 830 v_align='top', 831 maxwidth=c_width * 0.9, 832 color=(0.6, 0.56, 0.6), 833 position=(c_width * 0.5, v), 834 ) 835 v -= 90 836 bui.textwidget( 837 parent=self._container, 838 text=bui.Lstr(resource='gatherWindow.dedicatedServerInfoText'), 839 size=(0, 0), 840 scale=0.7, 841 flatness=1.0, 842 h_align='center', 843 v_align='center', 844 maxwidth=c_width * 0.9, 845 color=(0.5, 0.46, 0.5), 846 position=(c_width * 0.5, v), 847 ) 848 849 # If public sharing is already on, 850 # launch a status-check immediately. 851 if bs.get_public_party_enabled(): 852 self._do_status_check() 853 854 def _on_public_party_query_result( 855 self, result: dict[str, Any] | None 856 ) -> None: 857 starttime = time.time() 858 self._have_server_list_response = True 859 860 if result is None: 861 self._have_valid_server_list = False 862 return 863 864 if not self._have_valid_server_list: 865 self._first_valid_server_list_time = time.time() 866 867 self._have_valid_server_list = True 868 parties_in = result['l'] 869 870 assert isinstance(parties_in, list) 871 self._pending_party_infos += parties_in 872 873 # To avoid causing a stutter here, we do most processing of 874 # these entries incrementally in our _update() method. 875 # The one thing we do here is prune parties not contained in 876 # this result. 877 for partyval in list(self._parties.values()): 878 partyval.claimed = False 879 for party_in in parties_in: 880 addr = party_in['a'] 881 assert isinstance(addr, str) 882 port = party_in['p'] 883 assert isinstance(port, int) 884 party_key = f'{addr}_{port}' 885 party = self._parties.get(party_key) 886 if party is not None: 887 party.claimed = True 888 self._parties = { 889 key: val for key, val in list(self._parties.items()) if val.claimed 890 } 891 self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed] 892 self._party_lists_dirty = True 893 894 # self._update_server_list() 895 if DEBUG_PROCESSING: 896 print( 897 f'Handled public party query results in ' 898 f'{time.time()-starttime:.5f}s.' 899 ) 900 901 def _update(self) -> None: 902 """Periodic updating.""" 903 904 plus = bui.app.plus 905 assert plus is not None 906 907 if self._sub_tab is SubTabType.JOIN: 908 # Keep our filter-text up to date from the UI. 909 text = self._filter_text 910 if text: 911 filter_value = cast(str, bui.textwidget(query=text)) 912 if filter_value != self._filter_value: 913 self._filter_value = filter_value 914 self._party_lists_dirty = True 915 916 # Also wipe out party clean-row states. 917 # (otherwise if a party disappears from a row due to 918 # filtering and then reappears on that same row when 919 # the filter is removed it may not update) 920 for party in self._parties.values(): 921 party.clean_display_index = None 922 923 self._query_party_list_periodically() 924 self._ping_parties_periodically() 925 926 # If any new party infos have come in, apply some of them. 927 self._process_pending_party_infos() 928 929 # Anytime we sign in/out, make sure we refresh our list. 930 signed_in = plus.get_v1_account_state() == 'signed_in' 931 if self._signed_in != signed_in: 932 self._signed_in = signed_in 933 self._party_lists_dirty = True 934 935 # Update sorting to account for ping updates, new parties, etc. 936 self._update_party_lists() 937 938 # If we've got a party-name text widget, keep its value plugged 939 # into our public host name. 940 text = self._host_name_text 941 if text: 942 name = cast(str, bui.textwidget(query=self._host_name_text)) 943 bs.set_public_party_name(name) 944 945 # Update status text. 946 status_text = self._join_status_text 947 if status_text: 948 if not signed_in: 949 bui.textwidget( 950 edit=status_text, text=bui.Lstr(resource='notSignedInText') 951 ) 952 else: 953 # If we have a valid list, show no status; just the list. 954 # Otherwise show either 'loading...' or 'error' depending 955 # on whether this is our first go-round. 956 if self._have_valid_server_list: 957 bui.textwidget(edit=status_text, text='') 958 else: 959 if self._have_server_list_response: 960 bui.textwidget( 961 edit=status_text, 962 text=bui.Lstr(resource='errorText'), 963 ) 964 else: 965 bui.textwidget( 966 edit=status_text, 967 text=bui.Lstr( 968 value='${A}...', 969 subs=[ 970 ( 971 '${A}', 972 bui.Lstr(resource='store.loadingText'), 973 ) 974 ], 975 ), 976 ) 977 978 self._update_party_rows() 979 980 def _update_party_rows(self) -> None: 981 plus = bui.app.plus 982 assert plus is not None 983 984 columnwidget = self._join_list_column 985 if not columnwidget: 986 return 987 988 assert self._join_text 989 assert self._filter_text 990 991 # Janky - allow escaping when there's nothing in our list. 992 assert self._host_scrollwidget 993 bui.containerwidget( 994 edit=self._host_scrollwidget, 995 claims_up_down=(len(self._parties_displayed) > 0), 996 ) 997 bui.textwidget(edit=self._no_servers_found_text, text='') 998 999 # Clip if we have more UI rows than parties to show. 1000 clipcount = len(self._ui_rows) - len(self._parties_displayed) 1001 if clipcount > 0: 1002 clipcount = max(clipcount, 50) 1003 self._ui_rows = self._ui_rows[:-clipcount] 1004 1005 # If we have no parties to show, we're done. 1006 if not self._parties_displayed: 1007 text = self._join_status_text 1008 if ( 1009 plus.get_v1_account_state() == 'signed_in' 1010 and cast(str, bui.textwidget(query=text)) == '' 1011 ): 1012 bui.textwidget( 1013 edit=self._no_servers_found_text, 1014 text=bui.Lstr(resource='noServersFoundText'), 1015 ) 1016 return 1017 1018 sub_scroll_width = 830 1019 lineheight = 42 1020 sub_scroll_height = lineheight * len(self._parties_displayed) + 50 1021 bui.containerwidget( 1022 edit=columnwidget, size=(sub_scroll_width, sub_scroll_height) 1023 ) 1024 1025 # Any time our height changes, reset the refresh back to the top 1026 # so we don't see ugly empty spaces appearing during initial list 1027 # filling. 1028 if sub_scroll_height != self._last_sub_scroll_height: 1029 self._refresh_ui_row = 0 1030 self._last_sub_scroll_height = sub_scroll_height 1031 1032 # Also note that we need to redisplay everything since its pos 1033 # will have changed.. :( 1034 for party in self._parties.values(): 1035 party.clean_display_index = None 1036 1037 # Ew; this rebuilding generates deferred selection callbacks 1038 # so we need to push deferred notices so we know to ignore them. 1039 def refresh_on() -> None: 1040 self._refreshing_list = True 1041 1042 bui.pushcall(refresh_on) 1043 1044 # Ok, now here's the deal: we want to avoid creating/updating this 1045 # entire list at one time because it will lead to hitches. So we 1046 # refresh individual rows quickly in a loop. 1047 rowcount = min(12, len(self._parties_displayed)) 1048 1049 party_vals_displayed = list(self._parties_displayed.values()) 1050 while rowcount > 0: 1051 refresh_row = self._refresh_ui_row % len(self._parties_displayed) 1052 if refresh_row >= len(self._ui_rows): 1053 self._ui_rows.append(UIRow()) 1054 refresh_row = len(self._ui_rows) - 1 1055 1056 # For the first few seconds after getting our first server-list, 1057 # refresh only the top section of the list; this allows the lowest 1058 # ping servers to show up more quickly. 1059 if self._first_valid_server_list_time is not None: 1060 if time.time() - self._first_valid_server_list_time < 4.0: 1061 if refresh_row > 40: 1062 refresh_row = 0 1063 1064 self._ui_rows[refresh_row].update( 1065 refresh_row, 1066 party_vals_displayed[refresh_row], 1067 sub_scroll_width=sub_scroll_width, 1068 sub_scroll_height=sub_scroll_height, 1069 lineheight=lineheight, 1070 columnwidget=columnwidget, 1071 join_text=self._join_text, 1072 existing_selection=self._selection, 1073 filter_text=self._filter_text, 1074 tab=self, 1075 ) 1076 self._refresh_ui_row = refresh_row + 1 1077 rowcount -= 1 1078 1079 # So our selection callbacks can start firing.. 1080 def refresh_off() -> None: 1081 self._refreshing_list = False 1082 1083 bui.pushcall(refresh_off) 1084 1085 def _process_pending_party_infos(self) -> None: 1086 starttime = time.time() 1087 1088 # We want to do this in small enough pieces to not cause UI hitches. 1089 chunksize = 30 1090 parties_in = self._pending_party_infos[:chunksize] 1091 self._pending_party_infos = self._pending_party_infos[chunksize:] 1092 for party_in in parties_in: 1093 addr = party_in['a'] 1094 assert isinstance(addr, str) 1095 port = party_in['p'] 1096 assert isinstance(port, int) 1097 party_key = f'{addr}_{port}' 1098 party = self._parties.get(party_key) 1099 if party is None: 1100 # If this party is new to us, init it. 1101 party = PartyEntry( 1102 address=addr, 1103 next_ping_time=bui.apptime() + 0.001 * party_in['pd'], 1104 index=self._next_entry_index, 1105 ) 1106 self._parties[party_key] = party 1107 self._parties_sorted.append((party_key, party)) 1108 self._party_lists_dirty = True 1109 self._next_entry_index += 1 1110 assert isinstance(party.address, str) 1111 assert isinstance(party.next_ping_time, float) 1112 1113 # Now, new or not, update its values. 1114 party.queue = party_in.get('q') 1115 assert isinstance(party.queue, (str, type(None))) 1116 party.port = port 1117 party.name = party_in['n'] 1118 assert isinstance(party.name, str) 1119 party.size = party_in['s'] 1120 assert isinstance(party.size, int) 1121 party.size_max = party_in['sm'] 1122 assert isinstance(party.size_max, int) 1123 1124 # Server provides this in milliseconds; we use seconds. 1125 party.ping_interval = 0.001 * party_in['pi'] 1126 assert isinstance(party.ping_interval, float) 1127 party.stats_addr = party_in['sa'] 1128 assert isinstance(party.stats_addr, (str, type(None))) 1129 1130 # Make sure the party's UI gets updated. 1131 party.clean_display_index = None 1132 1133 if DEBUG_PROCESSING and parties_in: 1134 print( 1135 f'Processed {len(parties_in)} raw party infos in' 1136 f' {time.time()-starttime:.5f}s.' 1137 ) 1138 1139 def _update_party_lists(self) -> None: 1140 plus = bui.app.plus 1141 assert plus is not None 1142 1143 if not self._party_lists_dirty: 1144 return 1145 starttime = time.time() 1146 assert len(self._parties_sorted) == len(self._parties) 1147 1148 self._parties_sorted.sort( 1149 key=lambda p: ( 1150 p[1].ping if p[1].ping is not None else 999999.0, 1151 p[1].index, 1152 ) 1153 ) 1154 1155 # If signed out or errored, show no parties. 1156 if ( 1157 plus.get_v1_account_state() != 'signed_in' 1158 or not self._have_valid_server_list 1159 ): 1160 self._parties_displayed = {} 1161 else: 1162 if self._filter_value: 1163 filterval = self._filter_value.lower() 1164 self._parties_displayed = { 1165 k: v 1166 for k, v in self._parties_sorted 1167 if filterval in v.name.lower() 1168 } 1169 else: 1170 self._parties_displayed = dict(self._parties_sorted) 1171 1172 # Any time our selection disappears from the displayed list, go back to 1173 # auto-selecting the top entry. 1174 if ( 1175 self._selection is not None 1176 and self._selection.entry_key not in self._parties_displayed 1177 ): 1178 self._have_user_selected_row = False 1179 1180 # Whenever the user hasn't selected something, keep the first visible 1181 # row selected. 1182 if not self._have_user_selected_row and self._parties_displayed: 1183 firstpartykey = next(iter(self._parties_displayed)) 1184 self._selection = Selection(firstpartykey, SelectionComponent.NAME) 1185 1186 self._party_lists_dirty = False 1187 if DEBUG_PROCESSING: 1188 print( 1189 f'Sorted {len(self._parties_sorted)} parties in' 1190 f' {time.time()-starttime:.5f}s.' 1191 ) 1192 1193 def _query_party_list_periodically(self) -> None: 1194 now = bui.apptime() 1195 1196 plus = bui.app.plus 1197 assert plus is not None 1198 1199 # Fire off a new public-party query periodically. 1200 if ( 1201 self._last_server_list_query_time is None 1202 or now - self._last_server_list_query_time 1203 > 0.001 1204 * plus.get_v1_account_misc_read_val('pubPartyRefreshMS', 10000) 1205 ): 1206 self._last_server_list_query_time = now 1207 if DEBUG_SERVER_COMMUNICATION: 1208 print('REQUESTING SERVER LIST') 1209 if plus.get_v1_account_state() == 'signed_in': 1210 plus.add_v1_account_transaction( 1211 { 1212 'type': 'PUBLIC_PARTY_QUERY', 1213 'proto': bs.protocol_version(), 1214 'lang': bui.app.lang.language, 1215 }, 1216 callback=bui.WeakCall(self._on_public_party_query_result), 1217 ) 1218 plus.run_v1_account_transactions() 1219 else: 1220 self._on_public_party_query_result(None) 1221 1222 def _ping_parties_periodically(self) -> None: 1223 assert bui.app.classic is not None 1224 now = bui.apptime() 1225 1226 # Go through our existing public party entries firing off pings 1227 # for any that have timed out. 1228 for party in list(self._parties.values()): 1229 if ( 1230 party.next_ping_time <= now 1231 and bui.app.classic.ping_thread_count < 15 1232 ): 1233 # Crank the interval up for high-latency or non-responding 1234 # parties to save us some useless work. 1235 mult = 1 1236 if party.ping_responses == 0: 1237 if party.ping_attempts > 4: 1238 mult = 10 1239 elif party.ping_attempts > 2: 1240 mult = 5 1241 if party.ping is not None: 1242 mult = ( 1243 10 if party.ping > 300 else 5 if party.ping > 150 else 2 1244 ) 1245 1246 interval = party.ping_interval * mult 1247 if DEBUG_SERVER_COMMUNICATION: 1248 print( 1249 f'pinging #{party.index} cur={party.ping} ' 1250 f'interval={interval} ' 1251 f'({party.ping_responses}/{party.ping_attempts})' 1252 ) 1253 1254 party.next_ping_time = now + party.ping_interval * mult 1255 party.ping_attempts += 1 1256 1257 PingThread( 1258 party.address, party.port, bui.WeakCall(self._ping_callback) 1259 ).start() 1260 1261 def _ping_callback( 1262 self, address: str, port: int | None, result: float | None 1263 ) -> None: 1264 # Look for a widget corresponding to this target. 1265 # If we find one, update our list. 1266 party_key = f'{address}_{port}' 1267 party = self._parties.get(party_key) 1268 if party is not None: 1269 if result is not None: 1270 party.ping_responses += 1 1271 1272 # We now smooth ping a bit to reduce jumping around in the list 1273 # (only where pings are relatively good). 1274 current_ping = party.ping 1275 if current_ping is not None and result is not None and result < 150: 1276 smoothing = 0.7 1277 party.ping = ( 1278 smoothing * current_ping + (1.0 - smoothing) * result 1279 ) 1280 else: 1281 party.ping = result 1282 1283 # Need to re-sort the list and update the row display. 1284 party.clean_display_index = None 1285 self._party_lists_dirty = True 1286 1287 def _fetch_local_addr_cb(self, val: str) -> None: 1288 self._local_address = str(val) 1289 1290 def _on_public_party_accessible_response( 1291 self, data: dict[str, Any] | None 1292 ) -> None: 1293 # If we've got status text widgets, update them. 1294 text = self._host_status_text 1295 if text: 1296 if data is None: 1297 bui.textwidget( 1298 edit=text, 1299 text=bui.Lstr( 1300 resource='gatherWindow.' 'partyStatusNoConnectionText' 1301 ), 1302 color=(1, 0, 0), 1303 ) 1304 else: 1305 if not data.get('accessible', False): 1306 ex_line: str | bui.Lstr 1307 if self._local_address is not None: 1308 ex_line = bui.Lstr( 1309 value='\n${A} ${B}', 1310 subs=[ 1311 ( 1312 '${A}', 1313 bui.Lstr( 1314 resource='gatherWindow.' 1315 'manualYourLocalAddressText' 1316 ), 1317 ), 1318 ('${B}', self._local_address), 1319 ], 1320 ) 1321 else: 1322 ex_line = '' 1323 bui.textwidget( 1324 edit=text, 1325 text=bui.Lstr( 1326 value='${A}\n${B}${C}', 1327 subs=[ 1328 ( 1329 '${A}', 1330 bui.Lstr( 1331 resource='gatherWindow.' 1332 'partyStatusNotJoinableText' 1333 ), 1334 ), 1335 ( 1336 '${B}', 1337 bui.Lstr( 1338 resource='gatherWindow.' 1339 'manualRouterForwardingText', 1340 subs=[ 1341 ( 1342 '${PORT}', 1343 str(bs.get_game_port()), 1344 ) 1345 ], 1346 ), 1347 ), 1348 ('${C}', ex_line), 1349 ], 1350 ), 1351 color=(1, 0, 0), 1352 ) 1353 else: 1354 bui.textwidget( 1355 edit=text, 1356 text=bui.Lstr( 1357 resource='gatherWindow.' 'partyStatusJoinableText' 1358 ), 1359 color=(0, 1, 0), 1360 ) 1361 1362 def _do_status_check(self) -> None: 1363 assert bui.app.classic is not None 1364 bui.textwidget( 1365 edit=self._host_status_text, 1366 color=(1, 1, 0), 1367 text=bui.Lstr(resource='gatherWindow.' 'partyStatusCheckingText'), 1368 ) 1369 bui.app.classic.master_server_v1_get( 1370 'bsAccessCheck', 1371 {'b': bui.app.env.engine_build_number}, 1372 callback=bui.WeakCall(self._on_public_party_accessible_response), 1373 ) 1374 1375 def _on_start_advertizing_press(self) -> None: 1376 from bauiv1lib.account import show_sign_in_prompt 1377 1378 plus = bui.app.plus 1379 assert plus is not None 1380 1381 if plus.get_v1_account_state() != 'signed_in': 1382 show_sign_in_prompt() 1383 return 1384 1385 name = cast(str, bui.textwidget(query=self._host_name_text)) 1386 if name == '': 1387 bui.screenmessage( 1388 bui.Lstr(resource='internal.invalidNameErrorText'), 1389 color=(1, 0, 0), 1390 ) 1391 bui.getsound('error').play() 1392 return 1393 bs.set_public_party_name(name) 1394 cfg = bui.app.config 1395 cfg['Public Party Name'] = name 1396 cfg.commit() 1397 bui.getsound('shieldUp').play() 1398 bs.set_public_party_enabled(True) 1399 1400 # In GUI builds we want to authenticate clients only when hosting 1401 # public parties. 1402 bs.set_authenticate_clients(True) 1403 1404 self._do_status_check() 1405 bui.buttonwidget( 1406 edit=self._host_toggle_button, 1407 label=bui.Lstr( 1408 resource='gatherWindow.makePartyPrivateText', 1409 fallback_resource='gatherWindow.stopAdvertisingText', 1410 ), 1411 on_activate_call=self._on_stop_advertising_press, 1412 ) 1413 1414 def _on_stop_advertising_press(self) -> None: 1415 bs.set_public_party_enabled(False) 1416 1417 # In GUI builds we want to authenticate clients only when hosting 1418 # public parties. 1419 bs.set_authenticate_clients(False) 1420 bui.getsound('shieldDown').play() 1421 text = self._host_status_text 1422 if text: 1423 bui.textwidget( 1424 edit=text, 1425 text=bui.Lstr( 1426 resource='gatherWindow.' 'partyStatusNotPublicText' 1427 ), 1428 color=(0.6, 0.6, 0.6), 1429 ) 1430 bui.buttonwidget( 1431 edit=self._host_toggle_button, 1432 label=bui.Lstr( 1433 resource='gatherWindow.makePartyPublicText', 1434 fallback_resource='gatherWindow.startAdvertisingText', 1435 ), 1436 on_activate_call=self._on_start_advertizing_press, 1437 ) 1438 1439 def on_public_party_activate(self, party: PartyEntry) -> None: 1440 """Called when a party is clicked or otherwise activated.""" 1441 self.save_state() 1442 if party.queue is not None: 1443 from bauiv1lib.partyqueue import PartyQueueWindow 1444 1445 bui.getsound('swish').play() 1446 PartyQueueWindow(party.queue, party.address, party.port) 1447 else: 1448 address = party.address 1449 port = party.port 1450 1451 # Rate limit this a bit. 1452 now = time.time() 1453 last_connect_time = self._last_connect_attempt_time 1454 if last_connect_time is None or now - last_connect_time > 2.0: 1455 bs.connect_to_party(address, port=port) 1456 self._last_connect_attempt_time = now 1457 1458 def set_public_party_selection(self, sel: Selection) -> None: 1459 """Set the sel.""" 1460 if self._refreshing_list: 1461 return 1462 self._selection = sel 1463 self._have_user_selected_row = True 1464 1465 def _on_max_public_party_size_minus_press(self) -> None: 1466 val = max(1, bs.get_public_party_max_size() - 1) 1467 bs.set_public_party_max_size(val) 1468 bui.textwidget(edit=self._host_max_party_size_value, text=str(val)) 1469 1470 def _on_max_public_party_size_plus_press(self) -> None: 1471 val = bs.get_public_party_max_size() 1472 val += 1 1473 bs.set_public_party_max_size(val) 1474 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=(c_width * 0.5 - 150, 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=(c_width * 0.5 - 170, 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=((c_width - sub_scroll_width) * 0.5 + 50, 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=( 626 c_width * 0.5 + sub_scroll_width * 0.5 - 110, 627 v - 8, 628 ), 629 maxwidth=60, 630 scale=0.6, 631 color=(0.5, 0.46, 0.5), 632 flatness=1.0, 633 h_align='center', 634 v_align='center', 635 ) 636 bui.textwidget( 637 text=bui.Lstr(resource='gatherWindow.pingText'), 638 parent=self._container, 639 size=(0, 0), 640 position=( 641 c_width * 0.5 + sub_scroll_width * 0.5 - 30, 642 v - 8, 643 ), 644 maxwidth=60, 645 scale=0.6, 646 color=(0.5, 0.46, 0.5), 647 flatness=1.0, 648 h_align='center', 649 v_align='center', 650 ) 651 v -= sub_scroll_height + 23 652 self._host_scrollwidget = scrollw = bui.scrollwidget( 653 parent=self._container, 654 simple_culling_v=10, 655 position=((c_width - sub_scroll_width) * 0.5, v), 656 size=(sub_scroll_width, sub_scroll_height), 657 claims_up_down=False, 658 claims_left_right=True, 659 autoselect=True, 660 ) 661 self._join_list_column = bui.containerwidget( 662 parent=scrollw, 663 background=False, 664 size=(400, 400), 665 claims_left_right=True, 666 ) 667 self._join_status_text = bui.textwidget( 668 parent=self._container, 669 text='', 670 size=(0, 0), 671 scale=0.9, 672 flatness=1.0, 673 shadow=0.0, 674 h_align='center', 675 v_align='top', 676 maxwidth=c_width, 677 color=(0.6, 0.6, 0.6), 678 position=(c_width * 0.5, c_height * 0.5), 679 ) 680 self._no_servers_found_text = bui.textwidget( 681 parent=self._container, 682 text='', 683 size=(0, 0), 684 scale=0.9, 685 flatness=1.0, 686 shadow=0.0, 687 h_align='center', 688 v_align='top', 689 color=(0.6, 0.6, 0.6), 690 position=(c_width * 0.5, c_height * 0.5), 691 ) 692 693 def _build_host_tab( 694 self, region_width: float, region_height: float 695 ) -> None: 696 c_width = region_width 697 c_height = region_height - 20 698 v = c_height - 35 699 v -= 25 700 is_public_enabled = bs.get_public_party_enabled() 701 v -= 30 702 703 bui.textwidget( 704 parent=self._container, 705 size=(0, 0), 706 h_align='center', 707 v_align='center', 708 maxwidth=c_width * 0.9, 709 scale=0.7, 710 flatness=1.0, 711 color=(0.5, 0.46, 0.5), 712 position=(region_width * 0.5, v + 10), 713 text=bui.Lstr(resource='gatherWindow.publicHostRouterConfigText'), 714 ) 715 v -= 30 716 717 party_name_text = bui.Lstr( 718 resource='gatherWindow.partyNameText', 719 fallback_resource='editGameListWindow.nameText', 720 ) 721 assert bui.app.classic is not None 722 bui.textwidget( 723 parent=self._container, 724 size=(0, 0), 725 h_align='right', 726 v_align='center', 727 maxwidth=200, 728 scale=0.8, 729 color=bui.app.ui_v1.infotextcolor, 730 position=(210, v - 9), 731 text=party_name_text, 732 ) 733 self._host_name_text = bui.textwidget( 734 parent=self._container, 735 editable=True, 736 size=(535, 40), 737 position=(230, v - 30), 738 text=bui.app.config.get('Public Party Name', ''), 739 maxwidth=494, 740 shadow=0.3, 741 flatness=1.0, 742 description=party_name_text, 743 autoselect=True, 744 v_align='center', 745 corner_scale=1.0, 746 ) 747 748 v -= 60 749 bui.textwidget( 750 parent=self._container, 751 size=(0, 0), 752 h_align='right', 753 v_align='center', 754 maxwidth=200, 755 scale=0.8, 756 color=bui.app.ui_v1.infotextcolor, 757 position=(210, v - 9), 758 text=bui.Lstr( 759 resource='maxPartySizeText', 760 fallback_resource='maxConnectionsText', 761 ), 762 ) 763 self._host_max_party_size_value = bui.textwidget( 764 parent=self._container, 765 size=(0, 0), 766 h_align='center', 767 v_align='center', 768 scale=1.2, 769 color=(1, 1, 1), 770 position=(240, v - 9), 771 text=str(bs.get_public_party_max_size()), 772 ) 773 btn1 = self._host_max_party_size_minus_button = bui.buttonwidget( 774 parent=self._container, 775 size=(40, 40), 776 on_activate_call=bui.WeakCall( 777 self._on_max_public_party_size_minus_press 778 ), 779 position=(280, v - 26), 780 label='-', 781 autoselect=True, 782 ) 783 btn2 = self._host_max_party_size_plus_button = bui.buttonwidget( 784 parent=self._container, 785 size=(40, 40), 786 on_activate_call=bui.WeakCall( 787 self._on_max_public_party_size_plus_press 788 ), 789 position=(350, v - 26), 790 label='+', 791 autoselect=True, 792 ) 793 v -= 50 794 v -= 70 795 if is_public_enabled: 796 label = bui.Lstr( 797 resource='gatherWindow.makePartyPrivateText', 798 fallback_resource='gatherWindow.stopAdvertisingText', 799 ) 800 else: 801 label = bui.Lstr( 802 resource='gatherWindow.makePartyPublicText', 803 fallback_resource='gatherWindow.startAdvertisingText', 804 ) 805 self._host_toggle_button = bui.buttonwidget( 806 parent=self._container, 807 label=label, 808 size=(400, 80), 809 on_activate_call=( 810 self._on_stop_advertising_press 811 if is_public_enabled 812 else self._on_start_advertizing_press 813 ), 814 position=(c_width * 0.5 - 200, v), 815 autoselect=True, 816 up_widget=btn2, 817 ) 818 bui.widget(edit=self._host_name_text, down_widget=btn2) 819 bui.widget(edit=btn2, up_widget=self._host_name_text) 820 bui.widget(edit=btn1, up_widget=self._host_name_text) 821 assert self._join_text is not None 822 bui.widget(edit=self._join_text, down_widget=self._host_name_text) 823 v -= 10 824 self._host_status_text = bui.textwidget( 825 parent=self._container, 826 text=bui.Lstr(resource='gatherWindow.' 'partyStatusNotPublicText'), 827 size=(0, 0), 828 scale=0.7, 829 flatness=1.0, 830 h_align='center', 831 v_align='top', 832 maxwidth=c_width * 0.9, 833 color=(0.6, 0.56, 0.6), 834 position=(c_width * 0.5, v), 835 ) 836 v -= 90 837 bui.textwidget( 838 parent=self._container, 839 text=bui.Lstr(resource='gatherWindow.dedicatedServerInfoText'), 840 size=(0, 0), 841 scale=0.7, 842 flatness=1.0, 843 h_align='center', 844 v_align='center', 845 maxwidth=c_width * 0.9, 846 color=(0.5, 0.46, 0.5), 847 position=(c_width * 0.5, v), 848 ) 849 850 # If public sharing is already on, 851 # launch a status-check immediately. 852 if bs.get_public_party_enabled(): 853 self._do_status_check() 854 855 def _on_public_party_query_result( 856 self, result: dict[str, Any] | None 857 ) -> None: 858 starttime = time.time() 859 self._have_server_list_response = True 860 861 if result is None: 862 self._have_valid_server_list = False 863 return 864 865 if not self._have_valid_server_list: 866 self._first_valid_server_list_time = time.time() 867 868 self._have_valid_server_list = True 869 parties_in = result['l'] 870 871 assert isinstance(parties_in, list) 872 self._pending_party_infos += parties_in 873 874 # To avoid causing a stutter here, we do most processing of 875 # these entries incrementally in our _update() method. 876 # The one thing we do here is prune parties not contained in 877 # this result. 878 for partyval in list(self._parties.values()): 879 partyval.claimed = False 880 for party_in in parties_in: 881 addr = party_in['a'] 882 assert isinstance(addr, str) 883 port = party_in['p'] 884 assert isinstance(port, int) 885 party_key = f'{addr}_{port}' 886 party = self._parties.get(party_key) 887 if party is not None: 888 party.claimed = True 889 self._parties = { 890 key: val for key, val in list(self._parties.items()) if val.claimed 891 } 892 self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed] 893 self._party_lists_dirty = True 894 895 # self._update_server_list() 896 if DEBUG_PROCESSING: 897 print( 898 f'Handled public party query results in ' 899 f'{time.time()-starttime:.5f}s.' 900 ) 901 902 def _update(self) -> None: 903 """Periodic updating.""" 904 905 plus = bui.app.plus 906 assert plus is not None 907 908 if self._sub_tab is SubTabType.JOIN: 909 # Keep our filter-text up to date from the UI. 910 text = self._filter_text 911 if text: 912 filter_value = cast(str, bui.textwidget(query=text)) 913 if filter_value != self._filter_value: 914 self._filter_value = filter_value 915 self._party_lists_dirty = True 916 917 # Also wipe out party clean-row states. 918 # (otherwise if a party disappears from a row due to 919 # filtering and then reappears on that same row when 920 # the filter is removed it may not update) 921 for party in self._parties.values(): 922 party.clean_display_index = None 923 924 self._query_party_list_periodically() 925 self._ping_parties_periodically() 926 927 # If any new party infos have come in, apply some of them. 928 self._process_pending_party_infos() 929 930 # Anytime we sign in/out, make sure we refresh our list. 931 signed_in = plus.get_v1_account_state() == 'signed_in' 932 if self._signed_in != signed_in: 933 self._signed_in = signed_in 934 self._party_lists_dirty = True 935 936 # Update sorting to account for ping updates, new parties, etc. 937 self._update_party_lists() 938 939 # If we've got a party-name text widget, keep its value plugged 940 # into our public host name. 941 text = self._host_name_text 942 if text: 943 name = cast(str, bui.textwidget(query=self._host_name_text)) 944 bs.set_public_party_name(name) 945 946 # Update status text. 947 status_text = self._join_status_text 948 if status_text: 949 if not signed_in: 950 bui.textwidget( 951 edit=status_text, text=bui.Lstr(resource='notSignedInText') 952 ) 953 else: 954 # If we have a valid list, show no status; just the list. 955 # Otherwise show either 'loading...' or 'error' depending 956 # on whether this is our first go-round. 957 if self._have_valid_server_list: 958 bui.textwidget(edit=status_text, text='') 959 else: 960 if self._have_server_list_response: 961 bui.textwidget( 962 edit=status_text, 963 text=bui.Lstr(resource='errorText'), 964 ) 965 else: 966 bui.textwidget( 967 edit=status_text, 968 text=bui.Lstr( 969 value='${A}...', 970 subs=[ 971 ( 972 '${A}', 973 bui.Lstr(resource='store.loadingText'), 974 ) 975 ], 976 ), 977 ) 978 979 self._update_party_rows() 980 981 def _update_party_rows(self) -> None: 982 plus = bui.app.plus 983 assert plus is not None 984 985 columnwidget = self._join_list_column 986 if not columnwidget: 987 return 988 989 assert self._join_text 990 assert self._filter_text 991 992 # Janky - allow escaping when there's nothing in our list. 993 assert self._host_scrollwidget 994 bui.containerwidget( 995 edit=self._host_scrollwidget, 996 claims_up_down=(len(self._parties_displayed) > 0), 997 ) 998 bui.textwidget(edit=self._no_servers_found_text, text='') 999 1000 # Clip if we have more UI rows than parties to show. 1001 clipcount = len(self._ui_rows) - len(self._parties_displayed) 1002 if clipcount > 0: 1003 clipcount = max(clipcount, 50) 1004 self._ui_rows = self._ui_rows[:-clipcount] 1005 1006 # If we have no parties to show, we're done. 1007 if not self._parties_displayed: 1008 text = self._join_status_text 1009 if ( 1010 plus.get_v1_account_state() == 'signed_in' 1011 and cast(str, bui.textwidget(query=text)) == '' 1012 ): 1013 bui.textwidget( 1014 edit=self._no_servers_found_text, 1015 text=bui.Lstr(resource='noServersFoundText'), 1016 ) 1017 return 1018 1019 sub_scroll_width = 830 1020 lineheight = 42 1021 sub_scroll_height = lineheight * len(self._parties_displayed) + 50 1022 bui.containerwidget( 1023 edit=columnwidget, size=(sub_scroll_width, sub_scroll_height) 1024 ) 1025 1026 # Any time our height changes, reset the refresh back to the top 1027 # so we don't see ugly empty spaces appearing during initial list 1028 # filling. 1029 if sub_scroll_height != self._last_sub_scroll_height: 1030 self._refresh_ui_row = 0 1031 self._last_sub_scroll_height = sub_scroll_height 1032 1033 # Also note that we need to redisplay everything since its pos 1034 # will have changed.. :( 1035 for party in self._parties.values(): 1036 party.clean_display_index = None 1037 1038 # Ew; this rebuilding generates deferred selection callbacks 1039 # so we need to push deferred notices so we know to ignore them. 1040 def refresh_on() -> None: 1041 self._refreshing_list = True 1042 1043 bui.pushcall(refresh_on) 1044 1045 # Ok, now here's the deal: we want to avoid creating/updating this 1046 # entire list at one time because it will lead to hitches. So we 1047 # refresh individual rows quickly in a loop. 1048 rowcount = min(12, len(self._parties_displayed)) 1049 1050 party_vals_displayed = list(self._parties_displayed.values()) 1051 while rowcount > 0: 1052 refresh_row = self._refresh_ui_row % len(self._parties_displayed) 1053 if refresh_row >= len(self._ui_rows): 1054 self._ui_rows.append(UIRow()) 1055 refresh_row = len(self._ui_rows) - 1 1056 1057 # For the first few seconds after getting our first server-list, 1058 # refresh only the top section of the list; this allows the lowest 1059 # ping servers to show up more quickly. 1060 if self._first_valid_server_list_time is not None: 1061 if time.time() - self._first_valid_server_list_time < 4.0: 1062 if refresh_row > 40: 1063 refresh_row = 0 1064 1065 self._ui_rows[refresh_row].update( 1066 refresh_row, 1067 party_vals_displayed[refresh_row], 1068 sub_scroll_width=sub_scroll_width, 1069 sub_scroll_height=sub_scroll_height, 1070 lineheight=lineheight, 1071 columnwidget=columnwidget, 1072 join_text=self._join_text, 1073 existing_selection=self._selection, 1074 filter_text=self._filter_text, 1075 tab=self, 1076 ) 1077 self._refresh_ui_row = refresh_row + 1 1078 rowcount -= 1 1079 1080 # So our selection callbacks can start firing.. 1081 def refresh_off() -> None: 1082 self._refreshing_list = False 1083 1084 bui.pushcall(refresh_off) 1085 1086 def _process_pending_party_infos(self) -> None: 1087 starttime = time.time() 1088 1089 # We want to do this in small enough pieces to not cause UI hitches. 1090 chunksize = 30 1091 parties_in = self._pending_party_infos[:chunksize] 1092 self._pending_party_infos = self._pending_party_infos[chunksize:] 1093 for party_in in parties_in: 1094 addr = party_in['a'] 1095 assert isinstance(addr, str) 1096 port = party_in['p'] 1097 assert isinstance(port, int) 1098 party_key = f'{addr}_{port}' 1099 party = self._parties.get(party_key) 1100 if party is None: 1101 # If this party is new to us, init it. 1102 party = PartyEntry( 1103 address=addr, 1104 next_ping_time=bui.apptime() + 0.001 * party_in['pd'], 1105 index=self._next_entry_index, 1106 ) 1107 self._parties[party_key] = party 1108 self._parties_sorted.append((party_key, party)) 1109 self._party_lists_dirty = True 1110 self._next_entry_index += 1 1111 assert isinstance(party.address, str) 1112 assert isinstance(party.next_ping_time, float) 1113 1114 # Now, new or not, update its values. 1115 party.queue = party_in.get('q') 1116 assert isinstance(party.queue, (str, type(None))) 1117 party.port = port 1118 party.name = party_in['n'] 1119 assert isinstance(party.name, str) 1120 party.size = party_in['s'] 1121 assert isinstance(party.size, int) 1122 party.size_max = party_in['sm'] 1123 assert isinstance(party.size_max, int) 1124 1125 # Server provides this in milliseconds; we use seconds. 1126 party.ping_interval = 0.001 * party_in['pi'] 1127 assert isinstance(party.ping_interval, float) 1128 party.stats_addr = party_in['sa'] 1129 assert isinstance(party.stats_addr, (str, type(None))) 1130 1131 # Make sure the party's UI gets updated. 1132 party.clean_display_index = None 1133 1134 if DEBUG_PROCESSING and parties_in: 1135 print( 1136 f'Processed {len(parties_in)} raw party infos in' 1137 f' {time.time()-starttime:.5f}s.' 1138 ) 1139 1140 def _update_party_lists(self) -> None: 1141 plus = bui.app.plus 1142 assert plus is not None 1143 1144 if not self._party_lists_dirty: 1145 return 1146 starttime = time.time() 1147 assert len(self._parties_sorted) == len(self._parties) 1148 1149 self._parties_sorted.sort( 1150 key=lambda p: ( 1151 p[1].ping if p[1].ping is not None else 999999.0, 1152 p[1].index, 1153 ) 1154 ) 1155 1156 # If signed out or errored, show no parties. 1157 if ( 1158 plus.get_v1_account_state() != 'signed_in' 1159 or not self._have_valid_server_list 1160 ): 1161 self._parties_displayed = {} 1162 else: 1163 if self._filter_value: 1164 filterval = self._filter_value.lower() 1165 self._parties_displayed = { 1166 k: v 1167 for k, v in self._parties_sorted 1168 if filterval in v.name.lower() 1169 } 1170 else: 1171 self._parties_displayed = dict(self._parties_sorted) 1172 1173 # Any time our selection disappears from the displayed list, go back to 1174 # auto-selecting the top entry. 1175 if ( 1176 self._selection is not None 1177 and self._selection.entry_key not in self._parties_displayed 1178 ): 1179 self._have_user_selected_row = False 1180 1181 # Whenever the user hasn't selected something, keep the first visible 1182 # row selected. 1183 if not self._have_user_selected_row and self._parties_displayed: 1184 firstpartykey = next(iter(self._parties_displayed)) 1185 self._selection = Selection(firstpartykey, SelectionComponent.NAME) 1186 1187 self._party_lists_dirty = False 1188 if DEBUG_PROCESSING: 1189 print( 1190 f'Sorted {len(self._parties_sorted)} parties in' 1191 f' {time.time()-starttime:.5f}s.' 1192 ) 1193 1194 def _query_party_list_periodically(self) -> None: 1195 now = bui.apptime() 1196 1197 plus = bui.app.plus 1198 assert plus is not None 1199 1200 # Fire off a new public-party query periodically. 1201 if ( 1202 self._last_server_list_query_time is None 1203 or now - self._last_server_list_query_time 1204 > 0.001 1205 * plus.get_v1_account_misc_read_val('pubPartyRefreshMS', 10000) 1206 ): 1207 self._last_server_list_query_time = now 1208 if DEBUG_SERVER_COMMUNICATION: 1209 print('REQUESTING SERVER LIST') 1210 if plus.get_v1_account_state() == 'signed_in': 1211 plus.add_v1_account_transaction( 1212 { 1213 'type': 'PUBLIC_PARTY_QUERY', 1214 'proto': bs.protocol_version(), 1215 'lang': bui.app.lang.language, 1216 }, 1217 callback=bui.WeakCall(self._on_public_party_query_result), 1218 ) 1219 plus.run_v1_account_transactions() 1220 else: 1221 self._on_public_party_query_result(None) 1222 1223 def _ping_parties_periodically(self) -> None: 1224 assert bui.app.classic is not None 1225 now = bui.apptime() 1226 1227 # Go through our existing public party entries firing off pings 1228 # for any that have timed out. 1229 for party in list(self._parties.values()): 1230 if ( 1231 party.next_ping_time <= now 1232 and bui.app.classic.ping_thread_count < 15 1233 ): 1234 # Crank the interval up for high-latency or non-responding 1235 # parties to save us some useless work. 1236 mult = 1 1237 if party.ping_responses == 0: 1238 if party.ping_attempts > 4: 1239 mult = 10 1240 elif party.ping_attempts > 2: 1241 mult = 5 1242 if party.ping is not None: 1243 mult = ( 1244 10 if party.ping > 300 else 5 if party.ping > 150 else 2 1245 ) 1246 1247 interval = party.ping_interval * mult 1248 if DEBUG_SERVER_COMMUNICATION: 1249 print( 1250 f'pinging #{party.index} cur={party.ping} ' 1251 f'interval={interval} ' 1252 f'({party.ping_responses}/{party.ping_attempts})' 1253 ) 1254 1255 party.next_ping_time = now + party.ping_interval * mult 1256 party.ping_attempts += 1 1257 1258 PingThread( 1259 party.address, party.port, bui.WeakCall(self._ping_callback) 1260 ).start() 1261 1262 def _ping_callback( 1263 self, address: str, port: int | None, result: float | None 1264 ) -> None: 1265 # Look for a widget corresponding to this target. 1266 # If we find one, update our list. 1267 party_key = f'{address}_{port}' 1268 party = self._parties.get(party_key) 1269 if party is not None: 1270 if result is not None: 1271 party.ping_responses += 1 1272 1273 # We now smooth ping a bit to reduce jumping around in the list 1274 # (only where pings are relatively good). 1275 current_ping = party.ping 1276 if current_ping is not None and result is not None and result < 150: 1277 smoothing = 0.7 1278 party.ping = ( 1279 smoothing * current_ping + (1.0 - smoothing) * result 1280 ) 1281 else: 1282 party.ping = result 1283 1284 # Need to re-sort the list and update the row display. 1285 party.clean_display_index = None 1286 self._party_lists_dirty = True 1287 1288 def _fetch_local_addr_cb(self, val: str) -> None: 1289 self._local_address = str(val) 1290 1291 def _on_public_party_accessible_response( 1292 self, data: dict[str, Any] | None 1293 ) -> None: 1294 # If we've got status text widgets, update them. 1295 text = self._host_status_text 1296 if text: 1297 if data is None: 1298 bui.textwidget( 1299 edit=text, 1300 text=bui.Lstr( 1301 resource='gatherWindow.' 'partyStatusNoConnectionText' 1302 ), 1303 color=(1, 0, 0), 1304 ) 1305 else: 1306 if not data.get('accessible', False): 1307 ex_line: str | bui.Lstr 1308 if self._local_address is not None: 1309 ex_line = bui.Lstr( 1310 value='\n${A} ${B}', 1311 subs=[ 1312 ( 1313 '${A}', 1314 bui.Lstr( 1315 resource='gatherWindow.' 1316 'manualYourLocalAddressText' 1317 ), 1318 ), 1319 ('${B}', self._local_address), 1320 ], 1321 ) 1322 else: 1323 ex_line = '' 1324 bui.textwidget( 1325 edit=text, 1326 text=bui.Lstr( 1327 value='${A}\n${B}${C}', 1328 subs=[ 1329 ( 1330 '${A}', 1331 bui.Lstr( 1332 resource='gatherWindow.' 1333 'partyStatusNotJoinableText' 1334 ), 1335 ), 1336 ( 1337 '${B}', 1338 bui.Lstr( 1339 resource='gatherWindow.' 1340 'manualRouterForwardingText', 1341 subs=[ 1342 ( 1343 '${PORT}', 1344 str(bs.get_game_port()), 1345 ) 1346 ], 1347 ), 1348 ), 1349 ('${C}', ex_line), 1350 ], 1351 ), 1352 color=(1, 0, 0), 1353 ) 1354 else: 1355 bui.textwidget( 1356 edit=text, 1357 text=bui.Lstr( 1358 resource='gatherWindow.' 'partyStatusJoinableText' 1359 ), 1360 color=(0, 1, 0), 1361 ) 1362 1363 def _do_status_check(self) -> None: 1364 assert bui.app.classic is not None 1365 bui.textwidget( 1366 edit=self._host_status_text, 1367 color=(1, 1, 0), 1368 text=bui.Lstr(resource='gatherWindow.' 'partyStatusCheckingText'), 1369 ) 1370 bui.app.classic.master_server_v1_get( 1371 'bsAccessCheck', 1372 {'b': bui.app.env.engine_build_number}, 1373 callback=bui.WeakCall(self._on_public_party_accessible_response), 1374 ) 1375 1376 def _on_start_advertizing_press(self) -> None: 1377 from bauiv1lib.account import show_sign_in_prompt 1378 1379 plus = bui.app.plus 1380 assert plus is not None 1381 1382 if plus.get_v1_account_state() != 'signed_in': 1383 show_sign_in_prompt() 1384 return 1385 1386 name = cast(str, bui.textwidget(query=self._host_name_text)) 1387 if name == '': 1388 bui.screenmessage( 1389 bui.Lstr(resource='internal.invalidNameErrorText'), 1390 color=(1, 0, 0), 1391 ) 1392 bui.getsound('error').play() 1393 return 1394 bs.set_public_party_name(name) 1395 cfg = bui.app.config 1396 cfg['Public Party Name'] = name 1397 cfg.commit() 1398 bui.getsound('shieldUp').play() 1399 bs.set_public_party_enabled(True) 1400 1401 # In GUI builds we want to authenticate clients only when hosting 1402 # public parties. 1403 bs.set_authenticate_clients(True) 1404 1405 self._do_status_check() 1406 bui.buttonwidget( 1407 edit=self._host_toggle_button, 1408 label=bui.Lstr( 1409 resource='gatherWindow.makePartyPrivateText', 1410 fallback_resource='gatherWindow.stopAdvertisingText', 1411 ), 1412 on_activate_call=self._on_stop_advertising_press, 1413 ) 1414 1415 def _on_stop_advertising_press(self) -> None: 1416 bs.set_public_party_enabled(False) 1417 1418 # In GUI builds we want to authenticate clients only when hosting 1419 # public parties. 1420 bs.set_authenticate_clients(False) 1421 bui.getsound('shieldDown').play() 1422 text = self._host_status_text 1423 if text: 1424 bui.textwidget( 1425 edit=text, 1426 text=bui.Lstr( 1427 resource='gatherWindow.' 'partyStatusNotPublicText' 1428 ), 1429 color=(0.6, 0.6, 0.6), 1430 ) 1431 bui.buttonwidget( 1432 edit=self._host_toggle_button, 1433 label=bui.Lstr( 1434 resource='gatherWindow.makePartyPublicText', 1435 fallback_resource='gatherWindow.startAdvertisingText', 1436 ), 1437 on_activate_call=self._on_start_advertizing_press, 1438 ) 1439 1440 def on_public_party_activate(self, party: PartyEntry) -> None: 1441 """Called when a party is clicked or otherwise activated.""" 1442 self.save_state() 1443 if party.queue is not None: 1444 from bauiv1lib.partyqueue import PartyQueueWindow 1445 1446 bui.getsound('swish').play() 1447 PartyQueueWindow(party.queue, party.address, party.port) 1448 else: 1449 address = party.address 1450 port = party.port 1451 1452 # Rate limit this a bit. 1453 now = time.time() 1454 last_connect_time = self._last_connect_attempt_time 1455 if last_connect_time is None or now - last_connect_time > 2.0: 1456 bs.connect_to_party(address, port=port) 1457 self._last_connect_attempt_time = now 1458 1459 def set_public_party_selection(self, sel: Selection) -> None: 1460 """Set the sel.""" 1461 if self._refreshing_list: 1462 return 1463 self._selection = sel 1464 self._have_user_selected_row = True 1465 1466 def _on_max_public_party_size_minus_press(self) -> None: 1467 val = max(1, bs.get_public_party_max_size() - 1) 1468 bs.set_public_party_max_size(val) 1469 bui.textwidget(edit=self._host_max_party_size_value, text=str(val)) 1470 1471 def _on_max_public_party_size_plus_press(self) -> None: 1472 val = bs.get_public_party_max_size() 1473 val += 1 1474 bs.set_public_party_max_size(val) 1475 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.
1440 def on_public_party_activate(self, party: PartyEntry) -> None: 1441 """Called when a party is clicked or otherwise activated.""" 1442 self.save_state() 1443 if party.queue is not None: 1444 from bauiv1lib.partyqueue import PartyQueueWindow 1445 1446 bui.getsound('swish').play() 1447 PartyQueueWindow(party.queue, party.address, party.port) 1448 else: 1449 address = party.address 1450 port = party.port 1451 1452 # Rate limit this a bit. 1453 now = time.time() 1454 last_connect_time = self._last_connect_attempt_time 1455 if last_connect_time is None or now - last_connect_time > 2.0: 1456 bs.connect_to_party(address, port=port) 1457 self._last_connect_attempt_time = now
Called when a party is clicked or otherwise activated.
1459 def set_public_party_selection(self, sel: Selection) -> None: 1460 """Set the sel.""" 1461 if self._refreshing_list: 1462 return 1463 self._selection = sel 1464 self._have_user_selected_row = True
Set the sel.