mirror of
https://github.com/yairm210/Unciv.git
synced 2025-07-11 00:08:58 +07:00
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:
@ -785,6 +785,9 @@ Currently playing: [title] =
|
||||
Download music =
|
||||
Downloading... =
|
||||
Could not download music! =
|
||||
—Paused— =
|
||||
—Default— =
|
||||
—History— =
|
||||
|
||||
## Advanced tab
|
||||
Advanced =
|
||||
|
@ -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)
|
||||
|
@ -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]) */
|
||||
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user