diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 788b6f67ea..4cedaa5e04 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -474,6 +474,7 @@ Turns between autosaves = Sound effects volume = Music volume = Pause between tracks = +Currently playing: [title] = Download music = Downloading... = Could not download music! = diff --git a/core/src/com/unciv/ui/audio/MusicController.kt b/core/src/com/unciv/ui/audio/MusicController.kt index eba5b60705..999298b56a 100644 --- a/core/src/com/unciv/ui/audio/MusicController.kt +++ b/core/src/com/unciv/ui/audio/MusicController.kt @@ -78,13 +78,39 @@ 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 + //endregion //region Pure functions /** @return the path of the playing track or null if none playing */ - fun currentlyPlaying() = if (state != ControllerState.Playing && state != ControllerState.PlaySingle) null + private fun currentlyPlaying(): String = if (state != ControllerState.Playing && state != ControllerState.PlaySingle) "" else musicHistory.peekLast() + /** 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"). + * + * Will be called on a background thread, so please decouple UI access on the receiving side. + */ + fun onChange(listener: ((String)->Unit)?) { + onTrackChangeListener = listener + fireOnChange() + } + private fun fireOnChange() { + val fileName = currentlyPlaying() + if (fileName.isEmpty()) { + onTrackChangeListener?.invoke(fileName) + return + } + 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 fileExtensions) + trackName = trackName.removeSuffix(".$extension") + onTrackChangeListener?.invoke(modName + (if (modName.isEmpty()) "" else ": ") + trackName) + } + /** * Determines whether any music tracks are available for the options menu */ @@ -112,12 +138,15 @@ class MusicController { // no music to play - begin silence or shut down ticksOfSilence = 0 state = if (state == ControllerState.PlaySingle) ControllerState.Shutdown else ControllerState.Silence + fireOnChange() } else if (next!!.state.canPlay) { // Next track - if top slot empty and a next exists, move it to top and start current = next next = null if (!current!!.play()) state = ControllerState.Shutdown + else + fireOnChange() } // else wait for the thread of next.load() to finish } else if (!current!!.isPlaying()) { // normal end of track @@ -133,10 +162,13 @@ class MusicController { ticksOfSilence = 0 chooseTrack() } - ControllerState.Shutdown, ControllerState.Idle -> { - state = ControllerState.Idle - shutdown() + ControllerState.Shutdown -> { + // Fade out first, when all queue entries are idle set up for real shutdown + if (current?.shutdownTick() != false && next?.shutdownTick() != false) + state = ControllerState.Idle } + ControllerState.Idle -> + shutdown() // stops timer so this will not repeat ControllerState.Pause -> current?.timerTick() } @@ -311,8 +343,10 @@ class MusicController { } /** Forceful shutdown of music playback and timers - see [gracefulShutdown] */ - fun shutdown() { + private fun shutdown() { state = ControllerState.Idle + fireOnChange() + onTrackChangeListener = null if (musicTimer != null) { musicTimer!!.cancel() musicTimer = null diff --git a/core/src/com/unciv/ui/audio/MusicTrackController.kt b/core/src/com/unciv/ui/audio/MusicTrackController.kt index 8dcc25d809..61dda35bc1 100644 --- a/core/src/com/unciv/ui/audio/MusicTrackController.kt +++ b/core/src/com/unciv/ui/audio/MusicTrackController.kt @@ -99,14 +99,17 @@ class MusicTrackController(private var volume: Float) { } private fun fadeOutStep() { // fade-out: linearly ramp fadeVolume to 0.0, then act according to Status (Playing->Silence/Pause/Shutdown) + // This needs to guard against the music backend breaking mid-fade away during game shutdown fadeVolume -= fadeStep - if (fadeVolume >= 0.001f && music != null && music!!.isPlaying) { - music!!.volume = volume * fadeVolume - return - } - fadeVolume = 0f - music!!.volume = 0f - music!!.pause() + try { + if (fadeVolume >= 0.001f && music != null && music!!.isPlaying) { + music!!.volume = volume * fadeVolume + return + } + fadeVolume = 0f + music!!.volume = 0f + music!!.pause() + } catch (_: Throwable) {} state = State.Idle } @@ -121,6 +124,19 @@ class MusicTrackController(private var volume: Float) { state = fade } + /** Graceful shutdown tick event - fade out then report Idle + * @return `true` shutdown can proceed, `false` still fading out + */ + fun shutdownTick(): Boolean { + if (!state.canPlay) state = State.Idle + if (state == State.Idle) return true + if (state != State.FadeOut) { + state = State.FadeOut + fadeStep = MusicController.defaultFadingStep + } + return timerTick() == State.Idle + } + /** @return [Music.isPlaying] (Gdx music stream is playing) unless [state] says it won't make sense */ fun isPlaying() = state.canPlay && music?.isPlaying == true diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt index b3737578b5..740f7e8ef1 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt @@ -84,6 +84,7 @@ class OptionsPopup(val previousScreen: CameraStageBaseScreen) : Popup(previousSc } addCloseButton { + previousScreen.game.musicController.onChange(null) previousScreen.game.limitOrientationsHelper?.allowPortrait(settings.allowAndroidPortrait) if (previousScreen is WorldScreen) previousScreen.enableNextTurnButtonAfterOptions() @@ -210,6 +211,7 @@ class OptionsPopup(val previousScreen: CameraStageBaseScreen) : Popup(previousSc if (previousScreen.game.musicController.isMusicAvailable()) { addMusicVolumeSlider() addMusicPauseSlider() + addMusicCurrentlyPlaying() } else { addDownloadMusic() } @@ -456,6 +458,17 @@ class OptionsPopup(val previousScreen: CameraStageBaseScreen) : Popup(previousSc add(pauseLengthSlider).pad(5f).row() } + private fun Table.addMusicCurrentlyPlaying() { + val label = WrappableLabel("", this.width - 10f, Color(-0x2f5001), 16) + label.wrap = true + add(label).padTop(20f).colspan(2).fillX().row() + previousScreen.game.musicController.onChange { + Gdx.app.postRunnable { + label.setText("Currently playing: [$it]".tr()) + } + } + } + private fun Table.addDownloadMusic() { val downloadMusicButton = "Download music".toTextButton() add(downloadMusicButton).colspan(2).row()