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.cloud.BSPrivatePartyResponse | 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.cloud.BSPrivatePartyMessage( 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.cloud.BSPrivatePartyResponse | 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 _on_get_tokens_press(self) -> None: 555 # if self._waiting_for_start_stop_response: 556 # return 557 558 # # Bring up get-tickets window and then kill ourself (we're on 559 # # the overlay layer so we'd show up above it). 560 # GetTokensWindow(origin_widget=self._get_tokens_button) 561 562 def _build_host_tab(self) -> None: 563 # pylint: disable=too-many-branches 564 # pylint: disable=too-many-statements 565 classic = bui.app.classic 566 assert classic is not None 567 568 plus = bui.app.plus 569 assert plus is not None 570 571 hostingstate = self._hostingstate 572 573 havegoldpass = self._v2state is not None and self._v2state.gold_pass 574 575 # We use both v1 and v2 account functionality here (sigh). So 576 # make sure we're signed in on both ends. 577 578 # Make sure the V1 side is good to go. 579 if plus.get_v1_account_state() != 'signed_in': 580 bui.textwidget( 581 parent=self._container, 582 size=(0, 0), 583 h_align='center', 584 v_align='center', 585 maxwidth=self._c_width * 0.8, 586 scale=0.8, 587 color=(0.6, 0.56, 0.6), 588 position=(self._c_width * 0.5, self._c_height * 0.5), 589 text=bui.Lstr(resource='notSignedInErrorText'), 590 ) 591 self._showing_not_signed_in_screen = True 592 return 593 594 # Make sure the V2 side is good to go. 595 if plus.accounts.primary is None: 596 bui.textwidget( 597 parent=self._container, 598 size=(0, 0), 599 h_align='center', 600 v_align='center', 601 maxwidth=self._c_width * 0.8, 602 scale=0.8, 603 color=(0.6, 0.56, 0.6), 604 position=(self._c_width * 0.5, self._c_height * 0.5), 605 text=bui.Lstr(resource='v2AccountRequiredText'), 606 ) 607 self._showing_not_signed_in_screen = True 608 return 609 610 self._showing_not_signed_in_screen = False 611 612 # At first we don't want to show anything until we've gotten a 613 # state. Update: In this situation we now simply show our 614 # existing state but give the start/stop button a loading 615 # message and disallow its use. This keeps things a lot less 616 # jumpy looking and allows selecting playlists/etc without 617 # having to wait for the server each time back to the ui. 618 if self._waiting_for_initial_state and bool(False): 619 bui.textwidget( 620 parent=self._container, 621 size=(0, 0), 622 h_align='center', 623 v_align='center', 624 maxwidth=200, 625 scale=0.8, 626 color=(0.6, 0.56, 0.6), 627 position=(self._c_width * 0.5, self._c_height * 0.5), 628 text=bui.Lstr( 629 value='${A}...', 630 subs=[('${A}', bui.Lstr(resource='store.loadingText'))], 631 ), 632 ) 633 return 634 635 # If we're not currently hosting and hosting requires tokens, 636 # Show our count (possibly with a link to purchase more). 637 if ( 638 not self._waiting_for_initial_state 639 and hostingstate.party_code is None 640 and hostingstate.tickets_to_host_now != 0 641 and not havegoldpass 642 ): 643 pass 644 645 v = self._c_height - 90 646 if hostingstate.party_code is None: 647 bui.textwidget( 648 parent=self._container, 649 size=(0, 0), 650 h_align='center', 651 v_align='center', 652 maxwidth=self._c_width * 0.9, 653 scale=0.7, 654 flatness=1.0, 655 color=(0.5, 0.46, 0.5), 656 position=(self._c_width * 0.5, v), 657 text=bui.Lstr( 658 resource='gatherWindow.privatePartyCloudDescriptionText' 659 ), 660 ) 661 662 v -= 90 663 if hostingstate.party_code is None: 664 # We've got no current party running; show options to set 665 # one up. 666 bui.textwidget( 667 parent=self._container, 668 size=(0, 0), 669 h_align='right', 670 v_align='center', 671 maxwidth=200, 672 scale=0.8, 673 color=(0.6, 0.56, 0.6), 674 position=(self._c_width * 0.5 - 210, v), 675 text=bui.Lstr(resource='playlistText'), 676 ) 677 self._host_playlist_button = bui.buttonwidget( 678 parent=self._container, 679 size=(400, 70), 680 color=(0.6, 0.5, 0.6), 681 textcolor=(0.8, 0.75, 0.8), 682 label=self._hostingconfig.playlist_name, 683 on_activate_call=self._playlist_press, 684 position=(self._c_width * 0.5 - 200, v - 35), 685 up_widget=self._host_sub_tab_text, 686 autoselect=True, 687 ) 688 689 # If it appears we're coming back from playlist selection, 690 # re-select our playlist button. 691 if self._state.playlist_select_context is not None: 692 self._state.playlist_select_context = None 693 bui.containerwidget( 694 edit=self._container, 695 selected_child=self._host_playlist_button, 696 ) 697 698 else: 699 # We've got a current party; show its info. 700 bui.textwidget( 701 parent=self._container, 702 size=(0, 0), 703 h_align='center', 704 v_align='center', 705 maxwidth=600, 706 scale=0.9, 707 color=(0.7, 0.64, 0.7), 708 position=(self._c_width * 0.5, v + 90), 709 text=bui.Lstr(resource='gatherWindow.partyServerRunningText'), 710 ) 711 bui.textwidget( 712 parent=self._container, 713 size=(0, 0), 714 h_align='center', 715 v_align='center', 716 maxwidth=600, 717 scale=0.7, 718 color=(0.7, 0.64, 0.7), 719 position=(self._c_width * 0.5, v + 50), 720 text=bui.Lstr(resource='gatherWindow.partyCodeText'), 721 ) 722 bui.textwidget( 723 parent=self._container, 724 size=(0, 0), 725 h_align='center', 726 v_align='center', 727 scale=2.0, 728 color=(0.0, 1.0, 0.0), 729 position=(self._c_width * 0.5, v + 10), 730 text=hostingstate.party_code, 731 ) 732 733 # Also action buttons to copy it and connect to it. 734 if bui.clipboard_is_supported(): 735 cbtnoffs = 10 736 self._host_copy_button = bui.buttonwidget( 737 parent=self._container, 738 size=(140, 40), 739 color=(0.6, 0.5, 0.6), 740 textcolor=(0.8, 0.75, 0.8), 741 label=bui.Lstr(resource='gatherWindow.copyCodeText'), 742 on_activate_call=self._host_copy_press, 743 position=(self._c_width * 0.5 - 150, v - 70), 744 autoselect=True, 745 ) 746 else: 747 cbtnoffs = -70 748 self._host_connect_button = bui.buttonwidget( 749 parent=self._container, 750 size=(140, 40), 751 color=(0.6, 0.5, 0.6), 752 textcolor=(0.8, 0.75, 0.8), 753 label=bui.Lstr(resource='gatherWindow.manualConnectText'), 754 on_activate_call=self._host_connect_press, 755 position=(self._c_width * 0.5 + cbtnoffs, v - 70), 756 autoselect=True, 757 ) 758 759 v -= 110 760 761 # Line above the main action button: 762 763 # If we don't want to show anything until we get a state: 764 if self._waiting_for_initial_state: 765 pass 766 elif hostingstate.unavailable_error is not None: 767 # If hosting is unavailable, show the associated reason. 768 bui.textwidget( 769 parent=self._container, 770 size=(0, 0), 771 h_align='center', 772 v_align='center', 773 maxwidth=self._c_width * 0.9, 774 scale=0.7, 775 flatness=1.0, 776 color=(1.0, 0.0, 0.0), 777 position=(self._c_width * 0.5, v), 778 text=bui.Lstr( 779 translate=( 780 'serverResponses', 781 hostingstate.unavailable_error, 782 ) 783 ), 784 ) 785 elif havegoldpass: 786 # If we have a gold pass, none of the 787 # timing/free-server-availability info below is relevant to 788 # us. 789 pass 790 elif hostingstate.free_host_minutes_remaining is not None: 791 # If we've been pre-approved to start/stop for free, show 792 # that. 793 bui.textwidget( 794 parent=self._container, 795 size=(0, 0), 796 h_align='center', 797 v_align='center', 798 maxwidth=self._c_width * 0.9, 799 scale=0.7, 800 flatness=1.0, 801 color=( 802 (0.7, 0.64, 0.7) 803 if hostingstate.party_code 804 else (0.0, 1.0, 0.0) 805 ), 806 position=(self._c_width * 0.5, v), 807 text=bui.Lstr( 808 resource='gatherWindow.startStopHostingMinutesText', 809 subs=[ 810 ( 811 '${MINUTES}', 812 f'{hostingstate.free_host_minutes_remaining:.0f}', 813 ) 814 ], 815 ), 816 ) 817 else: 818 # Otherwise tell whether the free cloud server is available 819 # or will be at some point. 820 if hostingstate.party_code is None: 821 if hostingstate.tickets_to_host_now == 0: 822 bui.textwidget( 823 parent=self._container, 824 size=(0, 0), 825 h_align='center', 826 v_align='center', 827 maxwidth=self._c_width * 0.9, 828 scale=0.7, 829 flatness=1.0, 830 color=(0.0, 1.0, 0.0), 831 position=(self._c_width * 0.5, v), 832 text=bui.Lstr( 833 resource=( 834 'gatherWindow.freeCloudServerAvailableNowText' 835 ) 836 ), 837 ) 838 else: 839 if hostingstate.minutes_until_free_host is None: 840 bui.textwidget( 841 parent=self._container, 842 size=(0, 0), 843 h_align='center', 844 v_align='center', 845 maxwidth=self._c_width * 0.9, 846 scale=0.7, 847 flatness=1.0, 848 color=(1.0, 0.6, 0.0), 849 position=(self._c_width * 0.5, v), 850 text=bui.Lstr( 851 resource=( 852 'gatherWindow' 853 '.freeCloudServerNotAvailableText' 854 ) 855 ), 856 ) 857 else: 858 availmins = hostingstate.minutes_until_free_host 859 bui.textwidget( 860 parent=self._container, 861 size=(0, 0), 862 h_align='center', 863 v_align='center', 864 maxwidth=self._c_width * 0.9, 865 scale=0.7, 866 flatness=1.0, 867 color=(1.0, 0.6, 0.0), 868 position=(self._c_width * 0.5, v), 869 text=bui.Lstr( 870 resource='gatherWindow.' 871 'freeCloudServerAvailableMinutesText', 872 subs=[('${MINUTES}', f'{availmins:.0f}')], 873 ), 874 ) 875 876 v -= 100 877 878 if ( 879 self._waiting_for_start_stop_response 880 or self._waiting_for_initial_state 881 ): 882 btnlabel = bui.Lstr(resource='oneMomentText') 883 else: 884 if hostingstate.unavailable_error is not None: 885 btnlabel = bui.Lstr( 886 resource='gatherWindow.hostingUnavailableText' 887 ) 888 elif hostingstate.party_code is None: 889 ticon = bui.charstr(bui.SpecialChar.TOKEN) 890 nowtokens = hostingstate.tokens_to_host_now 891 if nowtokens > 0 and not havegoldpass: 892 btnlabel = bui.Lstr( 893 resource='gatherWindow.startHostingPaidText', 894 subs=[('${COST}', f'{ticon}{nowtokens}')], 895 ) 896 else: 897 btnlabel = bui.Lstr( 898 resource='gatherWindow.startHostingText' 899 ) 900 else: 901 btnlabel = bui.Lstr(resource='gatherWindow.stopHostingText') 902 903 disabled = ( 904 hostingstate.unavailable_error is not None 905 or self._waiting_for_initial_state 906 ) 907 waiting = self._waiting_for_start_stop_response 908 self._host_start_stop_button = bui.buttonwidget( 909 parent=self._container, 910 size=(400, 80), 911 color=( 912 (0.6, 0.6, 0.6) 913 if disabled 914 else (0.5, 1.0, 0.5) if waiting else None 915 ), 916 enable_sound=False, 917 label=btnlabel, 918 textcolor=((0.7, 0.7, 0.7) if disabled else None), 919 position=(self._c_width * 0.5 - 200, v), 920 on_activate_call=self._start_stop_button_press, 921 autoselect=True, 922 ) 923 924 def _playlist_press(self) -> None: 925 assert self._host_playlist_button is not None 926 927 self._state.playlist_select_context = PlaylistSelectContext() 928 929 self.window.playlist_select( 930 origin_widget=self._host_playlist_button, 931 context=self._state.playlist_select_context, 932 ) 933 934 def _host_copy_press(self) -> None: 935 assert self._hostingstate.party_code is not None 936 bui.clipboard_set_text(self._hostingstate.party_code) 937 bui.screenmessage(bui.Lstr(resource='gatherWindow.copyCodeConfirmText')) 938 939 def _host_connect_press(self) -> None: 940 assert self._hostingstate.party_code is not None 941 self._connect_to_party_code(self._hostingstate.party_code) 942 943 def _debug_server_comm(self, msg: str) -> None: 944 if DEBUG_SERVER_COMMUNICATION: 945 print( 946 f'PPTABCOM: {msg} at time ' 947 f'{time.time()-self._create_time:.2f}' 948 ) 949 950 def _connect_to_party_code(self, code: str) -> None: 951 # Ignore attempted followup sends for a few seconds. (this will 952 # reset if we get a response) 953 plus = bui.app.plus 954 assert plus is not None 955 956 now = time.time() 957 if ( 958 self._connect_press_time is not None 959 and now - self._connect_press_time < 5.0 960 ): 961 self._debug_server_comm( 962 'not sending private party connect (too soon)' 963 ) 964 return 965 self._connect_press_time = now 966 967 self._debug_server_comm('sending private party connect') 968 plus.add_v1_account_transaction( 969 { 970 'type': 'PRIVATE_PARTY_CONNECT', 971 'expire_time': time.time() + 20, 972 'code': code, 973 }, 974 callback=bui.WeakCall(self._connect_response), 975 ) 976 plus.run_v1_account_transactions() 977 978 def _start_stop_button_press(self) -> None: 979 plus = bui.app.plus 980 assert plus is not None 981 if ( 982 self._waiting_for_start_stop_response 983 or self._waiting_for_initial_state 984 ): 985 return 986 987 if plus.get_v1_account_state() != 'signed_in': 988 bui.screenmessage(bui.Lstr(resource='notSignedInErrorText')) 989 bui.getsound('error').play() 990 self._refresh_sub_tab() 991 return 992 993 if self._hostingstate.unavailable_error is not None: 994 bui.getsound('error').play() 995 return 996 997 bui.getsound('click01').play() 998 999 # We need our v2 info for this. 1000 if self._v2state is None or self._v2state.datacode is None: 1001 bui.screenmessage( 1002 bui.Lstr(resource='internal.unavailableNoConnectionText'), 1003 color=(1, 0, 0), 1004 ) 1005 bui.getsound('error').play() 1006 return 1007 1008 # If we're not hosting, start. 1009 if self._hostingstate.party_code is None: 1010 # If there's a token cost, make sure we have enough tokens 1011 # or a gold pass. 1012 if self._hostingstate.tokens_to_host_now > 0: 1013 1014 if ( 1015 not self._v2state.gold_pass 1016 and self._v2state.tokens 1017 < self._hostingstate.tokens_to_host_now 1018 ): 1019 show_get_tokens_prompt() 1020 bui.getsound('error').play() 1021 return 1022 1023 self._last_action_send_time = time.time() 1024 plus.add_v1_account_transaction( 1025 { 1026 'type': 'PRIVATE_PARTY_START', 1027 'config': dataclass_to_dict(self._hostingconfig), 1028 'region_pings': bui.app.net.zone_pings, 1029 'expire_time': time.time() + 20, 1030 'datacode': self._v2state.datacode, 1031 }, 1032 callback=bui.WeakCall(self._hosting_state_response), 1033 ) 1034 plus.run_v1_account_transactions() 1035 1036 else: 1037 self._last_action_send_time = time.time() 1038 plus.add_v1_account_transaction( 1039 { 1040 'type': 'PRIVATE_PARTY_STOP', 1041 'expire_time': time.time() + 20, 1042 }, 1043 callback=bui.WeakCall(self._hosting_state_response), 1044 ) 1045 plus.run_v1_account_transactions() 1046 bui.getsound('click01').play() 1047 1048 self._waiting_for_start_stop_response = True 1049 self._refresh_sub_tab() 1050 1051 def _join_connect_press(self) -> None: 1052 # Error immediately if its an empty code. 1053 code: str | None = None 1054 if self._join_party_code_text: 1055 code = cast(str, bui.textwidget(query=self._join_party_code_text)) 1056 if not code: 1057 bui.screenmessage( 1058 bui.Lstr(translate=('serverResponses', 'Invalid code.')), 1059 color=(1, 0, 0), 1060 ) 1061 bui.getsound('error').play() 1062 return 1063 1064 self._connect_to_party_code(code) 1065 1066 def _connect_response(self, result: dict[str, Any] | None) -> None: 1067 try: 1068 self._connect_press_time = None 1069 if result is None: 1070 raise RuntimeError() 1071 cresult = dataclass_from_dict( 1072 PrivatePartyConnectResult, result, discard_unknown_attrs=True 1073 ) 1074 if cresult.error is not None: 1075 self._debug_server_comm('got error connect response') 1076 bui.screenmessage( 1077 bui.Lstr(translate=('serverResponses', cresult.error)), 1078 (1, 0, 0), 1079 ) 1080 bui.getsound('error').play() 1081 return 1082 self._debug_server_comm('got valid connect response') 1083 assert cresult.address4 is not None and cresult.port is not None 1084 1085 # Store UI location to return to when done. 1086 if bs.app.classic is not None: 1087 bs.app.classic.save_ui_state() 1088 1089 bs.connect_to_party(cresult.address4, port=cresult.port) 1090 except Exception: 1091 self._debug_server_comm('got connect response error') 1092 bui.getsound('error').play() 1093 1094 @override 1095 def save_state(self) -> None: 1096 assert bui.app.classic is not None 1097 bui.app.ui_v1.window_states[type(self)] = copy.deepcopy(self._state) 1098 1099 @override 1100 def restore_state(self) -> None: 1101 assert bui.app.classic is not None 1102 state = bui.app.ui_v1.window_states.get(type(self)) 1103 if state is None: 1104 state = State() 1105 assert isinstance(state, State) 1106 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.cloud.BSPrivatePartyResponse | 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.cloud.BSPrivatePartyMessage( 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.cloud.BSPrivatePartyResponse | 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 _on_get_tokens_press(self) -> None: 556 # if self._waiting_for_start_stop_response: 557 # return 558 559 # # Bring up get-tickets window and then kill ourself (we're on 560 # # the overlay layer so we'd show up above it). 561 # GetTokensWindow(origin_widget=self._get_tokens_button) 562 563 def _build_host_tab(self) -> None: 564 # pylint: disable=too-many-branches 565 # pylint: disable=too-many-statements 566 classic = bui.app.classic 567 assert classic is not None 568 569 plus = bui.app.plus 570 assert plus is not None 571 572 hostingstate = self._hostingstate 573 574 havegoldpass = self._v2state is not None and self._v2state.gold_pass 575 576 # We use both v1 and v2 account functionality here (sigh). So 577 # make sure we're signed in on both ends. 578 579 # Make sure the V1 side is good to go. 580 if plus.get_v1_account_state() != 'signed_in': 581 bui.textwidget( 582 parent=self._container, 583 size=(0, 0), 584 h_align='center', 585 v_align='center', 586 maxwidth=self._c_width * 0.8, 587 scale=0.8, 588 color=(0.6, 0.56, 0.6), 589 position=(self._c_width * 0.5, self._c_height * 0.5), 590 text=bui.Lstr(resource='notSignedInErrorText'), 591 ) 592 self._showing_not_signed_in_screen = True 593 return 594 595 # Make sure the V2 side is good to go. 596 if plus.accounts.primary is None: 597 bui.textwidget( 598 parent=self._container, 599 size=(0, 0), 600 h_align='center', 601 v_align='center', 602 maxwidth=self._c_width * 0.8, 603 scale=0.8, 604 color=(0.6, 0.56, 0.6), 605 position=(self._c_width * 0.5, self._c_height * 0.5), 606 text=bui.Lstr(resource='v2AccountRequiredText'), 607 ) 608 self._showing_not_signed_in_screen = True 609 return 610 611 self._showing_not_signed_in_screen = False 612 613 # At first we don't want to show anything until we've gotten a 614 # state. Update: In this situation we now simply show our 615 # existing state but give the start/stop button a loading 616 # message and disallow its use. This keeps things a lot less 617 # jumpy looking and allows selecting playlists/etc without 618 # having to wait for the server each time back to the ui. 619 if self._waiting_for_initial_state and bool(False): 620 bui.textwidget( 621 parent=self._container, 622 size=(0, 0), 623 h_align='center', 624 v_align='center', 625 maxwidth=200, 626 scale=0.8, 627 color=(0.6, 0.56, 0.6), 628 position=(self._c_width * 0.5, self._c_height * 0.5), 629 text=bui.Lstr( 630 value='${A}...', 631 subs=[('${A}', bui.Lstr(resource='store.loadingText'))], 632 ), 633 ) 634 return 635 636 # If we're not currently hosting and hosting requires tokens, 637 # Show our count (possibly with a link to purchase more). 638 if ( 639 not self._waiting_for_initial_state 640 and hostingstate.party_code is None 641 and hostingstate.tickets_to_host_now != 0 642 and not havegoldpass 643 ): 644 pass 645 646 v = self._c_height - 90 647 if hostingstate.party_code is None: 648 bui.textwidget( 649 parent=self._container, 650 size=(0, 0), 651 h_align='center', 652 v_align='center', 653 maxwidth=self._c_width * 0.9, 654 scale=0.7, 655 flatness=1.0, 656 color=(0.5, 0.46, 0.5), 657 position=(self._c_width * 0.5, v), 658 text=bui.Lstr( 659 resource='gatherWindow.privatePartyCloudDescriptionText' 660 ), 661 ) 662 663 v -= 90 664 if hostingstate.party_code is None: 665 # We've got no current party running; show options to set 666 # one up. 667 bui.textwidget( 668 parent=self._container, 669 size=(0, 0), 670 h_align='right', 671 v_align='center', 672 maxwidth=200, 673 scale=0.8, 674 color=(0.6, 0.56, 0.6), 675 position=(self._c_width * 0.5 - 210, v), 676 text=bui.Lstr(resource='playlistText'), 677 ) 678 self._host_playlist_button = bui.buttonwidget( 679 parent=self._container, 680 size=(400, 70), 681 color=(0.6, 0.5, 0.6), 682 textcolor=(0.8, 0.75, 0.8), 683 label=self._hostingconfig.playlist_name, 684 on_activate_call=self._playlist_press, 685 position=(self._c_width * 0.5 - 200, v - 35), 686 up_widget=self._host_sub_tab_text, 687 autoselect=True, 688 ) 689 690 # If it appears we're coming back from playlist selection, 691 # re-select our playlist button. 692 if self._state.playlist_select_context is not None: 693 self._state.playlist_select_context = None 694 bui.containerwidget( 695 edit=self._container, 696 selected_child=self._host_playlist_button, 697 ) 698 699 else: 700 # We've got a current party; show its info. 701 bui.textwidget( 702 parent=self._container, 703 size=(0, 0), 704 h_align='center', 705 v_align='center', 706 maxwidth=600, 707 scale=0.9, 708 color=(0.7, 0.64, 0.7), 709 position=(self._c_width * 0.5, v + 90), 710 text=bui.Lstr(resource='gatherWindow.partyServerRunningText'), 711 ) 712 bui.textwidget( 713 parent=self._container, 714 size=(0, 0), 715 h_align='center', 716 v_align='center', 717 maxwidth=600, 718 scale=0.7, 719 color=(0.7, 0.64, 0.7), 720 position=(self._c_width * 0.5, v + 50), 721 text=bui.Lstr(resource='gatherWindow.partyCodeText'), 722 ) 723 bui.textwidget( 724 parent=self._container, 725 size=(0, 0), 726 h_align='center', 727 v_align='center', 728 scale=2.0, 729 color=(0.0, 1.0, 0.0), 730 position=(self._c_width * 0.5, v + 10), 731 text=hostingstate.party_code, 732 ) 733 734 # Also action buttons to copy it and connect to it. 735 if bui.clipboard_is_supported(): 736 cbtnoffs = 10 737 self._host_copy_button = bui.buttonwidget( 738 parent=self._container, 739 size=(140, 40), 740 color=(0.6, 0.5, 0.6), 741 textcolor=(0.8, 0.75, 0.8), 742 label=bui.Lstr(resource='gatherWindow.copyCodeText'), 743 on_activate_call=self._host_copy_press, 744 position=(self._c_width * 0.5 - 150, v - 70), 745 autoselect=True, 746 ) 747 else: 748 cbtnoffs = -70 749 self._host_connect_button = bui.buttonwidget( 750 parent=self._container, 751 size=(140, 40), 752 color=(0.6, 0.5, 0.6), 753 textcolor=(0.8, 0.75, 0.8), 754 label=bui.Lstr(resource='gatherWindow.manualConnectText'), 755 on_activate_call=self._host_connect_press, 756 position=(self._c_width * 0.5 + cbtnoffs, v - 70), 757 autoselect=True, 758 ) 759 760 v -= 110 761 762 # Line above the main action button: 763 764 # If we don't want to show anything until we get a state: 765 if self._waiting_for_initial_state: 766 pass 767 elif hostingstate.unavailable_error is not None: 768 # If hosting is unavailable, show the associated reason. 769 bui.textwidget( 770 parent=self._container, 771 size=(0, 0), 772 h_align='center', 773 v_align='center', 774 maxwidth=self._c_width * 0.9, 775 scale=0.7, 776 flatness=1.0, 777 color=(1.0, 0.0, 0.0), 778 position=(self._c_width * 0.5, v), 779 text=bui.Lstr( 780 translate=( 781 'serverResponses', 782 hostingstate.unavailable_error, 783 ) 784 ), 785 ) 786 elif havegoldpass: 787 # If we have a gold pass, none of the 788 # timing/free-server-availability info below is relevant to 789 # us. 790 pass 791 elif hostingstate.free_host_minutes_remaining is not None: 792 # If we've been pre-approved to start/stop for free, show 793 # that. 794 bui.textwidget( 795 parent=self._container, 796 size=(0, 0), 797 h_align='center', 798 v_align='center', 799 maxwidth=self._c_width * 0.9, 800 scale=0.7, 801 flatness=1.0, 802 color=( 803 (0.7, 0.64, 0.7) 804 if hostingstate.party_code 805 else (0.0, 1.0, 0.0) 806 ), 807 position=(self._c_width * 0.5, v), 808 text=bui.Lstr( 809 resource='gatherWindow.startStopHostingMinutesText', 810 subs=[ 811 ( 812 '${MINUTES}', 813 f'{hostingstate.free_host_minutes_remaining:.0f}', 814 ) 815 ], 816 ), 817 ) 818 else: 819 # Otherwise tell whether the free cloud server is available 820 # or will be at some point. 821 if hostingstate.party_code is None: 822 if hostingstate.tickets_to_host_now == 0: 823 bui.textwidget( 824 parent=self._container, 825 size=(0, 0), 826 h_align='center', 827 v_align='center', 828 maxwidth=self._c_width * 0.9, 829 scale=0.7, 830 flatness=1.0, 831 color=(0.0, 1.0, 0.0), 832 position=(self._c_width * 0.5, v), 833 text=bui.Lstr( 834 resource=( 835 'gatherWindow.freeCloudServerAvailableNowText' 836 ) 837 ), 838 ) 839 else: 840 if hostingstate.minutes_until_free_host is None: 841 bui.textwidget( 842 parent=self._container, 843 size=(0, 0), 844 h_align='center', 845 v_align='center', 846 maxwidth=self._c_width * 0.9, 847 scale=0.7, 848 flatness=1.0, 849 color=(1.0, 0.6, 0.0), 850 position=(self._c_width * 0.5, v), 851 text=bui.Lstr( 852 resource=( 853 'gatherWindow' 854 '.freeCloudServerNotAvailableText' 855 ) 856 ), 857 ) 858 else: 859 availmins = hostingstate.minutes_until_free_host 860 bui.textwidget( 861 parent=self._container, 862 size=(0, 0), 863 h_align='center', 864 v_align='center', 865 maxwidth=self._c_width * 0.9, 866 scale=0.7, 867 flatness=1.0, 868 color=(1.0, 0.6, 0.0), 869 position=(self._c_width * 0.5, v), 870 text=bui.Lstr( 871 resource='gatherWindow.' 872 'freeCloudServerAvailableMinutesText', 873 subs=[('${MINUTES}', f'{availmins:.0f}')], 874 ), 875 ) 876 877 v -= 100 878 879 if ( 880 self._waiting_for_start_stop_response 881 or self._waiting_for_initial_state 882 ): 883 btnlabel = bui.Lstr(resource='oneMomentText') 884 else: 885 if hostingstate.unavailable_error is not None: 886 btnlabel = bui.Lstr( 887 resource='gatherWindow.hostingUnavailableText' 888 ) 889 elif hostingstate.party_code is None: 890 ticon = bui.charstr(bui.SpecialChar.TOKEN) 891 nowtokens = hostingstate.tokens_to_host_now 892 if nowtokens > 0 and not havegoldpass: 893 btnlabel = bui.Lstr( 894 resource='gatherWindow.startHostingPaidText', 895 subs=[('${COST}', f'{ticon}{nowtokens}')], 896 ) 897 else: 898 btnlabel = bui.Lstr( 899 resource='gatherWindow.startHostingText' 900 ) 901 else: 902 btnlabel = bui.Lstr(resource='gatherWindow.stopHostingText') 903 904 disabled = ( 905 hostingstate.unavailable_error is not None 906 or self._waiting_for_initial_state 907 ) 908 waiting = self._waiting_for_start_stop_response 909 self._host_start_stop_button = bui.buttonwidget( 910 parent=self._container, 911 size=(400, 80), 912 color=( 913 (0.6, 0.6, 0.6) 914 if disabled 915 else (0.5, 1.0, 0.5) if waiting else None 916 ), 917 enable_sound=False, 918 label=btnlabel, 919 textcolor=((0.7, 0.7, 0.7) if disabled else None), 920 position=(self._c_width * 0.5 - 200, v), 921 on_activate_call=self._start_stop_button_press, 922 autoselect=True, 923 ) 924 925 def _playlist_press(self) -> None: 926 assert self._host_playlist_button is not None 927 928 self._state.playlist_select_context = PlaylistSelectContext() 929 930 self.window.playlist_select( 931 origin_widget=self._host_playlist_button, 932 context=self._state.playlist_select_context, 933 ) 934 935 def _host_copy_press(self) -> None: 936 assert self._hostingstate.party_code is not None 937 bui.clipboard_set_text(self._hostingstate.party_code) 938 bui.screenmessage(bui.Lstr(resource='gatherWindow.copyCodeConfirmText')) 939 940 def _host_connect_press(self) -> None: 941 assert self._hostingstate.party_code is not None 942 self._connect_to_party_code(self._hostingstate.party_code) 943 944 def _debug_server_comm(self, msg: str) -> None: 945 if DEBUG_SERVER_COMMUNICATION: 946 print( 947 f'PPTABCOM: {msg} at time ' 948 f'{time.time()-self._create_time:.2f}' 949 ) 950 951 def _connect_to_party_code(self, code: str) -> None: 952 # Ignore attempted followup sends for a few seconds. (this will 953 # reset if we get a response) 954 plus = bui.app.plus 955 assert plus is not None 956 957 now = time.time() 958 if ( 959 self._connect_press_time is not None 960 and now - self._connect_press_time < 5.0 961 ): 962 self._debug_server_comm( 963 'not sending private party connect (too soon)' 964 ) 965 return 966 self._connect_press_time = now 967 968 self._debug_server_comm('sending private party connect') 969 plus.add_v1_account_transaction( 970 { 971 'type': 'PRIVATE_PARTY_CONNECT', 972 'expire_time': time.time() + 20, 973 'code': code, 974 }, 975 callback=bui.WeakCall(self._connect_response), 976 ) 977 plus.run_v1_account_transactions() 978 979 def _start_stop_button_press(self) -> None: 980 plus = bui.app.plus 981 assert plus is not None 982 if ( 983 self._waiting_for_start_stop_response 984 or self._waiting_for_initial_state 985 ): 986 return 987 988 if plus.get_v1_account_state() != 'signed_in': 989 bui.screenmessage(bui.Lstr(resource='notSignedInErrorText')) 990 bui.getsound('error').play() 991 self._refresh_sub_tab() 992 return 993 994 if self._hostingstate.unavailable_error is not None: 995 bui.getsound('error').play() 996 return 997 998 bui.getsound('click01').play() 999 1000 # We need our v2 info for this. 1001 if self._v2state is None or self._v2state.datacode is None: 1002 bui.screenmessage( 1003 bui.Lstr(resource='internal.unavailableNoConnectionText'), 1004 color=(1, 0, 0), 1005 ) 1006 bui.getsound('error').play() 1007 return 1008 1009 # If we're not hosting, start. 1010 if self._hostingstate.party_code is None: 1011 # If there's a token cost, make sure we have enough tokens 1012 # or a gold pass. 1013 if self._hostingstate.tokens_to_host_now > 0: 1014 1015 if ( 1016 not self._v2state.gold_pass 1017 and self._v2state.tokens 1018 < self._hostingstate.tokens_to_host_now 1019 ): 1020 show_get_tokens_prompt() 1021 bui.getsound('error').play() 1022 return 1023 1024 self._last_action_send_time = time.time() 1025 plus.add_v1_account_transaction( 1026 { 1027 'type': 'PRIVATE_PARTY_START', 1028 'config': dataclass_to_dict(self._hostingconfig), 1029 'region_pings': bui.app.net.zone_pings, 1030 'expire_time': time.time() + 20, 1031 'datacode': self._v2state.datacode, 1032 }, 1033 callback=bui.WeakCall(self._hosting_state_response), 1034 ) 1035 plus.run_v1_account_transactions() 1036 1037 else: 1038 self._last_action_send_time = time.time() 1039 plus.add_v1_account_transaction( 1040 { 1041 'type': 'PRIVATE_PARTY_STOP', 1042 'expire_time': time.time() + 20, 1043 }, 1044 callback=bui.WeakCall(self._hosting_state_response), 1045 ) 1046 plus.run_v1_account_transactions() 1047 bui.getsound('click01').play() 1048 1049 self._waiting_for_start_stop_response = True 1050 self._refresh_sub_tab() 1051 1052 def _join_connect_press(self) -> None: 1053 # Error immediately if its an empty code. 1054 code: str | None = None 1055 if self._join_party_code_text: 1056 code = cast(str, bui.textwidget(query=self._join_party_code_text)) 1057 if not code: 1058 bui.screenmessage( 1059 bui.Lstr(translate=('serverResponses', 'Invalid code.')), 1060 color=(1, 0, 0), 1061 ) 1062 bui.getsound('error').play() 1063 return 1064 1065 self._connect_to_party_code(code) 1066 1067 def _connect_response(self, result: dict[str, Any] | None) -> None: 1068 try: 1069 self._connect_press_time = None 1070 if result is None: 1071 raise RuntimeError() 1072 cresult = dataclass_from_dict( 1073 PrivatePartyConnectResult, result, discard_unknown_attrs=True 1074 ) 1075 if cresult.error is not None: 1076 self._debug_server_comm('got error connect response') 1077 bui.screenmessage( 1078 bui.Lstr(translate=('serverResponses', cresult.error)), 1079 (1, 0, 0), 1080 ) 1081 bui.getsound('error').play() 1082 return 1083 self._debug_server_comm('got valid connect response') 1084 assert cresult.address4 is not None and cresult.port is not None 1085 1086 # Store UI location to return to when done. 1087 if bs.app.classic is not None: 1088 bs.app.classic.save_ui_state() 1089 1090 bs.connect_to_party(cresult.address4, port=cresult.port) 1091 except Exception: 1092 self._debug_server_comm('got connect response error') 1093 bui.getsound('error').play() 1094 1095 @override 1096 def save_state(self) -> None: 1097 assert bui.app.classic is not None 1098 bui.app.ui_v1.window_states[type(self)] = copy.deepcopy(self._state) 1099 1100 @override 1101 def restore_state(self) -> None: 1102 assert bui.app.classic is not None 1103 state = bui.app.ui_v1.window_states.get(type(self)) 1104 if state is None: 1105 state = State() 1106 assert isinstance(state, State) 1107 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.cloud.BSPrivatePartyResponse | 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:
1095 @override 1096 def save_state(self) -> None: 1097 assert bui.app.classic is not None 1098 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:
1100 @override 1101 def restore_state(self) -> None: 1102 assert bui.app.classic is not None 1103 state = bui.app.ui_v1.window_states.get(type(self)) 1104 if state is None: 1105 state = State() 1106 assert isinstance(state, State) 1107 self._state = state
Called when the parent window is restoring state.