├── versions ├── mainProject ├── 1.18.1-forge │ └── gradle.properties ├── 1.18.1-fabric │ └── gradle.properties ├── 1.12.2 │ ├── gradle.properties │ └── src │ │ └── main │ │ ├── kotlin │ │ └── dev │ │ │ └── mediamod │ │ │ └── command │ │ │ └── MediaModCommand.kt │ │ └── java │ │ └── dev │ │ └── mediamod │ │ └── mixin │ │ └── InGameHudMixin.java ├── 1.8.9 │ ├── gradle.properties │ └── src │ │ └── main │ │ ├── kotlin │ │ └── dev │ │ │ └── mediamod │ │ │ └── command │ │ │ └── MediaModCommand.kt │ │ └── java │ │ └── dev │ │ └── mediamod │ │ └── mixin │ │ └── InGameHudMixin.java ├── 1.18.1-1.12.2.txt └── 1.12.2-1.8.9.txt ├── .github └── images │ └── screenshot.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src └── main │ ├── resources │ ├── assets │ │ └── mediamod │ │ │ ├── icon.png │ │ │ ├── textures │ │ │ └── icon │ │ │ │ └── lock.png │ │ │ └── success.html │ ├── pack.mcmeta │ ├── mediamod.mixins.json │ ├── mcmod.info │ └── fabric.mod.json │ ├── kotlin │ └── dev │ │ └── mediamod │ │ ├── utils │ │ ├── bytearray.kt │ │ ├── iterable.kt │ │ ├── coroutines.kt │ │ ├── constants.kt │ │ ├── elementa.kt │ │ └── ColorQuantizer.kt │ │ ├── gui │ │ ├── ColorPalette.kt │ │ ├── style │ │ │ └── Stylesheet.kt │ │ ├── component │ │ │ └── UIButton.kt │ │ ├── screen │ │ │ ├── editor │ │ │ │ ├── component │ │ │ │ │ ├── CustomThemeListItem.kt │ │ │ │ │ ├── ThemeColorComponent.kt │ │ │ │ │ ├── ThemeListItem.kt │ │ │ │ │ └── ThemeEditorContainer.kt │ │ │ │ └── ThemeEditorScreen.kt │ │ │ └── RepositionScreen.kt │ │ └── hud │ │ │ ├── RotatingTextComponent.kt │ │ │ ├── ProgressBarComponent.kt │ │ │ └── PlayerComponent.kt │ │ ├── data │ │ ├── Track.kt │ │ ├── api │ │ │ ├── spotify │ │ │ │ ├── SpotifyAPIResponse.kt │ │ │ │ └── SpotifyCurrentTrackResponse.kt │ │ │ ├── mojang │ │ │ │ └── SessionJoinRequest.kt │ │ │ ├── mediamod │ │ │ │ ├── PublishThemeResponse.kt │ │ │ │ ├── PublishThemeRequest.kt │ │ │ │ └── APIResponse.kt │ │ │ └── browser │ │ │ │ └── ExtensionTrackInfo.kt │ │ └── serialization │ │ │ ├── SpotifyAPIResponseSerializer.kt │ │ │ └── APIResponseSerializer.kt │ │ ├── modmenu │ │ └── ModMenuImpl.kt │ │ ├── service │ │ ├── Service.kt │ │ └── impl │ │ │ ├── browser │ │ │ ├── BrowserService.kt │ │ │ └── connection │ │ │ │ └── ExtensionConnectionManager.kt │ │ │ └── spotify │ │ │ ├── callback │ │ │ └── SpotifyCallbackManager.kt │ │ │ ├── SpotifyService.kt │ │ │ └── api │ │ │ └── SpotifyAPI.kt │ │ ├── websocket │ │ ├── message │ │ │ ├── impl │ │ │ │ ├── outgoing │ │ │ │ │ ├── impl │ │ │ │ │ │ ├── OutgoingHeartbeatMessage.kt │ │ │ │ │ │ ├── OutgoingTrackMessage.kt │ │ │ │ │ │ └── OutgoingHandshakeMessage.kt │ │ │ │ │ └── OutgoingSocketMessage.kt │ │ │ │ └── incoming │ │ │ │ │ ├── impl │ │ │ │ │ ├── IncomingHeartbeatMessage.kt │ │ │ │ │ ├── IncomingHandshakeMessage.kt │ │ │ │ │ └── IncomingTrackMessage.kt │ │ │ │ │ └── IncomingSocketMessage.kt │ │ │ └── serialization │ │ │ │ ├── IncomingSocketMessageSerializer.kt │ │ │ │ └── OutgoingSocketMessageSerializer.kt │ │ └── ExtensionSocket.kt │ │ ├── theme │ │ ├── impl │ │ │ ├── ClassicTheme.kt │ │ │ └── DynamicTheme.kt │ │ └── Theme.kt │ │ ├── serializer │ │ └── ColorSerializer.kt │ │ ├── manager │ │ ├── NotificationManager.kt │ │ ├── ServiceManager.kt │ │ ├── RenderManager.kt │ │ ├── SessionManager.kt │ │ ├── APIManager.kt │ │ └── ThemeManager.kt │ │ ├── MediaMod.kt │ │ └── config │ │ └── Configuration.kt │ └── java │ └── dev │ └── mediamod │ └── mixin │ └── InGameHudMixin.java ├── .gitignore ├── README.md ├── root.gradle.kts ├── gradle.properties ├── settings.gradle.kts ├── gradlew.bat ├── gradlew └── LICENSE /versions/mainProject: -------------------------------------------------------------------------------- 1 | 1.18.1-fabric -------------------------------------------------------------------------------- /.github/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaModMC/MediaMod/HEAD/.github/images/screenshot.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaModMC/MediaMod/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /versions/1.18.1-forge/gradle.properties: -------------------------------------------------------------------------------- 1 | loom.platform = forge 2 | minecraftVersion = 1.18.1 3 | forgeVersion = 1.18.1-39.1.2 -------------------------------------------------------------------------------- /versions/1.18.1-fabric/gradle.properties: -------------------------------------------------------------------------------- 1 | minecraftVersion = 1.18.1 2 | mappingsVersion = 1.18.1+build.22 3 | loaderVersion = 0.13.3 -------------------------------------------------------------------------------- /src/main/resources/assets/mediamod/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaModMC/MediaMod/HEAD/src/main/resources/assets/mediamod/icon.png -------------------------------------------------------------------------------- /src/main/resources/pack.mcmeta: -------------------------------------------------------------------------------- 1 | { 2 | "pack": { 3 | "description": "MediaMod Resources", 4 | "pack_format": 8 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/utils/bytearray.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.utils 2 | 3 | internal fun ByteArray.hex() = joinToString("") { "%02x".format(it) } 4 | -------------------------------------------------------------------------------- /versions/1.12.2/gradle.properties: -------------------------------------------------------------------------------- 1 | loom.platform = forge 2 | minecraftVersion = 1.12.2 3 | mappingsVersion = mcp_stable:39-1.12 4 | forgeVersion = 1.12.2-14.23.0.2486 -------------------------------------------------------------------------------- /versions/1.8.9/gradle.properties: -------------------------------------------------------------------------------- 1 | loom.platform = forge 2 | minecraftVersion = 1.8.9 3 | mappingsVersion = mcp_stable:22-1.8.9 4 | forgeVersion = 1.8.9-11.15.1.2318-1.8.9 -------------------------------------------------------------------------------- /src/main/resources/assets/mediamod/textures/icon/lock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaModMC/MediaMod/HEAD/src/main/resources/assets/mediamod/textures/icon/lock.png -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/utils/iterable.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.utils 2 | 3 | fun Iterable.firstNotNullOrNull(): T? { 4 | return filterNotNull().firstOrNull() 5 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/gui/ColorPalette.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.gui 2 | 3 | import java.awt.Color 4 | 5 | object ColorPalette { 6 | val background = Color(30, 30, 30) 7 | val secondaryBackground = Color(40, 40, 40) 8 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | *.launch 3 | .settings 4 | .metadata 5 | .classpath 6 | .project 7 | 8 | *.ipr 9 | *.iws 10 | *.iml 11 | .idea/* 12 | 13 | build 14 | .gradle 15 | out 16 | 17 | eclipse 18 | run 19 | 20 | .DS_Store 21 | .vscode/ -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/data/Track.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.data 2 | 3 | import java.net.URL 4 | 5 | data class Track( 6 | val name: String, 7 | val artist: String, 8 | val artwork: URL, 9 | val elapsed: Long, 10 | val duration: Long, 11 | val paused: Boolean 12 | ) -------------------------------------------------------------------------------- /src/main/resources/mediamod.mixins.json: -------------------------------------------------------------------------------- 1 | { 2 | "required": true, 3 | "minVersion": "0.8", 4 | "package": "dev.mediamod.mixin", 5 | "compatibilityLevel": "JAVA_8", 6 | "client": [ 7 | "InGameHudMixin" 8 | ], 9 | "injectors": { 10 | "defaultRequire": 1 11 | } 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/data/api/spotify/SpotifyAPIResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.data.api.spotify 2 | 3 | import dev.mediamod.data.serialization.SpotifyAPIResponseSerializer 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable(with = SpotifyAPIResponseSerializer::class) 7 | open class SpotifyAPIResponse -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/data/api/mojang/SessionJoinRequest.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.data.api.mojang 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class SessionJoinRequest( 7 | val accessToken: String, 8 | val selectedProfile: String, 9 | val serverId: String 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/utils/coroutines.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.utils 2 | 3 | import kotlinx.coroutines.async 4 | import kotlinx.coroutines.awaitAll 5 | import kotlinx.coroutines.coroutineScope 6 | 7 | suspend fun Iterable.pmap(f: suspend (A) -> B): List = coroutineScope { 8 | map { async { f(it) } }.awaitAll() 9 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/data/api/mediamod/PublishThemeResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.data.api.mediamod 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class PublishThemeResponse( 8 | @SerialName("theme_id") 9 | val themeID: String 10 | ) : APIResponse() 11 | -------------------------------------------------------------------------------- /versions/1.18.1-1.12.2.txt: -------------------------------------------------------------------------------- 1 | net.minecraft.client.Minecraft getUser() net.minecraft.client.Minecraft getSession() 2 | 3 | net.minecraft.client.User getName() net.minecraft.util.Session getUsername() 4 | net.minecraft.client.User getAccessToken() net.minecraft.util.Session getToken() 5 | net.minecraft.client.User getUuid() net.minecraft.util.Session getPlayerID() 6 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/data/api/mediamod/PublishThemeRequest.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.data.api.mediamod 2 | 3 | import dev.mediamod.theme.Theme 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class PublishThemeRequest( 8 | val username: String, 9 | val uuid: String, 10 | val sharedSecret: String, 11 | val theme: Theme.LoadedTheme 12 | ) 13 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/utils/constants.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.utils 2 | 3 | import kotlinx.serialization.json.Json 4 | import org.apache.logging.log4j.LogManager 5 | 6 | internal val spotifyClientID = "88ddf756462c4e078933a42f4cdb33e8" 7 | 8 | internal val logger = LogManager.getLogger("MediaMod") 9 | 10 | internal val json = Json { 11 | ignoreUnknownKeys = true 12 | prettyPrint = true 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/modmenu/ModMenuImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.modmenu 2 | 3 | //#if FABRIC==1 4 | import com.terraformersmc.modmenu.api.ConfigScreenFactory 5 | import com.terraformersmc.modmenu.api.ModMenuApi 6 | import dev.mediamod.config.Configuration 7 | 8 | class ModMenuImpl : ModMenuApi { 9 | override fun getModConfigScreenFactory() = 10 | ConfigScreenFactory { Configuration.gui() } 11 | } 12 | //#endif -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/service/Service.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.service 2 | 3 | import dev.mediamod.data.Track 4 | import gg.essential.vigilance.Vigilant 5 | 6 | abstract class Service { 7 | abstract val displayName: String 8 | abstract suspend fun pollTrack(): Track? 9 | 10 | open val hasConfiguration: Boolean = false 11 | 12 | open fun init() {} 13 | open fun Vigilant.CategoryPropertyBuilder.configuration() {} 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/websocket/message/impl/outgoing/impl/OutgoingHeartbeatMessage.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.websocket.message.impl.outgoing.impl 2 | 3 | import dev.mediamod.websocket.message.impl.outgoing.OutgoingSocketMessage 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class OutgoingHeartbeatMessage( 8 | override val id: String = "HEARTBEAT", 9 | override val data: Data? = null 10 | ) : OutgoingSocketMessage() { 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/utils/elementa.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.utils 2 | 3 | import gg.essential.elementa.UIComponent 4 | import gg.essential.elementa.constraints.ColorConstraint 5 | import gg.essential.elementa.constraints.animation.Animations 6 | import gg.essential.elementa.dsl.animate 7 | 8 | internal fun UIComponent.setColorAnimated(color: ColorConstraint, time: Float = 0.5f) = apply { 9 | animate { 10 | setColorAnimation(Animations.IN_OUT_EXP, time, color, 0f) 11 | } 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/websocket/message/impl/incoming/impl/IncomingHeartbeatMessage.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.websocket.message.impl.incoming.impl 2 | 3 | import dev.mediamod.websocket.message.impl.incoming.IncomingSocketMessage 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class IncomingHeartbeatMessage( 8 | override val id: String = "HEARTBEAT", 9 | override val data: Data? = null, 10 | override val token: String 11 | ) : IncomingSocketMessage() { 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/websocket/message/impl/incoming/impl/IncomingHandshakeMessage.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.websocket.message.impl.incoming.impl 2 | 3 | import dev.mediamod.websocket.message.impl.incoming.IncomingSocketMessage 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class IncomingHandshakeMessage( 8 | override val id: String = "HANDSHAKE", 9 | override val data: Data? = null, 10 | override val token: String? = null 11 | ) : IncomingSocketMessage() { 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/websocket/message/impl/outgoing/OutgoingSocketMessage.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.websocket.message.impl.outgoing 2 | 3 | import dev.mediamod.websocket.message.serialization.OutgoingSocketMessageSerializer 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable(with = OutgoingSocketMessageSerializer::class) 7 | abstract class OutgoingSocketMessage { 8 | abstract val id: String 9 | abstract val data: Data? 10 | 11 | @Serializable 12 | open class Data 13 | } -------------------------------------------------------------------------------- /versions/1.12.2/src/main/kotlin/dev/mediamod/command/MediaModCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.command 2 | 3 | import dev.mediamod.config.Configuration 4 | import gg.essential.api.commands.Command 5 | import gg.essential.api.commands.DefaultHandler 6 | import gg.essential.api.utils.GuiUtil 7 | 8 | @Suppress("unused") 9 | class MediaModCommand : Command("mediamod") { 10 | @DefaultHandler 11 | fun handle() { 12 | val gui = Configuration.gui() ?: return 13 | GuiUtil.open(gui) 14 | } 15 | } -------------------------------------------------------------------------------- /versions/1.8.9/src/main/kotlin/dev/mediamod/command/MediaModCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.command 2 | 3 | import dev.mediamod.config.Configuration 4 | import gg.essential.api.commands.Command 5 | import gg.essential.api.commands.DefaultHandler 6 | import gg.essential.api.utils.GuiUtil 7 | 8 | @Suppress("unused") 9 | class MediaModCommand : Command("mediamod") { 10 | @DefaultHandler 11 | fun handle() { 12 | val gui = Configuration.gui() ?: return 13 | GuiUtil.open(gui) 14 | } 15 | } -------------------------------------------------------------------------------- /src/main/resources/mcmod.info: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "modid": "mediamod", 4 | "name": "MediaMod", 5 | "description": "View your current song inside of Minecraft!", 6 | "version": "${version}", 7 | "mcversion": "${mcversion}", 8 | "url": "", 9 | "updateUrl": "", 10 | "authorList": [ 11 | "Conor Byrne" 12 | ], 13 | "credits": "", 14 | "logoFile": "", 15 | "screenshots": [], 16 | "dependencies": [] 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # MediaMod 4 | 5 | View your current song in Minecraft with support for services like Spotify, YouTube, SoundCloud & more. 6 | 7 | **More documentation & an actual README coming soon** 8 | 9 | ### Supported Versions 10 | 11 | **Fabric:** 12 | - 1.18.x 13 | 14 | **Forge:** 15 | - ~~1.18.x~~ (planned) 16 | - 1.12.2 17 | - 1.8.9 18 | 19 | ## License 20 | 21 | [LGPL 3.0](https://choosealicense.com/licenses/lgpl-3.0/) 22 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/websocket/message/impl/incoming/IncomingSocketMessage.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.websocket.message.impl.incoming 2 | 3 | import dev.mediamod.websocket.message.serialization.IncomingSocketMessageSerializer 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable(with = IncomingSocketMessageSerializer::class) 7 | abstract class IncomingSocketMessage { 8 | abstract val id: String 9 | abstract val data: Data? 10 | abstract val token: String? 11 | 12 | @Serializable 13 | open class Data 14 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/data/api/browser/ExtensionTrackInfo.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.data.api.browser 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class ExtensionTrackInfo( 8 | val title: String, 9 | val artist: String, 10 | @SerialName("album_art") 11 | val albumArt: String, 12 | val timestamps: Timestamps, 13 | val paused: Boolean 14 | ) { 15 | @Serializable 16 | data class Timestamps( 17 | val duration: Long, 18 | val elapsed: Long 19 | ) 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/websocket/message/impl/outgoing/impl/OutgoingTrackMessage.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.websocket.message.impl.outgoing.impl 2 | 3 | import dev.mediamod.websocket.message.impl.outgoing.OutgoingSocketMessage 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class OutgoingTrackMessage( 8 | override val id: String = "TRACK", 9 | override val data: RequestData 10 | ) : OutgoingSocketMessage() { 11 | constructor(nonce: String) : this(data = RequestData(nonce)) 12 | 13 | @Serializable 14 | data class RequestData( 15 | val nonce: String 16 | ) : Data() 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/theme/impl/ClassicTheme.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.theme.impl 2 | 3 | import dev.mediamod.theme.Colors 4 | import dev.mediamod.theme.Theme 5 | import java.awt.Color 6 | import java.awt.image.BufferedImage 7 | 8 | internal val classicColors = Colors( 9 | background = Color.darkGray.darker(), 10 | text = Color.white, 11 | progressBar = Color.green, 12 | progressBarBackground = Color.gray, 13 | progressBarText = Color.darkGray.darker() 14 | ) 15 | 16 | class ClassicTheme : Theme.InbuiltTheme("Classic") { 17 | override val colors = classicColors 18 | override fun update(image: BufferedImage) {} 19 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/websocket/message/impl/outgoing/impl/OutgoingHandshakeMessage.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.websocket.message.impl.outgoing.impl 2 | 3 | import dev.mediamod.websocket.message.impl.outgoing.OutgoingSocketMessage 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class OutgoingHandshakeMessage( 8 | override val id: String = "HANDSHAKE", 9 | override val data: HandshakeData 10 | ) : OutgoingSocketMessage() { 11 | constructor(token: String) : this(data = HandshakeData(token)) 12 | 13 | @Serializable 14 | data class HandshakeData( 15 | val token: String 16 | ) : Data() 17 | } -------------------------------------------------------------------------------- /root.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") version "1.6.10" apply false 3 | kotlin("plugin.serialization") version "1.6.10" apply false 4 | id("com.github.johnrengelman.shadow") version "7.1.2" apply false 5 | id("com.replaymod.preprocess") version "0ab22d2" 6 | id("gg.essential.loom") version "0.10.0.2" apply false 7 | } 8 | 9 | configurations.register("compileClasspath") 10 | 11 | preprocess { 12 | "1.18.1-fabric"(11801, "yarn") { 13 | "1.18.1-forge"(11801, "srg") { 14 | "1.12.2"(11202, "srg", file("versions/1.18.1-1.12.2.txt")) { 15 | "1.8.9"(10809, "srg", file("versions/1.12.2-1.8.9.txt")) 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/websocket/message/impl/incoming/impl/IncomingTrackMessage.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.websocket.message.impl.incoming.impl 2 | 3 | import dev.mediamod.data.api.browser.ExtensionTrackInfo 4 | import dev.mediamod.websocket.message.impl.incoming.IncomingSocketMessage 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class IncomingTrackMessage( 9 | override val id: String = "TRACK", 10 | override val data: TrackData, 11 | override val token: String 12 | ) : IncomingSocketMessage() { 13 | @Serializable 14 | data class TrackData( 15 | val track: ExtensionTrackInfo? = null, 16 | val nonce: String 17 | ) : Data() 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/gui/style/Stylesheet.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.gui.style 2 | 3 | import gg.essential.elementa.UIComponent 4 | import gg.essential.elementa.UIConstraints 5 | 6 | class Stylesheet { 7 | private val styles = mutableMapOf Unit>() 8 | 9 | operator fun String.invoke(configuration: UIConstraints.() -> Unit) = apply { styles[this] = configuration } 10 | operator fun get(name: String) = styles[name] 11 | } 12 | 13 | infix fun T.styled(style: (UIConstraints.() -> Unit)?) = apply { 14 | style?.invoke(constraints) 15 | } 16 | 17 | fun stylesheet(configuration: Stylesheet.() -> Unit) = 18 | Stylesheet().apply(configuration) -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/service/impl/browser/BrowserService.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.service.impl.browser 2 | 3 | import dev.mediamod.data.Track 4 | import dev.mediamod.service.Service 5 | import dev.mediamod.service.impl.browser.connection.ExtensionConnectionManager 6 | import java.net.URL 7 | 8 | class BrowserService : Service() { 9 | private val extensionManager = ExtensionConnectionManager() 10 | override val displayName = "Browser" 11 | 12 | override fun init() = extensionManager.init() 13 | override suspend fun pollTrack() = extensionManager.requestTrack()?.let { 14 | Track(it.title, it.artist, URL(it.albumArt), it.timestamps.elapsed, it.timestamps.duration, it.paused) 15 | } 16 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | org.gradle.parallel=true 3 | org.gradle.parallel.threads=4 4 | org.gradle.jvmargs=-Xmx2G 5 | 6 | elementaVersion=495 7 | universalCraftVersion=209 8 | vigilanceVersion=203 9 | modMenuVersion=3.0.1 10 | fuelVersion=2.3.1 11 | resultVersion=3.1.0 12 | fabricLanguageKotlinVersion=1.7.1+kotlin.1.6.10 13 | fabricApiVersion=0.46.4+1.18 14 | kotlinxSerializationVersion=1.3.2 15 | essentialVersion=1725 16 | essentialLoaderVersion=1.1.3 17 | mixinVersion=0.8.5 18 | nightconfigVersion=3.6.0 19 | dom4jVersion=2.1.1 20 | javaWebsocketVersion=1.5.2 21 | kotlinxCoroutinesVersion=1.6.0 22 | devauthVersion=1.0.0 23 | toastsVersion=5c9b09f08b 24 | slf4jVersion=1.7.25 25 | kotlinForForgeVersion=3.1.0 -------------------------------------------------------------------------------- /versions/1.12.2/src/main/java/dev/mediamod/mixin/InGameHudMixin.java: -------------------------------------------------------------------------------- 1 | package dev.mediamod.mixin; 2 | 3 | import dev.mediamod.MediaMod; 4 | import gg.essential.universal.UMatrixStack; 5 | import net.minecraftforge.client.GuiIngameForge; 6 | import org.spongepowered.asm.mixin.Mixin; 7 | import org.spongepowered.asm.mixin.injection.At; 8 | import org.spongepowered.asm.mixin.injection.Inject; 9 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 10 | 11 | @Mixin(GuiIngameForge.class) 12 | public class InGameHudMixin { 13 | @Inject( 14 | method = "renderGameOverlay", 15 | at = @At(value = "TAIL") 16 | ) 17 | public void mediamod_onRenderTick(CallbackInfo ci) { 18 | MediaMod.INSTANCE.getRenderManager().onRenderTick(new UMatrixStack()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /versions/1.8.9/src/main/java/dev/mediamod/mixin/InGameHudMixin.java: -------------------------------------------------------------------------------- 1 | package dev.mediamod.mixin; 2 | 3 | import dev.mediamod.MediaMod; 4 | import gg.essential.universal.UMatrixStack; 5 | import net.minecraftforge.client.GuiIngameForge; 6 | import org.spongepowered.asm.mixin.Mixin; 7 | import org.spongepowered.asm.mixin.injection.At; 8 | import org.spongepowered.asm.mixin.injection.Inject; 9 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 10 | 11 | @Mixin(GuiIngameForge.class) 12 | public class InGameHudMixin { 13 | @Inject( 14 | method = "renderGameOverlay", 15 | at = @At(value = "TAIL") 16 | ) 17 | public void mediamod_onRenderTick(CallbackInfo ci) { 18 | MediaMod.INSTANCE.getRenderManager().onRenderTick(new UMatrixStack()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/data/api/mediamod/APIResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.data.api.mediamod 2 | 3 | import dev.mediamod.data.serialization.APIResponseSerializer 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable(with = APIResponseSerializer::class) 8 | open class APIResponse 9 | 10 | @Serializable 11 | data class SpotifyTokenResponse( 12 | @SerialName("access_token") 13 | val accessToken: String, 14 | @SerialName("refresh_token") 15 | val refreshToken: String, 16 | @SerialName("token_type") 17 | val tokenType: String, 18 | @SerialName("expires_in") 19 | val expiresIn: Int, 20 | val scope: String 21 | ) : APIResponse() 22 | 23 | @Serializable 24 | data class ErrorResponse( 25 | val message: String 26 | ) : APIResponse() -------------------------------------------------------------------------------- /src/main/java/dev/mediamod/mixin/InGameHudMixin.java: -------------------------------------------------------------------------------- 1 | package dev.mediamod.mixin; 2 | 3 | import dev.mediamod.MediaMod; 4 | import gg.essential.universal.UMatrixStack; 5 | import net.minecraft.client.gui.hud.InGameHud; 6 | import net.minecraft.client.util.math.MatrixStack; 7 | import org.spongepowered.asm.mixin.Mixin; 8 | import org.spongepowered.asm.mixin.injection.At; 9 | import org.spongepowered.asm.mixin.injection.Inject; 10 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 11 | 12 | @Mixin(InGameHud.class) 13 | public class InGameHudMixin { 14 | @Inject( 15 | method = "render", 16 | at = @At("TAIL") 17 | ) 18 | public void mediamod_onRenderTick(MatrixStack matrixStack, float tickDelta, CallbackInfo ci) { 19 | MediaMod.INSTANCE.getRenderManager().onRenderTick(new UMatrixStack(matrixStack)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/data/serialization/SpotifyAPIResponseSerializer.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.data.serialization 2 | 3 | import dev.mediamod.data.api.spotify.SpotifyAPIResponse 4 | import dev.mediamod.data.api.spotify.SpotifyCurrentTrackResponse 5 | import kotlinx.serialization.DeserializationStrategy 6 | import kotlinx.serialization.json.* 7 | 8 | object SpotifyAPIResponseSerializer : JsonContentPolymorphicSerializer(SpotifyAPIResponse::class) { 9 | override fun selectDeserializer(element: JsonElement): DeserializationStrategy { 10 | val type = element.contentOrNull("currently_playing_type") 11 | if (type != null) 12 | return SpotifyCurrentTrackResponse.serializer() 13 | 14 | return SpotifyAPIResponse.serializer() 15 | } 16 | 17 | private fun JsonElement.contentOrNull(key: String) = 18 | jsonObject[key]?.jsonPrimitive?.contentOrNull 19 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/data/api/spotify/SpotifyCurrentTrackResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.data.api.spotify 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class SpotifyCurrentTrackResponse( 8 | val item: Item? = null, 9 | @SerialName("progress_ms") 10 | val progressMs: Long = 0L, 11 | @SerialName("is_playing") 12 | val isPlaying: Boolean = false 13 | ) : SpotifyAPIResponse() 14 | 15 | @Serializable 16 | data class Item( 17 | val album: Album, 18 | val artists: List, 19 | val name: String, 20 | @SerialName("duration_ms") 21 | val durationMS: Long, 22 | ) 23 | 24 | @Serializable 25 | data class Album( 26 | val images: List, 27 | val name: String, 28 | ) 29 | 30 | @Serializable 31 | data class Artist( 32 | val name: String, 33 | ) 34 | 35 | @Serializable 36 | data class Image( 37 | val height: Long, 38 | val url: String, 39 | val width: Long 40 | ) -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/serializer/ColorSerializer.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.serializer 2 | 3 | import kotlinx.serialization.KSerializer 4 | import kotlinx.serialization.descriptors.PrimitiveKind 5 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 6 | import kotlinx.serialization.descriptors.SerialDescriptor 7 | import kotlinx.serialization.encoding.Decoder 8 | import kotlinx.serialization.encoding.Encoder 9 | import java.awt.Color 10 | 11 | object ColorSerializer : KSerializer { 12 | override val descriptor: SerialDescriptor = 13 | PrimitiveSerialDescriptor("Color", PrimitiveKind.STRING) 14 | 15 | override fun serialize(encoder: Encoder, value: Color) { 16 | encoder.encodeString(String.format("#%06X", 0xFFFFFF and value.rgb)) 17 | } 18 | 19 | override fun deserialize(decoder: Decoder): Color { 20 | val value = decoder.decodeString() 21 | if (!value.startsWith("#")) 22 | error("You must supply a hexadecimal color! $value is not a valid hexadecimal color.") 23 | 24 | return Color.decode(value) 25 | } 26 | } -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "MediaMod" 2 | rootProject.buildFileName = "root.gradle.kts" 3 | 4 | pluginManagement { 5 | repositories { 6 | gradlePluginPortal() 7 | mavenCentral() 8 | maven("https://jitpack.io") 9 | maven("https://maven.fabricmc.net") 10 | maven("https://maven.minecraftforge.net") 11 | maven("https://maven.architectury.dev/") 12 | maven("https://repo.sk1er.club/repository/maven-releases/") 13 | } 14 | resolutionStrategy { 15 | eachPlugin { 16 | when (requested.id.id) { 17 | "com.replaymod.preprocess" -> { 18 | useModule("com.github.replaymod:preprocessor:${requested.version}") 19 | } 20 | } 21 | } 22 | } 23 | } 24 | 25 | listOf( 26 | "1.8.9", 27 | "1.12.2", 28 | "1.18.1-forge", 29 | "1.18.1-fabric" 30 | ).forEach { version -> 31 | include(":$version") 32 | project(":$version").apply { 33 | projectDir = file("versions/$version") 34 | buildFileName = "../../build.gradle.kts" 35 | } 36 | } -------------------------------------------------------------------------------- /src/main/resources/fabric.mod.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 1, 3 | "id": "mediamod", 4 | "version": "${version}", 5 | "name": "MediaMod", 6 | "description": "View your current song inside of Minecraft!", 7 | "authors": [ 8 | "Conor Byrne" 9 | ], 10 | "contact": { 11 | "homepage": "https://mediamod.dev/", 12 | "sources": "https://github.com/MediaModMC/MediaMod" 13 | }, 14 | "license": "LGPL-3", 15 | "icon": "assets/mediamod/icon.png", 16 | "environment": "*", 17 | "entrypoints": { 18 | "main": [ 19 | { 20 | "adapter": "kotlin", 21 | "value": "dev.mediamod.MediaMod::init" 22 | } 23 | ], 24 | "modmenu": [ 25 | "dev.mediamod.modmenu.ModMenuImpl" 26 | ] 27 | }, 28 | "mixins": [ 29 | "mediamod.mixins.json" 30 | ], 31 | "depends": { 32 | "fabricloader": ">=0.12.12", 33 | "minecraft": "1.18.x", 34 | "java": ">=17", 35 | "fabric-language-kotlin": ">=1.7.1+kotlin.1.6.10" 36 | }, 37 | "recommends": { 38 | "modmenu": ">=3.1.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | @file:UseSerializers(ColorSerializer::class) 2 | 3 | package dev.mediamod.theme 4 | 5 | import dev.mediamod.serializer.ColorSerializer 6 | import kotlinx.serialization.SerialName 7 | import kotlinx.serialization.Serializable 8 | import kotlinx.serialization.UseSerializers 9 | import java.awt.Color 10 | import java.awt.image.BufferedImage 11 | 12 | sealed class Theme { 13 | abstract val name: String 14 | abstract val colors: Colors 15 | 16 | abstract class InbuiltTheme(override val name: String) : Theme() { 17 | abstract override val colors: Colors 18 | } 19 | 20 | @Serializable 21 | data class LoadedTheme( 22 | override var name: String, 23 | override var colors: Colors 24 | ) : Theme() { 25 | override fun update(image: BufferedImage) {} 26 | } 27 | 28 | abstract fun update(image: BufferedImage) 29 | } 30 | 31 | @Serializable 32 | data class Colors( 33 | var background: Color, 34 | var text: Color, 35 | @SerialName("progress_bar") 36 | var progressBar: Color, 37 | @SerialName("progress_bar_background") 38 | var progressBarBackground: Color, 39 | @SerialName("progress_bar_text") 40 | var progressBarText: Color 41 | ) 42 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/manager/NotificationManager.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.manager 2 | 3 | //#if FABRIC!=0 4 | import dev.cbyrne.toasts.impl.builder.BasicToastBuilder 5 | //#elseif MC<=11202 6 | //$$ import gg.essential.api.EssentialAPI 7 | //#endif 8 | 9 | import dev.mediamod.MediaMod 10 | import dev.mediamod.config.Configuration 11 | import dev.mediamod.data.Track 12 | 13 | class NotificationManager { 14 | private var previousTrack: Track? = null 15 | 16 | fun init() { 17 | MediaMod.serviceManager.currentTrack.onSetValue { track -> 18 | if (!Configuration.trackNotifications || track == null) return@onSetValue 19 | if (previousTrack?.name == track.name || previousTrack?.artist == track.artist) return@onSetValue 20 | 21 | showNotification("Now playing", "${track.name} by ${track.artist}") 22 | previousTrack = track 23 | } 24 | } 25 | 26 | fun showNotification(title: String, message: String) { 27 | //#if FABRIC!=0 28 | BasicToastBuilder() 29 | .title(title) 30 | .description(message) 31 | .build() 32 | .show() 33 | //#elseif MC<=11202 34 | //$$ EssentialAPI.getNotifications().push(title, message) 35 | //#endif 36 | } 37 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/websocket/message/serialization/IncomingSocketMessageSerializer.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.websocket.message.serialization 2 | 3 | import dev.mediamod.websocket.message.impl.incoming.IncomingSocketMessage 4 | import dev.mediamod.websocket.message.impl.incoming.impl.IncomingHandshakeMessage 5 | import dev.mediamod.websocket.message.impl.incoming.impl.IncomingHeartbeatMessage 6 | import dev.mediamod.websocket.message.impl.incoming.impl.IncomingTrackMessage 7 | import kotlinx.serialization.DeserializationStrategy 8 | import kotlinx.serialization.json.* 9 | 10 | object IncomingSocketMessageSerializer : 11 | JsonContentPolymorphicSerializer(IncomingSocketMessage::class) { 12 | override fun selectDeserializer(element: JsonElement): DeserializationStrategy = 13 | when (val id = element.contentOrNull("id")) { 14 | "HANDSHAKE" -> IncomingHandshakeMessage.serializer() 15 | "HEARTBEAT" -> IncomingHeartbeatMessage.serializer() 16 | "TRACK" -> IncomingTrackMessage.serializer() 17 | else -> error("Unknown incoming message id: $id") 18 | } 19 | 20 | private fun JsonElement.contentOrNull(key: String) = 21 | jsonObject[key]?.jsonPrimitive?.contentOrNull 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/websocket/message/serialization/OutgoingSocketMessageSerializer.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.websocket.message.serialization 2 | 3 | import dev.mediamod.websocket.message.impl.outgoing.OutgoingSocketMessage 4 | import dev.mediamod.websocket.message.impl.outgoing.impl.OutgoingHandshakeMessage 5 | import dev.mediamod.websocket.message.impl.outgoing.impl.OutgoingHeartbeatMessage 6 | import dev.mediamod.websocket.message.impl.outgoing.impl.OutgoingTrackMessage 7 | import kotlinx.serialization.DeserializationStrategy 8 | import kotlinx.serialization.json.* 9 | 10 | object OutgoingSocketMessageSerializer : JsonContentPolymorphicSerializer( 11 | OutgoingSocketMessage::class 12 | ) { 13 | override fun selectDeserializer(element: JsonElement): DeserializationStrategy = 14 | when (val id = element.contentOrNull("id")) { 15 | "HANDSHAKE" -> OutgoingHandshakeMessage.serializer() 16 | "HEARTBEAT" -> OutgoingHeartbeatMessage.serializer() 17 | "TRACK" -> OutgoingTrackMessage.serializer() 18 | else -> error("Unknown outgoing message id: $id") 19 | } 20 | 21 | private fun JsonElement.contentOrNull(key: String) = 22 | jsonObject[key]?.jsonPrimitive?.contentOrNull 23 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/gui/component/UIButton.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.gui.component 2 | 3 | import gg.essential.elementa.UIComponent 4 | import gg.essential.elementa.components.UIBlock 5 | import gg.essential.elementa.components.UIText 6 | import gg.essential.elementa.constraints.CenterConstraint 7 | import gg.essential.elementa.dsl.childOf 8 | import gg.essential.elementa.dsl.constrain 9 | import gg.essential.elementa.dsl.constraint 10 | import gg.essential.elementa.dsl.percent 11 | import gg.essential.universal.USound 12 | import java.awt.Color 13 | 14 | class UIButton( 15 | text: String, 16 | textColor: Color, 17 | shadow: Boolean = true 18 | ) : UIBlock() { 19 | private var action: (UIComponent.() -> Unit)? = null 20 | 21 | init { 22 | constrain { 23 | width = 100.percent() 24 | height = 100.percent() 25 | } 26 | 27 | onMouseClick { 28 | USound.playButtonPress() 29 | action?.invoke(this) 30 | } 31 | 32 | UIText(text, shadow) 33 | .constrain { 34 | x = CenterConstraint() 35 | y = CenterConstraint() 36 | color = textColor.constraint 37 | } childOf this 38 | } 39 | 40 | fun onClick(block: UIComponent.() -> Unit) = apply { 41 | this.action = block 42 | } 43 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/data/serialization/APIResponseSerializer.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.data.serialization 2 | 3 | import dev.mediamod.data.api.mediamod.APIResponse 4 | import dev.mediamod.data.api.mediamod.ErrorResponse 5 | import dev.mediamod.data.api.mediamod.PublishThemeResponse 6 | import dev.mediamod.data.api.mediamod.SpotifyTokenResponse 7 | import kotlinx.serialization.DeserializationStrategy 8 | import kotlinx.serialization.json.* 9 | 10 | object APIResponseSerializer : JsonContentPolymorphicSerializer(APIResponse::class) { 11 | override fun selectDeserializer(element: JsonElement): DeserializationStrategy { 12 | val ok = element.booleanOrNull("ok") 13 | if (ok == false) 14 | return ErrorResponse.serializer() 15 | 16 | val accessToken = element.contentOrNull("access_token") 17 | if (accessToken != null) 18 | return SpotifyTokenResponse.serializer() 19 | 20 | val themeId = element.contentOrNull("theme_id") 21 | if (themeId != null) 22 | return PublishThemeResponse.serializer() 23 | 24 | return APIResponse.serializer() 25 | } 26 | 27 | private fun JsonElement.contentOrNull(key: String) = 28 | jsonObject[key]?.jsonPrimitive?.contentOrNull 29 | 30 | private fun JsonElement.booleanOrNull(key: String) = 31 | jsonObject[key]?.jsonPrimitive?.booleanOrNull 32 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/gui/screen/editor/component/CustomThemeListItem.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.gui.screen.editor.component 2 | 3 | import gg.essential.elementa.UIComponent 4 | import gg.essential.elementa.components.UIContainer 5 | import gg.essential.elementa.components.UIText 6 | import gg.essential.elementa.constraints.CenterConstraint 7 | import gg.essential.elementa.constraints.ChildBasedMaxSizeConstraint 8 | import gg.essential.elementa.constraints.ColorConstraint 9 | import gg.essential.elementa.dsl.childOf 10 | import gg.essential.elementa.dsl.constrain 11 | import gg.essential.elementa.dsl.constraint 12 | import gg.essential.universal.USound 13 | import java.awt.Color 14 | 15 | class CustomThemeListItem( 16 | text: String, 17 | private val color: ColorConstraint = Color.white.darker().constraint 18 | ) : UIContainer() { 19 | private var action: (UIComponent.() -> Unit)? = null 20 | 21 | init { 22 | UIText(text) 23 | .constrain { 24 | y = CenterConstraint() 25 | color = this@CustomThemeListItem.color 26 | } childOf this 27 | 28 | constrain { 29 | height = ChildBasedMaxSizeConstraint() 30 | } 31 | 32 | onMouseClick { 33 | USound.playButtonPress() 34 | action?.invoke(this) 35 | } 36 | } 37 | 38 | fun onClick(block: UIComponent.() -> Unit) = apply { this.action = block } 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/manager/ServiceManager.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.manager 2 | 3 | import dev.mediamod.config.Configuration 4 | import dev.mediamod.data.Track 5 | import dev.mediamod.service.Service 6 | import dev.mediamod.service.impl.browser.BrowserService 7 | import dev.mediamod.service.impl.spotify.SpotifyService 8 | import dev.mediamod.utils.firstNotNullOrNull 9 | import dev.mediamod.utils.pmap 10 | import gg.essential.elementa.state.BasicState 11 | import kotlinx.coroutines.* 12 | import kotlin.concurrent.fixedRateTimer 13 | 14 | class ServiceManager { 15 | private val scope = CoroutineScope(Dispatchers.IO) + Job() 16 | val currentTrack = BasicState(null) 17 | val services = mutableListOf() 18 | 19 | fun init() { 20 | addService(SpotifyService()) 21 | addService(BrowserService()) 22 | 23 | fixedRateTimer("MediaMod Track Polling", true, period = 3000L) { 24 | scope.launch { 25 | services 26 | .sortedByDescending { it.displayName == Configuration.preferredService } 27 | .pmap { 28 | it.pollTrack() 29 | } 30 | .firstNotNullOrNull() 31 | .let { currentTrack.set(it) } 32 | } 33 | } 34 | } 35 | 36 | private fun addService(service: Service) { 37 | services.add(service) 38 | service.init() 39 | } 40 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/manager/RenderManager.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.manager 2 | 3 | import dev.mediamod.config.Configuration 4 | import dev.mediamod.gui.hud.PlayerComponent 5 | import dev.mediamod.gui.screen.RepositionScreen 6 | import dev.mediamod.gui.screen.editor.ThemeEditorScreen 7 | import gg.essential.elementa.ElementaVersion 8 | import gg.essential.elementa.components.Window 9 | import gg.essential.elementa.dsl.constrain 10 | import gg.essential.elementa.dsl.pixels 11 | import gg.essential.universal.UMatrixStack 12 | import gg.essential.universal.UScreen 13 | 14 | class RenderManager { 15 | private val window = Window(ElementaVersion.V1) 16 | private lateinit var playerComponent: PlayerComponent 17 | 18 | fun onRenderTick(stack: UMatrixStack) { 19 | if (UScreen.currentScreen is RepositionScreen || UScreen.currentScreen is ThemeEditorScreen) 20 | return 21 | 22 | if (window.children.isEmpty()) { 23 | playerComponent = PlayerComponent() 24 | .constrain { 25 | x = Configuration.playerX.pixels() 26 | y = Configuration.playerY.pixels() 27 | 28 | width = 150.pixels() 29 | height = 50.pixels() 30 | } 31 | 32 | window.addChild(playerComponent) 33 | } else { 34 | // TODO: State 35 | playerComponent.setX(Configuration.playerX.pixels()) 36 | playerComponent.setY(Configuration.playerY.pixels()) 37 | } 38 | 39 | window.draw(stack) 40 | } 41 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/service/impl/browser/connection/ExtensionConnectionManager.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.service.impl.browser.connection 2 | 3 | import dev.mediamod.data.api.browser.ExtensionTrackInfo 4 | import dev.mediamod.utils.logger 5 | import dev.mediamod.websocket.ExtensionSocket 6 | import dev.mediamod.websocket.message.impl.incoming.IncomingSocketMessage 7 | import dev.mediamod.websocket.message.impl.incoming.impl.IncomingTrackMessage 8 | import dev.mediamod.websocket.message.impl.outgoing.impl.OutgoingTrackMessage 9 | import kotlinx.coroutines.flow.MutableSharedFlow 10 | import kotlinx.coroutines.flow.filterIsInstance 11 | import kotlinx.coroutines.flow.firstOrNull 12 | import kotlinx.coroutines.withTimeoutOrNull 13 | import java.util.* 14 | 15 | class ExtensionConnectionManager { 16 | private val flow = MutableSharedFlow() 17 | private val server = ExtensionSocket("localhost", 9104, flow) 18 | 19 | fun init() { 20 | try { 21 | server.start() 22 | } catch (error: Error) { 23 | logger.error("Failed to start extension websocket server: ", error) 24 | } 25 | } 26 | 27 | suspend fun requestTrack(): ExtensionTrackInfo? { 28 | val nonce = UUID.randomUUID().toString() 29 | server.sendMessage(OutgoingTrackMessage(nonce)) 30 | 31 | return withTimeoutOrNull(3000) { 32 | flow 33 | .filterIsInstance() 34 | .firstOrNull { it.data.nonce == nonce } 35 | ?.data?.track 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/manager/SessionManager.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.manager 2 | 3 | import com.github.kittinunf.fuel.Fuel 4 | import com.github.kittinunf.fuel.core.extensions.jsonBody 5 | import dev.mediamod.data.api.mojang.SessionJoinRequest 6 | import dev.mediamod.utils.json 7 | import dev.mediamod.utils.hex 8 | import gg.essential.universal.UMinecraft 9 | import kotlinx.serialization.encodeToString 10 | import java.security.MessageDigest 11 | import java.security.SecureRandom 12 | 13 | class SessionManager { 14 | private val constant = "82074fcd6eef4cafbc954dac50485fb7".encodeToByteArray() 15 | private val baseURL = "https://sessionserver.mojang.com" 16 | 17 | fun joinServer(): ByteArray? { 18 | val sharedSecret = generateSharedSecret() 19 | val serverIdHash = generateServerIdHash(sharedSecret) 20 | 21 | val body = SessionJoinRequest( 22 | UMinecraft.getMinecraft().session.accessToken, 23 | UMinecraft.getMinecraft().session.uuid, 24 | serverIdHash 25 | ) 26 | 27 | val (_, response, _) = Fuel.post("${baseURL}/session/minecraft/join") 28 | .jsonBody(json.encodeToString(body)) 29 | .response() 30 | 31 | if (response.statusCode != 204) 32 | return null 33 | 34 | return sharedSecret 35 | } 36 | 37 | private fun generateServerIdHash(secret: ByteArray): String { 38 | val id = secret + constant 39 | return sha1(id).hex() 40 | } 41 | 42 | private fun generateSharedSecret(): ByteArray { 43 | val random = SecureRandom() 44 | val bytes = ByteArray(16) 45 | 46 | random.nextBytes(bytes) 47 | return bytes 48 | } 49 | 50 | private fun sha1(input: ByteArray): ByteArray { 51 | val sha1 = MessageDigest.getInstance("SHA-1") 52 | return sha1.digest(input) 53 | } 54 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/manager/APIManager.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.manager 2 | 3 | import com.github.kittinunf.fuel.Fuel 4 | import com.github.kittinunf.fuel.core.FuelError 5 | import com.github.kittinunf.fuel.core.extensions.jsonBody 6 | import com.github.kittinunf.fuel.serialization.responseObject 7 | import com.github.kittinunf.result.Result 8 | import dev.mediamod.MediaMod 9 | import dev.mediamod.data.api.mediamod.APIResponse 10 | import dev.mediamod.theme.Theme 11 | import dev.mediamod.utils.json 12 | import dev.mediamod.utils.hex 13 | import kotlinx.serialization.encodeToString 14 | import dev.mediamod.data.api.mediamod.PublishThemeRequest 15 | import gg.essential.universal.UMinecraft 16 | 17 | class APIManager { 18 | private val baseURL = runCatching { System.getProperty("mediamod.overrideApiUrl") }.getOrNull() 19 | ?: "https://staging-api.mediamod.dev" 20 | 21 | fun exchangeCode(code: String) = 22 | Fuel.post("$baseURL/api/v1/spotify/auth") 23 | .jsonBody(json.encodeToString(mapOf("code" to code))) 24 | .responseObject(json) 25 | .third 26 | 27 | fun refreshAccessToken(refreshToken: String) = 28 | Fuel.post("$baseURL/api/v1/spotify/refresh") 29 | .jsonBody(json.encodeToString(mapOf("refresh_token" to refreshToken))) 30 | .responseObject(json) 31 | .third 32 | 33 | fun publishTheme(theme: Theme.LoadedTheme): Result { 34 | val sharedSecret = MediaMod.sessionManager.joinServer() 35 | ?: error("Failed to contact Session Server!") 36 | 37 | val body = PublishThemeRequest( 38 | UMinecraft.getMinecraft().session.username, 39 | UMinecraft.getMinecraft().session.uuid, 40 | sharedSecret.hex(), 41 | theme 42 | ) 43 | 44 | return Fuel.post("$baseURL/api/v1/themes/publish") 45 | .jsonBody(json.encodeToString(body)) 46 | .responseObject(json) 47 | .third 48 | } 49 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/MediaMod.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod 2 | 3 | import dev.mediamod.manager.* 4 | import dev.mediamod.utils.logger 5 | import java.io.File 6 | import kotlin.concurrent.thread 7 | import kotlin.reflect.KParameter 8 | import kotlin.reflect.jvm.javaMethod 9 | import kotlin.reflect.jvm.kotlinFunction 10 | import kotlin.system.measureTimeMillis 11 | 12 | //#if FABRIC==0 13 | //$$ import net.minecraftforge.fml.common.Mod 14 | //#endif 15 | //#if MC<=11202 16 | //$$ import net.minecraftforge.fml.common.event.FMLInitializationEvent 17 | //$$ import gg.essential.api.EssentialAPI 18 | //$$ import dev.mediamod.command.MediaModCommand 19 | //#endif 20 | 21 | //#if FABRIC==0 && MC<=11202 22 | //$$ @Mod(modid = "mediamod", modLanguageAdapter = "gg.essential.api.utils.KotlinAdapter") 23 | //#endif 24 | object MediaMod { 25 | val dataDirectory = File("./mediamod") 26 | 27 | val apiManager = APIManager() 28 | val notificationManager = NotificationManager() 29 | val serviceManager = ServiceManager() 30 | val renderManager = RenderManager() 31 | val sessionManager = SessionManager() 32 | val themeManager = ThemeManager() 33 | 34 | //#if MC<=11202 35 | //$$ @Mod.EventHandler 36 | //$$ fun init(event: FMLInitializationEvent) { 37 | //#else 38 | fun init() { 39 | //#endif 40 | logger.info("MediaMod has started!") 41 | 42 | //#if FABRIC==1 43 | initializeReflect() 44 | //#endif 45 | 46 | themeManager.init() 47 | serviceManager.init() 48 | notificationManager.init() 49 | 50 | //#if MC<=11202 51 | //$$ EssentialAPI.getCommandRegistry().registerCommand(MediaModCommand()) 52 | //#endif 53 | } 54 | 55 | private fun initializeReflect() { 56 | thread(start = true, isDaemon = false, name = "MediaMod kreflect warmup") { 57 | val time = measureTimeMillis { 58 | themeManager::onChange.javaMethod 59 | ?.kotlinFunction?.parameters 60 | ?.filter { it.kind == KParameter.Kind.VALUE } 61 | ?: run { 62 | logger.warn("Failed to locate method for kreflect warmup!") 63 | return@measureTimeMillis 64 | } 65 | } 66 | 67 | logger.info("Took $time ms to warm up kreflect") 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/service/impl/spotify/callback/SpotifyCallbackManager.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.service.impl.spotify.callback 2 | 3 | import com.github.kittinunf.fuel.core.FuelError 4 | import com.github.kittinunf.result.Result 5 | import com.sun.net.httpserver.HttpExchange 6 | import com.sun.net.httpserver.HttpHandler 7 | import com.sun.net.httpserver.HttpServer 8 | import dev.mediamod.MediaMod 9 | import dev.mediamod.data.api.mediamod.APIResponse 10 | import dev.mediamod.utils.logger 11 | import java.net.InetSocketAddress 12 | import kotlin.concurrent.thread 13 | 14 | class SpotifyCallbackManager { 15 | private val callbackResultListeners = mutableSetOf<(Result.() -> Unit)>() 16 | 17 | fun init() { 18 | try { 19 | val server = HttpServer.create(InetSocketAddress("localhost", 9103), 0) 20 | server.createContext("/callback", CallbackRoute(this)) 21 | server.start() 22 | 23 | logger.info("Spotify callback server started on localhost:9103!") 24 | } catch (error: Error) { 25 | logger.error("Failed to start Spotify callback server: ", error) 26 | } 27 | } 28 | 29 | fun onCallback(callback: Result.() -> Unit) = 30 | callbackResultListeners.add(callback) 31 | 32 | class CallbackRoute(private val manager: SpotifyCallbackManager) : HttpHandler { 33 | override fun handle(exchange: HttpExchange) { 34 | val params = exchange.requestURI.query 35 | .split("&") 36 | .associate { 37 | val (left, right) = it.split("=") 38 | left to right 39 | } 40 | 41 | val code = params["code"] ?: return exchange.sendResponse("Invalid oauth code!") 42 | 43 | thread(true) { 44 | val result = MediaMod.apiManager.exchangeCode(code) 45 | manager.callbackResultListeners.forEach { it.invoke(result) } 46 | } 47 | 48 | val resource = javaClass.getResource("/assets/mediamod/success.html") 49 | ?: run { 50 | exchange.sendResponse("You can now return to Minecraft.") 51 | return 52 | } 53 | 54 | exchange.sendResponse(resource.readText()) 55 | } 56 | 57 | private fun HttpExchange.sendResponse(message: String) { 58 | sendResponseHeaders(200, message.length.toLong()) 59 | responseBody.write(message.encodeToByteArray()) 60 | responseBody.close() 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/gui/screen/editor/component/ThemeColorComponent.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.gui.screen.editor.component 2 | 3 | import dev.mediamod.gui.style.styled 4 | import dev.mediamod.gui.style.stylesheet 5 | import gg.essential.elementa.components.UIBlock 6 | import gg.essential.elementa.components.UIContainer 7 | import gg.essential.elementa.components.UIText 8 | import gg.essential.elementa.constraints.CenterConstraint 9 | import gg.essential.elementa.constraints.ChildBasedMaxSizeConstraint 10 | import gg.essential.elementa.constraints.ConstantColorConstraint 11 | import gg.essential.elementa.constraints.SiblingConstraint 12 | import gg.essential.elementa.dsl.childOf 13 | import gg.essential.elementa.dsl.percent 14 | import gg.essential.elementa.dsl.pixels 15 | import gg.essential.elementa.state.BasicState 16 | import gg.essential.vigilance.gui.settings.ColorComponent 17 | import java.awt.Color 18 | 19 | class ThemeColorComponent(themeColor: Color, text: String, locked: Boolean) : UIContainer() { 20 | private val colorState = BasicState(themeColor) 21 | private var onChange: ((Color) -> Unit)? = null 22 | 23 | private val stylesheet = stylesheet { 24 | "this" { 25 | width = 100.percent() 26 | height = ChildBasedMaxSizeConstraint() 27 | } 28 | 29 | "preview" { 30 | color = ConstantColorConstraint().bindColor(colorState) 31 | y = CenterConstraint() 32 | width = 8.pixels() 33 | height = 8.pixels() 34 | } 35 | 36 | "text" { 37 | x = SiblingConstraint(5f) 38 | y = CenterConstraint() 39 | } 40 | 41 | "colorPicker" { 42 | x = 0.pixels(true) 43 | y = CenterConstraint() 44 | } 45 | } 46 | 47 | init { 48 | styled(stylesheet["this"]) 49 | 50 | UIBlock() 51 | .styled(stylesheet["preview"]) 52 | .childOf(this) 53 | 54 | UIText(text, false) 55 | .styled(stylesheet["text"]) 56 | .childOf(this) 57 | 58 | ColorComponent(themeColor, false) 59 | .styled(stylesheet["colorPicker"]) 60 | .childOf(this) 61 | .apply { 62 | // For some reason, onValueChange isn't an apply 63 | onValueChange { 64 | if (it !is Color) return@onValueChange 65 | colorState.set(it) 66 | onChange?.invoke(it) 67 | } 68 | 69 | if (locked) mouseClickListeners.clear() 70 | } 71 | } 72 | 73 | fun onChange(block: (Color) -> Unit) = apply { this.onChange = block } 74 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/gui/screen/editor/component/ThemeListItem.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.gui.screen.editor.component 2 | 3 | import dev.mediamod.gui.style.styled 4 | import dev.mediamod.gui.style.stylesheet 5 | import dev.mediamod.theme.Theme 6 | import dev.mediamod.utils.setColorAnimated 7 | import gg.essential.elementa.components.UIContainer 8 | import gg.essential.elementa.components.UIImage 9 | import gg.essential.elementa.components.UIText 10 | import gg.essential.elementa.constraints.CenterConstraint 11 | import gg.essential.elementa.constraints.ChildBasedMaxSizeConstraint 12 | import gg.essential.elementa.constraints.ColorConstraint 13 | import gg.essential.elementa.constraints.SiblingConstraint 14 | import gg.essential.elementa.dsl.* 15 | import gg.essential.elementa.state.BasicState 16 | import gg.essential.universal.ChatColor 17 | import gg.essential.universal.USound 18 | import java.awt.Color 19 | 20 | class ThemeListItem( 21 | val theme: Theme, 22 | private val selectedColor: ColorConstraint = Color.white.constraint, 23 | private val unselectedColor: ColorConstraint = Color.white.darker().constraint 24 | ) : UIContainer() { 25 | private var action: (Theme.() -> Unit)? = null 26 | private val textState = BasicState(theme.name) 27 | 28 | private val stylesheet = stylesheet { 29 | "this" { 30 | height = ChildBasedMaxSizeConstraint() 31 | } 32 | 33 | "lockImage" { 34 | y = CenterConstraint() - 0.5f.pixels() 35 | width = 8.pixels() 36 | height = 8.pixels() 37 | } 38 | 39 | "text" { 40 | x = SiblingConstraint(3f) 41 | y = CenterConstraint() 42 | color = unselectedColor 43 | } 44 | } 45 | 46 | private val lockImage by UIImage.ofResource("/assets/mediamod/textures/icon/lock.png") 47 | .styled(stylesheet["lockImage"]) 48 | .childOf(this) 49 | 50 | private val text by UIText() 51 | .bindText(textState) 52 | .styled(stylesheet["text"]) 53 | .childOf(this) 54 | 55 | init { 56 | styled(stylesheet["this"]) 57 | 58 | if (theme !is Theme.InbuiltTheme) { 59 | lockImage.hide() 60 | text.setX(11.pixels()) 61 | } 62 | 63 | onMouseClick { 64 | USound.playButtonPress() 65 | action?.invoke(theme) 66 | 67 | select() 68 | } 69 | } 70 | 71 | fun select() { 72 | textState.set("${ChatColor.BOLD}${theme.name}") 73 | text.setColorAnimated(selectedColor, 0.1f) 74 | } 75 | 76 | fun unselect() { 77 | textState.set(theme.name) 78 | text.setColorAnimated(unselectedColor, 0.1f) 79 | } 80 | 81 | fun onClick(block: Theme.() -> Unit) = apply { this.action = block } 82 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/gui/hud/RotatingTextComponent.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.gui.hud 2 | 3 | import dev.mediamod.config.Configuration 4 | import dev.mediamod.utils.setColorAnimated 5 | import gg.essential.elementa.components.UIContainer 6 | import gg.essential.elementa.components.UIText 7 | import gg.essential.elementa.constraints.ConstantColorConstraint 8 | import gg.essential.elementa.constraints.SiblingConstraint 9 | import gg.essential.elementa.dsl.childOf 10 | import gg.essential.elementa.dsl.constrain 11 | import gg.essential.elementa.dsl.percent 12 | import gg.essential.elementa.dsl.pixels 13 | import gg.essential.elementa.state.BasicState 14 | import gg.essential.elementa.state.pixels 15 | import gg.essential.universal.UGraphics 16 | 17 | class RotatingTextComponent( 18 | private val state: BasicState, 19 | shadow: Boolean = false 20 | ) : UIContainer() { 21 | private val firstXPosition = BasicState(0f) 22 | private val textPadding = 25f 23 | 24 | private val firstText = UIText(shadow = shadow) 25 | .constrain { 26 | x = firstXPosition.pixels() 27 | } childOf this 28 | 29 | private val secondText = UIText(shadow = shadow) 30 | .constrain { 31 | x = SiblingConstraint(textPadding) 32 | } childOf this 33 | 34 | init { 35 | constrain { 36 | width = 100.percent() 37 | height = firstText.getHeight().pixels() 38 | } 39 | 40 | firstText.bindText(state) 41 | secondText.bindText(state) 42 | } 43 | 44 | override fun animationFrame() { 45 | super.animationFrame() 46 | 47 | val text = state.get() 48 | val textWidth = UGraphics.getStringWidth(text) 49 | val parentWidth = parent.getWidth() 50 | val firstCurrentPosition = firstXPosition.get() 51 | 52 | if (textWidth < parentWidth) { 53 | // If the text's width doesn't exceed the parent, we don't need to rotate it around 54 | firstXPosition.set(0f) 55 | secondText.hide(true) 56 | return 57 | } else { 58 | // Time to start rotating 59 | secondText.unhide() 60 | } 61 | 62 | if (secondText.getLeft() <= parent.getLeft()) { 63 | firstXPosition.set(0f) 64 | return 65 | } 66 | 67 | firstXPosition.set(firstCurrentPosition - Configuration.textScrollSpeed) 68 | } 69 | 70 | internal fun changeColorAnimated(constraint: ConstantColorConstraint) = apply { 71 | firstText.setColorAnimated(constraint) 72 | secondText.setColorAnimated(constraint) 73 | } 74 | 75 | internal fun color(constraint: ConstantColorConstraint) = apply { 76 | firstText.setColor(constraint) 77 | secondText.setColor(constraint) 78 | } 79 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/manager/ThemeManager.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.manager 2 | 3 | import dev.mediamod.MediaMod 4 | import dev.mediamod.theme.Theme 5 | import dev.mediamod.theme.impl.ClassicTheme 6 | import dev.mediamod.theme.impl.DynamicTheme 7 | import dev.mediamod.utils.json 8 | import dev.mediamod.utils.logger 9 | import kotlinx.serialization.decodeFromString 10 | import kotlinx.serialization.encodeToString 11 | import java.awt.image.BufferedImage 12 | import java.io.File 13 | import kotlin.io.path.Path 14 | import kotlin.io.path.div 15 | import kotlin.io.path.writeText 16 | 17 | class ThemeManager { 18 | private val loadedThemesUpdateSubscribers = mutableSetOf<(List) -> Unit>() 19 | private val changeSubscribers = mutableSetOf Unit>() 20 | private val updateSubscribers = mutableSetOf Unit>() 21 | private val themesDirectory = File(MediaMod.dataDirectory, "themes") 22 | private val themeLocations = mutableMapOf() 23 | 24 | val loadedThemes = mutableListOf(DynamicTheme(), ClassicTheme()) 25 | var currentTheme: Theme = loadedThemes.first() 26 | set(value) { 27 | field = value 28 | emitChange() 29 | } 30 | 31 | fun init() { 32 | if (!themesDirectory.exists()) { 33 | themesDirectory.mkdirs() 34 | 35 | // The directory was just created, the chance of there being any themes available is extremely extremely extremely slim 36 | return 37 | } 38 | 39 | themesDirectory.walk().forEach { 40 | if (it.extension != "json") 41 | return@forEach 42 | 43 | val theme: Theme.LoadedTheme = json.decodeFromString(it.readText()) 44 | if (themeLocations[theme.name] != null) { 45 | logger.warn("Found theme with the same name as another from ${it.path}! Refusing to load it.") 46 | return 47 | } 48 | 49 | logger.info("Loaded theme: ${theme.name} from ${it.path}") 50 | 51 | themeLocations[theme.name] = it.name 52 | loadedThemes.add(theme) 53 | } 54 | } 55 | 56 | fun addTheme(theme: Theme) { 57 | loadedThemes.add(theme) 58 | emitLoadedThemesUpdate() 59 | } 60 | 61 | fun onLoadedThemesUpdate(callback: (List) -> Unit) = 62 | loadedThemesUpdateSubscribers.add(callback) 63 | 64 | fun emitLoadedThemesUpdate() = 65 | loadedThemesUpdateSubscribers.forEach { it.invoke(loadedThemes) } 66 | 67 | fun onChange(callback: Theme.() -> Unit) = 68 | changeSubscribers.add(callback) 69 | 70 | fun emitChange() = 71 | changeSubscribers.forEach { it.invoke(currentTheme) } 72 | 73 | fun onUpdate(callback: Theme.() -> Unit) = 74 | updateSubscribers.add(callback) 75 | 76 | fun emitUpdate() = 77 | updateSubscribers.forEach { it.invoke(currentTheme) } 78 | 79 | fun updateTheme(image: BufferedImage) { 80 | currentTheme.update(image) 81 | emitUpdate() 82 | } 83 | 84 | fun saveTheme(theme: Theme.LoadedTheme) { 85 | val fileName = themeLocations[theme.name] ?: return 86 | val path = Path(themesDirectory.absolutePath) / fileName 87 | path.writeText(json.encodeToString(theme)) 88 | } 89 | 90 | fun importTheme(theme: Theme.LoadedTheme) { 91 | val fileName = "${theme.name.trim().lowercase().replace("[\\\\/:*?\"<>|]", "_")}.json" 92 | themeLocations[theme.name] = fileName 93 | 94 | saveTheme(theme) 95 | addTheme(theme) 96 | } 97 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/service/impl/spotify/SpotifyService.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.service.impl.spotify 2 | 3 | import com.github.kittinunf.result.Result 4 | import dev.mediamod.config.Configuration 5 | import dev.mediamod.data.Track 6 | import dev.mediamod.data.api.mediamod.ErrorResponse 7 | import dev.mediamod.data.api.mediamod.SpotifyTokenResponse 8 | import dev.mediamod.service.Service 9 | import dev.mediamod.service.impl.spotify.api.SpotifyAPI 10 | import dev.mediamod.service.impl.spotify.callback.SpotifyCallbackManager 11 | import dev.mediamod.utils.logger 12 | import dev.mediamod.utils.spotifyClientID 13 | import gg.essential.universal.UDesktop 14 | import gg.essential.vigilance.Vigilant 15 | import java.net.URL 16 | import java.util.* 17 | 18 | class SpotifyService : Service() { 19 | private val api = SpotifyAPI(spotifyClientID) 20 | private val callbackManager = SpotifyCallbackManager() 21 | 22 | override val displayName = "Spotify" 23 | override val hasConfiguration = true 24 | 25 | override fun init() { 26 | callbackManager.init() 27 | callbackManager.onCallback { 28 | when (this) { 29 | is Result.Success -> when (val value = this.get()) { 30 | is SpotifyTokenResponse -> login(value) 31 | is ErrorResponse -> loginError(value.message) 32 | } 33 | is Result.Failure -> loginError(error.message ?: error.response.responseMessage) 34 | } 35 | } 36 | } 37 | 38 | private fun loginError(error: String) { 39 | logger.error("Failed to log in to spotify: ", error) 40 | } 41 | 42 | private fun login(response: SpotifyTokenResponse) { 43 | Configuration.spotifyAccessToken = response.accessToken 44 | Configuration.spotifyRefreshToken = response.refreshToken 45 | Configuration.markDirty() 46 | 47 | logger.info("Successfully logged in to Spotify!") 48 | } 49 | 50 | override suspend fun pollTrack(): Track? { 51 | if (Configuration.spotifyAccessToken.isEmpty()) 52 | return null 53 | 54 | val response = api.getCurrentTrack() ?: return null 55 | return response.item?.let { 56 | Track( 57 | name = it.name, 58 | artist = it.artists.joinToString(", ") { artist -> artist.name }, 59 | artwork = URL(it.album.images.first().url), 60 | elapsed = response.progressMs, 61 | duration = it.durationMS, 62 | paused = !response.isPlaying 63 | ) 64 | } 65 | } 66 | 67 | override fun Vigilant.CategoryPropertyBuilder.configuration() { 68 | subcategory("Authentication") { 69 | button( 70 | name = "Login", 71 | description = "This will open a new tab in your browser to authenticate with the Spotify API.", 72 | buttonText = "Login" 73 | ) { 74 | val uri = api.generateAuthorizationURI( 75 | scopes = "user-read-currently-playing user-read-playback-position", 76 | redirectURI = "http://localhost:9103/callback", 77 | state = UUID.randomUUID().toString() 78 | ) 79 | 80 | logger.info("Opening $uri") 81 | UDesktop.browse(uri) 82 | } 83 | 84 | text( 85 | field = Configuration::spotifyAccessToken, 86 | name = "accessToken", 87 | hidden = true 88 | ) 89 | 90 | text( 91 | field = Configuration::spotifyRefreshToken, 92 | name = "refreshToken", 93 | hidden = true 94 | ) 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/gui/hud/ProgressBarComponent.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.gui.hud 2 | 3 | import dev.mediamod.MediaMod 4 | import dev.mediamod.theme.Theme 5 | import dev.mediamod.utils.setColorAnimated 6 | import gg.essential.elementa.components.UIBlock 7 | import gg.essential.elementa.components.UIText 8 | import gg.essential.elementa.constraints.CenterConstraint 9 | import gg.essential.elementa.dsl.* 10 | import gg.essential.elementa.state.BasicState 11 | import gg.essential.universal.UMatrixStack 12 | import org.apache.commons.lang3.time.DurationFormatUtils 13 | import kotlin.math.min 14 | 15 | class ProgressBarComponent : UIBlock(MediaMod.themeManager.currentTheme.colors.progressBarBackground) { 16 | private val elapsedTextState = BasicState("0:00") 17 | private val durationTextState = BasicState("0:00") 18 | 19 | private var lastUpdate = 0L 20 | 21 | private val progressBlock = UIBlock(MediaMod.themeManager.currentTheme.colors.progressBar) 22 | .constrain { 23 | width = 1.pixels() 24 | height = 100.percent() 25 | } childOf this 26 | 27 | private val elapsedText = UIText("0:00", false) 28 | .constrain { 29 | x = 2.pixels() 30 | y = CenterConstraint() 31 | textScale = 0.7f.pixels() 32 | color = MediaMod.themeManager.currentTheme.colors.progressBarText.constraint 33 | } childOf this 34 | 35 | private val durationText = UIText("0:00", false) 36 | .constrain { 37 | x = 2.pixels(alignOpposite = true) 38 | y = CenterConstraint() 39 | textScale = 0.7f.pixels() 40 | color = MediaMod.themeManager.currentTheme.colors.progressBarText.constraint 41 | } childOf this 42 | 43 | init { 44 | elapsedText.bindText(elapsedTextState) 45 | durationText.bindText(durationTextState) 46 | 47 | MediaMod.serviceManager.currentTrack.onSetValue { 48 | it?.let { 49 | updateProgress(it.elapsed, it.duration) 50 | lastUpdate = System.currentTimeMillis() 51 | } ?: progressBlock.setWidth(0.pixels()) 52 | } 53 | 54 | MediaMod.themeManager.onUpdate(this::updateTheme) 55 | MediaMod.themeManager.onChange(this::updateTheme) 56 | 57 | MediaMod.serviceManager.currentTrack.get()?.let { 58 | updateProgress(it.elapsed, it.duration) 59 | lastUpdate = System.currentTimeMillis() 60 | } 61 | } 62 | 63 | override fun draw(matrixStack: UMatrixStack) { 64 | MediaMod.serviceManager.currentTrack.get()?.let { 65 | if (lastUpdate != 0L && !it.paused) { 66 | val change = System.currentTimeMillis() - lastUpdate 67 | val elapsed = it.elapsed + change 68 | updateProgress(min(elapsed, it.duration), it.duration) 69 | } 70 | } 71 | 72 | super.draw(matrixStack) 73 | } 74 | 75 | private fun updateTheme(theme: Theme) { 76 | setColorAnimated(theme.colors.progressBarBackground.constraint) 77 | progressBlock.setColorAnimated(theme.colors.progressBar.constraint) 78 | elapsedText.setColorAnimated(theme.colors.progressBarText.constraint) 79 | durationText.setColorAnimated(theme.colors.progressBarText.constraint) 80 | } 81 | 82 | private fun updateProgress(elapsed: Long, duration: Long) { 83 | val progress = elapsed / duration.toFloat() 84 | progressBlock.setWidth((progress * 100).percent()) 85 | 86 | updateProgressText(elapsed, duration) 87 | } 88 | 89 | private fun updateProgressText(elapsed: Long, duration: Long) { 90 | elapsedTextState.set(DurationFormatUtils.formatDuration(elapsed, "mm:ss")) 91 | durationTextState.set(DurationFormatUtils.formatDuration(duration, "mm:ss")) 92 | } 93 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/service/impl/spotify/api/SpotifyAPI.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("MemberVisibilityCanBePrivate") 2 | 3 | package dev.mediamod.service.impl.spotify.api 4 | 5 | import com.github.kittinunf.fuel.Fuel 6 | import com.github.kittinunf.fuel.core.extensions.authentication 7 | import com.github.kittinunf.fuel.coroutines.awaitStringResult 8 | import com.github.kittinunf.result.Result 9 | import dev.mediamod.MediaMod 10 | import dev.mediamod.config.Configuration 11 | import dev.mediamod.data.api.mediamod.ErrorResponse 12 | import dev.mediamod.data.api.mediamod.SpotifyTokenResponse 13 | import dev.mediamod.data.api.spotify.SpotifyCurrentTrackResponse 14 | import dev.mediamod.utils.json 15 | import dev.mediamod.utils.logger 16 | import gg.essential.universal.UMinecraft 17 | import gg.essential.universal.UScreen 18 | import kotlinx.serialization.decodeFromString 19 | import org.apache.http.client.utils.URIBuilder 20 | import java.net.URI 21 | 22 | class SpotifyAPI( 23 | private val clientID: String 24 | ) { 25 | private var warnedNotAvailable = false 26 | 27 | companion object { 28 | private const val authBaseURL = "accounts.spotify.com" 29 | private const val apiBaseURL = "api.spotify.com/v1" 30 | } 31 | 32 | fun generateAuthorizationURI(scopes: String, redirectURI: String, state: String): URI = 33 | URIBuilder().apply { 34 | scheme = "https" 35 | host = authBaseURL 36 | path = "/authorize" 37 | addParameter("response_type", "code") 38 | addParameter("client_id", clientID) 39 | addParameter("scope", scopes) 40 | addParameter("redirect_uri", redirectURI) 41 | addParameter("state", state) 42 | }.build() 43 | 44 | suspend fun getCurrentTrack(): SpotifyCurrentTrackResponse? { 45 | val accessToken = Configuration.spotifyAccessToken 46 | 47 | val result = Fuel 48 | .get("https://$apiBaseURL/me/player/currently-playing") 49 | .authentication() 50 | .bearer(accessToken) 51 | .awaitStringResult() 52 | 53 | return when (result) { 54 | is Result.Success -> json.decodeFromString(result.get()) 55 | is Result.Failure -> { 56 | refreshAccessToken(Configuration.spotifyRefreshToken) 57 | null 58 | } 59 | } 60 | } 61 | 62 | fun refreshAccessToken(refreshToken: String) = 63 | when (val result = MediaMod.apiManager.refreshAccessToken(refreshToken)) { 64 | is Result.Success -> { 65 | when (val response = result.get()) { 66 | is ErrorResponse -> logger.error("Error occurred when refreshing access token: ${response.message}") 67 | is SpotifyTokenResponse -> { 68 | Configuration.spotifyAccessToken = response.accessToken 69 | Configuration.spotifyRefreshToken = response.refreshToken 70 | Configuration.markDirty() 71 | 72 | logger.info("Successfully refreshed access token!") 73 | } 74 | else -> logger.error("Error occurred when refreshing access token: ${result.get()}") 75 | } 76 | } 77 | is Result.Failure -> run { 78 | if (!warnedNotAvailable) { 79 | logger.error("Error occurred when refreshing access token! (API not accessible)") 80 | //#if MC>=11801 81 | if (UScreen.currentScreen == null || UMinecraft.getMinecraft().overlay != null) return 82 | //#else 83 | //$$ if (UScreen.currentScreen == null) return 84 | //#endif 85 | MediaMod.notificationManager.showNotification("MediaMod", "API features may be limited.") 86 | warnedNotAvailable = true 87 | } 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/websocket/ExtensionSocket.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.websocket 2 | 3 | import dev.mediamod.utils.json 4 | import dev.mediamod.utils.logger 5 | import dev.mediamod.websocket.message.impl.incoming.IncomingSocketMessage 6 | import dev.mediamod.websocket.message.impl.incoming.impl.IncomingHandshakeMessage 7 | import dev.mediamod.websocket.message.impl.incoming.impl.IncomingHeartbeatMessage 8 | import dev.mediamod.websocket.message.impl.outgoing.OutgoingSocketMessage 9 | import dev.mediamod.websocket.message.impl.outgoing.impl.OutgoingHandshakeMessage 10 | import dev.mediamod.websocket.message.impl.outgoing.impl.OutgoingHeartbeatMessage 11 | import kotlinx.coroutines.flow.MutableSharedFlow 12 | import kotlinx.coroutines.runBlocking 13 | import kotlinx.serialization.decodeFromString 14 | import kotlinx.serialization.encodeToString 15 | import kotlinx.serialization.json.Json 16 | import org.java_websocket.WebSocket 17 | import org.java_websocket.handshake.ClientHandshake 18 | import org.java_websocket.server.WebSocketServer 19 | import java.net.InetSocketAddress 20 | import java.util.* 21 | import kotlin.concurrent.fixedRateTimer 22 | 23 | class ExtensionSocket( 24 | host: String, 25 | port: Int, 26 | private val messageFlow: MutableSharedFlow 27 | ) : WebSocketServer(InetSocketAddress(host, port)) { 28 | private var token: String? = null 29 | private var lastHeartbeat: Long = 0 30 | 31 | companion object { 32 | private val strictJson = Json { encodeDefaults = true } 33 | } 34 | 35 | init { 36 | fixedRateTimer("ExtensionSocket - Heartbeat", false, 0, 5000) { 37 | if (connections.isEmpty() || token == null) return@fixedRateTimer 38 | 39 | val difference = System.currentTimeMillis() - lastHeartbeat 40 | if (difference >= 3000) { 41 | logger.warn("Haven't received a heartbeat from the extension in 3 seconds! Invalidating all connections.") 42 | 43 | connections.forEach { it.close(1000, "No heartbeat") } 44 | token = null 45 | } 46 | } 47 | } 48 | 49 | override fun onStart() { 50 | logger.info("Extension websocket server started on localhost:9104!") 51 | } 52 | 53 | override fun onOpen(socket: WebSocket, handshake: ClientHandshake) { 54 | logger.info("Connection opened!") 55 | } 56 | 57 | override fun onClose(socket: WebSocket, code: Int, reason: String, remote: Boolean) { 58 | // Clear the current connection 59 | logger.warn("Extension connection closed") 60 | token = null 61 | } 62 | 63 | override fun onError(socket: WebSocket?, ex: Exception) { 64 | // Depending on the error, we may need to close the connection 65 | logger.error("Extension socket encountered an error: ", ex) 66 | } 67 | 68 | override fun onMessage(socket: WebSocket, data: String) { 69 | val message = runCatching { json.decodeFromString(data) }.getOrNull() 70 | ?: return logger.error("Failed to parse message from websocket: $data") 71 | 72 | if (token != message.token) { 73 | logger.error("Received mismatched token!") 74 | return 75 | } 76 | 77 | when (message) { 78 | is IncomingHandshakeMessage -> { 79 | UUID.randomUUID().toString().let { 80 | token = it 81 | sendMessage(OutgoingHandshakeMessage(it)) 82 | } 83 | } 84 | 85 | is IncomingHeartbeatMessage -> { 86 | lastHeartbeat = System.currentTimeMillis() 87 | sendMessage(OutgoingHeartbeatMessage()) 88 | } 89 | } 90 | 91 | runBlocking { messageFlow.emit(message) } 92 | } 93 | 94 | internal fun sendMessage(packet: OutgoingSocketMessage) { 95 | val string = strictJson.encodeToString(packet) 96 | broadcast(string) 97 | } 98 | } -------------------------------------------------------------------------------- /src/main/resources/assets/mediamod/success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MediaMod | Success! 8 | 109 | 110 | 111 | 146 | 147 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/gui/hud/PlayerComponent.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.gui.hud 2 | 3 | import dev.mediamod.MediaMod 4 | import dev.mediamod.config.Configuration 5 | import dev.mediamod.data.Track 6 | import dev.mediamod.theme.Theme 7 | import dev.mediamod.utils.setColorAnimated 8 | import gg.essential.elementa.components.UIBlock 9 | import gg.essential.elementa.components.UIContainer 10 | import gg.essential.elementa.components.UIImage 11 | import gg.essential.elementa.constraints.CenterConstraint 12 | import gg.essential.elementa.constraints.ChildBasedSizeConstraint 13 | import gg.essential.elementa.constraints.FillConstraint 14 | import gg.essential.elementa.constraints.SiblingConstraint 15 | import gg.essential.elementa.dsl.* 16 | import gg.essential.elementa.effects.ScissorEffect 17 | import gg.essential.elementa.state.BasicState 18 | import java.awt.image.BufferedImage 19 | import java.net.URL 20 | import java.util.concurrent.CompletableFuture 21 | 22 | class PlayerComponent : UIBlock(MediaMod.themeManager.currentTheme.colors.background.constraint) { 23 | companion object { 24 | private val cache = HashMap() 25 | } 26 | 27 | private var previousTrack: Track? = null 28 | private val trackNameState = BasicState("Unknown track") 29 | private val artistNameState = BasicState("Unknown artist") 30 | 31 | private val imageContainer = UIContainer().constrain { 32 | x = 5.pixels() 33 | y = CenterConstraint() 34 | 35 | width = 40.pixels() 36 | height = 40.pixels() 37 | } childOf this 38 | 39 | private val textContainer = UIContainer().constrain { 40 | x = SiblingConstraint(5f) 41 | y = CenterConstraint() 42 | 43 | width = FillConstraint(false) 44 | height = ChildBasedSizeConstraint() 45 | } childOf this effect ScissorEffect() 46 | 47 | private val trackText = RotatingTextComponent(trackNameState) 48 | .color(MediaMod.themeManager.currentTheme.colors.text.constraint) 49 | .childOf(textContainer) 50 | 51 | private val artistText = RotatingTextComponent(artistNameState) 52 | .constrain { 53 | y = SiblingConstraint(3f) 54 | } 55 | .color(MediaMod.themeManager.currentTheme.colors.text.darker().constraint) 56 | .childOf(textContainer) 57 | 58 | private var image = UIImage.ofResource("") 59 | 60 | init { 61 | ProgressBarComponent() 62 | .constrain { 63 | y = SiblingConstraint(5f) 64 | 65 | width = 100.percent() - 5.pixels() 66 | height = 8.pixels() 67 | } childOf textContainer 68 | 69 | MediaMod.serviceManager.currentTrack.onSetValue { 70 | it?.let { updateInformation(it) } 71 | } 72 | 73 | MediaMod.themeManager.onChange { 74 | MediaMod.serviceManager.currentTrack.get()?.let { 75 | updateInformation(it, true) 76 | } 77 | 78 | updateTheme(this) 79 | } 80 | 81 | Configuration.listen(Configuration::playerFirstFormatString) { 82 | previousTrack?.let { trackNameState.set(this.formatTrack(it)) } 83 | } 84 | 85 | Configuration.listen(Configuration::playerSecondFormatString) { 86 | previousTrack?.let { artistNameState.set(this.formatTrack(it)) } 87 | } 88 | 89 | MediaMod.themeManager.onUpdate(this::updateTheme) 90 | MediaMod.serviceManager.currentTrack.get()?.let { updateInformation(it) } 91 | } 92 | 93 | private fun updateInformation(track: Track, forceUpdate: Boolean = false) { 94 | trackNameState.set(Configuration.playerFirstFormatString.formatTrack(track)) 95 | artistNameState.set(Configuration.playerSecondFormatString.formatTrack(track)) 96 | 97 | if (forceUpdate || previousTrack?.artwork != track.artwork) { 98 | imageContainer.clearChildren() 99 | 100 | image = UIImage(fetchImage(track.artwork)) 101 | .constrain { 102 | width = 100.percent() 103 | height = 100.percent() 104 | } childOf imageContainer 105 | } 106 | 107 | previousTrack = track 108 | } 109 | 110 | private fun fetchImage(artwork: URL) = CompletableFuture.supplyAsync { 111 | val image = cache[artwork] ?: UIImage.get(artwork) 112 | with(image) { 113 | MediaMod.themeManager.updateTheme(this) 114 | cache.computeIfAbsent(artwork) { 115 | // TODO: Make this configurable 116 | if (cache.size >= 20) cache.clear() 117 | this 118 | } 119 | } 120 | } 121 | 122 | private fun updateTheme(theme: Theme) = 123 | theme.apply { 124 | setColorAnimated(colors.background.constraint) 125 | trackText.changeColorAnimated(colors.text.constraint) 126 | artistText.changeColorAnimated(colors.text.darker().constraint) 127 | } 128 | 129 | private fun String.formatTrack(track: Track) = 130 | replace("[track]", track.name).replace("[artist]", track.artist) 131 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/theme/impl/DynamicTheme.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.theme.impl 2 | 3 | import dev.mediamod.theme.Colors 4 | import dev.mediamod.theme.Theme 5 | import dev.mediamod.utils.ColorQuantizer 6 | import dev.mediamod.utils.logger 7 | import java.awt.Color 8 | import java.awt.image.BufferedImage 9 | import kotlin.math.abs 10 | import kotlin.math.pow 11 | 12 | class DynamicTheme : Theme.InbuiltTheme("Dynamic") { 13 | override var colors = classicColors 14 | private set 15 | 16 | override fun update(image: BufferedImage) { 17 | try { 18 | val quantizedColors = ColorQuantizer.quantize(image) 19 | 20 | // Use the most common color as the background 21 | val background = quantizedColors[0].color 22 | 23 | val progressBar = if (quantizedColors.size >= 2) { 24 | // Of the rest of the colors with more than 10% of the pixels, find the one with the most contrast 25 | // against the background 26 | quantizedColors.subList(1, quantizedColors.size - 1) 27 | .filter { it.fraction > 0.10 } 28 | .maxByOrNull { it.color.contrast(background) } 29 | ?.color 30 | // If none of remaining colors have more than 10% of the pixels, use the 2nd most common color 31 | ?: quantizedColors[1].color 32 | } else { 33 | // Very rare case if image is all one color 34 | background.changeBy(0.2f) 35 | } 36 | 37 | // Create lighter/darker variants of the progress bar color for background and text 38 | // The change parameter can be tuned to get different results 39 | val progressBarBackground = progressBar.changeBy(0.15f) 40 | val progressBarText = progressBar.changeBy(0.35f) 41 | 42 | // Use black text if the background is bright 43 | val text = if (background.luminance > 0.5) { 44 | Color.BLACK 45 | } else { 46 | Color.WHITE 47 | } 48 | 49 | colors = Colors( 50 | background = background, 51 | text = text, 52 | progressBar = progressBar, 53 | progressBarBackground = progressBarBackground, 54 | progressBarText = progressBarText 55 | ) 56 | } catch (e: Exception) { 57 | logger.error("An error occurred when doing color quantization: ", e) 58 | return 59 | } 60 | } 61 | 62 | private val Color.luminance: Float 63 | get() = (0.2126f * (red / 255.0).pow(2.2) + 64 | 0.7152f * (green / 255.0).pow(2.2) + 65 | 0.0722f * (blue / 255.0).pow(2.2)).toFloat() 66 | 67 | private fun Color.contrast(other: Color): Float { 68 | var l1 = luminance 69 | var l2 = other.luminance 70 | if (l2 > l1) { 71 | val old = l1 72 | l1 = l2 73 | l2 = old 74 | } 75 | return (l1 + 0.05f) / (l2 + 0.05f) 76 | } 77 | 78 | private fun Color.changeBy(change: Float): Color { 79 | val hsl = toHSL() 80 | if (hsl[2] > 0.5) { 81 | hsl[2] -= change 82 | } else { 83 | hsl[2] += change 84 | } 85 | return toRGB(hsl) 86 | } 87 | 88 | // https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB 89 | private fun Color.toHSL(): FloatArray { 90 | val r = red / 255f 91 | val g = green / 255f 92 | val b = blue / 255f 93 | val v = maxOf(r, g, b) 94 | val min = minOf(r, g, b) 95 | val l = (v + min) / 2f 96 | val c = v - min 97 | 98 | var h: Float 99 | if (c == 0f) { 100 | h = 0f 101 | } else if (v == r) { 102 | h = (g - b) / c 103 | if (h < 0) h += 6f 104 | } else if (v == g) { 105 | h = 2 + (b - r) / c 106 | } else { 107 | h = 4 + (r - g) / c 108 | } 109 | h /= 6f 110 | 111 | val s = if (l == 0f || l == 1f) { 112 | 0f 113 | } else { 114 | (v - l) / minOf(l, 1 - l) 115 | } 116 | 117 | val hsl = FloatArray(3) 118 | hsl[0] = h 119 | hsl[1] = s 120 | hsl[2] = l 121 | return hsl 122 | } 123 | 124 | // https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB 125 | private fun toRGB(hsl: FloatArray): Color { 126 | val h = hsl[0] * 6f 127 | val c = (1 - abs(2 * hsl[2] - 1)) * hsl[1] 128 | val x = c * (1 - abs(h % 2 - 1)) 129 | var r = 0f 130 | var g = 0f 131 | var b = 0f 132 | 133 | if (h < 1) { 134 | r = c 135 | g = x 136 | } else if (h < 2) { 137 | r = x 138 | g = c 139 | } else if (h < 3) { 140 | g = c 141 | b = x 142 | } else if (h < 4) { 143 | g = x 144 | b = c 145 | } else if (h < 5) { 146 | r = x 147 | b = c 148 | } else { 149 | r = c 150 | b = x 151 | } 152 | 153 | val m = hsl[2] - c / 2 154 | return Color(r + m, g + m, b + m) 155 | } 156 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/gui/screen/RepositionScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.gui.screen 2 | 3 | import dev.mediamod.config.Configuration 4 | import dev.mediamod.gui.ColorPalette 5 | import dev.mediamod.gui.component.UIButton 6 | import dev.mediamod.gui.hud.PlayerComponent 7 | import dev.mediamod.gui.style.styled 8 | import dev.mediamod.gui.style.stylesheet 9 | import gg.essential.elementa.ElementaVersion 10 | import gg.essential.elementa.WindowScreen 11 | import gg.essential.elementa.components.UIBlock 12 | import gg.essential.elementa.components.UIContainer 13 | import gg.essential.elementa.components.UIText 14 | import gg.essential.elementa.constraints.CenterConstraint 15 | import gg.essential.elementa.constraints.ChildBasedMaxSizeConstraint 16 | import gg.essential.elementa.constraints.ChildBasedSizeConstraint 17 | import gg.essential.elementa.constraints.SiblingConstraint 18 | import gg.essential.elementa.dsl.* 19 | import gg.essential.elementa.state.BasicState 20 | import gg.essential.elementa.state.pixels 21 | import gg.essential.elementa.utils.withAlpha 22 | import java.awt.Color 23 | 24 | class RepositionScreen : WindowScreen( 25 | version = ElementaVersion.V1, 26 | restoreCurrentGuiOnClose = true 27 | ) { 28 | private val stylesheet = stylesheet { 29 | "container" { 30 | width = 100.percent() 31 | height = 100.percent() 32 | } 33 | 34 | "textContainer" { 35 | y = CenterConstraint() 36 | width = 100.percent() 37 | height = ChildBasedMaxSizeConstraint() 38 | } 39 | 40 | "title" { 41 | x = CenterConstraint() 42 | textScale = 1.5f.pixels() 43 | } 44 | 45 | "subtitle" { 46 | x = CenterConstraint() 47 | y = SiblingConstraint(5f) 48 | 49 | color = Color.gray.toConstraint() 50 | textScale = 0.75f.pixels() 51 | } 52 | 53 | "player" { 54 | x = xState.pixels() 55 | y = yState.pixels() 56 | 57 | width = 150.pixels() 58 | height = 50.pixels() 59 | } 60 | 61 | "closeButton" { 62 | x = CenterConstraint() 63 | y = 15.pixels(true) 64 | width = ChildBasedSizeConstraint() + 50.pixels() 65 | height = 25.pixels() 66 | color = ColorPalette.secondaryBackground.brighter().constraint 67 | } 68 | } 69 | 70 | private val xState = BasicState(Configuration.playerX) 71 | private val yState = BasicState(Configuration.playerY) 72 | private val container by UIBlock(ColorPalette.background.withAlpha(0.8f)) 73 | .styled(stylesheet["container"]) 74 | .childOf(window) 75 | 76 | init { 77 | xState.onSetValue { Configuration.playerX = it } 78 | yState.onSetValue { Configuration.playerY = it } 79 | 80 | val textContainer = UIContainer() 81 | .styled(stylesheet["textContainer"]) 82 | .childOf(container) 83 | 84 | UIText("Reposition Player") 85 | .styled(stylesheet["title"]) 86 | .childOf(textContainer) 87 | 88 | UIText("Drag the player around to change its position. Double click it to reset.") 89 | .styled(stylesheet["subtitle"]) 90 | .childOf(textContainer) 91 | 92 | var dragOffset = 0f to 0f 93 | var isDragging = false 94 | PlayerComponent() 95 | .styled(stylesheet["player"]) 96 | .onMouseClick { event -> 97 | if (event.clickCount == 2) { 98 | // Reset on double click 99 | xState.set(5f) 100 | yState.set(5f) 101 | 102 | return@onMouseClick 103 | } 104 | 105 | isDragging = true 106 | dragOffset = event.absoluteX to event.absoluteY 107 | } 108 | .onMouseRelease { 109 | isDragging = false 110 | } 111 | .onMouseDrag { mouseX, mouseY, mouseButton -> 112 | if (mouseButton != 0 || !isDragging) 113 | return@onMouseDrag 114 | 115 | val absoluteX = mouseX + getLeft() 116 | val absoluteY = mouseY + getTop() 117 | val deltaX = absoluteX - dragOffset.first 118 | val deltaY = absoluteY - dragOffset.second 119 | 120 | dragOffset = absoluteX to absoluteY 121 | 122 | val newX = getLeft() + deltaX 123 | val newY = getTop() + deltaY 124 | 125 | if (newX >= 0 && newX <= (this@RepositionScreen.window.getWidth() - this.getWidth())) { 126 | xState.set(newX) 127 | } 128 | 129 | if (newY >= 0 && newY <= (this@RepositionScreen.window.getHeight() - this.getHeight())) { 130 | yState.set(newY) 131 | } 132 | } childOf container 133 | 134 | UIButton("Close", Color.white) 135 | .styled(stylesheet["closeButton"]) 136 | .onClick { 137 | restorePreviousScreen() 138 | } childOf container 139 | } 140 | 141 | override fun onScreenClose() { 142 | super.onScreenClose() 143 | Configuration.markDirty() 144 | } 145 | } -------------------------------------------------------------------------------- /versions/1.12.2-1.8.9.txt: -------------------------------------------------------------------------------- 1 | net.minecraft.util.text.event.ClickEvent net.minecraft.event.ClickEvent 2 | net.minecraft.util.text.event.HoverEvent net.minecraft.event.HoverEvent 3 | net.minecraft.util.text.ITextComponent getStyle() net.minecraft.util.IChatComponent getChatStyle() 4 | net.minecraft.util.text.Style setClickEvent() net.minecraft.util.ChatStyle setChatClickEvent() 5 | net.minecraft.util.text.translation.I18n translateToLocal() net.minecraft.util.StatCollector translateToLocal() 6 | net.minecraft.client.entity.EntityPlayerSP sendMessage() net.minecraft.client.entity.EntityPlayerSP addChatComponentMessage() 7 | net.minecraft.client.renderer.entity.layers.LayerArmorBase renderEnchantedGlint() net.minecraft.client.renderer.entity.layers.LayerArmorBase renderGlint() 8 | net.minecraft.client.renderer.entity.RenderEntityItem getModelCount() net.minecraft.client.renderer.entity.RenderEntityItem func_177078_a() 9 | net.minecraft.client.gui.advancements.GuiAdvancement func_191817_b() net.minecraft.client.gui.achievement.GuiAchievement updateAchievementWindow() 10 | net.minecraft.client.multiplayer.ServerList set() net.minecraft.client.multiplayer.ServerList func_147413_a() 11 | net.minecraft.world.World getCollisionBoxes() net.minecraft.world.World getCollidingBoundingBoxes() 12 | net.minecraft.server.MinecraftServer applyServerIconToResponse() net.minecraft.server.MinecraftServer addFaviconToStatusResponse() 13 | net.minecraft.client.renderer.chunk.VisGraph floodFill() net.minecraft.client.renderer.chunk.VisGraph func_178604_a() 14 | net.minecraft.client.renderer.chunk.VisGraph addEdges() net.minecraft.client.renderer.chunk.VisGraph func_178610_a() 15 | 16 | net.minecraft.init.MobEffects POISON net.minecraft.potion.Potion poison 17 | net.minecraft.init.MobEffects WITHER net.minecraft.potion.Potion wither 18 | net.minecraft.client.gui.GuiScreenServerList lastScreen net.minecraft.client.gui.GuiScreenServerList field_146303_a 19 | 20 | net.minecraft.init.MobEffects net.minecraft.potion.Potion 21 | net.minecraft.util.BlockRenderLayer net/minecraft/util/EnumWorldBlockLayer 22 | net.minecraft.util.math.BlockPos net.minecraft.util.BlockPos 23 | net.minecraft.util.math.AxisAlignedBB net.minecraft.util.AxisAlignedBB 24 | net.minecraft.util.math.MathHelper net.minecraft.util.MathHelper 25 | net.minecraft.util.math.Vec3d net.minecraft.util.Vec3 26 | net.minecraft.util.math.Vec3i net.minecraft.util.Vec3i 27 | net.minecraft.util.math.RayTraceResult net.minecraft.util.MovingObjectPosition 28 | net.minecraft.util.math.RayTraceResult$Type net.minecraft.util.MovingObjectPosition$MovingObjectType 29 | net.minecraft.util.text.Style net.minecraft.util.ChatStyle 30 | net.minecraft.util.text.Style net.minecraft.util.ChatStyle 31 | net.minecraft.util.text.TextComponentBase net.minecraft.util.ChatComponentStyle 32 | net.minecraft.util.text.TextComponentString net.minecraft.util.ChatComponentText 33 | net.minecraft.util.text.translation.I18n net.minecraft.util.StatCollector 34 | net.minecraft.network.play.client.CPacketChatMessage net.minecraft.network.play.client.C01PacketChatMessage 35 | net.minecraft.network.play.client.CPacketCustomPayload net.minecraft.network.play.client.C17PacketCustomPayload 36 | net.minecraft.network.play.server.SPacketChat net.minecraft.network.play.server.S02PacketChat 37 | net.minecraft.network.play.server.SPacketEntity net.minecraft.network.play.server.S14PacketEntity 38 | net.minecraft.network.play.server.SPacketEntityHeadLook net.minecraft.network.play.server.S19PacketEntityHeadLook 39 | net.minecraft.network.play.server.SPacketEntityStatus net.minecraft.network.play.server.S19PacketEntityStatus 40 | net.minecraft.network.play.server.SPacketCloseWindow net.minecraft.network.play.server.S2EPacketCloseWindow 41 | net.minecraft.network.play.server.SPacketCustomPayload net.minecraft.network.play.server.S3FPacketCustomPayload 42 | net.minecraft.network.status.client.CPacketServerQuery net.minecraft.network.status.client.C00PacketServerQuery 43 | net.minecraft.network.status.server.SPacketServerInfo net.minecraft.network.status.server.S00PacketServerInfo 44 | net.minecraft.network.status.server.SPacketPong net.minecraft.network.status.server.S01PacketPong 45 | net.minecraft.util.text.ITextComponent net.minecraft.util.IChatComponent 46 | net.minecraft.client.audio.SoundEventAccessor net.minecraft.client.audio.SoundEventAccessorComposite 47 | net.minecraft.client.renderer.BufferBuilder net.minecraft.client.renderer.WorldRenderer 48 | net.minecraft.client.renderer.entity.RenderLivingBase net.minecraft.client.renderer.entity.RendererLivingEntity 49 | net.minecraft.client.renderer.entity.RenderArmorStand net.minecraft.client.renderer.entity.ArmorStandRenderer 50 | net.minecraft.client.renderer.entity.RenderItemFrame net.minecraft.client.renderer.tileentity.RenderItemFrame 51 | net.minecraft.client.renderer.block.model.IBakedModel net.minecraft.client.resources.model.IBakedModel 52 | net.minecraft.client.renderer.block.model.ModelManager net.minecraft.client.resources.model.ModelManager 53 | net.minecraft.client.renderer.RenderItem net.minecraft.client.renderer.entity.RenderItem 54 | net.minecraft.client.particle.ParticleManager net.minecraft.client.particle.EffectRenderer 55 | net.minecraft.client.particle.Particle net.minecraft.client.particle.EntityFX 56 | net.minecraft.client.gui.advancements.GuiAdvancement net.minecraft.client.gui.achievement.GuiAchievement 57 | net.minecraft.client.gui.GuiWorldSelection net.minecraft.client.gui.GuiSelectWorld 58 | net.minecraft.pathfinding.NodeProcessor net.minecraft.world.pathfinder.NodeProcessor -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/gui/screen/editor/component/ThemeEditorContainer.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.gui.screen.editor.component 2 | 3 | import com.github.kittinunf.result.getOrNull 4 | import dev.mediamod.MediaMod 5 | import dev.mediamod.data.api.mediamod.PublishThemeResponse 6 | import dev.mediamod.gui.ColorPalette 7 | import dev.mediamod.gui.component.UIButton 8 | import dev.mediamod.gui.style.styled 9 | import dev.mediamod.gui.style.stylesheet 10 | import dev.mediamod.theme.Colors 11 | import dev.mediamod.theme.Theme 12 | import gg.essential.elementa.components.ScrollComponent 13 | import gg.essential.elementa.components.UIBlock 14 | import gg.essential.elementa.components.UIContainer 15 | import gg.essential.elementa.components.UIText 16 | import gg.essential.elementa.constraints.ChildBasedSizeConstraint 17 | import gg.essential.elementa.constraints.SiblingConstraint 18 | import gg.essential.elementa.dsl.* 19 | import gg.essential.elementa.state.BasicState 20 | import gg.essential.universal.UDesktop 21 | import java.awt.Color 22 | import kotlin.concurrent.thread 23 | import kotlin.reflect.KMutableProperty 24 | 25 | @Suppress("unused") 26 | class ThemeEditorContainer : UIContainer() { 27 | val theme = BasicState(null) 28 | 29 | private val stylesheet = stylesheet { 30 | "this" { 31 | x = 15.pixels() 32 | y = 15.pixels() 33 | width = 100.percent() - 30.pixels() 34 | height = 100.percent() - 30.pixels() 35 | } 36 | 37 | "colorsContainer" { 38 | y = SiblingConstraint(7.5f) 39 | width = 100.percent() 40 | height = 100.percent() - 50.pixels() 41 | } 42 | 43 | "scrollBar" { 44 | x = (-7.5f).pixels(true) 45 | y = 5.pixels() 46 | width = 3.pixels() 47 | height = 90.percent() 48 | } 49 | 50 | "publishButton" { 51 | x = 0.pixels(true) 52 | y = 0.pixels(true) 53 | width = ChildBasedSizeConstraint() + 50.pixels() 54 | height = 25.pixels() 55 | color = ColorPalette.secondaryBackground.brighter().constraint 56 | } 57 | } 58 | 59 | private val themeNameState = BasicState("") 60 | private val themeNameText = UIText() 61 | .bindText(themeNameState) 62 | .constrain { 63 | textScale = 1.5f.pixels() 64 | } childOf this 65 | 66 | private val colorsContainer by ScrollComponent() 67 | .styled(stylesheet["colorsContainer"]) 68 | .childOf(this) 69 | private val colorsContainerScrollbar by UIBlock(ColorPalette.secondaryBackground.brighter().brighter()) 70 | .styled(stylesheet["scrollBar"]) 71 | .childOf(this) 72 | 73 | init { 74 | colorsContainer.setVerticalScrollBarComponent(colorsContainerScrollbar, hideWhenUseless = true) 75 | styled(stylesheet["this"]) 76 | 77 | val publish = UIButton("Publish", Color.white) 78 | .styled(stylesheet["publishButton"]) 79 | .onClick { 80 | theme.get()?.let { 81 | if (it !is Theme.LoadedTheme) return@let 82 | thread(true) { publishTheme(it) } 83 | } 84 | } childOf this 85 | 86 | if (theme.get() !is Theme.LoadedTheme) 87 | publish.hide(true) 88 | 89 | theme.onSetValue { 90 | it?.let { 91 | themeNameState.set(it.name) 92 | loadColors(it.colors) 93 | } ?: run { 94 | themeNameState.set("") 95 | colorsContainer.clearChildren() 96 | } 97 | 98 | if (theme.get() !is Theme.LoadedTheme) { 99 | publish.hide(true) 100 | } else { 101 | publish.unhide() 102 | } 103 | } 104 | } 105 | 106 | private fun publishTheme(it: Theme.LoadedTheme) { 107 | MediaMod.notificationManager.showNotification("MediaMod", "Publishing theme...") 108 | 109 | val response = MediaMod.apiManager.publishTheme(it).getOrNull() 110 | ?: return MediaMod.notificationManager.showNotification("MediaMod", "Failed to publish theme!") 111 | 112 | if (response is PublishThemeResponse) { 113 | UDesktop.setClipboardString("https://themes.mediamod.dev/${response.themeID}") 114 | MediaMod.notificationManager.showNotification( 115 | "Theme published!", 116 | "URL copied to clipboard." 117 | ) 118 | } else { 119 | MediaMod.notificationManager.showNotification("MediaMod", "Failed to publish theme!") 120 | } 121 | } 122 | 123 | private fun loadColors(colors: Colors) { 124 | colorsContainer.clearChildren() 125 | 126 | fun complete() = theme.get()?.let { 127 | if (it !is Theme.LoadedTheme) return@let 128 | it.colors = colors 129 | 130 | theme.set(it) 131 | } 132 | 133 | colorComponent(colors::background, "Background", ::complete) 134 | colorComponent(colors::progressBar, "Progress Bar", ::complete) 135 | colorComponent(colors::progressBarBackground, "Progress Bar Background", ::complete) 136 | colorComponent(colors::progressBarText, "Progress Bar Text", ::complete) 137 | colorComponent(colors::text, "Text", ::complete) 138 | } 139 | 140 | private fun colorComponent(color: KMutableProperty, name: String, block: () -> Unit) { 141 | ThemeColorComponent(color.getter.call(), name, theme.get() is Theme.InbuiltTheme) 142 | .constrain { 143 | y = SiblingConstraint(5f) 144 | }.onChange { 145 | color.setter.call(it) 146 | block() 147 | } childOf colorsContainer 148 | } 149 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/config/Configuration.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.config 2 | 3 | import dev.mediamod.MediaMod 4 | import dev.mediamod.gui.screen.RepositionScreen 5 | import dev.mediamod.gui.screen.editor.ThemeEditorScreen 6 | import gg.essential.universal.UScreen 7 | import gg.essential.vigilance.Vigilant 8 | import java.io.File 9 | import kotlin.properties.Delegates 10 | import kotlin.reflect.KProperty 11 | 12 | @Suppress("ObjectPropertyName") 13 | object Configuration : Vigilant(File("./config/mediamod.toml"), "MediaMod") { 14 | private val updateListeners = mutableMapOf, MutableList<(Any.() -> Unit)>>() 15 | 16 | private var _selectedTheme = 0 17 | set(value) { 18 | field = value 19 | selectedTheme = MediaMod.themeManager.loadedThemes[value].name 20 | } 21 | 22 | var selectedTheme = "Dynamic" 23 | set(value) { 24 | field = value 25 | 26 | val manager = MediaMod.themeManager 27 | manager.currentTheme = manager.loadedThemes.firstOrNull { it.name == value } ?: manager.loadedThemes.first() 28 | manager.emitChange() 29 | } 30 | 31 | var spotifyAccessToken = "" 32 | var spotifyRefreshToken = "" 33 | 34 | var playerFirstFormatString by observable("[track]") 35 | var playerSecondFormatString by observable("by [artist]") 36 | 37 | var playerX = 5f 38 | var playerY = 5f 39 | 40 | var textScrollSpeed = 0.09f 41 | 42 | var trackNotifications = false 43 | 44 | private var _preferredService = 0 45 | set(value) { 46 | field = value 47 | preferredService = MediaMod.serviceManager.services[value].displayName 48 | } 49 | 50 | var preferredService = "" 51 | 52 | init { 53 | category("General") { 54 | subcategory("Appearance") { 55 | button( 56 | "Reposition Player", 57 | "Change the position of the MediaMod Player", 58 | "Open" 59 | ) { 60 | UScreen.displayScreen(RepositionScreen()) 61 | } 62 | 63 | selector( 64 | ::_selectedTheme, 65 | "Theme", 66 | "Change the appearance of the MediaMod Player", 67 | MediaMod.themeManager.loadedThemes.map { it.name } 68 | ) 69 | 70 | button( 71 | "Theme Editor", 72 | "Create or edit themes for the MediaMod Player", 73 | "Open" 74 | ) { 75 | UScreen.displayScreen(ThemeEditorScreen()) 76 | } 77 | 78 | decimalSlider(::playerX, "Player X", hidden = true) 79 | decimalSlider(::playerY, "Player Y", hidden = true) 80 | text(::selectedTheme, "Theme Name", hidden = true) 81 | } 82 | 83 | subcategory("Behaviour") { 84 | text( 85 | ::playerFirstFormatString, 86 | "Track name format", 87 | "Customise the way the track name appears on the MediaMod Player. You can use [track] for the track name, and [artist] for the artist name.", 88 | "[track]" 89 | ) 90 | 91 | text( 92 | ::playerSecondFormatString, 93 | "Artist name format", 94 | "Customise the way the artist name appears on the MediaMod Player. You can use [track] for the track name, and [artist] for the artist name.", 95 | "by [artist]" 96 | ) 97 | 98 | decimalSlider( 99 | ::textScrollSpeed, 100 | "Text Scroll Speed", 101 | "Determines how fast the text will scroll when it exceeds the width of the player", 102 | min = 0.025f, 103 | max = 0.4f, 104 | decimalPlaces = 3 105 | ) 106 | } 107 | } 108 | 109 | category("Services") { 110 | subcategory("Behaviour") { 111 | selector( 112 | ::_preferredService, 113 | "Preferred Service", 114 | "Choose the service which will take priority over others. If that service isn't playing anything, it will fallback to the others.", 115 | MediaMod.serviceManager.services.map { it.displayName } 116 | ) 117 | 118 | text(::preferredService, "Preferred Service Name", hidden = true) 119 | } 120 | } 121 | 122 | category("Notifications") { 123 | subcategory("General") { 124 | switch( 125 | ::trackNotifications, 126 | "Now playing notifications", 127 | "Show a notification whenever the current track changes" 128 | ) 129 | } 130 | } 131 | 132 | MediaMod.serviceManager.services 133 | .filter { it.hasConfiguration } 134 | .forEach { 135 | with(it) { 136 | category(displayName) { configuration() } 137 | } 138 | } 139 | 140 | initialize() 141 | 142 | _selectedTheme = MediaMod.themeManager.loadedThemes 143 | .indexOfFirst { it.name == selectedTheme } 144 | .takeIf { it >= 0 } ?: 0 145 | 146 | _preferredService = MediaMod.serviceManager.services 147 | .indexOfFirst { it.displayName == preferredService } 148 | .takeIf { it >= 0 } ?: 0 149 | } 150 | 151 | @Suppress("UNCHECKED_CAST") 152 | private fun observable(default: T) = Delegates.observable(default) { property, _, newValue -> 153 | updateListeners[property]?.forEach { it.invoke(newValue as Any) } 154 | } 155 | 156 | @Suppress("UNCHECKED_CAST") 157 | fun listen(property: KProperty, block: T.() -> Unit) { 158 | updateListeners 159 | .computeIfAbsent(property) { mutableListOf() } 160 | .add(block as Any.() -> Unit) 161 | } 162 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MSYS* | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/gui/screen/editor/ThemeEditorScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.gui.screen.editor 2 | 3 | import com.github.kittinunf.fuel.Fuel 4 | import dev.mediamod.MediaMod 5 | import dev.mediamod.gui.ColorPalette 6 | import dev.mediamod.gui.component.UIButton 7 | import dev.mediamod.gui.screen.editor.component.CustomThemeListItem 8 | import dev.mediamod.gui.screen.editor.component.ThemeEditorContainer 9 | import dev.mediamod.gui.screen.editor.component.ThemeListItem 10 | import dev.mediamod.gui.style.styled 11 | import dev.mediamod.gui.style.stylesheet 12 | import dev.mediamod.theme.Theme 13 | import dev.mediamod.theme.impl.classicColors 14 | import dev.mediamod.utils.json 15 | import dev.mediamod.utils.logger 16 | import gg.essential.elementa.ElementaVersion 17 | import gg.essential.elementa.WindowScreen 18 | import gg.essential.elementa.components.* 19 | import gg.essential.elementa.constraints.CenterConstraint 20 | import gg.essential.elementa.constraints.ChildBasedMaxSizeConstraint 21 | import gg.essential.elementa.constraints.FillConstraint 22 | import gg.essential.elementa.constraints.SiblingConstraint 23 | import gg.essential.elementa.dsl.* 24 | import gg.essential.universal.UDesktop 25 | import kotlinx.serialization.Serializable 26 | import kotlinx.serialization.decodeFromString 27 | import java.awt.Color 28 | import java.net.URL 29 | 30 | class ThemeEditorScreen : WindowScreen( 31 | version = ElementaVersion.V1, 32 | restoreCurrentGuiOnClose = true, 33 | newGuiScale = 5 34 | ) { 35 | private val stylesheet = stylesheet { 36 | "mainContainer" { 37 | width = 100.percent() 38 | height = 100.percent() 39 | } 40 | 41 | "leftContainer" { 42 | width = 35.percent() 43 | height = 100.percent() 44 | } 45 | 46 | "rightContainer" { 47 | x = SiblingConstraint() 48 | width = FillConstraint() 49 | height = 100.percent() 50 | } 51 | 52 | "themesList" { 53 | width = 100.percent() 54 | height = 100.percent() 55 | } 56 | 57 | "themesListContainer" { 58 | x = 15.pixels() 59 | y = SiblingConstraint(10f) 60 | width = 100.percent() - 15.pixels() 61 | height = FillConstraint() - 65.pixels() 62 | } 63 | 64 | "scrollBar" { 65 | x = 2.5.pixels(true) 66 | y = 5.pixels() 67 | width = 3.pixels() 68 | height = 90.percent() 69 | } 70 | 71 | "themesListItem" { 72 | y = SiblingConstraint(5f) 73 | width = 100.percent() 74 | height = ChildBasedMaxSizeConstraint() 75 | } 76 | 77 | "welcomeText" { 78 | x = CenterConstraint() 79 | y = CenterConstraint() 80 | width = 90.percent() 81 | } 82 | 83 | "title" { 84 | x = 15.pixels() 85 | y = 15.pixels() 86 | textScale = 1.5f.pixels() 87 | } 88 | 89 | "subtitle" { 90 | x = 15.pixels() 91 | y = SiblingConstraint(20f) 92 | } 93 | 94 | "closeButton" { 95 | x = CenterConstraint() 96 | y = 15.pixels(true) 97 | width = 80.percent() 98 | height = 25.pixels() 99 | color = ColorPalette.secondaryBackground.brighter().constraint 100 | } 101 | } 102 | 103 | private val mainContainer by UIBlock(ColorPalette.background) 104 | .styled(stylesheet["mainContainer"]) 105 | .childOf(window) 106 | 107 | private val leftContainer by UIBlock(ColorPalette.secondaryBackground) 108 | .styled(stylesheet["leftContainer"]) 109 | .childOf(mainContainer) 110 | 111 | private val rightContainer by UIContainer() 112 | .styled(stylesheet["rightContainer"]) 113 | .childOf(mainContainer) 114 | 115 | private val themesListContainer by UIContainer() styled stylesheet["themesListContainer"] 116 | private val themesList = ScrollComponent() 117 | .styled(stylesheet["themesList"]) 118 | .childOf(themesListContainer) 119 | private val themesListScrollBar by UIBlock(ColorPalette.secondaryBackground.brighter().brighter()) 120 | .styled(stylesheet["scrollBar"]) 121 | .childOf(themesListContainer) 122 | 123 | private val themeEditor by ThemeEditorContainer() childOf rightContainer 124 | 125 | private val welcomeText by UIWrappedText( 126 | text = "Choose a theme on the left side of your screen to edit it!", 127 | centered = true 128 | ) 129 | .styled(stylesheet["welcomeText"]) 130 | .childOf(rightContainer) 131 | 132 | init { 133 | themesList.setVerticalScrollBarComponent(themesListScrollBar, hideWhenUseless = true) 134 | MediaMod.themeManager.onLoadedThemesUpdate(::addLoadedThemes) 135 | 136 | UIText("Theme Editor") 137 | .styled(stylesheet["title"]) 138 | .childOf(leftContainer) 139 | 140 | UIText("Themes") 141 | .styled(stylesheet["subtitle"]) 142 | .childOf(leftContainer) 143 | 144 | addLoadedThemes(MediaMod.themeManager.loadedThemes) 145 | themesListContainer childOf leftContainer 146 | 147 | UIButton(text = "Close", textColor = Color.white) 148 | .styled(stylesheet["closeButton"]) 149 | .onClick { 150 | restorePreviousScreen() 151 | } childOf leftContainer 152 | } 153 | 154 | override fun onScreenClose() { 155 | super.onScreenClose() 156 | saveTheme(themeEditor.theme.get()) 157 | } 158 | 159 | private fun editTheme(theme: Theme?) { 160 | saveTheme(themeEditor.theme.get()) 161 | themeEditor.theme.set(theme) 162 | 163 | if (theme == null) { 164 | welcomeText.unhide() 165 | } else { 166 | welcomeText.hide() 167 | } 168 | } 169 | 170 | private fun createTheme() { 171 | val count = MediaMod.themeManager.loadedThemes.filter { it.name.lowercase().startsWith("my theme") }.size 172 | val suffix = if (count == 0) "" else " (${count + 1})" 173 | val theme = Theme.LoadedTheme("My Theme$suffix", classicColors.copy()) 174 | 175 | MediaMod.themeManager.addTheme(theme) 176 | editTheme(theme) 177 | select(theme) 178 | } 179 | 180 | private fun addLoadedThemes(themes: List) { 181 | themesList.clearChildren() 182 | 183 | themes.forEach { theme -> 184 | ThemeListItem(theme) 185 | .styled(stylesheet["themesListItem"]) 186 | .onClick { 187 | select(null) 188 | editTheme(this) 189 | } childOf themesList 190 | } 191 | 192 | CustomThemeListItem("+ Create new theme...") 193 | .styled(stylesheet["themesListItem"]) 194 | .onClick { 195 | select(null) 196 | createTheme() 197 | } childOf themesList 198 | 199 | CustomThemeListItem("↓ Import theme...") 200 | .styled(stylesheet["themesListItem"]) 201 | .onClick { 202 | attemptToImportTheme()?.let { 203 | select(it) 204 | editTheme(it) 205 | } 206 | } childOf themesList 207 | } 208 | 209 | private fun select(theme: Theme?) { 210 | if (theme == null) themeEditor.theme.set(null) 211 | 212 | themesList.allChildren 213 | .filterIsInstance() 214 | .forEach { 215 | when (theme) { 216 | it.theme -> it.select() 217 | else -> it.unselect() 218 | } 219 | } 220 | } 221 | 222 | private fun saveTheme(theme: Theme?) { 223 | theme?.let { 224 | if (it !is Theme.LoadedTheme) return@let 225 | MediaMod.themeManager.saveTheme(it) 226 | } 227 | } 228 | 229 | @Serializable 230 | data class ThemeResponse(val ok: Boolean, val theme: Theme.LoadedTheme?) 231 | 232 | private fun attemptToImportTheme(): Theme.LoadedTheme? { 233 | fun errorNotification(message: String = "Invalid theme URL!") = 234 | MediaMod.notificationManager.showNotification("Import theme", message) 235 | 236 | val contents = UDesktop.getClipboardString() 237 | runCatching { URL(contents) }.getOrNull() ?: run { 238 | errorNotification() 239 | return null 240 | } 241 | 242 | val (_, _, result) = Fuel.get(contents) 243 | .set("User-Agent", "MediaMod") 244 | .responseString() 245 | 246 | result.fold( 247 | success = { string -> 248 | try { 249 | val response: ThemeResponse = json.decodeFromString(string) 250 | val theme = response.theme ?: error("Invalid response: $response") 251 | 252 | val existingTheme = MediaMod.themeManager.loadedThemes.firstOrNull { 253 | it.name.lowercase().trim() == theme.name.lowercase().trim() 254 | } 255 | if (existingTheme != null) { 256 | errorNotification("Theme name already exists!") 257 | return null 258 | } 259 | 260 | MediaMod.themeManager.importTheme(theme) 261 | MediaMod.notificationManager.showNotification("Theme imported!", theme.name) 262 | 263 | return theme 264 | } catch (e: Exception) { 265 | errorNotification() 266 | logger.error("Failed to fetch theme $contents:", e) 267 | } 268 | }, 269 | failure = { 270 | errorNotification() 271 | logger.error("Failed to fetch theme $contents:", it) 272 | } 273 | ) 274 | 275 | return null 276 | } 277 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/mediamod/utils/ColorQuantizer.kt: -------------------------------------------------------------------------------- 1 | package dev.mediamod.utils 2 | 3 | import java.awt.Color 4 | import java.awt.image.BufferedImage 5 | import java.util.* 6 | 7 | /** 8 | * A color quantizer 9 | * 10 | * Based of leptonica's colorquant2.c "Modified median cut color quantization" 11 | * 12 | * References: 13 | * - [colorquant2.c](https://github.com/DanBloomberg/leptonica/blob/master/src/colorquant2.c) 14 | * - [Color quantization using modified median cut - Dan S. Bloomberg](http://leptonica.org/papers/mediancut.pdf) 15 | * 16 | * @author DJtheRedstoner 17 | */ 18 | object ColorQuantizer { 19 | 20 | private const val fractByPopulation = 0.85f 21 | private const val maxItersAllowed = 5000 22 | private const val sigbits = 5 23 | private const val rshift = 8 - sigbits 24 | private const val mask = 0xff shr rshift 25 | 26 | fun quantize(image: BufferedImage, maxcolors: Int = 16): List { 27 | val pixelCount = image.width * image.height 28 | 29 | val histosize = 1 shl (3 * sigbits) 30 | val histo = IntArray(histosize) 31 | 32 | for (x in 0 until image.width) { 33 | for (y in 0 until image.height) { 34 | val rgb = image.getRGB(x, y) 35 | val rval = rgb shr 16 + rshift and mask 36 | val gval = rgb shr 8 + rshift and mask 37 | val bval = rgb shr rshift and mask 38 | val idx = (rval shl 2 * sigbits) + (gval shl sigbits) + bval 39 | histo[idx]++ 40 | } 41 | } 42 | 43 | var count = 0 44 | var smalln = true 45 | for (i in 0 until histosize) { 46 | if (histo[i] > 0) { 47 | count++ 48 | } 49 | if (count > maxcolors) { 50 | smalln = false 51 | break 52 | } 53 | } 54 | 55 | if (smalln) { 56 | val list = mutableListOf() 57 | for (i in 0 until histosize) { 58 | if (histo[i] > 0) { 59 | val rval = (i shr (2 * sigbits)) shl rshift 60 | val gval = ((i shr sigbits) and mask) shl rshift 61 | val bval = (i and mask) shl rshift 62 | list.add(ColorData(Color(rval, gval, bval), histo[i] / pixelCount.toFloat())) 63 | } 64 | } 65 | return list.sortedByDescending { it.fraction } 66 | } 67 | 68 | var rmin = 1000000 69 | var gmin = 1000000 70 | var bmin = 1000000 71 | var rmax = 0 72 | var bmax = 0 73 | var gmax = 0 74 | 75 | for (x in 0 until image.width) { 76 | for (y in 0 until image.height) { 77 | val rgb: Int = image.getRGB(x, y) 78 | val rval = rgb shr 16 + rshift and mask 79 | val gval = rgb shr 8 + rshift and mask 80 | val bval = rgb shr rshift and mask 81 | if (rval < rmin) rmin = rval 82 | else if (rval > rmax) rmax = rval 83 | if (gval < gmin) gmin = gval 84 | else if (gval > gmax) gmax = gval 85 | if (bval < bmin) bmin = bval 86 | else if (bval > bmax) bmax = bval 87 | } 88 | } 89 | 90 | val initialVbox = VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo) 91 | 92 | val queueByPop = PriorityQueue(Comparator.comparingInt(VBox::count).reversed()) 93 | queueByPop.add(initialVbox) 94 | 95 | var ncolors = 1 96 | var nitters = 0 97 | val popcolors = (fractByPopulation * maxcolors).toInt() 98 | while (true) { 99 | val vbox = queueByPop.remove() 100 | if (vbox.count() == 0) { 101 | queueByPop.add(vbox) 102 | continue 103 | } 104 | val (vbox1, vbox2) = medianCutApply(histo, vbox) 105 | queueByPop.add(vbox1) 106 | if (vbox2 != null) { 107 | queueByPop.add(vbox2) 108 | ncolors++ 109 | } 110 | if (ncolors >= popcolors) { 111 | break 112 | } 113 | if (nitters++ > maxItersAllowed) { 114 | // println("infinite loop; perhaps too few pixels!") 115 | break 116 | } 117 | } 118 | 119 | var maxprod = 0f 120 | for (vbox in queueByPop) { 121 | val prod = vbox.count().toFloat() * vbox.volume().toFloat() 122 | if (prod > maxprod) maxprod = prod 123 | } 124 | val norm = if (maxprod == 0f) 1f else 1000000.0f / maxprod 125 | 126 | val queueByVol = PriorityQueue(Comparator.comparingDouble { (norm * it.count() * it.volume()).toDouble() }.reversed()) 127 | queueByVol.addAll(queueByPop) 128 | 129 | while (true) { 130 | val vbox = queueByVol.remove() 131 | if (vbox.count() == 0) { 132 | queueByVol.add(vbox) 133 | continue 134 | } 135 | val (vbox1, vbox2) = medianCutApply(histo, vbox) 136 | queueByVol.add(vbox1) 137 | if (vbox2 != null) { 138 | queueByVol.add(vbox2) 139 | ncolors++ 140 | } 141 | if (ncolors >= maxcolors) { 142 | break 143 | } 144 | if (nitters++ > maxItersAllowed) { 145 | // println("infinite loop; perhaps too few pixels!") 146 | break 147 | } 148 | } 149 | 150 | return queueByVol.toList() 151 | .sortedByDescending(VBox::count) 152 | .map { ColorData(Color(it.avg()), it.count() / pixelCount.toFloat()) } 153 | } 154 | 155 | private fun medianCutApply(histo: IntArray, vbox: VBox): Pair { 156 | val rw = vbox.r2 - vbox.r1 + 1 157 | val gw = vbox.g2 - vbox.g1 + 1 158 | val bw = vbox.b2 - vbox.b1 + 1 159 | 160 | if (rw == 1 && gw == 1 && bw == 1) { 161 | return vbox.copy() to null 162 | } 163 | 164 | val maxw = maxOf(rw, gw, bw) 165 | 166 | var total = 0 167 | val partialsum = IntArray(128) 168 | if (maxw == rw) { 169 | for (i in vbox.r1..vbox.r2) { 170 | var sum = 0 171 | for (j in vbox.g1..vbox.g2) { 172 | for (k in vbox.b1..vbox.b2) { 173 | val index = (i shl 2 * sigbits) + (j shl sigbits) + k 174 | sum += histo[index] 175 | } 176 | } 177 | total += sum 178 | partialsum[i] = total 179 | } 180 | } else if (maxw == gw) { 181 | for (i in vbox.g1..vbox.g2) { 182 | var sum = 0 183 | for (j in vbox.r1..vbox.r2) { 184 | for (k in vbox.b1..vbox.b2) { 185 | val index = (i shl sigbits) + (j shl 2 * sigbits) + k 186 | sum += histo[index] 187 | } 188 | } 189 | total += sum 190 | partialsum[i] = total 191 | } 192 | } else { 193 | for (i in vbox.b1..vbox.b2) { 194 | var sum = 0 195 | for (j in vbox.r1..vbox.r2) { 196 | for (k in vbox.g1..vbox.g2) { 197 | val index = i + (j shl 2 * sigbits) + (k shl sigbits) 198 | sum += histo[index] 199 | } 200 | } 201 | total += sum 202 | partialsum[i] = total 203 | } 204 | } 205 | 206 | var vbox1: VBox? = null 207 | var vbox2: VBox? = null 208 | if (maxw == rw) { 209 | for (i in vbox.r1..vbox.r2) { 210 | if (partialsum[i] > total / 2) { 211 | vbox1 = vbox.copy() 212 | vbox2 = vbox.copy() 213 | val left = i - vbox.r1 214 | val right = vbox.r2 - i 215 | if (left <= right) { 216 | vbox1.r2 = Math.min(vbox.r2 - 1, i + right / 2) 217 | } else { 218 | vbox1.r2 = Math.max(vbox.r1, i - 1 - left / 2) 219 | } 220 | vbox2.r1 = vbox1.r2 + 1 221 | break 222 | } 223 | } 224 | } else if (maxw == gw) { 225 | for (i in vbox.g1..vbox.g2) { 226 | if (partialsum[i] > total / 2) { 227 | vbox1 = vbox.copy() 228 | vbox2 = vbox.copy() 229 | val left = i - vbox.g1 230 | val right = vbox.g2 - i 231 | if (left <= right) { 232 | vbox1.g2 = Math.min(vbox.g2 - 1, i + right / 2) 233 | } else { 234 | vbox1.g2 = Math.max(vbox.g1, i - 1 - left / 2) 235 | } 236 | vbox2.g1 = vbox1.g2 + 1 237 | break 238 | } 239 | } 240 | } else { 241 | for (i in vbox.b1..vbox.b2) { 242 | if (partialsum[i] > total / 2) { 243 | vbox1 = vbox.copy() 244 | vbox2 = vbox.copy() 245 | val left = i - vbox.b1 246 | val right = vbox.b2 - i 247 | if (left <= right) { 248 | vbox1.b2 = Math.min(vbox.b2 - 1, i + right / 2) 249 | } else { 250 | vbox1.b2 = Math.max(vbox.b1, i - 1 - left / 2) 251 | } 252 | vbox2.b1 = vbox1.b2 + 1 253 | break 254 | } 255 | } 256 | } 257 | 258 | return vbox1!! to vbox2!! 259 | } 260 | 261 | class VBox( 262 | var r1: Int, 263 | var r2: Int, 264 | var g1: Int, 265 | var g2: Int, 266 | var b1: Int, 267 | var b2: Int, 268 | private val histo: IntArray 269 | ) { 270 | private val count by lazy { 271 | var npix = 0 272 | for (i in r1..r2) { 273 | for (j in g1..g2) { 274 | for (k in b1..b2) { 275 | npix += histo[(i shl 2 * sigbits) + (j shl sigbits) + k] 276 | } 277 | } 278 | } 279 | npix 280 | } 281 | 282 | fun count() = count 283 | 284 | fun volume() = (r2 - r1 + 1) * (g2 - g1 + 1) * (b2 - b1 + 1) 285 | 286 | fun avg(): Int { 287 | var rsum = 0 288 | var gsum = 0 289 | var bsum = 0 290 | var ntot = 0 291 | val mult = 1 shl (8 - sigbits) 292 | 293 | for (i in r1..r2) { 294 | for (j in g1..g2) { 295 | for (k in b1..b2) { 296 | val histoindex = (i shl 2 * sigbits) + (j shl sigbits) + k 297 | ntot += histo[histoindex] 298 | rsum += (histo[histoindex] * (i + 0.5) * mult).toInt() 299 | gsum += (histo[histoindex] * (j + 0.5) * mult).toInt() 300 | bsum += (histo[histoindex] * (k + 0.5) * mult).toInt() 301 | } 302 | } 303 | } 304 | 305 | return if (ntot == 0) { 306 | (mult * (r1 + r2 + 1) / 2 shl 16) + 307 | (mult * (g1 + g2 + 1) shl 8) + 308 | mult * (b1 + b2 + 1) 309 | } else { 310 | (rsum / ntot shl 16) + 311 | (gsum / ntot shl 8) + 312 | bsum / ntot 313 | } 314 | } 315 | 316 | fun copy() = VBox(r1, r2, g1, g2, b1, b2, histo) 317 | } 318 | 319 | class ColorData(val color: Color, val fraction: Float) 320 | } --------------------------------------------------------------------------------