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
115 changes: 10 additions & 105 deletions frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -81,11 +80,15 @@ class PlayerBridge(private val browser: JBCefBrowser) {
fun updateProgress(percent: Double) =
executeJs("window.__jetplayProgress=$percent;window.jetplayUpdateProgress?.($percent)")

fun mediaReady(url: String) =
executeJs("window.__jetplayReadyUrl='${escapeJs(url)}';window.jetplayReady?.('${escapeJs(url)}')")
fun mediaReady(url: String) {
val escaped = PlayerPayloads.escapeJs(url)
executeJs("window.__jetplayReadyUrl='$escaped';window.jetplayReady?.('$escaped')")
}

fun showError(message: String) =
executeJs("window.__jetplayError='${escapeJs(message)}';window.jetplayError?.('${escapeJs(message)}')")
fun showError(message: String) {
val escaped = PlayerPayloads.escapeJs(message)
executeJs("window.__jetplayError='$escaped';window.jetplayError?.('$escaped')")
}

// Same stash-then-notify race guard as updateProgress.
fun sendWaveform(bars: List<Double>) = executeJs(
Expand All @@ -94,13 +97,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)")
}

Expand All @@ -121,102 +124,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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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("};</script>")
Expand Down
104 changes: 104 additions & 0 deletions frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerPayloads.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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("<script>"))
assertEquals("\\x3cscript\\x3e", PlayerPayloads.escapeJs("<script>"))
}

@Test
fun combinedSpecialCharacters() {
val input = "it's a <b>\"test\"</b>\nwith\\stuff\r"
val expected = "it\\'s a \\x3cb\\x3e\\\"test\\\"\\x3c/b\\x3e\\nwith\\\\stuff"
assertEquals(expected, PlayerBridge.escapeJs(input))
assertEquals(expected, PlayerPayloads.escapeJs(input))
}

@Test
fun emptyString() {
assertEquals("", PlayerBridge.escapeJs(""))
assertEquals("", PlayerPayloads.escapeJs(""))
}

// --- media-info JSON serialization (carries arbitrary tag text) ---

@Test
fun jsonStringEscapesQuotesBackslashControlAndLineSeparators() {
assertEquals("\"a\\\"b\"", PlayerBridge.jsonString("a\"b"))
assertEquals("\"a\\\\b\"", PlayerBridge.jsonString("a\\b"))
assertEquals("\"l1\\nl2\"", PlayerBridge.jsonString("l1\nl2"))
assertEquals("\"a\\\"b\"", PlayerPayloads.jsonString("a\"b"))
assertEquals("\"a\\\\b\"", PlayerPayloads.jsonString("a\\b"))
assertEquals("\"l1\\nl2\"", PlayerPayloads.jsonString("l1\nl2"))
// A lone control char becomes a \u00xx escape.
assertEquals("\"a\\u0001b\"", PlayerBridge.jsonString("a\u0001b"))
assertEquals("\"a\\u0001b\"", PlayerPayloads.jsonString("a\u0001b"))
// U+2028 is legal JSON but terminates a JS string literal, so it must escape.
assertEquals("\"a\\u2028b\"", PlayerBridge.jsonString("a\u2028b"))
assertEquals("\"a\\u2028b\"", PlayerPayloads.jsonString("a\u2028b"))
}

@Test
Expand All @@ -86,7 +86,7 @@ class PlayerBridgeEscapeTest {
tags = listOf(MediaTag("Title", "O'Brien \"x\""), MediaTag("Artist", "A\\B")),
albumArt = "data:image/png;base64,AAAA",
)
val json = PlayerBridge.mediaInfoJson(info)!!
val json = PlayerPayloads.mediaInfoJson(info)!!
assertTrue(json.startsWith("{") && json.endsWith("}"))
assertTrue(json.contains("\"sampleRateHz\":44100"))
assertTrue(json.contains("\"label\":\"Title\",\"value\":\"O'Brien \\\"x\\\"\""))
Expand All @@ -99,14 +99,14 @@ class PlayerBridgeEscapeTest {
@Test
fun mediaInfoJsonReturnsNullWhenEverythingIsEmpty() {
val empty = MediaInfo(null, null, null, null, null, null, null, null, null)
assertNull(PlayerBridge.mediaInfoJson(empty))
assertNull(PlayerPayloads.mediaInfoJson(empty))
}

// --- spectrogram JSON serialization (carries a base64 magnitude matrix) ---

@Test
fun spectrogramJsonForNullSignalsUnavailable() {
assertEquals("{\"ok\":false}", PlayerBridge.spectrogramJson(null))
assertEquals("{\"ok\":false}", PlayerPayloads.spectrogramJson(null))
}

@Test
Expand All @@ -123,7 +123,7 @@ class PlayerBridgeEscapeTest {
logFreq = true,
magnitudes = byteArrayOf(0, 127, -1, 64),
)
val json = PlayerBridge.spectrogramJson(spec)
val json = PlayerPayloads.spectrogramJson(spec)
assertTrue(json.contains("\"ok\":true"))
assertTrue(json.contains("\"timeCols\":2"))
assertTrue(json.contains("\"freqBins\":2"))
Expand Down
Loading