bauiv1lib.gather.privatetab

Defines the Private tab in the gather UI.

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

Available sub-tabs.

JOIN = <SubTabType.JOIN: 'join'>
HOST = <SubTabType.HOST: 'host'>
Inherited Members
enum.Enum
name
value
@dataclass
class State:
48@dataclass
49class State:
50    """Our core state that persists while the app is running."""
51
52    sub_tab: SubTabType = SubTabType.JOIN

Our core state that persists while the app is running.

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

The private tab in the gather UI

PrivateGatherTab(window: bauiv1lib.gather.GatherWindow)
58    def __init__(self, window: GatherWindow) -> None:
59        super().__init__(window)
60        self._container: bui.Widget | None = None
61        self._state: State = State()
62        self._last_datacode_refresh_time: float | None = None
63        self._hostingstate = PrivateHostingState()
64        self._v2state: bacommon.cloud.BSPrivatePartyResponse | None = None
65        self._join_sub_tab_text: bui.Widget | None = None
66        self._host_sub_tab_text: bui.Widget | None = None
67        self._update_timer: bui.AppTimer | None = None
68        self._join_party_code_text: bui.Widget | None = None
69        self._c_width: float = 0.0
70        self._c_height: float = 0.0
71        self._last_hosting_state_query_time: float | None = None
72        self._last_v2_state_query_time: float | None = None
73        self._waiting_for_initial_state = True
74        self._waiting_for_start_stop_response = True
75        self._host_playlist_button: bui.Widget | None = None
76        self._host_copy_button: bui.Widget | None = None
77        self._host_connect_button: bui.Widget | None = None
78        self._host_start_stop_button: bui.Widget | None = None
79        self._get_tokens_button: bui.Widget | None = None
80        self._token_count_text: bui.Widget | None = None
81        self._showing_not_signed_in_screen = False
82        self._create_time = time.time()
83        self._last_action_send_time: float | None = None
84        self._connect_press_time: float | None = None
85        try:
86            self._hostingconfig = self._build_hosting_config()
87        except Exception:
88            logging.exception('Error building hosting config.')
89            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:
 91    @override
 92    def on_activate(
 93        self,
 94        parent_widget: bui.Widget,
 95        tab_button: bui.Widget,
 96        region_width: float,
 97        region_height: float,
 98        region_left: float,
 99        region_bottom: float,
100    ) -> bui.Widget:
101        self._c_width = region_width
102        self._c_height = region_height - 20
103        self._container = bui.containerwidget(
104            parent=parent_widget,
105            position=(
106                region_left,
107                region_bottom + (region_height - self._c_height) * 0.5,
108            ),
109            size=(self._c_width, self._c_height),
110            background=False,
111            selection_loops_to_parent=True,
112        )
113        v = self._c_height - 30.0
114        self._join_sub_tab_text = bui.textwidget(
115            parent=self._container,
116            position=(self._c_width * 0.5 - 245, v - 13),
117            color=(0.6, 1.0, 0.6),
118            scale=1.3,
119            size=(200, 30),
120            maxwidth=250,
121            h_align='left',
122            v_align='center',
123            click_activate=True,
124            selectable=True,
125            autoselect=True,
126            on_activate_call=lambda: self._set_sub_tab(
127                SubTabType.JOIN,
128                playsound=True,
129            ),
130            text=bui.Lstr(resource='gatherWindow.privatePartyJoinText'),
131            glow_type='uniform',
132        )
133        self._host_sub_tab_text = bui.textwidget(
134            parent=self._container,
135            position=(self._c_width * 0.5 + 45, v - 13),
136            color=(0.6, 1.0, 0.6),
137            scale=1.3,
138            size=(200, 30),
139            maxwidth=250,
140            h_align='left',
141            v_align='center',
142            click_activate=True,
143            selectable=True,
144            autoselect=True,
145            on_activate_call=lambda: self._set_sub_tab(
146                SubTabType.HOST,
147                playsound=True,
148            ),
149            text=bui.Lstr(resource='gatherWindow.privatePartyHostText'),
150            glow_type='uniform',
151        )
152        bui.widget(edit=self._join_sub_tab_text, up_widget=tab_button)
153        bui.widget(
154            edit=self._host_sub_tab_text,
155            left_widget=self._join_sub_tab_text,
156            up_widget=tab_button,
157        )
158        bui.widget(
159            edit=self._join_sub_tab_text, right_widget=self._host_sub_tab_text
160        )
161
162        self._update_timer = bui.AppTimer(
163            1.0, bui.WeakCall(self._update), repeat=True
164        )
165
166        # Prevent taking any action until we've updated our state.
167        self._waiting_for_initial_state = True
168
169        # Force some immediate refreshes.
170        self._last_datacode_refresh_time = None
171        self._last_v2_state_query_time = None
172        self._last_action_send_time = None  # Ensure we don't ignore response.
173        self._last_hosting_state_query_time = None
174        self._update()
175
176        self._set_sub_tab(self._state.sub_tab)
177
178        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 on_deactivate(self) -> None:
266    @override
267    def on_deactivate(self) -> None:
268        self._update_timer = None

Called when the tab will no longer be the active one.

@override
def save_state(self) -> None:
1113    @override
1114    def save_state(self) -> None:
1115        assert bui.app.classic is not None
1116        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:
1118    @override
1119    def restore_state(self) -> None:
1120        assert bui.app.classic is not None
1121        state = bui.app.ui_v1.window_states.get(type(self))
1122        if state is None:
1123            state = State()
1124        assert isinstance(state, State)
1125        self._state = state

Called when the parent window is restoring state.