bauiv1lib.gather.privatetab

Defines the Private tab in the gather UI.

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

Available sub-tabs.

JOIN = <SubTabType.JOIN: 'join'>
HOST = <SubTabType.HOST: 'host'>
@dataclass
class State:
49@dataclass
50class State:
51    """Our core state that persists while the app is running."""
52
53    sub_tab: SubTabType = SubTabType.JOIN
54    playlist_select_context: PlaylistSelectContext | None = None

Our core state that persists while the app is running.

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

The private tab in the gather UI

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

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

@override
def save_state(self) -> None:
1095    @override
1096    def save_state(self) -> None:
1097        assert bui.app.classic is not None
1098        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:
1100    @override
1101    def restore_state(self) -> None:
1102        assert bui.app.classic is not None
1103        state = bui.app.ui_v1.window_states.get(type(self))
1104        if state is None:
1105            state = State()
1106        assert isinstance(state, State)
1107        self._state = state

Called when the parent window is restoring state.