From 43296b611e293c85323187b853b1d0555e677759 Mon Sep 17 00:00:00 2001 From: AngrySoundTech Date: Fri, 26 Jun 2026 18:05:07 -0400 Subject: [PATCH] Allow the user to enter a custom url --- .../betterrecords/api/client/MusicCache.kt | 8 + .../client/cache/FilesystemCache.kt | 13 ++ .../client/cache/downloadUtil.kt | 26 ++- .../client/screen/RecordEtcherScreen.kt | 206 ++++++++++++++++-- .../assets/betterrecords/lang/en_us.json | 9 +- 5 files changed, 239 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/com/hemogoblins/betterrecords/api/client/MusicCache.kt b/src/main/kotlin/com/hemogoblins/betterrecords/api/client/MusicCache.kt index 5febb7bb..f898cb25 100644 --- a/src/main/kotlin/com/hemogoblins/betterrecords/api/client/MusicCache.kt +++ b/src/main/kotlin/com/hemogoblins/betterrecords/api/client/MusicCache.kt @@ -1,6 +1,7 @@ package com.hemogoblins.betterrecords.api.client import java.io.File +import java.util.concurrent.CompletableFuture /** * Simple object to represent a file in the cache, with extra information @@ -37,4 +38,11 @@ interface MusicCache { @Deprecated("This is NOT stable yet, and may change") fun get(url: String, progress: (progressPercent: Int) -> Unit): CacheEntry + /** + * Downloads [url] into the cache, reporting download percentage through [progress] as it runs. + * + * The future completes with the cached entry, or completes exceptionally if the download fails. + */ + fun download(url: String, progress: (progressPercent: Int) -> Unit): CompletableFuture + } diff --git a/src/main/kotlin/com/hemogoblins/betterrecords/client/cache/FilesystemCache.kt b/src/main/kotlin/com/hemogoblins/betterrecords/client/cache/FilesystemCache.kt index d8a10f95..1bf40181 100644 --- a/src/main/kotlin/com/hemogoblins/betterrecords/client/cache/FilesystemCache.kt +++ b/src/main/kotlin/com/hemogoblins/betterrecords/client/cache/FilesystemCache.kt @@ -3,9 +3,13 @@ package com.hemogoblins.betterrecords.client.cache import com.hemogoblins.betterrecords.BetterRecords import com.hemogoblins.betterrecords.api.client.CacheEntry import com.hemogoblins.betterrecords.api.client.MusicCache +import net.minecraft.Util +import net.minecraft.client.Minecraft import org.apache.commons.codec.digest.DigestUtils import java.io.File import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException class FilesystemCache( private val tempDirectory: File, @@ -52,4 +56,13 @@ class FilesystemCache( return CacheEntry(newChecksum, targetFile) } + + override fun download(url: String, progress: (progressPercent: Int) -> Unit): CompletableFuture { + return CompletableFuture + .supplyAsync({ get(url, progress) }, Util.ioPool()) + .handleAsync({ entry, error -> + if (error != null) throw CompletionException(error) + entry + }, Minecraft.getInstance()) + } } diff --git a/src/main/kotlin/com/hemogoblins/betterrecords/client/cache/downloadUtil.kt b/src/main/kotlin/com/hemogoblins/betterrecords/client/cache/downloadUtil.kt index 4052d693..204934ee 100644 --- a/src/main/kotlin/com/hemogoblins/betterrecords/client/cache/downloadUtil.kt +++ b/src/main/kotlin/com/hemogoblins/betterrecords/client/cache/downloadUtil.kt @@ -3,9 +3,23 @@ package com.hemogoblins.betterrecords.client.cache import java.io.BufferedInputStream import java.io.File import java.io.FileOutputStream +import java.io.IOException import java.lang.Exception import java.net.URL +/** + * Thrown when a downloaded URL responds with a content type that isn't audio (e.g. an HTML page or redirect) + */ +class UnsupportedAudioTypeException(val contentType: String, url: String) : + IOException("Expected an audio file but $url returned content type '$contentType'") + +private fun isAcceptableAudioType(contentType: String): Boolean { + return contentType.startsWith("audio/") || + contentType == "application/ogg" || + contentType == "application/octet-stream" || + contentType == "binary/octet-stream" +} + fun downloadFile( url: String, target: File, @@ -14,7 +28,15 @@ fun downloadFile( val source = URL(url) val connection = source.openConnection() - val size = connection.contentLength.toLong() + connection.connect() + + // Reject anything that isn't audio + val contentType = connection.contentType?.substringBefore(';')?.trim()?.lowercase() + if (contentType != null && !isAcceptableAudioType(contentType)) { + throw UnsupportedAudioTypeException(contentType, url) + } + + val size = connection.contentLengthLong // TODO: Download max size if (false) { @@ -24,7 +46,7 @@ fun downloadFile( // Just in case we attempted this one already, delete it. target.delete() - val inputStream = BufferedInputStream(source.openStream()) + val inputStream = BufferedInputStream(connection.getInputStream()) val outputStream = FileOutputStream(target) var bytesCopied = 0L diff --git a/src/main/kotlin/com/hemogoblins/betterrecords/client/screen/RecordEtcherScreen.kt b/src/main/kotlin/com/hemogoblins/betterrecords/client/screen/RecordEtcherScreen.kt index 7c751fae..629ae7fc 100644 --- a/src/main/kotlin/com/hemogoblins/betterrecords/client/screen/RecordEtcherScreen.kt +++ b/src/main/kotlin/com/hemogoblins/betterrecords/client/screen/RecordEtcherScreen.kt @@ -1,15 +1,19 @@ package com.hemogoblins.betterrecords.client.screen import com.hemogoblins.betterrecords.BetterRecords +import com.hemogoblins.betterrecords.client.cache.UnsupportedAudioTypeException import com.hemogoblins.betterrecords.menu.RecordEtcherMenu import com.hemogoblins.betterrecords.network.ModNetwork import com.hemogoblins.betterrecords.network.serverbound.RequestEtchPacket import net.minecraft.client.gui.GuiGraphics import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen import net.minecraft.network.chat.Component import net.minecraft.resources.ResourceLocation import net.minecraft.world.entity.player.Inventory +import org.lwjgl.glfw.GLFW +import kotlin.math.roundToInt class RecordEtcherScreen( container: RecordEtcherMenu, @@ -19,37 +23,174 @@ class RecordEtcherScreen( private val BACKGROUND_TEXTURE = ResourceLocation(BetterRecords.ID, "textures/gui/record_etcher.png") + private val ERROR_COLOR = 0xFF5555 + private val SUCCESS_COLOR = 0x55FF55 + private val BAR_TRACK_COLOR = 0xFF333333.toInt() + private val BAR_FILL_COLOR = 0xFF4CAF50.toInt() + + private lateinit var urlBox: EditBox + private lateinit var nameBox: EditBox + private lateinit var etchButton: Button + + /** True once the player has manually edited the name, so we stop autofilling it from the URL */ + private var nameEdited = false + private var settingNameProgrammatically = false + + /** Feedback message */ + @Volatile + private var statusMessage: Component? = null + + /** Color the current [statusMessage] is drawn in */ + @Volatile + private var statusColor = ERROR_COLOR + + /** True while a song is being downloaded */ + @Volatile + private var downloading = false + + /** Download progress in the range 0..1 */ + @Volatile + private var downloadProgress = 0f + init { imageHeight = 202 inventoryLabelY = imageHeight - 94 } - val etchButton = Button.builder(Component.translatable("menu.${BetterRecords.ID}.record_etcher.etch")) { - try { - // TODO URL From text field - val url = "https://betterrecords-files.s3.amazonaws.com/fastcar.mp3" + override fun init() { + super.init() + + urlBox = EditBox( + font, + leftPos + 8, + topPos + 44, + imageWidth - 16, + 16, + Component.translatable("menu.${BetterRecords.ID}.record_etcher.url"), + ).apply { + setMaxLength(512) + setHint(Component.translatable("menu.${BetterRecords.ID}.record_etcher.url")) + setResponder { onUrlChanged(it) } + } - val cacheEntry = BetterRecords.cache.get(url ) { progressPercent -> - // TODO: Progress Bar + nameBox = EditBox( + font, + leftPos + 8, + topPos + 62, + imageWidth - 16, + 16, + Component.translatable("menu.${BetterRecords.ID}.record_etcher.name"), + ).apply { + setMaxLength(128) + setHint(Component.translatable("menu.${BetterRecords.ID}.record_etcher.name")) + // Disabled until a URL is entered. + active = false + setResponder { + if (!settingNameProgrammatically) nameEdited = true } + } - ModNetwork.channel.sendToServer(RequestEtchPacket( - container.blockEntity.blockPos, - name = "Fast Car", - url = url, - checksum = cacheEntry.checksum - )) - } catch (e: Exception) { - println(e) + addRenderableWidget(urlBox) + addRenderableWidget(nameBox) + + etchButton = Button.builder(Component.translatable("menu.${BetterRecords.ID}.record_etcher.etch")) { onEtch() } + .pos(leftPos + (imageWidth - 60) / 2, topPos + 80) + .size(60, 18) + .build() + addRenderableWidget(etchButton) + } + + /** + * Keep the name box enabled/disabled with the URL + * until the player edits the name themselves, then keeps it filled with the name derived from the current URL. + */ + private fun onUrlChanged(rawUrl: String) { + val url = rawUrl.trim() + nameBox.active = url.isNotEmpty() + + if (!nameEdited) { + settingNameProgrammatically = true + nameBox.value = if (url.isEmpty()) "" else nameFromUrl(url) + settingNameProgrammatically = false } } - .pos(guiLeft + 175, guiTop + 40 ) - .width(20) - .build() - override fun init() { - super.init() - addRenderableWidget(etchButton) + private fun onEtch() { + val url = urlBox.value.trim() + if (url.isEmpty()) { + setError("empty_url") + return + } + if (!menu.slots.last().hasItem()) { + setError("no_record") + return + } + + statusMessage = null + etchButton.active = false + downloading = true + downloadProgress = 0f + + val pos = menu.blockEntity.blockPos + val name = nameBox.value.trim().ifBlank { nameFromUrl(url) } + + BetterRecords.cache.download(url) { percent -> + downloadProgress = (percent / 100f).coerceIn(0f, 1f) + }.whenComplete { cacheEntry, error -> + downloading = false + etchButton.active = true + if (error != null) { + val unsupported = generateSequence(error) { it.cause.takeIf { c -> c !== it } } + .filterIsInstance() + .firstOrNull() + if (unsupported != null) { + setError("not_audio", unsupported.contentType) + } else { + setError("download_failed") + } + BetterRecords.logger.error("Failed to download record for etching", error) + return@whenComplete + } + + ModNetwork.channel.sendToServer( + RequestEtchPacket( + pos, + name = name, + url = url, + checksum = cacheEntry.checksum, + ) + ) + + statusMessage = Component.translatable("menu.${BetterRecords.ID}.record_etcher.success", name) + statusColor = SUCCESS_COLOR + } + } + + override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + // Escape should always let us out. + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + return super.keyPressed(keyCode, scanCode, modifiers) + } + // Focused text box swallows key presses, so we don't for example hit e and close the gui + if (urlBox.keyPressed(keyCode, scanCode, modifiers) || urlBox.canConsumeInput() || + nameBox.keyPressed(keyCode, scanCode, modifiers) || nameBox.canConsumeInput() + ) { + return true + } + return super.keyPressed(keyCode, scanCode, modifiers) + } + + private fun setError(key: String, vararg args: Any) { + statusMessage = Component.translatable("menu.${BetterRecords.ID}.record_etcher.error.$key", *args) + statusColor = ERROR_COLOR + } + + /** Derives a human-readable record name from the file portion of [url]. + * TODO: this can bear to be improved + */ + private fun nameFromUrl(url: String): String { + val fileName = url.substringBefore('?').substringAfterLast('/').substringBeforeLast('.') + return fileName.ifBlank { "Record" } } override fun render(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTicks: Float) { @@ -58,6 +199,31 @@ class RecordEtcherScreen( this.renderTooltip(guiGraphics, mouseX, mouseY) } + override fun renderLabels(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int) { + super.renderLabels(guiGraphics, mouseX, mouseY) + + if (downloading) { + renderProgressBar(guiGraphics) + } else { + statusMessage?.let { + val x = (imageWidth - font.width(it)) / 2 + guiGraphics.drawString(font, it, x, 100, statusColor, false) + } + } + } + + private fun renderProgressBar(guiGraphics: GuiGraphics) { + val barX = 8 + val barY = 100 + val barWidth = imageWidth - 16 + val barHeight = 6 + val progress = downloadProgress.coerceIn(0f, 1f) + + guiGraphics.fill(barX, barY, barX + barWidth, barY + barHeight, BAR_TRACK_COLOR) + val filled = (barWidth * progress).roundToInt().coerceIn(0, barWidth) + guiGraphics.fill(barX, barY, barX + filled, barY + barHeight, BAR_FILL_COLOR) + } + override fun renderBg(guiGraphics: GuiGraphics, partialTicks: Float, x: Int, y: Int) { val k = (width - imageWidth) / 2 val l = (height - imageHeight) / 2 diff --git a/src/main/resources/assets/betterrecords/lang/en_us.json b/src/main/resources/assets/betterrecords/lang/en_us.json index ebf251ce..367092b5 100644 --- a/src/main/resources/assets/betterrecords/lang/en_us.json +++ b/src/main/resources/assets/betterrecords/lang/en_us.json @@ -7,5 +7,12 @@ "block.betterrecords.record_player": "Record Player", "menu.betterrecords.record_etcher.title": "Record Etcher", - "menu.betterrecords.record_etcher.etch": "Etch" + "menu.betterrecords.record_etcher.etch": "Etch", + "menu.betterrecords.record_etcher.url": "Song URL", + "menu.betterrecords.record_etcher.name": "Record Name", + "menu.betterrecords.record_etcher.success": "Etched \"%s\"!", + "menu.betterrecords.record_etcher.error.empty_url": "Enter a song URL", + "menu.betterrecords.record_etcher.error.no_record": "Insert a record to etch", + "menu.betterrecords.record_etcher.error.download_failed": "Could not download song", + "menu.betterrecords.record_etcher.error.not_audio": "Not an audio file: (%s)" } \ No newline at end of file