Make ExpanderTab "expand" properly (#9522)

* Make ExpanderTab "expand" properly

* Make ExpanderTab "expand" properly - patch1

* Make ExpanderTab "expand" properly - new signature

* Make ExpanderTab "expand" properly - enable dynamic content

* Minor WorldScreenMusicPopup visual tweaks

* Make ExpanderTab "expand" properly - tweaks

* Make ExpanderTab "expand" properly - Kdoc and types review

* Post-merge fixes
This commit is contained in:
SomeTroglodyte
2023-06-12 06:16:06 +02:00
committed by GitHub
parent 8d2af7af78
commit ae74dca074
11 changed files with 244 additions and 136 deletions

View File

@ -1,31 +1,41 @@
package com.unciv.ui.components
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.math.Interpolation
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.actions.FloatAction
import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.Container
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup
import com.badlogic.gdx.scenes.scene2d.utils.Layout
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.components.input.onClick
import com.unciv.models.metadata.GameSettings
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.onClick
import com.unciv.ui.images.IconCircleGroup
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import kotlin.math.abs
/**
* A widget with a header that when clicked shows/hides a sub-Table.
*
* @param title The header text, automatically translated.
* @param fontSize Size applied to header text (only)
* @param icon Optional icon - please use [Image][com.badlogic.gdx.scenes.scene2d.ui.Image] or [IconCircleGroup]
* @param icon Optional icon - please use [Image] or [IconCircleGroup] and make sure size is set
* @param startsOutOpened Default initial "open" state if no [persistenceID] set or no persistes state found
* @param defaultPad Padding between content and wrapper.
* @param headerPad Default padding for the header Table.
* @param expanderWidth If set initializes header width
* @param headerAlign How the header content aligns - use [Align] constants.
* @param expanderWidth If set initializes cell minWidth and wrapper width
* @param persistenceID If specified, the ExpanderTab will remember its open/closed state for the duration of one app run
* @param onChange If specified, this will be called after the visual change for a change in [isOpen] completes (e.g. to react to changed size)
* @param initContent Optional lambda with [innerTable] as parameter, to help initialize content.
* @param animated Controls whether opening/closing is animated, defaults to the [continuousRendering][GameSettings.continuousRendering] setting.
* @param content An [Actor] supporting [Layout] with the content to display in expanded state. Will be `pack()`ed!
* @param onChange If specified, this will be called on any visual change: repeatedly during animation if enabled, otherwise once after each change to [isOpen]. (e.g. to react to changed size)
*/
class ExpanderTab(
title: String,
@ -34,27 +44,64 @@ class ExpanderTab(
startsOutOpened: Boolean = true,
defaultPad: Float = 10f,
headerPad: Float = 10f,
expanderWidth: Float = 0f,
headerAlign: Int = Align.center,
private val expanderWidth: Float = 0f,
private val persistenceID: String? = null,
private val onChange: (() -> Unit)? = null,
initContent: ((Table) -> Unit)? = null
): Table(BaseScreen.skin) {
private companion object {
const val arrowSize = 18f
const val arrowImage = "OtherIcons/BackArrow"
val arrowColor = Color(1f,0.96f,0.75f,1f)
const val animationDuration = 0.2f
animated: Boolean? = null,
private val content: WidgetGroup,
private val onChange: (() -> Unit)? = null
) : Table(BaseScreen.skin) {
/** Alternate builder-style constructor for an [ExpanderTab]
*
* @param initContent A lambda with the future [content] as parameter, to help initialize. Will be `pack()`ed when done!
*/
constructor(
title: String,
fontSize: Int = Constants.headingFontSize,
icon: Actor? = null,
startsOutOpened: Boolean = true,
defaultPad: Float = 10f,
headerPad: Float = 10f,
headerAlign: Int = Align.center,
expanderWidth: Float = 0f,
persistenceID: String? = null,
animated: Boolean? = null,
onChange: (() -> Unit)? = null,
initContent: ((Table) -> Unit)
) : this (
title, fontSize, icon, startsOutOpened, defaultPad,
headerPad, headerAlign, expanderWidth, persistenceID, animated,
Table(BaseScreen.skin).apply {
defaults().growX()
initContent(this)
},
onChange
)
val persistedStates = HashMap<String, Boolean>()
companion object {
private const val arrowSize = 18f
private const val arrowImage = "OtherIcons/BackArrow"
private val arrowColor = Color(1f,0.96f,0.75f,1f)
private const val animationDurationForStageHeight = 0.5f // also serves as maximum
private val persistedStates = HashMap<String, Boolean>()
}
val header = Table(skin) // Header with label and icon, touchable to show/hide
// _Please_ don't make header, wrapper or content public. Makes tweaking this widget harder.
// If more control is needed and the parameter count gets too high, consider using a Style class
// or open class / protected fun createHeader() or dedicated setters instead.
private val header = Table(skin) // Header with label and icon, touchable to show/hide
private val headerLabel = title.toLabel(fontSize = fontSize)
private val headerIcon = ImageGetter.getImage(arrowImage)
private val contentWrapper = Table() // Wrapper for innerTable, this is what will be shown/hidden
private val arrowIcon = ImageGetter.getImage(arrowImage)
private val headerCell: Cell<Table>
/** The container where the client should add the content to toggle */
val innerTable = Table()
private val wrapper: Container<WidgetGroup>
private val wrapperCell: Cell<Container<WidgetGroup>>
private var wrapperWidth: Float = 0f
private var wrapperHeight: Float = 0f
private var currentPercent = 0f
private val noAnimation = !(animated ?: UncivGame.Current.settings.continuousRendering)
/** Indicates whether the contents are currently shown, changing this will animate the widget */
// This works because a HashMap _could_ store an entry for the null key but we cannot actually store one when declaring as HashMap<String, Boolean>
@ -66,11 +113,14 @@ class ExpanderTab(
}
init {
setLayoutEnabled(false)
header.align(headerAlign)
header.defaults().pad(headerPad)
headerIcon.setSize(arrowSize, arrowSize)
headerIcon.setOrigin(Align.center)
headerIcon.rotation = 180f
headerIcon.color = arrowColor
arrowIcon.setSize(arrowSize, arrowSize)
arrowIcon.setOrigin(Align.center)
arrowIcon.rotation = 180f
arrowIcon.color = arrowColor
header.background(
BaseScreen.skinStrings.getUiBackground(
"General/ExpanderTab",
@ -79,48 +129,78 @@ class ExpanderTab(
)
if (icon != null) header.add(icon)
header.add(headerLabel)
header.add(headerIcon).size(arrowSize).align(Align.center)
header.add(arrowIcon).size(arrowSize).align(Align.center)
header.touchable= Touchable.enabled
header.onClick { toggle() }
if (expanderWidth != 0f)
defaults().minWidth(expanderWidth)
content.pack()
measureContent()
wrapper = Container(content).apply {
setRound(false)
bottom() // controls what is seen first on opening!
setSize(wrapperWidth, 0f)
}
defaults().growX()
contentWrapper.defaults().growX().pad(defaultPad)
innerTable.defaults().growX()
add(header).fillY().row()
add(contentWrapper)
contentWrapper.add(innerTable) // update will revert this
initContent?.invoke(innerTable)
if (expanderWidth == 0f) {
// Measure content width incl. pad, set header to same width
if (innerTable.needsLayout()) contentWrapper.pack()
getCell(header).minWidth(contentWrapper.width)
}
update(noAnimation = true)
headerCell = add(header).minWidth(wrapperWidth)
row()
wrapperCell = add(wrapper).size(wrapperWidth, 0f).pad(defaultPad)
setLayoutEnabled(true)
update(fromInit = true)
}
private fun update(noAnimation: Boolean = false) {
override fun getPrefHeight() = header.prefHeight + wrapperHeight * currentPercent
override fun layout() {
// Critical magic here! Key to allow dynamic content.
// However, I can't explain why an invalidated header also needs to trigger it. Without, the
// WorldScreenMusicPopup's expanders, which are width-controlled by their outer cell's fillX/expandX,
// start aligned and same width, but will slightly misalign by some 10f on opening/closing some of them.
if (content.needsLayout() || header.needsLayout())
contentHasChanged()
super.layout()
}
private fun contentHasChanged() {
val oldWidth = wrapperWidth
val oldHeight = wrapperHeight
content.pack()
measureContent()
if (wrapperWidth == oldWidth && wrapperHeight == oldHeight) return
headerCell.minWidth(wrapperWidth)
currentPercent *= oldHeight / wrapperHeight // to animate smoothly to new height, >1f should work too
update()
}
private fun measureContent() {
wrapperWidth = if (expanderWidth > 0f) expanderWidth else content.width
wrapperHeight = content.height
}
private fun update(fromInit: Boolean = false) {
if (persistenceID != null)
persistedStates[persistenceID] = isOpen
if (noAnimation || !UncivGame.Current.settings.continuousRendering) {
contentWrapper.clear()
if (isOpen) contentWrapper.add(innerTable)
headerIcon.rotation = if (isOpen) 90f else 180f
if (!noAnimation) onChange?.invoke()
if (noAnimation || fromInit) {
updateContentVisibility(if (isOpen) 1f else 0f)
wrapper.isVisible = isOpen
if (!fromInit) onChange?.invoke()
return
}
val action = object: FloatAction ( 90f, 180f, animationDuration, Interpolation.linear) {
override fun update(percent: Float) {
super.update(percent)
headerIcon.rotation = this.value
if (this.isComplete) {
contentWrapper.clear()
if (isOpen) contentWrapper.add(innerTable)
onChange?.invoke()
}
}
}.apply { isReverse = isOpen }
addAction(action)
clearActions()
addAction(ExpandAction())
}
private fun updateContentVisibility(percent: Float) {
currentPercent = percent
val height = percent * wrapperHeight
wrapperCell.size(wrapperWidth, height) // needed for layout
wrapper.setSize(wrapperWidth, height) // needed for clipping
arrowIcon.rotation = 90f * (2f - percent)
invalidateHierarchy()
}
/** Toggle [isOpen], animated */
@ -128,8 +208,38 @@ class ExpanderTab(
isOpen = !isOpen
}
/** Change header label text after initialization */
/** Change header label text after initialization - **no** auto-translation! */
fun setText(text: String) {
headerLabel.setText(text)
}
private inner class ExpandAction : FloatAction() {
init {
start = currentPercent // start from wherever we were if turned around midway
end = if (isOpen) 1f else 0f
// Duration: shorter if less content height...
val heightFactor = stage?.run { wrapperHeight.coerceAtMost(height) / height } ?: 0.5f
// ... and shorter if turned around midway
val distanceFactor = abs(end - currentPercent)
duration = (animationDurationForStageHeight * heightFactor)
.coerceAtLeast(0.15f) * distanceFactor
}
override fun begin() {
super.begin()
wrapper.clip(true)
wrapper.isVisible = true
}
override fun update(percent: Float) {
super.update(percent)
updateContentVisibility(value)
onChange?.invoke()
}
override fun end() {
wrapper.clip(false)
wrapper.isVisible = isOpen // allows turning clip off in closed state
}
}
}

View File

@ -123,7 +123,7 @@ class ModCheckTab(
.apply { color = Color.BLACK }
.surroundWithCircle(30f, color = iconColor)
val expanderTab = ExpanderTab(mod.name, icon = icon, startsOutOpened = false) {
val expanderTab = ExpanderTab(mod.name, icon = icon, startsOutOpened = false, headerAlign = Align.left) {
it.defaults().align(Align.left)
if (!noProblem && mod.folderLocation != null) {
val replaceableUniques = getDeprecatedReplaceableUniques(mod)
@ -143,7 +143,6 @@ class ModCheckTab(
.joinToString("\n") { line -> line.text }
}).row()
}
expanderTab.header.left()
val loadingLabel = modCheckResultTable.children.last()
modCheckResultTable.removeActor(loadingLabel)

View File

@ -83,16 +83,15 @@ class CitizenManagementTable(val cityScreen: CityScreen) : Table(BaseScreen.skin
}
fun asExpander(onChange: (() -> Unit)?): ExpanderTab {
update()
return ExpanderTab(
title = "{Citizen Management}",
fontSize = Constants.defaultFontSize,
persistenceID = "CityStatsTable.CitizenManagement",
startsOutOpened = false,
content = this,
onChange = onChange
) {
it.add(this)
update()
}
)
}
}

View File

@ -96,6 +96,7 @@ class CityReligionInfoTable(
fun asExpander(onChange: (()->Unit)?): ExpanderTab {
val (icon, label) = getIconAndLabel(religionManager.getMajorityReligion())
defaults().center().pad(5f)
return ExpanderTab(
title = "Majority Religion: [$label]",
fontSize = Constants.defaultFontSize,
@ -103,10 +104,8 @@ class CityReligionInfoTable(
defaultPad = 0f,
persistenceID = "CityStatsTable.Religion",
startsOutOpened = false,
content = this,
onChange = onChange
) {
defaults().center().pad(5f)
it.add(this)
}
)
}
}

View File

@ -232,7 +232,6 @@ class CityStatsTable(private val cityScreen: CityScreen): Table() {
otherBuildings.sortBy { it.name }
val totalTable = Table()
lowerTable.addCategory("Buildings", totalTable, false)
if (specialistBuildings.isNotEmpty()) {
val specialistBuildingsTable = Table()
@ -261,6 +260,8 @@ class CityStatsTable(private val cityScreen: CityScreen): Table() {
for (building in otherBuildings) addBuildingButton(building, regularBuildingsTable)
totalTable.add(regularBuildingsTable).growX().right().row()
}
lowerTable.addCategory("Buildings", totalTable, false)
}
private fun addBuildingButton(building: Building, destinationTable: Table) {
@ -312,17 +313,15 @@ class CityStatsTable(private val cityScreen: CityScreen): Table() {
destinationTable.add(button).pad(1f).padBottom(2f).padTop(2f).expandX().right().row()
}
private fun Table.addCategory(category: String, showHideTable: Table, startsOpened: Boolean = true, innerPadding: Float = 10f) : ExpanderTab {
private fun Table.addCategory(category: String, showHideTable: Table, startsOpened: Boolean = true) : ExpanderTab {
val expanderTab = ExpanderTab(
title = category,
fontSize = Constants.defaultFontSize,
persistenceID = "CityInfo.$category",
startsOutOpened = startsOpened,
defaultPad = innerPadding,
content = showHideTable,
onChange = { onContentResize() }
) {
it.add(showHideTable).fillX().right()
}
)
add(expanderTab).growX().row()
return expanderTab
}

View File

@ -136,16 +136,15 @@ class SpecialistAllocationTable(private val cityScreen: CityScreen) : Table(Base
fun asExpander(onChange: (() -> Unit)?): ExpanderTab {
update()
return ExpanderTab(
title = "{Specialists}:",
fontSize = Constants.defaultFontSize,
persistenceID = "CityStatsTable.Specialists",
startsOutOpened = true,
content = this,
onChange = onChange
) {
it.add(this)
update()
}
)
}
}

View File

@ -23,6 +23,7 @@ import com.unciv.models.ruleset.tile.ResourceSupplyList
import com.unciv.models.translations.tr
import com.unciv.ui.components.ExpanderTab
import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.onClick
import com.unciv.ui.images.IconTextButton
import com.unciv.ui.images.ImageGetter
@ -41,8 +42,11 @@ class OffersListScroll(
) : ScrollPane(null) {
val table = Table(BaseScreen.skin).apply { defaults().pad(5f) }
private val expanderTabs = HashMap<TradeType, ExpanderTab>()
private data class ExpanderData(
val label: String,
val content: Table = Table().apply { defaults().pad(5f) }
)
private val expanderContents = HashMap<TradeType, ExpanderData>()
/**
* @param offersToDisplay The offers which should be displayed as buttons
@ -55,10 +59,10 @@ class OffersListScroll(
untradableOffers: ResourceSupplyList = ResourceSupplyList.emptyList
) {
table.clear()
expanderTabs.clear()
expanderContents.clear()
for (offerType in values()) {
val labelName = when(offerType){
val labelName = when(offerType) {
Gold, Gold_Per_Turn, Treaty, Agreement, Introduction -> ""
Luxury_Resource -> "Luxury resources"
Strategic_Resource -> "Strategic resources"
@ -68,11 +72,12 @@ class OffersListScroll(
}
val offersOfType = offersToDisplay.filter { it.type == offerType }
if (labelName.isNotEmpty() && offersOfType.any()) {
expanderTabs[offerType] = ExpanderTab(labelName, persistenceID = "Trade.$persistenceID.$offerType") {
it.defaults().pad(5f)
}
expanderContents[offerType] = ExpanderData(labelName)
}
}
val expanderWidth = (expanderContents.values.maxByOrNull { it.label.length }
?.run { label.toLabel(fontSize = Constants.headingFontSize).prefWidth }
?: 0f) + 50f // 50 for Expander header pad and arrow
for (offerType in values()) {
val offersOfType = offersToDisplay.filter { it.type == offerType }
@ -81,11 +86,6 @@ class OffersListScroll(
{ if (it.type==City) it.getOfferText() else it.name.tr() }
))
if (expanderTabs.containsKey(offerType)) {
expanderTabs[offerType]!!.innerTable.clear()
table.add(expanderTabs[offerType]!!).row()
}
for (offer in offersOfType) {
val tradeLabel = offer.getOfferText(untradableOffers.sumBy(offer.name))
val tradeIcon = when (offer.type) {
@ -122,11 +122,18 @@ class OffersListScroll(
else tradeButton.disable() // for instance we have negative gold
if (expanderTabs.containsKey(offerType))
expanderTabs[offerType]!!.innerTable.add(tradeButton).row()
if (expanderContents.containsKey(offerType))
expanderContents[offerType]!!.content.add(tradeButton).row()
else
table.add(tradeButton).row()
}
expanderContents[offerType]?.run {
table.add(
ExpanderTab(label, expanderWidth = expanderWidth,
persistenceID = "Trade.$persistenceID.$offerType", content = content)
).row()
}
}
actor = table
}

View File

@ -120,12 +120,11 @@ class MapEditorViewTab(
"{Natural Wonders} (${naturalWonders.size})",
fontSize = 21,
startsOutOpened = false,
headerPad = 5f
) {
it.add(MarkupRenderer.render(lines, iconDisplay = IconDisplay.NoLink) { name->
scrollToWonder(name)
})
}).row()
headerPad = 5f,
content = MarkupRenderer.render(lines, iconDisplay = IconDisplay.NoLink) {
scrollToWonder(it)
}
)).row()
}
// Starting locations not cached like natural wonders - storage is already compact
@ -136,12 +135,11 @@ class MapEditorViewTab(
"{Starting locations} (${tileMap.startingLocationsByNation.size})",
fontSize = 21,
startsOutOpened = false,
headerPad = 5f
) {
it.add(MarkupRenderer.render(lines.asIterable(), iconDisplay = IconDisplay.NoLink) { name ->
scrollToStartOfNation(name)
})
}).row()
headerPad = 5f,
content = MarkupRenderer.render(lines.asIterable(), iconDisplay = IconDisplay.NoLink) {
scrollToStartOfNation(it)
}
)).row()
}
addSeparator()

View File

@ -244,23 +244,20 @@ class NewGameScreen(
private fun initPortrait() {
scrollPane.setScrollingDisabled(false,false)
topTable.add(ExpanderTab("Game Options") {
it.add(newGameOptionsTable).row()
}).expandX().fillX().row()
topTable.add(ExpanderTab("Game Options", content = newGameOptionsTable))
.expandX().fillX().row()
topTable.addSeparator(Color.DARK_GRAY, height = 1f)
topTable.add(newGameOptionsTable.modCheckboxes).expandX().fillX().row()
topTable.addSeparator(Color.DARK_GRAY, height = 1f)
topTable.add(ExpanderTab("Map Options") {
it.add(mapOptionsTable).row()
}).expandX().fillX().row()
topTable.add(ExpanderTab("Map Options", content = mapOptionsTable))
.expandX().fillX().row()
topTable.addSeparator(Color.DARK_GRAY, height = 1f)
(playerPickerTable.playerListTable.parent as ScrollPane).setScrollingDisabled(true,true)
topTable.add(ExpanderTab("Civilizations") {
it.add(playerPickerTable).row()
}).expandX().fillX().row()
topTable.add(ExpanderTab("Civilizations", content = playerPickerTable))
.expandX().fillX().row()
}
private fun checkConnectionToMultiplayerServer(): Boolean {

View File

@ -158,21 +158,16 @@ class ModManagementScreen(
topTable.add(optionsManager.expander).top().growX().row()
installedExpanderTab = ExpanderTab(optionsManager.getInstalledHeader(), expanderWidth = stage.width) {
it.add(scrollInstalledMods).growX()
}
installedExpanderTab = ExpanderTab(optionsManager.getInstalledHeader(), expanderWidth = stage.width, content = scrollInstalledMods)
topTable.add(installedExpanderTab).top().growX().row()
onlineExpanderTab = ExpanderTab(optionsManager.getOnlineHeader(), expanderWidth = stage.width) {
it.add(scrollOnlineMods).growX()
}
onlineExpanderTab = ExpanderTab(optionsManager.getOnlineHeader(), expanderWidth = stage.width, content = scrollOnlineMods)
topTable.add(onlineExpanderTab).top().padTop(10f).growX().row()
topTable.add().expandY().row() // helps with top() being ignored
topTable.add(ExpanderTab("Mod info and options", expanderWidth = stage.width) {
it.add(modActionTable).growX()
}).bottom().padTop(10f).growX().row()
topTable.add(ExpanderTab("Mod info and options", expanderWidth = stage.width, content = modActionTable))
.bottom().padTop(10f).growX().row()
}
private fun initLandscape() {

View File

@ -34,7 +34,7 @@ class WorldScreenMusicPopup(
private val musicController = UncivGame.Current.musicController
private val trackStyle: TextButton.TextButtonStyle
private val historyExpander: ExpanderTab
private val historyTable = Table()
private val visualMods = worldScreen.game.settings.visualMods
private val mods = worldScreen.gameInfo.gameParameters.mods
@ -58,13 +58,19 @@ class WorldScreenMusicPopup(
trackStyle.disabledFontColor = Color.LIGHT_GRAY
addMusicMods(settings)
historyExpander = addHistory()
addHistory()
addMusicControls(bottomTable, settings, musicController)
addCloseButton().colspan(2)
addCloseButton().padTop(10f).padBottom(0f).colspan(2)
getScrollPane()?.run {
fadeScrollBars = false
if (bottomTable.prefWidth < prefWidth)
bottomTable.width = prefWidth
}
musicController.onChange {
historyExpander.innerTable.clear()
historyExpander.innerTable.updateTrackList(musicController.getHistory())
historyTable.clear()
historyTable.updateTrackList(musicController.getHistory())
}
}
@ -88,24 +94,24 @@ class WorldScreenMusicPopup(
}
}
private fun addHistory() = addTrackList("—History—", musicController.getHistory())
private fun addHistory() = addTrackList("—History—", musicController.getHistory(), historyTable)
private fun addTrackList(title: String, tracks: Sequence<MusicController.MusicTrackInfo>): ExpanderTab {
private fun addTrackList(title: String, tracks: Sequence<MusicController.MusicTrackInfo>, table: Table? = null) {
// Note title is either a mod name or something that cannot be a mod name (thanks to the em-dashes)
val icon = when (title) {
in mods -> "OtherIcons/Mods"
in visualMods -> "UnitPromotionIcons/Scouting"
else -> null
}?.let { ImageGetter.getImage(it).apply { setSize(18f) } }
val content = table ?: Table()
content.defaults().growX()
content.updateTrackList(tracks)
val expander = ExpanderTab(title, Constants.defaultFontSize, icon,
startsOutOpened = false, defaultPad = 0f, headerPad = 5f,
persistenceID = "MusicPopup.$title",
) {
it.updateTrackList(tracks)
}
content = content
)
add(expander).colspan(2).growX().row()
return expander
}
private fun Table.updateTrackList(tracks: Sequence<MusicController.MusicTrackInfo>) {