bauiv1lib.gather.privatetab
Defines the Private 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 Private tab in the gather UI.""" 5 6from __future__ import annotations 7 8import os 9import copy 10import time 11import logging 12from enum import Enum 13from dataclasses import dataclass 14from typing import TYPE_CHECKING, cast, override 15 16from efro.error import CommunicationError 17from efro.dataclassio import dataclass_from_dict, dataclass_to_dict 18import bacommon.cloud 19from bacommon.net import ( 20 PrivateHostingState, 21 PrivateHostingConfig, 22 PrivatePartyConnectResult, 23) 24from bauiv1lib.gather import GatherTab 25 26from bauiv1lib.gettokens import GetTokensWindow, show_get_tokens_prompt 27import bascenev1 as bs 28import bauiv1 as bui 29 30if TYPE_CHECKING: 31 from typing import Any 32 33 from bauiv1lib.gather import GatherWindow 34 35 36# Print a bit of info about queries, etc. 37DEBUG_SERVER_COMMUNICATION = os.environ.get('BA_DEBUG_PPTABCOM') == '1' 38 39 40class SubTabType(Enum): 41 """Available sub-tabs.""" 42 43 JOIN = 'join' 44 HOST = 'host' 45 46 47@dataclass 48class State: 49 """Our core state that persists while the app is running.""" 50 51 sub_tab: SubTabType = SubTabType.JOIN 52 53 54class PrivateGatherTab(GatherTab): 55 """The private tab in the gather UI""" 56 57 def __init__(self, window: GatherWindow) -> None: 58 super().__init__(window) 59 self._container: bui.Widget | None = None 60 self._state: State = State() 61 self._last_datacode_refresh_time: float | None = None 62 self._hostingstate = PrivateHostingState() 63 self._v2state: bacommon.cloud.BSPrivatePartyResponse | None = None 64 self._join_sub_tab_text: bui.Widget | None = None 65 self._host_sub_tab_text: bui.Widget | None = None 66 self._update_timer: bui.AppTimer | None = None 67 self._join_party_code_text: bui.Widget | None = None 68 self._c_width: float = 0.0 69 self._c_height: float = 0.0 70 self._last_hosting_state_query_time: float | None = None 71 self._last_v2_state_query_time: float | None = None 72 self._waiting_for_initial_state = True 73 self._waiting_for_start_stop_response = True 74 self._host_playlist_button: bui.Widget | None = None 75 self._host_copy_button: bui.Widget | None = None 76 self._host_connect_button: bui.Widget | None = None 77 self._host_start_stop_button: bui.Widget | None = None 78 self._get_tokens_button: bui.Widget | None = None 79 self._token_count_text: bui.Widget | None = None 80 self._showing_not_signed_in_screen = False 81 self._create_time = time.time() 82 self._last_action_send_time: float | None = None 83 self._connect_press_time: float | None = None 84 try: 85 self._hostingconfig = self._build_hosting_config() 86 except Exception: 87 logging.exception('Error building hosting config.') 88 self._hostingconfig = PrivateHostingConfig() 89 90 @override 91 def on_activate( 92 self, 93 parent_widget: bui.Widget, 94 tab_button: bui.Widget, 95 region_width: float, 96 region_height: float, 97 region_left: float, 98 region_bottom: float, 99 ) -> bui.Widget: 100 self._c_width = region_width 101 self._c_height = region_height - 20 102 self._container = bui.containerwidget( 103 parent=parent_widget, 104 position=( 105 region_left, 106 region_bottom + (region_height - self._c_height) * 0.5, 107 ), 108 size=(self._c_width, self._c_height), 109 background=False, 110 selection_loops_to_parent=True, 111 ) 112 v = self._c_height - 30.0 113 self._join_sub_tab_text = bui.textwidget( 114 parent=self._container, 115 position=(self._c_width * 0.5 - 245, v - 13), 116 color=(0.6, 1.0, 0.6), 117 scale=1.3, 118 size=(200, 30), 119 maxwidth=250, 120 h_align='left', 121 v_align='center', 122 click_activate=True, 123 selectable=True, 124 autoselect=True, 125 on_activate_call=lambda: self._set_sub_tab( 126 SubTabType.JOIN, 127 playsound=True, 128 ), 129 text=bui.Lstr(resource='gatherWindow.privatePartyJoinText'), 130 glow_type='uniform', 131 ) 132 self._host_sub_tab_text = bui.textwidget( 133 parent=self._container, 134 position=(self._c_width * 0.5 + 45, v - 13), 135 color=(0.6, 1.0, 0.6), 136 scale=1.3, 137 size=(200, 30), 138 maxwidth=250, 139 h_align='left', 140 v_align='center', 141 click_activate=True, 142 selectable=True, 143 autoselect=True, 144 on_activate_call=lambda: self._set_sub_tab( 145 SubTabType.HOST, 146 playsound=True, 147 ), 148 text=bui.Lstr(resource='gatherWindow.privatePartyHostText'), 149 glow_type='uniform', 150 ) 151 bui.widget(edit=self._join_sub_tab_text, up_widget=tab_button) 152 bui.widget( 153 edit=self._host_sub_tab_text, 154 left_widget=self._join_sub_tab_text, 155 up_widget=tab_button, 156 ) 157 bui.widget( 158 edit=self._join_sub_tab_text, right_widget=self._host_sub_tab_text 159 ) 160 161 self._update_timer = bui.AppTimer( 162 1.0, bui.WeakCall(self._update), repeat=True 163 ) 164 165 # Prevent taking any action until we've updated our state. 166 self._waiting_for_initial_state = True 167 168 # Force some immediate refreshes. 169 self._last_datacode_refresh_time = None 170 self._last_v2_state_query_time = None 171 self._last_action_send_time = None # Ensure we don't ignore response. 172 self._last_hosting_state_query_time = None 173 self._update() 174 175 self._set_sub_tab(self._state.sub_tab) 176 177 return self._container 178 179 def _build_hosting_config(self) -> PrivateHostingConfig: 180 # pylint: disable=too-many-branches 181 # pylint: disable=too-many-locals 182 from bauiv1lib.playlist import PlaylistTypeVars 183 from bascenev1 import filter_playlist 184 185 hcfg = PrivateHostingConfig() 186 cfg = bui.app.config 187 sessiontypestr = cfg.get('Private Party Host Session Type', 'ffa') 188 if not isinstance(sessiontypestr, str): 189 raise RuntimeError(f'Invalid sessiontype {sessiontypestr}') 190 hcfg.session_type = sessiontypestr 191 192 sessiontype: type[bs.Session] 193 if hcfg.session_type == 'ffa': 194 sessiontype = bs.FreeForAllSession 195 elif hcfg.session_type == 'teams': 196 sessiontype = bs.DualTeamSession 197 else: 198 raise RuntimeError(f'Invalid sessiontype: {hcfg.session_type}') 199 pvars = PlaylistTypeVars(sessiontype) 200 201 playlist_name = bui.app.config.get( 202 f'{pvars.config_name} Playlist Selection' 203 ) 204 if not isinstance(playlist_name, str): 205 playlist_name = '__default__' 206 hcfg.playlist_name = ( 207 pvars.default_list_name.evaluate() 208 if playlist_name == '__default__' 209 else playlist_name 210 ) 211 212 playlist: list[dict[str, Any]] | None = None 213 if playlist_name != '__default__': 214 playlist = cfg.get(f'{pvars.config_name} Playlists', {}).get( 215 playlist_name 216 ) 217 if playlist is None: 218 playlist = pvars.get_default_list_call() 219 220 hcfg.playlist = filter_playlist( 221 playlist, sessiontype, name=playlist_name 222 ) 223 224 randomize = cfg.get(f'{pvars.config_name} Playlist Randomize') 225 if not isinstance(randomize, bool): 226 randomize = False 227 hcfg.randomize = randomize 228 229 tutorial = cfg.get('Show Tutorial') 230 if not isinstance(tutorial, bool): 231 tutorial = True 232 hcfg.tutorial = tutorial 233 234 if hcfg.session_type == 'teams': 235 ctn: list[str] | None = cfg.get('Custom Team Names') 236 if ctn is not None: 237 ctn_any: Any = ctn # Actual value may not match type checker. 238 if ( 239 isinstance(ctn_any, (list, tuple)) 240 and len(ctn) == 2 241 and all(isinstance(x, str) for x in ctn) 242 ): 243 hcfg.custom_team_names = (ctn[0], ctn[1]) 244 else: 245 print(f'Found invalid custom-team-names data: {ctn}') 246 247 ctc: list[list[float]] | None = cfg.get('Custom Team Colors') 248 if ctc is not None: 249 ctc_any: Any = ctc # Actual value may not match type checker. 250 if ( 251 isinstance(ctc_any, (list, tuple)) 252 and len(ctc) == 2 253 and all(isinstance(x, (list, tuple)) for x in ctc) 254 and all(len(x) == 3 for x in ctc) 255 ): 256 hcfg.custom_team_colors = ( 257 (ctc[0][0], ctc[0][1], ctc[0][2]), 258 (ctc[1][0], ctc[1][1], ctc[1][2]), 259 ) 260 else: 261 print(f'Found invalid custom-team-colors data: {ctc}') 262 263 return hcfg 264 265 @override 266 def on_deactivate(self) -> None: 267 self._update_timer = None 268 269 def _update_currency_ui(self) -> None: 270 # Keep currency count up to date if applicable. 271 plus = bui.app.plus 272 assert plus is not None 273 274 if self._v2state is not None: 275 t_str = str(self._v2state.tokens) 276 else: 277 t_str = '-' 278 279 if self._get_tokens_button: 280 bui.buttonwidget( 281 edit=self._get_tokens_button, 282 label=bui.charstr(bui.SpecialChar.TOKEN) + t_str, 283 ) 284 if self._token_count_text: 285 bui.textwidget( 286 edit=self._token_count_text, 287 text=bui.charstr(bui.SpecialChar.TOKEN) + t_str, 288 ) 289 290 def _update(self) -> None: 291 """Periodic updating.""" 292 293 plus = bui.app.plus 294 assert plus is not None 295 296 now = bui.apptime() 297 298 self._update_currency_ui() 299 300 if self._state.sub_tab is SubTabType.HOST: 301 # If we're not signed in, just refresh to show that. 302 if ( 303 plus.get_v1_account_state() != 'signed_in' 304 or plus.accounts.primary is None 305 ) and not self._showing_not_signed_in_screen: 306 self._refresh_sub_tab() 307 else: 308 # Query an updated v1 state periodically. 309 if ( 310 self._last_hosting_state_query_time is None 311 or now - self._last_hosting_state_query_time > 15.0 312 ): 313 self._debug_server_comm('querying private party state') 314 if plus.get_v1_account_state() == 'signed_in': 315 plus.add_v1_account_transaction( 316 { 317 'type': 'PRIVATE_PARTY_QUERY', 318 'expire_time': time.time() + 20, 319 }, 320 callback=bui.WeakCall( 321 self._idle_hosting_state_response 322 ), 323 ) 324 plus.run_v1_account_transactions() 325 else: 326 self._idle_hosting_state_response(None) 327 self._last_hosting_state_query_time = now 328 329 # Query an updated v2 state periodically. 330 if ( 331 self._last_v2_state_query_time is None 332 or now - self._last_v2_state_query_time > 12.0 333 ): 334 self._debug_server_comm('querying pp v2 state') 335 336 if plus.accounts.primary is not None: 337 with plus.accounts.primary: 338 plus.cloud.send_message_cb( 339 bacommon.cloud.BSPrivatePartyMessage( 340 need_datacode=( 341 self._last_datacode_refresh_time is None 342 or time.monotonic() 343 - self._last_datacode_refresh_time 344 > 30.0 345 ) 346 ), 347 on_response=bui.WeakCall( 348 self._on_private_party_query_response 349 ), 350 ) 351 352 self._last_v2_state_query_time = now 353 354 def _on_private_party_query_response( 355 self, response: bacommon.cloud.BSPrivatePartyResponse | Exception 356 ) -> None: 357 if isinstance(response, Exception): 358 self._debug_server_comm('got pp v2 state response (err)') 359 # We expect comm errors sometimes. Make noise on anything else. 360 if not isinstance(response, CommunicationError): 361 logging.exception('Error on private-party-query-response') 362 return 363 364 # Ignore if something went wrong server-side. 365 if not response.success: 366 self._debug_server_comm('got pp v2 state response (serverside err)') 367 return 368 369 self._debug_server_comm('got pp v2 state response') 370 371 existing_datacode = ( 372 None if self._v2state is None else self._v2state.datacode 373 ) 374 375 self._v2state = response 376 if self._v2state.datacode is None: 377 # We don't fetch datacode each time; preserve our existing 378 # if we didn't. 379 self._v2state.datacode = existing_datacode 380 else: 381 # If we *did* fetch it, note the time. 382 self._last_datacode_refresh_time = time.monotonic() 383 384 def _idle_hosting_state_response( 385 self, result: dict[str, Any] | None 386 ) -> None: 387 # This simply passes through to our standard response handler. 388 # The one exception is if we've recently sent an action to the 389 # server (start/stop hosting/etc.) In that case we want to 390 # ignore idle background updates and wait for the response to 391 # our action. (this keeps the button showing 'one moment...' 392 # until the change takes effect, etc.) 393 if ( 394 self._last_action_send_time is not None 395 and time.time() - self._last_action_send_time < 5.0 396 ): 397 self._debug_server_comm( 398 'ignoring private party state response due to recent action' 399 ) 400 return 401 self._hosting_state_response(result) 402 403 def _hosting_state_response(self, result: dict[str, Any] | None) -> None: 404 # Its possible for this to come back to us after our UI is dead; 405 # ignore in that case. 406 if not self._container: 407 return 408 409 state: PrivateHostingState | None = None 410 if result is not None: 411 self._debug_server_comm('got private party state response') 412 try: 413 state = dataclass_from_dict( 414 PrivateHostingState, result, discard_unknown_attrs=True 415 ) 416 except Exception: 417 logging.exception('Got invalid PrivateHostingState data') 418 else: 419 self._debug_server_comm('private party state response errored') 420 421 # Hmm I guess let's just ignore failed responses?... Or should 422 # we show some sort of error state to the user?... 423 if result is None or state is None: 424 return 425 426 self._waiting_for_initial_state = False 427 self._waiting_for_start_stop_response = False 428 self._hostingstate = state 429 self._refresh_sub_tab() 430 431 def _set_sub_tab(self, value: SubTabType, playsound: bool = False) -> None: 432 assert self._container 433 if playsound: 434 bui.getsound('click01').play() 435 436 # If switching from join to host, force some refreshes. 437 if self._state.sub_tab is SubTabType.JOIN and value is SubTabType.HOST: 438 # Prevent taking any action until we've gotten a fresh 439 # state. 440 self._waiting_for_initial_state = True 441 442 # Get some refreshes going immediately. 443 self._last_hosting_state_query_time = None 444 self._last_action_send_time = None # So we don't ignore response. 445 self._last_datacode_refresh_time = None 446 self._last_v2_state_query_time = None 447 self._update() 448 449 self._state.sub_tab = value 450 active_color = (0.6, 1.0, 0.6) 451 inactive_color = (0.5, 0.4, 0.5) 452 bui.textwidget( 453 edit=self._join_sub_tab_text, 454 color=active_color if value is SubTabType.JOIN else inactive_color, 455 ) 456 bui.textwidget( 457 edit=self._host_sub_tab_text, 458 color=active_color if value is SubTabType.HOST else inactive_color, 459 ) 460 461 self._refresh_sub_tab() 462 463 # Kick off an update to get any needed messages sent/etc. 464 bui.pushcall(self._update) 465 466 def _selwidgets(self) -> list[bui.Widget | None]: 467 """An indexed list of widgets we can use for saving/restoring sel.""" 468 return [ 469 self._host_playlist_button, 470 self._host_copy_button, 471 self._host_connect_button, 472 self._host_start_stop_button, 473 self._get_tokens_button, 474 ] 475 476 def _refresh_sub_tab(self) -> None: 477 assert self._container 478 479 # Store an index for our current selection so we can reselect 480 # the equivalent recreated widget if possible. 481 selindex: int | None = None 482 selchild = self._container.get_selected_child() 483 if selchild is not None: 484 try: 485 selindex = self._selwidgets().index(selchild) 486 except ValueError: 487 pass 488 489 # Clear anything existing in the old sub-tab. 490 for widget in self._container.get_children(): 491 if widget and widget not in { 492 self._host_sub_tab_text, 493 self._join_sub_tab_text, 494 }: 495 widget.delete() 496 497 if self._state.sub_tab is SubTabType.JOIN: 498 self._build_join_tab() 499 elif self._state.sub_tab is SubTabType.HOST: 500 self._build_host_tab() 501 else: 502 raise RuntimeError('Invalid state.') 503 504 # Select the new equivalent widget if there is one. 505 if selindex is not None: 506 selwidget = self._selwidgets()[selindex] 507 if selwidget: 508 bui.containerwidget( 509 edit=self._container, selected_child=selwidget 510 ) 511 512 def _build_join_tab(self) -> None: 513 bui.textwidget( 514 parent=self._container, 515 position=(self._c_width * 0.5, self._c_height - 140), 516 color=(0.5, 0.46, 0.5), 517 scale=1.5, 518 size=(0, 0), 519 maxwidth=250, 520 h_align='center', 521 v_align='center', 522 text=bui.Lstr(resource='gatherWindow.partyCodeText'), 523 ) 524 525 self._join_party_code_text = bui.textwidget( 526 parent=self._container, 527 position=(self._c_width * 0.5 - 150, self._c_height - 250), 528 flatness=1.0, 529 scale=1.5, 530 size=(300, 50), 531 editable=True, 532 max_chars=20, 533 description=bui.Lstr(resource='gatherWindow.partyCodeText'), 534 autoselect=True, 535 h_align='left', 536 v_align='center', 537 text='', 538 ) 539 btn = bui.buttonwidget( 540 parent=self._container, 541 size=(300, 70), 542 label=bui.Lstr(resource='gatherWindow.' 'manualConnectText'), 543 position=(self._c_width * 0.5 - 150, self._c_height - 350), 544 on_activate_call=self._join_connect_press, 545 autoselect=True, 546 ) 547 bui.textwidget( 548 edit=self._join_party_code_text, on_return_press_call=btn.activate 549 ) 550 551 def _on_get_tokens_press(self) -> None: 552 if self._waiting_for_start_stop_response: 553 return 554 555 # Bring up get-tickets window and then kill ourself (we're on 556 # the overlay layer so we'd show up above it). 557 GetTokensWindow(origin_widget=self._get_tokens_button) 558 559 def _build_host_tab(self) -> None: 560 # pylint: disable=too-many-branches 561 # pylint: disable=too-many-statements 562 classic = bui.app.classic 563 assert classic is not None 564 565 plus = bui.app.plus 566 assert plus is not None 567 568 hostingstate = self._hostingstate 569 570 havegoldpass = self._v2state is not None and self._v2state.gold_pass 571 572 # We use both v1 and v2 account functionality here (sigh). So 573 # make sure we're signed in on both ends. 574 575 # Make sure the V1 side is good to go. 576 if plus.get_v1_account_state() != 'signed_in': 577 bui.textwidget( 578 parent=self._container, 579 size=(0, 0), 580 h_align='center', 581 v_align='center', 582 maxwidth=self._c_width * 0.8, 583 scale=0.8, 584 color=(0.6, 0.56, 0.6), 585 position=(self._c_width * 0.5, self._c_height * 0.5), 586 text=bui.Lstr(resource='notSignedInErrorText'), 587 ) 588 self._showing_not_signed_in_screen = True 589 return 590 591 # Make sure the V2 side is good to go. 592 if plus.accounts.primary is None: 593 bui.textwidget( 594 parent=self._container, 595 size=(0, 0), 596 h_align='center', 597 v_align='center', 598 maxwidth=self._c_width * 0.8, 599 scale=0.8, 600 color=(0.6, 0.56, 0.6), 601 position=(self._c_width * 0.5, self._c_height * 0.5), 602 text=bui.Lstr(resource='v2AccountRequiredText'), 603 ) 604 self._showing_not_signed_in_screen = True 605 return 606 607 self._showing_not_signed_in_screen = False 608 609 # At first we don't want to show anything until we've gotten a 610 # state. Update: In this situation we now simply show our 611 # existing state but give the start/stop button a loading 612 # message and disallow its use. This keeps things a lot less 613 # jumpy looking and allows selecting playlists/etc without 614 # having to wait for the server each time back to the ui. 615 if self._waiting_for_initial_state and bool(False): 616 bui.textwidget( 617 parent=self._container, 618 size=(0, 0), 619 h_align='center', 620 v_align='center', 621 maxwidth=200, 622 scale=0.8, 623 color=(0.6, 0.56, 0.6), 624 position=(self._c_width * 0.5, self._c_height * 0.5), 625 text=bui.Lstr( 626 value='${A}...', 627 subs=[('${A}', bui.Lstr(resource='store.loadingText'))], 628 ), 629 ) 630 return 631 632 # If we're not currently hosting and hosting requires tokens, 633 # Show our count (possibly with a link to purchase more). 634 if ( 635 not self._waiting_for_initial_state 636 and hostingstate.party_code is None 637 and hostingstate.tickets_to_host_now != 0 638 and not havegoldpass 639 ): 640 pass 641 642 v = self._c_height - 90 643 if hostingstate.party_code is None: 644 bui.textwidget( 645 parent=self._container, 646 size=(0, 0), 647 h_align='center', 648 v_align='center', 649 maxwidth=self._c_width * 0.9, 650 scale=0.7, 651 flatness=1.0, 652 color=(0.5, 0.46, 0.5), 653 position=(self._c_width * 0.5, v), 654 text=bui.Lstr( 655 resource='gatherWindow.privatePartyCloudDescriptionText' 656 ), 657 ) 658 659 v -= 90 660 if hostingstate.party_code is None: 661 # We've got no current party running; show options to set 662 # one up. 663 bui.textwidget( 664 parent=self._container, 665 size=(0, 0), 666 h_align='right', 667 v_align='center', 668 maxwidth=200, 669 scale=0.8, 670 color=(0.6, 0.56, 0.6), 671 position=(self._c_width * 0.5 - 210, v), 672 text=bui.Lstr(resource='playlistText'), 673 ) 674 self._host_playlist_button = bui.buttonwidget( 675 parent=self._container, 676 size=(400, 70), 677 color=(0.6, 0.5, 0.6), 678 textcolor=(0.8, 0.75, 0.8), 679 label=self._hostingconfig.playlist_name, 680 on_activate_call=self._playlist_press, 681 position=(self._c_width * 0.5 - 200, v - 35), 682 up_widget=self._host_sub_tab_text, 683 autoselect=True, 684 ) 685 686 # If it appears we're coming back from playlist selection, 687 # re-select our playlist button. 688 if classic.selecting_private_party_playlist: 689 bui.containerwidget( 690 edit=self._container, 691 selected_child=self._host_playlist_button, 692 ) 693 classic.selecting_private_party_playlist = False 694 else: 695 # We've got a current party; show its info. 696 bui.textwidget( 697 parent=self._container, 698 size=(0, 0), 699 h_align='center', 700 v_align='center', 701 maxwidth=600, 702 scale=0.9, 703 color=(0.7, 0.64, 0.7), 704 position=(self._c_width * 0.5, v + 90), 705 text=bui.Lstr(resource='gatherWindow.partyServerRunningText'), 706 ) 707 bui.textwidget( 708 parent=self._container, 709 size=(0, 0), 710 h_align='center', 711 v_align='center', 712 maxwidth=600, 713 scale=0.7, 714 color=(0.7, 0.64, 0.7), 715 position=(self._c_width * 0.5, v + 50), 716 text=bui.Lstr(resource='gatherWindow.partyCodeText'), 717 ) 718 bui.textwidget( 719 parent=self._container, 720 size=(0, 0), 721 h_align='center', 722 v_align='center', 723 scale=2.0, 724 color=(0.0, 1.0, 0.0), 725 position=(self._c_width * 0.5, v + 10), 726 text=hostingstate.party_code, 727 ) 728 729 # Also action buttons to copy it and connect to it. 730 if bui.clipboard_is_supported(): 731 cbtnoffs = 10 732 self._host_copy_button = bui.buttonwidget( 733 parent=self._container, 734 size=(140, 40), 735 color=(0.6, 0.5, 0.6), 736 textcolor=(0.8, 0.75, 0.8), 737 label=bui.Lstr(resource='gatherWindow.copyCodeText'), 738 on_activate_call=self._host_copy_press, 739 position=(self._c_width * 0.5 - 150, v - 70), 740 autoselect=True, 741 ) 742 else: 743 cbtnoffs = -70 744 self._host_connect_button = bui.buttonwidget( 745 parent=self._container, 746 size=(140, 40), 747 color=(0.6, 0.5, 0.6), 748 textcolor=(0.8, 0.75, 0.8), 749 label=bui.Lstr(resource='gatherWindow.manualConnectText'), 750 on_activate_call=self._host_connect_press, 751 position=(self._c_width * 0.5 + cbtnoffs, v - 70), 752 autoselect=True, 753 ) 754 755 v -= 110 756 757 # Line above the main action button: 758 759 # If we don't want to show anything until we get a state: 760 if self._waiting_for_initial_state: 761 pass 762 elif hostingstate.unavailable_error is not None: 763 # If hosting is unavailable, show the associated reason. 764 bui.textwidget( 765 parent=self._container, 766 size=(0, 0), 767 h_align='center', 768 v_align='center', 769 maxwidth=self._c_width * 0.9, 770 scale=0.7, 771 flatness=1.0, 772 color=(1.0, 0.0, 0.0), 773 position=(self._c_width * 0.5, v), 774 text=bui.Lstr( 775 translate=( 776 'serverResponses', 777 hostingstate.unavailable_error, 778 ) 779 ), 780 ) 781 elif havegoldpass: 782 # If we have a gold pass, none of the 783 # timing/free-server-availability info below is relevant to 784 # us. 785 pass 786 elif hostingstate.free_host_minutes_remaining is not None: 787 # If we've been pre-approved to start/stop for free, show 788 # that. 789 bui.textwidget( 790 parent=self._container, 791 size=(0, 0), 792 h_align='center', 793 v_align='center', 794 maxwidth=self._c_width * 0.9, 795 scale=0.7, 796 flatness=1.0, 797 color=( 798 (0.7, 0.64, 0.7) 799 if hostingstate.party_code 800 else (0.0, 1.0, 0.0) 801 ), 802 position=(self._c_width * 0.5, v), 803 text=bui.Lstr( 804 resource='gatherWindow.startStopHostingMinutesText', 805 subs=[ 806 ( 807 '${MINUTES}', 808 f'{hostingstate.free_host_minutes_remaining:.0f}', 809 ) 810 ], 811 ), 812 ) 813 else: 814 # Otherwise tell whether the free cloud server is available 815 # or will be at some point. 816 if hostingstate.party_code is None: 817 if hostingstate.tickets_to_host_now == 0: 818 bui.textwidget( 819 parent=self._container, 820 size=(0, 0), 821 h_align='center', 822 v_align='center', 823 maxwidth=self._c_width * 0.9, 824 scale=0.7, 825 flatness=1.0, 826 color=(0.0, 1.0, 0.0), 827 position=(self._c_width * 0.5, v), 828 text=bui.Lstr( 829 resource=( 830 'gatherWindow.freeCloudServerAvailableNowText' 831 ) 832 ), 833 ) 834 else: 835 if hostingstate.minutes_until_free_host is None: 836 bui.textwidget( 837 parent=self._container, 838 size=(0, 0), 839 h_align='center', 840 v_align='center', 841 maxwidth=self._c_width * 0.9, 842 scale=0.7, 843 flatness=1.0, 844 color=(1.0, 0.6, 0.0), 845 position=(self._c_width * 0.5, v), 846 text=bui.Lstr( 847 resource=( 848 'gatherWindow' 849 '.freeCloudServerNotAvailableText' 850 ) 851 ), 852 ) 853 else: 854 availmins = hostingstate.minutes_until_free_host 855 bui.textwidget( 856 parent=self._container, 857 size=(0, 0), 858 h_align='center', 859 v_align='center', 860 maxwidth=self._c_width * 0.9, 861 scale=0.7, 862 flatness=1.0, 863 color=(1.0, 0.6, 0.0), 864 position=(self._c_width * 0.5, v), 865 text=bui.Lstr( 866 resource='gatherWindow.' 867 'freeCloudServerAvailableMinutesText', 868 subs=[('${MINUTES}', f'{availmins:.0f}')], 869 ), 870 ) 871 872 v -= 100 873 874 if ( 875 self._waiting_for_start_stop_response 876 or self._waiting_for_initial_state 877 ): 878 btnlabel = bui.Lstr(resource='oneMomentText') 879 else: 880 if hostingstate.unavailable_error is not None: 881 btnlabel = bui.Lstr( 882 resource='gatherWindow.hostingUnavailableText' 883 ) 884 elif hostingstate.party_code is None: 885 ticon = bui.charstr(bui.SpecialChar.TOKEN) 886 nowtokens = hostingstate.tokens_to_host_now 887 if nowtokens > 0 and not havegoldpass: 888 btnlabel = bui.Lstr( 889 resource='gatherWindow.startHostingPaidText', 890 subs=[('${COST}', f'{ticon}{nowtokens}')], 891 ) 892 else: 893 btnlabel = bui.Lstr( 894 resource='gatherWindow.startHostingText' 895 ) 896 else: 897 btnlabel = bui.Lstr(resource='gatherWindow.stopHostingText') 898 899 disabled = ( 900 hostingstate.unavailable_error is not None 901 or self._waiting_for_initial_state 902 ) 903 waiting = self._waiting_for_start_stop_response 904 self._host_start_stop_button = bui.buttonwidget( 905 parent=self._container, 906 size=(400, 80), 907 color=( 908 (0.6, 0.6, 0.6) 909 if disabled 910 else (0.5, 1.0, 0.5) if waiting else None 911 ), 912 enable_sound=False, 913 label=btnlabel, 914 textcolor=((0.7, 0.7, 0.7) if disabled else None), 915 position=(self._c_width * 0.5 - 200, v), 916 on_activate_call=self._start_stop_button_press, 917 autoselect=True, 918 ) 919 920 def _playlist_press(self) -> None: 921 if bool(True): 922 bui.screenmessage('UNDER CONSTRUCTION') 923 return 924 assert self._host_playlist_button is not None 925 self.window.playlist_select(origin_widget=self._host_playlist_button) 926 927 def _host_copy_press(self) -> None: 928 assert self._hostingstate.party_code is not None 929 bui.clipboard_set_text(self._hostingstate.party_code) 930 bui.screenmessage(bui.Lstr(resource='gatherWindow.copyCodeConfirmText')) 931 932 def _host_connect_press(self) -> None: 933 assert self._hostingstate.party_code is not None 934 self._connect_to_party_code(self._hostingstate.party_code) 935 936 def _debug_server_comm(self, msg: str) -> None: 937 if DEBUG_SERVER_COMMUNICATION: 938 print( 939 f'PPTABCOM: {msg} at time ' 940 f'{time.time()-self._create_time:.2f}' 941 ) 942 943 def _connect_to_party_code(self, code: str) -> None: 944 # Ignore attempted followup sends for a few seconds. (this will 945 # reset if we get a response) 946 plus = bui.app.plus 947 assert plus is not None 948 949 now = time.time() 950 if ( 951 self._connect_press_time is not None 952 and now - self._connect_press_time < 5.0 953 ): 954 self._debug_server_comm( 955 'not sending private party connect (too soon)' 956 ) 957 return 958 self._connect_press_time = now 959 960 self._debug_server_comm('sending private party connect') 961 plus.add_v1_account_transaction( 962 { 963 'type': 'PRIVATE_PARTY_CONNECT', 964 'expire_time': time.time() + 20, 965 'code': code, 966 }, 967 callback=bui.WeakCall(self._connect_response), 968 ) 969 plus.run_v1_account_transactions() 970 971 def _start_stop_button_press(self) -> None: 972 plus = bui.app.plus 973 assert plus is not None 974 if ( 975 self._waiting_for_start_stop_response 976 or self._waiting_for_initial_state 977 ): 978 return 979 980 if plus.get_v1_account_state() != 'signed_in': 981 bui.screenmessage(bui.Lstr(resource='notSignedInErrorText')) 982 bui.getsound('error').play() 983 self._refresh_sub_tab() 984 return 985 986 if self._hostingstate.unavailable_error is not None: 987 bui.getsound('error').play() 988 return 989 990 bui.getsound('click01').play() 991 992 # We need our v2 info for this. 993 if self._v2state is None or self._v2state.datacode is None: 994 bui.screenmessage( 995 bui.Lstr(resource='internal.unavailableNoConnectionText'), 996 color=(1, 0, 0), 997 ) 998 bui.getsound('error').play() 999 return 1000 1001 # If we're not hosting, start. 1002 if self._hostingstate.party_code is None: 1003 # If there's a token cost, make sure we have enough tokens 1004 # or a gold pass. 1005 if self._hostingstate.tokens_to_host_now > 0: 1006 1007 if ( 1008 not self._v2state.gold_pass 1009 and self._v2state.tokens 1010 < self._hostingstate.tokens_to_host_now 1011 ): 1012 show_get_tokens_prompt() 1013 bui.getsound('error').play() 1014 return 1015 1016 self._last_action_send_time = time.time() 1017 plus.add_v1_account_transaction( 1018 { 1019 'type': 'PRIVATE_PARTY_START', 1020 'config': dataclass_to_dict(self._hostingconfig), 1021 'region_pings': bui.app.net.zone_pings, 1022 'expire_time': time.time() + 20, 1023 'datacode': self._v2state.datacode, 1024 }, 1025 callback=bui.WeakCall(self._hosting_state_response), 1026 ) 1027 plus.run_v1_account_transactions() 1028 1029 else: 1030 self._last_action_send_time = time.time() 1031 plus.add_v1_account_transaction( 1032 { 1033 'type': 'PRIVATE_PARTY_STOP', 1034 'expire_time': time.time() + 20, 1035 }, 1036 callback=bui.WeakCall(self._hosting_state_response), 1037 ) 1038 plus.run_v1_account_transactions() 1039 bui.getsound('click01').play() 1040 1041 self._waiting_for_start_stop_response = True 1042 self._refresh_sub_tab() 1043 1044 def _join_connect_press(self) -> None: 1045 # Error immediately if its an empty code. 1046 code: str | None = None 1047 if self._join_party_code_text: 1048 code = cast(str, bui.textwidget(query=self._join_party_code_text)) 1049 if not code: 1050 bui.screenmessage( 1051 bui.Lstr(translate=('serverResponses', 'Invalid code.')), 1052 color=(1, 0, 0), 1053 ) 1054 bui.getsound('error').play() 1055 return 1056 1057 self._connect_to_party_code(code) 1058 1059 def _connect_response(self, result: dict[str, Any] | None) -> None: 1060 try: 1061 self._connect_press_time = None 1062 if result is None: 1063 raise RuntimeError() 1064 cresult = dataclass_from_dict( 1065 PrivatePartyConnectResult, result, discard_unknown_attrs=True 1066 ) 1067 if cresult.error is not None: 1068 self._debug_server_comm('got error connect response') 1069 bui.screenmessage( 1070 bui.Lstr(translate=('serverResponses', cresult.error)), 1071 (1, 0, 0), 1072 ) 1073 bui.getsound('error').play() 1074 return 1075 self._debug_server_comm('got valid connect response') 1076 assert cresult.address4 is not None and cresult.port is not None 1077 bs.connect_to_party(cresult.address4, port=cresult.port) 1078 except Exception: 1079 self._debug_server_comm('got connect response error') 1080 bui.getsound('error').play() 1081 1082 @override 1083 def save_state(self) -> None: 1084 assert bui.app.classic is not None 1085 bui.app.ui_v1.window_states[type(self)] = copy.deepcopy(self._state) 1086 1087 @override 1088 def restore_state(self) -> None: 1089 assert bui.app.classic is not None 1090 state = bui.app.ui_v1.window_states.get(type(self)) 1091 if state is None: 1092 state = State() 1093 assert isinstance(state, State) 1094 self._state = state
DEBUG_SERVER_COMMUNICATION =
False
class
SubTabType(enum.Enum):
Available sub-tabs.
JOIN =
<SubTabType.JOIN: 'join'>
HOST =
<SubTabType.HOST: 'host'>
Inherited Members
- enum.Enum
- name
- value
@dataclass
class
State:
48@dataclass 49class State: 50 """Our core state that persists while the app is running.""" 51 52 sub_tab: SubTabType = SubTabType.JOIN
Our core state that persists while the app is running.
State( sub_tab: SubTabType = <SubTabType.JOIN: 'join'>)
55class PrivateGatherTab(GatherTab): 56 """The private tab in the gather UI""" 57 58 def __init__(self, window: GatherWindow) -> None: 59 super().__init__(window) 60 self._container: bui.Widget | None = None 61 self._state: State = State() 62 self._last_datacode_refresh_time: float | None = None 63 self._hostingstate = PrivateHostingState() 64 self._v2state: bacommon.cloud.BSPrivatePartyResponse | None = None 65 self._join_sub_tab_text: bui.Widget | None = None 66 self._host_sub_tab_text: bui.Widget | None = None 67 self._update_timer: bui.AppTimer | None = None 68 self._join_party_code_text: bui.Widget | None = None 69 self._c_width: float = 0.0 70 self._c_height: float = 0.0 71 self._last_hosting_state_query_time: float | None = None 72 self._last_v2_state_query_time: float | None = None 73 self._waiting_for_initial_state = True 74 self._waiting_for_start_stop_response = True 75 self._host_playlist_button: bui.Widget | None = None 76 self._host_copy_button: bui.Widget | None = None 77 self._host_connect_button: bui.Widget | None = None 78 self._host_start_stop_button: bui.Widget | None = None 79 self._get_tokens_button: bui.Widget | None = None 80 self._token_count_text: bui.Widget | None = None 81 self._showing_not_signed_in_screen = False 82 self._create_time = time.time() 83 self._last_action_send_time: float | None = None 84 self._connect_press_time: float | None = None 85 try: 86 self._hostingconfig = self._build_hosting_config() 87 except Exception: 88 logging.exception('Error building hosting config.') 89 self._hostingconfig = PrivateHostingConfig() 90 91 @override 92 def on_activate( 93 self, 94 parent_widget: bui.Widget, 95 tab_button: bui.Widget, 96 region_width: float, 97 region_height: float, 98 region_left: float, 99 region_bottom: float, 100 ) -> bui.Widget: 101 self._c_width = region_width 102 self._c_height = region_height - 20 103 self._container = bui.containerwidget( 104 parent=parent_widget, 105 position=( 106 region_left, 107 region_bottom + (region_height - self._c_height) * 0.5, 108 ), 109 size=(self._c_width, self._c_height), 110 background=False, 111 selection_loops_to_parent=True, 112 ) 113 v = self._c_height - 30.0 114 self._join_sub_tab_text = bui.textwidget( 115 parent=self._container, 116 position=(self._c_width * 0.5 - 245, v - 13), 117 color=(0.6, 1.0, 0.6), 118 scale=1.3, 119 size=(200, 30), 120 maxwidth=250, 121 h_align='left', 122 v_align='center', 123 click_activate=True, 124 selectable=True, 125 autoselect=True, 126 on_activate_call=lambda: self._set_sub_tab( 127 SubTabType.JOIN, 128 playsound=True, 129 ), 130 text=bui.Lstr(resource='gatherWindow.privatePartyJoinText'), 131 glow_type='uniform', 132 ) 133 self._host_sub_tab_text = bui.textwidget( 134 parent=self._container, 135 position=(self._c_width * 0.5 + 45, v - 13), 136 color=(0.6, 1.0, 0.6), 137 scale=1.3, 138 size=(200, 30), 139 maxwidth=250, 140 h_align='left', 141 v_align='center', 142 click_activate=True, 143 selectable=True, 144 autoselect=True, 145 on_activate_call=lambda: self._set_sub_tab( 146 SubTabType.HOST, 147 playsound=True, 148 ), 149 text=bui.Lstr(resource='gatherWindow.privatePartyHostText'), 150 glow_type='uniform', 151 ) 152 bui.widget(edit=self._join_sub_tab_text, up_widget=tab_button) 153 bui.widget( 154 edit=self._host_sub_tab_text, 155 left_widget=self._join_sub_tab_text, 156 up_widget=tab_button, 157 ) 158 bui.widget( 159 edit=self._join_sub_tab_text, right_widget=self._host_sub_tab_text 160 ) 161 162 self._update_timer = bui.AppTimer( 163 1.0, bui.WeakCall(self._update), repeat=True 164 ) 165 166 # Prevent taking any action until we've updated our state. 167 self._waiting_for_initial_state = True 168 169 # Force some immediate refreshes. 170 self._last_datacode_refresh_time = None 171 self._last_v2_state_query_time = None 172 self._last_action_send_time = None # Ensure we don't ignore response. 173 self._last_hosting_state_query_time = None 174 self._update() 175 176 self._set_sub_tab(self._state.sub_tab) 177 178 return self._container 179 180 def _build_hosting_config(self) -> PrivateHostingConfig: 181 # pylint: disable=too-many-branches 182 # pylint: disable=too-many-locals 183 from bauiv1lib.playlist import PlaylistTypeVars 184 from bascenev1 import filter_playlist 185 186 hcfg = PrivateHostingConfig() 187 cfg = bui.app.config 188 sessiontypestr = cfg.get('Private Party Host Session Type', 'ffa') 189 if not isinstance(sessiontypestr, str): 190 raise RuntimeError(f'Invalid sessiontype {sessiontypestr}') 191 hcfg.session_type = sessiontypestr 192 193 sessiontype: type[bs.Session] 194 if hcfg.session_type == 'ffa': 195 sessiontype = bs.FreeForAllSession 196 elif hcfg.session_type == 'teams': 197 sessiontype = bs.DualTeamSession 198 else: 199 raise RuntimeError(f'Invalid sessiontype: {hcfg.session_type}') 200 pvars = PlaylistTypeVars(sessiontype) 201 202 playlist_name = bui.app.config.get( 203 f'{pvars.config_name} Playlist Selection' 204 ) 205 if not isinstance(playlist_name, str): 206 playlist_name = '__default__' 207 hcfg.playlist_name = ( 208 pvars.default_list_name.evaluate() 209 if playlist_name == '__default__' 210 else playlist_name 211 ) 212 213 playlist: list[dict[str, Any]] | None = None 214 if playlist_name != '__default__': 215 playlist = cfg.get(f'{pvars.config_name} Playlists', {}).get( 216 playlist_name 217 ) 218 if playlist is None: 219 playlist = pvars.get_default_list_call() 220 221 hcfg.playlist = filter_playlist( 222 playlist, sessiontype, name=playlist_name 223 ) 224 225 randomize = cfg.get(f'{pvars.config_name} Playlist Randomize') 226 if not isinstance(randomize, bool): 227 randomize = False 228 hcfg.randomize = randomize 229 230 tutorial = cfg.get('Show Tutorial') 231 if not isinstance(tutorial, bool): 232 tutorial = True 233 hcfg.tutorial = tutorial 234 235 if hcfg.session_type == 'teams': 236 ctn: list[str] | None = cfg.get('Custom Team Names') 237 if ctn is not None: 238 ctn_any: Any = ctn # Actual value may not match type checker. 239 if ( 240 isinstance(ctn_any, (list, tuple)) 241 and len(ctn) == 2 242 and all(isinstance(x, str) for x in ctn) 243 ): 244 hcfg.custom_team_names = (ctn[0], ctn[1]) 245 else: 246 print(f'Found invalid custom-team-names data: {ctn}') 247 248 ctc: list[list[float]] | None = cfg.get('Custom Team Colors') 249 if ctc is not None: 250 ctc_any: Any = ctc # Actual value may not match type checker. 251 if ( 252 isinstance(ctc_any, (list, tuple)) 253 and len(ctc) == 2 254 and all(isinstance(x, (list, tuple)) for x in ctc) 255 and all(len(x) == 3 for x in ctc) 256 ): 257 hcfg.custom_team_colors = ( 258 (ctc[0][0], ctc[0][1], ctc[0][2]), 259 (ctc[1][0], ctc[1][1], ctc[1][2]), 260 ) 261 else: 262 print(f'Found invalid custom-team-colors data: {ctc}') 263 264 return hcfg 265 266 @override 267 def on_deactivate(self) -> None: 268 self._update_timer = None 269 270 def _update_currency_ui(self) -> None: 271 # Keep currency count up to date if applicable. 272 plus = bui.app.plus 273 assert plus is not None 274 275 if self._v2state is not None: 276 t_str = str(self._v2state.tokens) 277 else: 278 t_str = '-' 279 280 if self._get_tokens_button: 281 bui.buttonwidget( 282 edit=self._get_tokens_button, 283 label=bui.charstr(bui.SpecialChar.TOKEN) + t_str, 284 ) 285 if self._token_count_text: 286 bui.textwidget( 287 edit=self._token_count_text, 288 text=bui.charstr(bui.SpecialChar.TOKEN) + t_str, 289 ) 290 291 def _update(self) -> None: 292 """Periodic updating.""" 293 294 plus = bui.app.plus 295 assert plus is not None 296 297 now = bui.apptime() 298 299 self._update_currency_ui() 300 301 if self._state.sub_tab is SubTabType.HOST: 302 # If we're not signed in, just refresh to show that. 303 if ( 304 plus.get_v1_account_state() != 'signed_in' 305 or plus.accounts.primary is None 306 ) and not self._showing_not_signed_in_screen: 307 self._refresh_sub_tab() 308 else: 309 # Query an updated v1 state periodically. 310 if ( 311 self._last_hosting_state_query_time is None 312 or now - self._last_hosting_state_query_time > 15.0 313 ): 314 self._debug_server_comm('querying private party state') 315 if plus.get_v1_account_state() == 'signed_in': 316 plus.add_v1_account_transaction( 317 { 318 'type': 'PRIVATE_PARTY_QUERY', 319 'expire_time': time.time() + 20, 320 }, 321 callback=bui.WeakCall( 322 self._idle_hosting_state_response 323 ), 324 ) 325 plus.run_v1_account_transactions() 326 else: 327 self._idle_hosting_state_response(None) 328 self._last_hosting_state_query_time = now 329 330 # Query an updated v2 state periodically. 331 if ( 332 self._last_v2_state_query_time is None 333 or now - self._last_v2_state_query_time > 12.0 334 ): 335 self._debug_server_comm('querying pp v2 state') 336 337 if plus.accounts.primary is not None: 338 with plus.accounts.primary: 339 plus.cloud.send_message_cb( 340 bacommon.cloud.BSPrivatePartyMessage( 341 need_datacode=( 342 self._last_datacode_refresh_time is None 343 or time.monotonic() 344 - self._last_datacode_refresh_time 345 > 30.0 346 ) 347 ), 348 on_response=bui.WeakCall( 349 self._on_private_party_query_response 350 ), 351 ) 352 353 self._last_v2_state_query_time = now 354 355 def _on_private_party_query_response( 356 self, response: bacommon.cloud.BSPrivatePartyResponse | Exception 357 ) -> None: 358 if isinstance(response, Exception): 359 self._debug_server_comm('got pp v2 state response (err)') 360 # We expect comm errors sometimes. Make noise on anything else. 361 if not isinstance(response, CommunicationError): 362 logging.exception('Error on private-party-query-response') 363 return 364 365 # Ignore if something went wrong server-side. 366 if not response.success: 367 self._debug_server_comm('got pp v2 state response (serverside err)') 368 return 369 370 self._debug_server_comm('got pp v2 state response') 371 372 existing_datacode = ( 373 None if self._v2state is None else self._v2state.datacode 374 ) 375 376 self._v2state = response 377 if self._v2state.datacode is None: 378 # We don't fetch datacode each time; preserve our existing 379 # if we didn't. 380 self._v2state.datacode = existing_datacode 381 else: 382 # If we *did* fetch it, note the time. 383 self._last_datacode_refresh_time = time.monotonic() 384 385 def _idle_hosting_state_response( 386 self, result: dict[str, Any] | None 387 ) -> None: 388 # This simply passes through to our standard response handler. 389 # The one exception is if we've recently sent an action to the 390 # server (start/stop hosting/etc.) In that case we want to 391 # ignore idle background updates and wait for the response to 392 # our action. (this keeps the button showing 'one moment...' 393 # until the change takes effect, etc.) 394 if ( 395 self._last_action_send_time is not None 396 and time.time() - self._last_action_send_time < 5.0 397 ): 398 self._debug_server_comm( 399 'ignoring private party state response due to recent action' 400 ) 401 return 402 self._hosting_state_response(result) 403 404 def _hosting_state_response(self, result: dict[str, Any] | None) -> None: 405 # Its possible for this to come back to us after our UI is dead; 406 # ignore in that case. 407 if not self._container: 408 return 409 410 state: PrivateHostingState | None = None 411 if result is not None: 412 self._debug_server_comm('got private party state response') 413 try: 414 state = dataclass_from_dict( 415 PrivateHostingState, result, discard_unknown_attrs=True 416 ) 417 except Exception: 418 logging.exception('Got invalid PrivateHostingState data') 419 else: 420 self._debug_server_comm('private party state response errored') 421 422 # Hmm I guess let's just ignore failed responses?... Or should 423 # we show some sort of error state to the user?... 424 if result is None or state is None: 425 return 426 427 self._waiting_for_initial_state = False 428 self._waiting_for_start_stop_response = False 429 self._hostingstate = state 430 self._refresh_sub_tab() 431 432 def _set_sub_tab(self, value: SubTabType, playsound: bool = False) -> None: 433 assert self._container 434 if playsound: 435 bui.getsound('click01').play() 436 437 # If switching from join to host, force some refreshes. 438 if self._state.sub_tab is SubTabType.JOIN and value is SubTabType.HOST: 439 # Prevent taking any action until we've gotten a fresh 440 # state. 441 self._waiting_for_initial_state = True 442 443 # Get some refreshes going immediately. 444 self._last_hosting_state_query_time = None 445 self._last_action_send_time = None # So we don't ignore response. 446 self._last_datacode_refresh_time = None 447 self._last_v2_state_query_time = None 448 self._update() 449 450 self._state.sub_tab = value 451 active_color = (0.6, 1.0, 0.6) 452 inactive_color = (0.5, 0.4, 0.5) 453 bui.textwidget( 454 edit=self._join_sub_tab_text, 455 color=active_color if value is SubTabType.JOIN else inactive_color, 456 ) 457 bui.textwidget( 458 edit=self._host_sub_tab_text, 459 color=active_color if value is SubTabType.HOST else inactive_color, 460 ) 461 462 self._refresh_sub_tab() 463 464 # Kick off an update to get any needed messages sent/etc. 465 bui.pushcall(self._update) 466 467 def _selwidgets(self) -> list[bui.Widget | None]: 468 """An indexed list of widgets we can use for saving/restoring sel.""" 469 return [ 470 self._host_playlist_button, 471 self._host_copy_button, 472 self._host_connect_button, 473 self._host_start_stop_button, 474 self._get_tokens_button, 475 ] 476 477 def _refresh_sub_tab(self) -> None: 478 assert self._container 479 480 # Store an index for our current selection so we can reselect 481 # the equivalent recreated widget if possible. 482 selindex: int | None = None 483 selchild = self._container.get_selected_child() 484 if selchild is not None: 485 try: 486 selindex = self._selwidgets().index(selchild) 487 except ValueError: 488 pass 489 490 # Clear anything existing in the old sub-tab. 491 for widget in self._container.get_children(): 492 if widget and widget not in { 493 self._host_sub_tab_text, 494 self._join_sub_tab_text, 495 }: 496 widget.delete() 497 498 if self._state.sub_tab is SubTabType.JOIN: 499 self._build_join_tab() 500 elif self._state.sub_tab is SubTabType.HOST: 501 self._build_host_tab() 502 else: 503 raise RuntimeError('Invalid state.') 504 505 # Select the new equivalent widget if there is one. 506 if selindex is not None: 507 selwidget = self._selwidgets()[selindex] 508 if selwidget: 509 bui.containerwidget( 510 edit=self._container, selected_child=selwidget 511 ) 512 513 def _build_join_tab(self) -> None: 514 bui.textwidget( 515 parent=self._container, 516 position=(self._c_width * 0.5, self._c_height - 140), 517 color=(0.5, 0.46, 0.5), 518 scale=1.5, 519 size=(0, 0), 520 maxwidth=250, 521 h_align='center', 522 v_align='center', 523 text=bui.Lstr(resource='gatherWindow.partyCodeText'), 524 ) 525 526 self._join_party_code_text = bui.textwidget( 527 parent=self._container, 528 position=(self._c_width * 0.5 - 150, self._c_height - 250), 529 flatness=1.0, 530 scale=1.5, 531 size=(300, 50), 532 editable=True, 533 max_chars=20, 534 description=bui.Lstr(resource='gatherWindow.partyCodeText'), 535 autoselect=True, 536 h_align='left', 537 v_align='center', 538 text='', 539 ) 540 btn = bui.buttonwidget( 541 parent=self._container, 542 size=(300, 70), 543 label=bui.Lstr(resource='gatherWindow.' 'manualConnectText'), 544 position=(self._c_width * 0.5 - 150, self._c_height - 350), 545 on_activate_call=self._join_connect_press, 546 autoselect=True, 547 ) 548 bui.textwidget( 549 edit=self._join_party_code_text, on_return_press_call=btn.activate 550 ) 551 552 def _on_get_tokens_press(self) -> None: 553 if self._waiting_for_start_stop_response: 554 return 555 556 # Bring up get-tickets window and then kill ourself (we're on 557 # the overlay layer so we'd show up above it). 558 GetTokensWindow(origin_widget=self._get_tokens_button) 559 560 def _build_host_tab(self) -> None: 561 # pylint: disable=too-many-branches 562 # pylint: disable=too-many-statements 563 classic = bui.app.classic 564 assert classic is not None 565 566 plus = bui.app.plus 567 assert plus is not None 568 569 hostingstate = self._hostingstate 570 571 havegoldpass = self._v2state is not None and self._v2state.gold_pass 572 573 # We use both v1 and v2 account functionality here (sigh). So 574 # make sure we're signed in on both ends. 575 576 # Make sure the V1 side is good to go. 577 if plus.get_v1_account_state() != 'signed_in': 578 bui.textwidget( 579 parent=self._container, 580 size=(0, 0), 581 h_align='center', 582 v_align='center', 583 maxwidth=self._c_width * 0.8, 584 scale=0.8, 585 color=(0.6, 0.56, 0.6), 586 position=(self._c_width * 0.5, self._c_height * 0.5), 587 text=bui.Lstr(resource='notSignedInErrorText'), 588 ) 589 self._showing_not_signed_in_screen = True 590 return 591 592 # Make sure the V2 side is good to go. 593 if plus.accounts.primary is None: 594 bui.textwidget( 595 parent=self._container, 596 size=(0, 0), 597 h_align='center', 598 v_align='center', 599 maxwidth=self._c_width * 0.8, 600 scale=0.8, 601 color=(0.6, 0.56, 0.6), 602 position=(self._c_width * 0.5, self._c_height * 0.5), 603 text=bui.Lstr(resource='v2AccountRequiredText'), 604 ) 605 self._showing_not_signed_in_screen = True 606 return 607 608 self._showing_not_signed_in_screen = False 609 610 # At first we don't want to show anything until we've gotten a 611 # state. Update: In this situation we now simply show our 612 # existing state but give the start/stop button a loading 613 # message and disallow its use. This keeps things a lot less 614 # jumpy looking and allows selecting playlists/etc without 615 # having to wait for the server each time back to the ui. 616 if self._waiting_for_initial_state and bool(False): 617 bui.textwidget( 618 parent=self._container, 619 size=(0, 0), 620 h_align='center', 621 v_align='center', 622 maxwidth=200, 623 scale=0.8, 624 color=(0.6, 0.56, 0.6), 625 position=(self._c_width * 0.5, self._c_height * 0.5), 626 text=bui.Lstr( 627 value='${A}...', 628 subs=[('${A}', bui.Lstr(resource='store.loadingText'))], 629 ), 630 ) 631 return 632 633 # If we're not currently hosting and hosting requires tokens, 634 # Show our count (possibly with a link to purchase more). 635 if ( 636 not self._waiting_for_initial_state 637 and hostingstate.party_code is None 638 and hostingstate.tickets_to_host_now != 0 639 and not havegoldpass 640 ): 641 pass 642 643 v = self._c_height - 90 644 if hostingstate.party_code is None: 645 bui.textwidget( 646 parent=self._container, 647 size=(0, 0), 648 h_align='center', 649 v_align='center', 650 maxwidth=self._c_width * 0.9, 651 scale=0.7, 652 flatness=1.0, 653 color=(0.5, 0.46, 0.5), 654 position=(self._c_width * 0.5, v), 655 text=bui.Lstr( 656 resource='gatherWindow.privatePartyCloudDescriptionText' 657 ), 658 ) 659 660 v -= 90 661 if hostingstate.party_code is None: 662 # We've got no current party running; show options to set 663 # one up. 664 bui.textwidget( 665 parent=self._container, 666 size=(0, 0), 667 h_align='right', 668 v_align='center', 669 maxwidth=200, 670 scale=0.8, 671 color=(0.6, 0.56, 0.6), 672 position=(self._c_width * 0.5 - 210, v), 673 text=bui.Lstr(resource='playlistText'), 674 ) 675 self._host_playlist_button = bui.buttonwidget( 676 parent=self._container, 677 size=(400, 70), 678 color=(0.6, 0.5, 0.6), 679 textcolor=(0.8, 0.75, 0.8), 680 label=self._hostingconfig.playlist_name, 681 on_activate_call=self._playlist_press, 682 position=(self._c_width * 0.5 - 200, v - 35), 683 up_widget=self._host_sub_tab_text, 684 autoselect=True, 685 ) 686 687 # If it appears we're coming back from playlist selection, 688 # re-select our playlist button. 689 if classic.selecting_private_party_playlist: 690 bui.containerwidget( 691 edit=self._container, 692 selected_child=self._host_playlist_button, 693 ) 694 classic.selecting_private_party_playlist = False 695 else: 696 # We've got a current party; show its info. 697 bui.textwidget( 698 parent=self._container, 699 size=(0, 0), 700 h_align='center', 701 v_align='center', 702 maxwidth=600, 703 scale=0.9, 704 color=(0.7, 0.64, 0.7), 705 position=(self._c_width * 0.5, v + 90), 706 text=bui.Lstr(resource='gatherWindow.partyServerRunningText'), 707 ) 708 bui.textwidget( 709 parent=self._container, 710 size=(0, 0), 711 h_align='center', 712 v_align='center', 713 maxwidth=600, 714 scale=0.7, 715 color=(0.7, 0.64, 0.7), 716 position=(self._c_width * 0.5, v + 50), 717 text=bui.Lstr(resource='gatherWindow.partyCodeText'), 718 ) 719 bui.textwidget( 720 parent=self._container, 721 size=(0, 0), 722 h_align='center', 723 v_align='center', 724 scale=2.0, 725 color=(0.0, 1.0, 0.0), 726 position=(self._c_width * 0.5, v + 10), 727 text=hostingstate.party_code, 728 ) 729 730 # Also action buttons to copy it and connect to it. 731 if bui.clipboard_is_supported(): 732 cbtnoffs = 10 733 self._host_copy_button = bui.buttonwidget( 734 parent=self._container, 735 size=(140, 40), 736 color=(0.6, 0.5, 0.6), 737 textcolor=(0.8, 0.75, 0.8), 738 label=bui.Lstr(resource='gatherWindow.copyCodeText'), 739 on_activate_call=self._host_copy_press, 740 position=(self._c_width * 0.5 - 150, v - 70), 741 autoselect=True, 742 ) 743 else: 744 cbtnoffs = -70 745 self._host_connect_button = bui.buttonwidget( 746 parent=self._container, 747 size=(140, 40), 748 color=(0.6, 0.5, 0.6), 749 textcolor=(0.8, 0.75, 0.8), 750 label=bui.Lstr(resource='gatherWindow.manualConnectText'), 751 on_activate_call=self._host_connect_press, 752 position=(self._c_width * 0.5 + cbtnoffs, v - 70), 753 autoselect=True, 754 ) 755 756 v -= 110 757 758 # Line above the main action button: 759 760 # If we don't want to show anything until we get a state: 761 if self._waiting_for_initial_state: 762 pass 763 elif hostingstate.unavailable_error is not None: 764 # If hosting is unavailable, show the associated reason. 765 bui.textwidget( 766 parent=self._container, 767 size=(0, 0), 768 h_align='center', 769 v_align='center', 770 maxwidth=self._c_width * 0.9, 771 scale=0.7, 772 flatness=1.0, 773 color=(1.0, 0.0, 0.0), 774 position=(self._c_width * 0.5, v), 775 text=bui.Lstr( 776 translate=( 777 'serverResponses', 778 hostingstate.unavailable_error, 779 ) 780 ), 781 ) 782 elif havegoldpass: 783 # If we have a gold pass, none of the 784 # timing/free-server-availability info below is relevant to 785 # us. 786 pass 787 elif hostingstate.free_host_minutes_remaining is not None: 788 # If we've been pre-approved to start/stop for free, show 789 # that. 790 bui.textwidget( 791 parent=self._container, 792 size=(0, 0), 793 h_align='center', 794 v_align='center', 795 maxwidth=self._c_width * 0.9, 796 scale=0.7, 797 flatness=1.0, 798 color=( 799 (0.7, 0.64, 0.7) 800 if hostingstate.party_code 801 else (0.0, 1.0, 0.0) 802 ), 803 position=(self._c_width * 0.5, v), 804 text=bui.Lstr( 805 resource='gatherWindow.startStopHostingMinutesText', 806 subs=[ 807 ( 808 '${MINUTES}', 809 f'{hostingstate.free_host_minutes_remaining:.0f}', 810 ) 811 ], 812 ), 813 ) 814 else: 815 # Otherwise tell whether the free cloud server is available 816 # or will be at some point. 817 if hostingstate.party_code is None: 818 if hostingstate.tickets_to_host_now == 0: 819 bui.textwidget( 820 parent=self._container, 821 size=(0, 0), 822 h_align='center', 823 v_align='center', 824 maxwidth=self._c_width * 0.9, 825 scale=0.7, 826 flatness=1.0, 827 color=(0.0, 1.0, 0.0), 828 position=(self._c_width * 0.5, v), 829 text=bui.Lstr( 830 resource=( 831 'gatherWindow.freeCloudServerAvailableNowText' 832 ) 833 ), 834 ) 835 else: 836 if hostingstate.minutes_until_free_host is None: 837 bui.textwidget( 838 parent=self._container, 839 size=(0, 0), 840 h_align='center', 841 v_align='center', 842 maxwidth=self._c_width * 0.9, 843 scale=0.7, 844 flatness=1.0, 845 color=(1.0, 0.6, 0.0), 846 position=(self._c_width * 0.5, v), 847 text=bui.Lstr( 848 resource=( 849 'gatherWindow' 850 '.freeCloudServerNotAvailableText' 851 ) 852 ), 853 ) 854 else: 855 availmins = hostingstate.minutes_until_free_host 856 bui.textwidget( 857 parent=self._container, 858 size=(0, 0), 859 h_align='center', 860 v_align='center', 861 maxwidth=self._c_width * 0.9, 862 scale=0.7, 863 flatness=1.0, 864 color=(1.0, 0.6, 0.0), 865 position=(self._c_width * 0.5, v), 866 text=bui.Lstr( 867 resource='gatherWindow.' 868 'freeCloudServerAvailableMinutesText', 869 subs=[('${MINUTES}', f'{availmins:.0f}')], 870 ), 871 ) 872 873 v -= 100 874 875 if ( 876 self._waiting_for_start_stop_response 877 or self._waiting_for_initial_state 878 ): 879 btnlabel = bui.Lstr(resource='oneMomentText') 880 else: 881 if hostingstate.unavailable_error is not None: 882 btnlabel = bui.Lstr( 883 resource='gatherWindow.hostingUnavailableText' 884 ) 885 elif hostingstate.party_code is None: 886 ticon = bui.charstr(bui.SpecialChar.TOKEN) 887 nowtokens = hostingstate.tokens_to_host_now 888 if nowtokens > 0 and not havegoldpass: 889 btnlabel = bui.Lstr( 890 resource='gatherWindow.startHostingPaidText', 891 subs=[('${COST}', f'{ticon}{nowtokens}')], 892 ) 893 else: 894 btnlabel = bui.Lstr( 895 resource='gatherWindow.startHostingText' 896 ) 897 else: 898 btnlabel = bui.Lstr(resource='gatherWindow.stopHostingText') 899 900 disabled = ( 901 hostingstate.unavailable_error is not None 902 or self._waiting_for_initial_state 903 ) 904 waiting = self._waiting_for_start_stop_response 905 self._host_start_stop_button = bui.buttonwidget( 906 parent=self._container, 907 size=(400, 80), 908 color=( 909 (0.6, 0.6, 0.6) 910 if disabled 911 else (0.5, 1.0, 0.5) if waiting else None 912 ), 913 enable_sound=False, 914 label=btnlabel, 915 textcolor=((0.7, 0.7, 0.7) if disabled else None), 916 position=(self._c_width * 0.5 - 200, v), 917 on_activate_call=self._start_stop_button_press, 918 autoselect=True, 919 ) 920 921 def _playlist_press(self) -> None: 922 if bool(True): 923 bui.screenmessage('UNDER CONSTRUCTION') 924 return 925 assert self._host_playlist_button is not None 926 self.window.playlist_select(origin_widget=self._host_playlist_button) 927 928 def _host_copy_press(self) -> None: 929 assert self._hostingstate.party_code is not None 930 bui.clipboard_set_text(self._hostingstate.party_code) 931 bui.screenmessage(bui.Lstr(resource='gatherWindow.copyCodeConfirmText')) 932 933 def _host_connect_press(self) -> None: 934 assert self._hostingstate.party_code is not None 935 self._connect_to_party_code(self._hostingstate.party_code) 936 937 def _debug_server_comm(self, msg: str) -> None: 938 if DEBUG_SERVER_COMMUNICATION: 939 print( 940 f'PPTABCOM: {msg} at time ' 941 f'{time.time()-self._create_time:.2f}' 942 ) 943 944 def _connect_to_party_code(self, code: str) -> None: 945 # Ignore attempted followup sends for a few seconds. (this will 946 # reset if we get a response) 947 plus = bui.app.plus 948 assert plus is not None 949 950 now = time.time() 951 if ( 952 self._connect_press_time is not None 953 and now - self._connect_press_time < 5.0 954 ): 955 self._debug_server_comm( 956 'not sending private party connect (too soon)' 957 ) 958 return 959 self._connect_press_time = now 960 961 self._debug_server_comm('sending private party connect') 962 plus.add_v1_account_transaction( 963 { 964 'type': 'PRIVATE_PARTY_CONNECT', 965 'expire_time': time.time() + 20, 966 'code': code, 967 }, 968 callback=bui.WeakCall(self._connect_response), 969 ) 970 plus.run_v1_account_transactions() 971 972 def _start_stop_button_press(self) -> None: 973 plus = bui.app.plus 974 assert plus is not None 975 if ( 976 self._waiting_for_start_stop_response 977 or self._waiting_for_initial_state 978 ): 979 return 980 981 if plus.get_v1_account_state() != 'signed_in': 982 bui.screenmessage(bui.Lstr(resource='notSignedInErrorText')) 983 bui.getsound('error').play() 984 self._refresh_sub_tab() 985 return 986 987 if self._hostingstate.unavailable_error is not None: 988 bui.getsound('error').play() 989 return 990 991 bui.getsound('click01').play() 992 993 # We need our v2 info for this. 994 if self._v2state is None or self._v2state.datacode is None: 995 bui.screenmessage( 996 bui.Lstr(resource='internal.unavailableNoConnectionText'), 997 color=(1, 0, 0), 998 ) 999 bui.getsound('error').play() 1000 return 1001 1002 # If we're not hosting, start. 1003 if self._hostingstate.party_code is None: 1004 # If there's a token cost, make sure we have enough tokens 1005 # or a gold pass. 1006 if self._hostingstate.tokens_to_host_now > 0: 1007 1008 if ( 1009 not self._v2state.gold_pass 1010 and self._v2state.tokens 1011 < self._hostingstate.tokens_to_host_now 1012 ): 1013 show_get_tokens_prompt() 1014 bui.getsound('error').play() 1015 return 1016 1017 self._last_action_send_time = time.time() 1018 plus.add_v1_account_transaction( 1019 { 1020 'type': 'PRIVATE_PARTY_START', 1021 'config': dataclass_to_dict(self._hostingconfig), 1022 'region_pings': bui.app.net.zone_pings, 1023 'expire_time': time.time() + 20, 1024 'datacode': self._v2state.datacode, 1025 }, 1026 callback=bui.WeakCall(self._hosting_state_response), 1027 ) 1028 plus.run_v1_account_transactions() 1029 1030 else: 1031 self._last_action_send_time = time.time() 1032 plus.add_v1_account_transaction( 1033 { 1034 'type': 'PRIVATE_PARTY_STOP', 1035 'expire_time': time.time() + 20, 1036 }, 1037 callback=bui.WeakCall(self._hosting_state_response), 1038 ) 1039 plus.run_v1_account_transactions() 1040 bui.getsound('click01').play() 1041 1042 self._waiting_for_start_stop_response = True 1043 self._refresh_sub_tab() 1044 1045 def _join_connect_press(self) -> None: 1046 # Error immediately if its an empty code. 1047 code: str | None = None 1048 if self._join_party_code_text: 1049 code = cast(str, bui.textwidget(query=self._join_party_code_text)) 1050 if not code: 1051 bui.screenmessage( 1052 bui.Lstr(translate=('serverResponses', 'Invalid code.')), 1053 color=(1, 0, 0), 1054 ) 1055 bui.getsound('error').play() 1056 return 1057 1058 self._connect_to_party_code(code) 1059 1060 def _connect_response(self, result: dict[str, Any] | None) -> None: 1061 try: 1062 self._connect_press_time = None 1063 if result is None: 1064 raise RuntimeError() 1065 cresult = dataclass_from_dict( 1066 PrivatePartyConnectResult, result, discard_unknown_attrs=True 1067 ) 1068 if cresult.error is not None: 1069 self._debug_server_comm('got error connect response') 1070 bui.screenmessage( 1071 bui.Lstr(translate=('serverResponses', cresult.error)), 1072 (1, 0, 0), 1073 ) 1074 bui.getsound('error').play() 1075 return 1076 self._debug_server_comm('got valid connect response') 1077 assert cresult.address4 is not None and cresult.port is not None 1078 bs.connect_to_party(cresult.address4, port=cresult.port) 1079 except Exception: 1080 self._debug_server_comm('got connect response error') 1081 bui.getsound('error').play() 1082 1083 @override 1084 def save_state(self) -> None: 1085 assert bui.app.classic is not None 1086 bui.app.ui_v1.window_states[type(self)] = copy.deepcopy(self._state) 1087 1088 @override 1089 def restore_state(self) -> None: 1090 assert bui.app.classic is not None 1091 state = bui.app.ui_v1.window_states.get(type(self)) 1092 if state is None: 1093 state = State() 1094 assert isinstance(state, State) 1095 self._state = state
The private tab in the gather UI
PrivateGatherTab(window: bauiv1lib.gather.GatherWindow)
58 def __init__(self, window: GatherWindow) -> None: 59 super().__init__(window) 60 self._container: bui.Widget | None = None 61 self._state: State = State() 62 self._last_datacode_refresh_time: float | None = None 63 self._hostingstate = PrivateHostingState() 64 self._v2state: bacommon.cloud.BSPrivatePartyResponse | None = None 65 self._join_sub_tab_text: bui.Widget | None = None 66 self._host_sub_tab_text: bui.Widget | None = None 67 self._update_timer: bui.AppTimer | None = None 68 self._join_party_code_text: bui.Widget | None = None 69 self._c_width: float = 0.0 70 self._c_height: float = 0.0 71 self._last_hosting_state_query_time: float | None = None 72 self._last_v2_state_query_time: float | None = None 73 self._waiting_for_initial_state = True 74 self._waiting_for_start_stop_response = True 75 self._host_playlist_button: bui.Widget | None = None 76 self._host_copy_button: bui.Widget | None = None 77 self._host_connect_button: bui.Widget | None = None 78 self._host_start_stop_button: bui.Widget | None = None 79 self._get_tokens_button: bui.Widget | None = None 80 self._token_count_text: bui.Widget | None = None 81 self._showing_not_signed_in_screen = False 82 self._create_time = time.time() 83 self._last_action_send_time: float | None = None 84 self._connect_press_time: float | None = None 85 try: 86 self._hostingconfig = self._build_hosting_config() 87 except Exception: 88 logging.exception('Error building hosting config.') 89 self._hostingconfig = PrivateHostingConfig()
@override
def
on_activate( self, parent_widget: _bauiv1.Widget, tab_button: _bauiv1.Widget, region_width: float, region_height: float, region_left: float, region_bottom: float) -> _bauiv1.Widget:
91 @override 92 def on_activate( 93 self, 94 parent_widget: bui.Widget, 95 tab_button: bui.Widget, 96 region_width: float, 97 region_height: float, 98 region_left: float, 99 region_bottom: float, 100 ) -> bui.Widget: 101 self._c_width = region_width 102 self._c_height = region_height - 20 103 self._container = bui.containerwidget( 104 parent=parent_widget, 105 position=( 106 region_left, 107 region_bottom + (region_height - self._c_height) * 0.5, 108 ), 109 size=(self._c_width, self._c_height), 110 background=False, 111 selection_loops_to_parent=True, 112 ) 113 v = self._c_height - 30.0 114 self._join_sub_tab_text = bui.textwidget( 115 parent=self._container, 116 position=(self._c_width * 0.5 - 245, v - 13), 117 color=(0.6, 1.0, 0.6), 118 scale=1.3, 119 size=(200, 30), 120 maxwidth=250, 121 h_align='left', 122 v_align='center', 123 click_activate=True, 124 selectable=True, 125 autoselect=True, 126 on_activate_call=lambda: self._set_sub_tab( 127 SubTabType.JOIN, 128 playsound=True, 129 ), 130 text=bui.Lstr(resource='gatherWindow.privatePartyJoinText'), 131 glow_type='uniform', 132 ) 133 self._host_sub_tab_text = bui.textwidget( 134 parent=self._container, 135 position=(self._c_width * 0.5 + 45, v - 13), 136 color=(0.6, 1.0, 0.6), 137 scale=1.3, 138 size=(200, 30), 139 maxwidth=250, 140 h_align='left', 141 v_align='center', 142 click_activate=True, 143 selectable=True, 144 autoselect=True, 145 on_activate_call=lambda: self._set_sub_tab( 146 SubTabType.HOST, 147 playsound=True, 148 ), 149 text=bui.Lstr(resource='gatherWindow.privatePartyHostText'), 150 glow_type='uniform', 151 ) 152 bui.widget(edit=self._join_sub_tab_text, up_widget=tab_button) 153 bui.widget( 154 edit=self._host_sub_tab_text, 155 left_widget=self._join_sub_tab_text, 156 up_widget=tab_button, 157 ) 158 bui.widget( 159 edit=self._join_sub_tab_text, right_widget=self._host_sub_tab_text 160 ) 161 162 self._update_timer = bui.AppTimer( 163 1.0, bui.WeakCall(self._update), repeat=True 164 ) 165 166 # Prevent taking any action until we've updated our state. 167 self._waiting_for_initial_state = True 168 169 # Force some immediate refreshes. 170 self._last_datacode_refresh_time = None 171 self._last_v2_state_query_time = None 172 self._last_action_send_time = None # Ensure we don't ignore response. 173 self._last_hosting_state_query_time = None 174 self._update() 175 176 self._set_sub_tab(self._state.sub_tab) 177 178 return self._container
Called when the tab becomes the active one.
The tab should create and return a container widget covering the specified region.
@override
def
save_state(self) -> None:
1083 @override 1084 def save_state(self) -> None: 1085 assert bui.app.classic is not None 1086 bui.app.ui_v1.window_states[type(self)] = copy.deepcopy(self._state)
Called when the parent window is saving state.
@override
def
restore_state(self) -> None:
1088 @override 1089 def restore_state(self) -> None: 1090 assert bui.app.classic is not None 1091 state = bui.app.ui_v1.window_states.get(type(self)) 1092 if state is None: 1093 state = State() 1094 assert isinstance(state, State) 1095 self._state = state
Called when the parent window is restoring state.