Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<CacheEntry>

}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -52,4 +56,13 @@ class FilesystemCache(

return CacheEntry(newChecksum, targetFile)
}

override fun download(url: String, progress: (progressPercent: Int) -> Unit): CompletableFuture<CacheEntry> {
return CompletableFuture
.supplyAsync({ get(url, progress) }, Util.ioPool())
.handleAsync({ entry, error ->
if (error != null) throw CompletionException(error)
entry
}, Minecraft.getInstance())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<UnsupportedAudioTypeException>()
.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) {
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/main/resources/assets/betterrecords/lang/en_us.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
Loading