bauiv1lib.soundtrack.browser

Provides UI for browsing soundtracks.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Provides UI for browsing soundtracks."""
  4
  5from __future__ import annotations
  6
  7import copy
  8import logging
  9from typing import TYPE_CHECKING, override
 10
 11import bauiv1 as bui
 12
 13if TYPE_CHECKING:
 14    from typing import Any
 15
 16REQUIRE_PRO = False
 17
 18
 19class SoundtrackBrowserWindow(bui.MainWindow):
 20    """Window for browsing soundtracks."""
 21
 22    def __init__(
 23        self,
 24        transition: str | None = 'in_right',
 25        origin_widget: bui.Widget | None = None,
 26    ):
 27        # pylint: disable=too-many-statements
 28        # pylint: disable=too-many-locals
 29
 30        self._r = 'editSoundtrackWindow'
 31        assert bui.app.classic is not None
 32        uiscale = bui.app.ui_v1.uiscale
 33        self._width = 1200 if uiscale is bui.UIScale.SMALL else 650
 34        self._height = 800 if uiscale is bui.UIScale.SMALL else 400
 35
 36        # Do some fancy math to fill all available screen area up to the
 37        # size of our backing container. This lets us fit to the exact
 38        # screen shape at small ui scale.
 39        screensize = bui.get_virtual_screen_size()
 40        scale = (
 41            2.1
 42            if uiscale is bui.UIScale.SMALL
 43            else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0
 44        )
 45        # Calc screen size in our local container space and clamp to a
 46        # bit smaller than our container size.
 47        # target_width = min(self._width - 60, screensize[0] / scale)
 48        target_height = min(self._height - 70, screensize[1] / scale)
 49
 50        # To get top/left coords, go to the center of our window and
 51        # offset by half the width/height of our target area.
 52        yoffs = 0.5 * self._height + 0.5 * target_height + 30.0
 53
 54        super().__init__(
 55            root_widget=bui.containerwidget(
 56                size=(self._width, self._height),
 57                toolbar_visibility=(
 58                    'menu_minimal'
 59                    if uiscale is bui.UIScale.SMALL
 60                    else 'menu_full'
 61                ),
 62                scale=scale,
 63            ),
 64            transition=transition,
 65            origin_widget=origin_widget,
 66            # We're affected by screen size only at small ui-scale.
 67            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
 68        )
 69
 70        assert bui.app.classic is not None
 71        if uiscale is bui.UIScale.SMALL:
 72            self._back_button = None
 73        else:
 74            self._back_button = bui.buttonwidget(
 75                parent=self._root_widget,
 76                position=(50, yoffs - 60),
 77                size=(60, 60),
 78                scale=0.8,
 79                label=bui.charstr(bui.SpecialChar.BACK),
 80                button_type='backSmall',
 81                autoselect=True,
 82            )
 83        bui.textwidget(
 84            parent=self._root_widget,
 85            position=(
 86                self._width * 0.5,
 87                yoffs - (55 if uiscale is bui.UIScale.SMALL else 35),
 88            ),
 89            size=(0, 0),
 90            maxwidth=300,
 91            text=bui.Lstr(resource=f'{self._r}.titleText'),
 92            color=bui.app.ui_v1.title_color,
 93            h_align='center',
 94            v_align='center',
 95        )
 96
 97        # Generally center all other content
 98        x_inset = self._width * 0.5 - 320
 99        vbase = v = self._height * 0.5 + 130
100
101        h = 43 + x_inset
102        b_color = (0.6, 0.53, 0.63)
103        b_textcolor = (0.75, 0.7, 0.8)
104        lock_tex = bui.gettexture('lock')
105        self._lock_images: list[bui.Widget] = []
106
107        scl = 1.2
108        v -= 60.0 * scl
109        self._new_button = btn = bui.buttonwidget(
110            parent=self._root_widget,
111            position=(h, v),
112            size=(100, 55.0 * scl),
113            on_activate_call=self._new_soundtrack,
114            color=b_color,
115            button_type='square',
116            autoselect=True,
117            textcolor=b_textcolor,
118            text_scale=0.7,
119            label=bui.Lstr(resource=f'{self._r}.newText'),
120        )
121        self._lock_images.append(
122            bui.imagewidget(
123                parent=self._root_widget,
124                size=(30, 30),
125                draw_controller=btn,
126                position=(h - 10, v + 55.0 * scl - 28),
127                texture=lock_tex,
128            )
129        )
130
131        if self._back_button is None:
132            bui.widget(
133                edit=btn,
134                left_widget=bui.get_special_widget('back_button'),
135            )
136        v -= 60.0 * scl
137
138        self._edit_button = btn = bui.buttonwidget(
139            parent=self._root_widget,
140            position=(h, v),
141            size=(100, 55.0 * scl),
142            on_activate_call=self._edit_soundtrack,
143            color=b_color,
144            button_type='square',
145            autoselect=True,
146            textcolor=b_textcolor,
147            text_scale=0.7,
148            label=bui.Lstr(resource=f'{self._r}.editText'),
149        )
150        self._lock_images.append(
151            bui.imagewidget(
152                parent=self._root_widget,
153                size=(30, 30),
154                draw_controller=btn,
155                position=(h - 10, v + 55.0 * scl - 28),
156                texture=lock_tex,
157            )
158        )
159        if self._back_button is None:
160            bui.widget(
161                edit=btn,
162                left_widget=bui.get_special_widget('back_button'),
163            )
164        v -= 60.0 * scl
165
166        self._duplicate_button = btn = bui.buttonwidget(
167            parent=self._root_widget,
168            position=(h, v),
169            size=(100, 55.0 * scl),
170            on_activate_call=self._duplicate_soundtrack,
171            button_type='square',
172            autoselect=True,
173            color=b_color,
174            textcolor=b_textcolor,
175            text_scale=0.7,
176            label=bui.Lstr(resource=f'{self._r}.duplicateText'),
177        )
178        self._lock_images.append(
179            bui.imagewidget(
180                parent=self._root_widget,
181                size=(30, 30),
182                draw_controller=btn,
183                position=(h - 10, v + 55.0 * scl - 28),
184                texture=lock_tex,
185            )
186        )
187        if self._back_button is None:
188            bui.widget(
189                edit=btn,
190                left_widget=bui.get_special_widget('back_button'),
191            )
192        v -= 60.0 * scl
193
194        self._delete_button = btn = bui.buttonwidget(
195            parent=self._root_widget,
196            position=(h, v),
197            size=(100, 55.0 * scl),
198            on_activate_call=self._delete_soundtrack,
199            color=b_color,
200            button_type='square',
201            autoselect=True,
202            textcolor=b_textcolor,
203            text_scale=0.7,
204            label=bui.Lstr(resource=f'{self._r}.deleteText'),
205        )
206        self._lock_images.append(
207            bui.imagewidget(
208                parent=self._root_widget,
209                size=(30, 30),
210                draw_controller=btn,
211                position=(h - 10, v + 55.0 * scl - 28),
212                texture=lock_tex,
213            )
214        )
215        if self._back_button is None:
216            bui.widget(
217                edit=btn,
218                left_widget=bui.get_special_widget('back_button'),
219            )
220
221        # Keep our lock images up to date/etc.
222        self._update_timer = bui.AppTimer(
223            1.0, bui.WeakCall(self._update), repeat=True
224        )
225        self._update()
226
227        v = vbase - 6
228        scroll_height = 280
229        v -= scroll_height
230        self._scrollwidget = scrollwidget = bui.scrollwidget(
231            parent=self._root_widget,
232            position=(152 + x_inset, v),
233            highlight=False,
234            size=(450, scroll_height),
235            border_opacity=0.4,
236        )
237        bui.widget(
238            edit=self._scrollwidget,
239            left_widget=self._new_button,
240            right_widget=bui.get_special_widget('squad_button'),
241        )
242        self._col = bui.columnwidget(parent=scrollwidget, border=2, margin=0)
243
244        self._soundtracks: dict[str, Any] | None = None
245        self._selected_soundtrack: str | None = None
246        self._selected_soundtrack_index: int | None = None
247        self._soundtrack_widgets: list[bui.Widget] = []
248        self._allow_changing_soundtracks = False
249        self._refresh()
250        if self._back_button is not None:
251            bui.buttonwidget(
252                edit=self._back_button, on_activate_call=self.main_window_back
253            )
254            bui.containerwidget(
255                edit=self._root_widget, cancel_button=self._back_button
256            )
257        else:
258            bui.containerwidget(
259                edit=self._root_widget, on_cancel_call=self.main_window_back
260            )
261
262    @override
263    def get_main_window_state(self) -> bui.MainWindowState:
264        # Support recreating our window for back/refresh purposes.
265        cls = type(self)
266        return bui.BasicMainWindowState(
267            create_call=lambda transition, origin_widget: cls(
268                transition=transition, origin_widget=origin_widget
269            )
270        )
271
272    @override
273    def on_main_window_close(self) -> None:
274        self._save_state()
275
276    def _update(self) -> None:
277        have_pro = (
278            bui.app.classic is None
279            or bui.app.classic.accounts.have_pro_options()
280        )
281        for lock in self._lock_images:
282            bui.imagewidget(
283                edit=lock, opacity=0.0 if (have_pro or not REQUIRE_PRO) else 1.0
284            )
285
286    def _do_delete_soundtrack(self) -> None:
287        cfg = bui.app.config
288        soundtracks = cfg.setdefault('Soundtracks', {})
289        if self._selected_soundtrack in soundtracks:
290            del soundtracks[self._selected_soundtrack]
291        cfg.commit()
292        bui.getsound('shieldDown').play()
293        assert self._selected_soundtrack_index is not None
294        assert self._soundtracks is not None
295        self._selected_soundtrack_index = min(
296            self._selected_soundtrack_index, len(self._soundtracks)
297        )
298        self._refresh()
299
300    def _delete_soundtrack(self) -> None:
301        # pylint: disable=cyclic-import
302        from bauiv1lib.purchase import PurchaseWindow
303        from bauiv1lib.confirm import ConfirmWindow
304
305        if REQUIRE_PRO and (
306            bui.app.classic is not None
307            and not bui.app.classic.accounts.have_pro_options()
308        ):
309            PurchaseWindow(items=['pro'])
310            return
311        if self._selected_soundtrack is None:
312            return
313        if self._selected_soundtrack == '__default__':
314            bui.getsound('error').play()
315            bui.screenmessage(
316                bui.Lstr(resource=f'{self._r}.cantDeleteDefaultText'),
317                color=(1, 0, 0),
318            )
319        else:
320            ConfirmWindow(
321                bui.Lstr(
322                    resource=f'{self._r}.deleteConfirmText',
323                    subs=[('${NAME}', self._selected_soundtrack)],
324                ),
325                self._do_delete_soundtrack,
326                450,
327                150,
328            )
329
330    def _duplicate_soundtrack(self) -> None:
331        # pylint: disable=cyclic-import
332        from bauiv1lib.purchase import PurchaseWindow
333
334        if REQUIRE_PRO and (
335            bui.app.classic is not None
336            and not bui.app.classic.accounts.have_pro_options()
337        ):
338            PurchaseWindow(items=['pro'])
339            return
340        cfg = bui.app.config
341        cfg.setdefault('Soundtracks', {})
342
343        if self._selected_soundtrack is None:
344            return
345        sdtk: dict[str, Any]
346        if self._selected_soundtrack == '__default__':
347            sdtk = {}
348        else:
349            sdtk = cfg['Soundtracks'][self._selected_soundtrack]
350
351        # Find a valid dup name that doesn't exist.
352        test_index = 1
353        copy_text = bui.Lstr(resource='copyOfText').evaluate()
354        # Get just 'Copy' or whatnot.
355        copy_word = copy_text.replace('${NAME}', '').strip()
356        base_name = self._get_soundtrack_display_name(
357            self._selected_soundtrack
358        ).evaluate()
359        assert isinstance(base_name, str)
360
361        # If it looks like a copy, strip digits and spaces off the end.
362        if copy_word in base_name:
363            while base_name[-1].isdigit() or base_name[-1] == ' ':
364                base_name = base_name[:-1]
365        while True:
366            if copy_word in base_name:
367                test_name = base_name
368            else:
369                test_name = copy_text.replace('${NAME}', base_name)
370            if test_index > 1:
371                test_name += ' ' + str(test_index)
372            if test_name not in cfg['Soundtracks']:
373                break
374            test_index += 1
375
376        cfg['Soundtracks'][test_name] = copy.deepcopy(sdtk)
377        cfg.commit()
378        self._refresh(select_soundtrack=test_name)
379
380    def _select(self, name: str, index: int) -> None:
381        assert bui.app.classic is not None
382        music = bui.app.classic.music
383        self._selected_soundtrack_index = index
384        self._selected_soundtrack = name
385        cfg = bui.app.config
386        current_soundtrack = cfg.setdefault('Soundtrack', '__default__')
387
388        # If it varies from current, commit and play.
389        if current_soundtrack != name and self._allow_changing_soundtracks:
390            bui.getsound('gunCocking').play()
391            cfg['Soundtrack'] = self._selected_soundtrack
392            cfg.commit()
393
394            # Just play whats already playing.. this'll grab it from the
395            # new soundtrack.
396            music.do_play_music(
397                music.music_types[bui.app.classic.MusicPlayMode.REGULAR]
398            )
399
400    def _edit_soundtrack_with_sound(self) -> None:
401        # pylint: disable=cyclic-import
402        from bauiv1lib.purchase import PurchaseWindow
403
404        if REQUIRE_PRO and (
405            bui.app.classic is not None
406            and not bui.app.classic.accounts.have_pro_options()
407        ):
408            PurchaseWindow(items=['pro'])
409            return
410        bui.getsound('swish').play()
411        self._edit_soundtrack()
412
413    def _edit_soundtrack(self) -> None:
414        # pylint: disable=cyclic-import
415        from bauiv1lib.purchase import PurchaseWindow
416        from bauiv1lib.soundtrack.edit import SoundtrackEditWindow
417
418        # no-op if we don't have control.
419        if not self.main_window_has_control():
420            return
421
422        if REQUIRE_PRO and (
423            bui.app.classic is not None
424            and not bui.app.classic.accounts.have_pro_options()
425        ):
426            PurchaseWindow(items=['pro'])
427            return
428
429        if self._selected_soundtrack is None:
430            return
431
432        if self._selected_soundtrack == '__default__':
433            bui.getsound('error').play()
434            bui.screenmessage(
435                bui.Lstr(resource=f'{self._r}.cantEditDefaultText'),
436                color=(1, 0, 0),
437            )
438            return
439
440        self.main_window_replace(
441            SoundtrackEditWindow(existing_soundtrack=self._selected_soundtrack)
442        )
443
444    def _get_soundtrack_display_name(self, soundtrack: str) -> bui.Lstr:
445        if soundtrack == '__default__':
446            return bui.Lstr(resource=f'{self._r}.defaultSoundtrackNameText')
447        return bui.Lstr(value=soundtrack)
448
449    def _refresh(self, select_soundtrack: str | None = None) -> None:
450        from efro.util import asserttype
451
452        self._allow_changing_soundtracks = False
453        old_selection = self._selected_soundtrack
454
455        # If there was no prev selection, look in prefs.
456        if old_selection is None:
457            old_selection = bui.app.config.get('Soundtrack')
458        old_selection_index = self._selected_soundtrack_index
459
460        # Delete old.
461        while self._soundtrack_widgets:
462            self._soundtrack_widgets.pop().delete()
463
464        self._soundtracks = bui.app.config.get('Soundtracks', {})
465        assert self._soundtracks is not None
466        items = list(self._soundtracks.items())
467        items.sort(key=lambda x: asserttype(x[0], str).lower())
468        items = [('__default__', None)] + items  # default is always first
469        index = 0
470        for pname, _pval in items:
471            assert pname is not None
472            txtw = bui.textwidget(
473                parent=self._col,
474                size=(self._width - 40, 24),
475                text=self._get_soundtrack_display_name(pname),
476                h_align='left',
477                v_align='center',
478                maxwidth=self._width - 110,
479                always_highlight=True,
480                on_select_call=bui.WeakCall(self._select, pname, index),
481                on_activate_call=self._edit_soundtrack_with_sound,
482                selectable=True,
483            )
484            if index == 0:
485                bui.widget(edit=txtw, up_widget=self._back_button)
486            self._soundtrack_widgets.append(txtw)
487
488            # Select this one if the user requested it
489            if select_soundtrack is not None:
490                if pname == select_soundtrack:
491                    bui.columnwidget(
492                        edit=self._col, selected_child=txtw, visible_child=txtw
493                    )
494            else:
495                # Select this one if it was previously selected.
496                # Go by index if there's one.
497                if old_selection_index is not None:
498                    if index == old_selection_index:
499                        bui.columnwidget(
500                            edit=self._col,
501                            selected_child=txtw,
502                            visible_child=txtw,
503                        )
504                else:  # Otherwise look by name.
505                    if pname == old_selection:
506                        bui.columnwidget(
507                            edit=self._col,
508                            selected_child=txtw,
509                            visible_child=txtw,
510                        )
511            index += 1
512
513        # Explicitly run select callback on current one and re-enable
514        # callbacks.
515
516        # Eww need to run this in a timer so it happens after our select
517        # callbacks. With a small-enough time sometimes it happens before
518        # anyway. Ew. need a way to just schedule a callable i guess.
519        bui.apptimer(0.1, bui.WeakCall(self._set_allow_changing))
520
521    def _set_allow_changing(self) -> None:
522        self._allow_changing_soundtracks = True
523        assert self._selected_soundtrack is not None
524        assert self._selected_soundtrack_index is not None
525        self._select(self._selected_soundtrack, self._selected_soundtrack_index)
526
527    def _new_soundtrack(self) -> None:
528        # pylint: disable=cyclic-import
529        from bauiv1lib.purchase import PurchaseWindow
530        from bauiv1lib.soundtrack.edit import SoundtrackEditWindow
531
532        # no-op if we're not in control.
533        if not self.main_window_has_control():
534            return
535
536        if REQUIRE_PRO and (
537            bui.app.classic is not None
538            and not bui.app.classic.accounts.have_pro_options()
539        ):
540            PurchaseWindow(items=['pro'])
541            return
542
543        self.main_window_replace(SoundtrackEditWindow(existing_soundtrack=None))
544
545    def _create_done(self, new_soundtrack: str) -> None:
546        if new_soundtrack is not None:
547            bui.getsound('gunCocking').play()
548            self._refresh(select_soundtrack=new_soundtrack)
549
550    def _save_state(self) -> None:
551        try:
552            sel = self._root_widget.get_selected_child()
553            if sel == self._scrollwidget:
554                sel_name = 'Scroll'
555            elif sel == self._new_button:
556                sel_name = 'New'
557            elif sel == self._edit_button:
558                sel_name = 'Edit'
559            elif sel == self._duplicate_button:
560                sel_name = 'Duplicate'
561            elif sel == self._delete_button:
562                sel_name = 'Delete'
563            elif sel == self._back_button:
564                sel_name = 'Back'
565            else:
566                raise ValueError(f'unrecognized selection \'{sel}\'')
567            assert bui.app.classic is not None
568            bui.app.ui_v1.window_states[type(self)] = sel_name
569        except Exception:
570            logging.exception('Error saving state for %s.', self)
571
572    def _restore_state(self) -> None:
573        try:
574            assert bui.app.classic is not None
575            sel_name = bui.app.ui_v1.window_states.get(type(self))
576            if sel_name == 'Scroll':
577                sel = self._scrollwidget
578            elif sel_name == 'New':
579                sel = self._new_button
580            elif sel_name == 'Edit':
581                sel = self._edit_button
582            elif sel_name == 'Duplicate':
583                sel = self._duplicate_button
584            elif sel_name == 'Delete':
585                sel = self._delete_button
586            else:
587                sel = self._scrollwidget
588            bui.containerwidget(edit=self._root_widget, selected_child=sel)
589        except Exception:
590            logging.exception('Error restoring state for %s.', self)
REQUIRE_PRO = False
class SoundtrackBrowserWindow(bauiv1._uitypes.MainWindow):
 20class SoundtrackBrowserWindow(bui.MainWindow):
 21    """Window for browsing soundtracks."""
 22
 23    def __init__(
 24        self,
 25        transition: str | None = 'in_right',
 26        origin_widget: bui.Widget | None = None,
 27    ):
 28        # pylint: disable=too-many-statements
 29        # pylint: disable=too-many-locals
 30
 31        self._r = 'editSoundtrackWindow'
 32        assert bui.app.classic is not None
 33        uiscale = bui.app.ui_v1.uiscale
 34        self._width = 1200 if uiscale is bui.UIScale.SMALL else 650
 35        self._height = 800 if uiscale is bui.UIScale.SMALL else 400
 36
 37        # Do some fancy math to fill all available screen area up to the
 38        # size of our backing container. This lets us fit to the exact
 39        # screen shape at small ui scale.
 40        screensize = bui.get_virtual_screen_size()
 41        scale = (
 42            2.1
 43            if uiscale is bui.UIScale.SMALL
 44            else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0
 45        )
 46        # Calc screen size in our local container space and clamp to a
 47        # bit smaller than our container size.
 48        # target_width = min(self._width - 60, screensize[0] / scale)
 49        target_height = min(self._height - 70, screensize[1] / scale)
 50
 51        # To get top/left coords, go to the center of our window and
 52        # offset by half the width/height of our target area.
 53        yoffs = 0.5 * self._height + 0.5 * target_height + 30.0
 54
 55        super().__init__(
 56            root_widget=bui.containerwidget(
 57                size=(self._width, self._height),
 58                toolbar_visibility=(
 59                    'menu_minimal'
 60                    if uiscale is bui.UIScale.SMALL
 61                    else 'menu_full'
 62                ),
 63                scale=scale,
 64            ),
 65            transition=transition,
 66            origin_widget=origin_widget,
 67            # We're affected by screen size only at small ui-scale.
 68            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
 69        )
 70
 71        assert bui.app.classic is not None
 72        if uiscale is bui.UIScale.SMALL:
 73            self._back_button = None
 74        else:
 75            self._back_button = bui.buttonwidget(
 76                parent=self._root_widget,
 77                position=(50, yoffs - 60),
 78                size=(60, 60),
 79                scale=0.8,
 80                label=bui.charstr(bui.SpecialChar.BACK),
 81                button_type='backSmall',
 82                autoselect=True,
 83            )
 84        bui.textwidget(
 85            parent=self._root_widget,
 86            position=(
 87                self._width * 0.5,
 88                yoffs - (55 if uiscale is bui.UIScale.SMALL else 35),
 89            ),
 90            size=(0, 0),
 91            maxwidth=300,
 92            text=bui.Lstr(resource=f'{self._r}.titleText'),
 93            color=bui.app.ui_v1.title_color,
 94            h_align='center',
 95            v_align='center',
 96        )
 97
 98        # Generally center all other content
 99        x_inset = self._width * 0.5 - 320
100        vbase = v = self._height * 0.5 + 130
101
102        h = 43 + x_inset
103        b_color = (0.6, 0.53, 0.63)
104        b_textcolor = (0.75, 0.7, 0.8)
105        lock_tex = bui.gettexture('lock')
106        self._lock_images: list[bui.Widget] = []
107
108        scl = 1.2
109        v -= 60.0 * scl
110        self._new_button = btn = bui.buttonwidget(
111            parent=self._root_widget,
112            position=(h, v),
113            size=(100, 55.0 * scl),
114            on_activate_call=self._new_soundtrack,
115            color=b_color,
116            button_type='square',
117            autoselect=True,
118            textcolor=b_textcolor,
119            text_scale=0.7,
120            label=bui.Lstr(resource=f'{self._r}.newText'),
121        )
122        self._lock_images.append(
123            bui.imagewidget(
124                parent=self._root_widget,
125                size=(30, 30),
126                draw_controller=btn,
127                position=(h - 10, v + 55.0 * scl - 28),
128                texture=lock_tex,
129            )
130        )
131
132        if self._back_button is None:
133            bui.widget(
134                edit=btn,
135                left_widget=bui.get_special_widget('back_button'),
136            )
137        v -= 60.0 * scl
138
139        self._edit_button = btn = bui.buttonwidget(
140            parent=self._root_widget,
141            position=(h, v),
142            size=(100, 55.0 * scl),
143            on_activate_call=self._edit_soundtrack,
144            color=b_color,
145            button_type='square',
146            autoselect=True,
147            textcolor=b_textcolor,
148            text_scale=0.7,
149            label=bui.Lstr(resource=f'{self._r}.editText'),
150        )
151        self._lock_images.append(
152            bui.imagewidget(
153                parent=self._root_widget,
154                size=(30, 30),
155                draw_controller=btn,
156                position=(h - 10, v + 55.0 * scl - 28),
157                texture=lock_tex,
158            )
159        )
160        if self._back_button is None:
161            bui.widget(
162                edit=btn,
163                left_widget=bui.get_special_widget('back_button'),
164            )
165        v -= 60.0 * scl
166
167        self._duplicate_button = btn = bui.buttonwidget(
168            parent=self._root_widget,
169            position=(h, v),
170            size=(100, 55.0 * scl),
171            on_activate_call=self._duplicate_soundtrack,
172            button_type='square',
173            autoselect=True,
174            color=b_color,
175            textcolor=b_textcolor,
176            text_scale=0.7,
177            label=bui.Lstr(resource=f'{self._r}.duplicateText'),
178        )
179        self._lock_images.append(
180            bui.imagewidget(
181                parent=self._root_widget,
182                size=(30, 30),
183                draw_controller=btn,
184                position=(h - 10, v + 55.0 * scl - 28),
185                texture=lock_tex,
186            )
187        )
188        if self._back_button is None:
189            bui.widget(
190                edit=btn,
191                left_widget=bui.get_special_widget('back_button'),
192            )
193        v -= 60.0 * scl
194
195        self._delete_button = btn = bui.buttonwidget(
196            parent=self._root_widget,
197            position=(h, v),
198            size=(100, 55.0 * scl),
199            on_activate_call=self._delete_soundtrack,
200            color=b_color,
201            button_type='square',
202            autoselect=True,
203            textcolor=b_textcolor,
204            text_scale=0.7,
205            label=bui.Lstr(resource=f'{self._r}.deleteText'),
206        )
207        self._lock_images.append(
208            bui.imagewidget(
209                parent=self._root_widget,
210                size=(30, 30),
211                draw_controller=btn,
212                position=(h - 10, v + 55.0 * scl - 28),
213                texture=lock_tex,
214            )
215        )
216        if self._back_button is None:
217            bui.widget(
218                edit=btn,
219                left_widget=bui.get_special_widget('back_button'),
220            )
221
222        # Keep our lock images up to date/etc.
223        self._update_timer = bui.AppTimer(
224            1.0, bui.WeakCall(self._update), repeat=True
225        )
226        self._update()
227
228        v = vbase - 6
229        scroll_height = 280
230        v -= scroll_height
231        self._scrollwidget = scrollwidget = bui.scrollwidget(
232            parent=self._root_widget,
233            position=(152 + x_inset, v),
234            highlight=False,
235            size=(450, scroll_height),
236            border_opacity=0.4,
237        )
238        bui.widget(
239            edit=self._scrollwidget,
240            left_widget=self._new_button,
241            right_widget=bui.get_special_widget('squad_button'),
242        )
243        self._col = bui.columnwidget(parent=scrollwidget, border=2, margin=0)
244
245        self._soundtracks: dict[str, Any] | None = None
246        self._selected_soundtrack: str | None = None
247        self._selected_soundtrack_index: int | None = None
248        self._soundtrack_widgets: list[bui.Widget] = []
249        self._allow_changing_soundtracks = False
250        self._refresh()
251        if self._back_button is not None:
252            bui.buttonwidget(
253                edit=self._back_button, on_activate_call=self.main_window_back
254            )
255            bui.containerwidget(
256                edit=self._root_widget, cancel_button=self._back_button
257            )
258        else:
259            bui.containerwidget(
260                edit=self._root_widget, on_cancel_call=self.main_window_back
261            )
262
263    @override
264    def get_main_window_state(self) -> bui.MainWindowState:
265        # Support recreating our window for back/refresh purposes.
266        cls = type(self)
267        return bui.BasicMainWindowState(
268            create_call=lambda transition, origin_widget: cls(
269                transition=transition, origin_widget=origin_widget
270            )
271        )
272
273    @override
274    def on_main_window_close(self) -> None:
275        self._save_state()
276
277    def _update(self) -> None:
278        have_pro = (
279            bui.app.classic is None
280            or bui.app.classic.accounts.have_pro_options()
281        )
282        for lock in self._lock_images:
283            bui.imagewidget(
284                edit=lock, opacity=0.0 if (have_pro or not REQUIRE_PRO) else 1.0
285            )
286
287    def _do_delete_soundtrack(self) -> None:
288        cfg = bui.app.config
289        soundtracks = cfg.setdefault('Soundtracks', {})
290        if self._selected_soundtrack in soundtracks:
291            del soundtracks[self._selected_soundtrack]
292        cfg.commit()
293        bui.getsound('shieldDown').play()
294        assert self._selected_soundtrack_index is not None
295        assert self._soundtracks is not None
296        self._selected_soundtrack_index = min(
297            self._selected_soundtrack_index, len(self._soundtracks)
298        )
299        self._refresh()
300
301    def _delete_soundtrack(self) -> None:
302        # pylint: disable=cyclic-import
303        from bauiv1lib.purchase import PurchaseWindow
304        from bauiv1lib.confirm import ConfirmWindow
305
306        if REQUIRE_PRO and (
307            bui.app.classic is not None
308            and not bui.app.classic.accounts.have_pro_options()
309        ):
310            PurchaseWindow(items=['pro'])
311            return
312        if self._selected_soundtrack is None:
313            return
314        if self._selected_soundtrack == '__default__':
315            bui.getsound('error').play()
316            bui.screenmessage(
317                bui.Lstr(resource=f'{self._r}.cantDeleteDefaultText'),
318                color=(1, 0, 0),
319            )
320        else:
321            ConfirmWindow(
322                bui.Lstr(
323                    resource=f'{self._r}.deleteConfirmText',
324                    subs=[('${NAME}', self._selected_soundtrack)],
325                ),
326                self._do_delete_soundtrack,
327                450,
328                150,
329            )
330
331    def _duplicate_soundtrack(self) -> None:
332        # pylint: disable=cyclic-import
333        from bauiv1lib.purchase import PurchaseWindow
334
335        if REQUIRE_PRO and (
336            bui.app.classic is not None
337            and not bui.app.classic.accounts.have_pro_options()
338        ):
339            PurchaseWindow(items=['pro'])
340            return
341        cfg = bui.app.config
342        cfg.setdefault('Soundtracks', {})
343
344        if self._selected_soundtrack is None:
345            return
346        sdtk: dict[str, Any]
347        if self._selected_soundtrack == '__default__':
348            sdtk = {}
349        else:
350            sdtk = cfg['Soundtracks'][self._selected_soundtrack]
351
352        # Find a valid dup name that doesn't exist.
353        test_index = 1
354        copy_text = bui.Lstr(resource='copyOfText').evaluate()
355        # Get just 'Copy' or whatnot.
356        copy_word = copy_text.replace('${NAME}', '').strip()
357        base_name = self._get_soundtrack_display_name(
358            self._selected_soundtrack
359        ).evaluate()
360        assert isinstance(base_name, str)
361
362        # If it looks like a copy, strip digits and spaces off the end.
363        if copy_word in base_name:
364            while base_name[-1].isdigit() or base_name[-1] == ' ':
365                base_name = base_name[:-1]
366        while True:
367            if copy_word in base_name:
368                test_name = base_name
369            else:
370                test_name = copy_text.replace('${NAME}', base_name)
371            if test_index > 1:
372                test_name += ' ' + str(test_index)
373            if test_name not in cfg['Soundtracks']:
374                break
375            test_index += 1
376
377        cfg['Soundtracks'][test_name] = copy.deepcopy(sdtk)
378        cfg.commit()
379        self._refresh(select_soundtrack=test_name)
380
381    def _select(self, name: str, index: int) -> None:
382        assert bui.app.classic is not None
383        music = bui.app.classic.music
384        self._selected_soundtrack_index = index
385        self._selected_soundtrack = name
386        cfg = bui.app.config
387        current_soundtrack = cfg.setdefault('Soundtrack', '__default__')
388
389        # If it varies from current, commit and play.
390        if current_soundtrack != name and self._allow_changing_soundtracks:
391            bui.getsound('gunCocking').play()
392            cfg['Soundtrack'] = self._selected_soundtrack
393            cfg.commit()
394
395            # Just play whats already playing.. this'll grab it from the
396            # new soundtrack.
397            music.do_play_music(
398                music.music_types[bui.app.classic.MusicPlayMode.REGULAR]
399            )
400
401    def _edit_soundtrack_with_sound(self) -> None:
402        # pylint: disable=cyclic-import
403        from bauiv1lib.purchase import PurchaseWindow
404
405        if REQUIRE_PRO and (
406            bui.app.classic is not None
407            and not bui.app.classic.accounts.have_pro_options()
408        ):
409            PurchaseWindow(items=['pro'])
410            return
411        bui.getsound('swish').play()
412        self._edit_soundtrack()
413
414    def _edit_soundtrack(self) -> None:
415        # pylint: disable=cyclic-import
416        from bauiv1lib.purchase import PurchaseWindow
417        from bauiv1lib.soundtrack.edit import SoundtrackEditWindow
418
419        # no-op if we don't have control.
420        if not self.main_window_has_control():
421            return
422
423        if REQUIRE_PRO and (
424            bui.app.classic is not None
425            and not bui.app.classic.accounts.have_pro_options()
426        ):
427            PurchaseWindow(items=['pro'])
428            return
429
430        if self._selected_soundtrack is None:
431            return
432
433        if self._selected_soundtrack == '__default__':
434            bui.getsound('error').play()
435            bui.screenmessage(
436                bui.Lstr(resource=f'{self._r}.cantEditDefaultText'),
437                color=(1, 0, 0),
438            )
439            return
440
441        self.main_window_replace(
442            SoundtrackEditWindow(existing_soundtrack=self._selected_soundtrack)
443        )
444
445    def _get_soundtrack_display_name(self, soundtrack: str) -> bui.Lstr:
446        if soundtrack == '__default__':
447            return bui.Lstr(resource=f'{self._r}.defaultSoundtrackNameText')
448        return bui.Lstr(value=soundtrack)
449
450    def _refresh(self, select_soundtrack: str | None = None) -> None:
451        from efro.util import asserttype
452
453        self._allow_changing_soundtracks = False
454        old_selection = self._selected_soundtrack
455
456        # If there was no prev selection, look in prefs.
457        if old_selection is None:
458            old_selection = bui.app.config.get('Soundtrack')
459        old_selection_index = self._selected_soundtrack_index
460
461        # Delete old.
462        while self._soundtrack_widgets:
463            self._soundtrack_widgets.pop().delete()
464
465        self._soundtracks = bui.app.config.get('Soundtracks', {})
466        assert self._soundtracks is not None
467        items = list(self._soundtracks.items())
468        items.sort(key=lambda x: asserttype(x[0], str).lower())
469        items = [('__default__', None)] + items  # default is always first
470        index = 0
471        for pname, _pval in items:
472            assert pname is not None
473            txtw = bui.textwidget(
474                parent=self._col,
475                size=(self._width - 40, 24),
476                text=self._get_soundtrack_display_name(pname),
477                h_align='left',
478                v_align='center',
479                maxwidth=self._width - 110,
480                always_highlight=True,
481                on_select_call=bui.WeakCall(self._select, pname, index),
482                on_activate_call=self._edit_soundtrack_with_sound,
483                selectable=True,
484            )
485            if index == 0:
486                bui.widget(edit=txtw, up_widget=self._back_button)
487            self._soundtrack_widgets.append(txtw)
488
489            # Select this one if the user requested it
490            if select_soundtrack is not None:
491                if pname == select_soundtrack:
492                    bui.columnwidget(
493                        edit=self._col, selected_child=txtw, visible_child=txtw
494                    )
495            else:
496                # Select this one if it was previously selected.
497                # Go by index if there's one.
498                if old_selection_index is not None:
499                    if index == old_selection_index:
500                        bui.columnwidget(
501                            edit=self._col,
502                            selected_child=txtw,
503                            visible_child=txtw,
504                        )
505                else:  # Otherwise look by name.
506                    if pname == old_selection:
507                        bui.columnwidget(
508                            edit=self._col,
509                            selected_child=txtw,
510                            visible_child=txtw,
511                        )
512            index += 1
513
514        # Explicitly run select callback on current one and re-enable
515        # callbacks.
516
517        # Eww need to run this in a timer so it happens after our select
518        # callbacks. With a small-enough time sometimes it happens before
519        # anyway. Ew. need a way to just schedule a callable i guess.
520        bui.apptimer(0.1, bui.WeakCall(self._set_allow_changing))
521
522    def _set_allow_changing(self) -> None:
523        self._allow_changing_soundtracks = True
524        assert self._selected_soundtrack is not None
525        assert self._selected_soundtrack_index is not None
526        self._select(self._selected_soundtrack, self._selected_soundtrack_index)
527
528    def _new_soundtrack(self) -> None:
529        # pylint: disable=cyclic-import
530        from bauiv1lib.purchase import PurchaseWindow
531        from bauiv1lib.soundtrack.edit import SoundtrackEditWindow
532
533        # no-op if we're not in control.
534        if not self.main_window_has_control():
535            return
536
537        if REQUIRE_PRO and (
538            bui.app.classic is not None
539            and not bui.app.classic.accounts.have_pro_options()
540        ):
541            PurchaseWindow(items=['pro'])
542            return
543
544        self.main_window_replace(SoundtrackEditWindow(existing_soundtrack=None))
545
546    def _create_done(self, new_soundtrack: str) -> None:
547        if new_soundtrack is not None:
548            bui.getsound('gunCocking').play()
549            self._refresh(select_soundtrack=new_soundtrack)
550
551    def _save_state(self) -> None:
552        try:
553            sel = self._root_widget.get_selected_child()
554            if sel == self._scrollwidget:
555                sel_name = 'Scroll'
556            elif sel == self._new_button:
557                sel_name = 'New'
558            elif sel == self._edit_button:
559                sel_name = 'Edit'
560            elif sel == self._duplicate_button:
561                sel_name = 'Duplicate'
562            elif sel == self._delete_button:
563                sel_name = 'Delete'
564            elif sel == self._back_button:
565                sel_name = 'Back'
566            else:
567                raise ValueError(f'unrecognized selection \'{sel}\'')
568            assert bui.app.classic is not None
569            bui.app.ui_v1.window_states[type(self)] = sel_name
570        except Exception:
571            logging.exception('Error saving state for %s.', self)
572
573    def _restore_state(self) -> None:
574        try:
575            assert bui.app.classic is not None
576            sel_name = bui.app.ui_v1.window_states.get(type(self))
577            if sel_name == 'Scroll':
578                sel = self._scrollwidget
579            elif sel_name == 'New':
580                sel = self._new_button
581            elif sel_name == 'Edit':
582                sel = self._edit_button
583            elif sel_name == 'Duplicate':
584                sel = self._duplicate_button
585            elif sel_name == 'Delete':
586                sel = self._delete_button
587            else:
588                sel = self._scrollwidget
589            bui.containerwidget(edit=self._root_widget, selected_child=sel)
590        except Exception:
591            logging.exception('Error restoring state for %s.', self)

Window for browsing soundtracks.

SoundtrackBrowserWindow( transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None)
 23    def __init__(
 24        self,
 25        transition: str | None = 'in_right',
 26        origin_widget: bui.Widget | None = None,
 27    ):
 28        # pylint: disable=too-many-statements
 29        # pylint: disable=too-many-locals
 30
 31        self._r = 'editSoundtrackWindow'
 32        assert bui.app.classic is not None
 33        uiscale = bui.app.ui_v1.uiscale
 34        self._width = 1200 if uiscale is bui.UIScale.SMALL else 650
 35        self._height = 800 if uiscale is bui.UIScale.SMALL else 400
 36
 37        # Do some fancy math to fill all available screen area up to the
 38        # size of our backing container. This lets us fit to the exact
 39        # screen shape at small ui scale.
 40        screensize = bui.get_virtual_screen_size()
 41        scale = (
 42            2.1
 43            if uiscale is bui.UIScale.SMALL
 44            else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0
 45        )
 46        # Calc screen size in our local container space and clamp to a
 47        # bit smaller than our container size.
 48        # target_width = min(self._width - 60, screensize[0] / scale)
 49        target_height = min(self._height - 70, screensize[1] / scale)
 50
 51        # To get top/left coords, go to the center of our window and
 52        # offset by half the width/height of our target area.
 53        yoffs = 0.5 * self._height + 0.5 * target_height + 30.0
 54
 55        super().__init__(
 56            root_widget=bui.containerwidget(
 57                size=(self._width, self._height),
 58                toolbar_visibility=(
 59                    'menu_minimal'
 60                    if uiscale is bui.UIScale.SMALL
 61                    else 'menu_full'
 62                ),
 63                scale=scale,
 64            ),
 65            transition=transition,
 66            origin_widget=origin_widget,
 67            # We're affected by screen size only at small ui-scale.
 68            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
 69        )
 70
 71        assert bui.app.classic is not None
 72        if uiscale is bui.UIScale.SMALL:
 73            self._back_button = None
 74        else:
 75            self._back_button = bui.buttonwidget(
 76                parent=self._root_widget,
 77                position=(50, yoffs - 60),
 78                size=(60, 60),
 79                scale=0.8,
 80                label=bui.charstr(bui.SpecialChar.BACK),
 81                button_type='backSmall',
 82                autoselect=True,
 83            )
 84        bui.textwidget(
 85            parent=self._root_widget,
 86            position=(
 87                self._width * 0.5,
 88                yoffs - (55 if uiscale is bui.UIScale.SMALL else 35),
 89            ),
 90            size=(0, 0),
 91            maxwidth=300,
 92            text=bui.Lstr(resource=f'{self._r}.titleText'),
 93            color=bui.app.ui_v1.title_color,
 94            h_align='center',
 95            v_align='center',
 96        )
 97
 98        # Generally center all other content
 99        x_inset = self._width * 0.5 - 320
100        vbase = v = self._height * 0.5 + 130
101
102        h = 43 + x_inset
103        b_color = (0.6, 0.53, 0.63)
104        b_textcolor = (0.75, 0.7, 0.8)
105        lock_tex = bui.gettexture('lock')
106        self._lock_images: list[bui.Widget] = []
107
108        scl = 1.2
109        v -= 60.0 * scl
110        self._new_button = btn = bui.buttonwidget(
111            parent=self._root_widget,
112            position=(h, v),
113            size=(100, 55.0 * scl),
114            on_activate_call=self._new_soundtrack,
115            color=b_color,
116            button_type='square',
117            autoselect=True,
118            textcolor=b_textcolor,
119            text_scale=0.7,
120            label=bui.Lstr(resource=f'{self._r}.newText'),
121        )
122        self._lock_images.append(
123            bui.imagewidget(
124                parent=self._root_widget,
125                size=(30, 30),
126                draw_controller=btn,
127                position=(h - 10, v + 55.0 * scl - 28),
128                texture=lock_tex,
129            )
130        )
131
132        if self._back_button is None:
133            bui.widget(
134                edit=btn,
135                left_widget=bui.get_special_widget('back_button'),
136            )
137        v -= 60.0 * scl
138
139        self._edit_button = btn = bui.buttonwidget(
140            parent=self._root_widget,
141            position=(h, v),
142            size=(100, 55.0 * scl),
143            on_activate_call=self._edit_soundtrack,
144            color=b_color,
145            button_type='square',
146            autoselect=True,
147            textcolor=b_textcolor,
148            text_scale=0.7,
149            label=bui.Lstr(resource=f'{self._r}.editText'),
150        )
151        self._lock_images.append(
152            bui.imagewidget(
153                parent=self._root_widget,
154                size=(30, 30),
155                draw_controller=btn,
156                position=(h - 10, v + 55.0 * scl - 28),
157                texture=lock_tex,
158            )
159        )
160        if self._back_button is None:
161            bui.widget(
162                edit=btn,
163                left_widget=bui.get_special_widget('back_button'),
164            )
165        v -= 60.0 * scl
166
167        self._duplicate_button = btn = bui.buttonwidget(
168            parent=self._root_widget,
169            position=(h, v),
170            size=(100, 55.0 * scl),
171            on_activate_call=self._duplicate_soundtrack,
172            button_type='square',
173            autoselect=True,
174            color=b_color,
175            textcolor=b_textcolor,
176            text_scale=0.7,
177            label=bui.Lstr(resource=f'{self._r}.duplicateText'),
178        )
179        self._lock_images.append(
180            bui.imagewidget(
181                parent=self._root_widget,
182                size=(30, 30),
183                draw_controller=btn,
184                position=(h - 10, v + 55.0 * scl - 28),
185                texture=lock_tex,
186            )
187        )
188        if self._back_button is None:
189            bui.widget(
190                edit=btn,
191                left_widget=bui.get_special_widget('back_button'),
192            )
193        v -= 60.0 * scl
194
195        self._delete_button = btn = bui.buttonwidget(
196            parent=self._root_widget,
197            position=(h, v),
198            size=(100, 55.0 * scl),
199            on_activate_call=self._delete_soundtrack,
200            color=b_color,
201            button_type='square',
202            autoselect=True,
203            textcolor=b_textcolor,
204            text_scale=0.7,
205            label=bui.Lstr(resource=f'{self._r}.deleteText'),
206        )
207        self._lock_images.append(
208            bui.imagewidget(
209                parent=self._root_widget,
210                size=(30, 30),
211                draw_controller=btn,
212                position=(h - 10, v + 55.0 * scl - 28),
213                texture=lock_tex,
214            )
215        )
216        if self._back_button is None:
217            bui.widget(
218                edit=btn,
219                left_widget=bui.get_special_widget('back_button'),
220            )
221
222        # Keep our lock images up to date/etc.
223        self._update_timer = bui.AppTimer(
224            1.0, bui.WeakCall(self._update), repeat=True
225        )
226        self._update()
227
228        v = vbase - 6
229        scroll_height = 280
230        v -= scroll_height
231        self._scrollwidget = scrollwidget = bui.scrollwidget(
232            parent=self._root_widget,
233            position=(152 + x_inset, v),
234            highlight=False,
235            size=(450, scroll_height),
236            border_opacity=0.4,
237        )
238        bui.widget(
239            edit=self._scrollwidget,
240            left_widget=self._new_button,
241            right_widget=bui.get_special_widget('squad_button'),
242        )
243        self._col = bui.columnwidget(parent=scrollwidget, border=2, margin=0)
244
245        self._soundtracks: dict[str, Any] | None = None
246        self._selected_soundtrack: str | None = None
247        self._selected_soundtrack_index: int | None = None
248        self._soundtrack_widgets: list[bui.Widget] = []
249        self._allow_changing_soundtracks = False
250        self._refresh()
251        if self._back_button is not None:
252            bui.buttonwidget(
253                edit=self._back_button, on_activate_call=self.main_window_back
254            )
255            bui.containerwidget(
256                edit=self._root_widget, cancel_button=self._back_button
257            )
258        else:
259            bui.containerwidget(
260                edit=self._root_widget, on_cancel_call=self.main_window_back
261            )

Create a MainWindow given a root widget and transition info.

Automatically handles in and out transitions on the provided widget, so there is no need to set transitions when creating it.

@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
263    @override
264    def get_main_window_state(self) -> bui.MainWindowState:
265        # Support recreating our window for back/refresh purposes.
266        cls = type(self)
267        return bui.BasicMainWindowState(
268            create_call=lambda transition, origin_widget: cls(
269                transition=transition, origin_widget=origin_widget
270            )
271        )

Return a WindowState to recreate this window, if supported.

@override
def on_main_window_close(self) -> None:
273    @override
274    def on_main_window_close(self) -> None:
275        self._save_state()

Called before transitioning out a main window.

A good opportunity to save window state/etc.