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

Called when the parent window is restoring state.