Upgraded music player popup (#9514)

* Popups get the ability to scroll only the content without the buttons

* Centralize LoadingPopup

* Split non-WorldScreenMenuPopup classes off from that file

* Linting

* Nicer music playback dialog

* Translation templates
This commit is contained in:
SomeTroglodyte
2023-06-04 10:53:56 +02:00
committed by GitHub
parent 8a024bf9fe
commit 13619d18a1
6 changed files with 208 additions and 41 deletions

View File

@ -785,6 +785,9 @@ Currently playing: [title] =
Download music =
Downloading... =
Could not download music! =
—Paused— =
—Default— =
—History— =
## Advanced tab
Advanced =

View File

@ -7,8 +7,8 @@ import com.badlogic.gdx.files.FileHandle
import com.unciv.UncivGame
import com.unciv.logic.multiplayer.storage.DropBox
import com.unciv.models.metadata.GameSettings
import com.unciv.utils.Concurrency
import com.unciv.utils.Log
import com.unciv.utils.debug
import java.util.EnumSet
import java.util.Timer
import kotlin.concurrent.thread
@ -58,10 +58,35 @@ class MusicController {
}
}
/** Container for track info - used for [onChange] and [getHistory].
*
* [toString] returns a prettified label: "Modname: Track".
* No track playing is reported as a MusicTrackInfo instance with all
* fields empty, for which _`toString`_ returns "—Paused—".
*/
data class MusicTrackInfo(val mod: String, val track: String, val type: String) {
/** Used for display, not only debugging */
override fun toString() = if (track.isEmpty()) "—Paused—" // using em-dash U+2014
else if (mod.isEmpty()) track else "$mod: $track"
companion object {
/** Parse a path - must be relative to `Gdx.files.local` */
fun parse(fileName: String): MusicTrackInfo {
if (fileName.isEmpty())
return MusicTrackInfo("", "", "")
val fileNameParts = fileName.split('/')
val modName = if (fileNameParts.size > 1 && fileNameParts[0] == "mods") fileNameParts[1] else ""
var trackName = fileNameParts[if (fileNameParts.size > 3 && fileNameParts[2] == "music") 3 else 1]
val type = gdxSupportedFileExtensions.firstOrNull {trackName.endsWith(".$it") } ?: ""
trackName = trackName.removeSuffix(".$type")
return MusicTrackInfo(modName, trackName, type)
}
}
}
//region Fields
/** mirrors [GameSettings.musicVolume] - use [setVolume] to update */
var baseVolume: Float = UncivGame.Current.settings.musicVolume
private set
private var baseVolume: Float = UncivGame.Current.settings.musicVolume
/** Pause in seconds between tracks unless [chooseTrack] is called to force a track change */
var silenceLength: Float
@ -102,8 +127,8 @@ class MusicController {
/** Keeps paths of recently played track to reduce repetition */
private val musicHistory = ArrayDeque<String>(musicHistorySize)
/** One potential listener gets notified when track changes */
private var onTrackChangeListener: ((String)->Unit)? = null
/** These listeners get notified when track changes */
private var onTrackChangeListeners = mutableListOf<(MusicTrackInfo)->Unit>()
//endregion
//region Pure functions
@ -127,13 +152,17 @@ class MusicController {
else -> ""
}
/** Registers a callback that will be called with the new track name every time it changes.
* The track name will be prettified ("Modname: Track" instead of "mods/Modname/music/Track.ogg").
/** Registers a callback that will be called with the new track every time it changes.
*
* Will be called on a background thread, so please decouple UI access on the receiving side.
* The track is given as [MusicTrackInfo], which has a `toString` that returns it prettified.
*
* Several callbacks can be registered, but a onChange(null) clears them all.
*
* Callbacks will be safely called on the GL thread.
*/
fun onChange(listener: ((String)->Unit)?) {
onTrackChangeListener = listener
fun onChange(listener: ((MusicTrackInfo)->Unit)?) {
if (listener == null) onTrackChangeListeners.clear()
else onTrackChangeListeners.add(listener)
fireOnChange()
}
@ -147,6 +176,14 @@ class MusicController {
return current?.isPlaying() == true
}
/** @return Sequence of most recently played tracks, oldest first, current last */
fun getHistory() = musicHistory.asSequence().map { MusicTrackInfo.parse(it) }
/** @return Sequence of all available and enabled music tracks */
fun getAllMusicFileInfo() = getAllMusicFiles().map {
MusicTrackInfo.parse(it.path())
}
//endregion
//region Internal helpers
@ -233,7 +270,7 @@ class MusicController {
clearNext()
clearCurrent()
musicHistory.clear()
debug("MusicController shut down.")
Log.debug("MusicController shut down.")
}
private fun audioExceptionHandler(ex: Throwable, music: Music) {
@ -267,7 +304,7 @@ class MusicController {
yield(getFile(musicPath))
}
/** Get sequence of all existing music files */
/** Get a sequence of all existing music files */
private fun getAllMusicFiles() = getMusicFolders()
.filter { it.exists() && it.isDirectory }
.flatMap { it.list().asSequence() }
@ -302,25 +339,18 @@ class MusicController {
}
private fun fireOnChange() {
if (onTrackChangeListener == null) return
val fileName = currentlyPlaying()
if (fileName.isEmpty()) {
fireOnChange(fileName)
return
if (onTrackChangeListeners.isEmpty()) return
Concurrency.runOnGLThread {
fireOnChange(MusicTrackInfo.parse(currentlyPlaying()))
}
val fileNameParts = fileName.split('/')
val modName = if (fileNameParts.size > 1 && fileNameParts[0] == "mods") fileNameParts[1] else ""
var trackName = fileNameParts[if (fileNameParts.size > 3 && fileNameParts[2] == "music") 3 else 1]
for (extension in gdxSupportedFileExtensions)
trackName = trackName.removeSuffix(".$extension")
fireOnChange(modName + (if (modName.isEmpty()) "" else ": ") + trackName)
}
private fun fireOnChange(trackLabel: String) {
private fun fireOnChange(trackInfo: MusicTrackInfo) {
try {
onTrackChangeListener?.invoke(trackLabel)
for (listener in onTrackChangeListeners)
listener.invoke(trackInfo)
} catch (ex: Throwable) {
debug("onTrackChange event invoke failed", ex)
onTrackChangeListener = null
Log.debug("onTrackChange event invoke failed", ex)
onTrackChangeListeners.clear()
}
}
@ -350,7 +380,7 @@ class MusicController {
fun chooseTrack(
prefix: String = "",
suffix: String = MusicMood.Ambient,
flags: EnumSet<MusicTrackChooserFlags> = EnumSet.of(MusicTrackChooserFlags.SuffixMustMatch)
flags: EnumSet<MusicTrackChooserFlags> = MusicTrackChooserFlags.default
): Boolean {
if (baseVolume == 0f) return false
@ -358,9 +388,20 @@ class MusicController {
if (musicFile == null) {
// MustMatch flags at work or Music folder empty
debug("No music found for prefix=%s, suffix=%s, flags=%s", prefix, suffix, flags)
Log.debug("No music found for prefix=%s, suffix=%s, flags=%s", prefix, suffix, flags)
return false
}
Log.debug("Track chosen: %s for prefix=%s, suffix=%s, flags=%s", musicFile.path(), prefix, suffix, flags)
return startTrack(musicFile, flags)
}
/** Initiate playback of a _specific_ track by handle - part of [chooseTrack].
* Manages fade-over from the previous track.
*/
private fun startTrack(
musicFile: FileHandle,
flags: EnumSet<MusicTrackChooserFlags> = MusicTrackChooserFlags.default
): Boolean {
if (musicFile.path() == currentlyPlaying())
return true // picked file already playing
if (!musicFile.exists())
@ -374,7 +415,7 @@ class MusicController {
state = ControllerState.Silence // will retry after one silence period
next = null
}, onSuccess = {
debug("Music queued: %s for prefix=%s, suffix=%s, flags=%s", musicFile.path(), prefix, suffix, flags)
Log.debug("Music queued: %s", musicFile.path())
if (musicHistory.size >= musicHistorySize) musicHistory.removeFirst()
musicHistory.addLast(musicFile.path())
@ -404,7 +445,7 @@ class MusicController {
fun chooseTrack(
prefix: String = "",
suffixes: List<String>,
flags: EnumSet<MusicTrackChooserFlags> = EnumSet.noneOf(MusicTrackChooserFlags::class.java)
flags: EnumSet<MusicTrackChooserFlags> = MusicTrackChooserFlags.none
): Boolean {
for (suffix in suffixes) {
if (chooseTrack(prefix, suffix, flags)) return true
@ -412,13 +453,23 @@ class MusicController {
return false
}
/** Initiate playback of a _specific_ track by a [MusicTrackInfo] instance */
fun startTrack(trackInfo: MusicTrackInfo): Boolean {
if (trackInfo.track.isEmpty()) return false
val path = trackInfo.run {
if (mod.isEmpty()) "$musicPath/$track.$type"
else "mods/$mod/$musicPath/$track.$type"
}
return startTrack(getFile(path))
}
/**
* Pause playback with fade-out
*
* @param speedFactor accelerate (>1) or slow down (<1) the fade-out. Clamped to 1/1000..1000.
*/
fun pause(speedFactor: Float = 1f) {
debug("MusicTrackController.pause called")
Log.debug("MusicTrackController.pause called")
val controller = current
if ((state != ControllerState.Playing && state != ControllerState.PlaySingle) || controller == null) return
val fadingStep = defaultFadingStep * speedFactor.coerceIn(0.001f..1000f)
@ -435,7 +486,7 @@ class MusicController {
* @param speedFactor accelerate (>1) or slow down (<1) the fade-in. Clamped to 1/1000..1000.
*/
fun resume(speedFactor: Float = 1f) {
debug("MusicTrackController.resume called")
Log.debug("MusicTrackController.resume called")
if (state == ControllerState.Pause && current != null) {
val fadingStep = defaultFadingStep * speedFactor.coerceIn(0.001f..1000f)
current!!.startFade(MusicTrackController.State.FadeIn, fadingStep)
@ -447,6 +498,7 @@ class MusicController {
}
/** Fade out then shutdown with a given [duration] in seconds, defaults to a 'slow' fade (4.5s) */
@Suppress("unused") // might be useful instead of gracefulShutdown
fun fadeoutToSilence(duration: Float = defaultFadeDuration * 5) {
val fadingStep = 1f / ticksPerSecond / duration
current?.startFade(MusicTrackController.State.FadeOut, fadingStep)

View File

@ -17,6 +17,7 @@ enum class MusicTrackChooserFlags {
companion object {
// EnumSet factories
val default: EnumSet<MusicTrackChooserFlags> = EnumSet.of(SuffixMustMatch)
/** EnumSet.of([PlayDefaultFile], [PlaySingle]) */
val setPlayDefault: EnumSet<MusicTrackChooserFlags> = EnumSet.of(PlayDefaultFile, PlaySingle)
/** EnumSet.of([PrefixMustMatch], [PlaySingle]) */

View File

@ -16,6 +16,7 @@ import com.unciv.ui.components.extensions.onClick
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.extensions.toImageButton
import com.unciv.ui.popups.Popup
import com.unciv.utils.Concurrency
import com.unciv.utils.launchOnGLThread
import kotlin.math.floor
@ -154,9 +155,10 @@ private fun addMusicCurrentlyPlaying(table: Table, music: MusicController) {
label.wrap = true
table.add(label).padTop(20f).colspan(2).fillX().row()
music.onChange {
Concurrency.runOnGLThread {
label.setText("Currently playing: [$it]".tr())
}
table.firstAscendant(Popup::class.java)?.run {
closeListeners.add { music.onChange(null) }
}
}

View File

@ -1,17 +1,123 @@
package com.unciv.ui.screens.worldscreen.mainmenu
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.models.metadata.GameSettings
import com.unciv.models.metadata.ScreenSize
import com.unciv.ui.audio.MusicController
import com.unciv.ui.components.ExpanderTab
import com.unciv.ui.components.Fonts
import com.unciv.ui.components.extensions.onClick
import com.unciv.ui.components.extensions.setSize
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popups.Popup
import com.unciv.ui.popups.options.addMusicControls
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.worldscreen.WorldScreen
class WorldScreenMusicPopup(val worldScreen: WorldScreen) : Popup(worldScreen) {
class WorldScreenMusicPopup(
worldScreen: WorldScreen
) : Popup(worldScreen, maxSizePercentage = calcSize(worldScreen)) {
companion object {
// 3/4 of the screen is just a bit too small on small screen settings
private fun calcSize(worldScreen: WorldScreen) = when(worldScreen.game.settings.screenSize) {
ScreenSize.Tiny -> 0.95f
ScreenSize.Small -> 0.85f
else -> 0.75f
}
}
private val musicController = UncivGame.Current.musicController
private val trackStyle: TextButton.TextButtonStyle
private val historyExpander: ExpanderTab
private val visualMods = worldScreen.game.settings.visualMods
private val mods = worldScreen.gameInfo.gameParameters.mods
init {
val musicController = UncivGame.Current.musicController
val settings = UncivGame.Current.settings
getScrollPane()!!.setScrollingDisabled(true, false)
defaults().fillX()
addMusicControls(this, settings, musicController)
val sk = BaseScreen.skinStrings
// Make the list flat but with mouse-over highlighting and visual click-down feedback
// by making them buttons instead of labels, but with a style that has no NinePatches
// as backgrounds, just tinted whiteDot. As default, but skinnable.
val up = sk.getUiBackground("WorldScreenMusicPopup/TrackList/Up", tintColor = skin.getColor("color"))
val down = sk.getUiBackground("WorldScreenMusicPopup/TrackList/Down", tintColor = skin.getColor("positive"))
val over = sk.getUiBackground("WorldScreenMusicPopup/TrackList/Over", tintColor = skin.getColor("highlight"))
trackStyle = TextButton.TextButtonStyle(up, down, null, Fonts.font)
trackStyle.over = over
trackStyle.disabled = up
trackStyle.disabledFontColor = Color.LIGHT_GRAY
addMusicMods(settings)
historyExpander = addHistory()
addMusicControls(bottomTable, settings, musicController)
addCloseButton().colspan(2)
musicController.onChange {
historyExpander.innerTable.clear()
historyExpander.innerTable.updateTrackList(musicController.getHistory())
}
}
private fun String.toSmallUntranslatedButton(rightSide: Boolean = false) =
TextButton(this, trackStyle).apply {
label.setFontScale(14 / Fonts.ORIGINAL_FONT_SIZE)
label.setAlignment(if (rightSide) Align.right else Align.left)
labelCell.pad(5f)
isDisabled = rightSide
}
private fun addMusicMods(settings: GameSettings) {
val modsToTracks = musicController.getAllMusicFileInfo().groupBy{ it.mod }
val collator = settings.getCollatorFromLocale()
val modsSorted = modsToTracks.entries.asSequence()
.sortedWith(compareBy(collator) { it.key })
for ((modLabel, trackList) in modsSorted) {
val tracksSorted = trackList.asSequence()
.sortedWith(compareBy(collator) { it.track })
addTrackList(modLabel.ifEmpty { "—Default—" }, tracksSorted)
}
}
private fun addHistory() = addTrackList("—History—", musicController.getHistory())
private fun addTrackList(title: String, tracks: Sequence<MusicController.MusicTrackInfo>): ExpanderTab {
// 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 expander = ExpanderTab(title, Constants.defaultFontSize, icon,
startsOutOpened = false, defaultPad = 0f, headerPad = 5f,
persistenceID = "MusicPopup.$title",
) {
it.updateTrackList(tracks)
}
add(expander).colspan(2).growX().row()
return expander
}
private fun Table.updateTrackList(tracks: Sequence<MusicController.MusicTrackInfo>) {
for (entry in tracks) {
val trackLabel = entry.track.toSmallUntranslatedButton()
trackLabel.onClick { musicController.startTrack(entry) }
add(trackLabel).fillX()
add(entry.type.toSmallUntranslatedButton(true)).right().row()
// Note - displaying the file extension is meant as modder help, and could possibly
// be extended to a modder tool - maybe display eligibility for known triggers?
// Might also be gated by a setting so casual users won't see it?
}
}
}

View File

@ -114,6 +114,9 @@ These shapes are used all over Unciv and can be replaced to make a lot of UI ele
| WorldScreen/TopBar/ | ResourceTable | null | |
| WorldScreen/TopBar/ | RightAttachment | roundedEdgeRectangle | |
| WorldScreen/TopBar/ | StatsTable | null | |
| WorldScreenMusicPopup/TrackList/ | Down", tintColor = skin.getColor("positive | null | |
| WorldScreenMusicPopup/TrackList/ | Over", tintColor = skin.getColor("highlight | null | |
| WorldScreenMusicPopup/TrackList/ | Up", tintColor = skin.getColor("color | null | |
<!--- DO NOT REMOVE OR MODIFY THIS LINE UI_ELEMENT_TABLE_REGION_END -->
## SkinConfig