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