From a2d1a8b3df181801b67cfe8b2f7a81bc2bed153b Mon Sep 17 00:00:00 2001 From: James Ding Date: Mon, 29 Jun 2026 16:18:02 -0700 Subject: [PATCH 1/2] refactor(frontend): move JS payload encoders out of PlayerBridge into PlayerPayloads --- .../twango/jetplay/browser/PlayerBridge.kt | 107 +----------------- .../jetplay/browser/PlayerHtmlLoader.kt | 16 +-- .../twango/jetplay/browser/PlayerPayloads.kt | 104 +++++++++++++++++ .../jetplay/browser/PlayerBridgeEscapeTest.kt | 36 +++--- 4 files changed, 134 insertions(+), 129 deletions(-) create mode 100644 frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerPayloads.kt diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt index 75087153..d73db70f 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt @@ -9,7 +9,6 @@ import dev.twango.jetplay.media.Spectrogram import org.cef.browser.CefBrowser import org.cef.browser.CefFrame import org.cef.handler.CefLoadHandlerAdapter -import java.util.Base64 import javax.swing.SwingUtilities class PlayerBridge(private val browser: JBCefBrowser) { @@ -82,10 +81,10 @@ class PlayerBridge(private val browser: JBCefBrowser) { executeJs("window.__jetplayProgress=$percent;window.jetplayUpdateProgress?.($percent)") fun mediaReady(url: String) = - executeJs("window.__jetplayReadyUrl='${escapeJs(url)}';window.jetplayReady?.('${escapeJs(url)}')") + executeJs("window.__jetplayReadyUrl='${PlayerPayloads.escapeJs(url)}';window.jetplayReady?.('${PlayerPayloads.escapeJs(url)}')") fun showError(message: String) = - executeJs("window.__jetplayError='${escapeJs(message)}';window.jetplayError?.('${escapeJs(message)}')") + executeJs("window.__jetplayError='${PlayerPayloads.escapeJs(message)}';window.jetplayError?.('${PlayerPayloads.escapeJs(message)}')") // Same stash-then-notify race guard as updateProgress. fun sendWaveform(bars: List) = executeJs( @@ -94,13 +93,13 @@ class PlayerBridge(private val browser: JBCefBrowser) { ) fun sendMediaInfo(info: MediaInfo) { - val json = mediaInfoJson(info) ?: return + val json = PlayerPayloads.mediaInfoJson(info) ?: return executeJs("window.__jetplayMediaInfo=$json;if(window.jetplayMediaInfo)window.jetplayMediaInfo(window.__jetplayMediaInfo)") } // Carries either the matrix or {ok:false} so a lazy request that finds nothing can stop the spinner. fun sendSpectrogram(spec: Spectrogram?) { - val json = spectrogramJson(spec) + val json = PlayerPayloads.spectrogramJson(spec) executeJs("window.__jetplaySpectrogram=$json;if(window.jetplaySpectrogram)window.jetplaySpectrogram(window.__jetplaySpectrogram)") } @@ -121,102 +120,4 @@ class PlayerBridge(private val browser: JBCefBrowser) { spectrogramRequestQuery.dispose() browser.dispose() } - - companion object { - private const val HEX_RADIX = 16 - private const val UNICODE_ESCAPE_HEX_LENGTH = 4 - - fun escapeJs(s: String): String = s.replace("\\", "\\\\") - .replace("'", "\\'") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "") - .replace("<", "\\x3c") - .replace(">", "\\x3e") - - // Strict JSON, not spliced: it carries arbitrary tag text and a base64 art URL. - internal fun mediaInfoJson(info: MediaInfo): String? { - val parts = buildList { - info.codec?.let { add("\"codec\":${jsonString(it)}") } - info.container?.let { add("\"container\":${jsonString(it)}") } - info.sampleRateHz?.let { add("\"sampleRateHz\":$it") } - info.channels?.let { add("\"channels\":$it") } - info.channelLabel?.let { add("\"channelLabel\":${jsonString(it)}") } - info.bitDepth?.let { add("\"bitDepth\":${jsonString(it)}") } - info.bitrateBps?.let { add("\"bitrateBps\":$it") } - info.durationMs?.let { add("\"durationMs\":$it") } - info.sizeBytes?.let { add("\"sizeBytes\":$it") } - info.width?.let { add("\"width\":$it") } - info.height?.let { add("\"height\":$it") } - info.frameRate?.let { add("\"frameRate\":$it") } - info.videoCodec?.let { add("\"videoCodec\":${jsonString(it)}") } - info.pixelFormat?.let { add("\"pixelFormat\":${jsonString(it)}") } - info.videoBitrateBps?.let { add("\"videoBitrateBps\":$it") } - if (info.tags.isNotEmpty()) { - val arr = info.tags.joinToString(",", "[", "]") { tag -> - "{\"label\":${jsonString(tag.label)},\"value\":${jsonString(tag.value)}}" - } - add("\"tags\":$arr") - } - info.albumArt?.let { add("\"albumArt\":${jsonString(it)}") } - } - if (parts.isEmpty()) return null - return parts.joinToString(",", "{", "}") - } - - // Numbers are emitted as literals; only the base64 matrix is a string (it never contains JS specials). - internal fun spectrogramJson(spec: Spectrogram?): String { - if (spec == null) return "{\"ok\":false}" - val data = Base64.getEncoder().encodeToString(spec.magnitudes) - return buildString { - append("{\"ok\":true") - append(",\"timeCols\":${spec.timeCols}") - append(",\"freqBins\":${spec.freqBins}") - append(",\"durationMs\":${spec.durationMs}") - append(",\"sampleRateHz\":${spec.sampleRateHz}") - append(",\"minHz\":${spec.minHz}") - append(",\"maxHz\":${spec.maxHz}") - append(",\"dbFloor\":${spec.dbFloor}") - append(",\"dbCeil\":${spec.dbCeil}") - append(",\"logFreq\":${spec.logFreq}") - append(",\"data\":${jsonString(data)}") - append("}") - } - } - - internal fun jsonString(s: String): String { - val sb = StringBuilder(s.length + 2) - sb.append('"') - for (c in s) { - when (c) { - '"' -> sb.append("\\\"") - - '\\' -> sb.append("\\\\") - - '\n' -> sb.append("\\n") - - '\r' -> sb.append("\\r") - - '\t' -> sb.append("\\t") - - '\b' -> sb.append("\\b") - - '\u000C' -> sb.append("\\f") - - // Valid in JSON but terminate a JS string literal. - '\u2028' -> sb.append("\\u2028") - - '\u2029' -> sb.append("\\u2029") - - else -> if (c < ' ') { - sb.append("\\u").append(c.code.toString(HEX_RADIX).padStart(UNICODE_ESCAPE_HEX_LENGTH, '0')) - } else { - sb.append(c) - } - } - } - sb.append('"') - return sb.toString() - } - } } diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt index cefd47da..5f8b4fd4 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt @@ -19,21 +19,21 @@ class PlayerHtmlLoader(private val bridge: PlayerBridge) { append("window.jetplay = {") append("state: '${config.state}',") append("isVideo: ${config.isVideo},") - append("fileName: '${PlayerBridge.escapeJs(config.fileName)}',") - append("fileExtension: '${PlayerBridge.escapeJs(config.fileExtension)}',") - config.mediaUrl?.let { append("mediaUrl: '${PlayerBridge.escapeJs(it)}',") } - if (config.errorMessage.isNotEmpty()) append("errorMessage: '${PlayerBridge.escapeJs(config.errorMessage)}',") - if (config.transcodingReason.isNotEmpty()) append("transcodingReason: '${PlayerBridge.escapeJs(config.transcodingReason)}',") + append("fileName: '${PlayerPayloads.escapeJs(config.fileName)}',") + append("fileExtension: '${PlayerPayloads.escapeJs(config.fileExtension)}',") + config.mediaUrl?.let { append("mediaUrl: '${PlayerPayloads.escapeJs(it)}',") } + if (config.errorMessage.isNotEmpty()) append("errorMessage: '${PlayerPayloads.escapeJs(config.errorMessage)}',") + if (config.transcodingReason.isNotEmpty()) append("transcodingReason: '${PlayerPayloads.escapeJs(config.transcodingReason)}',") // Emit only non-empty strings so the Svelte component's own defaults stand for unset copy. append("ui: {") if (config.ui.transcodingLabel.isNotEmpty()) { - append("transcodingLabel: '${PlayerBridge.escapeJs(config.ui.transcodingLabel)}',") + append("transcodingLabel: '${PlayerPayloads.escapeJs(config.ui.transcodingLabel)}',") } if (config.ui.transcodingTip.isNotEmpty()) { - append("transcodingTip: '${PlayerBridge.escapeJs(config.ui.transcodingTip)}',") + append("transcodingTip: '${PlayerPayloads.escapeJs(config.ui.transcodingTip)}',") } if (config.ui.errorTitle.isNotEmpty()) { - append("errorTitle: '${PlayerBridge.escapeJs(config.ui.errorTitle)}',") + append("errorTitle: '${PlayerPayloads.escapeJs(config.ui.errorTitle)}',") } append("},") append("};") diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerPayloads.kt b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerPayloads.kt new file mode 100644 index 00000000..0e2569ee --- /dev/null +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerPayloads.kt @@ -0,0 +1,104 @@ +package dev.twango.jetplay.browser + +import dev.twango.jetplay.media.MediaInfo +import dev.twango.jetplay.media.Spectrogram +import java.util.Base64 + +// Hand-rolled JS/JSON encoders for the JCEF bridge payloads. +internal object PlayerPayloads { + private const val HEX_RADIX = 16 + private const val UNICODE_ESCAPE_HEX_LENGTH = 4 + + fun escapeJs(s: String): String = s.replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "") + .replace("<", "\\x3c") + .replace(">", "\\x3e") + + // Strict JSON, not spliced: it carries arbitrary tag text and a base64 art URL. + internal fun mediaInfoJson(info: MediaInfo): String? { + val parts = buildList { + info.codec?.let { add("\"codec\":${jsonString(it)}") } + info.container?.let { add("\"container\":${jsonString(it)}") } + info.sampleRateHz?.let { add("\"sampleRateHz\":$it") } + info.channels?.let { add("\"channels\":$it") } + info.channelLabel?.let { add("\"channelLabel\":${jsonString(it)}") } + info.bitDepth?.let { add("\"bitDepth\":${jsonString(it)}") } + info.bitrateBps?.let { add("\"bitrateBps\":$it") } + info.durationMs?.let { add("\"durationMs\":$it") } + info.sizeBytes?.let { add("\"sizeBytes\":$it") } + info.width?.let { add("\"width\":$it") } + info.height?.let { add("\"height\":$it") } + info.frameRate?.let { add("\"frameRate\":$it") } + info.videoCodec?.let { add("\"videoCodec\":${jsonString(it)}") } + info.pixelFormat?.let { add("\"pixelFormat\":${jsonString(it)}") } + info.videoBitrateBps?.let { add("\"videoBitrateBps\":$it") } + if (info.tags.isNotEmpty()) { + val arr = info.tags.joinToString(",", "[", "]") { tag -> + "{\"label\":${jsonString(tag.label)},\"value\":${jsonString(tag.value)}}" + } + add("\"tags\":$arr") + } + info.albumArt?.let { add("\"albumArt\":${jsonString(it)}") } + } + if (parts.isEmpty()) return null + return parts.joinToString(",", "{", "}") + } + + // Numbers are emitted as literals; only the base64 matrix is a string (it never contains JS specials). + internal fun spectrogramJson(spec: Spectrogram?): String { + if (spec == null) return "{\"ok\":false}" + val data = Base64.getEncoder().encodeToString(spec.magnitudes) + return buildString { + append("{\"ok\":true") + append(",\"timeCols\":${spec.timeCols}") + append(",\"freqBins\":${spec.freqBins}") + append(",\"durationMs\":${spec.durationMs}") + append(",\"sampleRateHz\":${spec.sampleRateHz}") + append(",\"minHz\":${spec.minHz}") + append(",\"maxHz\":${spec.maxHz}") + append(",\"dbFloor\":${spec.dbFloor}") + append(",\"dbCeil\":${spec.dbCeil}") + append(",\"logFreq\":${spec.logFreq}") + append(",\"data\":${jsonString(data)}") + append("}") + } + } + + internal fun jsonString(s: String): String { + val sb = StringBuilder(s.length + 2) + sb.append('"') + for (c in s) { + when (c) { + '"' -> sb.append("\\\"") + + '\\' -> sb.append("\\\\") + + '\n' -> sb.append("\\n") + + '\r' -> sb.append("\\r") + + '\t' -> sb.append("\\t") + + '\b' -> sb.append("\\b") + + '\u000C' -> sb.append("\\f") + + // Valid in JSON but terminate a JS string literal. + '\u2028' -> sb.append("\\u2028") + + '\u2029' -> sb.append("\\u2029") + + else -> if (c < ' ') { + sb.append("\\u").append(c.code.toString(HEX_RADIX).padStart(UNICODE_ESCAPE_HEX_LENGTH, '0')) + } else { + sb.append(c) + } + } + } + sb.append('"') + return sb.toString() + } +} diff --git a/frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt b/frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt index bbc926cd..ea18e664 100644 --- a/frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt +++ b/frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt @@ -13,62 +13,62 @@ class PlayerBridgeEscapeTest { @Test fun plainStringUnchanged() { - assertEquals("hello world", PlayerBridge.escapeJs("hello world")) + assertEquals("hello world", PlayerPayloads.escapeJs("hello world")) } @Test fun backslashEscaped() { - assertEquals("a\\\\b", PlayerBridge.escapeJs("a\\b")) + assertEquals("a\\\\b", PlayerPayloads.escapeJs("a\\b")) } @Test fun singleQuoteEscaped() { - assertEquals("it\\'s", PlayerBridge.escapeJs("it's")) + assertEquals("it\\'s", PlayerPayloads.escapeJs("it's")) } @Test fun doubleQuoteEscaped() { - assertEquals("say \\\"hi\\\"", PlayerBridge.escapeJs("say \"hi\"")) + assertEquals("say \\\"hi\\\"", PlayerPayloads.escapeJs("say \"hi\"")) } @Test fun newlineEscaped() { - assertEquals("line1\\nline2", PlayerBridge.escapeJs("line1\nline2")) + assertEquals("line1\\nline2", PlayerPayloads.escapeJs("line1\nline2")) } @Test fun carriageReturnRemoved() { - assertEquals("ab", PlayerBridge.escapeJs("a\rb")) + assertEquals("ab", PlayerPayloads.escapeJs("a\rb")) } @Test fun angleBracketsEscaped() { - assertEquals("\\x3cscript\\x3e", PlayerBridge.escapeJs("