bauiv1lib.gather.privatetab

Defines the Private tab in the gather UI.

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

Available sub-tabs.

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

Our core state that persists while the app is running.

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

Called when the tab becomes the active one.

The tab should create and return a container widget covering the specified region.

@override
def on_deactivate(self) -> None:
269    @override
270    def on_deactivate(self) -> None:
271        self._update_timer = None

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

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

Called when the parent window is restoring state.