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