Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a758ce5
refactor: restructure into shared/frontend/backend content modules
twangodev Jun 13, 2026
23d9a9e
feat: add backend module with @Rpc MediaAccessor service
twangodev Jun 13, 2026
9d68437
feat: fall back to error state when JCEF is unavailable, trace media …
twangodev Jun 13, 2026
1c9cdef
build: point detekt at content-module sources
twangodev Jun 13, 2026
77cfcf4
feat: fail playback explicitly when the media URL never reaches the b…
twangodev Jun 13, 2026
56ca8b8
test: re-home tests into content modules and cover the split-mode RPC…
twangodev Jun 13, 2026
b429aee
fix(player): surface empty/unresolvable media instead of serving 0-by…
twangodev Jun 13, 2026
01af498
test(backend): cover readRange filling the buffer across partial reads
twangodev Jun 13, 2026
8cbeecf
refactor(modular): align RPC modules with the platform template
twangodev Jun 13, 2026
43baaf4
fix(build): raise pluginSinceBuild to 253 for Platform V2 compatibility
twangodev Jun 13, 2026
f2433bf
fix(backend): scope RPC file access to a live project and dispatch bl…
twangodev Jun 13, 2026
864adf3
refactor(frontend): normalize media token extraction
twangodev Jun 13, 2026
b5c60cb
docs: point extension source-of-truth at the frontend module descriptor
twangodev Jun 13, 2026
1367fc6
ci(verify): tolerate split-mode internal APIs and external javacv bin…
twangodev Jun 13, 2026
9e92486
ci(verify): name module jars by module id so the verifier needs no su…
twangodev Jun 13, 2026
209b529
refactor: trim comments to terse single-line notes
twangodev Jun 13, 2026
2a32ef5
feat: render the media player on the client in split mode
twangodev Jun 14, 2026
0f1b984
fix(build): resolve frontend-split RD jars lazily so CI configures wi…
twangodev Jun 14, 2026
42da163
fix(media): cap streamRange writes to the declared byte count
twangodev Jun 14, 2026
e9f4f1a
fix(media): release loopback URLs registered after dispose
twangodev Jun 14, 2026
3533fc7
build: fail early when frontend-split internal jars are missing
twangodev Jun 14, 2026
6294ed9
refactor(media): replace MediaLoader thread/Future plumbing with coro…
twangodev Jun 14, 2026
ab5d2bd
fix(player): make split-mode playback reliable against JCEF load races
twangodev Jun 14, 2026
78abb4c
refactor: remove the dead "downloading" UI state
twangodev Jun 14, 2026
b854848
fix(frontend): address CodeRabbit review findings
twangodev Jun 15, 2026
6b3f37f
ci: fix verify on EAP layouts and drop stale downloading specs
twangodev Jun 15, 2026
912027b
refactor: rename frontend-split content module to client
twangodev Jun 15, 2026
5299b0c
ci(verify): drop EAP IDEs from the verify matrix; port discovery to P…
twangodev Jun 15, 2026
beb4614
docs: correct min platform to 2025.3 to match pluginSinceBuild
twangodev Jun 15, 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
46 changes: 46 additions & 0 deletions .github/scripts/list-verifier-ides.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Emit the IDEs `verifyPlugin` should target as a compact JSON array.

`recommended()` (resolved by the printProductsReleases task) also returns the
current EAP, e.g. ["IU-2026.1.3", "IU-262.7581.18"]. We keep only released IDEs:
EAP builds relocate the @ApiStatus.Internal split-mode classes the client module
compiles against, so the plugin can't even build there. 2026.2 rejoins the matrix
automatically once it ships as IU-2026.2.x.

Feeds a GitHub Actions discover job whose output drives a one-runner-per-IDE matrix:

- id: list
run: echo "ides=$(python3 .github/scripts/list-verifier-ides.py)" >> "$GITHUB_OUTPUT"
"""

import json
import subprocess
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]


def is_release(ide: str) -> bool:
"""A release's leading version is a calendar year (IU-2026.1.3); an EAP's is a
build branch number (IU-262.7581.18), which is below 2000."""
major = int(ide.split("-")[1].split(".")[0])
return major >= 2000


def main() -> None:
result = subprocess.run(
["./gradlew", "-q", "printProductsReleases"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=True,
)
ides = [line for line in result.stdout.splitlines() if line]
# printProductsReleases repeats builds across update channels; one verify run
# per unique IDE is enough, and sorting keeps the matrix order stable.
releases = sorted({ide for ide in ides if is_release(ide)})
print(json.dumps(releases, separators=(",", ":")))


if __name__ == "__main__":
main()
23 changes: 0 additions & 23 deletions .github/scripts/list-verifier-ides.sh

This file was deleted.

2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ jobs:

- name: List verifier IDEs
id: list
run: echo "ides=$(.github/scripts/list-verifier-ides.sh)" >> "$GITHUB_OUTPUT"
run: echo "ides=$(python3 .github/scripts/list-verifier-ides.py)" >> "$GITHUB_OUTPUT"

# Run plugin structure verification along with IntelliJ Plugin Verifier.
# One runner per IDE so a single IDE failure doesn't cascade through the
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
.qodana
build
# Generated by `npm run build` in ui/ (vite outDir, emptyOutDir wipes it each build)
src/main/resources/player/
frontend/src/main/resources/player/
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,23 @@ IntelliJ Platform plugin providing native audio/video playback in JetBrains IDEs

- Kotlin
- Gradle (Kotlin DSL)
- IntelliJ Platform SDK (2025.2+)
- IntelliJ Platform SDK (2025.3+)
- JCEF (JBCefBrowser) for media rendering
- JDK 21

## Project Structure

- `src/main/kotlin/dev/twango/jetplay/` — plugin source
- `src/main/resources/META-INF/plugin.xml` — plugin descriptor (single source of truth for supported extensions)
- `shared/`, `frontend/`, `client/`, `backend/` — Plugin Model V2 content modules, each under `<module>/src/main/kotlin/dev/twango/jetplay/`. `shared` (loads everywhere): shared types, RPC contract, i18n bundle. `frontend` (loads on the Remote Dev host **and** client): file type + editor provider + JCEF player + loopback media server; JCEF is guarded off on the host. `client` (JetBrains Client only, binds the platform's `intellij.platform.frontend.split` module): `rdclient.fileEditorModelHandler` that renders the player client-side in split mode. `backend` (host): ffmpeg + RPC byte/transcode access.
- `src/main/resources/META-INF/plugin.xml` — root plugin descriptor (content-module wiring only)
- `frontend/src/main/resources/dev.twango.jetplay.frontend.xml` — frontend module descriptor; single source of truth for supported extensions. The `frontend` module loads on both host and client, so its `fileType`/`fileEditorProvider` register on the host (for detection/selection) while the JCEF player renders on the client.
- `gradle.properties` — plugin metadata and version config

## Conventions

- Follow IntelliJ Platform plugin conventions and API patterns
- Use `FileEditorProvider` / `FileEditor` for registering custom editors
- Keep the plugin lightweight — no unnecessary services or actions
- Supported extensions are defined in `plugin.xml`, not hardcoded in Kotlin
- Supported extensions are defined in the frontend module descriptor (`dev.twango.jetplay.frontend.xml`), not hardcoded in Kotlin

## Build

Expand Down
48 changes: 48 additions & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import org.jetbrains.intellij.platform.gradle.TestFrameworkType

dependencies {
intellijPlatform {
bundledModule("intellij.platform.kernel.backend")
bundledModule("intellij.platform.rpc")
bundledModule("intellij.platform.rpc.backend")
bundledModule("intellij.platform.backend")
compileOnly(libs.kotlin.serialization.core.jvm)
compileOnly(libs.kotlin.serialization.json.jvm)
testFramework(TestFrameworkType.Platform)
}
implementation(project(":shared"))

testImplementation(libs.junit)
testImplementation(libs.opentest4j)

implementation("org.bytedeco:javacv:1.5.13") {
exclude(group = "org.bytedeco", module = "opencv")
exclude(group = "org.bytedeco", module = "openblas")
exclude(group = "org.bytedeco", module = "flycapture")
exclude(group = "org.bytedeco", module = "libdc1394")
exclude(group = "org.bytedeco", module = "libfreenect")
exclude(group = "org.bytedeco", module = "libfreenect2")
exclude(group = "org.bytedeco", module = "librealsense")
exclude(group = "org.bytedeco", module = "librealsense2")
exclude(group = "org.bytedeco", module = "videoinput")
exclude(group = "org.bytedeco", module = "artoolkitplus")
exclude(group = "org.bytedeco", module = "flandmark")
exclude(group = "org.bytedeco", module = "leptonica")
exclude(group = "org.bytedeco", module = "tesseract")
}
implementation("org.bytedeco:ffmpeg:7.1-1.5.13:linux-x86_64")
implementation("org.bytedeco:ffmpeg:7.1-1.5.13:macosx-x86_64")
implementation("org.bytedeco:ffmpeg:7.1-1.5.13:macosx-arm64")
implementation("org.bytedeco:ffmpeg:7.1-1.5.13:windows-x86_64")
implementation("org.bytedeco:javacpp:1.5.13:linux-x86_64")
implementation("org.bytedeco:javacpp:1.5.13:macosx-x86_64")
implementation("org.bytedeco:javacpp:1.5.13:macosx-arm64")
implementation("org.bytedeco:javacpp:1.5.13:windows-x86_64")
}

// IPGP names the content-module jar "<rootProject>.<module>" (jetplay.backend), but the platform and the
// plugin verifier resolve content modules by "lib/modules/<moduleId>.jar". Align the jar name with the module
// id so the descriptor resolves instead of falling back to scanning every bundled jar (incl. javacv).
tasks.named<org.jetbrains.intellij.platform.gradle.tasks.ComposedJarTask>("composedJar") {
archiveBaseName.set("dev.twango.jetplay.backend")
}
122 changes: 122 additions & 0 deletions backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
@file:Suppress("UnstableApiUsage")

package dev.twango.jetplay.rpc

import com.intellij.ide.vfs.VirtualFileId
import com.intellij.ide.vfs.virtualFile
import com.intellij.platform.project.ProjectId
import com.intellij.platform.project.findProjectOrNull
import dev.twango.jetplay.media.MediaInfo
import dev.twango.jetplay.transcode.FfmpegAvailability
import dev.twango.jetplay.transcode.MediaInfoExtractor
import dev.twango.jetplay.transcode.TranscodeRunner
import dev.twango.jetplay.transcode.WaveformExtractor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import java.io.File
import java.io.RandomAccessFile

private const val CHUNK_BYTES = 1 shl 20 // 1 MB

class MediaAccessorImpl : MediaAccessor {

// Resolve only within a live project; a dead projectId means a stale RPC caller.
private fun resolveFile(fileId: VirtualFileId, projectId: ProjectId): File? {
if (projectId.findProjectOrNull() == null) return null

Check warning on line 29 in backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constant conditions

Condition 'projectId.findProjectOrNull() == null' is always false
return fileId.virtualFile()?.takeIf { it.isValid }?.let { vf ->
runCatching { vf.toNioPath().toFile() }.getOrNull()?.takeIf { it.isFile }
}
}

override suspend fun streamFileBytes(fileId: VirtualFileId, projectId: ProjectId): Flow<ByteArray> = flow {
val file = resolveFile(fileId, projectId) ?: return@flow
RandomAccessFile(file, "r").use { raf ->
val buf = ByteArray(CHUNK_BYTES)
while (true) {
val n = raf.read(buf)
if (n <= 0) break
emit(if (n == buf.size) buf.copyOf() else buf.copyOf(n))
}
}
}.flowOn(Dispatchers.IO)

override suspend fun fileLength(fileId: VirtualFileId, projectId: ProjectId): Long =
withContext(Dispatchers.IO) { resolveFile(fileId, projectId)?.length() ?: -1L }

override suspend fun readRange(fileId: VirtualFileId, projectId: ProjectId, offset: Long, length: Int): ByteArray =
withContext(Dispatchers.IO) {
if (offset < 0 || length <= 0) return@withContext ByteArray(0)
val file = resolveFile(fileId, projectId) ?: return@withContext ByteArray(0)
RandomAccessFile(file, "r").use { raf ->
raf.seek(offset)
val out = ByteArray(length)
// raf.read() may return a short count; loop until full or EOF.
var total = 0
while (total < length) {
val n = raf.read(out, total, length - total)
if (n < 0) break
total += n
}
when (total) {
0 -> ByteArray(0)
length -> out
else -> out.copyOf(total)
}
}
}

override suspend fun transcodeFile(fileId: VirtualFileId, projectId: ProjectId): Flow<TranscodeEvent> =
channelFlow {
if (!FfmpegAvailability.available) {
send(TranscodeEvent.Unavailable)
return@channelFlow
}
val input = resolveFile(fileId, projectId) ?: run {
send(TranscodeEvent.Failed("source unavailable"))
return@channelFlow
}
val output = try {
withContext(Dispatchers.IO) {
// onProgress fires synchronously inside ffmpeg, so trySend (non-suspending) bridges it.
TranscodeRunner.transcode(input) { pct -> trySend(TranscodeEvent.Progress(pct)) }
}
} catch (e: Exception) {
send(TranscodeEvent.Failed(e.message ?: "unknown"))
return@channelFlow
}
try {
withContext(Dispatchers.IO) {
RandomAccessFile(output, "r").use { raf ->
val buf = ByteArray(CHUNK_BYTES)
while (true) {
val n = raf.read(buf)
if (n <= 0) break
send(TranscodeEvent.Chunk(if (n == buf.size) buf.copyOf() else buf.copyOf(n)))
}
}
}
} finally {
// Frontend now holds the bytes; drop the backend copy.
runCatching { output.delete() }
}
send(TranscodeEvent.Done)
}

override suspend fun extractWaveform(fileId: VirtualFileId, projectId: ProjectId): List<Double> =
withContext(Dispatchers.IO) {
if (!FfmpegAvailability.available) return@withContext emptyList()
val file = resolveFile(fileId, projectId) ?: return@withContext emptyList()
runCatching { WaveformExtractor.extract(file) }.getOrDefault(emptyList())
}

override suspend fun extractMediaInfo(fileId: VirtualFileId, projectId: ProjectId): MediaInfo? =
withContext(Dispatchers.IO) {
if (!FfmpegAvailability.available) return@withContext null
val file = resolveFile(fileId, projectId) ?: return@withContext null
runCatching { MediaInfoExtractor.extract(file) }.getOrNull()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@file:Suppress("UnstableApiUsage")

package dev.twango.jetplay.rpc

import com.intellij.platform.rpc.backend.RemoteApiProvider
import fleet.rpc.remoteApiDescriptor

internal class MediaAccessorProvider : RemoteApiProvider {
override fun RemoteApiProvider.Sink.remoteApis() {
remoteApi(remoteApiDescriptor<MediaAccessor>()) { MediaAccessorImpl() }
}
}
Loading
Loading