From 13619d18a19c0aa9d05b2d451af99c97bd09ed07 Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Sun, 4 Jun 2023 10:53:56 +0200 Subject: [PATCH] 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 --- .../jsons/translations/template.properties | 3 + .../src/com/unciv/ui/audio/MusicController.kt | 122 +++++++++++++----- .../unciv/ui/audio/MusicTrackChooserFlags.kt | 1 + .../com/unciv/ui/popups/options/SoundTab.kt | 8 +- .../mainmenu/WorldScreenMusicPopup.kt | 112 +++++++++++++++- docs/Modders/Creating-a-UI-skin.md | 3 + 6 files changed, 208 insertions(+), 41 deletions(-) diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 0bda1c50c7..55665e1bad 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -785,6 +785,9 @@ Currently playing: [title] = Download music = Downloading... = Could not download music! = +—Paused— = +—Default— = +—History— = ## Advanced tab Advanced = diff --git a/core/src/com/unciv/ui/audio/MusicController.kt b/core/src/com/unciv/ui/audio/MusicController.kt index 7b36ff3e18..9dc1b35e97 100644 --- a/core/src/com/unciv/ui/audio/MusicController.kt +++ b/core/src/com/unciv/ui/audio/MusicController.kt @@ -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(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() } } @@ -347,10 +377,10 @@ class MusicController { * @param flags a set of optional flags to tune the choice and playback. * @return `true` = success, `false` = no match, no playback change */ - fun chooseTrack ( + fun chooseTrack( prefix: String = "", suffix: String = MusicMood.Ambient, - flags: EnumSet = EnumSet.of(MusicTrackChooserFlags.SuffixMustMatch) + flags: EnumSet = 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.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()) @@ -401,10 +442,10 @@ class MusicController { } /** Variant of [chooseTrack] that tries several moods ([suffixes]) until a match is chosen */ - fun chooseTrack ( + fun chooseTrack( prefix: String = "", suffixes: List, - flags: EnumSet = EnumSet.noneOf(MusicTrackChooserFlags::class.java) + flags: EnumSet = 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) diff --git a/core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt b/core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt index 9fd3da0edb..9d4bd0c9f2 100644 --- a/core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt +++ b/core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt @@ -17,6 +17,7 @@ enum class MusicTrackChooserFlags { companion object { // EnumSet factories + val default: EnumSet = EnumSet.of(SuffixMustMatch) /** EnumSet.of([PlayDefaultFile], [PlaySingle]) */ val setPlayDefault: EnumSet = EnumSet.of(PlayDefaultFile, PlaySingle) /** EnumSet.of([PrefixMustMatch], [PlaySingle]) */ diff --git a/core/src/com/unciv/ui/popups/options/SoundTab.kt b/core/src/com/unciv/ui/popups/options/SoundTab.kt index e0b25b0f5a..6bdc64bd0c 100644 --- a/core/src/com/unciv/ui/popups/options/SoundTab.kt +++ b/core/src/com/unciv/ui/popups/options/SoundTab.kt @@ -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()) - } + label.setText("Currently playing: [$it]".tr()) + } + table.firstAscendant(Popup::class.java)?.run { + closeListeners.add { music.onChange(null) } } } diff --git a/core/src/com/unciv/ui/screens/worldscreen/mainmenu/WorldScreenMusicPopup.kt b/core/src/com/unciv/ui/screens/worldscreen/mainmenu/WorldScreenMusicPopup.kt index bd2fd09e83..1f4243874a 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/mainmenu/WorldScreenMusicPopup.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/mainmenu/WorldScreenMusicPopup.kt @@ -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): 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) { + 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? + } + } + } diff --git a/docs/Modders/Creating-a-UI-skin.md b/docs/Modders/Creating-a-UI-skin.md index 8e61e62250..492967df7e 100644 --- a/docs/Modders/Creating-a-UI-skin.md +++ b/docs/Modders/Creating-a-UI-skin.md @@ -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 | | ## SkinConfig