Source code for bauiv1lib.soundtrack.browser

# Released under the MIT License. See LICENSE for details.
#
"""Provides UI for browsing soundtracks."""

from __future__ import annotations

import copy
import logging
from typing import TYPE_CHECKING, override

import bauiv1 as bui

if TYPE_CHECKING:
    from typing import Any

REQUIRE_PRO = False


[docs] class SoundtrackBrowserWindow(bui.MainWindow): """Window for browsing soundtracks.""" def __init__( self, transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): # pylint: disable=too-many-statements # pylint: disable=too-many-locals self._r = 'editSoundtrackWindow' assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale self._width = 1200 if uiscale is bui.UIScale.SMALL else 650 self._height = 800 if uiscale is bui.UIScale.SMALL else 400 # Do some fancy math to fill all available screen area up to the # size of our backing container. This lets us fit to the exact # screen shape at small ui scale. screensize = bui.get_virtual_screen_size() scale = ( 2.1 if uiscale is bui.UIScale.SMALL else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 ) # Calc screen size in our local container space and clamp to a # bit smaller than our container size. # target_width = min(self._width - 60, screensize[0] / scale) target_height = min(self._height - 70, screensize[1] / scale) # To get top/left coords, go to the center of our window and # offset by half the width/height of our target area. yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), toolbar_visibility=( 'menu_minimal' if uiscale is bui.UIScale.SMALL else 'menu_full' ), scale=scale, ), transition=transition, origin_widget=origin_widget, # We're affected by screen size only at small ui-scale. refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) assert bui.app.classic is not None if uiscale is bui.UIScale.SMALL: self._back_button = None else: self._back_button = bui.buttonwidget( parent=self._root_widget, position=(50, yoffs - 60), size=(60, 60), scale=0.8, label=bui.charstr(bui.SpecialChar.BACK), button_type='backSmall', autoselect=True, ) bui.textwidget( parent=self._root_widget, position=( self._width * 0.5, yoffs - (55 if uiscale is bui.UIScale.SMALL else 35), ), size=(0, 0), maxwidth=300, text=bui.Lstr(resource=f'{self._r}.titleText'), color=bui.app.ui_v1.title_color, h_align='center', v_align='center', ) # Generally center all other content x_inset = self._width * 0.5 - 320 vbase = v = self._height * 0.5 + 130 h = 43 + x_inset b_color = (0.6, 0.53, 0.63) b_textcolor = (0.75, 0.7, 0.8) lock_tex = bui.gettexture('lock') self._lock_images: list[bui.Widget] = [] scl = 1.2 v -= 60.0 * scl self._new_button = btn = bui.buttonwidget( parent=self._root_widget, position=(h, v), size=(100, 55.0 * scl), on_activate_call=self._new_soundtrack, color=b_color, button_type='square', autoselect=True, textcolor=b_textcolor, text_scale=0.7, label=bui.Lstr(resource=f'{self._r}.newText'), ) self._lock_images.append( bui.imagewidget( parent=self._root_widget, size=(30, 30), draw_controller=btn, position=(h - 10, v + 55.0 * scl - 28), texture=lock_tex, ) ) if self._back_button is None: bui.widget( edit=btn, left_widget=bui.get_special_widget('back_button'), ) v -= 60.0 * scl self._edit_button = btn = bui.buttonwidget( parent=self._root_widget, position=(h, v), size=(100, 55.0 * scl), on_activate_call=self._edit_soundtrack, color=b_color, button_type='square', autoselect=True, textcolor=b_textcolor, text_scale=0.7, label=bui.Lstr(resource=f'{self._r}.editText'), ) self._lock_images.append( bui.imagewidget( parent=self._root_widget, size=(30, 30), draw_controller=btn, position=(h - 10, v + 55.0 * scl - 28), texture=lock_tex, ) ) if self._back_button is None: bui.widget( edit=btn, left_widget=bui.get_special_widget('back_button'), ) v -= 60.0 * scl self._duplicate_button = btn = bui.buttonwidget( parent=self._root_widget, position=(h, v), size=(100, 55.0 * scl), on_activate_call=self._duplicate_soundtrack, button_type='square', autoselect=True, color=b_color, textcolor=b_textcolor, text_scale=0.7, label=bui.Lstr(resource=f'{self._r}.duplicateText'), ) self._lock_images.append( bui.imagewidget( parent=self._root_widget, size=(30, 30), draw_controller=btn, position=(h - 10, v + 55.0 * scl - 28), texture=lock_tex, ) ) if self._back_button is None: bui.widget( edit=btn, left_widget=bui.get_special_widget('back_button'), ) v -= 60.0 * scl self._delete_button = btn = bui.buttonwidget( parent=self._root_widget, position=(h, v), size=(100, 55.0 * scl), on_activate_call=self._delete_soundtrack, color=b_color, button_type='square', autoselect=True, textcolor=b_textcolor, text_scale=0.7, label=bui.Lstr(resource=f'{self._r}.deleteText'), ) self._lock_images.append( bui.imagewidget( parent=self._root_widget, size=(30, 30), draw_controller=btn, position=(h - 10, v + 55.0 * scl - 28), texture=lock_tex, ) ) if self._back_button is None: bui.widget( edit=btn, left_widget=bui.get_special_widget('back_button'), ) # Keep our lock images up to date/etc. self._update_timer = bui.AppTimer( 1.0, bui.WeakCall(self._update), repeat=True ) self._update() v = vbase - 6 scroll_height = 280 v -= scroll_height self._scrollwidget = scrollwidget = bui.scrollwidget( parent=self._root_widget, position=(152 + x_inset, v), highlight=False, size=(450, scroll_height), border_opacity=0.4, ) bui.widget( edit=self._scrollwidget, left_widget=self._new_button, right_widget=bui.get_special_widget('squad_button'), ) self._col = bui.columnwidget(parent=scrollwidget, border=2, margin=0) self._soundtracks: dict[str, Any] | None = None self._selected_soundtrack: str | None = None self._selected_soundtrack_index: int | None = None self._soundtrack_widgets: list[bui.Widget] = [] self._allow_changing_soundtracks = False self._refresh() if self._back_button is not None: bui.buttonwidget( edit=self._back_button, on_activate_call=self.main_window_back ) bui.containerwidget( edit=self._root_widget, cancel_button=self._back_button ) else: bui.containerwidget( edit=self._root_widget, on_cancel_call=self.main_window_back )
[docs] @override def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. cls = type(self) return bui.BasicMainWindowState( create_call=lambda transition, origin_widget: cls( transition=transition, origin_widget=origin_widget ) )
[docs] @override def on_main_window_close(self) -> None: self._save_state()
def _update(self) -> None: have_pro = ( bui.app.classic is None or bui.app.classic.accounts.have_pro_options() ) for lock in self._lock_images: bui.imagewidget( edit=lock, opacity=0.0 if (have_pro or not REQUIRE_PRO) else 1.0 ) def _do_delete_soundtrack(self) -> None: cfg = bui.app.config soundtracks = cfg.setdefault('Soundtracks', {}) if self._selected_soundtrack in soundtracks: del soundtracks[self._selected_soundtrack] cfg.commit() bui.getsound('shieldDown').play() assert self._selected_soundtrack_index is not None assert self._soundtracks is not None self._selected_soundtrack_index = min( self._selected_soundtrack_index, len(self._soundtracks) ) self._refresh() def _delete_soundtrack(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.purchase import PurchaseWindow from bauiv1lib.confirm import ConfirmWindow if REQUIRE_PRO and ( bui.app.classic is not None and not bui.app.classic.accounts.have_pro_options() ): PurchaseWindow(items=['pro']) return if self._selected_soundtrack is None: return if self._selected_soundtrack == '__default__': bui.getsound('error').play() bui.screenmessage( bui.Lstr(resource=f'{self._r}.cantDeleteDefaultText'), color=(1, 0, 0), ) else: ConfirmWindow( bui.Lstr( resource=f'{self._r}.deleteConfirmText', subs=[('${NAME}', self._selected_soundtrack)], ), self._do_delete_soundtrack, 450, 150, ) def _duplicate_soundtrack(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.purchase import PurchaseWindow if REQUIRE_PRO and ( bui.app.classic is not None and not bui.app.classic.accounts.have_pro_options() ): PurchaseWindow(items=['pro']) return cfg = bui.app.config cfg.setdefault('Soundtracks', {}) if self._selected_soundtrack is None: return sdtk: dict[str, Any] if self._selected_soundtrack == '__default__': sdtk = {} else: sdtk = cfg['Soundtracks'][self._selected_soundtrack] # Find a valid dup name that doesn't exist. test_index = 1 copy_text = bui.Lstr(resource='copyOfText').evaluate() # Get just 'Copy' or whatnot. copy_word = copy_text.replace('${NAME}', '').strip() base_name = self._get_soundtrack_display_name( self._selected_soundtrack ).evaluate() assert isinstance(base_name, str) # If it looks like a copy, strip digits and spaces off the end. if copy_word in base_name: while base_name[-1].isdigit() or base_name[-1] == ' ': base_name = base_name[:-1] while True: if copy_word in base_name: test_name = base_name else: test_name = copy_text.replace('${NAME}', base_name) if test_index > 1: test_name += ' ' + str(test_index) if test_name not in cfg['Soundtracks']: break test_index += 1 cfg['Soundtracks'][test_name] = copy.deepcopy(sdtk) cfg.commit() self._refresh(select_soundtrack=test_name) def _select(self, name: str, index: int) -> None: assert bui.app.classic is not None music = bui.app.classic.music self._selected_soundtrack_index = index self._selected_soundtrack = name cfg = bui.app.config current_soundtrack = cfg.setdefault('Soundtrack', '__default__') # If it varies from current, commit and play. if current_soundtrack != name and self._allow_changing_soundtracks: bui.getsound('gunCocking').play() cfg['Soundtrack'] = self._selected_soundtrack cfg.commit() # Just play whats already playing.. this'll grab it from the # new soundtrack. music.do_play_music( music.music_types[bui.app.classic.MusicPlayMode.REGULAR] ) def _edit_soundtrack_with_sound(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.purchase import PurchaseWindow if REQUIRE_PRO and ( bui.app.classic is not None and not bui.app.classic.accounts.have_pro_options() ): PurchaseWindow(items=['pro']) return bui.getsound('swish').play() self._edit_soundtrack() def _edit_soundtrack(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.purchase import PurchaseWindow from bauiv1lib.soundtrack.edit import SoundtrackEditWindow # no-op if we don't have control. if not self.main_window_has_control(): return if REQUIRE_PRO and ( bui.app.classic is not None and not bui.app.classic.accounts.have_pro_options() ): PurchaseWindow(items=['pro']) return if self._selected_soundtrack is None: return if self._selected_soundtrack == '__default__': bui.getsound('error').play() bui.screenmessage( bui.Lstr(resource=f'{self._r}.cantEditDefaultText'), color=(1, 0, 0), ) return self.main_window_replace( SoundtrackEditWindow(existing_soundtrack=self._selected_soundtrack) ) def _get_soundtrack_display_name(self, soundtrack: str) -> bui.Lstr: if soundtrack == '__default__': return bui.Lstr(resource=f'{self._r}.defaultSoundtrackNameText') return bui.Lstr(value=soundtrack) def _refresh(self, select_soundtrack: str | None = None) -> None: from efro.util import asserttype self._allow_changing_soundtracks = False old_selection = self._selected_soundtrack # If there was no prev selection, look in prefs. if old_selection is None: old_selection = bui.app.config.get('Soundtrack') old_selection_index = self._selected_soundtrack_index # Delete old. while self._soundtrack_widgets: self._soundtrack_widgets.pop().delete() self._soundtracks = bui.app.config.get('Soundtracks', {}) assert self._soundtracks is not None items = list(self._soundtracks.items()) items.sort(key=lambda x: asserttype(x[0], str).lower()) items = [('__default__', None)] + items # default is always first index = 0 for pname, _pval in items: assert pname is not None txtw = bui.textwidget( parent=self._col, size=(self._width - 40, 24), text=self._get_soundtrack_display_name(pname), h_align='left', v_align='center', maxwidth=self._width - 110, always_highlight=True, on_select_call=bui.WeakCall(self._select, pname, index), on_activate_call=self._edit_soundtrack_with_sound, selectable=True, ) if index == 0: bui.widget(edit=txtw, up_widget=self._back_button) self._soundtrack_widgets.append(txtw) # Select this one if the user requested it if select_soundtrack is not None: if pname == select_soundtrack: bui.columnwidget( edit=self._col, selected_child=txtw, visible_child=txtw ) else: # Select this one if it was previously selected. # Go by index if there's one. if old_selection_index is not None: if index == old_selection_index: bui.columnwidget( edit=self._col, selected_child=txtw, visible_child=txtw, ) else: # Otherwise look by name. if pname == old_selection: bui.columnwidget( edit=self._col, selected_child=txtw, visible_child=txtw, ) index += 1 # Explicitly run select callback on current one and re-enable # callbacks. # Eww need to run this in a timer so it happens after our select # callbacks. With a small-enough time sometimes it happens before # anyway. Ew. need a way to just schedule a callable i guess. bui.apptimer(0.1, bui.WeakCall(self._set_allow_changing)) def _set_allow_changing(self) -> None: self._allow_changing_soundtracks = True assert self._selected_soundtrack is not None assert self._selected_soundtrack_index is not None self._select(self._selected_soundtrack, self._selected_soundtrack_index) def _new_soundtrack(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.purchase import PurchaseWindow from bauiv1lib.soundtrack.edit import SoundtrackEditWindow # no-op if we're not in control. if not self.main_window_has_control(): return if REQUIRE_PRO and ( bui.app.classic is not None and not bui.app.classic.accounts.have_pro_options() ): PurchaseWindow(items=['pro']) return self.main_window_replace(SoundtrackEditWindow(existing_soundtrack=None)) def _create_done(self, new_soundtrack: str) -> None: if new_soundtrack is not None: bui.getsound('gunCocking').play() self._refresh(select_soundtrack=new_soundtrack) def _save_state(self) -> None: try: sel = self._root_widget.get_selected_child() if sel == self._scrollwidget: sel_name = 'Scroll' elif sel == self._new_button: sel_name = 'New' elif sel == self._edit_button: sel_name = 'Edit' elif sel == self._duplicate_button: sel_name = 'Duplicate' elif sel == self._delete_button: sel_name = 'Delete' elif sel == self._back_button: sel_name = 'Back' else: raise ValueError(f'unrecognized selection \'{sel}\'') assert bui.app.classic is not None bui.app.ui_v1.window_states[type(self)] = sel_name except Exception: logging.exception('Error saving state for %s.', self) def _restore_state(self) -> None: try: assert bui.app.classic is not None sel_name = bui.app.ui_v1.window_states.get(type(self)) if sel_name == 'Scroll': sel = self._scrollwidget elif sel_name == 'New': sel = self._new_button elif sel_name == 'Edit': sel = self._edit_button elif sel_name == 'Duplicate': sel = self._duplicate_button elif sel_name == 'Delete': sel = self._delete_button else: sel = self._scrollwidget bui.containerwidget(edit=self._root_widget, selected_child=sel) except Exception: logging.exception('Error restoring state for %s.', self)
# Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum