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