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

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:
268    @override
269    def on_deactivate(self) -> None:
270        self._update_timer = None

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

@override
def save_state(self) -> None:
1089    @override
1090    def save_state(self) -> None:
1091        assert bui.app.classic is not None
1092        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:
1094    @override
1095    def restore_state(self) -> None:
1096        assert bui.app.classic is not None
1097        state = bui.app.ui_v1.window_states.get(type(self))
1098        if state is None:
1099            state = State()
1100        assert isinstance(state, State)
1101        self._state = state

Called when the parent window is restoring state.