From a758ce5fda38467402863563a50980b52ce6e153 Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 17:26:47 -0700 Subject: [PATCH 01/29] refactor: restructure into shared/frontend/backend content modules Convert the single-module plugin into a Plugin Model V2 modular build with split mode enabled. Add shared/frontend/backend Gradle modules, the rpc + kotlinx.serialization plugins, and V2 module descriptors; rewrite the root plugin.xml to a block. The entire dev.twango.jetplay source tree moves into shared temporarily (bytedeco parked on shared) and is partitioned in later steps. player/ and messages/ resources move to frontend; buildPlayerUi and the vite outDir now target the frontend module. --- .gitignore | 2 +- backend/build.gradle.kts | 8 +++ .../resources/dev.twango.jetplay.backend.xml | 7 ++ build.gradle.kts | 70 +++++++------------ frontend/build.gradle.kts | 23 ++++++ .../resources/dev.twango.jetplay.frontend.xml | 15 ++++ .../messages/JetPlayBundle.properties | 0 .../messages/JetPlayBundle_de.properties | 0 .../messages/JetPlayBundle_es.properties | 0 .../messages/JetPlayBundle_fr.properties | 0 .../messages/JetPlayBundle_it.properties | 0 .../messages/JetPlayBundle_ja.properties | 0 .../messages/JetPlayBundle_ko.properties | 0 .../messages/JetPlayBundle_pl.properties | 0 .../messages/JetPlayBundle_pt_BR.properties | 0 .../messages/JetPlayBundle_ru.properties | 0 .../messages/JetPlayBundle_zh_CN.properties | 0 .../messages/JetPlayBundle_zh_TW.properties | 0 gradle.properties | 2 + gradle/libs.versions.toml | 3 + settings.gradle.kts | 31 ++++++++ shared/build.gradle.kts | 38 ++++++++++ .../dev/twango/jetplay/JetPlayBundle.kt | 0 .../dev/twango/jetplay/JetPlayConstants.kt | 0 .../twango/jetplay/browser/PlayerBridge.kt | 0 .../twango/jetplay/browser/PlayerConfig.kt | 0 .../jetplay/browser/PlayerHtmlLoader.kt | 0 .../twango/jetplay/editor/MediaFileEditor.kt | 0 .../jetplay/editor/MediaFileEditorProvider.kt | 0 .../twango/jetplay/editor/MediaFileType.kt | 0 .../dev/twango/jetplay/editor/MediaLoader.kt | 0 .../jetplay/media/LocalFileMediaSource.kt | 0 .../jetplay/media/MediaClassification.kt | 0 .../dev/twango/jetplay/media/MediaServer.kt | 0 .../dev/twango/jetplay/media/MediaSource.kt | 0 .../jetplay/media/RemoteFileMediaSource.kt | 0 .../dev/twango/jetplay/star/StarReminder.kt | 0 .../twango/jetplay/star/StarReminderPolicy.kt | 0 .../jetplay/transcode/FfmpegAvailability.kt | 0 .../jetplay/transcode/MediaInfoExtractor.kt | 0 .../jetplay/transcode/MediaTranscoder.kt | 0 .../jetplay/transcode/TranscodeSession.kt | 0 .../jetplay/transcode/WaveformExtractor.kt | 0 .../jetplay/transfer/DownloadSession.kt | 0 .../resources/dev.twango.jetplay.shared.xml | 2 + .../jetplay/browser/PlayerBridgeEscapeTest.kt | 0 .../jetplay/browser/PlayerHtmlLoaderTest.kt | 0 .../editor/MediaFileEditorProviderTest.kt | 0 .../twango/jetplay/media/MediaServerTest.kt | 0 .../jetplay/star/StarReminderPolicyTest.kt | 0 .../transcode/MediaInfoExtractorTest.kt | 0 .../jetplay/transcode/MediaTranscoderTest.kt | 0 .../transcode/WaveformExtractorTest.kt | 0 src/main/resources/META-INF/plugin.xml | 18 ++--- ui/vite.config.ts | 2 +- 55 files changed, 163 insertions(+), 58 deletions(-) create mode 100644 backend/build.gradle.kts create mode 100644 backend/src/main/resources/dev.twango.jetplay.backend.xml create mode 100644 frontend/build.gradle.kts create mode 100644 frontend/src/main/resources/dev.twango.jetplay.frontend.xml rename {src => frontend/src}/main/resources/messages/JetPlayBundle.properties (100%) rename {src => frontend/src}/main/resources/messages/JetPlayBundle_de.properties (100%) rename {src => frontend/src}/main/resources/messages/JetPlayBundle_es.properties (100%) rename {src => frontend/src}/main/resources/messages/JetPlayBundle_fr.properties (100%) rename {src => frontend/src}/main/resources/messages/JetPlayBundle_it.properties (100%) rename {src => frontend/src}/main/resources/messages/JetPlayBundle_ja.properties (100%) rename {src => frontend/src}/main/resources/messages/JetPlayBundle_ko.properties (100%) rename {src => frontend/src}/main/resources/messages/JetPlayBundle_pl.properties (100%) rename {src => frontend/src}/main/resources/messages/JetPlayBundle_pt_BR.properties (100%) rename {src => frontend/src}/main/resources/messages/JetPlayBundle_ru.properties (100%) rename {src => frontend/src}/main/resources/messages/JetPlayBundle_zh_CN.properties (100%) rename {src => frontend/src}/main/resources/messages/JetPlayBundle_zh_TW.properties (100%) create mode 100644 shared/build.gradle.kts rename {src => shared/src}/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/JetPlayConstants.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/editor/MediaFileType.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/media/MediaServer.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/media/MediaSource.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/star/StarReminder.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/star/StarReminderPolicy.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/transcode/FfmpegAvailability.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt (100%) rename {src => shared/src}/main/kotlin/dev/twango/jetplay/transfer/DownloadSession.kt (100%) create mode 100644 shared/src/main/resources/dev.twango.jetplay.shared.xml rename {src => shared/src}/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt (100%) rename {src => shared/src}/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt (100%) rename {src => shared/src}/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt (100%) rename {src => shared/src}/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt (100%) rename {src => shared/src}/test/kotlin/dev/twango/jetplay/star/StarReminderPolicyTest.kt (100%) rename {src => shared/src}/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt (100%) rename {src => shared/src}/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt (100%) rename {src => shared/src}/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt (100%) diff --git a/.gitignore b/.gitignore index 38cb9b1b..c36e2a10 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts new file mode 100644 index 00000000..e6357d9f --- /dev/null +++ b/backend/build.gradle.kts @@ -0,0 +1,8 @@ +dependencies { + intellijPlatform { + bundledModule("intellij.platform.kernel.backend") + bundledModule("intellij.platform.rpc.backend") + bundledModule("intellij.platform.backend") + } + implementation(project(":shared")) +} diff --git a/backend/src/main/resources/dev.twango.jetplay.backend.xml b/backend/src/main/resources/dev.twango.jetplay.backend.xml new file mode 100644 index 00000000..f9ae6eab --- /dev/null +++ b/backend/src/main/resources/dev.twango.jetplay.backend.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index 45ba2652..311ef2a3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,14 +1,17 @@ import org.jetbrains.changelog.Changelog import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware plugins { id("java") - alias(libs.plugins.kotlin) - alias(libs.plugins.intelliJPlatform) + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.intellij.platform") alias(libs.plugins.changelog) alias(libs.plugins.detekt) alias(libs.plugins.qodana) alias(libs.plugins.kover) + id("rpc") apply false + id("org.jetbrains.kotlin.plugin.serialization") apply false } group = providers.gradleProperty("pluginGroup").get() @@ -18,38 +21,24 @@ kotlin { jvmToolchain(17) } -repositories { - mavenCentral() - intellijPlatform { - defaultRepositories() +subprojects { + apply(plugin = "org.jetbrains.intellij.platform.module") + apply(plugin = "rpc") + apply(plugin = "org.jetbrains.kotlin.jvm") + apply(plugin = "org.jetbrains.kotlin.plugin.serialization") + + kotlin { + jvmToolchain(17) } -} -dependencies { - 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") + dependencies { + intellijPlatform { + intellijIdea(providers.gradleProperty("platformVersion")) + } } - 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") +} +dependencies { detektPlugins(libs.detekt.formatting) testImplementation(libs.junit) @@ -62,6 +51,10 @@ dependencies { bundledModules(providers.gradleProperty("platformBundledModules").map { it.split(',') }) testFramework(TestFrameworkType.Platform) pluginVerifier(libs.versions.pluginVerifier.get()) + + pluginModule(implementation(project(":shared"))) + pluginModule(implementation(project(":frontend"))) + pluginModule(implementation(project(":backend"))) } } @@ -71,6 +64,9 @@ changelog { } intellijPlatform { + splitMode = true + pluginInstallationTarget = SplitModeAware.PluginInstallationTarget.BOTH + pluginConfiguration { name = providers.gradleProperty("pluginName") version = providers.gradleProperty("pluginVersion") @@ -151,21 +147,7 @@ tasks.withType().configureEach { } } -val buildPlayerUi by tasks.registering(Exec::class) { - workingDir = file("ui") - commandLine("bash", "-lc", "npm run build") - inputs.dir("ui/src") - inputs.file("ui/index.html") - inputs.file("ui/vite.config.ts") - inputs.file("ui/package.json") - outputs.file("src/main/resources/player/index.html") -} - tasks { - processResources { - dependsOn(buildPlayerUi) - } - wrapper { gradleVersion = providers.gradleProperty("gradleVersion").get() } diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts new file mode 100644 index 00000000..604d4590 --- /dev/null +++ b/frontend/build.gradle.kts @@ -0,0 +1,23 @@ +dependencies { + intellijPlatform { + bundledModule("intellij.platform.frontend") + compileOnly(libs.kotlin.serialization.core.jvm) + compileOnly(libs.kotlin.serialization.json.jvm) + } + implementation(project(":shared")) +} + +// Svelte player UI is built from the repo-root ui/ tree into this module's resources. +val buildPlayerUi by tasks.registering(Exec::class) { + workingDir = rootProject.file("ui") + commandLine("bash", "-lc", "npm run build") + inputs.dir(rootProject.file("ui/src")) + inputs.file(rootProject.file("ui/index.html")) + inputs.file(rootProject.file("ui/vite.config.ts")) + inputs.file(rootProject.file("ui/package.json")) + outputs.file(layout.projectDirectory.file("src/main/resources/player/index.html")) +} + +tasks.processResources { + dependsOn(buildPlayerUi) +} diff --git a/frontend/src/main/resources/dev.twango.jetplay.frontend.xml b/frontend/src/main/resources/dev.twango.jetplay.frontend.xml new file mode 100644 index 00000000..d427861b --- /dev/null +++ b/frontend/src/main/resources/dev.twango.jetplay.frontend.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/src/main/resources/messages/JetPlayBundle.properties b/frontend/src/main/resources/messages/JetPlayBundle.properties similarity index 100% rename from src/main/resources/messages/JetPlayBundle.properties rename to frontend/src/main/resources/messages/JetPlayBundle.properties diff --git a/src/main/resources/messages/JetPlayBundle_de.properties b/frontend/src/main/resources/messages/JetPlayBundle_de.properties similarity index 100% rename from src/main/resources/messages/JetPlayBundle_de.properties rename to frontend/src/main/resources/messages/JetPlayBundle_de.properties diff --git a/src/main/resources/messages/JetPlayBundle_es.properties b/frontend/src/main/resources/messages/JetPlayBundle_es.properties similarity index 100% rename from src/main/resources/messages/JetPlayBundle_es.properties rename to frontend/src/main/resources/messages/JetPlayBundle_es.properties diff --git a/src/main/resources/messages/JetPlayBundle_fr.properties b/frontend/src/main/resources/messages/JetPlayBundle_fr.properties similarity index 100% rename from src/main/resources/messages/JetPlayBundle_fr.properties rename to frontend/src/main/resources/messages/JetPlayBundle_fr.properties diff --git a/src/main/resources/messages/JetPlayBundle_it.properties b/frontend/src/main/resources/messages/JetPlayBundle_it.properties similarity index 100% rename from src/main/resources/messages/JetPlayBundle_it.properties rename to frontend/src/main/resources/messages/JetPlayBundle_it.properties diff --git a/src/main/resources/messages/JetPlayBundle_ja.properties b/frontend/src/main/resources/messages/JetPlayBundle_ja.properties similarity index 100% rename from src/main/resources/messages/JetPlayBundle_ja.properties rename to frontend/src/main/resources/messages/JetPlayBundle_ja.properties diff --git a/src/main/resources/messages/JetPlayBundle_ko.properties b/frontend/src/main/resources/messages/JetPlayBundle_ko.properties similarity index 100% rename from src/main/resources/messages/JetPlayBundle_ko.properties rename to frontend/src/main/resources/messages/JetPlayBundle_ko.properties diff --git a/src/main/resources/messages/JetPlayBundle_pl.properties b/frontend/src/main/resources/messages/JetPlayBundle_pl.properties similarity index 100% rename from src/main/resources/messages/JetPlayBundle_pl.properties rename to frontend/src/main/resources/messages/JetPlayBundle_pl.properties diff --git a/src/main/resources/messages/JetPlayBundle_pt_BR.properties b/frontend/src/main/resources/messages/JetPlayBundle_pt_BR.properties similarity index 100% rename from src/main/resources/messages/JetPlayBundle_pt_BR.properties rename to frontend/src/main/resources/messages/JetPlayBundle_pt_BR.properties diff --git a/src/main/resources/messages/JetPlayBundle_ru.properties b/frontend/src/main/resources/messages/JetPlayBundle_ru.properties similarity index 100% rename from src/main/resources/messages/JetPlayBundle_ru.properties rename to frontend/src/main/resources/messages/JetPlayBundle_ru.properties diff --git a/src/main/resources/messages/JetPlayBundle_zh_CN.properties b/frontend/src/main/resources/messages/JetPlayBundle_zh_CN.properties similarity index 100% rename from src/main/resources/messages/JetPlayBundle_zh_CN.properties rename to frontend/src/main/resources/messages/JetPlayBundle_zh_CN.properties diff --git a/src/main/resources/messages/JetPlayBundle_zh_TW.properties b/frontend/src/main/resources/messages/JetPlayBundle_zh_TW.properties similarity index 100% rename from src/main/resources/messages/JetPlayBundle_zh_TW.properties rename to frontend/src/main/resources/messages/JetPlayBundle_zh_TW.properties diff --git a/gradle.properties b/gradle.properties index 17ac3a06..67464d11 100644 --- a/gradle.properties +++ b/gradle.properties @@ -33,3 +33,5 @@ org.gradle.configuration-cache = true # Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html org.gradle.caching = true + +org.gradle.jvmargs = -Xmx4096m -XX:MaxMetaspaceSize=512m diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 75cc5c21..7bf9a463 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] # libraries junit = "4.13.2" +kotlin-serialization = "1.9.0" opentest4j = "1.3.0" pluginVerifier = "1.405" @@ -15,6 +16,8 @@ qodana = "2025.3.2" [libraries] detekt-formatting = { module = "dev.detekt:detekt-rules-ktlint-wrapper", version.ref = "detekt" } junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlin-serialization-core-jvm = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core-jvm", version.ref = "kotlin-serialization" } +kotlin-serialization-json-jvm = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", version.ref = "kotlin-serialization" } opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "opentest4j" } verifier-cli = { module = "org.jetbrains.intellij.plugins:verifier-cli", version.ref = "pluginVerifier" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2ad3077b..a70684fa 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,36 @@ +@file:Suppress("UnstableApiUsage") + +import org.jetbrains.intellij.platform.gradle.extensions.intellijPlatform + +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies/") + } + plugins { + id("rpc") version "2.3.20-RC2-0.1" + id("org.jetbrains.kotlin.jvm") version "2.3.20" + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.20" + } +} + plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" + id("org.jetbrains.intellij.platform.settings") version "2.16.0" } rootProject.name = "jetplay" + +dependencyResolutionManagement { + repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } + } +} + +include("shared") +include("frontend") +include("backend") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 00000000..939cc377 --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,38 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + +// STEP 1 ONLY: bytedeco lives here temporarily while all code is in shared. +// Step 2 moves it to backend/build.gradle.kts; shared then keeps only serialization. +// Tests are also parked here temporarily; Step 5 re-homes them per module. +dependencies { + intellijPlatform { + compileOnly(libs.kotlin.serialization.core.jvm) + compileOnly(libs.kotlin.serialization.json.jvm) + testFramework(TestFrameworkType.Platform) + } + + 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") +} diff --git a/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt b/shared/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt rename to shared/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt diff --git a/src/main/kotlin/dev/twango/jetplay/JetPlayConstants.kt b/shared/src/main/kotlin/dev/twango/jetplay/JetPlayConstants.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/JetPlayConstants.kt rename to shared/src/main/kotlin/dev/twango/jetplay/JetPlayConstants.kt diff --git a/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt b/shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt rename to shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt diff --git a/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt b/shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt rename to shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt diff --git a/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt b/shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt rename to shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt diff --git a/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt b/shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt rename to shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt diff --git a/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt b/shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt rename to shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt diff --git a/src/main/kotlin/dev/twango/jetplay/editor/MediaFileType.kt b/shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileType.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/editor/MediaFileType.kt rename to shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileType.kt diff --git a/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/shared/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt rename to shared/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt diff --git a/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt rename to shared/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt diff --git a/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt rename to shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt diff --git a/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt rename to shared/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt diff --git a/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt rename to shared/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt diff --git a/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt rename to shared/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt diff --git a/src/main/kotlin/dev/twango/jetplay/star/StarReminder.kt b/shared/src/main/kotlin/dev/twango/jetplay/star/StarReminder.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/star/StarReminder.kt rename to shared/src/main/kotlin/dev/twango/jetplay/star/StarReminder.kt diff --git a/src/main/kotlin/dev/twango/jetplay/star/StarReminderPolicy.kt b/shared/src/main/kotlin/dev/twango/jetplay/star/StarReminderPolicy.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/star/StarReminderPolicy.kt rename to shared/src/main/kotlin/dev/twango/jetplay/star/StarReminderPolicy.kt diff --git a/src/main/kotlin/dev/twango/jetplay/transcode/FfmpegAvailability.kt b/shared/src/main/kotlin/dev/twango/jetplay/transcode/FfmpegAvailability.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/transcode/FfmpegAvailability.kt rename to shared/src/main/kotlin/dev/twango/jetplay/transcode/FfmpegAvailability.kt diff --git a/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt b/shared/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt rename to shared/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt diff --git a/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt b/shared/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt rename to shared/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt diff --git a/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt b/shared/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt rename to shared/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt diff --git a/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt b/shared/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt rename to shared/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt diff --git a/src/main/kotlin/dev/twango/jetplay/transfer/DownloadSession.kt b/shared/src/main/kotlin/dev/twango/jetplay/transfer/DownloadSession.kt similarity index 100% rename from src/main/kotlin/dev/twango/jetplay/transfer/DownloadSession.kt rename to shared/src/main/kotlin/dev/twango/jetplay/transfer/DownloadSession.kt diff --git a/shared/src/main/resources/dev.twango.jetplay.shared.xml b/shared/src/main/resources/dev.twango.jetplay.shared.xml new file mode 100644 index 00000000..164f46cf --- /dev/null +++ b/shared/src/main/resources/dev.twango.jetplay.shared.xml @@ -0,0 +1,2 @@ + + diff --git a/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt b/shared/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt similarity index 100% rename from src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt rename to shared/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt diff --git a/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt b/shared/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt similarity index 100% rename from src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt rename to shared/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt diff --git a/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt b/shared/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt similarity index 100% rename from src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt rename to shared/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt diff --git a/src/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt b/shared/src/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt similarity index 100% rename from src/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt rename to shared/src/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt diff --git a/src/test/kotlin/dev/twango/jetplay/star/StarReminderPolicyTest.kt b/shared/src/test/kotlin/dev/twango/jetplay/star/StarReminderPolicyTest.kt similarity index 100% rename from src/test/kotlin/dev/twango/jetplay/star/StarReminderPolicyTest.kt rename to shared/src/test/kotlin/dev/twango/jetplay/star/StarReminderPolicyTest.kt diff --git a/src/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt b/shared/src/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt similarity index 100% rename from src/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt rename to shared/src/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt diff --git a/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt b/shared/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt similarity index 100% rename from src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt rename to shared/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt diff --git a/src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt b/shared/src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt similarity index 100% rename from src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt rename to shared/src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index fbdde548..25415912 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -3,15 +3,9 @@ jetplay twangodev - com.intellij.modules.platform - - - - - - - - \ No newline at end of file + + + + + + diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 027c2f3b..85111f6b 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ }, }, build: { - outDir: '../src/main/resources/player', + outDir: '../frontend/src/main/resources/player', emptyOutDir: true, }, }) \ No newline at end of file From 23d9a9e989553a01a0cad7e489e77270d0976216 Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 17:47:26 -0700 Subject: [PATCH 02/29] feat: add backend module with @Rpc MediaAccessor service Move ffmpeg/byte-access (MediaTranscoder, MediaInfoExtractor, WaveformExtractor, FfmpegAvailability, LocalFileMediaSource) and the bytedeco natives into the backend content module so they are backend-only. Define the @Rpc MediaAccessor contract and @Serializable DTOs (MediaInfo/MediaTag, TranscodeEvent) in shared, implement MediaAccessorImpl with chunked Flow streaming plus a readRange fallback, and register it via the platform.rpc.backend.remoteApiProvider EP. Relocate the editor/JCEF/MediaServer classes into the frontend module and rewire MediaLoader onto the RPC API with a monolith fast path (serve the real file directly when bytes are readable in-process). Hoist needsTranscoding/rawAudioExtensions into shared MediaClassification and bump the Kotlin toolchain to 21 (required by the RPC inline API). --- backend/build.gradle.kts | 27 ++ .../jetplay/media/LocalFileMediaSource.kt | 5 +- .../twango/jetplay/rpc/MediaAccessorImpl.kt | 100 +++++++ .../jetplay/rpc/MediaAccessorProvider.kt | 10 + .../jetplay/transcode/FfmpegAvailability.kt | 0 .../jetplay/transcode/MediaInfoExtractor.kt | 30 +- .../jetplay/transcode/MediaTranscoder.kt | 16 -- .../jetplay/transcode/TranscodeRunner.kt | 10 + .../jetplay/transcode/WaveformExtractor.kt | 0 .../resources/dev.twango.jetplay.backend.xml | 6 + build.gradle.kts | 4 +- frontend/build.gradle.kts | 1 + .../dev/twango/jetplay/JetPlayBundle.kt | 0 .../twango/jetplay/browser/PlayerBridge.kt | 2 +- .../twango/jetplay/browser/PlayerConfig.kt | 0 .../jetplay/browser/PlayerHtmlLoader.kt | 0 .../twango/jetplay/editor/MediaFileEditor.kt | 4 +- .../jetplay/editor/MediaFileEditorProvider.kt | 10 +- .../twango/jetplay/editor/MediaFileType.kt | 0 .../dev/twango/jetplay/editor/MediaLoader.kt | 262 ++++++++++++++++++ .../twango/jetplay/media/EditorMediaSource.kt | 21 ++ .../dev/twango/jetplay/media/MediaServer.kt | 0 .../dev/twango/jetplay/star/StarReminder.kt | 0 .../twango/jetplay/star/StarReminderPolicy.kt | 0 shared/build.gradle.kts | 28 +- .../dev/twango/jetplay/editor/MediaLoader.kt | 178 ------------ .../jetplay/media/MediaClassification.kt | 25 ++ .../dev/twango/jetplay/media/MediaInfo.kt | 33 +++ .../dev/twango/jetplay/media/MediaSource.kt | 3 - .../jetplay/media/RemoteFileMediaSource.kt | 32 --- .../dev/twango/jetplay/rpc/MediaAccessor.kt | 37 +++ .../dev/twango/jetplay/rpc/TranscodeEvent.kt | 27 ++ .../jetplay/transcode/TranscodeSession.kt | 56 ---- .../jetplay/transfer/DownloadSession.kt | 77 ----- 34 files changed, 572 insertions(+), 432 deletions(-) rename {shared => backend}/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt (66%) create mode 100644 backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt create mode 100644 backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorProvider.kt rename {shared => backend}/src/main/kotlin/dev/twango/jetplay/transcode/FfmpegAvailability.kt (100%) rename {shared => backend}/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt (89%) rename {shared => backend}/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt (93%) create mode 100644 backend/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeRunner.kt rename {shared => backend}/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt (100%) rename {shared => frontend}/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt (100%) rename {shared => frontend}/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt (99%) rename {shared => frontend}/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt (100%) rename {shared => frontend}/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt (100%) rename {shared => frontend}/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt (94%) rename {shared => frontend}/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt (73%) rename {shared => frontend}/src/main/kotlin/dev/twango/jetplay/editor/MediaFileType.kt (100%) create mode 100644 frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt create mode 100644 frontend/src/main/kotlin/dev/twango/jetplay/media/EditorMediaSource.kt rename {shared => frontend}/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt (100%) rename {shared => frontend}/src/main/kotlin/dev/twango/jetplay/star/StarReminder.kt (100%) rename {shared => frontend}/src/main/kotlin/dev/twango/jetplay/star/StarReminderPolicy.kt (100%) delete mode 100644 shared/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt create mode 100644 shared/src/main/kotlin/dev/twango/jetplay/media/MediaInfo.kt delete mode 100644 shared/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt create mode 100644 shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt create mode 100644 shared/src/main/kotlin/dev/twango/jetplay/rpc/TranscodeEvent.kt delete mode 100644 shared/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt delete mode 100644 shared/src/main/kotlin/dev/twango/jetplay/transfer/DownloadSession.kt diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index e6357d9f..72a278c3 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -1,8 +1,35 @@ 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) } implementation(project(":shared")) + + 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") } diff --git a/shared/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt b/backend/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt similarity index 66% rename from shared/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt rename to backend/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt index ca63095c..f7c4a381 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt +++ b/backend/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt @@ -1,7 +1,6 @@ package dev.twango.jetplay.media import com.intellij.openapi.vfs.VirtualFile -import dev.twango.jetplay.transcode.MediaTranscoder import java.io.File class LocalFileMediaSource(private val file: VirtualFile) : MediaSource { @@ -12,9 +11,9 @@ class LocalFileMediaSource(private val file: VirtualFile) : MediaSource { override val isVideo: Boolean = MediaClassification.isVideo(extension) - override val needsTranscoding: Boolean = MediaTranscoder.needsTranscoding(extension) + override val needsTranscoding: Boolean = MediaClassification.needsTranscoding(extension) override val isRemote: Boolean = false - override fun toLocalFile(): File = file.toNioPath().toFile() + fun toLocalFile(): File = file.toNioPath().toFile() } diff --git a/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt new file mode 100644 index 00000000..7f4c5e47 --- /dev/null +++ b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt @@ -0,0 +1,100 @@ +package dev.twango.jetplay.rpc + +import com.intellij.ide.vfs.VirtualFileId +import com.intellij.ide.vfs.virtualFile +import com.intellij.platform.project.ProjectId +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 { + + private fun resolveFile(fileId: VirtualFileId): File? = + fileId.virtualFile()?.takeIf { it.isValid }?.let { vf -> + runCatching { vf.toNioPath().toFile() }.getOrNull()?.takeIf { it.isFile } + } + + override suspend fun streamFileBytes(fileId: VirtualFileId, projectId: ProjectId): Flow = flow { + val file = resolveFile(fileId) ?: 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 = + resolveFile(fileId)?.length() ?: -1L + + override suspend fun readRange(fileId: VirtualFileId, projectId: ProjectId, offset: Long, length: Int): ByteArray { + val file = resolveFile(fileId) ?: return ByteArray(0) + RandomAccessFile(file, "r").use { raf -> + raf.seek(offset) + val out = ByteArray(length) + val read = raf.read(out) + return when { + read <= 0 -> ByteArray(0) + read == length -> out + else -> out.copyOf(read) + } + } + } + + override suspend fun transcodeFile(fileId: VirtualFileId, projectId: ProjectId): Flow = channelFlow { + if (!FfmpegAvailability.available) { + send(TranscodeEvent.Unavailable) + return@channelFlow + } + val input = resolveFile(fileId) ?: run { + send(TranscodeEvent.Failed("source unavailable")) + return@channelFlow + } + val output = try { + withContext(Dispatchers.IO) { + // onProgress fires synchronously inside ffmpeg; trySend bridges it onto this channel without suspending. + TranscodeRunner.transcode(input) { pct -> trySend(TranscodeEvent.Progress(pct)) } + } + } catch (e: Exception) { + send(TranscodeEvent.Failed(e.message ?: "unknown")) + return@channelFlow + } + 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))) + } + } + } + send(TranscodeEvent.Done) + } + + override suspend fun extractWaveform(fileId: VirtualFileId, projectId: ProjectId): List { + if (!FfmpegAvailability.available) return emptyList() + val file = resolveFile(fileId) ?: return emptyList() + return runCatching { WaveformExtractor.extract(file) }.getOrDefault(emptyList()) + } + + override suspend fun extractMediaInfo(fileId: VirtualFileId, projectId: ProjectId): MediaInfo? { + if (!FfmpegAvailability.available) return null + val file = resolveFile(fileId) ?: return null + return runCatching { MediaInfoExtractor.extract(file) }.getOrNull() + } +} diff --git a/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorProvider.kt b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorProvider.kt new file mode 100644 index 00000000..b3a4d3dc --- /dev/null +++ b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorProvider.kt @@ -0,0 +1,10 @@ +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()) { MediaAccessorImpl() } + } +} diff --git a/shared/src/main/kotlin/dev/twango/jetplay/transcode/FfmpegAvailability.kt b/backend/src/main/kotlin/dev/twango/jetplay/transcode/FfmpegAvailability.kt similarity index 100% rename from shared/src/main/kotlin/dev/twango/jetplay/transcode/FfmpegAvailability.kt rename to backend/src/main/kotlin/dev/twango/jetplay/transcode/FfmpegAvailability.kt diff --git a/shared/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt b/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt similarity index 89% rename from shared/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt rename to backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt index 87bbf2db..55c33cf6 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt +++ b/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt @@ -1,6 +1,8 @@ package dev.twango.jetplay.transcode import com.intellij.openapi.diagnostic.Logger +import dev.twango.jetplay.media.MediaInfo +import dev.twango.jetplay.media.MediaTag import org.bytedeco.ffmpeg.avformat.AVStream import org.bytedeco.ffmpeg.global.avcodec import org.bytedeco.ffmpeg.global.avformat @@ -10,34 +12,6 @@ import org.bytedeco.javacv.FrameGrabber import java.io.File import java.util.Base64 -/** Codec-inspector metadata; nullable fields let the UI skip anything FFmpeg couldn't determine. */ -data class MediaInfo( - val codec: String?, - val container: String?, - val sampleRateHz: Int?, - val channels: Int?, - val channelLabel: String?, - /** Only set when meaningful (PCM / lossless). Null for lossy codecs. */ - val bitDepth: String?, - val bitrateBps: Long?, - val durationMs: Long?, - val sizeBytes: Long?, - // Video-stream fields (null for audio-only files). - val width: Int? = null, - val height: Int? = null, - val frameRate: Double? = null, - val videoCodec: String? = null, - val pixelFormat: String? = null, - val videoBitrateBps: Long? = null, - /** Embedded text tags (title/artist/album/…), in display order. */ - val tags: List = emptyList(), - /** Embedded cover art as a `data:` URL, or null when there is none. */ - val albumArt: String? = null, -) - -/** One embedded metadata tag, already labeled for display. */ -data class MediaTag(val label: String, val value: String) - /** Probes audio/video stream details via header-only FFmpeg reads; null when no readable stream exists. */ object MediaInfoExtractor { diff --git a/shared/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt b/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt similarity index 93% rename from shared/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt rename to backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt index 688b07c3..2234694d 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt +++ b/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt @@ -24,18 +24,6 @@ object MediaTranscoder { private const val INDETERMINATE_TENTH = -1L private const val REPORTED_INDETERMINATE_TENTH = -2L - // Chromium can play these natively, so JCEF needs no transcoding. - private val JCEF_NATIVE_EXTENSIONS = setOf( - "webm", - "ogv", - "ogg", - "oga", - "opus", - "wav", - "flac", - "mp3", - ) - // Headerless raw codec streams need an explicit demuxer + sample rate + channels. private data class RawAudioHint(val format: String, val sampleRate: Int, val channels: Int) @@ -49,10 +37,6 @@ object MediaTranscoder { "sln" to RawAudioHint("s16le", 8000, 1), ) - internal val rawAudioExtensions: Set get() = RAW_AUDIO_HINTS.keys - - fun needsTranscoding(extension: String?): Boolean = extension?.lowercase() !in JCEF_NATIVE_EXTENSIONS - fun transcode(inputFile: File, onProgress: (Double) -> Unit = {}): File { val outputFile = Files.createTempFile("jetplay-", ".webm").toFile().apply { deleteOnExit() } diff --git a/backend/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeRunner.kt b/backend/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeRunner.kt new file mode 100644 index 00000000..03ecacb6 --- /dev/null +++ b/backend/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeRunner.kt @@ -0,0 +1,10 @@ +package dev.twango.jetplay.transcode + +import java.io.File + +object TranscodeRunner { + + /** Runs ffmpeg, invoking onProgress(percent). Returns the transcoded File; throws on failure. */ + fun transcode(inputFile: File, onProgress: (Double) -> Unit): File = + MediaTranscoder.transcode(inputFile, onProgress) +} diff --git a/shared/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt b/backend/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt similarity index 100% rename from shared/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt rename to backend/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt diff --git a/backend/src/main/resources/dev.twango.jetplay.backend.xml b/backend/src/main/resources/dev.twango.jetplay.backend.xml index f9ae6eab..e2cb6874 100644 --- a/backend/src/main/resources/dev.twango.jetplay.backend.xml +++ b/backend/src/main/resources/dev.twango.jetplay.backend.xml @@ -2,6 +2,12 @@ + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index 311ef2a3..6fe3d581 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,7 +18,7 @@ group = providers.gradleProperty("pluginGroup").get() version = providers.gradleProperty("pluginVersion").get() kotlin { - jvmToolchain(17) + jvmToolchain(21) } subprojects { @@ -28,7 +28,7 @@ subprojects { apply(plugin = "org.jetbrains.kotlin.plugin.serialization") kotlin { - jvmToolchain(17) + jvmToolchain(21) } dependencies { diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts index 604d4590..85c95f03 100644 --- a/frontend/build.gradle.kts +++ b/frontend/build.gradle.kts @@ -1,6 +1,7 @@ dependencies { intellijPlatform { bundledModule("intellij.platform.frontend") + bundledModule("intellij.platform.rpc") compileOnly(libs.kotlin.serialization.core.jvm) compileOnly(libs.kotlin.serialization.json.jvm) } diff --git a/shared/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt b/frontend/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt similarity index 100% rename from shared/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt diff --git a/shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt similarity index 99% rename from shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt index 106a263a..514a8273 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt @@ -4,7 +4,7 @@ import com.intellij.ide.BrowserUtil 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 dev.twango.jetplay.media.MediaInfo import javax.swing.SwingUtilities class PlayerBridge(private val browser: JBCefBrowser) { diff --git a/shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt similarity index 100% rename from shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt diff --git a/shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt similarity index 100% rename from shared/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt diff --git a/shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt similarity index 94% rename from shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt index a771f20e..63cacffd 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt @@ -9,13 +9,13 @@ import com.intellij.ui.jcef.JBCefBrowser import dev.twango.jetplay.JetPlayBundle import dev.twango.jetplay.browser.PlayerBridge import dev.twango.jetplay.browser.PlayerHtmlLoader -import dev.twango.jetplay.media.MediaSource +import dev.twango.jetplay.media.EditorMediaSource import java.awt.BorderLayout import java.beans.PropertyChangeListener import javax.swing.JComponent import javax.swing.JPanel -class MediaFileEditor(private val project: Project, private val file: VirtualFile, private val source: MediaSource) : +class MediaFileEditor(private val project: Project, private val file: VirtualFile, private val source: EditorMediaSource) : UserDataHolderBase(), FileEditor { diff --git a/shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt similarity index 73% rename from shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt index 7291f825..ab111b3d 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt @@ -5,10 +5,8 @@ import com.intellij.openapi.fileEditor.FileEditorPolicy import com.intellij.openapi.fileEditor.FileEditorProvider import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile -import dev.twango.jetplay.media.LocalFileMediaSource -import dev.twango.jetplay.media.RemoteFileMediaSource +import dev.twango.jetplay.media.EditorMediaSource import dev.twango.jetplay.star.StarReminder class MediaFileEditorProvider : @@ -18,11 +16,7 @@ class MediaFileEditorProvider : override fun accept(project: Project, file: VirtualFile): Boolean = file.fileType == MediaFileType.INSTANCE override fun createEditor(project: Project, file: VirtualFile): FileEditor { - val source = if (file.fileSystem is LocalFileSystem) { - LocalFileMediaSource(file) - } else { - RemoteFileMediaSource(file) - } + val source = EditorMediaSource(file) StarReminder.maybeShow(project) return MediaFileEditor(project, file, source) } diff --git a/shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileType.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileType.kt similarity index 100% rename from shared/src/main/kotlin/dev/twango/jetplay/editor/MediaFileType.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileType.kt diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt new file mode 100644 index 00000000..9807ae23 --- /dev/null +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt @@ -0,0 +1,262 @@ +package dev.twango.jetplay.editor + +import com.intellij.ide.BrowserUtil +import com.intellij.ide.vfs.rpcId +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.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.platform.project.projectId +import dev.twango.jetplay.JetPlayBundle +import dev.twango.jetplay.JetPlayConstants +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.EditorMediaSource +import dev.twango.jetplay.media.MediaClassification +import dev.twango.jetplay.media.MediaServer +import dev.twango.jetplay.rpc.MediaAccessor +import dev.twango.jetplay.rpc.TranscodeEvent +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.runBlocking +import java.io.File +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Future + +class MediaLoader( + private val project: Project, + private val source: EditorMediaSource, + private val bridge: PlayerBridge, + private val htmlLoader: PlayerHtmlLoader, +) { + + private val tasks = CopyOnWriteArrayList>() + + // Loopback URLs handed out for this editor's media, released on dispose. + private val servedUrls = CopyOnWriteArrayList() + + private fun serve(file: 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"), + transcodingTip = JetPlayBundle.message("ui.transcoding.tip"), + errorTitle = JetPlayBundle.message("ui.error.title"), + ) + + private val fileId by lazy { source.file.rpcId() } + private val projectId by lazy { project.projectId() } + + fun load() { + when { + source.isRemote -> startDownload() + source.needsTranscoding -> startTranscoding() + else -> playDirectly() + } + maybeSendWaveform() + maybeSendMediaInfo() + } + + private fun maybeSendWaveform() { + if (source.isVideo || source.isRemote) return + // Raw telephony codecs lack the demuxer hints to decode cleanly, risking a garbage waveform. + if (source.extension.lowercase() in MediaClassification.rawAudioExtensions) return + submit { + if (bridge.disposed) return@submit + val bars = runBlocking { MediaAccessor.getInstance().extractWaveform(fileId, projectId) } + if (bars.isNotEmpty() && !bridge.disposed) bridge.sendWaveform(bars) + } + } + + private fun maybeSendMediaInfo() { + if (source.isRemote) return + if (source.extension.lowercase() in MediaClassification.rawAudioExtensions) return + submit { + if (bridge.disposed) return@submit + val info = runBlocking { MediaAccessor.getInstance().extractMediaInfo(fileId, projectId) } + if (info != null && !bridge.disposed) bridge.sendMediaInfo(info) + } + } + + private fun startDownload() { + htmlLoader.load( + PlayerConfig( + state = "downloading", + isVideo = source.isVideo, + fileName = source.fileName, + fileExtension = source.extension, + downloadingReason = JetPlayBundle.message("downloading.reason"), + ui = uiStrings, + ), + ) + submit { + val temp = streamToTemp(::reportDownloadProgress) ?: return@submit + if (bridge.disposed) return@submit + if (source.needsTranscoding) { + startTranscoding() + } else { + bridge.mediaReady(serve(temp)) + } + } + } + + private fun startTranscoding() { + if (source.isRemote) { + bridge.executeJs("window.__jetplayState='loading';window.__jetplayProgress=0;window.jetplayStartTranscoding?.()") + } else { + htmlLoader.load( + PlayerConfig( + state = "loading", + isVideo = source.isVideo, + fileName = source.fileName, + fileExtension = source.extension, + transcodingReason = JetPlayBundle.message("transcoding.reason", source.extension.uppercase()), + ui = uiStrings, + ), + ) + } + submit { runTranscode() } + } + + private fun runTranscode() { + val temp = File.createTempFile("jetplay-", ".webm").apply { deleteOnExit() } + try { + runBlocking { + val api = MediaAccessor.getInstance() + temp.outputStream().use { out -> + api.transcodeFile(fileId, projectId).collect { event -> + when (event) { + is TranscodeEvent.Progress -> if (!bridge.disposed) bridge.updateProgress(event.percent) + is TranscodeEvent.Chunk -> out.write(event.bytes) + is TranscodeEvent.Failed -> throw TranscodeFailure(event.message) + TranscodeEvent.Unavailable -> throw TranscodeUnavailable + TranscodeEvent.Done -> Unit + } + } + } + } + if (!bridge.disposed) bridge.mediaReady(serve(temp)) + } catch (_: TranscodeUnavailable) { + showTranscodingError() + } catch (e: TranscodeFailure) { + if (!bridge.disposed) bridge.showError(e.message ?: JetPlayBundle.message("error.unknown")) + } catch (e: Exception) { + showLoadError(e.message) + } + } + + private fun playDirectly() { + val local = source.localFileOrNull() + if (local != null) { + // MONOLITH / local file: identical to today — serve the real file, no RPC, no temp copy. + htmlLoader.load( + PlayerConfig( + isVideo = source.isVideo, + fileName = source.fileName, + fileExtension = source.extension, + mediaUrl = serve(local), + ui = uiStrings, + ), + ) + return + } + // SPLIT MODE: pull bytes from backend into a temp file, then serve. + submit { + val temp = streamToTemp { } ?: return@submit + if (!bridge.disposed) { + htmlLoader.load( + PlayerConfig( + isVideo = source.isVideo, + fileName = source.fileName, + fileExtension = source.extension, + mediaUrl = serve(temp), + ui = uiStrings, + ), + ) + } + } + } + + /** Streams the source bytes from the backend into a temp file. Returns null on failure (error surfaced). */ + private fun streamToTemp(onProgress: (Long) -> Unit): File? { + val temp = File.createTempFile("jetplay-", ".${source.extension}").apply { deleteOnExit() } + return try { + runBlocking { + val api = MediaAccessor.getInstance() + var written = 0L + temp.outputStream().use { out -> + api.streamFileBytes(fileId, projectId).collect { chunk -> + out.write(chunk) + written += chunk.size + onProgress(written) + } + } + } + temp + } catch (e: Exception) { + showLoadError(e.message) + null + } + } + + private fun reportDownloadProgress(bytes: Long) { + if (bridge.disposed) return + val total = source.file.length + if (total > 0) bridge.updateDownloadProgress(bytes.toDouble() / total * PERCENT_SCALE) + } + + private fun showLoadError(raw: String?) { + val msg = raw ?: JetPlayBundle.message("error.unknown") + log.warn("media load failed: $msg") + if (bridge.disposed) return + bridge.showError(JetPlayBundle.message("error.download", msg)) + } + + private fun showTranscodingError() { + // Load the shell in the error state: this runs before any page exists, so a + // bridge.showError() JS push would have nothing to render against. + htmlLoader.load( + PlayerConfig( + state = "error", + isVideo = source.isVideo, + fileName = source.fileName, + fileExtension = source.extension, + errorMessage = JetPlayBundle.message("error.transcoding.message"), + ui = uiStrings, + ), + ) + NotificationGroupManager.getInstance() + .getNotificationGroup(JetPlayConstants.NOTIFICATION_GROUP_ID) + .createNotification( + JetPlayBundle.message("error.transcoding.notification.title"), + JetPlayBundle.message("error.transcoding.notification.content", source.extension.uppercase()), + NotificationType.WARNING, + ) + .addAction( + NotificationAction.createSimpleExpiring(JetPlayBundle.message("action.report.issue")) { + BrowserUtil.browse(JetPlayConstants.ISSUES_URL) + }, + ) + .notify(project) + } + + private fun submit(block: () -> Unit) { + tasks.add(ApplicationManager.getApplication().executeOnPooledThread(block)) + } + + fun dispose() { + tasks.forEach { it.cancel(true) } + servedUrls.forEach(MediaServer::release) + } + + private object TranscodeUnavailable : Exception() + private class TranscodeFailure(message: String) : Exception(message) + + companion object { + private val log = Logger.getInstance(MediaLoader::class.java) + private const val PERCENT_SCALE = 100.0 + } +} diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/media/EditorMediaSource.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/EditorMediaSource.kt new file mode 100644 index 00000000..8753406f --- /dev/null +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/EditorMediaSource.kt @@ -0,0 +1,21 @@ +package dev.twango.jetplay.media + +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import java.io.File + +/** + * Frontend view of a media file. Holds the VirtualFile for identity (rpcId) and a + * monolith fast-path nio File when the bytes are directly readable in-process. + */ +class EditorMediaSource(val file: VirtualFile) : MediaSource { + override val fileName: String = file.name + override val extension: String = file.extension?.lowercase() ?: "" + override val isVideo: Boolean = MediaClassification.isVideo(extension) + override val needsTranscoding: Boolean = MediaClassification.needsTranscoding(extension) + override val isRemote: Boolean = file.fileSystem !is LocalFileSystem + + /** Non-null iff the bytes are directly readable in THIS process (monolith local file). */ + fun localFileOrNull(): File? = + if (!isRemote) runCatching { file.toNioPath().toFile() }.getOrNull()?.takeIf { it.isFile } else null +} diff --git a/shared/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt similarity index 100% rename from shared/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt diff --git a/shared/src/main/kotlin/dev/twango/jetplay/star/StarReminder.kt b/frontend/src/main/kotlin/dev/twango/jetplay/star/StarReminder.kt similarity index 100% rename from shared/src/main/kotlin/dev/twango/jetplay/star/StarReminder.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/star/StarReminder.kt diff --git a/shared/src/main/kotlin/dev/twango/jetplay/star/StarReminderPolicy.kt b/frontend/src/main/kotlin/dev/twango/jetplay/star/StarReminderPolicy.kt similarity index 100% rename from shared/src/main/kotlin/dev/twango/jetplay/star/StarReminderPolicy.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/star/StarReminderPolicy.kt diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 939cc377..b09970ce 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,10 +1,9 @@ import org.jetbrains.intellij.platform.gradle.TestFrameworkType -// STEP 1 ONLY: bytedeco lives here temporarily while all code is in shared. -// Step 2 moves it to backend/build.gradle.kts; shared then keeps only serialization. -// Tests are also parked here temporarily; Step 5 re-homes them per module. +// Tests are parked here temporarily; Step 5 re-homes them per module. dependencies { intellijPlatform { + bundledModule("intellij.platform.rpc") compileOnly(libs.kotlin.serialization.core.jvm) compileOnly(libs.kotlin.serialization.json.jvm) testFramework(TestFrameworkType.Platform) @@ -12,27 +11,4 @@ dependencies { 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") } diff --git a/shared/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/shared/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt deleted file mode 100644 index ea38cc44..00000000 --- a/shared/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt +++ /dev/null @@ -1,178 +0,0 @@ -package dev.twango.jetplay.editor - -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.project.Project -import dev.twango.jetplay.JetPlayBundle -import dev.twango.jetplay.JetPlayConstants -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.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.Future - -class MediaLoader( - private val project: Project, - private val source: MediaSource, - private val bridge: PlayerBridge, - private val htmlLoader: PlayerHtmlLoader, -) { - - 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() - - 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"), - transcodingTip = JetPlayBundle.message("ui.transcoding.tip"), - errorTitle = JetPlayBundle.message("ui.error.title"), - ) - - fun load() { - if (source.isRemote) { - startDownload() - } else if (source.needsTranscoding) { - startTranscoding() - } else { - playDirectly() - } - maybeSendWaveform() - maybeSendMediaInfo() - } - - // FFmpeg decodes the bars off the EDT for any local audio format, cheaper than the browser decoding the whole file. - private fun maybeSendWaveform() { - if (source.isVideo || source.isRemote || !FfmpegAvailability.available) return - // Raw telephony codecs lack the demuxer hints to decode cleanly, risking 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) - } - } - - // FFmpeg probes container/codec/stream details off the EDT for the inspector; raw audio lacks the hints to probe cleanly. - 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() { - htmlLoader.load( - PlayerConfig( - state = "downloading", - isVideo = source.isVideo, - fileName = source.fileName, - fileExtension = source.extension, - downloadingReason = JetPlayBundle.message("downloading.reason"), - ui = uiStrings, - ), - ) - downloadSession = DownloadSession(source as RemoteFileMediaSource, bridge) { - if (source.needsTranscoding) { - startTranscoding() - } else { - bridge.mediaReady(serve(source.toLocalFile())) - } - }.also { it.start() } - } - - private fun startTranscoding() { - if (!FfmpegAvailability.available) { - showTranscodingError() - return - } - if (source.isRemote) { - bridge.executeJs("window.__jetplayState='loading';window.__jetplayProgress=0;window.jetplayStartTranscoding?.()") - } else { - htmlLoader.load( - PlayerConfig( - state = "loading", - isVideo = source.isVideo, - fileName = source.fileName, - fileExtension = source.extension, - transcodingReason = JetPlayBundle.message("transcoding.reason", source.extension.uppercase()), - ui = uiStrings, - ), - ) - } - transcodeSession = TranscodeSession(source.toLocalFile(), bridge) { transcoded -> - bridge.mediaReady(serve(transcoded)) - }.also { it.start() } - } - - private fun playDirectly() { - htmlLoader.load( - PlayerConfig( - isVideo = source.isVideo, - fileName = source.fileName, - fileExtension = source.extension, - mediaUrl = serve(source.toLocalFile()), - ui = uiStrings, - ), - ) - } - - private fun showTranscodingError() { - // Load the shell in the error state: this runs before any page exists, so a - // bridge.showError() JS push would have nothing to render against. - htmlLoader.load( - PlayerConfig( - state = "error", - isVideo = source.isVideo, - fileName = source.fileName, - fileExtension = source.extension, - errorMessage = JetPlayBundle.message("error.transcoding.message"), - ui = uiStrings, - ), - ) - NotificationGroupManager.getInstance() - .getNotificationGroup(JetPlayConstants.NOTIFICATION_GROUP_ID) - .createNotification( - JetPlayBundle.message("error.transcoding.notification.title"), - JetPlayBundle.message("error.transcoding.notification.content", source.extension.uppercase()), - NotificationType.WARNING, - ) - .addAction( - NotificationAction.createSimpleExpiring(JetPlayBundle.message("action.report.issue")) { - BrowserUtil.browse(JetPlayConstants.ISSUES_URL) - }, - ) - .notify(project) - } - - fun dispose() { - downloadSession?.cancel() - transcodeSession?.cancel() - waveformFuture?.cancel(true) - mediaInfoFuture?.cancel(true) - servedUrls.forEach(MediaServer::release) - } -} diff --git a/shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt index a6737760..482dc3d6 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt +++ b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt @@ -20,5 +20,30 @@ object MediaClassification { "ivf", ) + // Chromium can play these natively, so JCEF needs no transcoding. + private val JCEF_NATIVE_EXTENSIONS = setOf( + "webm", + "ogv", + "ogg", + "oga", + "opus", + "wav", + "flac", + "mp3", + ) + + // Headerless raw codec streams that need explicit demuxer hints; backend supplies the demuxer config. + val rawAudioExtensions: Set = setOf( + "pcmu", + "ulaw", + "pcma", + "alaw", + "g722", + "gsm", + "sln", + ) + fun isVideo(extension: String): Boolean = extension.lowercase() in VIDEO_EXTENSIONS + + fun needsTranscoding(extension: String?): Boolean = extension?.lowercase() !in JCEF_NATIVE_EXTENSIONS } diff --git a/shared/src/main/kotlin/dev/twango/jetplay/media/MediaInfo.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaInfo.kt new file mode 100644 index 00000000..c190f8e3 --- /dev/null +++ b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaInfo.kt @@ -0,0 +1,33 @@ +package dev.twango.jetplay.media + +import kotlinx.serialization.Serializable + +/** Codec-inspector metadata; nullable fields let the UI skip anything FFmpeg couldn't determine. */ +@Serializable +data class MediaInfo( + val codec: String?, + val container: String?, + val sampleRateHz: Int?, + val channels: Int?, + val channelLabel: String?, + /** Only set when meaningful (PCM / lossless). Null for lossy codecs. */ + val bitDepth: String?, + val bitrateBps: Long?, + val durationMs: Long?, + val sizeBytes: Long?, + // Video-stream fields (null for audio-only files). + val width: Int? = null, + val height: Int? = null, + val frameRate: Double? = null, + val videoCodec: String? = null, + val pixelFormat: String? = null, + val videoBitrateBps: Long? = null, + /** Embedded text tags (title/artist/album/…), in display order. */ + val tags: List = emptyList(), + /** Embedded cover art as a `data:` URL, or null when there is none. */ + val albumArt: String? = null, +) + +/** One embedded metadata tag, already labeled for display. */ +@Serializable +data class MediaTag(val label: String, val value: String) diff --git a/shared/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt index 4e10ce95..c97e8a82 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt +++ b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt @@ -1,12 +1,9 @@ package dev.twango.jetplay.media -import java.io.File - interface MediaSource { val fileName: String val extension: String val isVideo: Boolean val needsTranscoding: Boolean val isRemote: Boolean - fun toLocalFile(): File } diff --git a/shared/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt deleted file mode 100644 index 2f88d63d..00000000 --- a/shared/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt +++ /dev/null @@ -1,32 +0,0 @@ -package dev.twango.jetplay.media - -import com.intellij.openapi.vfs.VirtualFile -import dev.twango.jetplay.transcode.MediaTranscoder -import java.io.File -import java.io.InputStream - -class RemoteFileMediaSource(private val file: VirtualFile) : MediaSource { - - override val fileName: String = file.name - - override val extension: String = file.extension?.lowercase() ?: "" - - override val isVideo: Boolean = MediaClassification.isVideo(extension) - - override val needsTranscoding: Boolean = MediaTranscoder.needsTranscoding(extension) - - override val isRemote: Boolean = true - - val fileSize: Long = file.length - - @Volatile - private var localFile: File? = null - - fun inputStream(): InputStream = file.inputStream - - fun setLocalFile(file: File) { - localFile = file - } - - override fun toLocalFile(): File = localFile ?: error("Remote file not yet downloaded") -} diff --git a/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt b/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt new file mode 100644 index 00000000..a4686f20 --- /dev/null +++ b/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt @@ -0,0 +1,37 @@ +package dev.twango.jetplay.rpc + +import com.intellij.ide.vfs.VirtualFileId +import com.intellij.platform.project.ProjectId +import com.intellij.platform.rpc.RemoteApiProviderService +import dev.twango.jetplay.media.MediaInfo +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Internal +@Rpc +interface MediaAccessor : RemoteApi { + /** Stream raw source bytes in order. Primary path; element type is plain ByteArray (Serializable). */ + suspend fun streamFileBytes(fileId: VirtualFileId, projectId: ProjectId): Flow + + /** Fallback random-access read; always available even if Flow streaming underperforms on large media. */ + suspend fun fileLength(fileId: VirtualFileId, projectId: ProjectId): Long + + suspend fun readRange(fileId: VirtualFileId, projectId: ProjectId, offset: Long, length: Int): ByteArray + + /** Transcode to WebM on backend; emit progress then the transcoded bytes as a stream. */ + suspend fun transcodeFile(fileId: VirtualFileId, projectId: ProjectId): Flow + + /** Empty list if ffmpeg unavailable or format unsupported (NEVER throws to caller). */ + suspend fun extractWaveform(fileId: VirtualFileId, projectId: ProjectId): List + + /** null if ffmpeg unavailable or no readable stream. */ + suspend fun extractMediaInfo(fileId: VirtualFileId, projectId: ProjectId): MediaInfo? + + companion object { + suspend fun getInstance(): MediaAccessor = + RemoteApiProviderService.resolve(remoteApiDescriptor()) + } +} diff --git a/shared/src/main/kotlin/dev/twango/jetplay/rpc/TranscodeEvent.kt b/shared/src/main/kotlin/dev/twango/jetplay/rpc/TranscodeEvent.kt new file mode 100644 index 00000000..c1e5ba53 --- /dev/null +++ b/shared/src/main/kotlin/dev/twango/jetplay/rpc/TranscodeEvent.kt @@ -0,0 +1,27 @@ +package dev.twango.jetplay.rpc + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface TranscodeEvent { + @Serializable + data class Progress(val percent: Double) : TranscodeEvent + + /** Ordered output chunk of the transcoded WebM. */ + @Serializable + data class Chunk(val bytes: ByteArray) : TranscodeEvent { + override fun equals(other: Any?) = this === other || (other is Chunk && bytes.contentEquals(other.bytes)) + override fun hashCode() = bytes.contentHashCode() + } + + @Serializable + data object Done : TranscodeEvent + + /** Raw (un-localized) error string; frontend wraps with JetPlayBundle. */ + @Serializable + data class Failed(val message: String) : TranscodeEvent + + /** ffmpeg not available on backend — frontend shows transcoding-error state. */ + @Serializable + data object Unavailable : TranscodeEvent +} diff --git a/shared/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt b/shared/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt deleted file mode 100644 index 2a4d9e52..00000000 --- a/shared/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt +++ /dev/null @@ -1,56 +0,0 @@ -package dev.twango.jetplay.transcode - -import com.intellij.openapi.diagnostic.Logger -import dev.twango.jetplay.JetPlayBundle -import dev.twango.jetplay.browser.PlayerBridge -import java.io.File -import kotlin.concurrent.thread - -class TranscodeSession( - private val inputFile: File, - private val bridge: PlayerBridge, - private val onReady: (File) -> Unit, -) { - - companion object { - private val log = Logger.getInstance(TranscodeSession::class.java) - } - - @Volatile - var cancelled = false - private set - - private var thread: Thread? = null - - // Serializes cancel() against the onReady handoff so a racing cancel can't slip between the check and the callback. - private val readyLock = Any() - - private fun deliverIfActive(file: File) = synchronized(readyLock) { - if (!cancelled) { - onReady(file) - } - } - - fun start() { - thread = thread(name = "jetplay-transcode", isDaemon = true) { - try { - val transcoded = MediaTranscoder.transcode(inputFile) { percent -> - if (!cancelled) bridge.updateProgress(percent) - } - deliverIfActive(transcoded) - } catch (_: InterruptedException) { - log.info("Transcoding interrupted for ${inputFile.name}") - } catch (e: Exception) { - log.warn("Transcoding failed for ${inputFile.name}", e) - if (!cancelled) { - bridge.showError(e.message ?: JetPlayBundle.message("error.unknown")) - } - } - } - } - - fun cancel() { - synchronized(readyLock) { cancelled = true } - thread?.interrupt() - } -} diff --git a/shared/src/main/kotlin/dev/twango/jetplay/transfer/DownloadSession.kt b/shared/src/main/kotlin/dev/twango/jetplay/transfer/DownloadSession.kt deleted file mode 100644 index 9b89d86f..00000000 --- a/shared/src/main/kotlin/dev/twango/jetplay/transfer/DownloadSession.kt +++ /dev/null @@ -1,77 +0,0 @@ -package dev.twango.jetplay.transfer - -import com.intellij.openapi.diagnostic.Logger -import dev.twango.jetplay.JetPlayBundle -import dev.twango.jetplay.JetPlayConstants -import dev.twango.jetplay.browser.PlayerBridge -import dev.twango.jetplay.media.RemoteFileMediaSource -import java.io.File -import kotlin.concurrent.thread - -class DownloadSession( - private val source: RemoteFileMediaSource, - private val bridge: PlayerBridge, - private val onComplete: (File) -> Unit, -) { - - companion object { - private val log = Logger.getInstance(DownloadSession::class.java) - } - - @Volatile - var cancelled = false - private set - - private var thread: Thread? = null - - fun start() { - thread = thread(name = "jetplay-download", isDaemon = true) { - try { - val tempFile = File.createTempFile("jetplay-", ".${source.extension}").apply { - deleteOnExit() - } - val totalBytes = source.fileSize - var bytesRead = 0L - var lastReportedPercent = -10.0 - - source.inputStream().use { input -> - tempFile.outputStream().use { output -> - val buffer = ByteArray(8192) - var n: Int - while (input.read(buffer).also { n = it } != -1) { - if (cancelled) return@thread - output.write(buffer, 0, n) - bytesRead += n - if (totalBytes > 0) { - val percent = (bytesRead.toDouble() / totalBytes) * 100 - if (percent - lastReportedPercent >= 1.0) { - bridge.updateDownloadProgress(percent) - lastReportedPercent = percent - } - } - } - } - } - - if (!cancelled) { - source.setLocalFile(tempFile) - log.info("Downloaded ${source.fileName} (${tempFile.length() / JetPlayConstants.BYTES_PER_KB} KB)") - onComplete(tempFile) - } - } catch (_: InterruptedException) { - log.info("Download interrupted for ${source.fileName}") - } catch (e: Exception) { - log.warn("Download failed for ${source.fileName}", e) - if (!cancelled) { - val errorMsg = e.message ?: JetPlayBundle.message("error.unknown") - bridge.showError(JetPlayBundle.message("error.download", errorMsg)) - } - } - } - } - - fun cancel() { - cancelled = true - thread?.interrupt() - } -} From 9d68437eb058cd01a8255c319490e67daea3e4b1 Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 17:55:52 -0700 Subject: [PATCH 03/29] feat: fall back to error state when JCEF is unavailable, trace media requests Guard MediaFileEditorProvider on JBCefApp.isSupported(): render a plain-Swing MediaErrorEditor instead of an empty browser pane that would spin indefinitely. Add per-request debug logging plus a missing-token warning in MediaServer.handle so split-mode/remote-dev serving failures are diagnosable. --- .../twango/jetplay/editor/MediaErrorEditor.kt | 42 +++++++++++++++++++ .../jetplay/editor/MediaFileEditorProvider.kt | 13 ++++++ .../dev/twango/jetplay/media/MediaServer.kt | 6 +++ .../messages/JetPlayBundle.properties | 1 + 4 files changed, 62 insertions(+) create mode 100644 frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaErrorEditor.kt diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaErrorEditor.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaErrorEditor.kt new file mode 100644 index 00000000..99975c37 --- /dev/null +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaErrorEditor.kt @@ -0,0 +1,42 @@ +package dev.twango.jetplay.editor + +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.util.UserDataHolderBase +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import dev.twango.jetplay.JetPlayBundle +import java.beans.PropertyChangeListener +import javax.swing.JComponent + +/** + * Plain-Swing fallback editor for when JCEF (the media renderer) is unavailable. Shown instead of + * an empty/broken browser pane so the failure is explicit rather than an indefinite blank tab. + */ +class MediaErrorEditor(private val file: VirtualFile, message: String) : + UserDataHolderBase(), + FileEditor { + + private val component: JComponent = JBLabel( + "
$message
", + JBLabel.CENTER, + ).apply { + border = JBUI.Borders.empty(PADDING) + } + + override fun getComponent(): JComponent = component + override fun getPreferredFocusedComponent(): JComponent = component + override fun getName(): String = JetPlayBundle.message("editor.name") + override fun setState(state: FileEditorState) = Unit + override fun isModified(): Boolean = false + override fun isValid(): Boolean = file.isValid + override fun addPropertyChangeListener(listener: PropertyChangeListener) = Unit + override fun removePropertyChangeListener(listener: PropertyChangeListener) = Unit + override fun getFile(): VirtualFile = file + override fun dispose() = Unit + + private companion object { + private const val PADDING = 24 + } +} diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt index ab111b3d..19455bb1 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt @@ -1,11 +1,14 @@ package dev.twango.jetplay.editor +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.fileEditor.FileEditor import com.intellij.openapi.fileEditor.FileEditorPolicy import com.intellij.openapi.fileEditor.FileEditorProvider import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.jcef.JBCefApp +import dev.twango.jetplay.JetPlayBundle import dev.twango.jetplay.media.EditorMediaSource import dev.twango.jetplay.star.StarReminder @@ -16,6 +19,12 @@ class MediaFileEditorProvider : override fun accept(project: Project, file: VirtualFile): Boolean = file.fileType == MediaFileType.INSTANCE override fun createEditor(project: Project, file: VirtualFile): FileEditor { + // No JCEF (e.g. headless JBR, or this provider somehow loaded on a remote-dev host) → explicit + // error rather than an empty pane that spins forever waiting on a browser that never renders. + if (!JBCefApp.isSupported()) { + log.warn("JCEF unavailable; opening ${file.name} in fallback error editor") + return MediaErrorEditor(file, JetPlayBundle.message("error.jcef.unavailable")) + } val source = EditorMediaSource(file) StarReminder.maybeShow(project) return MediaFileEditor(project, file, source) @@ -24,4 +33,8 @@ class MediaFileEditorProvider : override fun getEditorTypeId(): String = "media-player" override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_DEFAULT_EDITOR + + private companion object { + private val log = Logger.getInstance(MediaFileEditorProvider::class.java) + } } diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt index 405c422a..6c9702f5 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt @@ -61,6 +61,10 @@ object MediaServer { } private fun handle(exchange: HttpExchange) { + // Per-request trace: the only window into split-mode serving when playback silently stalls on a remote host. + if (log.isDebugEnabled) { + log.debug("${exchange.requestMethod} ${exchange.requestURI.path} range=${exchange.requestHeaders.getFirst("Range")}") + } try { val headers = exchange.responseHeaders // Null-origin JCEF page has no origin to allowlist; security rests on the random token + loopback bind + Host check. @@ -83,6 +87,8 @@ object MediaServer { val file = files[exchange.requestURI.path.trimStart('/')] if (file == null || !file.isFile) { + // A live editor requesting an unknown/vanished token signals a load-path failure, not a benign 404. + log.warn("Media request for missing file: ${exchange.requestURI.path} (registered=${file != null})") exchange.sendResponseHeaders(HTTP_NOT_FOUND, -1) return } diff --git a/frontend/src/main/resources/messages/JetPlayBundle.properties b/frontend/src/main/resources/messages/JetPlayBundle.properties index 95fa79f5..dbe819ab 100644 --- a/frontend/src/main/resources/messages/JetPlayBundle.properties +++ b/frontend/src/main/resources/messages/JetPlayBundle.properties @@ -17,6 +17,7 @@ error.download={0} error.transcoding.message=Transcoding is unavailable \u2014 the bundled FFmpeg libraries failed to load. Try reinstalling the plugin. Files in native formats (.webm, .ogg, .mp3, .wav) will still play. error.transcoding.notification.title=JetPlay: Transcoding Unavailable error.transcoding.notification.content={0} files require transcoding for playback, but the bundled FFmpeg libraries failed to load. Try reinstalling the plugin. +error.jcef.unavailable=Media playback needs the embedded browser (JCEF), which is unavailable in this IDE. Switch to a JetBrains Runtime with JCEF enabled to play media. # UI ui.downloading.label=Downloading\u2026 From 1c9cdefa0a537a770fe17fa876e00c3fb283c04d Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 18:08:41 -0700 Subject: [PATCH 04/29] build: point detekt at content-module sources The shared/frontend/backend restructure left detekt scanning the root project's now-empty source set, so the CI lint gate silently passed everything. Point the root detekt task at each module's src/main/kotlin and fix the ktlint signature violations it surfaced. --- .../twango/jetplay/rpc/MediaAccessorImpl.kt | 60 +++++++++---------- build.gradle.kts | 2 + .../twango/jetplay/editor/MediaFileEditor.kt | 7 ++- 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt index 7f4c5e47..13edd0e8 100644 --- a/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt +++ b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt @@ -21,10 +21,9 @@ private const val CHUNK_BYTES = 1 shl 20 // 1 MB class MediaAccessorImpl : MediaAccessor { - private fun resolveFile(fileId: VirtualFileId): File? = - fileId.virtualFile()?.takeIf { it.isValid }?.let { vf -> - runCatching { vf.toNioPath().toFile() }.getOrNull()?.takeIf { it.isFile } - } + private fun resolveFile(fileId: VirtualFileId): File? = fileId.virtualFile()?.takeIf { it.isValid }?.let { vf -> + runCatching { vf.toNioPath().toFile() }.getOrNull()?.takeIf { it.isFile } + } override suspend fun streamFileBytes(fileId: VirtualFileId, projectId: ProjectId): Flow = flow { val file = resolveFile(fileId) ?: return@flow @@ -55,36 +54,37 @@ class MediaAccessorImpl : MediaAccessor { } } - override suspend fun transcodeFile(fileId: VirtualFileId, projectId: ProjectId): Flow = channelFlow { - if (!FfmpegAvailability.available) { - send(TranscodeEvent.Unavailable) - return@channelFlow - } - val input = resolveFile(fileId) ?: run { - send(TranscodeEvent.Failed("source unavailable")) - return@channelFlow - } - val output = try { - withContext(Dispatchers.IO) { - // onProgress fires synchronously inside ffmpeg; trySend bridges it onto this channel without suspending. - TranscodeRunner.transcode(input) { pct -> trySend(TranscodeEvent.Progress(pct)) } + override suspend fun transcodeFile(fileId: VirtualFileId, projectId: ProjectId): Flow = + channelFlow { + if (!FfmpegAvailability.available) { + send(TranscodeEvent.Unavailable) + return@channelFlow } - } catch (e: Exception) { - send(TranscodeEvent.Failed(e.message ?: "unknown")) - return@channelFlow - } - 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))) + val input = resolveFile(fileId) ?: run { + send(TranscodeEvent.Failed("source unavailable")) + return@channelFlow + } + val output = try { + withContext(Dispatchers.IO) { + // onProgress fires synchronously inside ffmpeg; trySend bridges it onto this channel without suspending. + TranscodeRunner.transcode(input) { pct -> trySend(TranscodeEvent.Progress(pct)) } } + } catch (e: Exception) { + send(TranscodeEvent.Failed(e.message ?: "unknown")) + return@channelFlow } + 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))) + } + } + } + send(TranscodeEvent.Done) } - send(TranscodeEvent.Done) - } override suspend fun extractWaveform(fileId: VirtualFileId, projectId: ProjectId): List { if (!FfmpegAvailability.available) return emptyList() diff --git a/build.gradle.kts b/build.gradle.kts index 6fe3d581..15db60c4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -138,6 +138,8 @@ detekt { buildUponDefaultConfig = true config.setFrom(files("$projectDir/detekt.yml")) basePath.set(projectDir) + // Sources moved into content modules; point detekt at them so the root task still lints the codebase. + source.setFrom(subprojects.map { it.layout.projectDirectory.dir("src/main/kotlin") }) } tasks.withType().configureEach { diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt index 63cacffd..093fa239 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt @@ -15,8 +15,11 @@ import java.beans.PropertyChangeListener import javax.swing.JComponent import javax.swing.JPanel -class MediaFileEditor(private val project: Project, private val file: VirtualFile, private val source: EditorMediaSource) : - UserDataHolderBase(), +class MediaFileEditor( + private val project: Project, + private val file: VirtualFile, + private val source: EditorMediaSource, +) : UserDataHolderBase(), FileEditor { private val browser = JBCefBrowser() From 77cfcf485d39b588f1a5c194b04b04f56af4f5e3 Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 18:08:54 -0700 Subject: [PATCH 05/29] feat: fail playback explicitly when the media URL never reaches the browser In remote dev the JCEF Chromium may run frontend-side and never reach the loopback MediaServer, leaving the player spinning forever. Track which served tokens the browser actually fetches and arm a per-load watchdog: if nothing fetches the token before the deadline, surface an explicit error state pointing at the Remote Development limitation instead of an indefinite spinner. The fetch tracking doubles as the telemetry needed to diagnose split-mode serving failures. --- .../dev/twango/jetplay/editor/MediaLoader.kt | 40 +++++++++++++++++-- .../dev/twango/jetplay/media/MediaServer.kt | 17 +++++++- .../messages/JetPlayBundle.properties | 1 + 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt index 9807ae23..d370a8e0 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt @@ -9,6 +9,7 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.platform.project.projectId +import com.intellij.util.concurrency.AppExecutorUtil import dev.twango.jetplay.JetPlayBundle import dev.twango.jetplay.JetPlayConstants import dev.twango.jetplay.browser.PlayerBridge @@ -25,6 +26,8 @@ import kotlinx.coroutines.runBlocking import java.io.File import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Future +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit class MediaLoader( private val project: Project, @@ -38,8 +41,25 @@ class MediaLoader( // Loopback URLs handed out for this editor's media, released on dispose. private val servedUrls = CopyOnWriteArrayList() + @Volatile + private var watchdog: ScheduledFuture<*>? = null + private fun serve(file: File): String = MediaServer.serve(file).also { servedUrls.add(it) } + /** + * The player has the URL but JCEF may never reach the loopback server (frontend-side CEF in remote + * dev). If nothing fetches the token before the deadline, surface an explicit error so the user + * sees a diagnosable failure instead of an indefinite spinner. + */ + private fun armLoadWatchdog(url: String) { + watchdog?.cancel(false) + watchdog = AppExecutorUtil.getAppScheduledExecutorService().schedule({ + if (bridge.disposed || MediaServer.wasFetched(url)) return@schedule + log.warn("Media load watchdog: $url served but never fetched after ${LOAD_TIMEOUT_SECONDS}s") + bridge.showError(JetPlayBundle.message("error.load.timeout")) + }, LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + private val uiStrings = UiStrings( downloadingLabel = JetPlayBundle.message("ui.downloading.label"), transcodingLabel = JetPlayBundle.message("ui.transcoding.label"), @@ -98,7 +118,9 @@ class MediaLoader( if (source.needsTranscoding) { startTranscoding() } else { - bridge.mediaReady(serve(temp)) + val url = serve(temp) + bridge.mediaReady(url) + armLoadWatchdog(url) } } } @@ -138,7 +160,11 @@ class MediaLoader( } } } - if (!bridge.disposed) bridge.mediaReady(serve(temp)) + if (!bridge.disposed) { + val url = serve(temp) + bridge.mediaReady(url) + armLoadWatchdog(url) + } } catch (_: TranscodeUnavailable) { showTranscodingError() } catch (e: TranscodeFailure) { @@ -152,30 +178,34 @@ class MediaLoader( val local = source.localFileOrNull() if (local != null) { // MONOLITH / local file: identical to today — serve the real file, no RPC, no temp copy. + val url = serve(local) htmlLoader.load( PlayerConfig( isVideo = source.isVideo, fileName = source.fileName, fileExtension = source.extension, - mediaUrl = serve(local), + mediaUrl = url, ui = uiStrings, ), ) + armLoadWatchdog(url) return } // SPLIT MODE: pull bytes from backend into a temp file, then serve. submit { val temp = streamToTemp { } ?: return@submit if (!bridge.disposed) { + val url = serve(temp) htmlLoader.load( PlayerConfig( isVideo = source.isVideo, fileName = source.fileName, fileExtension = source.extension, - mediaUrl = serve(temp), + mediaUrl = url, ui = uiStrings, ), ) + armLoadWatchdog(url) } } } @@ -248,6 +278,7 @@ class MediaLoader( } fun dispose() { + watchdog?.cancel(false) tasks.forEach { it.cancel(true) } servedUrls.forEach(MediaServer::release) } @@ -258,5 +289,6 @@ class MediaLoader( companion object { private val log = Logger.getInstance(MediaLoader::class.java) private const val PERCENT_SCALE = 100.0 + private const val LOAD_TIMEOUT_SECONDS = 20L } } diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt index 6c9702f5..0cd259ac 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt @@ -23,6 +23,10 @@ object MediaServer { private val log = Logger.getInstance(MediaServer::class.java) private val files = ConcurrentHashMap() + + // Tokens the browser has actually fetched. Drives the load watchdog: a served-but-never-fetched + // token means the loopback URL is unreachable from the (possibly remote-dev/frontend-side) Chromium. + private val fetched = ConcurrentHashMap.newKeySet() private const val CHUNK = 64 * 1024 private const val HTTP_OK = 200 @@ -44,9 +48,14 @@ object MediaServer { return "http://127.0.0.1:${srv.address.port}/$token" } + /** True once the browser has fetched [url] at least once (any method, any range). */ + fun wasFetched(url: String): Boolean = fetched.contains(url.substringAfterLast('/')) + /** Stops serving the file behind [url]. */ fun release(url: String) { - files.remove(url.substringAfterLast('/')) + val token = url.substringAfterLast('/') + files.remove(token) + fetched.remove(token) } private fun start(): HttpServer { @@ -85,13 +94,17 @@ object MediaServer { return } - val file = files[exchange.requestURI.path.trimStart('/')] + val token = exchange.requestURI.path.trimStart('/') + val file = files[token] if (file == null || !file.isFile) { // A live editor requesting an unknown/vanished token signals a load-path failure, not a benign 404. log.warn("Media request for missing file: ${exchange.requestURI.path} (registered=${file != null})") exchange.sendResponseHeaders(HTTP_NOT_FOUND, -1) return } + // First reachable fetch of this token: the watchdog reads this to tell a stalled load + // (URL never reached the browser) from a genuinely playing one. + if (fetched.add(token)) log.debug("First media fetch for token $token") headers.add("Content-Type", contentType(file)) headers.add("Accept-Ranges", "bytes") diff --git a/frontend/src/main/resources/messages/JetPlayBundle.properties b/frontend/src/main/resources/messages/JetPlayBundle.properties index dbe819ab..710ca842 100644 --- a/frontend/src/main/resources/messages/JetPlayBundle.properties +++ b/frontend/src/main/resources/messages/JetPlayBundle.properties @@ -18,6 +18,7 @@ error.transcoding.message=Transcoding is unavailable \u2014 the bundled FFmpeg l error.transcoding.notification.title=JetPlay: Transcoding Unavailable error.transcoding.notification.content={0} files require transcoding for playback, but the bundled FFmpeg libraries failed to load. Try reinstalling the plugin. error.jcef.unavailable=Media playback needs the embedded browser (JCEF), which is unavailable in this IDE. Switch to a JetBrains Runtime with JCEF enabled to play media. +error.load.timeout=Playback could not start. The media stream never reached the player. This is a known limitation of Remote Development \u2014 try opening the file in the local IDE instead. # UI ui.downloading.label=Downloading\u2026 From 56ca8b8320f15b0c2edc9dbaf58c9561bd52c619 Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 18:21:36 -0700 Subject: [PATCH 06/29] test: re-home tests into content modules and cover the split-mode RPC path Each test now lives in the module owning the class under test: the editor/browser/ star/MediaServer suites move to frontend, the ffmpeg extractor suites to backend, and the format classifier + RPC event tests to shared. Fixes stale transcode.MediaInfo imports left by the package move and routes the fileType-registration check at the frontend descriptor. Adds coverage for the migration's new surface: MediaAccessorImpl byte streaming (1MB chunk boundary, range reads, file length) over a real VirtualFileId round-trip, the monolith local-file fast-path in EditorMediaSource, MediaClassification, and the TranscodeEvent value semantics. --- backend/build.gradle.kts | 6 ++ .../jetplay/transcode/MediaTranscoder.kt | 3 + .../jetplay/rpc/MediaAccessorImplTest.kt | 65 +++++++++++++++++ .../transcode/MediaInfoExtractorTest.kt | 0 .../jetplay/transcode/MediaTranscoderTest.kt | 15 ++++ .../transcode/WaveformExtractorTest.kt | 0 frontend/build.gradle.kts | 6 ++ .../jetplay/browser/PlayerBridgeEscapeTest.kt | 4 +- .../jetplay/browser/PlayerHtmlLoaderTest.kt | 0 .../editor/MediaFileEditorProviderTest.kt | 21 ++++++ .../jetplay/media/EditorMediaSourceTest.kt | 41 +++++++++++ .../twango/jetplay/media/MediaServerTest.kt | 0 .../jetplay/media/RawAudioRegistrationTest.kt | 20 ++++++ .../jetplay/star/StarReminderPolicyTest.kt | 0 shared/build.gradle.kts | 1 - .../jetplay/media/MediaClassificationTest.kt | 71 +++++++++++++++++++ .../twango/jetplay/rpc/TranscodeEventTest.kt | 43 +++++++++++ .../jetplay/transcode/MediaTranscoderTest.kt | 67 ----------------- 18 files changed, 293 insertions(+), 70 deletions(-) create mode 100644 backend/src/test/kotlin/dev/twango/jetplay/rpc/MediaAccessorImplTest.kt rename {shared => backend}/src/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt (100%) create mode 100644 backend/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt rename {shared => backend}/src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt (100%) rename {shared => frontend}/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt (97%) rename {shared => frontend}/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt (100%) rename {shared => frontend}/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt (76%) create mode 100644 frontend/src/test/kotlin/dev/twango/jetplay/media/EditorMediaSourceTest.kt rename {shared => frontend}/src/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt (100%) create mode 100644 frontend/src/test/kotlin/dev/twango/jetplay/media/RawAudioRegistrationTest.kt rename {shared => frontend}/src/test/kotlin/dev/twango/jetplay/star/StarReminderPolicyTest.kt (100%) create mode 100644 shared/src/test/kotlin/dev/twango/jetplay/media/MediaClassificationTest.kt create mode 100644 shared/src/test/kotlin/dev/twango/jetplay/rpc/TranscodeEventTest.kt delete mode 100644 shared/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 72a278c3..e3829fb7 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + dependencies { intellijPlatform { bundledModule("intellij.platform.kernel.backend") @@ -6,9 +8,13 @@ dependencies { 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") diff --git a/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt b/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt index 2234694d..d0d0250d 100644 --- a/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt +++ b/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt @@ -37,6 +37,9 @@ object MediaTranscoder { "sln" to RawAudioHint("s16le", 8000, 1), ) + /** Extensions for which a demuxer hint is configured; must stay in sync with the shared classifier. */ + internal val rawAudioExtensions: Set get() = RAW_AUDIO_HINTS.keys + fun transcode(inputFile: File, onProgress: (Double) -> Unit = {}): File { val outputFile = Files.createTempFile("jetplay-", ".webm").toFile().apply { deleteOnExit() } diff --git a/backend/src/test/kotlin/dev/twango/jetplay/rpc/MediaAccessorImplTest.kt b/backend/src/test/kotlin/dev/twango/jetplay/rpc/MediaAccessorImplTest.kt new file mode 100644 index 00000000..34a6a46a --- /dev/null +++ b/backend/src/test/kotlin/dev/twango/jetplay/rpc/MediaAccessorImplTest.kt @@ -0,0 +1,65 @@ +package dev.twango.jetplay.rpc + +import com.intellij.ide.vfs.VirtualFileId +import com.intellij.ide.vfs.rpcId +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.platform.project.ProjectId +import com.intellij.platform.project.projectId +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import java.nio.file.Files + +class MediaAccessorImplTest : BasePlatformTestCase() { + + private val impl = MediaAccessorImpl() + + private fun fileId(bytes: ByteArray): VirtualFileId { + val path = Files.createTempFile("jetplay-accessor-", ".bin") + path.toFile().writeBytes(bytes) + path.toFile().deleteOnExit() + return LocalFileSystem.getInstance().refreshAndFindFileByNioFile(path)!!.rpcId() + } + + private fun projectId(): ProjectId = project.projectId() + + fun testStreamFileBytesReassemblesEveryByteInOrder() { + val data = ByteArray(2048) { (it % 251).toByte() } + val streamed = runBlocking { + impl.streamFileBytes(fileId(data), projectId()).toList() + } + assertTrue("a 2KB file fits in a single 1MB chunk", streamed.size == 1) + assertArrayEquals(data, streamed.single()) + } + + fun testStreamFileBytesChunksOnTheMegabyteBoundary() { + // 1 MB + 17 bytes: one full chunk plus a short tail; the tail must be exactly 17 bytes, not a padded 1 MB. + val size = (1 shl 20) + 17 + val data = ByteArray(size) { (it % 251).toByte() } + val streamed = runBlocking { + impl.streamFileBytes(fileId(data), projectId()).toList() + } + assertEquals(2, streamed.size) + assertEquals(1 shl 20, streamed[0].size) + assertEquals(17, streamed[1].size) + assertArrayEquals(data, streamed[0] + streamed[1]) + } + + fun testFileLengthReportsRealSize() { + val data = ByteArray(777) + assertEquals(777L, runBlocking { impl.fileLength(fileId(data), projectId()) }) + } + + fun testReadRangeReturnsTheRequestedWindow() { + val data = ByteArray(100) { it.toByte() } + val window = runBlocking { impl.readRange(fileId(data), projectId(), offset = 10, length = 5) } + assertArrayEquals(byteArrayOf(10, 11, 12, 13, 14), window) + } + + fun testReadRangePastEndIsTruncatedToWhatExists() { + val data = ByteArray(8) { it.toByte() } + val window = runBlocking { impl.readRange(fileId(data), projectId(), offset = 5, length = 100) } + assertArrayEquals(byteArrayOf(5, 6, 7), window) + } +} diff --git a/shared/src/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt b/backend/src/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt similarity index 100% rename from shared/src/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt rename to backend/src/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt diff --git a/backend/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt b/backend/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt new file mode 100644 index 00000000..d033cb48 --- /dev/null +++ b/backend/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt @@ -0,0 +1,15 @@ +package dev.twango.jetplay.transcode + +import dev.twango.jetplay.media.MediaClassification +import org.junit.Assert.assertEquals +import org.junit.Test + +class MediaTranscoderTest { + + @Test + fun demuxerHintsCoverEveryRawAudioExtension() { + // The backend supplies a demuxer hint for each headerless codec the classifier flags; a mismatch + // means a raw-audio file routes to ffmpeg with no format set and fails to decode. + assertEquals(MediaClassification.rawAudioExtensions, MediaTranscoder.rawAudioExtensions) + } +} diff --git a/shared/src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt b/backend/src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt similarity index 100% rename from shared/src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt rename to backend/src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts index 85c95f03..e00922c9 100644 --- a/frontend/build.gradle.kts +++ b/frontend/build.gradle.kts @@ -1,11 +1,17 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + dependencies { intellijPlatform { bundledModule("intellij.platform.frontend") bundledModule("intellij.platform.rpc") compileOnly(libs.kotlin.serialization.core.jvm) compileOnly(libs.kotlin.serialization.json.jvm) + testFramework(TestFrameworkType.Platform) } implementation(project(":shared")) + + testImplementation(libs.junit) + testImplementation(libs.opentest4j) } // Svelte player UI is built from the repo-root ui/ tree into this module's resources. diff --git a/shared/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt b/frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt similarity index 97% rename from shared/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt rename to frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt index 17bb6131..0d1f3af0 100644 --- a/shared/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt +++ b/frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt @@ -1,7 +1,7 @@ package dev.twango.jetplay.browser -import dev.twango.jetplay.transcode.MediaInfo -import dev.twango.jetplay.transcode.MediaTag +import dev.twango.jetplay.media.MediaInfo +import dev.twango.jetplay.media.MediaTag import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull diff --git a/shared/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt b/frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt similarity index 100% rename from shared/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt rename to frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt diff --git a/shared/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt b/frontend/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt similarity index 76% rename from shared/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt rename to frontend/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt index c3448771..b3023d3a 100644 --- a/shared/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt +++ b/frontend/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt @@ -1,14 +1,35 @@ package dev.twango.jetplay.editor +import com.intellij.openapi.application.WriteAction +import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.testFramework.fixtures.BasePlatformTestCase class MediaFileEditorProviderTest : BasePlatformTestCase() { private lateinit var provider: MediaFileEditorProvider + // The fileType→extension mapping ships in the frontend module descriptor, which BasePlatformTestCase + // does not load. Register it here so the provider can resolve the Media type for these extensions. + private val mediaExtensions = listOf( + "mp4", "webm", "mkv", "avi", "mov", "mp3", "ogg", "wav", "flac", "aac", "opus", + ) + override fun setUp() { super.setUp() provider = MediaFileEditorProvider() + WriteAction.runAndWait { + mediaExtensions.forEach { FileTypeManager.getInstance().associateExtension(MediaFileType.INSTANCE, it) } + } + } + + override fun tearDown() { + try { + WriteAction.runAndWait { + mediaExtensions.forEach { FileTypeManager.getInstance().removeAssociatedExtension(MediaFileType.INSTANCE, it) } + } + } finally { + super.tearDown() + } } fun testAcceptsMp4() { diff --git a/frontend/src/test/kotlin/dev/twango/jetplay/media/EditorMediaSourceTest.kt b/frontend/src/test/kotlin/dev/twango/jetplay/media/EditorMediaSourceTest.kt new file mode 100644 index 00000000..3f45f5e6 --- /dev/null +++ b/frontend/src/test/kotlin/dev/twango/jetplay/media/EditorMediaSourceTest.kt @@ -0,0 +1,41 @@ +package dev.twango.jetplay.media + +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.nio.file.Files + +class EditorMediaSourceTest : BasePlatformTestCase() { + + // A real LocalFileSystem file stands in for the monolith / local-IDE case where bytes are readable in-process. + private fun localSource(name: String): EditorMediaSource { + val path = Files.createTempFile("jetplay-source-", "-$name") + path.toFile().writeBytes("data".toByteArray()) + path.toFile().deleteOnExit() + val vf = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(path) + ?: error("could not resolve $path into the VFS") + return EditorMediaSource(vf) + } + + fun testLocalFileExposesNioFastPath() { + val source = localSource("clip.mp4") + val local = source.localFileOrNull() + assertNotNull("a LocalFileSystem file must expose its nio path for direct serving", local) + assertTrue(local!!.isFile) + assertEquals("data", local.readText()) + assertFalse("a local file is never treated as remote", source.isRemote) + } + + fun testVideoExtensionClassifiesAsVideoAndNeedsTranscoding() { + val source = localSource("clip.mp4") + assertTrue(source.isVideo) + assertTrue(source.needsTranscoding) + assertEquals("mp4", source.extension) + } + + fun testNativeAudioNeedsNoTranscoding() { + val source = localSource("song.mp3") + assertFalse(source.isVideo) + assertFalse(source.needsTranscoding) + assertEquals("mp3", source.extension) + } +} diff --git a/shared/src/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt b/frontend/src/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt similarity index 100% rename from shared/src/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt rename to frontend/src/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt diff --git a/frontend/src/test/kotlin/dev/twango/jetplay/media/RawAudioRegistrationTest.kt b/frontend/src/test/kotlin/dev/twango/jetplay/media/RawAudioRegistrationTest.kt new file mode 100644 index 00000000..337b69a7 --- /dev/null +++ b/frontend/src/test/kotlin/dev/twango/jetplay/media/RawAudioRegistrationTest.kt @@ -0,0 +1,20 @@ +package dev.twango.jetplay.media + +import org.junit.Assert.assertTrue +import org.junit.Test + +class RawAudioRegistrationTest { + + @Test + fun rawAudioHintsAreRegisteredInTheFrontendDescriptor() { + val xml = javaClass.getResource("/dev.twango.jetplay.frontend.xml")!!.readText() + val registered = Regex("""extensions\s*=\s*"([^"]*)"""").find(xml) + ?.groupValues?.get(1) + ?.split(";") + ?.map { it.trim().lowercase() } + ?.filterTo(mutableSetOf()) { it.isNotEmpty() } + ?: error("Could not find a fileType extensions attribute in the frontend descriptor") + val missing = MediaClassification.rawAudioExtensions - registered + assertTrue("raw-audio extensions missing from the frontend descriptor: $missing", missing.isEmpty()) + } +} diff --git a/shared/src/test/kotlin/dev/twango/jetplay/star/StarReminderPolicyTest.kt b/frontend/src/test/kotlin/dev/twango/jetplay/star/StarReminderPolicyTest.kt similarity index 100% rename from shared/src/test/kotlin/dev/twango/jetplay/star/StarReminderPolicyTest.kt rename to frontend/src/test/kotlin/dev/twango/jetplay/star/StarReminderPolicyTest.kt diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index b09970ce..85f099b4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,6 +1,5 @@ import org.jetbrains.intellij.platform.gradle.TestFrameworkType -// Tests are parked here temporarily; Step 5 re-homes them per module. dependencies { intellijPlatform { bundledModule("intellij.platform.rpc") diff --git a/shared/src/test/kotlin/dev/twango/jetplay/media/MediaClassificationTest.kt b/shared/src/test/kotlin/dev/twango/jetplay/media/MediaClassificationTest.kt new file mode 100644 index 00000000..fae46249 --- /dev/null +++ b/shared/src/test/kotlin/dev/twango/jetplay/media/MediaClassificationTest.kt @@ -0,0 +1,71 @@ +package dev.twango.jetplay.media + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MediaClassificationTest { + + @Test + fun nativeVideoFormatsDoNotNeedTranscoding() { + assertFalse(MediaClassification.needsTranscoding("webm")) + assertFalse(MediaClassification.needsTranscoding("ogv")) + } + + @Test + fun nativeAudioFormatsDoNotNeedTranscoding() { + assertFalse(MediaClassification.needsTranscoding("ogg")) + assertFalse(MediaClassification.needsTranscoding("oga")) + assertFalse(MediaClassification.needsTranscoding("opus")) + assertFalse(MediaClassification.needsTranscoding("wav")) + assertFalse(MediaClassification.needsTranscoding("flac")) + assertFalse(MediaClassification.needsTranscoding("mp3")) + } + + @Test + fun nonNativeFormatsNeedTranscoding() { + assertTrue(MediaClassification.needsTranscoding("mp4")) + assertTrue(MediaClassification.needsTranscoding("m4v")) + assertTrue(MediaClassification.needsTranscoding("m4a")) + assertTrue(MediaClassification.needsTranscoding("aac")) + } + + @Test + fun transcodingCheckIsCaseInsensitive() { + assertFalse(MediaClassification.needsTranscoding("WEBM")) + assertFalse(MediaClassification.needsTranscoding("MP3")) + assertFalse(MediaClassification.needsTranscoding("Wav")) + assertTrue(MediaClassification.needsTranscoding("MP4")) + } + + @Test + fun nullExtensionNeedsTranscoding() { + assertTrue(MediaClassification.needsTranscoding(null)) + } + + @Test + fun emptyExtensionNeedsTranscoding() { + assertTrue(MediaClassification.needsTranscoding("")) + } + + @Test + fun videoExtensionsClassifyAsVideo() { + assertTrue(MediaClassification.isVideo("mp4")) + assertTrue(MediaClassification.isVideo("MKV")) + assertTrue(MediaClassification.isVideo("webm")) + } + + @Test + fun audioExtensionsDoNotClassifyAsVideo() { + assertFalse(MediaClassification.isVideo("mp3")) + assertFalse(MediaClassification.isVideo("flac")) + assertFalse(MediaClassification.isVideo("opus")) + } + + @Test + fun rawAudioExtensionsNeedTranscoding() { + MediaClassification.rawAudioExtensions.forEach { + assertTrue("raw codec $it must transcode", MediaClassification.needsTranscoding(it)) + } + } +} diff --git a/shared/src/test/kotlin/dev/twango/jetplay/rpc/TranscodeEventTest.kt b/shared/src/test/kotlin/dev/twango/jetplay/rpc/TranscodeEventTest.kt new file mode 100644 index 00000000..7a9aea94 --- /dev/null +++ b/shared/src/test/kotlin/dev/twango/jetplay/rpc/TranscodeEventTest.kt @@ -0,0 +1,43 @@ +package dev.twango.jetplay.rpc + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class TranscodeEventTest { + + // Chunk carries a ByteArray, so it hand-rolls equals/hashCode over contents; verify that contract holds, + // since the frontend de-dups and the transport relies on value semantics rather than array identity. + @Test + fun chunksWithEqualBytesAreEqual() { + val a = TranscodeEvent.Chunk(byteArrayOf(1, 2, 3)) + val b = TranscodeEvent.Chunk(byteArrayOf(1, 2, 3)) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun chunksWithDifferentBytesAreNotEqual() { + assertNotEquals( + TranscodeEvent.Chunk(byteArrayOf(1, 2, 3)), + TranscodeEvent.Chunk(byteArrayOf(1, 2, 4)), + ) + } + + @Test + fun progressCarriesPercent() { + assertEquals(42.0, (TranscodeEvent.Progress(42.0)).percent, 0.0) + } + + @Test + fun failedCarriesMessage() { + assertEquals("boom", (TranscodeEvent.Failed("boom")).message) + } + + @Test + fun terminalSingletonsAreDistinct() { + val done: TranscodeEvent = TranscodeEvent.Done + val unavailable: TranscodeEvent = TranscodeEvent.Unavailable + assertNotEquals(done, unavailable) + } +} diff --git a/shared/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt b/shared/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt deleted file mode 100644 index 4010e8d9..00000000 --- a/shared/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package dev.twango.jetplay.transcode - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class MediaTranscoderTest { - - @Test - fun nativeVideoFormatsDoNotNeedTranscoding() { - assertFalse(MediaTranscoder.needsTranscoding("webm")) - assertFalse(MediaTranscoder.needsTranscoding("ogv")) - } - - @Test - fun nativeAudioFormatsDoNotNeedTranscoding() { - assertFalse(MediaTranscoder.needsTranscoding("ogg")) - assertFalse(MediaTranscoder.needsTranscoding("oga")) - assertFalse(MediaTranscoder.needsTranscoding("opus")) - assertFalse(MediaTranscoder.needsTranscoding("wav")) - assertFalse(MediaTranscoder.needsTranscoding("flac")) - assertFalse(MediaTranscoder.needsTranscoding("mp3")) - } - - @Test - fun nonNativeFormatsNeedTranscoding() { - assertTrue(MediaTranscoder.needsTranscoding("mp4")) - assertTrue(MediaTranscoder.needsTranscoding("m4v")) - assertTrue(MediaTranscoder.needsTranscoding("m4a")) - assertTrue(MediaTranscoder.needsTranscoding("aac")) - } - - @Test - fun extensionCheckIsCaseInsensitive() { - assertFalse(MediaTranscoder.needsTranscoding("WEBM")) - assertFalse(MediaTranscoder.needsTranscoding("MP3")) - assertFalse(MediaTranscoder.needsTranscoding("Wav")) - assertTrue(MediaTranscoder.needsTranscoding("MP4")) - } - - @Test - fun nullExtensionNeedsTranscoding() { - assertTrue(MediaTranscoder.needsTranscoding(null)) - } - - @Test - fun emptyExtensionNeedsTranscoding() { - assertTrue(MediaTranscoder.needsTranscoding("")) - } - - @Test - fun rawAudioHintsAreRegisteredInPluginXml() { - val xml = MediaTranscoder::class.java.getResource("/META-INF/plugin.xml")!!.readText() - val match = Regex("""extensions\s*=\s*"([^"]*)"""").find(xml) - ?: error("Could not find extensions attribute in plugin.xml") - val registered = match.groupValues[1] - .split(";") - .map { it.trim().lowercase() } - .filter { it.isNotEmpty() } - .toSet() - val missing = MediaTranscoder.rawAudioExtensions - registered - assertTrue( - "RAW_AUDIO_HINTS keys missing from plugin.xml: $missing", - missing.isEmpty(), - ) - } -} From b429aee836e26d3cf5b0644d76375eb219e85bd6 Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 18:42:27 -0700 Subject: [PATCH 07/29] fix(player): surface empty/unresolvable media instead of serving 0-byte files Guard streamToTemp against the backend's empty-flow result (returned when a VirtualFile cannot be resolved to a local nio path): delete the temp and show an explicit error rather than handing the player a permanently broken 0-byte URL. Route transcoding ahead of the remote-download path so transcodable remote files transcode backend-side instead of discarding a wasted download, and show a loading shell before the split-mode streaming RPC so the tab is never blank. Delete frontend transcode/stream temps on dispose and failure, and the backend transcode output after it has streamed. Fill readRange across partial reads. --- .../twango/jetplay/rpc/MediaAccessorImpl.kt | 36 +++++--- .../dev/twango/jetplay/editor/MediaLoader.kt | 89 ++++++++++++------- .../messages/JetPlayBundle.properties | 1 + 3 files changed, 83 insertions(+), 43 deletions(-) diff --git a/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt index 13edd0e8..bfff1b9e 100644 --- a/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt +++ b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt @@ -45,11 +45,18 @@ class MediaAccessorImpl : MediaAccessor { RandomAccessFile(file, "r").use { raf -> raf.seek(offset) val out = ByteArray(length) - val read = raf.read(out) - return when { - read <= 0 -> ByteArray(0) - read == length -> out - else -> out.copyOf(read) + // A single raf.read() may return fewer bytes than requested even when more remain + // (JDK contract); loop until the buffer is full or EOF is hit. + var total = 0 + while (total < length) { + val n = raf.read(out, total, length - total) + if (n < 0) break + total += n + } + return when (total) { + 0 -> ByteArray(0) + length -> out + else -> out.copyOf(total) } } } @@ -73,15 +80,20 @@ class MediaAccessorImpl : MediaAccessor { send(TranscodeEvent.Failed(e.message ?: "unknown")) return@channelFlow } - 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))) + 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 { + // The transcoded bytes now live in the frontend temp; the backend copy is no longer needed. + runCatching { output.delete() } } send(TranscodeEvent.Done) } diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt index d370a8e0..c0a2c793 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt @@ -71,9 +71,12 @@ class MediaLoader( private val projectId by lazy { project.projectId() } fun load() { + // Transcoding (remote or local) runs entirely backend-side: ffmpeg reads the file there and + // streams WebM back. Pre-downloading the source would only waste bandwidth — the transcode + // re-reads it on the backend regardless — so transcoding takes precedence over the download path. when { - source.isRemote -> startDownload() source.needsTranscoding -> startTranscoding() + source.isRemote -> startDownload() else -> playDirectly() } maybeSendWaveform() @@ -114,32 +117,29 @@ class MediaLoader( ) submit { val temp = streamToTemp(::reportDownloadProgress) ?: return@submit - if (bridge.disposed) return@submit - if (source.needsTranscoding) { - startTranscoding() - } else { - val url = serve(temp) - bridge.mediaReady(url) - armLoadWatchdog(url) + if (bridge.disposed) { + temp.delete() + return@submit } + val url = serve(temp) + bridge.mediaReady(url) + armLoadWatchdog(url) } } private fun startTranscoding() { - if (source.isRemote) { - bridge.executeJs("window.__jetplayState='loading';window.__jetplayProgress=0;window.jetplayStartTranscoding?.()") - } else { - htmlLoader.load( - PlayerConfig( - state = "loading", - isVideo = source.isVideo, - fileName = source.fileName, - fileExtension = source.extension, - transcodingReason = JetPlayBundle.message("transcoding.reason", source.extension.uppercase()), - ui = uiStrings, - ), - ) - } + // Transcoding (local or remote) runs backend-side with no prior page loaded, so render the + // loading shell directly rather than pushing a state change into a page that may not exist yet. + htmlLoader.load( + PlayerConfig( + state = "loading", + isVideo = source.isVideo, + fileName = source.fileName, + fileExtension = source.extension, + transcodingReason = JetPlayBundle.message("transcoding.reason", source.extension.uppercase()), + ui = uiStrings, + ), + ) submit { runTranscode() } } @@ -160,16 +160,21 @@ class MediaLoader( } } } - if (!bridge.disposed) { + if (bridge.disposed) { + temp.delete() + } else { val url = serve(temp) bridge.mediaReady(url) armLoadWatchdog(url) } } catch (_: TranscodeUnavailable) { + temp.delete() showTranscodingError() } catch (e: TranscodeFailure) { + temp.delete() if (!bridge.disposed) bridge.showError(e.message ?: JetPlayBundle.message("error.unknown")) } catch (e: Exception) { + temp.delete() showLoadError(e.message) } } @@ -191,10 +196,22 @@ class MediaLoader( armLoadWatchdog(url) return } - // SPLIT MODE: pull bytes from backend into a temp file, then serve. + // SPLIT MODE: pull bytes from backend into a temp file, then serve. Show a loading shell up + // front so the streaming RPC doesn't leave the tab on a blank Chromium page. + htmlLoader.load( + PlayerConfig( + state = "loading", + isVideo = source.isVideo, + fileName = source.fileName, + fileExtension = source.extension, + ui = uiStrings, + ), + ) submit { val temp = streamToTemp { } ?: return@submit - if (!bridge.disposed) { + if (bridge.disposed) { + temp.delete() + } else { val url = serve(temp) htmlLoader.load( PlayerConfig( @@ -213,23 +230,33 @@ class MediaLoader( /** Streams the source bytes from the backend into a temp file. Returns null on failure (error surfaced). */ private fun streamToTemp(onProgress: (Long) -> Unit): File? { val temp = File.createTempFile("jetplay-", ".${source.extension}").apply { deleteOnExit() } - return try { + val written = try { runBlocking { val api = MediaAccessor.getInstance() - var written = 0L + var bytes = 0L temp.outputStream().use { out -> api.streamFileBytes(fileId, projectId).collect { chunk -> out.write(chunk) - written += chunk.size - onProgress(written) + bytes += chunk.size + onProgress(bytes) } } + bytes } - temp } catch (e: Exception) { + temp.delete() showLoadError(e.message) - null + return null + } + // The backend emits an empty flow (no bytes, no error) when it can't resolve the file + // (e.g. a non-LocalFileSystem VirtualFile). Serving the 0-byte temp would hand the player + // a permanently broken URL with no diagnosable failure — surface an explicit error instead. + if (written == 0L) { + temp.delete() + showLoadError(JetPlayBundle.message("error.empty")) + return null } + return temp } private fun reportDownloadProgress(bytes: Long) { diff --git a/frontend/src/main/resources/messages/JetPlayBundle.properties b/frontend/src/main/resources/messages/JetPlayBundle.properties index 710ca842..c6975e09 100644 --- a/frontend/src/main/resources/messages/JetPlayBundle.properties +++ b/frontend/src/main/resources/messages/JetPlayBundle.properties @@ -13,6 +13,7 @@ transcoding.reason={0} uses codecs not natively supported by the embedded browse # Errors error.unknown=Unknown error +error.empty=The media stream was empty. The file could not be read from the host. error.download={0} error.transcoding.message=Transcoding is unavailable \u2014 the bundled FFmpeg libraries failed to load. Try reinstalling the plugin. Files in native formats (.webm, .ogg, .mp3, .wav) will still play. error.transcoding.notification.title=JetPlay: Transcoding Unavailable From 01af498bf4d5c82cc6bc24b85b2db08a14863053 Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 18:42:33 -0700 Subject: [PATCH 08/29] test(backend): cover readRange filling the buffer across partial reads --- .../dev/twango/jetplay/rpc/MediaAccessorImplTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/src/test/kotlin/dev/twango/jetplay/rpc/MediaAccessorImplTest.kt b/backend/src/test/kotlin/dev/twango/jetplay/rpc/MediaAccessorImplTest.kt index 34a6a46a..ad6a425a 100644 --- a/backend/src/test/kotlin/dev/twango/jetplay/rpc/MediaAccessorImplTest.kt +++ b/backend/src/test/kotlin/dev/twango/jetplay/rpc/MediaAccessorImplTest.kt @@ -62,4 +62,12 @@ class MediaAccessorImplTest : BasePlatformTestCase() { val window = runBlocking { impl.readRange(fileId(data), projectId(), offset = 5, length = 100) } assertArrayEquals(byteArrayOf(5, 6, 7), window) } + + fun testReadRangeFillsTheWholeBufferAcrossPartialReads() { + // A range larger than a typical single OS read must come back fully populated, not truncated + // to whatever the first raf.read() happened to return. + val data = ByteArray(256 * 1024) { (it % 251).toByte() } + val window = runBlocking { impl.readRange(fileId(data), projectId(), offset = 0, length = data.size) } + assertArrayEquals(data, window) + } } From 8cbeecf371d85b8216a4a598ae7cbf56ad600151 Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 18:42:43 -0700 Subject: [PATCH 09/29] refactor(modular): align RPC modules with the platform template Suppress UnstableApiUsage on the @Rpc interface and the backend provider (both touch ApiStatus.Internal fleet.rpc APIs). Drop the redundant intellij.platform.rpc.backend module dep from backend.xml and the redundant intellij.platform.rpc bundledModule from frontend (both satisfied transitively, matching the modular template and the markdown plugin). Remove the unreachable LocalFileMediaSource left over from the pre-split monolith. --- .../jetplay/media/LocalFileMediaSource.kt | 19 ------------------- .../jetplay/rpc/MediaAccessorProvider.kt | 2 ++ .../resources/dev.twango.jetplay.backend.xml | 1 - frontend/build.gradle.kts | 1 - .../dev/twango/jetplay/rpc/MediaAccessor.kt | 2 ++ 5 files changed, 4 insertions(+), 21 deletions(-) delete mode 100644 backend/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt diff --git a/backend/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt b/backend/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt deleted file mode 100644 index f7c4a381..00000000 --- a/backend/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt +++ /dev/null @@ -1,19 +0,0 @@ -package dev.twango.jetplay.media - -import com.intellij.openapi.vfs.VirtualFile -import java.io.File - -class LocalFileMediaSource(private val file: VirtualFile) : MediaSource { - - override val fileName: String = file.name - - override val extension: String = file.extension?.lowercase() ?: "" - - override val isVideo: Boolean = MediaClassification.isVideo(extension) - - override val needsTranscoding: Boolean = MediaClassification.needsTranscoding(extension) - - override val isRemote: Boolean = false - - fun toLocalFile(): File = file.toNioPath().toFile() -} diff --git a/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorProvider.kt b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorProvider.kt index b3a4d3dc..81ca260b 100644 --- a/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorProvider.kt +++ b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorProvider.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnstableApiUsage") + package dev.twango.jetplay.rpc import com.intellij.platform.rpc.backend.RemoteApiProvider diff --git a/backend/src/main/resources/dev.twango.jetplay.backend.xml b/backend/src/main/resources/dev.twango.jetplay.backend.xml index e2cb6874..97bd94d9 100644 --- a/backend/src/main/resources/dev.twango.jetplay.backend.xml +++ b/backend/src/main/resources/dev.twango.jetplay.backend.xml @@ -2,7 +2,6 @@ - diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts index e00922c9..fcc87250 100644 --- a/frontend/build.gradle.kts +++ b/frontend/build.gradle.kts @@ -3,7 +3,6 @@ import org.jetbrains.intellij.platform.gradle.TestFrameworkType dependencies { intellijPlatform { bundledModule("intellij.platform.frontend") - bundledModule("intellij.platform.rpc") compileOnly(libs.kotlin.serialization.core.jvm) compileOnly(libs.kotlin.serialization.json.jvm) testFramework(TestFrameworkType.Platform) diff --git a/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt b/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt index a4686f20..b7b0fd74 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt +++ b/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnstableApiUsage") + package dev.twango.jetplay.rpc import com.intellij.ide.vfs.VirtualFileId From 43baaf4a461118781bb436453ecd75a4ed189532 Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 18:42:49 -0700 Subject: [PATCH 10/29] fix(build): raise pluginSinceBuild to 253 for Platform V2 compatibility Modular content modules, Fleet RPC, and split mode all require 2025.3+ (253.x); the old 223 falsely advertised compatibility with releases that cannot load the plugin (JDK 21 bytecode + Fleet RPC). The release-please generic marker only tracks pluginVersion, so this line is safe to change. --- gradle.properties | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 67464d11..ac003949 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,7 +9,8 @@ pluginVersion = 0.3.0 # x-release-please-end # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html -pluginSinceBuild = 223 +# Platform V2 modular content modules + Fleet RPC + split mode require 2025.3+ (253.x). +pluginSinceBuild = 253 # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension platformVersion = 2026.1 From f2433bfbcd47c7fbab76015a732c9c415a36273a Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 20:57:51 -0700 Subject: [PATCH 11/29] fix(backend): scope RPC file access to a live project and dispatch blocking work on IO resolveFile now rejects an unresolvable projectId (findProjectOrNull), so the RPC bytes/transcode surface only serves files within a live project context. readRange/fileLength/extractWaveform/extractMediaInfo run their blocking disk and ffmpeg work under withContext(Dispatchers.IO) instead of the RPC dispatcher, and readRange guards against negative offset/length before seek/allocation. --- .../twango/jetplay/rpc/MediaAccessorImpl.kt | 77 +++++++++++-------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt index bfff1b9e..0b9412fa 100644 --- a/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt +++ b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt @@ -1,8 +1,11 @@ +@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 @@ -21,12 +24,16 @@ private const val CHUNK_BYTES = 1 shl 20 // 1 MB class MediaAccessorImpl : MediaAccessor { - private fun resolveFile(fileId: VirtualFileId): File? = fileId.virtualFile()?.takeIf { it.isValid }?.let { vf -> - runCatching { vf.toNioPath().toFile() }.getOrNull()?.takeIf { it.isFile } + // Resolve only within a live project: an unresolvable projectId means a stale or out-of-context RPC caller. + private fun resolveFile(fileId: VirtualFileId, projectId: ProjectId): File? { + if (projectId.findProjectOrNull() == null) return null + 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 = flow { - val file = resolveFile(fileId) ?: return@flow + val file = resolveFile(fileId, projectId) ?: return@flow RandomAccessFile(file, "r").use { raf -> val buf = ByteArray(CHUNK_BYTES) while (true) { @@ -38,28 +45,30 @@ class MediaAccessorImpl : MediaAccessor { }.flowOn(Dispatchers.IO) override suspend fun fileLength(fileId: VirtualFileId, projectId: ProjectId): Long = - resolveFile(fileId)?.length() ?: -1L + withContext(Dispatchers.IO) { resolveFile(fileId, projectId)?.length() ?: -1L } - override suspend fun readRange(fileId: VirtualFileId, projectId: ProjectId, offset: Long, length: Int): ByteArray { - val file = resolveFile(fileId) ?: return ByteArray(0) - RandomAccessFile(file, "r").use { raf -> - raf.seek(offset) - val out = ByteArray(length) - // A single raf.read() may return fewer bytes than requested even when more remain - // (JDK contract); loop until the buffer is full or EOF is hit. - var total = 0 - while (total < length) { - val n = raf.read(out, total, length - total) - if (n < 0) break - total += n - } - return when (total) { - 0 -> ByteArray(0) - length -> out - else -> out.copyOf(total) + 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) + // A single raf.read() may return fewer bytes than requested even when more remain + // (JDK contract); loop until the buffer is full or EOF is hit. + 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 = channelFlow { @@ -67,7 +76,7 @@ class MediaAccessorImpl : MediaAccessor { send(TranscodeEvent.Unavailable) return@channelFlow } - val input = resolveFile(fileId) ?: run { + val input = resolveFile(fileId, projectId) ?: run { send(TranscodeEvent.Failed("source unavailable")) return@channelFlow } @@ -98,15 +107,17 @@ class MediaAccessorImpl : MediaAccessor { send(TranscodeEvent.Done) } - override suspend fun extractWaveform(fileId: VirtualFileId, projectId: ProjectId): List { - if (!FfmpegAvailability.available) return emptyList() - val file = resolveFile(fileId) ?: return emptyList() - return runCatching { WaveformExtractor.extract(file) }.getOrDefault(emptyList()) - } + override suspend fun extractWaveform(fileId: VirtualFileId, projectId: ProjectId): List = + 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? { - if (!FfmpegAvailability.available) return null - val file = resolveFile(fileId) ?: return null - return runCatching { MediaInfoExtractor.extract(file) }.getOrNull() - } + 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() + } } From 864adf3eb0472c18a5c7ec5763e92c6101a0b458 Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 20:57:51 -0700 Subject: [PATCH 12/29] refactor(frontend): normalize media token extraction wasFetched/release now derive the token via the same request-path parsing as handle(), so query/fragment suffixes can't desync fetch-state from the served key. --- .../main/kotlin/dev/twango/jetplay/media/MediaServer.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt index 0cd259ac..2ad16302 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt @@ -7,6 +7,7 @@ import java.io.File import java.io.RandomAccessFile import java.net.InetAddress import java.net.InetSocketAddress +import java.net.URI import java.nio.file.Files import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -49,15 +50,19 @@ object MediaServer { } /** True once the browser has fetched [url] at least once (any method, any range). */ - fun wasFetched(url: String): Boolean = fetched.contains(url.substringAfterLast('/')) + fun wasFetched(url: String): Boolean = fetched.contains(tokenOf(url)) /** Stops serving the file behind [url]. */ fun release(url: String) { - val token = url.substringAfterLast('/') + val token = tokenOf(url) files.remove(token) fetched.remove(token) } + // Mirror [handle]'s extraction (request path, query/fragment stripped) so fetch-state lookups never drift from the served key. + private fun tokenOf(url: String): String = + runCatching { URI(url).path }.getOrNull()?.trimStart('/') ?: url.substringAfterLast('/') + private fun start(): HttpServer { val srv = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0) srv.executor = Executors.newCachedThreadPool { r -> From b5c60cb88f0e03d422625c05d5433025278c4ef7 Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 20:57:51 -0700 Subject: [PATCH 13/29] docs: point extension source-of-truth at the frontend module descriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the Plugin Model V2 split the fileType/fileEditorProvider live in the frontend content module (frontend-side APIs), so the frontend descriptor — not the root plugin.xml — owns the supported-extension list. --- AGENTS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f6a6a60d..cfdfa02a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,9 @@ IntelliJ Platform plugin providing native audio/video playback in JetBrains IDEs ## 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/`, `backend/` — Plugin Model V2 content modules (shared types + RPC contract; frontend editor/JCEF player/loopback media server; backend ffmpeg + byte access), each under `/src/main/kotlin/dev/twango/jetplay/` +- `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 (`fileType`/`fileEditorProvider` are frontend-side, so they live here, not in the root descriptor) - `gradle.properties` — plugin metadata and version config ## Conventions @@ -21,7 +22,7 @@ IntelliJ Platform plugin providing native audio/video playback in JetBrains IDEs - 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 From 1367fc6b5d313e6f55ecd95dd1f718d1a33db36c Mon Sep 17 00:00:00 2001 From: James Ding Date: Fri, 12 Jun 2026 23:24:02 -0700 Subject: [PATCH 14/29] ci(verify): tolerate split-mode internal APIs and external javacv bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifyPlugin failed on two categories the V2 restructure introduced: INTERNAL_API_USAGES (the RPC framework — RemoteApiProviderService, the remoteApiProvider EP, ProjectId/ VirtualFileId — has no stable equivalent for split mode) and COMPATIBILITY_PROBLEMS from javacv's optional capture/vision frame-grabbers referencing native bindings the build excludes. Narrow failureLevel to real incompatibilities + invalid structure, and mark the excluded javacv packages as external prefixes. Verified Compatible against IU-2026.1. --- build.gradle.kts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 15db60c4..0792b77c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ import org.jetbrains.changelog.Changelog import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware plugins { @@ -111,6 +112,35 @@ intellijPlatform { } pluginVerification { + // Split mode requires @ApiStatus.Internal RPC APIs (RemoteApiProviderService, the remoteApiProvider EP, + // ProjectId/VirtualFileId) that have no stable equivalent, so internal/experimental usage can't gate the + // build; keep failing on real binary incompatibilities and invalid plugin structure. + failureLevel = listOf( + VerifyPluginTask.FailureLevel.COMPATIBILITY_PROBLEMS, + VerifyPluginTask.FailureLevel.INVALID_PLUGIN, + ) + // javacv (backend, ffmpeg only) bundles optional capture/vision frame-grabbers we exclude from the deps; + // mark those packages external so the verifier stops reading javacv's unused references to them as + // missing-package compatibility problems. + externalPrefixes = listOf( + "org.bytedeco.opencv", + "org.bytedeco.librealsense", + "org.bytedeco.librealsense2", + "org.bytedeco.videoinput", + "org.bytedeco.flycapture", + "org.bytedeco.libdc1394", + "org.bytedeco.libfreenect", + "org.bytedeco.libfreenect2", + "org.bytedeco.artoolkitplus", + "org.bytedeco.flandmark", + "org.bytedeco.leptonica", + "org.bytedeco.tesseract", + "org.opencv", + "javafx", + "com.badlogic", + "com.jogamp", + "org.apache.maven", + ) ides { val pinned = providers.gradleProperty("verifierIde").orNull if (pinned.isNullOrBlank()) { From 9e92486ed051b1f447f3686d458d1b3a8d6af8a5 Mon Sep 17 00:00:00 2001 From: James Ding Date: Sat, 13 Jun 2026 00:58:34 -0700 Subject: [PATCH 15/29] ci(verify): name module jars by module id so the verifier needs no suppression list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 16 javacv 'package not found' compatibility problems and the 3 'config file not found' defects were one root cause: IPGP names the content-module jars . (jetplay.backend), but the platform and plugin verifier resolve content modules by lib/modules/.jar. With the names mismatched the verifier could not load the module descriptors and fell back to scanning every bundled jar — surfacing javacv's excluded-binding references. Override each module's composedJar archiveBaseName to its module id so the descriptors resolve and javacv is never scanned: drops externalPrefixes entirely and clears the config defects. failureLevel returns to the default minus INTERNAL_API_USAGES (split-mode RPC has no stable API), with OVERRIDE_ONLY restored. Verified Compatible against IU-2026.1.3 with no config defects. --- backend/build.gradle.kts | 7 +++++++ build.gradle.kts | 33 +++++++-------------------------- frontend/build.gradle.kts | 6 ++++++ shared/build.gradle.kts | 6 ++++++ 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index e3829fb7..f43704c5 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -39,3 +39,10 @@ dependencies { 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 "." (jetplay.backend), but the platform and the +// plugin verifier resolve content modules by "lib/modules/.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("composedJar") { + archiveBaseName.set("dev.twango.jetplay.backend") +} diff --git a/build.gradle.kts b/build.gradle.kts index 0792b77c..bf542c31 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -112,34 +112,15 @@ intellijPlatform { } pluginVerification { - // Split mode requires @ApiStatus.Internal RPC APIs (RemoteApiProviderService, the remoteApiProvider EP, - // ProjectId/VirtualFileId) that have no stable equivalent, so internal/experimental usage can't gate the - // build; keep failing on real binary incompatibilities and invalid plugin structure. + // Default gating set is [COMPATIBILITY_PROBLEMS, INTERNAL_API_USAGES, OVERRIDE_ONLY_API_USAGES]. + // Drop only INTERNAL_API_USAGES: split mode's RPC stack (RemoteApiProviderService, the remoteApiProvider + // EP, ProjectId/VirtualFileId) is @ApiStatus.Internal with no stable equivalent. The javacv missing-package + // problems that previously forced externalPrefixes are gone now that the content-module jars are named to + // match their module ids (see each subproject's composedJar override) — the verifier resolves the + // descriptors and no longer falls back to scanning javacv. failureLevel = listOf( VerifyPluginTask.FailureLevel.COMPATIBILITY_PROBLEMS, - VerifyPluginTask.FailureLevel.INVALID_PLUGIN, - ) - // javacv (backend, ffmpeg only) bundles optional capture/vision frame-grabbers we exclude from the deps; - // mark those packages external so the verifier stops reading javacv's unused references to them as - // missing-package compatibility problems. - externalPrefixes = listOf( - "org.bytedeco.opencv", - "org.bytedeco.librealsense", - "org.bytedeco.librealsense2", - "org.bytedeco.videoinput", - "org.bytedeco.flycapture", - "org.bytedeco.libdc1394", - "org.bytedeco.libfreenect", - "org.bytedeco.libfreenect2", - "org.bytedeco.artoolkitplus", - "org.bytedeco.flandmark", - "org.bytedeco.leptonica", - "org.bytedeco.tesseract", - "org.opencv", - "javafx", - "com.badlogic", - "com.jogamp", - "org.apache.maven", + VerifyPluginTask.FailureLevel.OVERRIDE_ONLY_API_USAGES, ) ides { val pinned = providers.gradleProperty("verifierIde").orNull diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts index fcc87250..c94fb7f4 100644 --- a/frontend/build.gradle.kts +++ b/frontend/build.gradle.kts @@ -27,3 +27,9 @@ val buildPlayerUi by tasks.registering(Exec::class) { tasks.processResources { dependsOn(buildPlayerUi) } + +// Align the content-module jar name with the module id ("lib/modules/.jar") so the verifier and +// platform resolve the descriptor instead of falling back to scanning every bundled jar. +tasks.named("composedJar") { + archiveBaseName.set("dev.twango.jetplay.frontend") +} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 85f099b4..3ad131b4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -11,3 +11,9 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.opentest4j) } + +// Align the content-module jar name with the module id ("lib/modules/.jar") so the verifier and +// platform resolve the descriptor instead of falling back to scanning every bundled jar. +tasks.named("composedJar") { + archiveBaseName.set("dev.twango.jetplay.shared") +} From 209b529fa706806fca5aca21dde44a19e4804c42 Mon Sep 17 00:00:00 2001 From: James Ding Date: Sat, 13 Jun 2026 12:31:11 -0700 Subject: [PATCH 16/29] refactor: trim comments to terse single-line notes Condense multi-line rationale blocks to their core, drop redundant section labels and restated-WHAT comments across the modular media code. Comments only; no logic changes. --- .../twango/jetplay/rpc/MediaAccessorImpl.kt | 9 +++---- .../jetplay/transcode/MediaInfoExtractor.kt | 21 +++++++-------- .../jetplay/transcode/MediaTranscoder.kt | 4 +-- .../jetplay/transcode/WaveformExtractor.kt | 12 +++------ .../twango/jetplay/browser/PlayerBridge.kt | 7 +++-- .../twango/jetplay/editor/MediaErrorEditor.kt | 5 +--- .../jetplay/editor/MediaFileEditorProvider.kt | 3 +-- .../dev/twango/jetplay/editor/MediaLoader.kt | 26 +++++-------------- .../twango/jetplay/media/EditorMediaSource.kt | 7 ++--- .../dev/twango/jetplay/media/MediaServer.kt | 23 +++++++--------- .../jetplay/media/MediaClassification.kt | 4 +-- .../dev/twango/jetplay/media/MediaInfo.kt | 10 +++---- .../dev/twango/jetplay/rpc/MediaAccessor.kt | 8 +++--- .../dev/twango/jetplay/rpc/TranscodeEvent.kt | 4 +-- 14 files changed, 55 insertions(+), 88 deletions(-) diff --git a/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt index 0b9412fa..dd7d2144 100644 --- a/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt +++ b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt @@ -24,7 +24,7 @@ private const val CHUNK_BYTES = 1 shl 20 // 1 MB class MediaAccessorImpl : MediaAccessor { - // Resolve only within a live project: an unresolvable projectId means a stale or out-of-context RPC caller. + // 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 return fileId.virtualFile()?.takeIf { it.isValid }?.let { vf -> @@ -54,8 +54,7 @@ class MediaAccessorImpl : MediaAccessor { RandomAccessFile(file, "r").use { raf -> raf.seek(offset) val out = ByteArray(length) - // A single raf.read() may return fewer bytes than requested even when more remain - // (JDK contract); loop until the buffer is full or EOF is hit. + // 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) @@ -82,7 +81,7 @@ class MediaAccessorImpl : MediaAccessor { } val output = try { withContext(Dispatchers.IO) { - // onProgress fires synchronously inside ffmpeg; trySend bridges it onto this channel without suspending. + // onProgress fires synchronously inside ffmpeg, so trySend (non-suspending) bridges it. TranscodeRunner.transcode(input) { pct -> trySend(TranscodeEvent.Progress(pct)) } } } catch (e: Exception) { @@ -101,7 +100,7 @@ class MediaAccessorImpl : MediaAccessor { } } } finally { - // The transcoded bytes now live in the frontend temp; the backend copy is no longer needed. + // Frontend now holds the bytes; drop the backend copy. runCatching { output.delete() } } send(TranscodeEvent.Done) diff --git a/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt b/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt index 55c33cf6..cc975b9f 100644 --- a/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt +++ b/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt @@ -17,13 +17,13 @@ object MediaInfoExtractor { private val log = Logger.getInstance(MediaInfoExtractor::class.java) - // Lossy codecs decode to float internally, so their "bit depth" would be misleading; only these report it. + // Only lossless codecs report a meaningful bit depth; lossy decode to float internally. private val LOSSLESS = setOf("flac", "alac", "wavpack", "truehd", "mlp", "tta", "als") - // Larger cover art only bloats the bridge payload; a blurred background needs no fidelity (typical art < 1 MB). + // Cap cover art: larger only bloats the bridge payload. private const val MAX_ART_BYTES = 4_000_000 - // Lowercase FFmpeg metadata keys mapped to display labels, in display order. + // FFmpeg metadata keys to display labels, in display order. private val TAG_FIELDS = listOf( "title" to "Title", "artist" to "Artist", @@ -54,7 +54,7 @@ object MediaInfoExtractor { /** Returns the file's stream metadata, or null if it has no readable streams. */ fun extract(file: File): MediaInfo? { - // RAW reports the source sample/pixel formats; SHORT/COLOR would report the decoder's output instead. + // RAW reports the source sample/pixel formats, not the decoder's output. val grabber = FFmpegFrameGrabber(file).apply { sampleMode = FrameGrabber.SampleMode.RAW imageMode = FrameGrabber.ImageMode.RAW @@ -71,13 +71,11 @@ object MediaInfoExtractor { val durationMs = grabber.lengthInTime.takeIf { it > 0 }?.div(1000) val sizeBytes = file.length().takeIf { it > 0 } - // Audio (canonical codec from the id, not the decoder name). val audioCodec = if (hasAudio) canonicalCodec(grabber.audioCodec) else null val audioBitrate = if (hasAudio) grabber.audioBitrate.toLong().takeIf { it > 0 } else null - // The size/duration fallback is whole-file bitrate, valid for audio only when there is no video. + // Whole-file bitrate fallback is valid for audio only when there is no video. val bitrate = audioBitrate ?: if (!hasVideo) computeBitrate(sizeBytes, durationMs) else null - // Video. val videoCodec = if (hasVideo) canonicalCodec(grabber.videoCodec) else null val frameRate = if (hasVideo) grabber.videoFrameRate.takeIf { it.isFinite() && it > 0 } else null val pixelFormat = if (hasVideo) { @@ -115,7 +113,7 @@ object MediaInfoExtractor { } } - // Codec name from the id ("mp3", "h264"), not the decoder name ("mp3float") the *CodecName getters return. + // Codec name from the id, not the decoder name the *CodecName getters return. private fun canonicalCodec(codecId: Int): String? = avcodec.avcodec_get_name(codecId)?.getString()?.takeIf { it.isNotBlank() && it != "unknown" && it != "none" } @@ -138,7 +136,7 @@ object MediaInfoExtractor { private fun bitDepth(codec: String?, sampleFormat: Int): String? = when { codec == null -> null - // PCM names carry exact depth (pcm_s24le → 24-bit); the sample format would widen it to S32. + // PCM names carry exact depth; the sample format would widen it. codec.startsWith("pcm_") -> PCM_PATTERN.find(codec)?.let { match -> val bits = match.groupValues[2] if (match.groupValues[1] == "f") "$bits-bit float" else "$bits-bit" @@ -156,17 +154,16 @@ object MediaInfoExtractor { else -> null } - /** Maps FFmpeg's metadata map to an ordered, labeled list of display tags. */ internal fun buildTags(metadata: Map): List { if (metadata.isEmpty()) return emptyList() - // FFmpeg keys are normally lowercase, but be tolerant of odd containers. + // Tolerate non-lowercase keys from odd containers. val lower = metadata.entries.associate { it.key.lowercase() to it.value } return TAG_FIELDS.mapNotNull { (key, label) -> lower[key]?.trim()?.takeIf { it.isNotEmpty() }?.let { MediaTag(label, it) } } } - /** First attached-picture stream's raw bytes as a `data:` URL — sniff the type and base64, no decode/re-encode. */ + /** First attached-picture stream's raw bytes as a `data:` URL, with no decode/re-encode. */ private fun extractAlbumArt(grabber: FFmpegFrameGrabber): String? { return try { val oc = grabber.formatContext ?: return null diff --git a/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt b/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt index d0d0250d..7ec6a445 100644 --- a/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt +++ b/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt @@ -37,7 +37,7 @@ object MediaTranscoder { "sln" to RawAudioHint("s16le", 8000, 1), ) - /** Extensions for which a demuxer hint is configured; must stay in sync with the shared classifier. */ + /** Must stay in sync with the shared classifier. */ internal val rawAudioExtensions: Set get() = RAW_AUDIO_HINTS.keys fun transcode(inputFile: File, onProgress: (Double) -> Unit = {}): File { @@ -97,7 +97,7 @@ object MediaTranscoder { recorder.videoBitrate = grabber.videoBitrate.takeIf { it > 0 } ?: DEFAULT_VIDEO_BITRATE recorder.frameRate = grabber.frameRate.takeIf { it > 0 } ?: DEFAULT_FRAME_RATE recorder.gopSize = DEFAULT_GOP_SIZE - // Previews only need to be watchable; libvpx's default deadline is so slow an HD clip looks hung. + // libvpx's default deadline is so slow an HD clip looks hung. recorder.setVideoOption("deadline", "realtime") recorder.setVideoOption("cpu-used", "8") recorder.setVideoOption("row-mt", "1") diff --git a/backend/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt b/backend/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt index 7dbea2cf..71eb5d4e 100644 --- a/backend/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt +++ b/backend/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt @@ -9,13 +9,7 @@ import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt -/** - * Decodes an audio file into normalized amplitude bars (`[0, 1]` at a fixed - * bars-per-second) for the UI waveform. - * - * Done here with bundled FFmpeg rather than in the browser to avoid shipping - * and decoding the whole file client-side; output matches the UI's `sampleWaveform`. - */ +/** Decodes audio into normalized amplitude bars; output matches the UI's `sampleWaveform`. */ object WaveformExtractor { private val log = Logger.getInstance(WaveformExtractor::class.java) @@ -37,7 +31,7 @@ object WaveformExtractor { } return try { grabber.start() - // Fast pre-skip: lengthInTime is unreliable (0/AV_NOPTS_VALUE), so sampleToBars enforces the real cap. + // lengthInTime is unreliable, so sampleToBars enforces the real cap. val durationSeconds = grabber.lengthInTime / MICROS_PER_SECOND if (durationSeconds > MAX_DURATION_SECONDS) { log.info("Skipping waveform for ${file.name}: ${durationSeconds.roundToInt()}s exceeds cap") @@ -55,7 +49,7 @@ object WaveformExtractor { private fun sampleToBars(grabber: FFmpegFrameGrabber, barsPerSecond: Int): List { val samplesPerBar = (grabber.sampleRate / barsPerSecond).coerceAtLeast(1) - // Hard ceiling that bounds the decode even when container duration is unknown/misreported. + // Bounds the decode even when container duration is unknown. val maxBars = MAX_DURATION_SECONDS * barsPerSecond val bars = ArrayList(minOf(maxBars, INITIAL_BARS_CAPACITY)) var sum = 0.0 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 514a8273..ec540caf 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt @@ -30,7 +30,7 @@ class PlayerBridge(private val browser: JBCefBrowser) { } } - // Stash on window before notifying: a fast transcode can beat page load, so the app reads the stash on mount. + // Stash before notifying: a fast transcode can beat page load. fun updateProgress(percent: Double) = executeJs("window.__jetplayProgress=$percent;window.jetplayUpdateProgress?.($percent)") @@ -49,7 +49,6 @@ class PlayerBridge(private val browser: JBCefBrowser) { "if(window.jetplayWaveform)window.jetplayWaveform(window.__jetplayWaveform)", ) - // Same stash-then-notify race guard as updateProgress. fun sendMediaInfo(info: MediaInfo) { val json = mediaInfoJson(info) ?: return executeJs("window.__jetplayMediaInfo=$json;if(window.jetplayMediaInfo)window.jetplayMediaInfo(window.__jetplayMediaInfo)") @@ -75,7 +74,7 @@ class PlayerBridge(private val browser: JBCefBrowser) { .replace("<", "\\x3c") .replace(">", "\\x3e") - // Built as strict JSON (a JS subset), not spliced, because it carries arbitrary tag text and a base64 art URL. + // 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)}") } @@ -124,7 +123,7 @@ class PlayerBridge(private val browser: JBCefBrowser) { '\u000C' -> sb.append("\\f") - // Valid in JSON but terminate a JS string literal — must escape. + // Valid in JSON but terminate a JS string literal. '\u2028' -> sb.append("\\u2028") '\u2029' -> sb.append("\\u2029") diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaErrorEditor.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaErrorEditor.kt index 99975c37..99c2e38e 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaErrorEditor.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaErrorEditor.kt @@ -10,10 +10,7 @@ import dev.twango.jetplay.JetPlayBundle import java.beans.PropertyChangeListener import javax.swing.JComponent -/** - * Plain-Swing fallback editor for when JCEF (the media renderer) is unavailable. Shown instead of - * an empty/broken browser pane so the failure is explicit rather than an indefinite blank tab. - */ +/** Plain-Swing fallback editor that makes JCEF unavailability explicit instead of a blank tab. */ class MediaErrorEditor(private val file: VirtualFile, message: String) : UserDataHolderBase(), FileEditor { diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt index 19455bb1..1c13a148 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt @@ -19,8 +19,7 @@ class MediaFileEditorProvider : override fun accept(project: Project, file: VirtualFile): Boolean = file.fileType == MediaFileType.INSTANCE override fun createEditor(project: Project, file: VirtualFile): FileEditor { - // No JCEF (e.g. headless JBR, or this provider somehow loaded on a remote-dev host) → explicit - // error rather than an empty pane that spins forever waiting on a browser that never renders. + // Without JCEF, show an explicit error rather than a pane that spins forever on a browser that never renders. if (!JBCefApp.isSupported()) { log.warn("JCEF unavailable; opening ${file.name} in fallback error editor") return MediaErrorEditor(file, JetPlayBundle.message("error.jcef.unavailable")) diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt index c0a2c793..91930a93 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt @@ -38,7 +38,7 @@ class MediaLoader( private val tasks = CopyOnWriteArrayList>() - // Loopback URLs handed out for this editor's media, released on dispose. + // Loopback URLs to release on dispose. private val servedUrls = CopyOnWriteArrayList() @Volatile @@ -46,11 +46,7 @@ class MediaLoader( private fun serve(file: File): String = MediaServer.serve(file).also { servedUrls.add(it) } - /** - * The player has the URL but JCEF may never reach the loopback server (frontend-side CEF in remote - * dev). If nothing fetches the token before the deadline, surface an explicit error so the user - * sees a diagnosable failure instead of an indefinite spinner. - */ + /** JCEF may never reach the loopback server; error out if the token is unfetched by the deadline. */ private fun armLoadWatchdog(url: String) { watchdog?.cancel(false) watchdog = AppExecutorUtil.getAppScheduledExecutorService().schedule({ @@ -71,9 +67,7 @@ class MediaLoader( private val projectId by lazy { project.projectId() } fun load() { - // Transcoding (remote or local) runs entirely backend-side: ffmpeg reads the file there and - // streams WebM back. Pre-downloading the source would only waste bandwidth — the transcode - // re-reads it on the backend regardless — so transcoding takes precedence over the download path. + // Transcoding runs backend-side and re-reads the source, so it takes precedence over download. when { source.needsTranscoding -> startTranscoding() source.isRemote -> startDownload() @@ -128,8 +122,7 @@ class MediaLoader( } private fun startTranscoding() { - // Transcoding (local or remote) runs backend-side with no prior page loaded, so render the - // loading shell directly rather than pushing a state change into a page that may not exist yet. + // No prior page exists yet, so render the loading shell directly instead of pushing a state change. htmlLoader.load( PlayerConfig( state = "loading", @@ -182,7 +175,6 @@ class MediaLoader( private fun playDirectly() { val local = source.localFileOrNull() if (local != null) { - // MONOLITH / local file: identical to today — serve the real file, no RPC, no temp copy. val url = serve(local) htmlLoader.load( PlayerConfig( @@ -196,8 +188,7 @@ class MediaLoader( armLoadWatchdog(url) return } - // SPLIT MODE: pull bytes from backend into a temp file, then serve. Show a loading shell up - // front so the streaming RPC doesn't leave the tab on a blank Chromium page. + // Show a loading shell up front so the streaming RPC doesn't leave the tab on a blank page. htmlLoader.load( PlayerConfig( state = "loading", @@ -248,9 +239,7 @@ class MediaLoader( showLoadError(e.message) return null } - // The backend emits an empty flow (no bytes, no error) when it can't resolve the file - // (e.g. a non-LocalFileSystem VirtualFile). Serving the 0-byte temp would hand the player - // a permanently broken URL with no diagnosable failure — surface an explicit error instead. + // Backend emits an empty flow when it can't resolve the file; error out instead of serving 0 bytes. if (written == 0L) { temp.delete() showLoadError(JetPlayBundle.message("error.empty")) @@ -273,8 +262,7 @@ class MediaLoader( } private fun showTranscodingError() { - // Load the shell in the error state: this runs before any page exists, so a - // bridge.showError() JS push would have nothing to render against. + // No page exists yet, so load the shell in the error state rather than pushing showError over JS. htmlLoader.load( PlayerConfig( state = "error", diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/media/EditorMediaSource.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/EditorMediaSource.kt index 8753406f..5278614d 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/media/EditorMediaSource.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/EditorMediaSource.kt @@ -4,10 +4,7 @@ import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile import java.io.File -/** - * Frontend view of a media file. Holds the VirtualFile for identity (rpcId) and a - * monolith fast-path nio File when the bytes are directly readable in-process. - */ +/** Frontend view of a media file; VirtualFile carries identity for rpcId. */ class EditorMediaSource(val file: VirtualFile) : MediaSource { override val fileName: String = file.name override val extension: String = file.extension?.lowercase() ?: "" @@ -15,7 +12,7 @@ class EditorMediaSource(val file: VirtualFile) : MediaSource { override val needsTranscoding: Boolean = MediaClassification.needsTranscoding(extension) override val isRemote: Boolean = file.fileSystem !is LocalFileSystem - /** Non-null iff the bytes are directly readable in THIS process (monolith local file). */ + /** Non-null only when the bytes are readable in this process. */ fun localFileOrNull(): File? = if (!isRemote) runCatching { file.toNioPath().toFile() }.getOrNull()?.takeIf { it.isFile } else null } diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt index 2ad16302..5020dda9 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt @@ -14,19 +14,17 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors /** - * Loopback HTTP server streaming registered local media to JCEF. http+CORS+range - * (not file://) lets the null-origin page fetch()/decode audio and range-seek large files. + * Loopback HTTP server streaming registered local media to JCEF; http+CORS+range lets the + * null-origin page fetch/decode and range-seek where file:// cannot. * - * Security: binds 127.0.0.1 only; serves ONLY files registered via [serve], each - * behind an unguessable random token (no directory listing, no traversal). + * Security: binds 127.0.0.1 only; serves only files registered via [serve], each behind a random token. */ object MediaServer { private val log = Logger.getInstance(MediaServer::class.java) private val files = ConcurrentHashMap() - // Tokens the browser has actually fetched. Drives the load watchdog: a served-but-never-fetched - // token means the loopback URL is unreachable from the (possibly remote-dev/frontend-side) Chromium. + // Tokens the browser actually fetched; a served-but-never-fetched token means the loopback URL is unreachable. private val fetched = ConcurrentHashMap.newKeySet() private const val CHUNK = 64 * 1024 @@ -49,7 +47,7 @@ object MediaServer { return "http://127.0.0.1:${srv.address.port}/$token" } - /** True once the browser has fetched [url] at least once (any method, any range). */ + /** True once the browser has fetched [url] at least once. */ fun wasFetched(url: String): Boolean = fetched.contains(tokenOf(url)) /** Stops serving the file behind [url]. */ @@ -59,7 +57,7 @@ object MediaServer { fetched.remove(token) } - // Mirror [handle]'s extraction (request path, query/fragment stripped) so fetch-state lookups never drift from the served key. + // Mirror [handle]'s path extraction so fetch-state lookups never drift from the served key. private fun tokenOf(url: String): String = runCatching { URI(url).path }.getOrNull()?.trimStart('/') ?: url.substringAfterLast('/') @@ -75,7 +73,7 @@ object MediaServer { } private fun handle(exchange: HttpExchange) { - // Per-request trace: the only window into split-mode serving when playback silently stalls on a remote host. + // Per-request trace: the only window into serving when playback silently stalls on a remote host. if (log.isDebugEnabled) { log.debug("${exchange.requestMethod} ${exchange.requestURI.path} range=${exchange.requestHeaders.getFirst("Range")}") } @@ -107,8 +105,7 @@ object MediaServer { exchange.sendResponseHeaders(HTTP_NOT_FOUND, -1) return } - // First reachable fetch of this token: the watchdog reads this to tell a stalled load - // (URL never reached the browser) from a genuinely playing one. + // First reachable fetch: the watchdog reads this to tell a stalled load from a playing one. if (fetched.add(token)) log.debug("First media fetch for token $token") headers.add("Content-Type", contentType(file)) @@ -132,9 +129,9 @@ object MediaServer { private fun isLoopbackHost(host: String?): Boolean { if (host.isNullOrBlank()) return false val name = if (host.startsWith("[")) { - host.substringAfter("[").substringBefore("]") // [::1]:port + host.substringAfter("[").substringBefore("]") } else { - host.substringBefore(":") // 127.0.0.1:port / localhost:port + host.substringBefore(":") } return name.equals("127.0.0.1", true) || name.equals("localhost", true) || name.equals("::1", true) } diff --git a/shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt index 482dc3d6..dd8fecc4 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt +++ b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt @@ -2,7 +2,7 @@ package dev.twango.jetplay.media object MediaClassification { - // Video subset of extensions registered in plugin.xml — keep in sync + // Keep in sync with the video extensions in plugin.xml. private val VIDEO_EXTENSIONS = setOf( "mp4", "m4v", @@ -32,7 +32,7 @@ object MediaClassification { "mp3", ) - // Headerless raw codec streams that need explicit demuxer hints; backend supplies the demuxer config. + // Headerless raw codec streams that need explicit demuxer hints. val rawAudioExtensions: Set = setOf( "pcmu", "ulaw", diff --git a/shared/src/main/kotlin/dev/twango/jetplay/media/MediaInfo.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaInfo.kt index c190f8e3..5d92087a 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/media/MediaInfo.kt +++ b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaInfo.kt @@ -2,7 +2,7 @@ package dev.twango.jetplay.media import kotlinx.serialization.Serializable -/** Codec-inspector metadata; nullable fields let the UI skip anything FFmpeg couldn't determine. */ +/** Null fields mark anything FFmpeg couldn't determine. */ @Serializable data class MediaInfo( val codec: String?, @@ -10,21 +10,21 @@ data class MediaInfo( val sampleRateHz: Int?, val channels: Int?, val channelLabel: String?, - /** Only set when meaningful (PCM / lossless). Null for lossy codecs. */ + /** Set only for PCM/lossless; null for lossy codecs. */ val bitDepth: String?, val bitrateBps: Long?, val durationMs: Long?, val sizeBytes: Long?, - // Video-stream fields (null for audio-only files). + // Null for audio-only files. val width: Int? = null, val height: Int? = null, val frameRate: Double? = null, val videoCodec: String? = null, val pixelFormat: String? = null, val videoBitrateBps: Long? = null, - /** Embedded text tags (title/artist/album/…), in display order. */ + /** Embedded text tags, in display order. */ val tags: List = emptyList(), - /** Embedded cover art as a `data:` URL, or null when there is none. */ + /** Cover art as a `data:` URL. */ val albumArt: String? = null, ) diff --git a/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt b/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt index b7b0fd74..048426a4 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt +++ b/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt @@ -15,18 +15,18 @@ import org.jetbrains.annotations.ApiStatus @ApiStatus.Internal @Rpc interface MediaAccessor : RemoteApi { - /** Stream raw source bytes in order. Primary path; element type is plain ByteArray (Serializable). */ + /** Primary path: stream raw source bytes in order. */ suspend fun streamFileBytes(fileId: VirtualFileId, projectId: ProjectId): Flow - /** Fallback random-access read; always available even if Flow streaming underperforms on large media. */ + /** Fallback random-access read for when Flow streaming underperforms. */ suspend fun fileLength(fileId: VirtualFileId, projectId: ProjectId): Long suspend fun readRange(fileId: VirtualFileId, projectId: ProjectId, offset: Long, length: Int): ByteArray - /** Transcode to WebM on backend; emit progress then the transcoded bytes as a stream. */ + /** Transcode to WebM on backend, emitting progress then bytes. */ suspend fun transcodeFile(fileId: VirtualFileId, projectId: ProjectId): Flow - /** Empty list if ffmpeg unavailable or format unsupported (NEVER throws to caller). */ + /** Never throws: empty list if ffmpeg unavailable or format unsupported. */ suspend fun extractWaveform(fileId: VirtualFileId, projectId: ProjectId): List /** null if ffmpeg unavailable or no readable stream. */ diff --git a/shared/src/main/kotlin/dev/twango/jetplay/rpc/TranscodeEvent.kt b/shared/src/main/kotlin/dev/twango/jetplay/rpc/TranscodeEvent.kt index c1e5ba53..358e213e 100644 --- a/shared/src/main/kotlin/dev/twango/jetplay/rpc/TranscodeEvent.kt +++ b/shared/src/main/kotlin/dev/twango/jetplay/rpc/TranscodeEvent.kt @@ -17,11 +17,11 @@ sealed interface TranscodeEvent { @Serializable data object Done : TranscodeEvent - /** Raw (un-localized) error string; frontend wraps with JetPlayBundle. */ + /** Raw un-localized error string. */ @Serializable data class Failed(val message: String) : TranscodeEvent - /** ffmpeg not available on backend — frontend shows transcoding-error state. */ + /** ffmpeg not available on backend. */ @Serializable data object Unavailable : TranscodeEvent } From 2a32ef5044c732731dafbb920a2fe01ff0442c7f Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 12:53:31 -0700 Subject: [PATCH 17/29] feat: render the media player on the client in split mode Open and play media in split mode (Remote Dev / Gateway), where the editor renders on the JetBrains Client but file bytes live on the backend host. - frontend module loads on host AND client so the host detects media files and selects the editor; JCEF is guarded off on the host (AppMode.isRemoteDevHost) - new client-only frontend-split module registers rdclient.fileEditorModelHandler to build the JCEF editor on the client (internal RD APIs, compileOnly) - MediaServer serves a MediaByteSource; MediaLoader streams backend media on demand via MediaAccessor.readRange instead of pre-downloading (instant seek) - move JetPlayBundle + messages to shared; make splitMode a Gradle property --- AGENTS.md | 4 +- build.gradle.kts | 5 +- frontend-split/build.gradle.kts | 35 +++++ .../split/MediaFrontendEditorModelHandler.kt | 39 ++++++ .../dev.twango.jetplay.frontend.split.xml | 17 +++ frontend/build.gradle.kts | 1 - .../jetplay/editor/MediaFileEditorProvider.kt | 11 +- .../dev/twango/jetplay/editor/MediaLoader.kt | 128 +++++------------- .../twango/jetplay/media/MediaByteSource.kt | 63 +++++++++ .../dev/twango/jetplay/media/MediaServer.kt | 68 ++++------ .../resources/dev.twango.jetplay.frontend.xml | 1 - .../jetplay/media/MediaByteSourceTest.kt | 28 ++++ .../jetplay/media/MediaServerSourceTest.kt | 48 +++++++ settings.gradle.kts | 1 + .../dev/twango/jetplay/JetPlayBundle.kt | 0 .../messages/JetPlayBundle.properties | 0 .../messages/JetPlayBundle_de.properties | 0 .../messages/JetPlayBundle_es.properties | 0 .../messages/JetPlayBundle_fr.properties | 0 .../messages/JetPlayBundle_it.properties | 0 .../messages/JetPlayBundle_ja.properties | 0 .../messages/JetPlayBundle_ko.properties | 0 .../messages/JetPlayBundle_pl.properties | 0 .../messages/JetPlayBundle_pt_BR.properties | 0 .../messages/JetPlayBundle_ru.properties | 0 .../messages/JetPlayBundle_zh_CN.properties | 0 .../messages/JetPlayBundle_zh_TW.properties | 0 src/main/resources/META-INF/plugin.xml | 1 + 28 files changed, 305 insertions(+), 145 deletions(-) create mode 100644 frontend-split/build.gradle.kts create mode 100644 frontend-split/src/main/kotlin/dev/twango/jetplay/editor/split/MediaFrontendEditorModelHandler.kt create mode 100644 frontend-split/src/main/resources/dev.twango.jetplay.frontend.split.xml create mode 100644 frontend/src/main/kotlin/dev/twango/jetplay/media/MediaByteSource.kt create mode 100644 frontend/src/test/kotlin/dev/twango/jetplay/media/MediaByteSourceTest.kt create mode 100644 frontend/src/test/kotlin/dev/twango/jetplay/media/MediaServerSourceTest.kt rename {frontend => shared}/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt (100%) rename {frontend => shared}/src/main/resources/messages/JetPlayBundle.properties (100%) rename {frontend => shared}/src/main/resources/messages/JetPlayBundle_de.properties (100%) rename {frontend => shared}/src/main/resources/messages/JetPlayBundle_es.properties (100%) rename {frontend => shared}/src/main/resources/messages/JetPlayBundle_fr.properties (100%) rename {frontend => shared}/src/main/resources/messages/JetPlayBundle_it.properties (100%) rename {frontend => shared}/src/main/resources/messages/JetPlayBundle_ja.properties (100%) rename {frontend => shared}/src/main/resources/messages/JetPlayBundle_ko.properties (100%) rename {frontend => shared}/src/main/resources/messages/JetPlayBundle_pl.properties (100%) rename {frontend => shared}/src/main/resources/messages/JetPlayBundle_pt_BR.properties (100%) rename {frontend => shared}/src/main/resources/messages/JetPlayBundle_ru.properties (100%) rename {frontend => shared}/src/main/resources/messages/JetPlayBundle_zh_CN.properties (100%) rename {frontend => shared}/src/main/resources/messages/JetPlayBundle_zh_TW.properties (100%) diff --git a/AGENTS.md b/AGENTS.md index cfdfa02a..3e7fb792 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,9 +12,9 @@ IntelliJ Platform plugin providing native audio/video playback in JetBrains IDEs ## Project Structure -- `shared/`, `frontend/`, `backend/` — Plugin Model V2 content modules (shared types + RPC contract; frontend editor/JCEF player/loopback media server; backend ffmpeg + byte access), each under `/src/main/kotlin/dev/twango/jetplay/` +- `shared/`, `frontend/`, `frontend-split/`, `backend/` — Plugin Model V2 content modules, each under `/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. `frontend-split` (JetBrains Client only): `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 (`fileType`/`fileEditorProvider` are frontend-side, so they live here, not in the root descriptor) +- `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 diff --git a/build.gradle.kts b/build.gradle.kts index bf542c31..e459ff9b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { pluginModule(implementation(project(":shared"))) pluginModule(implementation(project(":frontend"))) + pluginModule(implementation(project(":frontend-split"))) pluginModule(implementation(project(":backend"))) } } @@ -65,7 +66,7 @@ changelog { } intellijPlatform { - splitMode = true + splitMode = providers.gradleProperty("splitMode").map { it.toBoolean() }.orElse(true) pluginInstallationTarget = SplitModeAware.PluginInstallationTarget.BOTH pluginConfiguration { @@ -118,6 +119,8 @@ intellijPlatform { // problems that previously forced externalPrefixes are gone now that the content-module jars are named to // match their module ids (see each subproject's composedJar override) — the verifier resolves the // descriptors and no longer falls back to scanning javacv. + // Split mode's client editor adds @ApiStatus.Internal RD APIs + // (rdclient.*, intellij.rd.*) under the same INTERNAL_API_USAGES drop. failureLevel = listOf( VerifyPluginTask.FailureLevel.COMPATIBILITY_PROBLEMS, VerifyPluginTask.FailureLevel.OVERRIDE_ONLY_API_USAGES, diff --git a/frontend-split/build.gradle.kts b/frontend-split/build.gradle.kts new file mode 100644 index 00000000..8b1a7b04 --- /dev/null +++ b/frontend-split/build.gradle.kts @@ -0,0 +1,35 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + +// The rdclient / rd jars below aren't exposed as bundled modules in IU-261, +// so pin them as compileOnly from the portably-resolved IDE home. +val ideHome = rootProject.layout.projectDirectory.dir(".intellijPlatform/ides").asFile + .listFiles()?.sortedByDescending { it.name }?.firstOrNull() + ?: error("No resolved IDE under .intellijPlatform/ides; run a Gradle build first") + +dependencies { + intellijPlatform { + bundledModule("intellij.platform.frontend") + compileOnly(libs.kotlin.serialization.core.jvm) + compileOnly(libs.kotlin.serialization.json.jvm) + testFramework(TestFrameworkType.Platform) + } + implementation(project(":shared")) + implementation(project(":frontend")) + + compileOnly(files( + ideHome.resolve("plugins/cwm-plugin/lib/frontend-split/rd-client.jar"), + ideHome.resolve("plugins/cwm-plugin/lib/frontend-split/frontend-split.jar"), + ideHome.resolve("lib/intellij.rd.platform.jar"), + ideHome.resolve("lib/intellij.rd.ui.jar"), + ideHome.resolve("lib/intellij.rd.ide.model.generated.jar"), + ideHome.resolve("lib/intellij.libraries.rd.core.jar"), + )) + + testImplementation(libs.junit) + testImplementation(libs.opentest4j) +} + +// Align the content-module jar name with the module id so the verifier/platform resolve the descriptor. +tasks.named("composedJar") { + archiveBaseName.set("dev.twango.jetplay.frontend.split") +} diff --git a/frontend-split/src/main/kotlin/dev/twango/jetplay/editor/split/MediaFrontendEditorModelHandler.kt b/frontend-split/src/main/kotlin/dev/twango/jetplay/editor/split/MediaFrontendEditorModelHandler.kt new file mode 100644 index 00000000..fa8aae4a --- /dev/null +++ b/frontend-split/src/main/kotlin/dev/twango/jetplay/editor/split/MediaFrontendEditorModelHandler.kt @@ -0,0 +1,39 @@ +package dev.twango.jetplay.editor.split + +import com.intellij.openapi.client.ClientProjectSession +import com.intellij.openapi.fileEditor.ex.FileEditorWithProvider +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.jetbrains.rd.ide.model.FileEditorModel +import com.jetbrains.rd.util.lifetime.Lifetime +import com.jetbrains.rdclient.fileEditors.AsyncFrontendFileEditorModelHandler +import dev.twango.jetplay.editor.MediaFileEditorProvider +import dev.twango.jetplay.editor.MediaFileType + +// A media file has no backend text model to bind (unlike text editors), so we ignore the model +// param and build the editor from MediaFileEditorProvider, which returns the live JCEF player on the client. +class MediaFrontendEditorModelHandler : AsyncFrontendFileEditorModelHandler { + + override fun accept(project: Project, file: VirtualFile, model: FileEditorModel): Boolean = + file.fileType == MediaFileType.INSTANCE + + override fun createEditorWithProvider( + project: Project, + lifetime: Lifetime, + file: VirtualFile, + model: FileEditorModel, + ): FileEditorWithProvider = buildEditor(project, file) + + override suspend fun createEditorWithProviderAsync( + session: ClientProjectSession, + file: VirtualFile, + editorLifetime: Lifetime, + model: FileEditorModel, + ): FileEditorWithProvider = buildEditor(session.project, file) + + private fun buildEditor(project: Project, file: VirtualFile): FileEditorWithProvider { + val provider = MediaFileEditorProvider() + val editor = provider.createEditor(project, file) + return FileEditorWithProvider(editor, provider) + } +} diff --git a/frontend-split/src/main/resources/dev.twango.jetplay.frontend.split.xml b/frontend-split/src/main/resources/dev.twango.jetplay.frontend.split.xml new file mode 100644 index 00000000..ba902f27 --- /dev/null +++ b/frontend-split/src/main/resources/dev.twango.jetplay.frontend.split.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts index c94fb7f4..de169f4a 100644 --- a/frontend/build.gradle.kts +++ b/frontend/build.gradle.kts @@ -2,7 +2,6 @@ import org.jetbrains.intellij.platform.gradle.TestFrameworkType dependencies { intellijPlatform { - bundledModule("intellij.platform.frontend") compileOnly(libs.kotlin.serialization.core.jvm) compileOnly(libs.kotlin.serialization.json.jvm) testFramework(TestFrameworkType.Platform) diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt index 1c13a148..dead59fb 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt @@ -1,5 +1,6 @@ package dev.twango.jetplay.editor +import com.intellij.idea.AppMode import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.fileEditor.FileEditor import com.intellij.openapi.fileEditor.FileEditorPolicy @@ -19,16 +20,16 @@ class MediaFileEditorProvider : override fun accept(project: Project, file: VirtualFile): Boolean = file.fileType == MediaFileType.INSTANCE override fun createEditor(project: Project, file: VirtualFile): FileEditor { - // Without JCEF, show an explicit error rather than a pane that spins forever on a browser that never renders. - if (!JBCefApp.isSupported()) { - log.warn("JCEF unavailable; opening ${file.name} in fallback error editor") + if (!canRenderJcefHere()) { + log.warn("JCEF unavailable or on the Remote Dev host; opening ${file.name} in the fallback editor") return MediaErrorEditor(file, JetPlayBundle.message("error.jcef.unavailable")) } - val source = EditorMediaSource(file) StarReminder.maybeShow(project) - return MediaFileEditor(project, file, source) + return MediaFileEditor(project, file, EditorMediaSource(file)) } + private fun canRenderJcefHere(): Boolean = !AppMode.isRemoteDevHost() && JBCefApp.isSupported() + override fun getEditorTypeId(): String = "media-player" override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_DEFAULT_EDITOR diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt index 91930a93..92795090 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt @@ -19,6 +19,8 @@ import dev.twango.jetplay.browser.UiStrings import dev.twango.jetplay.media.EditorMediaSource import dev.twango.jetplay.media.MediaClassification import dev.twango.jetplay.media.MediaServer +import dev.twango.jetplay.media.RemoteRangeByteSource +import dev.twango.jetplay.media.contentTypeForExtension import dev.twango.jetplay.rpc.MediaAccessor import dev.twango.jetplay.rpc.TranscodeEvent import kotlinx.coroutines.flow.collect @@ -67,11 +69,9 @@ class MediaLoader( private val projectId by lazy { project.projectId() } fun load() { - // Transcoding runs backend-side and re-reads the source, so it takes precedence over download. when { source.needsTranscoding -> startTranscoding() - source.isRemote -> startDownload() - else -> playDirectly() + else -> playFromSource() } maybeSendWaveform() maybeSendMediaInfo() @@ -98,29 +98,50 @@ class MediaLoader( } } - private fun startDownload() { + private fun playFromSource() { + val local = source.localFileOrNull() + if (local != null) { + val url = serve(local) + loadPlayer(url) + return + } htmlLoader.load( PlayerConfig( - state = "downloading", + state = "loading", isVideo = source.isVideo, fileName = source.fileName, fileExtension = source.extension, - downloadingReason = JetPlayBundle.message("downloading.reason"), ui = uiStrings, ), ) submit { - val temp = streamToTemp(::reportDownloadProgress) ?: return@submit - if (bridge.disposed) { - temp.delete() + val len = runBlocking { MediaAccessor.getInstance().fileLength(fileId, projectId) } + if (len <= 0L) { + showLoadError(JetPlayBundle.message("error.empty")) return@submit } - val url = serve(temp) - bridge.mediaReady(url) - armLoadWatchdog(url) + if (bridge.disposed) return@submit + val remote = RemoteRangeByteSource(len, contentTypeForExtension(source.extension)) { offset, length -> + runBlocking { MediaAccessor.getInstance().readRange(fileId, projectId, offset, length) } + } + val url = MediaServer.serve(remote).also { servedUrls.add(it) } + loadPlayer(url) } } + private fun loadPlayer(url: String) { + htmlLoader.load( + PlayerConfig( + isVideo = source.isVideo, + fileName = source.fileName, + fileExtension = source.extension, + mediaUrl = url, + ui = uiStrings, + ), + ) + armLoadWatchdog(url) + } + private fun startTranscoding() { // No prior page exists yet, so render the loading shell directly instead of pushing a state change. htmlLoader.load( @@ -172,88 +193,6 @@ class MediaLoader( } } - private fun playDirectly() { - val local = source.localFileOrNull() - if (local != null) { - val url = serve(local) - htmlLoader.load( - PlayerConfig( - isVideo = source.isVideo, - fileName = source.fileName, - fileExtension = source.extension, - mediaUrl = url, - ui = uiStrings, - ), - ) - armLoadWatchdog(url) - return - } - // Show a loading shell up front so the streaming RPC doesn't leave the tab on a blank page. - htmlLoader.load( - PlayerConfig( - state = "loading", - isVideo = source.isVideo, - fileName = source.fileName, - fileExtension = source.extension, - ui = uiStrings, - ), - ) - submit { - val temp = streamToTemp { } ?: return@submit - if (bridge.disposed) { - temp.delete() - } else { - val url = serve(temp) - htmlLoader.load( - PlayerConfig( - isVideo = source.isVideo, - fileName = source.fileName, - fileExtension = source.extension, - mediaUrl = url, - ui = uiStrings, - ), - ) - armLoadWatchdog(url) - } - } - } - - /** Streams the source bytes from the backend into a temp file. Returns null on failure (error surfaced). */ - private fun streamToTemp(onProgress: (Long) -> Unit): File? { - val temp = File.createTempFile("jetplay-", ".${source.extension}").apply { deleteOnExit() } - val written = try { - runBlocking { - val api = MediaAccessor.getInstance() - var bytes = 0L - temp.outputStream().use { out -> - api.streamFileBytes(fileId, projectId).collect { chunk -> - out.write(chunk) - bytes += chunk.size - onProgress(bytes) - } - } - bytes - } - } catch (e: Exception) { - temp.delete() - showLoadError(e.message) - return null - } - // Backend emits an empty flow when it can't resolve the file; error out instead of serving 0 bytes. - if (written == 0L) { - temp.delete() - showLoadError(JetPlayBundle.message("error.empty")) - return null - } - return temp - } - - private fun reportDownloadProgress(bytes: Long) { - if (bridge.disposed) return - val total = source.file.length - if (total > 0) bridge.updateDownloadProgress(bytes.toDouble() / total * PERCENT_SCALE) - } - private fun showLoadError(raw: String?) { val msg = raw ?: JetPlayBundle.message("error.unknown") log.warn("media load failed: $msg") @@ -303,7 +242,6 @@ class MediaLoader( companion object { private val log = Logger.getInstance(MediaLoader::class.java) - private const val PERCENT_SCALE = 100.0 private const val LOAD_TIMEOUT_SECONDS = 20L } } diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaByteSource.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaByteSource.kt new file mode 100644 index 00000000..f6c64582 --- /dev/null +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaByteSource.kt @@ -0,0 +1,63 @@ +package dev.twango.jetplay.media + +import java.io.File +import java.io.RandomAccessFile +import java.nio.file.Files + +interface MediaByteSource { + val length: Long? + val contentType: String? + + fun read(offset: Long, length: Int): ByteArray +} + +class FileByteSource(private val file: File) : MediaByteSource { + override val length: Long? get() = if (file.isFile) file.length() else null + override val contentType: String? get() = contentTypeForFile(file) + + override fun read(offset: Long, length: Int): ByteArray { + if (offset < 0 || length <= 0 || !file.isFile) return ByteArray(0) + RandomAccessFile(file, "r").use { raf -> + raf.seek(offset) + val out = ByteArray(length) + var total = 0 + while (total < length) { + val n = raf.read(out, total, length - total) + if (n < 0) break + total += n + } + return when (total) { + 0 -> ByteArray(0) + length -> out + else -> out.copyOf(total) + } + } + } +} + +class RemoteRangeByteSource( + override val length: Long?, + override val contentType: String?, + private val reader: (offset: Long, length: Int) -> ByteArray, +) : MediaByteSource { + override fun read(offset: Long, length: Int): ByteArray { + if (offset < 0 || length <= 0) return ByteArray(0) + return reader(offset, length) + } +} + +internal fun contentTypeForFile(file: File): String = + runCatching { Files.probeContentType(file.toPath()) }.getOrNull() ?: contentTypeForExtension(file.extension) + +internal fun contentTypeForExtension(extension: String): String = when (extension.lowercase()) { + "mp3" -> "audio/mpeg" + "ogg", "oga" -> "audio/ogg" + "opus" -> "audio/opus" + "wav" -> "audio/wav" + "flac" -> "audio/flac" + "m4a", "aac" -> "audio/mp4" + "webm" -> "video/webm" + "mp4", "m4v" -> "video/mp4" + "ogv" -> "video/ogg" + else -> "application/octet-stream" +} diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt index 5020dda9..c0296794 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt @@ -4,11 +4,9 @@ import com.intellij.openapi.diagnostic.Logger import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpServer import java.io.File -import java.io.RandomAccessFile import java.net.InetAddress import java.net.InetSocketAddress import java.net.URI -import java.nio.file.Files import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors @@ -22,7 +20,7 @@ import java.util.concurrent.Executors object MediaServer { private val log = Logger.getInstance(MediaServer::class.java) - private val files = ConcurrentHashMap() + private val files = ConcurrentHashMap() // Tokens the browser actually fetched; a served-but-never-fetched token means the loopback URL is unreachable. private val fetched = ConcurrentHashMap.newKeySet() @@ -38,12 +36,13 @@ object MediaServer { @Volatile private var server: HttpServer? = null - /** Registers [file] and returns a loopback URL the browser can fetch + play. */ + fun serve(file: File): String = serve(FileByteSource(file)) + @Synchronized - fun serve(file: File): String { + fun serve(source: MediaByteSource): String { val srv = server ?: start().also { server = it } val token = UUID.randomUUID().toString().replace("-", "") - files[token] = file + files[token] = source return "http://127.0.0.1:${srv.address.port}/$token" } @@ -98,26 +97,24 @@ object MediaServer { } val token = exchange.requestURI.path.trimStart('/') - val file = files[token] - if (file == null || !file.isFile) { - // A live editor requesting an unknown/vanished token signals a load-path failure, not a benign 404. - log.warn("Media request for missing file: ${exchange.requestURI.path} (registered=${file != null})") + val source = files[token] + val length = source?.length + if (source == null || length == null) { + log.warn("Media request for missing source: ${exchange.requestURI.path} (registered=${source != null})") exchange.sendResponseHeaders(HTTP_NOT_FOUND, -1) return } - // First reachable fetch: the watchdog reads this to tell a stalled load from a playing one. if (fetched.add(token)) log.debug("First media fetch for token $token") - headers.add("Content-Type", contentType(file)) + headers.add("Content-Type", source.contentType ?: "application/octet-stream") headers.add("Accept-Ranges", "bytes") - val length = file.length() val singleByteRange = exchange.requestHeaders.getFirst("Range") ?.takeIf { it.startsWith("bytes=") && !it.contains(',') } if (singleByteRange != null && length > 0) { - writeRange(exchange, file, length, singleByteRange) + writeRange(exchange, source, length, singleByteRange) } else { exchange.sendResponseHeaders(HTTP_OK, if (length == 0L) -1 else length) - file.inputStream().use { it.copyTo(exchange.responseBody) } + streamRange(source, 0, length, exchange.responseBody) } } catch (e: Exception) { log.warn("Media server request failed", e) @@ -136,7 +133,7 @@ object MediaServer { return name.equals("127.0.0.1", true) || name.equals("localhost", true) || name.equals("::1", true) } - private fun writeRange(exchange: HttpExchange, file: File, length: Long, range: String) { + private fun writeRange(exchange: HttpExchange, source: MediaByteSource, length: Long, range: String) { val spec = range.removePrefix("bytes=").split('-', limit = 2) val startTok = spec.getOrNull(0)?.trim().orEmpty() val endTok = spec.getOrNull(1)?.trim().orEmpty() @@ -144,7 +141,6 @@ object MediaServer { val start: Long val end: Long if (startTok.isEmpty()) { - // Suffix range "bytes=-N": the LAST n bytes. val n = endTok.toLongOrNull() if (n == null || n <= 0) return send416(exchange, length) start = maxOf(0, length - n) @@ -159,16 +155,22 @@ object MediaServer { val count = end - start + 1 exchange.responseHeaders.add("Content-Range", "bytes $start-$end/$length") exchange.sendResponseHeaders(HTTP_PARTIAL_CONTENT, count) - RandomAccessFile(file, "r").use { raf -> - raf.seek(start) - val buffer = ByteArray(CHUNK) - var remaining = count - while (remaining > 0) { - val read = raf.read(buffer, 0, minOf(buffer.size.toLong(), remaining).toInt()) - if (read == -1) break - exchange.responseBody.write(buffer, 0, read) - remaining -= read + streamRange(source, start, count, exchange.responseBody) + } + + private fun streamRange(source: MediaByteSource, start: Long, count: Long, out: java.io.OutputStream) { + var offset = start + var remaining = count + while (remaining > 0) { + val want = minOf(CHUNK.toLong(), remaining).toInt() + val bytes = source.read(offset, want) + if (bytes.isEmpty()) { + log.warn("streamRange: source empty at offset=$offset, $remaining bytes undelivered") + break } + out.write(bytes) + offset += bytes.size + remaining -= bytes.size } } @@ -176,18 +178,4 @@ object MediaServer { exchange.responseHeaders.add("Content-Range", "bytes */$length") exchange.sendResponseHeaders(HTTP_RANGE_NOT_SATISFIABLE, -1) } - - private fun contentType(file: File): String = runCatching { Files.probeContentType(file.toPath()) }.getOrNull() - ?: when (file.extension.lowercase()) { - "mp3" -> "audio/mpeg" - "ogg", "oga" -> "audio/ogg" - "opus" -> "audio/opus" - "wav" -> "audio/wav" - "flac" -> "audio/flac" - "m4a", "aac" -> "audio/mp4" - "webm" -> "video/webm" - "mp4", "m4v" -> "video/mp4" - "ogv" -> "video/ogg" - else -> "application/octet-stream" - } } diff --git a/frontend/src/main/resources/dev.twango.jetplay.frontend.xml b/frontend/src/main/resources/dev.twango.jetplay.frontend.xml index d427861b..928ffea7 100644 --- a/frontend/src/main/resources/dev.twango.jetplay.frontend.xml +++ b/frontend/src/main/resources/dev.twango.jetplay.frontend.xml @@ -1,6 +1,5 @@ - diff --git a/frontend/src/test/kotlin/dev/twango/jetplay/media/MediaByteSourceTest.kt b/frontend/src/test/kotlin/dev/twango/jetplay/media/MediaByteSourceTest.kt new file mode 100644 index 00000000..aebfb5a5 --- /dev/null +++ b/frontend/src/test/kotlin/dev/twango/jetplay/media/MediaByteSourceTest.kt @@ -0,0 +1,28 @@ +package dev.twango.jetplay.media + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class MediaByteSourceTest { + + @Test + fun remoteSourceDelegatesReadToReader() { + val data = ByteArray(100) { it.toByte() } + val source = RemoteRangeByteSource( + length = data.size.toLong(), + contentType = "video/mp4", + ) { offset, len -> data.copyOfRange(offset.toInt(), offset.toInt() + len) } + + assertEquals(100L, source.length) + assertEquals("video/mp4", source.contentType) + assertArrayEquals(byteArrayOf(10, 11, 12), source.read(10, 3)) + } + + @Test + fun remoteSourceRejectsNonPositiveLength() { + val source = RemoteRangeByteSource(10, "video/mp4") { _, _ -> ByteArray(0) } + assertArrayEquals(ByteArray(0), source.read(0, 0)) + assertArrayEquals(ByteArray(0), source.read(-1, 5)) + } +} diff --git a/frontend/src/test/kotlin/dev/twango/jetplay/media/MediaServerSourceTest.kt b/frontend/src/test/kotlin/dev/twango/jetplay/media/MediaServerSourceTest.kt new file mode 100644 index 00000000..0bb2ab30 --- /dev/null +++ b/frontend/src/test/kotlin/dev/twango/jetplay/media/MediaServerSourceTest.kt @@ -0,0 +1,48 @@ +package dev.twango.jetplay.media + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import java.net.HttpURLConnection +import java.net.URI + +class MediaServerSourceTest { + + @Test + fun servesRangeFromRemoteSource() { + val data = ByteArray(1000) { (it % 256).toByte() } + val source = RemoteRangeByteSource(data.size.toLong(), "video/mp4") { off, len -> + data.copyOfRange(off.toInt(), minOf(off.toInt() + len, data.size)) + } + val url = MediaServer.serve(source) + try { + val conn = URI(url).toURL().openConnection() as HttpURLConnection + conn.setRequestProperty("Range", "bytes=10-19") + conn.setRequestProperty("Host", "127.0.0.1") + assertEquals(206, conn.responseCode) + assertEquals("bytes 10-19/1000", conn.getHeaderField("Content-Range")) + assertArrayEquals(data.copyOfRange(10, 20), conn.inputStream.readBytes()) + conn.disconnect() + } finally { + MediaServer.release(url) + } + } + + @Test + fun servesFullBodyFromRemoteSource() { + val data = ByteArray(500) { (it % 256).toByte() } + val source = RemoteRangeByteSource(data.size.toLong(), "audio/mpeg") { off, len -> + data.copyOfRange(off.toInt(), minOf(off.toInt() + len, data.size)) + } + val url = MediaServer.serve(source) + try { + val conn = URI(url).toURL().openConnection() as HttpURLConnection + conn.setRequestProperty("Host", "127.0.0.1") + assertEquals(200, conn.responseCode) + assertArrayEquals(data, conn.inputStream.readBytes()) + conn.disconnect() + } finally { + MediaServer.release(url) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a70684fa..4fb2e498 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,3 +34,4 @@ dependencyResolutionManagement { include("shared") include("frontend") include("backend") +include("frontend-split") diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt b/shared/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt similarity index 100% rename from frontend/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt rename to shared/src/main/kotlin/dev/twango/jetplay/JetPlayBundle.kt diff --git a/frontend/src/main/resources/messages/JetPlayBundle.properties b/shared/src/main/resources/messages/JetPlayBundle.properties similarity index 100% rename from frontend/src/main/resources/messages/JetPlayBundle.properties rename to shared/src/main/resources/messages/JetPlayBundle.properties diff --git a/frontend/src/main/resources/messages/JetPlayBundle_de.properties b/shared/src/main/resources/messages/JetPlayBundle_de.properties similarity index 100% rename from frontend/src/main/resources/messages/JetPlayBundle_de.properties rename to shared/src/main/resources/messages/JetPlayBundle_de.properties diff --git a/frontend/src/main/resources/messages/JetPlayBundle_es.properties b/shared/src/main/resources/messages/JetPlayBundle_es.properties similarity index 100% rename from frontend/src/main/resources/messages/JetPlayBundle_es.properties rename to shared/src/main/resources/messages/JetPlayBundle_es.properties diff --git a/frontend/src/main/resources/messages/JetPlayBundle_fr.properties b/shared/src/main/resources/messages/JetPlayBundle_fr.properties similarity index 100% rename from frontend/src/main/resources/messages/JetPlayBundle_fr.properties rename to shared/src/main/resources/messages/JetPlayBundle_fr.properties diff --git a/frontend/src/main/resources/messages/JetPlayBundle_it.properties b/shared/src/main/resources/messages/JetPlayBundle_it.properties similarity index 100% rename from frontend/src/main/resources/messages/JetPlayBundle_it.properties rename to shared/src/main/resources/messages/JetPlayBundle_it.properties diff --git a/frontend/src/main/resources/messages/JetPlayBundle_ja.properties b/shared/src/main/resources/messages/JetPlayBundle_ja.properties similarity index 100% rename from frontend/src/main/resources/messages/JetPlayBundle_ja.properties rename to shared/src/main/resources/messages/JetPlayBundle_ja.properties diff --git a/frontend/src/main/resources/messages/JetPlayBundle_ko.properties b/shared/src/main/resources/messages/JetPlayBundle_ko.properties similarity index 100% rename from frontend/src/main/resources/messages/JetPlayBundle_ko.properties rename to shared/src/main/resources/messages/JetPlayBundle_ko.properties diff --git a/frontend/src/main/resources/messages/JetPlayBundle_pl.properties b/shared/src/main/resources/messages/JetPlayBundle_pl.properties similarity index 100% rename from frontend/src/main/resources/messages/JetPlayBundle_pl.properties rename to shared/src/main/resources/messages/JetPlayBundle_pl.properties diff --git a/frontend/src/main/resources/messages/JetPlayBundle_pt_BR.properties b/shared/src/main/resources/messages/JetPlayBundle_pt_BR.properties similarity index 100% rename from frontend/src/main/resources/messages/JetPlayBundle_pt_BR.properties rename to shared/src/main/resources/messages/JetPlayBundle_pt_BR.properties diff --git a/frontend/src/main/resources/messages/JetPlayBundle_ru.properties b/shared/src/main/resources/messages/JetPlayBundle_ru.properties similarity index 100% rename from frontend/src/main/resources/messages/JetPlayBundle_ru.properties rename to shared/src/main/resources/messages/JetPlayBundle_ru.properties diff --git a/frontend/src/main/resources/messages/JetPlayBundle_zh_CN.properties b/shared/src/main/resources/messages/JetPlayBundle_zh_CN.properties similarity index 100% rename from frontend/src/main/resources/messages/JetPlayBundle_zh_CN.properties rename to shared/src/main/resources/messages/JetPlayBundle_zh_CN.properties diff --git a/frontend/src/main/resources/messages/JetPlayBundle_zh_TW.properties b/shared/src/main/resources/messages/JetPlayBundle_zh_TW.properties similarity index 100% rename from frontend/src/main/resources/messages/JetPlayBundle_zh_TW.properties rename to shared/src/main/resources/messages/JetPlayBundle_zh_TW.properties diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 25415912..01b24a06 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -6,6 +6,7 @@ + From 0f1b98494ef13308103548212e640c97131de969 Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 13:23:43 -0700 Subject: [PATCH 18/29] fix(build): resolve frontend-split RD jars lazily so CI configures without a cached IDE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compileOnly jar paths were resolved eagerly at configuration time, but on a fresh CI checkout .intellijPlatform/ides is empty until the platform plugin downloads the IDE — so the lookup failed during task-graph calculation. Defer it to a Callable evaluated at compile time, capturing the dir as a config-cache-safe File. --- frontend-split/build.gradle.kts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/frontend-split/build.gradle.kts b/frontend-split/build.gradle.kts index 8b1a7b04..ffa39099 100644 --- a/frontend-split/build.gradle.kts +++ b/frontend-split/build.gradle.kts @@ -1,10 +1,7 @@ import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import java.util.concurrent.Callable -// The rdclient / rd jars below aren't exposed as bundled modules in IU-261, -// so pin them as compileOnly from the portably-resolved IDE home. -val ideHome = rootProject.layout.projectDirectory.dir(".intellijPlatform/ides").asFile - .listFiles()?.sortedByDescending { it.name }?.firstOrNull() - ?: error("No resolved IDE under .intellijPlatform/ides; run a Gradle build first") +val idesDir = rootProject.layout.projectDirectory.dir(".intellijPlatform/ides").asFile dependencies { intellijPlatform { @@ -16,14 +13,20 @@ dependencies { implementation(project(":shared")) implementation(project(":frontend")) - compileOnly(files( - ideHome.resolve("plugins/cwm-plugin/lib/frontend-split/rd-client.jar"), - ideHome.resolve("plugins/cwm-plugin/lib/frontend-split/frontend-split.jar"), - ideHome.resolve("lib/intellij.rd.platform.jar"), - ideHome.resolve("lib/intellij.rd.ui.jar"), - ideHome.resolve("lib/intellij.rd.ide.model.generated.jar"), - ideHome.resolve("lib/intellij.libraries.rd.core.jar"), - )) + // These RD jars aren't exposed as bundledModule() in IU-261, so pin them as compileOnly — resolved + // lazily via Callable because the IDE dir is empty until the platform plugin downloads it (fresh CI). + compileOnly(files(Callable { + val ideHome = idesDir.listFiles()?.sortedByDescending { it.name }?.firstOrNull() + ?: error("No resolved IDE under $idesDir") + listOf( + ideHome.resolve("plugins/cwm-plugin/lib/frontend-split/rd-client.jar"), + ideHome.resolve("plugins/cwm-plugin/lib/frontend-split/frontend-split.jar"), + ideHome.resolve("lib/intellij.rd.platform.jar"), + ideHome.resolve("lib/intellij.rd.ui.jar"), + ideHome.resolve("lib/intellij.rd.ide.model.generated.jar"), + ideHome.resolve("lib/intellij.libraries.rd.core.jar"), + ) + })) testImplementation(libs.junit) testImplementation(libs.opentest4j) From 42da163729d5b1cbc19b6b21c184dc13567a4b0c Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 13:35:12 -0700 Subject: [PATCH 19/29] fix(media): cap streamRange writes to the declared byte count Guard against a MediaByteSource that returns more bytes than requested, which would push the HTTP body past its Content-Length/Content-Range and corrupt a 206. --- .../main/kotlin/dev/twango/jetplay/media/MediaServer.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt index c0296794..5a912826 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt @@ -168,9 +168,11 @@ object MediaServer { log.warn("streamRange: source empty at offset=$offset, $remaining bytes undelivered") break } - out.write(bytes) - offset += bytes.size - remaining -= bytes.size + // Never write past the declared count even if a source over-reads (would corrupt a 206 body). + val n = minOf(bytes.size.toLong(), remaining).toInt() + out.write(bytes, 0, n) + offset += n + remaining -= n } } From e9f4f1a3f5258c2393f0ff837d08020ef296a91c Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 13:35:12 -0700 Subject: [PATCH 20/29] fix(media): release loopback URLs registered after dispose An async load task could call MediaServer.serve() after dispose() had already released the served URLs, leaking the token/source. Gate registration on a loader-level disposed flag and release immediately if disposal wins the race. --- .../dev/twango/jetplay/editor/MediaLoader.kt | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt index 92795090..4867b81f 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt @@ -46,7 +46,18 @@ class MediaLoader( @Volatile private var watchdog: ScheduledFuture<*>? = null - private fun serve(file: File): String = MediaServer.serve(file).also { servedUrls.add(it) } + @Volatile + private var disposed = false + + // Registers [url] for release on dispose; returns null (already released) if disposal won the race. + private fun registerServed(url: String): String? { + servedUrls.add(url) + if (disposed) { + MediaServer.release(url) + return null + } + return url + } /** JCEF may never reach the loopback server; error out if the token is unfetched by the deadline. */ private fun armLoadWatchdog(url: String) { @@ -101,7 +112,7 @@ class MediaLoader( private fun playFromSource() { val local = source.localFileOrNull() if (local != null) { - val url = serve(local) + val url = registerServed(MediaServer.serve(local)) ?: return loadPlayer(url) return } @@ -124,7 +135,7 @@ class MediaLoader( val remote = RemoteRangeByteSource(len, contentTypeForExtension(source.extension)) { offset, length -> runBlocking { MediaAccessor.getInstance().readRange(fileId, projectId, offset, length) } } - val url = MediaServer.serve(remote).also { servedUrls.add(it) } + val url = registerServed(MediaServer.serve(remote)) ?: return@submit loadPlayer(url) } } @@ -177,9 +188,13 @@ class MediaLoader( if (bridge.disposed) { temp.delete() } else { - val url = serve(temp) - bridge.mediaReady(url) - armLoadWatchdog(url) + val url = registerServed(MediaServer.serve(temp)) + if (url == null) { + temp.delete() + } else { + bridge.mediaReady(url) + armLoadWatchdog(url) + } } } catch (_: TranscodeUnavailable) { temp.delete() @@ -232,6 +247,7 @@ class MediaLoader( } fun dispose() { + disposed = true watchdog?.cancel(false) tasks.forEach { it.cancel(true) } servedUrls.forEach(MediaServer::release) From 3533fc74f24800a8e831438807481f657e96b852 Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 13:35:13 -0700 Subject: [PATCH 21/29] build: fail early when frontend-split internal jars are missing Validate the IDE-internal RD jars exist when resolving the compileOnly classpath, so an IDE layout change reports the missing paths instead of cryptic compile errors. --- frontend-split/build.gradle.kts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend-split/build.gradle.kts b/frontend-split/build.gradle.kts index ffa39099..ca338c0f 100644 --- a/frontend-split/build.gradle.kts +++ b/frontend-split/build.gradle.kts @@ -18,7 +18,7 @@ dependencies { compileOnly(files(Callable { val ideHome = idesDir.listFiles()?.sortedByDescending { it.name }?.firstOrNull() ?: error("No resolved IDE under $idesDir") - listOf( + val jars = listOf( ideHome.resolve("plugins/cwm-plugin/lib/frontend-split/rd-client.jar"), ideHome.resolve("plugins/cwm-plugin/lib/frontend-split/frontend-split.jar"), ideHome.resolve("lib/intellij.rd.platform.jar"), @@ -26,6 +26,13 @@ dependencies { ideHome.resolve("lib/intellij.rd.ide.model.generated.jar"), ideHome.resolve("lib/intellij.libraries.rd.core.jar"), ) + // Fail early with a clear message if the IDE layout moved these internal jars. + val missing = jars.filterNot { it.exists() } + require(missing.isEmpty()) { + "Missing IntelliJ internal jars for :frontend-split under $ideHome:\n" + + missing.joinToString("\n") { " $it" } + } + jars })) testImplementation(libs.junit) From 6294ed9c6def7871e28b992abb81ff8e08d7d62f Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 14:04:25 -0700 Subject: [PATCH 22/29] refactor(media): replace MediaLoader thread/Future plumbing with coroutines Collapse three overlapping concurrency mechanisms (pooled-thread Futures + runBlocking, the ScheduledFuture watchdog, and manual cancellation) into a single per-editor childScope of a project @Service CoroutineScope. The MediaAccessor RPCs are already suspend, so launches call them directly rather than blocking a pooled thread per round-trip -- relevant under Remote Dev where each is a network hop. dispose() cancels the scope; the loopback-token release race guard is kept and transcode temp cleanup moved into finally so cancellation can't leak it. Uses only platform-bundled kotlinx-coroutines + childScope -- no new dependencies. --- .../jetplay/editor/MediaCoroutineService.kt | 8 ++ .../dev/twango/jetplay/editor/MediaLoader.kt | 104 +++++++++--------- 2 files changed, 58 insertions(+), 54 deletions(-) create mode 100644 frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaCoroutineService.kt diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaCoroutineService.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaCoroutineService.kt new file mode 100644 index 00000000..0d1c47c2 --- /dev/null +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaCoroutineService.kt @@ -0,0 +1,8 @@ +package dev.twango.jetplay.editor + +import com.intellij.openapi.components.Service +import kotlinx.coroutines.CoroutineScope + +// Platform-injected, project-lifecycle CoroutineScope; each MediaLoader takes a childScope of it. +@Service(Service.Level.PROJECT) +class MediaCoroutineService(val scope: CoroutineScope) diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt index 4867b81f..f48a802c 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt @@ -5,11 +5,11 @@ import com.intellij.ide.vfs.rpcId 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.components.service import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.platform.project.projectId -import com.intellij.util.concurrency.AppExecutorUtil +import com.intellij.platform.util.coroutines.childScope import dev.twango.jetplay.JetPlayBundle import dev.twango.jetplay.JetPlayConstants import dev.twango.jetplay.browser.PlayerBridge @@ -23,13 +23,15 @@ import dev.twango.jetplay.media.RemoteRangeByteSource import dev.twango.jetplay.media.contentTypeForExtension import dev.twango.jetplay.rpc.MediaAccessor import dev.twango.jetplay.rpc.TranscodeEvent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.io.File import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.Future -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.TimeUnit class MediaLoader( private val project: Project, @@ -38,16 +40,16 @@ class MediaLoader( private val htmlLoader: PlayerHtmlLoader, ) { - private val tasks = CopyOnWriteArrayList>() + private val scope = project.service().scope.childScope("MediaLoader") // Loopback URLs to release on dispose. private val servedUrls = CopyOnWriteArrayList() @Volatile - private var watchdog: ScheduledFuture<*>? = null + private var disposed = false @Volatile - private var disposed = false + private var watchdog: Job? = null // Registers [url] for release on dispose; returns null (already released) if disposal won the race. private fun registerServed(url: String): String? { @@ -59,14 +61,14 @@ class MediaLoader( return url } - /** JCEF may never reach the loopback server; error out if the token is unfetched by the deadline. */ private fun armLoadWatchdog(url: String) { - watchdog?.cancel(false) - watchdog = AppExecutorUtil.getAppScheduledExecutorService().schedule({ - if (bridge.disposed || MediaServer.wasFetched(url)) return@schedule + watchdog?.cancel() + watchdog = scope.launch { + delay(LOAD_TIMEOUT_SECONDS * MILLIS_PER_SECOND) + if (bridge.disposed || MediaServer.wasFetched(url)) return@launch log.warn("Media load watchdog: $url served but never fetched after ${LOAD_TIMEOUT_SECONDS}s") bridge.showError(JetPlayBundle.message("error.load.timeout")) - }, LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } } private val uiStrings = UiStrings( @@ -92,9 +94,8 @@ class MediaLoader( if (source.isVideo || source.isRemote) return // Raw telephony codecs lack the demuxer hints to decode cleanly, risking a garbage waveform. if (source.extension.lowercase() in MediaClassification.rawAudioExtensions) return - submit { - if (bridge.disposed) return@submit - val bars = runBlocking { MediaAccessor.getInstance().extractWaveform(fileId, projectId) } + scope.launch { + val bars = MediaAccessor.getInstance().extractWaveform(fileId, projectId) if (bars.isNotEmpty() && !bridge.disposed) bridge.sendWaveform(bars) } } @@ -102,9 +103,8 @@ class MediaLoader( private fun maybeSendMediaInfo() { if (source.isRemote) return if (source.extension.lowercase() in MediaClassification.rawAudioExtensions) return - submit { - if (bridge.disposed) return@submit - val info = runBlocking { MediaAccessor.getInstance().extractMediaInfo(fileId, projectId) } + scope.launch { + val info = MediaAccessor.getInstance().extractMediaInfo(fileId, projectId) if (info != null && !bridge.disposed) bridge.sendMediaInfo(info) } } @@ -125,17 +125,17 @@ class MediaLoader( ui = uiStrings, ), ) - submit { - val len = runBlocking { MediaAccessor.getInstance().fileLength(fileId, projectId) } + scope.launch { + val len = MediaAccessor.getInstance().fileLength(fileId, projectId) if (len <= 0L) { showLoadError(JetPlayBundle.message("error.empty")) - return@submit + return@launch } - if (bridge.disposed) return@submit + // The HTTP server thread calls this reader synchronously, so it bridges the suspend RPC with runBlocking. val remote = RemoteRangeByteSource(len, contentTypeForExtension(source.extension)) { offset, length -> runBlocking { MediaAccessor.getInstance().readRange(fileId, projectId, offset, length) } } - val url = registerServed(MediaServer.serve(remote)) ?: return@submit + val url = registerServed(MediaServer.serve(remote)) ?: return@launch loadPlayer(url) } } @@ -165,46 +165,46 @@ class MediaLoader( ui = uiStrings, ), ) - submit { runTranscode() } + scope.launch { runTranscode() } } - private fun runTranscode() { + private suspend fun runTranscode() { val temp = File.createTempFile("jetplay-", ".webm").apply { deleteOnExit() } + var served = false try { - runBlocking { - val api = MediaAccessor.getInstance() - temp.outputStream().use { out -> - api.transcodeFile(fileId, projectId).collect { event -> - when (event) { - is TranscodeEvent.Progress -> if (!bridge.disposed) bridge.updateProgress(event.percent) - is TranscodeEvent.Chunk -> out.write(event.bytes) - is TranscodeEvent.Failed -> throw TranscodeFailure(event.message) - TranscodeEvent.Unavailable -> throw TranscodeUnavailable - TranscodeEvent.Done -> Unit - } - } - } + val api = MediaAccessor.getInstance() + temp.outputStream().use { out -> + api.transcodeFile(fileId, projectId).collect { event -> writeTranscodeEvent(event, out) } } - if (bridge.disposed) { - temp.delete() - } else { + if (!bridge.disposed) { val url = registerServed(MediaServer.serve(temp)) - if (url == null) { - temp.delete() - } else { + if (url != null) { + served = true bridge.mediaReady(url) armLoadWatchdog(url) } } } catch (_: TranscodeUnavailable) { - temp.delete() showTranscodingError() } catch (e: TranscodeFailure) { - temp.delete() if (!bridge.disposed) bridge.showError(e.message ?: JetPlayBundle.message("error.unknown")) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - temp.delete() showLoadError(e.message) + } finally { + // Once served, the browser owns the temp (cleaned on JVM exit); otherwise drop it now. + if (!served) temp.delete() + } + } + + private fun writeTranscodeEvent(event: TranscodeEvent, out: java.io.OutputStream) { + when (event) { + is TranscodeEvent.Progress -> if (!bridge.disposed) bridge.updateProgress(event.percent) + is TranscodeEvent.Chunk -> out.write(event.bytes) + is TranscodeEvent.Failed -> throw TranscodeFailure(event.message) + TranscodeEvent.Unavailable -> throw TranscodeUnavailable + TranscodeEvent.Done -> Unit } } @@ -242,14 +242,9 @@ class MediaLoader( .notify(project) } - private fun submit(block: () -> Unit) { - tasks.add(ApplicationManager.getApplication().executeOnPooledThread(block)) - } - fun dispose() { disposed = true - watchdog?.cancel(false) - tasks.forEach { it.cancel(true) } + scope.cancel() servedUrls.forEach(MediaServer::release) } @@ -259,5 +254,6 @@ class MediaLoader( companion object { private val log = Logger.getInstance(MediaLoader::class.java) private const val LOAD_TIMEOUT_SECONDS = 20L + private const val MILLIS_PER_SECOND = 1000L } } From ab5d2bd3e4f1a2114adb000605e33bc758b723de Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 16:46:01 -0700 Subject: [PATCH 23/29] fix(player): make split-mode playback reliable against JCEF load races Remote Dev loads the JCEF page slowly while the media pipeline is fast, exposing two races that stranded editors on "loading"/"transcoding": - executeJavaScript is dropped until the page finishes loading, so progress/ready pushes were lost. PlayerBridge now queues JS and flushes it on onLoadEnd. - the remote and transcode paths issued a second loadHTML that raced the shell's load; whichever finished last won the screen. They now load the shell once and push the URL in-page via mediaReady. Also gate the load watchdog on editor visibility, so background tabs (whose JCEF browser never loads until shown) stop false-firing "unable to play". --- .../twango/jetplay/browser/PlayerBridge.kt | 52 ++++++++++++++++--- .../jetplay/editor/MediaFileEditorProvider.kt | 7 ++- .../dev/twango/jetplay/editor/MediaLoader.kt | 8 ++- .../dev/twango/jetplay/media/MediaServer.kt | 1 - 4 files changed, 58 insertions(+), 10 deletions(-) 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 ec540caf..9ee1aabd 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt @@ -5,6 +5,9 @@ import com.intellij.ui.jcef.JBCefBrowser import com.intellij.ui.jcef.JBCefBrowserBase import com.intellij.ui.jcef.JBCefJSQuery import dev.twango.jetplay.media.MediaInfo +import org.cef.browser.CefBrowser +import org.cef.browser.CefFrame +import org.cef.handler.CefLoadHandlerAdapter import javax.swing.SwingUtilities class PlayerBridge(private val browser: JBCefBrowser) { @@ -13,6 +16,10 @@ class PlayerBridge(private val browser: JBCefBrowser) { var disposed = false private set + // JCEF drops executeJavaScript until the page finishes loading, so queue calls and flush them on load-end. + private var pageLoaded = false + private val pendingJs = mutableListOf() + val openLinkQuery: JBCefJSQuery = JBCefJSQuery.create(browser as JBCefBrowserBase).apply { addHandler { url -> BrowserUtil.browse(url) @@ -20,16 +27,43 @@ class PlayerBridge(private val browser: JBCefBrowser) { } } - fun executeJs(js: String) { - if (!disposed) { - SwingUtilities.invokeLater { - if (!disposed) { - browser.cefBrowser.executeJavaScript(js, "", 0) + init { + browser.jbCefClient.addLoadHandler( + object : CefLoadHandlerAdapter() { + override fun onLoadEnd(b: CefBrowser?, frame: CefFrame?, httpStatusCode: Int) { + if (frame?.isMain != true) return + val queued = synchronized(pendingJs) { + pageLoaded = true + pendingJs.toList().also { pendingJs.clear() } + } + queued.forEach(::runJs) } + }, + browser.cefBrowser, + ) + } + + fun executeJs(js: String) { + if (disposed) return + val runNow = synchronized(pendingJs) { + if (pageLoaded) { + true + } else { + pendingJs.add(js) + false } } + if (runNow) runJs(js) } + private fun runJs(js: String) { + SwingUtilities.invokeLater { + if (!disposed) browser.cefBrowser.executeJavaScript(js, "", 0) + } + } + + fun isShowing(): Boolean = !disposed && browser.component.isShowing + // Stash before notifying: a fast transcode can beat page load. fun updateProgress(percent: Double) = executeJs("window.__jetplayProgress=$percent;window.jetplayUpdateProgress?.($percent)") @@ -54,7 +88,13 @@ class PlayerBridge(private val browser: JBCefBrowser) { executeJs("window.__jetplayMediaInfo=$json;if(window.jetplayMediaInfo)window.jetplayMediaInfo(window.__jetplayMediaInfo)") } - fun loadHtml(html: String) = browser.loadHTML(html) + fun loadHtml(html: String) { + synchronized(pendingJs) { + pageLoaded = false + pendingJs.clear() + } + browser.loadHTML(html) + } fun dispose() { disposed = true diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt index dead59fb..ea42eac7 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt @@ -21,7 +21,12 @@ class MediaFileEditorProvider : override fun createEditor(project: Project, file: VirtualFile): FileEditor { if (!canRenderJcefHere()) { - log.warn("JCEF unavailable or on the Remote Dev host; opening ${file.name} in the fallback editor") + if (AppMode.isRemoteDevHost()) { + // Expected: the host has no display; the client renders the player via the rdclient handler. + log.debug("On the Remote Dev host; the client renders ${file.name}") + } else { + log.warn("JCEF unavailable; opening ${file.name} in the fallback editor") + } return MediaErrorEditor(file, JetPlayBundle.message("error.jcef.unavailable")) } StarReminder.maybeShow(project) diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt index f48a802c..150b1d1e 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt @@ -51,7 +51,7 @@ class MediaLoader( @Volatile private var watchdog: Job? = null - // Registers [url] for release on dispose; returns null (already released) if disposal won the race. + // null if disposal already released the URL (lost the race with dispose). private fun registerServed(url: String): String? { servedUrls.add(url) if (disposed) { @@ -66,6 +66,8 @@ class MediaLoader( watchdog = scope.launch { delay(LOAD_TIMEOUT_SECONDS * MILLIS_PER_SECOND) if (bridge.disposed || MediaServer.wasFetched(url)) return@launch + // A backgrounded tab never loads its JCEF page, so it never fetches; only flag a stall the user can see. + if (!bridge.isShowing()) return@launch log.warn("Media load watchdog: $url served but never fetched after ${LOAD_TIMEOUT_SECONDS}s") bridge.showError(JetPlayBundle.message("error.load.timeout")) } @@ -136,7 +138,9 @@ class MediaLoader( runBlocking { MediaAccessor.getInstance().readRange(fileId, projectId, offset, length) } } val url = registerServed(MediaServer.serve(remote)) ?: return@launch - loadPlayer(url) + // Push the URL in-page rather than a second loadHTML, which would race the shell's load. + bridge.mediaReady(url) + armLoadWatchdog(url) } } diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt index 5a912826..3529032b 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt @@ -72,7 +72,6 @@ object MediaServer { } private fun handle(exchange: HttpExchange) { - // Per-request trace: the only window into serving when playback silently stalls on a remote host. if (log.isDebugEnabled) { log.debug("${exchange.requestMethod} ${exchange.requestURI.path} range=${exchange.requestHeaders.getFirst("Range")}") } From 78abb4c00c91a07e693ea3791f60dc39fd20f32c Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 16:57:25 -0700 Subject: [PATCH 24/29] refactor: remove the dead "downloading" UI state The remote path streams bytes on demand via the readRange proxy rather than pre-downloading the whole file, so the "downloading" state, its progress bar, and reason text became unreachable. Drop them across Kotlin, the i18n bundles, and the Svelte player (DownloadingState component, updateDownloadProgress). --- .../twango/jetplay/browser/PlayerBridge.kt | 3 - .../twango/jetplay/browser/PlayerConfig.kt | 8 +-- .../jetplay/browser/PlayerHtmlLoader.kt | 2 - .../dev/twango/jetplay/editor/MediaLoader.kt | 1 - .../jetplay/browser/PlayerHtmlLoaderTest.kt | 14 ----- .../messages/JetPlayBundle.properties | 3 - .../messages/JetPlayBundle_de.properties | 3 - .../messages/JetPlayBundle_es.properties | 3 - .../messages/JetPlayBundle_fr.properties | 3 - .../messages/JetPlayBundle_it.properties | 3 - .../messages/JetPlayBundle_ja.properties | 3 - .../messages/JetPlayBundle_ko.properties | 3 - .../messages/JetPlayBundle_pl.properties | 3 - .../messages/JetPlayBundle_pt_BR.properties | 3 - .../messages/JetPlayBundle_ru.properties | 3 - .../messages/JetPlayBundle_zh_CN.properties | 3 - .../messages/JetPlayBundle_zh_TW.properties | 3 - ui/src/App.svelte | 11 +--- ui/src/global.d.ts | 8 +-- ui/src/lib/DownloadingState.svelte | 55 ------------------- 20 files changed, 4 insertions(+), 134 deletions(-) delete mode 100644 ui/src/lib/DownloadingState.svelte 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 9ee1aabd..6b461afe 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt @@ -68,9 +68,6 @@ class PlayerBridge(private val browser: JBCefBrowser) { fun updateProgress(percent: Double) = executeJs("window.__jetplayProgress=$percent;window.jetplayUpdateProgress?.($percent)") - fun updateDownloadProgress(percent: Double) = - executeJs("window.__jetplayDownloadProgress=$percent;window.jetplayUpdateDownloadProgress?.($percent)") - fun mediaReady(url: String) = executeJs("window.__jetplayReadyUrl='${escapeJs(url)}';window.jetplayReady?.('${escapeJs(url)}')") diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt index 1e23aec3..69e5acc9 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt @@ -8,13 +8,7 @@ data class PlayerConfig( val mediaUrl: String? = null, val errorMessage: String = "", val transcodingReason: String = "", - val downloadingReason: String = "", val ui: UiStrings = UiStrings(), ) -data class UiStrings( - val downloadingLabel: String = "", - val transcodingLabel: String = "", - val transcodingTip: String = "", - val errorTitle: String = "", -) +data class UiStrings(val transcodingLabel: String = "", val transcodingTip: String = "", val errorTitle: String = "") 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 0c2d8c64..502fa309 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt @@ -19,9 +19,7 @@ class PlayerHtmlLoader(private val bridge: PlayerBridge) { 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)}',") - if (config.downloadingReason.isNotEmpty()) append("downloadingReason: '${PlayerBridge.escapeJs(config.downloadingReason)}',") append("ui: {") - append("downloadingLabel: '${PlayerBridge.escapeJs(config.ui.downloadingLabel)}',") append("transcodingLabel: '${PlayerBridge.escapeJs(config.ui.transcodingLabel)}',") append("transcodingTip: '${PlayerBridge.escapeJs(config.ui.transcodingTip)}',") append("errorTitle: '${PlayerBridge.escapeJs(config.ui.errorTitle)}',") diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt index 150b1d1e..de58cc72 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt @@ -74,7 +74,6 @@ class MediaLoader( } private val uiStrings = UiStrings( - downloadingLabel = JetPlayBundle.message("ui.downloading.label"), transcodingLabel = JetPlayBundle.message("ui.transcoding.label"), transcodingTip = JetPlayBundle.message("ui.transcoding.tip"), errorTitle = JetPlayBundle.message("ui.error.title"), diff --git a/frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt b/frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt index 92a8286e..b75f67e1 100644 --- a/frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt +++ b/frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt @@ -79,31 +79,17 @@ class PlayerHtmlLoaderTest { assertFalse(result.contains("transcodingReason:")) } - @Test - fun includesDownloadingReasonWhenNotEmpty() { - val result = buildScript(PlayerConfig(downloadingReason = "Remote file")) - assertTrue(result.contains("downloadingReason: 'Remote file'")) - } - - @Test - fun omitsDownloadingReasonWhenEmpty() { - val result = buildScript(PlayerConfig(downloadingReason = "")) - assertFalse(result.contains("downloadingReason:")) - } - @Test fun includesUiStrings() { val result = buildScript( PlayerConfig( ui = UiStrings( - downloadingLabel = "Loading...", transcodingLabel = "Converting...", transcodingTip = "Use webm", errorTitle = "Error!", ), ), ) - assertTrue(result.contains("downloadingLabel: 'Loading...'")) assertTrue(result.contains("transcodingLabel: 'Converting...'")) assertTrue(result.contains("transcodingTip: 'Use webm'")) assertTrue(result.contains("errorTitle: 'Error!'")) diff --git a/shared/src/main/resources/messages/JetPlayBundle.properties b/shared/src/main/resources/messages/JetPlayBundle.properties index c6975e09..cbed5316 100644 --- a/shared/src/main/resources/messages/JetPlayBundle.properties +++ b/shared/src/main/resources/messages/JetPlayBundle.properties @@ -5,8 +5,6 @@ editor.name=Media Player filetype.name=Media filetype.description=Media files (audio/video) -# Downloading -downloading.reason=This file is on a remote host. Downloading to enable local playback. # Transcoding transcoding.reason={0} uses codecs not natively supported by the embedded browser. Converting to WebM (VP9/Opus) for playback. @@ -22,7 +20,6 @@ error.jcef.unavailable=Media playback needs the embedded browser (JCEF), which i error.load.timeout=Playback could not start. The media stream never reached the player. This is a known limitation of Remote Development \u2014 try opening the file in the local IDE instead. # UI -ui.downloading.label=Downloading\u2026 ui.transcoding.label=Converting for playback\u2026 ui.transcoding.tip=Use .webm, .ogg, .opus, .wav, or .mp3 files to play instantly without conversion. ui.error.title=Unable to play this file diff --git a/shared/src/main/resources/messages/JetPlayBundle_de.properties b/shared/src/main/resources/messages/JetPlayBundle_de.properties index 1edbcf17..665cfd5a 100644 --- a/shared/src/main/resources/messages/JetPlayBundle_de.properties +++ b/shared/src/main/resources/messages/JetPlayBundle_de.properties @@ -5,8 +5,6 @@ editor.name=Mediaplayer filetype.name=Medien filetype.description=Mediendateien (Audio/Video) -# Downloading -downloading.reason=Diese Datei befindet sich auf einem Remote-Host. Sie wird für die lokale Wiedergabe heruntergeladen. # Transcoding transcoding.reason={0} verwendet Codecs, die vom eingebetteten Browser nicht nativ unterstützt werden. Für die Wiedergabe wird in WebM (VP9/Opus) konvertiert. @@ -19,7 +17,6 @@ error.transcoding.notification.title=JetPlay: Transkodierung nicht verfügbar error.transcoding.notification.content={0}-Dateien erfordern Transkodierung für die Wiedergabe, aber die mitgelieferten FFmpeg-Bibliotheken konnten nicht geladen werden. Bitte installieren Sie das Plugin neu. # UI -ui.downloading.label=Wird heruntergeladen\u2026 ui.transcoding.label=Wird für die Wiedergabe konvertiert\u2026 ui.transcoding.tip=Verwenden Sie .webm-, .ogg-, .opus-, .wav- oder .mp3-Dateien, um sofort ohne Konvertierung abzuspielen. ui.error.title=Diese Datei kann nicht abgespielt werden diff --git a/shared/src/main/resources/messages/JetPlayBundle_es.properties b/shared/src/main/resources/messages/JetPlayBundle_es.properties index 785f7c14..7f8629be 100644 --- a/shared/src/main/resources/messages/JetPlayBundle_es.properties +++ b/shared/src/main/resources/messages/JetPlayBundle_es.properties @@ -5,8 +5,6 @@ editor.name=Reproductor multimedia filetype.name=Multimedia filetype.description=Archivos multimedia (audio/vídeo) -# Downloading -downloading.reason=Este archivo se encuentra en un host remoto. Descargando para habilitar la reproducción local. # Transcoding transcoding.reason={0} utiliza códecs no compatibles de forma nativa con el navegador integrado. Convirtiendo a WebM (VP9/Opus) para la reproducción. @@ -19,7 +17,6 @@ error.transcoding.notification.title=JetPlay: Transcodificación no disponible error.transcoding.notification.content=Los archivos {0} requieren transcodificación para su reproducción, pero las bibliotecas FFmpeg incluidas no se pudieron cargar. Intente reinstalar el plugin. # UI -ui.downloading.label=Descargando\u2026 ui.transcoding.label=Convirtiendo para reproducción\u2026 ui.transcoding.tip=Use archivos .webm, .ogg, .opus, .wav o .mp3 para reproducir instantáneamente sin conversión. ui.error.title=No se puede reproducir este archivo diff --git a/shared/src/main/resources/messages/JetPlayBundle_fr.properties b/shared/src/main/resources/messages/JetPlayBundle_fr.properties index 1bedd3b5..9f3d8d3d 100644 --- a/shared/src/main/resources/messages/JetPlayBundle_fr.properties +++ b/shared/src/main/resources/messages/JetPlayBundle_fr.properties @@ -5,8 +5,6 @@ editor.name=Lecteur multimédia filetype.name=Média filetype.description=Fichiers multimédias (audio/vidéo) -# Downloading -downloading.reason=Ce fichier se trouve sur un hôte distant. Téléchargement en cours pour la lecture locale. # Transcoding transcoding.reason={0} utilise des codecs non pris en charge nativement par le navigateur intégré. Conversion en WebM (VP9/Opus) pour la lecture. @@ -19,7 +17,6 @@ error.transcoding.notification.title=JetPlay : Transcodage indisponible error.transcoding.notification.content=Les fichiers {0} nécessitent un transcodage pour la lecture, mais les bibliothèques FFmpeg intégrées n'ont pas pu être chargées. Veuillez réinstaller le plugin. # UI -ui.downloading.label=Téléchargement\u2026 ui.transcoding.label=Conversion pour la lecture\u2026 ui.transcoding.tip=Utilisez des fichiers .webm, .ogg, .opus, .wav ou .mp3 pour une lecture instantanée sans conversion. ui.error.title=Impossible de lire ce fichier diff --git a/shared/src/main/resources/messages/JetPlayBundle_it.properties b/shared/src/main/resources/messages/JetPlayBundle_it.properties index 67334f71..54e00c37 100644 --- a/shared/src/main/resources/messages/JetPlayBundle_it.properties +++ b/shared/src/main/resources/messages/JetPlayBundle_it.properties @@ -5,8 +5,6 @@ editor.name=Lettore multimediale filetype.name=Media filetype.description=File multimediali (audio/video) -# Downloading -downloading.reason=Questo file si trova su un host remoto. Download in corso per abilitare la riproduzione locale. # Transcoding transcoding.reason={0} utilizza codec non supportati nativamente dal browser integrato. Conversione in WebM (VP9/Opus) per la riproduzione. @@ -19,7 +17,6 @@ error.transcoding.notification.title=JetPlay: Transcodifica non disponibile error.transcoding.notification.content=I file {0} richiedono la transcodifica per la riproduzione, ma le librerie FFmpeg integrate non sono state caricate. Provare a reinstallare il plugin. # UI -ui.downloading.label=Download in corso\u2026 ui.transcoding.label=Conversione per la riproduzione\u2026 ui.transcoding.tip=Usa file .webm, .ogg, .opus, .wav o .mp3 per riprodurre istantaneamente senza conversione. ui.error.title=Impossibile riprodurre questo file diff --git a/shared/src/main/resources/messages/JetPlayBundle_ja.properties b/shared/src/main/resources/messages/JetPlayBundle_ja.properties index 32fc65c2..af98ad44 100644 --- a/shared/src/main/resources/messages/JetPlayBundle_ja.properties +++ b/shared/src/main/resources/messages/JetPlayBundle_ja.properties @@ -5,8 +5,6 @@ editor.name=メディアプレーヤー filetype.name=メディア filetype.description=メディアファイル(音声/動画) -# Downloading -downloading.reason=このファイルはリモートホスト上にあります。ローカル再生のためにダウンロードしています。 # Transcoding transcoding.reason={0} は内蔵ブラウザーがネイティブにサポートしていないコーデックを使用しています。再生のために WebM (VP9/Opus) に変換しています。 @@ -19,7 +17,6 @@ error.transcoding.notification.title=JetPlay: トランスコード利用不可 error.transcoding.notification.content={0} ファイルの再生にはトランスコードが必要ですが、バンドルされた FFmpeg ライブラリの読み込みに失敗しました。プラグインを再インストールしてください。 # UI -ui.downloading.label=ダウンロード中\u2026 ui.transcoding.label=再生用に変換中\u2026 ui.transcoding.tip=.webm、.ogg、.opus、.wav、.mp3 ファイルは変換なしですぐに再生できます。 ui.error.title=このファイルを再生できません diff --git a/shared/src/main/resources/messages/JetPlayBundle_ko.properties b/shared/src/main/resources/messages/JetPlayBundle_ko.properties index 05b2a16c..364e3ad7 100644 --- a/shared/src/main/resources/messages/JetPlayBundle_ko.properties +++ b/shared/src/main/resources/messages/JetPlayBundle_ko.properties @@ -5,8 +5,6 @@ editor.name=미디어 플레이어 filetype.name=미디어 filetype.description=미디어 파일 (오디오/비디오) -# Downloading -downloading.reason=이 파일은 원격 호스트에 있습니다. 로컬 재생을 위해 다운로드 중입니다. # Transcoding transcoding.reason={0}은(는) 내장 브라우저가 기본적으로 지원하지 않는 코덱을 사용합니다. 재생을 위해 WebM (VP9/Opus)으로 변환 중입니다. @@ -19,7 +17,6 @@ error.transcoding.notification.title=JetPlay: 트랜스코딩 사용 불가 error.transcoding.notification.content={0} 파일을 재생하려면 트랜스코딩이 필요하지만, 번들된 FFmpeg 라이브러리를 로드하지 못했습니다. 플러그인을 다시 설치해 보세요. # UI -ui.downloading.label=다운로드 중\u2026 ui.transcoding.label=재생을 위해 변환 중\u2026 ui.transcoding.tip=.webm, .ogg, .opus, .wav 또는 .mp3 파일을 사용하면 변환 없이 바로 재생할 수 있습니다. ui.error.title=이 파일을 재생할 수 없습니다 diff --git a/shared/src/main/resources/messages/JetPlayBundle_pl.properties b/shared/src/main/resources/messages/JetPlayBundle_pl.properties index 28b1ca88..5e56af19 100644 --- a/shared/src/main/resources/messages/JetPlayBundle_pl.properties +++ b/shared/src/main/resources/messages/JetPlayBundle_pl.properties @@ -5,8 +5,6 @@ editor.name=Odtwarzacz multimediów filetype.name=Media filetype.description=Pliki multimedialne (audio/wideo) -# Downloading -downloading.reason=Ten plik znajduje się na zdalnym hoście. Pobieranie w celu umożliwienia lokalnego odtwarzania. # Transcoding transcoding.reason={0} używa kodeków nieobsługiwanych natywnie przez wbudowaną przeglądarkę. Konwertowanie do WebM (VP9/Opus) w celu odtworzenia. @@ -19,7 +17,6 @@ error.transcoding.notification.title=JetPlay: Transkodowanie niedostępne error.transcoding.notification.content=Pliki {0} wymagają transkodowania do odtwarzania, ale nie udało się załadować dołączonych bibliotek FFmpeg. Spróbuj ponownie zainstalować wtyczkę. # UI -ui.downloading.label=Pobieranie\u2026 ui.transcoding.label=Konwertowanie do odtwarzania\u2026 ui.transcoding.tip=Użyj plików .webm, .ogg, .opus, .wav lub .mp3, aby odtwarzać natychmiast bez konwersji. ui.error.title=Nie można odtworzyć tego pliku diff --git a/shared/src/main/resources/messages/JetPlayBundle_pt_BR.properties b/shared/src/main/resources/messages/JetPlayBundle_pt_BR.properties index 372bb9e9..64844473 100644 --- a/shared/src/main/resources/messages/JetPlayBundle_pt_BR.properties +++ b/shared/src/main/resources/messages/JetPlayBundle_pt_BR.properties @@ -5,8 +5,6 @@ editor.name=Reprodutor de mídia filetype.name=Mídia filetype.description=Arquivos de mídia (áudio/vídeo) -# Downloading -downloading.reason=Este arquivo está em um host remoto. Baixando para habilitar a reprodução local. # Transcoding transcoding.reason={0} usa codecs não suportados nativamente pelo navegador integrado. Convertendo para WebM (VP9/Opus) para reprodução. @@ -19,7 +17,6 @@ error.transcoding.notification.title=JetPlay: Transcodificação indisponível error.transcoding.notification.content=Arquivos {0} requerem transcodificação para reprodução, mas as bibliotecas FFmpeg incluídas não puderam ser carregadas. Tente reinstalar o plugin. # UI -ui.downloading.label=Baixando\u2026 ui.transcoding.label=Convertendo para reprodução\u2026 ui.transcoding.tip=Use arquivos .webm, .ogg, .opus, .wav ou .mp3 para reproduzir instantaneamente sem conversão. ui.error.title=Não foi possível reproduzir este arquivo diff --git a/shared/src/main/resources/messages/JetPlayBundle_ru.properties b/shared/src/main/resources/messages/JetPlayBundle_ru.properties index 11d81674..ce4b507d 100644 --- a/shared/src/main/resources/messages/JetPlayBundle_ru.properties +++ b/shared/src/main/resources/messages/JetPlayBundle_ru.properties @@ -5,8 +5,6 @@ editor.name=Медиаплеер filetype.name=Медиа filetype.description=Медиафайлы (аудио/видео) -# Downloading -downloading.reason=Этот файл находится на удалённом хосте. Загрузка для локального воспроизведения. # Transcoding transcoding.reason={0} использует кодеки, не поддерживаемые встроенным браузером. Конвертация в WebM (VP9/Opus) для воспроизведения. @@ -19,7 +17,6 @@ error.transcoding.notification.title=JetPlay: Перекодирование н error.transcoding.notification.content=Для воспроизведения файлов {0} требуется перекодирование, но не удалось загрузить встроенные библиотеки FFmpeg. Попробуйте переустановить плагин. # UI -ui.downloading.label=Загрузка\u2026 ui.transcoding.label=Конвертация для воспроизведения\u2026 ui.transcoding.tip=Используйте файлы .webm, .ogg, .opus, .wav или .mp3 для мгновенного воспроизведения без конвертации. ui.error.title=Не удалось воспроизвести этот файл diff --git a/shared/src/main/resources/messages/JetPlayBundle_zh_CN.properties b/shared/src/main/resources/messages/JetPlayBundle_zh_CN.properties index ae6e30ea..b6820a0e 100644 --- a/shared/src/main/resources/messages/JetPlayBundle_zh_CN.properties +++ b/shared/src/main/resources/messages/JetPlayBundle_zh_CN.properties @@ -5,8 +5,6 @@ editor.name=媒体播放器 filetype.name=媒体 filetype.description=媒体文件(音频/视频) -# Downloading -downloading.reason=此文件位于远程主机上。正在下载以启用本地播放。 # Transcoding transcoding.reason={0} 使用的编解码器不受内嵌浏览器原生支持。正在转换为 WebM (VP9/Opus) 以进行播放。 @@ -19,7 +17,6 @@ error.transcoding.notification.title=JetPlay:转码不可用 error.transcoding.notification.content={0} 文件需要转码才能播放,但内置的 FFmpeg 库加载失败。请尝试重新安装插件。 # UI -ui.downloading.label=正在下载\u2026 ui.transcoding.label=正在转换以播放\u2026 ui.transcoding.tip=使用 .webm、.ogg、.opus、.wav 或 .mp3 文件可直接播放,无需转换。 ui.error.title=无法播放此文件 diff --git a/shared/src/main/resources/messages/JetPlayBundle_zh_TW.properties b/shared/src/main/resources/messages/JetPlayBundle_zh_TW.properties index c129e3c1..bd302d93 100644 --- a/shared/src/main/resources/messages/JetPlayBundle_zh_TW.properties +++ b/shared/src/main/resources/messages/JetPlayBundle_zh_TW.properties @@ -5,8 +5,6 @@ editor.name=媒體播放器 filetype.name=媒體 filetype.description=媒體檔案(音訊/視訊) -# Downloading -downloading.reason=此檔案位於遠端主機上。正在下載以啟用本機播放。 # Transcoding transcoding.reason={0} 使用的編解碼器不受內嵌瀏覽器原生支援。正在轉換為 WebM (VP9/Opus) 以進行播放。 @@ -19,7 +17,6 @@ error.transcoding.notification.title=JetPlay:轉碼不可用 error.transcoding.notification.content={0} 檔案需要轉碼才能播放,但內建的 FFmpeg 函式庫載入失敗。請嘗試重新安裝外掛程式。 # UI -ui.downloading.label=正在下載\u2026 ui.transcoding.label=正在轉換以播放\u2026 ui.transcoding.tip=使用 .webm、.ogg、.opus、.wav 或 .mp3 檔案可直接播放,無需轉換。 ui.error.title=無法播放此檔案 diff --git a/ui/src/App.svelte b/ui/src/App.svelte index de3d9660..1f1ce84d 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,7 +1,6 @@ -{#if state === 'downloading'} - -{:else if state === 'loading'} +{#if state === 'loading'} {:else if state === 'error'} diff --git a/ui/src/global.d.ts b/ui/src/global.d.ts index 05161378..b274225f 100644 --- a/ui/src/global.d.ts +++ b/ui/src/global.d.ts @@ -28,21 +28,18 @@ declare global { fileName?: string fileExtension?: string isVideo?: boolean - state?: 'downloading' | 'loading' | 'ready' | 'error' + state?: 'loading' | 'ready' | 'error' errorMessage?: string transcodingReason?: string - downloadingReason?: string waveform?: number[] mediaInfo?: MediaInfo ui?: { - downloadingLabel?: string transcodingLabel?: string transcodingTip?: string errorTitle?: string } } jetplayUpdateProgress?: (percent: number) => void - jetplayUpdateDownloadProgress?: (percent: number) => void jetplayStartTranscoding?: () => void jetplayReady?: (mediaUrl: string) => void jetplayError?: (message: string) => void @@ -51,9 +48,8 @@ declare global { // Buffered state pushes (read on mount so an early transition isn't dropped). __jetplayReadyUrl?: string __jetplayError?: string - __jetplayState?: 'downloading' | 'loading' | 'ready' | 'error' + __jetplayState?: 'loading' | 'ready' | 'error' __jetplayProgress?: number - __jetplayDownloadProgress?: number jetplayMediaInfo?: (info: MediaInfo) => void __jetplayMediaInfo?: MediaInfo jetplayOpenLink?: (url: string) => void diff --git a/ui/src/lib/DownloadingState.svelte b/ui/src/lib/DownloadingState.svelte deleted file mode 100644 index 904ecb61..00000000 --- a/ui/src/lib/DownloadingState.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - -
-
- -
- -
{downloadingLabel}
- -
-
- {#if indeterminate} -
- {:else} -
- {/if} -
- - {#if !indeterminate} -
- {Math.round(progress)}% -
- {/if} -
- -
{fileName}
- - {#if reason} -
-
- -
- {reason} -
- {/if} - - -
- - From b854848da52df0a62a6a9e932d7bcf54085be24f Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 17:28:55 -0700 Subject: [PATCH 25/29] fix(frontend): address CodeRabbit review findings - serve .aac as audio/aac instead of audio/mp4 - marshal PlayerBridge.loadHtml onto the EDT for off-thread error paths - delete served transcode temps on dispose instead of leaking until JVM exit - emit ui.* config only when non-empty so Svelte defaults stand --- .../dev/twango/jetplay/browser/PlayerBridge.kt | 5 ++++- .../dev/twango/jetplay/browser/PlayerHtmlLoader.kt | 13 ++++++++++--- .../kotlin/dev/twango/jetplay/editor/MediaLoader.kt | 7 ++++++- .../dev/twango/jetplay/media/MediaByteSource.kt | 3 ++- 4 files changed, 22 insertions(+), 6 deletions(-) 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 6b461afe..40d2229f 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt @@ -90,7 +90,10 @@ class PlayerBridge(private val browser: JBCefBrowser) { pageLoaded = false pendingJs.clear() } - browser.loadHTML(html) + // JCEF/Swing access must be on the EDT; coroutine error paths can reach here off-thread. + SwingUtilities.invokeLater { + if (!disposed) browser.loadHTML(html) + } } fun dispose() { 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 502fa309..360d915b 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt @@ -19,10 +19,17 @@ class PlayerHtmlLoader(private val bridge: PlayerBridge) { 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)}',") + // Emit only non-empty strings so the Svelte component's own defaults stand for unset copy. append("ui: {") - append("transcodingLabel: '${PlayerBridge.escapeJs(config.ui.transcodingLabel)}',") - append("transcodingTip: '${PlayerBridge.escapeJs(config.ui.transcodingTip)}',") - append("errorTitle: '${PlayerBridge.escapeJs(config.ui.errorTitle)}',") + if (config.ui.transcodingLabel.isNotEmpty()) { + append("transcodingLabel: '${PlayerBridge.escapeJs(config.ui.transcodingLabel)}',") + } + if (config.ui.transcodingTip.isNotEmpty()) { + append("transcodingTip: '${PlayerBridge.escapeJs(config.ui.transcodingTip)}',") + } + if (config.ui.errorTitle.isNotEmpty()) { + append("errorTitle: '${PlayerBridge.escapeJs(config.ui.errorTitle)}',") + } append("},") append("};") } diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt index de58cc72..69bcbd87 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt @@ -45,6 +45,9 @@ class MediaLoader( // Loopback URLs to release on dispose. private val servedUrls = CopyOnWriteArrayList() + // Transcode outputs to delete on dispose rather than leaking until JVM exit. + private val servedTempFiles = CopyOnWriteArrayList() + @Volatile private var disposed = false @@ -183,6 +186,7 @@ class MediaLoader( val url = registerServed(MediaServer.serve(temp)) if (url != null) { served = true + servedTempFiles.add(temp) bridge.mediaReady(url) armLoadWatchdog(url) } @@ -196,7 +200,7 @@ class MediaLoader( } catch (e: Exception) { showLoadError(e.message) } finally { - // Once served, the browser owns the temp (cleaned on JVM exit); otherwise drop it now. + // Unserved temps drop now; served ones are deleted on dispose() when the editor closes. if (!served) temp.delete() } } @@ -249,6 +253,7 @@ class MediaLoader( disposed = true scope.cancel() servedUrls.forEach(MediaServer::release) + servedTempFiles.forEach(File::delete) } private object TranscodeUnavailable : Exception() diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaByteSource.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaByteSource.kt index f6c64582..63b9d823 100644 --- a/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaByteSource.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaByteSource.kt @@ -55,7 +55,8 @@ internal fun contentTypeForExtension(extension: String): String = when (extensio "opus" -> "audio/opus" "wav" -> "audio/wav" "flac" -> "audio/flac" - "m4a", "aac" -> "audio/mp4" + "m4a" -> "audio/mp4" + "aac" -> "audio/aac" "webm" -> "video/webm" "mp4", "m4v" -> "video/mp4" "ogv" -> "video/ogg" From 6b3f37fa4bae34153f6bbad1f7f9c92556588cc3 Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 17:29:03 -0700 Subject: [PATCH 26/29] ci: fix verify on EAP layouts and drop stale downloading specs - glob frontend-split/*.jar instead of naming rd-client.jar, which EAP builds rename (fixes :frontend-split verify on IU-262) - remove Playwright tests for the deleted downloading state --- frontend-split/build.gradle.kts | 27 +++++++++++++++------------ ui/tests/states.spec.ts | 22 ---------------------- ui/tests/transitions.spec.ts | 30 +----------------------------- 3 files changed, 16 insertions(+), 63 deletions(-) diff --git a/frontend-split/build.gradle.kts b/frontend-split/build.gradle.kts index ca338c0f..ed12630d 100644 --- a/frontend-split/build.gradle.kts +++ b/frontend-split/build.gradle.kts @@ -18,21 +18,24 @@ dependencies { compileOnly(files(Callable { val ideHome = idesDir.listFiles()?.sortedByDescending { it.name }?.firstOrNull() ?: error("No resolved IDE under $idesDir") - val jars = listOf( - ideHome.resolve("plugins/cwm-plugin/lib/frontend-split/rd-client.jar"), - ideHome.resolve("plugins/cwm-plugin/lib/frontend-split/frontend-split.jar"), - ideHome.resolve("lib/intellij.rd.platform.jar"), - ideHome.resolve("lib/intellij.rd.ui.jar"), - ideHome.resolve("lib/intellij.rd.ide.model.generated.jar"), - ideHome.resolve("lib/intellij.libraries.rd.core.jar"), - ) + // Glob the whole frontend-split dir instead of naming rd-client.jar/frontend-split.jar: + // EAP builds rename these, and the split classes we compile against live somewhere in here. + val splitDir = ideHome.resolve("plugins/cwm-plugin/lib/frontend-split") + val splitJars = splitDir.listFiles { f -> f.extension == "jar" }?.toList().orEmpty() + val libJars = listOf( + "lib/intellij.rd.platform.jar", + "lib/intellij.rd.ui.jar", + "lib/intellij.rd.ide.model.generated.jar", + "lib/intellij.libraries.rd.core.jar", + ).map { ideHome.resolve(it) } // Fail early with a clear message if the IDE layout moved these internal jars. - val missing = jars.filterNot { it.exists() } - require(missing.isEmpty()) { + val missingLib = libJars.filterNot { it.exists() } + require(splitJars.isNotEmpty() && missingLib.isEmpty()) { "Missing IntelliJ internal jars for :frontend-split under $ideHome:\n" + - missing.joinToString("\n") { " $it" } + (if (splitJars.isEmpty()) " $splitDir/*.jar\n" else "") + + missingLib.joinToString("\n") { " $it" } } - jars + splitJars + libJars })) testImplementation(libs.junit) diff --git a/ui/tests/states.spec.ts b/ui/tests/states.spec.ts index 34319276..83974929 100644 --- a/ui/tests/states.spec.ts +++ b/ui/tests/states.spec.ts @@ -27,28 +27,6 @@ test('video player renders in ready state', async ({ loadApp }) => { await expect(page.locator('video')).toBeAttached() }) -test('downloading state shows progress and file name', async ({ loadApp }) => { - const page = await loadApp({ - state: 'downloading', - fileName: 'big-file.mp4', - }) - - await expect(page.getByText('Downloading\u2026')).toBeVisible() - await expect(page.getByText('big-file.mp4')).toBeVisible() - // Progress bar container exists - await expect(page.locator('.progress-fill')).toBeAttached() -}) - -test('downloading state shows reason when provided', async ({ loadApp }) => { - const page = await loadApp({ - state: 'downloading', - fileName: 'remote.mp4', - downloadingReason: 'Remote file needs to be downloaded', - }) - - await expect(page.getByText('Remote file needs to be downloaded')).toBeVisible() -}) - test('transcoding state shows progress and tip', async ({ loadApp }) => { const page = await loadApp({ state: 'loading', diff --git a/ui/tests/transitions.spec.ts b/ui/tests/transitions.spec.ts index d1c96d46..f468073b 100644 --- a/ui/tests/transitions.spec.ts +++ b/ui/tests/transitions.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from './fixtures' test('jetplayReady transitions to audio player', async ({ loadApp }) => { const page = await loadApp({ - state: 'downloading', + state: 'loading', fileName: 'track.ogg', fileExtension: 'ogg', isVideo: false, @@ -34,21 +34,6 @@ test('jetplayReady transitions to video player', async ({ loadApp }) => { await expect(page.locator('video')).toBeAttached() }) -test('jetplayStartTranscoding transitions from downloading to loading', async ({ loadApp }) => { - const page = await loadApp({ - state: 'downloading', - fileName: 'track.aac', - }) - - await expect(page.getByText('Downloading\u2026')).toBeVisible() - - await page.evaluate(() => { - window.jetplayStartTranscoding?.() - }) - - await expect(page.getByText('Converting for playback\u2026')).toBeVisible() -}) - test('jetplayError transitions to error state', async ({ loadApp }) => { const page = await loadApp({ state: 'ready', @@ -76,17 +61,4 @@ test('jetplayUpdateProgress updates transcoding progress', async ({ loadApp }) = }) await expect(page.getByText('50%')).toBeVisible() -}) - -test('jetplayUpdateDownloadProgress updates download progress', async ({ loadApp }) => { - const page = await loadApp({ - state: 'downloading', - fileName: 'big.mp4', - }) - - await page.evaluate(() => { - window.jetplayUpdateDownloadProgress?.(75) - }) - - await expect(page.getByText('75%')).toBeVisible() }) \ No newline at end of file From 912027b25c08b1b5459ee195b04013defe96513e Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 17:41:27 -0700 Subject: [PATCH 27/29] refactor: rename frontend-split content module to client The module loads only in the JetBrains Client process, so 'client' reads clearer than 'frontend-split' (which collided with the platform's own frontend.split vocabulary). Renames the gradle subproject, content-module id (dev.twango.jetplay.client), descriptor file, and editor.client package. JetBrains-owned names (the intellij.platform.frontend.split dependency and the cwm-plugin/lib/frontend-split jar dir) are unchanged. --- AGENTS.md | 2 +- build.gradle.kts | 2 +- {frontend-split => client}/build.gradle.kts | 4 ++-- .../jetplay/editor/client}/MediaFrontendEditorModelHandler.kt | 2 +- .../src/main/resources/dev.twango.jetplay.client.xml | 4 ++-- settings.gradle.kts | 2 +- src/main/resources/META-INF/plugin.xml | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) rename {frontend-split => client}/build.gradle.kts (93%) rename {frontend-split/src/main/kotlin/dev/twango/jetplay/editor/split => client/src/main/kotlin/dev/twango/jetplay/editor/client}/MediaFrontendEditorModelHandler.kt (97%) rename frontend-split/src/main/resources/dev.twango.jetplay.frontend.split.xml => client/src/main/resources/dev.twango.jetplay.client.xml (70%) diff --git a/AGENTS.md b/AGENTS.md index 3e7fb792..c1a7fe53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ IntelliJ Platform plugin providing native audio/video playback in JetBrains IDEs ## Project Structure -- `shared/`, `frontend/`, `frontend-split/`, `backend/` — Plugin Model V2 content modules, each under `/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. `frontend-split` (JetBrains Client only): `rdclient.fileEditorModelHandler` that renders the player client-side in split mode. `backend` (host): ffmpeg + RPC byte/transcode access. +- `shared/`, `frontend/`, `client/`, `backend/` — Plugin Model V2 content modules, each under `/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 diff --git a/build.gradle.kts b/build.gradle.kts index e459ff9b..30959918 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,7 +55,7 @@ dependencies { pluginModule(implementation(project(":shared"))) pluginModule(implementation(project(":frontend"))) - pluginModule(implementation(project(":frontend-split"))) + pluginModule(implementation(project(":client"))) pluginModule(implementation(project(":backend"))) } } diff --git a/frontend-split/build.gradle.kts b/client/build.gradle.kts similarity index 93% rename from frontend-split/build.gradle.kts rename to client/build.gradle.kts index ed12630d..253ab94f 100644 --- a/frontend-split/build.gradle.kts +++ b/client/build.gradle.kts @@ -31,7 +31,7 @@ dependencies { // Fail early with a clear message if the IDE layout moved these internal jars. val missingLib = libJars.filterNot { it.exists() } require(splitJars.isNotEmpty() && missingLib.isEmpty()) { - "Missing IntelliJ internal jars for :frontend-split under $ideHome:\n" + + "Missing IntelliJ internal jars for :client under $ideHome:\n" + (if (splitJars.isEmpty()) " $splitDir/*.jar\n" else "") + missingLib.joinToString("\n") { " $it" } } @@ -44,5 +44,5 @@ dependencies { // Align the content-module jar name with the module id so the verifier/platform resolve the descriptor. tasks.named("composedJar") { - archiveBaseName.set("dev.twango.jetplay.frontend.split") + archiveBaseName.set("dev.twango.jetplay.client") } diff --git a/frontend-split/src/main/kotlin/dev/twango/jetplay/editor/split/MediaFrontendEditorModelHandler.kt b/client/src/main/kotlin/dev/twango/jetplay/editor/client/MediaFrontendEditorModelHandler.kt similarity index 97% rename from frontend-split/src/main/kotlin/dev/twango/jetplay/editor/split/MediaFrontendEditorModelHandler.kt rename to client/src/main/kotlin/dev/twango/jetplay/editor/client/MediaFrontendEditorModelHandler.kt index fa8aae4a..da66f747 100644 --- a/frontend-split/src/main/kotlin/dev/twango/jetplay/editor/split/MediaFrontendEditorModelHandler.kt +++ b/client/src/main/kotlin/dev/twango/jetplay/editor/client/MediaFrontendEditorModelHandler.kt @@ -1,4 +1,4 @@ -package dev.twango.jetplay.editor.split +package dev.twango.jetplay.editor.client import com.intellij.openapi.client.ClientProjectSession import com.intellij.openapi.fileEditor.ex.FileEditorWithProvider diff --git a/frontend-split/src/main/resources/dev.twango.jetplay.frontend.split.xml b/client/src/main/resources/dev.twango.jetplay.client.xml similarity index 70% rename from frontend-split/src/main/resources/dev.twango.jetplay.frontend.split.xml rename to client/src/main/resources/dev.twango.jetplay.client.xml index ba902f27..4bb99d9a 100644 --- a/frontend-split/src/main/resources/dev.twango.jetplay.frontend.split.xml +++ b/client/src/main/resources/dev.twango.jetplay.client.xml @@ -1,6 +1,6 @@ - + @@ -11,7 +11,7 @@ diff --git a/settings.gradle.kts b/settings.gradle.kts index 4fb2e498..2f3b4f16 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,4 +34,4 @@ dependencyResolutionManagement { include("shared") include("frontend") include("backend") -include("frontend-split") +include("client") diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 01b24a06..2f8ea373 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -6,7 +6,7 @@ - + From 5299b0c06b8a1926461c68fe7525f7ab9ebe58e3 Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 17:57:44 -0700 Subject: [PATCH 28/29] ci(verify): drop EAP IDEs from the verify matrix; port discovery to Python recommended() pulls in the latest EAP (e.g. IU-262.7581.18), but EAPs relocate the @ApiStatus.Internal split-mode RD classes the client module compiles against, so the plugin can't build there. Keep only released, year-versioned IDEs; 2026.2 rejoins automatically once it ships. Rewrites the discovery script in Python (clearer than the bash+jq regex) and dedupes the matrix. --- .github/scripts/list-verifier-ides.py | 46 +++++++++++++++++++++++++++ .github/scripts/list-verifier-ides.sh | 23 -------------- .github/workflows/build.yml | 2 +- 3 files changed, 47 insertions(+), 24 deletions(-) create mode 100755 .github/scripts/list-verifier-ides.py delete mode 100755 .github/scripts/list-verifier-ides.sh diff --git a/.github/scripts/list-verifier-ides.py b/.github/scripts/list-verifier-ides.py new file mode 100755 index 00000000..36f72475 --- /dev/null +++ b/.github/scripts/list-verifier-ides.py @@ -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() diff --git a/.github/scripts/list-verifier-ides.sh b/.github/scripts/list-verifier-ides.sh deleted file mode 100755 index b6839883..00000000 --- a/.github/scripts/list-verifier-ides.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -# -# Resolve the IDEs that `verifyPlugin` would target via `recommended()` and -# emit them as a compact JSON array, e.g. `["IU-2026.1.2","IU-2025.2.6.2",...]`. -# -# Intended for a GitHub Actions "discover" job that feeds a matrix: -# -# - id: list -# run: echo "ides=$(.github/scripts/list-verifier-ides.sh)" >> "$GITHUB_OUTPUT" -# -# Then in the downstream job: -# -# strategy: -# fail-fast: false -# matrix: -# ide: ${{ fromJSON(needs.discover.outputs.ides) }} - -set -euo pipefail - -cd "$(dirname "$0")/../.." - -./gradlew -q printProductsReleases \ - | jq -R -s -c 'split("\n") | map(select(length > 0))' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2485039..dc34a51b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 From beb4614a452f6dc5eaeb7a5e565578c6f1a03448 Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 14 Jun 2026 18:12:41 -0700 Subject: [PATCH 29/29] docs: correct min platform to 2025.3 to match pluginSinceBuild --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c1a7fe53..90cf8836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ 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