# Released under the MIT License. See LICENSE for details.
#
"""Provides UI for viewing/creating/editing playlists."""
from __future__ import annotations
import copy
import time
from typing import TYPE_CHECKING, override
import bauiv1 as bui
if TYPE_CHECKING:
from typing import Any, Callable
import bascenev1 as bs
REQUIRE_PRO = False
[docs]
class PlaylistCustomizeBrowserWindow(bui.MainWindow):
"""Window for viewing a playlist."""
def __init__(
self,
sessiontype: type[bs.Session],
transition: str | None = 'in_right',
origin_widget: bui.Widget | None = None,
select_playlist: str | None = None,
):
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
# pylint: disable=cyclic-import
from bauiv1lib import playlist
self._sessiontype = sessiontype
self._pvars = playlist.PlaylistTypeVars(sessiontype)
self._max_playlists = 30
self._r = 'gameListWindow'
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
self._width = 1200.0 if uiscale is bui.UIScale.SMALL else 650.0
self._height = (
800.0
if uiscale is bui.UIScale.SMALL
else 420.0 if uiscale is bui.UIScale.MEDIUM else 500.0
)
# 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 = (
1.8
if uiscale is bui.UIScale.SMALL
else 1.4 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 - 70, screensize[0] / scale)
target_height = min(self._height - 40, 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 if uiscale is bui.UIScale.SMALL else 50)
)
self._button_width = 90
self._x_inset = 10
self._scroll_width = (
target_width - self._button_width - 2.0 * self._x_inset
)
self._scroll_height = target_height - 75
self._scroll_bottom = yoffs - 98 - self._scroll_height
self._button_height = self._scroll_height / 6.0
super().__init__(
root_widget=bui.containerwidget(
size=(self._width, self._height),
scale=scale,
toolbar_visibility=(
'menu_minimal'
if uiscale is bui.UIScale.SMALL
else 'menu_full'
),
),
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,
)
self._back_button: bui.Widget | None
if uiscale is bui.UIScale.SMALL:
self._back_button = None
bui.containerwidget(
edit=self._root_widget, on_cancel_call=self.main_window_back
)
else:
self._back_button = bui.buttonwidget(
parent=self._root_widget,
position=(43, yoffs - 87),
size=(60, 60),
scale=0.77,
autoselect=True,
text_scale=1.3,
label=bui.charstr(bui.SpecialChar.BACK),
button_type='backSmall',
)
bui.textwidget(
parent=self._root_widget,
position=(0, yoffs - (77 if uiscale is bui.UIScale.SMALL else 77)),
size=(self._width, 25),
text=bui.Lstr(
resource=f'{self._r}.titleText',
subs=[('${TYPE}', self._pvars.window_title_name)],
),
color=bui.app.ui_v1.heading_color,
maxwidth=290,
h_align='center',
v_align='center',
)
h = self._width * 0.5 - (self._scroll_width + self._button_width) * 0.5
b_color = (0.6, 0.53, 0.63)
b_textcolor = (0.75, 0.7, 0.8)
self._lock_images: list[bui.Widget] = []
xmargin = 0.06
ymargin = 0.05
def _make_button(
i: int, label: bui.Lstr, call: Callable[[], None]
) -> bui.Widget:
v = self._scroll_bottom + self._button_height * i
return bui.buttonwidget(
parent=self._root_widget,
position=(
h + xmargin * self._button_width,
v + ymargin * self._button_height,
),
size=(
self._button_width * (1.0 - 2.0 * xmargin),
self._button_height * (1.0 - 2.0 * ymargin),
),
on_activate_call=call,
color=b_color,
autoselect=True,
button_type='square',
textcolor=b_textcolor,
text_scale=0.7,
label=label,
)
new_button = _make_button(
5,
bui.Lstr(
resource='newText', fallback_resource=f'{self._r}.newText'
),
self._new_playlist,
)
self._edit_button = _make_button(
4,
bui.Lstr(
resource='editText',
fallback_resource=f'{self._r}.editText',
),
self._edit_playlist,
)
duplicate_button = _make_button(
3,
bui.Lstr(
resource='duplicateText',
fallback_resource=f'{self._r}.duplicateText',
),
self._duplicate_playlist,
)
delete_button = _make_button(
2,
bui.Lstr(
resource='deleteText', fallback_resource=f'{self._r}.deleteText'
),
self._delete_playlist,
)
self._import_button = _make_button(
1, bui.Lstr(resource='importText'), self._import_playlist
)
share_button = _make_button(
0, bui.Lstr(resource='shareText'), self._share_playlist
)
scrollwidget = bui.scrollwidget(
parent=self._root_widget,
size=(self._scroll_width, self._scroll_height),
position=(
self._width * 0.5
- (self._scroll_width + self._button_width) * 0.5
+ self._button_width,
self._scroll_bottom,
),
highlight=False,
border_opacity=0.4,
)
if self._back_button is not None:
bui.widget(edit=self._back_button, right_widget=scrollwidget)
self._columnwidget = bui.columnwidget(
parent=scrollwidget, border=2, margin=0
)
h = 145
self._do_randomize_val = bui.app.config.get(
self._pvars.config_name + ' Playlist Randomize', 0
)
h += 210
for btn in [
new_button,
delete_button,
self._edit_button,
duplicate_button,
self._import_button,
share_button,
]:
bui.widget(edit=btn, right_widget=scrollwidget)
bui.widget(
edit=scrollwidget,
left_widget=new_button,
right_widget=bui.get_special_widget('squad_button'),
)
# Make sure config exists.
self._config_name_full = f'{self._pvars.config_name} Playlists'
if self._config_name_full not in bui.app.config:
bui.app.config[self._config_name_full] = {}
self._selected_playlist_name: str | None = None
self._selected_playlist_index: int | None = None
self._playlist_widgets: list[bui.Widget] = []
self._refresh(select_playlist=select_playlist)
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
)
bui.containerwidget(edit=self._root_widget, selected_child=scrollwidget)
# Keep our lock images up to date/etc.
self._update_timer = bui.AppTimer(
1.0, bui.WeakCall(self._update), repeat=True
)
self._update()
[docs]
@override
def get_main_window_state(self) -> bui.MainWindowState:
# Support recreating our window for back/refresh purposes.
cls = type(self)
# Avoid dereferencing self within the lambda or we'll keep
# ourself alive indefinitely.
stype = self._sessiontype
return bui.BasicMainWindowState(
create_call=lambda transition, origin_widget: cls(
transition=transition,
origin_widget=origin_widget,
sessiontype=stype,
)
)
[docs]
@override
def on_main_window_close(self) -> None:
if self._selected_playlist_name is not None:
cfg = bui.app.config
cfg[f'{self._pvars.config_name} Playlist Selection'] = (
self._selected_playlist_name
)
cfg.commit()
def _update(self) -> None:
assert bui.app.classic is not None
have = bui.app.classic.accounts.have_pro_options()
for lock in self._lock_images:
bui.imagewidget(
edit=lock, opacity=0.0 if (have or not REQUIRE_PRO) else 1.0
)
def _select(self, name: str, index: int) -> None:
self._selected_playlist_name = name
self._selected_playlist_index = index
def _refresh(self, select_playlist: str | None = None) -> None:
from efro.util import asserttype
old_selection = self._selected_playlist_name
# If there was no prev selection, look in prefs.
if old_selection is None:
old_selection = bui.app.config.get(
self._pvars.config_name + ' Playlist Selection'
)
old_selection_index = self._selected_playlist_index
# Delete old.
while self._playlist_widgets:
self._playlist_widgets.pop().delete()
items = list(bui.app.config[self._config_name_full].items())
# Make sure everything is unicode now.
items = [
(i[0].decode(), i[1]) if not isinstance(i[0], str) else i
for i in items
]
items.sort(key=lambda x: asserttype(x[0], str).lower())
items = [['__default__', None]] + items # Default is always first.
index = 0
for pname, _ in items:
assert pname is not None
txtw = bui.textwidget(
parent=self._columnwidget,
size=(self._width - 40, 30),
maxwidth=440,
text=self._get_playlist_display_name(pname),
h_align='left',
v_align='center',
color=(
(0.6, 0.6, 0.7, 1.0)
if pname == '__default__'
else (0.85, 0.85, 0.85, 1)
),
always_highlight=True,
on_select_call=bui.Call(self._select, pname, index),
on_activate_call=bui.Call(self._edit_button.activate),
selectable=True,
)
bui.widget(edit=txtw, show_buffer_top=50, show_buffer_bottom=50)
# Hitting up from top widget should jump to 'back'.
if index == 0:
bui.widget(
edit=txtw,
up_widget=(
self._back_button
if self._back_button is not None
else bui.get_special_widget('back_button')
),
)
self._playlist_widgets.append(txtw)
# Select this one if the user requested it.
if select_playlist is not None:
if pname == select_playlist:
bui.columnwidget(
edit=self._columnwidget,
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._columnwidget,
selected_child=txtw,
visible_child=txtw,
)
else: # Otherwise look by name.
if pname == old_selection:
bui.columnwidget(
edit=self._columnwidget,
selected_child=txtw,
visible_child=txtw,
)
index += 1
def _save_playlist_selection(self) -> None:
# Store the selected playlist in prefs. This serves dual
# purposes of letting us re-select it next time if we want and
# also lets us pass it to the game (since we reset the whole
# python environment that's not actually easy).
cfg = bui.app.config
cfg[self._pvars.config_name + ' Playlist Selection'] = (
self._selected_playlist_name
)
cfg[self._pvars.config_name + ' Playlist Randomize'] = (
self._do_randomize_val
)
cfg.commit()
def _new_playlist(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.playlist.editcontroller import PlaylistEditController
from bauiv1lib.purchase import PurchaseWindow
# No-op if we're not in control.
if not self.main_window_has_control():
return
assert bui.app.classic is not None
if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options():
PurchaseWindow(items=['pro'])
return
# Clamp at our max playlist number.
if len(bui.app.config[self._config_name_full]) > self._max_playlists:
bui.screenmessage(
bui.Lstr(
translate=(
'serverResponses',
'Max number of playlists reached.',
)
),
color=(1, 0, 0),
)
bui.getsound('error').play()
return
# In case they cancel so we can return to this state.
self._save_playlist_selection()
# Kick off the edit UI.
PlaylistEditController(sessiontype=self._sessiontype, from_window=self)
def _edit_playlist(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.playlist.editcontroller import PlaylistEditController
from bauiv1lib.purchase import PurchaseWindow
assert bui.app.classic is not None
if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options():
PurchaseWindow(items=['pro'])
return
if self._selected_playlist_name is None:
return
if self._selected_playlist_name == '__default__':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=f'{self._r}.cantEditDefaultText')
)
return
self._save_playlist_selection()
PlaylistEditController(
existing_playlist_name=self._selected_playlist_name,
sessiontype=self._sessiontype,
from_window=self,
)
def _do_delete_playlist(self) -> None:
plus = bui.app.plus
assert plus is not None
plus.add_v1_account_transaction(
{
'type': 'REMOVE_PLAYLIST',
'playlistType': self._pvars.config_name,
'playlistName': self._selected_playlist_name,
}
)
plus.run_v1_account_transactions()
bui.getsound('shieldDown').play()
# (we don't use len()-1 here because the default list adds one)
assert self._selected_playlist_index is not None
self._selected_playlist_index = min(
self._selected_playlist_index,
len(bui.app.config[self._pvars.config_name + ' Playlists']),
)
self._refresh()
def _import_playlist(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.playlist import share
plus = bui.app.plus
assert plus is not None
# Gotta be signed in for this to work.
if plus.get_v1_account_state() != 'signed_in':
bui.screenmessage(
bui.Lstr(resource='notSignedInErrorText'), color=(1, 0, 0)
)
bui.getsound('error').play()
return
share.SharePlaylistImportWindow(
origin_widget=self._import_button,
on_success_callback=bui.WeakCall(self._on_playlist_import_success),
)
def _on_playlist_import_success(self) -> None:
self._refresh()
def _on_share_playlist_response(self, name: str, response: Any) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.playlist import share
if response is None:
bui.screenmessage(
bui.Lstr(resource='internal.unavailableNoConnectionText'),
color=(1, 0, 0),
)
bui.getsound('error').play()
return
share.SharePlaylistResultsWindow(name, response)
def _share_playlist(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.purchase import PurchaseWindow
plus = bui.app.plus
assert plus is not None
assert bui.app.classic is not None
if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options():
PurchaseWindow(items=['pro'])
return
# Gotta be signed in for this to work.
if plus.get_v1_account_state() != 'signed_in':
bui.screenmessage(
bui.Lstr(resource='notSignedInErrorText'), color=(1, 0, 0)
)
bui.getsound('error').play()
return
if self._selected_playlist_name == '__default__':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=f'{self._r}.cantShareDefaultText'),
color=(1, 0, 0),
)
return
if self._selected_playlist_name is None:
return
plus.add_v1_account_transaction(
{
'type': 'SHARE_PLAYLIST',
'expire_time': time.time() + 5,
'playlistType': self._pvars.config_name,
'playlistName': self._selected_playlist_name,
},
callback=bui.WeakCall(
self._on_share_playlist_response, self._selected_playlist_name
),
)
plus.run_v1_account_transactions()
bui.screenmessage(bui.Lstr(resource='sharingText'))
def _delete_playlist(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.purchase import PurchaseWindow
from bauiv1lib.confirm import ConfirmWindow
assert bui.app.classic is not None
if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options():
PurchaseWindow(items=['pro'])
return
if self._selected_playlist_name is None:
return
if self._selected_playlist_name == '__default__':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=f'{self._r}.cantDeleteDefaultText')
)
else:
ConfirmWindow(
bui.Lstr(
resource=f'{self._r}.deleteConfirmText',
subs=[('${LIST}', self._selected_playlist_name)],
),
self._do_delete_playlist,
450,
150,
)
def _get_playlist_display_name(self, playlist: str) -> bui.Lstr:
if playlist == '__default__':
return self._pvars.default_list_name
return (
playlist
if isinstance(playlist, bui.Lstr)
else bui.Lstr(value=playlist)
)
def _duplicate_playlist(self) -> None:
# pylint: disable=too-many-branches
# pylint: disable=cyclic-import
from bauiv1lib.purchase import PurchaseWindow
plus = bui.app.plus
assert plus is not None
assert bui.app.classic is not None
if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options():
PurchaseWindow(items=['pro'])
return
if self._selected_playlist_name is None:
return
plst: list[dict[str, Any]] | None
if self._selected_playlist_name == '__default__':
plst = self._pvars.get_default_list_call()
else:
plst = bui.app.config[self._config_name_full].get(
self._selected_playlist_name
)
if plst is None:
bui.getsound('error').play()
return
# Clamp at our max playlist number.
if len(bui.app.config[self._config_name_full]) > self._max_playlists:
bui.screenmessage(
bui.Lstr(
translate=(
'serverResponses',
'Max number of playlists reached.',
)
),
color=(1, 0, 0),
)
bui.getsound('error').play()
return
copy_text = bui.Lstr(resource='copyOfText').evaluate()
# Get just 'Copy' or whatnot.
copy_word = copy_text.replace('${NAME}', '').strip()
# Find a valid dup name that doesn't exist.
test_index = 1
base_name = self._get_playlist_display_name(
self._selected_playlist_name
).evaluate()
# 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 bui.app.config[self._config_name_full]:
break
test_index += 1
plus.add_v1_account_transaction(
{
'type': 'ADD_PLAYLIST',
'playlistType': self._pvars.config_name,
'playlistName': test_name,
'playlist': copy.deepcopy(plst),
}
)
plus.run_v1_account_transactions()
bui.getsound('gunCocking').play()
self._refresh(select_playlist=test_name)
# 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