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 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/AGENTS.md b/AGENTS.md index f6a6a60d..90cf8836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,14 +6,15 @@ IntelliJ Platform plugin providing native audio/video playback in JetBrains IDEs - Kotlin - Gradle (Kotlin DSL) -- IntelliJ Platform SDK (2025.2+) +- IntelliJ Platform SDK (2025.3+) - JCEF (JBCefBrowser) for media rendering - JDK 21 ## Project Structure -- `src/main/kotlin/dev/twango/jetplay/` — plugin source -- `src/main/resources/META-INF/plugin.xml` — plugin descriptor (single source of truth for supported extensions) +- `shared/`, `frontend/`, `client/`, `backend/` — Plugin Model V2 content modules, each under `/src/main/kotlin/dev/twango/jetplay/`. `shared` (loads everywhere): shared types, RPC contract, i18n bundle. `frontend` (loads on the Remote Dev host **and** client): file type + editor provider + JCEF player + loopback media server; JCEF is guarded off on the host. `client` (JetBrains Client only, binds the platform's `intellij.platform.frontend.split` module): `rdclient.fileEditorModelHandler` that renders the player client-side in split mode. `backend` (host): ffmpeg + RPC byte/transcode access. +- `src/main/resources/META-INF/plugin.xml` — root plugin descriptor (content-module wiring only) +- `frontend/src/main/resources/dev.twango.jetplay.frontend.xml` — frontend module descriptor; single source of truth for supported extensions. The `frontend` module loads on both host and client, so its `fileType`/`fileEditorProvider` register on the host (for detection/selection) while the JCEF player renders on the client. - `gradle.properties` — plugin metadata and version config ## Conventions @@ -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 diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts new file mode 100644 index 00000000..f43704c5 --- /dev/null +++ b/backend/build.gradle.kts @@ -0,0 +1,48 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + +dependencies { + intellijPlatform { + bundledModule("intellij.platform.kernel.backend") + bundledModule("intellij.platform.rpc") + bundledModule("intellij.platform.rpc.backend") + bundledModule("intellij.platform.backend") + compileOnly(libs.kotlin.serialization.core.jvm) + compileOnly(libs.kotlin.serialization.json.jvm) + testFramework(TestFrameworkType.Platform) + } + implementation(project(":shared")) + + testImplementation(libs.junit) + testImplementation(libs.opentest4j) + + implementation("org.bytedeco:javacv:1.5.13") { + exclude(group = "org.bytedeco", module = "opencv") + exclude(group = "org.bytedeco", module = "openblas") + exclude(group = "org.bytedeco", module = "flycapture") + exclude(group = "org.bytedeco", module = "libdc1394") + exclude(group = "org.bytedeco", module = "libfreenect") + exclude(group = "org.bytedeco", module = "libfreenect2") + exclude(group = "org.bytedeco", module = "librealsense") + exclude(group = "org.bytedeco", module = "librealsense2") + exclude(group = "org.bytedeco", module = "videoinput") + exclude(group = "org.bytedeco", module = "artoolkitplus") + exclude(group = "org.bytedeco", module = "flandmark") + exclude(group = "org.bytedeco", module = "leptonica") + exclude(group = "org.bytedeco", module = "tesseract") + } + implementation("org.bytedeco:ffmpeg:7.1-1.5.13:linux-x86_64") + implementation("org.bytedeco:ffmpeg:7.1-1.5.13:macosx-x86_64") + implementation("org.bytedeco:ffmpeg:7.1-1.5.13:macosx-arm64") + implementation("org.bytedeco:ffmpeg:7.1-1.5.13:windows-x86_64") + implementation("org.bytedeco:javacpp:1.5.13:linux-x86_64") + implementation("org.bytedeco:javacpp:1.5.13:macosx-x86_64") + implementation("org.bytedeco:javacpp:1.5.13:macosx-arm64") + implementation("org.bytedeco:javacpp:1.5.13:windows-x86_64") +} + +// IPGP names the content-module jar "." (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/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..dd7d2144 --- /dev/null +++ b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorImpl.kt @@ -0,0 +1,122 @@ +@file:Suppress("UnstableApiUsage") + +package dev.twango.jetplay.rpc + +import com.intellij.ide.vfs.VirtualFileId +import com.intellij.ide.vfs.virtualFile +import com.intellij.platform.project.ProjectId +import com.intellij.platform.project.findProjectOrNull +import dev.twango.jetplay.media.MediaInfo +import dev.twango.jetplay.transcode.FfmpegAvailability +import dev.twango.jetplay.transcode.MediaInfoExtractor +import dev.twango.jetplay.transcode.TranscodeRunner +import dev.twango.jetplay.transcode.WaveformExtractor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import java.io.File +import java.io.RandomAccessFile + +private const val CHUNK_BYTES = 1 shl 20 // 1 MB + +class MediaAccessorImpl : MediaAccessor { + + // Resolve only within a live project; a dead projectId means a stale RPC caller. + private fun resolveFile(fileId: VirtualFileId, projectId: ProjectId): File? { + if (projectId.findProjectOrNull() == null) return null + 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, projectId) ?: return@flow + RandomAccessFile(file, "r").use { raf -> + val buf = ByteArray(CHUNK_BYTES) + while (true) { + val n = raf.read(buf) + if (n <= 0) break + emit(if (n == buf.size) buf.copyOf() else buf.copyOf(n)) + } + } + }.flowOn(Dispatchers.IO) + + override suspend fun fileLength(fileId: VirtualFileId, projectId: ProjectId): Long = + withContext(Dispatchers.IO) { resolveFile(fileId, projectId)?.length() ?: -1L } + + override suspend fun readRange(fileId: VirtualFileId, projectId: ProjectId, offset: Long, length: Int): ByteArray = + withContext(Dispatchers.IO) { + if (offset < 0 || length <= 0) return@withContext ByteArray(0) + val file = resolveFile(fileId, projectId) ?: return@withContext ByteArray(0) + RandomAccessFile(file, "r").use { raf -> + raf.seek(offset) + val out = ByteArray(length) + // raf.read() may return a short count; loop until full or EOF. + var total = 0 + while (total < length) { + val n = raf.read(out, total, length - total) + if (n < 0) break + total += n + } + when (total) { + 0 -> ByteArray(0) + length -> out + else -> out.copyOf(total) + } + } + } + + override suspend fun transcodeFile(fileId: VirtualFileId, projectId: ProjectId): Flow = + channelFlow { + if (!FfmpegAvailability.available) { + send(TranscodeEvent.Unavailable) + return@channelFlow + } + val input = resolveFile(fileId, projectId) ?: run { + send(TranscodeEvent.Failed("source unavailable")) + return@channelFlow + } + val output = try { + withContext(Dispatchers.IO) { + // onProgress fires synchronously inside ffmpeg, so trySend (non-suspending) bridges it. + TranscodeRunner.transcode(input) { pct -> trySend(TranscodeEvent.Progress(pct)) } + } + } catch (e: Exception) { + send(TranscodeEvent.Failed(e.message ?: "unknown")) + return@channelFlow + } + try { + withContext(Dispatchers.IO) { + RandomAccessFile(output, "r").use { raf -> + val buf = ByteArray(CHUNK_BYTES) + while (true) { + val n = raf.read(buf) + if (n <= 0) break + send(TranscodeEvent.Chunk(if (n == buf.size) buf.copyOf() else buf.copyOf(n))) + } + } + } + } finally { + // Frontend now holds the bytes; drop the backend copy. + runCatching { output.delete() } + } + send(TranscodeEvent.Done) + } + + override suspend fun extractWaveform(fileId: VirtualFileId, projectId: ProjectId): List = + withContext(Dispatchers.IO) { + if (!FfmpegAvailability.available) return@withContext emptyList() + val file = resolveFile(fileId, projectId) ?: return@withContext emptyList() + runCatching { WaveformExtractor.extract(file) }.getOrDefault(emptyList()) + } + + override suspend fun extractMediaInfo(fileId: VirtualFileId, projectId: ProjectId): MediaInfo? = + withContext(Dispatchers.IO) { + if (!FfmpegAvailability.available) return@withContext null + val file = resolveFile(fileId, projectId) ?: return@withContext null + runCatching { MediaInfoExtractor.extract(file) }.getOrNull() + } +} 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..81ca260b --- /dev/null +++ b/backend/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessorProvider.kt @@ -0,0 +1,12 @@ +@file:Suppress("UnstableApiUsage") + +package dev.twango.jetplay.rpc + +import com.intellij.platform.rpc.backend.RemoteApiProvider +import fleet.rpc.remoteApiDescriptor + +internal class MediaAccessorProvider : RemoteApiProvider { + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { MediaAccessorImpl() } + } +} diff --git a/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 src/main/kotlin/dev/twango/jetplay/transcode/FfmpegAvailability.kt rename to backend/src/main/kotlin/dev/twango/jetplay/transcode/FfmpegAvailability.kt diff --git a/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt b/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt similarity index 79% rename from src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt rename to backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt index 87bbf2db..cc975b9f 100644 --- a/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,46 +12,18 @@ 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 { 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", @@ -80,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 @@ -97,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) { @@ -141,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" } @@ -164,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" @@ -182,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/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt b/backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt similarity index 92% rename from src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt rename to backend/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt index 688b07c3..7ec6a445 100644 --- a/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,9 @@ object MediaTranscoder { "sln" to RawAudioHint("s16le", 8000, 1), ) + /** Must stay in sync with the shared classifier. */ 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() } @@ -110,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/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/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt b/backend/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt similarity index 86% rename from src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt rename to backend/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt index 7dbea2cf..71eb5d4e 100644 --- a/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/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..97bd94d9 --- /dev/null +++ b/backend/src/main/resources/dev.twango.jetplay.backend.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + 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..ad6a425a --- /dev/null +++ b/backend/src/test/kotlin/dev/twango/jetplay/rpc/MediaAccessorImplTest.kt @@ -0,0 +1,73 @@ +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) + } + + 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) + } +} diff --git a/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 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/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 src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt rename to backend/src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 45ba2652..30959918 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,55 +1,45 @@ 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 { 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() version = providers.gradleProperty("pluginVersion").get() kotlin { - jvmToolchain(17) + jvmToolchain(21) } -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(21) } -} -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 +52,11 @@ 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(":client"))) + pluginModule(implementation(project(":backend"))) } } @@ -71,6 +66,9 @@ changelog { } intellijPlatform { + splitMode = providers.gradleProperty("splitMode").map { it.toBoolean() }.orElse(true) + pluginInstallationTarget = SplitModeAware.PluginInstallationTarget.BOTH + pluginConfiguration { name = providers.gradleProperty("pluginName") version = providers.gradleProperty("pluginVersion") @@ -115,6 +113,18 @@ intellijPlatform { } pluginVerification { + // 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. + // 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, + ) ides { val pinned = providers.gradleProperty("verifierIde").orNull if (pinned.isNullOrBlank()) { @@ -142,6 +152,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 { @@ -151,21 +163,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/client/build.gradle.kts b/client/build.gradle.kts new file mode 100644 index 00000000..253ab94f --- /dev/null +++ b/client/build.gradle.kts @@ -0,0 +1,48 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import java.util.concurrent.Callable + +val idesDir = rootProject.layout.projectDirectory.dir(".intellijPlatform/ides").asFile + +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")) + + // 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") + // 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 missingLib = libJars.filterNot { it.exists() } + require(splitJars.isNotEmpty() && missingLib.isEmpty()) { + "Missing IntelliJ internal jars for :client under $ideHome:\n" + + (if (splitJars.isEmpty()) " $splitDir/*.jar\n" else "") + + missingLib.joinToString("\n") { " $it" } + } + splitJars + libJars + })) + + 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.client") +} diff --git a/client/src/main/kotlin/dev/twango/jetplay/editor/client/MediaFrontendEditorModelHandler.kt b/client/src/main/kotlin/dev/twango/jetplay/editor/client/MediaFrontendEditorModelHandler.kt new file mode 100644 index 00000000..da66f747 --- /dev/null +++ b/client/src/main/kotlin/dev/twango/jetplay/editor/client/MediaFrontendEditorModelHandler.kt @@ -0,0 +1,39 @@ +package dev.twango.jetplay.editor.client + +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/client/src/main/resources/dev.twango.jetplay.client.xml b/client/src/main/resources/dev.twango.jetplay.client.xml new file mode 100644 index 00000000..4bb99d9a --- /dev/null +++ b/client/src/main/resources/dev.twango.jetplay.client.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts new file mode 100644 index 00000000..de169f4a --- /dev/null +++ b/frontend/build.gradle.kts @@ -0,0 +1,34 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + +dependencies { + intellijPlatform { + 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. +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) +} + +// 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/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt similarity index 71% rename from src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt index 106a263a..40d2229f 100644 --- a/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt @@ -4,7 +4,10 @@ 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 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,23 +27,47 @@ 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) } - // Stash on window before notifying: a fast transcode can beat page load, so the app reads the stash on mount. + 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)") - fun updateDownloadProgress(percent: Double) = - executeJs("window.__jetplayDownloadProgress=$percent;window.jetplayUpdateDownloadProgress?.($percent)") - fun mediaReady(url: String) = executeJs("window.__jetplayReadyUrl='${escapeJs(url)}';window.jetplayReady?.('${escapeJs(url)}')") @@ -49,13 +80,21 @@ 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)") } - fun loadHtml(html: String) = browser.loadHTML(html) + fun loadHtml(html: String) { + synchronized(pendingJs) { + pageLoaded = false + pendingJs.clear() + } + // 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() { disposed = true @@ -75,7 +114,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 +163,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/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt similarity index 61% rename from src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerConfig.kt index 1e23aec3..69e5acc9 100644 --- a/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/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt similarity index 72% rename from src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt index 0c2d8c64..360d915b 100644 --- a/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt @@ -19,12 +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)}',") - if (config.downloadingReason.isNotEmpty()) append("downloadingReason: '${PlayerBridge.escapeJs(config.downloadingReason)}',") + // Emit only non-empty strings so the Svelte component's own defaults stand for unset copy. 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)}',") + 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/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/MediaErrorEditor.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaErrorEditor.kt new file mode 100644 index 00000000..99c2e38e --- /dev/null +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaErrorEditor.kt @@ -0,0 +1,39 @@ +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 that makes JCEF unavailability explicit instead of a 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/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt similarity index 88% rename from src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt index a771f20e..093fa239 100644 --- a/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt @@ -9,14 +9,17 @@ 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) : - UserDataHolderBase(), +class MediaFileEditor( + private val project: Project, + private val file: VirtualFile, + private val source: EditorMediaSource, +) : UserDataHolderBase(), FileEditor { private val browser = JBCefBrowser() diff --git a/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt new file mode 100644 index 00000000..ea42eac7 --- /dev/null +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt @@ -0,0 +1,45 @@ +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 +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 + +class MediaFileEditorProvider : + FileEditorProvider, + DumbAware { + + override fun accept(project: Project, file: VirtualFile): Boolean = file.fileType == MediaFileType.INSTANCE + + override fun createEditor(project: Project, file: VirtualFile): FileEditor { + if (!canRenderJcefHere()) { + 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) + 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 + + private companion object { + private val log = Logger.getInstance(MediaFileEditorProvider::class.java) + } +} diff --git a/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 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..69bcbd87 --- /dev/null +++ b/frontend/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt @@ -0,0 +1,267 @@ +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.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.platform.project.projectId +import com.intellij.platform.util.coroutines.childScope +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.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 + +class MediaLoader( + private val project: Project, + private val source: EditorMediaSource, + private val bridge: PlayerBridge, + private val htmlLoader: PlayerHtmlLoader, +) { + + private val scope = project.service().scope.childScope("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 + + @Volatile + private var watchdog: Job? = null + + // null if disposal already released the URL (lost the race with dispose). + private fun registerServed(url: String): String? { + servedUrls.add(url) + if (disposed) { + MediaServer.release(url) + return null + } + return url + } + + private fun armLoadWatchdog(url: String) { + watchdog?.cancel() + 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")) + } + } + + private val uiStrings = UiStrings( + 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.needsTranscoding -> startTranscoding() + else -> playFromSource() + } + 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 + scope.launch { + val bars = 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 + scope.launch { + val info = MediaAccessor.getInstance().extractMediaInfo(fileId, projectId) + if (info != null && !bridge.disposed) bridge.sendMediaInfo(info) + } + } + + private fun playFromSource() { + val local = source.localFileOrNull() + if (local != null) { + val url = registerServed(MediaServer.serve(local)) ?: return + loadPlayer(url) + return + } + htmlLoader.load( + PlayerConfig( + state = "loading", + isVideo = source.isVideo, + fileName = source.fileName, + fileExtension = source.extension, + ui = uiStrings, + ), + ) + scope.launch { + val len = MediaAccessor.getInstance().fileLength(fileId, projectId) + if (len <= 0L) { + showLoadError(JetPlayBundle.message("error.empty")) + return@launch + } + // 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@launch + // Push the URL in-page rather than a second loadHTML, which would race the shell's load. + bridge.mediaReady(url) + armLoadWatchdog(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( + PlayerConfig( + state = "loading", + isVideo = source.isVideo, + fileName = source.fileName, + fileExtension = source.extension, + transcodingReason = JetPlayBundle.message("transcoding.reason", source.extension.uppercase()), + ui = uiStrings, + ), + ) + scope.launch { runTranscode() } + } + + private suspend fun runTranscode() { + val temp = File.createTempFile("jetplay-", ".webm").apply { deleteOnExit() } + var served = false + try { + val api = MediaAccessor.getInstance() + temp.outputStream().use { out -> + api.transcodeFile(fileId, projectId).collect { event -> writeTranscodeEvent(event, out) } + } + if (!bridge.disposed) { + val url = registerServed(MediaServer.serve(temp)) + if (url != null) { + served = true + servedTempFiles.add(temp) + bridge.mediaReady(url) + armLoadWatchdog(url) + } + } + } catch (_: TranscodeUnavailable) { + showTranscodingError() + } catch (e: TranscodeFailure) { + if (!bridge.disposed) bridge.showError(e.message ?: JetPlayBundle.message("error.unknown")) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + showLoadError(e.message) + } finally { + // Unserved temps drop now; served ones are deleted on dispose() when the editor closes. + 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 + } + } + + 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() { + // No page exists yet, so load the shell in the error state rather than pushing showError over JS. + 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() { + disposed = true + scope.cancel() + servedUrls.forEach(MediaServer::release) + servedTempFiles.forEach(File::delete) + } + + private object TranscodeUnavailable : Exception() + private class TranscodeFailure(message: String) : Exception(message) + + companion object { + private val log = Logger.getInstance(MediaLoader::class.java) + private const val LOAD_TIMEOUT_SECONDS = 20L + private const val MILLIS_PER_SECOND = 1000L + } +} 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..5278614d --- /dev/null +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/EditorMediaSource.kt @@ -0,0 +1,18 @@ +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; VirtualFile carries identity for rpcId. */ +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 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/MediaByteSource.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaByteSource.kt new file mode 100644 index 00000000..63b9d823 --- /dev/null +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaByteSource.kt @@ -0,0 +1,64 @@ +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" -> "audio/mp4" + "aac" -> "audio/aac" + "webm" -> "video/webm" + "mp4", "m4v" -> "video/mp4" + "ogv" -> "video/ogg" + else -> "application/octet-stream" +} diff --git a/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt similarity index 62% rename from src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt index 405c422a..3529032b 100644 --- a/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt +++ b/frontend/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt @@ -4,25 +4,26 @@ 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.nio.file.Files +import java.net.URI import java.util.UUID 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() + 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() private const val CHUNK = 64 * 1024 private const val HTTP_OK = 200 @@ -35,20 +36,30 @@ 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" } + /** 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]. */ fun release(url: String) { - files.remove(url.substringAfterLast('/')) + val token = tokenOf(url) + files.remove(token) + fetched.remove(token) } + // 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('/') + private fun start(): HttpServer { val srv = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0) srv.executor = Executors.newCachedThreadPool { r -> @@ -61,6 +72,9 @@ object MediaServer { } private fun handle(exchange: HttpExchange) { + 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. @@ -81,22 +95,25 @@ object MediaServer { return } - val file = files[exchange.requestURI.path.trimStart('/')] - if (file == null || !file.isFile) { + val token = exchange.requestURI.path.trimStart('/') + 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 } + 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) @@ -108,14 +125,14 @@ 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) } - 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() @@ -123,7 +140,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) @@ -138,16 +154,24 @@ 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 } + // 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 } } @@ -155,18 +179,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/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 src/main/kotlin/dev/twango/jetplay/star/StarReminder.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/star/StarReminder.kt diff --git a/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 src/main/kotlin/dev/twango/jetplay/star/StarReminderPolicy.kt rename to frontend/src/main/kotlin/dev/twango/jetplay/star/StarReminderPolicy.kt 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..928ffea7 --- /dev/null +++ b/frontend/src/main/resources/dev.twango.jetplay.frontend.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/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 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/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/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt b/frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt similarity index 86% rename from src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt rename to frontend/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt index 92a8286e..b75f67e1 100644 --- a/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/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 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/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/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/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 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/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 src/test/kotlin/dev/twango/jetplay/star/StarReminderPolicyTest.kt rename to frontend/src/test/kotlin/dev/twango/jetplay/star/StarReminderPolicyTest.kt diff --git a/gradle.properties b/gradle.properties index 17ac3a06..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 @@ -33,3 +34,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..2f3b4f16 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,37 @@ +@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") +include("client") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 00000000..3ad131b4 --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,19 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + +dependencies { + intellijPlatform { + bundledModule("intellij.platform.rpc") + compileOnly(libs.kotlin.serialization.core.jvm) + compileOnly(libs.kotlin.serialization.json.jvm) + testFramework(TestFrameworkType.Platform) + } + + 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") +} 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/shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt new file mode 100644 index 00000000..dd8fecc4 --- /dev/null +++ b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt @@ -0,0 +1,49 @@ +package dev.twango.jetplay.media + +object MediaClassification { + + // Keep in sync with the video extensions in plugin.xml. + private val VIDEO_EXTENSIONS = setOf( + "mp4", + "m4v", + "mkv", + "avi", + "mov", + "wmv", + "flv", + "webm", + "ogv", + "ts", + "mts", + "m2ts", + "3gp", + "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. + 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..5d92087a --- /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 + +/** Null fields mark anything FFmpeg couldn't determine. */ +@Serializable +data class MediaInfo( + val codec: String?, + val container: String?, + val sampleRateHz: Int?, + val channels: Int?, + val channelLabel: String?, + /** Set only for PCM/lossless; null for lossy codecs. */ + val bitDepth: String?, + val bitrateBps: Long?, + val durationMs: Long?, + val sizeBytes: Long?, + // 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, in display order. */ + val tags: List = emptyList(), + /** Cover art as a `data:` URL. */ + 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/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt b/shared/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt similarity index 80% rename from src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt rename to shared/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt index 4e10ce95..c97e8a82 100644 --- a/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/rpc/MediaAccessor.kt b/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt new file mode 100644 index 00000000..048426a4 --- /dev/null +++ b/shared/src/main/kotlin/dev/twango/jetplay/rpc/MediaAccessor.kt @@ -0,0 +1,39 @@ +@file:Suppress("UnstableApiUsage") + +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 { + /** Primary path: stream raw source bytes in order. */ + suspend fun streamFileBytes(fileId: VirtualFileId, projectId: ProjectId): Flow + + /** 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, emitting progress then bytes. */ + suspend fun transcodeFile(fileId: VirtualFileId, projectId: ProjectId): Flow + + /** 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. */ + 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..358e213e --- /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. */ + @Serializable + data class Failed(val message: String) : TranscodeEvent + + /** ffmpeg not available on backend. */ + @Serializable + data object Unavailable : TranscodeEvent +} 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/main/resources/messages/JetPlayBundle.properties b/shared/src/main/resources/messages/JetPlayBundle.properties similarity index 73% rename from src/main/resources/messages/JetPlayBundle.properties rename to shared/src/main/resources/messages/JetPlayBundle.properties index 95fa79f5..cbed5316 100644 --- a/src/main/resources/messages/JetPlayBundle.properties +++ b/shared/src/main/resources/messages/JetPlayBundle.properties @@ -5,21 +5,21 @@ 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. # 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 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 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/src/main/resources/messages/JetPlayBundle_de.properties b/shared/src/main/resources/messages/JetPlayBundle_de.properties similarity index 86% rename from src/main/resources/messages/JetPlayBundle_de.properties rename to shared/src/main/resources/messages/JetPlayBundle_de.properties index 1edbcf17..665cfd5a 100644 --- a/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/src/main/resources/messages/JetPlayBundle_es.properties b/shared/src/main/resources/messages/JetPlayBundle_es.properties similarity index 87% rename from src/main/resources/messages/JetPlayBundle_es.properties rename to shared/src/main/resources/messages/JetPlayBundle_es.properties index 785f7c14..7f8629be 100644 --- a/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/src/main/resources/messages/JetPlayBundle_fr.properties b/shared/src/main/resources/messages/JetPlayBundle_fr.properties similarity index 87% rename from src/main/resources/messages/JetPlayBundle_fr.properties rename to shared/src/main/resources/messages/JetPlayBundle_fr.properties index 1bedd3b5..9f3d8d3d 100644 --- a/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/src/main/resources/messages/JetPlayBundle_it.properties b/shared/src/main/resources/messages/JetPlayBundle_it.properties similarity index 86% rename from src/main/resources/messages/JetPlayBundle_it.properties rename to shared/src/main/resources/messages/JetPlayBundle_it.properties index 67334f71..54e00c37 100644 --- a/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/src/main/resources/messages/JetPlayBundle_ja.properties b/shared/src/main/resources/messages/JetPlayBundle_ja.properties similarity index 86% rename from src/main/resources/messages/JetPlayBundle_ja.properties rename to shared/src/main/resources/messages/JetPlayBundle_ja.properties index 32fc65c2..af98ad44 100644 --- a/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/src/main/resources/messages/JetPlayBundle_ko.properties b/shared/src/main/resources/messages/JetPlayBundle_ko.properties similarity index 87% rename from src/main/resources/messages/JetPlayBundle_ko.properties rename to shared/src/main/resources/messages/JetPlayBundle_ko.properties index 05b2a16c..364e3ad7 100644 --- a/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/src/main/resources/messages/JetPlayBundle_pl.properties b/shared/src/main/resources/messages/JetPlayBundle_pl.properties similarity index 87% rename from src/main/resources/messages/JetPlayBundle_pl.properties rename to shared/src/main/resources/messages/JetPlayBundle_pl.properties index 28b1ca88..5e56af19 100644 --- a/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/src/main/resources/messages/JetPlayBundle_pt_BR.properties b/shared/src/main/resources/messages/JetPlayBundle_pt_BR.properties similarity index 88% rename from src/main/resources/messages/JetPlayBundle_pt_BR.properties rename to shared/src/main/resources/messages/JetPlayBundle_pt_BR.properties index 372bb9e9..64844473 100644 --- a/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/src/main/resources/messages/JetPlayBundle_ru.properties b/shared/src/main/resources/messages/JetPlayBundle_ru.properties similarity index 87% rename from src/main/resources/messages/JetPlayBundle_ru.properties rename to shared/src/main/resources/messages/JetPlayBundle_ru.properties index 11d81674..ce4b507d 100644 --- a/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/src/main/resources/messages/JetPlayBundle_zh_CN.properties b/shared/src/main/resources/messages/JetPlayBundle_zh_CN.properties similarity index 87% rename from src/main/resources/messages/JetPlayBundle_zh_CN.properties rename to shared/src/main/resources/messages/JetPlayBundle_zh_CN.properties index ae6e30ea..b6820a0e 100644 --- a/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/src/main/resources/messages/JetPlayBundle_zh_TW.properties b/shared/src/main/resources/messages/JetPlayBundle_zh_TW.properties similarity index 87% rename from src/main/resources/messages/JetPlayBundle_zh_TW.properties rename to shared/src/main/resources/messages/JetPlayBundle_zh_TW.properties index c129e3c1..bd302d93 100644 --- a/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/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/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt b/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt deleted file mode 100644 index 7291f825..00000000 --- a/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditorProvider.kt +++ /dev/null @@ -1,33 +0,0 @@ -package dev.twango.jetplay.editor - -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.LocalFileSystem -import com.intellij.openapi.vfs.VirtualFile -import dev.twango.jetplay.media.LocalFileMediaSource -import dev.twango.jetplay.media.RemoteFileMediaSource -import dev.twango.jetplay.star.StarReminder - -class MediaFileEditorProvider : - FileEditorProvider, - DumbAware { - - 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) - } - StarReminder.maybeShow(project) - return MediaFileEditor(project, file, source) - } - - override fun getEditorTypeId(): String = "media-player" - - override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_DEFAULT_EDITOR -} diff --git a/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt b/src/main/kotlin/dev/twango/jetplay/editor/MediaLoader.kt deleted file mode 100644 index ea38cc44..00000000 --- a/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/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt b/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt deleted file mode 100644 index ca63095c..00000000 --- a/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt +++ /dev/null @@ -1,20 +0,0 @@ -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 { - - 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 = false - - override fun toLocalFile(): File = file.toNioPath().toFile() -} diff --git a/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt b/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt deleted file mode 100644 index a6737760..00000000 --- a/src/main/kotlin/dev/twango/jetplay/media/MediaClassification.kt +++ /dev/null @@ -1,24 +0,0 @@ -package dev.twango.jetplay.media - -object MediaClassification { - - // Video subset of extensions registered in plugin.xml — keep in sync - private val VIDEO_EXTENSIONS = setOf( - "mp4", - "m4v", - "mkv", - "avi", - "mov", - "wmv", - "flv", - "webm", - "ogv", - "ts", - "mts", - "m2ts", - "3gp", - "ivf", - ) - - fun isVideo(extension: String): Boolean = extension.lowercase() in VIDEO_EXTENSIONS -} diff --git a/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt b/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt deleted file mode 100644 index 2f88d63d..00000000 --- a/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/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt b/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt deleted file mode 100644 index 2a4d9e52..00000000 --- a/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/src/main/kotlin/dev/twango/jetplay/transfer/DownloadSession.kt b/src/main/kotlin/dev/twango/jetplay/transfer/DownloadSession.kt deleted file mode 100644 index 9b89d86f..00000000 --- a/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() - } -} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index fbdde548..2f8ea373 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -3,15 +3,10 @@ jetplay twangodev - com.intellij.modules.platform - - - - - - - - \ No newline at end of file + + + + + + + diff --git a/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt b/src/test/kotlin/dev/twango/jetplay/transcode/MediaTranscoderTest.kt deleted file mode 100644 index 4010e8d9..00000000 --- a/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(), - ) - } -} 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} - - -
- - 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 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