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