Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
76aa703
feat(ui): scaffold shadcn-svelte foundation for player refresh (Phase 0)
twangodev Jun 1, 2026
0304acc
feat(ui): adopt sv11 waveform audio player (Phase 1)
twangodev Jun 6, 2026
46ceaa1
fix(ui): drop crossorigin so file:// media plays in JCEF
twangodev Jun 6, 2026
e315075
feat: compute waveform via ffmpeg and push to the player
twangodev Jun 6, 2026
24b8972
fix: harden waveform extraction + delivery (review follow-up)
twangodev Jun 6, 2026
f92d6cf
feat(ui): match sv11 look & feel for the audio player
twangodev Jun 6, 2026
f389e77
feat: serve media over a loopback http server (enables waveform scratch)
twangodev Jun 6, 2026
5c7f73e
fix(ui): volume uses the scrub-bar slider; speed shows a gauge icon
twangodev Jun 6, 2026
834c705
feat: add a codec inspector to the audio player
twangodev Jun 7, 2026
0aaa816
fix(ui): drop the container from the inspector summary (it dupes the …
twangodev Jun 7, 2026
1df56c2
feat: surface embedded tags and blur the cover behind the player
twangodev Jun 7, 2026
83454ac
fix: show the canonical codec name, not FFmpeg's decoder name
twangodev Jun 7, 2026
fe8ae7f
test: add tagged sample tracks for the inspector (Kevin MacLeod, CC BY)
twangodev Jun 7, 2026
9b35557
docs: tidy sample media credits
twangodev Jun 7, 2026
4296a10
feat(ui): refresh the video player to match sv11 + add a video inspector
twangodev Jun 7, 2026
e750e60
fix(ui): stop the video player stuttering (per-frame seek feedback loop)
twangodev Jun 7, 2026
0985da1
perf(transcode): encode VP9 in real time so converting doesn't appear…
twangodev Jun 7, 2026
efb9038
feat(ui): remove fullscreen from the video player
twangodev Jun 7, 2026
207c0d8
fix: don't drop the transcode's ready transition before the page mounts
twangodev Jun 7, 2026
b59ba96
style: clear detekt violations blocking CI
twangodev Jun 7, 2026
3f7e7f8
fix: address PR review findings in the sv11 player
twangodev Jun 7, 2026
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
.kotlin
.qodana
build
src/main/resources/player/index.html
# Generated by `npm run build` in ui/ (vite outDir, emptyOutDir wipes it each build)
src/main/resources/player/
18 changes: 18 additions & 0 deletions assets/CREDITS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Sample Media Credits

Audio fixtures used to exercise the player (codec inspector, tags, album art).

## Kevin MacLeod Tracks

Royalty-free music by **Kevin MacLeod** (https://incompetech.com), licensed
under **Creative Commons: By Attribution 4.0** (https://creativecommons.org/licenses/by/4.0/):

| File | Track |
|------|-------|
| `sneaky-snitch.mp3` | "Sneaky Snitch" |
| `local-forecast-elevator.mp3` | "Local Forecast - Elevator" |
| `monkeys-spinning-monkeys.mp3` | "Monkeys Spinning Monkeys" |
| `fluffing-a-duck.mp3` | "Fluffing a Duck" |
| `pixel-peeker-polka-faster.mp3` | "Pixel Peeker Polka (faster)" |

> Music by Kevin MacLeod — incompetech.com — Licensed under Creative Commons: By Attribution 4.0.
Binary file added assets/fluffing-a-duck.mp3
Binary file not shown.
Binary file added assets/local-forecast-elevator.mp3
Binary file not shown.
Binary file added assets/monkeys-spinning-monkeys.mp3
Binary file not shown.
Binary file added assets/pixel-peeker-polka-faster.mp3
Binary file not shown.
Binary file added assets/sneaky-snitch.mp3
Binary file not shown.
104 changes: 100 additions & 4 deletions src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.intellij.ui.jcef.JBCefBrowser
import com.intellij.ui.jcef.JBCefBrowserBase
import com.intellij.ui.jcef.JBCefJSQuery
import dev.twango.jetplay.transcode.MediaInfo
import javax.swing.SwingUtilities

class PlayerBridge(private val browser: JBCefBrowser) {
Expand All @@ -29,13 +30,38 @@
}
}

fun updateProgress(percent: Double) = executeJs("window.jetplayUpdateProgress?.($percent)")
// Each of these stashes its value on window before calling the handler: the
// transcode/download can finish before the page has mounted and defined the
// handler (a fast transcode easily beats the page load), in which case the
// `?.` call is a silent no-op. The app reads the stash on mount so the state
// transition (esp. mediaReady → 'ready') is never dropped, leaving it stuck.
fun updateProgress(percent: Double) =
executeJs("window.__jetplayProgress=$percent;window.jetplayUpdateProgress?.($percent)")

fun updateDownloadProgress(percent: Double) = executeJs("window.jetplayUpdateDownloadProgress?.($percent)")
fun updateDownloadProgress(percent: Double) =
executeJs("window.__jetplayDownloadProgress=$percent;window.jetplayUpdateDownloadProgress?.($percent)")

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

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

// Stash the bars as well as calling the handler: extraction can finish
// before the page defines window.jetplayWaveform (short files), so the app
// reads window.__jetplayWaveform on mount to avoid dropping an early push.
fun sendWaveform(bars: List<Double>) = executeJs(
"window.__jetplayWaveform=[${bars.joinToString(",")}];" +
"if(window.jetplayWaveform)window.jetplayWaveform(window.__jetplayWaveform)",
)

// Same stash-then-call pattern as sendWaveform: the probe can finish before
// the page defines window.jetplayMediaInfo, so the app reads
// window.__jetplayMediaInfo on mount to avoid dropping an early push.
fun sendMediaInfo(info: MediaInfo) {
val json = mediaInfoJson(info) ?: return
executeJs("window.__jetplayMediaInfo=$json;if(window.jetplayMediaInfo)window.jetplayMediaInfo(window.__jetplayMediaInfo)")
}

fun loadHtml(html: String) = browser.loadHTML(html)

Expand All @@ -46,12 +72,82 @@
}

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")

// The media-info payload carries arbitrary tag text and a base64 art URL,
// so it's built as strict JSON (a subset of JS) rather than spliced.
// Returns null when there's nothing to send.
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(",", "{", "}")
}

internal fun jsonString(s: String): String {
val sb = StringBuilder(s.length + 2)
sb.append('"')
for (c in s) {
when (c) {
'"' -> sb.append("\\\"")

Check warning on line 123 in src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constant conditions

'when' branch is never reachable

'\\' -> sb.append("\\\\")

Check warning on line 125 in src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constant conditions

'when' branch is never reachable

'\n' -> sb.append("\\n")

Check warning on line 127 in src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constant conditions

'when' branch is never reachable

'\r' -> sb.append("\\r")

Check warning on line 129 in src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constant conditions

'when' branch is never reachable

'\t' -> sb.append("\\t")

Check warning on line 131 in src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constant conditions

'when' branch is never reachable

'\b' -> sb.append("\\b")

Check warning on line 133 in src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constant conditions

'when' branch is never reachable

'\u000C' -> sb.append("\\f")

Check warning on line 135 in src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constant conditions

'when' branch is never reachable

// Valid in JSON but terminate a JS string literal — must escape.
'\u2028' -> sb.append("\\u2028")

Check warning on line 138 in src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constant conditions

'when' branch is never reachable

'\u2029' -> sb.append("\\u2029")

Check warning on line 140 in src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constant conditions

'when' branch is never reachable

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()
}
}
}
69 changes: 65 additions & 4 deletions src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.intellij.ide.BrowserUtil
import com.intellij.notification.NotificationAction
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.fileEditor.FileEditor
import com.intellij.openapi.fileEditor.FileEditorState
import com.intellij.openapi.project.Project
Expand All @@ -16,16 +17,26 @@ import dev.twango.jetplay.browser.PlayerBridge
import dev.twango.jetplay.browser.PlayerConfig
import dev.twango.jetplay.browser.PlayerHtmlLoader
import dev.twango.jetplay.browser.UiStrings
import dev.twango.jetplay.media.MediaServer
import dev.twango.jetplay.media.MediaSource
import dev.twango.jetplay.media.RemoteFileMediaSource
import dev.twango.jetplay.transcode.FfmpegAvailability
import dev.twango.jetplay.transcode.MediaInfoExtractor
import dev.twango.jetplay.transcode.MediaTranscoder
import dev.twango.jetplay.transcode.TranscodeSession
import dev.twango.jetplay.transcode.WaveformExtractor
import dev.twango.jetplay.transfer.DownloadSession
import java.awt.BorderLayout
import java.beans.PropertyChangeListener
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Future
import javax.swing.JComponent
import javax.swing.JPanel

// FileEditor mandates ~10 overrides; with the media-loading helpers this trips
// detekt's per-class function cap. Splitting them out would scatter tightly
// coupled editor logic for no readability gain.
@Suppress("TooManyFunctions")
class MediaFileEditor(private val project: Project, private val file: VirtualFile, private val source: MediaSource) :
UserDataHolderBase(),
FileEditor {
Expand All @@ -35,6 +46,13 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil
private val htmlLoader = PlayerHtmlLoader(bridge)
private var downloadSession: DownloadSession? = null
private var transcodeSession: TranscodeSession? = null
private var waveformFuture: Future<*>? = null
private var mediaInfoFuture: Future<*>? = null

// Loopback URLs handed out for this editor's media, released on dispose.
private val servedUrls = CopyOnWriteArrayList<String>()

private fun serve(file: java.io.File): String = MediaServer.serve(file).also { servedUrls.add(it) }
private val uiStrings = UiStrings(
downloadingLabel = JetPlayBundle.message("ui.downloading.label"),
transcodingLabel = JetPlayBundle.message("ui.transcoding.label"),
Expand All @@ -54,6 +72,44 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil
} else {
playDirectly()
}
maybeSendWaveform()
maybeSendMediaInfo()
}

/**
* Decode the waveform for local audio here with FFmpeg (off the EDT) and
* push the bars to the player — cheaper than the browser decoding the whole
* file, and works for any format. Remote sources need their download first
* (skipped for now); video has no waveform.
*/
private fun maybeSendWaveform() {
if (source.isVideo || source.isRemote || !FfmpegAvailability.available) return
// Raw telephony codecs need demuxer hints the extractor doesn't apply;
// skip them rather than risk a garbage waveform.
if (source.extension.lowercase() in MediaTranscoder.rawAudioExtensions) return
val localFile = source.toLocalFile()
waveformFuture = ApplicationManager.getApplication().executeOnPooledThread {
if (bridge.disposed) return@executeOnPooledThread
val bars = WaveformExtractor.extract(localFile)
if (bars.isNotEmpty()) bridge.sendWaveform(bars)
}
}

/**
* Probe the file's container/codec/stream details with FFmpeg (off the EDT)
* and push them to the player's codec inspector — audio and video both.
* Local only (remote needs its download first); raw audio codecs lack the
* demuxer hints to probe cleanly, so they're skipped.
*/
private fun maybeSendMediaInfo() {
if (source.isRemote || !FfmpegAvailability.available) return
if (source.extension.lowercase() in MediaTranscoder.rawAudioExtensions) return
val localFile = source.toLocalFile()
mediaInfoFuture = ApplicationManager.getApplication().executeOnPooledThread {
if (bridge.disposed) return@executeOnPooledThread
val info = MediaInfoExtractor.extract(localFile)
if (info != null) bridge.sendMediaInfo(info)
}
}

private fun startDownload() {
Expand All @@ -71,7 +127,7 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil
if (source.needsTranscoding) {
startTranscoding()
} else {
bridge.mediaReady(source.resolvePlayableUrl())
bridge.mediaReady(serve(source.toLocalFile()))
}
}.also { it.start() }
}
Expand All @@ -82,7 +138,7 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil
return
}
if (source.isRemote) {
bridge.executeJs("window.jetplayStartTranscoding?.()")
bridge.executeJs("window.__jetplayState='loading';window.__jetplayProgress=0;window.jetplayStartTranscoding?.()")
} else {
htmlLoader.load(
PlayerConfig(
Expand All @@ -95,7 +151,9 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil
),
)
}
transcodeSession = TranscodeSession(source.toLocalFile(), bridge).also { it.start() }
transcodeSession = TranscodeSession(source.toLocalFile(), bridge) { transcoded ->
bridge.mediaReady(serve(transcoded))
}.also { it.start() }
}

private fun playDirectly() {
Expand All @@ -104,7 +162,7 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil
isVideo = source.isVideo,
fileName = source.fileName,
fileExtension = source.extension,
mediaUrl = source.resolvePlayableUrl(),
mediaUrl = serve(source.toLocalFile()),
ui = uiStrings,
),
)
Expand Down Expand Up @@ -140,6 +198,9 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil
override fun dispose() {
downloadSession?.cancel()
transcodeSession?.cancel()
waveformFuture?.cancel(true)
mediaInfoFuture?.cancel(true)
servedUrls.forEach(MediaServer::release)
bridge.dispose()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,5 @@ class LocalFileMediaSource(private val file: VirtualFile) : MediaSource {

override val isRemote: Boolean = false

override fun resolvePlayableUrl(): String = file.toNioPath().toUri().toString()

override fun toLocalFile(): File = file.toNioPath().toFile()
}
Loading
Loading